aboutsummaryrefslogtreecommitdiff
path: root/src/ui/dialogs.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/ui/dialogs.rs')
-rw-r--r--src/ui/dialogs.rs1158
1 files changed, 0 insertions, 1158 deletions
diff --git a/src/ui/dialogs.rs b/src/ui/dialogs.rs
deleted file mode 100644
index ff6ced3d..00000000
--- a/src/ui/dialogs.rs
+++ /dev/null
@@ -1,1158 +0,0 @@
-use ratatui::{
- layout::{Constraint, Layout, Rect},
- style::{Modifier, Style},
- text::{Line, Span},
- widgets::{Clear, Paragraph, Wrap},
- Frame,
-};
-
-use super::text::{display_width_u16, truncate_end};
-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};
-use crate::terminal::TerminalRuntimeRegistry;
-
-const NEW_LINKED_WORKTREE_POPUP_WIDTH: u16 = 68;
-const NEW_LINKED_WORKTREE_POPUP_HEIGHT: u16 = 12;
-
-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])
-}
-
-/// Draws the shared `name_input` field and puts the host cursor on its caret.
-///
-/// IMEs draw their composition preview at the host terminal cursor. Without an
-/// explicit cursor the frame carries none, the client keeps the position last
-/// reported by the focused pane, and composition lands behind the dialog.
-fn render_name_input_field(app: &AppState, frame: &mut Frame, input_rect: Rect) {
- frame.render_widget(Clear, input_rect);
-
- // The text stops one column short of the field so the clamped caret always
- // lands on a blank cell: a host terminal inverts the cell under its cursor,
- // and an IME composes there.
- let text_rect = Rect {
- width: input_rect.width.saturating_sub(1),
- ..input_rect
- };
- frame.render_widget(
- Paragraph::new(format!(" {}", app.name_input)).style(
- Style::default()
- .fg(app.palette.text)
- .bg(app.palette.surface0),
- ),
- text_rect,
- );
-
- if input_rect.width == 0 {
- return;
- }
- let caret_x = input_rect
- .x
- .saturating_add(1)
- .saturating_add(display_width_u16(&app.name_input))
- .min(input_rect.right().saturating_sub(1));
- frame.set_cursor_position((caret_x, input_rect.y));
-}
-
-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 if app.pending_workspace_create_cwd.is_some() => "new workspace",
- 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);
- render_name_input_field(app, frame, 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<Rect> {
- centered_popup_rect(
- area,
- NEW_LINKED_WORKTREE_POPUP_WIDTH,
- NEW_LINKED_WORKTREE_POPUP_HEIGHT,
- )
- .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_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,
- NEW_LINKED_WORKTREE_POPUP_WIDTH,
- NEW_LINKED_WORKTREE_POPUP_HEIGHT,
- &app.palette,
- ) else {
- return;
- };
- if inner.height < 9 {
- return;
- }
-
- let rows = Layout::vertical([
- Constraint::Length(1),
- Constraint::Length(1),
- Constraint::Length(1),
- Constraint::Length(1),
- Constraint::Length(1),
- Constraint::Length(3),
- 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);
- render_name_input_field(app, frame, 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))
- .wrap(Wrap { trim: false }),
- 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(display_width_u16(status))
- .saturating_sub(4) as usize;
- let mut title = format!(
- "{marker} {}",
- truncate_end(&entry.display_name(), title_width)
- );
- if !status.is_empty() {
- let pad = inner
- .width
- .saturating_sub(display_width_u16(&title))
- .saturating_sub(display_width_u16(status))
- .max(1);
- title.push_str(&" ".repeat(pad as usize));
- title.push_str(status);
- }
- frame.render_widget(
- Paragraph::new(truncate_end(&title, inner.width as usize)).style(row_style),
- Rect::new(inner.x, y, inner.width, 1),
- );
- frame.render_widget(
- Paragraph::new(truncate_end(
- &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,
- terminal_runtimes: &TerminalRuntimeRegistry,
-) -> (String, String) {
- let ws_name = app
- .workspaces
- .get(app.selected)
- .map(|ws| ws.display_name_from(&app.terminals, terminal_runtimes))
- .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::<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,
- terminal_runtimes: &TerminalRuntimeRegistry,
- frame: &mut Frame,
- area: Rect,
-) {
- let (title, detail) = confirm_close_overlay_text(app, terminal_runtimes);
-
- 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<Rect> {
- 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::{state::WorktreeCreateState, AppState, Mode},
- workspace::Workspace,
- };
- use ratatui::{
- backend::TestBackend,
- buffer::Buffer,
- layout::{Position, Rect},
- Terminal,
- };
-
- use super::{
- confirm_close_overlay_text, render_new_linked_worktree_overlay, render_rename_overlay,
- };
-
- #[test]
- fn confirm_close_text_uses_live_workspace_cwd_label() {
- let mut app = AppState::test_new();
- let mut workspace = Workspace::test_new("initial");
- workspace.custom_name = None;
- workspace.identity_cwd = "/projects/original".into();
- let root_pane = workspace.tabs[0].root_pane;
- let terminal_id = workspace.tabs[0].panes[&root_pane]
- .attached_terminal_id
- .clone();
- app.workspaces = vec![workspace];
- app.ensure_test_terminals();
- app.terminals.get_mut(&terminal_id).unwrap().cwd = "/projects/current".into();
- app.selected = 0;
-
- let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new();
- let (title, detail) = confirm_close_overlay_text(&app, &terminal_runtimes);
-
- assert_eq!(title, "Close workspace?");
- assert_eq!(detail, "current — 1 pane");
- }
-
- #[cfg(unix)]
- #[tokio::test]
- async fn confirm_close_text_prefers_live_runtime_cwd_over_stale_terminal_cwd() {
- let root = std::env::temp_dir().join(format!(
- "herdr-confirm-close-runtime-cwd-{}",
- std::process::id()
- ));
- let stale_cwd = root.join("original");
- let live_cwd = root.join("current");
- std::fs::create_dir_all(&live_cwd).unwrap();
-
- let mut app = AppState::test_new();
- let mut workspace = Workspace::test_new("initial");
- workspace.custom_name = None;
- workspace.identity_cwd = stale_cwd.clone();
- let root_pane = workspace.tabs[0].root_pane;
- let terminal_id = workspace.tabs[0].panes[&root_pane]
- .attached_terminal_id
- .clone();
- app.workspaces = vec![workspace];
- app.ensure_test_terminals();
- app.selected = 0;
-
- let (events, _) = tokio::sync::mpsc::channel(4);
- let runtime = crate::terminal::TerminalRuntime::spawn(
- root_pane,
- 24,
- 80,
- live_cwd,
- 0,
- crate::terminal_theme::TerminalTheme::default(),
- None,
- crate::pane::PaneShellConfig::new("/bin/sh", crate::config::ShellModeConfig::NonLogin),
- &crate::pane::PaneLaunchEnv::default(),
- events,
- std::sync::Arc::new(tokio::sync::Notify::new()),
- std::sync::Arc::new(crate::render_signal::RenderSignal::new()),
- )
- .unwrap();
- let mut terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new();
- terminal_runtimes.insert(terminal_id, runtime);
-
- let (_, detail) = confirm_close_overlay_text(&app, &terminal_runtimes);
-
- assert_eq!(detail, "current — 1 pane");
-
- drop(terminal_runtimes);
- std::fs::remove_dir_all(root).unwrap();
- }
-
- #[test]
- fn confirm_close_text_uses_selected_custom_name_instead_of_active_workspace_cwd() {
- let mut app = AppState::test_new();
- let active = Workspace::test_new("active");
- let selected = Workspace::test_new("selected");
- let selected_root = selected.tabs[0].root_pane;
- let selected_terminal_id = selected.tabs[0].panes[&selected_root]
- .attached_terminal_id
- .clone();
- app.workspaces = vec![active, selected];
- app.ensure_test_terminals();
- app.terminals.get_mut(&selected_terminal_id).unwrap().cwd = "/projects/current".into();
- app.active = Some(0);
- app.selected = 1;
-
- let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new();
- let (_, detail) = confirm_close_overlay_text(&app, &terminal_runtimes);
-
- assert_eq!(detail, "selected — 1 pane");
- }
-
- #[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 terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new();
- let (title, detail) = confirm_close_overlay_text(&app, &terminal_runtimes);
-
- assert_eq!(title, "Close worktree group?");
- assert_eq!(detail, "main — 2 workspaces, 2 panes");
- }
-
- #[test]
- fn new_worktree_error_renders_fatal_stderr_line() {
- let mut app = AppState::test_new();
- app.name_input = "foo".into();
- app.worktree_create = Some(WorktreeCreateState {
- source_workspace_id: "source".into(),
- source_checkout_path: "/repo/herdr".into(),
- source_existing_membership: None,
- source_repo_root: "/repo/herdr".into(),
- repo_key: "repo-key".into(),
- repo_name: "herdr".into(),
- branch: "foo".into(),
- checkout_path: "/repo/.worktrees/herdr/foo".into(),
- error: Some(
- "Preparing worktree (new branch 'foo')\nfatal: a branch named 'foo' already exists"
- .into(),
- ),
- creating: false,
- });
-
- let mut terminal =
- Terminal::new(TestBackend::new(100, 30)).expect("test terminal should initialize");
- terminal
- .draw(|frame| render_new_linked_worktree_overlay(&app, frame, Rect::new(0, 0, 100, 30)))
- .expect("new worktree overlay should render");
- let rendered = terminal
- .backend()
- .buffer()
- .content()
- .iter()
- .map(|cell| cell.symbol())
- .collect::<String>();
-
- assert!(rendered.contains("fatal: a branch named 'foo' already exists"));
- }
-
- #[test]
- fn new_worktree_hit_test_geometry_matches_modal_size() {
- let area = Rect::new(0, 0, 100, 30);
- let inner = super::new_linked_worktree_inner_rect(area).unwrap();
- let (create, cancel) = super::new_linked_worktree_button_rects(inner);
-
- assert_eq!(inner.width, super::NEW_LINKED_WORKTREE_POPUP_WIDTH - 2);
- assert_eq!(inner.height, super::NEW_LINKED_WORKTREE_POPUP_HEIGHT - 2);
- assert_eq!(create.y, inner.y + inner.height - 1);
- assert_eq!(cancel.y, inner.y + inner.height - 1);
- }
-
- const RENAME_AREA: Rect = Rect {
- x: 0,
- y: 0,
- width: 80,
- height: 20,
- };
- const WORKTREE_AREA: Rect = Rect {
- x: 0,
- y: 0,
- width: 100,
- height: 30,
- };
-
- /// Reproduces the input row that `render_rename_overlay` lays out: the
- /// centred popup, the border inset, then the third row of the vertical
- /// split.
- fn rename_input_rect(area: Rect) -> Rect {
- let popup = super::centered_popup_rect(area, 56, 7).expect("popup fits");
- let inner = Rect::new(popup.x + 1, popup.y + 1, popup.width - 2, popup.height - 2);
- Rect::new(inner.x, inner.y + 2, inner.width, 1)
- }
-
- fn rename_overlay_caret_in(mode: Mode, name: &str) -> (Position, Buffer) {
- let mut app = AppState::test_new();
- app.mode = mode;
- app.name_input = name.into();
-
- let mut terminal = Terminal::new(TestBackend::new(RENAME_AREA.width, RENAME_AREA.height))
- .expect("test terminal");
- terminal
- .draw(|frame| render_rename_overlay(&app, frame, RENAME_AREA))
- .expect("rename overlay should render");
- let caret = terminal.get_cursor_position().expect("cursor position");
- (caret, terminal.backend().buffer().clone())
- }
-
- fn rename_overlay_caret(name: &str) -> Position {
- rename_overlay_caret_in(Mode::RenameWorkspace, name).0
- }
-
- fn worktree_overlay_caret(branch: &str) -> Position {
- let mut app = AppState::test_new();
- app.name_input = branch.into();
- app.worktree_create = Some(WorktreeCreateState {
- source_workspace_id: "source".into(),
- source_checkout_path: "/repo/herdr".into(),
- source_existing_membership: None,
- source_repo_root: "/repo/herdr".into(),
- repo_key: "repo-key".into(),
- repo_name: "herdr".into(),
- branch: branch.into(),
- checkout_path: "/repo/.worktrees/herdr/foo".into(),
- error: None,
- creating: false,
- });
-
- let mut terminal =
- Terminal::new(TestBackend::new(WORKTREE_AREA.width, WORKTREE_AREA.height))
- .expect("test terminal");
- terminal
- .draw(|frame| render_new_linked_worktree_overlay(&app, frame, WORKTREE_AREA))
- .expect("new worktree overlay should render");
- terminal.get_cursor_position().expect("cursor position")
- }
-
- #[test]
- fn rename_overlay_anchors_the_host_cursor_to_the_input_caret() {
- let input = rename_input_rect(RENAME_AREA);
-
- // Without an explicit cursor the frame carries none, the client parks the
- // host cursor where the focused pane last reported it, and the IME
- // composes there instead of in the dialog.
- assert_eq!(
- rename_overlay_caret(""),
- Position::new(input.x + 1, input.y),
- "empty input should put the caret past the one-column left padding"
- );
- assert_eq!(
- rename_overlay_caret("abcd"),
- Position::new(input.x + 5, input.y)
- );
-
- // The cell under the caret has to be blank: a host terminal draws its
- // cursor by inverting that cell, so a glyph there would swallow it.
- let (caret, buffer) = rename_overlay_caret_in(Mode::RenameWorkspace, "ab");
- assert_eq!(caret, Position::new(input.x + 3, input.y));
- assert_eq!(buffer[(caret.x, caret.y)].symbol(), " ");
- assert_eq!(buffer[(caret.x - 1, caret.y)].symbol(), "b");
- }
-
- #[test]
- fn rename_overlay_anchors_the_cursor_in_every_rename_mode() {
- let input = rename_input_rect(RENAME_AREA);
- let expected = Position::new(input.x + 3, input.y);
-
- for mode in [Mode::RenameWorkspace, Mode::RenameTab, Mode::RenamePane] {
- assert_eq!(
- rename_overlay_caret_in(mode, "ab").0,
- expected,
- "{mode:?} should anchor the caret like the other rename modes"
- );
- }
- }
-
- #[test]
- fn rename_overlay_caret_counts_wide_characters_as_two_columns() {
- let input = rename_input_rect(RENAME_AREA);
-
- // "あい" is two columns per character, so the caret sits two cells further
- // right than the two-column "ab".
- assert_eq!(
- rename_overlay_caret("あい"),
- Position::new(input.x + 5, input.y)
- );
- assert_eq!(
- rename_overlay_caret("aあ"),
- Position::new(input.x + 4, input.y)
- );
- }
-
- #[test]
- fn rename_overlay_caret_stays_inside_the_input_when_the_name_overflows() {
- let input = rename_input_rect(RENAME_AREA);
- let last_column = input.right() - 1;
-
- // The field is 54 columns wide. 51 characters is the last name whose
- // caret still lands strictly inside it; from 52 on the unclamped column
- // would leave the field and gets pinned to the final cell.
- assert_eq!(
- rename_overlay_caret(&"a".repeat(51)),
- Position::new(input.x + 52, input.y)
- );
- assert_eq!(
- rename_overlay_caret(&"a".repeat(53)),
- Position::new(last_column, input.y)
- );
- assert_eq!(
- rename_overlay_caret(&"a".repeat(200)),
- Position::new(last_column, input.y)
- );
-
- // The clamped cell has to stay blank as well, or the host cursor would
- // sit on a glyph and the IME would compose over it.
- let (caret, buffer) = rename_overlay_caret_in(Mode::RenameWorkspace, &"a".repeat(200));
- assert_eq!(caret, Position::new(last_column, input.y));
- assert_eq!(buffer[(caret.x, caret.y)].symbol(), " ");
- assert_eq!(buffer[(caret.x - 1, caret.y)].symbol(), "a");
- }
-
- #[test]
- fn rename_overlay_caret_reaches_the_frame_the_server_sends() {
- let input = rename_input_rect(RENAME_AREA);
- let mut app = AppState::test_new();
- app.mode = Mode::RenameWorkspace;
- app.name_input = "ab".into();
-
- // The widget tests above stop at the ratatui frame. This one goes through
- // the server's cursor resolution, which is where the bug lived: the frame
- // used to leave here with `cursor: None`.
- let (_, cursor) =
- crate::server::render_stream::render_virtual(&mut app, RENAME_AREA, false);
- let cursor = cursor.expect("the modal caret should survive cursor resolution");
-
- assert_eq!((cursor.x, cursor.y), (input.x + 3, input.y));
- assert!(cursor.visible);
- }
-
- #[test]
- fn new_worktree_overlay_anchors_the_host_cursor_to_the_input_caret() {
- let popup = super::new_linked_worktree_inner_rect(WORKTREE_AREA).expect("popup fits");
- let input = Rect::new(popup.x, popup.y + 2, popup.width, 1);
-
- assert_eq!(
- worktree_overlay_caret(""),
- Position::new(input.x + 1, input.y)
- );
- assert_eq!(
- worktree_overlay_caret("ab"),
- Position::new(input.x + 3, input.y)
- );
- assert_eq!(
- worktree_overlay_caret("あい"),
- Position::new(input.x + 5, input.y)
- );
- }
-}