diff --git a/crates/maestro-tui/src/action.rs b/crates/maestro-tui/src/action.rs index 0efba87..c3a3403 100644 --- a/crates/maestro-tui/src/action.rs +++ b/crates/maestro-tui/src/action.rs @@ -34,6 +34,7 @@ pub enum Action { // Sidebar highlight movement SidebarNext, SidebarPrev, + ToggleSidebarGroup, // Catalog actions on the highlighted sidebar row AttachHighlighted, @@ -81,6 +82,7 @@ impl Action { "FocusSidebar" => return Ok(Action::FocusSidebar), "SidebarNext" => return Ok(Action::SidebarNext), "SidebarPrev" => return Ok(Action::SidebarPrev), + "ToggleSidebarGroup" => return Ok(Action::ToggleSidebarGroup), "AttachHighlighted" => return Ok(Action::AttachHighlighted), "ToggleAttachHighlighted" => return Ok(Action::ToggleAttachHighlighted), "NewDraftPane" => return Ok(Action::NewDraftPane), diff --git a/crates/maestro-tui/src/app.rs b/crates/maestro-tui/src/app.rs index 245a628..b08547b 100644 --- a/crates/maestro-tui/src/app.rs +++ b/crates/maestro-tui/src/app.rs @@ -30,14 +30,16 @@ use crate::catalog::CatalogStore; use crate::config::TuiConfig; use crate::draft::DraftInputResult; use crate::emulators::SessionEmulators; -use crate::input::{canonicalise, ChordResult, ChordState, ClickTracker, InputKey}; +use crate::input::{ + canonicalise, ChordResult, ChordState, ClickTracker, InputKey, MouseKind, Region, +}; use crate::keymap::Mode; use crate::overlays::HelpOverlay; use crate::pane::{Pane, PaneStatus}; use crate::render::{draw, RegionMap, RenderInputs}; use crate::session_task::{SessionInput, SessionTask}; use crate::sidebar::Sidebar; -use crate::state::{AppState, Focus, OverlayKind, PaneRef}; +use crate::state::{AppState, Focus, OverlayKind, PaneRef, SidebarItem}; use crate::tabs::TabBar; use crate::worktrees::WorktreesView; use crate::TuiError; @@ -52,6 +54,7 @@ enum OverlayDecision { pub struct App { cfg: TuiConfig, + home_dir: String, catalog: CatalogStore, emulators: SessionEmulators, state: AppState, @@ -137,6 +140,7 @@ impl App { .expect("build reqwest client"); Self { cfg, + home_dir: std::env::var("HOME").unwrap_or_default(), catalog, emulators: SessionEmulators::default(), state: AppState::new(), @@ -158,6 +162,9 @@ impl App { mut self, terminal: &mut Terminal>, ) -> Result<(), TuiError> { + if let Ok(info) = self.daemon.daemon_info().await { + self.home_dir = info.home_dir; + } let mut events = EventStream::new(); loop { self.redraw(terminal)?; @@ -190,6 +197,10 @@ impl App { }; let key = self.clicks.promote(key, std::time::Instant::now()); + if self.handle_sidebar_mouse(&key) { + return Ok(false); + } + // Overlay intercept first. let (decision, effects) = self.handle_overlay_input(&key); self.perform(effects).await; @@ -224,6 +235,34 @@ impl App { } } + /// Clicks select rendered sidebar items directly. Double-clicks continue + /// through the keymap so the configured attach binding remains intact. + fn handle_sidebar_mouse(&mut self, key: &InputKey) -> bool { + let InputKey::Mouse { + kind: MouseKind::Click, + region: Region::SidebarRow, + row, + .. + } = key + else { + return false; + }; + let Some(item) = self.region.sidebar_item_at(*row) else { + return true; + }; + match item { + SidebarItem::Group(key) => { + self.state.sidebar_highlight = Some(SidebarItem::Group(key.clone())); + self.toggle_sidebar_group(key); + } + SidebarItem::Session(id) => { + self.state.sidebar_highlight = Some(SidebarItem::Session(id)); + self.state.focus = Focus::Sidebar; + } + } + true + } + /// Carry out what `dispatch` decided. The only place in `App` that talks to /// the daemon. async fn perform(&mut self, effects: Vec) { @@ -330,15 +369,22 @@ impl App { self.state.focus = Focus::Pane; } Action::SidebarNext => { - let order = visible_sidebar_order(&self.catalog.snapshot()); + let order = visible_sidebar_order( + &self.catalog.snapshot(), + &self.state.collapsed_sidebar_groups, + ); self.state.move_sidebar_highlight(&order, 1); self.state.focus = Focus::Sidebar; } Action::SidebarPrev => { - let order = visible_sidebar_order(&self.catalog.snapshot()); + let order = visible_sidebar_order( + &self.catalog.snapshot(), + &self.state.collapsed_sidebar_groups, + ); self.state.move_sidebar_highlight(&order, -1); self.state.focus = Focus::Sidebar; } + Action::ToggleSidebarGroup => self.toggle_highlighted_sidebar_group(), Action::NewTab => self.state.new_tab(), Action::CloseTab => { if let Some(sid) = self.state.close_tab() { @@ -403,13 +449,50 @@ impl App { /// Return the session under the sidebar highlight, falling back to the /// first listed session so the user can attach without arrowing first. fn pick_sidebar_target(&self) -> Option { - if let Some(sid) = self.state.sidebar_highlight { + if let Some(SidebarItem::Session(sid)) = self.state.sidebar_highlight { return Some(sid); } + if self.state.sidebar_highlight.is_some() { + return None; + } let snap = self.catalog.snapshot(); snap.sessions.first().map(|s| s.id) } + fn toggle_highlighted_sidebar_group(&mut self) { + let key = match self.state.sidebar_highlight.clone() { + Some(SidebarItem::Group(key)) => Some(key), + Some(SidebarItem::Session(id)) => self + .catalog + .snapshot() + .sessions + .into_iter() + .find(|s| s.id == id) + .and_then(|s| maestro_protocol::display::session_tree_path(&s)), + None => None, + }; + if let Some(key) = key { + self.toggle_sidebar_group(key); + } + } + + fn toggle_sidebar_group(&mut self, key: PathBuf) { + let contains_highlight = match self.state.sidebar_highlight { + Some(SidebarItem::Session(id)) => { + self.catalog + .snapshot() + .sessions + .iter() + .find(|s| s.id == id) + .and_then(maestro_protocol::display::session_tree_path) + .as_ref() + == Some(&key) + } + _ => false, + }; + self.state.toggle_sidebar_group(key, contains_highlight); + } + /// Encode a keystroke for the focused pane. `None` when there is nothing /// attached to send it to. fn forward_to_session(&mut self, key: &InputKey) -> Option { @@ -811,7 +894,7 @@ impl App { .collect(); let sidebar = Sidebar { sessions: snap.sessions.clone(), - highlight: self.state.sidebar_highlight, + highlight: self.state.sidebar_highlight.clone(), attached, }; let tab_bar = TabBar { @@ -833,6 +916,7 @@ impl App { let inputs = RenderInputs { sidebar: &sidebar, + home_dir: &self.home_dir, tab_bar: &tab_bar, pane: pane.as_ref(), state: &self.state, @@ -881,8 +965,16 @@ impl App { /// Order the sidebar walks for `j`/`k`: the sessions in the same order /// [`crate::sidebar::Sidebar::render`] emits them. Keeps highlight movement in /// visual sync with what the user sees. -fn visible_sidebar_order(snap: &crate::catalog::CatalogSnapshot) -> Vec { - snap.sessions.iter().map(|s| s.id).collect() +fn visible_sidebar_order( + snap: &crate::catalog::CatalogSnapshot, + collapsed: &std::collections::HashSet, +) -> Vec { + Sidebar { + sessions: snap.sessions.clone(), + highlight: None, + attached: HashSet::new(), + } + .rendered_items_with_collapsed(collapsed) } /// Wire encoding for a keystroke headed to a session's PTY. `None` for keys @@ -1134,9 +1226,32 @@ mod tests { }], }); app.dispatch(Action::SidebarNext); + app.dispatch(Action::SidebarNext); app } + fn test_session(sid: Uuid, cwd: &str) -> maestro_protocol::Session { + use chrono::Utc; + use maestro_protocol::{Session, SessionStatus}; + Session { + id: sid, + command: "bash".into(), + cwd: cwd.into(), + env: Default::default(), + metadata: Default::default(), + 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, + } + } + #[test] fn attach_highlighted_asks_for_an_attach() { let sid = Uuid::from_u128(11); @@ -1148,6 +1263,64 @@ mod tests { assert_eq!(app.state.active_tab().session(), Some(sid)); } + #[test] + fn group_highlight_is_not_a_session_action_target() { + use maestro_protocol::CatalogEvent; + + let sid = Uuid::from_u128(14); + let mut app = make_app(); + app.catalog.apply_event(CatalogEvent::Snapshot { + sessions: vec![test_session(sid, "/repo")], + }); + app.dispatch(Action::SidebarNext); + assert_eq!( + app.state.sidebar_highlight, + Some(SidebarItem::Group(PathBuf::from("/repo"))) + ); + assert!(app.dispatch(Action::AttachHighlighted).is_empty()); + assert!(app.dispatch(Action::KillHighlighted).is_empty()); + assert!(app.state.overlay().is_none()); + } + + #[test] + fn collapsing_the_group_containing_a_card_highlight_keeps_a_visible_group_highlight() { + let sid = Uuid::from_u128(15); + let mut app = app_with_highlighted_session(sid); + assert_eq!(app.state.sidebar_highlight, Some(SidebarItem::Session(sid))); + assert!(app.dispatch(Action::ToggleSidebarGroup).is_empty()); + assert_eq!( + app.state.sidebar_highlight, + Some(SidebarItem::Group(PathBuf::from("/"))) + ); + assert!(app + .state + .collapsed_sidebar_groups + .contains(&PathBuf::from("/"))); + } + + #[test] + fn clicking_a_group_toggles_it_and_selects_the_group() { + let sid = Uuid::from_u128(16); + let mut app = app_with_highlighted_session(sid); + app.region.sidebar = Some(ratatui::layout::Rect::new(0, 0, 28, 10)); + app.region.sidebar_items = vec![SidebarItem::Group(PathBuf::from("/"))]; + let click = InputKey::Mouse { + kind: MouseKind::Click, + region: Region::SidebarRow, + col: 3, + row: 2, + }; + assert!(app.handle_sidebar_mouse(&click)); + assert_eq!( + app.state.sidebar_highlight, + Some(SidebarItem::Group(PathBuf::from("/"))) + ); + assert!(app + .state + .collapsed_sidebar_groups + .contains(&PathBuf::from("/"))); + } + #[test] fn confirming_a_kill_asks_for_the_kill() { let sid = Uuid::from_u128(12); diff --git a/crates/maestro-tui/src/config.rs b/crates/maestro-tui/src/config.rs index 4b305ea..494278c 100644 --- a/crates/maestro-tui/src/config.rs +++ b/crates/maestro-tui/src/config.rs @@ -45,6 +45,7 @@ const DEFAULT_NORMAL: &[(&str, &str)] = &[ ("k", "SidebarPrev"), ("down", "SidebarNext"), ("up", "SidebarPrev"), + ("c", "ToggleSidebarGroup"), // Catalog actions ("enter", "AttachHighlighted"), ("s", "NewDraftPane"), diff --git a/crates/maestro-tui/src/render.rs b/crates/maestro-tui/src/render.rs index d222420..86d0b04 100644 --- a/crates/maestro-tui/src/render.rs +++ b/crates/maestro-tui/src/render.rs @@ -14,7 +14,7 @@ use crate::overlays::{HelpOverlay, KillConfirm, Toast}; use crate::palette; use crate::pane::Pane; use crate::sidebar::Sidebar; -use crate::state::{AppState, Focus, OverlayKind}; +use crate::state::{AppState, Focus, OverlayKind, SidebarItem}; use crate::tabs::TabBar; use crate::worktrees::{ReclaimConfirm, WorktreesView}; @@ -23,6 +23,7 @@ use crate::worktrees::{ReclaimConfirm, WorktreesView}; #[derive(Debug, Default, Clone)] pub struct RegionMap { pub sidebar: Option, + pub sidebar_items: Vec, pub tab_bar: Option, pub pane: Option, pub modal: Option, @@ -52,6 +53,24 @@ impl RegionMap { } Region::Unknown } + + /// Resolve a mouse row against the rendered sidebar sequence. The title + /// occupies one inner row; groups occupy one row and visible cards two. + pub fn sidebar_item_at(&self, row: u16) -> Option { + let area = self.sidebar?; + let mut cursor = area.y.saturating_add(2); + for item in &self.sidebar_items { + let height = match item { + SidebarItem::Group(_) => 1, + SidebarItem::Session(_) => 2, + }; + if row >= cursor && row < cursor.saturating_add(height) { + return Some(item.clone()); + } + cursor = cursor.saturating_add(height); + } + None + } } fn inside(r: Rect, col: u16, row: u16) -> bool { @@ -63,6 +82,7 @@ fn inside(r: Rect, col: u16, row: u16) -> bool { #[derive(Debug)] pub struct RenderInputs<'a> { pub sidebar: &'a Sidebar, + pub home_dir: &'a str, pub tab_bar: &'a TabBar, pub pane: Option<&'a Pane>, /// Inline spawn form when the active tab's pane is a draft (issue #63). @@ -102,10 +122,17 @@ pub fn draw(f: &mut Frame, inputs: RenderInputs<'_>) -> RegionMap { let right = cols[1]; if inputs.state.sidebar_visible { - inputs - .sidebar - .render_with_focus(f, sidebar_area, inputs.state.focus == Focus::Sidebar); + inputs.sidebar.render_with_focus_and_collapsed_home( + f, + sidebar_area, + inputs.state.focus == Focus::Sidebar, + &inputs.state.collapsed_sidebar_groups, + inputs.home_dir, + ); map.sidebar = Some(sidebar_area); + map.sidebar_items = inputs + .sidebar + .rendered_items_with_collapsed(&inputs.state.collapsed_sidebar_groups); } // The pane always owns its full border, so focus changes only recolour @@ -233,6 +260,7 @@ mod tests { width: 10, height: 20, }), + sidebar_items: Vec::new(), tab_bar: Some(Rect { x: 10, y: 0, @@ -262,6 +290,7 @@ mod tests { width: 10, height: 20, }), + sidebar_items: Vec::new(), tab_bar: Some(Rect { x: 10, y: 0, @@ -286,6 +315,40 @@ mod tests { assert_eq!(map.region_at(3, 12), Region::SidebarRow); // outside modal } + #[test] + fn sidebar_mouse_rows_follow_group_and_two_line_card_heights() { + use std::path::PathBuf; + use uuid::Uuid; + + let group_a = SidebarItem::Group(PathBuf::from("/a")); + let card_a = SidebarItem::Session(Uuid::from_u128(1)); + let group_b = SidebarItem::Group(PathBuf::from("/b")); + let card_b = SidebarItem::Session(Uuid::from_u128(2)); + let map = RegionMap { + sidebar: Some(Rect::new(0, 0, 28, 10)), + sidebar_items: vec![group_a.clone(), card_a.clone(), group_b.clone(), card_b], + ..RegionMap::default() + }; + assert_eq!(map.sidebar_item_at(2), Some(group_a)); + assert_eq!(map.sidebar_item_at(3), Some(card_a.clone())); + assert_eq!(map.sidebar_item_at(4), Some(card_a)); + assert_eq!(map.sidebar_item_at(5), Some(group_b)); + + let collapsed = RegionMap { + sidebar: Some(Rect::new(0, 0, 28, 10)), + sidebar_items: vec![ + SidebarItem::Group(PathBuf::from("/a")), + SidebarItem::Group(PathBuf::from("/b")), + SidebarItem::Session(Uuid::from_u128(2)), + ], + ..RegionMap::default() + }; + assert_eq!( + collapsed.sidebar_item_at(3), + Some(SidebarItem::Group(PathBuf::from("/b"))) + ); + } + /// Issue #63: an empty pane is a draft — the frame must show the spawn /// form where the terminal would otherwise be. #[test] @@ -312,6 +375,7 @@ mod tests { let help = HelpOverlay { lines: Vec::new() }; let inputs = RenderInputs { sidebar: &sidebar, + home_dir: "", tab_bar: &tab_bar, pane: None, draft: Some(draft), @@ -368,6 +432,7 @@ mod tests { state.focus = focus; let inputs = RenderInputs { sidebar: &sidebar, + home_dir: "", tab_bar: &tab_bar, pane: None, draft: None, @@ -411,6 +476,7 @@ mod tests { state.focus = focus; let inputs = RenderInputs { sidebar: &sidebar, + home_dir: "", tab_bar: &tab_bar, pane: None, draft: None, diff --git a/crates/maestro-tui/src/sidebar.rs b/crates/maestro-tui/src/sidebar.rs index 84ddbb9..77eb7a3 100644 --- a/crates/maestro-tui/src/sidebar.rs +++ b/crates/maestro-tui/src/sidebar.rs @@ -4,9 +4,11 @@ //! Line 2: activity dot + duration in the state's colour, plus the //! adaptive detail, blocked-work, exited, and command fragments. -use std::collections::HashSet; +use std::collections::{BTreeMap, HashSet}; +use std::path::PathBuf; use chrono::Utc; +use maestro_protocol::display::{abbreviate_dir, abbreviate_folder_name, session_tree_path}; use maestro_protocol::{ActivityState, Session, SessionStatus}; use ratatui::layout::Rect; use ratatui::style::{Color, Modifier, Style}; @@ -16,6 +18,7 @@ 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). @@ -44,10 +47,17 @@ fn card_base(highlighted: bool, focused: bool) -> Style { #[derive(Debug, Clone)] pub struct Sidebar { pub sessions: Vec, - pub highlight: Option, + pub highlight: Option, pub attached: HashSet, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SidebarGroup { + pub key: PathBuf, + pub sessions: Vec, + pub collapsed: bool, +} + impl Sidebar { pub fn render(&self, f: &mut Frame, area: Rect) { self.render_with_focus(f, area, false); @@ -57,6 +67,27 @@ impl Sidebar { /// and whether the highlighted card claims the selection surface, never /// its geometry. 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, + ) { + 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, + home: &str, + ) { let border_style = palette::active().border_style(focused); let block = Block::default() .borders(Borders::ALL) @@ -69,23 +100,110 @@ impl Sidebar { "sessions", Style::default().add_modifier(Modifier::BOLD), ))]; - for s in &self.sessions { - let highlighted = self.highlight == Some(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))); + 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), inner); } + /// Items in exactly the order and height they occupy on screen. + pub fn rendered_items(&self) -> Vec { + self.rendered_items_with_collapsed(&HashSet::new()) + } + + pub fn rendered_items_with_collapsed(&self, collapsed: &HashSet) -> Vec { + 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) -> Vec { + let mut grouped: BTreeMap> = 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> { + let highlighted = self.highlight == Some(SidebarItem::Group(group.key.clone())); + let palette = palette::active(); + let surface = palette.surface(highlighted && focused); + let base = Style::default().bg(surface.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 mut spans = vec![Span::styled( + prefix, + if edge { + base.fg(palette.focus_accent) + } else { + base + }, + )]; + spans.push(Span::styled(chevron, base)); + if let Some(stem) = name.stem { + spans.push(Span::styled(format!("{stem}/"), surface.dim_style())); + } + spans.push(Span::styled(name.last, base)); + spans.push(Span::styled(count, surface.muted_style())); + pad_to_width(fit_to_width(spans, width), width, base) + } + fn name_row(&self, s: &Session, width: usize, focused: bool) -> Vec> { let glyph = match (s.status, self.attached.contains(&s.id)) { (SessionStatus::Dead, _) => "✗", (SessionStatus::Running, true) => "●", (SessionStatus::Running, false) => "○", }; - let highlighted = self.highlight == Some(s.id); + let highlighted = self.highlight == Some(SidebarItem::Session(s.id)); let base = card_base(highlighted, focused); // Line-1 actions are keyboard affordances shown on the highlighted // row only; they reserve their width so the name truncates with `…` @@ -138,7 +256,7 @@ impl Sidebar { // 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(s.id); + let highlighted = self.highlight == Some(SidebarItem::Session(s.id)); let base = card_base(highlighted, focused); let dot_style = base.patch(Style::default().fg(activity_color(s.activity, highlighted, focused))); @@ -269,6 +387,10 @@ fn name_for(s: &Session) -> String { } } +fn home_dir() -> String { + std::env::var("HOME").unwrap_or_default() +} + #[cfg(test)] mod tests { use super::*; @@ -314,6 +436,15 @@ mod tests { terminal.backend().buffer().clone() } + fn draw_collapsed(sidebar: &Sidebar, w: u16, h: u16, collapsed: &HashSet) -> 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 { (0..buf.area.height) .map(|y| { @@ -360,6 +491,82 @@ mod tests { ); } + #[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 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[2].contains("/tmp")); + assert!(all[3].contains("claude")); + assert!(all[4].contains("bash")); + assert!(!all[5].contains("bash")); + } + #[test] fn attached_session_uses_filled_glyph() { let sid = Uuid::from_u128(1); @@ -450,17 +657,17 @@ mod tests { assert_eq!(content(&all[1]), "sessions"); assert!(row_containing(&all, "claude").contains('○')); assert!( - all[3].contains('●'), + all[4].contains('●'), "line 2 must carry the dot: {:?}", all[3] ); assert!( - all[3].contains("bash"), + all[4].contains("bash"), "line 2 must carry the command: {:?}", all[3] ); assert!( - !all[3].contains("10s"), + !all[4].contains("10s"), "fresh cards have no duration: {:?}", all[3] ); @@ -541,7 +748,7 @@ mod tests { s.activity_since = Utc::now() - Duration::seconds(120); let sb = Sidebar { sessions: vec![s], - highlight: Some(Uuid::from_u128(1)), + highlight: Some(SidebarItem::Session(Uuid::from_u128(1))), attached: Default::default(), }; let buf = draw(&sb, 28, 8); @@ -782,12 +989,12 @@ mod tests { }; let all = rows(&draw(&sb, 40, 6)); assert!( - all[2].contains("friendly name"), + all[3].contains("friendly name"), "name must stay on line 1: {:?}", all[2] ); assert!( - all[3].contains("bash"), + all[4].contains("bash"), "command must be on line 2: {:?}", all[3] ); @@ -803,12 +1010,12 @@ mod tests { }; let all = rows(&draw(&sb, 10, 6)); assert!( - all[3].contains('●'), + all[4].contains('●'), "dot must survive narrow cards: {:?}", all[3] ); assert!( - all[3].contains('…'), + all[4].contains('…'), "command should truncate: {:?}", all[3] ); @@ -828,8 +1035,8 @@ mod tests { attached: Default::default(), }; let all = rows(&draw(&sb, 29, 6)); - assert!(all[3].contains("⚠ 1 uncommitted file"), "{:?}", all[3]); - let meta = content(&all[3]); + assert!(all[4].contains("⚠ 1 uncommitted file"), "{:?}", all[4]); + let meta = content(&all[4]); assert!(meta.ends_with('…'), "command must truncate: {meta:?}"); } @@ -847,13 +1054,13 @@ mod tests { attached: Default::default(), }; let all = rows(&draw(&sb, 40, 6)); - assert!(all[3].contains("⚠ 1 uncommitted file"), "{:?}", all[3]); + assert!(all[4].contains("⚠ 1 uncommitted file"), "{:?}", all[4]); assert!( - all[3].contains("bash"), + all[4].contains("bash"), "command must survive: {:?}", all[3] ); - assert!(all[3].contains('…'), "detail must truncate: {:?}", all[3]); + assert!(all[4].contains('…'), "detail must truncate: {:?}", all[4]); } /// Reversed video makes a card unreadable against a background; cards @@ -864,7 +1071,7 @@ mod tests { highlighted.activity_since = Utc::now() - Duration::seconds(120); let sb = Sidebar { sessions: vec![highlighted], - highlight: Some(Uuid::from_u128(1)), + highlight: Some(SidebarItem::Session(Uuid::from_u128(1))), attached: Default::default(), }; let buf = draw(&sb, 28, 8); @@ -889,7 +1096,7 @@ mod tests { let two = session_named(2, "bash"); let sb = Sidebar { sessions: vec![one, two], - highlight: Some(Uuid::from_u128(1)), + highlight: Some(SidebarItem::Session(Uuid::from_u128(1))), attached: Default::default(), }; let buf = draw(&sb, 28, 8); @@ -924,7 +1131,7 @@ mod tests { fn highlighted_card_is_hollow_when_sidebar_is_unfocused() { let sb = Sidebar { sessions: vec![session_named(1, "claude")], - highlight: Some(Uuid::from_u128(1)), + highlight: Some(SidebarItem::Session(Uuid::from_u128(1))), attached: Default::default(), }; let buf = draw_with_focus(&sb, 28, 8, false); @@ -982,7 +1189,7 @@ mod tests { fn highlighted_row_anchors_the_actions_top_right() { let sb = Sidebar { sessions: vec![session_named(1, "claude")], - highlight: Some(Uuid::from_u128(1)), + highlight: Some(SidebarItem::Session(Uuid::from_u128(1))), attached: Default::default(), }; let all = rows(&draw(&sb, 28, 8)); @@ -999,7 +1206,7 @@ mod tests { let two = session_named(2, "bash"); let sb = Sidebar { sessions: vec![one, two], - highlight: Some(Uuid::from_u128(1)), + highlight: Some(SidebarItem::Session(Uuid::from_u128(1))), attached: Default::default(), }; let all = rows(&draw(&sb, 28, 8)); @@ -1020,7 +1227,7 @@ mod tests { } let sb = Sidebar { sessions: vec![s], - highlight: Some(Uuid::from_u128(1)), + highlight: Some(SidebarItem::Session(Uuid::from_u128(1))), attached: Default::default(), }; let all = rows(&draw(&sb, 28, 8)); diff --git a/crates/maestro-tui/src/state.rs b/crates/maestro-tui/src/state.rs index 4e487c8..85e4d13 100644 --- a/crates/maestro-tui/src/state.rs +++ b/crates/maestro-tui/src/state.rs @@ -8,6 +8,9 @@ //! //! v1.5 will replace `Tab::pane`'s single slot with a split tree. +use std::collections::HashSet; +use std::path::PathBuf; + use uuid::Uuid; use crate::draft::DraftPane; @@ -19,6 +22,16 @@ pub enum Focus { Pane, } +/// One selectable item in the rendered sidebar sequence. +/// +/// Group paths, unlike a session's position in the catalog, survive catalog +/// redraws and make a highlighted collapsed group unambiguous. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum SidebarItem { + Group(PathBuf), + Session(Uuid), +} + /// What a tab's pane slot holds: a live session, or an inline draft form. // The draft variant dwarfs `Session(Uuid)`; tabs are moved rarely enough // that boxing every access costs more than the copy does. @@ -70,7 +83,10 @@ pub struct AppState { pub focus: Focus, pub mode: Mode, pub sidebar_visible: bool, - pub sidebar_highlight: Option, + pub sidebar_highlight: Option, + /// Group paths whose cards are hidden. This is UI state rather than + /// catalog state, so it survives every catalog redraw. + pub collapsed_sidebar_groups: HashSet, /// Private: every transition goes through [`AppState::show_overlay`], /// [`AppState::dismiss_overlay`] or [`AppState::toast`]. Poking the field /// from four call sites is what left a toast on screen with no way to @@ -96,6 +112,7 @@ impl AppState { mode: Mode::Normal, sidebar_visible: true, sidebar_highlight: None, + collapsed_sidebar_groups: HashSet::new(), overlay: None, } } @@ -213,19 +230,32 @@ impl AppState { } /// Move the sidebar highlight by `delta` (positive = down) within the - /// given ordered list of selectable session ids. Wraps. No-op on empty. - pub fn move_sidebar_highlight(&mut self, order: &[Uuid], delta: i32) { + /// rendered item sequence. Wraps. No-op on empty. + pub fn move_sidebar_highlight(&mut self, order: &[SidebarItem], delta: i32) { if order.is_empty() { return; } let len = order.len() as i32; let cur = self .sidebar_highlight - .and_then(|id| order.iter().position(|x| *x == id)) + .as_ref() + .and_then(|id| order.iter().position(|x| x == id)) .map(|i| i as i32) .unwrap_or(-1); let next = (cur + delta).rem_euclid(len) as usize; - self.sidebar_highlight = Some(order[next]); + self.sidebar_highlight = Some(order[next].clone()); + } + + /// Toggle a group. When collapsing a group containing the current card, + /// leave the highlight on the group row that remains visible. + pub fn toggle_sidebar_group(&mut self, key: PathBuf, contains_highlight: bool) { + if self.collapsed_sidebar_groups.remove(&key) { + return; + } + self.collapsed_sidebar_groups.insert(key.clone()); + if contains_highlight { + self.sidebar_highlight = Some(SidebarItem::Group(key)); + } } /// Toggle sidebar visibility. @@ -318,22 +348,30 @@ mod tests { let a = Uuid::from_u128(1); let b = Uuid::from_u128(2); let c = Uuid::from_u128(3); - let order = vec![a, b, c]; + let order = vec![ + SidebarItem::Session(a), + SidebarItem::Group("repo".into()), + SidebarItem::Session(b), + SidebarItem::Session(c), + ]; // No prior highlight + delta +1 lands on first item. state.move_sidebar_highlight(&order, 1); - assert_eq!(state.sidebar_highlight, Some(a)); + assert_eq!(state.sidebar_highlight, Some(SidebarItem::Session(a))); state.move_sidebar_highlight(&order, 1); - assert_eq!(state.sidebar_highlight, Some(b)); + assert_eq!( + state.sidebar_highlight, + Some(SidebarItem::Group("repo".into())) + ); state.move_sidebar_highlight(&order, 1); - assert_eq!(state.sidebar_highlight, Some(c)); + assert_eq!(state.sidebar_highlight, Some(SidebarItem::Session(b))); // Wraps forward. state.move_sidebar_highlight(&order, 1); - assert_eq!(state.sidebar_highlight, Some(a)); + assert_eq!(state.sidebar_highlight, Some(SidebarItem::Session(c))); // Wraps backward. state.move_sidebar_highlight(&order, -1); - assert_eq!(state.sidebar_highlight, Some(c)); + assert_eq!(state.sidebar_highlight, Some(SidebarItem::Session(b))); } #[test] @@ -343,6 +381,22 @@ mod tests { assert_eq!(state.sidebar_highlight, None); } + #[test] + fn collapsing_highlighted_group_moves_a_card_highlight_to_the_group() { + let key = PathBuf::from("/repo"); + let mut state = AppState::new(); + state.sidebar_highlight = Some(SidebarItem::Session(Uuid::from_u128(9))); + state.toggle_sidebar_group(key.clone(), true); + assert_eq!( + state.sidebar_highlight, + Some(SidebarItem::Group(key.clone())) + ); + assert!(state.collapsed_sidebar_groups.contains(&key)); + state.toggle_sidebar_group(key.clone(), false); + assert!(!state.collapsed_sidebar_groups.contains(&key)); + assert_eq!(state.sidebar_highlight, Some(SidebarItem::Group(key))); + } + #[test] fn attach_binds_focuses_pane_and_enters_insert_mode() { let mut state = AppState::new();