diff --git a/CHANGELOG.md b/CHANGELOG.md index a3de423..04d3e49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ Release downloads and full notes are available on [GitHub Releases](https://github.com/Commanderx-code/command-center/releases). +## [0.7.0](https://github.com/Commanderx-code/command-center/releases/tag/v0.7.0) — 2026-09-24 + +- 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. +- Added a [Wiki user guide](https://github.com/Commanderx-code/command-center/wiki) covering every page and feature, linked from the README. + ## [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/README.md b/README.md index 83c1352..2add59c 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@

Download · + Wiki guide · Installation · User guide · Contributing · @@ -46,6 +47,12 @@ Command Center brings [Commander Toolbox](https://github.com/Commanderx-code/com The app runs as your normal user. Commands are reviewed before execution; Toolbox scripts retain their own privilege checks and confirmations. Integration paths are editable in **Settings**. See the [user guide](docs/user-guide.md) for exact behavior and limitations. +## New in 0.7.0 + +- **Backup file history:** search every backup snapshot for a file, see each saved version with its date, size and whether it changed, spot files that were deleted, and restore the version you want into a new folder. + +Read [Backup file history](docs/user-guide.md#backup-file-history) in the user guide. + ## New in 0.6.0 - Setup imports can merge with this machine's setup, and an import interrupted by a crash or power loss is rolled back automatically on the next launch. @@ -79,13 +86,13 @@ Read [Workflows, profiles, and recovery verification](docs/operations.md) for se **[Get the latest release →](https://github.com/Commanderx-code/command-center/releases/latest)** -| Package | Download v0.6.0 | Install the downloaded file | +| Package | Download v0.7.0 | Install the downloaded file | | --------------- | ------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------ | -| Debian / Ubuntu | [`.deb` · amd64](https://github.com/Commanderx-code/command-center/releases/download/v0.6.0/command-center_0.6.0_amd64.deb) | `sudo apt install ./command-center_0.6.0_amd64.deb` | -| Fedora / RPM | [`.rpm` · x86_64](https://github.com/Commanderx-code/command-center/releases/download/v0.6.0/command-center-0.6.0-1.x86_64.rpm) | `sudo dnf install ./command-center-0.6.0-1.x86_64.rpm` | -| Arch / Garuda | [`.pkg.tar.zst` · x86_64](https://github.com/Commanderx-code/command-center/releases/download/v0.6.0/command-center-0.6.0-1-x86_64.pkg.tar.zst) | `sudo pacman -U ./command-center-0.6.0-1-x86_64.pkg.tar.zst` | +| Debian / Ubuntu | [`.deb` · amd64](https://github.com/Commanderx-code/command-center/releases/download/v0.7.0/command-center_0.7.0_amd64.deb) | `sudo apt install ./command-center_0.7.0_amd64.deb` | +| Fedora / RPM | [`.rpm` · x86_64](https://github.com/Commanderx-code/command-center/releases/download/v0.7.0/command-center-0.7.0-1.x86_64.rpm) | `sudo dnf install ./command-center-0.7.0-1.x86_64.rpm` | +| Arch / Garuda | [`.pkg.tar.zst` · x86_64](https://github.com/Commanderx-code/command-center/releases/download/v0.7.0/command-center-0.7.0-1-x86_64.pkg.tar.zst) | `sudo pacman -U ./command-center-0.7.0-1-x86_64.pkg.tar.zst` | -Version 0.6.0 packages require **Linux x86_64, glibc 2.35+, GTK 3, and WebKitGTK 4.1**. They are install-tested on Ubuntu 22.04 and 24.04, Debian 12, and Fedora 43, and the Arch package on current Arch; the release notes link the workflow run. Release assets include `SHA256SUMS` for verification. +Version 0.7.0 packages require **Linux x86_64, glibc 2.35+, GTK 3, and WebKitGTK 4.1**. They are install-tested on Ubuntu 22.04 and 24.04, Debian 12, and Fedora 43, and the Arch package on current Arch; the release notes link the workflow run. Release assets include `SHA256SUMS` for verification. Arch/Garuda users can also build the same package with `makepkg -si` from `packaging/aur/`, or develop from source using the [installation guide](docs/installation.md#from-source-on-archgaruda). @@ -113,6 +120,7 @@ Open `http://127.0.0.1:4173` for a browser preview with sample data. Desktop ope ## Documentation +- **[Wiki: how to use Command Center](https://github.com/Commanderx-code/command-center/wiki)**: a step-by-step guide to every page and feature, plus troubleshooting. - [Installation](docs/installation.md) — packages, requirements, checksums, and source builds. - [User guide](docs/user-guide.md) — features, integrations, command behavior, and local data. - [Development](docs/development.md) — repository layout, checks, and Toolbox updates. diff --git a/docs/installation.md b/docs/installation.md index 74c8195..3798761 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -19,19 +19,19 @@ Each downloaded package must report `OK`. The `--ignore-missing` option lets you On a compatible Debian/Ubuntu system: ```bash -sudo apt install ./command-center_0.6.0_amd64.deb +sudo apt install ./command-center_0.7.0_amd64.deb ``` On a compatible Fedora/RPM system: ```bash -sudo dnf install ./command-center-0.6.0-1.x86_64.rpm +sudo dnf install ./command-center-0.7.0-1.x86_64.rpm ``` On Arch or an Arch-based system such as Garuda (0.6.0 and later): ```bash -sudo pacman -U ./command-center-0.6.0-1-x86_64.pkg.tar.zst +sudo pacman -U ./command-center-0.7.0-1-x86_64.pkg.tar.zst ``` The Arch package is built from the release tag with `packaging/aur/PKGBUILD` in a clean Arch container and tracks current Arch libraries; update your system before installing it. To build it yourself instead, run `makepkg -si` from a copy of `packaging/aur/`. diff --git a/docs/release-notes.md b/docs/release-notes.md index a778365..99c877c 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -1,34 +1,33 @@ -Command Center 0.6.0 makes setup imports safer, runs independent jobs side by side, and brings the packages to older distributions. +Command Center 0.7.0 adds backup file history: find every saved version of a file across your backups and restore the one you want. ### What's new -- **Merge setup imports:** choose **Merge** (the default) to keep this machine's preferences and saved definitions and add the bundle's new workflows, machine profiles, personal tools, quick actions, workspace profiles, scan folders, and Toolbox favorites. Empty integration paths are filled; different items whose IDs already exist here are kept and listed in the preview. **Replace** works as before. -- **Interrupted imports recover automatically:** imports now keep a journal. If the app or computer stops mid-import, the next launch restores every file from the pre-import backup before the interface opens and tells you on the dashboard. -- **Run several jobs:** repository actions and project tasks in different repositories run at the same time, alongside one workstation task such as a backup, installer, or Home Manager switch. Jobs in the same folder, and embedded-terminal sessions, still take turns; a waiting job names the job it is waiting for. Stop each job from Activity. -- **Wider distribution support:** packages now require glibc 2.35+ instead of 2.39, adding Ubuntu 22.04 and Debian 12. Every package is install- and launch-tested on Ubuntu 22.04, Debian 12, Ubuntu 24.04, and Fedora 43, and the Arch recipe is built, linted, and launched in a clean Arch container. -- **MIT licensed:** Command Center is now released under the MIT License. The `.deb`, `.rpm`, and Arch packages include the license and the bundled third-party notices. -- **Favorites saved with your data:** Toolbox favorites move from webview storage into the app's data folder on first launch and are included transactionally in setup imports. +- **Backup file history:** in **Backup & Restore → File history**, search all Restic snapshots for a file name (for example `notes.md`, matched in any folder), an exact path (`~/Documents/report.odt`), or a pattern (`*.kdbx`), with optional **Ignore case**. +- **See how a file changed over time:** results are grouped by path, newest first. Each saved copy shows when it was backed up, the snapshot and host, and the file's modification time and size, marked **First saved**, **Changed** or **Same as previous**. **Show only versions that changed** hides identical copies. +- **Find deleted files:** if newer snapshots of the same machine and backup path no longer contain a file, the result says it may have been deleted or moved, and when it was last saved. +- **Restore a chosen version:** **Restore this version** fills the restore form with that snapshot and the file's exact path. Pick a new destination folder and review. Restores still never overwrite existing files and are verified as they're written. +- **Wiki user guide:** a step-by-step guide to every page and feature, now linked from the README: https://github.com/Commanderx-code/command-center/wiki ### Behavior to know -- Merge never changes preferences such as theme, editor, or terminal, and never replaces a non-empty integration path. Use **Replace** to adopt another machine's setup wholesale. -- The backup health helper, password-file, and wallet settings always stay local, as in 0.5.1. -- Automatic recovery only restores the files an import writes. If it cannot finish, the dashboard shows the reason and the manual steps in the [setup guide](https://github.com/Commanderx-code/command-center/blob/v0.6.0/docs/setup-and-workspaces.md). -- Two jobs never share a working directory, so a project task and a Git action in the same repository still run one after the other. +- Searching is a reviewed, read-only job. It lists snapshots, then searches all of them, so it can take a while on large repositories and may ask KWallet to unlock. Nothing is restored until you review a restore. +- "Changed" compares size and modification time between copies. It doesn't compare file contents. +- A search matching a huge number of files can't be displayed. Search a more specific path instead. +- Restic treats `*`, `?` and `[` in paths as pattern characters. For file names containing them, check the include path before restoring. ### Linux downloads -The `.deb` and `.rpm` assets target **x86_64** and require **glibc 2.35+, GTK 3, WebKitGTK 4.1, and the platform's AppIndicator library**. Notifications additionally use `notify-send`. File restores require Restic 0.17+. +The `.deb` and `.rpm` assets target **x86_64** and require **glibc 2.35+, GTK 3, WebKitGTK 4.1, and the platform's AppIndicator library**. Notifications additionally use `notify-send`. File restores and file history require Restic 0.17+. ```sh # Debian / Ubuntu -sudo apt install ./command-center_0.6.0_amd64.deb +sudo apt install ./command-center_0.7.0_amd64.deb # Fedora / RPM -sudo dnf install ./command-center-0.6.0-1.x86_64.rpm +sudo dnf install ./command-center-0.7.0-1.x86_64.rpm # Arch / Garuda (built against current Arch libraries) -sudo pacman -U ./command-center-0.6.0-1-x86_64.pkg.tar.zst +sudo pacman -U ./command-center-0.7.0-1-x86_64.pkg.tar.zst ``` Download `SHA256SUMS` beside the package and run: diff --git a/docs/releases.md b/docs/releases.md index b88b21e..66212fe 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -5,7 +5,7 @@ Open Settings → About & updates. Check for releases, read the notes, then export the source updater. Close Command Center and run the displayed command, for example: ```sh -bash ~/Downloads/update-desktop.sh v0.6.0 +bash ~/Downloads/update-desktop.sh v0.7.0 ``` The helper requires Linux build dependencies, Git, Node/npm, and Rust/Cargo. Run as your normal user, without sudo. It asks you to type the tag, downloads that tag into a temporary checkout, checks its package version, installs dependencies, runs JavaScript and Rust tests, then builds and installs the desktop app. A failed check stops installation. It does not modify your project checkout or app settings. The GitHub tag must already exist; the release checker only advertises published stable releases. Offline/API errors are shown without claiming that the installed version is current. GitHub is contacted only when you request a release check or open its release page. @@ -25,9 +25,9 @@ This rolls back the binary only, not settings or user data. It applies to the no Keep package.json, package-lock.json, Cargo.toml, Cargo.lock, and tauri.conf.json versions aligned. Update docs/release-notes.md and CHANGELOG.md. Commit and push the reviewed source, then create and push an annotated tag matching the version: ```sh -git tag -a v0.6.0 -m 'Command Center v0.6.0' -git push origin v0.6.0 -npm run release:draft -- v0.6.0 +git tag -a v0.7.0 -m 'Command Center v0.7.0' +git push origin v0.7.0 +npm run release:draft -- v0.7.0 ``` The draft command requires an authenticated GitHub CLI (`gh`). It checks for a clean working tree and a matching local/remote tag and runs the checks and tests. It then finds the successful **Linux packages** run for the tagged commit, downloads that run's `packages` and `arch-package` artifacts, verifies their checksums, writes one `SHA256SUMS` covering the `.deb`, `.rpm`, and Arch package, and creates an **unpublished** GitHub release. The release notes are docs/release-notes.md plus a build-and-validation section linking the workflow run. If CI has not passed for the tag, no draft is created. The command never builds release packages locally: a build on a newer distribution such as Garuda would require a newer glibc than the packages declare. It does not push tags or publish the draft. Download and verify the hosted assets and test the app on your machine before publishing. 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/package-lock.json b/package-lock.json index 4cece2d..c602494 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "command-center", - "version": "0.6.0", + "version": "0.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "command-center", - "version": "0.6.0", + "version": "0.7.0", "license": "MIT", "dependencies": { "@xterm/addon-fit": "^0.11.0", diff --git a/package.json b/package.json index bc791b1..c592a0f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "command-center", - "version": "0.6.0", + "version": "0.7.0", "private": true, "type": "module", "description": "A Linux control deck for repositories, configuration, backup, and recovery.", diff --git a/packaging/aur/.SRCINFO b/packaging/aur/.SRCINFO index 8e80c43..3df757b 100644 --- a/packaging/aur/.SRCINFO +++ b/packaging/aur/.SRCINFO @@ -1,6 +1,6 @@ pkgbase = command-center pkgdesc = Local Linux workstation dashboard for repositories, backups, configuration and Toolbox - pkgver = 0.6.0 + pkgver = 0.7.0 pkgrel = 1 url = https://github.com/Commanderx-code/command-center arch = x86_64 @@ -25,7 +25,7 @@ pkgbase = command-center optdepends = restic: backup recovery optdepends = fish: Fish custom actions options = !lto - source = git+https://github.com/Commanderx-code/command-center.git#tag=v0.6.0 + source = git+https://github.com/Commanderx-code/command-center.git#tag=v0.7.0 sha256sums = SKIP pkgname = command-center diff --git a/packaging/aur/PKGBUILD b/packaging/aur/PKGBUILD index 3f6f493..de3c74c 100644 --- a/packaging/aur/PKGBUILD +++ b/packaging/aur/PKGBUILD @@ -2,7 +2,7 @@ # namcap, installs it, and launches it. Not yet submitted to AUR. # libappindicator-gtk3 is loaded at runtime for the tray, so namcap cannot see it. pkgname=command-center -pkgver=0.6.0 +pkgver=0.7.0 pkgrel=1 pkgdesc='Local Linux workstation dashboard for repositories, backups, configuration and Toolbox' arch=('x86_64') diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 472ca3f..e5a505b 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -345,7 +345,7 @@ dependencies = [ [[package]] name = "command-center" -version = "0.6.0" +version = "0.7.0" dependencies = [ "libc", "linutil_core", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index a7677c8..65e5064 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "command-center" -version = "0.6.0" +version = "0.7.0" description = "A Linux control deck for repositories, configuration, backup, and recovery" authors = ["Commander"] license = "MIT" 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-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 986fa32..c42e37a 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Command Center", - "version": "0.6.0", + "version": "0.7.0", "identifier": "io.helixstack.commandcenter", "build": { "beforeDevCommand": "npm run dev", 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: [