Compare commits

..
Author SHA1 Message Date
Miguel Palhas 2ab07c2343 fix(maestro): harden grouped sidebar interactions
ci / web (push) Successful in 1m29s
ci / rust (push) Successful in 5m25s
ci / flake (push) Successful in 7m56s
2026-08-25 13:59:38 +01:00
Miguel Palhas 342e732ef0 merge: TUI shell/124 — collapsible project groups 2026-08-25 13:54:09 +01:00
Miguel Palhas 3205270b51 feat(maestro): group TUI sidebar by project 2026-08-25 13:53:51 +01:00
Miguel Palhas d8e31f2760 fix(maestro): resize PTY to bordered pane content 2026-08-25 13:37:30 +01:00
Miguel Palhas fdf9ea1b31 merge: TUI shell/123 — unmistakable panel focus 2026-08-25 13:31:24 +01:00
Miguel Palhas 22cbfbc9bc merge: TUI shell/122 — configurable theme presets 2026-08-25 13:31:24 +01:00
Miguel Palhas 839dd27925 feat(maestro): make TUI panel focus unmistakable 2026-08-25 13:26:54 +01:00
6 changed files with 822 additions and 108 deletions
+2
View File
@@ -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),
+211 -8
View File
@@ -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<CrosstermBackend<Stdout>>,
) -> 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)?;
@@ -197,6 +204,10 @@ impl App {
return Ok(false);
}
if self.handle_sidebar_mouse(&key) {
return Ok(false);
}
let known = self.cfg.keymap.known_bindings(self.state.mode);
match self.chord.feed(&key, &known) {
ChordResult::Pending => Ok(false),
@@ -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;
};
self.state.focus = Focus::Sidebar;
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));
}
}
true
}
/// Carry out what `dispatch` decided. The only place in `App` that talks to
/// the daemon.
async fn perform(&mut self, effects: Vec<Effect>) {
@@ -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<Uuid> {
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<Effect> {
@@ -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<Uuid> {
snap.sessions.iter().map(|s| s.id).collect()
fn visible_sidebar_order(
snap: &crate::catalog::CatalogSnapshot,
collapsed: &std::collections::HashSet<PathBuf>,
) -> Vec<SidebarItem> {
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,94 @@ 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("/")));
assert_eq!(app.state.focus, Focus::Sidebar);
}
#[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("/")));
assert_eq!(app.state.focus, Focus::Sidebar);
}
#[tokio::test]
async fn sidebar_click_dismisses_a_toast_before_handling_the_click() {
use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
let sid = Uuid::from_u128(17);
let mut app = app_with_highlighted_session(sid);
app.state.toast("notice");
app.region.sidebar = Some(ratatui::layout::Rect::new(0, 0, 28, 10));
app.region.sidebar_items = vec![SidebarItem::Group(PathBuf::from("/"))];
let quit = app
.handle_event(CtEvent::Mouse(MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 3,
row: 2,
modifiers: crossterm::event::KeyModifiers::NONE,
}))
.await
.unwrap();
assert!(!quit);
assert!(app.state.overlay().is_none());
assert!(app
.state
.collapsed_sidebar_groups
.contains(&PathBuf::from("/")));
}
#[test]
fn confirming_a_kill_asks_for_the_kill() {
let sid = Uuid::from_u128(12);
+1
View File
@@ -45,6 +45,7 @@ const DEFAULT_NORMAL: &[(&str, &str)] = &[
("k", "SidebarPrev"),
("down", "SidebarNext"),
("up", "SidebarPrev"),
("c", "ToggleSidebarGroup"),
// Catalog actions
("enter", "AttachHighlighted"),
("s", "NewDraftPane"),
+177 -23
View File
@@ -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<Rect>,
pub sidebar_items: Vec<SidebarItem>,
pub tab_bar: Option<Rect>,
pub pane: Option<Rect>,
pub modal: Option<Rect>,
@@ -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<SidebarItem> {
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,44 +122,46 @@ 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);
}
// 1-row tab bar + 1-row horizontal rule (doubles as the pane's focus
// indicator) + flex pane area below. Saves the 4 rows the old
// `Length(3) + Borders::ALL` layout was eating before any content showed.
// The pane always owns its full border, so focus changes only recolour
// chrome and never change the rectangles handed to the input router.
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1),
Constraint::Length(1),
Constraint::Min(0),
])
.constraints([Constraint::Length(1), Constraint::Min(0)])
.split(right);
let tab_area = rows[0];
let separator_area = rows[1];
let pane_area = rows[2];
let pane_area = rows[1];
inputs.tab_bar.render(f, tab_area);
map.tab_bar = Some(tab_area);
let pane_focused = inputs.state.focus == Focus::Pane;
f.render_widget(
Block::default()
.borders(Borders::TOP)
.border_style(focus_style(pane_focused)),
separator_area,
);
let pane_block = Block::default()
.borders(Borders::ALL)
.border_style(focus_style(pane_focused));
let pane_inner = pane_block.inner(pane_area);
f.render_widget(pane_block, pane_area);
if let Some(p) = inputs.pane {
p.render(f, pane_area);
p.render(f, pane_inner);
}
if let Some(d) = inputs.draft {
d.render(f, pane_area, pane_focused);
d.render(f, pane_inner, pane_focused);
}
map.pane = Some(pane_area);
// `RegionMap::pane` is also the PTY resize source. Keep it aligned with
// the cells the emulator actually owns, excluding the persistent border.
map.pane = Some(pane_inner);
// Status bar — always visible, never eclipsed by overlays so the user can
// always see the current mode + key hints.
@@ -238,6 +260,7 @@ mod tests {
width: 10,
height: 20,
}),
sidebar_items: Vec::new(),
tab_bar: Some(Rect {
x: 10,
y: 0,
@@ -267,6 +290,7 @@ mod tests {
width: 10,
height: 20,
}),
sidebar_items: Vec::new(),
tab_bar: Some(Rect {
x: 10,
y: 0,
@@ -291,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]
@@ -317,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),
@@ -351,4 +410,99 @@ mod tests {
assert!(out.contains(s), "missing {s:?}:\n{out}");
}
}
#[test]
fn panel_rectangles_are_stable_when_focus_moves() {
use crate::overlays::HelpOverlay;
use crate::sidebar::Sidebar;
use std::collections::HashSet;
let sidebar = Sidebar {
sessions: Vec::new(),
highlight: None,
attached: HashSet::new(),
};
let tab_bar = TabBar {
tab_names: vec!["1".into()],
active_idx: 0,
};
let help = HelpOverlay { lines: Vec::new() };
let draw_map = |focus| {
let mut state = AppState::new();
state.focus = focus;
let inputs = RenderInputs {
sidebar: &sidebar,
home_dir: "",
tab_bar: &tab_bar,
pane: None,
draft: None,
state: &state,
help: &help,
worktrees: None,
};
let backend = ratatui::backend::TestBackend::new(80, 24);
let mut terminal = ratatui::Terminal::new(backend).unwrap();
let mut map = None;
terminal.draw(|f| map = Some(draw(f, inputs))).unwrap();
map.unwrap()
};
let sidebar_focus = draw_map(Focus::Sidebar);
let pane_focus = draw_map(Focus::Pane);
assert_eq!(sidebar_focus.sidebar, pane_focus.sidebar);
assert_eq!(sidebar_focus.tab_bar, pane_focus.tab_bar);
assert_eq!(sidebar_focus.pane, pane_focus.pane);
assert_eq!(sidebar_focus.pane, Some(Rect::new(29, 2, 50, 20)));
}
#[test]
fn panel_border_colours_follow_focus_without_restyling_the_pane() {
use crate::overlays::HelpOverlay;
use crate::sidebar::Sidebar;
use std::collections::HashSet;
let sidebar = Sidebar {
sessions: Vec::new(),
highlight: None,
attached: HashSet::new(),
};
let tab_bar = TabBar {
tab_names: vec!["1".into()],
active_idx: 0,
};
let help = HelpOverlay { lines: Vec::new() };
let render = |focus| {
let mut state = AppState::new();
state.focus = focus;
let inputs = RenderInputs {
sidebar: &sidebar,
home_dir: "",
tab_bar: &tab_bar,
pane: None,
draft: None,
state: &state,
help: &help,
worktrees: None,
};
let backend = ratatui::backend::TestBackend::new(80, 24);
let mut terminal = ratatui::Terminal::new(backend).unwrap();
terminal
.draw(|f| {
draw(f, inputs);
})
.unwrap();
terminal.backend().buffer().clone()
};
let sidebar_focused = render(Focus::Sidebar);
let pane_focused = render(Focus::Pane);
let p = palette::active();
assert_eq!(sidebar_focused[(0, 0)].style().fg, Some(p.border_focused));
assert_eq!(pane_focused[(0, 0)].style().fg, Some(p.border_unfocused));
assert_eq!(
sidebar_focused[(28, 1)].style().fg,
Some(p.border_unfocused)
);
assert_eq!(pane_focused[(28, 1)].style().fg, Some(p.border_focused));
}
}
+366 -66
View File
@@ -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).
@@ -31,35 +34,63 @@ const META_INDENT: &str = " ";
/// Spawn-similar and kill, shown on the highlighted card's top-right corner.
const ACTIONS: &str = "⧉ ✕";
/// Every card sits on the palette's card surface so cards read as cards
/// against the panel; the highlighted card swaps in the selection surface
/// instead (`Modifier::REVERSED` made the card unreadable against a
/// background).
fn card_base(highlighted: bool) -> Style {
palette::active().surface(highlighted).text_style()
/// 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 {
palette::active()
.surface(highlighted && focused)
.text_style()
}
/// Renderable sidebar state.
#[derive(Debug, Clone)]
pub struct Sidebar {
pub sessions: Vec<Session>,
pub highlight: Option<Uuid>,
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);
}
/// Same as [`Sidebar::render`] but tints the right-edge separator in the
/// focus colour when `focused`. Box borders eat too many rows/cols for
/// the v1 layout — a single vertical rule keeps the panel boundary
/// without stealing space.
/// Render the panel with a persistent border. Focus changes its colour
/// 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<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 border_style = palette::active().border_style(focused);
let block = Block::default()
.borders(Borders::RIGHT)
.borders(Borders::ALL)
.border_style(border_style);
let inner = block.inner(area);
f.render_widget(block, area);
@@ -69,24 +100,117 @@ 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);
lines.push(Line::from(self.name_row(s, width)));
let meta = self.meta_row(s, width);
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);
}
fn name_row(&self, s: &Session, width: usize) -> Vec<Span<'static>> {
/// 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 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 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());
let label_budget = width.saturating_sub(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()));
}
label.push(Span::styled(name.last, base));
let mut spans = vec![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(s.id);
let base = card_base(highlighted);
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 `…`
// before it can ever reach them (docs/SIDEBAR.md).
@@ -102,8 +226,16 @@ impl Sidebar {
let sep = if highlighted { 1 } else { 0 };
let name_budget = usable.saturating_sub(prefix + sep);
let edge = highlighted && !focused;
let mut spans: Vec<Span<'static>> = vec![
Span::styled(INDENT, base),
Span::styled(
if edge { "▏ " } else { INDENT },
if edge {
base.fg(palette::active().focus_accent)
} else {
base
},
),
Span::styled(format!("{glyph} "), base),
];
spans.extend(
@@ -122,7 +254,7 @@ impl Sidebar {
}
/// The adaptive second line: `<dot> [<duration>] [<detail>] [<blocked work> | exited] <command>`.
fn meta_row(&self, s: &Session, width: usize) -> Vec<Span<'static>> {
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);
@@ -130,12 +262,23 @@ 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 base = card_base(highlighted);
let dot_style = base.patch(Style::default().fg(activity_color(s.activity, highlighted)));
let muted = palette::active().surface(highlighted).muted_style();
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)));
let muted = palette::active()
.surface(highlighted && focused)
.muted_style();
let edge = highlighted && !focused;
let mut spans = vec![
Span::styled(META_INDENT, base),
Span::styled(
if edge { " ▏ " } else { META_INDENT },
if edge {
base.fg(palette::active().focus_accent)
} else {
base
},
),
Span::styled("●", dot_style),
];
if let Some(d) = held.filter(|_| held_secs >= META_LINE_AFTER_SECS) {
@@ -179,8 +322,10 @@ impl Sidebar {
/// 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) -> Color {
palette::active().surface(highlighted).activity(state)
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,
@@ -248,6 +393,10 @@ fn name_for(s: &Session) -> String {
}
}
fn home_dir() -> String {
std::env::var("HOME").unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
@@ -281,9 +430,24 @@ mod tests {
}
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(f, f.area())).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()
}
@@ -309,7 +473,10 @@ mod tests {
/// Row text without the trailing space padding and right border.
fn content(row: &str) -> &str {
row.trim_end().trim_end_matches('│').trim_end()
row.trim()
.trim_start_matches('│')
.trim_end_matches('│')
.trim()
}
#[test]
@@ -330,6 +497,102 @@ 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 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[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);
@@ -417,22 +680,22 @@ mod tests {
attached: Default::default(),
};
let all = rows(&draw(&sb, 28, 8));
assert_eq!(content(&all[0]), "sessions");
assert_eq!(content(&all[1]), "sessions");
assert!(row_containing(&all, "claude").contains('○'));
assert!(
all[2].contains('●'),
all[4].contains('●'),
"line 2 must carry the dot: {:?}",
all[2]
all[3]
);
assert!(
all[2].contains("bash"),
all[4].contains("bash"),
"line 2 must carry the command: {:?}",
all[2]
all[3]
);
assert!(
!all[2].contains("10s"),
!all[4].contains("10s"),
"fresh cards have no duration: {:?}",
all[2]
all[3]
);
}
@@ -511,7 +774,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);
@@ -660,7 +923,7 @@ mod tests {
highlight: None,
attached: Default::default(),
};
let all = rows(&draw(&sb, 40, 8));
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:?}");
@@ -752,14 +1015,14 @@ mod tests {
};
let all = rows(&draw(&sb, 40, 6));
assert!(
all[1].contains("friendly name"),
all[3].contains("friendly name"),
"name must stay on line 1: {:?}",
all[1]
all[2]
);
assert!(
all[2].contains("bash"),
all[4].contains("bash"),
"command must be on line 2: {:?}",
all[2]
all[3]
);
}
@@ -773,14 +1036,14 @@ mod tests {
};
let all = rows(&draw(&sb, 10, 6));
assert!(
all[2].contains('●'),
all[4].contains('●'),
"dot must survive narrow cards: {:?}",
all[2]
all[3]
);
assert!(
all[2].contains('…'),
all[4].contains('…'),
"command should truncate: {:?}",
all[2]
all[3]
);
}
@@ -798,8 +1061,8 @@ mod tests {
attached: Default::default(),
};
let all = rows(&draw(&sb, 29, 6));
assert!(all[2].contains("⚠ 1 uncommitted file"), "{:?}", all[2]);
let meta = content(&all[2]);
assert!(all[4].contains("⚠ 1 uncommitted file"), "{:?}", all[4]);
let meta = content(&all[4]);
assert!(meta.ends_with('…'), "command must truncate: {meta:?}");
}
@@ -817,13 +1080,13 @@ mod tests {
attached: Default::default(),
};
let all = rows(&draw(&sb, 40, 6));
assert!(all[2].contains("⚠ 1 uncommitted file"), "{:?}", all[2]);
assert!(all[4].contains("⚠ 1 uncommitted file"), "{:?}", all[4]);
assert!(
all[2].contains("bash"),
all[4].contains("bash"),
"command must survive: {:?}",
all[2]
all[3]
);
assert!(all[2].contains('…'), "detail must truncate: {:?}", all[2]);
assert!(all[4].contains('…'), "detail must truncate: {:?}", all[4]);
}
/// Reversed video makes a card unreadable against a background; cards
@@ -834,7 +1097,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);
@@ -859,7 +1122,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);
@@ -869,27 +1132,64 @@ mod tests {
let p = palette::active();
assert_eq!(
buf[(0, claude_y as u16)].style().bg,
buf[(1, claude_y as u16)].style().bg,
Some(p.selection.bg),
"focused card must sit on the selection surface"
);
assert_eq!(
buf[(0, claude_y as u16)].style().fg,
buf[(1, claude_y as u16)].style().fg,
Some(p.selection.fg),
"focused card text must use the selection foreground role"
);
assert_eq!(
buf[(0, bash_y as u16)].style().bg,
buf[(1, bash_y as u16)].style().bg,
Some(p.card.bg),
"unfocused cards must still sit on the card surface"
);
// The panel itself (header row) stays on the terminal default.
assert!(
matches!(buf[(0, 0)].style().bg, None | Some(Color::Reset)),
matches!(buf[(1, 1)].style().bg, None | Some(Color::Reset)),
"panel must keep the terminal default background"
);
}
#[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() {
@@ -901,7 +1201,7 @@ mod tests {
};
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 0..27 {
for x in 1..27 {
assert_eq!(
buf[(x, y)].style().bg,
Some(palette::active().card.bg),
@@ -915,7 +1215,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));
@@ -932,7 +1232,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));
@@ -953,7 +1253,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));
+65 -11
View File
@@ -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<Uuid>,
pub sidebar_highlight: Option<SidebarItem>,
/// 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<PathBuf>,
/// 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();