aboutsummaryrefslogtreecommitdiff
path: root/src
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
parenta9ff4012bdca0dbdbc77bc6e53a430689fc899be (diff)
feat: add git worktree workspace management
refs #137
Diffstat (limited to 'src')
-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
-rw-r--r--src/config/io.rs8
-rw-r--r--src/config/keybinds.rs25
-rw-r--r--src/config/model.rs38
-rw-r--r--src/events.rs17
-rw-r--r--src/main.rs7
-rw-r--r--src/persist/io.rs1
-rw-r--r--src/persist/restore.rs31
-rw-r--r--src/persist/snapshot.rs38
-rw-r--r--src/server/headless.rs43
-rw-r--r--src/ui.rs31
-rw-r--r--src/ui/dialogs.rs497
-rw-r--r--src/ui/keybind_help.rs6
-rw-r--r--src/ui/sidebar.rs691
-rw-r--r--src/workspace.rs30
-rw-r--r--src/workspace/git.rs95
-rw-r--r--src/worktree.rs478
30 files changed, 4613 insertions, 125 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);
+ }
+}
diff --git a/src/config/io.rs b/src/config/io.rs
index 3b483ba6..fe415d1c 100644
--- a/src/config/io.rs
+++ b/src/config/io.rs
@@ -191,6 +191,14 @@ fn load_live_config_from_str(content: &str) -> Result<LoadedConfig, Vec<String>>
);
load_live_section(
table,
+ "worktrees",
+ "worktree config",
+ &mut diagnostics,
+ &mut invalid_sections,
+ |section| config.worktrees = section,
+ );
+ load_live_section(
+ table,
"experimental",
"experimental config",
&mut diagnostics,
diff --git a/src/config/keybinds.rs b/src/config/keybinds.rs
index 1c9c491b..edd64c90 100644
--- a/src/config/keybinds.rs
+++ b/src/config/keybinds.rs
@@ -246,6 +246,9 @@ pub struct Keybinds {
pub help: ActionKeybinds,
pub settings: ActionKeybinds,
pub new_workspace: ActionKeybinds,
+ pub new_worktree: ActionKeybinds,
+ pub open_worktree: ActionKeybinds,
+ pub remove_worktree: ActionKeybinds,
pub rename_workspace: ActionKeybinds,
pub close_workspace: ActionKeybinds,
pub workspace_picker: ActionKeybinds,
@@ -375,6 +378,9 @@ impl Config {
help: action!("keys.help", &self.keys.help),
settings: action!("keys.settings", &self.keys.settings),
new_workspace: action!("keys.new_workspace", &self.keys.new_workspace),
+ new_worktree: action!("keys.new_worktree", &self.keys.new_worktree),
+ open_worktree: action!("keys.open_worktree", &self.keys.open_worktree),
+ remove_worktree: action!("keys.remove_worktree", &self.keys.remove_worktree),
rename_workspace: action!("keys.rename_workspace", &self.keys.rename_workspace),
close_workspace: action!("keys.close_workspace", &self.keys.close_workspace),
workspace_picker: action!("keys.workspace_picker", &self.keys.workspace_picker),
@@ -1042,6 +1048,25 @@ next_tab = "prefix+n"
}
#[test]
+ fn new_worktree_defaults_to_prefix_shift_g() {
+ let kb = Config::default().keybinds();
+ assert_eq!(
+ binding_triggers(&kb.new_worktree),
+ vec![BindingTrigger::Prefix((
+ KeyCode::Char('g'),
+ KeyModifiers::SHIFT
+ ))]
+ );
+ }
+
+ #[test]
+ fn open_and_remove_worktree_keybinds_are_unset_by_default() {
+ let kb = Config::default().keybinds();
+ assert!(kb.open_worktree.bindings.is_empty());
+ assert!(kb.remove_worktree.bindings.is_empty());
+ }
+
+ #[test]
fn array_bindings_allow_prefix_and_modified_direct() {
let config: Config = toml::from_str(
r#"
diff --git a/src/config/model.rs b/src/config/model.rs
index f270a69c..215e6f32 100644
--- a/src/config/model.rs
+++ b/src/config/model.rs
@@ -81,6 +81,7 @@ pub struct Config {
pub terminal: TerminalConfig,
pub keys: KeysConfig,
pub ui: UiConfig,
+ pub worktrees: WorktreesConfig,
pub advanced: AdvancedConfig,
pub experimental: ExperimentalConfig,
}
@@ -103,6 +104,12 @@ pub struct KeysConfig {
pub settings: BindingConfig,
/// Create a new workspace. Default: "prefix+shift+n"
pub new_workspace: BindingConfig,
+ /// Create a Git worktree from the selected workspace. Default: "prefix+shift+g"
+ pub new_worktree: BindingConfig,
+ /// Open an existing Git worktree from the selected workspace. Unset by default.
+ pub open_worktree: BindingConfig,
+ /// Delete the selected managed worktree checkout after confirmation. Unset by default.
+ pub remove_worktree: BindingConfig,
/// Rename the selected workspace. Default: "prefix+shift+w"
pub rename_workspace: BindingConfig,
/// Close the selected workspace. Default: "prefix+shift+d"
@@ -188,6 +195,13 @@ pub struct IndexedKeysConfig {
#[derive(Debug, Deserialize)]
#[serde(default)]
+pub struct WorktreesConfig {
+ /// Root directory under which Herdr creates <repo>/<branch-slug> checkouts.
+ pub directory: String,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(default)]
pub struct UiConfig {
pub sidebar_width: u16,
/// Minimum sidebar width (columns) when expanded. Default: 18.
@@ -288,6 +302,9 @@ impl Default for KeysConfig {
help: BindingConfig::one("prefix+?"),
settings: BindingConfig::one("prefix+s"),
new_workspace: BindingConfig::one("prefix+shift+n"),
+ new_worktree: BindingConfig::one("prefix+shift+g"),
+ open_worktree: BindingConfig::empty(),
+ remove_worktree: BindingConfig::empty(),
rename_workspace: BindingConfig::one("prefix+shift+w"),
close_workspace: BindingConfig::one("prefix+shift+d"),
workspace_picker: BindingConfig::one("prefix+w"),
@@ -326,6 +343,14 @@ impl Default for KeysConfig {
}
}
+impl Default for WorktreesConfig {
+ fn default() -> Self {
+ Self {
+ directory: "~/.herdr/worktrees".into(),
+ }
+ }
+}
+
impl Default for UiConfig {
fn default() -> Self {
Self {
@@ -432,6 +457,19 @@ show_agent_labels_on_pane_borders = true
}
#[test]
+ fn worktrees_directory_defaults_and_parses() {
+ let default_config = Config::default();
+ assert_eq!(default_config.worktrees.directory, "~/.herdr/worktrees");
+
+ let toml = r#"
+[worktrees]
+directory = "~/Projects/herdr-worktrees"
+"#;
+ let config: Config = toml::from_str(toml).unwrap();
+ assert_eq!(config.worktrees.directory, "~/Projects/herdr-worktrees");
+ }
+
+ #[test]
fn prompt_new_tab_name_defaults_on_and_parses() {
let default_config = Config::default();
assert!(default_config.ui.prompt_new_tab_name);
diff --git a/src/events.rs b/src/events.rs
index 2e0143e3..90a65957 100644
--- a/src/events.rs
+++ b/src/events.rs
@@ -7,6 +7,19 @@ use crate::detect::{Agent, AgentState};
use crate::layout::PaneId;
use crate::workspace::WorkspaceGitStatus;
+#[derive(Debug)]
+pub struct WorktreeAddResult {
+ pub path: std::path::PathBuf,
+ pub result: Result<(), String>,
+}
+
+#[derive(Debug)]
+pub struct WorktreeRemoveResult {
+ pub workspace_id: String,
+ pub path: std::path::PathBuf,
+ pub result: Result<(), String>,
+}
+
/// An event from a background task to the main loop.
#[derive(Debug)]
pub enum AppEvent {
@@ -52,4 +65,8 @@ pub enum AppEvent {
ClipboardWrite { content: Vec<u8> },
/// Background git status refresh completed for workspaces.
GitStatusRefreshed { results: Vec<WorkspaceGitStatus> },
+ /// Background `git worktree add` completed.
+ WorktreeAddFinished(WorktreeAddResult),
+ /// Background `git worktree remove` completed.
+ WorktreeRemoveFinished(WorktreeRemoveResult),
}
diff --git a/src/main.rs b/src/main.rs
index 59aac658..a846d092 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -50,6 +50,7 @@ mod terminal_theme;
mod ui;
mod update;
mod workspace;
+mod worktree;
fn init_logging() {
crate::logging::init_file_logging("herdr.log");
@@ -100,6 +101,9 @@ const DEFAULT_CONFIG: &str = r##"# herdr configuration
# open_notification_target = "prefix+o"
# workspace_picker = "prefix+w"
# new_workspace = "prefix+shift+n"
+# new_worktree = "prefix+shift+g"
+# open_worktree = "" # optional, unset by default
+# remove_worktree = "" # optional, unset by default; opens confirmation
# rename_workspace = "prefix+shift+w"
# close_workspace = "prefix+shift+d"
# previous_workspace = "" # optional, unset by default
@@ -144,6 +148,9 @@ const DEFAULT_CONFIG: &str = r##"# herdr configuration
# workspaces = "" # e.g. "ctrl+shift" makes ctrl+shift+1..9 switch workspaces directly
# agents = "" # e.g. "alt" makes alt+1..9 focus agent rows directly
+# [worktrees]
+# directory = "~/.herdr/worktrees"
+
[ui]
# Sidebar width (auto-scaled based on workspace names, this sets the default)
# sidebar_width = 26
diff --git a/src/persist/io.rs b/src/persist/io.rs
index f8525bc5..1b589b09 100644
--- a/src/persist/io.rs
+++ b/src/persist/io.rs
@@ -133,6 +133,7 @@ mod tests {
agent_panel_scope: AgentPanelScope::CurrentWorkspace,
sidebar_width: Some(26),
sidebar_section_split: Some(0.5),
+ collapsed_space_keys: std::collections::HashSet::new(),
}
}
diff --git a/src/persist/restore.rs b/src/persist/restore.rs
index f558761d..ebad2f16 100644
--- a/src/persist/restore.rs
+++ b/src/persist/restore.rs
@@ -99,6 +99,8 @@ fn restore_workspace(
return None;
}
+ let worktree_space = restored_worktree_space_membership(snap.worktree_space.clone());
+
Some((
Workspace {
id: snap
@@ -107,8 +109,10 @@ fn restore_workspace(
.unwrap_or_else(crate::workspace::generate_workspace_id),
custom_name: snap.custom_name.clone(),
identity_cwd: snap.identity_cwd.clone(),
- cached_git_branch: None,
+ cached_git_branch: crate::workspace::git_branch(&snap.identity_cwd),
cached_git_ahead_behind: None,
+ cached_git_space: crate::workspace::git_space_metadata(&snap.identity_cwd),
+ worktree_space,
public_pane_numbers,
next_public_pane_number,
active_tab: snap.active_tab.min(tabs.len().saturating_sub(1)),
@@ -121,6 +125,16 @@ fn restore_workspace(
))
}
+fn restored_worktree_space_membership(
+ space: Option<crate::workspace::WorktreeSpaceMembership>,
+) -> Option<crate::workspace::WorktreeSpaceMembership> {
+ space.filter(|space| {
+ space.checkout_path.exists()
+ && crate::workspace::git_space_metadata(&space.checkout_path)
+ .is_some_and(|current| current.key == space.key)
+ })
+}
+
fn restore_tab(
snap: &TabSnapshot,
number: usize,
@@ -407,4 +421,19 @@ mod tests {
Some(first)
);
}
+
+ #[test]
+ fn restored_worktree_space_membership_drops_missing_checkout() {
+ let missing =
+ std::env::temp_dir().join(format!("herdr-missing-worktree-{}", std::process::id()));
+ let membership = crate::workspace::WorktreeSpaceMembership {
+ key: "repo-key".into(),
+ label: "herdr".into(),
+ repo_root: missing.join("repo"),
+ checkout_path: missing.join("checkout"),
+ is_linked_worktree: true,
+ };
+
+ assert_eq!(restored_worktree_space_membership(Some(membership)), None);
+ }
}
diff --git a/src/persist/snapshot.rs b/src/persist/snapshot.rs
index a3811c36..79d5c406 100644
--- a/src/persist/snapshot.rs
+++ b/src/persist/snapshot.rs
@@ -26,6 +26,8 @@ pub struct SessionSnapshot {
pub sidebar_width: Option<u16>,
#[serde(default)]
pub sidebar_section_split: Option<f32>,
+ #[serde(default)]
+ pub collapsed_space_keys: std::collections::HashSet<String>,
}
#[derive(Serialize, Deserialize)]
@@ -35,6 +37,8 @@ pub struct WorkspaceSnapshot {
#[serde(default)]
pub custom_name: Option<String>,
pub identity_cwd: PathBuf,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub worktree_space: Option<crate::workspace::WorktreeSpaceMembership>,
pub tabs: Vec<TabSnapshot>,
#[serde(default)]
pub active_tab: usize,
@@ -109,6 +113,7 @@ impl From<LegacyWorkspaceSnapshot> for WorkspaceSnapshot {
id: None,
custom_name: snap.custom_name,
identity_cwd,
+ worktree_space: None,
tabs: vec![tab],
active_tab: 0,
}
@@ -131,6 +136,8 @@ struct RawSessionSnapshot {
sidebar_width: Option<u16>,
#[serde(default)]
sidebar_section_split: Option<f32>,
+ #[serde(default)]
+ collapsed_space_keys: std::collections::HashSet<String>,
}
fn migrate_snapshot(raw: RawSessionSnapshot) -> Result<SessionSnapshot, String> {
@@ -146,6 +153,7 @@ fn migrate_snapshot(raw: RawSessionSnapshot) -> Result<SessionSnapshot, String>
agent_panel_scope: raw.agent_panel_scope,
sidebar_width: raw.sidebar_width,
sidebar_section_split: raw.sidebar_section_split,
+ collapsed_space_keys: raw.collapsed_space_keys,
})
}
@@ -208,6 +216,7 @@ pub fn capture(
agent_panel_scope: crate::app::state::AgentPanelScope,
sidebar_width: u16,
sidebar_section_split: f32,
+ collapsed_space_keys: std::collections::HashSet<String>,
) -> SessionSnapshot {
SessionSnapshot {
version: SNAPSHOT_VERSION,
@@ -220,6 +229,7 @@ pub fn capture(
agent_panel_scope,
sidebar_width: Some(sidebar_width),
sidebar_section_split: Some(sidebar_section_split),
+ collapsed_space_keys,
}
}
@@ -237,6 +247,7 @@ fn capture_workspace(
identity_cwd: ws
.resolved_identity_cwd_from(terminals, terminal_runtimes)
.unwrap_or_else(|| ws.identity_cwd.clone()),
+ worktree_space: ws.worktree_space.clone(),
tabs: ws
.tabs
.iter()
@@ -375,6 +386,7 @@ mod tests {
state.agent_panel_scope,
state.sidebar_width,
state.sidebar_section_split,
+ state.collapsed_space_keys.clone(),
)
}
@@ -395,6 +407,7 @@ mod tests {
agent_panel_scope: AgentPanelScope::CurrentWorkspace,
sidebar_width: Some(26),
sidebar_section_split: Some(0.5),
+ collapsed_space_keys: std::collections::HashSet::new(),
};
let json = serde_json::to_string(&snap).unwrap();
let restored = parse_snapshot(&json).unwrap();
@@ -451,6 +464,7 @@ mod tests {
id: Some("wproj".to_string()),
custom_name: Some("pi-mono".to_string()),
identity_cwd: PathBuf::from("/home/can/Projects/herdr"),
+ worktree_space: None,
tabs: vec![TabSnapshot {
custom_name: Some("api".to_string()),
layout: LayoutSnapshot::Split {
@@ -471,6 +485,7 @@ mod tests {
agent_panel_scope: AgentPanelScope::CurrentWorkspace,
sidebar_width: Some(26),
sidebar_section_split: Some(0.5),
+ collapsed_space_keys: std::collections::HashSet::new(),
version: SNAPSHOT_VERSION,
};
@@ -622,11 +637,32 @@ mod tests {
state.sidebar_width = 31;
state.sidebar_section_split = 0.4;
state.agent_panel_scope = AgentPanelScope::AllWorkspaces;
+ state.collapsed_space_keys.insert("repo-key".into());
let snapshot = capture_from_state(&state);
assert_eq!(snapshot.sidebar_width, Some(31));
assert_eq!(snapshot.sidebar_section_split, Some(0.4));
assert_eq!(snapshot.agent_panel_scope, AgentPanelScope::AllWorkspaces);
+ assert!(snapshot.collapsed_space_keys.contains("repo-key"));
+ }
+
+ #[test]
+ fn capture_contract_tracks_worktree_space_membership() {
+ let mut state = state_with_workspaces(&["main"]);
+ state.workspaces[0].worktree_space = Some(crate::workspace::WorktreeSpaceMembership {
+ key: "repo-key".into(),
+ label: "herdr".into(),
+ repo_root: PathBuf::from("/repo/herdr"),
+ checkout_path: PathBuf::from("/repo/herdr/worktree-a"),
+ is_linked_worktree: true,
+ });
+
+ let snapshot = capture_from_state(&state);
+
+ assert_eq!(
+ snapshot.workspaces[0].worktree_space,
+ state.workspaces[0].worktree_space
+ );
}
#[test]
@@ -776,6 +812,7 @@ mod tests {
id: Some("test-ws".to_string()),
custom_name: Some("fallback test".to_string()),
identity_cwd: PathBuf::from("/tmp"),
+ worktree_space: None,
tabs: vec![TabSnapshot {
custom_name: None,
layout: LayoutSnapshot::Split {
@@ -796,6 +833,7 @@ mod tests {
agent_panel_scope: AgentPanelScope::CurrentWorkspace,
sidebar_width: Some(26),
sidebar_section_split: Some(0.5),
+ collapsed_space_keys: std::collections::HashSet::new(),
};
let json = serde_json::to_string(&snap).unwrap();
diff --git a/src/server/headless.rs b/src/server/headless.rs
index 842ded80..5c6a1ec6 100644
--- a/src/server/headless.rs
+++ b/src/server/headless.rs
@@ -25,7 +25,7 @@ use std::time::{Duration, Instant};
use ratatui::layout::Rect;
use tokio::sync::mpsc;
-use tracing::{debug, info, warn};
+use tracing::{debug, error, info, warn};
use base64::Engine;
use bytes::Bytes;
@@ -251,6 +251,47 @@ impl HeadlessServer {
needs_render = true;
}
+ if let Some(ws_idx) = self.app.state.request_new_linked_worktree.take() {
+ self.app.open_new_linked_worktree_dialog(ws_idx);
+ needs_render = true;
+ }
+
+ if let Some(ws_idx) = self.app.state.request_open_existing_worktree.take() {
+ self.app.open_existing_worktree_dialog(ws_idx);
+ needs_render = true;
+ }
+
+ if let Some(cwd) = self.app.state.request_new_workspace_cwd.take() {
+ if let Err(err) = self.app.create_workspace_with_options(cwd, true) {
+ error!(err = %err, "failed to create workspace at requested cwd");
+ self.app.state.mode = app::Mode::Navigate;
+ }
+ needs_render = true;
+ }
+
+ if let Some(ws_idx) = self.app.state.request_remove_linked_worktree.take() {
+ self.app.open_remove_linked_worktree_confirmation(ws_idx);
+ needs_render = true;
+ }
+
+ if self.app.state.request_submit_worktree_create {
+ self.app.state.request_submit_worktree_create = false;
+ self.app.start_worktree_add();
+ needs_render = true;
+ }
+
+ if self.app.state.request_submit_worktree_open {
+ self.app.state.request_submit_worktree_open = false;
+ self.app.open_selected_existing_worktree();
+ needs_render = true;
+ }
+
+ if self.app.state.request_submit_worktree_remove {
+ self.app.state.request_submit_worktree_remove = false;
+ self.app.start_worktree_remove();
+ needs_render = true;
+ }
+
if self.app.state.request_reload_config {
self.app.state.request_reload_config = false;
self.reload_server_config();
diff --git a/src/ui.rs b/src/ui.rs
index 9082ce00..42b861ee 100644
--- a/src/ui.rs
+++ b/src/ui.rs
@@ -19,7 +19,10 @@ mod status;
mod tabs;
mod widgets;
-use self::dialogs::{render_confirm_close_overlay, render_rename_overlay};
+use self::dialogs::{
+ render_confirm_close_overlay, render_new_linked_worktree_overlay,
+ render_open_existing_worktree_overlay, render_remove_worktree_overlay, render_rename_overlay,
+};
use self::keybind_help::render_keybind_help_overlay;
use self::menus::{
render_context_menu, render_global_launcher_menu, render_navigate_overlay,
@@ -48,14 +51,20 @@ use self::sidebar::{render_sidebar, render_sidebar_collapsed};
use self::status::{render_config_diagnostic, render_toast_notification, toast_notification_rect};
use self::tabs::render_tab_bar;
pub(crate) use self::{
- dialogs::{confirm_close_button_rects, confirm_close_popup_rect, rename_button_rects},
+ dialogs::{
+ confirm_close_button_rects, confirm_close_popup_rect, new_linked_worktree_button_rects,
+ new_linked_worktree_inner_rect, open_existing_worktree_button_rects,
+ open_existing_worktree_inner_rect, open_existing_worktree_visible_start,
+ remove_worktree_button_rects, remove_worktree_popup_rect, rename_button_rects,
+ },
settings::{settings_button_rects, settings_show_primary_action},
sidebar::{
agent_panel_body_rect, agent_panel_entries, agent_panel_scroll_metrics,
agent_panel_scrollbar_rect, agent_panel_toggle_rect, collapsed_sidebar_sections,
collapsed_sidebar_toggle_rect, compute_workspace_card_areas, expanded_sidebar_sections,
- sidebar_section_divider_rect, workspace_drop_indicator_row, workspace_list_rect,
- workspace_list_scroll_metrics, workspace_list_scrollbar_rect,
+ normalized_workspace_scroll, sidebar_section_divider_rect, workspace_drop_indicator_row,
+ workspace_list_entries, workspace_list_rect, workspace_list_scroll_metrics,
+ workspace_list_scrollbar_rect, workspace_parent_group_state, WorkspaceListEntry,
},
};
pub(crate) use self::{
@@ -180,14 +189,15 @@ fn compute_view_internal(
(Rect::default(), main_area)
};
- app.workspace_scroll = app
- .workspace_scroll
- .min(app.workspaces.len().saturating_sub(1));
if !app.sidebar_collapsed {
+ app.workspace_scroll = normalized_workspace_scroll(app, sidebar_area, app.workspace_scroll);
let (_, detail_area) = expanded_sidebar_sections(sidebar_area, app.sidebar_section_split);
let max_agent_scroll = agent_panel_scroll_metrics(app, detail_area).max_offset_from_bottom;
app.agent_panel_scroll = app.agent_panel_scroll.min(max_agent_scroll);
} else {
+ app.workspace_scroll = app
+ .workspace_scroll
+ .min(app.workspaces.len().saturating_sub(1));
app.agent_panel_scroll = 0;
}
@@ -348,7 +358,7 @@ pub fn render_with_runtime_registry(
} else if app.sidebar_collapsed {
render_sidebar_collapsed(app, frame, sidebar_area);
} else {
- render_sidebar(app, frame, sidebar_area);
+ render_sidebar(app, terminal_runtimes, frame, sidebar_area);
}
if app.view.layout != ViewLayout::Mobile {
render_tab_bar(app, frame, tab_bar_area);
@@ -376,6 +386,11 @@ pub fn render_with_runtime_registry(
Mode::RenameWorkspace | Mode::RenameTab | Mode::RenamePane => {
render_rename_overlay(app, frame, frame.area())
}
+ Mode::NewLinkedWorktree => render_new_linked_worktree_overlay(app, frame, frame.area()),
+ Mode::OpenExistingWorktree => {
+ render_open_existing_worktree_overlay(app, frame, frame.area())
+ }
+ Mode::ConfirmRemoveWorktree => render_remove_worktree_overlay(app, frame, frame.area()),
Mode::GlobalMenu => render_global_launcher_menu(app, frame),
Mode::KeybindHelp => render_keybind_help_overlay(app, frame),
Mode::Terminal => {}
diff --git a/src/ui/dialogs.rs b/src/ui/dialogs.rs
index 3c481a97..f7396fd8 100644
--- a/src/ui/dialogs.rs
+++ b/src/ui/dialogs.rs
@@ -12,6 +12,22 @@ use super::widgets::{
};
use crate::app::{AppState, Mode};
+fn truncate_text(text: &str, max_width: usize) -> String {
+ let len = text.chars().count();
+ if len <= max_width {
+ return text.to_string();
+ }
+ if max_width <= 1 {
+ return "…".into();
+ }
+ format!(
+ "{}…",
+ text.chars()
+ .take(max_width.saturating_sub(1))
+ .collect::<String>()
+ )
+}
+
pub(crate) fn rename_button_rects(inner: Rect) -> (Rect, Rect, Rect) {
let rects = action_button_row_rects(
inner,
@@ -109,23 +125,449 @@ pub(super) fn render_rename_overlay(app: &AppState, frame: &mut Frame, area: Rec
);
}
-pub(super) fn render_confirm_close_overlay(app: &AppState, frame: &mut Frame, area: Rect) {
+pub(crate) fn new_linked_worktree_inner_rect(area: Rect) -> Option<Rect> {
+ centered_popup_rect(area, 68, 10).map(|popup| {
+ Rect::new(
+ popup.x + 1,
+ popup.y + 1,
+ popup.width.saturating_sub(2),
+ popup.height.saturating_sub(2),
+ )
+ })
+}
+
+pub(crate) fn new_linked_worktree_button_rects(inner: Rect) -> (Rect, Rect) {
+ let rects = action_button_row_rects(
+ inner,
+ &[
+ ActionButtonSpec {
+ hint: Some("↵"),
+ label: "create and open",
+ },
+ ActionButtonSpec {
+ hint: Some("esc"),
+ label: "cancel",
+ },
+ ],
+ 2,
+ inner.height.saturating_sub(1),
+ );
+ (rects[0], rects[1])
+}
+
+pub(crate) fn remove_worktree_popup_rect(area: Rect) -> Option<Rect> {
+ centered_popup_rect(area, 72, 10)
+}
+
+pub(crate) fn remove_worktree_button_rects(inner: Rect, force_confirmation: bool) -> (Rect, Rect) {
+ let primary_label = if force_confirmation {
+ "delete anyway"
+ } else {
+ "remove"
+ };
+ let rects = action_button_row_rects(
+ inner,
+ &[
+ ActionButtonSpec {
+ hint: Some("↵"),
+ label: primary_label,
+ },
+ ActionButtonSpec {
+ hint: Some("esc"),
+ label: "cancel",
+ },
+ ],
+ 2,
+ inner.height.saturating_sub(1),
+ );
+ (rects[0], rects[1])
+}
+
+pub(crate) fn open_existing_worktree_inner_rect(area: Rect, entry_count: usize) -> Option<Rect> {
+ let height = (entry_count as u16).saturating_add(5).clamp(8, 18);
+ centered_popup_rect(area, 78, height).map(|popup| {
+ Rect::new(
+ popup.x + 1,
+ popup.y + 1,
+ popup.width.saturating_sub(2),
+ popup.height.saturating_sub(2),
+ )
+ })
+}
+
+pub(crate) fn open_existing_worktree_visible_start(selected: usize, max_rows: usize) -> usize {
+ selected.saturating_sub(max_rows.saturating_sub(1))
+}
+
+pub(crate) fn open_existing_worktree_button_rects(inner: Rect) -> (Rect, Rect) {
+ let rects = action_button_row_rects(
+ inner,
+ &[
+ ActionButtonSpec {
+ hint: Some("↵"),
+ label: "open",
+ },
+ ActionButtonSpec {
+ hint: Some("esc"),
+ label: "cancel",
+ },
+ ],
+ 2,
+ inner.height.saturating_sub(1),
+ );
+ (rects[0], rects[1])
+}
+
+pub(super) fn render_new_linked_worktree_overlay(app: &AppState, frame: &mut Frame, area: Rect) {
+ let Some(create) = app.worktree_create.as_ref() else {
+ return;
+ };
+
+ super::dim_background(frame, area);
+ let Some(inner) = render_modal_shell(frame, area, 68, 10, &app.palette) else {
+ return;
+ };
+ if inner.height < 7 {
+ return;
+ }
+
+ let rows = Layout::vertical([
+ Constraint::Length(1),
+ Constraint::Length(1),
+ Constraint::Length(1),
+ Constraint::Length(1),
+ Constraint::Length(1),
+ Constraint::Length(1),
+ Constraint::Length(1),
+ Constraint::Min(0),
+ ])
+ .areas::<8>(inner);
+
+ render_modal_header(frame, rows[0], "new worktree", &app.palette);
+
+ frame.render_widget(
+ Paragraph::new(" branch").style(Style::default().fg(app.palette.overlay0)),
+ rows[1],
+ );
+ let input_rect = Rect::new(rows[2].x, rows[2].y, rows[2].width, 1);
+ frame.render_widget(Clear, input_rect);
+ frame.render_widget(
+ Paragraph::new(format!(" {}█", app.name_input)).style(
+ Style::default()
+ .fg(app.palette.text)
+ .bg(app.palette.surface0),
+ ),
+ input_rect,
+ );
+
+ let checkout = create.checkout_path.display().to_string();
+ frame.render_widget(
+ Paragraph::new(" checkout").style(Style::default().fg(app.palette.overlay0)),
+ rows[3],
+ );
+ frame.render_widget(
+ Paragraph::new(format!(" {checkout}")).style(Style::default().fg(app.palette.subtext0)),
+ rows[4],
+ );
+
+ if create.creating {
+ frame.render_widget(
+ Paragraph::new(" creating…").style(Style::default().fg(app.palette.overlay0)),
+ rows[5],
+ );
+ } else if let Some(error) = &create.error {
+ frame.render_widget(
+ Paragraph::new(format!(" {error}")).style(Style::default().fg(app.palette.red)),
+ rows[5],
+ );
+ }
+
+ let (create_rect, cancel_rect) = new_linked_worktree_button_rects(inner);
+ render_action_button(
+ frame,
+ create_rect,
+ Some("↵"),
+ "create and open",
+ Style::default()
+ .fg(panel_contrast_fg(&app.palette))
+ .bg(app.palette.accent)
+ .add_modifier(Modifier::BOLD),
+ );
+ render_action_button(
+ frame,
+ cancel_rect,
+ Some("esc"),
+ "cancel",
+ Style::default()
+ .fg(app.palette.text)
+ .bg(app.palette.surface0)
+ .add_modifier(Modifier::BOLD),
+ );
+}
+
+pub(super) fn render_remove_worktree_overlay(app: &AppState, frame: &mut Frame, area: Rect) {
+ let Some(remove) = app.worktree_remove.as_ref() else {
+ return;
+ };
+
+ super::dim_background(frame, area);
+ let Some(popup) = remove_worktree_popup_rect(area) else {
+ return;
+ };
+ let Some(inner) = render_panel_shell(frame, popup, app.palette.red, app.palette.panel_bg)
+ else {
+ return;
+ };
+
+ let rows = Layout::vertical([
+ Constraint::Length(1),
+ Constraint::Length(1),
+ Constraint::Length(1),
+ Constraint::Length(1),
+ Constraint::Length(1),
+ Constraint::Length(1),
+ Constraint::Length(1),
+ Constraint::Min(0),
+ ])
+ .areas::<8>(inner);
+
+ frame.render_widget(
+ Paragraph::new(Line::from(vec![Span::styled(
+ " delete worktree checkout?",
+ Style::default()
+ .fg(app.palette.red)
+ .add_modifier(Modifier::BOLD),
+ )])),
+ rows[0],
+ );
+ frame.render_widget(
+ Paragraph::new(" This removes the checkout folder:")
+ .style(Style::default().fg(app.palette.overlay0)),
+ rows[1],
+ );
+ frame.render_widget(
+ Paragraph::new(format!(" {}", remove.path.display()))
+ .style(Style::default().fg(app.palette.text)),
+ rows[2],
+ );
+ frame.render_widget(
+ Paragraph::new(" The branch is not deleted. The Herdr workspace will close.")
+ .style(Style::default().fg(app.palette.overlay0)),
+ rows[3],
+ );
+ if remove.force_confirmation {
+ frame.render_widget(
+ Paragraph::new(" Dirty or untracked files will be permanently deleted.")
+ .style(Style::default().fg(app.palette.red)),
+ rows[4],
+ );
+ }
+ if remove.removing {
+ frame.render_widget(
+ Paragraph::new(" removing…").style(Style::default().fg(app.palette.overlay0)),
+ rows[5],
+ );
+ } else if let Some(error) = &remove.error {
+ frame.render_widget(
+ Paragraph::new(format!(" {error}")).style(Style::default().fg(app.palette.red)),
+ rows[5],
+ );
+ }
+
+ let (remove_rect, cancel_rect) = remove_worktree_button_rects(inner, remove.force_confirmation);
+ let remove_label = if remove.force_confirmation {
+ "delete anyway"
+ } else {
+ "remove"
+ };
+ render_action_button(
+ frame,
+ remove_rect,
+ Some("↵"),
+ remove_label,
+ Style::default()
+ .fg(panel_contrast_fg(&app.palette))
+ .bg(app.palette.red)
+ .add_modifier(Modifier::BOLD),
+ );
+ render_action_button(
+ frame,
+ cancel_rect,
+ Some("esc"),
+ "cancel",
+ Style::default()
+ .fg(app.palette.text)
+ .bg(app.palette.surface0)
+ .add_modifier(Modifier::BOLD),
+ );
+}
+
+pub(super) fn render_open_existing_worktree_overlay(app: &AppState, frame: &mut Frame, area: Rect) {
+ let Some(open) = app.worktree_open.as_ref() else {
+ return;
+ };
+
+ super::dim_background(frame, area);
+ let height = (open.entries.len() as u16).saturating_add(5).clamp(8, 18);
+ let Some(inner) = render_modal_shell(frame, area, 78, height, &app.palette) else {
+ return;
+ };
+ if inner.height < 6 {
+ return;
+ }
+
+ render_modal_header(
+ frame,
+ Rect::new(inner.x, inner.y, inner.width, 1),
+ "open worktree",
+ &app.palette,
+ );
+ frame.render_widget(
+ Paragraph::new(" existing checkouts").style(Style::default().fg(app.palette.overlay0)),
+ Rect::new(inner.x, inner.y.saturating_add(1), inner.width, 1),
+ );
+
+ let max_rows = inner.height.saturating_sub(4) as usize;
+ let start = open_existing_worktree_visible_start(open.selected, max_rows);
+ for (visible_idx, (entry_idx, entry)) in open
+ .entries
+ .iter()
+ .enumerate()
+ .skip(start)
+ .take(max_rows)
+ .enumerate()
+ {
+ let selected = entry_idx == open.selected;
+ let y = inner.y.saturating_add(2 + visible_idx as u16);
+ let marker = if selected { "›" } else { " " };
+ let branch = entry
+ .branch
+ .as_deref()
+ .unwrap_or(if entry.is_linked_worktree {
+ "detached"
+ } else {
+ "root"
+ });
+ let open_label = if entry.already_open_ws_idx.is_some() {
+ " open"
+ } else {
+ ""
+ };
+ let label = format!("{marker} {branch}{open_label} {}", entry.path.display());
+ let style = if selected {
+ Style::default()
+ .fg(app.palette.text)
+ .bg(app.palette.surface0)
+ .add_modifier(Modifier::BOLD)
+ } else {
+ Style::default().fg(app.palette.subtext0)
+ };
+ frame.render_widget(
+ Paragraph::new(truncate_text(&label, inner.width as usize)).style(style),
+ Rect::new(inner.x, y, inner.width, 1),
+ );
+ }
+
+ if let Some(error) = &open.error {
+ frame.render_widget(
+ Paragraph::new(format!(" {error}")).style(Style::default().fg(app.palette.red)),
+ Rect::new(
+ inner.x,
+ inner.y + inner.height.saturating_sub(2),
+ inner.width,
+ 1,
+ ),
+ );
+ }
+
+ let (open_rect, cancel_rect) = open_existing_worktree_button_rects(inner);
+ render_action_button(
+ frame,
+ open_rect,
+ Some("↵"),
+ "open",
+ Style::default()
+ .fg(panel_contrast_fg(&app.palette))
+ .bg(app.palette.accent)
+ .add_modifier(Modifier::BOLD),
+ );
+ render_action_button(
+ frame,
+ cancel_rect,
+ Some("esc"),
+ "cancel",
+ Style::default()
+ .fg(app.palette.text)
+ .bg(app.palette.surface0)
+ .add_modifier(Modifier::BOLD),
+ );
+}
+
+fn confirm_close_overlay_text(app: &AppState) -> (String, String) {
let ws_name = app
.workspaces
.get(app.selected)
.map(|ws| ws.display_name())
.unwrap_or_else(|| "?".to_string());
- let pane_count = app
+ let selected_space = app
.workspaces
.get(app.selected)
- .map(|ws| ws.layout.pane_count())
- .unwrap_or(0);
+ .and_then(|ws| ws.worktree_space());
+ let group_member_indices = selected_space
+ .filter(|space| !space.is_linked_worktree)
+ .map(|space| {
+ app.workspaces
+ .iter()
+ .enumerate()
+ .filter_map(|(idx, ws)| {
+ ws.worktree_space()
+ .is_some_and(|member| member.key == space.key)
+ .then_some(idx)
+ })
+ .collect::<Vec<_>>()
+ })
+ .unwrap_or_default();
+ let closes_group = group_member_indices.len() > 1;
+ let pane_count = if closes_group {
+ group_member_indices
+ .iter()
+ .filter_map(|idx| app.workspaces.get(*idx))
+ .map(|ws| ws.layout.pane_count())
+ .sum()
+ } else {
+ app.workspaces
+ .get(app.selected)
+ .map(|ws| ws.layout.pane_count())
+ .unwrap_or(0)
+ };
let pane_text = if pane_count == 1 {
"1 pane".to_string()
} else {
format!("{pane_count} panes")
};
+ let workspace_text = if closes_group {
+ let count = group_member_indices.len();
+ if count == 1 {
+ "1 workspace, ".to_string()
+ } else {
+ format!("{count} workspaces, ")
+ }
+ } else {
+ String::new()
+ };
+
+ let title = if closes_group {
+ "Close worktree group?"
+ } else {
+ "Close workspace?"
+ };
+ let detail = format!("{ws_name} — {workspace_text}{pane_text}");
+ (title.to_string(), detail)
+}
+
+pub(super) fn render_confirm_close_overlay(app: &AppState, frame: &mut Frame, area: Rect) {
+ let (title, detail) = confirm_close_overlay_text(app);
super::dim_background(frame, area);
@@ -138,16 +580,22 @@ pub(super) fn render_confirm_close_overlay(app: &AppState, frame: &mut Frame, ar
.add_modifier(Modifier::BOLD);
let dim = Style::default().fg(app.palette.overlay0);
- let title_line = Line::from(vec![Span::styled(" Close workspace?", warn)]);
+ let title_line = Line::from(vec![Span::styled(format!(" {title}"), warn)]);
let detail_line = Line::from(vec![
Span::styled(
- format!(" {ws_name}"),
+ format!(" {}", detail.split(" — ").next().unwrap_or(&detail)),
Style::default()
.fg(app.palette.text)
.add_modifier(Modifier::BOLD),
),
- Span::styled(format!(" — {pane_text}"), dim),
+ Span::styled(
+ detail
+ .split_once(" — ")
+ .map(|(_, rest)| format!(" — {rest}"))
+ .unwrap_or_default(),
+ dim,
+ ),
]);
let Some(inner) = render_panel_shell(frame, popup, app.palette.red, app.palette.panel_bg)
@@ -212,3 +660,38 @@ pub(crate) fn confirm_close_button_rects(inner: Rect) -> (Rect, Rect) {
);
(rects[0], rects[1])
}
+
+#[cfg(test)]
+mod tests {
+ use crate::{app::AppState, workspace::Workspace};
+
+ use super::confirm_close_overlay_text;
+
+ #[test]
+ fn confirm_close_text_reports_parent_group_scope() {
+ let mut app = AppState::test_new();
+ let mut parent = Workspace::test_new("main");
+ parent.worktree_space = Some(crate::workspace::WorktreeSpaceMembership {
+ key: "repo-key".into(),
+ label: "herdr".into(),
+ repo_root: "/repo/herdr".into(),
+ checkout_path: "/repo/herdr".into(),
+ is_linked_worktree: false,
+ });
+ let mut child = Workspace::test_new("issue");
+ child.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.workspaces = vec![parent, child];
+ app.selected = 0;
+
+ let (title, detail) = confirm_close_overlay_text(&app);
+
+ assert_eq!(title, "Close worktree group?");
+ assert_eq!(detail, "main — 2 workspaces, 2 panes");
+ }
+}
diff --git a/src/ui/keybind_help.rs b/src/ui/keybind_help.rs
index fb79077e..6fc778ea 100644
--- a/src/ui/keybind_help.rs
+++ b/src/ui/keybind_help.rs
@@ -80,6 +80,12 @@ pub(super) fn keybind_help_groups(
let workspace_tab = vec![
(keybind_label(&kb.workspace_picker), "workspace navigation"),
(keybind_label(&kb.new_workspace), "new workspace"),
+ (keybind_label(&kb.new_worktree), "new worktree"),
+ (keybind_label(&kb.open_worktree), "open worktree"),
+ (
+ keybind_label(&kb.remove_worktree),
+ "delete worktree checkout",
+ ),
(keybind_label(&kb.rename_workspace), "rename workspace"),
(keybind_label(&kb.close_workspace), "close workspace"),
(keybind_label(&kb.previous_workspace), "previous workspace"),
diff --git a/src/ui/sidebar.rs b/src/ui/sidebar.rs
index a2432716..509c400b 100644
--- a/src/ui/sidebar.rs
+++ b/src/ui/sidebar.rs
@@ -11,6 +11,7 @@ use super::status::{agent_icon, state_dot, state_label, state_label_color};
use crate::app::state::{AgentPanelScope, Palette};
use crate::app::{AppState, Mode};
use crate::detect::AgentState;
+use crate::terminal::TerminalRuntimeRegistry;
const WORKSPACE_SECTION_HEADER_ROWS: u16 = 2;
const AGENT_PANEL_HEADER_ROWS: u16 = 3;
@@ -224,6 +225,189 @@ fn workspace_row_height(ws: &crate::workspace::Workspace) -> u16 {
}
}
+fn workspace_attention_priority(state: AgentState, seen: bool) -> u8 {
+ match (state, seen) {
+ (AgentState::Blocked, _) => 4,
+ (AgentState::Idle, false) => 3,
+ (AgentState::Working, _) => 2,
+ (AgentState::Idle, true) => 1,
+ (AgentState::Unknown, _) => 0,
+ }
+}
+
+fn space_aggregate_state(app: &AppState, key: &str) -> (AgentState, bool) {
+ app.workspaces
+ .iter()
+ .filter(|ws| ws.worktree_space().is_some_and(|space| space.key == key))
+ .map(|ws| ws.aggregate_state(&app.terminals))
+ .max_by_key(|(state, seen)| workspace_attention_priority(*state, *seen))
+ .unwrap_or((AgentState::Unknown, true))
+}
+
+pub(crate) fn workspace_parent_group_state(
+ app: &AppState,
+ ws_idx: usize,
+) -> Option<(String, bool)> {
+ let space = app.workspaces.get(ws_idx)?.worktree_space()?;
+ if space.is_linked_worktree {
+ return None;
+ }
+ let member_count = app
+ .workspaces
+ .iter()
+ .filter(|ws| {
+ ws.worktree_space()
+ .is_some_and(|member| member.key == space.key)
+ })
+ .count();
+ (member_count >= 2).then(|| {
+ (
+ space.key.clone(),
+ app.collapsed_space_keys.contains(&space.key),
+ )
+ })
+}
+
+fn grouped_child_display_label(label: &str, branch: Option<&str>, has_custom_name: bool) -> String {
+ if has_custom_name {
+ return label.to_string();
+ }
+ let Some(branch) = branch else {
+ return label.to_string();
+ };
+ branch
+ .strip_prefix("worktree/")
+ .unwrap_or(branch)
+ .to_string()
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub(crate) enum WorkspaceListEntry {
+ Workspace { ws_idx: usize, indented: bool },
+}
+
+fn next_entry_is_indented_workspace(entries: &[WorkspaceListEntry], idx: usize) -> bool {
+ matches!(
+ entries.get(idx.saturating_add(1)),
+ Some(WorkspaceListEntry::Workspace { indented: true, .. })
+ )
+}
+
+pub(crate) fn normalized_workspace_scroll(app: &AppState, area: Rect, requested: usize) -> usize {
+ let ws_area = workspace_list_rect(area, app.sidebar_section_split);
+ let body = workspace_list_body_rect(ws_area, false);
+ if body.height == 0 {
+ return requested;
+ }
+
+ let entry_count = workspace_list_entries(app).len();
+ if entry_count == 0 {
+ 0
+ } else {
+ requested.min(entry_count.saturating_sub(1))
+ }
+}
+
+pub(crate) fn workspace_list_entries(app: &AppState) -> Vec<WorkspaceListEntry> {
+ let mut members_by_key = std::collections::HashMap::<String, Vec<usize>>::new();
+ for (ws_idx, ws) in app.workspaces.iter().enumerate() {
+ if let Some(space) = ws.worktree_space() {
+ members_by_key
+ .entry(space.key.clone())
+ .or_default()
+ .push(ws_idx);
+ }
+ }
+ let grouped_keys = members_by_key
+ .iter()
+ .filter(|(_, members)| {
+ members.len() >= 2
+ && members.iter().any(|idx| {
+ app.workspaces
+ .get(*idx)
+ .and_then(|ws| ws.worktree_space())
+ .is_some_and(|space| !space.is_linked_worktree)
+ })
+ })
+ .map(|(key, _)| key.clone())
+ .collect::<std::collections::HashSet<_>>();
+
+ let visible_group_idx = if matches!(app.mode, Mode::Navigate) {
+ Some(app.selected)
+ } else {
+ app.active
+ };
+ let active_group = visible_group_idx.and_then(|idx| {
+ app.workspaces
+ .get(idx)
+ .and_then(|ws| ws.worktree_space())
+ .map(|space| space.key.clone())
+ });
+
+ let mut emitted_groups = std::collections::HashSet::<String>::new();
+ let mut entries = Vec::new();
+ for (ws_idx, ws) in app.workspaces.iter().enumerate() {
+ let Some(space) = ws
+ .worktree_space()
+ .filter(|space| grouped_keys.contains(&space.key))
+ else {
+ entries.push(WorkspaceListEntry::Workspace {
+ ws_idx,
+ indented: false,
+ });
+ continue;
+ };
+
+ if !emitted_groups.insert(space.key.clone()) {
+ continue;
+ }
+
+ let Some(members) = members_by_key.get(&space.key) else {
+ continue;
+ };
+ let Some(parent_idx) = members.iter().copied().find(|idx| {
+ app.workspaces
+ .get(*idx)
+ .and_then(|member| member.worktree_space())
+ .is_some_and(|member_space| !member_space.is_linked_worktree)
+ }) else {
+ entries.push(WorkspaceListEntry::Workspace {
+ ws_idx,
+ indented: false,
+ });
+ continue;
+ };
+ let collapsed = app.collapsed_space_keys.contains(&space.key);
+ entries.push(WorkspaceListEntry::Workspace {
+ ws_idx: parent_idx,
+ indented: false,
+ });
+
+ if collapsed {
+ if let Some(active_idx) = visible_group_idx
+ .filter(|idx| *idx != parent_idx)
+ .filter(|_| active_group.as_deref() == Some(space.key.as_str()))
+ {
+ entries.push(WorkspaceListEntry::Workspace {
+ ws_idx: active_idx,
+ indented: true,
+ });
+ }
+ } else {
+ for member_idx in members {
+ if *member_idx == parent_idx {
+ continue;
+ }
+ entries.push(WorkspaceListEntry::Workspace {
+ ws_idx: *member_idx,
+ indented: true,
+ });
+ }
+ }
+ }
+ entries
+}
+
pub(crate) fn workspace_list_rect(area: Rect, split_ratio: f32) -> Rect {
let (ws_area, _) = expanded_sidebar_sections(area, split_ratio);
ws_area
@@ -249,8 +433,24 @@ fn workspace_list_visible_count(app: &AppState, area: Rect, scroll: usize) -> us
let mut used_rows = 0u16;
let mut visible = 0usize;
- for ws in app.workspaces.iter().skip(scroll) {
- let needed = workspace_row_height(ws).saturating_add(1);
+ let entries = workspace_list_entries(app);
+ for (entry_idx, entry) in entries.iter().enumerate().skip(scroll) {
+ let needed = match entry {
+ WorkspaceListEntry::Workspace { ws_idx, indented } => {
+ let Some(ws) = app.workspaces.get(*ws_idx) else {
+ continue;
+ };
+ let row_height = if *indented {
+ 1
+ } else {
+ workspace_row_height(ws)
+ };
+ let gap = u16::from(
+ !(*indented && next_entry_is_indented_workspace(&entries, entry_idx)),
+ );
+ row_height.saturating_add(gap)
+ }
+ };
if used_rows.saturating_add(needed) > body.height {
break;
}
@@ -264,11 +464,13 @@ pub(crate) fn workspace_list_scroll_metrics(
app: &AppState,
area: Rect,
) -> crate::pane::ScrollMetrics {
- let viewport_rows = workspace_list_visible_count(app, area, app.workspace_scroll);
- let total_rows = app.workspaces.len();
+ let entries = workspace_list_entries(app);
+ let total_rows = entries.len();
+ let scroll = app.workspace_scroll.min(total_rows.saturating_sub(1));
+ let viewport_rows = workspace_list_visible_count(app, area, scroll);
let max_offset_from_bottom = total_rows.saturating_sub(viewport_rows);
let offset_from_bottom = total_rows
- .saturating_sub(app.workspace_scroll)
+ .saturating_sub(scroll)
.saturating_sub(viewport_rows);
crate::pane::ScrollMetrics {
@@ -344,38 +546,63 @@ pub(crate) fn agent_panel_scrollbar_rect(app: &AppState, area: Rect) -> Option<R
))
}
-pub(crate) fn compute_workspace_card_areas(
+pub(crate) fn compute_workspace_list_areas(
app: &AppState,
area: Rect,
-) -> Vec<crate::app::state::WorkspaceCardArea> {
+) -> (Vec<crate::app::state::WorkspaceCardArea>, Vec<()>) {
let ws_area = workspace_list_rect(area, app.sidebar_section_split);
if ws_area == Rect::default() {
- return Vec::new();
+ return (Vec::new(), Vec::new());
}
let metrics = workspace_list_scroll_metrics(app, ws_area);
let body = workspace_list_body_rect(ws_area, should_show_scrollbar(metrics));
if body.width == 0 || body.height == 0 {
- return Vec::new();
+ return (Vec::new(), Vec::new());
}
+ let scroll = app.workspace_scroll;
let mut row_y = body.y;
let body_bottom = body.y + body.height;
let mut cards = Vec::new();
-
- for (ws_idx, ws) in app.workspaces.iter().enumerate().skip(app.workspace_scroll) {
- let row_height = workspace_row_height(ws);
- if row_y.saturating_add(row_height).saturating_add(1) > body_bottom {
- break;
+ let headers = Vec::new();
+
+ let entries = workspace_list_entries(app);
+ for (entry_idx, entry) in entries.iter().enumerate().skip(scroll) {
+ match entry {
+ WorkspaceListEntry::Workspace { ws_idx, indented } => {
+ let Some(ws) = app.workspaces.get(*ws_idx) else {
+ continue;
+ };
+ let row_height = if *indented {
+ 1
+ } else {
+ workspace_row_height(ws)
+ };
+ let gap = u16::from(
+ !(*indented && next_entry_is_indented_workspace(&entries, entry_idx)),
+ );
+ if row_y.saturating_add(row_height).saturating_add(gap) > body_bottom {
+ break;
+ }
+ cards.push(crate::app::state::WorkspaceCardArea {
+ ws_idx: *ws_idx,
+ rect: Rect::new(body.x, row_y, body.width, row_height),
+ indented: *indented,
+ });
+ row_y = row_y.saturating_add(row_height + gap);
+ }
}
- cards.push(crate::app::state::WorkspaceCardArea {
- ws_idx,
- rect: Rect::new(body.x, row_y, body.width, row_height),
- });
- row_y = row_y.saturating_add(row_height + 1);
}
- cards
+ (cards, headers)
+}
+
+pub(crate) fn compute_workspace_card_areas(
+ app: &AppState,
+ area: Rect,
+) -> Vec<crate::app::state::WorkspaceCardArea> {
+ compute_workspace_list_areas(app, area).0
}
/// Auto-scale sidebar width based on workspace identity + agent summary.
@@ -530,18 +757,28 @@ pub(crate) fn workspace_drop_indicator_row(
return first.rect.y.checked_sub(1).filter(|y| *y < list_bottom);
}
- if let Some(card) = cards.iter().find(|card| card.ws_idx == insert_idx) {
- return card.rect.y.checked_sub(1).filter(|y| *y < list_bottom);
- }
-
- cards
+ if let Some(row) = cards
.last()
.filter(|card| insert_idx == card.ws_idx.saturating_add(1))
.map(|card| card.rect.y.saturating_add(card.rect.height))
.filter(|y| *y < list_bottom)
+ {
+ return Some(row);
+ }
+
+ if let Some(card) = cards.iter().find(|card| card.ws_idx == insert_idx) {
+ return card.rect.y.checked_sub(1).filter(|y| *y < list_bottom);
+ }
+
+ None
}
-pub(super) fn render_sidebar(app: &AppState, frame: &mut Frame, area: Rect) {
+pub(super) fn render_sidebar(
+ app: &AppState,
+ terminal_runtimes: &TerminalRuntimeRegistry,
+ frame: &mut Frame,
+ area: Rect,
+) {
let p = &app.palette;
let is_navigating = matches!(app.mode, Mode::Navigate);
let sep_style = if is_navigating {
@@ -559,12 +796,18 @@ pub(super) fn render_sidebar(app: &AppState, frame: &mut Frame, area: Rect) {
let (ws_area, detail_area) = expanded_sidebar_sections(area, app.sidebar_section_split);
- render_workspace_list(app, frame, ws_area, is_navigating);
+ render_workspace_list(app, terminal_runtimes, frame, ws_area, is_navigating);
render_agent_detail(app, frame, detail_area);
render_sidebar_toggle(app, frame, area, false, p);
}
-fn render_workspace_list(app: &AppState, frame: &mut Frame, area: Rect, is_navigating: bool) {
+fn render_workspace_list(
+ app: &AppState,
+ terminal_runtimes: &TerminalRuntimeRegistry,
+ frame: &mut Frame,
+ area: Rect,
+ is_navigating: bool,
+) {
let p = &app.palette;
let dragged_ws_idx = match app.drag.as_ref().map(|drag| &drag.target) {
Some(crate::app::state::DragTarget::WorkspaceReorder { source_ws_idx, .. }) => {
@@ -625,17 +868,6 @@ fn render_workspace_list(app: &AppState, frame: &mut Frame, area: Rect, is_navig
}
}
- if is_active {
- let buf = frame.buffer_mut();
- for y in row_y..row_y + row_height {
- if y >= list_bottom {
- break;
- }
- buf[(card.rect.x, y)].set_symbol("▌");
- buf[(card.rect.x, y)].set_style(Style::default().fg(p.accent));
- }
- }
-
let name_style = if selected || is_active || is_dragged {
Style::default().fg(p.text).add_modifier(Modifier::BOLD)
} else {
@@ -643,12 +875,43 @@ fn render_workspace_list(app: &AppState, frame: &mut Frame, area: Rect, is_navig
};
let (icon, icon_style) = state_dot(agg_state, agg_seen, p);
- let line1 = vec![
- Span::styled(" ", Style::default()),
- Span::styled(icon, icon_style),
- Span::styled(" ", Style::default()),
- Span::styled(ws.display_name(), name_style),
- ];
+ let label = ws.display_name_from(&app.terminals, terminal_runtimes);
+ let mut line1 = Vec::new();
+ let mut show_workspace_icon = true;
+ if card.indented {
+ line1.push(Span::styled(" ", Style::default()));
+ } else if let Some((key, collapsed)) = workspace_parent_group_state(app, i) {
+ let icon = if collapsed { "▸" } else { "▾" };
+ let (state_icon, state_style) = if collapsed {
+ let (state, seen) = space_aggregate_state(app, &key);
+ state_dot(state, seen, p)
+ } else {
+ (icon, Style::default().fg(p.accent))
+ };
+ line1.push(Span::styled(icon, Style::default().fg(p.accent)));
+ if collapsed {
+ line1.push(Span::styled(" ", Style::default()));
+ line1.push(Span::styled(state_icon, state_style));
+ show_workspace_icon = false;
+ }
+ line1.push(Span::styled(" ", Style::default()));
+ } else {
+ line1.push(Span::styled(" ", Style::default()));
+ }
+ if show_workspace_icon {
+ line1.push(Span::styled(icon, icon_style));
+ line1.push(Span::styled(" ", Style::default()));
+ }
+ if card.indented {
+ let display_label = grouped_child_display_label(
+ &label,
+ ws.branch().as_deref(),
+ ws.custom_name.is_some(),
+ );
+ line1.push(Span::styled(display_label, name_style));
+ } else {
+ line1.push(Span::styled(label, name_style));
+ }
frame.render_widget(
Paragraph::new(Line::from(line1)),
@@ -684,8 +947,9 @@ fn render_workspace_list(app: &AppState, frame: &mut Frame, area: Rect, is_navig
} else {
p.overlay0
};
+ let branch_indent = if card.indented { " " } else { " " };
let mut spans = vec![
- Span::styled(" ", Style::default()),
+ Span::styled(branch_indent, Style::default()),
Span::styled(branch_display, Style::default().fg(branch_color)),
];
if let Some(parts) = upstream_label {
@@ -996,4 +1260,339 @@ mod tests {
assert_eq!(divider, Rect::default());
}
+
+ #[test]
+ fn grouped_child_label_keeps_custom_workspace_name() {
+ assert_eq!(
+ grouped_child_display_label("renamed issue", Some("worktree/issue-137"), true),
+ "renamed issue"
+ );
+ }
+
+ #[test]
+ fn grouped_child_label_uses_short_branch_for_auto_named_workspace() {
+ assert_eq!(
+ grouped_child_display_label("herdr-issue", Some("worktree/issue-137"), false),
+ "issue-137"
+ );
+ }
+
+ fn workspace_with_worktree_space(
+ name: &str,
+ key: Option<&str>,
+ checkout_key: &str,
+ ) -> crate::workspace::Workspace {
+ let mut ws = crate::workspace::Workspace::test_new(name);
+ if let Some(key) = key {
+ ws.worktree_space = Some(crate::workspace::WorktreeSpaceMembership {
+ key: key.into(),
+ label: "herdr".into(),
+ repo_root: std::path::PathBuf::from("/repo/herdr"),
+ checkout_path: std::path::PathBuf::from(checkout_key),
+ is_linked_worktree: name != "main",
+ });
+ }
+ ws
+ }
+
+ fn workspace_with_git_space(name: &str, key: &str) -> crate::workspace::Workspace {
+ let mut ws = crate::workspace::Workspace::test_new(name);
+ ws.cached_git_space = Some(crate::workspace::GitSpaceMetadata {
+ key: key.into(),
+ checkout_key: format!("/repo/{name}"),
+ label: "herdr".into(),
+ repo_root: std::path::PathBuf::from(format!("/repo/{name}")),
+ is_linked_worktree: false,
+ });
+ ws
+ }
+
+ #[test]
+ fn parent_workspace_row_stays_clickable_when_grouped() {
+ let mut app = AppState::test_new();
+ app.workspaces = vec![
+ workspace_with_worktree_space("main", Some("repo-key"), "/repo/herdr"),
+ workspace_with_worktree_space("issue", Some("repo-key"), "/repo/herdr-issue"),
+ ];
+
+ let (cards, headers) = compute_workspace_list_areas(&app, Rect::new(0, 0, 30, 20));
+
+ assert!(headers.is_empty());
+ assert_eq!(cards[0].ws_idx, 0);
+ assert!(!cards[0].indented);
+ assert_eq!(cards[1].ws_idx, 1);
+ assert!(cards[1].indented);
+ assert_eq!(cards[1].rect.y, cards[0].rect.y + cards[0].rect.height + 1);
+ }
+
+ #[test]
+ fn linked_only_worktree_members_do_not_form_parentless_group() {
+ let mut app = AppState::test_new();
+ app.workspaces = vec![
+ workspace_with_worktree_space("issue", Some("repo-key"), "/repo/herdr-issue"),
+ workspace_with_worktree_space("review", Some("repo-key"), "/repo/herdr-review"),
+ ];
+
+ let entries = workspace_list_entries(&app);
+
+ assert_eq!(
+ entries,
+ vec![
+ WorkspaceListEntry::Workspace {
+ ws_idx: 0,
+ indented: false
+ },
+ WorkspaceListEntry::Workspace {
+ ws_idx: 1,
+ indented: false
+ },
+ ]
+ );
+ }
+
+ #[test]
+ fn compact_space_group_scroll_offset_can_start_inside_group() {
+ let mut app = AppState::test_new();
+ app.workspaces = vec![
+ workspace_with_worktree_space("main", Some("repo-key"), "/repo/herdr"),
+ workspace_with_worktree_space("one", Some("repo-key"), "/repo/herdr-one"),
+ workspace_with_worktree_space("two", Some("repo-key"), "/repo/herdr-two"),
+ ];
+ let area = Rect::new(0, 0, 30, 20);
+ app.workspace_scroll = normalized_workspace_scroll(&app, area, 2);
+
+ let (cards, headers) = compute_workspace_list_areas(&app, area);
+
+ assert!(headers.is_empty());
+ assert_eq!(cards.len(), 1);
+ assert_eq!(cards[0].ws_idx, 2);
+ }
+
+ #[test]
+ fn workspace_scroll_metrics_count_display_entries_not_raw_workspaces() {
+ let mut app = AppState::test_new();
+ app.workspaces = vec![
+ workspace_with_worktree_space("main", Some("repo-key"), "/repo/herdr"),
+ workspace_with_worktree_space("issue", Some("repo-key"), "/repo/herdr-issue"),
+ Workspace::test_new("notes"),
+ ];
+ app.collapsed_space_keys.insert("repo-key".into());
+ app.active = None;
+ app.mode = Mode::Terminal;
+
+ let ws_area = Rect::new(0, 0, 30, 6);
+ let metrics = workspace_list_scroll_metrics(&app, ws_area);
+
+ assert_eq!(metrics.viewport_rows, 1);
+ assert_eq!(metrics.max_offset_from_bottom, 1);
+ assert_eq!(metrics.offset_from_bottom, 1);
+ }
+
+ #[test]
+ fn workspace_scroll_offset_applies_to_group_children() {
+ let mut app = AppState::test_new();
+ app.workspaces = vec![
+ workspace_with_worktree_space("main", Some("repo-key"), "/repo/herdr"),
+ workspace_with_worktree_space("issue", Some("repo-key"), "/repo/herdr-issue"),
+ Workspace::test_new("notes"),
+ ];
+ app.collapsed_space_keys.insert("repo-key".into());
+ app.active = None;
+ app.mode = Mode::Terminal;
+ app.workspace_scroll = 1;
+
+ let (cards, headers) = compute_workspace_list_areas(&app, Rect::new(0, 0, 30, 12));
+
+ assert!(headers.is_empty());
+ assert_eq!(cards.len(), 1);
+ assert_eq!(cards[0].ws_idx, 2);
+ }
+
+ #[test]
+ fn workspace_list_entries_group_multiple_workspaces_in_same_git_space() {
+ let mut app = AppState::test_new();
+ app.workspaces = vec![
+ workspace_with_worktree_space("main", Some("repo-key"), "/repo/herdr"),
+ workspace_with_worktree_space("issue", Some("repo-key"), "/repo/herdr-issue"),
+ ];
+
+ assert_eq!(
+ workspace_list_entries(&app),
+ vec![
+ WorkspaceListEntry::Workspace {
+ ws_idx: 0,
+ indented: false,
+ },
+ WorkspaceListEntry::Workspace {
+ ws_idx: 1,
+ indented: true,
+ },
+ ]
+ );
+ }
+
+ #[test]
+ fn workspace_list_entries_group_non_contiguous_explicit_members() {
+ let mut app = AppState::test_new();
+ app.workspaces = vec![
+ workspace_with_worktree_space("main", Some("repo-key"), "/repo/herdr"),
+ workspace_with_git_space("normal", "other-key"),
+ workspace_with_worktree_space("issue", Some("repo-key"), "/repo/herdr-issue"),
+ ];
+
+ assert_eq!(
+ workspace_list_entries(&app),
+ vec![
+ WorkspaceListEntry::Workspace {
+ ws_idx: 0,
+ indented: false,
+ },
+ WorkspaceListEntry::Workspace {
+ ws_idx: 2,
+ indented: true,
+ },
+ WorkspaceListEntry::Workspace {
+ ws_idx: 1,
+ indented: false,
+ },
+ ]
+ );
+ }
+
+ #[test]
+ fn workspace_list_entries_do_not_group_normal_git_workspaces() {
+ let mut app = AppState::test_new();
+ app.workspaces = vec![
+ workspace_with_git_space("one", "repo-key"),
+ workspace_with_git_space("two", "repo-key"),
+ ];
+
+ assert_eq!(
+ workspace_list_entries(&app),
+ vec![
+ WorkspaceListEntry::Workspace {
+ ws_idx: 0,
+ indented: false,
+ },
+ WorkspaceListEntry::Workspace {
+ ws_idx: 1,
+ indented: false,
+ },
+ ]
+ );
+ }
+
+ #[test]
+ fn workspace_list_entries_do_not_auto_attach_normal_git_workspace_to_group() {
+ let mut app = AppState::test_new();
+ app.workspaces = vec![
+ workspace_with_worktree_space("main", Some("repo-key"), "/repo/herdr"),
+ workspace_with_git_space("scratch", "repo-key"),
+ workspace_with_worktree_space("issue", Some("repo-key"), "/repo/herdr-issue"),
+ ];
+
+ assert_eq!(
+ workspace_list_entries(&app),
+ vec![
+ WorkspaceListEntry::Workspace {
+ ws_idx: 0,
+ indented: false,
+ },
+ WorkspaceListEntry::Workspace {
+ ws_idx: 2,
+ indented: true,
+ },
+ WorkspaceListEntry::Workspace {
+ ws_idx: 1,
+ indented: false,
+ },
+ ]
+ );
+ }
+
+ #[test]
+ fn workspace_list_entries_leave_single_git_and_non_git_workspaces_flat() {
+ let mut app = AppState::test_new();
+ app.workspaces = vec![
+ workspace_with_git_space("one", "repo-key"),
+ workspace_with_worktree_space("notes", None, "/notes"),
+ ];
+
+ assert_eq!(
+ workspace_list_entries(&app),
+ vec![
+ WorkspaceListEntry::Workspace {
+ ws_idx: 0,
+ indented: false,
+ },
+ WorkspaceListEntry::Workspace {
+ ws_idx: 1,
+ indented: false,
+ },
+ ]
+ );
+ }
+
+ #[test]
+ fn collapsed_group_hides_inactive_children_but_keeps_active_visible() {
+ let mut app = AppState::test_new();
+ app.workspaces = vec![
+ workspace_with_worktree_space("main", Some("repo-key"), "/repo/herdr"),
+ workspace_with_worktree_space("issue", Some("repo-key"), "/repo/herdr-issue"),
+ ];
+ app.active = Some(1);
+ app.mode = Mode::Terminal;
+ app.collapsed_space_keys.insert("repo-key".into());
+
+ assert_eq!(
+ workspace_list_entries(&app),
+ vec![
+ WorkspaceListEntry::Workspace {
+ ws_idx: 0,
+ indented: false,
+ },
+ WorkspaceListEntry::Workspace {
+ ws_idx: 1,
+ indented: true,
+ },
+ ]
+ );
+
+ app.active = None;
+ app.mode = Mode::Terminal;
+ assert_eq!(
+ workspace_list_entries(&app),
+ vec![WorkspaceListEntry::Workspace {
+ ws_idx: 0,
+ indented: false,
+ }]
+ );
+ }
+
+ #[test]
+ fn collapsed_group_keeps_selected_child_visible_in_navigate_mode() {
+ let mut app = AppState::test_new();
+ app.workspaces = vec![
+ workspace_with_worktree_space("main", Some("repo-key"), "/repo/herdr"),
+ workspace_with_worktree_space("issue", Some("repo-key"), "/repo/herdr-issue"),
+ ];
+ app.mode = Mode::Navigate;
+ app.selected = 1;
+ app.active = Some(1);
+ app.collapsed_space_keys.insert("repo-key".into());
+
+ assert_eq!(
+ workspace_list_entries(&app),
+ vec![
+ WorkspaceListEntry::Workspace {
+ ws_idx: 0,
+ indented: false,
+ },
+ WorkspaceListEntry::Workspace {
+ ws_idx: 1,
+ indented: true,
+ },
+ ]
+ );
+ }
}
diff --git a/src/workspace.rs b/src/workspace.rs
index 70ff97f5..a302f3f8 100644
--- a/src/workspace.rs
+++ b/src/workspace.rs
@@ -21,16 +21,26 @@ mod tab;
use self::git::git_ahead_behind;
pub use self::{
- git::{derive_label_from_cwd, git_branch},
+ git::{derive_label_from_cwd, git_branch, git_space_metadata, GitSpaceMetadata},
tab::Tab,
};
+#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
+pub struct WorktreeSpaceMembership {
+ pub key: String,
+ pub label: String,
+ pub repo_root: PathBuf,
+ pub checkout_path: PathBuf,
+ pub is_linked_worktree: bool,
+}
+
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorkspaceGitStatus {
pub workspace_id: String,
pub resolved_identity_cwd: PathBuf,
pub branch: Option<String>,
pub ahead_behind: Option<(usize, usize)>,
+ pub space: Option<GitSpaceMetadata>,
}
static NEXT_WORKSPACE_ID: AtomicU64 = AtomicU64::new(1);
@@ -56,6 +66,10 @@ pub struct Workspace {
pub(crate) cached_git_branch: Option<String>,
/// Cached ahead/behind counts for the workspace repo's current branch upstream.
pub(crate) cached_git_ahead_behind: Option<(usize, usize)>,
+ /// Cached derived Git repo metadata for worktree actions and status display.
+ pub(crate) cached_git_space: Option<GitSpaceMetadata>,
+ /// Explicit Herdr-managed worktree grouping provenance.
+ pub worktree_space: Option<WorktreeSpaceMembership>,
/// Stable-ish public pane numbers within this workspace.
/// New panes append at the end; closing a pane compacts higher numbers down.
pub public_pane_numbers: HashMap<PaneId, usize>,
@@ -182,6 +196,8 @@ impl Workspace {
identity_cwd: initial_cwd.clone(),
cached_git_branch: git_branch(&initial_cwd),
cached_git_ahead_behind: None,
+ cached_git_space: None,
+ worktree_space: None,
public_pane_numbers,
next_public_pane_number: 2,
tabs: vec![tab],
@@ -576,11 +592,20 @@ impl Workspace {
self.cached_git_ahead_behind
}
+ pub fn git_space(&self) -> Option<&GitSpaceMetadata> {
+ self.cached_git_space.as_ref()
+ }
+
+ pub fn worktree_space(&self) -> Option<&WorktreeSpaceMembership> {
+ self.worktree_space.as_ref()
+ }
+
#[cfg(test)]
pub fn refresh_git_ahead_behind(&mut self) {
let cwd = self.resolved_identity_cwd();
self.cached_git_branch = cwd.as_deref().and_then(git_branch);
self.cached_git_ahead_behind = cwd.as_deref().and_then(git_ahead_behind);
+ self.cached_git_space = cwd.as_deref().and_then(git_space_metadata);
}
pub fn git_status_for_cwd(
@@ -590,6 +615,7 @@ impl Workspace {
WorkspaceGitStatus {
branch: git_branch(&resolved_identity_cwd),
ahead_behind: git_ahead_behind(&resolved_identity_cwd),
+ space: git_space_metadata(&resolved_identity_cwd),
workspace_id,
resolved_identity_cwd,
}
@@ -704,6 +730,8 @@ impl Workspace {
identity_cwd: identity_cwd.clone(),
cached_git_branch: git_branch(&identity_cwd),
cached_git_ahead_behind: None,
+ cached_git_space: None,
+ worktree_space: None,
public_pane_numbers,
next_public_pane_number: 2,
tabs: vec![tab],
diff --git a/src/workspace/git.rs b/src/workspace/git.rs
index ca453003..d06c8538 100644
--- a/src/workspace/git.rs
+++ b/src/workspace/git.rs
@@ -1,5 +1,23 @@
use std::path::{Path, PathBuf};
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct GitSpaceMetadata {
+ pub key: String,
+ pub checkout_key: String,
+ pub label: String,
+ pub repo_root: PathBuf,
+ pub is_linked_worktree: bool,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct GitWorktreeInfo {
+ pub repo_root: PathBuf,
+ pub git_dir: PathBuf,
+ pub git_common_dir: PathBuf,
+ pub is_bare: bool,
+ pub is_linked_worktree: bool,
+}
+
pub fn derive_label_from_cwd(cwd: &Path) -> String {
if let Some(repo_root) = git_repo_root(cwd) {
if let Some(name) = repo_root.file_name().and_then(|n| n.to_str()) {
@@ -21,6 +39,83 @@ pub fn derive_label_from_cwd(cwd: &Path) -> String {
.unwrap_or_else(|| cwd.display().to_string())
}
+pub fn git_worktree_info(cwd: &Path) -> Option<GitWorktreeInfo> {
+ let repo_root = git_rev_parse(cwd, &["--show-toplevel"])?;
+ let git_dir = git_rev_parse(cwd, &["--path-format=absolute", "--git-dir"])?;
+ let git_common_dir = git_rev_parse(cwd, &["--path-format=absolute", "--git-common-dir"])?;
+ let is_bare = git_rev_parse(cwd, &["--is-bare-repository"])? == "true";
+ let is_linked_worktree =
+ canonicalize_best_effort(&git_dir) != canonicalize_best_effort(&git_common_dir);
+
+ Some(GitWorktreeInfo {
+ repo_root: PathBuf::from(repo_root),
+ git_dir: PathBuf::from(git_dir),
+ git_common_dir: PathBuf::from(git_common_dir),
+ is_bare,
+ is_linked_worktree,
+ })
+}
+
+fn git_rev_parse(cwd: &Path, args: &[&str]) -> Option<String> {
+ let output = std::process::Command::new("git")
+ .arg("-C")
+ .arg(cwd)
+ .arg("rev-parse")
+ .args(args)
+ .output()
+ .ok()?;
+
+ if !output.status.success() {
+ return None;
+ }
+
+ let stdout = String::from_utf8(output.stdout).ok()?;
+ Some(stdout.trim().to_string())
+}
+
+fn canonicalize_best_effort(path: &str) -> PathBuf {
+ std::fs::canonicalize(path).unwrap_or_else(|_| PathBuf::from(path))
+}
+
+pub fn git_space_metadata(cwd: &Path) -> Option<GitSpaceMetadata> {
+ let info = git_worktree_info(cwd)?;
+ if info.is_bare {
+ return None;
+ }
+ let key = canonicalize_best_effort_path(&info.git_common_dir)
+ .display()
+ .to_string();
+ let checkout_key = canonicalize_best_effort_path(&info.repo_root)
+ .display()
+ .to_string();
+ let label_path = if info
+ .git_common_dir
+ .file_name()
+ .and_then(|name| name.to_str())
+ == Some(".git")
+ {
+ info.git_common_dir.parent().unwrap_or(&info.repo_root)
+ } else {
+ &info.repo_root
+ };
+ let label = label_path
+ .file_name()
+ .and_then(|name| name.to_str())
+ .unwrap_or("repo")
+ .to_string();
+ Some(GitSpaceMetadata {
+ key,
+ checkout_key,
+ label,
+ repo_root: info.repo_root,
+ is_linked_worktree: info.is_linked_worktree,
+ })
+}
+
+fn canonicalize_best_effort_path(path: &Path) -> PathBuf {
+ std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
+}
+
pub fn git_branch(cwd: &Path) -> Option<String> {
let repo_root = git_repo_root(cwd)?;
let git_dir = git_dir_for_repo_root(&repo_root)?;
diff --git a/src/worktree.rs b/src/worktree.rs
new file mode 100644
index 00000000..058ee2b8
--- /dev/null
+++ b/src/worktree.rs
@@ -0,0 +1,478 @@
+use std::path::{Path, PathBuf};
+
+const DEFAULT_WORKTREE_PREFIX: &str = "worktree";
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub(crate) struct WorktreeCommand {
+ pub program: String,
+ pub args: Vec<String>,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub(crate) struct ExistingWorktree {
+ pub path: PathBuf,
+ pub branch: Option<String>,
+ pub is_bare: bool,
+ pub is_detached: bool,
+ pub is_prunable: bool,
+}
+
+pub(crate) fn generated_branch_slug(seed: u64) -> String {
+ let adjectives = [
+ "brave", "calm", "clear", "green", "lucky", "quiet", "rapid", "silver",
+ ];
+ let nouns = [
+ "river", "cloud", "field", "forest", "harbor", "meadow", "stone", "valley",
+ ];
+ let adjective = adjectives[(seed as usize) % adjectives.len()];
+ let noun = nouns[((seed / adjectives.len() as u64) as usize) % nouns.len()];
+ let suffix = seed & 0xffff;
+ format!("{DEFAULT_WORKTREE_PREFIX}/{adjective}-{noun}-{suffix:04x}")
+}
+
+pub(crate) fn branch_to_path_slug(branch: &str) -> String {
+ let mut slug = String::new();
+ let mut last_was_dash = false;
+
+ for ch in branch.chars() {
+ if ch.is_ascii_alphanumeric() {
+ slug.push(ch.to_ascii_lowercase());
+ last_was_dash = false;
+ } else if !last_was_dash {
+ slug.push('-');
+ last_was_dash = true;
+ }
+ }
+
+ let trimmed = slug.trim_matches('-').to_string();
+ if trimmed.is_empty() {
+ DEFAULT_WORKTREE_PREFIX.to_string()
+ } else {
+ trimmed
+ }
+}
+
+pub(crate) fn expand_tilde_path(path: &str) -> PathBuf {
+ if path == "~" {
+ return std::env::var("HOME")
+ .map(PathBuf::from)
+ .unwrap_or_else(|_| PathBuf::from(path));
+ }
+
+ if let Some(rest) = path.strip_prefix("~/") {
+ return std::env::var("HOME")
+ .map(|home| PathBuf::from(home).join(rest))
+ .unwrap_or_else(|_| PathBuf::from(path));
+ }
+
+ PathBuf::from(path)
+}
+
+pub(crate) fn canonical_or_original(path: &Path) -> PathBuf {
+ std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
+}
+
+pub(crate) fn default_checkout_path(root: &Path, repo_name: &str, branch: &str) -> PathBuf {
+ root.join(repo_name).join(branch_to_path_slug(branch))
+}
+
+pub(crate) fn build_worktree_remove_command(
+ repo_root: &Path,
+ path: &Path,
+ force: bool,
+) -> WorktreeCommand {
+ let mut args = vec![
+ "-C".to_string(),
+ repo_root.display().to_string(),
+ "worktree".to_string(),
+ "remove".to_string(),
+ ];
+ if force {
+ args.push("--force".to_string());
+ }
+ args.push(path.display().to_string());
+
+ WorktreeCommand {
+ program: "git".to_string(),
+ args,
+ }
+}
+
+pub(crate) fn is_dirty_worktree_remove_error(message: &str) -> bool {
+ let lower = message.to_ascii_lowercase();
+ lower.contains("contains modified or untracked files")
+ && lower.contains("use --force to delete it")
+}
+
+pub(crate) fn build_worktree_add_new_branch_command(
+ repo_root: &Path,
+ path: &Path,
+ branch: &str,
+ base: &str,
+) -> WorktreeCommand {
+ WorktreeCommand {
+ program: "git".to_string(),
+ args: vec![
+ "-C".to_string(),
+ repo_root.display().to_string(),
+ "worktree".to_string(),
+ "add".to_string(),
+ "-b".to_string(),
+ branch.to_string(),
+ path.display().to_string(),
+ base.to_string(),
+ ],
+ }
+}
+
+pub(crate) fn run_worktree_command(command: &WorktreeCommand) -> Result<(), String> {
+ let output = std::process::Command::new(&command.program)
+ .args(&command.args)
+ .output()
+ .map_err(|err| err.to_string())?;
+
+ if output.status.success() {
+ return Ok(());
+ }
+
+ let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
+ let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
+ let message = if stderr.is_empty() { stdout } else { stderr };
+ Err(if message.is_empty() {
+ format!("{} failed with status {}", command.program, output.status)
+ } else {
+ message
+ })
+}
+
+pub(crate) fn parse_worktree_list_porcelain(output: &str) -> Vec<ExistingWorktree> {
+ let mut entries = Vec::new();
+ let mut path: Option<PathBuf> = None;
+ let mut branch = None;
+ let mut is_bare = false;
+ let mut is_detached = false;
+ let mut is_prunable = false;
+
+ let finish = |entries: &mut Vec<ExistingWorktree>,
+ path: &mut Option<PathBuf>,
+ branch: &mut Option<String>,
+ is_bare: &mut bool,
+ is_detached: &mut bool,
+ is_prunable: &mut bool| {
+ if let Some(path) = path.take() {
+ entries.push(ExistingWorktree {
+ path,
+ branch: branch.take(),
+ is_bare: *is_bare,
+ is_detached: *is_detached,
+ is_prunable: *is_prunable,
+ });
+ }
+ *is_bare = false;
+ *is_detached = false;
+ *is_prunable = false;
+ };
+
+ for line in output.lines() {
+ if line.trim().is_empty() {
+ finish(
+ &mut entries,
+ &mut path,
+ &mut branch,
+ &mut is_bare,
+ &mut is_detached,
+ &mut is_prunable,
+ );
+ continue;
+ }
+ if let Some(value) = line.strip_prefix("worktree ") {
+ path = Some(PathBuf::from(value));
+ } else if let Some(value) = line.strip_prefix("branch ") {
+ branch = Some(
+ value
+ .strip_prefix("refs/heads/")
+ .unwrap_or(value)
+ .to_string(),
+ );
+ } else if line == "detached" {
+ is_detached = true;
+ } else if line == "bare" {
+ is_bare = true;
+ } else if line.starts_with("prunable") {
+ is_prunable = true;
+ }
+ }
+
+ finish(
+ &mut entries,
+ &mut path,
+ &mut branch,
+ &mut is_bare,
+ &mut is_detached,
+ &mut is_prunable,
+ );
+ entries
+}
+
+pub(crate) fn list_existing_worktrees(repo_root: &Path) -> Result<Vec<ExistingWorktree>, String> {
+ let output = std::process::Command::new("git")
+ .arg("-C")
+ .arg(repo_root)
+ .args(["worktree", "list", "--porcelain"])
+ .output()
+ .map_err(|err| err.to_string())?;
+
+ if output.status.success() {
+ let stdout = String::from_utf8_lossy(&output.stdout);
+ return Ok(parse_worktree_list_porcelain(&stdout));
+ }
+
+ let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
+ Err(if stderr.is_empty() {
+ format!("git worktree list failed with status {}", output.status)
+ } else {
+ stderr
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn unique_temp_path(name: &str) -> 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: &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) -> 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
+ }
+
+ #[test]
+ fn generated_branch_slug_is_worktree_namespaced_and_stable() {
+ assert_eq!(generated_branch_slug(0), "worktree/brave-river-0000");
+ assert_eq!(generated_branch_slug(9), "worktree/calm-cloud-0009");
+ }
+
+ #[test]
+ fn parses_git_worktree_list_porcelain() {
+ let output = "\
+worktree /repo/main
+HEAD abc
+branch refs/heads/main
+
+worktree /repo/issue
+HEAD def
+branch refs/heads/worktree/issue
+
+worktree /repo/detached
+HEAD fed
+detached
+prunable stale
+
+";
+
+ assert_eq!(
+ parse_worktree_list_porcelain(output),
+ vec![
+ ExistingWorktree {
+ path: PathBuf::from("/repo/main"),
+ branch: Some("main".into()),
+ is_bare: false,
+ is_detached: false,
+ is_prunable: false,
+ },
+ ExistingWorktree {
+ path: PathBuf::from("/repo/issue"),
+ branch: Some("worktree/issue".into()),
+ is_bare: false,
+ is_detached: false,
+ is_prunable: false,
+ },
+ ExistingWorktree {
+ path: PathBuf::from("/repo/detached"),
+ branch: None,
+ is_bare: false,
+ is_detached: true,
+ is_prunable: true,
+ },
+ ]
+ );
+ }
+
+ #[test]
+ fn branch_to_path_slug_makes_branch_safe_folder_name() {
+ assert_eq!(
+ branch_to_path_slug("worktree/brave-river"),
+ "worktree-brave-river"
+ );
+ assert_eq!(
+ branch_to_path_slug("issue/137 Worktree Spaces"),
+ "issue-137-worktree-spaces"
+ );
+ assert_eq!(branch_to_path_slug("///"), "worktree");
+ }
+
+ #[test]
+ fn expand_tilde_path_uses_home_when_available() {
+ let old_home = std::env::var_os("HOME");
+ std::env::set_var("HOME", "/home/me");
+ assert_eq!(
+ expand_tilde_path("~/.herdr/worktrees"),
+ PathBuf::from("/home/me/.herdr/worktrees")
+ );
+ assert_eq!(
+ expand_tilde_path("/tmp/worktrees"),
+ PathBuf::from("/tmp/worktrees")
+ );
+ if let Some(home) = old_home {
+ std::env::set_var("HOME", home);
+ } else {
+ std::env::remove_var("HOME");
+ }
+ }
+
+ #[test]
+ fn default_checkout_path_appends_repo_and_branch_slug() {
+ assert_eq!(
+ default_checkout_path(
+ Path::new("/home/me/.herdr/worktrees"),
+ "herdr",
+ "worktree/brave-river",
+ ),
+ PathBuf::from("/home/me/.herdr/worktrees/herdr/worktree-brave-river")
+ );
+ }
+
+ #[test]
+ fn worktree_remove_command_preserves_branch_by_not_deleting_it() {
+ let command = build_worktree_remove_command(
+ Path::new("/repo/herdr"),
+ Path::new("/w/herdr/issue-137"),
+ false,
+ );
+ assert_eq!(command.program, "git");
+ assert_eq!(
+ command.args,
+ vec![
+ "-C",
+ "/repo/herdr",
+ "worktree",
+ "remove",
+ "/w/herdr/issue-137"
+ ]
+ );
+ }
+
+ #[test]
+ fn forced_worktree_remove_command_uses_git_force_flag() {
+ let command = build_worktree_remove_command(
+ Path::new("/repo/herdr"),
+ Path::new("/w/herdr/issue-137"),
+ true,
+ );
+ assert_eq!(
+ command.args,
+ vec![
+ "-C",
+ "/repo/herdr",
+ "worktree",
+ "remove",
+ "--force",
+ "/w/herdr/issue-137"
+ ]
+ );
+ }
+
+ #[test]
+ fn dirty_remove_error_detection_matches_git_force_hint() {
+ assert!(is_dirty_worktree_remove_error(
+ "fatal: '/w/herdr' contains modified or untracked files, use --force to delete it"
+ ));
+ assert!(!is_dirty_worktree_remove_error(
+ "fatal: '/w/herdr' is a missing but already registered worktree"
+ ));
+ assert!(!is_dirty_worktree_remove_error(
+ "fatal: '/w/herdr' contains a locked worktree, use --force only if you know why"
+ ));
+ }
+
+ #[test]
+ fn worktree_add_command_creates_new_branch_from_base() {
+ let command = build_worktree_add_new_branch_command(
+ Path::new("/repo/herdr"),
+ Path::new("/w/herdr/worktree-brave-river"),
+ "worktree/brave-river",
+ "HEAD",
+ );
+ assert_eq!(command.program, "git");
+ assert_eq!(
+ command.args,
+ vec![
+ "-C",
+ "/repo/herdr",
+ "worktree",
+ "add",
+ "-b",
+ "worktree/brave-river",
+ "/w/herdr/worktree-brave-river",
+ "HEAD"
+ ]
+ );
+ }
+
+ #[test]
+ fn run_worktree_add_and_remove_create_and_delete_checkout() {
+ let repo = create_committed_repo("worktree-run-repo");
+ let checkout = unique_temp_path("worktree-run-checkout");
+ let branch = "worktree/test-create-remove";
+
+ let add = build_worktree_add_new_branch_command(&repo, &checkout, branch, "HEAD");
+ run_worktree_command(&add).unwrap();
+
+ assert!(checkout.join("README.md").exists());
+ let branch_name = std::process::Command::new("git")
+ .arg("-C")
+ .arg(&checkout)
+ .args(["branch", "--show-current"])
+ .output()
+ .unwrap();
+ assert!(branch_name.status.success());
+ assert_eq!(
+ String::from_utf8(branch_name.stdout).unwrap().trim(),
+ branch
+ );
+
+ let remove = build_worktree_remove_command(&repo, &checkout, false);
+ run_worktree_command(&remove).unwrap();
+ assert!(!checkout.exists());
+
+ let _ = std::fs::remove_dir_all(repo);
+ }
+}