fix: widen undo dialog and polish its summary line

The undo/redo confirmation dialog capped at 60 columns and wrapped
even a short insert on wide terminals, showed a lowercase "snapshot
taken", a raw ISO-8601 timestamp, and lowercase yes/no labels.

Grow the dialog to fit its longest line (bounded 34–100), capitalise
Snapshot/Yes/No, and render the snapshot timestamp in local time,
human-formatted (24 May 2026, 11:00) via a new chrono dependency
(clock feature only; English month names). Yes/No capitalisation also
applies to the rebuild-confirm dialog.
This commit is contained in:
claude@clouddev1
2026-05-29 22:07:32 +00:00
parent d20f765325
commit 5ea69dbc08
6 changed files with 235 additions and 19 deletions
+170 -14
View File
@@ -317,6 +317,52 @@ fn render_rebuild_confirm(summary: &str, theme: &Theme, frame: &mut Frame<'_>, a
frame.render_widget(paragraph, dialog_area);
}
/// Format a stored ISO-8601 UTC timestamp for display in a
/// confirmation dialog (issue #13): parse it, convert to the
/// machine's local timezone, and render a fixed human-friendly
/// form (`24 May 2026, 11:00`). Month names stay English — no
/// locale feature. Falls back to the raw input if it can't be
/// parsed; this is defensive only, since stored values are always
/// `utc_iso8601_now()` output.
fn format_snapshot_timestamp(iso: &str) -> String {
chrono::DateTime::parse_from_rfc3339(iso)
.map(|dt| format_local_datetime(dt.with_timezone(&chrono::Local)))
.unwrap_or_else(|_| iso.to_string())
}
/// Render a timezone-aware datetime in the fixed display form.
/// Split out from [`format_snapshot_timestamp`] so the format can
/// be unit-tested deterministically with a fixed offset (the
/// `Local` conversion itself is machine-dependent).
fn format_local_datetime<Tz>(dt: chrono::DateTime<Tz>) -> String
where
Tz: chrono::TimeZone,
Tz::Offset: std::fmt::Display,
{
dt.format("%-d %b %Y, %H:%M").to_string()
}
/// Preferred outer width (columns) for the undo/redo confirm
/// dialog (issue #13): wide enough to hold the longest content
/// line on a single row, clamped to sane bounds and the available
/// area so a short insert no longer wraps on roomy terminals.
fn undo_dialog_width(
content_widths: impl IntoIterator<Item = usize>,
area_width: u16,
) -> u16 {
/// Floor — comfortably fits the button row plus borders.
const MIN: u16 = 34;
/// Ceiling for outlier (ultra-wide) terminals.
const MAX: u16 = 100;
let widest = content_widths.into_iter().max().unwrap_or(0);
// +4: left/right border (2) + one padding column each side (2).
let preferred =
u16::try_from(widest).unwrap_or(u16::MAX).saturating_add(4);
let upper = area_width.min(MAX);
let lower = MIN.min(upper);
preferred.clamp(lower, upper)
}
/// `undo` / `redo` confirmation modal (ADR-0006 Amendment 1). Names
/// the command that will be undone / re-applied and when its
/// snapshot was taken, then prompts `Y` / `N`.
@@ -326,19 +372,48 @@ fn render_undo_confirm(
frame: &mut Frame<'_>,
area: Rect,
) {
let dialog_w = area.width.clamp(20, 60);
let inner_w = dialog_w.saturating_sub(4) as usize;
let intro = if m.is_redo {
crate::t!("modal.redo_confirm_command")
} else {
crate::t!("modal.undo_confirm_command")
};
let mut body_lines: Vec<String> = wrap_lines(&format!("{intro} {}", m.command), inner_w);
body_lines.extend(wrap_lines(
&crate::t!("modal.undo_confirm_when", timestamp = m.timestamp),
inner_w,
));
let title = if m.is_redo {
crate::t!("modal.redo_confirm_title")
} else {
crate::t!("modal.undo_confirm_title")
};
let intro_line = format!("{intro} {}", m.command);
// Local-time, human-formatted snapshot stamp (issue #13).
let when_display = format_snapshot_timestamp(&m.timestamp);
let when_line =
crate::t!("modal.undo_confirm_when", timestamp = when_display);
let prompt = crate::t!("modal.undo_confirm_prompt");
// Reconstruct the button row purely to measure its width — the
// styled spans are built below. Keep this in sync with them.
let buttons_measure = format!(
"[Y] {} [N] {} Esc {}",
crate::t!("shortcut.yes"),
crate::t!("shortcut.no"),
crate::t!("shortcut.cancel"),
);
// Grow the dialog to fit the longest content line on one row
// (issue #13). The title sits in the border, so it needs two
// extra columns for the surrounding spaces.
let dialog_w = undo_dialog_width(
[
title.chars().count() + 2,
intro_line.chars().count(),
when_line.chars().count(),
prompt.chars().count(),
buttons_measure.chars().count(),
],
area.width,
);
let inner_w = dialog_w.saturating_sub(4) as usize;
let mut body_lines: Vec<String> = wrap_lines(&intro_line, inner_w);
body_lines.extend(wrap_lines(&when_line, inner_w));
let body_height = body_lines.len() as u16;
// Title row + blank + body + blank + prompt + blank + keys + borders (2).
let dialog_h = body_height.saturating_add(7).min(area.height);
@@ -354,11 +429,6 @@ fn render_undo_confirm(
frame.render_widget(ratatui::widgets::Clear, dialog_area);
let title = if m.is_redo {
crate::t!("modal.redo_confirm_title")
} else {
crate::t!("modal.undo_confirm_title")
};
let title_style = Style::default().fg(theme.fg).add_modifier(Modifier::BOLD);
let block = Block::default()
.borders(Borders::ALL)
@@ -376,7 +446,7 @@ fn render_undo_confirm(
text_lines.push(Line::from(line));
}
text_lines.push(Line::from(""));
text_lines.push(Line::from(crate::t!("modal.undo_confirm_prompt")));
text_lines.push(Line::from(prompt));
text_lines.push(Line::from(""));
text_lines.push(Line::from(vec![
Span::styled("[Y]", Style::default().fg(theme.fg).add_modifier(Modifier::BOLD)),
@@ -1399,6 +1469,92 @@ mod tests {
insta::assert_snapshot!("rebuild_confirm_modal_dark", snapshot);
}
// ---- Issue #13: undo confirm dialog -------------------------
#[test]
fn format_local_datetime_renders_fixed_human_form() {
// Deterministic: a fixed offset (not Local) so the output
// does not depend on the test machine's timezone.
let dt = chrono::DateTime::parse_from_rfc3339("2026-05-24T11:05:00+02:00")
.expect("valid rfc3339");
assert_eq!(format_local_datetime(dt), "24 May 2026, 11:05");
// Single-digit day: no leading zero on the day, but zero-
// padded hour.
let dt = chrono::DateTime::parse_from_rfc3339("2026-05-04T09:05:00+00:00")
.expect("valid rfc3339");
assert_eq!(format_local_datetime(dt), "4 May 2026, 09:05");
}
#[test]
fn format_snapshot_timestamp_drops_machine_syntax() {
// The stored UTC string is reformatted: no 'T'/'Z' machine
// syntax survives, and the year is preserved. (Day/month
// can shift across the date line depending on local TZ, so
// we assert only the stable parts.)
let out = format_snapshot_timestamp("2026-07-24T10:00:00Z");
assert!(!out.contains('T'), "no date/time 'T' separator: {out}");
assert!(!out.contains('Z'), "no UTC 'Z' suffix: {out}");
assert!(out.contains("2026"), "year preserved: {out}");
}
#[test]
fn format_snapshot_timestamp_falls_back_on_garbage() {
assert_eq!(format_snapshot_timestamp("not a timestamp"), "not a timestamp");
}
#[test]
fn undo_dialog_width_grows_to_fit_and_clamps() {
// Grows to the widest line + 4 (borders + padding).
assert_eq!(undo_dialog_width([50usize], 120), 54);
// Floors at MIN (34) for tiny content.
assert_eq!(undo_dialog_width([3usize], 120), 34);
// Caps at MAX (100) for absurdly long content.
assert_eq!(undo_dialog_width([400usize], 120), 100);
// Never exceeds the available area, and never panics when
// the area is narrower than MIN.
assert_eq!(undo_dialog_width([50usize], 40), 40);
assert_eq!(undo_dialog_width([50usize], 10), 10);
}
#[test]
fn undo_modal_command_does_not_wrap_on_wide_terminal() {
use crate::app::{Modal, UndoConfirmModal};
let mut app = App::new();
app.modal = Some(Modal::UndoConfirm(UndoConfirmModal {
command: "insert into Customers values (1, 'Oliver Sturm')".to_string(),
timestamp: "2026-05-24T10:00:00Z".to_string(),
is_redo: false,
}));
let theme = Theme::dark();
let out = render_to_string(&mut app, &theme, 120, 30);
assert!(
out.lines().any(|l| l.contains(
"This will undo: insert into Customers values (1, 'Oliver Sturm')"
)),
"command must sit on one row on a wide terminal:\n{out}"
);
}
#[test]
fn undo_modal_uses_capitalized_labels_and_formatted_time() {
use crate::app::{Modal, UndoConfirmModal};
let mut app = App::new();
app.modal = Some(Modal::UndoConfirm(UndoConfirmModal {
command: "delete from T where id = 1".to_string(),
timestamp: "2026-05-24T10:00:00Z".to_string(),
is_redo: false,
}));
let theme = Theme::dark();
let out = render_to_string(&mut app, &theme, 120, 30);
assert!(out.contains("Snapshot taken"), "capitalized Snapshot:\n{out}");
assert!(out.contains("[Y] Yes"), "capitalized Yes:\n{out}");
assert!(out.contains("[N] No"), "capitalized No:\n{out}");
assert!(
!out.contains("2026-05-24T10:00:00Z"),
"raw ISO timestamp must not appear:\n{out}"
);
}
#[test]
fn populated_with_table_snapshot() {
// Items panel lists tables; output panel shows the