aboutsummaryrefslogtreecommitdiff
path: root/src/ui
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-05-22 02:21:12 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-05-22 02:23:35 +0300
commit0148c1379b76c5c0a9d05588c07dcb06ec32f93f (patch)
tree554766d959c738ca2aa433c3d4b4a7de25568008 /src/ui
parenta9ff4012bdca0dbdbc77bc6e53a430689fc899be (diff)
feat: add git worktree workspace management
refs #137
Diffstat (limited to 'src/ui')
-rw-r--r--src/ui/dialogs.rs497
-rw-r--r--src/ui/keybind_help.rs6
-rw-r--r--src/ui/sidebar.rs691
3 files changed, 1141 insertions, 53 deletions
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,
+ },
+ ]
+ );
+ }
}