From 15154929385d8c5f47ba53d7ce73b904d6989fe8 Mon Sep 17 00:00:00 2001 From: Daniele Date: Mon, 10 Aug 2026 14:27:48 +0100 Subject: [PATCH] feat(file-viewer): browse a versioned file's git history MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A clock button in the file viewer header lists the versions of a file whose project keeps a git history; picking one shows the file as of that commit, read-only, with a banner back to the current version. A past version is never served from the working tree: the whole repository is materialized at that revision (git archive streamed through tar into a size-bounded, immutable-by-rev cache) and every fetch — content, compiled LaTeX, markdown images, downloads — resolves inside that tree, so dependencies are contemporaneous with the file: a .tex compiles against its \input's and images of that moment. Backend: new git_versions module (repo discovery bounded by the workspace mount, host-git log/rev-parse/archive, extraction cache with oldest-first prune) + GET /api/file/versions and a rev param on GET /api/file (rev is the ETag; never X-Writable). Frontend: history mode in FileViewerBase shared by the desktop and mobile viewers — popover, banner, watcher paused while browsing, rev propagated to every /api/file URL it builds. --- crates/skald-core/src/git_versions.rs | 500 ++++++++++++++++++++ crates/skald-core/src/lib.rs | 1 + crates/skald-core/src/skald/accessors.rs | 2 + crates/skald-core/src/skald/bundles.rs | 3 + docs/projects.md | 4 +- src/frontend/api/files.rs | 172 ++++++- src/frontend/api/mod.rs | 1 + web/components/file-viewer-page.js | 2 + web/components/shared/file-viewer-base.js | 170 ++++++- web/components/shared/file-viewer-mobile.js | 2 + web/css/file-viewer.css | 96 ++++ web/i18n/en.js | 4 + web/i18n/fr.js | 4 + web/i18n/it.js | 4 + 14 files changed, 950 insertions(+), 15 deletions(-) create mode 100644 crates/skald-core/src/git_versions.rs diff --git a/crates/skald-core/src/git_versions.rs b/crates/skald-core/src/git_versions.rs new file mode 100644 index 0000000..b9a89d4 --- /dev/null +++ b/crates/skald-core/src/git_versions.rs @@ -0,0 +1,500 @@ +//! Read-only access to the git history of workspace files. +//! +//! Project versioning is agent-driven (the project-coordinator commits inside +//! the user's container, straight into the bind-mounted project folder); this +//! module is the *read* side, backing the file viewer's history mode: +//! +//! - [`GitVersions::history`] lists the commits that touched a file; +//! - [`GitVersions::tree_at`] materializes a full copy of the repository at a +//! revision — `git archive` streamed through the host `tar` — into a +//! content-addressed cache, and [`GitVersions::file_at`] resolves one file +//! inside it. +//! +//! Serving a revision from a whole extracted tree (never from the working +//! tree) is what makes dependency-bearing formats correct: a `.tex` compiles +//! against the `\input`s and images *of that revision*, and a markdown file's +//! relative assets load contemporaneously too. Extracted trees are immutable +//! by construction, so the cache needs no invalidation — only a size-bounded +//! oldest-first prune. + +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::sync::Mutex; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{Duration, Instant, SystemTime}; + +use anyhow::{bail, Context, Result}; +use serde::Serialize; +use sha2::{Digest, Sha256}; +use tokio::process::Command; +use tokio::sync::OnceCell; + +/// One commit that touched a file (`%H`, `%aI`, `%s` — see [`parse_history`]). +#[derive(Debug, Clone, Serialize)] +pub struct VersionEntry { + /// Full commit sha. + pub rev: String, + /// Author date, ISO-8601. + pub date: String, + /// Commit subject line. + pub subject: String, +} + +/// Cache root name for extracted trees, under the OS temp dir. +const TREES_DIR_NAME: &str = "skald-git-trees"; +/// Total size ceiling for extracted trees; oldest extractions are pruned. +const TREES_MAX_BYTES: u64 = 1 << 30; // 1 GiB +/// The cache is re-walked for pruning at most this often. +const PRUNE_INTERVAL: Duration = Duration::from_secs(600); +/// Versions listed per file, at most. +const HISTORY_LIMIT: &str = "200"; +/// Timeout for one git invocation (log, rev-parse) and for archive+extract. +const GIT_TIMEOUT: Duration = Duration::from_secs(60); + +/// Accept only hex shas. Beyond rejecting junk this is what keeps `rev` +/// option-injection-safe when handed to git as an argument: a string starting +/// with `-` can never pass. +pub fn valid_rev(rev: &str) -> bool { + (7..=64).contains(&rev.len()) && rev.bytes().all(|b| b.is_ascii_hexdigit()) +} + +/// Facade over the host `git` binary plus the extracted-tree cache. Owns only +/// paths and prune state; constructed once and shared via `Arc` (on `Skald`). +pub struct GitVersions { + trees_dir: PathBuf, + git_ok: OnceCell, + last_prune: Mutex>, +} + +impl Default for GitVersions { + fn default() -> Self { Self::new() } +} + +impl GitVersions { + pub fn new() -> Self { + Self { + trees_dir: std::env::temp_dir().join(TREES_DIR_NAME), + git_ok: OnceCell::new(), + last_prune: Mutex::new(None), + } + } + + /// `git` reachable on the host PATH (memoized). The repos are committed + /// from inside containers, but they live on host bind mounts and reading + /// them (`log`, `archive`) needs no identity or write access, so the host + /// git is sufficient — and may be absent, in which case history mode + /// simply never appears. + pub async fn available(&self) -> bool { + *self + .git_ok + .get_or_init(|| async { + Command::new("git") + .arg("--version") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .await + .map(|s| s.success()) + .unwrap_or(false) + }) + .await + } + + /// Walk up from `file` looking for a `.git`, never past `boundary` (the + /// workspace mount base) — so a dev box's own checkout above the data root + /// is never mistaken for a user's repo. Returns `(repo_root, rel)`, where + /// `rel` is `file` relative to the repo root. `.git` may be a directory or + /// a file (worktrees), hence `.exists()`. + pub fn repo_for(file: &Path, boundary: &Path) -> Option<(PathBuf, PathBuf)> { + // Both sides are canonicalized: `boundary` comes from config (lexical) + // while `file` went through symlink-resolving containment checks, so a + // symlinked component on either side would otherwise silently disable + // the boundary — and the walk would escape past the workspace. + let file = std::fs::canonicalize(file).ok()?; + let boundary = std::fs::canonicalize(boundary).unwrap_or_else(|_| boundary.to_path_buf()); + let mut dir = file.parent()?; + loop { + if dir.join(".git").exists() { + return Some((dir.to_path_buf(), file.strip_prefix(dir).ok()?.to_path_buf())); + } + if dir == boundary || !dir.starts_with(&boundary) { + return None; + } + dir = dir.parent()?; + } + } + + /// Commits that touched `rel` in `repo_root`, newest first. `--follow` + /// keeps the history across renames of the file. + pub async fn history(&self, repo_root: &Path, rel: &Path) -> Result> { + let rel = rel.to_string_lossy(); + let out = self + .git(repo_root, &["log", "--follow", "--format=%H%x1f%aI%x1f%s", "-n", HISTORY_LIMIT, "--", &rel]) + .await?; + Ok(parse_history(&String::from_utf8_lossy(&out))) + } + + /// The current HEAD sha, or `None` for a repo with no commits yet (where + /// `git log` would exit non-zero — the caller treats that as "versioned, + /// but empty" rather than an error). + pub async fn head_rev(&self, repo_root: &Path) -> Option { + let out = self.git(repo_root, &["rev-parse", "--verify", "HEAD"]).await.ok()?; + let rev = String::from_utf8_lossy(&out).trim().to_string(); + if rev.is_empty() { None } else { Some(rev) } + } + + /// Materialize the full tree at `rev` into the cache and return its + /// (canonical) root. Extraction happens once per (repo, revision): the + /// tar stream is unpacked into a staging dir atomically renamed into + /// place, so a concurrent request either waits out the race or finds the + /// finished tree. + pub async fn tree_at(&self, repo_root: &Path, rev: &str) -> Result { + debug_assert!(valid_rev(rev)); + let final_dir = self.trees_dir.join(repo_key(repo_root)).join(rev); + if final_dir.is_dir() { + return Ok(tokio::fs::canonicalize(&final_dir).await.unwrap_or(final_dir)); + } + + let staging = final_dir.with_file_name(format!(".{rev}.tmp-{}", unique_suffix())); + tokio::fs::create_dir_all(&staging).await?; + if let Err(e) = self.extract_archive(repo_root, rev, &staging).await { + let _ = tokio::fs::remove_dir_all(&staging).await; + return Err(e); + } + match tokio::fs::rename(&staging, &final_dir).await { + Ok(()) => {} + // Lost the race to a concurrent extraction — same content, use it. + Err(_) if final_dir.is_dir() => { + let _ = tokio::fs::remove_dir_all(&staging).await; + } + Err(e) => { + let _ = tokio::fs::remove_dir_all(&staging).await; + return Err(e).context("git tree cache rename failed"); + } + } + self.maybe_prune(); + Ok(tokio::fs::canonicalize(&final_dir).await.unwrap_or(final_dir)) + } + + /// The on-disk path of `rel` inside the extracted tree at `rev` — `None` + /// when the file did not exist at that revision. Canonicalize + + /// prefix-check: a symlink committed inside the repo must not lead reads + /// out of the tree (the same discipline `resolve_host_path` applies to + /// the workspace). + pub async fn file_at(&self, repo_root: &Path, rev: &str, rel: &Path) -> Result> { + let tree = self.tree_at(repo_root, rev).await?; + let candidate = tree.join(rel); + if !candidate.exists() { + return Ok(None); + } + let canon = tokio::fs::canonicalize(&candidate) + .await + .with_context(|| format!("cannot resolve {}", candidate.display()))?; + if !canon.starts_with(&tree) { + tracing::warn!(path = %candidate.display(), "git tree entry escapes the tree — refusing"); + return Ok(None); + } + Ok(Some(canon)) + } + + /// Run `git -C repo_root `, returning raw stdout. Args are passed as + /// argv (no shell); stderr text becomes the error on a non-zero exit. + async fn git(&self, repo_root: &Path, args: &[&str]) -> Result> { + let root = repo_root.to_string_lossy().into_owned(); + let mut cmd = Command::new("git"); + cmd.arg("-C").arg(&root).args(args) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + let out = match tokio::time::timeout(GIT_TIMEOUT, cmd.output()).await { + Ok(Ok(o)) => o, + Ok(Err(e)) => return Err(e).context("failed to spawn `git`"), + Err(_) => bail!("git timed out after {GIT_TIMEOUT:?}"), + }; + if out.status.success() { + Ok(out.stdout) + } else { + bail!("{}", String::from_utf8_lossy(&out.stderr).trim()) + } + } + + /// `git archive ` on stdout, piped into the host `tar` unpacking into + /// `dest`. git writes the tar itself, so path handling inside the archive + /// is git's own (always tree-relative); we never interpolate user input + /// into a command line. + async fn extract_archive(&self, repo_root: &Path, rev: &str, dest: &Path) -> Result<()> { + let root = repo_root.to_string_lossy().into_owned(); + let dest_str = dest.to_string_lossy().into_owned(); + + let mut git = Command::new("git") + .arg("-C").arg(&root) + .args(["archive", "--format=tar", rev]) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true) + .spawn() + .context("failed to spawn `git`")?; + let mut tar = Command::new("tar") + .args(["-x", "-C"]).arg(&dest_str) + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .kill_on_drop(true) + .spawn() + .context("failed to spawn `tar`")?; + + let work = async move { + let mut git_out = git.stdout.take().context("git stdout piped")?; + let mut tar_in = tar.stdin.take().context("tar stdin piped")?; + let pump = tokio::io::copy(&mut git_out, &mut tar_in).await; + drop(tar_in); // EOF, so tar can finish + let git_outcome = git.wait_with_output().await; + let tar_outcome = tar.wait_with_output().await; + // Process errors carry the useful stderr; a bare pump error + // (broken pipe) is just their symptom, so it is reported last. + let git_out = git_outcome.context("git wait failed")?; + if !git_out.status.success() { + bail!("{}", String::from_utf8_lossy(&git_out.stderr).trim()); + } + let tar_out = tar_outcome.context("tar wait failed")?; + if !tar_out.status.success() { + bail!("tar: {}", String::from_utf8_lossy(&tar_out.stderr).trim()); + } + pump?; + Ok(()) + }; + match tokio::time::timeout(GIT_TIMEOUT, work).await { + Ok(r) => r, + Err(_) => bail!("git archive timed out after {GIT_TIMEOUT:?}"), + } + } + + /// Prune the tree cache if it grew past the ceiling — at most once per + /// [`PRUNE_INTERVAL`], off the request path. Trees are immutable, so this + /// is purely a size policy: oldest extraction first. + fn maybe_prune(&self) { + { + let mut last = self.last_prune.lock().unwrap(); + let now = Instant::now(); + if last.is_some_and(|t| now.duration_since(t) < PRUNE_INTERVAL) { + return; + } + *last = Some(now); + } + let root = self.trees_dir.clone(); + tokio::task::spawn_blocking(move || prune_trees(&root, TREES_MAX_BYTES)); + } +} + +/// Parse `git log --format=%H%x1f%aI%x1f%s` output: one entry per line, fields +/// separated by U+001F. Malformed lines are skipped; entries whose first field +/// is not a sha are dropped (defence in depth — the rev round-trips into later +/// git invocations). +fn parse_history(out: &str) -> Vec { + out.lines() + .filter_map(|line| { + let mut fields = line.splitn(3, '\u{1f}'); + let rev = fields.next()?.to_string(); + let date = fields.next()?.to_string(); + let subject = fields.next()?.to_string(); + valid_rev(&rev).then_some(VersionEntry { rev, date, subject }) + }) + .collect() +} + +/// Cache-dir key for one repository: first 5 bytes of SHA-256 over its +/// canonical path (same convention as the latex cache). +fn repo_key(repo_root: &Path) -> String { + let key = std::fs::canonicalize(repo_root).unwrap_or_else(|_| repo_root.to_path_buf()); + let digest = Sha256::digest(key.to_string_lossy().as_bytes()); + digest.iter().take(5).map(|b| format!("{b:02x}")).collect() +} + +static UNIQUE_COUNTER: AtomicU64 = AtomicU64::new(0); + +/// Collision-proof suffix for staging dirs: pid + process-wide counter. +fn unique_suffix() -> String { + format!("{}-{}", std::process::id(), UNIQUE_COUNTER.fetch_add(1, Ordering::Relaxed)) +} + +/// Total size of a directory tree, best-effort (unreadable entries count 0). +fn dir_size(path: &Path) -> u64 { + let mut total = 0; + if let Ok(entries) = std::fs::read_dir(path) { + for entry in entries.flatten() { + let Ok(md) = entry.metadata() else { continue }; + if md.is_dir() { + total += dir_size(&entry.path()); + } else { + total += md.len(); + } + } + } + total +} + +/// Delete oldest extracted trees (never staging dirs) until the cache fits +/// under `cap`. Runs inside `spawn_blocking`. +fn prune_trees(root: &Path, cap: u64) { + let mut trees: Vec<(SystemTime, u64, PathBuf)> = Vec::new(); + let mut total = 0u64; + let Ok(repos) = std::fs::read_dir(root) else { return }; + for repo in repos.flatten() { + let Ok(revs) = std::fs::read_dir(repo.path()) else { continue }; + for rev in revs.flatten() { + let path = rev.path(); + let Ok(md) = rev.metadata() else { continue }; + if !md.is_dir() || rev.file_name().to_string_lossy().starts_with('.') { + continue; + } + let size = dir_size(&path); + total += size; + trees.push((md.modified().unwrap_or(SystemTime::UNIX_EPOCH), size, path)); + } + } + if total <= cap { + return; + } + trees.sort_by_key(|(modified, _, _)| *modified); + for (_, size, path) in trees { + if total <= cap { + break; + } + if std::fs::remove_dir_all(&path).is_ok() { + total = total.saturating_sub(size); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Run a git command synchronously, skipping the test when git or the + /// setup fails (CI hosts without git must not fail the suite). + fn git_sync(root: &Path, args: &[&str]) -> Result<()> { + let out = std::process::Command::new("git") + .arg("-C").arg(root) + .args(args) + .stdin(Stdio::null()) + .output() + .context("spawn git")?; + if out.status.success() { + Ok(()) + } else { + bail!("{}", String::from_utf8_lossy(&out.stderr)) + } + } + + /// A scratch dir under the OS temp dir, unique per test invocation. + fn scratch(tag: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("skald-git-versions-test-{tag}-{}", unique_suffix())); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + #[test] + fn rev_validation() { + assert!(valid_rev("a1b2c3d")); + assert!(valid_rev(&"f".repeat(40))); + assert!(valid_rev(&"9a".repeat(32))); // sha256 repos + assert!(!valid_rev("")); + assert!(!valid_rev("HEAD")); + assert!(!valid_rev("--output=/tmp/x")); // option injection + assert!(!valid_rev(&"f".repeat(65))); + assert!(!valid_rev("a1b2c3")); // too short + } + + #[test] + fn history_parsing() { + let out = "a1b2c3d\u{1f}2026-08-03T10:00:00+02:00\u{1f}first commit\n\ + e4f5a6b\u{1f}2026-08-04T11:30:00+02:00\u{1f}chapter 2: draft\n"; + let entries = parse_history(out); + assert_eq!(entries.len(), 2); + assert_eq!(entries[0].rev, "a1b2c3d"); + assert_eq!(entries[1].subject, "chapter 2: draft"); + assert!(parse_history("").is_empty()); + assert!(parse_history("garbage line without separators").is_empty()); + } + + #[test] + fn repo_discovery_respects_the_boundary() { + let root = scratch("discovery"); + let repo = root.join("workspace").join("mybook"); + let nested = repo.join("chapters"); + std::fs::create_dir_all(&nested).unwrap(); + std::fs::create_dir_all(repo.join(".git")).unwrap(); + let file = nested.join("ch1.tex"); + std::fs::write(&file, "x").unwrap(); + + // Found inside the boundary, at the project root. + let (found, rel) = GitVersions::repo_for(&file, &root.join("workspace")).unwrap(); + assert_eq!(found, std::fs::canonicalize(&repo).unwrap()); + assert_eq!(rel, Path::new("chapters").join("ch1.tex")); + + // Boundary exactly at the repo root still finds it. + assert!(GitVersions::repo_for(&file, &repo).is_some()); + + // Boundary below the repo root: no escape upwards. + assert!(GitVersions::repo_for(&file, &nested).is_none()); + + std::fs::remove_dir_all(&root).unwrap(); + } + + #[tokio::test] + async fn history_and_tree_extraction_round_trip() { + if std::process::Command::new("git").arg("--version").output().is_err() { + return; // no git on this host + } + let root = scratch("roundtrip"); + let repo = root.join("book"); + std::fs::create_dir_all(repo.join("chapters")).unwrap(); + if git_sync(&repo, &["init"]).is_err() + || git_sync(&repo, &["config", "user.email", "test@example.com"]).is_err() + || git_sync(&repo, &["config", "user.name", "Test"]).is_err() + { + std::fs::remove_dir_all(&root).unwrap(); + return; + } + std::fs::write(repo.join("chapters/ch1.tex"), "old chapter").unwrap(); + std::fs::write(repo.join("img.txt"), "old image").unwrap(); + git_sync(&repo, &["add", "-A"]).unwrap(); + git_sync(&repo, &["commit", "-m", "first"]).unwrap(); + std::fs::write(repo.join("chapters/ch1.tex"), "new chapter").unwrap(); + std::fs::write(repo.join("img.txt"), "new image").unwrap(); + git_sync(&repo, &["commit", "-am", "second"]).unwrap(); + + let gv = GitVersions::new(); + assert!(gv.available().await); + + let versions = gv.history(&repo, Path::new("chapters/ch1.tex")).await.unwrap(); + assert_eq!(versions.len(), 2); + assert_eq!(versions[0].subject, "second"); + let head = gv.head_rev(&repo).await.unwrap(); + assert_eq!(head, versions[0].rev); + + // The tree at the first revision holds the old contents — both the + // file and its "dependency". + let old = gv.file_at(&repo, &versions[1].rev, Path::new("chapters/ch1.tex")).await.unwrap().unwrap(); + assert_eq!(std::fs::read_to_string(old).unwrap(), "old chapter"); + let old_dep = gv.file_at(&repo, &versions[1].rev, Path::new("img.txt")).await.unwrap().unwrap(); + assert_eq!(std::fs::read_to_string(old_dep).unwrap(), "old image"); + + // A file that did not exist at that revision is None, not an error. + std::fs::write(repo.join("later.txt"), "added later").unwrap(); + git_sync(&repo, &["add", "-A"]).unwrap(); + git_sync(&repo, &["commit", "-m", "third"]).unwrap(); + assert!(gv.file_at(&repo, &versions[1].rev, Path::new("later.txt")).await.unwrap().is_none()); + + // Extraction is cached: the second call returns the same canonical dir. + let t1 = gv.tree_at(&repo, &versions[1].rev).await.unwrap(); + let t2 = gv.tree_at(&repo, &versions[1].rev).await.unwrap(); + assert_eq!(t1, t2); + + std::fs::remove_dir_all(&root).unwrap(); + std::fs::remove_dir_all(t1).unwrap(); + } +} diff --git a/crates/skald-core/src/lib.rs b/crates/skald-core/src/lib.rs index 9c3cf8f..b0a9239 100644 --- a/crates/skald-core/src/lib.rs +++ b/crates/skald-core/src/lib.rs @@ -23,6 +23,7 @@ pub mod elicitation; pub mod cron; pub mod db; pub mod events; +pub mod git_versions; pub mod image_generate; pub mod i18n; pub mod inbox; diff --git a/crates/skald-core/src/skald/accessors.rs b/crates/skald-core/src/skald/accessors.rs index 321ee71..47e8474 100644 --- a/crates/skald-core/src/skald/accessors.rs +++ b/crates/skald-core/src/skald/accessors.rs @@ -28,6 +28,7 @@ use crate::cron::TaskManager; use crate::elicitation::ElicitationManager; use crate::image_generate::ImageGeneratorManager; use crate::inbox::Inbox; +use crate::git_versions::GitVersions; use crate::latex::LatexCompiler; use crate::llm::LlmManager; use crate::location::LocationManager; @@ -405,6 +406,7 @@ impl Skald { // Infra pub fn latex_compiler(&self) -> &LatexCompiler { &self.infra.latex_compiler } + pub fn git_versions(&self) -> &GitVersions { &self.infra.git_versions } pub fn location_manager(&self) -> &Arc { &self.infra.location_manager } pub fn remote(&self) -> &Arc>>> { &self.infra.remote } diff --git a/crates/skald-core/src/skald/bundles.rs b/crates/skald-core/src/skald/bundles.rs index 9ea97c8..0b2b6ad 100644 --- a/crates/skald-core/src/skald/bundles.rs +++ b/crates/skald-core/src/skald/bundles.rs @@ -24,6 +24,7 @@ use crate::cron::TaskManager; use crate::elicitation::ElicitationManager; use crate::image_generate::ImageGeneratorManager; use crate::inbox::Inbox; +use crate::git_versions::GitVersions; use crate::latex::LatexCompiler; use crate::llm::LlmManager; use crate::location::LocationManager; @@ -440,6 +441,7 @@ impl Conversation { pub(super) struct Infra { pub(super) latex_compiler: LatexCompiler, + pub(super) git_versions: GitVersions, pub(super) location_manager: Arc, pub(super) remote: Arc>>>, } @@ -448,6 +450,7 @@ impl Infra { pub(super) fn build() -> Self { Infra { latex_compiler: LatexCompiler::new(), + git_versions: GitVersions::new(), location_manager: Arc::new(LocationManager::new()), remote: Arc::new(RwLock::new(None)), } diff --git a/docs/projects.md b/docs/projects.md index 958362c..9d9c693 100644 --- a/docs/projects.md +++ b/docs/projects.md @@ -49,6 +49,8 @@ For **Markdown** files (`.md`), if you have write access the viewer has two tabs Because the same file may be edited at the same time by another member, another of your tabs, or the assistant, saving is protected against silent overwrites: if the file changed on the server *after* you started editing, you'll see a banner — **Reload remote** (discard your edits and take the newer version), **Copy mine, then reload** (copy your edits to the clipboard, then take the remote version), or **Overwrite** (force your version). So no one's work is ever lost without you choosing. +**Looking back in time.** If the project keeps a history (see *Notes* below), the file viewer shows a small **clock button** in its header, next to the download button. Clicking it lists the snapshots of that file — when each was taken and the note the assistant wrote at the time. Picking one shows the file **as it was in that snapshot**: a banner on top reminds you which version you're looking at, everything is read-only, and **Back to current** returns to today's file. Documents made of several pieces travel together: a LaTeX book is re-compiled with the chapters and images *of that moment*, and a Markdown page shows the images as they were then — not today's. The download button, while you are viewing a snapshot, downloads that older version. + If you have write access you can also, from the toolbar or each row: - **New folder** — create a subfolder in the current location. @@ -73,4 +75,4 @@ Access changes apply immediately — no need for the other person to log out. - A private project is simply a project with one member (you). Share it later whenever you want. - Renaming a project does not move its folder, so links and the assistant's context keep working. - Deleting a project removes its folder for everyone — there is no undo. -- The assistant may offer to **keep a history** of the project: a trail of snapshots you can look back on, or return to if something goes wrong. If you accept, it notes that in the project's `SKALD.md` and saves a new snapshot whenever the project reaches a meaningful milestone. The history lives inside the project folder on the server (it is powered by git, but you never need to touch it). +- The assistant may offer to **keep a history** of the project: a trail of snapshots you can look back on, or return to if something goes wrong. If you accept, it notes that in the project's `SKALD.md` and saves a new snapshot whenever the project reaches a meaningful milestone. The history lives inside the project folder on the server (it is powered by git, but you never need to touch it). You browse the snapshots of any file from the file viewer's clock button (see *Looking back in time* above). diff --git a/src/frontend/api/files.rs b/src/frontend/api/files.rs index 70308d0..74223ec 100644 --- a/src/frontend/api/files.rs +++ b/src/frontend/api/files.rs @@ -12,6 +12,7 @@ use serde::{Deserialize, Serialize}; use std::sync::Arc; use core_api::user_fs::UserFs; use skald_core::db::memory_docs; +use skald_core::git_versions::{self, GitVersions}; use skald_core::session::handler::media; use skald_core::skald::Skald; use skald_core::latex::CompileError; @@ -322,6 +323,14 @@ pub struct FileQuery { /// inline. For a compiled `.tex` the attachment name is `.pdf`. #[serde(rename = "force_download", default)] pub force_download: bool, + /// Serve the file as of a git revision (a sha from + /// `GET /api/file/versions`). The bytes come from a full copy of the + /// repository extracted at that revision — never from the working tree — + /// so dependency-bearing formats (a `.tex`'s `\input`s and images, a + /// markdown file's relative assets) resolve against contemporaneous + /// files. See [`skald_core::git_versions`]. + #[serde(default)] + pub rev: Option, } /// Serve a file's raw bytes with a `Content-Type` derived from its extension. @@ -380,6 +389,17 @@ pub async fn get_file( }; let writable = user_fs.can_write_to(&agent); + // History mode: a revision is served from the extracted tree, and only + // host-backed files can have a git history at all. + if let Some(rev) = q.rev.as_deref() { + let fs_tools::FsTarget::Host(abs) = &target else { + return (StatusCode::BAD_REQUEST, format!( + "history is only available for files in your mounted folders: {}", q.path + )).into_response(); + }; + return get_file_at_rev(&state, &q, rev, abs, &user_fs, &agent).await; + } + // A container-only path (`/tmp/…`) has no host file behind it: the bytes come // out through the container, so the user sees what the agent read. Served // read-only — the editor's optimistic locking is an on-disk `mtime`+`len`, @@ -449,7 +469,157 @@ pub async fn get_file( } } -/// Mark a response as a browser download via `Content-Disposition: attachment`. +/// History-mode half of [`get_file`]: serve `path` as of git revision `rev`. +/// +/// The file is read from the repository tree extracted at `rev` by +/// [`skald_core::git_versions`], so anything the format pulls in relatively +/// (LaTeX `\input`s and `\includegraphics`, markdown images) is +/// contemporaneous with the file itself. A revision is immutable: the rev +/// itself is the ETag, and there is deliberately no `X-Writable` — history is +/// read-only, so the viewer never offers the editor on it. +async fn get_file_at_rev( + state: &Arc, + q: &FileQuery, + rev: &str, + abs: &Path, + user_fs: &UserFs, + agent: &str, +) -> Response { + if !git_versions::valid_rev(rev) { + return (StatusCode::BAD_REQUEST, format!("invalid revision: {rev}")).into_response(); + } + let gv = state.git_versions(); + if !gv.available().await { + return (StatusCode::NOT_IMPLEMENTED, "git is not available on the server").into_response(); + } + let base = match user_fs.host_base_and_tail(agent) { + Ok((base, _)) => base, + Err(e) => return (StatusCode::BAD_REQUEST, format!("Invalid path: {e}")).into_response(), + }; + let Some((repo, rel)) = GitVersions::repo_for(abs, &base) else { + return (StatusCode::NOT_FOUND, format!("not under git version control: {}", q.path)) + .into_response(); + }; + let file = match gv.file_at(&repo, rev, &rel).await { + Ok(Some(f)) => f, + Ok(None) => { + return (StatusCode::NOT_FOUND, format!("{} did not exist at {rev}", q.path)) + .into_response() + } + Err(e) => { + return (StatusCode::BAD_REQUEST, format!("cannot read revision {rev}: {e:#}")) + .into_response() + } + }; + + if q.compile_latex && is_latex(&q.path) { + return match state.latex_compiler().compile(&file).await { + Ok(pdf) => { + let mut response = pdf_response(pdf.bytes); + if q.force_download { + set_attachment(&mut response, &pdf_download_name(&q.path)); + } + response + } + Err(err) => compile_error_response(err), + }; + } + + match tokio::fs::read(&file).await { + Ok(bytes) => { + let mut response = bytes.into_response(); + response.headers_mut().insert( + header::CONTENT_TYPE, + HeaderValue::from_static(content_type_for(&q.path)), + ); + if let Ok(value) = HeaderValue::from_str(&format!("\"{rev}\"")) { + response.headers_mut().insert(header::ETAG, value); + } + if q.force_download { + set_attachment(&mut response, &basename(&q.path)); + } + response + } + Err(_) => (StatusCode::NOT_FOUND, format!("File not found: {}", q.path)).into_response(), + } +} + +#[derive(Deserialize)] +pub struct VersionsQuery { + pub path: String, +} + +#[derive(Serialize)] +pub struct FileVersions { + pub versioned: bool, + /// HEAD at query time, so the client can mark the current version in the + /// list. `None` on a repo with no commits yet. + #[serde(skip_serializing_if = "Option::is_none")] + pub current_rev: Option, + pub versions: Vec, +} + +fn not_versioned() -> Json { + Json(FileVersions { versioned: false, current_rev: None, versions: Vec::new() }) +} + +/// GET /api/file/versions?path=… — the git history of a workspace file, +/// backing the file viewer's history-mode button. +/// +/// Deliberately *not* an error surface: anything that cannot have a history +/// (memory notes, container-only paths, files outside any repository, a host +/// without git) answers `versioned: false`, and the client hides the button. +/// A repository with no commits yet is `versioned: true` with an empty list. +pub async fn list_file_versions( + State(state): State>, + Extension(auth): Extension, + Query(q): Query, +) -> Response { + let ctx = match require_context(&state, &auth.user_id).await { + Ok(c) => c, + Err(e) => return e.into_response(), + }; + if fs_tools::classify_memory(&q.path).is_some() { + return not_versioned().into_response(); + } + let user_fs = ctx.fs.load(); + let (target, agent) = match fs_tools::resolve_view_target(user_fs.as_ref(), &q.path) { + Ok(resolved) => resolved, + Err(e) => return (StatusCode::BAD_REQUEST, format!("Invalid path: {e}")).into_response(), + }; + let fs_tools::FsTarget::Host(abs) = target else { + return not_versioned().into_response(); + }; + let Ok((base, _)) = user_fs.host_base_and_tail(&agent) else { + return not_versioned().into_response(); + }; + let Some((repo, rel)) = GitVersions::repo_for(&abs, &base) else { + return not_versioned().into_response(); + }; + let gv = state.git_versions(); + if !gv.available().await { + return not_versioned().into_response(); + } + let Some(head) = gv.head_rev(&repo).await else { + return Json(FileVersions { versioned: true, current_rev: None, versions: Vec::new() }) + .into_response(); + }; + match gv.history(&repo, &rel).await { + Ok(versions) => Json(FileVersions { + versioned: true, + current_rev: Some(head), + versions, + }) + .into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("cannot read git history: {e:#}"), + ) + .into_response(), + } +} + + /// /// HTTP header values must be visible ASCII, so the filename is sanitised /// (quotes, backslashes and non-ASCII bytes become `_`). This keeps it diff --git a/src/frontend/api/mod.rs b/src/frontend/api/mod.rs index 0422c18..72d3539 100644 --- a/src/frontend/api/mod.rs +++ b/src/frontend/api/mod.rs @@ -237,6 +237,7 @@ pub fn router() -> Router> { .route("/file/upload", post(files::upload_file) .layer(DefaultBodyLimit::max(files::MAX_UPLOAD_BYTES))) .route("/file/download", get(files::download_dir)) + .route("/file/versions", get(files::list_file_versions)) .route("/file", put(files::save_file)) .route("/file", patch(files::rename_file)) .route("/file", delete(files::delete_file)) diff --git a/web/components/file-viewer-page.js b/web/components/file-viewer-page.js index 31e8f84..15ca15f 100644 --- a/web/components/file-viewer-page.js +++ b/web/components/file-viewer-page.js @@ -65,12 +65,14 @@ export class FileViewerPage extends FileViewerBase {

${this._path ?? ''}

+ ${this._renderHistoryButton('btn btn-sm btn-outline-secondary fv-download-btn')} ${this._renderModeToggle('btn btn-sm btn-outline-secondary fv-download-btn')}
+ ${this._renderVersionBanner()}
${this._renderBody()}
`; diff --git a/web/components/shared/file-viewer-base.js b/web/components/shared/file-viewer-base.js index 6905a81..28a0c80 100644 --- a/web/components/shared/file-viewer-base.js +++ b/web/components/shared/file-viewer-base.js @@ -78,12 +78,16 @@ function normalizePath(p) { * Resolve an asset reference found inside a markdown file. External URLs, data * URIs, protocol-relative and root-relative paths are left untouched; a path * relative to the markdown file's directory is routed through `/api/file` so it - * loads from disk instead of resolving against the SPA origin. + * loads from disk instead of resolving against the SPA origin. In history mode + * (`rev` set) the asset is served from the same extracted tree as the markdown + * itself, so the image is contemporaneous with the text referencing it. */ -function resolveAssetSrc(src, baseDir) { +function resolveAssetSrc(src, baseDir, rev) { if (!src || /^([a-z][a-z0-9+.-]*:|\/\/|#|\/)/i.test(src)) return src; const joined = baseDir ? `${baseDir}/${src}` : src; - return `/api/file?path=${encodeURIComponent(normalizePath(joined))}`; + let url = `/api/file?path=${encodeURIComponent(normalizePath(joined))}`; + if (rev) url += `&rev=${encodeURIComponent(rev)}`; + return url; } /** @@ -91,13 +95,13 @@ function resolveAssetSrc(src, baseDir) { * against the markdown file's location on disk (via `/api/file`). Parsed in an * inert