Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
12 changes: 11 additions & 1 deletion docs/user-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
94 changes: 93 additions & 1 deletion src-tauri/src/integrations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down Expand Up @@ -150,6 +152,36 @@ impl Plan {
fn vecs(values: &[&str]) -> Vec<String> {
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<String, String> {
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())
Expand Down Expand Up @@ -347,6 +379,16 @@ pub fn build_plan(r: &Request, s: &Settings) -> Result<Plan, String> {
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());
Expand Down Expand Up @@ -454,6 +496,32 @@ pub async fn sync_status(app: tauri::AppHandle) -> Result<Value, String> {
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());
Expand Down Expand Up @@ -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(),
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/src/jobs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -316,7 +316,7 @@ fn reader<T: Read + Send + 'static>(
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);
Expand Down
75 changes: 75 additions & 0 deletions src/feature-model.js
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
Loading
Loading