fix: confirm implicit worktree group close

refs #369
This commit is contained in:
Ogulcan Celik 2026-05-31 21:18:40 +03:00
parent 859185b491
commit 8cf0e9b35b
10 changed files with 636 additions and 90 deletions

View File

@ -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"]);
@ -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");
}
}

View File

@ -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 = {

View File

@ -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);
}
}

View File

@ -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);

View File

@ -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();

View File

@ -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();

View File

@ -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)]

View File

@ -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");

View File

@ -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::{

View File

@ -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])
}