aboutsummaryrefslogtreecommitdiff
path: root/src/app
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
parenta9ff4012bdca0dbdbc77bc6e53a430689fc899be (diff)
feat: add git worktree workspace management
refs #137
Diffstat (limited to 'src/app')
-rw-r--r--src/app/actions.rs237
-rw-r--r--src/app/api.rs10
-rw-r--r--src/app/api/panes.rs45
-rw-r--r--src/app/api/workspaces.rs43
-rw-r--r--src/app/creation.rs2
-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
-rw-r--r--src/app/mod.rs76
-rw-r--r--src/app/session.rs1
-rw-r--r--src/app/state.rs180
-rw-r--r--src/app/worktrees.rs1090
14 files changed, 2641 insertions, 61 deletions
diff --git a/src/app/actions.rs b/src/app/actions.rs
index 0c722125..d6b3ae36 100644
--- a/src/app/actions.rs
+++ b/src/app/actions.rs
@@ -152,24 +152,45 @@ impl AppState {
return;
}
+ let entries = crate::ui::workspace_list_entries(self);
+ let Some(target_entry_idx) = entries.iter().position(|entry| {
+ matches!(
+ entry,
+ crate::ui::WorkspaceListEntry::Workspace { ws_idx, .. } if *ws_idx == idx
+ )
+ }) else {
+ return;
+ };
+
+ self.workspace_scroll = crate::ui::normalized_workspace_scroll(
+ self,
+ self.view.sidebar_rect,
+ self.workspace_scroll,
+ );
let mut cards = crate::ui::compute_workspace_card_areas(self, self.view.sidebar_rect);
- if cards.is_empty() {
- self.workspace_scroll = idx;
+ if cards.iter().any(|card| card.ws_idx == idx) {
return;
}
- let first_idx = cards.first().map(|card| card.ws_idx).unwrap_or(0);
- if idx < first_idx {
- self.workspace_scroll = idx;
+ if target_entry_idx < self.workspace_scroll {
+ self.workspace_scroll = target_entry_idx;
return;
}
- while cards.last().map(|card| card.ws_idx).unwrap_or(idx) < idx {
+ while !cards.iter().any(|card| card.ws_idx == idx) {
let previous_scroll = self.workspace_scroll;
self.workspace_scroll = self.workspace_scroll.saturating_add(1);
if self.workspace_scroll == previous_scroll {
break;
}
+ self.workspace_scroll = crate::ui::normalized_workspace_scroll(
+ self,
+ self.view.sidebar_rect,
+ self.workspace_scroll,
+ );
+ if self.workspace_scroll == previous_scroll {
+ break;
+ }
cards = crate::ui::compute_workspace_card_areas(self, self.view.sidebar_rect);
if cards.is_empty() {
break;
@@ -235,24 +256,66 @@ impl AppState {
changed
}
+ pub(crate) fn visible_workspace_order(&self) -> Vec<usize> {
+ let order = crate::ui::workspace_list_entries(self)
+ .into_iter()
+ .map(|entry| match entry {
+ crate::ui::WorkspaceListEntry::Workspace { ws_idx, .. } => ws_idx,
+ })
+ .collect::<Vec<_>>();
+ if order.is_empty() {
+ (0..self.workspaces.len()).collect()
+ } else {
+ order
+ }
+ }
+
+ pub(crate) fn workspace_at_visible_position(&self, position: usize) -> Option<usize> {
+ self.visible_workspace_order().get(position).copied()
+ }
+
+ pub(crate) fn move_selected_workspace_by_visible_delta(&mut self, delta: isize) {
+ if self.workspaces.is_empty() {
+ return;
+ }
+ let order = self.visible_workspace_order();
+ let current_pos = order
+ .iter()
+ .position(|idx| *idx == self.selected)
+ .unwrap_or(0);
+ let target_pos = current_pos
+ .saturating_add_signed(delta)
+ .min(order.len().saturating_sub(1));
+ if let Some(ws_idx) = order.get(target_pos).copied() {
+ self.selected = ws_idx;
+ self.ensure_workspace_visible(ws_idx);
+ }
+ }
+
pub fn next_workspace(&mut self) {
- if !self.workspaces.is_empty() {
- let current = self.active.unwrap_or(self.selected);
- let next = (current + 1) % self.workspaces.len();
- self.switch_workspace(next);
+ if self.workspaces.is_empty() {
+ return;
}
+ let current = self.active.unwrap_or(self.selected);
+ let order = self.visible_workspace_order();
+ let current_pos = order.iter().position(|idx| *idx == current).unwrap_or(0);
+ let next = order[(current_pos + 1) % order.len()];
+ self.switch_workspace(next);
}
pub fn previous_workspace(&mut self) {
- if !self.workspaces.is_empty() {
- let current = self.active.unwrap_or(self.selected);
- let prev = if current == 0 {
- self.workspaces.len() - 1
- } else {
- current - 1
- };
- self.switch_workspace(prev);
+ if self.workspaces.is_empty() {
+ return;
}
+ let current = self.active.unwrap_or(self.selected);
+ let order = self.visible_workspace_order();
+ let current_pos = order.iter().position(|idx| *idx == current).unwrap_or(0);
+ let prev = if current_pos == 0 {
+ order[order.len() - 1]
+ } else {
+ order[current_pos - 1]
+ };
+ self.switch_workspace(prev);
}
pub fn move_workspace(&mut self, source_idx: usize, insert_idx: usize) {
@@ -477,10 +540,35 @@ impl AppState {
self.selection = None;
self.selection_autoscroll = None;
self.mark_session_dirty();
- let terminal_ids = self.terminal_ids_for_workspace(self.selected);
- let workspace_id = self.workspaces[self.selected].id.clone();
- crate::logging::workspace_closed(&workspace_id);
- self.workspaces.remove(self.selected);
+ let close_indices = self
+ .workspaces
+ .get(self.selected)
+ .and_then(|ws| ws.worktree_space())
+ .filter(|space| !space.is_linked_worktree)
+ .map(|space| {
+ self.workspaces
+ .iter()
+ .enumerate()
+ .filter_map(|(idx, ws)| {
+ ws.worktree_space()
+ .is_some_and(|member| member.key == space.key)
+ .then_some(idx)
+ })
+ .collect::<Vec<_>>()
+ })
+ .filter(|indices| indices.len() >= 2)
+ .unwrap_or_else(|| vec![self.selected]);
+
+ let mut terminal_ids = Vec::new();
+ for idx in &close_indices {
+ terminal_ids.extend(self.terminal_ids_for_workspace(*idx));
+ if let Some(workspace_id) = self.workspaces.get(*idx).map(|ws| ws.id.clone()) {
+ crate::logging::workspace_closed(&workspace_id);
+ }
+ }
+ for idx in close_indices.iter().rev() {
+ self.workspaces.remove(*idx);
+ }
self.remove_unattached_terminal_ids(terminal_ids);
if self.workspaces.is_empty() {
self.active = None;
@@ -749,6 +837,10 @@ impl AppState {
ws.cached_git_ahead_behind = result.ahead_behind;
changed = true;
}
+ if ws.cached_git_space != result.space {
+ ws.cached_git_space = result.space;
+ changed = true;
+ }
}
changed
}
@@ -840,6 +932,8 @@ impl AppState {
let _ = results;
Vec::new()
}
+ AppEvent::WorktreeAddFinished(_) => Vec::new(),
+ AppEvent::WorktreeRemoveFinished(_) => Vec::new(),
}
}
@@ -1016,6 +1110,16 @@ mod tests {
state
}
+ fn mark_linked_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: format!("/repo/worktree-{ws_idx}").into(),
+ is_linked_worktree: true,
+ });
+ }
+
#[test]
fn apply_workspace_git_statuses_updates_matching_workspace() {
let mut state = app_with_workspaces(&["one", "two"]);
@@ -1031,6 +1135,7 @@ mod tests {
resolved_identity_cwd: first_cwd,
branch: Some("main".into()),
ahead_behind: Some((2, 1)),
+ space: None,
}],
);
@@ -1056,6 +1161,7 @@ mod tests {
resolved_identity_cwd: std::path::PathBuf::from("/definitely/not/current"),
branch: Some("main".into()),
ahead_behind: Some((0, 1)),
+ space: None,
}],
);
@@ -1080,6 +1186,7 @@ mod tests {
resolved_identity_cwd: cwd,
branch: None,
ahead_behind: None,
+ space: None,
}],
);
@@ -1089,6 +1196,36 @@ mod tests {
}
#[test]
+ fn apply_workspace_git_statuses_does_not_change_worktree_membership() {
+ let mut state = app_with_workspaces(&["one"]);
+ mark_linked_worktree(&mut state, 0);
+ let workspace_id = state.workspaces[0].id.clone();
+ let cwd = state.workspaces[0].resolved_identity_cwd().unwrap();
+ let membership = state.workspaces[0].worktree_space().cloned();
+
+ let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new();
+ let changed = state.apply_workspace_git_statuses(
+ &terminal_runtimes,
+ vec![WorkspaceGitStatus {
+ workspace_id,
+ resolved_identity_cwd: cwd,
+ branch: Some("scratch".into()),
+ ahead_behind: None,
+ space: Some(crate::workspace::GitSpaceMetadata {
+ key: "other-repo-key".into(),
+ checkout_key: "/other/checkout".into(),
+ label: "other".into(),
+ repo_root: "/other/repo".into(),
+ is_linked_worktree: false,
+ }),
+ }],
+ );
+
+ assert!(changed);
+ assert_eq!(state.workspaces[0].worktree_space().cloned(), membership);
+ }
+
+ #[test]
fn update_ready_sets_explicit_upgrade_toast() {
let mut state = AppState::test_new();
state.toast_config.delivery = crate::config::ToastDelivery::Herdr;
@@ -1324,6 +1461,34 @@ mod tests {
}
#[test]
+ fn close_parent_worktree_workspace_closes_group() {
+ let mut state = app_with_workspaces(&["main", "issue", "notes"]);
+ 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,
+ });
+ state.selected = 0;
+ state.active = Some(0);
+
+ state.close_selected_workspace();
+
+ assert_eq!(state.workspaces.len(), 1);
+ assert_eq!(state.workspaces[0].display_name(), "notes");
+ assert_eq!(state.active, Some(0));
+ assert_eq!(state.selected, 0);
+ }
+
+ #[test]
fn close_last_workspace_clears_active() {
let mut state = app_with_workspaces(&["only"]);
state.selected = 0;
@@ -1868,4 +2033,32 @@ mod tests {
assert_eq!(state.workspaces[0].display_name(), "selected");
assert!(!state.terminals.contains_key(&active_terminal_id));
}
+
+ #[test]
+ fn close_tab_last_tab_in_linked_worktree_closes_workspace_only() {
+ let mut state = app_with_workspaces(&["selected", "active"]);
+ mark_linked_worktree(&mut state, 1);
+ state.active = Some(1);
+ state.selected = 0;
+
+ state.close_tab();
+
+ assert_eq!(state.request_remove_linked_worktree, None);
+ assert_eq!(state.workspaces.len(), 1);
+ assert_eq!(state.workspaces[0].display_name(), "selected");
+ }
+
+ #[test]
+ fn close_pane_last_pane_in_linked_worktree_closes_workspace_only() {
+ let mut state = app_with_workspaces(&["selected", "active"]);
+ mark_linked_worktree(&mut state, 1);
+ state.active = Some(1);
+ state.selected = 0;
+
+ state.close_pane();
+
+ assert_eq!(state.request_remove_linked_worktree, None);
+ assert_eq!(state.workspaces.len(), 1);
+ assert_eq!(state.workspaces[0].display_name(), "selected");
+ }
}
diff --git a/src/app/api.rs b/src/app/api.rs
index c8ae530a..877a9fee 100644
--- a/src/app/api.rs
+++ b/src/app/api.rs
@@ -31,6 +31,16 @@ impl App {
return;
}
+ if let AppEvent::WorktreeAddFinished(result) = ev {
+ self.handle_worktree_add_finished(result);
+ return;
+ }
+
+ if let AppEvent::WorktreeRemoveFinished(result) = ev {
+ self.handle_worktree_remove_finished(result);
+ return;
+ }
+
let overlay_state = if let AppEvent::PaneDied { pane_id } = &ev {
self.overlay_panes.remove(pane_id)
} else {
diff --git a/src/app/api/panes.rs b/src/app/api/panes.rs
index b09ecb2a..2b1741d9 100644
--- a/src/app/api/panes.rs
+++ b/src/app/api/panes.rs
@@ -351,3 +351,48 @@ fn pane_not_found(id: String, pane_id: &str) -> String {
fn invalid_agent(id: String) -> String {
encode_error(id, "invalid_agent", "agent label must not be empty")
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::{api::schema::SuccessResponse, config::Config, workspace::Workspace};
+
+ fn app_with_linked_worktree() -> App {
+ let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
+ let mut app = App::new(
+ &Config::default(),
+ true,
+ None,
+ api_rx,
+ crate::api::EventHub::default(),
+ );
+ app.state.workspaces = vec![Workspace::test_new("issue")];
+ app.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-issue".into(),
+ is_linked_worktree: true,
+ });
+ app
+ }
+
+ #[test]
+ fn api_pane_close_closes_linked_worktree_workspace_only() {
+ let mut app = app_with_linked_worktree();
+ let pane_id = app.state.workspaces[0].tabs[0].root_pane;
+ let public_pane_id = app.public_pane_id(0, pane_id).unwrap();
+
+ let response = app.handle_pane_close(
+ "req".into(),
+ PaneTarget {
+ pane_id: public_pane_id,
+ },
+ );
+
+ let success: SuccessResponse = serde_json::from_str(&response).unwrap();
+ assert_eq!(success.id, "req");
+ assert_eq!(app.state.request_remove_linked_worktree, None);
+ assert!(app.state.workspaces.is_empty());
+ }
+}
diff --git a/src/app/api/workspaces.rs b/src/app/api/workspaces.rs
index 2f8c1a28..f5e9a563 100644
--- a/src/app/api/workspaces.rs
+++ b/src/app/api/workspaces.rs
@@ -166,3 +166,46 @@ fn workspace_not_found(id: String, workspace_id: &str) -> String {
format!("workspace {workspace_id} not found"),
)
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::{api::schema::SuccessResponse, config::Config, workspace::Workspace};
+
+ fn app_with_linked_worktree() -> App {
+ let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
+ let mut app = App::new(
+ &Config::default(),
+ true,
+ None,
+ api_rx,
+ crate::api::EventHub::default(),
+ );
+ app.state.workspaces = vec![Workspace::test_new("issue")];
+ app.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-issue".into(),
+ is_linked_worktree: true,
+ });
+ app
+ }
+
+ #[test]
+ fn api_workspace_close_closes_linked_worktree_workspace_only() {
+ let mut app = app_with_linked_worktree();
+
+ let response = app.handle_workspace_close(
+ "req".into(),
+ WorkspaceTarget {
+ workspace_id: app.state.workspaces[0].id.clone(),
+ },
+ );
+
+ let success: SuccessResponse = serde_json::from_str(&response).unwrap();
+ assert_eq!(success.id, "req");
+ assert_eq!(app.state.request_remove_linked_worktree, None);
+ assert!(app.state.workspaces.is_empty());
+ }
+}
diff --git a/src/app/creation.rs b/src/app/creation.rs
index de1ad128..f6166a85 100644
--- a/src/app/creation.rs
+++ b/src/app/creation.rs
@@ -105,7 +105,7 @@ impl App {
Ok(idx)
}
- pub(super) fn create_workspace_with_options(
+ pub(crate) fn create_workspace_with_options(
&mut self,
initial_cwd: std::path::PathBuf,
focus: bool,
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();
diff --git a/src/app/mod.rs b/src/app/mod.rs
index 107a4393..a1ae4a74 100644
--- a/src/app/mod.rs
+++ b/src/app/mod.rs
@@ -17,6 +17,7 @@ mod session;
pub mod state;
mod terminal_targets;
mod theme_sync;
+mod worktrees;
use std::collections::{HashMap, HashSet};
use std::future::pending;
@@ -234,6 +235,7 @@ impl App {
sidebar_width,
sidebar_width_source,
sidebar_section_split,
+ collapsed_space_keys,
) = if no_session {
(
Vec::new(),
@@ -243,6 +245,7 @@ impl App {
config.ui.sidebar_width,
state::SidebarWidthSource::ConfigDefault,
0.5_f32,
+ std::collections::HashSet::new(),
)
} else if let Some(snap) = crate::persist::load() {
let (ws, terminals, terminal_runtimes) = crate::persist::restore(
@@ -271,6 +274,7 @@ impl App {
state::SidebarWidthSource::ConfigDefault
},
snap.sidebar_section_split.unwrap_or(0.5),
+ snap.collapsed_space_keys,
)
} else {
crate::logging::session_restored(ws.len(), "ok");
@@ -288,6 +292,7 @@ impl App {
state::SidebarWidthSource::ConfigDefault
},
snap.sidebar_section_split.unwrap_or(0.5),
+ snap.collapsed_space_keys,
)
}
} else {
@@ -299,6 +304,7 @@ impl App {
config.ui.sidebar_width,
state::SidebarWidthSource::ConfigDefault,
0.5_f32,
+ std::collections::HashSet::new(),
)
};
@@ -320,6 +326,8 @@ impl App {
(18, 36)
});
+ let worktree_directory = crate::worktree::expand_tilde_path(&config.worktrees.directory);
+
info!(
pane_scrollback_limit_bytes = config.advanced.scrollback_limit_bytes,
"using pane scrollback configuration"
@@ -357,12 +365,24 @@ impl App {
detach_requested: false,
request_new_workspace: false,
request_new_tab: false,
+ request_new_linked_worktree: None,
+ request_open_existing_worktree: None,
+ request_new_workspace_cwd: None,
+ request_remove_linked_worktree: None,
+ request_submit_worktree_create: false,
+ request_submit_worktree_open: false,
+ request_submit_worktree_remove: false,
request_reload_config: false,
request_client_sound_config_reload: false,
request_clipboard_write: None,
creating_new_tab: false,
requested_new_tab_name: None,
rename_pane_target: None,
+ worktree_create: None,
+ worktree_open: None,
+ worktree_remove: None,
+ worktree_directory,
+ collapsed_space_keys,
request_complete_onboarding: false,
name_input: String::new(),
name_input_replace_on_type: false,
@@ -569,6 +589,47 @@ impl App {
needs_render = true;
}
+ if let Some(ws_idx) = self.state.request_new_linked_worktree.take() {
+ self.open_new_linked_worktree_dialog(ws_idx);
+ needs_render = true;
+ }
+
+ if let Some(ws_idx) = self.state.request_open_existing_worktree.take() {
+ self.open_existing_worktree_dialog(ws_idx);
+ needs_render = true;
+ }
+
+ if let Some(cwd) = self.state.request_new_workspace_cwd.take() {
+ if let Err(err) = self.create_workspace_with_options(cwd, true) {
+ tracing::error!(err = %err, "failed to create workspace at requested cwd");
+ self.state.mode = Mode::Navigate;
+ }
+ needs_render = true;
+ }
+
+ if let Some(ws_idx) = self.state.request_remove_linked_worktree.take() {
+ self.open_remove_linked_worktree_confirmation(ws_idx);
+ needs_render = true;
+ }
+
+ if self.state.request_submit_worktree_create {
+ self.state.request_submit_worktree_create = false;
+ self.start_worktree_add();
+ needs_render = true;
+ }
+
+ if self.state.request_submit_worktree_open {
+ self.state.request_submit_worktree_open = false;
+ self.open_selected_existing_worktree();
+ needs_render = true;
+ }
+
+ if self.state.request_submit_worktree_remove {
+ self.state.request_submit_worktree_remove = false;
+ self.start_worktree_remove();
+ needs_render = true;
+ }
+
if self.state.request_reload_config {
self.state.request_reload_config = false;
self.reload_config();
@@ -945,6 +1006,11 @@ impl App {
self.state.default_shell = config.terminal.default_shell.clone();
}
+ if !invalid_section("worktrees") {
+ self.state.worktree_directory =
+ crate::worktree::expand_tilde_path(&config.worktrees.directory);
+ }
+
if !invalid_section("theme") {
self.state.palette = resolve_palette_with_legacy_accent(config, !invalid_section("ui"));
self.state.theme_name = config
@@ -1105,6 +1171,15 @@ impl App {
Mode::RenameWorkspace | Mode::RenameTab | Mode::RenamePane => {
input::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 => {
input::handle_resize_key(&mut self.state, key);
}
@@ -1255,6 +1330,7 @@ mod tests {
resolved_identity_cwd,
branch: Some("render-dirty-test".into()),
ahead_behind: Some((1, 0)),
+ space: None,
}],
});
diff --git a/src/app/session.rs b/src/app/session.rs
index 976e320e..2adff1b8 100644
--- a/src/app/session.rs
+++ b/src/app/session.rs
@@ -34,6 +34,7 @@ impl App {
self.state.agent_panel_scope,
self.state.sidebar_width,
self.state.sidebar_section_split,
+ self.state.collapsed_space_keys.clone(),
);
crate::persist::save(&snap);
}
diff --git a/src/app/state.rs b/src/app/state.rs
index b38afdb3..ff039c14 100644
--- a/src/app/state.rs
+++ b/src/app/state.rs
@@ -557,6 +557,52 @@ impl Palette {
pub struct WorkspaceCardArea {
pub ws_idx: usize,
pub rect: Rect,
+ pub indented: bool,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct WorktreeCreateState {
+ pub source_workspace_id: String,
+ pub source_checkout_path: std::path::PathBuf,
+ pub source_existing_membership: Option<crate::workspace::WorktreeSpaceMembership>,
+ pub source_repo_root: std::path::PathBuf,
+ pub repo_key: String,
+ pub repo_name: String,
+ pub branch: String,
+ pub checkout_path: std::path::PathBuf,
+ pub error: Option<String>,
+ pub creating: bool,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct WorktreeRemoveState {
+ pub workspace_id: String,
+ pub repo_root: std::path::PathBuf,
+ pub path: std::path::PathBuf,
+ pub error: Option<String>,
+ pub removing: bool,
+ pub force_confirmation: bool,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct WorktreeOpenEntry {
+ pub path: std::path::PathBuf,
+ pub branch: Option<String>,
+ pub is_linked_worktree: bool,
+ pub already_open_ws_idx: Option<usize>,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct WorktreeOpenState {
+ pub source_workspace_id: String,
+ pub source_existing_membership: Option<crate::workspace::WorktreeSpaceMembership>,
+ pub source_checkout_path: std::path::PathBuf,
+ pub source_repo_root: std::path::PathBuf,
+ pub repo_key: String,
+ pub repo_name: String,
+ pub entries: Vec<WorktreeOpenEntry>,
+ pub selected: usize,
+ pub error: Option<String>,
}
/// Computed view geometry — derived from AppState + terminal size.
@@ -595,6 +641,9 @@ pub enum Mode {
RenameWorkspace,
RenameTab,
RenamePane,
+ NewLinkedWorktree,
+ OpenExistingWorktree,
+ ConfirmRemoveWorktree,
Resize,
ConfirmClose,
ContextMenu,
@@ -785,11 +834,17 @@ pub(crate) struct TabPressState {
pub start_row: u16,
}
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ContextMenuKind {
Workspace {
ws_idx: usize,
},
+ GitWorkspace {
+ ws_idx: usize,
+ is_linked_worktree: bool,
+ has_worktree_children: bool,
+ collapsed: bool,
+ },
Tab {
ws_idx: usize,
tab_idx: usize,
@@ -812,6 +867,39 @@ impl ContextMenuState {
pub fn items(&self) -> &'static [&'static str] {
match self.kind {
ContextMenuKind::Workspace { .. } => &["Rename", "Close"],
+ ContextMenuKind::GitWorkspace {
+ is_linked_worktree: false,
+ has_worktree_children: false,
+ ..
+ } => &["Rename", "Close", "New worktree", "Open worktree..."],
+ ContextMenuKind::GitWorkspace {
+ is_linked_worktree: true,
+ ..
+ } => &["Rename", "Close", "Delete worktree checkout..."],
+ ContextMenuKind::GitWorkspace {
+ is_linked_worktree: false,
+ has_worktree_children: true,
+ collapsed: true,
+ ..
+ } => &[
+ "Rename",
+ "Close group",
+ "New worktree",
+ "Open worktree...",
+ "Expand",
+ ],
+ ContextMenuKind::GitWorkspace {
+ is_linked_worktree: false,
+ has_worktree_children: true,
+ collapsed: false,
+ ..
+ } => &[
+ "Rename",
+ "Close group",
+ "New worktree",
+ "Open worktree...",
+ "Collapse",
+ ],
ContextMenuKind::Tab { .. } => &["New tab", "Rename", "Close"],
ContextMenuKind::Pane {
has_manual_label: true,
@@ -905,6 +993,13 @@ pub struct AppState {
pub detach_requested: bool,
pub request_new_workspace: bool,
pub request_new_tab: bool,
+ pub request_new_linked_worktree: Option<usize>,
+ pub request_open_existing_worktree: Option<usize>,
+ pub request_new_workspace_cwd: Option<std::path::PathBuf>,
+ pub request_remove_linked_worktree: Option<usize>,
+ pub request_submit_worktree_create: bool,
+ pub request_submit_worktree_open: bool,
+ pub request_submit_worktree_remove: bool,
pub request_reload_config: bool,
/// Set when the headless server should ask attached clients to reload
/// their client-local sound config from disk.
@@ -915,6 +1010,11 @@ pub struct AppState {
pub creating_new_tab: bool,
pub requested_new_tab_name: Option<String>,
pub rename_pane_target: Option<PaneId>,
+ pub worktree_create: Option<WorktreeCreateState>,
+ pub worktree_open: Option<WorktreeOpenState>,
+ pub worktree_remove: Option<WorktreeRemoveState>,
+ pub worktree_directory: std::path::PathBuf,
+ pub collapsed_space_keys: std::collections::HashSet<String>,
pub request_complete_onboarding: bool,
pub name_input: String,
pub name_input_replace_on_type: bool,
@@ -1182,12 +1282,24 @@ impl AppState {
detach_requested: false,
request_new_workspace: false,
request_new_tab: false,
+ request_new_linked_worktree: None,
+ request_open_existing_worktree: None,
+ request_new_workspace_cwd: None,
+ request_remove_linked_worktree: None,
+ request_submit_worktree_create: false,
+ request_submit_worktree_open: false,
+ request_submit_worktree_remove: false,
request_reload_config: false,
request_client_sound_config_reload: false,
request_clipboard_write: None,
creating_new_tab: false,
requested_new_tab_name: None,
rename_pane_target: None,
+ worktree_create: None,
+ worktree_open: None,
+ worktree_remove: None,
+ worktree_directory: std::path::PathBuf::from("/tmp/herdr-worktrees"),
+ collapsed_space_keys: std::collections::HashSet::new(),
request_complete_onboarding: false,
name_input: String::new(),
name_input_replace_on_type: false,
@@ -1362,4 +1474,70 @@ mod tests {
KeyModifiers::SHIFT,
));
}
+
+ #[test]
+ fn linked_worktree_context_menu_keeps_safe_close_and_explicit_remove() {
+ let menu = ContextMenuState {
+ kind: ContextMenuKind::GitWorkspace {
+ ws_idx: 0,
+ is_linked_worktree: true,
+ has_worktree_children: false,
+ collapsed: false,
+ },
+ x: 0,
+ y: 0,
+ list: MenuListState::new(0),
+ };
+
+ assert_eq!(
+ menu.items(),
+ &["Rename", "Close", "Delete worktree checkout..."]
+ );
+ }
+
+ #[test]
+ fn git_workspace_context_menu_keeps_remove_for_managed_worktrees_only() {
+ let menu = ContextMenuState {
+ kind: ContextMenuKind::GitWorkspace {
+ ws_idx: 0,
+ is_linked_worktree: false,
+ has_worktree_children: false,
+ collapsed: false,
+ },
+ x: 0,
+ y: 0,
+ list: MenuListState::new(0),
+ };
+
+ assert_eq!(
+ menu.items(),
+ &["Rename", "Close", "New worktree", "Open worktree..."]
+ );
+ }
+
+ #[test]
+ fn parent_worktree_context_menu_uses_repo_actions() {
+ 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),
+ };
+
+ assert_eq!(
+ menu.items(),
+ &[
+ "Rename",
+ "Close group",
+ "New worktree",
+ "Open worktree...",
+ "Collapse"
+ ]
+ );
+ }
}
diff --git a/src/app/worktrees.rs b/src/app/worktrees.rs
new file mode 100644
index 00000000..d20b5fb0
--- /dev/null
+++ b/src/app/worktrees.rs
@@ -0,0 +1,1090 @@
+use std::sync::atomic::Ordering;
+use std::time::{SystemTime, UNIX_EPOCH};
+
+use crossterm::event::{KeyCode, KeyEvent};
+
+use super::{
+ state::{WorktreeCreateState, WorktreeOpenEntry, WorktreeOpenState, WorktreeRemoveState},
+ App, Mode,
+};
+use crate::events::{AppEvent, WorktreeAddResult, WorktreeRemoveResult};
+
+impl App {
+ fn worktree_source_metadata(
+ &self,
+ ws_idx: usize,
+ ) -> Result<
+ (
+ Option<crate::workspace::WorktreeSpaceMembership>,
+ crate::workspace::GitSpaceMetadata,
+ std::path::PathBuf,
+ String,
+ ),
+ String,
+ > {
+ let Some(ws) = self.state.workspaces.get(ws_idx) else {
+ return Err("Workspace not found.".into());
+ };
+ let existing_membership = ws.worktree_space().cloned();
+ if existing_membership
+ .as_ref()
+ .is_some_and(|membership| membership.is_linked_worktree)
+ {
+ return Err(
+ "New and open worktree actions start from the repo parent workspace.".into(),
+ );
+ }
+
+ let git_space = ws.git_space().cloned().or_else(|| {
+ ws.resolved_identity_cwd_from(&self.state.terminals, &self.terminal_runtimes)
+ .as_deref()
+ .and_then(crate::workspace::git_space_metadata)
+ });
+ if git_space
+ .as_ref()
+ .is_some_and(|metadata| metadata.is_linked_worktree)
+ {
+ return Err(
+ "New and open worktree actions start from the repo parent workspace.".into(),
+ );
+ }
+
+ let space = existing_membership
+ .as_ref()
+ .map_or(git_space, |membership| {
+ Some(crate::workspace::GitSpaceMetadata {
+ key: membership.key.clone(),
+ checkout_key: membership.checkout_path.display().to_string(),
+ label: membership.label.clone(),
+ repo_root: membership.repo_root.clone(),
+ is_linked_worktree: membership.is_linked_worktree,
+ })
+ })
+ .ok_or_else(|| {
+ "Herdr worktree actions require a workspace inside a Git work tree.".to_string()
+ })?;
+ let source_checkout_path = existing_membership
+ .as_ref()
+ .map(|membership| membership.checkout_path.clone())
+ .unwrap_or_else(|| space.repo_root.clone());
+ let source_workspace_id = self.state.workspaces[ws_idx].id.clone();
+ Ok((
+ existing_membership,
+ space,
+ source_checkout_path,
+ source_workspace_id,
+ ))
+ }
+
+ pub(crate) fn open_new_linked_worktree_dialog(&mut self, ws_idx: usize) {
+ let (existing_membership, space, source_checkout_path, source_workspace_id) =
+ match self.worktree_source_metadata(ws_idx) {
+ Ok(metadata) => metadata,
+ Err(err) => {
+ self.state.config_diagnostic = Some(err);
+ return;
+ }
+ };
+
+ let repo_name = space.label.clone();
+ let seed = SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .map(|duration| duration.as_micros().min(u128::from(u64::MAX)) as u64)
+ .unwrap_or(0);
+ let branch = crate::worktree::generated_branch_slug(seed);
+ let checkout_path = crate::worktree::default_checkout_path(
+ &self.state.worktree_directory,
+ &repo_name,
+ &branch,
+ );
+
+ tracing::info!(
+ ws_idx,
+ repo_root = %space.repo_root.display(),
+ branch,
+ checkout_path = %checkout_path.display(),
+ "opening worktree dialog"
+ );
+ self.state.selected = ws_idx;
+ self.state.name_input = branch.clone();
+ self.state.name_input_replace_on_type = true;
+ self.state.worktree_create = Some(WorktreeCreateState {
+ source_workspace_id,
+ source_checkout_path,
+ source_existing_membership: existing_membership,
+ source_repo_root: space.repo_root,
+ repo_key: space.key,
+ repo_name,
+ branch,
+ checkout_path,
+ error: None,
+ creating: false,
+ });
+ self.state.mode = Mode::NewLinkedWorktree;
+ }
+
+ pub(crate) fn open_remove_linked_worktree_confirmation(&mut self, ws_idx: usize) {
+ let Some(ws) = self.state.workspaces.get(ws_idx) else {
+ return;
+ };
+ if !ws
+ .worktree_space()
+ .is_some_and(|space| space.is_linked_worktree)
+ {
+ self.state.config_diagnostic =
+ Some("This workspace is not a Herdr-managed worktree checkout.".into());
+ return;
+ }
+ let Some(space) = ws.worktree_space().cloned() else {
+ return;
+ };
+ self.state.selected = ws_idx;
+ self.state.worktree_remove = Some(WorktreeRemoveState {
+ workspace_id: ws.id.clone(),
+ repo_root: space.repo_root,
+ path: space.checkout_path,
+ error: None,
+ removing: false,
+ force_confirmation: false,
+ });
+ self.state.mode = Mode::ConfirmRemoveWorktree;
+ }
+
+ pub(crate) fn open_existing_worktree_dialog(&mut self, ws_idx: usize) {
+ let (existing_membership, space, source_checkout_path, source_workspace_id) =
+ match self.worktree_source_metadata(ws_idx) {
+ Ok(metadata) => metadata,
+ Err(err) => {
+ self.state.config_diagnostic = Some(err);
+ return;
+ }
+ };
+
+ let list = match crate::worktree::list_existing_worktrees(&space.repo_root) {
+ Ok(list) => list,
+ Err(err) => {
+ self.state.config_diagnostic = Some(err);
+ return;
+ }
+ };
+ let entries = list
+ .into_iter()
+ .filter(|entry| !entry.is_bare && !entry.is_prunable)
+ .map(|entry| {
+ let entry_checkout_path = crate::worktree::canonical_or_original(&entry.path);
+ let entry_checkout_key = entry_checkout_path.display().to_string();
+ let repo_checkout_path = crate::worktree::canonical_or_original(&space.repo_root);
+ let already_open_ws_idx = self.state.workspaces.iter().position(|ws| {
+ if let Some(membership) = ws.worktree_space() {
+ return crate::worktree::canonical_or_original(&membership.checkout_path)
+ == entry_checkout_path;
+ }
+
+ let git_space = ws.git_space().cloned().or_else(|| {
+ ws.resolved_identity_cwd_from(
+ &self.state.terminals,
+ &self.terminal_runtimes,
+ )
+ .as_deref()
+ .and_then(crate::workspace::git_space_metadata)
+ });
+ if git_space
+ .as_ref()
+ .is_some_and(|metadata| metadata.checkout_key == entry_checkout_key)
+ {
+ return true;
+ }
+
+ ws.resolved_identity_cwd_from(&self.state.terminals, &self.terminal_runtimes)
+ .as_deref()
+ .is_some_and(|cwd| {
+ crate::worktree::canonical_or_original(cwd) == entry_checkout_path
+ })
+ });
+ WorktreeOpenEntry {
+ is_linked_worktree: entry_checkout_path != repo_checkout_path,
+ path: entry.path,
+ branch: entry.branch,
+ already_open_ws_idx,
+ }
+ })
+ .collect::<Vec<_>>();
+
+ if entries.is_empty() {
+ self.state.config_diagnostic = Some("No Git worktrees found for this repo.".into());
+ return;
+ }
+
+ self.state.selected = ws_idx;
+ self.state.worktree_open = Some(WorktreeOpenState {
+ source_workspace_id,
+ source_existing_membership: existing_membership,
+ source_checkout_path,
+ source_repo_root: space.repo_root,
+ repo_key: space.key,
+ repo_name: space.label,
+ entries,
+ selected: 0,
+ error: None,
+ });
+ self.state.mode = Mode::OpenExistingWorktree;
+ }
+
+ pub(crate) fn handle_worktree_create_key(&mut self, key: KeyEvent) {
+ match key.code {
+ KeyCode::Esc => {
+ if self
+ .state
+ .worktree_create
+ .as_ref()
+ .is_some_and(|create| create.creating)
+ {
+ return;
+ }
+ self.close_worktree_create_dialog();
+ }
+ KeyCode::Enter => self.start_worktree_add(),
+ KeyCode::Backspace => {
+ if self.state.name_input_replace_on_type {
+ self.state.name_input.clear();
+ self.state.name_input_replace_on_type = false;
+ } else {
+ self.state.name_input.pop();
+ }
+ self.sync_worktree_branch_from_input();
+ }
+ KeyCode::Char(c) => {
+ if self.state.name_input_replace_on_type {
+ self.state.name_input.clear();
+ self.state.name_input_replace_on_type = false;
+ }
+ self.state.name_input.push(c);
+ self.sync_worktree_branch_from_input();
+ }
+ _ => {}
+ }
+ }
+
+ pub(crate) fn handle_worktree_open_key(&mut self, key: KeyEvent) {
+ match key.code {
+ KeyCode::Esc => {
+ self.state.worktree_open = None;
+ self.state.mode = if self.state.active.is_some() {
+ Mode::Terminal
+ } else {
+ Mode::Navigate
+ };
+ }
+ KeyCode::Up => {
+ if let Some(open) = &mut self.state.worktree_open {
+ open.selected = open.selected.saturating_sub(1);
+ }
+ }
+ 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));
+ }
+ }
+ KeyCode::Enter => self.open_selected_existing_worktree(),
+ _ => {}
+ }
+ }
+
+ pub(crate) fn open_selected_existing_worktree(&mut self) {
+ let Some(open) = self.state.worktree_open.as_ref() else {
+ return;
+ };
+ let Some(entry) = open.entries.get(open.selected).cloned() else {
+ return;
+ };
+ let source_workspace_id = open.source_workspace_id.clone();
+ let source_existing_membership = open.source_existing_membership.clone();
+ let source_checkout_path = open.source_checkout_path.clone();
+ let source_repo_root = open.source_repo_root.clone();
+ let repo_key = open.repo_key.clone();
+ let repo_name = open.repo_name.clone();
+ self.state.worktree_open = None;
+
+ if let Some(ws_idx) = entry.already_open_ws_idx {
+ self.mark_opened_existing_worktree_membership(
+ &source_workspace_id,
+ source_existing_membership,
+ source_checkout_path,
+ source_repo_root,
+ repo_key,
+ repo_name,
+ ws_idx,
+ entry.path,
+ entry.is_linked_worktree,
+ );
+ self.state.switch_workspace(ws_idx);
+ self.state.mode = Mode::Terminal;
+ return;
+ }
+
+ match self.create_workspace_with_options(entry.path.clone(), true) {
+ Ok(new_ws_idx) => {
+ self.mark_opened_existing_worktree_membership(
+ &source_workspace_id,
+ source_existing_membership,
+ source_checkout_path,
+ source_repo_root,
+ repo_key,
+ repo_name,
+ new_ws_idx,
+ entry.path,
+ entry.is_linked_worktree,
+ );
+ }
+ Err(err) => {
+ self.state.worktree_open = Some(WorktreeOpenState {
+ source_workspace_id,
+ source_existing_membership,
+ source_checkout_path,
+ source_repo_root,
+ repo_key,
+ repo_name,
+ entries: vec![entry],
+ selected: 0,
+ error: Some(format!("failed to open worktree: {err}")),
+ });
+ self.state.mode = Mode::OpenExistingWorktree;
+ }
+ }
+ }
+
+ // The caller has already extracted the open-worktree dialog state; keeping the
+ // membership fields explicit here avoids borrowing AppState across workspace creation.
+ #[allow(clippy::too_many_arguments)]
+ fn mark_opened_existing_worktree_membership(
+ &mut self,
+ source_workspace_id: &str,
+ source_existing_membership: Option<crate::workspace::WorktreeSpaceMembership>,
+ source_checkout_path: std::path::PathBuf,
+ source_repo_root: std::path::PathBuf,
+ repo_key: String,
+ repo_name: String,
+ target_ws_idx: usize,
+ target_path: std::path::PathBuf,
+ target_is_linked_worktree: bool,
+ ) {
+ if let Some(source_ws_idx) = self
+ .state
+ .workspaces
+ .iter()
+ .position(|ws| ws.id == source_workspace_id)
+ {
+ if let Some(source_membership) = source_existing_membership {
+ self.state.workspaces[source_ws_idx].worktree_space = Some(source_membership);
+ } else {
+ self.state.workspaces[source_ws_idx].worktree_space =
+ Some(crate::workspace::WorktreeSpaceMembership {
+ key: repo_key.clone(),
+ label: repo_name.clone(),
+ repo_root: source_repo_root.clone(),
+ checkout_path: source_checkout_path,
+ is_linked_worktree: false,
+ });
+ }
+ }
+ if let Some(target) = self.state.workspaces.get_mut(target_ws_idx) {
+ target.worktree_space = Some(crate::workspace::WorktreeSpaceMembership {
+ key: repo_key,
+ label: repo_name,
+ repo_root: source_repo_root,
+ checkout_path: target_path,
+ is_linked_worktree: target_is_linked_worktree,
+ });
+ }
+ self.state.mark_session_dirty();
+ }
+
+ fn close_worktree_create_dialog(&mut self) {
+ self.state.worktree_create = None;
+ self.state.name_input.clear();
+ self.state.name_input_replace_on_type = false;
+ self.state.mode = if self.state.active.is_some() {
+ Mode::Terminal
+ } else {
+ Mode::Navigate
+ };
+ }
+
+ fn sync_worktree_branch_from_input(&mut self) {
+ let Some(create) = &mut self.state.worktree_create else {
+ return;
+ };
+ create.branch = self.state.name_input.clone();
+ create.checkout_path = crate::worktree::default_checkout_path(
+ &self.state.worktree_directory,
+ &create.repo_name,
+ &create.branch,
+ );
+ create.error = None;
+ }
+
+ pub(crate) fn start_worktree_add(&mut self) {
+ self.sync_worktree_branch_from_input();
+ let Some(create) = &mut self.state.worktree_create else {
+ return;
+ };
+ let branch = create.branch.trim().to_string();
+ if branch.is_empty() {
+ create.error = Some("branch is required".into());
+ return;
+ }
+ if create.creating {
+ return;
+ }
+
+ create.branch = branch.clone();
+ self.state.name_input = branch.clone();
+ create.checkout_path = crate::worktree::default_checkout_path(
+ &self.state.worktree_directory,
+ &create.repo_name,
+ &branch,
+ );
+ create.creating = true;
+ create.error = None;
+
+ let command = crate::worktree::build_worktree_add_new_branch_command(
+ &create.source_checkout_path,
+ &create.checkout_path,
+ &create.branch,
+ "HEAD",
+ );
+ let parent_dir = create
+ .checkout_path
+ .parent()
+ .map(std::path::Path::to_path_buf);
+ tracing::info!(
+ repo_root = %create.source_repo_root.display(),
+ branch = %create.branch,
+ checkout_path = %create.checkout_path.display(),
+ "starting git worktree add"
+ );
+ let path = create.checkout_path.clone();
+ let event_tx = self.event_tx.clone();
+ std::thread::spawn(move || {
+ let result = if let Some(parent_dir) = parent_dir {
+ std::fs::create_dir_all(&parent_dir)
+ .map_err(|err| err.to_string())
+ .and_then(|()| crate::worktree::run_worktree_command(&command))
+ } else {
+ crate::worktree::run_worktree_command(&command)
+ };
+ let _ = event_tx.blocking_send(AppEvent::WorktreeAddFinished(WorktreeAddResult {
+ path,
+ result,
+ }));
+ });
+ }
+
+ pub(crate) fn handle_worktree_remove_key(&mut self, key: KeyEvent) {
+ match key.code {
+ KeyCode::Esc => {
+ if self
+ .state
+ .worktree_remove
+ .as_ref()
+ .is_some_and(|remove| remove.removing)
+ {
+ return;
+ }
+ self.state.worktree_remove = None;
+ self.state.mode = if self.state.active.is_some() {
+ Mode::Terminal
+ } else {
+ Mode::Navigate
+ };
+ }
+ KeyCode::Enter => self.start_worktree_remove(),
+ _ => {}
+ }
+ }
+
+ pub(crate) fn start_worktree_remove(&mut self) {
+ let Some(remove) = &mut self.state.worktree_remove else {
+ return;
+ };
+ if remove.removing {
+ return;
+ }
+ remove.removing = true;
+ remove.error = None;
+ let force = remove.force_confirmation;
+
+ let command =
+ crate::worktree::build_worktree_remove_command(&remove.repo_root, &remove.path, force);
+ tracing::info!(workspace_id = %remove.workspace_id, path = %remove.path.display(), force, "starting git worktree remove");
+ let path = remove.path.clone();
+ let workspace_id = remove.workspace_id.clone();
+ let event_tx = self.event_tx.clone();
+ std::thread::spawn(move || {
+ let result = crate::worktree::run_worktree_command(&command);
+ let _ =
+ event_tx.blocking_send(AppEvent::WorktreeRemoveFinished(WorktreeRemoveResult {
+ workspace_id,
+ path,
+ result,
+ }));
+ });
+ }
+
+ pub(crate) fn handle_worktree_add_finished(&mut self, result: WorktreeAddResult) {
+ let Some(create) = &mut self.state.worktree_create else {
+ return;
+ };
+ if create.checkout_path != result.path {
+ return;
+ }
+
+ match result.result {
+ Ok(()) => {
+ tracing::info!(checkout_path = %create.checkout_path.display(), "git worktree add completed");
+ let path = create.checkout_path.clone();
+ let source_workspace_id = create.source_workspace_id.clone();
+ let source_checkout_path = create.source_checkout_path.clone();
+ let source_existing_membership = create.source_existing_membership.clone();
+ let repo_key = create.repo_key.clone();
+ let repo_name = create.repo_name.clone();
+ let source_repo_root = create.source_repo_root.clone();
+ self.state.worktree_create = None;
+ self.state.name_input.clear();
+ self.state.name_input_replace_on_type = false;
+ match self.create_workspace_with_options(path.clone(), true) {
+ Ok(ws_idx) => {
+ let source_membership = source_existing_membership.unwrap_or(
+ crate::workspace::WorktreeSpaceMembership {
+ key: repo_key.clone(),
+ label: repo_name.clone(),
+ repo_root: source_repo_root.clone(),
+ checkout_path: source_checkout_path,
+ is_linked_worktree: false,
+ },
+ );
+ if let Some(ws) = self
+ .state
+ .workspaces
+ .iter_mut()
+ .find(|ws| ws.id == source_workspace_id)
+ {
+ ws.worktree_space = Some(source_membership);
+ }
+ if let Some(ws) = self.state.workspaces.get_mut(ws_idx) {
+ ws.worktree_space = Some(crate::workspace::WorktreeSpaceMembership {
+ key: repo_key,
+ label: repo_name,
+ repo_root: source_repo_root,
+ checkout_path: path,
+ is_linked_worktree: true,
+ });
+ }
+ self.state.mark_session_dirty();
+ }
+ Err(err) => {
+ self.state.config_diagnostic = Some(format!(
+ "created worktree but failed to open workspace: {err}"
+ ));
+ self.state.mode = Mode::Navigate;
+ }
+ }
+ self.render_dirty.store(true, Ordering::Release);
+ self.render_notify.notify_one();
+ }
+ Err(message) => {
+ tracing::warn!(checkout_path = %create.checkout_path.display(), error = %message, "git worktree add failed");
+ create.creating = false;
+ create.error = Some(message);
+ self.render_dirty.store(true, Ordering::Release);
+ self.render_notify.notify_one();
+ }
+ }
+ }
+ pub(crate) fn handle_worktree_remove_finished(&mut self, result: WorktreeRemoveResult) {
+ let Some(remove) = &mut self.state.worktree_remove else {
+ return;
+ };
+ if remove.workspace_id != result.workspace_id || remove.path != result.path {
+ return;
+ }
+
+ match result.result {
+ Ok(()) => {
+ tracing::info!(workspace_id = %result.workspace_id, path = %result.path.display(), "git worktree remove completed");
+ self.state.worktree_remove = None;
+ if let Some(ws_idx) = self
+ .state
+ .workspaces
+ .iter()
+ .position(|ws| ws.id == result.workspace_id)
+ {
+ let still_same_linked_worktree = self.state.workspaces[ws_idx]
+ .worktree_space()
+ .is_some_and(|space| {
+ space.is_linked_worktree && space.checkout_path == result.path
+ });
+ if still_same_linked_worktree {
+ self.state.selected = ws_idx;
+ self.state.close_selected_workspace();
+ }
+ }
+ self.state.mode = if self.state.active.is_some() {
+ Mode::Terminal
+ } else {
+ Mode::Navigate
+ };
+ self.render_dirty.store(true, Ordering::Release);
+ self.render_notify.notify_one();
+ }
+ Err(message) => {
+ tracing::warn!(workspace_id = %result.workspace_id, path = %result.path.display(), error = %message, "git worktree remove failed");
+ remove.removing = false;
+ if !remove.force_confirmation
+ && crate::worktree::is_dirty_worktree_remove_error(&message)
+ {
+ remove.force_confirmation = true;
+ remove.error = None;
+ } else {
+ remove.error = Some(message);
+ }
+ self.render_dirty.store(true, Ordering::Release);
+ self.render_notify.notify_one();
+ }
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn unique_temp_path(name: &str) -> std::path::PathBuf {
+ let nanos = std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .map(|d| d.as_nanos())
+ .unwrap_or(0);
+ std::env::temp_dir().join(format!("herdr-{name}-{}-{nanos}", std::process::id()))
+ }
+
+ fn run_git(repo: &std::path::Path, args: &[&str]) {
+ let status = std::process::Command::new("git")
+ .arg("-C")
+ .arg(repo)
+ .args(args)
+ .status()
+ .unwrap();
+ assert!(
+ status.success(),
+ "git command failed: git -C {} {}",
+ repo.display(),
+ args.join(" ")
+ );
+ }
+
+ fn create_committed_repo(name: &str) -> std::path::PathBuf {
+ let repo = unique_temp_path(name);
+ std::fs::create_dir_all(&repo).unwrap();
+ run_git(&repo, &["init", "--quiet"]);
+ run_git(&repo, &["config", "user.email", "herdr@example.invalid"]);
+ run_git(&repo, &["config", "user.name", "Herdr Test"]);
+ std::fs::write(repo.join("README.md"), "test\n").unwrap();
+ run_git(&repo, &["add", "README.md"]);
+ run_git(&repo, &["commit", "--quiet", "-m", "initial"]);
+ repo
+ }
+
+ fn wait_for_worktree_event(app: &mut App) -> AppEvent {
+ let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
+ while std::time::Instant::now() < deadline {
+ if let Ok(event) = app.event_rx.try_recv() {
+ return event;
+ }
+ std::thread::sleep(std::time::Duration::from_millis(10));
+ }
+ panic!("timed out waiting for worktree event");
+ }
+
+ fn app_for_worktree_tests() -> App {
+ App::new(
+ &crate::config::Config::default(),
+ true,
+ None,
+ tokio::sync::mpsc::unbounded_channel().1,
+ crate::api::EventHub::default(),
+ )
+ }
+
+ #[test]
+ fn open_selected_existing_worktree_focuses_already_open_workspace() {
+ let mut app = app_for_worktree_tests();
+ app.state.workspaces = vec![
+ crate::workspace::Workspace::test_new("main"),
+ crate::workspace::Workspace::test_new("issue"),
+ ];
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ app.state.worktree_open = Some(WorktreeOpenState {
+ source_workspace_id: app.state.workspaces[0].id.clone(),
+ 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-issue".into(),
+ branch: Some("worktree/issue".into()),
+ is_linked_worktree: true,
+ already_open_ws_idx: Some(1),
+ }],
+ selected: 0,
+ error: None,
+ });
+
+ app.open_selected_existing_worktree();
+
+ assert_eq!(app.state.active, Some(1));
+ assert_eq!(app.state.selected, 1);
+ assert!(app.state.worktree_open.is_none());
+ assert!(app.state.workspaces[0].worktree_space().is_some());
+ let target_membership = app.state.workspaces[1].worktree_space().unwrap();
+ assert_eq!(target_membership.key, "repo-key");
+ assert_eq!(
+ target_membership.checkout_path,
+ std::path::PathBuf::from("/repo/herdr-issue")
+ );
+ assert!(target_membership.is_linked_worktree);
+ }
+
+ #[test]
+ fn open_existing_worktree_detects_already_open_checkout_from_subdirectory() {
+ let repo = create_committed_repo("app-worktree-open-existing-repo");
+ let checkout = unique_temp_path("app-worktree-open-existing-checkout");
+ run_git(
+ &repo,
+ &[
+ "worktree",
+ "add",
+ "--quiet",
+ "-b",
+ "worktree/open-existing",
+ checkout.to_str().unwrap(),
+ "HEAD",
+ ],
+ );
+ let subdir = checkout.join("nested");
+ std::fs::create_dir_all(&subdir).unwrap();
+
+ let mut app = app_for_worktree_tests();
+ app.state.workspaces = vec![
+ crate::workspace::Workspace::test_new("main"),
+ crate::workspace::Workspace::test_new("nested"),
+ ];
+ app.state.workspaces[0].identity_cwd = repo;
+ app.state.workspaces[1].identity_cwd = subdir;
+
+ app.open_existing_worktree_dialog(0);
+
+ let open = app.state.worktree_open.as_ref().unwrap();
+ let checkout = crate::worktree::canonical_or_original(&checkout);
+ let entry = open
+ .entries
+ .iter()
+ .find(|entry| crate::worktree::canonical_or_original(&entry.path) == checkout)
+ .unwrap_or_else(|| panic!("missing checkout in entries: {:?}", open.entries));
+ assert_eq!(entry.already_open_ws_idx, Some(1));
+ }
+
+ #[test]
+ fn worktree_create_and_open_dialogs_reject_linked_child_source() {
+ let mut app = app_for_worktree_tests();
+ app.state.workspaces = vec![crate::workspace::Workspace::test_new("issue")];
+ app.state.mode = Mode::Navigate;
+ app.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-issue".into(),
+ is_linked_worktree: true,
+ });
+
+ app.open_new_linked_worktree_dialog(0);
+
+ assert_eq!(app.state.mode, Mode::Navigate);
+ assert!(app.state.worktree_create.is_none());
+ assert_eq!(
+ app.state.config_diagnostic.as_deref(),
+ Some("New and open worktree actions start from the repo parent workspace.")
+ );
+
+ app.state.config_diagnostic = None;
+ app.open_existing_worktree_dialog(0);
+
+ assert!(app.state.worktree_open.is_none());
+ assert_eq!(
+ app.state.config_diagnostic.as_deref(),
+ Some("New and open worktree actions start from the repo parent workspace.")
+ );
+ }
+
+ #[test]
+ fn sync_worktree_branch_updates_derived_path() {
+ let mut app = app_for_worktree_tests();
+ app.state.worktree_directory = std::path::PathBuf::from("/w");
+ app.state.name_input = "issue/137".into();
+ app.state.worktree_create = Some(WorktreeCreateState {
+ source_workspace_id: "source".into(),
+ source_checkout_path: std::path::PathBuf::from("/repo/herdr"),
+ source_existing_membership: None,
+ source_repo_root: std::path::PathBuf::from("/repo/herdr"),
+ repo_key: "repo-key".into(),
+ repo_name: "herdr".into(),
+ branch: "old".into(),
+ checkout_path: std::path::PathBuf::from("/old"),
+ error: Some("old error".into()),
+ creating: false,
+ });
+
+ app.sync_worktree_branch_from_input();
+
+ let create = app.state.worktree_create.unwrap();
+ assert_eq!(create.branch, "issue/137");
+ assert_eq!(
+ create.checkout_path,
+ std::path::PathBuf::from("/w/herdr/issue-137")
+ );
+ assert_eq!(create.error, None);
+ }
+
+ #[test]
+ fn start_worktree_add_runs_git_on_worker_and_emits_result() {
+ let repo = create_committed_repo("app-worktree-add-repo");
+ let worktree_root = unique_temp_path("app-worktree-add-root");
+ let branch = "worktree/app-worker";
+ let checkout = crate::worktree::default_checkout_path(&worktree_root, "herdr", branch);
+ let mut app = app_for_worktree_tests();
+ app.state.worktree_directory = worktree_root.clone();
+ app.state.name_input = branch.into();
+ app.state.worktree_create = Some(WorktreeCreateState {
+ source_workspace_id: "source".into(),
+ source_checkout_path: repo.clone(),
+ source_existing_membership: None,
+ source_repo_root: repo.clone(),
+ repo_key: "repo-key".into(),
+ repo_name: "herdr".into(),
+ branch: branch.into(),
+ checkout_path: checkout.clone(),
+ error: None,
+ creating: false,
+ });
+
+ app.start_worktree_add();
+
+ assert!(app
+ .state
+ .worktree_create
+ .as_ref()
+ .is_some_and(|create| create.creating));
+ let event = wait_for_worktree_event(&mut app);
+ match event {
+ AppEvent::WorktreeAddFinished(result) => {
+ assert_eq!(result.path, checkout);
+ assert_eq!(result.result, Ok(()));
+ }
+ other => panic!("unexpected event: {other:?}"),
+ }
+ assert!(checkout.join("README.md").exists());
+
+ let remove = crate::worktree::build_worktree_remove_command(&repo, &checkout, false);
+ crate::worktree::run_worktree_command(&remove).unwrap();
+ let _ = std::fs::remove_dir_all(worktree_root);
+ let _ = std::fs::remove_dir_all(repo);
+ }
+
+ #[test]
+ fn start_worktree_add_uses_source_checkout_head_as_base() {
+ let repo = create_committed_repo("app-worktree-add-source-repo");
+ let source_checkout = unique_temp_path("app-worktree-add-source-checkout");
+ run_git(
+ &repo,
+ &[
+ "worktree",
+ "add",
+ "--quiet",
+ "-b",
+ "worktree/source-base",
+ source_checkout.to_str().unwrap(),
+ "HEAD",
+ ],
+ );
+ std::fs::write(source_checkout.join("SOURCE.md"), "source branch\n").unwrap();
+ run_git(&source_checkout, &["add", "SOURCE.md"]);
+ run_git(&source_checkout, &["commit", "--quiet", "-m", "source"]);
+
+ let worktree_root = unique_temp_path("app-worktree-add-from-source-root");
+ let branch = "worktree/from-source";
+ let checkout = crate::worktree::default_checkout_path(&worktree_root, "herdr", branch);
+ let mut app = app_for_worktree_tests();
+ app.state.worktree_directory = worktree_root.clone();
+ app.state.name_input = branch.into();
+ app.state.worktree_create = Some(WorktreeCreateState {
+ source_workspace_id: "source".into(),
+ source_checkout_path: source_checkout.clone(),
+ source_existing_membership: None,
+ source_repo_root: repo.clone(),
+ repo_key: "repo-key".into(),
+ repo_name: "herdr".into(),
+ branch: branch.into(),
+ checkout_path: checkout.clone(),
+ error: None,
+ creating: false,
+ });
+
+ app.start_worktree_add();
+
+ let event = wait_for_worktree_event(&mut app);
+ match event {
+ AppEvent::WorktreeAddFinished(result) => {
+ assert_eq!(result.path, checkout);
+ assert_eq!(result.result, Ok(()));
+ }
+ other => panic!("unexpected event: {other:?}"),
+ }
+ assert!(checkout.join("SOURCE.md").exists());
+
+ let remove_new = crate::worktree::build_worktree_remove_command(&repo, &checkout, false);
+ crate::worktree::run_worktree_command(&remove_new).unwrap();
+ let remove_source =
+ crate::worktree::build_worktree_remove_command(&repo, &source_checkout, false);
+ crate::worktree::run_worktree_command(&remove_source).unwrap();
+ let _ = std::fs::remove_dir_all(worktree_root);
+ let _ = std::fs::remove_dir_all(repo);
+ }
+
+ #[test]
+ fn dirty_worktree_remove_failure_requests_force_confirmation() {
+ let path = std::path::PathBuf::from("/w/herdr/dirty");
+ let mut app = app_for_worktree_tests();
+ app.state.worktree_remove = Some(WorktreeRemoveState {
+ workspace_id: "ws".into(),
+ repo_root: std::path::PathBuf::from("/repo/herdr"),
+ path: path.clone(),
+ error: None,
+ removing: true,
+ force_confirmation: false,
+ });
+
+ app.handle_worktree_remove_finished(WorktreeRemoveResult {
+ workspace_id: "ws".into(),
+ path,
+ result: Err(
+ "fatal: '/w/herdr/dirty' contains modified or untracked files, use --force to delete it"
+ .into(),
+ ),
+ });
+
+ let remove = app.state.worktree_remove.unwrap();
+ assert!(!remove.removing);
+ assert!(remove.force_confirmation);
+ assert_eq!(remove.error, None);
+ }
+
+ #[test]
+ fn non_dirty_worktree_remove_failure_keeps_error_message() {
+ let path = std::path::PathBuf::from("/w/herdr/missing");
+ let mut app = app_for_worktree_tests();
+ app.state.worktree_remove = Some(WorktreeRemoveState {
+ workspace_id: "ws".into(),
+ repo_root: std::path::PathBuf::from("/repo/herdr"),
+ path: path.clone(),
+ error: None,
+ removing: true,
+ force_confirmation: false,
+ });
+
+ app.handle_worktree_remove_finished(WorktreeRemoveResult {
+ workspace_id: "ws".into(),
+ path,
+ result: Err("fatal: '/w/herdr/missing' is not a working tree".into()),
+ });
+
+ let remove = app.state.worktree_remove.unwrap();
+ assert!(!remove.removing);
+ assert!(!remove.force_confirmation);
+ assert_eq!(
+ remove.error,
+ Some("fatal: '/w/herdr/missing' is not a working tree".into())
+ );
+ }
+
+ #[test]
+ fn dirty_worktree_remove_retries_with_force_and_closes_workspace() {
+ let repo = create_committed_repo("app-worktree-dirty-remove-repo");
+ let checkout = unique_temp_path("app-worktree-dirty-remove-checkout");
+ run_git(
+ &repo,
+ &[
+ "worktree",
+ "add",
+ "--quiet",
+ "-b",
+ "worktree/dirty-remove",
+ checkout.to_str().unwrap(),
+ "HEAD",
+ ],
+ );
+ std::fs::write(checkout.join("README.md"), "dirty\n").unwrap();
+
+ let mut app = app_for_worktree_tests();
+ app.state.workspaces = vec![crate::workspace::Workspace::test_new("issue")];
+ let workspace_id = app.state.workspaces[0].id.clone();
+ app.state.workspaces[0].worktree_space = Some(crate::workspace::WorktreeSpaceMembership {
+ key: "repo-key".into(),
+ label: "herdr".into(),
+ repo_root: repo.clone(),
+ checkout_path: checkout.clone(),
+ is_linked_worktree: true,
+ });
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ app.open_remove_linked_worktree_confirmation(0);
+
+ app.start_worktree_remove();
+ let safe_event = wait_for_worktree_event(&mut app);
+ match safe_event {
+ AppEvent::WorktreeRemoveFinished(result) => {
+ assert_eq!(result.workspace_id, workspace_id);
+ assert_eq!(result.path, checkout);
+ assert!(result.result.is_err());
+ app.handle_worktree_remove_finished(result);
+ }
+ other => panic!("unexpected event: {other:?}"),
+ }
+
+ let remove = app.state.worktree_remove.as_ref().unwrap();
+ assert!(!remove.removing);
+ assert!(remove.force_confirmation);
+ assert!(checkout.exists());
+
+ app.start_worktree_remove();
+ let force_event = wait_for_worktree_event(&mut app);
+ match force_event {
+ AppEvent::WorktreeRemoveFinished(result) => {
+ assert_eq!(result.workspace_id, workspace_id);
+ assert_eq!(result.path, checkout);
+ assert_eq!(result.result, Ok(()));
+ app.handle_worktree_remove_finished(result);
+ }
+ other => panic!("unexpected event: {other:?}"),
+ }
+
+ assert!(!checkout.exists());
+ assert!(app.state.worktree_remove.is_none());
+ assert!(app.state.workspaces.is_empty());
+
+ let _ = std::fs::remove_dir_all(repo);
+ }
+}