feat(tui): add bounded restore snapshot listing

Harvested from PR #2513 by @cyq1017.

Co-authored-by: cyq1017 <61975706+cyq1017@users.noreply.github.com>
This commit is contained in:
Hunter B
2026-06-03 20:00:41 -07:00
parent 111a805eb8
commit 311eb4002b
4 changed files with 222 additions and 27 deletions
+13
View File
@@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- Added `/restore list [N]` so users can inspect more side-git rollback
snapshots with UTC timestamps before choosing a restore point. Plain
`/restore` now shows the 20 most recent snapshots, numeric restore targets can
reach beyond that default listing up to a bounded index, and list requests
above the visible cap fail explicitly instead of silently truncating.
### Community
Thanks to **@cyq1017** for the restore-listing implementation (#2513) and
**@wywsoor** for the broader macOS/iTerm rollback UX report (#2494).
## [0.8.53] - 2026-06-03
### Added
+1 -1
View File
@@ -495,7 +495,7 @@ pub const COMMANDS: &[CommandInfo] = &[
CommandInfo {
name: "restore",
aliases: &[],
usage: "/restore [N]",
usage: "/restore [N|list [N]]",
description_id: MessageId::CmdRestoreDescription,
},
// RLM command
+202 -21
View File
@@ -1,19 +1,23 @@
//! `/restore` slash command — roll back the workspace to a prior snapshot.
//!
//! `/restore` (no arg) lists the most recent snapshots so the user can
//! see what's available. `/restore <N>` restores the *N*th-most-recent
//! snapshot, where `N=1` is the newest. In non-YOLO mode we refuse to
//! mutate files unless the user has explicitly trusted the workspace
//! (`/trust on` or YOLO) — the user can always view the list, just not
//! one-shot revert without a safety net.
//! `/restore` (no arg) lists the 20 most recent snapshots so the user can
//! see what's available. `/restore list [N]` lists more snapshots, capped
//! at 100. `/restore <N>` restores the *N*th-most-recent snapshot, where
//! `N=1` is the newest. In non-YOLO mode we refuse to mutate files unless
//! the user has explicitly trusted the workspace (`/trust on` or YOLO) —
//! the user can always view the list, just not one-shot revert without a
//! safety net.
use super::CommandResult;
use crate::snapshot::SnapshotRepo;
use crate::snapshot::{Snapshot, SnapshotRepo};
use crate::tui::app::App;
use chrono::TimeZone;
const LIST_LIMIT: usize = 10;
const DEFAULT_LIST_LIMIT: usize = 20;
const MAX_LIST_LIMIT: usize = 100;
const MAX_RESTORE_INDEX: usize = 1000;
/// Entry point for `/restore [N]`.
/// Entry point for `/restore [N|list [N]]`.
pub fn restore(app: &mut App, arg: Option<&str>) -> CommandResult {
let workspace = app.workspace.clone();
let repo = match SnapshotRepo::open_or_init(&workspace) {
@@ -26,29 +30,51 @@ pub fn restore(app: &mut App, arg: Option<&str>) -> CommandResult {
}
};
let snapshots = match repo.list(LIST_LIMIT) {
let Some(arg) = arg.map(str::trim).filter(|s| !s.is_empty()) else {
let snapshots = match repo.list(DEFAULT_LIST_LIMIT) {
Ok(s) => s,
Err(e) => return CommandResult::error(format!("Failed to list snapshots: {e}")),
};
if snapshots.is_empty() {
return CommandResult::message(
"No snapshots yet. Send a message to create the first pre-turn snapshot.",
);
return no_snapshots_message();
}
let Some(arg) = arg.map(str::trim).filter(|s| !s.is_empty()) else {
return CommandResult::message(format_listing(&snapshots));
};
if let Some(limit) = match parse_list_arg(arg) {
Ok(limit) => limit,
Err(message) => return CommandResult::error(message),
} {
let snapshots = match repo.list(limit) {
Ok(s) => s,
Err(e) => return CommandResult::error(format!("Failed to list snapshots: {e}")),
};
if snapshots.is_empty() {
return no_snapshots_message();
}
return CommandResult::message(format_listing(&snapshots));
}
let n: usize = match arg.parse() {
Ok(n) if n >= 1 => n,
Ok(n) if (1..=MAX_RESTORE_INDEX).contains(&n) => n,
Ok(n) if n > MAX_RESTORE_INDEX => {
return CommandResult::error(format!(
"Restore index must be <= {MAX_RESTORE_INDEX}; got {n}. Use /restore list [N] to inspect snapshots first.",
));
}
_ => {
return CommandResult::error(format!(
"Usage: /restore <N> (N is 1-based; got '{arg}')",
"Usage: /restore <N> or /restore list [N] (N is 1-based; got '{arg}')",
));
}
};
let snapshots = match repo.list(n.max(DEFAULT_LIST_LIMIT)) {
Ok(s) => s,
Err(e) => return CommandResult::error(format!("Failed to list snapshots: {e}")),
};
if snapshots.is_empty() {
return no_snapshots_message();
}
if n > snapshots.len() {
return CommandResult::error(format!(
@@ -81,12 +107,49 @@ pub fn restore(app: &mut App, arg: Option<&str>) -> CommandResult {
))
}
fn format_listing(snapshots: &[crate::snapshot::Snapshot]) -> String {
let mut out = String::from("Recent snapshots (newest first; pass /restore <N> to revert):\n");
fn parse_list_arg(arg: &str) -> Result<Option<usize>, String> {
let mut parts = arg.split_whitespace();
let action = match parts.next() {
Some(action) => action,
None => return Ok(None),
};
if action != "list" {
return Ok(None);
}
let Some(value) = parts.next() else {
return Ok(Some(DEFAULT_LIST_LIMIT));
};
if parts.next().is_some() {
return Err(format!(
"Usage: /restore list [N] (got extra arguments in '{arg}')",
));
}
match value.parse::<usize>() {
Ok(limit @ 1..=MAX_LIST_LIMIT) => Ok(Some(limit)),
Ok(limit) if limit > MAX_LIST_LIMIT => Err(format!(
"Restore list limit must be <= {MAX_LIST_LIMIT}; got {limit}.",
)),
_ => Err(format!(
"Usage: /restore list [N] (N must be >= 1; got '{value}')",
)),
}
}
fn no_snapshots_message() -> CommandResult {
CommandResult::message(
"No snapshots yet. Send a message to create the first pre-turn snapshot.",
)
}
fn format_listing(snapshots: &[Snapshot]) -> String {
let mut out = String::from(
"Recent snapshots (newest first; pass /restore <N> to revert; /restore list 50 shows more):\n",
);
for (i, s) in snapshots.iter().enumerate() {
out.push_str(&format!(
" #{:<2} {} {}\n",
" #{:<2} {} {} {}\n",
i + 1,
format_snapshot_time(s.timestamp),
short_sha(s.id.as_str()),
s.label,
));
@@ -94,6 +157,13 @@ fn format_listing(snapshots: &[crate::snapshot::Snapshot]) -> String {
out
}
fn format_snapshot_time(timestamp: i64) -> String {
match chrono::Utc.timestamp_opt(timestamp, 0).single() {
Some(dt) => dt.format("%Y-%m-%d %H:%M UTC").to_string(),
None => "unknown time".to_string(),
}
}
fn short_sha(sha: &str) -> &str {
&sha[..sha.len().min(8)]
}
@@ -195,6 +265,117 @@ mod tests {
assert!(msg.contains("#2"));
}
#[test]
fn restore_lists_more_than_ten_snapshots_by_default() {
let tmp = TempDir::new().unwrap();
let _home = scoped_home(&tmp);
let mut app = make_app(&tmp, true);
let repo = SnapshotRepo::open_or_init(&app.workspace).unwrap();
for i in 0..12 {
std::fs::write(app.workspace.join("a.txt"), format!("v{i}")).unwrap();
repo.snapshot(&format!("turn:{i}")).unwrap();
}
let result = restore(&mut app, None);
let msg = result.message.expect("expected message");
assert!(msg.contains("#12"), "{msg}");
assert!(msg.contains("turn:0"), "{msg}");
}
#[test]
fn restore_listing_includes_snapshot_utc_time() {
let snapshots = [Snapshot {
id: crate::snapshot::SnapshotId("abcdef123456".to_string()),
label: "turn:demo".to_string(),
timestamp: 1_700_000_000,
}];
let msg = format_listing(&snapshots);
assert!(msg.contains("2023-11-14 22:13 UTC"), "{msg}");
assert!(msg.contains("abcdef12"), "{msg}");
assert!(msg.contains("turn:demo"), "{msg}");
}
#[test]
fn restore_list_subcommand_accepts_explicit_limit() {
let tmp = TempDir::new().unwrap();
let _home = scoped_home(&tmp);
let mut app = make_app(&tmp, true);
let repo = SnapshotRepo::open_or_init(&app.workspace).unwrap();
for i in 0..15 {
std::fs::write(app.workspace.join("a.txt"), format!("v{i}")).unwrap();
repo.snapshot(&format!("turn:{i}")).unwrap();
}
let result = restore(&mut app, Some("list 12"));
let msg = result.message.expect("expected message");
assert!(msg.contains("#12"), "{msg}");
assert!(!msg.contains("#13"), "{msg}");
}
#[test]
fn restore_list_subcommand_rejects_invalid_limit() {
let tmp = TempDir::new().unwrap();
let _home = scoped_home(&tmp);
let mut app = make_app(&tmp, true);
let result = restore(&mut app, Some("list nope"));
assert!(result.is_error);
assert!(result.message.unwrap().contains("Usage: /restore list [N]"));
}
#[test]
fn restore_list_subcommand_rejects_limit_above_cap() {
let tmp = TempDir::new().unwrap();
let _home = scoped_home(&tmp);
let mut app = make_app(&tmp, true);
let result = restore(&mut app, Some("list 101"));
assert!(result.is_error);
assert!(
result
.message
.unwrap()
.contains("Restore list limit must be <= 100")
);
}
#[test]
fn restore_numeric_index_can_target_beyond_default_listing() {
let tmp = TempDir::new().unwrap();
let _home = scoped_home(&tmp);
let mut app = make_app(&tmp, true);
let repo = SnapshotRepo::open_or_init(&app.workspace).unwrap();
let f = app.workspace.join("a.txt");
for i in 0..12 {
std::fs::write(&f, format!("v{i}")).unwrap();
repo.snapshot(&format!("turn:{i}")).unwrap();
}
std::fs::write(&f, "changed").unwrap();
let result = restore(&mut app, Some("12"));
assert!(result.message.unwrap().contains("Restored"));
assert_eq!(std::fs::read_to_string(&f).unwrap(), "v0");
}
#[test]
fn restore_numeric_index_rejects_unbounded_query() {
let tmp = TempDir::new().unwrap();
let _home = scoped_home(&tmp);
let mut app = make_app(&tmp, true);
let result = restore(&mut app, Some("1001"));
assert!(result.is_error);
assert!(
result
.message
.unwrap()
.contains("Restore index must be <= 1000")
);
}
#[test]
fn restore_in_yolo_reverts_workspace() {
let tmp = TempDir::new().unwrap();
+2 -1
View File
@@ -137,7 +137,8 @@ DeepSeek-TUI has three related but intentionally separate recovery paths:
- Esc-Esc backtrack rewinds the live transcript to a previous user prompt and
restores that prompt into the composer for editing.
- `/restore` and the `revert_turn` tool restore workspace files from side-git
snapshots. They do not rewrite conversation history.
snapshots. `/restore list [N]` lists more snapshot options before choosing a
rollback point. They do not rewrite conversation history.
A Pi-style in-file tree browser is a larger UI/data-model project. v0.8.40
ships the bounded fork/backtrack primitives and explicit lineage metadata.