aboutsummaryrefslogtreecommitdiff
path: root/src/app/actions.rs
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/actions.rs
parent01d371bf7a10330c6a5c139c7df766ae8f6f1cda (diff)
feat: connected tree glyphs, workspace grouping, and search auto-select in navigator
refs #1611
Diffstat (limited to 'src/app/actions.rs')
-rw-r--r--src/app/actions.rs204
1 files changed, 189 insertions, 15 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();