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::{state::WorktreeOpenState, 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_mul(2) .saturating_add(7) .clamp(12, 26); centered_popup_rect(area, 96, 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_max_visible_rows(inner: Rect) -> usize { usize::from(inner.height.saturating_sub(5) / 2) } pub(crate) fn open_existing_worktree_visible_start( open: &WorktreeOpenState, max_rows: usize, ) -> usize { let filtered = open.filtered_indices(); let selected = open.selected_entry_index().unwrap_or(open.selected); let selected_pos = filtered .iter() .position(|idx| *idx == selected) .unwrap_or(0); selected_pos.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_mul(2) .saturating_add(7) .clamp(12, 26); let Some(inner) = render_modal_shell(frame, area, 96, height, &app.palette) else { return; }; if inner.height < 8 { return; } render_modal_header( frame, Rect::new(inner.x, inner.y, inner.width, 1), "open worktree", &app.palette, ); render_open_worktree_search( app, frame, Rect::new(inner.x, inner.y + 1, inner.width, 1), open, ); frame.render_widget( Paragraph::new("─".repeat(inner.width as usize)) .style(Style::default().fg(app.palette.surface1)), Rect::new(inner.x, inner.y.saturating_add(2), inner.width, 1), ); let filtered = open.filtered_indices(); let max_rows = open_existing_worktree_max_visible_rows(inner); let start = open_existing_worktree_visible_start(open, max_rows); for (visible_idx, entry_idx) in filtered.iter().skip(start).take(max_rows).enumerate() { let Some(entry) = open.entries.get(*entry_idx) else { continue; }; let selected = Some(*entry_idx) == open.selected_entry_index(); let y = inner.y.saturating_add(3 + (visible_idx as u16 * 2)); let marker = if selected { "›" } else { " " }; let row_style = if selected { Style::default() .fg(app.palette.text) .bg(app.palette.surface0) .add_modifier(Modifier::BOLD) } else { Style::default().fg(app.palette.subtext0) }; let path_style = if selected { Style::default() .fg(app.palette.subtext0) .bg(app.palette.surface0) } else { Style::default().fg(app.palette.overlay0) }; let status = entry.status_label(); let title_width = inner .width .saturating_sub(status.len() as u16) .saturating_sub(4) as usize; let mut title = format!( "{marker} {}", truncate_text(&entry.display_name(), title_width) ); if !status.is_empty() { let pad = inner .width .saturating_sub(title.chars().count() as u16) .saturating_sub(status.len() as u16) .max(1); title.push_str(&" ".repeat(pad as usize)); title.push_str(status); } frame.render_widget( Paragraph::new(truncate_text(&title, inner.width as usize)).style(row_style), Rect::new(inner.x, y, inner.width, 1), ); frame.render_widget( Paragraph::new(truncate_text( &format!(" {}", entry.path.display()), inner.width as usize, )) .style(path_style), Rect::new(inner.x, y.saturating_add(1), inner.width, 1), ); } if filtered.is_empty() { frame.render_widget( Paragraph::new(" no matching worktrees") .style(Style::default().fg(app.palette.overlay0)), Rect::new(inner.x, inner.y.saturating_add(3), 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 render_open_worktree_search( app: &AppState, frame: &mut Frame, area: Rect, open: &WorktreeOpenState, ) { let focus_style = if open.search_focused { Style::default() .fg(app.palette.accent) .add_modifier(Modifier::BOLD) } else { Style::default().fg(app.palette.overlay0) }; let filtered_count = open.filtered_indices().len(); let count = if open.query.trim().is_empty() { format!("{} checkouts", open.entries.len()) } else { format!("{filtered_count}/{} checkouts", open.entries.len()) }; let mut spans = vec![Span::styled(" / ", focus_style)]; if open.query.trim().is_empty() { spans.push(Span::styled( "filter worktrees", Style::default().fg(app.palette.overlay0), )); } else { spans.push(Span::styled( open.query.clone(), Style::default().fg(app.palette.text), )); } spans.push(Span::styled( format!( "{count:>width$}", width = area.width.saturating_sub(18) as usize ), Style::default().fg(app.palette.overlay0), )); frame.render_widget(Paragraph::new(Line::from(spans)), area); } 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), Constraint::Length(1), ]) .areas::<4>(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, 64, 6) } 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, 3, ); (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"); } }