Sweep every first-party crate source (1956 .rs files) to the project comment guidelines: delete redundant restatements, decorative banners, change narration, and end-of-line comments; keep and tighten the crucial ones (invariants, bug rationale, SAFETY blocks, ported-source attribution). No functional code changed. Every edit is proven comment-only against the prior tree by a comment-stripping lexer (string/char/raw-string aware) plus a separate doctest-fence check. Where removing a comment made rustfmt or clippy want to re-lay-out adjacent code, the minimal triggering comment is restored so code tokens stay byte-identical. Gates green: cargo fmt --all --check (0 diffs), cargo check and cargo clippy --workspace --all-targets (0 warnings). Adds scripts/check_codegen_comment_guidelines.py — the enforcement gate for these guidelines (flags banners, end-of-line comments, change narration, and commented-out code).
329 lines
12 KiB
Rust
329 lines
12 KiB
Rust
//! Popup dialog for creating a new worktree with an optional label.
|
|
|
|
use ratatui::buffer::Buffer;
|
|
use ratatui::layout::{Constraint, Flex, Layout, Rect};
|
|
use ratatui::style::{Modifier, Style};
|
|
use ratatui::text::{Line, Span};
|
|
use ratatui::widgets::Widget;
|
|
use unicode_segmentation::UnicodeSegmentation;
|
|
use unicode_width::UnicodeWidthStr;
|
|
|
|
use crate::app::app_view::NewWorktreeDialogState;
|
|
use crate::theme::Theme;
|
|
|
|
/// Minimum dialog width (fits title + empty input + hints comfortably).
|
|
const MIN_DIALOG_WIDTH: u16 = 50;
|
|
const DIALOG_HEIGHT: u16 = 5;
|
|
/// Left/right padding inside the border (`inner_x = dialog.x + 2`).
|
|
const INNER_PAD: u16 = 4;
|
|
const LABEL_PREFIX: &str = "Name (optional): ";
|
|
|
|
/// Render the new-worktree popup dialog centered on screen.
|
|
///
|
|
/// The dialog grows with the typed label (up to the available terminal
|
|
/// width) so long names stay fully visible. When the terminal itself is
|
|
/// too narrow for the full name, the input scrolls to keep the cursor
|
|
/// (end of the label) in view, with a leading `…` when scrolled.
|
|
pub fn render_new_worktree_dialog(area: Rect, buf: &mut Buffer, state: &NewWorktreeDialogState) {
|
|
let theme = Theme::current();
|
|
|
|
let dialog_width = dialog_width_for(area.width, &state.label_input);
|
|
|
|
if area.height < DIALOG_HEIGHT || area.width < 20 {
|
|
// Too small to render — draw a minimal "resize" hint so the user
|
|
// knows the dialog is still active and can press Esc to dismiss.
|
|
if area.height >= 1 && area.width >= 16 {
|
|
let hint = Line::from(Span::styled(
|
|
"[Esc] to close",
|
|
Style::default().fg(theme.gray_dim),
|
|
));
|
|
hint.render(Rect::new(area.x, area.y, area.width.min(16), 1), buf);
|
|
}
|
|
return;
|
|
}
|
|
|
|
let [_, dialog_h, _] = Layout::horizontal([
|
|
Constraint::Min(0),
|
|
Constraint::Length(dialog_width),
|
|
Constraint::Min(0),
|
|
])
|
|
.flex(Flex::Center)
|
|
.areas(area);
|
|
|
|
let [_, dialog, _] = Layout::vertical([
|
|
Constraint::Min(0),
|
|
Constraint::Length(DIALOG_HEIGHT),
|
|
Constraint::Min(0),
|
|
])
|
|
.flex(Flex::Center)
|
|
.areas(dialog_h);
|
|
|
|
let bg_style = Style::default().bg(theme.bg_dark);
|
|
for y in dialog.y..dialog.y + dialog.height {
|
|
for x in dialog.x..dialog.x + dialog.width {
|
|
if let Some(cell) = buf.cell_mut((x, y)) {
|
|
cell.set_char(' ');
|
|
cell.set_style(bg_style);
|
|
}
|
|
}
|
|
}
|
|
|
|
let border_style = Style::default().fg(theme.gray_dim).bg(theme.bg_dark);
|
|
if let Some(cell) = buf.cell_mut((dialog.x, dialog.y)) {
|
|
cell.set_char('\u{256D}');
|
|
cell.set_style(border_style);
|
|
}
|
|
for x in dialog.x + 1..dialog.x + dialog.width - 1 {
|
|
if let Some(cell) = buf.cell_mut((x, dialog.y)) {
|
|
cell.set_char('\u{2500}');
|
|
cell.set_style(border_style);
|
|
}
|
|
}
|
|
if let Some(cell) = buf.cell_mut((dialog.x + dialog.width - 1, dialog.y)) {
|
|
cell.set_char('\u{256E}');
|
|
cell.set_style(border_style);
|
|
}
|
|
let bottom = dialog.y + dialog.height - 1;
|
|
if let Some(cell) = buf.cell_mut((dialog.x, bottom)) {
|
|
cell.set_char('\u{2570}');
|
|
cell.set_style(border_style);
|
|
}
|
|
for x in dialog.x + 1..dialog.x + dialog.width - 1 {
|
|
if let Some(cell) = buf.cell_mut((x, bottom)) {
|
|
cell.set_char('\u{2500}');
|
|
cell.set_style(border_style);
|
|
}
|
|
}
|
|
if let Some(cell) = buf.cell_mut((dialog.x + dialog.width - 1, bottom)) {
|
|
cell.set_char('\u{256F}');
|
|
cell.set_style(border_style);
|
|
}
|
|
for y in dialog.y + 1..dialog.y + dialog.height - 1 {
|
|
if let Some(cell) = buf.cell_mut((dialog.x, y)) {
|
|
cell.set_char('\u{2502}');
|
|
cell.set_style(border_style);
|
|
}
|
|
if let Some(cell) = buf.cell_mut((dialog.x + dialog.width - 1, y)) {
|
|
cell.set_char('\u{2502}');
|
|
cell.set_style(border_style);
|
|
}
|
|
}
|
|
|
|
let inner_x = dialog.x + 2;
|
|
let inner_width = dialog.width.saturating_sub(INNER_PAD);
|
|
|
|
// Row 1: Title
|
|
let title = Line::from(Span::styled(
|
|
"New Worktree",
|
|
Style::default()
|
|
.fg(theme.text_primary)
|
|
.add_modifier(Modifier::BOLD),
|
|
));
|
|
title.render(Rect::new(inner_x, dialog.y + 1, inner_width, 1), buf);
|
|
|
|
// Row 2: Label input — grow with content; scroll when still too wide.
|
|
let prefix_w = LABEL_PREFIX.width() as u16;
|
|
let cursor_w = 1u16;
|
|
let input_budget = inner_width
|
|
.saturating_sub(prefix_w)
|
|
.saturating_sub(cursor_w) as usize;
|
|
let visible_input = visible_input_suffix(&state.label_input, input_budget);
|
|
|
|
let prefix_span = Span::styled(LABEL_PREFIX, Style::default().fg(theme.gray_bright));
|
|
let input_span = Span::styled(visible_input, Style::default().fg(theme.text_primary));
|
|
let cursor_span = Span::styled("\u{2588}", Style::default().fg(theme.accent_user));
|
|
let input_line = Line::from(vec![prefix_span, input_span, cursor_span]);
|
|
input_line.render(Rect::new(inner_x, dialog.y + 2, inner_width, 1), buf);
|
|
|
|
// Row 3: Hints
|
|
let hints = Line::from(vec![
|
|
Span::styled(
|
|
"enter",
|
|
Style::default()
|
|
.fg(theme.accent_user)
|
|
.add_modifier(Modifier::BOLD),
|
|
),
|
|
Span::styled(" = create ", Style::default().fg(theme.gray)),
|
|
Span::styled(
|
|
"esc",
|
|
Style::default()
|
|
.fg(theme.accent_user)
|
|
.add_modifier(Modifier::BOLD),
|
|
),
|
|
Span::styled(" = cancel", Style::default().fg(theme.gray)),
|
|
]);
|
|
hints.render(Rect::new(inner_x, dialog.y + 3, inner_width, 1), buf);
|
|
}
|
|
|
|
/// Dialog width that fits the typed label, clamped to the available area.
|
|
fn dialog_width_for(area_width: u16, label: &str) -> u16 {
|
|
let max_width = area_width.saturating_sub(4);
|
|
// prefix + label + block cursor + inner pad
|
|
let needed = (LABEL_PREFIX.width() + label.width() + 1 + INNER_PAD as usize) as u16;
|
|
needed.max(MIN_DIALOG_WIDTH).min(max_width)
|
|
}
|
|
|
|
/// Return the visible portion of `label` for an end-anchored input field.
|
|
///
|
|
/// When `label` fits in `budget` columns, returns it unchanged. Otherwise
|
|
/// returns a leading `…` plus the suffix that fits, so the cursor at the
|
|
/// end of the label stays visible while typing a long name.
|
|
///
|
|
/// Walks Unicode grapheme clusters (not scalar values) so combining marks
|
|
/// and ZWJ sequences are never split across the scroll boundary.
|
|
fn visible_input_suffix(label: &str, budget: usize) -> String {
|
|
if budget == 0 {
|
|
return String::new();
|
|
}
|
|
if label.width() <= budget {
|
|
return label.to_string();
|
|
}
|
|
if budget == 1 {
|
|
return "…".to_string();
|
|
}
|
|
|
|
// Reserve one column for the leading …
|
|
let suffix_budget = budget - 1;
|
|
let mut width = 0usize;
|
|
let mut start = label.len();
|
|
let graphemes: Vec<(usize, &str)> = label.grapheme_indices(true).collect();
|
|
for &(i, g) in graphemes.iter().rev() {
|
|
let cw = UnicodeWidthStr::width(g);
|
|
if width + cw > suffix_budget {
|
|
break;
|
|
}
|
|
width += cw;
|
|
start = i;
|
|
}
|
|
format!("…{}", &label[start..])
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use ratatui::buffer::Buffer;
|
|
use ratatui::layout::Rect;
|
|
|
|
fn render_to_text(area: Rect, label: &str) -> String {
|
|
let mut buf = Buffer::empty(area);
|
|
let state = NewWorktreeDialogState {
|
|
label_input: label.to_string(),
|
|
};
|
|
render_new_worktree_dialog(area, &mut buf, &state);
|
|
let mut lines = Vec::new();
|
|
for y in 0..area.height {
|
|
let mut row = String::new();
|
|
for x in 0..area.width {
|
|
row.push_str(buf[(x, y)].symbol());
|
|
}
|
|
lines.push(row);
|
|
}
|
|
lines.join("\n")
|
|
}
|
|
|
|
#[test]
|
|
fn empty_dialog_uses_minimum_width() {
|
|
assert_eq!(dialog_width_for(120, ""), MIN_DIALOG_WIDTH);
|
|
assert_eq!(dialog_width_for(40, ""), 36);
|
|
}
|
|
|
|
#[test]
|
|
fn dialog_grows_with_long_label() {
|
|
let label = "a-very-long-worktree-name-that-exceeds-fifty";
|
|
let width = dialog_width_for(120, label);
|
|
assert!(
|
|
width > MIN_DIALOG_WIDTH,
|
|
"expected dialog wider than min for long label, got {width}"
|
|
);
|
|
// Full label + chrome must fit inside the grown dialog.
|
|
let inner = width.saturating_sub(INNER_PAD) as usize;
|
|
let needed = LABEL_PREFIX.width() + label.width() + 1;
|
|
assert!(
|
|
needed <= inner,
|
|
"grown dialog inner={inner} should fit needed={needed}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn dialog_clamps_to_terminal_width() {
|
|
let label = "x".repeat(100);
|
|
let width = dialog_width_for(60, &label);
|
|
assert_eq!(width, 56);
|
|
}
|
|
|
|
#[test]
|
|
fn visible_suffix_keeps_end_when_scrolled() {
|
|
let label = "abcdefghijklmnopqrstuvwxyz0123456789";
|
|
let visible = visible_input_suffix(label, 10);
|
|
assert!(
|
|
visible.starts_with('…'),
|
|
"expected leading ellipsis: {visible}"
|
|
);
|
|
assert!(
|
|
visible.ends_with("0123456789") || visible.ends_with("123456789"),
|
|
"expected end of label visible: {visible}"
|
|
);
|
|
assert_eq!(visible.width(), 10);
|
|
}
|
|
|
|
#[test]
|
|
fn visible_suffix_unchanged_when_fits() {
|
|
assert_eq!(visible_input_suffix("short", 20), "short");
|
|
}
|
|
|
|
#[test]
|
|
fn visible_suffix_does_not_split_grapheme_clusters() {
|
|
// "e" + combining acute (U+0301) is one grapheme; pad so we must scroll.
|
|
let cluster = "e\u{0301}";
|
|
let label = format!("{}{}", "x".repeat(20), cluster);
|
|
let visible = visible_input_suffix(&label, 8);
|
|
assert!(
|
|
visible.starts_with('…'),
|
|
"expected leading ellipsis: {visible}"
|
|
);
|
|
// Either the full cluster is present, or it was dropped as a unit —
|
|
// never a lone combining mark after the ellipsis.
|
|
let after_ellipsis = &visible[visible.char_indices().nth(1).map(|(i, _)| i).unwrap_or(0)..];
|
|
assert!(
|
|
!after_ellipsis.starts_with('\u{0301}'),
|
|
"must not start scrolled suffix on a combining mark: {visible:?}"
|
|
);
|
|
if after_ellipsis.contains('e') {
|
|
assert!(
|
|
after_ellipsis.contains(cluster),
|
|
"base 'e' must keep its combining mark: {visible:?}"
|
|
);
|
|
}
|
|
assert!(visible.width() <= 8, "width overflow: {visible:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn long_name_fully_visible_on_wide_terminal() {
|
|
let area = Rect::new(0, 0, 100, 20);
|
|
let label = "biscuit-worktree-popup-long-name-fix";
|
|
let text = render_to_text(area, label);
|
|
assert!(
|
|
text.contains(label),
|
|
"full long name must be visible on a wide terminal:\n{text}"
|
|
);
|
|
assert!(text.contains("New Worktree"), "title missing:\n{text}");
|
|
}
|
|
|
|
#[test]
|
|
fn long_name_end_visible_on_narrow_terminal() {
|
|
// Terminal narrower than the full label — end (cursor side) must show.
|
|
let area = Rect::new(0, 0, 40, 12);
|
|
let label = "super-long-worktree-name-that-will-not-fit";
|
|
let text = render_to_text(area, label);
|
|
let tail = &label[label.len().saturating_sub(8)..];
|
|
assert!(
|
|
text.contains(tail),
|
|
"end of long name must remain visible when scrolled:\n{text}"
|
|
);
|
|
assert!(
|
|
text.contains('…') || text.contains(tail),
|
|
"expected scrolled indicator or tail:\n{text}"
|
|
);
|
|
}
|
|
}
|