aboutsummaryrefslogtreecommitdiff
path: root/src/app
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/app
parent01d371bf7a10330c6a5c139c7df766ae8f6f1cda (diff)
feat: connected tree glyphs, workspace grouping, and search auto-select in navigator
refs #1611
Diffstat (limited to 'src/app')
-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
4 files changed, 324 insertions, 29 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 {