use ratatui::{ layout::{Constraint, Layout, Rect}, style::{Modifier, Style}, text::{Line, Span}, widgets::{Clear, Paragraph}, Frame, }; use super::widgets::{ action_button_row_rects, centered_popup_rect, panel_contrast_fg, render_action_button, render_modal_header, render_modal_shell, render_panel_shell, ActionButtonSpec, }; 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::() ) } pub(crate) fn rename_button_rects(inner: Rect) -> (Rect, Rect, Rect) { let rects = action_button_row_rects( inner, &[ ActionButtonSpec { hint: Some("↵"), label: "save", }, ActionButtonSpec { hint: Some("^c"), label: "clear", }, ActionButtonSpec { hint: Some("esc"), label: "cancel", }, ], 2, 3, ); (rects[0], rects[1], rects[2]) } pub(super) fn render_rename_overlay(app: &AppState, frame: &mut Frame, area: Rect) { super::dim_background(frame, area); let title = match app.mode { Mode::RenameWorkspace => "rename workspace", Mode::RenameTab if app.creating_new_tab => "new tab", Mode::RenameTab => "rename tab", Mode::RenamePane => "rename pane", _ => return, }; let Some(inner) = render_modal_shell(frame, area, 56, 7, &app.palette) else { return; }; if inner.height < 4 { return; } let rows = Layout::vertical([ Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Min(0), ]) .areas::<5>(inner); render_modal_header(frame, rows[0], title, &app.palette); 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 (save_rect, clear_rect, cancel_rect) = rename_button_rects(inner); render_action_button( frame, save_rect, Some("↵"), "save", Style::default() .fg(panel_contrast_fg(&app.palette)) .bg(app.palette.accent) .add_modifier(Modifier::BOLD), ); render_action_button( frame, clear_rect, Some("^c"), "clear", Style::default() .fg(app.palette.text) .bg(app.palette.surface0) .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(crate) fn new_linked_worktree_inner_rect(area: Rect) -> Option { 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 { 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 { 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 selected_space = app .workspaces .get(app.selected) .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::>() }) .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); let Some(popup) = confirm_close_popup_rect(area) else { return; }; let warn = Style::default() .fg(app.palette.red) .add_modifier(Modifier::BOLD); let dim = Style::default().fg(app.palette.overlay0); let title_line = Line::from(vec![Span::styled(format!(" {title}"), warn)]); let detail_line = Line::from(vec![ Span::styled( format!(" {}", detail.split(" — ").next().unwrap_or(&detail)), Style::default() .fg(app.palette.text) .add_modifier(Modifier::BOLD), ), 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) else { return; }; if inner.height >= 3 { let rows = Layout::vertical([ Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), ]) .areas::<3>(inner); frame.render_widget(Paragraph::new(title_line), rows[0]); frame.render_widget(Paragraph::new(detail_line), rows[1]); let (confirm_rect, cancel_rect) = confirm_close_button_rects(inner); render_action_button( frame, confirm_rect, Some("↵"), "confirm", 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(crate) fn confirm_close_popup_rect(area: Rect) -> Option { centered_popup_rect(area, 44, 5) } pub(crate) fn confirm_close_button_rects(inner: Rect) -> (Rect, Rect) { let rects = action_button_row_rects( inner, &[ ActionButtonSpec { hint: Some("↵"), label: "confirm", }, ActionButtonSpec { hint: Some("esc"), label: "cancel", }, ], 2, 2, ); (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"); } }