aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-07-19 20:44:43 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-07-19 20:44:44 +0300
commita26a654351ee03b1a51d7b398f3bdff2c1ca920d (patch)
tree05382168990e4a6f50f6dbb5220c52fad062923d /src
parent01d371bf7a10330c6a5c139c7df766ae8f6f1cda (diff)
feat: connected tree glyphs, workspace grouping, and search auto-select in navigator
refs #1611
Diffstat (limited to 'src')
-rw-r--r--src/app/actions.rs204
-rw-r--r--src/app/input/modal.rs16
-rw-r--r--src/app/input/overlays.rs16
-rw-r--r--src/app/state.rs117
-rw-r--r--src/ui/navigator.rs217
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));
+ }
+}