Leads the existing indent gutter on both the name row and the meta row with a one-column block glyph in the state's palette role, replacing the meta line's inline dot. Folder headers gain a matching blank column so labels stay aligned under session names.
1348 lines
48 KiB
Rust
1348 lines
48 KiB
Rust
//! Sidebar widget — session cards per `docs/SIDEBAR.md`.
|
|
//!
|
|
//! Line 1: activity rail + status glyph (● attached / ○ live / ✗ dead) + name.
|
|
//! Line 2: activity rail + duration in the state's colour, plus the
|
|
//! adaptive detail, blocked-work, exited, and command fragments.
|
|
|
|
use std::collections::{BTreeMap, HashSet};
|
|
use std::path::PathBuf;
|
|
|
|
use chrono::Utc;
|
|
use maestro_protocol::display::{
|
|
abbreviate_dir, abbreviate_folder_name, command_program, session_tree_path,
|
|
};
|
|
use maestro_protocol::{ActivityState, Session, SessionStatus, StatKind};
|
|
use ratatui::layout::Rect;
|
|
use ratatui::style::{Color, Modifier, Style};
|
|
use ratatui::text::{Line, Span};
|
|
use ratatui::widgets::{Block, Paragraph};
|
|
use ratatui::Frame;
|
|
use uuid::Uuid;
|
|
|
|
use crate::palette;
|
|
use crate::state::SidebarItem;
|
|
|
|
/// Time-in-state earns a duration fragment once the session has sat in its
|
|
/// current activity state this long (docs/SIDEBAR.md).
|
|
const META_LINE_AFTER_SECS: i64 = 30;
|
|
/// A payload predating #102 defaults `activity_since` to the Unix epoch. Per
|
|
/// docs/SIDEBAR.md such a session contributes no duration — the dot still
|
|
/// renders — so anything implausibly old counts as "never observed" rather
|
|
/// than a card announcing that the session has been idle for half a century.
|
|
/// Mirrors `PLAUSIBLE_EPOCH_MS` in `web/src/activity.ts`.
|
|
const PLAUSIBLE_SINCE_YEAR: i32 = 2010;
|
|
const INDENT: &str = " ";
|
|
const META_INDENT: &str = " ";
|
|
/// docs/SIDEBAR.md: a one-column activity rail leads the indent gutter on
|
|
/// both card rows, replacing the old inline dot glyph on the meta line.
|
|
const RAIL: &str = "▌";
|
|
|
|
/// Cards use the selection surface only while the sidebar owns focus. An
|
|
/// unfocused highlight remains an edge-marked cursor without claiming the
|
|
/// keyboard via a filled selection background.
|
|
fn card_base(highlighted: bool, focused: bool) -> Style {
|
|
let palette = palette::active();
|
|
let mut style = palette.surface(highlighted && focused).text_style();
|
|
if focused && !highlighted {
|
|
style = style.bg(palette.sidebar_focus_bg);
|
|
}
|
|
style
|
|
}
|
|
|
|
/// Renderable sidebar state.
|
|
#[derive(Debug, Clone)]
|
|
pub struct Sidebar {
|
|
pub sessions: Vec<Session>,
|
|
pub highlight: Option<SidebarItem>,
|
|
pub attached: HashSet<Uuid>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct SidebarGroup {
|
|
pub key: PathBuf,
|
|
pub sessions: Vec<Session>,
|
|
pub collapsed: bool,
|
|
}
|
|
|
|
impl Sidebar {
|
|
pub fn render(&self, f: &mut Frame, area: Rect) {
|
|
self.render_with_focus(f, area, false);
|
|
}
|
|
|
|
/// Render the panel without a border. Focus is the panel background, so
|
|
/// it remains obvious without spending rows or columns on chrome.
|
|
pub fn render_with_focus(&self, f: &mut Frame, area: Rect, focused: bool) {
|
|
self.render_with_focus_and_collapsed(f, area, focused, &HashSet::new());
|
|
}
|
|
|
|
pub fn render_with_focus_and_collapsed(
|
|
&self,
|
|
f: &mut Frame,
|
|
area: Rect,
|
|
focused: bool,
|
|
collapsed: &HashSet<PathBuf>,
|
|
) {
|
|
self.render_with_focus_and_collapsed_home(f, area, focused, collapsed, &home_dir());
|
|
}
|
|
|
|
pub fn render_with_focus_and_collapsed_home(
|
|
&self,
|
|
f: &mut Frame,
|
|
area: Rect,
|
|
focused: bool,
|
|
collapsed: &HashSet<PathBuf>,
|
|
home: &str,
|
|
) {
|
|
let palette = palette::active();
|
|
let panel_bg = if focused {
|
|
palette.sidebar_focus_bg
|
|
} else {
|
|
palette.chrome_bg
|
|
};
|
|
f.render_widget(Block::default().style(Style::default().bg(panel_bg)), area);
|
|
|
|
let width = area.width as usize;
|
|
let mut lines: Vec<Line> = vec![Line::from(Span::styled(
|
|
"sessions",
|
|
Style::default()
|
|
.bg(panel_bg)
|
|
.fg(palette.card.fg)
|
|
.add_modifier(Modifier::BOLD),
|
|
))];
|
|
for group in self.groups(collapsed) {
|
|
lines.push(Line::from(self.group_row(&group, width, focused, home)));
|
|
if !group.collapsed {
|
|
for s in &group.sessions {
|
|
let highlighted = self.highlight == Some(SidebarItem::Session(s.id));
|
|
let base = card_base(highlighted, focused);
|
|
lines.push(Line::from(self.name_row(s, width, focused)));
|
|
let meta = self.meta_row(s, width, focused);
|
|
lines.push(Line::from(pad_to_width(meta, width, base)));
|
|
}
|
|
}
|
|
}
|
|
f.render_widget(Paragraph::new(lines), area);
|
|
}
|
|
|
|
/// Items in exactly the order and height they occupy on screen.
|
|
pub fn rendered_items(&self) -> Vec<SidebarItem> {
|
|
self.rendered_items_with_collapsed(&HashSet::new())
|
|
}
|
|
|
|
pub fn rendered_items_with_collapsed(&self, collapsed: &HashSet<PathBuf>) -> Vec<SidebarItem> {
|
|
self.groups(collapsed)
|
|
.into_iter()
|
|
.flat_map(|group| {
|
|
let mut items = vec![SidebarItem::Group(group.key)];
|
|
if !group.collapsed {
|
|
items.extend(
|
|
group
|
|
.sessions
|
|
.into_iter()
|
|
.map(|s| SidebarItem::Session(s.id)),
|
|
);
|
|
}
|
|
items
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
pub fn groups(&self, collapsed: &HashSet<PathBuf>) -> Vec<SidebarGroup> {
|
|
let mut grouped: BTreeMap<PathBuf, Vec<Session>> = BTreeMap::new();
|
|
for session in &self.sessions {
|
|
if let Some(key) = session_tree_path(session) {
|
|
grouped.entry(key).or_default().push(session.clone());
|
|
}
|
|
}
|
|
grouped
|
|
.into_iter()
|
|
.map(|(key, mut sessions)| {
|
|
sessions
|
|
.sort_by(|a, b| name_for(a).cmp(&name_for(b)).then_with(|| a.id.cmp(&b.id)));
|
|
SidebarGroup {
|
|
collapsed: collapsed.contains(&key),
|
|
key,
|
|
sessions,
|
|
}
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn group_row(
|
|
&self,
|
|
group: &SidebarGroup,
|
|
width: usize,
|
|
focused: bool,
|
|
home: &str,
|
|
) -> Vec<Span<'static>> {
|
|
let highlighted = self.highlight == Some(SidebarItem::Group(group.key.clone()));
|
|
let palette = palette::active();
|
|
let surface = palette.surface(highlighted && focused);
|
|
let row_bg = if highlighted {
|
|
surface.bg
|
|
} else if focused {
|
|
palette.sidebar_focus_bg
|
|
} else {
|
|
surface.bg
|
|
};
|
|
let base = Style::default().bg(row_bg).fg(if highlighted {
|
|
surface.fg
|
|
} else {
|
|
palette.group_row
|
|
});
|
|
let edge = highlighted && !focused;
|
|
let prefix = if edge { "▏ " } else { " " };
|
|
let chevron = if group.collapsed { "▸ " } else { "▾ " };
|
|
let path = abbreviate_dir(&group.key, home);
|
|
let name = abbreviate_folder_name(&path);
|
|
let count = format!(" ({})", group.sessions.len());
|
|
let prefix = Span::styled(
|
|
prefix,
|
|
if edge {
|
|
base.fg(palette.focus_accent)
|
|
} else {
|
|
base
|
|
},
|
|
);
|
|
let chevron = Span::styled(chevron, base);
|
|
let count = Span::styled(count, surface.muted_style().bg(row_bg));
|
|
// "1 +" is the blank leading column matching the session rows' rail.
|
|
let label_budget =
|
|
width.saturating_sub(1 + prefix.width() + chevron.width() + count.width());
|
|
let mut label = Vec::new();
|
|
if let Some(stem) = name.stem {
|
|
label.push(Span::styled(
|
|
format!("{stem}/"),
|
|
surface.dim_style().bg(row_bg),
|
|
));
|
|
}
|
|
label.push(Span::styled(name.last, base));
|
|
|
|
// A blank leading column, matching the width the activity rail adds
|
|
// to session rows, so folder labels keep aligning under names.
|
|
let mut spans = vec![Span::styled(" ", base), prefix, chevron];
|
|
spans.extend(fit_to_width(label, label_budget));
|
|
spans.push(count);
|
|
pad_to_width(spans, width, base)
|
|
}
|
|
|
|
fn name_row(&self, s: &Session, width: usize, focused: bool) -> Vec<Span<'static>> {
|
|
let glyph = match (s.status, self.attached.contains(&s.id)) {
|
|
(SessionStatus::Dead, _) => "✗",
|
|
(SessionStatus::Running, true) => "●",
|
|
(SessionStatus::Running, false) => "○",
|
|
};
|
|
let highlighted = self.highlight == Some(SidebarItem::Session(s.id));
|
|
let base = card_base(highlighted, focused);
|
|
let prefix = 1 + INDENT.chars().count() + 2;
|
|
let name_budget = width.saturating_sub(prefix);
|
|
|
|
let edge = highlighted && !focused;
|
|
let rail_style = base.fg(activity_color(s.activity, highlighted, focused));
|
|
let mut spans: Vec<Span<'static>> = vec![
|
|
Span::styled(RAIL, rail_style),
|
|
Span::styled(
|
|
if edge { "▏ " } else { INDENT },
|
|
if edge {
|
|
base.fg(palette::active().focus_accent)
|
|
} else {
|
|
base
|
|
},
|
|
),
|
|
Span::styled(format!("{glyph} "), base),
|
|
];
|
|
spans.extend(
|
|
fit_to_width(vec![Span::raw(name_for(s))], name_budget)
|
|
.into_iter()
|
|
.map(|sp| Span::styled(sp.content, base.patch(sp.style))),
|
|
);
|
|
pad_to_width(spans, width, base)
|
|
}
|
|
|
|
/// The adaptive second line: `[<duration>] [<detail>] [<blocked work> | exited] <command>`,
|
|
/// led by the activity rail (docs/SIDEBAR.md).
|
|
fn meta_row(&self, s: &Session, width: usize, focused: bool) -> Vec<Span<'static>> {
|
|
let now = Utc::now();
|
|
let held = held_for(s, now);
|
|
let held_secs = held.map(|d| d.num_seconds()).unwrap_or(0);
|
|
// #83: a worktree that blocks a kill is summarised here rather than
|
|
// on line 1; the kill confirm names the specifics.
|
|
let blocked = s.blocked_work.filter(|b| !b.is_clear());
|
|
let dead = s.status == SessionStatus::Dead;
|
|
let highlighted = self.highlight == Some(SidebarItem::Session(s.id));
|
|
let base = card_base(highlighted, focused);
|
|
let rail_style =
|
|
base.patch(Style::default().fg(activity_color(s.activity, highlighted, focused)));
|
|
let muted = palette::active()
|
|
.surface(highlighted && focused)
|
|
.muted_style()
|
|
.bg(base.bg.unwrap_or(palette::active().chrome_bg));
|
|
let edge = highlighted && !focused;
|
|
let mut spans = vec![
|
|
Span::styled(RAIL, rail_style),
|
|
Span::styled(
|
|
if edge { " ▏ " } else { META_INDENT },
|
|
if edge {
|
|
base.fg(palette::active().focus_accent)
|
|
} else {
|
|
base
|
|
},
|
|
),
|
|
];
|
|
if let Some(d) = held.filter(|_| held_secs >= META_LINE_AFTER_SECS) {
|
|
spans.push(Span::styled(format_state_duration(d), rail_style));
|
|
}
|
|
let mut tail: Vec<Span<'static>> = Vec::new();
|
|
if let Some(b) = blocked {
|
|
let palette = palette::active();
|
|
let surface = palette.surface(highlighted && focused);
|
|
for (kind, text) in b.stats() {
|
|
let style = match kind {
|
|
StatKind::Added => muted.fg(surface.active),
|
|
StatKind::Removed => muted.fg(surface.error),
|
|
_ => muted,
|
|
};
|
|
tail.push(Span::styled(format!(" {text}"), style));
|
|
}
|
|
}
|
|
if dead {
|
|
tail.push(Span::styled(" exited", muted));
|
|
}
|
|
// docs/SIDEBAR.md: the card names the program, never its arguments.
|
|
let command = Span::styled(format!(" {}", command_program(&s.command)), muted);
|
|
|
|
// Reserve the triggered status fragments before detail and command.
|
|
// If the row is exceptionally narrow, keep one column for the
|
|
// unconditional command and truncate the status tail first. Detail
|
|
// then receives only space left after the full command reservation;
|
|
// when the full command cannot fit, it is the final truncation target.
|
|
let head_w: usize = spans.iter().map(|sp| sp.width()).sum();
|
|
let available = width.saturating_sub(head_w);
|
|
let command_floor = usize::from(available > 0);
|
|
let tail = fit_to_width(tail, available.saturating_sub(command_floor));
|
|
let tail_w: usize = tail.iter().map(|sp| sp.width()).sum();
|
|
let command_w = command.width().min(available.saturating_sub(tail_w));
|
|
if let Some(detail) = s.activity_detail.as_deref().filter(|d| !d.is_empty()) {
|
|
let budget = available.saturating_sub(tail_w + command_w);
|
|
spans.extend(fit_to_width(
|
|
vec![Span::styled(format!(" {detail}"), muted)],
|
|
budget,
|
|
));
|
|
}
|
|
spans.extend(tail);
|
|
spans.extend(fit_to_width(vec![command], command_w));
|
|
spans
|
|
}
|
|
}
|
|
|
|
/// Palette role carrying an [`ActivityState`], per docs/SIDEBAR.md. The
|
|
/// state word is never spelled out — this colour carries it. The highlighted
|
|
/// card takes its state colours from the selection surface: a colour picked
|
|
/// for the dark card background is not legible on the selection background.
|
|
fn activity_color(state: ActivityState, highlighted: bool, focused: bool) -> Color {
|
|
palette::active()
|
|
.surface(highlighted && focused)
|
|
.activity(state)
|
|
}
|
|
|
|
/// docs/SIDEBAR.md duration format — floored whole seconds / minutes / hours,
|
|
/// measured in the current activity state. No days, no rounding up.
|
|
/// How long the session has held its current activity state, or `None` when
|
|
/// the daemon never reported one (see [`PLAUSIBLE_SINCE_YEAR`]).
|
|
fn held_for(s: &Session, now: chrono::DateTime<Utc>) -> Option<chrono::Duration> {
|
|
use chrono::Datelike;
|
|
if s.activity_since.year() < PLAUSIBLE_SINCE_YEAR {
|
|
return None;
|
|
}
|
|
Some(now - s.activity_since)
|
|
}
|
|
|
|
fn format_state_duration(elapsed: chrono::Duration) -> String {
|
|
let secs = elapsed.num_seconds().max(0);
|
|
match secs {
|
|
0..=59 => format!("{secs}s"),
|
|
60..=3599 => format!("{}m", secs / 60),
|
|
_ => format!("{}h", secs / 3600),
|
|
}
|
|
}
|
|
|
|
/// Truncate a span list to `max` columns, ending it with `…` when cut. The
|
|
/// sidebar is narrow: card lines truncate, they never wrap.
|
|
fn fit_to_width(spans: Vec<Span<'static>>, max: usize) -> Vec<Span<'static>> {
|
|
let mut out: Vec<Span<'static>> = Vec::with_capacity(spans.len());
|
|
let mut used = 0usize;
|
|
for span in spans {
|
|
let w = span.width();
|
|
if used + w <= max {
|
|
used += w;
|
|
out.push(span);
|
|
continue;
|
|
}
|
|
let budget = max.saturating_sub(used + 1);
|
|
let ellipsis = "…";
|
|
if budget > 0 {
|
|
let text: String = span.content.chars().take(budget).collect();
|
|
out.push(Span::styled(format!("{text}{ellipsis}"), span.style));
|
|
} else if used < max {
|
|
out.push(Span::styled(ellipsis, span.style));
|
|
}
|
|
return out;
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Pad a card row out to the full sidebar width so the card background
|
|
/// paints edge to edge rather than stopping at the last glyph.
|
|
fn pad_to_width(mut spans: Vec<Span<'static>>, width: usize, base: Style) -> Vec<Span<'static>> {
|
|
let used: usize = spans.iter().map(|sp| sp.width()).sum();
|
|
if let Some(fill) = width.checked_sub(used).filter(|&n| n > 0) {
|
|
spans.push(Span::styled(" ".repeat(fill), base));
|
|
}
|
|
spans
|
|
}
|
|
|
|
/// `metadata.name` if present, else the command line.
|
|
pub(crate) fn name_for(s: &Session) -> String {
|
|
custom_name_for(s).unwrap_or_else(|| s.command.clone())
|
|
}
|
|
|
|
/// The session's own name, without the command fallback (#125): `None` for a
|
|
/// session that was never given one.
|
|
pub(crate) fn custom_name_for(s: &Session) -> Option<String> {
|
|
match s.metadata.get("name") {
|
|
Some(serde_json::Value::String(name)) => Some(name.clone()),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn home_dir() -> String {
|
|
std::env::var("HOME").unwrap_or_default()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use chrono::{DateTime, Duration};
|
|
use maestro_protocol::InTheWay;
|
|
use ratatui::backend::TestBackend;
|
|
use ratatui::buffer::Buffer;
|
|
use ratatui::Terminal;
|
|
use std::collections::HashMap;
|
|
|
|
fn session_named(id: u128, name: &str) -> Session {
|
|
let mut metadata = HashMap::new();
|
|
metadata.insert("name".into(), serde_json::Value::String(name.into()));
|
|
Session {
|
|
id: Uuid::from_u128(id),
|
|
command: "bash".into(),
|
|
cwd: std::path::PathBuf::from("/tmp"),
|
|
env: Default::default(),
|
|
metadata,
|
|
worktree: None,
|
|
status: SessionStatus::Running,
|
|
created_at: Utc::now(),
|
|
foreground: None,
|
|
activity: Default::default(),
|
|
activity_detail: None,
|
|
last_activity_at: Utc::now(),
|
|
activity_since: Utc::now(),
|
|
current_cwd: None,
|
|
blocked_work: None,
|
|
}
|
|
}
|
|
|
|
fn draw(sidebar: &Sidebar, w: u16, h: u16) -> Buffer {
|
|
draw_with_focus(sidebar, w, h, true)
|
|
}
|
|
|
|
fn draw_with_focus(sidebar: &Sidebar, w: u16, h: u16, focused: bool) -> Buffer {
|
|
let backend = TestBackend::new(w, h);
|
|
let mut terminal = Terminal::new(backend).unwrap();
|
|
terminal
|
|
.draw(|f| sidebar.render_with_focus(f, f.area(), focused))
|
|
.unwrap();
|
|
terminal.backend().buffer().clone()
|
|
}
|
|
|
|
fn draw_collapsed(sidebar: &Sidebar, w: u16, h: u16, collapsed: &HashSet<PathBuf>) -> Buffer {
|
|
let backend = TestBackend::new(w, h);
|
|
let mut terminal = Terminal::new(backend).unwrap();
|
|
terminal
|
|
.draw(|f| sidebar.render_with_focus_and_collapsed(f, f.area(), true, collapsed))
|
|
.unwrap();
|
|
terminal.backend().buffer().clone()
|
|
}
|
|
|
|
fn rows(buf: &Buffer) -> Vec<String> {
|
|
(0..buf.area.height)
|
|
.map(|y| {
|
|
(0..buf.area.width)
|
|
.map(|x| buf[(x, y)].symbol())
|
|
.collect::<String>()
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn render_to_string(buf: &Buffer) -> String {
|
|
rows(buf).join("\n")
|
|
}
|
|
|
|
fn row_containing<'a>(rows: &'a [String], needle: &str) -> &'a str {
|
|
rows.iter()
|
|
.find(|l| l.contains(needle))
|
|
.unwrap_or_else(|| panic!("no row containing {needle:?} in {rows:#?}"))
|
|
}
|
|
|
|
/// Row text without the trailing space padding and right border.
|
|
fn content(row: &str) -> &str {
|
|
row.trim()
|
|
.trim_start_matches('│')
|
|
.trim_end_matches('│')
|
|
.trim()
|
|
}
|
|
|
|
#[test]
|
|
fn renders_session_names() {
|
|
let sb = Sidebar {
|
|
sessions: vec![session_named(1, "claude"), session_named(2, "bash")],
|
|
highlight: None,
|
|
attached: Default::default(),
|
|
};
|
|
let rendered = render_to_string(&draw(&sb, 28, 12));
|
|
assert!(
|
|
rendered.contains("claude"),
|
|
"missing session name:\n{rendered}"
|
|
);
|
|
assert!(
|
|
rendered.contains("bash"),
|
|
"missing session name:\n{rendered}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn groups_worktrees_under_their_base_repository() {
|
|
let mut s = session_named(1, "claude");
|
|
s.cwd = "/home/u/repos/worktrees/feature".into();
|
|
s.worktree = Some(maestro_protocol::SessionWorktree {
|
|
path: s.cwd.clone(),
|
|
base_repo: "/home/u/repos/maestro".into(),
|
|
});
|
|
let groups = (Sidebar {
|
|
sessions: vec![s],
|
|
highlight: None,
|
|
attached: Default::default(),
|
|
})
|
|
.groups(&HashSet::new());
|
|
assert_eq!(groups[0].key, PathBuf::from("/home/u/repos/maestro"));
|
|
}
|
|
|
|
#[test]
|
|
fn does_not_render_groups_without_sessions() {
|
|
let sidebar = Sidebar {
|
|
sessions: Vec::new(),
|
|
highlight: None,
|
|
attached: Default::default(),
|
|
};
|
|
assert!(sidebar.groups(&HashSet::new()).is_empty());
|
|
assert!(sidebar.rendered_items().is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn collapsing_a_group_hides_cards_but_keeps_the_count_and_item() {
|
|
let sidebar = Sidebar {
|
|
sessions: vec![session_named(1, "claude"), session_named(2, "bash")],
|
|
highlight: None,
|
|
attached: Default::default(),
|
|
};
|
|
let key = PathBuf::from("/tmp");
|
|
let mut collapsed = HashSet::new();
|
|
collapsed.insert(key.clone());
|
|
let groups = sidebar.groups(&collapsed);
|
|
assert_eq!(groups[0].sessions.len(), 2);
|
|
assert!(groups[0].collapsed);
|
|
assert_eq!(
|
|
sidebar.rendered_items_with_collapsed(&collapsed),
|
|
vec![SidebarItem::Group(key)]
|
|
);
|
|
let rendered = render_to_string(&draw_collapsed(&sidebar, 40, 6, &collapsed));
|
|
assert!(rendered.contains("(2)"));
|
|
assert!(!rendered.contains("claude"));
|
|
}
|
|
|
|
#[test]
|
|
fn long_group_labels_yield_to_the_always_visible_count() {
|
|
let mut session = session_named(1, "claude");
|
|
session.cwd = "/very/long/project/path/extraordinarily-long-project-name".into();
|
|
let sidebar = Sidebar {
|
|
sessions: vec![session],
|
|
highlight: None,
|
|
attached: Default::default(),
|
|
};
|
|
let key = PathBuf::from("/very/long/project/path/extraordinarily-long-project-name");
|
|
let collapsed = HashSet::from([key]);
|
|
let rendered = render_to_string(&draw_collapsed(&sidebar, 28, 6, &collapsed));
|
|
|
|
assert!(rendered.contains('…'));
|
|
assert!(
|
|
rendered.contains("(1)"),
|
|
"group count was truncated: {rendered:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn shared_display_helpers_abbreviate_group_labels() {
|
|
let path = maestro_protocol::display::abbreviate_dir(
|
|
std::path::Path::new("/home/u/git.naps.pt/naps62/maestro"),
|
|
"/home/u",
|
|
);
|
|
let name = maestro_protocol::display::abbreviate_folder_name(&path);
|
|
assert_eq!(path, "~/git.naps.pt/naps62/maestro");
|
|
assert_eq!(name.stem.as_deref(), Some("~/g/n"));
|
|
assert_eq!(name.last, "maestro");
|
|
}
|
|
|
|
#[test]
|
|
fn rendered_cards_are_always_two_lines_after_one_group_row() {
|
|
let sidebar = Sidebar {
|
|
sessions: vec![session_named(1, "claude")],
|
|
highlight: None,
|
|
attached: Default::default(),
|
|
};
|
|
let all = rows(&draw(&sidebar, 40, 8));
|
|
assert!(all[1].contains("/tmp"));
|
|
assert!(all[2].contains("claude"));
|
|
assert!(all[3].contains("bash"));
|
|
assert!(!all[4].contains("bash"));
|
|
}
|
|
|
|
#[test]
|
|
fn attached_session_uses_filled_glyph() {
|
|
let sid = Uuid::from_u128(1);
|
|
let sb = Sidebar {
|
|
sessions: vec![session_named(1, "claude")],
|
|
highlight: None,
|
|
attached: [sid].into_iter().collect(),
|
|
};
|
|
let all = rows(&draw(&sb, 28, 6));
|
|
let line = row_containing(&all, "claude");
|
|
assert!(line.contains('●'), "expected filled glyph in: {line:?}");
|
|
assert!(
|
|
!line.contains('○'),
|
|
"should not contain hollow glyph: {line:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn dead_session_uses_cross_glyph() {
|
|
let mut sess = session_named(1, "claude");
|
|
sess.status = SessionStatus::Dead;
|
|
let sb = Sidebar {
|
|
sessions: vec![sess],
|
|
highlight: None,
|
|
attached: Default::default(),
|
|
};
|
|
let buf = draw(&sb, 28, 6);
|
|
let all = rows(&buf);
|
|
let line = row_containing(&all, "claude");
|
|
assert!(line.contains('✗'), "expected dead glyph in: {line:?}");
|
|
}
|
|
|
|
/// #83 via docs/SIDEBAR.md: blocked work lives on the meta line now, not
|
|
/// on the name row, and reads as diff notation; a clear or unprobed
|
|
/// worktree says nothing.
|
|
#[test]
|
|
fn blocked_session_states_its_diff_on_the_meta_line() {
|
|
let mut blocked = session_named(1, "claude");
|
|
blocked.activity_since = Utc::now() - Duration::seconds(5);
|
|
blocked.blocked_work = Some(InTheWay {
|
|
uncommitted_files: 2,
|
|
untracked_files: 3,
|
|
unpushed_commits: 4,
|
|
insertions: 124,
|
|
deletions: 18,
|
|
});
|
|
let mut clear = session_named(2, "bash");
|
|
clear.blocked_work = Some(InTheWay::default());
|
|
let unprobed = session_named(3, "vim");
|
|
let sb = Sidebar {
|
|
sessions: vec![blocked, clear, unprobed],
|
|
highlight: None,
|
|
attached: Default::default(),
|
|
};
|
|
let all = rows(&draw(&sb, 44, 10));
|
|
|
|
let meta = all
|
|
.iter()
|
|
.find(|l| l.contains("+124"))
|
|
.expect("blocked card must state its diff somewhere");
|
|
for fragment in ["-18", "?3", "↑4"] {
|
|
assert!(
|
|
meta.contains(fragment),
|
|
"meta line must carry {fragment}: {meta:?}"
|
|
);
|
|
}
|
|
let name_row = row_containing(&all, "claude");
|
|
assert!(
|
|
!name_row.contains("+124"),
|
|
"the diff stays off the name row: {name_row:?}"
|
|
);
|
|
for name in ["bash", "vim"] {
|
|
let idx = all.iter().position(|l| l.contains(name)).unwrap();
|
|
assert!(
|
|
!all[idx + 1].contains('+') && !all[idx + 1].contains('?'),
|
|
"{name} must not state a diff: {:?}",
|
|
all[idx + 1]
|
|
);
|
|
}
|
|
}
|
|
|
|
/// The additions and deletions are the only coloured fragments: they are
|
|
/// what a reader scans for, and colouring the rest would make the line
|
|
/// compete with the activity rail.
|
|
#[test]
|
|
fn the_diff_colours_only_its_additions_and_deletions() {
|
|
let mut s = session_named(1, "claude");
|
|
s.blocked_work = Some(InTheWay {
|
|
uncommitted_files: 1,
|
|
untracked_files: 0,
|
|
unpushed_commits: 2,
|
|
insertions: 7,
|
|
deletions: 3,
|
|
});
|
|
let sb = Sidebar {
|
|
sessions: vec![s],
|
|
highlight: None,
|
|
attached: Default::default(),
|
|
};
|
|
let buf = draw(&sb, 44, 8);
|
|
let palette = palette::active();
|
|
let surface = palette.surface(false);
|
|
let colour_of = |needle: char| {
|
|
(0..buf.area.width)
|
|
.flat_map(|x| (0..buf.area.height).map(move |y| (x, y)))
|
|
.find(|&(x, y)| buf[(x, y)].symbol() == needle.to_string())
|
|
.map(|(x, y)| buf[(x, y)].fg)
|
|
.expect("fragment must be drawn")
|
|
};
|
|
assert_eq!(colour_of('+'), surface.active);
|
|
assert_eq!(colour_of('-'), surface.error);
|
|
assert_eq!(colour_of('↑'), surface.muted);
|
|
}
|
|
|
|
/// A fresh session still gets its unconditional second line, but no
|
|
/// duration until it has been held in state for 30 seconds.
|
|
#[test]
|
|
fn fresh_session_renders_two_lines_without_duration() {
|
|
let mut s = session_named(1, "claude");
|
|
s.activity_since = Utc::now() - Duration::seconds(10);
|
|
let sb = Sidebar {
|
|
sessions: vec![s],
|
|
highlight: None,
|
|
attached: Default::default(),
|
|
};
|
|
let all = rows(&draw(&sb, 28, 8));
|
|
assert_eq!(content(&all[0]), "sessions");
|
|
assert!(row_containing(&all, "claude").contains('○'));
|
|
assert!(
|
|
all[3].contains(RAIL),
|
|
"line 2 must carry the rail: {:?}",
|
|
all[3]
|
|
);
|
|
assert!(
|
|
all[3].contains("bash"),
|
|
"line 2 must carry the command: {:?}",
|
|
all[3]
|
|
);
|
|
assert!(
|
|
!all[3].contains("10s"),
|
|
"fresh cards have no duration: {:?}",
|
|
all[3]
|
|
);
|
|
}
|
|
|
|
/// Rule 1 of docs/SIDEBAR.md: held in state longer than 30s earns the
|
|
/// meta line, with the floored duration next to the rail.
|
|
#[test]
|
|
fn long_held_session_gets_a_meta_line_with_duration() {
|
|
let mut s = session_named(1, "claude");
|
|
s.activity_since = Utc::now() - Duration::seconds(90);
|
|
let sb = Sidebar {
|
|
sessions: vec![s],
|
|
highlight: None,
|
|
attached: Default::default(),
|
|
};
|
|
let all = rows(&draw(&sb, 28, 8));
|
|
let idx = all.iter().position(|l| l.contains("claude")).unwrap();
|
|
let meta = &all[idx + 1];
|
|
assert!(meta.contains(RAIL), "meta line carries the rail: {meta:?}");
|
|
assert!(meta.contains("1m"), "expected 1m in: {meta:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn duration_format_floors_whole_units() {
|
|
let fmt = |secs| format_state_duration(Duration::seconds(secs));
|
|
assert_eq!(fmt(0), "0s");
|
|
assert_eq!(fmt(45), "45s");
|
|
assert_eq!(fmt(59), "59s");
|
|
assert_eq!(fmt(60), "1m");
|
|
assert_eq!(fmt(240), "4m");
|
|
assert_eq!(fmt(3599), "59m");
|
|
assert_eq!(fmt(3600), "1h");
|
|
assert_eq!(fmt(7200), "2h");
|
|
}
|
|
|
|
/// The rail and duration carry the activity state's colour; the state
|
|
/// word is never spelled out (docs/SIDEBAR.md colour table).
|
|
#[test]
|
|
fn meta_line_rail_uses_the_contract_colour_per_state() {
|
|
for state in [
|
|
ActivityState::Active,
|
|
ActivityState::Idle,
|
|
ActivityState::AwaitingInput,
|
|
ActivityState::Error,
|
|
] {
|
|
let expected = palette::active().card.activity(state);
|
|
let mut s = session_named(1, "claude");
|
|
s.activity = state;
|
|
s.activity_since = Utc::now() - Duration::seconds(120);
|
|
let sb = Sidebar {
|
|
sessions: vec![s],
|
|
highlight: None,
|
|
attached: Default::default(),
|
|
};
|
|
let buf = draw(&sb, 28, 8);
|
|
let found = (0..buf.area.height)
|
|
.any(|y| (0..buf.area.width).any(|x| buf[(x, y)].style().fg == Some(expected)));
|
|
assert!(found, "expected some cell in fg {expected:?} for {state:?}");
|
|
}
|
|
}
|
|
|
|
/// The selected card keeps carrying its state on the rail, but in the
|
|
/// selection surface's own colours. A state colour picked for the dark
|
|
/// card background painted onto the selection background is the contrast
|
|
/// bug this palette fixes — this test fails if it comes back.
|
|
#[test]
|
|
fn selected_card_never_paints_a_card_state_colour_on_the_selection_background() {
|
|
let p = palette::active();
|
|
for state in [
|
|
ActivityState::Active,
|
|
ActivityState::Idle,
|
|
ActivityState::AwaitingInput,
|
|
ActivityState::Error,
|
|
] {
|
|
let mut s = session_named(1, "claude");
|
|
s.activity = state;
|
|
s.activity_since = Utc::now() - Duration::seconds(120);
|
|
let sb = Sidebar {
|
|
sessions: vec![s],
|
|
highlight: Some(SidebarItem::Session(Uuid::from_u128(1))),
|
|
attached: Default::default(),
|
|
};
|
|
let buf = draw(&sb, 28, 8);
|
|
let on_selection = |fg: Color| {
|
|
(0..buf.area.height).any(|y| {
|
|
(0..buf.area.width).any(|x| {
|
|
let st = buf[(x, y)].style();
|
|
st.bg == Some(p.selection.bg) && st.fg == Some(fg)
|
|
})
|
|
})
|
|
};
|
|
assert!(
|
|
!on_selection(p.card.activity(state)),
|
|
"{state:?}: card surface colour {:?} painted onto the selection background",
|
|
p.card.activity(state)
|
|
);
|
|
assert!(
|
|
on_selection(p.selection.activity(state)),
|
|
"{state:?}: the rail must still carry the state on the selected card"
|
|
);
|
|
let ratio = palette::contrast_ratio(p.selection.bg, p.selection.activity(state))
|
|
.expect("palette roles are RGB");
|
|
assert!(
|
|
ratio >= 4.5,
|
|
"{state:?} on the selection background is only {ratio:.2}:1"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Rule 3 of docs/SIDEBAR.md: a dead session always gets a meta line,
|
|
/// even freshly dead, with the muted `exited` fragment.
|
|
#[test]
|
|
fn dead_session_shows_exited_on_its_meta_line() {
|
|
let mut s = session_named(1, "claude");
|
|
s.status = SessionStatus::Dead;
|
|
s.activity_since = Utc::now() - Duration::seconds(5);
|
|
let sb = Sidebar {
|
|
sessions: vec![s],
|
|
highlight: None,
|
|
attached: Default::default(),
|
|
};
|
|
let all = rows(&draw(&sb, 28, 8));
|
|
let idx = all.iter().position(|l| l.contains("claude")).unwrap();
|
|
assert!(
|
|
all[idx + 1].contains("exited"),
|
|
"expected exited fragment in: {:?}",
|
|
all[idx + 1]
|
|
);
|
|
}
|
|
|
|
/// docs/SIDEBAR.md: line 2 names the program, never its arguments.
|
|
#[test]
|
|
fn meta_line_drops_the_commands_arguments() {
|
|
let mut s = session_named(1, "handle");
|
|
s.command = "claude --dangerously-skip-permissions".into();
|
|
let sb = Sidebar {
|
|
sessions: vec![s],
|
|
highlight: None,
|
|
attached: Default::default(),
|
|
};
|
|
let all = rows(&draw(&sb, 60, 6));
|
|
let meta = content(&all[3]);
|
|
assert!(
|
|
meta.contains("claude"),
|
|
"line 2 must name the program: {meta:?}"
|
|
);
|
|
assert!(
|
|
!meta.contains("dangerously"),
|
|
"line 2 must not carry arguments: {meta:?}"
|
|
);
|
|
}
|
|
|
|
/// The sidebar is narrow: the command truncates with `…` and never wraps
|
|
/// onto a third row (docs/SIDEBAR.md).
|
|
#[test]
|
|
fn meta_line_command_truncates_instead_of_wrapping() {
|
|
let mut s = session_named(1, "claude");
|
|
s.command = "a-program-name-that-cannot-fit".into();
|
|
s.activity_since = Utc::now() - Duration::seconds(5);
|
|
s.blocked_work = Some(InTheWay {
|
|
uncommitted_files: 120,
|
|
..InTheWay::default()
|
|
});
|
|
let sb = Sidebar {
|
|
sessions: vec![s],
|
|
highlight: None,
|
|
attached: Default::default(),
|
|
};
|
|
let all = rows(&draw(&sb, 28, 8));
|
|
let idx = all.iter().position(|l| l.contains("claude")).unwrap();
|
|
let meta = content(&all[idx + 1]);
|
|
assert!(
|
|
meta.trim_end().ends_with('…'),
|
|
"truncated command must end with …: {meta:?}"
|
|
);
|
|
assert!(
|
|
content(&all[idx + 2]).trim().is_empty(),
|
|
"meta line must not wrap: {:?}",
|
|
all[idx + 2]
|
|
);
|
|
}
|
|
|
|
/// #112: the agent-reported detail sits right after the duration on the
|
|
/// meta line, in the muted style (docs/SIDEBAR.md).
|
|
#[test]
|
|
fn errored_session_shows_its_reported_detail_on_the_meta_line() {
|
|
let mut s = session_named(1, "claude");
|
|
s.activity = ActivityState::Error;
|
|
s.activity_since = Utc::now() - Duration::seconds(90);
|
|
s.activity_detail = Some("cargo build failed".into());
|
|
let sb = Sidebar {
|
|
sessions: vec![s],
|
|
highlight: None,
|
|
attached: Default::default(),
|
|
};
|
|
let all = rows(&draw(&sb, 40, 8));
|
|
let idx = all.iter().position(|l| l.contains("claude")).unwrap();
|
|
let meta = &all[idx + 1];
|
|
assert!(meta.contains(RAIL), "meta line carries the rail: {meta:?}");
|
|
assert!(
|
|
meta.contains("1m"),
|
|
"duration precedes the detail: {meta:?}"
|
|
);
|
|
assert!(
|
|
meta.contains("cargo build failed"),
|
|
"reported detail must render: {meta:?}"
|
|
);
|
|
}
|
|
|
|
/// A fresh Error report earns line 2 on its own (contract rule 4), with
|
|
/// the detail even though no duration has accrued.
|
|
#[test]
|
|
fn fresh_errored_session_earns_a_meta_line_for_its_detail() {
|
|
let mut s = session_named(1, "claude");
|
|
s.activity = ActivityState::Error;
|
|
s.activity_since = Utc::now() - Duration::seconds(5);
|
|
s.activity_detail = Some("waiting for approval".into());
|
|
let sb = Sidebar {
|
|
sessions: vec![s],
|
|
highlight: None,
|
|
attached: Default::default(),
|
|
};
|
|
let all = rows(&draw(&sb, 40, 8));
|
|
let idx = all.iter().position(|l| l.contains("claude")).unwrap();
|
|
assert!(
|
|
all[idx + 1].contains("waiting for approval"),
|
|
"detail must render on a fresh report's own meta line: {:?}",
|
|
all[idx + 1]
|
|
);
|
|
}
|
|
|
|
/// #112 via docs/SIDEBAR.md: the detail truncates with `…` ahead of the
|
|
/// trailing fragments — a long detail never pushes blocked work or
|
|
/// `exited` off the line, and never wraps.
|
|
#[test]
|
|
fn long_detail_yields_to_blocked_work_and_exited() {
|
|
let mut s = session_named(1, "claude");
|
|
s.status = SessionStatus::Dead;
|
|
s.activity_since = Utc::now() - Duration::seconds(5);
|
|
s.activity_detail =
|
|
Some("an absurdly long error message that cannot possibly fit ".repeat(3));
|
|
s.blocked_work = Some(InTheWay {
|
|
unpushed_commits: 2,
|
|
..InTheWay::default()
|
|
});
|
|
let sb = Sidebar {
|
|
sessions: vec![s],
|
|
highlight: None,
|
|
attached: Default::default(),
|
|
};
|
|
let all = rows(&draw(&sb, 42, 8));
|
|
let idx = all.iter().position(|l| l.contains("claude")).unwrap();
|
|
let meta = content(&all[idx + 1]);
|
|
assert!(meta.contains('…'), "truncated detail ends with …: {meta:?}");
|
|
assert!(
|
|
meta.contains("↑2"),
|
|
"blocked work stays on the line: {meta:?}"
|
|
);
|
|
assert!(
|
|
meta.contains("exited"),
|
|
"exited fragment stays on the line: {meta:?}"
|
|
);
|
|
assert!(
|
|
content(&all[idx + 2]).trim().is_empty(),
|
|
"meta line must not wrap: {:?}",
|
|
all[idx + 2]
|
|
);
|
|
}
|
|
|
|
/// docs/SIDEBAR.md: a payload predating #102 carries an epoch
|
|
/// `activity_since` and so contributes no duration — a card must not claim
|
|
/// the session has been idle since 1970. The rail still renders when some
|
|
/// other trigger puts line 2 on screen.
|
|
#[test]
|
|
fn epoch_activity_since_renders_no_duration() {
|
|
let mut legacy = session_named(1, "claude");
|
|
legacy.activity_since = DateTime::from_timestamp(0, 0).unwrap();
|
|
legacy.status = SessionStatus::Dead;
|
|
let sb = Sidebar {
|
|
sessions: vec![legacy],
|
|
highlight: None,
|
|
attached: Default::default(),
|
|
};
|
|
let rendered = render_to_string(&draw(&sb, 40, 8));
|
|
assert!(
|
|
rendered.contains("exited"),
|
|
"dead session must still show line 2:\n{rendered}"
|
|
);
|
|
for line in rendered.lines().filter(|l| l.contains(RAIL)) {
|
|
assert!(
|
|
!line
|
|
.split(RAIL)
|
|
.nth(1)
|
|
.unwrap_or("")
|
|
.trim_start()
|
|
.starts_with(char::is_numeric),
|
|
"duration rendered from an epoch stamp: {line:?}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn falls_back_to_command_when_name_metadata_is_absent() {
|
|
let s = Session {
|
|
id: Uuid::from_u128(1),
|
|
command: "vim".into(),
|
|
cwd: std::path::PathBuf::from("/tmp"),
|
|
env: Default::default(),
|
|
metadata: HashMap::new(),
|
|
worktree: None,
|
|
status: SessionStatus::Running,
|
|
created_at: Utc::now(),
|
|
foreground: None,
|
|
activity: Default::default(),
|
|
activity_detail: None,
|
|
last_activity_at: Utc::now(),
|
|
activity_since: Utc::now(),
|
|
current_cwd: None,
|
|
blocked_work: None,
|
|
};
|
|
let sb = Sidebar {
|
|
sessions: vec![s],
|
|
highlight: None,
|
|
attached: Default::default(),
|
|
};
|
|
let rendered = render_to_string(&draw(&sb, 28, 6));
|
|
assert!(
|
|
rendered.contains("vim"),
|
|
"missing fallback command name:\n{rendered}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn command_renders_on_line_two_even_when_name_is_metadata() {
|
|
let s = session_named(1, "friendly name");
|
|
let sb = Sidebar {
|
|
sessions: vec![s],
|
|
highlight: None,
|
|
attached: Default::default(),
|
|
};
|
|
let all = rows(&draw(&sb, 40, 6));
|
|
assert!(
|
|
all[2].contains("friendly name"),
|
|
"name must stay on line 1: {:?}",
|
|
all[2]
|
|
);
|
|
assert!(
|
|
all[3].contains("bash"),
|
|
"command must be on line 2: {:?}",
|
|
all[3]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn narrow_meta_line_keeps_rail_and_truncates_command() {
|
|
let s = session_named(1, "name");
|
|
let sb = Sidebar {
|
|
sessions: vec![s],
|
|
highlight: None,
|
|
attached: Default::default(),
|
|
};
|
|
let all = rows(&draw(&sb, 9, 6));
|
|
assert!(
|
|
all[3].contains(RAIL),
|
|
"rail must survive narrow cards: {:?}",
|
|
all[3]
|
|
);
|
|
assert!(
|
|
all[3].contains('…'),
|
|
"command should truncate: {:?}",
|
|
all[3]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn blocked_warning_survives_before_a_truncated_command() {
|
|
let mut s = session_named(1, "name");
|
|
s.command = "a-program-name-that-cannot-fit".into();
|
|
s.blocked_work = Some(InTheWay {
|
|
uncommitted_files: 1,
|
|
..Default::default()
|
|
});
|
|
let sb = Sidebar {
|
|
sessions: vec![s],
|
|
highlight: None,
|
|
attached: Default::default(),
|
|
};
|
|
let all = rows(&draw(&sb, 29, 6));
|
|
assert!(all[3].contains("~1"), "{:?}", all[3]);
|
|
let meta = content(&all[3]);
|
|
assert!(meta.ends_with('…'), "command must truncate: {meta:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn long_detail_yields_to_status_and_command() {
|
|
let mut s = session_named(1, "name");
|
|
s.activity_detail = Some("a very long agent-reported detail".into());
|
|
s.blocked_work = Some(InTheWay {
|
|
uncommitted_files: 1,
|
|
..Default::default()
|
|
});
|
|
let sb = Sidebar {
|
|
sessions: vec![s],
|
|
highlight: None,
|
|
attached: Default::default(),
|
|
};
|
|
let all = rows(&draw(&sb, 40, 6));
|
|
assert!(all[3].contains("~1"), "{:?}", all[3]);
|
|
assert!(
|
|
all[3].contains("bash"),
|
|
"command must survive: {:?}",
|
|
all[3]
|
|
);
|
|
assert!(all[3].contains('…'), "detail must truncate: {:?}", all[3]);
|
|
}
|
|
|
|
/// Reversed video makes a card unreadable against a background; cards
|
|
/// use explicit backgrounds instead (docs/SIDEBAR.md, #108).
|
|
#[test]
|
|
fn no_cell_is_ever_reversed() {
|
|
let mut highlighted = session_named(1, "claude");
|
|
highlighted.activity_since = Utc::now() - Duration::seconds(120);
|
|
let sb = Sidebar {
|
|
sessions: vec![highlighted],
|
|
highlight: Some(SidebarItem::Session(Uuid::from_u128(1))),
|
|
attached: Default::default(),
|
|
};
|
|
let buf = draw(&sb, 28, 8);
|
|
for y in 0..buf.area.height {
|
|
for x in 0..buf.area.width {
|
|
assert!(
|
|
!buf[(x, y)]
|
|
.style()
|
|
.add_modifier
|
|
.contains(Modifier::REVERSED),
|
|
"reversed video at ({x},{y})"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The highlighted card gets the selection background; the rest of the
|
|
/// focused sidebar shares its subtle panel background.
|
|
#[test]
|
|
fn cards_use_explicit_backgrounds_with_a_distinct_focus_background() {
|
|
let one = session_named(1, "claude");
|
|
let two = session_named(2, "bash");
|
|
let sb = Sidebar {
|
|
sessions: vec![one, two],
|
|
highlight: Some(SidebarItem::Session(Uuid::from_u128(1))),
|
|
attached: Default::default(),
|
|
};
|
|
let buf = draw(&sb, 28, 8);
|
|
let all = rows(&buf);
|
|
let claude_y = all.iter().position(|l| l.contains("claude")).unwrap();
|
|
let bash_y = all.iter().position(|l| l.contains("○ bash")).unwrap();
|
|
|
|
let p = palette::active();
|
|
assert_eq!(
|
|
buf[(1, claude_y as u16)].style().bg,
|
|
Some(p.selection.bg),
|
|
"focused card must sit on the selection surface"
|
|
);
|
|
assert_eq!(
|
|
buf[(1, claude_y as u16)].style().fg,
|
|
Some(p.selection.fg),
|
|
"focused card text must use the selection foreground role"
|
|
);
|
|
assert_eq!(
|
|
buf[(1, bash_y as u16)].style().bg,
|
|
Some(p.sidebar_focus_bg),
|
|
"ordinary cards must share the focused panel surface"
|
|
);
|
|
assert_eq!(
|
|
buf[(1, 0)].style().bg,
|
|
Some(p.sidebar_focus_bg),
|
|
"the focus background must cover the whole sidebar"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn highlighted_card_is_hollow_when_sidebar_is_unfocused() {
|
|
let sb = Sidebar {
|
|
sessions: vec![session_named(1, "claude")],
|
|
highlight: Some(SidebarItem::Session(Uuid::from_u128(1))),
|
|
attached: Default::default(),
|
|
};
|
|
let buf = draw_with_focus(&sb, 28, 8, false);
|
|
let y = (0..buf.area.height)
|
|
.find(|&y| (0..buf.area.width).any(|x| buf[(x, y)].symbol() == "c"))
|
|
.expect("highlighted session row");
|
|
assert_eq!(
|
|
buf[(1, y)].style().bg,
|
|
Some(palette::active().card.bg),
|
|
"unfocused highlight must not paint the selection surface"
|
|
);
|
|
assert!(
|
|
(0..buf.area.width).any(|x| buf[(x, y)].symbol() == "▏"),
|
|
"unfocused highlight must retain a visible edge marker"
|
|
);
|
|
let ordinary = draw_with_focus(
|
|
&Sidebar {
|
|
sessions: vec![session_named(1, "claude")],
|
|
highlight: None,
|
|
attached: Default::default(),
|
|
},
|
|
28,
|
|
8,
|
|
false,
|
|
);
|
|
assert!(
|
|
!(0..ordinary.area.height)
|
|
.any(|row| (0..ordinary.area.width).any(|x| ordinary[(x, row)].symbol() == "▏")),
|
|
"ordinary unhighlighted cards must not carry the cursor marker"
|
|
);
|
|
}
|
|
|
|
/// The card background paints the full card width, not just the glyphs.
|
|
#[test]
|
|
fn card_background_covers_the_full_card_width() {
|
|
let s = session_named(1, "claude");
|
|
let sb = Sidebar {
|
|
sessions: vec![s],
|
|
highlight: None,
|
|
attached: Default::default(),
|
|
};
|
|
let buf = draw(&sb, 28, 8);
|
|
let y = buf.content.iter().position(|c| c.symbol() == "c").unwrap() as u16 / buf.area.width;
|
|
for x in 1..27 {
|
|
assert_eq!(
|
|
buf[(x, y)].style().bg,
|
|
Some(palette::active().sidebar_focus_bg),
|
|
"gap in card background at col {x}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn highlighted_rows_do_not_show_action_glyphs() {
|
|
let sb = Sidebar {
|
|
sessions: vec![session_named(1, "claude")],
|
|
highlight: Some(SidebarItem::Session(Uuid::from_u128(1))),
|
|
attached: Default::default(),
|
|
};
|
|
let all = rows(&draw(&sb, 28, 8));
|
|
let line = content(row_containing(&all, "claude"));
|
|
assert!(
|
|
!line.contains('⧉') && !line.contains('✕'),
|
|
"keyboard-only actions should not consume card space: {line:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn unhighlighted_rows_show_no_actions() {
|
|
let one = session_named(1, "claude");
|
|
let two = session_named(2, "bash");
|
|
let sb = Sidebar {
|
|
sessions: vec![one, two],
|
|
highlight: Some(SidebarItem::Session(Uuid::from_u128(1))),
|
|
attached: Default::default(),
|
|
};
|
|
let all = rows(&draw(&sb, 28, 8));
|
|
let line = row_containing(&all, "bash");
|
|
assert!(
|
|
!line.contains('⧉') && !line.contains('✕'),
|
|
"actions are highlight-only: {line:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn long_name_uses_the_full_card_width() {
|
|
let mut s = session_named(1, "claude");
|
|
if let Some(serde_json::Value::String(name)) = s.metadata.get_mut("name") {
|
|
*name = "an-unreasonably-long-session-name".into();
|
|
}
|
|
let sb = Sidebar {
|
|
sessions: vec![s],
|
|
highlight: Some(SidebarItem::Session(Uuid::from_u128(1))),
|
|
attached: Default::default(),
|
|
};
|
|
let all = rows(&draw(&sb, 28, 8));
|
|
let line = row_containing(&all, "an-unreasonably");
|
|
assert!(line.contains('…'), "name must truncate with …: {line:?}");
|
|
assert!(
|
|
!line.contains('⧉') && !line.contains('✕'),
|
|
"actions leaked in: {line:?}"
|
|
);
|
|
}
|
|
}
|