aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/app/actions.rs149
-rw-r--r--src/app/api/panes.rs9
-rw-r--r--src/app/input/modal.rs64
-rw-r--r--src/app/input/mouse.rs36
-rw-r--r--src/app/input/navigate.rs26
-rw-r--r--src/app/mod.rs41
-rw-r--r--src/app/state.rs98
-rw-r--r--src/app/worktrees.rs124
-rw-r--r--src/ui.rs5
-rw-r--r--src/ui/dialogs.rs174
10 files changed, 636 insertions, 90 deletions
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<String>>) -> Option<String> {
@@ -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"]);
@@ -3908,6 +3989,22 @@ mod tests {
}
#[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"]);
mark_linked_worktree(&mut state, 1);
@@ -3922,6 +4019,22 @@ mod tests {
}
#[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"]);
mark_linked_worktree(&mut state, 1);
@@ -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
@@ -2889,6 +2889,47 @@ mod tests {
}
#[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();
app.no_session = false;
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<usize>,
}
+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<WorktreeOpenEntry>,
pub selected: usize,
+ pub query: String,
+ pub search_focused: bool,
pub error: Option<String>,
}
+impl WorktreeOpenState {
+ pub(crate) fn filtered_indices(&self) -> Vec<usize> {
+ 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<usize> {
+ 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,
});
@@ -760,6 +803,73 @@ mod tests {
}
#[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");
let checkout = unique_temp_path("app-worktree-open-existing-checkout");
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<Rect> {
- 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<Rect> {
- 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])
}