diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-07-19 20:44:43 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-07-19 20:44:44 +0300 |
| commit | a26a654351ee03b1a51d7b398f3bdff2c1ca920d (patch) | |
| tree | 05382168990e4a6f50f6dbb5220c52fad062923d | |
| parent | 01d371bf7a10330c6a5c139c7df766ae8f6f1cda (diff) | |
feat: connected tree glyphs, workspace grouping, and search auto-select in navigator
refs #1611
| -rw-r--r-- | src/app/actions.rs | 204 | ||||
| -rw-r--r-- | src/app/input/modal.rs | 16 | ||||
| -rw-r--r-- | src/app/input/overlays.rs | 16 | ||||
| -rw-r--r-- | src/app/state.rs | 117 | ||||
| -rw-r--r-- | src/ui/navigator.rs | 217 |
5 files changed, 504 insertions, 66 deletions
diff --git a/src/app/actions.rs b/src/app/actions.rs index b4f352b4..b21de729 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -15,6 +15,7 @@ use crate::terminal::{EffectiveStateChange, TerminalStateMutation}; use crate::workspace::WorkspaceGitStatus; use super::state::{ + navigator_display_index_of_row, navigator_display_lines, navigator_first_row_at_or_after, text_matches_query, AgentNotificationDelivery, AppState, Mode, NavigatorRow, NavigatorStateFilter, NavigatorTarget, PaneFocusTarget, PendingAgentNotification, ToastKind, ToastNotification, ToastTarget, ViewLayout, @@ -393,7 +394,8 @@ impl AppState { NavigatorQueryKind::Text => navigator_matches(&query, &workspace_search_text), }; - let child_rows = self.navigator_child_rows(ws_idx, query_kind, &query); + let child_rows = + self.navigator_child_rows(ws_idx, query_kind, &query, workspace_matches); if !workspace_matches && child_rows.is_empty() { continue; } @@ -414,6 +416,7 @@ impl AppState { is_tab: false, expanded, search_text: workspace_search_text, + matched: workspace_matches, }); if expanded { rows.extend(child_rows); @@ -427,6 +430,7 @@ impl AppState { ws_idx: usize, query_kind: NavigatorQueryKind, query: &str, + workspace_matches: bool, ) -> Vec<NavigatorRow> { let Some(ws) = self.workspaces.get(ws_idx) else { return Vec::new(); @@ -434,7 +438,7 @@ impl AppState { let multi_tab = ws.tabs.len() > 1; let mut rows = Vec::new(); for tab_idx in 0..ws.tabs.len() { - let tab_row = multi_tab.then(|| self.navigator_tab_row(ws_idx, tab_idx)); + let mut tab_row = multi_tab.then(|| self.navigator_tab_row(ws_idx, tab_idx)); let tab_matches = tab_row.as_ref().is_some_and(|row| match query_kind { NavigatorQueryKind::Empty => true, NavigatorQueryKind::State(filter) => { @@ -442,14 +446,24 @@ impl AppState { } NavigatorQueryKind::Text => navigator_matches(query, &row.search_text), }); - let pane_rows = self.navigator_pane_rows_for_tab(ws_idx, tab_idx, multi_tab); + if let Some(tab_row) = tab_row.as_mut() { + tab_row.matched = tab_matches; + } + let mut pane_rows = self.navigator_pane_rows_for_tab(ws_idx, tab_idx, multi_tab); let filtered_panes = match query_kind { NavigatorQueryKind::Empty => pane_rows, NavigatorQueryKind::State(filter) => pane_rows .into_iter() .filter(|row| navigator_state_filter_matches(filter, row.status, row.seen)) .collect::<Vec<_>>(), - NavigatorQueryKind::Text if tab_matches => pane_rows, + // A matching workspace or tab shows its whole subtree; panes + // keep their own match flag so context rows can be dimmed. + NavigatorQueryKind::Text if workspace_matches || tab_matches => { + for row in pane_rows.iter_mut() { + row.matched = navigator_matches(query, &row.search_text); + } + pane_rows + } NavigatorQueryKind::Text => pane_rows .into_iter() .filter(|row| navigator_matches(query, &row.search_text)) @@ -493,6 +507,7 @@ impl AppState { is_tab: true, expanded: true, search_text, + matched: true, } } @@ -569,6 +584,7 @@ impl AppState { is_tab: false, expanded: false, search_text, + matched: true, }); } rows @@ -594,15 +610,14 @@ impl AppState { self.navigator.scroll = 0; return; } - let max_scroll = self.navigator_max_scroll_from(terminal_runtimes, viewport); - if self.navigator.selected < self.navigator.scroll { - self.navigator.scroll = self.navigator.selected; - } else if self.navigator.selected >= self.navigator.scroll.saturating_add(viewport) { - self.navigator.scroll = self - .navigator - .selected - .saturating_add(1) - .saturating_sub(viewport); + let lines = navigator_display_lines(&self.navigator_rows_from(terminal_runtimes)); + let max_scroll = lines.len().saturating_sub(viewport); + let selected_line = + navigator_display_index_of_row(&lines, self.navigator.selected).unwrap_or(0); + if selected_line < self.navigator.scroll { + self.navigator.scroll = selected_line; + } else if selected_line >= self.navigator.scroll.saturating_add(viewport) { + self.navigator.scroll = selected_line.saturating_add(1).saturating_sub(viewport); } self.navigator.scroll = self.navigator.scroll.min(max_scroll); } @@ -615,11 +630,24 @@ impl AppState { if viewport == 0 { return 0; } - self.navigator_rows_from(terminal_runtimes) + navigator_display_lines(&self.navigator_rows_from(terminal_runtimes)) .len() .saturating_sub(viewport) } + /// After a mouse-wheel scroll, snap the selection to the first selectable + /// row at or below the top of the viewport. + pub(crate) fn align_navigator_selection_to_scroll_from( + &mut self, + terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry, + ) { + let lines = navigator_display_lines(&self.navigator_rows_from(terminal_runtimes)); + if let Some(row_idx) = navigator_first_row_at_or_after(&lines, self.navigator.scroll) { + self.navigator.selected = row_idx; + } + self.clamp_navigator_selection_from(terminal_runtimes); + } + pub(crate) fn move_navigator_selection_from( &mut self, terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry, @@ -636,6 +664,70 @@ impl AppState { self.ensure_navigator_selection_visible_from(terminal_runtimes); } + /// Move the selection by a distance measured in display lines (used for + /// half-page jumps), landing on the nearest selectable row in the move + /// direction. + pub(crate) fn move_navigator_selection_by_lines_from( + &mut self, + terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry, + delta_lines: isize, + ) { + let rows = self.navigator_rows_from(terminal_runtimes); + if rows.is_empty() { + self.navigator.selected = 0; + self.navigator.scroll = 0; + return; + } + let lines = navigator_display_lines(&rows); + let current_line = + navigator_display_index_of_row(&lines, self.navigator.selected.min(rows.len() - 1)) + .unwrap_or(0); + let target_line = + (current_line as isize + delta_lines).clamp(0, lines.len() as isize - 1) as usize; + let row_idx = if delta_lines >= 0 { + navigator_first_row_at_or_after(&lines, target_line) + } else { + lines[..=target_line] + .iter() + .rev() + .find_map(|line| match line { + super::state::NavigatorDisplayLine::Row(idx) => Some(*idx), + super::state::NavigatorDisplayLine::Spacer => None, + }) + }; + if let Some(row_idx) = row_idx { + self.navigator.selected = row_idx; + } + self.ensure_navigator_selection_visible_from(terminal_runtimes); + } + + /// After the query or state filter changes, select the first row that + /// itself matched the filter, so enter immediately accepts the best match. + /// State filters prefer pane matches over aggregate workspace/tab matches. + pub(crate) fn select_first_navigator_match_from( + &mut self, + terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry, + ) { + let query = self.navigator.query.trim().to_lowercase(); + let query_kind = navigator_query_kind(&query, self.navigator.state_filter); + if !matches!(query_kind, NavigatorQueryKind::Empty) { + let rows = self.navigator_rows_from(terminal_runtimes); + let idx = if matches!(query_kind, NavigatorQueryKind::State(_)) { + rows.iter() + .position(|row| { + row.matched && matches!(row.target, NavigatorTarget::Pane { .. }) + }) + .or_else(|| rows.iter().position(|row| row.matched)) + } else { + rows.iter().position(|row| row.matched) + }; + if let Some(idx) = idx { + self.navigator.selected = idx; + } + } + self.clamp_navigator_selection_from(terminal_runtimes); + } + pub(crate) fn clamp_navigator_selection_from( &mut self, terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry, @@ -3488,8 +3580,12 @@ mod tests { } let _ = std::fs::remove_dir_all(root); - assert_eq!(rows.len(), 1); + // The workspace matched by its live cwd label; its subtree cascades in + // as context. + assert_eq!(rows.len(), 2); assert_eq!(rows[0].label, "herdr (1)"); + assert!(rows[0].matched); + assert!(!rows[1].matched); } #[test] @@ -3679,6 +3775,84 @@ mod tests { } #[test] + fn navigator_workspace_match_cascades_full_subtree() { + let mut state = app_with_workspaces(&["one", "two"]); + let root = state.workspaces[0].tabs[0].root_pane; + let extra = state.workspaces[0].test_split(Direction::Horizontal); + state.ensure_test_terminals(); + for pane in [root, extra] { + let terminal_id = state.workspaces[0].terminal_id(pane).cloned().unwrap(); + state + .terminals + .get_mut(&terminal_id) + .unwrap() + .set_manual_label("unrelated".into()); + } + + state.open_navigator(); + state.navigator.query = "one".into(); + let rows = state.navigator_rows(); + + // Both panes cascade in even though only the workspace label matched, + // and only the workspace carries the matched flag. + let pane_rows: Vec<_> = rows.iter().filter(|row| !row.is_workspace).collect(); + assert_eq!(pane_rows.len(), 2); + assert!(pane_rows.iter().all(|row| !row.matched)); + assert!(rows.iter().any(|row| row.is_workspace && row.matched)); + assert!(!rows.iter().any(|row| row.label.starts_with("two"))); + } + + #[test] + fn navigator_search_selects_first_self_match() { + let mut state = app_with_workspaces(&["one", "two"]); + let pane = state.workspaces[1].tabs[0].root_pane; + state.ensure_test_terminals(); + let terminal_id = state.workspaces[1].terminal_id(pane).cloned().unwrap(); + state + .terminals + .get_mut(&terminal_id) + .unwrap() + .set_manual_label("pi ui build".into()); + + let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new(); + state.open_navigator_from(&terminal_runtimes); + state.navigator.query = "ui".into(); + state.select_first_navigator_match_from(&terminal_runtimes); + + let rows = state.navigator_rows_from(&terminal_runtimes); + let selected = &rows[state.navigator.selected]; + assert!(matches!( + selected.target, + crate::app::state::NavigatorTarget::Pane { pane_id, .. } if pane_id == pane + )); + } + + #[test] + fn navigator_state_filter_selects_matching_pane_over_workspace() { + let mut state = app_with_workspaces(&["one"]); + let working = state.workspaces[0].test_split(Direction::Horizontal); + state.ensure_test_terminals(); + let terminal_id = state.workspaces[0].terminal_id(working).cloned().unwrap(); + state + .terminals + .get_mut(&terminal_id) + .unwrap() + .set_detected_state(Some(Agent::Codex), AgentState::Working); + + let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new(); + state.open_navigator_from(&terminal_runtimes); + state.navigator.state_filter = Some(NavigatorStateFilter::Working); + state.select_first_navigator_match_from(&terminal_runtimes); + + let rows = state.navigator_rows_from(&terminal_runtimes); + let selected = &rows[state.navigator.selected]; + assert!(matches!( + selected.target, + crate::app::state::NavigatorTarget::Pane { pane_id, .. } if pane_id == working + )); + } + + #[test] fn apply_workspace_git_statuses_updates_matching_workspace() { let mut state = app_with_workspaces(&["one", "two"]); let first_id = state.workspaces[0].id.clone(); diff --git a/src/app/input/modal.rs b/src/app/input/modal.rs index 23134ddc..dc126b40 100644 --- a/src/app/input/modal.rs +++ b/src/app/input/modal.rs @@ -173,7 +173,7 @@ pub(crate) fn handle_navigator_key( KeyCode::Backspace => { state.navigator.state_filter = None; state.navigator.query.pop(); - state.clamp_navigator_selection_from(terminal_runtimes); + state.select_first_navigator_match_from(terminal_runtimes); } KeyCode::Up => state.move_navigator_selection_from(terminal_runtimes, -1), KeyCode::Down => state.move_navigator_selection_from(terminal_runtimes, 1), @@ -222,22 +222,22 @@ pub(crate) fn handle_navigator_key( KeyCode::Char('b') if key.modifiers.is_empty() => { state.navigator.query.clear(); state.navigator.state_filter = Some(NavigatorStateFilter::Blocked); - state.clamp_navigator_selection_from(terminal_runtimes); + state.select_first_navigator_match_from(terminal_runtimes); } KeyCode::Char('w') if key.modifiers.is_empty() => { state.navigator.query.clear(); state.navigator.state_filter = Some(NavigatorStateFilter::Working); - state.clamp_navigator_selection_from(terminal_runtimes); + state.select_first_navigator_match_from(terminal_runtimes); } KeyCode::Char('i') if key.modifiers.is_empty() => { state.navigator.query.clear(); state.navigator.state_filter = Some(NavigatorStateFilter::Idle); - state.clamp_navigator_selection_from(terminal_runtimes); + state.select_first_navigator_match_from(terminal_runtimes); } KeyCode::Char('d') if key.modifiers.is_empty() => { state.navigator.query.clear(); state.navigator.state_filter = Some(NavigatorStateFilter::Done); - state.clamp_navigator_selection_from(terminal_runtimes); + state.select_first_navigator_match_from(terminal_runtimes); } KeyCode::Char('j') | KeyCode::Down => { state.move_navigator_selection_from(terminal_runtimes, 1) @@ -246,12 +246,12 @@ pub(crate) fn handle_navigator_key( state.move_navigator_selection_from(terminal_runtimes, -1) } KeyCode::Char('d') if key.modifiers == KeyModifiers::CONTROL => state - .move_navigator_selection_from( + .move_navigator_selection_by_lines_from( terminal_runtimes, (state.navigator_body_rect().height / 2).max(1) as isize, ), KeyCode::Char('u') if key.modifiers == KeyModifiers::CONTROL => state - .move_navigator_selection_from( + .move_navigator_selection_by_lines_from( terminal_runtimes, -((state.navigator_body_rect().height / 2).max(1) as isize), ), @@ -281,7 +281,7 @@ pub(crate) fn insert_navigator_search_text( } state.navigator.state_filter = None; state.navigator.query.push_str(text); - state.clamp_navigator_selection_from(terminal_runtimes); + state.select_first_navigator_match_from(terminal_runtimes); } pub(crate) fn handle_keybind_help_key(state: &mut AppState, key: KeyEvent) { diff --git a/src/app/input/overlays.rs b/src/app/input/overlays.rs index 20944080..e9cf3139 100644 --- a/src/app/input/overlays.rs +++ b/src/app/input/overlays.rs @@ -172,9 +172,8 @@ impl App { } MouseEventKind::ScrollUp => { self.state.navigator.scroll = self.state.navigator.scroll.saturating_sub(3); - self.state.navigator.selected = self.state.navigator.scroll; self.state - .clamp_navigator_selection_from(&self.terminal_runtimes); + .align_navigator_selection_to_scroll_from(&self.terminal_runtimes); } MouseEventKind::ScrollDown => { let viewport = self.state.navigator_body_rect().height as usize; @@ -183,9 +182,8 @@ impl App { .navigator_max_scroll_from(&self.terminal_runtimes, viewport); self.state.navigator.scroll = self.state.navigator.scroll.saturating_add(3).min(max); - self.state.navigator.selected = self.state.navigator.scroll; self.state - .clamp_navigator_selection_from(&self.terminal_runtimes); + .align_navigator_selection_to_scroll_from(&self.terminal_runtimes); } _ => {} } @@ -337,11 +335,17 @@ impl AppState { if !rect_contains(body, col, row) { return None; } - let idx = self + let line_idx = self .navigator .scroll .saturating_add(row.saturating_sub(body.y) as usize); - (idx < self.navigator_rows_from(terminal_runtimes).len()).then_some(idx) + let lines = crate::app::state::navigator_display_lines( + &self.navigator_rows_from(terminal_runtimes), + ); + match lines.get(line_idx) { + Some(crate::app::state::NavigatorDisplayLine::Row(idx)) => Some(*idx), + _ => None, + } } pub(crate) fn navigator_row_caret_at(&self, col: u16) -> bool { diff --git a/src/app/state.rs b/src/app/state.rs index 0321a6ee..ee9c69be 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -869,6 +869,48 @@ pub(crate) struct NavigatorRow { pub is_tab: bool, pub expanded: bool, pub search_text: String, + /// Whether this row itself matched the active query/state filter, as + /// opposed to being included as ancestor context or cascaded subtree of a + /// matching workspace or tab. Always true when no filter is active. + pub matched: bool, +} + +/// One rendered line in the navigator body. Spacer lines separate workspace +/// groups visually and are not selectable. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum NavigatorDisplayLine { + Spacer, + Row(usize), +} + +pub(crate) fn navigator_display_lines(rows: &[NavigatorRow]) -> Vec<NavigatorDisplayLine> { + let mut lines = Vec::with_capacity(rows.len().saturating_mul(2)); + for (idx, row) in rows.iter().enumerate() { + if row.is_workspace && !lines.is_empty() { + lines.push(NavigatorDisplayLine::Spacer); + } + lines.push(NavigatorDisplayLine::Row(idx)); + } + lines +} + +pub(crate) fn navigator_display_index_of_row( + lines: &[NavigatorDisplayLine], + row_idx: usize, +) -> Option<usize> { + lines + .iter() + .position(|line| *line == NavigatorDisplayLine::Row(row_idx)) +} + +pub(crate) fn navigator_first_row_at_or_after( + lines: &[NavigatorDisplayLine], + line_idx: usize, +) -> Option<usize> { + lines.get(line_idx..)?.iter().find_map(|line| match line { + NavigatorDisplayLine::Row(idx) => Some(*idx), + NavigatorDisplayLine::Spacer => None, + }) } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -2289,6 +2331,81 @@ mod tests { state.assert_invariants_for_test(); } + fn navigator_row_for_display(is_workspace: bool) -> NavigatorRow { + NavigatorRow { + target: NavigatorTarget::Workspace { ws_idx: 0 }, + depth: if is_workspace { 0 } else { 1 }, + label: String::new(), + meta: String::new(), + status: crate::detect::AgentState::Idle, + seen: true, + is_current: false, + is_workspace, + is_tab: false, + expanded: true, + search_text: String::new(), + matched: true, + } + } + + #[test] + fn navigator_display_lines_separate_workspace_groups() { + let rows = vec![ + navigator_row_for_display(true), + navigator_row_for_display(false), + navigator_row_for_display(true), + navigator_row_for_display(false), + ]; + assert_eq!( + navigator_display_lines(&rows), + vec![ + NavigatorDisplayLine::Row(0), + NavigatorDisplayLine::Row(1), + NavigatorDisplayLine::Spacer, + NavigatorDisplayLine::Row(2), + NavigatorDisplayLine::Row(3), + ] + ); + } + + #[test] + fn navigator_display_lines_have_no_leading_spacer() { + let rows = vec![ + navigator_row_for_display(true), + navigator_row_for_display(false), + ]; + assert_eq!( + navigator_display_lines(&rows), + vec![NavigatorDisplayLine::Row(0), NavigatorDisplayLine::Row(1)] + ); + assert!(navigator_display_lines(&[]).is_empty()); + } + + #[test] + fn navigator_display_index_maps_row_to_line() { + let rows = vec![ + navigator_row_for_display(true), + navigator_row_for_display(false), + navigator_row_for_display(true), + ]; + let lines = navigator_display_lines(&rows); + assert_eq!(navigator_display_index_of_row(&lines, 2), Some(3)); + assert_eq!(navigator_display_index_of_row(&lines, 9), None); + } + + #[test] + fn navigator_first_row_skips_spacer_lines() { + let rows = vec![ + navigator_row_for_display(true), + navigator_row_for_display(false), + navigator_row_for_display(true), + ]; + let lines = navigator_display_lines(&rows); + // Line 2 is the spacer before the second workspace. + assert_eq!(navigator_first_row_at_or_after(&lines, 2), Some(2)); + assert_eq!(navigator_first_row_at_or_after(&lines, 4), None); + } + #[test] fn built_in_theme_names_resolve() { for name in THEME_NAMES { diff --git a/src/ui/navigator.rs b/src/ui/navigator.rs index 0ad336f6..28eb8530 100644 --- a/src/ui/navigator.rs +++ b/src/ui/navigator.rs @@ -12,7 +12,10 @@ use super::{ text::{display_width_u16, middle_elide, truncate_end}, widgets::{panel_contrast_fg, render_panel_shell}, }; -use crate::app::state::{AppState, NavigatorRow, NavigatorStateFilter, NavigatorTarget}; +use crate::app::state::{ + navigator_display_lines, AppState, NavigatorDisplayLine, NavigatorRow, NavigatorStateFilter, + NavigatorTarget, +}; use crate::terminal::TerminalRuntimeRegistry; pub(super) fn render_navigator_overlay( @@ -33,9 +36,11 @@ pub(super) fn render_navigator_overlay( render_search(app, frame, search); if body.height > 0 { + let rows = app.navigator_rows_from(terminal_runtimes); + let lines = navigator_display_lines(&rows); render_separator(frame, Rect::new(inner.x, search.y + 1, inner.width, 1), app); - render_rows(app, terminal_runtimes, frame, body); - render_navigator_scrollbar(app, terminal_runtimes, frame, body); + render_rows(app, &rows, &lines, frame, body); + render_navigator_scrollbar(app, lines.len(), frame, body); } render_detail(app, terminal_runtimes, frame, detail); render_footer(app, frame, footer); @@ -137,23 +142,33 @@ fn render_separator(frame: &mut Frame, area: Rect, app: &AppState) { fn render_rows( app: &AppState, - terminal_runtimes: &TerminalRuntimeRegistry, + rows: &[NavigatorRow], + lines: &[NavigatorDisplayLine], frame: &mut Frame, body: Rect, ) { - let rows = app.navigator_rows_from(terminal_runtimes); - let start = app.navigator.scroll.min(rows.len()); - let end = rows.len().min(start.saturating_add(body.height as usize)); - for (visible_idx, row) in rows[start..end].iter().enumerate() { - let idx = start + visible_idx; + let start = app.navigator.scroll.min(lines.len()); + let end = lines.len().min(start.saturating_add(body.height as usize)); + for (visible_idx, line) in lines[start..end].iter().enumerate() { + let NavigatorDisplayLine::Row(idx) = *line else { + continue; + }; let y = body.y + visible_idx as u16; let rect = Rect::new(body.x, y, body.width, 1); let selected = idx == app.navigator.selected; - render_row(app, frame, rect, row, selected); + render_row(app, frame, rect, rows, idx, selected); } } -fn render_row(app: &AppState, frame: &mut Frame, rect: Rect, row: &NavigatorRow, selected: bool) { +fn render_row( + app: &AppState, + frame: &mut Frame, + rect: Rect, + rows: &[NavigatorRow], + idx: usize, + selected: bool, +) { + let row = &rows[idx]; let p = &app.palette; frame.render_widget(Clear, rect); let base_style = if selected { @@ -166,8 +181,23 @@ fn render_row(app: &AppState, frame: &mut Frame, rect: Rect, row: &NavigatorRow, } else { Style::default().fg(p.overlay0).bg(p.panel_bg) }; + let filter_active = + app.navigator.state_filter.is_some() || !app.navigator.query.trim().is_empty(); + let context_only = filter_active && !row.matched; let text_style = if selected { base_style.add_modifier(Modifier::BOLD) + } else if context_only { + let dimmed = Style::default().fg(p.overlay0).bg(p.panel_bg); + if row.is_workspace { + dimmed.add_modifier(Modifier::BOLD) + } else { + dimmed + } + } else if row.is_workspace { + Style::default() + .fg(p.accent) + .bg(p.panel_bg) + .add_modifier(Modifier::BOLD) } else if row.is_current { Style::default() .fg(p.text) @@ -179,35 +209,43 @@ fn render_row(app: &AppState, frame: &mut Frame, rect: Rect, row: &NavigatorRow, let (status_icon, status_style) = agent_icon(row.status, row.seen, app.spinner_tick, p); let status_style = if selected { base_style.add_modifier(Modifier::BOLD) + } else if context_only { + Style::default().fg(p.overlay0).bg(p.panel_bg) } else { status_style.bg(p.panel_bg) }; - let prefix = if row.is_workspace { - if row.expanded { - "▾" - } else { - "▸" - } - } else if row.depth > 0 { - "├─" + let prefix = tree_prefix(rows, idx); + let current = if row.is_current { "◆" } else { " " }; + let gutter = format!(" {current} "); + let gutter_style = if selected { + base_style + } else if row.is_current { + Style::default().fg(p.accent).bg(p.panel_bg) } else { - " " + dim_style + }; + // Branch glyphs recede one shade below the workspace caret so the + // structure stays behind the labels. + let tree_style = if selected { + base_style + } else if row.is_workspace { + dim_style + } else { + Style::default().fg(p.surface1).bg(p.panel_bg) }; - let current = if row.is_current { "◆" } else { " " }; - let marker = if selected { "→" } else { " " }; - let indent = " ".repeat(row.depth as usize); - let left_fixed = format!(" {indent}{prefix} {marker} {current} "); let meta_width = metadata_width(rect.width); let left_budget = rect .width .saturating_sub(meta_width) - .saturating_sub(display_width_u16(&left_fixed)) + .saturating_sub(display_width_u16(&format!("{gutter}{prefix} "))) .saturating_sub(3) as usize; let title = truncate_end(&row.label, left_budget); let spans = vec![ - Span::styled(left_fixed, dim_style), + Span::styled(gutter, gutter_style), + Span::styled(prefix, tree_style), + Span::styled(" ", base_style), Span::styled(status_icon, status_style), Span::raw(" "), Span::styled(title, text_style), @@ -224,7 +262,7 @@ fn render_row(app: &AppState, frame: &mut Frame, rect: Rect, row: &NavigatorRow, let meta = truncate_end(&row.meta, meta_width.saturating_sub(2) as usize); let meta_style = if selected { base_style - } else if row.is_workspace || row.is_tab { + } else if context_only || row.is_workspace || row.is_tab { Style::default().fg(p.overlay0).bg(p.panel_bg) } else { Style::default() @@ -238,26 +276,56 @@ fn render_row(app: &AppState, frame: &mut Frame, rect: Rect, row: &NavigatorRow, } } -fn render_navigator_scrollbar( - app: &AppState, - terminal_runtimes: &TerminalRuntimeRegistry, - frame: &mut Frame, - body: Rect, -) { +/// Tree prefix for a navigator row: expand caret for workspaces, connected +/// branch glyphs for children (`├──`, `└──` for the last sibling, with `│` +/// continuation lines under ancestors that have more siblings below). +fn tree_prefix(rows: &[NavigatorRow], idx: usize) -> String { + let row = &rows[idx]; + if row.is_workspace { + return if row.expanded { "▾" } else { "▸" }.to_string(); + } + if row.depth == 0 { + return " ".to_string(); + } + let mut prefix = String::new(); + for level in 1..row.depth { + prefix.push_str(if has_following_sibling_at_depth(rows, idx, level) { + "│ " + } else { + " " + }); + } + prefix.push_str(if has_following_sibling_at_depth(rows, idx, row.depth) { + "├──" + } else { + "└──" + }); + prefix +} + +/// Whether another row at `depth` follows `idx` before the subtree at that +/// depth ends (a row shallower than `depth` closes the subtree). +fn has_following_sibling_at_depth(rows: &[NavigatorRow], idx: usize, depth: u8) -> bool { + rows[idx + 1..] + .iter() + .take_while(|row| row.depth >= depth) + .any(|row| row.depth == depth) +} + +fn render_navigator_scrollbar(app: &AppState, line_count: usize, frame: &mut Frame, body: Rect) { if body.width <= 1 || body.height == 0 { return; } - let rows = app.navigator_rows_from(terminal_runtimes).len(); let viewport = body.height as usize; - if rows <= viewport { + if line_count <= viewport { return; } let metrics = crate::pane::ScrollMetrics { viewport_rows: viewport, - offset_from_bottom: rows + offset_from_bottom: line_count .saturating_sub(viewport) .saturating_sub(app.navigator.scroll), - max_offset_from_bottom: rows.saturating_sub(viewport), + max_offset_from_bottom: line_count.saturating_sub(viewport), }; if !should_show_scrollbar(metrics) { return; @@ -503,3 +571,78 @@ fn render_footer(app: &AppState, frame: &mut Frame, area: Rect) { }; frame.render_widget(Paragraph::new(line), area); } + +#[cfg(test)] +mod tests { + use super::*; + use crate::detect::AgentState; + + fn row(depth: u8, is_workspace: bool) -> NavigatorRow { + NavigatorRow { + target: NavigatorTarget::Workspace { ws_idx: 0 }, + depth, + label: String::new(), + meta: String::new(), + status: AgentState::Idle, + seen: true, + is_current: false, + is_workspace, + is_tab: false, + expanded: true, + search_text: String::new(), + matched: true, + } + } + + fn multi_tab_rows() -> Vec<NavigatorRow> { + vec![ + row(0, true), // workspace + row(1, false), // tab a + row(2, false), // pane + row(2, false), // pane (last in tab a) + row(1, false), // tab b (last tab) + row(2, false), // pane (last in tab b) + row(0, true), // workspace + row(1, false), // pane (single child) + ] + } + + #[test] + fn workspace_rows_use_expand_caret() { + let rows = multi_tab_rows(); + assert_eq!(tree_prefix(&rows, 0), "▾"); + let mut collapsed = rows.clone(); + collapsed[0].expanded = false; + assert_eq!(tree_prefix(&collapsed, 0), "▸"); + } + + #[test] + fn middle_children_get_branch_glyph() { + let rows = multi_tab_rows(); + assert_eq!(tree_prefix(&rows, 1), "├──"); + assert_eq!(tree_prefix(&rows, 2), "│ ├──"); + } + + #[test] + fn last_children_get_terminator_glyph() { + let rows = multi_tab_rows(); + assert_eq!(tree_prefix(&rows, 3), "│ └──"); + assert_eq!(tree_prefix(&rows, 4), "└──"); + assert_eq!(tree_prefix(&rows, 7), "└──"); + } + + #[test] + fn spine_stops_after_last_ancestor_sibling() { + let rows = multi_tab_rows(); + assert_eq!(tree_prefix(&rows, 5), " └──"); + } + + #[test] + fn next_workspace_does_not_extend_previous_subtree() { + // The pane at idx 5 is last in its workspace even though another + // workspace with children follows. + let rows = multi_tab_rows(); + assert!(!has_following_sibling_at_depth(&rows, 5, 1)); + assert!(!has_following_sibling_at_depth(&rows, 5, 2)); + } +} |
