From ba77de419742963a7c92981a62900d273141e5f6 Mon Sep 17 00:00:00 2001 From: atagen Date: Sat, 30 May 2026 16:00:46 +1000 Subject: [PATCH] feat: add history + undo/redo Co-authored-by: Amaan Qureshi --- Cargo.lock | 11 + Cargo.toml | 1 + src/cli.rs | 20 ++ src/commands.rs | 62 +++- src/history.rs | 731 ++++++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 85 ++++-- 6 files changed, 888 insertions(+), 22 deletions(-) create mode 100644 src/history.rs diff --git a/Cargo.lock b/Cargo.lock index 3fb5963..42d4586 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -188,6 +188,16 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "etcetera" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de48cc4d1c1d97a20fd819def54b890cadde72ed3ad0c614822a0a433361be96" +dependencies = [ + "cfg-if", + "windows-sys", +] + [[package]] name = "fastrand" version = "2.4.1" @@ -728,6 +738,7 @@ version = "0.1.1" dependencies = [ "anyhow", "data-encoding", + "etcetera", "flate2", "git2", "lexopt", diff --git a/Cargo.toml b/Cargo.toml index 3973893..b42757a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,7 @@ path = "src/main.rs" [dependencies] anyhow = "1.0.102" data-encoding = "2.10.0" +etcetera = "0.11.0" flate2 = "1.1.9" git2 = "0.21" lexopt = "0.3.2" diff --git a/src/cli.rs b/src/cli.rs index 6d9c77e..36e8336 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -44,6 +44,10 @@ pub enum Command { rm: bool, }, Dedup, + Undo { + list: bool, + }, + Redo, Help, } @@ -214,6 +218,22 @@ fn parse_parser(mut parser: lexopt::Parser) -> Result { } Ok(Command::Dedup) }, + "undo" => { + let mut list = false; + while let Some(arg) = parser.next()? { + match arg { + Long("list") => list = true, + Short(_) | Long(_) | Value(_) => return Err(arg.unexpected().into()), + } + } + Ok(Command::Undo { list }) + }, + "redo" => { + if let Some(arg) = parser.next()? { + return Err(arg.unexpected().into()); + } + Ok(Command::Redo) + }, _ => Ok(Command::Help), } } diff --git a/src/commands.rs b/src/commands.rs index bc1ecad..8ecc37f 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -39,6 +39,7 @@ use crate::{ BranchComparison, CompareStatus, }, + history, lock, pins, pins::{ @@ -63,7 +64,7 @@ struct UpdateFetch { comparison: BranchComparison, } -fn dir() -> PathBuf { +pub fn dir() -> PathBuf { if let Some(dir) = env::var_os("TACK_DIR") { return PathBuf::from(dir); } @@ -74,16 +75,16 @@ fn dir() -> PathBuf { cwd.join(".tack") } -fn pins_path(dir: &Path) -> PathBuf { +pub fn pins_path(dir: &Path) -> PathBuf { dir.join("pins.toml") } -fn lock_path(dir: &Path) -> PathBuf { +pub fn lock_path(dir: &Path) -> PathBuf { dir.join("pins.lock.json") } /// resolver is `default.nix` in the modern `.tack/` layout, or `inputs.nix` /// when the dir is a repo root carrying the legacy layout -fn resolver_path(dir: &Path) -> PathBuf { +pub fn resolver_path(dir: &Path) -> PathBuf { let legacy = dir.join("inputs.nix"); if legacy.exists() { return legacy; @@ -136,7 +137,7 @@ fn short(rev: &str) -> String { rev.chars().take(7).collect() } -fn write_atomic(path: &Path, contents: &str) -> Result<()> { +pub fn write_atomic(path: &Path, contents: &str) -> Result<()> { let mut tmp_str = path.as_os_str().to_owned(); tmp_str.push(".tmp"); let tmp = PathBuf::from(tmp_str); @@ -1680,6 +1681,55 @@ fn pick_name(id: &str, aliases: &BTreeSet) -> String { .unwrap_or_default() } +pub fn undo(list: bool) -> Result<()> { + let dir = dir(); + let store = history::store_dir(&dir); + if list { + match history::list(&store) { + Some(view) => render(&view, 0, view.rows.len().saturating_sub(1)), + None => println!("no history"), + } + return Ok(()); + } + match history::undo(&dir, &store)? { + Some(view) => render_window(&view), + None => println!("nothing to undo"), + } + Ok(()) +} + +pub fn redo() -> Result<()> { + let dir = dir(); + let store = history::store_dir(&dir); + match history::redo(&dir, &store)? { + Some(view) => render_window(&view), + None => println!("nothing to redo"), + } + Ok(()) +} + +/// a radius-1 window around the new cursor: the redo target, the live state, +/// the undo target. +fn render_window(view: &history::View) { + let lo = view.cursor.saturating_sub(1); + let hi = (view.cursor + 1).min(view.rows.len().saturating_sub(1)); + render(view, lo, hi); +} + +/// rows `lo..=hi` newest-first, relative times aligned, `>` marking the cursor +fn render(view: &history::View, lo: usize, hi: usize) { + let now = history::now(); + let times = (lo..=hi) + .map(|idx| history::rel_time(now, view.rows[idx].ts)) + .collect::>(); + let width = times.iter().map(String::len).max().unwrap_or(0); + for idx in (lo..=hi).rev() { + let marker = if idx == view.cursor { '>' } else { ' ' }; + let when = ×[idx - lo]; + println!("{marker} {when:width$} {}", view.rows[idx].label); + } +} + pub fn help() { println!( "tack: flake-like toml nix pins, lazily fetched and transformed @@ -1694,6 +1744,8 @@ usage: tack rm tack alias