From cec046f43f3985c51d45597413d84b4125d6acea Mon Sep 17 00:00:00 2001 From: Commanderx-code Date: Thu, 24 Sep 2026 11:18:09 -0400 Subject: [PATCH] Add backup file history Search every Restic snapshot for a file name, path or pattern from Backup & Restore. One reviewed, read-only job lists snapshots and runs `restic find`, wrapped into a single JSON document so versions carry their snapshot times. The pattern is a positional argument to a fixed script, rejected if empty, multi-line or starting with a hyphen. Results are grouped by path, newest first, with each copy marked first saved, changed (size or mtime differs) or same as previous, and a notice when newer snapshots of the same host and path lack the file. "Restore this version" prefills the existing never-overwrite restore. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FV3o3TZ5zZ2ACWD2S7EZdc --- CHANGELOG.md | 4 ++ docs/user-guide.md | 12 ++++- src-tauri/src/integrations.rs | 94 ++++++++++++++++++++++++++++++++++- src-tauri/src/jobs.rs | 2 +- src/feature-model.js | 75 ++++++++++++++++++++++++++++ src/features.js | 56 +++++++++++++++++++++ src/styles.css | 8 +++ tests/features.test.mjs | 43 ++++++++++++++++ tests/interface.test.mjs | 47 ++++++++++++++++++ 9 files changed, 338 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a3de423..aabe5f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ Release downloads and full notes are available on [GitHub Releases](https://github.com/Commanderx-code/command-center/releases). +## Unreleased + +- Added **Backup file history**: search every Restic snapshot for a file name, path or pattern, see each saved copy with its snapshot time, size and modification time, spot the versions that changed or a file that was deleted, and restore a chosen version into a new folder. + ## [0.6.0](https://github.com/Commanderx-code/command-center/releases/tag/v0.6.0) — 2026-09-24 - Command Center is now licensed under the MIT License. Packages include the license and third-party notices. diff --git a/docs/user-guide.md b/docs/user-guide.md index d1ab01a..02c8ce7 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -11,7 +11,7 @@ - **Launch profiles:** save a documentation URL and choose whether a project opens its editor, terminal, and documentation together. - **Activity:** preview each command before starting, stream output, stop background jobs, and inspect the last 100 results across app restarts. Operations that use different resources can run at the same time (see [Running several jobs](#running-several-jobs)). Background output is capped at 2 MB per job, and truncation is explicit. Failed jobs can be marked reviewed. - **System Sync:** inspect dotfiles changes, compare Ghostty/Fastfetch sources with their live files, view Home Manager generations, fetch/pull the config repository, build, and apply Home Manager. -- **Backup & Restore:** run your personal backup helper, run the full recovery helper in a terminal for encryption prompts, load recent Restic snapshots, browse directories, check repository metadata, and restore a snapshot or selected path/pattern into a new folder beneath your home directory. +- **Backup & Restore:** run your personal backup helper, run the full recovery helper in a terminal for encryption prompts, load recent Restic snapshots, browse directories, check repository metadata, and restore a snapshot or selected path/pattern into a new folder beneath your home directory, and search a file's history across all snapshots to restore a chosen version. - **Configuration:** Ghostty font, theme, padding, opacity, and cursor controls; Fastfetch logo/separator controls and module add/remove/reordering. Both include a source editor and illustrative preview. Ghostty uses its installed validator. Fastfetch validates JSONC syntax and module structure; it does not execute command modules or claim full runtime/schema validation. - **System Health:** filesystem usage, failed user/system services, battery information, cached Arch package updates, installed tools, and locally recorded backup freshness. - **Needs attention:** changed/unpushed/behind repositories, unreviewed failed jobs, overdue or unavailable backup records, disks at least 90% full, and failed services. @@ -112,6 +112,16 @@ Every run previews the command and working folder and uses the existing job life Backup & Restore shows the location status reported by your backup-health helper, the exact last successful backup time when available, and the next setup step. A missing report is **unknown**, not proof that a drive is disconnected. Remote Restic locations are labeled as not connection-tested. Personal/full backup buttons require an executable configured helper and are disabled while checking health or when the helper explicitly reports a configured local drive disconnected. Refresh after attaching a drive. Helpers retain their own checks and prompts. +## Backup file history + +**Backup & Restore → File history** finds every saved copy of a file across all snapshots in the configured Restic repository, so you can pick the right version to restore. Enter a file name (`notes.md` matches it in any folder), an exact path starting with `/` or `~/`, or a pattern with `*` and `?`. Select **Ignore case** if needed, then **Search backups**. + +The search is a reviewed, read-only job. It lists snapshots, then runs `restic find` over all of them, so it can take a while on large repositories and may ask KWallet to unlock. Nothing is restored. + +Results are grouped by path, newest first. Each saved copy shows the snapshot time, snapshot ID, host, file modification time and size, and whether it is the **first saved** copy, **changed** from the previous copy (different size or modification time), or the **same as previous**. **Show only versions that changed** hides identical copies. If newer snapshots of the same host and backup path no longer contain the file, the result says it may have been deleted or moved. + +**Restore this version** selects that snapshot and the file's exact path in the restore form. Choose a new destination folder and **Review restore**: restores never overwrite existing files. Searches that match a very large number of files cannot be read; search a more specific path instead. File names containing `*`, `?` or `[` are treated as patterns by Restic, so check the include path before restoring such a file. + ## Services and backup timers **Services** lists installed and loaded units, filters by name/description/state, and displays properties plus the latest 100 journal entries. User services support reviewed start, stop, and restart operations. System services are view-only; available logs depend on your existing journal permissions. Template units without an instance are omitted. A systemd action can continue in the service manager after its command-line client is stopped; inspect the service state before retrying. diff --git a/src-tauri/src/integrations.rs b/src-tauri/src/integrations.rs index 2ff590e..6361216 100644 --- a/src-tauri/src/integrations.rs +++ b/src-tauri/src/integrations.rs @@ -105,6 +105,8 @@ pub struct Request { pub directory: String, pub target: String, pub include: String, + pub pattern: String, + pub ignore_case: bool, } #[derive(Clone, Debug, Serialize)] #[serde(rename_all = "camelCase")] @@ -150,6 +152,36 @@ impl Plan { fn vecs(values: &[&str]) -> Vec { values.iter().map(|v| v.to_string()).collect() } +/// A file name, absolute path, or Restic pattern to search for; `~/` expands to home. +fn find_pattern(value: &str) -> Result { + let value = value.trim(); + if value.is_empty() || value.len() > 1024 || value.contains(['\0', '\n', '\r']) { + return Err("Enter a file name, path or pattern to search for".into()); + } + let pattern = if value == "~" || value.starts_with("~/") { + p::expand(value)?.to_string_lossy().into_owned() + } else { + value.to_string() + }; + if pattern.starts_with('-') { + return Err("A search pattern cannot start with a hyphen".into()); + } + Ok(pattern) +} +// Snapshot times are not part of `restic find` output, so one job reads both and +// wraps them in a single JSON document. Values stay positional arguments. +const FIND_SCRIPT: &str = r#"set -e +pattern=$1; ignore_case=$2; shift 2 +printf '{"snapshots":' +restic "$@" snapshots --json +printf ',"matches":' +if [ "$ignore_case" = 1 ]; then + restic "$@" find --json --ignore-case -- "$pattern" +else + restic "$@" find --json -- "$pattern" +fi +printf '}\n' +"#; fn snapshot_id(id: &str) -> Result<(), String> { if !(8..=64).contains(&id.len()) || !id.bytes().all(|b| b.is_ascii_hexdigit()) { Err("Select a snapshot first".into()) @@ -347,6 +379,16 @@ pub fn build_plan(r: &Request, s: &Settings) -> Result { if health["available"]!=true {plan.explanation.push_str(" Backup health is unavailable; drive readiness could not be verified.");} return Ok(plan); } + if r.action == "snapshot-find" { + let pattern = find_pattern(&r.pattern)?; + let mut args = vecs(&["-c", FIND_SCRIPT, "command-center-find", &pattern]); + args.push(if r.ignore_case { "1" } else { "0" }.into()); + args.extend(restic_args(i)?); + let mut plan = Plan::new("Search backup history", "sh", args, &p::home()); + plan.timeout_seconds = 4 * 3600; + plan.explanation = format!("Search every snapshot for “{pattern}” and list the saved versions. Read-only: nothing is restored or changed. KWallet may request an unlock."); + return Ok(plan); + } if ["snapshots", "snapshot-files", "restic-check", "restic-access", "restore"].contains(&r.action.as_str()) { let mut args = restic_args(i)?; let mut plan = Plan::new("Restic", "restic", vec![], &p::home()); @@ -454,6 +496,32 @@ pub async fn sync_status(app: tauri::AppHandle) -> Result { mod tests { use super::*; #[test] + fn file_history_search_patterns_are_validated_and_passed_literally() { + assert!(find_pattern("").is_err()); + assert!(find_pattern(" ").is_err()); + assert!(find_pattern("--password-file=/x").is_err()); + assert!(find_pattern("a\nb").is_err()); + assert!(find_pattern(&"x".repeat(1025)).is_err()); + assert_eq!(find_pattern(" notes.md ").unwrap(), "notes.md"); + assert_eq!(find_pattern("~/Documents/a b.txt").unwrap(), p::home().join("Documents/a b.txt").to_string_lossy()); + let password = tempfile::NamedTempFile::new().unwrap(); + let mut s = Settings::default(); + s.integrations.restic_repository = "/backup/repo".into(); + s.integrations.restic_password_file = password.path().to_string_lossy().into_owned(); + let plan = build_plan( + &Request { action: "snapshot-find".into(), pattern: "$(touch x); *.kdbx".into(), ..Default::default() }, + &s, + ) + .unwrap(); + assert_eq!(plan.program, "sh"); + // The pattern is a positional argument after the fixed script, never part of it. + assert_eq!(plan.args[0], "-c"); + assert_eq!(plan.args[1], FIND_SCRIPT); + assert_eq!(plan.args[3], "$(touch x); *.kdbx"); + assert_eq!(plan.args[4], "0"); + assert_eq!(&plan.args[5..7], ["--repo", "/backup/repo"]); + } + #[test] fn rejects_option_like_snapshot_ids() { assert!(snapshot_id("--delete").is_err()); assert!(snapshot_id("a1b2c3d4").is_ok()); @@ -617,8 +685,32 @@ mod workflow_tests { let (ok, out, err) = p::output(&mut plan.command(), 30).unwrap(); assert!(ok, "{err}"); assert!(out.contains("wanted.txt")); - let destination = temp.path().join("restored"); + // File history: an absolute path, a case-insensitive name, and no match. let selected = data.join("wanted.txt"); + let search = |pattern: &str, ignore_case: bool| -> Value { + let plan = build_plan( + &Request { + action: "snapshot-find".into(), + pattern: pattern.into(), + ignore_case, + ..Default::default() + }, + &s, + ) + .unwrap(); + let (ok, out, err) = p::output(&mut plan.command(), 60).unwrap(); + assert!(ok, "{err}"); + serde_json::from_str(&out).unwrap() + }; + let found = search(selected.to_str().unwrap(), false); + assert_eq!(found["snapshots"][0]["id"], id); + assert_eq!(found["matches"][0]["snapshot"], id); + assert_eq!(found["matches"][0]["matches"][0]["path"], selected.to_str().unwrap()); + assert_eq!(found["matches"][0]["matches"][0]["size"], 10); + let by_name = search("WANTED.TXT", true); + assert_eq!(by_name["matches"][0]["matches"].as_array().unwrap().len(), 1); + assert_eq!(search("absent-*.bin", false)["matches"], json!([])); + let destination = temp.path().join("restored"); let request = Request { action: "restore".into(), snapshot: id.into(), diff --git a/src-tauri/src/jobs.rs b/src-tauri/src/jobs.rs index cdfa9c6..c7432f7 100644 --- a/src-tauri/src/jobs.rs +++ b/src-tauri/src/jobs.rs @@ -316,7 +316,7 @@ fn reader( if stdout { if let Ok(mut state) = jobs.0.lock() { if let Some(job) = state.jobs.iter_mut().find(|j| j.id == id) { - if ["snapshots", "snapshot-files"].contains(&job.action.as_str()) + if ["snapshots", "snapshot-files", "snapshot-find"].contains(&job.action.as_str()) && job.result.len() + text.len() <= OUTPUT_LIMIT { job.result.push_str(&text); diff --git a/src/feature-model.js b/src/feature-model.js index a996636..094bc88 100644 --- a/src/feature-model.js +++ b/src/feature-model.js @@ -131,3 +131,78 @@ export function parseSnapshotFiles(job) { .map((line) => JSON.parse(line)) .filter((v) => v.struct_type === "node" || (v.path && v.type)); } +export function formatBytes(size) { + if (typeof size !== "number" || !Number.isFinite(size) || size < 0) return "—"; + const units = ["B", "KiB", "MiB", "GiB", "TiB"]; + let value = size; + let unit = 0; + while (value >= 1024 && unit < units.length - 1) { + value /= 1024; + unit++; + } + return `${unit ? value.toFixed(1) : value} ${units[unit]}`; +} +// Groups a snapshot-find result by path, newest version first. A version is +// "changed" when its size or modification time differs from the next older one. +export function parseFileHistory(job) { + let data; + try { + data = JSON.parse(job.result || job.output); + } catch { + throw new Error( + "The search returned too many matches to read. Search a more specific path, for example ~/Documents/report.odt.", + ); + } + if (!Array.isArray(data?.snapshots) || !Array.isArray(data?.matches)) + throw new Error("Unexpected Restic search response"); + const snapshots = new Map(data.snapshots.map((s) => [s.id, s])); + const paths = new Map(); + for (const hit of data.matches) { + const snapshot = snapshots.get(hit.snapshot); + if (!snapshot) continue; + for (const match of hit.matches || []) { + if (!paths.has(match.path)) paths.set(match.path, []); + paths.get(match.path).push({ + snapshot: snapshot.id, + shortId: snapshot.short_id || snapshot.id.slice(0, 8), + time: snapshot.time, + hostname: snapshot.hostname || "", + type: match.type, + size: typeof match.size === "number" ? match.size : null, + mtime: match.mtime, + }); + } + } + return [...paths] + .map(([path, versions]) => { + versions.sort((a, b) => Date.parse(b.time) - Date.parse(a.time)); + versions.forEach((version, index) => { + const older = versions[index + 1]; + version.status = !older + ? "first" + : older.size !== version.size || older.mtime !== version.mtime + ? "changed" + : "same"; + }); + const newest = versions[0]; + // Newer snapshots of the same host and backup path that lack this file. + const covers = (s) => + (s.hostname || "") === newest.hostname && + (s.paths || []).some((root) => { + const base = root.replace(/\/+$/, "") || "/"; + return path === base || path.startsWith(base === "/" ? "/" : `${base}/`); + }); + const missingFrom = data.snapshots.filter( + (s) => covers(s) && Date.parse(s.time) > Date.parse(newest.time), + ).length; + return { + path, + type: newest.type, + versions, + distinct: versions.filter((v) => v.status !== "same").length, + lastSeen: newest.time, + missingFrom, + }; + }) + .sort((a, b) => a.path.localeCompare(b.path)); +} diff --git a/src/features.js b/src/features.js index 5dc63e7..5742136 100644 --- a/src/features.js +++ b/src/features.js @@ -16,6 +16,8 @@ import { filterProjects, parseSnapshots, parseSnapshotFiles, + parseFileHistory, + formatBytes, } from "./feature-model.js"; const integrationFields = [ [ @@ -91,6 +93,7 @@ export function createFeatures(api) { snapshotId = "", snapshotDirectory = "/", fileRows = [], + fileHistory = [], favoritesOnly = false, group = "", polling = false, @@ -133,6 +136,7 @@ export function createFeatures(api) {

Snapshots

${empty("Load snapshots to browse your backup repository.")}

Snapshot browser

${button("Browse", 'id="browse-snapshot"')}
${empty("Select a snapshot first.")}
+

File history

Find every saved version of a file across all snapshots, then restore the one you want into a new folder.

A name such as notes.md matches it in any folder. Start with / or ~/ for an exact path. * and ? are wildcards. Searching reads every snapshot, so it can take a while on large repositories.

Recovery readiness

${button("Read recovery instructions", 'id="read-recovery"')}
${empty("Refresh health to check available recovery files.")}
${head("Make it yours", "Configuration", "Edit the source, preview your changes, and keep a backup of the previous version.")} @@ -300,6 +304,21 @@ export function createFeatures(api) { }); }), ); + $("#file-history-form").addEventListener( + "submit", + guard(async (event) => { + event.preventDefault(); + const pattern = $("#file-history-pattern").value.trim(); + await action( + { action: "snapshot-find", pattern, ignoreCase: $("#file-history-case").checked }, + (job) => { + fileHistory = parseFileHistory(job); + renderFileHistory(pattern); + }, + ); + }), + ); + $("#file-history-changes").addEventListener("change", () => renderFileHistory()); $("#read-recovery").addEventListener( "click", guard(async () => { @@ -797,6 +816,43 @@ export function createFeatures(api) { }), ); } + let fileHistoryPattern = ""; + function renderFileHistory(pattern = fileHistoryPattern) { + fileHistoryPattern = pattern; + const onlyChanges = $("#file-history-changes").checked; + const statusLabel = { first: "First saved", changed: "Changed", same: "Same as previous" }; + const shown = fileHistory.slice(0, 50); + $("#file-history-results").innerHTML = !fileHistory.length + ? empty(`No saved versions match “${e(pattern)}”. Check the spelling, try Ignore case, or search just the file name.`) + : `${fileHistory.length > shown.length ? `

Showing ${shown.length} of ${fileHistory.length} matching paths. Search a more specific path to narrow the results.

` : ""}${shown + .map((entry, index) => { + const rows = entry.versions + .map((v, row) => ({ v, row })) + .filter(({ v }) => !onlyChanges || v.status !== "same"); + const hidden = entry.versions.length - rows.length; + return `

${e(entry.path)}${entry.type === "dir" ? " (folder)" : ""}

${entry.versions.length} saved ${entry.versions.length === 1 ? "copy" : "copies"} · ${entry.distinct} different ${entry.distinct === 1 ? "version" : "versions"} · last saved ${e(age(entry.lastSeen))}

${entry.missingFrom ? `

Not in the ${entry.missingFrom} newer ${entry.missingFrom === 1 ? "snapshot" : "snapshots"} of this backup. It may have been deleted or moved after ${e(age(entry.lastSeen))}.

` : ""}
${rows + .map( + ({ v, row }) => + `
${statusLabel[v.status]}${e(age(v.time))}Snapshot ${e(v.shortId)}${v.hostname ? ` · ${e(v.hostname)}` : ""}Modified${e(age(v.mtime))}Size${e(entry.type === "dir" ? "—" : formatBytes(v.size))}${button("Restore this version", `data-history-restore="${index}:${row}"`)}
`, + ) + .join("")}
${hidden ? `

${hidden} identical ${hidden === 1 ? "copy" : "copies"} hidden.

` : ""}
`; + }) + .join("")}`; + $$("[data-history-restore]").forEach((btn) => + btn.addEventListener("click", () => { + const [index, row] = btn.dataset.historyRestore.split(":").map(Number); + const entry = fileHistory[index]; + const version = entry.versions[row]; + snapshotId = version.snapshot; + $("#restore-snapshot").value = version.snapshot; + $("#restore-include").value = entry.path; + if (snapshots.length) renderSnapshots(); + $("#restore-target").scrollIntoView({ block: "center", behavior: "smooth" }); + $("#restore-target").focus({ preventScroll: true }); + toast(`Version from ${age(version.time)} selected. Choose a new destination folder, then Review restore.`); + }), + ); + } async function browseSnapshot(directory) { if (!snapshotId) throw new Error("Select a snapshot first"); const requestedSnapshot = snapshotId; diff --git a/src/styles.css b/src/styles.css index fb40a4d..97c911c 100644 --- a/src/styles.css +++ b/src/styles.css @@ -357,3 +357,11 @@ button:disabled { cursor:default; } #setup-form, #bundle-dialog { font-size:14px; line-height:1.6; } #setup-form select { margin-top:.5rem; } #setup-form h3:focus, #view-title:focus { outline:none; } +.inline-check { display:inline-flex; gap:8px; align-items:center; } +.inline-check input { accent-color:var(--cyan); } +.history-entry { border-top:1px solid var(--line); padding-top:14px; margin-top:14px; } +.history-entry h4 { margin:0 0 4px; overflow-wrap:anywhere; } +.history-rows { display:grid; gap:6px; margin-top:8px; } +.history-row { display:grid; grid-template-columns:minmax(120px,auto) minmax(160px,1fr) minmax(130px,auto) minmax(80px,auto) auto; gap:12px; align-items:center; padding:8px 10px; border:1px solid var(--line); border-radius:8px; } +.history-row small { display:block; color:var(--muted); font-size:11px; } +@media (max-width: 1200px) { .history-row { grid-template-columns:1fr 1fr; } } diff --git a/tests/features.test.mjs b/tests/features.test.mjs index 30142bf..f84f8d8 100644 --- a/tests/features.test.mjs +++ b/tests/features.test.mjs @@ -12,6 +12,8 @@ import { attentionItems, filterProjects, parseSnapshots, + parseFileHistory, + formatBytes, parseSnapshotFiles, } from "../src/feature-model.js"; @@ -103,3 +105,44 @@ test("backup results reject truncation and distinguish snapshot headers from fil assert.equal(nodes.length, 1); assert.equal(nodes[0].path, "/home"); }); + +test("file history groups versions by path, marks changes and detects deletion", () => { + const snap = (id, time, host = "desk", paths = ["/home/me"]) => ({ + id: id.repeat(8), short_id: id.repeat(8).slice(0, 8), time, hostname: host, paths, + }); + const file = (path, size, mtime) => ({ path, type: "file", size, mtime }); + const s1 = snap("a", "2026-09-01T10:00:00Z"); + const s2 = snap("b", "2026-09-02T10:00:00Z"); + const s3 = snap("c", "2026-09-03T10:00:00Z"); + const s4 = snap("d", "2026-09-04T10:00:00Z"); + const other = snap("e", "2026-09-05T10:00:00Z", "laptop"); + const result = JSON.stringify({ + snapshots: [s1, s2, s3, s4, other], + matches: [ + { snapshot: s4.id, hits: 1, matches: [file("/home/me/sub/notes.txt", 2, "m0")] }, + { snapshot: s3.id, hits: 2, matches: [file("/home/me/notes.txt", 10, "m2"), file("/home/me/sub/notes.txt", 2, "m0")] }, + { snapshot: s2.id, hits: 2, matches: [file("/home/me/notes.txt", 10, "m2"), file("/home/me/sub/notes.txt", 2, "m0")] }, + { snapshot: s1.id, hits: 2, matches: [file("/home/me/notes.txt", 3, "m1"), file("/home/me/sub/notes.txt", 2, "m0")] }, + ], + }); + const [notes, sub] = parseFileHistory({ result }); + assert.equal(notes.path, "/home/me/notes.txt"); + assert.deepEqual(notes.versions.map((v) => v.shortId), ["cccccccc", "bbbbbbbb", "aaaaaaaa"]); + assert.deepEqual(notes.versions.map((v) => v.status), ["same", "changed", "first"]); + assert.equal(notes.distinct, 2); + // Deleted before snapshot d; the laptop's snapshot is another machine and ignored. + assert.equal(notes.missingFrom, 1); + assert.equal(sub.versions.length, 4); + assert.equal(sub.distinct, 1); + assert.equal(sub.missingFrom, 0); + assert.deepEqual(parseFileHistory({ result: JSON.stringify({ snapshots: [s1], matches: [] }) }), []); + assert.throws(() => parseFileHistory({ result: '{"snapshots":[' }), /more specific path/); + assert.throws(() => parseFileHistory({ result: "[]" }), /Unexpected/); +}); + +test("byte sizes are human readable", () => { + assert.equal(formatBytes(0), "0 B"); + assert.equal(formatBytes(1536), "1.5 KiB"); + assert.equal(formatBytes(5 * 1024 ** 3), "5.0 GiB"); + assert.equal(formatBytes(null), "—"); +}); diff --git a/tests/interface.test.mjs b/tests/interface.test.mjs index d7fc529..0f9479d 100644 --- a/tests/interface.test.mjs +++ b/tests/interface.test.mjs @@ -258,6 +258,53 @@ test("snapshot results load through the result endpoint after a completed job", } }); +test("file history searches all snapshots, lists changed versions and prefills a reviewed restore", async () => { + const snap = (id, time) => ({ id: id.repeat(8), short_id: id.repeat(8), time, hostname: "desk", paths: ["/home/me"] }); + const [s1, s2, s3, s4] = [snap("a", "2026-09-01T10:00:00Z"), snap("b", "2026-09-02T10:00:00Z"), snap("c", "2026-09-03T10:00:00Z"), snap("d", "2026-09-04T10:00:00Z")]; + const hit = (s, size, mtime) => ({ snapshot: s.id, hits: 1, matches: [{ path: "/home/me/notes.txt", type: "file", size, mtime }] }); + const x = await setup({ + job_result: { result: JSON.stringify({ snapshots: [s1, s2, s3, s4], matches: [hit(s3, 2048, "2026-09-02T09:00:00Z"), hit(s2, 2048, "2026-09-02T09:00:00Z"), hit(s1, 10, "2026-08-30T09:00:00Z")] }), output: "" }, + }); + try { + x.$('[data-view="backup"]').click(); + x.$("#file-history-pattern").value = "~/notes.txt"; + x.$("#file-history-case").checked = true; + submit(x.w, x.$("#file-history-form")); + await settle(); + const request = x.calls.find((c) => c.command === "prepare_job").args.request; + assert.equal(request.action, "snapshot-find"); + assert.equal(request.pattern, "~/notes.txt"); + assert.equal(request.ignoreCase, true); + assert.equal(x.$("#review-dialog").open, true); + submit(x.w, x.$("#review-form")); + await settle(); + x.responses.job_history = { jobs: [{ id: "job-1", action: "snapshot-find", title: "Search backup history", status: "succeeded", command: "sh", cwd: "/home/me", startedAt: Date.now() - 1000, finishedAt: Date.now(), exitCode: 0, output: "" }] }; + x.$("#activity-refresh").click(); + await settle(); + const results = x.$("#file-history-results"); + assert.equal(results.querySelector("b"), null, "paths are escaped"); + assert.match(results.textContent, /notes<\/b>\.txt/); + assert.match(results.textContent, /3 saved copies · 2 different versions/); + assert.match(results.textContent, /Not in the 1 newer snapshot/); + assert.match(results.textContent, /2\.0 KiB/); + assert.equal(results.querySelectorAll(".history-row").length, 2); + assert.match(results.textContent, /1 identical copy hidden/); + x.$("#file-history-changes").checked = false; + x.$("#file-history-changes").dispatchEvent(new x.w.Event("change")); + assert.equal(results.querySelectorAll(".history-row").length, 3); + results.querySelector("[data-history-restore]").click(); + assert.equal(x.$("#restore-snapshot").value, s3.id); + assert.equal(x.$("#restore-include").value, "/home/me/notes.txt"); + x.$("#restore-target").value = "~/Recovered-notes"; + submit(x.w, x.$("#restore-form")); + await settle(); + const restore = x.calls.findLast((c) => c.command === "prepare_job").args.request; + assert.deepEqual({ ...restore }, { action: "restore", snapshot: s3.id, target: "~/Recovered-notes", include: "/home/me/notes.txt" }); + } finally { + x.dom.window.close(); + } +}); + const toolboxCatalog = { revision: "fixture-revision", actions: [