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>
333 lines
11 KiB
Rust
333 lines
11 KiB
Rust
//! 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()),
|
||
}
|
||
}
|