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
+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))