parent
859185b491
commit
8cf0e9b35b
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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::{
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue