Rebuild the sidebar around the four questions it exists to answer

The rail showed a frozen attention count over a worktree list backed by a crate
that does not exist. What replaces it starts from a question rather than from a
list of things we happened to know: what needs me, what am I running, what did I
set aside, where does it run and what will stop me.

Layout is data. sidebar/model.rs holds no renderer types, 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. Eleven model tests, none of which need a window.

The cursor is a RowKey rather than an index, because an index is wrong the moment
a row above it disappears and silently pointing at a different row is worse than
losing the cursor. Every header renders even when its section is empty, so
positions never move under the pointer. The filter's empty state does not quote
what was typed — the sidebar is the part of the window people screenshot.

One selection language everywhere: before this, attention cards darkened on hover
while worktree rows lightened, so the same gesture meant two different things a
hundred pixels apart.

Two defects the screenshots caught that review had not. Flexbox shrinks
proportionally, so the longer string wins: the attention row rendered as
"Te… Exited with code 7 · observed", having discarded the one word that says
which pane to look at. And three quota rows all read "CLAUDE CODE" with the scope
truncated away, naming the same thing three times and identifying none of them.
Titles now have a floor and the harness name prints once per group.

WORKSPACE is deliberately flat: a Repository → Worktree → Pane tree would need
lumbridge-git, and every level above Pane would be a second fixture. The depth
field and disclosure column are reserved for when it is real. HOSTS has two
states, live or not — connecting and unreachable are unbuildable until
lumbridge-remote exists, and shipping them would be the Buzz card again in a
Rust enum.

The rail drags between 200 and 480 px, applied live so the workspace reflows
under the pointer; decision 0009 measures pane thresholds after the sidebar, so
widening really can drop three panes to one. PTYs are resized on release only, or
every mouse-move is a SIGWINCH storm through the runtime's bounded queues.

While the sidebar owns the keyboard, on_key_down returns before encoding
anything. Without that guard a bare `j` would be written into whatever pane
happened to be selected while the user believed they were walking a list.

Not persisted yet, not virtualised, and describe() has nothing to attach to until
the accessibility adapter from decision 0017 lands. Recorded in 0021.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Metal Agent
2026-08-31 23:56:17 -07:00
co-authored by Claude Opus 5
parent 3a8a100ea5
commit 72887cb4ab
9 changed files with 1730 additions and 178 deletions
+9 -13
View File
@@ -138,17 +138,6 @@ impl<Pane: Copy + Eq> Attention<Pane> {
pub(crate) fn signals(&self) -> &[AttentionSignal<Pane>] { pub(crate) fn signals(&self) -> &[AttentionSignal<Pane>] {
&self.signals &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)] #[cfg(test)]
@@ -171,14 +160,21 @@ mod tests {
attention.observe(signal(1, AttentionSource::TitleHeuristic, 10)); attention.observe(signal(1, AttentionSource::TitleHeuristic, 10));
attention.observe(signal(2, AttentionSource::Fixture, 20)); attention.observe(signal(2, AttentionSource::Fixture, 20));
assert_eq!(attention.signals().len(), 2, "both are shown"); assert_eq!(attention.signals().len(), 2, "both are shown");
let countable = |attention: &Attention<u8>| {
attention
.signals()
.iter()
.filter(|signal| signal.is_countable())
.count()
};
assert_eq!( assert_eq!(
attention.countable(), countable(&attention),
0, 0,
"neither may claim that something is waiting" "neither may claim that something is waiting"
); );
attention.observe(signal(3, AttentionSource::RuntimeObserved, 30)); attention.observe(signal(3, AttentionSource::RuntimeObserved, 30));
assert_eq!(attention.countable(), 1); assert_eq!(countable(&attention), 1);
} }
#[test] #[test]
+10 -3
View File
@@ -29,9 +29,10 @@
use gpui::KeyBinding; use gpui::KeyBinding;
use crate::{ use crate::{
AddPanel, DetachSelectedPanel, FocusDown, FocusLeft, FocusRight, FocusUp, OpenPalette, AddPanel, DetachSelectedPanel, FocusDown, FocusLeft, FocusRight, FocusSidebar, FocusUp,
PasteIntoPane, RestartPane, SelectPane1, SelectPane2, SelectPane3, SelectPane4, SelectPane5, JumpToAttention, OpenPalette, PasteIntoPane, RestartPane, SelectPane1, SelectPane2,
SelectPane6, TerminalNarrower, TerminalShorter, TerminalTaller, TerminalWider, TerminatePane, SelectPane3, SelectPane4, SelectPane5, SelectPane6, TerminalNarrower, TerminalShorter,
TerminalTaller, TerminalWider, TerminatePane, ToggleSidebar,
}; };
/// The context every binding is scoped to. /// 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 // The terminal convention, and the only safe spelling: `secondary-v` is
// ctrl-v on Linux, which readline reads as quoted-insert. // ctrl-v on Linux, which readline reads as quoted-insert.
"secondary-shift-v", "secondary-shift-v",
"secondary-alt-b",
"secondary-alt-s",
"secondary-alt-a",
]; ];
/// Builds the bindings in the same order as [`BINDINGS`]. /// Builds the bindings in the same order as [`BINDINGS`].
@@ -93,6 +97,9 @@ pub(crate) fn bindings() -> Vec<KeyBinding> {
KeyBinding::new(BINDINGS[17], RestartPane, Some(CONTEXT)), KeyBinding::new(BINDINGS[17], RestartPane, Some(CONTEXT)),
KeyBinding::new(BINDINGS[18], TerminatePane, Some(CONTEXT)), KeyBinding::new(BINDINGS[18], TerminatePane, Some(CONTEXT)),
KeyBinding::new(BINDINGS[19], PasteIntoPane, 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)),
] ]
} }
+418 -162
View File
@@ -1,6 +1,7 @@
mod attention; mod attention;
mod keymap; mod keymap;
mod panel_registry; mod panel_registry;
mod sidebar;
mod theme; mod theme;
mod usage_feed; mod usage_feed;
@@ -31,6 +32,10 @@ use lumbridge_ui_fixture::{
use attention::{Attention, AttentionKind, AttentionSignal, AttentionSource}; use attention::{Attention, AttentionKind, AttentionSignal, AttentionSource};
use lumbridge_harness::MonotonicWallClock; use lumbridge_harness::MonotonicWallClock;
use panel_registry::{PanelId, PanelKind, PanelRegistry, SeedPane}; use panel_registry::{PanelId, PanelKind, PanelRegistry, SeedPane};
use sidebar::model::{
AttentionEntry, HostEntry, Indicator, PaneActivity, PaneEntry, QuotaEntry, SidebarInput,
SidebarState,
};
use theme::{ActiveTheme, ThemeColors}; use theme::{ActiveTheme, ThemeColors};
use usage_feed::{UsageFeed, UsageSegment}; 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_POLL_INTERVAL: Duration = Duration::from_millis(16);
const RUNTIME_DRAIN_LIMIT: usize = 64; const RUNTIME_DRAIN_LIMIT: usize = 64;
const WORKSPACE_SNAPSHOT_ID: &str = "lumbridge-code-gpui-spike"; const WORKSPACE_SNAPSHOT_ID: &str = "lumbridge-code-gpui-spike";
const SIDEBAR_WIDTH: f32 = 248.0;
const APP_HEADER_HEIGHT: f32 = 48.0; const APP_HEADER_HEIGHT: f32 = 48.0;
const TAB_BAR_HEIGHT: f32 = 38.0; const TAB_BAR_HEIGHT: f32 = 38.0;
const APP_FOOTER_HEIGHT: f32 = 46.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_WIDTH: f32 = 8.4;
const TERMINAL_CELL_HEIGHT: f32 = 18.0; const TERMINAL_CELL_HEIGHT: f32 = 18.0;
const WORK_PANEL_GAP: f32 = 4.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_ROW_STEP: u16 = 2;
const TERMINAL_COLUMN_STEP: u16 = 10; const TERMINAL_COLUMN_STEP: u16 = 10;
@@ -62,6 +68,9 @@ actions!(
RestartPane, RestartPane,
TerminatePane, TerminatePane,
PasteIntoPane, PasteIntoPane,
ToggleSidebar,
FocusSidebar,
JumpToAttention,
SelectPane1, SelectPane1,
SelectPane2, SelectPane2,
SelectPane3, SelectPane3,
@@ -100,6 +109,9 @@ struct LumbridgeShell {
input_gap: Option<String>, input_gap: Option<String>,
/// What is waiting on a human, and how we know. /// What is waiting on a human, and how we know.
attention: Attention<PanelId>, attention: Attention<PanelId>,
sidebar: SidebarState,
sidebar_has_focus: bool,
sidebar_dragging: bool,
/// Stamps attention signals. Monotonic, so a signal cannot appear to have /// Stamps attention signals. Monotonic, so a signal cannot appear to have
/// arrived before one recorded earlier. /// arrived before one recorded earlier.
clock: MonotonicWallClock, clock: MonotonicWallClock,
@@ -473,16 +485,17 @@ fn dim_color(color: Rgba) -> Rgba {
fn terminal_dimensions_for_window( fn terminal_dimensions_for_window(
window_size: Size<Pixels>, window_size: Size<Pixels>,
attached_panel_count: usize, attached_panel_count: usize,
sidebar_width: f32,
) -> TerminalDimensions { ) -> TerminalDimensions {
let width = f32::from(window_size.width); let width = f32::from(window_size.width);
let height = f32::from(window_size.height); 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) .min(attached_panel_count)
.max(1); .max(1);
let workspace_height = let workspace_height =
(height - APP_HEADER_HEIGHT - TAB_BAR_HEIGHT - APP_FOOTER_HEIGHT).max(0.0); (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 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. // 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_gaps = WORK_PANEL_GAP * lossless_f32(panel_count.saturating_sub(1));
let panel_width = let panel_width =
@@ -503,8 +516,13 @@ fn terminal_dimensions_for_window(
.expect("geometry clamps terminal dimensions above zero") .expect("geometry clamps terminal dimensions above zero")
} }
fn visible_panel_count(window_size: Size<Pixels>) -> usize { /// How many panes fit beside the rail.
let workspace_width = (f32::from(window_size.width) - SIDEBAR_WIDTH).max(0.0); ///
/// 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<Pixels>, sidebar_width: f32) -> usize {
let workspace_width = (f32::from(window_size.width) - sidebar_width).max(0.0);
if workspace_width >= 2_800.0 { if workspace_width >= 2_800.0 {
5 5
} else if workspace_width >= 1_100.0 { } else if workspace_width >= 1_100.0 {
@@ -669,8 +687,12 @@ impl LumbridgeShell {
.detach(); .detach();
let (store, panels, persistence_status) = load_panel_registry(); let (store, panels, persistence_status) = load_panel_registry();
let terminal_dimensions = let sidebar = SidebarState::default();
terminal_dimensions_for_window(window.bounds().size, panels.attached_count()); let terminal_dimensions = terminal_dimensions_for_window(
window.bounds().size,
panels.attached_count(),
sidebar.width(),
);
let mut runtimes = RuntimeRegistry::new(); let mut runtimes = RuntimeRegistry::new();
let mut live_terminals = BTreeMap::new(); let mut live_terminals = BTreeMap::new();
for panel in panels for panel in panels
@@ -688,8 +710,11 @@ impl LumbridgeShell {
} }
cx.observe_window_bounds(window, |shell, window, cx| { cx.observe_window_bounds(window, |shell, window, cx| {
let dimensions = let dimensions = terminal_dimensions_for_window(
terminal_dimensions_for_window(window.bounds().size, shell.panels.attached_count()); window.bounds().size,
shell.panels.attached_count(),
shell.sidebar.width(),
);
shell.resize_attached_terminals(dimensions.rows(), dimensions.columns(), cx); shell.resize_attached_terminals(dimensions.rows(), dimensions.columns(), cx);
}) })
.detach(); .detach();
@@ -721,6 +746,9 @@ impl LumbridgeShell {
pending_terminate: None, pending_terminate: None,
input_gap: None, input_gap: None,
attention: Attention::new(), attention: Attention::new(),
sidebar,
sidebar_has_focus: false,
sidebar_dragging: false,
clock: MonotonicWallClock::start(), clock: MonotonicWallClock::start(),
root_focus, root_focus,
} }
@@ -1005,8 +1033,11 @@ impl LumbridgeShell {
} }
fn resize_terminal_for_workspace(&mut self, window: &Window, cx: &mut Context<Self>) { fn resize_terminal_for_workspace(&mut self, window: &Window, cx: &mut Context<Self>) {
let dimensions = let dimensions = terminal_dimensions_for_window(
terminal_dimensions_for_window(window.bounds().size, self.panels.attached_count()); window.bounds().size,
self.panels.attached_count(),
self.sidebar.width(),
);
self.resize_attached_terminals(dimensions.rows(), dimensions.columns(), cx); self.resize_attached_terminals(dimensions.rows(), dimensions.columns(), cx);
} }
@@ -1024,8 +1055,11 @@ impl LumbridgeShell {
self.timing.mark_dispatch(); self.timing.mark_dispatch();
let pane = self.panels.create(kind); let pane = self.panels.create(kind);
if kind == PanelKind::Terminal { if kind == PanelKind::Terminal {
let dimensions = let dimensions = terminal_dimensions_for_window(
terminal_dimensions_for_window(window.bounds().size, self.panels.attached_count()); window.bounds().size,
self.panels.attached_count(),
self.sidebar.width(),
);
let mut terminal = LiveTerminalState::new(dimensions); let mut terminal = LiveTerminalState::new(dimensions);
if let Err(error) = spawn_live_runtime(&mut self.runtimes, pane, None, dimensions) { if let Err(error) = spawn_live_runtime(&mut self.runtimes, pane, None, dimensions) {
terminal.status = LiveRuntimeStatus::Fault(error.to_string()); terminal.status = LiveRuntimeStatus::Fault(error.to_string());
@@ -1070,8 +1104,11 @@ impl LumbridgeShell {
cx.notify(); cx.notify();
return; return;
} }
let dimensions = let dimensions = terminal_dimensions_for_window(
terminal_dimensions_for_window(window.bounds().size, self.panels.attached_count()); window.bounds().size,
self.panels.attached_count(),
self.sidebar.width(),
);
let mut terminal = LiveTerminalState::new(dimensions); let mut terminal = LiveTerminalState::new(dimensions);
if let Err(error) = spawn_live_runtime(&mut self.runtimes, pane, None, dimensions) { if let Err(error) = spawn_live_runtime(&mut self.runtimes, pane, None, dimensions) {
terminal.status = LiveRuntimeStatus::Fault(error.to_string()); terminal.status = LiveRuntimeStatus::Fault(error.to_string());
@@ -1144,6 +1181,111 @@ impl LumbridgeShell {
cx.notify(); cx.notify();
} }
fn toggle_sidebar(&mut self, _: &ToggleSidebar, window: &mut Window, cx: &mut Context<Self>) {
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<Self>) {
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<Self>,
) {
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<Self>) -> 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>) { fn add_panel_action(&mut self, _: &AddPanel, window: &mut Window, cx: &mut Context<Self>) {
self.dispatch(ShellAction::CloseCommandPalette); self.dispatch(ShellAction::CloseCommandPalette);
self.add_panel_chooser_open = !self.add_panel_chooser_open; 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<Self>) { fn on_key_down(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context<Self>) {
// 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 { if self.add_panel_chooser_open {
match event.keystroke.key.as_str() { match event.keystroke.key.as_str() {
"escape" => { "escape" => {
@@ -2432,7 +2580,6 @@ impl LumbridgeShell {
/// Where sessions are owned, and what each host is actually doing. /// Where sessions are owned, and what each host is actually doing.
fn runtime_rows(&self) -> Vec<RuntimeRow> { fn runtime_rows(&self) -> Vec<RuntimeRow> {
let theme = self.theme.colors;
let running = self let running = self
.live_terminals .live_terminals
.values() .values()
@@ -2441,12 +2588,8 @@ impl LumbridgeShell {
let total = self.live_terminals.len(); let total = self.live_terminals.len();
let local = RuntimeRow { let local = RuntimeRow {
label: "this machine", label: "this machine",
live: running > 0,
detail: format!("{running}/{total} live PTYs"), 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 // 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. // 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.reporting_profile_count(),
self.usage.declared_profile_count() self.usage.declared_profile_count()
), ),
tone: if self.usage.reporting_profile_count() == 0 { live: self.usage.reporting_profile_count() > 0,
theme.muted
} else {
theme.success
},
}; };
vec![local, adapter] vec![local, adapter]
} }
@@ -2565,6 +2704,9 @@ impl LumbridgeShell {
.on_action(cx.listener(Self::restart_pane)) .on_action(cx.listener(Self::restart_pane))
.on_action(cx.listener(Self::terminate_pane)) .on_action(cx.listener(Self::terminate_pane))
.on_action(cx.listener(Self::paste_into_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_taller))
.on_action(cx.listener(Self::terminal_shorter)) .on_action(cx.listener(Self::terminal_shorter))
.on_action(cx.listener(Self::terminal_wider)) .on_action(cx.listener(Self::terminal_wider))
@@ -2588,6 +2730,9 @@ impl LumbridgeShell {
shell.select_pane_at(5, window, cx); shell.select_pane_at(5, window, cx);
})) }))
.on_key_down(cx.listener(Self::on_key_down)) .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()
.flex_col() .flex_col()
.size_full() .size_full()
@@ -2633,7 +2778,7 @@ impl LumbridgeShell {
), ),
) )
.child( .child(
div().flex().flex_1().min_h_0().child(sidebar).child( div().flex().flex_1().min_h_0().children(sidebar).child(
div() div()
.flex() .flex()
.flex_col() .flex_col()
@@ -2685,148 +2830,256 @@ impl LumbridgeShell {
/// Split out of `render`, which was 353 lines. It is the first thing the /// 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 /// sidebar rework needs, and a 350-line render function is where a UI stops
/// being reviewable. /// being reviewable.
#[allow( /// One pane as the sidebar needs it, or nothing when the panel is gone.
clippy::too_many_lines, fn sidebar_pane_entry(&self, id: PanelId) -> Option<PaneEntry> {
reason = "a single declarative element tree; the sidebar rework replaces it wholesale" let panel = self.panels.panel(id)?;
)] let selected = self.panels.selected();
fn render_sidebar( let live = self.live_terminals.get(&id);
&self, let segment = self
attention_rows: &[AttentionRow], .usage
detached_entries: &[(PanelId, String)], .profile_for_seed(panel.seed)
cx: &mut Context<Self>, .and_then(|profile| self.usage.segment_for(profile));
) -> gpui::AnyElement { 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<String> = 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<Self>) -> gpui::AnyElement {
let theme = self.theme.colors; 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() div()
.relative()
.flex() .flex()
.flex_col() .flex_col()
.w(px(248.0)) .w(px(self.sidebar.width()))
.flex_none() .flex_none()
.bg(theme.surface) .bg(theme.chrome)
.border_r_1() .border_r_1()
.border_color(theme.border_quiet) .border_color(theme.border_quiet)
.child( .child(
div() div()
.px_4() .flex_1()
.pt_4() .min_h_0()
.pb_2() .overflow_hidden()
.text_xs() .children(rows.into_iter().map(|row| {
.text_color(if waiting == 0 { let cursored = cursor.as_ref() == Some(&row.key);
theme.muted let key = row.key.clone();
} 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(
div() div()
.text_sm() .id(sidebar::view::element_id(&key))
.text_color(theme.text) .cursor_pointer()
.child(row.title.clone()), .hover(|view| view.bg(theme.surface_raised))
) .on_click(cx.listener(move |shell, _, window, cx| {
.child( shell.activate_sidebar_row(&key, window, cx);
div() }))
.mt_1() .child(sidebar::view::row(
.text_xs() &row,
.text_color(theme.muted) sidebar::view::RowStyle {
.child(format!("{} · {}", row.reason, row.source)), theme,
) cursored,
.on_click(cx.listener(move |shell, _, window, cx| { focused,
shell.select_pane(id, window, cx); },
})) ))
})) })),
)
// The drag handle. Four pixels, occluding so it wins over the
// rows behind it.
.child( .child(
div() div()
.mt_3() .id("sidebar-resize")
.px_4() .absolute()
.py_2() .right_0()
.text_xs() .top_0()
.text_color(theme.muted) .bottom_0()
.child("RUNTIMES"), .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() .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<Self>,
) {
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<Self>,
) {
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<Self>) {
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>,
) {
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 standing of every harness, always, regardless of what is selected.
/// ///
/// The old footer answered only for the selected pane, so the moment you /// 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 /// The pieces `render` assembles, bundled so the assembly step takes one
/// argument rather than six. /// argument rather than six.
struct RootParts { struct RootParts {
sidebar: gpui::AnyElement, /// Absent when the rail is hidden.
sidebar: Option<gpui::AnyElement>,
tabs: gpui::AnyElement, tabs: gpui::AnyElement,
workspace_row: gpui::AnyElement, workspace_row: gpui::AnyElement,
footer_left: String, footer_left: String,
@@ -3016,7 +3270,9 @@ struct AttentionRow {
struct RuntimeRow { struct RuntimeRow {
label: &'static str, label: &'static str,
detail: String, 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 { fn footer_separator(theme: ThemeColors) -> impl IntoElement {
@@ -3049,7 +3305,7 @@ fn provenance_chip(
impl Render for LumbridgeShell { impl Render for LumbridgeShell {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> 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_panes = self.panels.attached_ids();
let attached_count = attached_panes.len(); let attached_count = attached_panes.len();
let detached_entries = self let detached_entries = self
@@ -3077,8 +3333,7 @@ impl Render for LumbridgeShell {
"{running_runtime_count}/{} LIVE PTYS", "{running_runtime_count}/{} LIVE PTYS",
self.live_terminals.len() self.live_terminals.len()
); );
let attention_rows = self.attention_rows(); let sidebar = self.sidebar.visible.then(|| self.render_sidebar(cx));
let sidebar = self.render_sidebar(&attention_rows, &detached_entries, cx);
let selected_position = attached_panes let selected_position = attached_panes
.iter() .iter()
@@ -3289,6 +3544,7 @@ mod tests {
indexed_terminal_color, terminal_dimensions_for_window, terminal_key_from_parts, indexed_terminal_color, terminal_dimensions_for_window, terminal_key_from_parts,
terminal_paint_rows, terminal_scroll_from_parts, visible_panel_count, visible_panel_range, terminal_paint_rows, terminal_scroll_from_parts, visible_panel_count, visible_panel_range,
}; };
use crate::sidebar::model::DEFAULT_WIDTH;
use gpui::{px, size}; use gpui::{px, size};
#[test] #[test]
@@ -3343,17 +3599,17 @@ mod tests {
#[test] #[test]
fn terminal_geometry_tracks_middle_sixty_percent_per_panel() { 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.rows(), 26);
assert_eq!(dimensions.columns(), 45); assert_eq!(dimensions.columns(), 45);
let ultrawide = size(px(3440.0), px(1440.0)); let ultrawide = size(px(3440.0), px(1440.0));
assert_eq!(visible_panel_count(ultrawide), 5); assert_eq!(visible_panel_count(ultrawide, DEFAULT_WIDTH), 5);
let dimensions = terminal_dimensions_for_window(ultrawide, 5); let dimensions = terminal_dimensions_for_window(ultrawide, 5, DEFAULT_WIDTH);
assert_eq!(dimensions.rows(), 42); assert_eq!(dimensions.rows(), 42);
assert_eq!(dimensions.columns(), 71); 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.rows(), 42);
assert_eq!(two_panels.columns(), 185); assert_eq!(two_panels.columns(), 185);
} }
+15
View File
@@ -10,6 +10,11 @@ impl PanelId {
pub(crate) const fn get(self) -> u64 { pub(crate) const fn get(self) -> u64 {
self.0 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)] #[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 { pub(crate) const fn description(self) -> &'static str {
match self { match self {
Self::Terminal => "A real local shell with its own runtime actor", Self::Terminal => "A real local shell with its own runtime actor",
+9
View File
@@ -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;
+808
View File
@@ -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<u32>,
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<String>,
/// 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<String>,
pub(crate) consumed_permille: Option<u64>,
pub(crate) critical: bool,
}
/// Everything the sidebar draws from.
#[derive(Clone, Debug, Default)]
pub(crate) struct SidebarInput {
pub(crate) attention: Vec<AttentionEntry>,
pub(crate) panes: Vec<PaneEntry>,
pub(crate) detached: Vec<PaneEntry>,
pub(crate) hosts: Vec<HostEntry>,
pub(crate) quota: Vec<QuotaEntry>,
}
/// 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<Section>,
pub(crate) filter: String,
pub(crate) cursor: Option<RowKey>,
}
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(&section)
}
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<usize>,
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<SidebarRow> {
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<RowKey> {
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<Section> = 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<RowKey> = {
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"
);
}
}
+332
View File
@@ -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<u64>, 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()),
}
}
+8
View File
@@ -339,12 +339,20 @@ impl UsageFeed {
"five-hour window" => "5h".to_owned(), "five-hour window" => "5h".to_owned(),
"seven-day window" => "7d".to_owned(), "seven-day window" => "7d".to_owned(),
"session transcripts" => "tokens".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 other => other
.strip_suffix(" weekly") .strip_suffix(" weekly")
.map_or_else(|| other.to_owned(), |name| format!("{name} wk")), .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<UsageSegment> {
self.segment(id)
}
fn segment(&self, id: &AccountProfileId) -> Option<UsageSegment> { fn segment(&self, id: &AccountProfileId) -> Option<UsageSegment> {
let profile = self.profiles.get(id)?; let profile = self.profiles.get(id)?;
let projection = self.projection(id); let projection = self.projection(id);
+121
View File
@@ -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<SidebarRow>` 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.