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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user