diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-24 02:57:03 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-24 02:59:39 +0300 |
| commit | 80a314a9089b28c1bfdc2cdf5196e9a2eb9f11ba (patch) | |
| tree | fca593cafa027f4283adcec80c2f8908ef79708e /src/app | |
| parent | a132a7c3b9ade3ad4636e03b1f0930b5fa711766 (diff) | |
feat: add session navigator
refs #157
Diffstat (limited to 'src/app')
| -rw-r--r-- | src/app/actions.rs | 703 | ||||
| -rw-r--r-- | src/app/input/mod.rs | 3 | ||||
| -rw-r--r-- | src/app/input/modal.rs | 119 | ||||
| -rw-r--r-- | src/app/input/navigate.rs | 27 | ||||
| -rw-r--r-- | src/app/input/overlays.rs | 143 | ||||
| -rw-r--r-- | src/app/mod.rs | 12 | ||||
| -rw-r--r-- | src/app/state.rs | 53 |
7 files changed, 1046 insertions, 14 deletions
diff --git a/src/app/actions.rs b/src/app/actions.rs index ba71cc87..9132d7bd 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -9,7 +9,10 @@ use crate::layout::{find_in_direction, NavDirection, PaneId}; use crate::terminal::{EffectiveStateChange, TerminalStateMutation}; use crate::workspace::WorkspaceGitStatus; -use super::state::{AppState, Mode, ToastKind, ToastNotification, ToastTarget, ViewLayout}; +use super::state::{ + AppState, Mode, NavigatorRow, NavigatorStateFilter, NavigatorTarget, ToastKind, + ToastNotification, ToastTarget, ViewLayout, +}; fn is_background_completion_transition(prev_state: AgentState, new_state: AgentState) -> bool { matches!(new_state, AgentState::Idle) @@ -95,6 +98,489 @@ pub struct PaneStateUpdate { } // --------------------------------------------------------------------------- +// Navigator operations +// --------------------------------------------------------------------------- + +impl AppState { + pub(crate) fn open_navigator(&mut self) { + self.navigator.query.clear(); + self.navigator.search_focused = false; + self.navigator.state_filter = None; + self.navigator.scroll = 0; + self.navigator.expanded_workspaces.clear(); + + for ws in &self.workspaces { + self.navigator.expanded_workspaces.insert(ws.id.clone()); + } + + self.mode = Mode::Navigator; + self.navigator.selected = self.current_navigator_row_index().unwrap_or(0); + self.ensure_navigator_selection_visible(); + } + + pub(crate) fn navigator_rows(&self) -> Vec<NavigatorRow> { + let query = self.navigator.query.trim().to_lowercase(); + let query_kind = navigator_query_kind(&query, self.navigator.state_filter); + let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new(); + let mut rows = Vec::new(); + for (ws_idx, ws) in self.workspaces.iter().enumerate() { + let workspace_label = ws.display_name_from(&self.terminals, &terminal_runtimes); + let activity = workspace_activity_summary(ws, &self.terminals); + let workspace_search_text = format!("{workspace_label} {activity}").to_lowercase(); + let workspace_matches = match query_kind { + NavigatorQueryKind::Empty => true, + NavigatorQueryKind::State(filter) => { + let (state, seen) = ws.aggregate_state(&self.terminals); + navigator_state_filter_matches(filter, state, seen) + } + NavigatorQueryKind::Text => navigator_matches(&query, &workspace_search_text), + }; + + let child_rows = self.navigator_child_rows(ws_idx, query_kind, &query); + if !workspace_matches && child_rows.is_empty() { + continue; + } + + let expanded = !matches!(query_kind, NavigatorQueryKind::Empty) + || self.navigator.expanded_workspaces.contains(&ws.id); + let (state, seen) = ws.aggregate_state(&self.terminals); + let pane_count = ws.tabs.iter().map(|tab| tab.panes.len()).sum::<usize>(); + rows.push(NavigatorRow { + target: NavigatorTarget::Workspace { ws_idx }, + depth: 0, + label: format!("{workspace_label} ({pane_count})"), + meta: activity, + status: state, + seen, + is_current: self.active == Some(ws_idx), + is_workspace: true, + is_tab: false, + expanded, + search_text: workspace_search_text, + }); + if expanded { + rows.extend(child_rows); + } + } + rows + } + + fn navigator_child_rows( + &self, + ws_idx: usize, + query_kind: NavigatorQueryKind, + query: &str, + ) -> Vec<NavigatorRow> { + let Some(ws) = self.workspaces.get(ws_idx) else { + return Vec::new(); + }; + 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 tab_matches = tab_row.as_ref().is_some_and(|row| match query_kind { + NavigatorQueryKind::Empty => true, + NavigatorQueryKind::State(filter) => { + navigator_state_filter_matches(filter, row.status, row.seen) + } + NavigatorQueryKind::Text => navigator_matches(query, &row.search_text), + }); + let 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, + NavigatorQueryKind::Text => pane_rows + .into_iter() + .filter(|row| navigator_matches(query, &row.search_text)) + .collect::<Vec<_>>(), + }; + + if let Some(tab_row) = tab_row { + if tab_matches || !filtered_panes.is_empty() { + rows.push(tab_row); + } + } + rows.extend(filtered_panes); + } + rows + } + + fn navigator_tab_row(&self, ws_idx: usize, tab_idx: usize) -> NavigatorRow { + let ws = &self.workspaces[ws_idx]; + let tab = &ws.tabs[tab_idx]; + let label = tab.display_name(); + let (status, seen) = tab_aggregate_state(tab, &self.terminals); + let activity = tab_activity_summary(tab, &self.terminals); + let pane_count = tab.panes.len(); + let meta = if activity.is_empty() { + format!("{pane_count} panes") + } else { + format!("{pane_count} panes · {activity}") + }; + let search_text = format!("{label} {meta}").to_lowercase(); + NavigatorRow { + target: NavigatorTarget::Tab { ws_idx, tab_idx }, + depth: 1, + label, + meta, + status, + seen, + is_current: false, + is_workspace: false, + is_tab: true, + expanded: true, + search_text, + } + } + + fn navigator_pane_rows_for_tab( + &self, + ws_idx: usize, + tab_idx: usize, + multi_tab: bool, + ) -> Vec<NavigatorRow> { + let Some(ws) = self.workspaces.get(ws_idx) else { + return Vec::new(); + }; + let Some(tab) = ws.tabs.get(tab_idx) else { + return Vec::new(); + }; + let mut rows = Vec::new(); + for pane_id in tab.layout.pane_ids() { + let Some(pane) = tab.panes.get(&pane_id) else { + continue; + }; + let terminal = self.terminals.get(&pane.attached_terminal_id); + let pane_number = ws.public_pane_number(pane_id).unwrap_or(0); + let label = terminal + .and_then(|terminal| terminal.manual_label.as_deref()) + .or_else(|| terminal.and_then(|terminal| terminal.agent_name.as_deref())) + .or_else(|| terminal.and_then(|terminal| terminal.effective_agent_label())) + .map(str::to_string) + .or_else(|| { + launch_label(terminal.and_then(|terminal| terminal.launch_argv.as_ref())) + }) + .unwrap_or_else(|| format!("pane {pane_number}")); + let agent_label = terminal + .and_then(|terminal| terminal.agent_name.as_deref()) + .or_else(|| terminal.and_then(|terminal| terminal.effective_agent_label())); + let custom_status = terminal.and_then(|terminal| terminal.effective_custom_status()); + let state = terminal + .map(|terminal| terminal.state) + .unwrap_or(AgentState::Unknown); + let status = custom_status + .map(str::to_string) + .or_else(|| agent_label.map(|_| state_label_text(state, pane.seen).to_string())); + let meta = match (agent_label, status.as_deref()) { + (Some(agent_label), Some(status)) => format!("{agent_label} · {status}"), + (Some(agent_label), None) => agent_label.to_string(), + (None, _) => "shell".to_string(), + }; + let is_current = self.is_active_pane(ws_idx, tab_idx, pane_id); + let search_text = format!("{label} {meta}").to_lowercase(); + rows.push(NavigatorRow { + target: NavigatorTarget::Pane { + ws_idx, + tab_idx, + pane_id, + }, + depth: if multi_tab { 2 } else { 1 }, + label, + meta, + status: state, + seen: pane.seen, + is_current, + is_workspace: false, + is_tab: false, + expanded: false, + search_text, + }); + } + rows + } + + fn current_navigator_row_index(&self) -> Option<usize> { + let rows = self.navigator_rows(); + rows.iter() + .position(|row| matches!(row.target, NavigatorTarget::Pane { .. }) && row.is_current) + .or_else(|| rows.iter().position(|row| row.is_current)) + } + + pub(crate) fn ensure_navigator_selection_visible(&mut self) { + let body = self.navigator_body_rect(); + let viewport = body.height as usize; + if viewport == 0 { + self.navigator.scroll = 0; + return; + } + let max_scroll = self.navigator_max_scroll(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); + } + self.navigator.scroll = self.navigator.scroll.min(max_scroll); + } + + pub(crate) fn navigator_max_scroll(&self, viewport: usize) -> usize { + if viewport == 0 { + return 0; + } + self.navigator_rows().len().saturating_sub(viewport) + } + + pub(crate) fn move_navigator_selection(&mut self, delta: isize) { + let count = self.navigator_rows().len(); + if count == 0 { + self.navigator.selected = 0; + self.navigator.scroll = 0; + return; + } + let current = self.navigator.selected.min(count - 1) as isize; + self.navigator.selected = (current + delta).clamp(0, count as isize - 1) as usize; + self.ensure_navigator_selection_visible(); + } + + pub(crate) fn clamp_navigator_selection(&mut self) { + let count = self.navigator_rows().len(); + self.navigator.selected = self.navigator.selected.min(count.saturating_sub(1)); + self.ensure_navigator_selection_visible(); + } + + pub(crate) fn toggle_selected_navigator_workspace(&mut self) { + let Some(row) = self.navigator_rows().get(self.navigator.selected).cloned() else { + return; + }; + let NavigatorTarget::Workspace { ws_idx } = row.target else { + return; + }; + let Some(workspace_id) = self.workspaces.get(ws_idx).map(|ws| ws.id.clone()) else { + return; + }; + if self.navigator.expanded_workspaces.contains(&workspace_id) { + self.navigator.expanded_workspaces.remove(&workspace_id); + } else { + self.navigator.expanded_workspaces.insert(workspace_id); + } + self.clamp_navigator_selection(); + } + + pub(crate) fn accept_navigator_selection(&mut self) -> bool { + let Some(row) = self.navigator_rows().get(self.navigator.selected).cloned() else { + return false; + }; + self.focus_navigator_target(row.target) + } + + pub(crate) fn focus_navigator_target(&mut self, target: NavigatorTarget) -> bool { + match target { + NavigatorTarget::Workspace { ws_idx } => { + if ws_idx >= self.workspaces.len() { + return false; + } + self.switch_workspace(ws_idx); + self.mode = Mode::Terminal; + true + } + NavigatorTarget::Tab { ws_idx, tab_idx } => { + if ws_idx >= self.workspaces.len() { + return false; + } + let tab_exists = self + .workspaces + .get(ws_idx) + .is_some_and(|ws| tab_idx < ws.tabs.len()); + if !tab_exists { + return false; + } + self.switch_workspace(ws_idx); + self.switch_tab(tab_idx); + self.mode = Mode::Terminal; + true + } + NavigatorTarget::Pane { + ws_idx, + tab_idx, + pane_id, + } => { + if ws_idx >= self.workspaces.len() { + return false; + } + self.switch_workspace(ws_idx); + self.switch_tab(tab_idx); + if let Some(tab) = self + .workspaces + .get_mut(ws_idx) + .and_then(|ws| ws.tabs.get_mut(tab_idx)) + { + if tab.panes.contains_key(&pane_id) { + tab.layout.focus_pane(pane_id); + self.mark_session_dirty(); + self.mode = Mode::Terminal; + return true; + } + } + false + } + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum NavigatorQueryKind { + Empty, + Text, + State(NavigatorStateFilter), +} + +fn navigator_query_kind( + query: &str, + state_filter: Option<NavigatorStateFilter>, +) -> NavigatorQueryKind { + if let Some(filter) = state_filter { + return NavigatorQueryKind::State(filter); + } + if query.is_empty() { + NavigatorQueryKind::Empty + } else { + NavigatorQueryKind::Text + } +} + +fn navigator_state_filter_matches( + filter: NavigatorStateFilter, + state: AgentState, + seen: bool, +) -> bool { + match filter { + NavigatorStateFilter::Blocked => state == AgentState::Blocked, + NavigatorStateFilter::Working => state == AgentState::Working, + NavigatorStateFilter::Idle => state == AgentState::Idle && seen, + NavigatorStateFilter::Done => state == AgentState::Idle && !seen, + } +} + +fn navigator_matches(query: &str, text: &str) -> bool { + query + .split_whitespace() + .all(|part| text.contains(&part.to_lowercase())) +} + +fn launch_label(argv: Option<&Vec<String>>) -> Option<String> { + let argv = argv?; + let command = argv.first()?; + std::path::Path::new(command) + .file_name() + .and_then(|name| name.to_str()) + .map(str::to_string) + .or_else(|| Some(command.clone())) +} + +fn state_label_text(state: AgentState, seen: bool) -> &'static str { + match (state, seen) { + (AgentState::Blocked, _) => "blocked", + (AgentState::Working, _) => "working", + (AgentState::Idle, false) => "done", + (AgentState::Idle, true) => "idle", + (AgentState::Unknown, _) => "unknown", + } +} + +fn tab_aggregate_state( + tab: &crate::workspace::Tab, + terminals: &std::collections::HashMap< + crate::terminal::TerminalId, + crate::terminal::TerminalState, + >, +) -> (AgentState, bool) { + let mut aggregate = AgentState::Unknown; + let mut seen = true; + for pane in tab.panes.values() { + let Some(terminal) = terminals.get(&pane.attached_terminal_id) else { + continue; + }; + if state_priority(terminal.state, pane.seen) > state_priority(aggregate, seen) { + aggregate = terminal.state; + seen = pane.seen; + } + } + (aggregate, seen) +} + +fn state_priority(state: AgentState, seen: bool) -> u8 { + match (state, seen) { + (AgentState::Blocked, _) => 5, + (AgentState::Working, _) => 4, + (AgentState::Idle, false) => 3, + (AgentState::Idle, true) => 2, + (AgentState::Unknown, _) => 1, + } +} + +fn tab_activity_summary( + tab: &crate::workspace::Tab, + terminals: &std::collections::HashMap< + crate::terminal::TerminalId, + crate::terminal::TerminalState, + >, +) -> String { + activity_summary_for_panes(tab.panes.values(), terminals) +} + +fn workspace_activity_summary( + ws: &crate::workspace::Workspace, + terminals: &std::collections::HashMap< + crate::terminal::TerminalId, + crate::terminal::TerminalState, + >, +) -> String { + activity_summary_for_panes(ws.tabs.iter().flat_map(|tab| tab.panes.values()), terminals) +} + +fn activity_summary_for_panes<'a>( + panes: impl Iterator<Item = &'a crate::pane::PaneState>, + terminals: &std::collections::HashMap< + crate::terminal::TerminalId, + crate::terminal::TerminalState, + >, +) -> String { + let mut blocked = 0usize; + let mut working = 0usize; + let mut done = 0usize; + for pane in panes { + let Some(terminal) = terminals.get(&pane.attached_terminal_id) else { + continue; + }; + match (terminal.state, pane.seen) { + (AgentState::Blocked, _) => blocked += 1, + (AgentState::Working, _) => working += 1, + (AgentState::Idle, false) => done += 1, + _ => {} + } + } + + let mut parts = Vec::new(); + if blocked > 0 { + parts.push(format!("{blocked} blocked")); + } + if working > 0 { + parts.push(format!("{working} working")); + } + if done > 0 { + parts.push(format!("{done} done")); + } + parts.join(" · ") +} + +// --------------------------------------------------------------------------- // Workspace operations // --------------------------------------------------------------------------- @@ -1140,6 +1626,221 @@ mod tests { } #[test] + fn navigator_rows_show_tab_nodes_only_for_multi_tab_workspaces() { + let mut state = app_with_workspaces(&["single", "multi"]); + state.workspaces[1].test_add_tab(Some("tests")); + state.ensure_test_terminals(); + + state.open_navigator(); + let rows = state.navigator_rows(); + + assert!(!rows.iter().any(|row| matches!( + row.target, + crate::app::state::NavigatorTarget::Tab { ws_idx: 0, .. } + ))); + assert!(rows.iter().any(|row| matches!( + row.target, + crate::app::state::NavigatorTarget::Tab { + ws_idx: 1, + tab_idx: 0 + } + ))); + assert!(rows.iter().any(|row| matches!( + row.target, + crate::app::state::NavigatorTarget::Tab { + ws_idx: 1, + tab_idx: 1 + } + ))); + } + + #[test] + fn navigator_rows_include_shell_and_agent_panes() { + let mut state = app_with_workspaces(&["one"]); + let shell = state.workspaces[0].tabs[0].root_pane; + let agent = state.workspaces[0].test_split(Direction::Horizontal); + state.ensure_test_terminals(); + + let agent_terminal_id = state.workspaces[0].terminal_id(agent).cloned().unwrap(); + let terminal = state.terminals.get_mut(&agent_terminal_id).unwrap(); + terminal.set_detected_state(Some(Agent::Claude), AgentState::Working); + + state.open_navigator(); + let rows = state.navigator_rows(); + + assert!(rows.iter().any(|row| matches!( + row.target, + crate::app::state::NavigatorTarget::Pane { pane_id, .. } if pane_id == shell + ))); + assert!(rows.iter().any(|row| matches!( + row.target, + crate::app::state::NavigatorTarget::Pane { pane_id, .. } if pane_id == agent + ) && row.meta.contains("claude"))); + } + + #[test] + fn opening_navigator_selects_current_pane_and_expands_attention_workspaces() { + let mut state = app_with_workspaces(&["one", "two"]); + let blocked = state.workspaces[1].tabs[0].root_pane; + let blocked_terminal_id = state.workspaces[1].terminal_id(blocked).cloned().unwrap(); + state + .terminals + .get_mut(&blocked_terminal_id) + .unwrap() + .set_detected_state(Some(Agent::Codex), AgentState::Blocked); + + state.open_navigator(); + let selected = state.navigator_rows()[state.navigator.selected].clone(); + + assert!(selected.is_current); + assert!(state + .navigator + .expanded_workspaces + .contains(&state.workspaces[0].id)); + assert!(state + .navigator + .expanded_workspaces + .contains(&state.workspaces[1].id)); + } + + #[test] + fn accepting_navigator_pane_switches_workspace_tab_and_focus() { + let mut state = app_with_workspaces(&["one", "two"]); + let target = state.workspaces[1].tabs[0].root_pane; + state.open_navigator(); + state + .navigator + .expanded_workspaces + .insert(state.workspaces[1].id.clone()); + state.navigator.selected = state + .navigator_rows() + .iter() + .position(|row| { + matches!( + row.target, + crate::app::state::NavigatorTarget::Pane { pane_id, .. } if pane_id == target + ) + }) + .unwrap(); + + assert!(state.accept_navigator_selection()); + + assert_eq!(state.active, Some(1)); + assert_eq!(state.workspaces[1].focused_pane_id(), Some(target)); + assert_eq!(state.mode, Mode::Terminal); + } + + #[test] + fn navigator_idle_search_matches_idle_agents_not_plain_shells() { + let mut state = app_with_workspaces(&["one"]); + let shell = state.workspaces[0].tabs[0].root_pane; + let agent = state.workspaces[0].test_split(Direction::Horizontal); + state.ensure_test_terminals(); + + let agent_terminal_id = state.workspaces[0].terminal_id(agent).cloned().unwrap(); + state + .terminals + .get_mut(&agent_terminal_id) + .unwrap() + .set_detected_state(Some(Agent::Claude), AgentState::Idle); + + state.open_navigator(); + state.navigator.query = "idle".into(); + let rows = state.navigator_rows(); + + assert!(rows.iter().any(|row| matches!( + row.target, + crate::app::state::NavigatorTarget::Pane { pane_id, .. } if pane_id == agent + ))); + assert!(!rows.iter().any(|row| matches!( + row.target, + crate::app::state::NavigatorTarget::Pane { pane_id, .. } if pane_id == shell + ))); + } + + #[test] + fn navigator_search_only_matches_visible_row_text() { + let mut state = app_with_workspaces(&["one"]); + state.workspaces[0].identity_cwd = "/tmp/herdr-worktrees/issue-work".into(); + + state.open_navigator(); + state.navigator.query = "work".into(); + + assert!(state.navigator_rows().is_empty()); + } + + #[test] + fn navigator_state_filter_is_separate_from_text_search() { + let mut state = app_with_workspaces(&["one"]); + let shell = state.workspaces[0].tabs[0].root_pane; + let working = state.workspaces[0].test_split(Direction::Horizontal); + state.ensure_test_terminals(); + + let shell_terminal_id = state.workspaces[0].terminal_id(shell).cloned().unwrap(); + state + .terminals + .get_mut(&shell_terminal_id) + .unwrap() + .set_manual_label("wheel notes".into()); + let working_terminal_id = state.workspaces[0].terminal_id(working).cloned().unwrap(); + state + .terminals + .get_mut(&working_terminal_id) + .unwrap() + .set_detected_state(Some(Agent::Codex), AgentState::Working); + + state.open_navigator(); + state.navigator.state_filter = Some(NavigatorStateFilter::Working); + let state_rows = state.navigator_rows(); + + assert!(state_rows.iter().any(|row| matches!( + row.target, + crate::app::state::NavigatorTarget::Pane { pane_id, .. } if pane_id == working + ))); + assert!(!state_rows.iter().any(|row| matches!( + row.target, + crate::app::state::NavigatorTarget::Pane { pane_id, .. } if pane_id == shell + ))); + + state.navigator.state_filter = None; + state.navigator.query = "w".into(); + let text_rows = state.navigator_rows(); + + assert!(text_rows.iter().any(|row| matches!( + row.target, + crate::app::state::NavigatorTarget::Pane { pane_id, .. } if pane_id == shell + ))); + assert!( + text_rows.iter().any(|row| matches!( + row.target, + crate::app::state::NavigatorTarget::Pane { pane_id, .. } if pane_id == working + )), + "literal one-letter search may still match visible state text" + ); + } + + #[test] + fn navigator_search_filters_panes_but_keeps_workspace_context() { + let mut state = app_with_workspaces(&["one"]); + let root = state.workspaces[0].tabs[0].root_pane; + let terminal_id = state.workspaces[0].terminal_id(root).cloned().unwrap(); + state + .terminals + .get_mut(&terminal_id) + .unwrap() + .set_manual_label("weekly review".into()); + state.open_navigator(); + state.navigator.query = "weekly".into(); + + let rows = state.navigator_rows(); + + assert!(rows.iter().any(|row| row.is_workspace)); + assert!(rows + .iter() + .any(|row| !row.is_workspace && row.label.contains("weekly"))); + } + + #[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/mod.rs b/src/app/input/mod.rs index ef8b93f3..44491631 100644 --- a/src/app/input/mod.rs +++ b/src/app/input/mod.rs @@ -34,7 +34,7 @@ mod terminal; pub(crate) use self::{ modal::{ handle_confirm_close_key, handle_context_menu_key, handle_global_menu_key, - handle_keybind_help_key, handle_rename_key, handle_resize_key, + handle_keybind_help_key, handle_navigator_key, handle_rename_key, handle_resize_key, }, navigate::terminal_direct_navigation_action, settings::open_settings_at, @@ -83,6 +83,7 @@ impl App { Mode::Settings => self.handle_settings_key(key_event), Mode::GlobalMenu => handle_global_menu_key(&mut self.state, key_event), Mode::KeybindHelp => handle_keybind_help_key(&mut self.state, key_event), + Mode::Navigator => handle_navigator_key(&mut self.state, key_event), Mode::Terminal => unreachable!(), } } diff --git a/src/app/input/modal.rs b/src/app/input/modal.rs index acd481b8..6821e64a 100644 --- a/src/app/input/modal.rs +++ b/src/app/input/modal.rs @@ -2,7 +2,9 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use ratatui::layout::{Direction, Rect}; use crate::{ - app::state::{AppState, ContextMenuKind, ContextMenuState, MenuListState, Mode}, + app::state::{ + AppState, ContextMenuKind, ContextMenuState, MenuListState, Mode, NavigatorStateFilter, + }, input::TerminalKey, layout::NavDirection, }; @@ -150,6 +152,121 @@ pub(crate) fn handle_global_menu_key(state: &mut AppState, key: KeyEvent) { } } +pub(crate) fn handle_navigator_key(state: &mut AppState, key: KeyEvent) { + if state.navigator.search_focused { + match key.code { + KeyCode::Esc => { + if state.navigator.query.is_empty() { + state.navigator.search_focused = false; + leave_modal(state); + } else { + state.navigator.query.clear(); + state.navigator.state_filter = None; + state.navigator.search_focused = false; + state.clamp_navigator_selection(); + } + } + KeyCode::Enter => { + state.accept_navigator_selection(); + } + KeyCode::Backspace => { + state.navigator.state_filter = None; + state.navigator.query.pop(); + state.clamp_navigator_selection(); + } + KeyCode::Up => state.move_navigator_selection(-1), + KeyCode::Down => state.move_navigator_selection(1), + KeyCode::Char('n') if key.modifiers == KeyModifiers::CONTROL => { + state.move_navigator_selection(1) + } + KeyCode::Char('p') if key.modifiers == KeyModifiers::CONTROL => { + state.move_navigator_selection(-1) + } + KeyCode::Char('u') if key.modifiers == KeyModifiers::CONTROL => { + state.navigator.query.clear(); + state.navigator.state_filter = None; + state.clamp_navigator_selection(); + } + KeyCode::Char(c) + if key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT => + { + state.navigator.state_filter = None; + state.navigator.query.push(c); + state.clamp_navigator_selection(); + } + _ => {} + } + return; + } + + match key.code { + KeyCode::Esc => { + if state.navigator.query.is_empty() && state.navigator.state_filter.is_none() { + leave_modal(state); + } else { + state.navigator.query.clear(); + state.navigator.state_filter = None; + state.clamp_navigator_selection(); + } + } + KeyCode::Enter => { + state.accept_navigator_selection(); + } + KeyCode::Char('/') => { + state.navigator.query.clear(); + state.navigator.state_filter = None; + state.navigator.search_focused = true; + state.clamp_navigator_selection(); + } + KeyCode::Backspace if state.navigator.state_filter.is_some() => { + state.navigator.state_filter = None; + state.clamp_navigator_selection(); + } + KeyCode::Char('a') if key.modifiers.is_empty() => { + state.navigator.query.clear(); + state.navigator.state_filter = None; + state.clamp_navigator_selection(); + } + KeyCode::Char('b') if key.modifiers.is_empty() => { + state.navigator.query.clear(); + state.navigator.state_filter = Some(NavigatorStateFilter::Blocked); + state.clamp_navigator_selection(); + } + KeyCode::Char('w') if key.modifiers.is_empty() => { + state.navigator.query.clear(); + state.navigator.state_filter = Some(NavigatorStateFilter::Working); + state.clamp_navigator_selection(); + } + KeyCode::Char('i') if key.modifiers.is_empty() => { + state.navigator.query.clear(); + state.navigator.state_filter = Some(NavigatorStateFilter::Idle); + state.clamp_navigator_selection(); + } + KeyCode::Char('d') if key.modifiers.is_empty() => { + state.navigator.query.clear(); + state.navigator.state_filter = Some(NavigatorStateFilter::Done); + state.clamp_navigator_selection(); + } + KeyCode::Char('j') | KeyCode::Down => state.move_navigator_selection(1), + KeyCode::Char('k') | KeyCode::Up => state.move_navigator_selection(-1), + KeyCode::Char('d') if key.modifiers == KeyModifiers::CONTROL => { + state.move_navigator_selection((state.navigator_body_rect().height / 2).max(1) as isize) + } + KeyCode::Char('u') if key.modifiers == KeyModifiers::CONTROL => state + .move_navigator_selection(-((state.navigator_body_rect().height / 2).max(1) as isize)), + KeyCode::Char(' ') => state.toggle_selected_navigator_workspace(), + KeyCode::Home => { + state.navigator.selected = 0; + state.ensure_navigator_selection_visible(); + } + KeyCode::End | KeyCode::Char('G') => { + state.navigator.selected = state.navigator_rows().len().saturating_sub(1); + state.ensure_navigator_selection_visible(); + } + _ => {} + } +} + pub(crate) fn handle_keybind_help_key(state: &mut AppState, key: KeyEvent) { match key.code { KeyCode::Up | KeyCode::Char('k') => state.scroll_keybind_help(-1), diff --git a/src/app/input/navigate.rs b/src/app/input/navigate.rs index da3d49bf..4e8a50b3 100644 --- a/src/app/input/navigate.rs +++ b/src/app/input/navigate.rs @@ -490,6 +490,7 @@ pub(crate) enum NavigateAction { ReloadConfig, OpenNotificationTarget, Detach, + OpenNavigator, } fn indexed_navigation_action( @@ -588,6 +589,7 @@ fn action_for_key( NavigateAction::OpenNotificationTarget, ), (&kb.detach, NavigateAction::Detach), + (&kb.goto, NavigateAction::OpenNavigator), ] { if action_matches(bindings, key, dispatch) { return Some(action); @@ -780,6 +782,7 @@ pub(super) fn execute_navigate_action_in_context( super::modal::request_detach(state); leave_navigate_mode(state); } + NavigateAction::OpenNavigator => state.open_navigator(), } finish_action_context(state, context, previous_mode); @@ -930,6 +933,18 @@ mod tests { } #[test] + fn default_goto_key_opens_navigator() { + let mut state = state_with_workspaces(&["test"]); + + handle_navigate_key( + &mut state, + KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty()), + ); + + assert_eq!(state.mode, Mode::Navigator); + } + + #[test] fn custom_rename_key_enters_rename_mode() { let mut state = state_with_workspaces(&["test"]); state.keybinds.rename_workspace = crate::config::ActionKeybinds::prefix("g"); @@ -1502,8 +1517,8 @@ mod tests { output_path.display() ); app.state.keybinds.custom_commands = vec![crate::config::CustomCommandKeybind { - bindings: crate::config::ActionKeybinds::prefix("g"), - label: "prefix+g".into(), + bindings: crate::config::ActionKeybinds::prefix("m"), + label: "prefix+m".into(), command, action: crate::config::CustomCommandAction::Shell, }]; @@ -1515,7 +1530,7 @@ mod tests { .await; assert_eq!(app.state.mode, Mode::Prefix); - app.handle_key(TerminalKey::new(KeyCode::Char('g'), KeyModifiers::empty())) + app.handle_key(TerminalKey::new(KeyCode::Char('m'), KeyModifiers::empty())) .await; let content = wait_for_file(&output_path); @@ -1561,8 +1576,8 @@ mod tests { let output_path = unique_temp_path("custom-pane-command"); let command = format!("printf done > '{}'", output_path.display()); app.state.keybinds.custom_commands = vec![crate::config::CustomCommandKeybind { - bindings: crate::config::ActionKeybinds::prefix("g"), - label: "prefix+g".into(), + bindings: crate::config::ActionKeybinds::prefix("m"), + label: "prefix+m".into(), command, action: crate::config::CustomCommandAction::Pane, }]; @@ -1572,7 +1587,7 @@ mod tests { app.state.prefix_mods, )) .await; - app.handle_key(TerminalKey::new(KeyCode::Char('g'), KeyModifiers::empty())) + app.handle_key(TerminalKey::new(KeyCode::Char('m'), KeyModifiers::empty())) .await; assert_eq!(app.state.workspaces[0].tabs[0].layout.pane_count(), 2); diff --git a/src/app/input/overlays.rs b/src/app/input/overlays.rs index 365de6b6..7d2a8ff3 100644 --- a/src/app/input/overlays.rs +++ b/src/app/input/overlays.rs @@ -5,7 +5,7 @@ use ratatui::{ }; use crate::app::{ - state::{AppState, DragState, DragTarget, Mode}, + state::{AppState, DragState, DragTarget, Mode, NavigatorTarget}, App, }; @@ -14,6 +14,10 @@ use super::{ ScrollbarClickTarget, }; +fn rect_contains(rect: Rect, col: u16, row: u16) -> bool { + col >= rect.x && col < rect.x + rect.width && row >= rect.y && row < rect.y + rect.height +} + impl App { pub(super) fn handle_overlay_mouse(&mut self, mouse: MouseEvent) -> bool { if self.state.mode == Mode::ReleaseNotes { @@ -119,6 +123,60 @@ impl App { return true; } + if self.state.mode == Mode::Navigator { + match mouse.kind { + MouseEventKind::Moved => { + if let Some(idx) = self.state.navigator_row_index_at(mouse.column, mouse.row) { + self.state.navigator.selected = idx; + self.state.ensure_navigator_selection_visible(); + } + } + MouseEventKind::Down(MouseButton::Left) => { + if self + .state + .navigator_search_contains(mouse.column, mouse.row) + { + self.state.navigator.search_focused = true; + } else if let Some(idx) = + self.state.navigator_row_index_at(mouse.column, mouse.row) + { + self.state.navigator.selected = idx; + let target = self + .state + .navigator_rows() + .get(idx) + .map(|row| (row.target.clone(), row.is_workspace)); + if let Some((NavigatorTarget::Workspace { .. }, true)) = target { + if self.state.navigator_row_caret_at(mouse.column) { + self.state.toggle_selected_navigator_workspace(); + } else { + self.state.accept_navigator_selection(); + } + } else { + self.state.accept_navigator_selection(); + } + } else if !self.state.navigator_popup_contains(mouse.column, mouse.row) { + leave_modal(&mut self.state); + } + } + 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(); + } + MouseEventKind::ScrollDown => { + let viewport = self.state.navigator_body_rect().height as usize; + let max = self.state.navigator_max_scroll(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(); + } + _ => {} + } + return true; + } + if self.state.mode == Mode::KeybindHelp { match mouse.kind { MouseEventKind::Down(MouseButton::Left) @@ -188,6 +246,89 @@ impl AppState { self.view.sidebar_rect.union(self.view.terminal_area) } + pub(crate) fn navigator_popup_rect(&self) -> Rect { + let area = self.onboarding_full_area(); + let margin_x = (area.width / 16).max(2); + let margin_y = (area.height / 10).max(1); + let width = area.width.saturating_sub(margin_x.saturating_mul(2)); + let height = area.height.saturating_sub(margin_y.saturating_mul(2)); + Rect::new( + area.x + margin_x, + area.y + margin_y, + width.max(4), + height.max(4), + ) + } + + pub(crate) fn navigator_inner_rect(&self) -> Rect { + Block::default() + .borders(Borders::ALL) + .inner(self.navigator_popup_rect()) + } + + pub(crate) fn navigator_search_rect(&self) -> Rect { + let inner = self.navigator_inner_rect(); + Rect::new(inner.x, inner.y, inner.width, inner.height.min(1)) + } + + pub(crate) fn navigator_body_rect(&self) -> Rect { + let inner = self.navigator_inner_rect(); + if inner.height <= 4 { + return Rect::default(); + } + Rect::new( + inner.x, + inner.y + 2, + inner.width, + inner.height.saturating_sub(4), + ) + } + + pub(crate) fn navigator_detail_rect(&self) -> Rect { + let inner = self.navigator_inner_rect(); + Rect::new( + inner.x, + inner.y + inner.height.saturating_sub(2), + inner.width, + inner.height.min(1), + ) + } + + pub(crate) fn navigator_footer_rect(&self) -> Rect { + let inner = self.navigator_inner_rect(); + Rect::new( + inner.x, + inner.y + inner.height.saturating_sub(1), + inner.width, + inner.height.min(1), + ) + } + + pub(crate) fn navigator_popup_contains(&self, col: u16, row: u16) -> bool { + rect_contains(self.navigator_popup_rect(), col, row) + } + + pub(crate) fn navigator_search_contains(&self, col: u16, row: u16) -> bool { + rect_contains(self.navigator_search_rect(), col, row) + } + + pub(crate) fn navigator_row_index_at(&self, col: u16, row: u16) -> Option<usize> { + let body = self.navigator_body_rect(); + if !rect_contains(body, col, row) { + return None; + } + let idx = self + .navigator + .scroll + .saturating_add(row.saturating_sub(body.y) as usize); + (idx < self.navigator_rows().len()).then_some(idx) + } + + pub(crate) fn navigator_row_caret_at(&self, col: u16) -> bool { + let body = self.navigator_body_rect(); + col <= body.x.saturating_add(3) + } + pub(super) fn onboarding_modal_inner(&self, popup_w: u16, popup_h: u16) -> Option<Rect> { let area = self.onboarding_full_area(); let popup_w = popup_w.min(area.width.saturating_sub(4)); diff --git a/src/app/mod.rs b/src/app/mod.rs index cfd5b5f8..8a981226 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -401,6 +401,7 @@ impl App { } }), keybind_help: state::KeybindHelpState { scroll: 0 }, + navigator: state::NavigatorState::default(), workspace_scroll: 0, agent_panel_scroll: 0, tab_scroll: 0, @@ -1224,6 +1225,9 @@ impl App { Mode::Settings => { self.handle_settings_key(key_event); } + Mode::Navigator => { + input::handle_navigator_key(&mut self.state, key_event); + } Mode::Terminal => { // Should not be called in terminal mode. } @@ -1473,7 +1477,7 @@ mod tests { std::fs::create_dir_all(path.parent().unwrap()).unwrap(); std::fs::write( &path, - "[terminal]\ndefault_shell = \"nu\"\nnew_cwd = \"home\"\n[keys]\nnew_workspace = \"prefix+g\"\nprefix = \"ctrl+a\"\n[ui]\nagent_panel_scope = \"current\"\n[ui.toast]\ndelivery = \"herdr\"\n", + "[terminal]\ndefault_shell = \"nu\"\nnew_cwd = \"home\"\n[keys]\nnew_workspace = \"prefix+m\"\nprefix = \"ctrl+a\"\n[ui]\nagent_panel_scope = \"current\"\n[ui.toast]\ndelivery = \"herdr\"\n", ) .unwrap(); std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path); @@ -1488,7 +1492,7 @@ mod tests { .state .keybinds .new_workspace - .matches_prefix(&KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty()))); + .matches_prefix(&KeyEvent::new(KeyCode::Char('m'), KeyModifiers::empty()))); assert_eq!( app.state.toast_config.delivery, crate::config::ToastDelivery::Herdr @@ -1703,7 +1707,7 @@ mod tests { std::fs::create_dir_all(path.parent().unwrap()).unwrap(); std::fs::write( &path, - "[keys]\nnew_workspace = \"prefix+g\"\n[ui.toast]\ndelivery = \"desktop\"\n", + "[keys]\nnew_workspace = \"prefix+m\"\n[ui.toast]\ndelivery = \"desktop\"\n", ) .unwrap(); std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path); @@ -1717,7 +1721,7 @@ mod tests { .state .keybinds .new_workspace - .matches_prefix(&KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty()))); + .matches_prefix(&KeyEvent::new(KeyCode::Char('m'), KeyModifiers::empty()))); assert_eq!( app.state.toast_config.delivery, crate::config::ToastDelivery::Herdr diff --git a/src/app/state.rs b/src/app/state.rs index 5d4fb67d..43d6e00f 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -3,6 +3,7 @@ use crossterm::event::{KeyCode, KeyModifiers}; use ratatui::layout::{Direction, Rect}; use ratatui::style::Color; +use crate::detect::AgentState; use crate::layout::{PaneId, PaneInfo, SplitBorder}; use crate::selection::Selection; @@ -650,6 +651,56 @@ pub enum Mode { Settings, GlobalMenu, KeybindHelp, + Navigator, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum NavigatorTarget { + Workspace { + ws_idx: usize, + }, + Tab { + ws_idx: usize, + tab_idx: usize, + }, + Pane { + ws_idx: usize, + tab_idx: usize, + pane_id: PaneId, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct NavigatorRow { + pub target: NavigatorTarget, + pub depth: u8, + pub label: String, + pub meta: String, + pub status: AgentState, + pub seen: bool, + pub is_current: bool, + pub is_workspace: bool, + pub is_tab: bool, + pub expanded: bool, + pub search_text: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum NavigatorStateFilter { + Blocked, + Working, + Idle, + Done, +} + +#[derive(Debug, Clone, Default)] +pub(crate) struct NavigatorState { + pub query: String, + pub selected: usize, + pub scroll: usize, + pub search_focused: bool, + pub state_filter: Option<NavigatorStateFilter>, + pub expanded_workspaces: std::collections::HashSet<String>, } #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] @@ -1021,6 +1072,7 @@ pub struct AppState { pub release_notes: Option<ReleaseNotesState>, pub product_announcement: Option<ProductAnnouncementState>, pub keybind_help: KeybindHelpState, + pub navigator: NavigatorState, pub workspace_scroll: usize, pub agent_panel_scroll: usize, pub tab_scroll: usize, @@ -1307,6 +1359,7 @@ impl AppState { release_notes: None, product_announcement: None, keybind_help: KeybindHelpState { scroll: 0 }, + navigator: NavigatorState::default(), workspace_scroll: 0, agent_panel_scroll: 0, tab_scroll: 0, |
