diff --git a/src/app/actions.rs b/src/app/actions.rs index 160947ff..f6c76a36 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -12,8 +12,8 @@ use crate::workspace::WorkspaceGitStatus; use unicode_width::UnicodeWidthChar; use super::state::{ - AppState, Mode, NavigatorRow, NavigatorStateFilter, NavigatorTarget, PaneFocusTarget, - ToastKind, ToastNotification, ToastTarget, ViewLayout, + text_matches_query, AppState, Mode, NavigatorRow, NavigatorStateFilter, NavigatorTarget, + PaneFocusTarget, ToastKind, ToastNotification, ToastTarget, ViewLayout, }; fn is_background_completion_transition(prev_state: AgentState, new_state: AgentState) -> bool { @@ -558,9 +558,7 @@ fn navigator_state_filter_matches( } fn navigator_matches(query: &str, text: &str) -> bool { - query - .split_whitespace() - .all(|part| text.contains(&part.to_lowercase())) + text_matches_query(query, text) } fn launch_label(argv: Option<&Vec>) -> Option { @@ -1390,11 +1388,68 @@ impl AppState { } } - pub fn close_pane(&mut self) { + pub(crate) fn workspace_close_would_close_worktree_group(&self, ws_idx: usize) -> bool { + self.workspaces + .get(ws_idx) + .and_then(|ws| ws.worktree_space()) + .filter(|space| !space.is_linked_worktree) + .is_some_and(|space| { + self.workspaces + .iter() + .filter(|ws| { + ws.worktree_space() + .is_some_and(|member| member.key == space.key) + }) + .count() + >= 2 + }) + } + + pub(crate) fn confirm_implicit_worktree_group_close(&mut self, ws_idx: usize) -> bool { + if self.confirm_close && self.workspace_close_would_close_worktree_group(ws_idx) { + self.selected = ws_idx; + self.mode = Mode::ConfirmClose; + true + } else { + false + } + } + + fn close_focused_pane_would_close_workspace(&self, ws_idx: usize) -> bool { + self.workspaces.get(ws_idx).is_some_and(|ws| { + let pane_count = ws + .active_tab() + .map(|tab| tab.layout.pane_count()) + .unwrap_or(0); + pane_count <= 1 && ws.tabs.len() <= 1 + }) + } + + pub(crate) fn close_pane_would_close_workspace(&self, ws_idx: usize, pane_id: PaneId) -> bool { + self.workspaces.get(ws_idx).is_some_and(|ws| { + ws.find_tab_index_for_pane(pane_id).is_some_and(|tab_idx| { + ws.tabs[tab_idx].layout.pane_count() <= 1 && ws.tabs.len() <= 1 + }) + }) + } + + /// Close the focused pane. Returns true when the close was deferred to confirmation. + pub fn close_pane(&mut self) -> bool { + let active = self.active; + if active.is_some_and(|ws_idx| { + self.close_focused_pane_would_close_workspace(ws_idx) + && self.workspace_close_would_close_worktree_group(ws_idx) + }) { + if let Some(ws_idx) = active { + if self.confirm_implicit_worktree_group_close(ws_idx) { + return true; + } + } + } + self.selection = None; self.selection_autoscroll = None; self.mark_session_dirty(); - let active = self.active; let terminal_ids = active .and_then(|i| { self.workspaces @@ -1415,9 +1470,24 @@ impl AppState { } else { self.remove_unattached_terminal_ids(terminal_ids); } + false } - pub fn close_tab(&mut self) { + /// Close the active tab. Returns true when the close was deferred to confirmation. + pub fn close_tab(&mut self) -> bool { + if self.active.is_some_and(|ws_idx| { + self.workspaces + .get(ws_idx) + .is_some_and(|ws| ws.tabs.len() <= 1) + && self.workspace_close_would_close_worktree_group(ws_idx) + }) { + if let Some(ws_idx) = self.active { + if self.confirm_implicit_worktree_group_close(ws_idx) { + return true; + } + } + } + self.selection = None; self.selection_autoscroll = None; self.mark_session_dirty(); @@ -1430,7 +1500,7 @@ impl AppState { self.selected = active; } self.close_selected_workspace(); - return; + return false; } if let Some(ws_idx) = self.active { let terminal_ids = self @@ -1439,7 +1509,7 @@ impl AppState { .map(|ws| self.terminal_ids_for_tab(ws_idx, ws.active_tab)) .unwrap_or_default(); let Some(ws) = self.workspaces.get_mut(ws_idx) else { - return; + return false; }; let workspace_id = ws.id.clone(); let closing_tab_id = format!("{}:{}", workspace_id, ws.active_tab + 1); @@ -1449,6 +1519,7 @@ impl AppState { self.tab_scroll_follow_active = true; self.refresh_tab_bar_view(); } + false } } @@ -2255,6 +2326,16 @@ mod tests { }); } + fn mark_parent_worktree(state: &mut AppState, ws_idx: usize) { + state.workspaces[ws_idx].worktree_space = Some(crate::workspace::WorktreeSpaceMembership { + key: "repo-key".into(), + label: "herdr".into(), + repo_root: "/repo/herdr".into(), + checkout_path: "/repo/herdr".into(), + is_linked_worktree: false, + }); + } + #[test] fn notification_context_formats_resolved_workspace_label() { let state = app_with_workspaces(&["stale"]); @@ -3907,6 +3988,22 @@ mod tests { assert!(!state.terminals.contains_key(&active_terminal_id)); } + #[test] + fn close_pane_last_pane_in_parent_worktree_group_prompts() { + let mut state = app_with_workspaces(&["parent", "child"]); + mark_parent_worktree(&mut state, 0); + mark_linked_worktree(&mut state, 1); + state.active = Some(0); + state.selected = 1; + + let deferred = state.close_pane(); + + assert!(deferred); + assert_eq!(state.mode, Mode::ConfirmClose); + assert_eq!(state.selected, 0); + assert_eq!(state.workspaces.len(), 2); + } + #[test] fn close_tab_in_linked_worktree_closes_workspace_only() { let mut state = app_with_workspaces(&["selected", "active"]); @@ -3921,6 +4018,22 @@ mod tests { assert_eq!(state.workspaces[0].display_name(), "selected"); } + #[test] + fn close_tab_last_tab_in_parent_worktree_group_prompts() { + let mut state = app_with_workspaces(&["parent", "child"]); + mark_parent_worktree(&mut state, 0); + mark_linked_worktree(&mut state, 1); + state.active = Some(0); + state.selected = 1; + + let deferred = state.close_tab(); + + assert!(deferred); + assert_eq!(state.mode, Mode::ConfirmClose); + assert_eq!(state.selected, 0); + assert_eq!(state.workspaces.len(), 2); + } + #[test] fn close_pane_last_pane_in_linked_worktree_closes_workspace_only() { let mut state = app_with_workspaces(&["selected", "active"]); @@ -3934,4 +4047,20 @@ mod tests { assert_eq!(state.workspaces.len(), 1); assert_eq!(state.workspaces[0].display_name(), "selected"); } + + #[test] + fn close_pane_last_pane_in_parent_worktree_group_closes_when_confirmation_disabled() { + let mut state = app_with_workspaces(&["parent", "child", "notes"]); + mark_parent_worktree(&mut state, 0); + mark_linked_worktree(&mut state, 1); + state.confirm_close = false; + state.active = Some(0); + state.selected = 0; + + let deferred = state.close_pane(); + + assert!(!deferred); + assert_eq!(state.workspaces.len(), 1); + assert_eq!(state.workspaces[0].display_name(), "notes"); + } } diff --git a/src/app/api/panes.rs b/src/app/api/panes.rs index 67bac8d3..0902ac39 100644 --- a/src/app/api/panes.rs +++ b/src/app/api/panes.rs @@ -387,6 +387,15 @@ impl App { let Some((ws_idx, pane_id)) = self.parse_pane_id(&target.pane_id) else { return pane_not_found(id, &target.pane_id); }; + if self.state.close_pane_would_close_workspace(ws_idx, pane_id) + && self.state.confirm_implicit_worktree_group_close(ws_idx) + { + return encode_error( + id, + "confirmation_required", + "closing this pane would close a worktree group", + ); + } let workspace_id = self.state.workspaces[ws_idx].id.clone(); let terminal_id = self.state.terminal_id_for_pane(ws_idx, pane_id); let should_close_workspace = { diff --git a/src/app/input/modal.rs b/src/app/input/modal.rs index 6cde98b5..e85e9bb1 100644 --- a/src/app/input/modal.rs +++ b/src/app/input/modal.rs @@ -695,12 +695,13 @@ pub(super) fn apply_context_menu_action( state.selected = ws_idx; state.active = Some(ws_idx); state.switch_tab(tab_idx); - state.close_tab(); - state.mode = if state.active.is_some() { - Mode::Terminal - } else { - Mode::Navigate - }; + if !state.close_tab() { + state.mode = if state.active.is_some() { + Mode::Terminal + } else { + Mode::Navigate + }; + } } (ContextMenuKind::Pane { pane_id, .. }, Some("Rename pane")) => { open_rename_pane(state, pane_id); @@ -732,12 +733,13 @@ pub(super) fn apply_context_menu_action( state.mode = Mode::Terminal; } (ContextMenuKind::Pane { .. }, Some("Close pane")) => { - state.close_pane(); - state.mode = if state.active.is_some() { - Mode::Terminal - } else { - Mode::Navigate - }; + if !state.close_pane() { + state.mode = if state.active.is_some() { + Mode::Terminal + } else { + Mode::Navigate + }; + } } _ => leave_modal(state), } @@ -1260,4 +1262,42 @@ mod tests { assert!(state.workspaces.is_empty()); assert_eq!(state.mode, Mode::Navigate); } + + #[test] + fn context_menu_close_pane_last_parent_group_pane_keeps_confirmation_mode() { + let mut state = state_with_workspaces(&["main", "issue"]); + state.active = Some(0); + state.selected = 1; + state.workspaces[0].worktree_space = Some(crate::workspace::WorktreeSpaceMembership { + key: "repo-key".into(), + label: "herdr".into(), + repo_root: "/repo/herdr".into(), + checkout_path: "/repo/herdr".into(), + is_linked_worktree: false, + }); + state.workspaces[1].worktree_space = Some(crate::workspace::WorktreeSpaceMembership { + key: "repo-key".into(), + label: "herdr".into(), + repo_root: "/repo/herdr".into(), + checkout_path: "/repo/herdr-issue".into(), + is_linked_worktree: true, + }); + let pane_id = state.workspaces[0].tabs[0].root_pane; + let menu = ContextMenuState { + kind: ContextMenuKind::Pane { + pane_id, + has_manual_label: false, + }, + x: 0, + y: 0, + list: MenuListState::new(4), + }; + let mut terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new(); + + apply_context_menu_action(&mut state, &mut terminal_runtimes, menu, 4); + + assert_eq!(state.selected, 0); + assert_eq!(state.mode, Mode::ConfirmClose); + assert_eq!(state.workspaces.len(), 2); + } } diff --git a/src/app/input/mouse.rs b/src/app/input/mouse.rs index e3ad0bc5..cc03c8f6 100644 --- a/src/app/input/mouse.rs +++ b/src/app/input/mouse.rs @@ -145,14 +145,13 @@ impl AppState { match mouse.kind { MouseEventKind::ScrollUp => { if let Some(open) = &mut self.worktree_open { - open.selected = open.selected.saturating_sub(1); + open.select_previous_filtered(); } return None; } MouseEventKind::ScrollDown => { if let Some(open) = &mut self.worktree_open { - open.selected = - (open.selected + 1).min(open.entries.len().saturating_sub(1)); + open.select_next_filtered(); } return None; } @@ -237,21 +236,28 @@ impl AppState { self.screen_rect(), open.entries.len(), ) { - let max_rows = inner.height.saturating_sub(4) as usize; - let start = crate::ui::open_existing_worktree_visible_start( - open.selected, - max_rows, - ); + let filtered = open.filtered_indices(); + let max_rows = + crate::ui::open_existing_worktree_max_visible_rows(inner); + let start = + crate::ui::open_existing_worktree_visible_start(open, max_rows); + if mouse.row == inner.y.saturating_add(1) + && mouse.column >= inner.x + && mouse.column < inner.x.saturating_add(inner.width) + { + if let Some(open) = &mut self.worktree_open { + open.search_focused = true; + } + return None; + } let row_idx = if rect_contains(inner, mouse.column, mouse.row) { mouse .row - .checked_sub(inner.y.saturating_add(2)) + .checked_sub(inner.y.saturating_add(3)) .map(usize::from) + .map(|row| row / 2) .filter(|row| *row < max_rows) - .and_then(|row| { - let entry_idx = start + row; - (entry_idx < open.entries.len()).then_some(entry_idx) - }) + .and_then(|row| filtered.get(start + row).copied()) } else { None }; @@ -1657,6 +1663,8 @@ mod tests { }, ], selected: 0, + query: String::new(), + search_focused: false, error: None, } } @@ -1810,7 +1818,7 @@ mod tests { app.handle_mouse(mouse( MouseEventKind::Down(MouseButton::Left), inner.x + 1, - inner.y + 3, + inner.y + 5, )); assert_eq!(app.state.worktree_open.as_ref().unwrap().selected, 1); diff --git a/src/app/input/navigate.rs b/src/app/input/navigate.rs index 8d09c00a..b6edefaf 100644 --- a/src/app/input/navigate.rs +++ b/src/app/input/navigate.rs @@ -761,8 +761,9 @@ pub(super) fn execute_navigate_action_in_context( leave_navigate_mode(state); } NavigateAction::CloseTab => { - state.close_tab(); - leave_navigate_mode(state); + if !state.close_tab() { + leave_navigate_mode(state); + } } NavigateAction::RenamePane => { if let Some(pane_id) = state @@ -786,8 +787,9 @@ pub(super) fn execute_navigate_action_in_context( leave_navigate_mode(state); } NavigateAction::ClosePane => { - state.close_pane(); - leave_navigate_mode(state); + if !state.close_pane() { + leave_navigate_mode(state); + } } NavigateAction::EditScrollback => {} NavigateAction::CopyMode => state.enter_copy_mode(terminal_runtimes), @@ -1756,6 +1758,22 @@ last_pane = "prefix+tab" assert_eq!(state.mode, Mode::Terminal); } + #[test] + fn prefix_close_pane_last_parent_group_pane_opens_confirmation() { + let mut state = state_with_workspaces(&["main", "issue"]); + mark_worktree_space_member(&mut state, 0, "repo-key"); + mark_worktree_space_member(&mut state, 1, "repo-key"); + state.selected = 1; + state.active = Some(0); + state.mode = Mode::Navigate; + + execute_navigate_action(&mut state, NavigateAction::ClosePane); + + assert_eq!(state.selected, 0); + assert_eq!(state.mode, Mode::ConfirmClose); + assert_eq!(state.workspaces.len(), 2); + } + #[tokio::test] async fn custom_command_runs_from_prefix_key_in_navigate_mode() { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); diff --git a/src/app/mod.rs b/src/app/mod.rs index 11931e51..661926ee 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -2888,6 +2888,47 @@ mod tests { assert!(app.state.workspaces.is_empty()); } + #[test] + fn pane_close_request_requires_confirmation_before_closing_parent_worktree_group() { + let mut app = test_app(); + let mut parent = Workspace::test_new("api-pane-close-parent"); + parent.worktree_space = Some(crate::workspace::WorktreeSpaceMembership { + key: "repo-key".into(), + label: "herdr".into(), + repo_root: "/repo/herdr".into(), + checkout_path: "/repo/herdr".into(), + is_linked_worktree: false, + }); + let mut child = Workspace::test_new("api-pane-close-child"); + child.worktree_space = Some(crate::workspace::WorktreeSpaceMembership { + key: "repo-key".into(), + label: "herdr".into(), + repo_root: "/repo/herdr".into(), + checkout_path: "/repo/herdr-child".into(), + is_linked_worktree: true, + }); + app.state.workspaces = vec![parent, child]; + app.state.ensure_test_terminals(); + app.state.active = Some(0); + app.state.selected = 1; + + let target_pane = app.state.workspaces[0].tabs[0].root_pane; + let target_pane_id = app.pane_info(0, target_pane).unwrap().pane_id; + + let response = app.handle_api_request(crate::api::schema::Request { + id: "req_pane_close_parent_group".into(), + method: crate::api::schema::Method::PaneClose(crate::api::schema::PaneTarget { + pane_id: target_pane_id, + }), + }); + let response: serde_json::Value = serde_json::from_str(&response).unwrap(); + + assert_eq!(response["error"]["code"], "confirmation_required"); + assert_eq!(app.state.mode, Mode::ConfirmClose); + assert_eq!(app.state.selected, 0); + assert_eq!(app.state.workspaces.len(), 2); + } + #[test] fn session_dirty_flag_schedules_debounced_save() { let mut app = test_app(); diff --git a/src/app/state.rs b/src/app/state.rs index a9c1b8a3..9466fcce 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -593,6 +593,48 @@ pub struct WorktreeOpenEntry { pub already_open_ws_idx: Option, } +impl WorktreeOpenEntry { + pub(crate) fn display_name(&self) -> String { + self.branch.clone().unwrap_or_else(|| { + self.path + .file_name() + .and_then(|name| name.to_str()) + .map(str::to_owned) + .unwrap_or_else(|| self.path.display().to_string()) + }) + } + + pub(crate) fn status_label(&self) -> &'static str { + if self.already_open_ws_idx.is_some() { + "open" + } else if self.branch.is_some() { + "" + } else if self.is_linked_worktree { + "detached" + } else { + "root" + } + } + + fn search_text(&self) -> String { + format!( + "{} {} {} {}", + self.display_name(), + self.path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or_default(), + self.path.display(), + self.status_label() + ) + .to_lowercase() + } + + fn matches_query(&self, query: &str) -> bool { + text_matches_query(query, &self.search_text()) + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct WorktreeOpenState { pub source_workspace_id: String, @@ -603,9 +645,65 @@ pub struct WorktreeOpenState { pub repo_name: String, pub entries: Vec, pub selected: usize, + pub query: String, + pub search_focused: bool, pub error: Option, } +impl WorktreeOpenState { + pub(crate) fn filtered_indices(&self) -> Vec { + let query = self.query.trim(); + self.entries + .iter() + .enumerate() + .filter_map(|(idx, entry)| { + (query.is_empty() || entry.matches_query(query)).then_some(idx) + }) + .collect() + } + + pub(crate) fn selected_entry_index(&self) -> Option { + let indices = self.filtered_indices(); + if indices.contains(&self.selected) { + Some(self.selected) + } else { + indices.first().copied() + } + } + + pub(crate) fn normalize_selection(&mut self) { + if let Some(selected) = self.selected_entry_index() { + self.selected = selected; + } + } + + pub(crate) fn select_previous_filtered(&mut self) { + let indices = self.filtered_indices(); + let Some(current) = self.selected_entry_index() else { + return; + }; + let pos = indices.iter().position(|idx| *idx == current).unwrap_or(0); + self.selected = indices[pos.saturating_sub(1)]; + } + + pub(crate) fn select_next_filtered(&mut self) { + let indices = self.filtered_indices(); + let Some(current) = self.selected_entry_index() else { + return; + }; + let pos = indices.iter().position(|idx| *idx == current).unwrap_or(0); + self.selected = indices[(pos + 1).min(indices.len().saturating_sub(1))]; + } +} + +pub(crate) fn text_matches_query(query: &str, text: &str) -> bool { + let haystack = text.to_lowercase(); + query + .to_lowercase() + .split_whitespace() + .all(|needle| haystack.contains(needle)) +} + /// Computed view geometry — derived from AppState + terminal size. /// Updated before each render, consumed by render and mouse handling. #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/src/app/worktrees.rs b/src/app/worktrees.rs index d20b5fb0..7874cb0b 100644 --- a/src/app/worktrees.rs +++ b/src/app/worktrees.rs @@ -1,7 +1,7 @@ use std::sync::atomic::Ordering; use std::time::{SystemTime, UNIX_EPOCH}; -use crossterm::event::{KeyCode, KeyEvent}; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use super::{ state::{WorktreeCreateState, WorktreeOpenEntry, WorktreeOpenState, WorktreeRemoveState}, @@ -225,6 +225,8 @@ impl App { repo_name: space.label, entries, selected: 0, + query: String::new(), + search_focused: false, error: None, }); self.state.mode = Mode::OpenExistingWorktree; @@ -277,15 +279,49 @@ impl App { } KeyCode::Up => { if let Some(open) = &mut self.state.worktree_open { - open.selected = open.selected.saturating_sub(1); + open.select_previous_filtered(); } } KeyCode::Down => { if let Some(open) = &mut self.state.worktree_open { - open.selected = open - .selected - .saturating_add(1) - .min(open.entries.len().saturating_sub(1)); + open.select_next_filtered(); + } + } + KeyCode::Char('/') => { + if let Some(open) = &mut self.state.worktree_open { + if open.search_focused { + open.query.push('/'); + open.normalize_selection(); + } else { + open.search_focused = true; + } + } + } + KeyCode::Char(ch) + if self + .state + .worktree_open + .as_ref() + .is_some_and(|open| open.search_focused) + && (key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT) => + { + if let Some(open) = &mut self.state.worktree_open { + if !ch.is_control() { + open.query.push(ch); + open.normalize_selection(); + } + } + } + KeyCode::Backspace + if self + .state + .worktree_open + .as_ref() + .is_some_and(|open| open.search_focused) => + { + if let Some(open) = &mut self.state.worktree_open { + open.query.pop(); + open.normalize_selection(); } } KeyCode::Enter => self.open_selected_existing_worktree(), @@ -297,7 +333,10 @@ impl App { let Some(open) = self.state.worktree_open.as_ref() else { return; }; - let Some(entry) = open.entries.get(open.selected).cloned() else { + let Some(entry_idx) = open.selected_entry_index() else { + return; + }; + let Some(entry) = open.entries.get(entry_idx).cloned() else { return; }; let source_workspace_id = open.source_workspace_id.clone(); @@ -349,6 +388,8 @@ impl App { repo_name, entries: vec![entry], selected: 0, + query: String::new(), + search_focused: false, error: Some(format!("failed to open worktree: {err}")), }); self.state.mode = Mode::OpenExistingWorktree; @@ -741,6 +782,8 @@ mod tests { already_open_ws_idx: Some(1), }], selected: 0, + query: String::new(), + search_focused: false, error: None, }); @@ -759,6 +802,73 @@ mod tests { assert!(target_membership.is_linked_worktree); } + #[test] + fn worktree_open_search_filters_entries() { + let mut app = app_for_worktree_tests(); + app.state.worktree_open = Some(WorktreeOpenState { + source_workspace_id: "source".into(), + source_existing_membership: None, + source_checkout_path: "/repo/herdr".into(), + source_repo_root: "/repo/herdr".into(), + repo_key: "repo-key".into(), + repo_name: "herdr".into(), + entries: vec![ + WorktreeOpenEntry { + path: "/repo/herdr".into(), + branch: Some("main".into()), + is_linked_worktree: false, + already_open_ws_idx: Some(0), + }, + WorktreeOpenEntry { + path: "/repo/fd-cleanup".into(), + branch: Some("fd-cleanup".into()), + is_linked_worktree: true, + already_open_ws_idx: None, + }, + WorktreeOpenEntry { + path: "/repo/bell-forward-macos-bounce".into(), + branch: Some("bell-forward-macos-bounce".into()), + is_linked_worktree: true, + already_open_ws_idx: None, + }, + ], + selected: 0, + query: String::new(), + search_focused: false, + error: None, + }); + + app.handle_worktree_open_key(crossterm::event::KeyEvent::new( + crossterm::event::KeyCode::Char('/'), + crossterm::event::KeyModifiers::empty(), + )); + app.handle_worktree_open_key(crossterm::event::KeyEvent::new( + crossterm::event::KeyCode::Char('f'), + crossterm::event::KeyModifiers::empty(), + )); + app.handle_worktree_open_key(crossterm::event::KeyEvent::new( + crossterm::event::KeyCode::Char('d'), + crossterm::event::KeyModifiers::empty(), + )); + app.handle_worktree_open_key(crossterm::event::KeyEvent::new( + crossterm::event::KeyCode::Char('-'), + crossterm::event::KeyModifiers::empty(), + )); + app.handle_worktree_open_key(crossterm::event::KeyEvent::new( + crossterm::event::KeyCode::Char('c'), + crossterm::event::KeyModifiers::empty(), + )); + app.handle_worktree_open_key(crossterm::event::KeyEvent::new( + crossterm::event::KeyCode::Char('l'), + crossterm::event::KeyModifiers::empty(), + )); + + let open = app.state.worktree_open.as_ref().unwrap(); + assert_eq!(open.query, "fd-cl"); + assert_eq!(open.filtered_indices(), vec![1]); + assert_eq!(open.selected_entry_index(), Some(1)); + } + #[test] fn open_existing_worktree_detects_already_open_checkout_from_subdirectory() { let repo = create_committed_repo("app-worktree-open-existing-repo"); diff --git a/src/ui.rs b/src/ui.rs index b9d9827a..00b9f22f 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -59,8 +59,9 @@ pub(crate) use self::{ dialogs::{ confirm_close_button_rects, confirm_close_popup_rect, new_linked_worktree_button_rects, new_linked_worktree_inner_rect, open_existing_worktree_button_rects, - open_existing_worktree_inner_rect, open_existing_worktree_visible_start, - remove_worktree_button_rects, remove_worktree_popup_rect, rename_button_rects, + open_existing_worktree_inner_rect, open_existing_worktree_max_visible_rows, + open_existing_worktree_visible_start, remove_worktree_button_rects, + remove_worktree_popup_rect, rename_button_rects, }, settings::{settings_button_rects, settings_show_primary_action}, sidebar::{ diff --git a/src/ui/dialogs.rs b/src/ui/dialogs.rs index f7396fd8..31275282 100644 --- a/src/ui/dialogs.rs +++ b/src/ui/dialogs.rs @@ -10,7 +10,7 @@ use super::widgets::{ action_button_row_rects, centered_popup_rect, panel_contrast_fg, render_action_button, render_modal_header, render_modal_shell, render_panel_shell, ActionButtonSpec, }; -use crate::app::{AppState, Mode}; +use crate::app::{state::WorktreeOpenState, AppState, Mode}; fn truncate_text(text: &str, max_width: usize) -> String { let len = text.chars().count(); @@ -184,8 +184,11 @@ pub(crate) fn remove_worktree_button_rects(inner: Rect, force_confirmation: bool } pub(crate) fn open_existing_worktree_inner_rect(area: Rect, entry_count: usize) -> Option { - let height = (entry_count as u16).saturating_add(5).clamp(8, 18); - centered_popup_rect(area, 78, height).map(|popup| { + let height = (entry_count as u16) + .saturating_mul(2) + .saturating_add(7) + .clamp(12, 26); + centered_popup_rect(area, 96, height).map(|popup| { Rect::new( popup.x + 1, popup.y + 1, @@ -195,8 +198,21 @@ pub(crate) fn open_existing_worktree_inner_rect(area: Rect, entry_count: usize) }) } -pub(crate) fn open_existing_worktree_visible_start(selected: usize, max_rows: usize) -> usize { - selected.saturating_sub(max_rows.saturating_sub(1)) +pub(crate) fn open_existing_worktree_max_visible_rows(inner: Rect) -> usize { + usize::from(inner.height.saturating_sub(5) / 2) +} + +pub(crate) fn open_existing_worktree_visible_start( + open: &WorktreeOpenState, + max_rows: usize, +) -> usize { + let filtered = open.filtered_indices(); + let selected = open.selected_entry_index().unwrap_or(open.selected); + let selected_pos = filtered + .iter() + .position(|idx| *idx == selected) + .unwrap_or(0); + selected_pos.saturating_sub(max_rows.saturating_sub(1)) } pub(crate) fn open_existing_worktree_button_rects(inner: Rect) -> (Rect, Rect) { @@ -408,11 +424,14 @@ pub(super) fn render_open_existing_worktree_overlay(app: &AppState, frame: &mut }; super::dim_background(frame, area); - let height = (open.entries.len() as u16).saturating_add(5).clamp(8, 18); - let Some(inner) = render_modal_shell(frame, area, 78, height, &app.palette) else { + let height = (open.entries.len() as u16) + .saturating_mul(2) + .saturating_add(7) + .clamp(12, 26); + let Some(inner) = render_modal_shell(frame, area, 96, height, &app.palette) else { return; }; - if inner.height < 6 { + if inner.height < 8 { return; } @@ -422,39 +441,29 @@ pub(super) fn render_open_existing_worktree_overlay(app: &AppState, frame: &mut "open worktree", &app.palette, ); + render_open_worktree_search( + app, + frame, + Rect::new(inner.x, inner.y + 1, inner.width, 1), + open, + ); frame.render_widget( - Paragraph::new(" existing checkouts").style(Style::default().fg(app.palette.overlay0)), - Rect::new(inner.x, inner.y.saturating_add(1), inner.width, 1), + Paragraph::new("─".repeat(inner.width as usize)) + .style(Style::default().fg(app.palette.surface1)), + Rect::new(inner.x, inner.y.saturating_add(2), inner.width, 1), ); - let max_rows = inner.height.saturating_sub(4) as usize; - let start = open_existing_worktree_visible_start(open.selected, max_rows); - for (visible_idx, (entry_idx, entry)) in open - .entries - .iter() - .enumerate() - .skip(start) - .take(max_rows) - .enumerate() - { - let selected = entry_idx == open.selected; - let y = inner.y.saturating_add(2 + visible_idx as u16); - let marker = if selected { "›" } else { " " }; - let branch = entry - .branch - .as_deref() - .unwrap_or(if entry.is_linked_worktree { - "detached" - } else { - "root" - }); - let open_label = if entry.already_open_ws_idx.is_some() { - " open" - } else { - "" + let filtered = open.filtered_indices(); + let max_rows = open_existing_worktree_max_visible_rows(inner); + let start = open_existing_worktree_visible_start(open, max_rows); + for (visible_idx, entry_idx) in filtered.iter().skip(start).take(max_rows).enumerate() { + let Some(entry) = open.entries.get(*entry_idx) else { + continue; }; - let label = format!("{marker} {branch}{open_label} {}", entry.path.display()); - let style = if selected { + let selected = Some(*entry_idx) == open.selected_entry_index(); + let y = inner.y.saturating_add(3 + (visible_idx as u16 * 2)); + let marker = if selected { "›" } else { " " }; + let row_style = if selected { Style::default() .fg(app.palette.text) .bg(app.palette.surface0) @@ -462,10 +471,51 @@ pub(super) fn render_open_existing_worktree_overlay(app: &AppState, frame: &mut } else { Style::default().fg(app.palette.subtext0) }; + let path_style = if selected { + Style::default() + .fg(app.palette.subtext0) + .bg(app.palette.surface0) + } else { + Style::default().fg(app.palette.overlay0) + }; + let status = entry.status_label(); + let title_width = inner + .width + .saturating_sub(status.len() as u16) + .saturating_sub(4) as usize; + let mut title = format!( + "{marker} {}", + truncate_text(&entry.display_name(), title_width) + ); + if !status.is_empty() { + let pad = inner + .width + .saturating_sub(title.chars().count() as u16) + .saturating_sub(status.len() as u16) + .max(1); + title.push_str(&" ".repeat(pad as usize)); + title.push_str(status); + } frame.render_widget( - Paragraph::new(truncate_text(&label, inner.width as usize)).style(style), + Paragraph::new(truncate_text(&title, inner.width as usize)).style(row_style), Rect::new(inner.x, y, inner.width, 1), ); + frame.render_widget( + Paragraph::new(truncate_text( + &format!(" {}", entry.path.display()), + inner.width as usize, + )) + .style(path_style), + Rect::new(inner.x, y.saturating_add(1), inner.width, 1), + ); + } + + if filtered.is_empty() { + frame.render_widget( + Paragraph::new(" no matching worktrees") + .style(Style::default().fg(app.palette.overlay0)), + Rect::new(inner.x, inner.y.saturating_add(3), inner.width, 1), + ); } if let Some(error) = &open.error { @@ -503,6 +553,47 @@ pub(super) fn render_open_existing_worktree_overlay(app: &AppState, frame: &mut ); } +fn render_open_worktree_search( + app: &AppState, + frame: &mut Frame, + area: Rect, + open: &WorktreeOpenState, +) { + let focus_style = if open.search_focused { + Style::default() + .fg(app.palette.accent) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(app.palette.overlay0) + }; + let filtered_count = open.filtered_indices().len(); + let count = if open.query.trim().is_empty() { + format!("{} checkouts", open.entries.len()) + } else { + format!("{filtered_count}/{} checkouts", open.entries.len()) + }; + let mut spans = vec![Span::styled(" / ", focus_style)]; + if open.query.trim().is_empty() { + spans.push(Span::styled( + "filter worktrees", + Style::default().fg(app.palette.overlay0), + )); + } else { + spans.push(Span::styled( + open.query.clone(), + Style::default().fg(app.palette.text), + )); + } + spans.push(Span::styled( + format!( + "{count:>width$}", + width = area.width.saturating_sub(18) as usize + ), + Style::default().fg(app.palette.overlay0), + )); + frame.render_widget(Paragraph::new(Line::from(spans)), area); +} + fn confirm_close_overlay_text(app: &AppState) -> (String, String) { let ws_name = app .workspaces @@ -608,8 +699,9 @@ pub(super) fn render_confirm_close_overlay(app: &AppState, frame: &mut Frame, ar Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), + Constraint::Length(1), ]) - .areas::<3>(inner); + .areas::<4>(inner); frame.render_widget(Paragraph::new(title_line), rows[0]); frame.render_widget(Paragraph::new(detail_line), rows[1]); @@ -639,7 +731,7 @@ pub(super) fn render_confirm_close_overlay(app: &AppState, frame: &mut Frame, ar } pub(crate) fn confirm_close_popup_rect(area: Rect) -> Option { - centered_popup_rect(area, 44, 5) + centered_popup_rect(area, 64, 6) } pub(crate) fn confirm_close_button_rects(inner: Rect) -> (Rect, Rect) { @@ -656,7 +748,7 @@ pub(crate) fn confirm_close_button_rects(inner: Rect) -> (Rect, Rect) { }, ], 2, - 2, + 3, ); (rects[0], rects[1]) }