T20: Debug

- DebugToolbar, BreakpointList, CallStack with folded library frames, ThreadList
- VariablesPanel and WatchPanel, colored by kind, changes washed
- HexViewer at a base address with the byte under the pointer lit on both sides
- Disassembly with source lines, registers and numbers colored, gutter breakpoints
- Flamegraph that zooms by frame, TimelineProfiler that zooms and pans by wheel
- NetworkInspector with a waterfall, a filter and a request's headers and body
- icons: redo-dot, arrow-down-to-dot, arrow-up-from-dot
- gallery: the Debug page
This commit is contained in:
ZacharyZhang-NY committed 2026-09-26 02:38:50 -04:00
1 parent 2c961833ac
commit f8bfa29c17
22 files changed
+3240 -14

No files matched your search

+303
View File
@@ -0,0 +1,303 @@
use std::{ops::Range, rc::Rc, sync::LazyLock};
use gpui::{
App, ElementId, HighlightStyle, InteractiveElement, IntoElement, MouseButton, ParentElement,
RenderOnce, SharedString, StatefulInteractiveElement, Styled, StyledText, Window, div,
prelude::*, uniform_list,
};
use regex::Regex;
use super::OnIndex;
use crate::{
primitives::{Icon, IconName},
theme::{ActiveTheme, IconSize, TextSize},
};
/// An instruction: its address, its bytes, its mnemonic and operands, an optional comment, and the source line it starts, when known.
#[derive(Clone, Debug, PartialEq)]
pub struct Instruction {
pub address: u64,
pub bytes: Vec<u8>,
pub mnemonic: SharedString,
pub operands: SharedString,
pub comment: Option<SharedString>,
pub source: Option<SharedString>,
}
/// How a piece of an operand reads.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Piece {
Register,
Number,
Mark,
}
static PIECES: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r"(?x)
(?P<register>\b(?:[re]?(?:ax|bx|cx|dx|si|di|sp|bp|ip)|r(?:[89]|1[0-5])[dwb]?|[xyz]mm[0-9]{1,2}|[abcd][lh]|[xw](?:[12]?[0-9]|30)|sp|lr|fp|pc|[xw]zr|[vqdsbh][0-9]{1,2})\b)
|(?P<number>\#?-?(?:0x[0-9a-fA-F]+|\b[0-9]+\b))
|(?P<mark>[\[\],+*!\-\#])",
)
.expect("the operand pattern compiles")
});
/// Registers, numbers and marks in `operands`, in order.
pub(crate) fn pieces(operands: &str) -> Vec<(Range<usize>, Piece)> {
PIECES
.captures_iter(operands)
.map(|found| {
let (kind, hit) = if let Some(hit) = found.name("register") {
(Piece::Register, hit)
} else if let Some(hit) = found.name("number") {
(Piece::Number, hit)
} else {
(
Piece::Mark,
found.name("mark").expect("a piece is one of three"),
)
};
(hit.range(), kind)
})
.collect()
}
/// A row: an instruction, or the source line above one.
#[derive(Clone, Copy)]
enum Line {
Source(usize),
Code(usize),
}
/// Machine code as read: address, bytes, the mnemonic and its operands colored, comments, and the source lines they came from. The current instruction is marked; a press in the gutter sets or clears a breakpoint. Long listings draw only the rows in view; it fills its box.
#[derive(IntoElement)]
pub struct Disassembly {
id: ElementId,
instructions: Rc<Vec<Instruction>>,
current: Option<usize>,
breakpoints: Vec<usize>,
on_breakpoint: Option<OnIndex>,
}
impl Disassembly {
pub fn new(id: impl Into<ElementId>, instructions: impl Into<Rc<Vec<Instruction>>>) -> Self {
Self {
id: id.into(),
instructions: instructions.into(),
current: None,
breakpoints: Vec::new(),
on_breakpoint: None,
}
}
pub fn current(mut self, instruction: usize) -> Self {
self.current = Some(instruction);
self
}
pub fn breakpoints(mut self, instructions: impl IntoIterator<Item = usize>) -> Self {
self.breakpoints = instructions.into_iter().collect();
self
}
pub fn on_breakpoint(
mut self,
handler: impl Fn(usize, &mut Window, &mut App) + 'static,
) -> Self {
self.on_breakpoint = Some(Rc::new(handler));
self
}
}
impl RenderOnce for Disassembly {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let lines: Rc<Vec<Line>> = Rc::new(
self.instructions
.iter()
.enumerate()
.flat_map(|(ix, instruction)| {
instruction
.source
.as_ref()
.map(|_| Line::Source(ix))
.into_iter()
.chain([Line::Code(ix)])
})
.collect(),
);
let widest = self
.instructions
.iter()
.map(|instruction| instruction.bytes.len())
.max()
.unwrap_or(0);
let (instructions, current, breakpoints, on_breakpoint, id) = (
self.instructions.clone(),
self.current,
self.breakpoints.clone(),
self.on_breakpoint.clone(),
self.id.clone(),
);
let list = uniform_list(
(self.id.clone(), "rows"),
lines.len(),
move |range, _, cx| {
let colors = cx.theme().colors.clone();
let syntax = colors.syntax;
range
.map(|row| match lines[row] {
Line::Source(ix) => div()
.id(row)
.w_full()
.pl_8()
.whitespace_nowrap()
.text_color(syntax.comment)
.child(
instructions[ix]
.source
.clone()
.expect("a source row has its line"),
),
Line::Code(ix) => {
let instruction = &instructions[ix];
let styles: Vec<(Range<usize>, HighlightStyle)> =
pieces(&instruction.operands)
.into_iter()
.map(|(range, piece)| {
let color = match piece {
Piece::Register => syntax.variable,
Piece::Number => syntax.number,
Piece::Mark => syntax.punctuation,
};
(range, color.into())
})
.collect();
let (marked, is_current) =
(breakpoints.contains(&ix), current == Some(ix));
let toggle = on_breakpoint.clone();
let hex: Vec<String> = instruction
.bytes
.iter()
.map(|byte| format!("{byte:02x}"))
.collect();
div()
.id(row)
.w_full()
.flex()
.items_center()
.gap_3()
.whitespace_nowrap()
.when(is_current, |line| line.bg(colors.warning.opacity(0.12)))
.child(
div()
.id((id.clone(), format!("gutter-{ix}")))
.flex_none()
.w_5()
.flex()
.justify_center()
.cursor_pointer()
.on_mouse_down(MouseButton::Left, |_, window, _| {
window.prevent_default()
})
.when_some(toggle, |gutter, toggle| {
gutter.on_click(move |_, window, cx| {
toggle(ix, window, cx)
})
})
.map(|gutter| {
if is_current {
gutter.child(
Icon::new(IconName::ArrowRight)
.size(IconSize::Xs)
.color(colors.warning),
)
} else if marked {
gutter.child(
div().size_2().rounded_full().bg(colors.danger),
)
} else {
gutter
}
}),
)
.child(
div()
.flex_none()
.text_color(colors.fg_subtle)
.child(format!("{:#010x}", instruction.address)),
)
.child(div().flex_none().text_color(colors.fg_subtle).child(
format!("{:<width$}", hex.join(" "), width = widest * 3),
))
.child(
div()
.flex_none()
.w_12()
.text_color(syntax.keyword)
.child(instruction.mnemonic.clone()),
)
.child(
StyledText::new(instruction.operands.clone())
.with_highlights(styles),
)
.children(instruction.comment.clone().map(|comment| {
div()
.text_color(syntax.comment)
.child(format!("; {comment}"))
}))
}
})
.collect()
},
)
.size_full();
div()
.size_full()
.font_family(theme.mono_family.clone())
.text_size(theme.text_size(TextSize::Xs))
.text_color(theme.colors.fg)
.child(list)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn kinds(operands: &str) -> Vec<(&str, Piece)> {
pieces(operands)
.into_iter()
.map(|(range, piece)| (&operands[range], piece))
.collect()
}
#[test]
fn operands_split_into_registers_numbers_and_marks() {
use Piece::*;
assert_eq!(
kinds("qword ptr [rbp - 0x18], rax"),
[
("[", Mark),
("rbp", Register),
("-", Mark),
("0x18", Number),
("]", Mark),
(",", Mark),
("rax", Register)
]
);
assert_eq!(
kinds("x0, [sp, #16]"),
[
("x0", Register),
(",", Mark),
("[", Mark),
("sp", Register),
(",", Mark),
("#16", Number),
("]", Mark)
]
);
}
}
+382
View File
@@ -0,0 +1,382 @@
use std::rc::Rc;
use gpui::{
App, Bounds, ElementId, Hsla, InteractiveElement, IntoElement, MouseButton, MouseDownEvent,
MouseMoveEvent, ParentElement, Pixels, RenderOnce, SharedString, Styled, Window, canvas, div,
fill, point, size,
};
use crate::{
theme::{ActiveTheme, ControlSize, TextSize},
typography::format,
};
/// A frame of a profile: its function, the samples taken in it alone, and the frames it called.
#[derive(Clone, Debug, PartialEq)]
pub struct ProfileFrame {
pub name: SharedString,
pub own: u64,
pub calls: Vec<ProfileFrame>,
}
impl ProfileFrame {
/// Samples in this frame and every frame under it.
pub fn total(&self) -> u64 {
self.own + self.calls.iter().map(ProfileFrame::total).sum::<u64>()
}
}
/// A frame as the graph lays it out: its path of call indices from the root, its depth, and its share of the width.
#[derive(Clone, Debug, PartialEq)]
pub struct Placed {
pub path: Vec<usize>,
pub name: SharedString,
pub depth: usize,
pub start: f32,
pub end: f32,
pub samples: u64,
}
/// The frames under `focus`, a path of call indices, each spanning its share of the samples; `focus` fills the width and its callers stack above it, full width.
pub fn place(root: &ProfileFrame, focus: &[usize]) -> Vec<Placed> {
let mut out = Vec::new();
let mut frame = root;
for (depth, ix) in focus.iter().enumerate() {
out.push(Placed {
path: focus[..depth].to_vec(),
name: frame.name.clone(),
depth,
start: 0.0,
end: 1.0,
samples: frame.total(),
});
frame = &frame.calls[*ix];
}
fn under(
frame: &ProfileFrame,
path: Vec<usize>,
depth: usize,
start: f32,
end: f32,
out: &mut Vec<Placed>,
) {
let total = frame.total().max(1) as f32;
out.push(Placed {
path: path.clone(),
name: frame.name.clone(),
depth,
start,
end,
samples: frame.total(),
});
let mut at = start;
for (ix, call) in frame.calls.iter().enumerate() {
let width = (end - start) * call.total() as f32 / total;
let mut inner = path.clone();
inner.push(ix);
under(call, inner, depth + 1, at, at + width, out);
at += width;
}
}
under(frame, focus.to_vec(), focus.len(), 0.0, 1.0, &mut out);
out
}
type OnFocus = Rc<dyn Fn(Vec<usize>, &mut Window, &mut App)>;
type OnPath = Rc<dyn Fn(Option<Vec<usize>>, &mut Window, &mut App)>;
/// A flame graph: each function as wide as its samples, callees stacked under callers, colored by name in the chart palette. Hover reads a frame; a press zooms into it and a press on a caller above zooms back out.
#[derive(IntoElement)]
pub struct Flamegraph {
id: ElementId,
root: Rc<ProfileFrame>,
focus: Vec<usize>,
hovered: Option<Vec<usize>>,
on_focus: Option<OnFocus>,
on_hover: Option<OnPath>,
}
impl Flamegraph {
pub fn new(id: impl Into<ElementId>, root: impl Into<Rc<ProfileFrame>>) -> Self {
Self {
id: id.into(),
root: root.into(),
focus: Vec::new(),
hovered: None,
on_focus: None,
on_hover: None,
}
}
/// The frame zoomed into, as call indices from the root.
pub fn focus(mut self, path: Vec<usize>) -> Self {
self.focus = path;
self
}
pub fn hovered(mut self, path: Option<Vec<usize>>) -> Self {
self.hovered = path;
self
}
pub fn on_focus(
mut self,
handler: impl Fn(Vec<usize>, &mut Window, &mut App) + 'static,
) -> Self {
self.on_focus = Some(Rc::new(handler));
self
}
pub fn on_hover(
mut self,
handler: impl Fn(Option<Vec<usize>>, &mut Window, &mut App) + 'static,
) -> Self {
self.on_hover = Some(Rc::new(handler));
self
}
}
/// A name cut to `room` characters, ending in an ellipsis; nothing when fewer than two fit.
fn fitted(name: &str, room: usize) -> String {
let count = name.chars().count();
match room {
room if room >= count => name.to_string(),
room if room < 2 => String::new(),
room => name.chars().take(room - 1).chain(['…']).collect(),
}
}
/// A steady color for a function's name.
fn tint(name: &str, palette: &[Hsla; 8]) -> Hsla {
let hash = name.bytes().fold(0u32, |hash, byte| {
hash.wrapping_mul(31).wrapping_add(byte as u32)
});
palette[hash as usize % palette.len()]
}
impl RenderOnce for Flamegraph {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let colors = theme.colors.clone();
let row = theme
.control_height(ControlSize::Sm)
.to_pixels(window.rem_size());
let placed = Rc::new(place(&self.root, &self.focus));
let depth = placed.iter().map(|frame| frame.depth).max().unwrap_or(0) + 1;
let total = self.root.total();
let reading = self
.hovered
.as_ref()
.and_then(|path| placed.iter().find(|frame| &frame.path == path))
.map(|frame| {
format!(
"{} · {} · {:.1} % of all",
frame.name,
format::plural(frame.samples, "sample", "samples"),
frame.samples as f32 * 100.0 / total.max(1) as f32
)
});
let hit = move |placed: &[Placed],
bounds: Bounds<Pixels>,
at: gpui::Point<Pixels>|
-> Option<Vec<usize>> {
let x = (at.x - bounds.origin.x) / bounds.size.width;
let level = ((at.y - bounds.origin.y) / row).floor() as usize;
placed
.iter()
.find(|frame| frame.depth == level && frame.start <= x && x < frame.end)
.map(|frame| frame.path.clone())
};
let (paint_placed, press_placed, move_placed) =
(placed.clone(), placed.clone(), placed.clone());
let (hovered, palette) = (self.hovered.clone(), colors.chart);
let (on_focus, on_hover) = (self.on_focus, self.on_hover);
let (fg, hover_ring, text_size) = (colors.fg, colors.focus, theme.text_size(TextSize::Xs));
let font = theme.mono_family.clone();
let gap = theme.chart().hairline;
let bounds_cell = Rc::new(std::cell::Cell::new(Bounds::default()));
let (measure, pressed_at, moved_at) =
(bounds_cell.clone(), bounds_cell.clone(), bounds_cell);
div()
.flex()
.flex_col()
.gap_1()
.child(
div()
.h(row)
.text_size(theme.text_size(TextSize::Sm))
.text_color(colors.fg_muted)
.child(reading.unwrap_or_else(|| {
format!(
"{} in all · press a frame to zoom in",
format::plural(total, "sample", "samples")
)
})),
)
.child(
div()
.id(self.id.clone())
.relative()
.w_full()
.h(row * depth as f32)
.cursor_pointer()
.on_mouse_down(
MouseButton::Left,
move |event: &MouseDownEvent, window, cx| {
if let (Some(on_focus), Some(path)) = (
&on_focus,
hit(&press_placed, pressed_at.get(), event.position),
) {
log::info!("flamegraph: focus {path:?}");
on_focus(path, window, cx)
}
},
)
.on_mouse_move(move |event: &MouseMoveEvent, window, cx| {
if let Some(on_hover) = &on_hover {
on_hover(
hit(&move_placed, moved_at.get(), event.position),
window,
cx,
)
}
})
.child(
canvas(
move |bounds, _, _| measure.set(bounds),
move |bounds, _, window, cx| {
let pixels = text_size.to_pixels(window.rem_size());
let face = gpui::font(font.clone());
let advance = window
.text_system()
.advance(window.text_system().resolve_font(&face), pixels, 'm')
.expect("the code font has an m")
.width;
for frame in paint_placed.iter() {
let x0 = bounds.origin.x + bounds.size.width * frame.start;
let width = bounds.size.width * (frame.end - frame.start);
let origin =
point(x0, bounds.origin.y + row * frame.depth as f32);
let cell = Bounds::new(
origin,
size(width - gap.min(width), row - gap),
);
let color = tint(&frame.name, &palette);
let lit = hovered.as_ref() == Some(&frame.path);
window.paint_quad(fill(
cell,
if lit { color } else { color.opacity(0.55) },
));
if lit {
window.paint_quad(gpui::outline(
cell,
hover_ring,
gpui::BorderStyle::Solid,
));
}
let label = fitted(
&frame.name,
((width - pixels * 0.8) / advance).max(0.0) as usize,
);
if !label.is_empty() {
let run = gpui::TextRun {
len: label.len(),
font: face.clone(),
color: fg,
background_color: None,
underline: None,
strikethrough: None,
};
window
.text_system()
.shape_line(label.into(), pixels, &[run], None)
.paint(
origin
+ point(
pixels * 0.4,
(row - pixels * 1.2) / 2.0,
),
pixels * 1.2,
window,
cx,
)
.expect("a flame label paints");
}
}
},
)
.absolute()
.top_0()
.left_0()
.size_full(),
),
)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn frame(name: &str, own: u64, calls: Vec<ProfileFrame>) -> ProfileFrame {
ProfileFrame {
name: name.to_string().into(),
own,
calls,
}
}
fn profile() -> ProfileFrame {
frame(
"main",
10,
vec![
frame("render", 30, vec![frame("layout", 20, vec![])]),
frame("io", 40, vec![]),
],
)
}
#[test]
fn names_fit_their_frames() {
assert_eq!(fitted("render", 10), "render");
assert_eq!(fitted("render", 4), "ren…");
assert_eq!(fitted("render", 1), "");
}
#[test]
fn frames_span_their_share() {
let placed = place(&profile(), &[]);
let spans: Vec<(&str, usize, f32, f32)> = placed
.iter()
.map(|frame| (frame.name.as_ref(), frame.depth, frame.start, frame.end))
.collect();
assert_eq!(
spans,
[
("main", 0, 0.0, 1.0),
("render", 1, 0.0, 0.5),
("layout", 2, 0.0, 0.2),
("io", 1, 0.5, 0.9)
]
);
}
#[test]
fn a_focused_frame_fills_the_width_under_its_callers() {
let placed = place(&profile(), &[0]);
let spans: Vec<(&str, usize, f32, f32)> = placed
.iter()
.map(|frame| (frame.name.as_ref(), frame.depth, frame.start, frame.end))
.collect();
assert_eq!(
spans,
[
("main", 0, 0.0, 1.0),
("render", 1, 0.0, 1.0),
("layout", 2, 0.0, 0.4)
]
);
assert_eq!(placed[1].path, [0], "the focused frame keeps its path");
}
}
+214
View File
@@ -0,0 +1,214 @@
use std::{ops::Range, rc::Rc};
use gpui::{
App, ElementId, InteractiveElement, IntoElement, MouseButton, ParentElement, RenderOnce,
StatefulInteractiveElement, Styled, Window, div, prelude::*, uniform_list,
};
use super::OnIndex;
use crate::theme::{ActiveTheme, TextSize};
/// Bytes a row shows.
const WIDTH: usize = 16;
/// How a byte reads as text: itself when it prints, a dot when not.
fn glyph(byte: u8) -> char {
if byte.is_ascii_graphic() || byte == b' ' {
byte as char
} else {
'·'
}
}
/// What the bytes from `at` read as: the byte in hex, decimal, signed and binary, and the little-endian words that fit.
pub(crate) fn inspect(bytes: &[u8], at: usize) -> String {
let byte = bytes[at];
let mut parts = vec![
format!("0x{byte:02X}"),
byte.to_string(),
format!("i8 {}", byte as i8),
format!("0b{byte:08b}"),
];
if let Some(word) = bytes.get(at..at + 2) {
parts.push(format!("u16 {}", u16::from_le_bytes([word[0], word[1]])));
}
if let Some(word) = bytes.get(at..at + 4) {
parts.push(format!(
"u32 {}",
u32::from_le_bytes([word[0], word[1], word[2], word[3]])
));
}
parts.join(" · ")
}
/// Memory as a hex dump: each row its address, sixteen bytes in fours, and the same bytes as text. A press selects a byte; the one under the pointer lights on both sides, and the selection reads below as numbers. Long dumps draw only the rows in view; it fills its box.
#[derive(IntoElement)]
pub struct HexViewer {
id: ElementId,
bytes: Rc<Vec<u8>>,
base: u64,
selected: Option<Range<usize>>,
on_select: Option<OnIndex>,
}
impl HexViewer {
pub fn new(id: impl Into<ElementId>, bytes: impl Into<Rc<Vec<u8>>>) -> Self {
Self {
id: id.into(),
bytes: bytes.into(),
base: 0,
selected: None,
on_select: None,
}
}
/// The address of the first byte.
pub fn base(mut self, base: u64) -> Self {
self.base = base;
self
}
pub fn selected(mut self, bytes: Range<usize>) -> Self {
assert!(
bytes.end <= self.bytes.len(),
"a selection lies inside the bytes"
);
self.selected = Some(bytes);
self
}
pub fn on_select(mut self, handler: impl Fn(usize, &mut Window, &mut App) + 'static) -> Self {
self.on_select = Some(Rc::new(handler));
self
}
}
impl RenderOnce for HexViewer {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let hovered =
window.use_keyed_state((self.id.clone(), "hovered"), cx, |_, _| None::<usize>);
let theme = cx.theme();
let colors = theme.colors.clone();
let rows = self.bytes.len().div_ceil(WIDTH);
let digits = format!("{:X}", self.base + self.bytes.len() as u64)
.len()
.max(8);
let reading = self
.selected
.as_ref()
.map(|range| inspect(&self.bytes, range.start));
let (bytes, base, selected, on_select, id) = (
self.bytes.clone(),
self.base,
self.selected.clone(),
self.on_select.clone(),
self.id.clone(),
);
let list = uniform_list((self.id.clone(), "rows"), rows, move |range, _, cx| {
let colors = cx.theme().colors.clone();
let lit = *hovered.read(cx);
range
.map(|row| {
let start = row * WIDTH;
let end = (start + WIDTH).min(bytes.len());
let wash = |at: usize| {
if selected.as_ref().is_some_and(|range| range.contains(&at)) {
Some(colors.selection)
} else if lit == Some(at) {
Some(colors.hover)
} else {
None
}
};
let cell = |at: usize, text: String, key: &str| {
let (pick, hover) = (on_select.clone(), hovered.clone());
div()
.id((id.clone(), format!("{key}-{at}")))
.px_0p5()
.when_some(wash(at), |cell, wash| cell.bg(wash))
.on_hover(move |on, _, cx| {
hover.update(cx, |hovered, cx| {
let next = if *on { Some(at) } else { None };
if *hovered != next && (*on || *hovered == Some(at)) {
*hovered = next;
cx.notify();
}
})
})
.on_mouse_down(MouseButton::Left, |_, window, _| {
window.prevent_default()
})
.when_some(pick, |cell, pick| {
cell.on_click(move |_, window, cx| pick(at, window, cx))
})
.child(text)
};
div()
.id(row)
.w_full()
.flex()
.gap_4()
.whitespace_nowrap()
.child(
div()
.text_color(colors.fg_subtle)
.child(format!("{:0digits$X}", base + start as u64)),
)
.child(div().flex().gap_2().children(
(start..end).collect::<Vec<_>>().chunks(4).map(|four| {
div().flex().children(
four.iter()
.map(|at| cell(*at, format!("{:02X}", bytes[*at]), "hex")),
)
}),
))
.child(div().flex().text_color(colors.fg_muted).children(
(start..end).map(|at| cell(at, glyph(bytes[at]).to_string(), "text")),
))
})
.collect()
})
.size_full();
div()
.size_full()
.flex()
.flex_col()
.gap_2()
.child(
div()
.flex_1()
.min_h_0()
.font_family(theme.mono_family.clone())
.text_size(theme.text_size(TextSize::Xs))
.text_color(colors.fg)
.child(list),
)
.children(reading.map(|reading| {
div()
.font_family(theme.mono_family.clone())
.text_size(theme.text_size(TextSize::Xs))
.text_color(colors.fg_muted)
.child(reading)
}))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bytes_read_as_numbers_and_text() {
let bytes = [0x2A, 0x01, 0x00, 0x00, 0xFF];
assert_eq!(
inspect(&bytes, 0),
"0x2A · 42 · i8 42 · 0b00101010 · u16 298 · u32 298"
);
assert_eq!(
inspect(&bytes, 4),
"0xFF · 255 · i8 -1 · 0b11111111",
"no word past the end"
);
assert_eq!((glyph(b'A'), glyph(0), glyph(b' ')), ('A', '·', ' '));
}
}
+24
View File
@@ -0,0 +1,24 @@
use std::rc::Rc;
use gpui::{App, Window};
mod disasm;
mod flame;
mod hex;
mod network;
mod session;
mod stack;
mod timeline;
mod variables;
pub use disasm::{Disassembly, Instruction};
pub use flame::{Flamegraph, Placed, ProfileFrame, place};
pub use hex::HexViewer;
pub use network::{NetworkInspector, Request};
pub use session::{Breakpoint, BreakpointList, DebugCommand, DebugState, DebugToolbar};
pub use stack::{CallStack, StackFrame, Thread, ThreadList, ThreadState};
pub use timeline::{Span, TimelineProfiler, zoomed};
pub use variables::{ValueKind, Variable, VariablesPanel, Watch, WatchPanel};
/// Tells the owner which row, by index.
type OnIndex = Rc<dyn Fn(usize, &mut Window, &mut App)>;
+265
View File
@@ -0,0 +1,265 @@
use std::rc::Rc;
use gpui::{
AnyElement, App, Div, ElementId, FontWeight, Hsla, InteractiveElement, IntoElement,
ParentElement, RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, div,
prelude::*, relative,
};
use super::OnIndex;
use crate::{
theme::{ActiveTheme, Palette, Radius, TextSize},
typography::{Ellipsis, format, tabular},
};
/// A request the page made: method and address, status once answered, kind, size, when it started and how long it took in milliseconds, and its headers and body.
#[derive(Clone, Debug, PartialEq)]
pub struct Request {
pub method: SharedString,
pub url: SharedString,
pub status: Option<u16>,
pub kind: SharedString,
pub size: u64,
pub start: f64,
pub duration: f64,
pub headers: Vec<(SharedString, SharedString)>,
pub body: Option<SharedString>,
}
/// A status's color: quiet for success, then info, warning and danger.
fn status_color(status: Option<u16>, colors: &Palette) -> Hsla {
match status {
None => colors.fg_subtle,
Some(200..=299) => colors.fg,
Some(300..=399) => colors.info,
Some(400..=499) => colors.warning,
Some(_) => colors.danger,
}
}
/// The last part of an address's path, else its host.
pub(crate) fn name(url: &str) -> &str {
let bare = url.split(['?', '#']).next().unwrap_or(url);
let path = bare.split_once("://").map_or(bare, |(_, rest)| rest);
path.trim_end_matches('/')
.rsplit('/')
.next()
.unwrap_or(path)
}
/// Requests in the order they started: name, status, kind, size and time, with a waterfall of when each ran; a query keeps those whose address holds it. A press opens a request's headers and body below.
#[derive(IntoElement)]
pub struct NetworkInspector {
id: ElementId,
requests: Vec<Request>,
query: SharedString,
selected: Option<usize>,
on_select: Option<OnIndex>,
}
impl NetworkInspector {
pub fn new(id: impl Into<ElementId>, requests: impl IntoIterator<Item = Request>) -> Self {
Self {
id: id.into(),
requests: requests.into_iter().collect(),
query: SharedString::default(),
selected: None,
on_select: None,
}
}
pub fn query(mut self, query: impl Into<SharedString>) -> Self {
self.query = query.into();
self
}
pub fn selected(mut self, request: usize) -> Self {
self.selected = Some(request);
self
}
pub fn on_select(mut self, handler: impl Fn(usize, &mut Window, &mut App) + 'static) -> Self {
self.on_select = Some(Rc::new(handler));
self
}
}
impl RenderOnce for NetworkInspector {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let colors = theme.colors.clone();
let query = self.query.to_lowercase();
let end = self
.requests
.iter()
.map(|request| request.start + request.duration)
.fold(0.0_f64, f64::max)
.max(1.0);
let columns = |row: Div| row.flex().items_center().gap_3().px_2();
let widths = [0.5, 0.6, 0.6, 0.6, 2.0].map(|share| theme.label_width() * share);
let head = columns(div())
.py_1()
.border_b_1()
.border_color(colors.border)
.text_color(colors.fg_subtle)
.child(div().flex_1().min_w_0().child("Name"))
.children(
["Status", "Type", "Size", "Time", "Waterfall"]
.iter()
.zip(widths)
.map(|(title, width)| div().flex_none().w(width).child(*title)),
);
let rows: Vec<AnyElement> = self
.requests
.iter()
.enumerate()
.filter(|(_, request)| query.is_empty() || request.url.to_lowercase().contains(&query))
.map(|(ix, request)| {
let lit = self.selected == Some(ix);
let pick = self.on_select.clone();
let tint = status_color(request.status, &colors);
columns(div())
.id((self.id.clone(), format!("request-{ix}")))
.py_1()
.rounded(theme.radius(Radius::Sm))
.cursor_pointer()
.when(lit, |row| row.bg(colors.active))
.when(!lit, |row| row.hover(|row| row.bg(colors.hover)))
.when_some(pick, |row, pick| {
row.on_click(move |_, window, cx| pick(ix, window, cx))
})
.child(
div()
.flex_1()
.min_w_0()
.text_color(if request.status.is_some_and(|code| code >= 400) {
tint
} else {
colors.fg
})
.child(Ellipsis::new(name(&request.url).to_string())),
)
.child(
tabular(div().flex_none().w(widths[0]).text_color(tint)).child(
request
.status
.map_or("—".to_string(), |code| code.to_string()),
),
)
.child(
div()
.flex_none()
.w(widths[1])
.text_color(colors.fg_muted)
.child(request.kind.clone()),
)
.child(
tabular(div().flex_none().w(widths[2]).text_color(colors.fg_muted))
.child(format::file_size(request.size, false)),
)
.child(
tabular(div().flex_none().w(widths[3]).text_color(colors.fg_muted))
.child(format!("{:.0} ms", request.duration)),
)
.child(
div().flex_none().w(widths[4]).h_2().relative().child(
div()
.absolute()
.top_0()
.bottom_0()
.left(relative((request.start / end) as f32))
.w(relative(((request.duration / end) as f32).max(0.005)))
.rounded(theme.radius(Radius::Sm))
.bg(if request.status.is_none() {
colors.fg_subtle
} else {
colors.chart[0]
}),
),
)
.into_any_element()
})
.collect();
let details = self
.selected
.and_then(|ix| self.requests.get(ix))
.map(|request| {
let pair = |key: SharedString, value: SharedString| {
div()
.flex()
.gap_3()
.child(
div()
.flex_none()
.w(theme.label_width())
.text_color(colors.fg_subtle)
.child(key),
)
.child(
div()
.flex_1()
.min_w_0()
.font_family(theme.mono_family.clone())
.text_color(colors.fg)
.child(value),
)
};
div()
.flex()
.flex_col()
.gap_1()
.p_3()
.rounded(theme.radius(Radius::Md))
.border_1()
.border_color(colors.border)
.child(
div()
.font_weight(FontWeight::MEDIUM)
.text_color(colors.fg)
.child(format!("{} {}", request.method, request.url)),
)
.child(pair(
"Status".into(),
request
.status
.map_or("pending".into(), |code| code.to_string().into()),
))
.children(
request
.headers
.iter()
.map(|(key, value)| pair(key.clone(), value.clone())),
)
.children(request.body.clone().map(|body| {
div()
.mt_2()
.p_2()
.rounded(theme.radius(Radius::Sm))
.bg(colors.sunken)
.font_family(theme.mono_family.clone())
.text_size(theme.text_size(TextSize::Xs))
.text_color(colors.fg)
.child(body)
}))
});
div()
.flex()
.flex_col()
.gap_2()
.text_size(theme.text_size(TextSize::Sm))
.child(div().flex().flex_col().child(head).children(rows))
.children(details)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_request_goes_by_the_last_part_of_its_path() {
assert_eq!(name("https://api.ely.dev/v1/quotes?symbol=ELY"), "quotes");
assert_eq!(name("https://ely.dev/"), "ely.dev");
assert_eq!(name("/static/app.js#top"), "app.js");
}
}
+292
View File
@@ -0,0 +1,292 @@
use std::rc::Rc;
use gpui::{
AnyElement, App, ElementId, FontWeight, InteractiveElement, IntoElement, ParentElement,
RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, div, prelude::*,
};
use super::OnIndex;
use crate::{
buttons::{ButtonVariant, IconButton},
forms::Checkbox,
primitives::IconName,
theme::{ActiveTheme, ControlSize, Elevation, Radius, TextSize},
typography::{Ellipsis, tabular},
};
/// Whether the program runs or waits.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DebugState {
Running,
Paused,
}
/// What the debugger is asked to do.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DebugCommand {
Continue,
Pause,
StepOver,
StepInto,
StepOut,
Restart,
Stop,
}
type OnCommand = Rc<dyn Fn(DebugCommand, &mut Window, &mut App)>;
type OnSwitch = Rc<dyn Fn(usize, bool, &mut Window, &mut App)>;
/// The floating row that drives a debug session: continue or pause, the three steps while paused, restart and stop, each with its key.
#[derive(IntoElement)]
pub struct DebugToolbar {
id: ElementId,
state: DebugState,
on_command: Option<OnCommand>,
}
impl DebugToolbar {
pub fn new(id: impl Into<ElementId>, state: DebugState) -> Self {
Self {
id: id.into(),
state,
on_command: None,
}
}
pub fn on_command(
mut self,
handler: impl Fn(DebugCommand, &mut Window, &mut App) + 'static,
) -> Self {
self.on_command = Some(Rc::new(handler));
self
}
}
impl RenderOnce for DebugToolbar {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let colors = theme.colors.clone();
let paused = self.state == DebugState::Paused;
let button = |command: DebugCommand, icon: IconName, words: &'static str, enabled: bool| {
let run = self.on_command.clone();
IconButton::new((self.id.clone(), format!("{command:?}")), icon)
.variant(ButtonVariant::Ghost)
.size(ControlSize::Sm)
.tooltip(words)
.disabled(!enabled)
.when_some(run, |button, run| {
button.on_click(move |_, window, cx| {
log::info!("debug toolbar: {command:?}");
run(command, window, cx)
})
})
};
let rule = || div().w_0().h_4().border_l_1().border_color(colors.border);
div()
.flex()
.items_center()
.gap_0p5()
.p_1()
.rounded(theme.radius(Radius::Lg))
.border_1()
.border_color(colors.border)
.bg(colors.overlay)
.shadow(theme.elevation(Elevation::Floating))
.child(if paused {
button(DebugCommand::Continue, IconName::Play, "Continue F5", true)
} else {
button(DebugCommand::Pause, IconName::Pause, "Pause F6", true)
})
.child(button(
DebugCommand::StepOver,
IconName::RedoDot,
"Step over F10",
paused,
))
.child(button(
DebugCommand::StepInto,
IconName::ArrowDownToDot,
"Step into F11",
paused,
))
.child(button(
DebugCommand::StepOut,
IconName::ArrowUpFromDot,
"Step out ⇧F11",
paused,
))
.child(rule())
.child(button(
DebugCommand::Restart,
IconName::RotateCw,
"Restart ⇧⌘F5",
true,
))
.child(button(
DebugCommand::Stop,
IconName::Square,
"Stop ⇧F5",
true,
))
}
}
/// A breakpoint: where, whether it is on, the condition that guards it, and how often it has hit.
#[derive(Clone, Debug, PartialEq)]
pub struct Breakpoint {
pub path: SharedString,
/// From one.
pub line: usize,
pub enabled: bool,
pub condition: Option<SharedString>,
pub hits: usize,
}
/// Every breakpoint: turn each on or off, see its condition and hits, go to it, or remove it; or all at once.
#[derive(IntoElement)]
pub struct BreakpointList {
id: ElementId,
breakpoints: Vec<Breakpoint>,
on_toggle: Option<OnSwitch>,
on_open: Option<OnIndex>,
on_remove: Option<OnIndex>,
}
impl BreakpointList {
pub fn new(
id: impl Into<ElementId>,
breakpoints: impl IntoIterator<Item = Breakpoint>,
) -> Self {
Self {
id: id.into(),
breakpoints: breakpoints.into_iter().collect(),
on_toggle: None,
on_open: None,
on_remove: None,
}
}
pub fn on_toggle(
mut self,
handler: impl Fn(usize, bool, &mut Window, &mut App) + 'static,
) -> Self {
self.on_toggle = Some(Rc::new(handler));
self
}
pub fn on_open(mut self, handler: impl Fn(usize, &mut Window, &mut App) + 'static) -> Self {
self.on_open = Some(Rc::new(handler));
self
}
pub fn on_remove(mut self, handler: impl Fn(usize, &mut Window, &mut App) + 'static) -> Self {
self.on_remove = Some(Rc::new(handler));
self
}
}
impl RenderOnce for BreakpointList {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let colors = theme.colors.clone();
let rows: Vec<AnyElement> = self
.breakpoints
.iter()
.enumerate()
.map(|(ix, point)| {
let (folder, name) = point.path.rsplit_once('/').unwrap_or(("", &point.path));
let group = SharedString::from(format!("breakpoint-{ix}"));
let toggle = self.on_toggle.clone();
let (open, remove) = (self.on_open.clone(), self.on_remove.clone());
let check = Checkbox::new((self.id.clone(), format!("on-{ix}")), point.enabled);
let check = match toggle {
Some(toggle) => {
check.on_change(move |on, window, cx| toggle(ix, on, window, cx))
}
None => check,
};
div()
.id((self.id.clone(), format!("row-{ix}")))
.group(group.clone())
.flex()
.items_center()
.gap_2()
.px_2()
.py_1()
.rounded(theme.radius(Radius::Sm))
.hover(|row| row.bg(colors.hover))
.when_some(open, |row, open| {
row.cursor_pointer()
.on_click(move |_, window, cx| open(ix, window, cx))
})
.child(check)
.child(
div()
.flex_none()
.size_2()
.rounded_full()
.border_1()
.border_color(colors.danger)
.when(point.enabled, |dot| dot.bg(colors.danger)),
)
.child(
div()
.flex_none()
.font_weight(FontWeight::MEDIUM)
.text_color(colors.fg)
.child(format!("{name}:{}", point.line)),
)
.child(
div()
.flex_1()
.min_w_0()
.text_color(colors.fg_subtle)
.child(Ellipsis::new(folder.to_string())),
)
.children(point.condition.clone().map(|condition| {
div()
.flex_none()
.font_family(theme.mono_family.clone())
.text_size(theme.text_size(TextSize::Xs))
.text_color(colors.fg_muted)
.child(format!("when {condition}"))
}))
.when(point.hits > 0, |row| {
row.child(
tabular(div().flex_none().text_color(colors.fg_subtle))
.child(format!("×{}", point.hits)),
)
})
.child(
div()
.opacity(0.)
.group_hover(group, |actions| actions.opacity(1.))
.child(
IconButton::new(
(self.id.clone(), format!("remove-{ix}")),
IconName::X,
)
.variant(ButtonVariant::Ghost)
.size(ControlSize::Sm)
.tooltip("Remove breakpoint")
.when_some(
remove,
|button, remove| {
button.on_click(move |_, window, cx| {
cx.stop_propagation();
remove(ix, window, cx)
})
},
),
),
)
.into_any_element()
})
.collect();
div()
.flex()
.flex_col()
.text_size(theme.text_size(TextSize::Sm))
.children(rows)
}
}
+320
View File
@@ -0,0 +1,320 @@
use std::{ops::Range, rc::Rc};
use gpui::{
AnyElement, App, ElementId, Hsla, InteractiveElement, IntoElement, MouseButton, ParentElement,
RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, div, prelude::*,
};
use super::OnIndex;
use crate::{
forms::OnFlag,
primitives::{Icon, IconName},
theme::{ActiveTheme, IconSize, Palette, Radius, TextSize},
typography::{Ellipsis, format},
};
type OnThread = Rc<dyn Fn(u64, &mut Window, &mut App)>;
/// A frame of the call stack: its function, where it is, and whether it is library code.
#[derive(Clone, Debug, PartialEq)]
pub struct StackFrame {
pub function: SharedString,
pub path: SharedString,
/// From one.
pub line: usize,
pub library: bool,
}
/// Frames as shown: each one of the program's, and each run of library frames as one fold, by the frames it holds.
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum Shown {
Frame(usize),
Folded(Range<usize>),
}
pub(crate) fn shown(frames: &[StackFrame], libraries: bool) -> Vec<Shown> {
let mut out = Vec::new();
for (ix, frame) in frames.iter().enumerate() {
if !frame.library || libraries {
out.push(Shown::Frame(ix));
continue;
}
match out.last_mut() {
Some(Shown::Folded(range)) if range.end == ix => range.end = ix + 1,
_ => out.push(Shown::Folded(ix..ix + 1)),
}
}
out
}
/// Where the program is paused, innermost call first: the current frame marked, runs of library frames folded until pressed; a press moves to a frame.
#[derive(IntoElement)]
pub struct CallStack {
id: ElementId,
frames: Vec<StackFrame>,
current: usize,
libraries: bool,
on_pick: Option<OnIndex>,
on_libraries: Option<OnFlag>,
}
impl CallStack {
pub fn new(id: impl Into<ElementId>, frames: impl IntoIterator<Item = StackFrame>) -> Self {
Self {
id: id.into(),
frames: frames.into_iter().collect(),
current: 0,
libraries: false,
on_pick: None,
on_libraries: None,
}
}
pub fn current(mut self, frame: usize) -> Self {
assert!(
frame < self.frames.len(),
"the current frame is on the stack"
);
self.current = frame;
self
}
/// Whether library frames show one by one.
pub fn libraries(mut self, shown: bool) -> Self {
self.libraries = shown;
self
}
pub fn on_pick(mut self, handler: impl Fn(usize, &mut Window, &mut App) + 'static) -> Self {
self.on_pick = Some(Rc::new(handler));
self
}
pub fn on_libraries(mut self, handler: impl Fn(bool, &mut Window, &mut App) + 'static) -> Self {
self.on_libraries = Some(Rc::new(handler));
self
}
}
impl RenderOnce for CallStack {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let colors = theme.colors.clone();
let rows: Vec<AnyElement> = shown(&self.frames, self.libraries)
.into_iter()
.map(|entry| match entry {
Shown::Frame(ix) => {
let frame = &self.frames[ix];
let (current, pick) = (ix == self.current, self.on_pick.clone());
let file = frame
.path
.rsplit('/')
.next()
.unwrap_or(&frame.path)
.to_string();
div()
.id((self.id.clone(), format!("frame-{ix}")))
.flex()
.items_center()
.gap_2()
.px_2()
.py_1()
.rounded(theme.radius(Radius::Sm))
.cursor_pointer()
.when(current, |row| row.bg(colors.warning.opacity(0.12)))
.when(!current, |row| row.hover(|row| row.bg(colors.hover)))
.when_some(pick, |row, pick| {
row.on_click(move |_, window, cx| pick(ix, window, cx))
})
.child(div().flex_none().w_4().children(current.then(|| {
Icon::new(IconName::ArrowRight)
.size(IconSize::Sm)
.color(colors.warning)
})))
.child(
div()
.flex_1()
.min_w_0()
.font_family(theme.mono_family.clone())
.text_color(if frame.library {
colors.fg_muted
} else {
colors.fg
})
.child(Ellipsis::new(frame.function.clone())),
)
.child(
div()
.flex_none()
.text_color(colors.fg_subtle)
.child(format!("{file}:{}", frame.line)),
)
.into_any_element()
}
Shown::Folded(range) => {
let show = self.on_libraries.clone();
div()
.id((self.id.clone(), format!("fold-{}", range.start)))
.flex()
.items_center()
.gap_2()
.px_2()
.py_1()
.pl_8()
.rounded(theme.radius(Radius::Sm))
.cursor_pointer()
.text_color(colors.fg_subtle)
.hover(|row| row.text_color(colors.fg))
.on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default())
.when_some(show, |row, show| {
row.on_click(move |_, window, cx| show(true, window, cx))
})
.child(format!(
"⋯ {}",
format::plural(range.len() as u64, "library frame", "library frames")
))
.into_any_element()
}
})
.collect();
div()
.flex()
.flex_col()
.text_size(theme.text_size(TextSize::Sm))
.children(rows)
}
}
/// How a thread stands.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ThreadState {
Running,
Paused,
/// Stopped on a breakpoint.
Stopped,
}
impl ThreadState {
fn look(self, colors: &Palette) -> (IconName, Hsla, &'static str) {
match self {
Self::Running => (IconName::Play, colors.success, "running"),
Self::Paused => (IconName::Pause, colors.fg_muted, "paused"),
Self::Stopped => (IconName::CircleDot, colors.danger, "on a breakpoint"),
}
}
}
/// A thread: its id, its name, and how it stands.
#[derive(Clone, Debug, PartialEq)]
pub struct Thread {
pub id: u64,
pub name: SharedString,
pub state: ThreadState,
}
/// The program's threads, each with how it stands; a press picks the one whose stack shows.
#[derive(IntoElement)]
pub struct ThreadList {
id: ElementId,
threads: Vec<Thread>,
selected: Option<u64>,
on_pick: Option<OnThread>,
}
impl ThreadList {
pub fn new(id: impl Into<ElementId>, threads: impl IntoIterator<Item = Thread>) -> Self {
Self {
id: id.into(),
threads: threads.into_iter().collect(),
selected: None,
on_pick: None,
}
}
pub fn selected(mut self, thread: u64) -> Self {
self.selected = Some(thread);
self
}
pub fn on_pick(mut self, handler: impl Fn(u64, &mut Window, &mut App) + 'static) -> Self {
self.on_pick = Some(Rc::new(handler));
self
}
}
impl RenderOnce for ThreadList {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let colors = theme.colors.clone();
div()
.flex()
.flex_col()
.text_size(theme.text_size(TextSize::Sm))
.children(self.threads.iter().map(|thread| {
let (icon, tint, words) = thread.state.look(&colors);
let selected = self.selected == Some(thread.id);
let (pick, id) = (self.on_pick.clone(), thread.id);
div()
.id((self.id.clone(), format!("thread-{}", thread.id)))
.flex()
.items_center()
.gap_2()
.px_2()
.py_1()
.rounded(theme.radius(Radius::Sm))
.cursor_pointer()
.when(selected, |row| row.bg(colors.active))
.when(!selected, |row| row.hover(|row| row.bg(colors.hover)))
.when_some(pick, |row, pick| {
row.on_click(move |_, window, cx| pick(id, window, cx))
})
.child(Icon::new(icon).size(IconSize::Sm).color(tint))
.child(
div()
.flex_1()
.text_color(colors.fg)
.child(thread.name.clone()),
)
.child(
div()
.text_color(colors.fg_subtle)
.child(format!("#{} · {words}", thread.id)),
)
}))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn frame(library: bool) -> StackFrame {
StackFrame {
function: "f".into(),
path: "a.rs".into(),
line: 1,
library,
}
}
#[test]
fn runs_of_library_frames_fold_into_one() {
let frames = [
frame(false),
frame(true),
frame(true),
frame(false),
frame(true),
];
assert_eq!(
shown(&frames, false),
[
Shown::Frame(0),
Shown::Folded(1..3),
Shown::Frame(3),
Shown::Folded(4..5)
]
);
assert_eq!(shown(&frames, true).len(), 5, "shown one by one when asked");
}
}
+282
View File
@@ -0,0 +1,282 @@
use std::{cell::Cell, rc::Rc};
use gpui::{
App, Bounds, ElementId, FontWeight, Hsla, InteractiveElement, IntoElement, MouseButton,
ParentElement, Pixels, RenderOnce, ScrollWheelEvent, SharedString, StatefulInteractiveElement,
Styled, Window, canvas, div, prelude::*, relative,
};
use super::OnIndex;
use crate::{
charts::nice_step,
theme::{ActiveTheme, ControlSize, Radius, TextSize},
typography::{Ellipsis, LEADING},
};
/// A stretch of work on a track, in milliseconds.
#[derive(Clone, Debug, PartialEq)]
pub struct Span {
pub track: usize,
pub name: SharedString,
pub start: f64,
pub end: f64,
}
/// Wheel pixels that zoom by a factor of e.
const ZOOM: f64 = 400.0;
/// Ticks along the ruler, about.
const TICKS: usize = 6;
/// `range` scaled by `factor` around the point at `at` across it, from 0 to 1; above 1 widens, below narrows.
pub fn zoomed(range: (f64, f64), at: f64, factor: f64) -> (f64, f64) {
assert!(
factor > 0.0 && range.1 > range.0,
"a range zooms by a positive factor"
);
let pivot = range.0 + (range.1 - range.0) * at.clamp(0.0, 1.0);
(
pivot - (pivot - range.0) * factor,
pivot + (range.1 - pivot) * factor,
)
}
/// A steady color for a span's name.
fn tint(name: &str, palette: &[Hsla; 8]) -> Hsla {
let hash = name.bytes().fold(0u32, |hash, byte| {
hash.wrapping_mul(31).wrapping_add(byte as u32)
});
palette[hash as usize % palette.len()]
}
type OnRange = Rc<dyn Fn((f64, f64), &mut Window, &mut App)>;
/// Work over time on tracks, such as threads: each span a bar from its start to its end under a time axis. The wheel zooms around the pointer and a sideways wheel pans; a press picks a span.
#[derive(IntoElement)]
pub struct TimelineProfiler {
id: ElementId,
tracks: Vec<SharedString>,
spans: Rc<Vec<Span>>,
range: (f64, f64),
selected: Option<usize>,
on_range: Option<OnRange>,
on_select: Option<OnIndex>,
}
impl TimelineProfiler {
/// `range` is the stretch of time in view, in milliseconds.
pub fn new(
id: impl Into<ElementId>,
tracks: impl IntoIterator<Item = impl Into<SharedString>>,
spans: impl Into<Rc<Vec<Span>>>,
range: (f64, f64),
) -> Self {
Self {
id: id.into(),
tracks: tracks.into_iter().map(Into::into).collect(),
spans: spans.into(),
range,
selected: None,
on_range: None,
on_select: None,
}
}
pub fn selected(mut self, span: usize) -> Self {
self.selected = Some(span);
self
}
pub fn on_range(
mut self,
handler: impl Fn((f64, f64), &mut Window, &mut App) + 'static,
) -> Self {
self.on_range = Some(Rc::new(handler));
self
}
pub fn on_select(mut self, handler: impl Fn(usize, &mut Window, &mut App) + 'static) -> Self {
self.on_select = Some(Rc::new(handler));
self
}
}
impl RenderOnce for TimelineProfiler {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let colors = theme.colors.clone();
let line = theme
.control_height(ControlSize::Md)
.to_pixels(window.rem_size());
let (from, to) = self.range;
let length = to - from;
let at = move |time: f64| ((time - from) / length) as f32;
let step = nice_step(length, TICKS);
let first = (from / step).ceil() as i64;
let ticks: Vec<f64> = (first..)
.map(|n| n as f64 * step)
.take_while(|time| *time < to)
.collect();
let lane = Rc::new(Cell::new(Bounds::<Pixels>::default()));
let (measure, wheel_lane) = (lane.clone(), lane);
let on_range = self.on_range.clone();
let label = theme.label_width() * 0.6;
let row = theme.control_height(ControlSize::Md);
let selected = self.selected.and_then(|ix| self.spans.get(ix).cloned());
div()
.flex()
.flex_col()
.gap_2()
.text_size(theme.text_size(TextSize::Xs))
.child(
div().flex().child(div().flex_none().w(label)).child(
div()
.relative()
.flex_1()
.h(theme.text_size(TextSize::Xs) * LEADING)
.line_height(relative(LEADING))
.overflow_hidden()
.border_b_1()
.border_color(colors.border)
.children(ticks.iter().map(|time| {
div()
.absolute()
.left(relative(at(*time)))
.top_0()
.pl_1()
.border_l_1()
.border_color(colors.border)
.text_color(colors.fg_subtle)
.child(format!("{time:.0} ms"))
})),
),
)
.child(
div()
.id(self.id.clone())
.flex()
.flex_col()
.gap_px()
.on_scroll_wheel(move |event: &ScrollWheelEvent, window, cx| {
let Some(on_range) = &on_range else {
return;
};
let bounds = wheel_lane.get();
let delta = event.delta.pixel_delta(line);
let (x, y) = (f64::from(f32::from(delta.x)), f64::from(f32::from(delta.y)));
cx.stop_propagation();
let width = f64::from(f32::from(bounds.size.width)).max(1.0);
if x.abs() > y.abs() {
let shift = -x / width * length;
on_range((from + shift, to + shift), window, cx);
} else {
let pointer =
f64::from(f32::from(event.position.x - bounds.origin.x)) / width;
on_range(zoomed((from, to), pointer, (-y / ZOOM).exp()), window, cx);
}
})
.children(self.tracks.iter().enumerate().map(|(track, name)| {
let bars = self
.spans
.iter()
.enumerate()
.filter(|(_, span)| {
span.track == track && span.end > from && span.start < to
})
.map(|(ix, span)| {
let (left, right) =
(at(span.start).max(0.0), at(span.end).min(1.0));
let lit = self.selected == Some(ix);
let pick = self.on_select.clone();
let color = tint(&span.name, &colors.chart);
div()
.id((self.id.clone(), format!("span-{ix}")))
.absolute()
.top_0p5()
.bottom_0p5()
.left(relative(left))
.w(relative(right - left))
.px_1()
.overflow_hidden()
.whitespace_nowrap()
.rounded(theme.radius(Radius::Sm))
.bg(if lit { color } else { color.opacity(0.55) })
.border_1()
.border_color(if lit {
colors.focus
} else {
color.opacity(0.0)
})
.text_color(colors.fg)
.cursor_pointer()
.on_mouse_down(MouseButton::Left, |_, window, _| {
window.prevent_default()
})
.when_some(pick, |bar, pick| {
bar.on_click(move |_, window, cx| pick(ix, window, cx))
})
.child(Ellipsis::new(span.name.clone()))
});
div()
.flex()
.h(row)
.child(
div()
.flex_none()
.w(label)
.pr_2()
.text_color(colors.fg_muted)
.font_weight(FontWeight::MEDIUM)
.child(name.clone()),
)
.child(
div()
.relative()
.flex_1()
.h_full()
.overflow_hidden()
.bg(colors.hover.opacity(0.5))
.when(track == 0, |lane| {
let measure = measure.clone();
lane.child(
canvas(
move |bounds, _, _| measure.set(bounds),
|_, _, _, _| {},
)
.absolute()
.top_0()
.left_0()
.size_full(),
)
})
.children(bars),
)
})),
)
.children(selected.map(|span| {
div().text_color(colors.fg_muted).child(format!(
"{} · {:.1} ms · from {:.1} to {:.1} ms",
span.name,
span.end - span.start,
span.start,
span.end
))
}))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn zoom_keeps_the_point_under_the_pointer() {
assert_eq!(zoomed((0.0, 100.0), 0.5, 0.5), (25.0, 75.0));
assert_eq!(
zoomed((0.0, 100.0), 0.0, 0.5),
(0.0, 50.0),
"zooming at the start keeps the start"
);
assert_eq!(zoomed((10.0, 20.0), 1.0, 2.0), (0.0, 20.0));
}
}
+344
View File
@@ -0,0 +1,344 @@
use std::rc::Rc;
use gpui::{
AnyElement, App, ElementId, Entity, FontWeight, Hsla, InteractiveElement, IntoElement,
MouseButton, ParentElement, RenderOnce, SharedString, StatefulInteractiveElement, Styled,
Window, div, prelude::*,
};
use super::OnIndex;
use crate::{
buttons::{ButtonVariant, IconButton},
forms::{Input, TextInput},
primitives::{Disclosure, IconName},
theme::{ActiveTheme, ControlSize, Palette, Radius, TextSize},
typography::Ellipsis,
};
/// What a value is, which picks its color.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ValueKind {
Text,
Number,
Bool,
Null,
Object,
List,
Function,
}
impl ValueKind {
fn color(self, colors: &Palette) -> Hsla {
let syntax = &colors.syntax;
match self {
Self::Text => syntax.string,
Self::Number => syntax.number,
Self::Bool | Self::Null => syntax.keyword,
Self::Object | Self::List => syntax.type_name,
Self::Function => syntax.function,
}
}
}
/// A variable: its name, its value as the debugger prints it, its kind and type, and its fields or items.
#[derive(Clone, Debug, PartialEq)]
pub struct Variable {
pub name: SharedString,
pub value: SharedString,
pub kind: ValueKind,
pub type_name: Option<SharedString>,
pub children: Vec<Variable>,
}
/// Rows for `variables` under `path`, their children when open: each row's key, depth and variable.
pub(crate) fn flat<'a>(
variables: &'a [Variable],
path: &str,
depth: usize,
open: &[SharedString],
out: &mut Vec<(SharedString, usize, &'a Variable)>,
) {
for variable in variables {
let key: SharedString = format!("{path}/{}", variable.name).into();
let opened = open.contains(&key);
out.push((key.clone(), depth, variable));
if opened {
flat(&variable.children, &key, depth + 1, open, out);
}
}
}
type OnToggle = Rc<dyn Fn(&SharedString, bool, &mut Window, &mut App)>;
/// Variables by scope, as a tree: each with its value colored by kind and its type, fields and items opening under it; values that changed at the last step washed.
#[derive(IntoElement)]
pub struct VariablesPanel {
id: ElementId,
scopes: Vec<(SharedString, Vec<Variable>)>,
open: Vec<SharedString>,
changed: Vec<SharedString>,
on_toggle: Option<OnToggle>,
}
impl VariablesPanel {
pub fn new(
id: impl Into<ElementId>,
scopes: impl IntoIterator<Item = (SharedString, Vec<Variable>)>,
) -> Self {
Self {
id: id.into(),
scopes: scopes.into_iter().collect(),
open: Vec::new(),
changed: Vec::new(),
on_toggle: None,
}
}
/// Scopes and variables shown open, by key: `scope`, `scope/name`, `scope/name/field`.
pub fn open(mut self, keys: impl IntoIterator<Item = SharedString>) -> Self {
self.open = keys.into_iter().collect();
self
}
/// Variables whose values changed at the last step, by key.
pub fn changed(mut self, keys: impl IntoIterator<Item = SharedString>) -> Self {
self.changed = keys.into_iter().collect();
self
}
pub fn on_toggle(
mut self,
handler: impl Fn(&SharedString, bool, &mut Window, &mut App) + 'static,
) -> Self {
self.on_toggle = Some(Rc::new(handler));
self
}
}
impl RenderOnce for VariablesPanel {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let colors = theme.colors.clone();
let toggle_row = |key: SharedString, opens: bool, opened: bool, depth: usize| {
let toggle = self.on_toggle.clone();
div()
.id((self.id.clone(), key.clone()))
.flex()
.items_center()
.gap_1()
.pl(theme.text_size(TextSize::Sm) * (depth as f32 * 1.1 + 0.4))
.pr_2()
.py_0p5()
.rounded(theme.radius(Radius::Sm))
.hover(|row| row.bg(colors.hover))
.on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default())
.when(opens, |row| {
row.cursor_pointer().when_some(toggle, |row, toggle| {
row.on_click(move |_, window, cx| toggle(&key, !opened, window, cx))
})
})
.child(div().flex_none().w_4().children(opens.then(|| {
Disclosure::new(
(self.id.clone(), format!("chevron-{depth}-{opened}")),
opened,
)
})))
};
let mut rows: Vec<AnyElement> = Vec::new();
for (scope, variables) in &self.scopes {
let scope_key = scope.clone();
let opened = self.open.contains(&scope_key);
rows.push(
toggle_row(scope_key.clone(), true, opened, 0)
.font_weight(FontWeight::MEDIUM)
.text_color(colors.fg_muted)
.child(scope.clone())
.into_any_element(),
);
if !opened {
continue;
}
let mut shown = Vec::new();
flat(variables, scope, 1, &self.open, &mut shown);
for (key, depth, variable) in shown {
let opens = !variable.children.is_empty();
let changed = self.changed.contains(&key);
rows.push(
toggle_row(key.clone(), opens, self.open.contains(&key), depth)
.font_family(theme.mono_family.clone())
.child(
div()
.flex_none()
.text_color(colors.syntax.property)
.child(variable.name.clone()),
)
.child(div().flex_none().text_color(colors.fg_subtle).child(":"))
.child(
div()
.flex_1()
.min_w_0()
.px_1()
.rounded(theme.radius(Radius::Sm))
.when(changed, |value| value.bg(colors.warning.opacity(0.18)))
.text_color(variable.kind.color(&colors))
.child(Ellipsis::new(variable.value.clone())),
)
.children(
variable.type_name.clone().map(|kind| {
div().flex_none().text_color(colors.fg_subtle).child(kind)
}),
)
.into_any_element(),
);
}
}
div()
.flex()
.flex_col()
.text_size(theme.text_size(TextSize::Sm))
.children(rows)
}
}
/// A watched expression and what it gives: a value and its kind, or the error it raised.
#[derive(Clone, Debug, PartialEq)]
pub struct Watch {
pub expression: SharedString,
pub value: Result<(SharedString, ValueKind), SharedString>,
}
/// Expressions watched at every step, each with its value or its error; a field adds one, and each removes.
#[derive(IntoElement)]
pub struct WatchPanel {
id: ElementId,
watches: Vec<Watch>,
field: Entity<TextInput>,
on_remove: Option<OnIndex>,
}
impl WatchPanel {
/// `field` takes a new expression; its owner adds it on Enter.
pub fn new(
id: impl Into<ElementId>,
watches: impl IntoIterator<Item = Watch>,
field: &Entity<TextInput>,
) -> Self {
Self {
id: id.into(),
watches: watches.into_iter().collect(),
field: field.clone(),
on_remove: None,
}
}
pub fn on_remove(mut self, handler: impl Fn(usize, &mut Window, &mut App) + 'static) -> Self {
self.on_remove = Some(Rc::new(handler));
self
}
}
impl RenderOnce for WatchPanel {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let colors = theme.colors.clone();
div()
.flex()
.flex_col()
.gap_1()
.text_size(theme.text_size(TextSize::Sm))
.children(self.watches.iter().enumerate().map(|(ix, watch)| {
let group = SharedString::from(format!("watch-{ix}"));
let remove = self.on_remove.clone();
let (value, color) = match &watch.value {
Ok((value, kind)) => (value.clone(), kind.color(&colors)),
Err(error) => (error.clone(), colors.danger),
};
div()
.id((self.id.clone(), format!("row-{ix}")))
.group(group.clone())
.flex()
.items_center()
.gap_2()
.px_2()
.py_0p5()
.rounded(theme.radius(Radius::Sm))
.hover(|row| row.bg(colors.hover))
.font_family(theme.mono_family.clone())
.child(
div()
.flex_none()
.text_color(colors.fg)
.child(watch.expression.clone()),
)
.child(div().flex_none().text_color(colors.fg_subtle).child("="))
.child(
div()
.flex_1()
.min_w_0()
.text_color(color)
.child(Ellipsis::new(value)),
)
.child(
div()
.opacity(0.)
.group_hover(group, |actions| actions.opacity(1.))
.child(
IconButton::new(
(self.id.clone(), format!("remove-{ix}")),
IconName::X,
)
.variant(ButtonVariant::Ghost)
.size(ControlSize::Sm)
.tooltip("Stop watching")
.when_some(
remove,
|button, remove| {
button.on_click(move |_, window, cx| remove(ix, window, cx))
},
),
),
)
}))
.child(Input::new(&self.field).size(ControlSize::Sm))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn variable(name: &str, children: Vec<Variable>) -> Variable {
Variable {
name: name.to_string().into(),
value: "".into(),
kind: ValueKind::Object,
type_name: None,
children,
}
}
#[test]
fn open_variables_show_their_fields_under_them() {
let all = [
variable(
"palette",
vec![variable("colors", vec![variable("accent", vec![])])],
),
variable("count", vec![]),
];
let mut rows = Vec::new();
flat(&all, "Locals", 1, &["Locals/palette".into()], &mut rows);
let keys: Vec<(&str, usize)> = rows
.iter()
.map(|(key, depth, _)| (key.as_ref(), *depth))
.collect();
assert_eq!(
keys,
[
("Locals/palette", 1),
("Locals/palette/colors", 2),
("Locals/count", 1)
]
);
}
}