aboutsummaryrefslogtreecommitdiff
path: root/src/ui/dialogs.rs
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/dialogs.rs
parenta9ff4012bdca0dbdbc77bc6e53a430689fc899be (diff)
feat: add git worktree workspace management
refs #137
Diffstat (limited to 'src/ui/dialogs.rs')
-rw-r--r--src/ui/dialogs.rs497
1 files changed, 490 insertions, 7 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");
+ }
+}