feat(file-viewer): browse a versioned file's git history
Nightly Build / build (push) Successful in 8m5s

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.
This commit is contained in:
Daniele
2026-08-10 14:27:48 +01:00
parent cd641ab89e
commit 1515492938
14 changed files with 950 additions and 15 deletions
+500
View File
@@ -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<bool>,
last_prune: Mutex<Option<Instant>>,
}
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<Vec<VersionEntry>> {
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<String> {
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<PathBuf> {
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<Option<PathBuf>> {
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 <args>`, 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<Vec<u8>> {
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 <rev>` 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<VersionEntry> {
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();
}
}
+1
View File
@@ -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;
+2
View File
@@ -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<LocationManager> { &self.infra.location_manager }
pub fn remote(&self) -> &Arc<RwLock<Option<Arc<dyn RemoteAccess>>>> { &self.infra.remote }
+3
View File
@@ -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<LocationManager>,
pub(super) remote: Arc<RwLock<Option<Arc<dyn RemoteAccess>>>>,
}
@@ -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)),
}
+3 -1
View File
@@ -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).
+171 -1
View File
@@ -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 `<stem>.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<String>,
}
/// 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<Skald>,
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<String>,
pub versions: Vec<git_versions::VersionEntry>,
}
fn not_versioned() -> Json<FileVersions> {
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<Arc<Skald>>,
Extension(auth): Extension<AuthUser>,
Query(q): Query<VersionsQuery>,
) -> 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
+1
View File
@@ -237,6 +237,7 @@ pub fn router() -> Router<Arc<Skald>> {
.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))
+2
View File
@@ -65,12 +65,14 @@ export class FileViewerPage extends FileViewerBase {
<h2 class="page-header-title fv-title" title=${this._path ?? ''}><bdi>${this._path ?? ''}</bdi></h2>
</div>
<div class="fv-header-actions">
${this._renderHistoryButton('btn btn-sm btn-outline-secondary fv-download-btn')}
${this._renderModeToggle('btn btn-sm btn-outline-secondary fv-download-btn')}
<button class="btn btn-sm btn-outline-secondary fv-download-btn" title=${t('fv.download')} @click=${() => this._download()}>
<i class="bi bi-download"></i>
</button>
</div>
</div>
${this._renderVersionBanner()}
<div class="fv-body">${this._renderBody()}</div>
</div>
`;
+157 -13
View File
@@ -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 <template> so the original (broken) URLs never trigger a fetch.
*/
function rewriteMarkdownAssets(htmlStr, baseDir) {
function rewriteMarkdownAssets(htmlStr, baseDir, rev) {
const tpl = document.createElement('template');
tpl.innerHTML = htmlStr;
let changed = false;
for (const img of tpl.content.querySelectorAll('img[src]')) {
const src = img.getAttribute('src');
const resolved = resolveAssetSrc(src, baseDir);
const resolved = resolveAssetSrc(src, baseDir, rev);
if (resolved !== src) { img.setAttribute('src', resolved); changed = true; }
}
return changed ? tpl.innerHTML : htmlStr;
@@ -146,6 +150,12 @@ export class FileViewerBase extends LightElement {
_canWrite: { state: true }, // caller may edit this path (X-Writable)
_conflict: { state: true }, // remote changed while editing — show the banner
_saving: { state: true },
// ── History mode (git-versioned files) ───────────────────────────────────
_versions: { state: true }, // null = not versioned/unknown; array = commits, newest first
_currentRev: { state: true }, // HEAD sha at load time
_rev: { state: true }, // revision being viewed (null = current working tree)
_revInfo: { state: true }, // the versions entry of _rev (drives the banner)
_historyOpen: { state: true }, // the versions popover
};
constructor() {
@@ -165,6 +175,11 @@ export class FileViewerBase extends LightElement {
this._canWrite = false;
this._conflict = false;
this._saving = false;
this._versions = null;
this._currentRev = null;
this._rev = null;
this._revInfo = null;
this._historyOpen = false;
this._watchPath = null; // path currently being watched (async-verified)
this._watchUnsub = null; // unsubscribe function returned by fileWatcher
this._reloadTimer = null; // debounce timer for change-triggered reloads
@@ -187,6 +202,13 @@ export class FileViewerBase extends LightElement {
// silently is the worse failure mode. (Accepted wrinkle: the hash has
// already moved; we don't fight the router here.)
if (this._editDirty && !confirm(t('fv.dirty_warn'))) return;
// Navigating to another file leaves any history mode behind.
if (path !== this._path) {
this._rev = null;
this._revInfo = null;
this._historyOpen = false;
this._versions = null;
}
this._setupWatch(path);
this._load(path);
}
@@ -206,17 +228,28 @@ export class FileViewerBase extends LightElement {
_download() {
const path = this._path;
if (!path) return;
const params = new URLSearchParams({ path });
if (this._kind === 'latex') params.set('compile-latex', 'true');
params.set('force_download', 'true');
// In history mode the download is the version being viewed.
const extra = { force_download: 'true' };
if (this._kind === 'latex') extra['compile-latex'] = 'true';
const a = document.createElement('a');
a.href = `/api/file?${params.toString()}`;
a.href = this._fileUrl(path, extra);
a.download = ''; // server Content-Disposition supplies the name
document.body.appendChild(a);
a.click();
a.remove();
}
/**
* The one place `/api/file` URLs are built — in history mode every fetch
* (content, compiled LaTeX, markdown assets, downloads) carries the same
* `rev`, so the whole view comes from the tree at that revision.
*/
_fileUrl(path, extra = {}) {
const params = new URLSearchParams({ path, ...extra });
if (this._rev) params.set('rev', this._rev);
return `/api/file?${params.toString()}`;
}
_revokeBlobUrl() {
if (this._blobUrl) {
URL.revokeObjectURL(this._blobUrl);
@@ -237,6 +270,11 @@ export class FileViewerBase extends LightElement {
this._etag = null;
this._canWrite = false;
this._conflict = false;
this._rev = null;
this._revInfo = null;
this._historyOpen = false;
this._versions = null;
this._currentRev = null;
this._revokeBlobUrl();
}
@@ -254,13 +292,15 @@ export class FileViewerBase extends LightElement {
this._conflict = false;
this._revokeBlobUrl();
this._loading = true;
this._versions = null; // no stale history button while the new file loads
this._loadVersions(path);
} else {
// Silent reload (file changed externally): keep showing the old content
// until the new fetch lands; only update visible state on success.
this._error = null;
}
try {
const url = `/api/file?path=${encodeURIComponent(path)}`;
const url = this._fileUrl(path);
if (this._kind === 'image' || this._kind === 'pdf' || this._kind === 'svg') {
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
@@ -299,7 +339,7 @@ export class FileViewerBase extends LightElement {
* message so the user can see why the compile failed.
*/
async _loadLatex(path) {
const compileUrl = `/api/file?path=${encodeURIComponent(path)}&compile-latex=true`;
const compileUrl = this._fileUrl(path, { 'compile-latex': 'true' });
try {
const res = await fetch(compileUrl);
if (res.ok) {
@@ -322,11 +362,60 @@ export class FileViewerBase extends LightElement {
}
// Fallback: fetch the raw .tex source.
this._revokeBlobUrl();
const res = await fetch(`/api/file?path=${encodeURIComponent(path)}`);
const res = await fetch(this._fileUrl(path));
if (!res.ok) throw new Error(`HTTP ${res.status}`);
this._content = await res.text();
}
// ── History mode (git-versioned files) ─────────────────────────────────────
/**
* Load the version list for `path`. Anything that cannot have a history
* answers `versioned: false` and the clock button simply never appears —
* this fetch failing is therefore silent by design.
*/
async _loadVersions(path) {
try {
const res = await fetch(`/api/file/versions?path=${encodeURIComponent(path)}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
// Race: the viewer may have navigated away while the fetch was in flight.
if (path !== this._path) return;
this._versions = data.versioned ? (data.versions || []) : null;
this._currentRev = data.current_rev || null;
} catch {
this._versions = null;
this._currentRev = null;
}
}
/** Open one version from the popover. Selecting HEAD is "back to current". */
_selectVersion(entry) {
this._historyOpen = false;
if (!entry || entry.rev === this._currentRev) {
this._backToCurrent();
return;
}
this._rev = entry.rev;
this._revInfo = entry;
this._load(this._path);
}
_backToCurrent() {
this._historyOpen = false;
if (!this._rev) return;
this._rev = null;
this._revInfo = null;
this._load(this._path);
}
_fmtVersionDate(iso) {
const d = new Date(iso);
if (isNaN(d)) return iso;
return d.toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' }) +
' ' + d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
}
// ── File watcher ────────────────────────────────────────────────────────────
_setupWatch(path) {
@@ -359,6 +448,10 @@ export class FileViewerBase extends LightElement {
}
_onFileChanged() {
// History mode shows an immutable revision: working-tree changes must not
// yank the user back to the present. The watcher re-engages on "back to
// current".
if (this._rev) return;
// Debounce: collapse bursts of FS events into a single reload. `_watchPath`
// is cleared by `_teardownWatch` (called on hide/path-change), so a queued
// change never reloads a file the viewer has already navigated away from.
@@ -512,6 +605,57 @@ export class FileViewerBase extends LightElement {
</div>`;
}
/**
* The history button + versions popover, rendered in the header of git-
* versioned files. Returns `nothing` when the file has no versions, so
* subclasses can drop it unconditionally into their header. `btnClass`
* carries the chrome-specific button styling (desktop vs mobile).
*/
_renderHistoryButton(btnClass) {
if (!this._versions?.length) return nothing;
return html`<span class="fv-history">
<button class=${btnClass} title=${t('fv.history')}
@click=${() => { this._historyOpen = !this._historyOpen; }}>
<i class="bi bi-clock-history"></i>
</button>
${this._historyOpen ? html`
<div class="fv-history-overlay" @click=${() => { this._historyOpen = false; }}></div>
<div class="fv-history-pop" role="menu">
${this._versions.map(v => html`
<button class="fv-history-row ${v.rev === this._rev ? 'active' : ''}" role="menuitem"
@click=${() => this._selectVersion(v)}>
<span class="fv-history-date">
${this._fmtVersionDate(v.date)}
${v.rev === this._currentRev
? html`<span class="fv-history-current">${t('fv.current')}</span>`
: nothing}
</span>
<span class="fv-history-subject"><bdi>${v.subject}</bdi></span>
</button>`)}
</div>` : nothing}
</span>`;
}
/**
* The history banner: shown while viewing a past revision — what it is, and
* the way back. Rendered by the subclasses between header and body.
*/
_renderVersionBanner() {
if (!this._rev) return nothing;
const v = this._revInfo;
return html`<div class="fv-version-banner" role="status">
<i class="bi bi-clock-history"></i>
<span class="fv-version-text">
${t('fv.version_banner', { date: v ? this._fmtVersionDate(v.date) : this._rev.slice(0, 7) })}${v?.subject
? html` — <bdi>${v.subject}</bdi>`
: nothing}
</span>
<button class="btn btn-sm btn-outline-secondary" @click=${() => this._backToCurrent()}>
<i class="bi bi-arrow-counterclockwise"></i>&nbsp;${t('fv.back_to_current')}
</button>
</div>`;
}
/**
* The conflict banner: shown while editing when the file was modified
* remotely (another user / tab / agent) after our buffer diverged. Three
@@ -607,7 +751,7 @@ export class FileViewerBase extends LightElement {
// View/Edit is a live preview of what you're writing — not a flashback to
// the on-disk content.
const mdSrc = editing || this._editDirty ? this._editBuffer : this._content;
const rendered = rewriteMarkdownAssets(renderMarkdown(mdSrc), dirOf(this._path || ''));
const rendered = rewriteMarkdownAssets(renderMarkdown(mdSrc), dirOf(this._path || ''), this._rev);
return html`<div class="fv-md-wrap">
${this._canWrite ? this._renderMdTabs() : nothing}
${editing
@@ -50,12 +50,14 @@ export class MobileFileViewerPage extends FileViewerBase {
<span class="fv-mobile-name" title=${this.path ?? ''}><bdi>${this._basename()}</bdi></span>
</span>
<span class="fv-header-actions">
${this._renderHistoryButton('chat-page-back')}
${this._renderModeToggle('chat-page-back')}
<button class="chat-page-back" title=${t('fv.download')} @click=${() => this._download()}>
<i class="bi bi-download"></i>
</button>
</span>
</div>
${this._renderVersionBanner()}
<div class="fv-body">${this._renderBody()}</div>
</div>
`;
+96
View File
@@ -260,6 +260,102 @@
flex-shrink: 0;
}
/* ── History mode (git versions) ───────────────────────────────────────────── */
.fv-history {
position: relative;
display: inline-flex;
}
/* Transparent click-catcher that closes the popover on outside click. */
.fv-history-overlay {
position: fixed;
inset: 0;
z-index: 1040;
}
.fv-history-pop {
position: absolute;
top: calc(100% + 6px);
right: 0;
z-index: 1050;
display: flex;
flex-direction: column;
gap: 2px;
min-width: 260px;
max-width: min(340px, 80vw);
max-height: 320px;
overflow-y: auto;
padding: 4px;
background: var(--card-bg);
border: 1px solid var(--card-border);
border-radius: var(--radius-md);
box-shadow: var(--card-shadow);
}
.fv-history-row {
display: flex;
flex-direction: column;
gap: 1px;
padding: 0.45rem 0.6rem;
text-align: left;
background: none;
border: 0;
border-radius: var(--radius-sm);
color: var(--bs-body-color);
cursor: pointer;
}
.fv-history-row:hover { background: var(--sidebar-hover); }
.fv-history-row.active { background: var(--sidebar-active-bg); }
.fv-history-date {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.72rem;
color: var(--bs-secondary-color);
}
.fv-history-current {
padding: 0 0.35rem;
font-size: 0.66rem;
border-radius: 999px;
background: var(--accent-soft);
color: var(--accent);
}
.fv-history-subject {
font-size: 0.8rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Banner shown between header and body while viewing a past revision. */
.fv-version-banner {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.6rem;
padding: 0.45rem 0.85rem;
background: var(--bs-info-bg-subtle, #cff4fc);
border-bottom: 1px solid var(--bs-info-border-subtle, #9eeaf9);
color: var(--bs-emphasis-color, inherit);
flex-shrink: 0;
}
.fv-version-banner > .bi {
color: var(--bs-info-text-emphasis, #055160);
flex-shrink: 0;
}
.fv-version-text {
flex: 1 1 200px;
min-width: 0;
font-size: 0.82rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ── PDF preview ─────────────────────────────────────────────────────────────── */
/* PDFs are drawn by <pdf-view> (web/components/shared/pdf-view.js) on canvas, not
+4
View File
@@ -1040,6 +1040,10 @@ export default {
'fv.zoom_in': 'Zoom in',
'fv.zoom_out': 'Zoom out',
'fv.pdf_failed': 'This PDF could not be displayed.',
'fv.history': 'History',
'fv.current': 'current',
'fv.version_banner': 'Version of {date} — read-only',
'fv.back_to_current': 'Back to current',
// ── Marketplace ─────────────────────────────────────────────────────────────
'marketplace.title': 'Marketplace',
+4
View File
@@ -1030,6 +1030,10 @@ export default {
'fv.zoom_in': 'Agrandir',
'fv.zoom_out': 'Réduire',
'fv.pdf_failed': 'Impossible dafficher ce PDF.',
'fv.history': 'Historique',
'fv.current': 'actuelle',
'fv.version_banner': 'Version du {date} — lecture seule',
'fv.back_to_current': 'Retour à la version actuelle',
// ── Marketplace ─────────────────────────────────────────────────────────────
'marketplace.title': 'Marketplace',
+4
View File
@@ -1030,6 +1030,10 @@ export default {
'fv.zoom_in': 'Ingrandisci',
'fv.zoom_out': 'Riduci',
'fv.pdf_failed': 'Impossibile visualizzare questo PDF.',
'fv.history': 'Cronologia',
'fv.current': 'attuale',
'fv.version_banner': 'Versione del {date} — sola lettura',
'fv.back_to_current': 'Torna allattuale',
// ── Marketplace ──────────────────────────────────────────────────────────────
'marketplace.title': 'Marketplace',