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/dialogs.rs | |
| parent | a9ff4012bdca0dbdbc77bc6e53a430689fc899be (diff) | |
feat: add git worktree workspace management
refs #137
Diffstat (limited to 'src/ui/dialogs.rs')
| -rw-r--r-- | src/ui/dialogs.rs | 497 |
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"); + } +} |
