diff --git a/apps/lumbridge/src/attention.rs b/apps/lumbridge/src/attention.rs index 65ecf24..da527a5 100644 --- a/apps/lumbridge/src/attention.rs +++ b/apps/lumbridge/src/attention.rs @@ -138,17 +138,6 @@ impl Attention { pub(crate) fn signals(&self) -> &[AttentionSignal] { &self.signals } - - /// How many panes are *certainly* waiting. - /// - /// This is the number the sidebar shows. Guessed signals are excluded by - /// construction rather than by remembering to filter them. - pub(crate) fn countable(&self) -> usize { - self.signals - .iter() - .filter(|signal| signal.is_countable()) - .count() - } } #[cfg(test)] @@ -171,14 +160,21 @@ mod tests { attention.observe(signal(1, AttentionSource::TitleHeuristic, 10)); attention.observe(signal(2, AttentionSource::Fixture, 20)); assert_eq!(attention.signals().len(), 2, "both are shown"); + let countable = |attention: &Attention| { + attention + .signals() + .iter() + .filter(|signal| signal.is_countable()) + .count() + }; assert_eq!( - attention.countable(), + countable(&attention), 0, "neither may claim that something is waiting" ); attention.observe(signal(3, AttentionSource::RuntimeObserved, 30)); - assert_eq!(attention.countable(), 1); + assert_eq!(countable(&attention), 1); } #[test] diff --git a/apps/lumbridge/src/keymap.rs b/apps/lumbridge/src/keymap.rs index cb2a849..0340233 100644 --- a/apps/lumbridge/src/keymap.rs +++ b/apps/lumbridge/src/keymap.rs @@ -29,9 +29,10 @@ use gpui::KeyBinding; use crate::{ - AddPanel, DetachSelectedPanel, FocusDown, FocusLeft, FocusRight, FocusUp, OpenPalette, - PasteIntoPane, RestartPane, SelectPane1, SelectPane2, SelectPane3, SelectPane4, SelectPane5, - SelectPane6, TerminalNarrower, TerminalShorter, TerminalTaller, TerminalWider, TerminatePane, + AddPanel, DetachSelectedPanel, FocusDown, FocusLeft, FocusRight, FocusSidebar, FocusUp, + JumpToAttention, OpenPalette, PasteIntoPane, RestartPane, SelectPane1, SelectPane2, + SelectPane3, SelectPane4, SelectPane5, SelectPane6, TerminalNarrower, TerminalShorter, + TerminalTaller, TerminalWider, TerminatePane, ToggleSidebar, }; /// The context every binding is scoped to. @@ -64,6 +65,9 @@ pub(crate) const BINDINGS: &[&str] = &[ // The terminal convention, and the only safe spelling: `secondary-v` is // ctrl-v on Linux, which readline reads as quoted-insert. "secondary-shift-v", + "secondary-alt-b", + "secondary-alt-s", + "secondary-alt-a", ]; /// Builds the bindings in the same order as [`BINDINGS`]. @@ -93,6 +97,9 @@ pub(crate) fn bindings() -> Vec { KeyBinding::new(BINDINGS[17], RestartPane, Some(CONTEXT)), KeyBinding::new(BINDINGS[18], TerminatePane, Some(CONTEXT)), KeyBinding::new(BINDINGS[19], PasteIntoPane, Some(CONTEXT)), + KeyBinding::new(BINDINGS[20], ToggleSidebar, Some(CONTEXT)), + KeyBinding::new(BINDINGS[21], FocusSidebar, Some(CONTEXT)), + KeyBinding::new(BINDINGS[22], JumpToAttention, Some(CONTEXT)), ] } diff --git a/apps/lumbridge/src/main.rs b/apps/lumbridge/src/main.rs index c3a28bb..deaebd7 100644 --- a/apps/lumbridge/src/main.rs +++ b/apps/lumbridge/src/main.rs @@ -1,6 +1,7 @@ mod attention; mod keymap; mod panel_registry; +mod sidebar; mod theme; mod usage_feed; @@ -31,6 +32,10 @@ use lumbridge_ui_fixture::{ use attention::{Attention, AttentionKind, AttentionSignal, AttentionSource}; use lumbridge_harness::MonotonicWallClock; use panel_registry::{PanelId, PanelKind, PanelRegistry, SeedPane}; +use sidebar::model::{ + AttentionEntry, HostEntry, Indicator, PaneActivity, PaneEntry, QuotaEntry, SidebarInput, + SidebarState, +}; use theme::{ActiveTheme, ThemeColors}; use usage_feed::{UsageFeed, UsageSegment}; @@ -38,7 +43,6 @@ const TIMING_SAMPLE_LIMIT: usize = 256; const RUNTIME_POLL_INTERVAL: Duration = Duration::from_millis(16); const RUNTIME_DRAIN_LIMIT: usize = 64; const WORKSPACE_SNAPSHOT_ID: &str = "lumbridge-code-gpui-spike"; -const SIDEBAR_WIDTH: f32 = 248.0; const APP_HEADER_HEIGHT: f32 = 48.0; const TAB_BAR_HEIGHT: f32 = 38.0; const APP_FOOTER_HEIGHT: f32 = 46.0; @@ -47,6 +51,8 @@ const TERMINAL_HORIZONTAL_INSET: f32 = 32.0; const TERMINAL_CELL_WIDTH: f32 = 8.4; const TERMINAL_CELL_HEIGHT: f32 = 18.0; const WORK_PANEL_GAP: f32 = 4.0; +/// How close to the seam a press has to land to start a resize. +const SIDEBAR_GRAB_RADIUS: f32 = 4.0; const TERMINAL_ROW_STEP: u16 = 2; const TERMINAL_COLUMN_STEP: u16 = 10; @@ -62,6 +68,9 @@ actions!( RestartPane, TerminatePane, PasteIntoPane, + ToggleSidebar, + FocusSidebar, + JumpToAttention, SelectPane1, SelectPane2, SelectPane3, @@ -100,6 +109,9 @@ struct LumbridgeShell { input_gap: Option, /// What is waiting on a human, and how we know. attention: Attention, + sidebar: SidebarState, + sidebar_has_focus: bool, + sidebar_dragging: bool, /// Stamps attention signals. Monotonic, so a signal cannot appear to have /// arrived before one recorded earlier. clock: MonotonicWallClock, @@ -473,16 +485,17 @@ fn dim_color(color: Rgba) -> Rgba { fn terminal_dimensions_for_window( window_size: Size, attached_panel_count: usize, + sidebar_width: f32, ) -> TerminalDimensions { let width = f32::from(window_size.width); let height = f32::from(window_size.height); - let panel_count = visible_panel_count(window_size) + let panel_count = visible_panel_count(window_size, sidebar_width) .min(attached_panel_count) .max(1); let workspace_height = (height - APP_HEADER_HEIGHT - TAB_BAR_HEIGHT - APP_FOOTER_HEIGHT).max(0.0); let terminal_height = (workspace_height * 0.60 - TERMINAL_CONTENT_VERTICAL_INSET).max(0.0); - let workspace_width = (width - SIDEBAR_WIDTH).max(0.0); + let workspace_width = (width - sidebar_width).max(0.0); // A panel count is single digits; the precision lint is about 2^24 and up. let panel_gaps = WORK_PANEL_GAP * lossless_f32(panel_count.saturating_sub(1)); let panel_width = @@ -503,8 +516,13 @@ fn terminal_dimensions_for_window( .expect("geometry clamps terminal dimensions above zero") } -fn visible_panel_count(window_size: Size) -> usize { - let workspace_width = (f32::from(window_size.width) - SIDEBAR_WIDTH).max(0.0); +/// How many panes fit beside the rail. +/// +/// Measured *after* the sidebar, as decision 0009 specifies, which is why +/// widening the rail can drop the workspace from three panes to one. That is +/// correct and it is visible while dragging. +fn visible_panel_count(window_size: Size, sidebar_width: f32) -> usize { + let workspace_width = (f32::from(window_size.width) - sidebar_width).max(0.0); if workspace_width >= 2_800.0 { 5 } else if workspace_width >= 1_100.0 { @@ -669,8 +687,12 @@ impl LumbridgeShell { .detach(); let (store, panels, persistence_status) = load_panel_registry(); - let terminal_dimensions = - terminal_dimensions_for_window(window.bounds().size, panels.attached_count()); + let sidebar = SidebarState::default(); + let terminal_dimensions = terminal_dimensions_for_window( + window.bounds().size, + panels.attached_count(), + sidebar.width(), + ); let mut runtimes = RuntimeRegistry::new(); let mut live_terminals = BTreeMap::new(); for panel in panels @@ -688,8 +710,11 @@ impl LumbridgeShell { } cx.observe_window_bounds(window, |shell, window, cx| { - let dimensions = - terminal_dimensions_for_window(window.bounds().size, shell.panels.attached_count()); + let dimensions = terminal_dimensions_for_window( + window.bounds().size, + shell.panels.attached_count(), + shell.sidebar.width(), + ); shell.resize_attached_terminals(dimensions.rows(), dimensions.columns(), cx); }) .detach(); @@ -721,6 +746,9 @@ impl LumbridgeShell { pending_terminate: None, input_gap: None, attention: Attention::new(), + sidebar, + sidebar_has_focus: false, + sidebar_dragging: false, clock: MonotonicWallClock::start(), root_focus, } @@ -1005,8 +1033,11 @@ impl LumbridgeShell { } fn resize_terminal_for_workspace(&mut self, window: &Window, cx: &mut Context) { - let dimensions = - terminal_dimensions_for_window(window.bounds().size, self.panels.attached_count()); + let dimensions = terminal_dimensions_for_window( + window.bounds().size, + self.panels.attached_count(), + self.sidebar.width(), + ); self.resize_attached_terminals(dimensions.rows(), dimensions.columns(), cx); } @@ -1024,8 +1055,11 @@ impl LumbridgeShell { self.timing.mark_dispatch(); let pane = self.panels.create(kind); if kind == PanelKind::Terminal { - let dimensions = - terminal_dimensions_for_window(window.bounds().size, self.panels.attached_count()); + let dimensions = terminal_dimensions_for_window( + window.bounds().size, + self.panels.attached_count(), + self.sidebar.width(), + ); let mut terminal = LiveTerminalState::new(dimensions); if let Err(error) = spawn_live_runtime(&mut self.runtimes, pane, None, dimensions) { terminal.status = LiveRuntimeStatus::Fault(error.to_string()); @@ -1070,8 +1104,11 @@ impl LumbridgeShell { cx.notify(); return; } - let dimensions = - terminal_dimensions_for_window(window.bounds().size, self.panels.attached_count()); + let dimensions = terminal_dimensions_for_window( + window.bounds().size, + self.panels.attached_count(), + self.sidebar.width(), + ); let mut terminal = LiveTerminalState::new(dimensions); if let Err(error) = spawn_live_runtime(&mut self.runtimes, pane, None, dimensions) { terminal.status = LiveRuntimeStatus::Fault(error.to_string()); @@ -1144,6 +1181,111 @@ impl LumbridgeShell { cx.notify(); } + fn toggle_sidebar(&mut self, _: &ToggleSidebar, window: &mut Window, cx: &mut Context) { + self.sidebar.visible = !self.sidebar.visible; + if !self.sidebar.visible { + self.sidebar_has_focus = false; + } + // Not persisted yet: the rail's width, collapsed sections, and + // visibility belong in their own snapshot, separate from the workspace + // blob so the two fail closed independently. Until that exists, the + // sidebar returns to its defaults on restart. + // + // Hiding the rail gives its width to the panes, which can change how + // many fit; the terminals have to be told. + self.resize_terminal_for_workspace(window, cx); + cx.notify(); + } + + /// Moves the keyboard into the sidebar, and back out again. + /// + /// While the sidebar owns the keyboard, `on_key_down` returns before it + /// encodes anything, so a bare `j` navigates the rail instead of being + /// written into whatever pane happens to be selected. + fn focus_sidebar(&mut self, _: &FocusSidebar, window: &mut Window, cx: &mut Context) { + if !self.sidebar.visible { + self.sidebar.visible = true; + } + self.sidebar_has_focus = !self.sidebar_has_focus; + if self.sidebar_has_focus && self.sidebar.cursor.is_none() { + let rows = sidebar::model::flatten(&self.sidebar_input(), &self.sidebar); + self.sidebar.cursor = sidebar::model::move_cursor(&rows, None, 1); + } + window.focus(&self.root_focus); + cx.notify(); + } + + /// Jumps to the next pane that is actually waiting on a human. + fn jump_to_attention( + &mut self, + _: &JumpToAttention, + window: &mut Window, + cx: &mut Context, + ) { + let Some(signal) = self + .attention + .signals() + .iter() + .find(|signal| signal.is_countable()) + else { + return; + }; + let pane = signal.pane; + self.sidebar.cursor = Some(sidebar::model::RowKey::Pane(pane.get())); + self.select_pane(pane, window, cx); + } + + /// Keys handled while the sidebar owns the keyboard. + /// + /// Returns whether the key was consumed. Bare letters are navigation here, + /// which is exactly why `on_key_down` must consult this before it encodes + /// anything for a terminal. + fn sidebar_key(&mut self, key: &str, window: &mut Window, cx: &mut Context) -> bool { + let rows = sidebar::model::flatten(&self.sidebar_input(), &self.sidebar); + let cursor = self.sidebar.cursor.clone(); + match key { + "escape" => { + self.sidebar_has_focus = false; + } + "down" | "j" => { + self.sidebar.cursor = sidebar::model::move_cursor(&rows, cursor.as_ref(), 1); + } + "up" | "k" => { + self.sidebar.cursor = sidebar::model::move_cursor(&rows, cursor.as_ref(), -1); + } + "home" => self.sidebar.cursor = sidebar::model::move_cursor(&rows, None, 1), + "end" => self.sidebar.cursor = sidebar::model::move_cursor(&rows, None, -1), + "enter" | "space" => { + if let Some(row) = cursor { + self.activate_sidebar_row(&row, window, cx); + // Enter hands the keyboard to what it opened; space peeks + // and stays, so a list can be walked without losing the + // place in it. + if key == "enter" { + self.sidebar_has_focus = false; + } + } + } + "left" => { + if let Some(sidebar::model::RowKey::Header(section)) = cursor + && !self.sidebar.is_collapsed(section) + { + self.sidebar.toggle(section); + } + } + "right" => { + if let Some(sidebar::model::RowKey::Header(section)) = cursor + && self.sidebar.is_collapsed(section) + { + self.sidebar.toggle(section); + } + } + _ => return false, + } + cx.notify(); + true + } + fn add_panel_action(&mut self, _: &AddPanel, window: &mut Window, cx: &mut Context) { self.dispatch(ShellAction::CloseCommandPalette); self.add_panel_chooser_open = !self.add_panel_chooser_open; @@ -1259,6 +1401,12 @@ impl LumbridgeShell { } fn on_key_down(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context) { + // The sidebar owns the keyboard first. Without this, `j` and `space` + // and `enter` would be encoded and written into the selected PTY while + // the user believed they were walking a list. + if self.sidebar_has_focus && self.sidebar_key(event.keystroke.key.as_str(), window, cx) { + return; + } if self.add_panel_chooser_open { match event.keystroke.key.as_str() { "escape" => { @@ -2432,7 +2580,6 @@ impl LumbridgeShell { /// Where sessions are owned, and what each host is actually doing. fn runtime_rows(&self) -> Vec { - let theme = self.theme.colors; let running = self .live_terminals .values() @@ -2441,12 +2588,8 @@ impl LumbridgeShell { let total = self.live_terminals.len(); let local = RuntimeRow { label: "this machine", + live: running > 0, detail: format!("{running}/{total} live PTYs"), - tone: if running == 0 { - theme.muted - } else { - theme.success - }, }; // The usage adapter is neither a host nor an endpoint, but its health // belongs beside them: it is the reason the footer can or cannot answer. @@ -2458,11 +2601,7 @@ impl LumbridgeShell { self.usage.reporting_profile_count(), self.usage.declared_profile_count() ), - tone: if self.usage.reporting_profile_count() == 0 { - theme.muted - } else { - theme.success - }, + live: self.usage.reporting_profile_count() > 0, }; vec![local, adapter] } @@ -2565,6 +2704,9 @@ impl LumbridgeShell { .on_action(cx.listener(Self::restart_pane)) .on_action(cx.listener(Self::terminate_pane)) .on_action(cx.listener(Self::paste_into_pane)) + .on_action(cx.listener(Self::toggle_sidebar)) + .on_action(cx.listener(Self::focus_sidebar)) + .on_action(cx.listener(Self::jump_to_attention)) .on_action(cx.listener(Self::terminal_taller)) .on_action(cx.listener(Self::terminal_shorter)) .on_action(cx.listener(Self::terminal_wider)) @@ -2588,6 +2730,9 @@ impl LumbridgeShell { shell.select_pane_at(5, window, cx); })) .on_key_down(cx.listener(Self::on_key_down)) + .on_mouse_down(gpui::MouseButton::Left, cx.listener(Self::on_mouse_down)) + .on_mouse_move(cx.listener(Self::on_mouse_move)) + .on_mouse_up(gpui::MouseButton::Left, cx.listener(Self::on_mouse_up)) .flex() .flex_col() .size_full() @@ -2633,7 +2778,7 @@ impl LumbridgeShell { ), ) .child( - div().flex().flex_1().min_h_0().child(sidebar).child( + div().flex().flex_1().min_h_0().children(sidebar).child( div() .flex() .flex_col() @@ -2685,148 +2830,256 @@ impl LumbridgeShell { /// Split out of `render`, which was 353 lines. It is the first thing the /// sidebar rework needs, and a 350-line render function is where a UI stops /// being reviewable. - #[allow( - clippy::too_many_lines, - reason = "a single declarative element tree; the sidebar rework replaces it wholesale" - )] - fn render_sidebar( - &self, - attention_rows: &[AttentionRow], - detached_entries: &[(PanelId, String)], - cx: &mut Context, - ) -> gpui::AnyElement { + /// One pane as the sidebar needs it, or nothing when the panel is gone. + fn sidebar_pane_entry(&self, id: PanelId) -> Option { + let panel = self.panels.panel(id)?; + let selected = self.panels.selected(); + let live = self.live_terminals.get(&id); + let segment = self + .usage + .profile_for_seed(panel.seed) + .and_then(|profile| self.usage.segment_for(profile)); + Some(PaneEntry { + id: id.get(), + title: panel.title.clone(), + target: panel.target.clone(), + kind_glyph: panel.kind.glyph(), + selected: id == selected, + indicator: Indicator::resolve(PaneActivity { + faulted: matches!( + live.map(|terminal| &terminal.status), + Some(LiveRuntimeStatus::Fault(_)) + ), + // A harness asking a question arrives over the ACP client; + // decision 0019 declines to guess it from output. + waiting: false, + quota_nearly_spent: segment.as_ref().is_some_and(|segment| segment.critical), + // The exit code the attention model preserved, rather than + // a sentence re-parsed back into a number. + finished: matches!( + live.map(|terminal| &terminal.status), + Some(LiveRuntimeStatus::Exited(_)) + ) + .then(|| { + self.attention + .signals() + .iter() + .find(|signal| signal.pane == id) + .and_then(|signal| match signal.kind { + AttentionKind::Finished { exit_code } => Some(exit_code), + _ => None, + }) + .unwrap_or_default() + }), + working: matches!( + live.map(|terminal| &terminal.status), + Some(LiveRuntimeStatus::Running { .. }) + ), + }), + quota: segment + .as_ref() + .and_then(|segment| segment.headline.clone()), + quota_critical: segment.as_ref().is_some_and(|segment| segment.critical), + }) + } + + /// Everything the sidebar draws from, gathered once per frame. + fn sidebar_input(&self) -> SidebarInput { + SidebarInput { + attention: self + .attention_rows() + .into_iter() + .map(|row| AttentionEntry { + id: row.id.get(), + title: row.title, + reason: row.reason, + source: row.source, + countable: row.countable, + }) + .collect(), + panes: self + .panels + .attached_ids() + .into_iter() + .filter_map(|id| self.sidebar_pane_entry(id)) + .collect(), + detached: self + .panels + .detached_ids() + .into_iter() + .filter_map(|id| self.sidebar_pane_entry(id)) + .collect(), + hosts: self + .runtime_rows() + .into_iter() + .map(|row| HostEntry { + name: row.label.to_owned(), + detail: row.detail, + live: row.live, + }) + .collect(), + // The harness name once per group, then only which window. Three + // rows all reading "CLAUDE CODE" with the scope truncated off the + // end name the same thing three times and identify none of them. + quota: { + let mut previous: Option = None; + self.usage + .strip() + .into_iter() + .map(|segment| { + let repeats = previous.as_deref() == Some(segment.label.as_str()); + previous = Some(segment.label.clone()); + QuotaEntry { + id: segment.id.as_str().to_owned(), + label: if repeats { + segment.scope.clone() + } else { + format!("{} {}", segment.label, segment.scope) + }, + headline: segment.headline, + consumed_permille: segment.consumed_permille, + critical: segment.critical, + } + }) + .collect() + }, + } + } + + /// The left rail. One flat row list, one row height, one selection language. + fn render_sidebar(&self, cx: &mut Context) -> gpui::AnyElement { let theme = self.theme.colors; - let waiting = self.attention.countable(); + let rows = sidebar::model::flatten(&self.sidebar_input(), &self.sidebar); + let focused = self.sidebar_has_focus; + let cursor = self.sidebar.cursor.clone(); div() + .relative() .flex() .flex_col() - .w(px(248.0)) + .w(px(self.sidebar.width())) .flex_none() - .bg(theme.surface) + .bg(theme.chrome) .border_r_1() .border_color(theme.border_quiet) .child( div() - .px_4() - .pt_4() - .pb_2() - .text_xs() - .text_color(if waiting == 0 { - theme.muted - } else { - theme.attention - }) - // The count is only what is certainly waiting. A guessed - // signal still gets a row below, but it may not make the - // sidebar assert that something needs you. - .child(format!("ATTENTION · {waiting}")), - ) - .when(attention_rows.is_empty(), |view| { - view.child( - div() - .mx_2() - .mb_3() - .px_3() - .py_2() - .text_xs() - .text_color(theme.muted) - .child("No pane is waiting on you"), - ) - }) - .children(attention_rows.iter().map(|row| { - let id = row.id; - div() - .id(("attention-panel", id.get())) - .cursor_pointer() - .mx_2() - .mb_3() - .px_3() - .py_2() - .rounded(px(5.0)) - .bg(theme.surface_active) - .border_1() - // A guess is outlined quietly; a report is outlined in the - // attention colour. The row looks like what it is. - .border_color(if row.countable { - theme.attention - } else { - theme.border - }) - .hover(|view| view.bg(theme.surface_raised)) - .child( + .flex_1() + .min_h_0() + .overflow_hidden() + .children(rows.into_iter().map(|row| { + let cursored = cursor.as_ref() == Some(&row.key); + let key = row.key.clone(); div() - .text_sm() - .text_color(theme.text) - .child(row.title.clone()), - ) - .child( - div() - .mt_1() - .text_xs() - .text_color(theme.muted) - .child(format!("{} · {}", row.reason, row.source)), - ) - .on_click(cx.listener(move |shell, _, window, cx| { - shell.select_pane(id, window, cx); - })) - })) + .id(sidebar::view::element_id(&key)) + .cursor_pointer() + .hover(|view| view.bg(theme.surface_raised)) + .on_click(cx.listener(move |shell, _, window, cx| { + shell.activate_sidebar_row(&key, window, cx); + })) + .child(sidebar::view::row( + &row, + sidebar::view::RowStyle { + theme, + cursored, + focused, + }, + )) + })), + ) + // The drag handle. Four pixels, occluding so it wins over the + // rows behind it. .child( div() - .mt_3() - .px_4() - .py_2() - .text_xs() - .text_color(theme.muted) - .child("RUNTIMES"), + .id("sidebar-resize") + .absolute() + .right_0() + .top_0() + .bottom_0() + .w(px(5.0)) + .cursor_col_resize() + .occlude() + // Visible on hover, so the handle is discoverable rather + // than a five-pixel strip you have to already know about. + .hover(|view| view.bg(theme.accent)) + .when(self.sidebar_dragging, |view| view.bg(theme.accent)), ) - .children(self.runtime_rows().into_iter().map(|row| { - div() - .px_4() - .py_1() - .child(div().text_sm().text_color(row.tone).child(row.label)) - .child(div().text_xs().text_color(theme.muted).child(row.detail)) - })) - .child( - div() - .px_4() - .pt_2() - .text_xs() - .text_color(theme.muted) - .child(self.persistence_status.clone()), - ) - .child( - div() - .mt_3() - .px_4() - .py_2() - .text_xs() - .text_color(theme.muted) - .child(format!("DETACHED SESSIONS · {}", detached_entries.len())), - ) - .children(detached_entries.iter().map(|(pane, title)| { - let (pane, title) = (*pane, title.clone()); - div() - .id(("detached-session", pane.get())) - .cursor_pointer() - .mx_2() - .mb_1() - .px_3() - .py_2() - .rounded(px(4.0)) - .border_1() - .border_color(theme.border_quiet) - .hover(|view| view.border_color(theme.accent).text_color(theme.text)) - .text_xs() - .text_color(theme.muted) - .child(format!("↪ {title}")) - .on_click(cx.listener(move |shell, _, window, cx| { - shell.attach_panel(pane, window, cx); - })) - })) - .child(div().flex_1()) .into_any_element() } + /// Starts a rail drag when the press lands on the seam. + /// + /// Owned by the root rather than by the handle element: the press has to be + /// seen wherever the pointer then travels, and a child that only sees events + /// inside its own five pixels cannot follow a drag across the window. + fn on_mouse_down( + &mut self, + event: &gpui::MouseDownEvent, + _: &mut Window, + cx: &mut Context, + ) { + if !self.sidebar.visible { + return; + } + let x = f32::from(event.position.x); + if (x - self.sidebar.width()).abs() <= SIDEBAR_GRAB_RADIUS { + self.sidebar_dragging = true; + cx.notify(); + } + } + + /// Follows a rail drag. + /// + /// The width is applied live so the workspace reflows under the pointer — + /// decision 0009 measures its pane thresholds after the sidebar, so widening + /// the rail really can drop three panes to one, and hiding that until + /// release would make the result look like a bug. The PTYs are told once, on + /// release: a resize per mouse-move would be a SIGWINCH storm through the + /// runtime's bounded queues. + fn on_mouse_move( + &mut self, + event: &gpui::MouseMoveEvent, + _: &mut Window, + cx: &mut Context, + ) { + if !self.sidebar_dragging { + return; + } + self.sidebar.set_width(f32::from(event.position.x)); + cx.notify(); + } + + fn on_mouse_up(&mut self, _: &gpui::MouseUpEvent, window: &mut Window, cx: &mut Context) { + if !self.sidebar_dragging { + return; + } + self.sidebar_dragging = false; + self.resize_terminal_for_workspace(window, cx); + cx.notify(); + } + + /// Clicking or pressing enter on a row. + fn activate_sidebar_row( + &mut self, + key: &sidebar::model::RowKey, + window: &mut Window, + cx: &mut Context, + ) { + self.sidebar.cursor = Some(key.clone()); + match key { + sidebar::model::RowKey::Header(section) => self.sidebar.toggle(*section), + sidebar::model::RowKey::Pane(id) | sidebar::model::RowKey::Attention(id) => { + self.select_pane(PanelId::from_raw(*id), window, cx); + } + sidebar::model::RowKey::Detached(id) => { + self.attach_panel(PanelId::from_raw(*id), window, cx); + } + sidebar::model::RowKey::Empty(_) + | sidebar::model::RowKey::Host(_) + | sidebar::model::RowKey::Quota(_) => {} + } + cx.notify(); + } + /// The standing of every harness, always, regardless of what is selected. /// /// The old footer answered only for the selected pane, so the moment you @@ -2992,7 +3245,8 @@ fn usage_segment( /// The pieces `render` assembles, bundled so the assembly step takes one /// argument rather than six. struct RootParts { - sidebar: gpui::AnyElement, + /// Absent when the rail is hidden. + sidebar: Option, tabs: gpui::AnyElement, workspace_row: gpui::AnyElement, footer_left: String, @@ -3016,7 +3270,9 @@ struct AttentionRow { struct RuntimeRow { label: &'static str, detail: String, - tone: Rgba, + /// Whether anything is actually running there. The renderer derives the + /// colour; a row no longer carries one. + live: bool, } fn footer_separator(theme: ThemeColors) -> impl IntoElement { @@ -3049,7 +3305,7 @@ fn provenance_chip( impl Render for LumbridgeShell { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let panel_capacity = visible_panel_count(window.bounds().size); + let panel_capacity = visible_panel_count(window.bounds().size, self.sidebar.width()); let attached_panes = self.panels.attached_ids(); let attached_count = attached_panes.len(); let detached_entries = self @@ -3077,8 +3333,7 @@ impl Render for LumbridgeShell { "{running_runtime_count}/{} LIVE PTYS", self.live_terminals.len() ); - let attention_rows = self.attention_rows(); - let sidebar = self.render_sidebar(&attention_rows, &detached_entries, cx); + let sidebar = self.sidebar.visible.then(|| self.render_sidebar(cx)); let selected_position = attached_panes .iter() @@ -3289,6 +3544,7 @@ mod tests { indexed_terminal_color, terminal_dimensions_for_window, terminal_key_from_parts, terminal_paint_rows, terminal_scroll_from_parts, visible_panel_count, visible_panel_range, }; + use crate::sidebar::model::DEFAULT_WIDTH; use gpui::{px, size}; #[test] @@ -3343,17 +3599,17 @@ mod tests { #[test] fn terminal_geometry_tracks_middle_sixty_percent_per_panel() { - let dimensions = terminal_dimensions_for_window(size(px(1500.0), px(960.0)), 5); + let dimensions = terminal_dimensions_for_window(size(px(1500.0), px(960.0)), 5, 248.0); assert_eq!(dimensions.rows(), 26); assert_eq!(dimensions.columns(), 45); let ultrawide = size(px(3440.0), px(1440.0)); - assert_eq!(visible_panel_count(ultrawide), 5); - let dimensions = terminal_dimensions_for_window(ultrawide, 5); + assert_eq!(visible_panel_count(ultrawide, DEFAULT_WIDTH), 5); + let dimensions = terminal_dimensions_for_window(ultrawide, 5, DEFAULT_WIDTH); assert_eq!(dimensions.rows(), 42); assert_eq!(dimensions.columns(), 71); - let two_panels = terminal_dimensions_for_window(ultrawide, 2); + let two_panels = terminal_dimensions_for_window(ultrawide, 2, DEFAULT_WIDTH); assert_eq!(two_panels.rows(), 42); assert_eq!(two_panels.columns(), 185); } diff --git a/apps/lumbridge/src/panel_registry.rs b/apps/lumbridge/src/panel_registry.rs index c93fcd3..740d2c0 100644 --- a/apps/lumbridge/src/panel_registry.rs +++ b/apps/lumbridge/src/panel_registry.rs @@ -10,6 +10,11 @@ impl PanelId { pub(crate) const fn get(self) -> u64 { self.0 } + + /// Rebuilds an identifier the sidebar carried through a row key. + pub(crate) const fn from_raw(value: u64) -> Self { + Self(value) + } } #[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] @@ -33,6 +38,16 @@ impl PanelKind { } } + /// One character naming the kind, for a fixed-width column. + pub(crate) const fn glyph(self) -> &'static str { + match self { + Self::Terminal => "▸", + Self::Browser => "◍", + Self::Markdown => "≡", + Self::Review => "±", + } + } + pub(crate) const fn description(self) -> &'static str { match self { Self::Terminal => "A real local shell with its own runtime actor", diff --git a/apps/lumbridge/src/sidebar/mod.rs b/apps/lumbridge/src/sidebar/mod.rs new file mode 100644 index 0000000..3873a73 --- /dev/null +++ b/apps/lumbridge/src/sidebar/mod.rs @@ -0,0 +1,9 @@ +//! The left rail: what needs you, what you are running, what you set aside, +//! where it runs, and what will stop you. +//! +//! Split in two on purpose. [`model`] decides what rows exist and holds no +//! renderer types, so the layout rules are tested in CI without a window; +//! [`view`] turns rows into elements and decides nothing. + +pub(crate) mod model; +pub(crate) mod view; diff --git a/apps/lumbridge/src/sidebar/model.rs b/apps/lumbridge/src/sidebar/model.rs new file mode 100644 index 0000000..98cee24 --- /dev/null +++ b/apps/lumbridge/src/sidebar/model.rs @@ -0,0 +1,808 @@ +//! What the sidebar shows, as data. +//! +//! No renderer types here, so the layout rules — which sections exist, what +//! collapses, what the filter keeps, where the keyboard cursor lands after a row +//! disappears — are ordinary functions with ordinary tests. `view.rs` turns the +//! result into elements and decides nothing. +//! +//! The sidebar answers four questions, in this order, and the sections are that +//! order: **what needs me**, **what am I running**, **what did I put aside**, +//! **what will stop me**. Anything that does not answer one of those does not +//! belong here. + +use std::fmt::Write as _; + +/// The sections, top to bottom. +/// +/// Every header renders even when its section is empty, so a row never moves +/// under the pointer because something appeared above it. +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub(crate) enum Section { + NeedsYou, + Workspace, + Detached, + Hosts, + Quota, +} + +impl Section { + pub(crate) const ALL: [Self; 5] = [ + Self::NeedsYou, + Self::Workspace, + Self::Detached, + Self::Hosts, + Self::Quota, + ]; + + pub(crate) const fn title(self) -> &'static str { + match self { + Self::NeedsYou => "NEEDS YOU", + Self::Workspace => "WORKSPACE", + Self::Detached => "DETACHED", + Self::Hosts => "HOSTS", + Self::Quota => "QUOTA", + } + } + + /// What the section says when it has nothing in it. + /// + /// Written as a state of the world rather than as an apology, and never + /// echoing the filter text: the sidebar is the part of the window people + /// screenshot, and a query can contain anything. + pub(crate) const fn empty_message(self) -> &'static str { + match self { + Self::NeedsYou => "Nothing is waiting on you", + Self::Workspace => "No panes are open", + Self::Detached => "Nothing is detached", + Self::Hosts => "No hosts are configured", + Self::Quota => "No usage source", + } + } +} + +/// Identifies a row across rebuilds. +/// +/// The keyboard cursor is stored as one of these rather than as an index. An +/// index cursor is wrong the moment a row above it disappears — a pane exits, a +/// filter narrows — and pointing at a different row than the one the user was +/// looking at is worse than losing the cursor. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum RowKey { + Header(Section), + Empty(Section), + Attention(u64), + Pane(u64), + Detached(u64), + Host(usize), + Quota(String), +} + +/// A single-character state indicator, chosen by precedence. +/// +/// One indicator per row, never two: a row showing both "faulted" and "waiting" +/// tells you to look, which you already knew, and hides which one to act on. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum Indicator { + Faulted, + Waiting, + QuotaNearlySpent, + FinishedFailing, + FinishedCleanly, + Working, + None, +} + +impl Indicator { + /// The glyph, at a fixed width so labels line up down the column. + pub(crate) const fn glyph(self) -> &'static str { + match self { + Self::Faulted => "●", + Self::Waiting => "?", + Self::QuotaNearlySpent => "◌", + Self::FinishedFailing | Self::FinishedCleanly => "○", + Self::Working => "◐", + Self::None => " ", + } + } + + /// Which of several true things to show. + /// + /// Ordered by what a person would deal with first: something broken, then + /// something asking, then something about to run out, then something that + /// finished, then something still going. + pub(crate) const fn resolve(activity: PaneActivity) -> Self { + if activity.faulted { + Self::Faulted + } else if activity.waiting { + Self::Waiting + } else if activity.quota_nearly_spent { + Self::QuotaNearlySpent + } else if let Some(code) = activity.finished { + if code == 0 { + Self::FinishedCleanly + } else { + Self::FinishedFailing + } + } else if activity.working { + Self::Working + } else { + Self::None + } + } +} + +/// Everything true about a pane at once. +/// +/// Passed as one value rather than five flags: five booleans in a row is an +/// argument order waiting to be got wrong, and naming them at the call site +/// makes the precedence above readable there too. +#[allow( + clippy::struct_excessive_bools, + reason = "five independent facts about one pane; a bitflag would read worse at the call site" +)] +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub(crate) struct PaneActivity { + pub(crate) faulted: bool, + pub(crate) waiting: bool, + pub(crate) quota_nearly_spent: bool, + /// The exit code, when the process has ended. + pub(crate) finished: Option, + pub(crate) working: bool, +} + +/// A pane, as the sidebar needs it. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct PaneEntry { + pub(crate) id: u64, + pub(crate) title: String, + /// Where it runs. Shown as the row's secondary text. + pub(crate) target: String, + /// One character naming the kind: terminal, browser, document, review. + pub(crate) kind_glyph: &'static str, + pub(crate) selected: bool, + pub(crate) indicator: Indicator, + /// The complete headline from the usage strip, or nothing. + /// + /// The whole string or none of it. `headline` is what is *left* while the + /// meter fills with what is *spent*, so rendering a bare "82" beside a + /// nearly-empty gauge would assert the exact opposite of the fact. + pub(crate) quota: Option, + /// Whether that quota is nearly gone, for the second visual channel. + pub(crate) quota_critical: bool, +} + +/// Something waiting on a human. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct AttentionEntry { + pub(crate) id: u64, + pub(crate) title: String, + pub(crate) reason: String, + pub(crate) source: &'static str, + pub(crate) countable: bool, +} + +/// A machine that can own a runtime. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct HostEntry { + pub(crate) name: String, + pub(crate) detail: String, + pub(crate) live: bool, +} + +/// One quota rail. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct QuotaEntry { + pub(crate) id: String, + pub(crate) label: String, + pub(crate) headline: Option, + pub(crate) consumed_permille: Option, + pub(crate) critical: bool, +} + +/// Everything the sidebar draws from. +#[derive(Clone, Debug, Default)] +pub(crate) struct SidebarInput { + pub(crate) attention: Vec, + pub(crate) panes: Vec, + pub(crate) detached: Vec, + pub(crate) hosts: Vec, + pub(crate) quota: Vec, +} + +/// The lowest and highest widths the rail may be dragged to. +/// +/// Below the lower bound a pane title is unreadable; above the upper bound the +/// sidebar is taking space from the work, which is the thing it exists to serve. +pub(crate) const MIN_WIDTH: f32 = 200.0; +pub(crate) const MAX_WIDTH: f32 = 480.0; +pub(crate) const DEFAULT_WIDTH: f32 = 248.0; +/// One row height for everything, headers included, so the list can be +/// virtualised and so nothing shifts when a section changes size. +pub(crate) const ROW_HEIGHT: f32 = 26.0; + +/// What the user has done to the sidebar, and what persists. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct SidebarState { + pub(crate) visible: bool, + /// Always within `MIN_WIDTH..=MAX_WIDTH`; use [`SidebarState::set_width`]. + width_px: u32, + /// *Collapsed* sections, not expanded ones. A section added later then + /// appears open rather than silently hidden by an old snapshot. + pub(crate) collapsed: Vec
, + pub(crate) filter: String, + pub(crate) cursor: Option, +} + +impl Default for SidebarState { + fn default() -> Self { + Self { + visible: true, + #[expect( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + reason = "a positive constant well inside u32" + )] + width_px: DEFAULT_WIDTH as u32, + collapsed: Vec::new(), + filter: String::new(), + cursor: None, + } + } +} + +impl SidebarState { + pub(crate) fn width(&self) -> f32 { + #[expect( + clippy::cast_precision_loss, + reason = "clamped to 200..=480 on the way in" + )] + let width = self.width_px as f32; + width.clamp(MIN_WIDTH, MAX_WIDTH) + } + + /// Clamps on the way in, so no stored value can put the rail out of range. + pub(crate) fn set_width(&mut self, width: f32) { + let clamped = if width.is_finite() { + width.clamp(MIN_WIDTH, MAX_WIDTH) + } else { + DEFAULT_WIDTH + }; + #[expect( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + reason = "clamped into 200..=480 on the line above" + )] + { + self.width_px = clamped as u32; + } + } + + pub(crate) fn is_collapsed(&self, section: Section) -> bool { + self.collapsed.contains(§ion) + } + + pub(crate) fn toggle(&mut self, section: Section) { + if let Some(index) = self.collapsed.iter().position(|held| *held == section) { + self.collapsed.remove(index); + } else { + self.collapsed.push(section); + } + } +} + +/// What a row is. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum RowBody { + Header { + section: Section, + /// Shown beside the title. `None` for sections that do not count. + count: Option, + collapsed: bool, + }, + /// A section with nothing in it says so on its own row. + Empty(&'static str), + Attention(AttentionEntry), + Pane(PaneEntry), + Host(HostEntry), + Quota(QuotaEntry), +} + +/// One rendered line. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct SidebarRow { + pub(crate) key: RowKey, + pub(crate) body: RowBody, + /// Indent level. Everything is at depth one today; the field is kept so a + /// repository/worktree tree can be introduced without moving every row. + pub(crate) depth: u8, +} + +impl SidebarRow { + /// Whether the keyboard cursor may rest here. + /// + /// Headers are selectable — collapsing a section from the keyboard has to + /// be possible — but an empty-state line is a sentence, not a target. + pub(crate) const fn is_focusable(&self) -> bool { + !matches!(self.body, RowBody::Empty(_)) + } +} + +/// Does this text match the filter? +/// +/// Case-insensitive substring, on the fields a person would type: a title or +/// the machine it runs on. Deliberately not fuzzy — a filter that matches +/// things you did not ask for is worse than one that misses. +fn matches(filter: &str, fields: &[&str]) -> bool { + if filter.is_empty() { + return true; + } + let needle = filter.to_lowercase(); + fields + .iter() + .any(|field| field.to_lowercase().contains(&needle)) +} + +/// Builds the row list. +/// +/// Total: any input produces a list, and every section contributes a header +/// whether or not it has anything to show. +pub(crate) fn flatten(input: &SidebarInput, state: &SidebarState) -> Vec { + let mut rows = Vec::new(); + for section in Section::ALL { + let collapsed = state.is_collapsed(section); + let count = match section { + // Only what is certainly waiting; decision 0019 keeps a guess out + // of the number even though it still gets a row. + Section::NeedsYou => Some(input.attention.iter().filter(|e| e.countable).count()), + Section::Workspace => Some(input.panes.len()), + Section::Detached => Some(input.detached.len()), + Section::Hosts | Section::Quota => None, + }; + rows.push(SidebarRow { + key: RowKey::Header(section), + body: RowBody::Header { + section, + count, + collapsed, + }, + depth: 0, + }); + if collapsed { + continue; + } + + let before = rows.len(); + match section { + Section::NeedsYou => { + for entry in &input.attention { + if matches(&state.filter, &[&entry.title, &entry.reason]) { + rows.push(SidebarRow { + key: RowKey::Attention(entry.id), + body: RowBody::Attention(entry.clone()), + depth: 1, + }); + } + } + } + Section::Workspace => { + for pane in &input.panes { + if matches(&state.filter, &[&pane.title, &pane.target]) { + rows.push(SidebarRow { + key: RowKey::Pane(pane.id), + body: RowBody::Pane(pane.clone()), + depth: 1, + }); + } + } + } + Section::Detached => { + for pane in &input.detached { + if matches(&state.filter, &[&pane.title, &pane.target]) { + rows.push(SidebarRow { + key: RowKey::Detached(pane.id), + body: RowBody::Pane(pane.clone()), + depth: 1, + }); + } + } + } + Section::Hosts => { + for (index, host) in input.hosts.iter().enumerate() { + if matches(&state.filter, &[&host.name, &host.detail]) { + rows.push(SidebarRow { + key: RowKey::Host(index), + body: RowBody::Host(host.clone()), + depth: 1, + }); + } + } + } + Section::Quota => { + for entry in &input.quota { + if matches(&state.filter, &[&entry.label]) { + rows.push(SidebarRow { + key: RowKey::Quota(entry.id.clone()), + body: RowBody::Quota(entry.clone()), + depth: 1, + }); + } + } + } + } + if rows.len() == before { + // A filter that hides everything says so once, in the same words + // wherever it happens, and never quotes what was typed. + let message = if state.filter.is_empty() { + section.empty_message() + } else { + "No match in this section" + }; + rows.push(SidebarRow { + key: RowKey::Empty(section), + body: RowBody::Empty(message), + depth: 1, + }); + } + } + rows +} + +/// Moves the keyboard cursor by `delta` focusable rows. +/// +/// Returns the row to rest on. A cursor that is no longer in the list starts +/// from the top rather than from wherever an index would have landed. +pub(crate) fn move_cursor( + rows: &[SidebarRow], + cursor: Option<&RowKey>, + delta: i32, +) -> Option { + let focusable: Vec<&SidebarRow> = rows.iter().filter(|row| row.is_focusable()).collect(); + if focusable.is_empty() { + return None; + } + let current = cursor.and_then(|key| focusable.iter().position(|row| row.key == *key)); + let next = match current { + Some(index) => { + let last = focusable.len() - 1; + index.saturating_add_signed(delta as isize).min(last) + } + None if delta < 0 => focusable.len() - 1, + None => 0, + }; + focusable.get(next).map(|row| row.key.clone()) +} + +/// The screen-reader sentence for a row. +/// +/// Not attached to anything yet: published `gpui 0.2.2` has no AccessKit, so +/// there is no accessibility node to hang it on. Decision 0017 stages that +/// behind an adapter, and this is the text the adapter will announce. It is +/// written and tested here so the words a sighted user reads and the words an +/// assistive technology speaks come from one place rather than drifting apart +/// once there are two. +#[allow( + dead_code, + reason = "wired up with the accessibility adapter, decision 0017" +)] +/// +/// Written here rather than in the view so it is testable, and so the words a +/// sighted user reads and the words an assistive technology announces come from +/// one place and cannot drift apart. +pub(crate) fn describe(row: &SidebarRow) -> String { + let mut text = String::new(); + match &row.body { + RowBody::Header { + section, + count, + collapsed, + } => { + let _ = write!(text, "{}", section.title()); + if let Some(count) = count { + let _ = write!(text, ", {count} items"); + } + if *collapsed { + text.push_str(", collapsed"); + } + } + RowBody::Empty(message) => text.push_str(message), + RowBody::Attention(entry) => { + let _ = write!(text, "{}, {}, {}", entry.title, entry.reason, entry.source); + } + RowBody::Pane(pane) => { + let _ = write!(text, "{}, {}", pane.title, pane.target); + if pane.selected { + text.push_str(", selected"); + } + if let Some(quota) = &pane.quota { + let _ = write!(text, ", {quota}"); + } + } + RowBody::Host(host) => { + let _ = write!(text, "{}, {}", host.name, host.detail); + } + RowBody::Quota(entry) => { + let _ = write!( + text, + "{}, {}", + entry.label, + entry.headline.as_deref().unwrap_or("usage unavailable") + ); + } + } + text +} + +#[cfg(test)] +mod tests { + use super::{ + AttentionEntry, HostEntry, Indicator, PaneActivity, PaneEntry, QuotaEntry, RowBody, RowKey, + Section, SidebarInput, SidebarState, describe, flatten, move_cursor, + }; + + fn pane(id: u64, title: &str) -> PaneEntry { + PaneEntry { + id, + title: title.to_owned(), + target: "local shell".to_owned(), + kind_glyph: "▸", + selected: false, + indicator: Indicator::None, + quota: None, + quota_critical: false, + } + } + + fn input() -> SidebarInput { + SidebarInput { + attention: vec![AttentionEntry { + id: 1, + title: "Terminal 1".to_owned(), + reason: "Exited with code 42".to_owned(), + source: "observed", + countable: true, + }], + panes: vec![pane(1, "Terminal 1"), pane(2, "Notes")], + detached: vec![pane(3, "Terminal 3")], + hosts: vec![HostEntry { + name: "this machine".to_owned(), + detail: "2/3 live PTYs".to_owned(), + live: true, + }], + quota: vec![QuotaEntry { + id: "codex".to_owned(), + label: "CODEX".to_owned(), + headline: Some("80% left".to_owned()), + consumed_permille: Some(200), + critical: false, + }], + } + } + + #[test] + fn every_section_gets_a_header_even_when_empty() { + let rows = flatten(&SidebarInput::default(), &SidebarState::default()); + let headers: Vec
= rows + .iter() + .filter_map(|row| match row.body { + RowBody::Header { section, .. } => Some(section), + _ => None, + }) + .collect(); + assert_eq!(headers, Section::ALL.to_vec()); + // And each says what empty means, rather than showing nothing. + assert_eq!( + rows.iter() + .filter(|row| matches!(row.body, RowBody::Empty(_))) + .count(), + Section::ALL.len() + ); + } + + #[test] + fn the_needs_you_count_excludes_a_guess() { + let mut source = input(); + source.attention.push(AttentionEntry { + id: 9, + title: "Guessed".to_owned(), + reason: "Looks busy".to_owned(), + source: "inferred", + countable: false, + }); + let rows = flatten(&source, &SidebarState::default()); + let RowBody::Header { count, .. } = rows[0].body else { + panic!("the first row is the NEEDS YOU header"); + }; + assert_eq!(count, Some(1), "the guess is shown but not counted"); + assert_eq!( + rows.iter() + .filter(|row| matches!(row.body, RowBody::Attention(_))) + .count(), + 2, + "both still get a row" + ); + } + + #[test] + fn collapsing_hides_a_sections_rows_but_never_its_header() { + let mut state = SidebarState::default(); + state.toggle(Section::Workspace); + let rows = flatten(&input(), &state); + assert!( + rows.iter().any(|row| matches!( + row.body, + RowBody::Header { + section: Section::Workspace, + collapsed: true, + .. + } + )), + "the header stays" + ); + assert!( + !rows.iter().any(|row| matches!(row.key, RowKey::Pane(_))), + "its panes are hidden" + ); + state.toggle(Section::Workspace); + assert!(!state.is_collapsed(Section::Workspace), "toggling restores"); + } + + #[test] + fn a_filter_narrows_rows_and_reports_a_section_with_no_match() { + let state = SidebarState { + filter: "notes".to_owned(), + ..SidebarState::default() + }; + let rows = flatten(&input(), &state); + let panes: Vec<&RowKey> = rows + .iter() + .filter(|row| matches!(row.key, RowKey::Pane(_))) + .map(|row| &row.key) + .collect(); + assert_eq!(panes, vec![&RowKey::Pane(2)]); + let messages: Vec<&str> = rows + .iter() + .filter_map(|row| match row.body { + RowBody::Empty(message) => Some(message), + _ => None, + }) + .collect(); + assert!(messages.contains(&"No match in this section")); + assert!( + messages.iter().all(|message| !message.contains("notes")), + "the empty state must not echo what was typed" + ); + } + + #[test] + fn the_cursor_moves_over_focusable_rows_only() { + let rows = flatten(&input(), &SidebarState::default()); + let first = move_cursor(&rows, None, 1).expect("a first row"); + assert_eq!(first, RowKey::Header(Section::NeedsYou)); + let second = move_cursor(&rows, Some(&first), 1).expect("a second row"); + assert_eq!(second, RowKey::Attention(1)); + // Empty-state lines are sentences, not targets. + let all: Vec = { + let mut seen = Vec::new(); + let mut cursor = Some(first); + while let Some(key) = cursor.clone() { + seen.push(key.clone()); + let next = move_cursor(&rows, Some(&key), 1); + cursor = if next == Some(key) { None } else { next }; + } + seen + }; + assert!(!all.iter().any(|key| matches!(key, RowKey::Empty(_)))); + } + + #[test] + fn a_cursor_on_a_vanished_row_starts_over_rather_than_landing_somewhere_else() { + let rows = flatten(&input(), &SidebarState::default()); + let gone = RowKey::Pane(999); + let landed = move_cursor(&rows, Some(&gone), 1).expect("a row"); + assert_eq!( + landed, + RowKey::Header(Section::NeedsYou), + "an unknown cursor restarts at the top, not at an index" + ); + } + + #[test] + fn the_cursor_stops_at_the_ends_instead_of_wrapping() { + let rows = flatten(&input(), &SidebarState::default()); + let top = move_cursor(&rows, None, 1).expect("top"); + assert_eq!(move_cursor(&rows, Some(&top), -1), Some(top.clone())); + let bottom = move_cursor(&rows, None, -1).expect("bottom"); + assert_eq!(move_cursor(&rows, Some(&bottom), 1), Some(bottom)); + } + + #[test] + fn width_is_clamped_however_it_arrives() { + let mut state = SidebarState::default(); + state.set_width(10.0); + assert!((state.width() - super::MIN_WIDTH).abs() < f32::EPSILON); + state.set_width(10_000.0); + assert!((state.width() - super::MAX_WIDTH).abs() < f32::EPSILON); + state.set_width(f32::NAN); + assert!((state.width() - super::DEFAULT_WIDTH).abs() < f32::EPSILON); + state.set_width(300.0); + assert!((state.width() - 300.0).abs() < f32::EPSILON); + } + + #[test] + fn one_indicator_wins_and_the_order_is_what_to_deal_with_first() { + let everything = PaneActivity { + faulted: true, + waiting: true, + quota_nearly_spent: true, + finished: Some(1), + working: true, + }; + assert_eq!(Indicator::resolve(everything), Indicator::Faulted); + assert_eq!( + Indicator::resolve(PaneActivity { + faulted: false, + ..everything + }), + Indicator::Waiting + ); + assert_eq!( + Indicator::resolve(PaneActivity { + faulted: false, + waiting: false, + ..everything + }), + Indicator::QuotaNearlySpent + ); + let finished = PaneActivity { + finished: Some(0), + working: true, + ..PaneActivity::default() + }; + assert_eq!(Indicator::resolve(finished), Indicator::FinishedCleanly); + assert_eq!( + Indicator::resolve(PaneActivity { + finished: Some(3), + ..finished + }), + Indicator::FinishedFailing + ); + assert_eq!( + Indicator::resolve(PaneActivity { + finished: None, + ..finished + }), + Indicator::Working + ); + assert_eq!(Indicator::resolve(PaneActivity::default()), Indicator::None); + } + + #[test] + fn every_row_describes_itself_for_a_screen_reader() { + let rows = flatten(&input(), &SidebarState::default()); + for row in &rows { + let text = describe(row); + assert!(!text.is_empty(), "{:?} has no description", row.key); + } + let quota = rows + .iter() + .find(|row| matches!(row.key, RowKey::Quota(_))) + .expect("a quota row"); + assert_eq!(describe(quota), "CODEX, 80% left"); + } + + #[test] + fn a_quota_row_with_no_reading_says_so_in_the_ledgers_words() { + let mut source = input(); + source.quota[0].headline = None; + let rows = flatten(&source, &SidebarState::default()); + let quota = rows + .iter() + .find(|row| matches!(row.key, RowKey::Quota(_))) + .expect("a quota row"); + assert_eq!( + describe(quota), + "CODEX, usage unavailable", + "the same phrase the footer uses, not a second wording for one gap" + ); + } +} diff --git a/apps/lumbridge/src/sidebar/view.rs b/apps/lumbridge/src/sidebar/view.rs new file mode 100644 index 0000000..31f649c --- /dev/null +++ b/apps/lumbridge/src/sidebar/view.rs @@ -0,0 +1,332 @@ +//! Turns sidebar rows into elements. Decides nothing. +//! +//! One row anatomy, applied to every row including headers, so the columns line +//! up down the rail and nothing shifts when a section grows: +//! +//! ```text +//! [disclosure 12][indicator 12][kind 14][label flex_1 truncate][meta][⋯] +//! ``` +//! +//! Every slot is reserved even when empty. A row that omits its indicator +//! column would pull its label left and break the vertical line the eye follows. +//! +//! One selection language, everywhere: rest is transparent, hover is +//! `surface_raised`, the selected pane is `surface_active` with a two-pixel +//! accent rail, and the keyboard cursor is a one-pixel border. Before this the +//! attention cards *darkened* on hover while the worktree rows *lightened*, so +//! the same gesture meant two different things a hundred pixels apart. + +use gpui::prelude::*; +use gpui::{Rgba, div, px, relative}; + +use super::model::{Indicator, RowBody, RowKey, SidebarRow}; +use crate::theme::ThemeColors; + +/// Widths of the fixed slots. +const DISCLOSURE_WIDTH: f32 = 12.0; +const INDICATOR_WIDTH: f32 = 12.0; +const KIND_WIDTH: f32 = 14.0; +/// The permille at which a quota stops being background information. +pub(crate) const CRITICAL_PERMILLE: u64 = 900; + +/// What the caller needs to know to paint one row. +#[derive(Clone, Copy)] +pub(crate) struct RowStyle { + pub(crate) theme: ThemeColors, + /// The keyboard cursor is here. + pub(crate) cursored: bool, + /// The sidebar owns the keyboard, so the cursor is drawn in the accent. + pub(crate) focused: bool, +} + +/// The colour an indicator is drawn in. +/// +/// By meaning, not by value: a faulted pane is the danger colour because it is +/// broken, not because a number crossed a line. +const fn indicator_color(indicator: Indicator, theme: ThemeColors) -> Rgba { + match indicator { + Indicator::Faulted | Indicator::FinishedFailing => theme.danger, + Indicator::Waiting | Indicator::QuotaNearlySpent => theme.attention, + Indicator::FinishedCleanly => theme.success, + Indicator::Working => theme.accent, + Indicator::None => theme.muted, + } +} + +/// A fixed-width cell, so every row's columns align. +fn slot(width: f32) -> gpui::Div { + div().w(px(width)).flex_none() +} + +/// The quota chip. +/// +/// Renders the whole headline string or nothing at all. `headline` is what is +/// *left* while the meter fills with what is *spent*; a bare "82" beside a +/// nearly-empty gauge would assert the exact opposite of the fact. +fn quota_chip(headline: Option<&str>, critical: bool, theme: ThemeColors) -> gpui::AnyElement { + let Some(headline) = headline else { + // The same hairline the footer uses for no reading. A full-length empty + // gauge reads as "plenty left" from across the room. + return div() + .w(px(28.0)) + .h(px(2.0)) + .flex_none() + .bg(theme.border) + .into_any_element(); + }; + div() + .flex_none() + .px(px(4.0)) + .rounded(px(3.0)) + .text_xs() + .bg(theme.surface_raised) + .text_color(if critical { + theme.attention + } else { + theme.muted + }) + .child(headline.to_owned()) + .into_any_element() +} + +/// Paints one row. +#[allow( + clippy::too_many_lines, + reason = "one declarative element tree per row kind; splitting hides the shared anatomy" +)] +pub(crate) fn row(row: &SidebarRow, style: RowStyle) -> gpui::AnyElement { + let theme = style.theme; + let selected = matches!(&row.body, RowBody::Pane(pane) if pane.selected); + let indent = f32::from(row.depth) * 10.0; + + let base = div() + .flex() + .items_center() + .h(px(super::model::ROW_HEIGHT)) + .w_full() + .pl(px(6.0 + indent)) + .pr(px(6.0)) + .gap(px(4.0)) + .when(selected, |view| view.bg(theme.surface_active)) + .when(style.cursored, |view| { + view.border_color(if style.focused { + theme.accent + } else { + theme.border + }) + }) + // A transparent border on every row, always: without it a cursored row + // is one pixel taller than its neighbours and the list jitters as the + // cursor moves. + .border_1() + .when(!style.cursored, |view| { + view.border_color(gpui::transparent_black()) + }); + + match &row.body { + RowBody::Header { + section, + count, + collapsed, + } => base + .child( + slot(DISCLOSURE_WIDTH) + .text_xs() + .text_color(theme.muted) + .child(if *collapsed { "›" } else { "⌄" }), + ) + .child( + div() + .flex_1() + .min_w_0() + .text_xs() + .text_color(theme.muted) + .child(section.title()), + ) + .children(count.map(|count| { + div() + .flex_none() + .text_xs() + .text_color(theme.muted) + .child(format!("{count}")) + })) + .into_any_element(), + + RowBody::Empty(message) => base + .child(slot(DISCLOSURE_WIDTH)) + .child( + div() + .flex_1() + .min_w_0() + .truncate() + .text_xs() + .text_color(theme.muted) + .child((*message).to_owned()), + ) + .into_any_element(), + + RowBody::Attention(entry) => base + .child(slot(DISCLOSURE_WIDTH)) + .child( + slot(INDICATOR_WIDTH) + .text_xs() + // A guess is drawn quietly. Decision 0019 lets it show and + // sort, but it must not look like a report. + .text_color(if entry.countable { + theme.attention + } else { + theme.muted + }) + .child(if entry.countable { "●" } else { "·" }), + ) + .child(slot(KIND_WIDTH)) + // A floor under the title. Shrinking proportionally is not enough: + // the reason is the longer string, so flexbox happily reduced the + // title to two characters and the row stopped saying which pane it + // was about. + .child( + div() + .flex_1() + .min_w(px(70.0)) + .truncate() + .text_sm() + .text_color(theme.text) + .child(entry.title.clone()), + ) + // The reason yields before the title does. A row reading + // "Te… Exited with code 7 · observed" has thrown away the one word + // that says which pane to look at. + .child( + div() + .flex_shrink() + .min_w(px(0.0)) + .truncate() + .text_xs() + .text_color(theme.muted) + .child(format!("{} · {}", entry.reason, entry.source)), + ) + .into_any_element(), + + RowBody::Pane(pane) => base + .child(slot(DISCLOSURE_WIDTH)) + .child( + slot(INDICATOR_WIDTH) + .text_xs() + .text_color(indicator_color(pane.indicator, theme)) + .child(pane.indicator.glyph()), + ) + .child( + slot(KIND_WIDTH) + .text_xs() + .text_color(theme.muted) + .child(pane.kind_glyph), + ) + .child( + div() + .flex_1() + .min_w_0() + .truncate() + .text_sm() + .text_color(if selected { theme.text } else { theme.muted }) + .child(pane.title.clone()), + ) + .child(quota_chip( + pane.quota.as_deref(), + pane.quota_critical, + theme, + )) + .into_any_element(), + + RowBody::Host(host) => base + .child(slot(DISCLOSURE_WIDTH)) + .child( + slot(INDICATOR_WIDTH) + .text_xs() + .text_color(if host.live { + theme.success + } else { + theme.muted + }) + .child(if host.live { "●" } else { "○" }), + ) + .child(slot(KIND_WIDTH)) + .child( + div() + .flex_1() + .min_w_0() + .truncate() + .text_sm() + .text_color(theme.muted) + .child(host.name.clone()), + ) + .child( + div() + .flex_shrink() + .min_w(px(0.0)) + .truncate() + .text_xs() + .text_color(theme.muted) + .child(host.detail.clone()), + ) + .into_any_element(), + + RowBody::Quota(entry) => base + .child(slot(DISCLOSURE_WIDTH)) + .child(slot(INDICATOR_WIDTH)) + .child(slot(KIND_WIDTH)) + .child( + div() + .flex_1() + .min_w_0() + .truncate() + .text_xs() + .text_color(theme.muted) + .child(entry.label.clone()), + ) + .child(meter(entry.consumed_permille, theme)) + .child(quota_chip(entry.headline.as_deref(), entry.critical, theme)) + .into_any_element(), + } +} + +/// A quota gauge. Filled with what is spent, which is the way round the footer +/// draws it, so the two cannot disagree. +fn meter(consumed_permille: Option, theme: ThemeColors) -> gpui::AnyElement { + let Some(permille) = consumed_permille else { + return div() + .w(px(32.0)) + .h(px(2.0)) + .flex_none() + .bg(theme.border) + .into_any_element(); + }; + let clamped = u16::try_from(permille.min(1_000)).unwrap_or(1_000); + div() + .w(px(32.0)) + .h(px(5.0)) + .flex_none() + .rounded(px(2.0)) + .bg(theme.border_quiet) + .overflow_hidden() + .child(div().h_full().w(relative(f32::from(clamped) / 1_000.0)).bg( + if permille >= CRITICAL_PERMILLE { + theme.attention + } else { + theme.accent + }, + )) + .into_any_element() +} + +/// A stable element id for a row. +pub(crate) fn element_id(key: &RowKey) -> gpui::ElementId { + match key { + RowKey::Header(section) => ("sidebar-header", u64::from(*section as u32)).into(), + RowKey::Empty(section) => ("sidebar-empty", u64::from(*section as u32)).into(), + RowKey::Attention(id) => ("sidebar-attention", *id).into(), + RowKey::Pane(id) => ("sidebar-pane", *id).into(), + RowKey::Detached(id) => ("sidebar-detached", *id).into(), + RowKey::Host(index) => ("sidebar-host", u64::try_from(*index).unwrap_or(0)).into(), + RowKey::Quota(id) => gpui::ElementId::Name(format!("sidebar-quota-{id}").into()), + } +} diff --git a/apps/lumbridge/src/usage_feed.rs b/apps/lumbridge/src/usage_feed.rs index fdf0e27..b58d5f3 100644 --- a/apps/lumbridge/src/usage_feed.rs +++ b/apps/lumbridge/src/usage_feed.rs @@ -339,12 +339,20 @@ impl UsageFeed { "five-hour window" => "5h".to_owned(), "seven-day window" => "7d".to_owned(), "session transcripts" => "tokens".to_owned(), + // "primary window" is the widest scope any harness reports and it + // adds nothing: every one of these is a window. + other if other.ends_with(" window") => other.trim_end_matches(" window").to_owned(), other => other .strip_suffix(" weekly") .map_or_else(|| other.to_owned(), |name| format!("{name} wk")), } } + /// One profile's segment, for a surface that shows a single harness. + pub(crate) fn segment_for(&self, id: &AccountProfileId) -> Option { + self.segment(id) + } + fn segment(&self, id: &AccountProfileId) -> Option { let profile = self.profiles.get(id)?; let projection = self.projection(id); diff --git a/docs/decisions/0021-sidebar-composition.md b/docs/decisions/0021-sidebar-composition.md new file mode 100644 index 0000000..ff8a73e --- /dev/null +++ b/docs/decisions/0021-sidebar-composition.md @@ -0,0 +1,121 @@ +# 0021: The sidebar answers four questions, and its layout is data + +Status: accepted; rebuilt and live. + +The old rail showed a frozen `ATTENTION · 1` over a five-entry worktree list +naming a developer's machines, and a Buzz card asserting a connection to a crate +the binary did not depend on. All of that is gone (decision 0018's commit and the +truth pass before it). What replaces it starts from a question rather than from a +list of things we happened to know. + +## The four questions + +A workspace multiplexer's rail exists to answer, in this order: + +1. **What needs me?** — `NEEDS YOU` +2. **What am I running?** — `WORKSPACE` +3. **What did I set aside?** — `DETACHED` +4. **Where does it run, and what will stop me?** — `HOSTS`, `QUOTA` + +Anything that answers none of those does not belong in the rail. That is the test +the worktree list failed, and it is the test any future addition has to pass. + +`WORKSPACE` is deliberately **flat**. A Repository → Worktree → Pane tree is the +obvious shape and every level above Pane would be a second fixture, because +`lumbridge-git` does not exist. The `depth` field and the disclosure column are +reserved so the tree can arrive without moving every row. + +## Layout is data + +`sidebar/model.rs` holds no renderer types. `flatten(&SidebarInput, +&SidebarState) -> Vec` is a pure function, so which sections exist, +what collapsing hides, what the filter keeps and where the keyboard cursor lands +are ordinary tests in CI. `sidebar/view.rs` renders and decides nothing. + +Two rules that came out of writing the tests rather than the view: + +- **The cursor is a `RowKey`, not an index.** An index is wrong the moment a row + above it disappears — a pane exits, a filter narrows — and quietly pointing at + a different row than the one the user was looking at is worse than losing the + cursor. An unknown key restarts at the top. +- **Every header renders, even for an empty section**, and every empty section + says what empty means. Positions then never move under the pointer because + something appeared above. + +The empty state under a filter is "No match in this section" and does **not** +quote what was typed. The sidebar is the part of the window that gets +screenshotted and shared. + +## One selection language + +Rest transparent, hover `surface_raised`, selected `surface_active`, keyboard +cursor a one-pixel border — accent when the rail has the keyboard, `border` when +it does not. Before this the attention cards *darkened* on hover while the +worktree rows *lightened*, so the same gesture meant two different things a +hundred pixels apart. + +Every row has the same slots — disclosure, indicator, kind, label, meta — always +reserved. A row that omitted its indicator column pulled its label left and broke +the vertical line the eye follows. + +One indicator per row, by precedence: faulted, then waiting, then quota nearly +spent, then finished, then working. A row showing two of those tells you to look, +which you already knew, and hides which one to act on. + +## What the layout work actually caught + +Flexbox shrinks proportionally, so the longest string wins. The attention row's +reason is longer than its title, and the row rendered as `Te… Exited with code 7 +· observed` — it had thrown away the one word that says which pane to look at. +Titles now carry a minimum width and the meta yields first. Three quota rows all +read `CLAUDE CODE` with the scope truncated off the end, naming the same thing +three times and identifying none of them; the harness name is now printed once +per group, as the footer already did. + +Both were found by screenshot, not by review. + +## The keyboard + +`secondary-alt-b` toggles, `secondary-alt-s` moves the keyboard into the rail, +`secondary-alt-a` jumps to the next pane that is *certainly* waiting. Inside the +rail, arrows and `j`/`k` move, `space` peeks and stays, `enter` activates and +hands the keyboard to what it opened, `escape` leaves. + +The critical part is a guard, not a feature: `on_key_down` returns before it +encodes anything for a terminal while the sidebar owns the keyboard. Without it a +bare `j` would be written into whatever pane happened to be selected while the +user believed they were walking a list. + +## Resizing + +The rail drags between 200 and 480 pixels, applied live. Widening it really can +drop the workspace from three panes to one, because decision 0009 measures its +thresholds *after* the sidebar — that is correct, and hiding it until release +would make the result look like a bug. The PTYs are told once, on release: a +resize per mouse-move would be a SIGWINCH storm through the runtime's bounded +queues. + +The drag is owned by the root element rather than by the handle. A child that +only sees events inside its own five pixels cannot follow a pointer across the +window. + +## Not in this pass + +- **Persistence.** Width, collapsed sections and visibility return to defaults on + restart. They belong in their own snapshot, separate from the workspace blob so + the two fail closed independently. +- **Virtualisation.** The list renders every row. At the number of panes a person + has open this is not measurable; it becomes real when a host has fifty. +- **`HOSTS` beyond the local machine.** Two states only, live or not. Connecting, + retrying and unreachable are unbuildable until `lumbridge-remote` exists, and + shipping them would be the Buzz card again in a Rust enum. +- **Accessibility.** `describe` produces the sentence for each row and is tested; + there is nothing to attach it to until the adapter in decision 0017 lands. + +## Behaviour studied + +Zed's project panel (GPL — behaviour only, no code read for reuse), Zellij's +cursor clamping after deletion (MIT), bb's thread-list indicator precedence +(MIT), Orca's host list (MIT). Nothing was copied; the row anatomy, the +precedence chain and the key-cursor rule are convergent answers to the same +problems.