diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-22 02:21:12 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-22 02:23:35 +0300 |
| commit | 0148c1379b76c5c0a9d05588c07dcb06ec32f93f (patch) | |
| tree | 554766d959c738ca2aa433c3d4b4a7de25568008 /src/ui | |
| parent | a9ff4012bdca0dbdbc77bc6e53a430689fc899be (diff) | |
feat: add git worktree workspace management
refs #137
Diffstat (limited to 'src/ui')
| -rw-r--r-- | src/ui/dialogs.rs | 497 | ||||
| -rw-r--r-- | src/ui/keybind_help.rs | 6 | ||||
| -rw-r--r-- | src/ui/sidebar.rs | 691 |
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, + }, + ] + ); + } } |
