aboutsummaryrefslogtreecommitdiff
path: root/src/app/input
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-05-22 02:21:12 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-05-22 02:23:35 +0300
commit0148c1379b76c5c0a9d05588c07dcb06ec32f93f (patch)
tree554766d959c738ca2aa433c3d4b4a7de25568008 /src/app/input
parenta9ff4012bdca0dbdbc77bc6e53a430689fc899be (diff)
feat: add git worktree workspace management
refs #137
Diffstat (limited to 'src/app/input')
-rw-r--r--src/app/input/mod.rs4
-rw-r--r--src/app/input/modal.rs110
-rw-r--r--src/app/input/mouse.rs383
-rw-r--r--src/app/input/navigate.rs285
-rw-r--r--src/app/input/sidebar.rs236
5 files changed, 981 insertions, 37 deletions
diff --git a/src/app/input/mod.rs b/src/app/input/mod.rs
index 7c2bbe03..e357b33d 100644
--- a/src/app/input/mod.rs
+++ b/src/app/input/mod.rs
@@ -68,6 +68,9 @@ impl App {
Mode::RenameWorkspace | Mode::RenameTab | Mode::RenamePane => {
handle_rename_key(&mut self.state, key_event)
}
+ Mode::NewLinkedWorktree => self.handle_worktree_create_key(key_event),
+ Mode::OpenExistingWorktree => self.handle_worktree_open_key(key_event),
+ Mode::ConfirmRemoveWorktree => self.handle_worktree_remove_key(key_event),
Mode::Resize => handle_resize_key(&mut self.state, key),
Mode::ConfirmClose => handle_confirm_close_key(&mut self.state, key_event),
Mode::ContextMenu => {
@@ -350,6 +353,7 @@ fn capture_snapshot(state: &AppState) -> crate::persist::SessionSnapshot {
state.agent_panel_scope,
state.sidebar_width,
state.sidebar_section_split,
+ state.collapsed_space_keys.clone(),
)
}
diff --git a/src/app/input/modal.rs b/src/app/input/modal.rs
index 868ee3d3..1fae3800 100644
--- a/src/app/input/modal.rs
+++ b/src/app/input/modal.rs
@@ -506,10 +506,49 @@ pub(super) fn apply_context_menu_action(
) {
let item = menu.items().get(idx).copied();
match (menu.kind, item) {
- (ContextMenuKind::Workspace { ws_idx }, Some("Rename")) => {
+ (ContextMenuKind::GitWorkspace { ws_idx, .. }, Some("New worktree")) => {
+ state.request_new_linked_worktree = Some(ws_idx);
+ leave_modal(state);
+ }
+ (ContextMenuKind::GitWorkspace { ws_idx, .. }, Some("Delete worktree checkout...")) => {
+ state.request_remove_linked_worktree = Some(ws_idx);
+ leave_modal(state);
+ }
+ (ContextMenuKind::GitWorkspace { ws_idx, .. }, Some("Open worktree...")) => {
+ state.request_open_existing_worktree = Some(ws_idx);
+ leave_modal(state);
+ }
+ (
+ ContextMenuKind::GitWorkspace {
+ ws_idx, collapsed, ..
+ },
+ Some("Collapse" | "Expand"),
+ ) => {
+ if let Some(key) = state
+ .workspaces
+ .get(ws_idx)
+ .and_then(|ws| ws.worktree_space())
+ .map(|space| space.key.clone())
+ {
+ if collapsed {
+ state.collapsed_space_keys.remove(&key);
+ } else {
+ state.collapsed_space_keys.insert(key);
+ }
+ state.mark_session_dirty();
+ }
+ leave_modal(state);
+ }
+ (
+ ContextMenuKind::Workspace { ws_idx } | ContextMenuKind::GitWorkspace { ws_idx, .. },
+ Some("Rename"),
+ ) => {
open_rename_workspace(state, ws_idx);
}
- (ContextMenuKind::Workspace { ws_idx }, Some("Close")) => {
+ (
+ ContextMenuKind::Workspace { ws_idx } | ContextMenuKind::GitWorkspace { ws_idx, .. },
+ Some("Close" | "Close group"),
+ ) => {
state.selected = ws_idx;
if state.confirm_close {
open_confirm_close(state);
@@ -1033,4 +1072,71 @@ mod tests {
);
assert_eq!(state.workspaces.len(), 1);
}
+
+ #[test]
+ fn confirm_close_for_linked_worktree_closes_workspace_only() {
+ let mut state = state_with_workspaces(&["main", "issue"]);
+ state.mode = Mode::ConfirmClose;
+ state.selected = 1;
+ 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,
+ });
+
+ handle_confirm_close_key(
+ &mut state,
+ KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()),
+ );
+
+ assert_eq!(state.request_remove_linked_worktree, None);
+ assert_eq!(state.workspaces.len(), 1);
+ assert_eq!(state.workspaces[0].display_name(), "main");
+ assert_eq!(state.mode, Mode::Terminal);
+ }
+
+ #[test]
+ fn context_menu_close_group_opens_group_close_confirmation() {
+ 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 menu = ContextMenuState {
+ kind: ContextMenuKind::GitWorkspace {
+ ws_idx: 0,
+ is_linked_worktree: false,
+ has_worktree_children: true,
+ collapsed: false,
+ },
+ x: 0,
+ y: 0,
+ list: MenuListState::new(0),
+ };
+ let mut terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new();
+
+ apply_context_menu_action(&mut state, &mut terminal_runtimes, menu, 1);
+
+ assert_eq!(state.selected, 0);
+ assert_eq!(state.mode, Mode::ConfirmClose);
+
+ confirm_close_accept(&mut state);
+
+ assert!(state.workspaces.is_empty());
+ assert_eq!(state.mode, Mode::Navigate);
+ }
}
diff --git a/src/app/input/mouse.rs b/src/app/input/mouse.rs
index 8d7fc35d..691026de 100644
--- a/src/app/input/mouse.rs
+++ b/src/app/input/mouse.rs
@@ -141,6 +141,33 @@ impl AppState {
&& mouse.row >= sidebar.y
&& mouse.row < sidebar.y + sidebar.height;
+ if self.mode == Mode::OpenExistingWorktree {
+ match mouse.kind {
+ MouseEventKind::ScrollUp => {
+ if let Some(open) = &mut self.worktree_open {
+ open.selected = open.selected.saturating_sub(1);
+ }
+ return None;
+ }
+ MouseEventKind::ScrollDown => {
+ if let Some(open) = &mut self.worktree_open {
+ open.selected =
+ (open.selected + 1).min(open.entries.len().saturating_sub(1));
+ }
+ return None;
+ }
+ _ => {}
+ }
+ }
+
+ if matches!(
+ self.mode,
+ Mode::NewLinkedWorktree | Mode::OpenExistingWorktree | Mode::ConfirmRemoveWorktree
+ ) && !matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left))
+ {
+ return None;
+ }
+
match mouse.kind {
MouseEventKind::Down(MouseButton::Left) => {
self.selection = None;
@@ -171,6 +198,135 @@ impl AppState {
return None;
}
+ if self.mode == Mode::NewLinkedWorktree {
+ if let Some(inner) =
+ crate::ui::new_linked_worktree_inner_rect(self.screen_rect())
+ {
+ let (create, cancel) = crate::ui::new_linked_worktree_button_rects(inner);
+ match modal_action_from_buttons(
+ mouse.column,
+ mouse.row,
+ &[
+ (create, ModalAction::Confirm),
+ (cancel, ModalAction::Cancel),
+ ],
+ ) {
+ Some(ModalAction::Confirm) => {
+ self.request_submit_worktree_create = true;
+ }
+ Some(ModalAction::Cancel)
+ if !self
+ .worktree_create
+ .as_ref()
+ .is_some_and(|create| create.creating) =>
+ {
+ self.worktree_create = None;
+ self.name_input.clear();
+ self.name_input_replace_on_type = false;
+ leave_modal(self);
+ }
+ _ => {}
+ }
+ }
+ return None;
+ }
+
+ if self.mode == Mode::OpenExistingWorktree {
+ if let Some(open) = self.worktree_open.as_ref() {
+ if let Some(inner) = crate::ui::open_existing_worktree_inner_rect(
+ 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 row_idx = if rect_contains(inner, mouse.column, mouse.row) {
+ mouse
+ .row
+ .checked_sub(inner.y.saturating_add(2))
+ .map(usize::from)
+ .filter(|row| *row < max_rows)
+ .and_then(|row| {
+ let entry_idx = start + row;
+ (entry_idx < open.entries.len()).then_some(entry_idx)
+ })
+ } else {
+ None
+ };
+ if let Some(entry_idx) = row_idx {
+ if let Some(open) = &mut self.worktree_open {
+ open.selected = entry_idx;
+ }
+ self.request_submit_worktree_open = true;
+ return None;
+ }
+
+ let (open_button, cancel) =
+ crate::ui::open_existing_worktree_button_rects(inner);
+ match modal_action_from_buttons(
+ mouse.column,
+ mouse.row,
+ &[
+ (open_button, ModalAction::Confirm),
+ (cancel, ModalAction::Cancel),
+ ],
+ ) {
+ Some(ModalAction::Confirm) => {
+ self.request_submit_worktree_open = true;
+ }
+ Some(ModalAction::Cancel) => {
+ self.worktree_open = None;
+ leave_modal(self);
+ }
+ _ => {}
+ }
+ }
+ }
+ return None;
+ }
+
+ if self.mode == Mode::ConfirmRemoveWorktree {
+ if let Some(popup) = crate::ui::remove_worktree_popup_rect(self.screen_rect()) {
+ let inner = Rect::new(
+ popup.x + 1,
+ popup.y + 1,
+ popup.width.saturating_sub(2),
+ popup.height.saturating_sub(2),
+ );
+ let force_confirmation = self
+ .worktree_remove
+ .as_ref()
+ .is_some_and(|remove| remove.force_confirmation);
+ let (remove, cancel) =
+ crate::ui::remove_worktree_button_rects(inner, force_confirmation);
+ match modal_action_from_buttons(
+ mouse.column,
+ mouse.row,
+ &[
+ (remove, ModalAction::Confirm),
+ (cancel, ModalAction::Cancel),
+ ],
+ ) {
+ Some(ModalAction::Confirm) => {
+ self.request_submit_worktree_remove = true;
+ }
+ Some(ModalAction::Cancel)
+ if !self
+ .worktree_remove
+ .as_ref()
+ .is_some_and(|remove| remove.removing) =>
+ {
+ self.worktree_remove = None;
+ leave_modal(self);
+ }
+ _ => {}
+ }
+ }
+ return None;
+ }
+
if matches!(
self.mode,
Mode::RenameWorkspace | Mode::RenameTab | Mode::RenamePane
@@ -336,6 +492,29 @@ impl AppState {
return None;
}
+ let cards = if self.view.workspace_card_areas.is_empty() {
+ crate::ui::compute_workspace_card_areas(self, self.view.sidebar_rect)
+ } else {
+ self.view.workspace_card_areas.clone()
+ };
+ if let Some(card) = cards.iter().find(|card| {
+ mouse.row == card.rect.y
+ && mouse.column == card.rect.x
+ && mouse.column < card.rect.x + card.rect.width
+ }) {
+ if let Some((key, collapsed)) =
+ crate::ui::workspace_parent_group_state(self, card.ws_idx)
+ {
+ if collapsed {
+ self.collapsed_space_keys.remove(&key);
+ } else {
+ self.collapsed_space_keys.insert(key);
+ }
+ self.mark_session_dirty();
+ return None;
+ }
+ }
+
if let Some(idx) = self.workspace_at_row(mouse.row) {
self.workspace_press = Some(WorkspacePressState {
ws_idx: idx,
@@ -437,7 +616,11 @@ impl AppState {
if let Some(press) = &self.workspace_press {
let delta_col = mouse.column.abs_diff(press.start_col);
let delta_row = mouse.row.abs_diff(press.start_row);
- if delta_col.max(delta_row) >= WORKSPACE_DRAG_THRESHOLD {
+ let can_reorder = self
+ .workspaces
+ .get(press.ws_idx)
+ .is_some_and(|ws| ws.worktree_space().is_none());
+ if can_reorder && delta_col.max(delta_row) >= WORKSPACE_DRAG_THRESHOLD {
self.drag = Some(DragState {
target: DragTarget::WorkspaceReorder {
source_ws_idx: press.ws_idx,
@@ -665,9 +848,8 @@ impl AppState {
crate::ui::workspace_list_scroll_metrics(self, self.workspace_list_rect()),
) {
self.scroll_workspace_list(-1);
- } else if self.selected > 0 {
- self.selected -= 1;
- self.ensure_workspace_visible(self.selected);
+ } else {
+ self.move_selected_workspace_by_visible_delta(-1);
}
}
MouseEventKind::ScrollDown if in_sidebar => {
@@ -685,9 +867,8 @@ impl AppState {
crate::ui::workspace_list_scroll_metrics(self, self.workspace_list_rect()),
) {
self.scroll_workspace_list(1);
- } else if !self.workspaces.is_empty() && self.selected < self.workspaces.len() - 1 {
- self.selected += 1;
- self.ensure_workspace_visible(self.selected);
+ } else {
+ self.move_selected_workspace_by_visible_delta(1);
}
}
@@ -699,6 +880,8 @@ impl AppState {
}
MouseEventKind::Down(MouseButton::Right) if in_sidebar && !self.sidebar_collapsed => {
+ self.workspace_press = None;
+ self.tab_press = None;
if self
.workspace_list_scrollbar_target_at(mouse.column, mouse.row)
.is_some()
@@ -707,8 +890,40 @@ impl AppState {
}
if let Some(idx) = self.workspace_at_row(mouse.row) {
self.selected = idx;
+ let kind = self
+ .workspaces
+ .get(idx)
+ .and_then(|ws| {
+ let group_state = crate::ui::workspace_parent_group_state(self, idx);
+ let git_space = ws.git_space().cloned().or_else(|| {
+ ws.resolved_identity_cwd_from(&self.terminals, terminal_runtimes)
+ .as_deref()
+ .and_then(crate::workspace::git_space_metadata)
+ });
+ let is_linked_worktree = ws.worktree_space().map_or_else(
+ || {
+ git_space
+ .as_ref()
+ .is_some_and(|space| space.is_linked_worktree)
+ },
+ |space| space.is_linked_worktree,
+ );
+ let show_git_menu = ws.worktree_space().is_some()
+ || git_space
+ .as_ref()
+ .is_some_and(|space| !space.is_linked_worktree);
+ show_git_menu.then_some(ContextMenuKind::GitWorkspace {
+ ws_idx: idx,
+ is_linked_worktree,
+ has_worktree_children: group_state.is_some(),
+ collapsed: group_state
+ .as_ref()
+ .is_some_and(|(_, collapsed)| *collapsed),
+ })
+ })
+ .unwrap_or(ContextMenuKind::Workspace { ws_idx: idx });
self.context_menu = Some(ContextMenuState {
- kind: ContextMenuKind::Workspace { ws_idx: idx },
+ kind,
x: mouse.column,
y: mouse.row,
list: MenuListState::new(0),
@@ -1433,6 +1648,33 @@ mod tests {
assert_eq!(metrics.offset_from_bottom, 7);
}
+ fn sample_worktree_open_state() -> crate::app::state::WorktreeOpenState {
+ crate::app::state::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![
+ crate::app::state::WorktreeOpenEntry {
+ path: "/repo/herdr".into(),
+ branch: Some("main".into()),
+ is_linked_worktree: false,
+ already_open_ws_idx: Some(0),
+ },
+ crate::app::state::WorktreeOpenEntry {
+ path: "/repo/herdr-issue".into(),
+ branch: Some("worktree/issue".into()),
+ is_linked_worktree: true,
+ already_open_ws_idx: None,
+ },
+ ],
+ selected: 0,
+ error: None,
+ }
+ }
+
#[test]
fn hovering_context_menu_updates_highlight() {
let mut app = app_for_mouse_test();
@@ -1559,6 +1801,131 @@ mod tests {
}
#[test]
+ fn clicking_open_worktree_row_selects_and_requests_open() {
+ let mut app = app_for_mouse_test();
+ app.state.mode = Mode::OpenExistingWorktree;
+ app.state.worktree_open = Some(sample_worktree_open_state());
+ let inner =
+ crate::ui::open_existing_worktree_inner_rect(app.state.screen_rect(), 2).unwrap();
+
+ app.handle_mouse(mouse(
+ MouseEventKind::Down(MouseButton::Left),
+ inner.x + 1,
+ inner.y + 3,
+ ));
+
+ assert_eq!(app.state.worktree_open.as_ref().unwrap().selected, 1);
+ assert!(app.state.request_submit_worktree_open);
+ }
+
+ #[test]
+ fn clicking_open_worktree_buttons_requests_open_or_cancels() {
+ let mut app = app_for_mouse_test();
+ app.state.mode = Mode::OpenExistingWorktree;
+ app.state.worktree_open = Some(sample_worktree_open_state());
+ let inner =
+ crate::ui::open_existing_worktree_inner_rect(app.state.screen_rect(), 2).unwrap();
+ let (open, _) = crate::ui::open_existing_worktree_button_rects(inner);
+
+ app.handle_mouse(mouse(
+ MouseEventKind::Down(MouseButton::Left),
+ open.x,
+ open.y,
+ ));
+
+ assert!(app.state.worktree_open.is_some());
+ assert!(app.state.request_submit_worktree_open);
+
+ let mut app = app_for_mouse_test();
+ app.state.mode = Mode::OpenExistingWorktree;
+ app.state.worktree_open = Some(sample_worktree_open_state());
+ let inner =
+ crate::ui::open_existing_worktree_inner_rect(app.state.screen_rect(), 2).unwrap();
+ let (_, cancel) = crate::ui::open_existing_worktree_button_rects(inner);
+
+ app.handle_mouse(mouse(
+ MouseEventKind::Down(MouseButton::Left),
+ cancel.x,
+ cancel.y,
+ ));
+
+ assert!(app.state.worktree_open.is_none());
+ assert_eq!(app.state.mode, Mode::Navigate);
+ }
+
+ #[test]
+ fn scrolling_open_worktree_picker_moves_selection() {
+ let mut app = app_for_mouse_test();
+ app.state.mode = Mode::OpenExistingWorktree;
+ app.state.worktree_open = Some(sample_worktree_open_state());
+
+ app.handle_mouse(mouse(MouseEventKind::ScrollDown, 1, 1));
+ assert_eq!(app.state.worktree_open.as_ref().unwrap().selected, 1);
+
+ app.handle_mouse(mouse(MouseEventKind::ScrollUp, 1, 1));
+ assert_eq!(app.state.worktree_open.as_ref().unwrap().selected, 0);
+ }
+
+ #[test]
+ fn clicking_remove_worktree_buttons_requests_remove_or_cancels() {
+ let mut app = app_for_mouse_test();
+ app.state.mode = Mode::ConfirmRemoveWorktree;
+ app.state.worktree_remove = Some(crate::app::state::WorktreeRemoveState {
+ workspace_id: "issue".into(),
+ repo_root: "/repo/herdr".into(),
+ path: "/repo/herdr-issue".into(),
+ error: None,
+ removing: false,
+ force_confirmation: false,
+ });
+ let popup = crate::ui::remove_worktree_popup_rect(app.state.screen_rect()).unwrap();
+ let inner = Rect::new(
+ popup.x + 1,
+ popup.y + 1,
+ popup.width.saturating_sub(2),
+ popup.height.saturating_sub(2),
+ );
+ let (remove, _) = crate::ui::remove_worktree_button_rects(inner, false);
+
+ app.handle_mouse(mouse(
+ MouseEventKind::Down(MouseButton::Left),
+ remove.x,
+ remove.y,
+ ));
+
+ assert!(app.state.worktree_remove.is_some());
+ assert!(app.state.request_submit_worktree_remove);
+
+ let mut app = app_for_mouse_test();
+ app.state.mode = Mode::ConfirmRemoveWorktree;
+ app.state.worktree_remove = Some(crate::app::state::WorktreeRemoveState {
+ workspace_id: "issue".into(),
+ repo_root: "/repo/herdr".into(),
+ path: "/repo/herdr-issue".into(),
+ error: None,
+ removing: false,
+ force_confirmation: false,
+ });
+ let popup = crate::ui::remove_worktree_popup_rect(app.state.screen_rect()).unwrap();
+ let inner = Rect::new(
+ popup.x + 1,
+ popup.y + 1,
+ popup.width.saturating_sub(2),
+ popup.height.saturating_sub(2),
+ );
+ let (_, cancel) = crate::ui::remove_worktree_button_rects(inner, false);
+
+ app.handle_mouse(mouse(
+ MouseEventKind::Down(MouseButton::Left),
+ cancel.x,
+ cancel.y,
+ ));
+
+ assert!(app.state.worktree_remove.is_none());
+ assert_eq!(app.state.mode, Mode::Navigate);
+ }
+
+ #[test]
fn clicking_confirm_close_accepts_after_workspace_context_menu_close() {
let mut app = app_for_mouse_test();
app.state.workspaces = vec![Workspace::test_new("a"), Workspace::test_new("b")];
diff --git a/src/app/input/navigate.rs b/src/app/input/navigate.rs
index 9256bbbb..da3d49bf 100644
--- a/src/app/input/navigate.rs
+++ b/src/app/input/navigate.rs
@@ -383,24 +383,18 @@ pub(super) fn handle_navigate_reserved_key(state: &mut AppState, key: KeyEvent)
}
KeyCode::Char(c @ '1'..='9') => {
let idx = (c as usize) - ('1' as usize);
- if idx < state.workspaces.len() {
- state.switch_workspace(idx);
+ if let Some(ws_idx) = state.workspace_at_visible_position(idx) {
+ state.switch_workspace(ws_idx);
leave_navigate_mode(state);
}
true
}
KeyCode::Up => {
- if state.selected > 0 {
- state.selected -= 1;
- state.ensure_workspace_visible(state.selected);
- }
+ state.move_selected_workspace_by_visible_delta(-1);
true
}
KeyCode::Down => {
- if !state.workspaces.is_empty() && state.selected < state.workspaces.len() - 1 {
- state.selected += 1;
- state.ensure_workspace_visible(state.selected);
- }
+ state.move_selected_workspace_by_visible_delta(1);
true
}
KeyCode::Char('h') | KeyCode::Left => {
@@ -459,6 +453,9 @@ pub(crate) fn handle_navigate_key(state: &mut AppState, key: KeyEvent) {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum NavigateAction {
NewWorkspace,
+ NewWorktree,
+ OpenWorktree,
+ RemoveWorktree,
RenameWorkspace,
CloseWorkspace,
SwitchWorkspace(usize),
@@ -557,6 +554,9 @@ fn action_for_key(
(&kb.settings, NavigateAction::Settings),
(&kb.workspace_picker, NavigateAction::WorkspacePicker),
(&kb.new_workspace, NavigateAction::NewWorkspace),
+ (&kb.new_worktree, NavigateAction::NewWorktree),
+ (&kb.open_worktree, NavigateAction::OpenWorktree),
+ (&kb.remove_worktree, NavigateAction::RemoveWorktree),
(&kb.rename_workspace, NavigateAction::RenameWorkspace),
(&kb.close_workspace, NavigateAction::CloseWorkspace),
(&kb.previous_workspace, NavigateAction::PreviousWorkspace),
@@ -619,13 +619,36 @@ pub(super) fn execute_navigate_action_in_context(
state.request_new_workspace = true;
leave_navigate_mode(state);
}
+ NavigateAction::NewWorktree => {
+ if let Some(ws_idx) = workspace_action_target(state, context)
+ .filter(|idx| workspace_can_start_worktree_action(state, terminal_runtimes, *idx))
+ {
+ state.request_new_linked_worktree = Some(ws_idx);
+ leave_navigate_mode(state);
+ }
+ }
+ NavigateAction::OpenWorktree => {
+ if let Some(ws_idx) = workspace_action_target(state, context)
+ .filter(|idx| workspace_can_start_worktree_action(state, terminal_runtimes, *idx))
+ {
+ state.request_open_existing_worktree = Some(ws_idx);
+ leave_navigate_mode(state);
+ }
+ }
+ NavigateAction::RemoveWorktree => {
+ if let Some(ws_idx) = workspace_action_target(state, context) {
+ state.request_remove_linked_worktree = Some(ws_idx);
+ leave_navigate_mode(state);
+ }
+ }
NavigateAction::RenameWorkspace => {
- if !state.workspaces.is_empty() {
- super::modal::open_rename_workspace(state, state.selected);
+ if let Some(ws_idx) = workspace_action_target(state, context) {
+ super::modal::open_rename_workspace(state, ws_idx);
}
}
NavigateAction::CloseWorkspace => {
- if !state.workspaces.is_empty() {
+ if let Some(ws_idx) = workspace_action_target(state, context) {
+ state.selected = ws_idx;
if state.confirm_close {
super::modal::open_confirm_close(state);
} else {
@@ -635,8 +658,8 @@ pub(super) fn execute_navigate_action_in_context(
}
}
NavigateAction::SwitchWorkspace(idx) => {
- if idx < state.workspaces.len() {
- state.switch_workspace(idx);
+ if let Some(ws_idx) = state.workspace_at_visible_position(idx) {
+ state.switch_workspace(ws_idx);
leave_navigate_mode(state);
}
}
@@ -762,6 +785,36 @@ pub(super) fn execute_navigate_action_in_context(
finish_action_context(state, context, previous_mode);
}
+fn workspace_action_target(state: &AppState, context: ActionContext) -> Option<usize> {
+ let idx = match context {
+ ActionContext::Direct | ActionContext::Prefix => state.active.unwrap_or(state.selected),
+ ActionContext::Navigate => state.selected,
+ };
+ (idx < state.workspaces.len()).then_some(idx)
+}
+
+fn workspace_can_start_worktree_action(
+ state: &AppState,
+ terminal_runtimes: &TerminalRuntimeRegistry,
+ ws_idx: usize,
+) -> bool {
+ let Some(ws) = state.workspaces.get(ws_idx) else {
+ return false;
+ };
+ if ws
+ .worktree_space()
+ .is_some_and(|space| space.is_linked_worktree)
+ {
+ return false;
+ }
+ let git_space = ws.git_space().cloned().or_else(|| {
+ ws.resolved_identity_cwd_from(&state.terminals, terminal_runtimes)
+ .as_deref()
+ .and_then(crate::workspace::git_space_metadata)
+ });
+ !git_space.is_some_and(|space| space.is_linked_worktree)
+}
+
fn leave_navigate_mode(state: &mut AppState) {
if state.active.is_some() {
state.mode = Mode::Terminal;
@@ -866,6 +919,16 @@ mod tests {
use super::*;
use crate::{app::App, config::Config, input::TerminalKey, workspace::Workspace};
+ fn mark_worktree_space_member(state: &mut AppState, ws_idx: usize, key: &str) {
+ state.workspaces[ws_idx].worktree_space = Some(crate::workspace::WorktreeSpaceMembership {
+ key: key.into(),
+ label: "herdr".into(),
+ repo_root: "/repo/herdr".into(),
+ checkout_path: format!("/repo/worktree-{ws_idx}").into(),
+ is_linked_worktree: ws_idx != 0,
+ });
+ }
+
#[test]
fn custom_rename_key_enters_rename_mode() {
let mut state = state_with_workspaces(&["test"]);
@@ -881,6 +944,55 @@ mod tests {
}
#[test]
+ fn prefix_rename_workspace_targets_active_workspace_not_stale_selection() {
+ let mut state = state_with_workspaces(&["main", "issue"]);
+ let mut terminal_runtimes = TerminalRuntimeRegistry::new();
+ state.active = Some(1);
+ state.selected = 0;
+ state.mode = Mode::Prefix;
+
+ execute_navigate_action_in_context(
+ &mut state,
+ &mut terminal_runtimes,
+ NavigateAction::RenameWorkspace,
+ ActionContext::Prefix,
+ );
+
+ assert_eq!(state.mode, Mode::RenameWorkspace);
+ assert_eq!(state.selected, 1);
+ assert_eq!(state.name_input, "issue");
+ }
+
+ #[test]
+ fn prefix_close_workspace_targets_active_linked_worktree_without_removing_checkout() {
+ let mut state = state_with_workspaces(&["main", "issue"]);
+ let mut terminal_runtimes = TerminalRuntimeRegistry::new();
+ state.active = Some(1);
+ state.selected = 0;
+ state.mode = Mode::Prefix;
+ state.confirm_close = 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,
+ });
+
+ execute_navigate_action_in_context(
+ &mut state,
+ &mut terminal_runtimes,
+ NavigateAction::CloseWorkspace,
+ ActionContext::Prefix,
+ );
+
+ assert_eq!(state.request_remove_linked_worktree, None);
+ assert_eq!(state.workspaces.len(), 1);
+ assert_eq!(state.workspaces[0].display_name(), "main");
+ assert_eq!(state.mode, Mode::Terminal);
+ }
+
+ #[test]
fn custom_new_workspace_key_requests_and_exits_navigate() {
let mut state = state_with_workspaces(&["test"]);
state.keybinds.new_workspace = crate::config::ActionKeybinds::prefix("g");
@@ -895,6 +1007,126 @@ mod tests {
}
#[test]
+ fn custom_new_worktree_key_requests_selected_workspace() {
+ let mut state = state_with_workspaces(&["main", "scratch"]);
+ state.workspaces[1].identity_cwd = unique_temp_path("navigate-new-worktree-selected");
+ state.mode = Mode::Navigate;
+ state.selected = 1;
+ state.active = Some(0);
+ state.keybinds.new_worktree = crate::config::ActionKeybinds::prefix("g");
+
+ handle_navigate_key(
+ &mut state,
+ KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty()),
+ );
+
+ assert_eq!(state.request_new_linked_worktree, Some(1));
+ assert_eq!(state.mode, Mode::Terminal);
+ }
+
+ #[test]
+ fn worktree_actions_do_not_start_from_linked_child_workspace() {
+ let mut terminal_runtimes = TerminalRuntimeRegistry::new();
+ 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.mode = Mode::Navigate;
+ state.selected = 1;
+ state.active = Some(0);
+
+ execute_navigate_action_in_context(
+ &mut state,
+ &mut terminal_runtimes,
+ NavigateAction::NewWorktree,
+ ActionContext::Navigate,
+ );
+ assert_eq!(state.request_new_linked_worktree, None);
+
+ execute_navigate_action_in_context(
+ &mut state,
+ &mut terminal_runtimes,
+ NavigateAction::OpenWorktree,
+ ActionContext::Navigate,
+ );
+ assert_eq!(state.request_open_existing_worktree, None);
+ }
+
+ #[test]
+ fn direct_new_worktree_action_targets_active_workspace() {
+ let mut terminal_runtimes = TerminalRuntimeRegistry::new();
+ let mut state = state_with_workspaces(&["main", "scratch"]);
+ state.workspaces[0].identity_cwd = unique_temp_path("navigate-new-worktree-active");
+ state.mode = Mode::Terminal;
+ state.selected = 1;
+ state.active = Some(0);
+
+ execute_navigate_action_in_context(
+ &mut state,
+ &mut terminal_runtimes,
+ NavigateAction::NewWorktree,
+ ActionContext::Direct,
+ );
+
+ assert_eq!(state.request_new_linked_worktree, Some(0));
+ }
+
+ #[test]
+ fn navigate_down_follows_grouped_sidebar_visual_order() {
+ let mut state = state_with_workspaces(&["main", "normal", "issue"]);
+ mark_worktree_space_member(&mut state, 0, "repo-key");
+ mark_worktree_space_member(&mut state, 2, "repo-key");
+ state.mode = Mode::Navigate;
+ state.active = Some(0);
+ state.selected = 0;
+
+ handle_navigate_key(
+ &mut state,
+ KeyEvent::new(KeyCode::Down, KeyModifiers::empty()),
+ );
+
+ assert_eq!(state.selected, 2);
+ }
+
+ #[test]
+ fn navigate_number_keys_follow_grouped_sidebar_visual_order() {
+ let mut state = state_with_workspaces(&["main", "normal", "issue"]);
+ mark_worktree_space_member(&mut state, 0, "repo-key");
+ mark_worktree_space_member(&mut state, 2, "repo-key");
+ state.mode = Mode::Navigate;
+ state.active = Some(0);
+ state.selected = 0;
+
+ handle_navigate_key(
+ &mut state,
+ KeyEvent::new(KeyCode::Char('2'), KeyModifiers::empty()),
+ );
+
+ assert_eq!(state.active, Some(2));
+ assert_eq!(state.selected, 2);
+ }
+
+ #[test]
+ fn indexed_switch_workspace_keybind_follows_grouped_sidebar_visual_order() {
+ let mut state = state_with_workspaces(&["main", "normal", "issue"]);
+ let mut terminal_runtimes = TerminalRuntimeRegistry::new();
+ mark_worktree_space_member(&mut state, 0, "repo-key");
+ mark_worktree_space_member(&mut state, 2, "repo-key");
+ state.mode = Mode::Prefix;
+ state.active = Some(0);
+ state.selected = 0;
+
+ execute_navigate_action_in_context(
+ &mut state,
+ &mut terminal_runtimes,
+ NavigateAction::SwitchWorkspace(1),
+ ActionContext::Prefix,
+ );
+
+ assert_eq!(state.active, Some(2));
+ assert_eq!(state.selected, 2);
+ }
+
+ #[test]
fn custom_sidebar_toggle_key_toggles_and_exits_navigate() {
let mut state = state_with_workspaces(&["test"]);
state.keybinds.toggle_sidebar = crate::config::ActionKeybinds::prefix("g");
@@ -1226,6 +1458,29 @@ mod tests {
assert!(state.workspaces.is_empty());
}
+ #[test]
+ fn closing_linked_worktree_closes_workspace_without_removing_checkout() {
+ let mut state = state_with_workspaces(&["main", "issue"]);
+ state.selected = 1;
+ state.active = Some(1);
+ state.mode = Mode::Navigate;
+ state.confirm_close = 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,
+ });
+
+ execute_navigate_action(&mut state, NavigateAction::CloseWorkspace);
+
+ assert_eq!(state.request_remove_linked_worktree, None);
+ assert_eq!(state.workspaces.len(), 1);
+ assert_eq!(state.workspaces[0].display_name(), "main");
+ assert_eq!(state.mode, Mode::Terminal);
+ }
+
#[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/input/sidebar.rs b/src/app/input/sidebar.rs
index dc192e50..8ba12bc6 100644
--- a/src/app/input/sidebar.rs
+++ b/src/app/input/sidebar.rs
@@ -69,6 +69,11 @@ impl AppState {
self.workspace_scroll = metrics
.max_offset_from_bottom
.saturating_sub(offset_from_bottom);
+ self.workspace_scroll = crate::ui::normalized_workspace_scroll(
+ self,
+ self.view.sidebar_rect,
+ self.workspace_scroll,
+ );
}
pub(super) fn scroll_workspace_list(&mut self, delta: i16) {
@@ -76,19 +81,25 @@ impl AppState {
self.workspace_scroll = self
.workspace_scroll
.saturating_sub(delta.unsigned_abs() as usize);
+ self.workspace_scroll = crate::ui::normalized_workspace_scroll(
+ self,
+ self.view.sidebar_rect,
+ self.workspace_scroll,
+ );
return;
}
- for _ in 0..delta as usize {
- let cards = crate::ui::compute_workspace_card_areas(self, self.view.sidebar_rect);
- let Some(last) = cards.last() else {
- break;
- };
- if last.ws_idx + 1 >= self.workspaces.len() {
- break;
- }
- self.workspace_scroll = self.workspace_scroll.saturating_add(1);
- }
+ let area = self.workspace_list_rect();
+ let metrics = crate::ui::workspace_list_scroll_metrics(self, area);
+ self.workspace_scroll = self
+ .workspace_scroll
+ .saturating_add(delta as usize)
+ .min(metrics.max_offset_from_bottom);
+ self.workspace_scroll = crate::ui::normalized_workspace_scroll(
+ self,
+ self.view.sidebar_rect,
+ self.workspace_scroll,
+ );
}
pub(super) fn agent_panel_scrollbar_target_at(
@@ -376,8 +387,23 @@ impl AppState {
}
let mut insert_indices = Vec::with_capacity(cards.len() + 1);
- insert_indices.push(cards[0].ws_idx);
- insert_indices.extend(cards.iter().skip(1).map(|card| card.ws_idx));
+ for (idx, card) in cards.iter().enumerate() {
+ let card_group = self
+ .workspaces
+ .get(card.ws_idx)
+ .and_then(|ws| ws.worktree_space())
+ .map(|space| space.key.as_str());
+ let previous_group = idx.checked_sub(1).and_then(|prev_idx| {
+ self.workspaces
+ .get(cards[prev_idx].ws_idx)
+ .and_then(|ws| ws.worktree_space())
+ .map(|space| space.key.as_str())
+ });
+ let inside_group_gap = card_group.is_some() && card_group == previous_group;
+ if !inside_group_gap {
+ insert_indices.push(card.ws_idx);
+ }
+ }
insert_indices.push(cards.last().map(|card| card.ws_idx + 1).unwrap_or(0));
let mut best: Option<(usize, u16)> = None;
@@ -970,6 +996,110 @@ mod tests {
}
#[test]
+ fn clicking_worktree_parent_row_focuses_workspace_without_toggling() {
+ let mut app = app_for_mouse_test();
+ app.state.workspaces = vec![Workspace::test_new("main"), Workspace::test_new("issue")];
+ for (idx, checkout_path) in ["/repo/herdr", "/repo/herdr-issue"].into_iter().enumerate() {
+ app.state.workspaces[idx].worktree_space =
+ Some(crate::workspace::WorktreeSpaceMembership {
+ key: "repo-key".into(),
+ label: "herdr".into(),
+ repo_root: "/repo/herdr".into(),
+ checkout_path: checkout_path.into(),
+ is_linked_worktree: idx > 0,
+ });
+ }
+ app.state.active = None;
+ app.state.mode = Mode::Terminal;
+ crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20));
+ let parent = app.state.view.workspace_card_areas[0].rect;
+
+ app.handle_mouse(mouse(
+ MouseEventKind::Down(MouseButton::Left),
+ parent.x + 2,
+ parent.y,
+ ));
+ app.handle_mouse(mouse(
+ MouseEventKind::Up(MouseButton::Left),
+ parent.x + 2,
+ parent.y,
+ ));
+
+ assert_eq!(app.state.active, Some(0));
+ assert!(!app.state.collapsed_space_keys.contains("repo-key"));
+ }
+
+ #[test]
+ fn clicking_worktree_parent_chevron_toggles_group_only() {
+ let mut app = app_for_mouse_test();
+ app.state.workspaces = vec![Workspace::test_new("main"), Workspace::test_new("issue")];
+ for (idx, checkout_path) in ["/repo/herdr", "/repo/herdr-issue"].into_iter().enumerate() {
+ app.state.workspaces[idx].worktree_space =
+ Some(crate::workspace::WorktreeSpaceMembership {
+ key: "repo-key".into(),
+ label: "herdr".into(),
+ repo_root: "/repo/herdr".into(),
+ checkout_path: checkout_path.into(),
+ is_linked_worktree: idx > 0,
+ });
+ }
+ app.state.active = None;
+ app.state.mode = Mode::Terminal;
+ crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20));
+ let parent = app.state.view.workspace_card_areas[0].rect;
+
+ app.handle_mouse(mouse(
+ MouseEventKind::Down(MouseButton::Left),
+ parent.x,
+ parent.y,
+ ));
+
+ assert_eq!(app.state.active, None);
+ assert!(app.state.workspace_press.is_none());
+ assert!(app.state.collapsed_space_keys.contains("repo-key"));
+
+ app.handle_mouse(mouse(
+ MouseEventKind::Down(MouseButton::Left),
+ parent.x,
+ parent.y,
+ ));
+
+ assert!(!app.state.collapsed_space_keys.contains("repo-key"));
+ }
+
+ #[test]
+ fn wheel_workspace_selection_follows_grouped_visual_order_without_scrollbar() {
+ let mut app = app_for_mouse_test();
+ app.state.workspaces = vec![
+ Workspace::test_new("main"),
+ Workspace::test_new("normal"),
+ Workspace::test_new("issue"),
+ ];
+ for (idx, checkout_path) in [(0, "/repo/herdr"), (2, "/repo/herdr-issue")] {
+ app.state.workspaces[idx].worktree_space =
+ Some(crate::workspace::WorktreeSpaceMembership {
+ key: "repo-key".into(),
+ label: "herdr".into(),
+ repo_root: "/repo/herdr".into(),
+ checkout_path: checkout_path.into(),
+ is_linked_worktree: idx != 0,
+ });
+ }
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ app.state.mode = Mode::Navigate;
+ crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 30));
+ let list = app.state.workspace_list_rect();
+ assert!(!crate::ui::should_show_scrollbar(
+ crate::ui::workspace_list_scroll_metrics(&app.state, list)
+ ));
+
+ app.handle_mouse(mouse(MouseEventKind::ScrollDown, list.x + 1, list.y + 1));
+
+ assert_eq!(app.state.selected, 2);
+ }
+
+ #[test]
fn dragging_workspace_reorders_without_changing_identity() {
let mut app = app_for_mouse_test();
app.state.workspaces = vec![
@@ -1166,6 +1296,18 @@ mod tests {
repo
}
+ fn workspace_with_space(name: &str, key: &str) -> Workspace {
+ let mut ws = Workspace::test_new(name);
+ ws.worktree_space = Some(crate::workspace::WorktreeSpaceMembership {
+ key: key.into(),
+ label: "herdr".into(),
+ repo_root: "/repo/herdr".into(),
+ checkout_path: format!("/repo/{name}").into(),
+ is_linked_worktree: name != "main",
+ });
+ ws
+ }
+
#[test]
fn top_drop_slot_is_distinct_from_gap_below_first_workspace() {
let mut app = app_for_mouse_test();
@@ -1231,6 +1373,76 @@ mod tests {
}
#[test]
+ fn grouped_sidebar_drop_slots_do_not_land_inside_compact_group() {
+ let mut app = app_for_mouse_test();
+ app.state.workspaces = vec![
+ workspace_with_space("main", "repo-key"),
+ Workspace::test_new("normal"),
+ workspace_with_space("issue", "repo-key"),
+ ];
+ app.state.active = Some(1);
+ app.state.selected = 1;
+ crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 40));
+
+ let cards = &app.state.view.workspace_card_areas;
+ let order = cards.iter().map(|card| card.ws_idx).collect::<Vec<_>>();
+ assert_eq!(order, vec![0, 2, 1]);
+ let issue = cards.iter().find(|card| card.ws_idx == 2).unwrap();
+ let normal = cards.iter().find(|card| card.ws_idx == 1).unwrap();
+
+ assert_eq!(app.state.workspace_drop_index_at_row(issue.rect.y), Some(1));
+ assert_eq!(
+ crate::ui::workspace_drop_indicator_row(cards, app.state.workspace_list_rect(), 2),
+ Some(normal.rect.y + normal.rect.height)
+ );
+ }
+
+ #[test]
+ fn dragging_worktree_space_member_does_not_reorder_workspaces() {
+ let mut app = app_for_mouse_test();
+ app.state.workspaces = vec![
+ workspace_with_space("main", "repo-key"),
+ Workspace::test_new("normal"),
+ workspace_with_space("issue", "repo-key"),
+ ];
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 40));
+
+ let source = app
+ .state
+ .view
+ .workspace_card_areas
+ .iter()
+ .find(|card| card.ws_idx == 2)
+ .unwrap()
+ .rect;
+ let target_row = crate::ui::workspace_drop_indicator_row(
+ &app.state.view.workspace_card_areas,
+ app.state.workspace_list_rect(),
+ 0,
+ )
+ .unwrap();
+
+ app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 2, source.y));
+ app.handle_mouse(mouse(
+ MouseEventKind::Drag(MouseButton::Left),
+ 2,
+ target_row,
+ ));
+ assert!(app.state.drag.is_none());
+ app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), 2, target_row));
+
+ let names = app
+ .state
+ .workspaces
+ .iter()
+ .map(|ws| ws.display_name())
+ .collect::<Vec<_>>();
+ assert_eq!(names, vec!["main", "normal", "issue"]);
+ }
+
+ #[test]
fn dragging_sidebar_divider_sets_manual_width() {
let mut app = app_for_mouse_test();