aboutsummaryrefslogtreecommitdiff
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
parenta9ff4012bdca0dbdbc77bc6e53a430689fc899be (diff)
feat: add git worktree workspace management
refs #137
-rw-r--r--docs/next/CHANGELOG.md1
-rw-r--r--docs/next/README.md1
-rw-r--r--docs/next/website/src/content/docs/configuration.mdx20
-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
33 files changed, 4635 insertions, 125 deletions
diff --git a/docs/next/CHANGELOG.md b/docs/next/CHANGELOG.md
index fad2404c..1385fdb0 100644
--- a/docs/next/CHANGELOG.md
+++ b/docs/next/CHANGELOG.md
@@ -6,6 +6,7 @@
- Added `ui.mouse_scroll_lines` to configure how many pane scrollback lines each mouse wheel notch scrolls. The default remains 3. (#236)
- Added `--remote-keybindings local|server` for `herdr --remote`. Remote attach now uses the launching client's local keybindings by default without copying config files to the remote host; use `--remote-keybindings server` to keep the remote server's keybindings. The client/server protocol is now version 9.
- Added `experimental.reveal_hidden_cursor_for_cjk_ime = false` (opt-in), `experimental.cjk_ime_agents = []` (optional allow-list), and `experimental.cjk_ime_cursor_shape = "steady_block"` to expose the focused pane's cursor anchor to the outer terminal even when the pane requested `?25l`, restoring macOS IME candidate-window tracking for TUIs that paint their own cursor (Claude Code, pi, codex). When `cjk_ime_agents` is non-empty, the reveal applies only to focused panes whose detected agent matches one of the listed names. When the pane reports no cursor position, the anchor falls back to the pane's top-left so a stable IME hint is always available. Trade-off when enabled: an extra hardware cursor may appear in the outer terminal for apps that hide the cursor without painting a replacement. (#149, thanks @ChihGodlee)
+- Added explicit sidebar Git worktree groups plus native worktree creation, existing checkout open, and safe checkout cleanup flows, configured by `[worktrees].directory`, `keys.new_worktree`, optional `keys.open_worktree`, and optional `keys.remove_worktree`.
### Fixed
- Antigravity CLI (`agy`) sessions are now detected, and their terminal UI now reports working and blocked states in the Agents panel. (#207)
diff --git a/docs/next/README.md b/docs/next/README.md
index 53a2a973..2b4683c0 100644
--- a/docs/next/README.md
+++ b/docs/next/README.md
@@ -212,6 +212,7 @@ press `ctrl+b` to enter prefix mode. default actions are prefix-first and tmux-l
| `prefix+1..9` | switch tab |
| `prefix+w` | workspace navigation |
| `prefix+shift+n` | new workspace |
+| `prefix+shift+g` | new worktree |
| `prefix+shift+w` | rename workspace |
| `prefix+shift+d` | close workspace |
| `prefix+h/j/k/l` | focus pane |
diff --git a/docs/next/website/src/content/docs/configuration.mdx b/docs/next/website/src/content/docs/configuration.mdx
index 9f19d4fd..72dc610c 100644
--- a/docs/next/website/src/content/docs/configuration.mdx
+++ b/docs/next/website/src/content/docs/configuration.mdx
@@ -56,6 +56,23 @@ default_shell = "nu"
When unset or empty, Herdr uses `$SHELL`, then `/bin/sh`. This is an executable name or path, not a shell command line. Existing panes keep their current shell until they are recreated. Command panes still run through `/bin/sh -c`; detached custom command keybindings use Herdr's existing `/bin/sh -lc` path.
+## Worktrees
+
+Set the root directory Herdr uses for Git worktree checkouts created from the sidebar:
+
+```toml
+[worktrees]
+directory = "~/.herdr/worktrees"
+```
+
+Herdr creates checkouts under `<directory>/<repo>/<branch-slug>`. For sibling-style checkouts, set this to a directory such as `~/Projects/herdr-worktrees`.
+
+Worktree actions are available from Git workspace rows. `New worktree` creates a branch and checkout, opens it as a new Herdr workspace, and groups it under the source workspace. `Open worktree...` lists existing Git worktree checkouts for that repo; choosing an already-open checkout focuses it, and choosing a closed checkout opens it in the same group.
+
+Grouped worktrees still behave like normal Herdr workspaces: they can be focused, renamed, closed, and contain their own tabs and panes. The parent row is the original workspace. Closing the parent row closes the whole Herdr group, but it does not delete checkout folders or branches.
+
+Deleting a worktree checkout is explicit. Use `Delete worktree checkout...` on a grouped child workspace to run `git worktree remove`. Herdr first asks Git to remove safely. If Git refuses because the checkout has modified or untracked files, Herdr asks again before running the forced remove. Branches are not deleted.
+
## Keybindings
Herdr has a prefix mode similar to tmux. The default prefix is `ctrl+b`. Keybinding strings are explicit: `prefix+n` means press the configured prefix and then `n`; `ctrl+alt+n` is a direct terminal-mode shortcut.
@@ -79,6 +96,7 @@ The default keymap is prefix-first and avoids direct shortcuts that can steal in
detach = "prefix+q"
workspace_picker = "prefix+w"
new_workspace = "prefix+shift+n"
+new_worktree = "prefix+shift+g"
rename_workspace = "prefix+shift+w"
close_workspace = "prefix+shift+d"
new_tab = "prefix+c"
@@ -105,6 +123,8 @@ Optional actions are unset by default. Bind them with `prefix+` for prefix-mode
[keys]
previous_workspace = "prefix+shift+left"
next_workspace = "prefix+shift+right"
+open_worktree = "prefix+shift+o"
+remove_worktree = "prefix+alt+d"
next_tab = ["prefix+n", "ctrl+alt+]"]
```
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);
+ }
+}