From 3c52587dee2db2f74e201793ea54c21d04bdfcb0 Mon Sep 17 00:00:00 2001 From: xavix-yo Date: Thu, 23 Jul 2026 22:03:43 +0100 Subject: [PATCH] file viewer: edit Markdown with optimistic-lock conflict detection - GET /api/file returns ETag (mtime+size) + X-Writable on disk files; PUT /api/file accepts optional if_match -> 409 Conflict on stale version (last-write-wins preserved when omitted), echoes the new ETag - FileViewerBase: View | Edit tabs for .md when the caller can write; source textarea with Save/Cancel, live preview while editing - Watcher no longer clobbers the buffer mid-edit: while editing with unsaved changes it probes the server ETag and only raises a conflict when the remote actually moved on (own-save echo is ignored) - Conflict banner: Reload remote | Copy mine, then reload | Overwrite - i18n (en/it/fr) + CSS; docs/projects.md updated --- docs/projects.md | 7 + src/frontend/api/files.rs | 69 ++++++- src/frontend/api/mod.rs | 7 + web/components/shared/file-viewer-base.js | 215 +++++++++++++++++++++- web/css/file-viewer.css | 118 ++++++++++++ web/i18n/en.js | 13 ++ web/i18n/fr.js | 13 ++ web/i18n/it.js | 13 ++ 8 files changed, 446 insertions(+), 9 deletions(-) diff --git a/docs/projects.md b/docs/projects.md index f5fb241..f9e6a47 100644 --- a/docs/projects.md +++ b/docs/projects.md @@ -30,6 +30,13 @@ A file explorer rooted at the project folder: - Click a **folder** to navigate into it. - The listing **updates by itself**: if another member or the assistant creates, renames or deletes a file while you're looking at a folder, the change appears within a second — no refresh needed. +For **Markdown** files (`.md`), if you have write access the viewer has two tabs: + +- **View** — the rendered document (the default). +- **Edit** — edit the Markdown source directly. Switch back to View any time to preview your changes; **Save** writes the file, **Cancel** discards. + +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. + If you have write access you can also, from the toolbar or each row: - **New folder** — create a subfolder in the current location. diff --git a/src/frontend/api/files.rs b/src/frontend/api/files.rs index dbd86df..3094f9c 100644 --- a/src/frontend/api/files.rs +++ b/src/frontend/api/files.rs @@ -4,7 +4,7 @@ use axum::{ Extension, Json, body::Bytes, extract::{Query, State}, - http::{HeaderValue, StatusCode, header}, + http::{HeaderValue, HeaderMap, StatusCode, header}, response::{IntoResponse, Response}, }; use serde::{Deserialize, Serialize}; @@ -58,6 +58,22 @@ fn require_write(fs: &UserFs, agent: &str) -> Result<(), ApiError> { } } +/// A lightweight version token for a file on disk, used as a strong-ish `ETag` +/// for the editor's optimistic locking. It is *not* a content hash: it combines +/// the mtime (nanosecond precision on the local filesystems we run on) and the +/// size, which is enough to detect "someone wrote after you loaded" without the +/// cost of hashing every served file (including images/PDFs). Two distinct +/// writes with identical size in the same nanosecond would collide — acceptable +/// for a small-instance, last-write-wins-becomes-visible contract. +fn disk_etag(md: &std::fs::Metadata) -> String { + let mtime_ns = md.modified() + .ok() + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_nanos()) + .unwrap_or(0); + format!("\"{}-{}\"", mtime_ns, md.len()) +} + /// GET /api/files/dir?path=… — the immediate children of a directory (dirs /// first, then name), resolved and scoped exactly like `GET /api/file`. pub async fn list_dir( @@ -182,10 +198,11 @@ pub async fn get_file( } let user_fs = ctx.fs.load(); - let abs = match fs_tools::resolve_view_path(user_fs.as_ref(), &q.path) { - Ok((abs, _)) => abs, - Err(e) => return (StatusCode::BAD_REQUEST, format!("Invalid path: {e}")).into_response(), + let (abs, agent) = match fs_tools::resolve_view_path(user_fs.as_ref(), &q.path) { + Ok((abs, agent)) => (abs, agent), + Err(e) => return (StatusCode::BAD_REQUEST, format!("Invalid path: {e}")).into_response(), }; + let writable = user_fs.can_write_to(&agent); if q.compile_latex && is_latex(&q.path) { return match state.latex_compiler().compile(&abs).await { @@ -207,6 +224,20 @@ pub async fn get_file( header::CONTENT_TYPE, HeaderValue::from_static(content_type_for(&q.path)), ); + // Optimistic-locking version token + write flag for the editor. + // Only disk files are editable through this surface, so both come + // from the on-disk metadata / `UserFs` membership snapshot. + if let Ok(md) = tokio::fs::metadata(&abs).await { + if let Ok(v) = HeaderValue::from_str(&disk_etag(&md)) { + response.headers_mut().insert(header::ETAG, v); + } + } + if writable { + response.headers_mut().insert( + header::HeaderName::from_static("x-writable"), + HeaderValue::from_static("1"), + ); + } if q.force_download { set_attachment(&mut response, &basename(&q.path)); } @@ -327,6 +358,14 @@ fn content_type_for(path: &str) -> &'static str { pub struct SavePayload { pub path: String, pub content: String, + /// Optional optimistic-locking token (the `ETag` returned by `GET /api/file` + /// when the editor loaded the file). When present, the save only succeeds + /// if the file on disk still matches; otherwise the handler returns + /// `409 Conflict` so the editor can prompt (reload remote / overwrite / + /// copy my changes) instead of silently clobbering a concurrent write. + /// Absent ⇒ legacy last-write-wins behaviour (no caller is broken). + #[serde(default)] + pub if_match: Option, } #[derive(Deserialize)] @@ -389,7 +428,7 @@ pub async fn save_file( State(state): State>, Extension(auth): Extension, Json(body): Json, -) -> Result { +) -> Result<(StatusCode, HeaderMap), ApiError> { let ctx = require_context(&state, &auth.user_id).await?; let fs = ctx.fs.load(); let (abs, display) = fs_tools::resolve_view_path(fs.as_ref(), &body.path) @@ -398,8 +437,26 @@ pub async fn save_file( if !abs.exists() { return Err(anyhow::anyhow!("File not found: {display}").into()); } + // Optimistic locking: if the caller pinned a version, refuse to overwrite a + // file that changed underneath it. We treat a missing file here as a + // conflict too (the base it was edited against is gone). + if let Some(expected) = body.if_match.as_deref() { + let current = std::fs::metadata(&abs).ok().map(|m| disk_etag(&m)); + if current.as_deref() != Some(expected) { + return Err(ApiError::conflict(format!( + "File modified remotely: {display}" + ))); + } + } std::fs::write(&abs, &body.content)?; - Ok(StatusCode::NO_CONTENT) + // Echo the new version so the editor can update its token without a re-fetch. + let mut headers = HeaderMap::new(); + if let Ok(md) = std::fs::metadata(&abs) { + if let Ok(v) = HeaderValue::from_str(&disk_etag(&md)) { + headers.insert(header::ETAG, v); + } + } + Ok((StatusCode::NO_CONTENT, headers)) } #[derive(Deserialize)] diff --git a/src/frontend/api/mod.rs b/src/frontend/api/mod.rs index 589dadc..3299077 100644 --- a/src/frontend/api/mod.rs +++ b/src/frontend/api/mod.rs @@ -238,6 +238,13 @@ impl ApiError { pub fn payload_too_large(msg: impl Into) -> Self { Self { status: StatusCode::PAYLOAD_TOO_LARGE, message: msg.into() } } + + /// `409 Conflict` — used by the file editor's optimistic locking: the + /// caller sent an `if_match` ETag that no longer matches the file on disk + /// (someone else — another user, another tab, or an agent — wrote first). + pub fn conflict(msg: impl Into) -> Self { + Self { status: StatusCode::CONFLICT, message: msg.into() } + } } /// Resolves the authenticated caller's per-user runtime context, or `401` when the diff --git a/web/components/shared/file-viewer-base.js b/web/components/shared/file-viewer-base.js index a2edb39..5b452a0 100644 --- a/web/components/shared/file-viewer-base.js +++ b/web/components/shared/file-viewer-base.js @@ -136,6 +136,14 @@ export class FileViewerBase extends LightElement { _error: { state: true }, _compileError: { state: true }, _htmlMode: { state: true }, + // ── Markdown source editor (View | Edit) ───────────────────────────────── + _mdMode: { state: true }, // 'view' | 'edit' + _editBuffer: { state: true }, // text being edited (diverges from _content when dirty) + _editDirty: { state: true }, // _editBuffer !== _content + _etag: { state: true }, // server version token (GET ETag) for optimistic locking + _canWrite: { state: true }, // caller may edit this path (X-Writable) + _conflict: { state: true }, // remote changed while editing — show the banner + _saving: { state: true }, }; constructor() { @@ -148,6 +156,13 @@ export class FileViewerBase extends LightElement { this._error = null; this._compileError = null; this._htmlMode = 'preview'; // HTML view: 'preview' (live iframe) | 'source' + this._mdMode = 'view'; // MD: 'view' (rendered) | 'edit' (source textarea) + this._editBuffer = ''; + this._editDirty = false; + this._etag = null; + this._canWrite = false; + this._conflict = false; + this._saving = 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 @@ -166,6 +181,10 @@ export class FileViewerBase extends LightElement { _show(path) { if (!path) return; if (path === this._path && !this._error) return; // already loaded + // Guard unsaved edits when navigating to a different file: dropping them + // 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; this._setupWatch(path); this._load(path); } @@ -210,6 +229,12 @@ export class FileViewerBase extends LightElement { this._error = null; this._compileError = null; this._htmlMode = 'preview'; + this._mdMode = 'view'; + this._editBuffer = ''; + this._editDirty = false; + this._etag = null; + this._canWrite = false; + this._conflict = false; this._revokeBlobUrl(); } @@ -220,6 +245,11 @@ export class FileViewerBase extends LightElement { this._content = ''; this._error = null; this._compileError = null; + // Fresh load: drop any editor state from the previous file. + this._mdMode = 'view'; + this._editDirty = false; + this._editBuffer = ''; + this._conflict = false; this._revokeBlobUrl(); this._loading = true; } else { @@ -243,8 +273,16 @@ export class FileViewerBase extends LightElement { const res = await fetch(url); if (!res.ok) throw new Error(`HTTP ${res.status}`); this._content = await res.text(); + // Optimistic-locking version token + write flag (editable surface). + this._etag = res.headers.get('ETag'); + this._canWrite = res.headers.get('X-Writable') === '1'; } // binary: nothing to fetch + // Keep the editor buffer glued to the content on any non-dirty load + // (initial load and silent external reloads while not editing). When the + // user is mid-edit with unsaved changes, the buffer is deliberately left + // alone — the watcher's _probeRemote path governs that case instead. + if (!this._editDirty) this._editBuffer = this._content; } catch (e) { this._error = e.message || String(e); } finally { @@ -326,16 +364,121 @@ export class FileViewerBase extends LightElement { this._reloadTimer = setTimeout(() => { this._reloadTimer = null; const path = this._watchPath; - if (path) this._load(path, true); + if (!path) return; + // While editing with unsaved changes, never clobber the buffer. Instead + // probe the server for the current version: if it moved on from what we + // last knew, raise a conflict; if it matches (most often our own save + // echoing back), stay quiet. + if (this._mdMode === 'edit' && this._editDirty) { + this._probeRemote(path); + return; + } + this._load(path, true); }, 300); } + async _probeRemote(path) { + try { + const res = await fetch(`/api/file?path=${encodeURIComponent(path)}`); + const remote = res.headers.get('ETag'); + if (remote && remote !== this._etag) this._conflict = true; + } catch { /* transient — the next change event retries */ } + } + // ── HTML preview/source toggle ────────────────────────────────────────────── _toggleHtmlMode() { this._htmlMode = this._htmlMode === 'preview' ? 'source' : 'preview'; } + // ── Markdown View | Edit ──────────────────────────────────────────────────── + + /** Switch the Markdown surface between rendered view and source editor. */ + _setMdMode(mode) { + if (mode === this._mdMode) return; + if (mode === 'edit') { + // Entering edit: seed the buffer from the on-disk content (unless the + // user still has unsaved edits from a previous foray into Edit on this + // same file, which we preserve). + if (!this._editDirty) this._editBuffer = this._content; + } + this._mdMode = mode; + } + + _onEditInput(e) { + this._editBuffer = e.target.value; + this._editDirty = this._editBuffer !== this._content; + } + + /** Discard edits and return to the rendered view. */ + _cancelEdit() { + if (this._editDirty && !confirm(t('fv.dirty_warn'))) return; + this._editBuffer = this._content; + this._editDirty = false; + this._conflict = false; + this._mdMode = 'view'; + } + + /** + * Persist the buffer. By default it sends `if_match` (optimistic locking): a + * `409` means the file changed remotely and we surface a conflict instead of + * overwriting. With `force=true` (the "Overwrite" button) it omits the token + * and clobbers whatever is on disk. + */ + async _save({ force = false } = {}) { + if (!this._path || this._saving) return; + this._saving = true; + try { + const payload = { path: this._path, content: this._editBuffer }; + if (!force) payload.if_match = this._etag; + const res = await fetch('/api/file', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (res.status === 409) { + // Remote moved on — keep the buffer, let the user decide via the banner. + this._conflict = true; + return; + } + if (!res.ok) throw new Error(await res.text()); + // Success: adopt the new version token + the written content as the new + // baseline. The buffer stays equal to _content ⇒ no longer dirty. + const etag = res.headers.get('ETag'); + if (etag) this._etag = etag; + this._content = this._editBuffer; + this._editDirty = false; + this._conflict = false; + } catch (e) { + this._error = e.message || String(e); + } finally { + this._saving = false; + } + } + + /** Conflict resolution: reload the remote version, discarding local edits. */ + async _reloadRemote() { + if (!this._path) return; + this._conflict = false; + this._editDirty = false; + await this._load(this._path, true); + this._editBuffer = this._content; + } + + /** Conflict resolution: overwrite the remote with our buffer (no lock check). */ + _overwrite() { + this._conflict = false; + this._save({ force: true }); + } + + /** Conflict resolution: copy our edits to the clipboard, then reload remote. */ + async _copyMyChanges() { + try { + await navigator.clipboard.writeText(this._editBuffer); + } catch { /* clipboard may be blocked; the reload still proceeds */ } + await this._reloadRemote(); + } + /** * Header button that flips an HTML file between the live preview and its raw * source. Returns `nothing` for every other kind, so subclasses can drop it @@ -353,6 +496,45 @@ export class FileViewerBase extends LightElement { `; } + /** View | Edit tab bar for Markdown. Only rendered when the caller can write. */ + _renderMdTabs() { + const view = this._mdMode === 'view'; + return html`
+ + + ${this._editDirty + ? html`` + : nothing} +
`; + } + + /** + * The conflict banner: shown while editing when the file was modified + * remotely (another user / tab / agent) after our buffer diverged. Three + * escapes — reload remote (discard mine), overwrite (force my version), or + * copy mine to the clipboard before reloading. + */ + _renderConflictBanner() { + if (!this._conflict) return nothing; + return html``; + } + // ── Body rendering (shared by both chromes) ───────────────────────────────── _renderBody() { @@ -410,8 +592,35 @@ export class FileViewerBase extends LightElement { } const ext = extOf(this._path); if (ext === 'md' || ext === 'markdown') { - const rendered = rewriteMarkdownAssets(renderMarkdown(this._content), dirOf(this._path || '')); - return html`
${unsafeHTML(rendered)}
`; + const editing = this._mdMode === 'edit' && this._canWrite; + // In View we render the edits in flight too (when dirty), so toggling + // 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 || '')); + return html`
+ ${this._canWrite ? this._renderMdTabs() : nothing} + ${editing + ? html`
+ ${this._renderConflictBanner()} + +
+ + + ${t('fv.edit_hint')} +
+
` + : html`
${unsafeHTML(rendered)}
`} +
`; } if (this._kind === 'latex') { // Compile failed — show why, then fall back to the source. diff --git a/web/css/file-viewer.css b/web/css/file-viewer.css index 2b61564..7444ea7 100644 --- a/web/css/file-viewer.css +++ b/web/css/file-viewer.css @@ -53,6 +53,124 @@ /* ── Markdown preview ────────────────────────────────────────────────────────── */ +.fv-md-wrap { + display: flex; + flex-direction: column; + min-height: 100%; +} + +/* View | Edit tab bar — shown only when the caller can write the file. */ +.fv-md-tabs { + display: flex; + align-items: center; + gap: 0.15rem; + padding: 0.4rem 1rem 0; + border-bottom: 1px solid var(--bs-border-color); + flex-shrink: 0; +} +.fv-md-tab { + appearance: none; + background: none; + border: none; + border-bottom: 2px solid transparent; + padding: 0.35rem 0.85rem; + margin-bottom: -1px; + font-size: 0.85rem; + color: var(--bs-secondary-color); + cursor: pointer; + border-radius: 4px 4px 0 0; +} +.fv-md-tab:hover { color: var(--bs-body-color); } +.fv-md-tab.active { + color: var(--bs-body-color); + border-bottom-color: var(--accent); + font-weight: 600; +} +.fv-md-dirty-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--accent); + margin-left: 0.35rem; + flex-shrink: 0; +} + +/* ── Source editor (Edit tab) ───────────────────────────────────────────────── */ + +.fv-edit { + flex: 1 1 auto; + display: flex; + flex-direction: column; + min-height: 0; + padding: 0.75rem 1rem 1rem; + gap: 0.5rem; +} +.fv-edit-textarea { + flex: 1 1 auto; + min-height: 50vh; + width: 100%; + resize: vertical; + padding: 0.85rem 1rem; + font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; + font-size: 0.85rem; + line-height: 1.55; + tab-size: 4; + color: var(--bs-body-color); + background: var(--bs-secondary-bg); + border: 1px solid var(--bs-border-color); + border-radius: 6px; + white-space: pre; + overflow-wrap: normal; + overflow: auto; +} +.fv-edit-textarea:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 30%, transparent); +} +.fv-edit-toolbar { + display: flex; + align-items: center; + gap: 0.5rem; + flex-shrink: 0; +} +.fv-edit-hint { + font-size: 0.78rem; + color: var(--bs-secondary-color); + margin-left: auto; +} + +/* ── Conflict banner (remote changed while editing) ─────────────────────────── */ + +.fv-conflict-banner { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.6rem; + padding: 0.6rem 0.85rem; + background: var(--bs-warning-bg-subtle, #fff3cd); + border: 1px solid var(--bs-warning-border-subtle, #ffe69c); + color: var(--bs-emphasis-color, inherit); + border-radius: 6px; + flex-shrink: 0; +} +.fv-conflict-banner > .bi { + color: var(--bs-warning-text-emphasis, #664d03); + font-size: 1.05rem; + flex-shrink: 0; +} +.fv-conflict-text { + flex: 1 1 200px; + font-size: 0.82rem; + min-width: 0; +} +.fv-conflict-actions { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + flex-shrink: 0; +} + .fv-md { max-width: 100%; padding: 1.5rem 2rem; diff --git a/web/i18n/en.js b/web/i18n/en.js index 4c19197..64c10b1 100644 --- a/web/i18n/en.js +++ b/web/i18n/en.js @@ -964,6 +964,19 @@ export default { 'fv.mode_source': 'Show source', 'fv.binary_unavailable': 'Preview not available for this file type.', 'fv.latex_failed': 'LaTeX compilation failed — showing source instead', + 'fv.tab_view': 'View', + 'fv.tab_edit': 'Edit', + 'fv.dirty_badge': 'Unsaved changes', + 'fv.dirty_warn': 'Discard unsaved changes?', + 'fv.save': 'Save', + 'fv.saving': 'Saving…', + 'fv.cancel': 'Cancel', + 'fv.edit_placeholder': 'Write Markdown…', + 'fv.edit_hint': 'Switch to View to preview', + 'fv.conflict_title': 'The file was modified remotely while you were editing. What do you want to do?', + 'fv.conflict_reload': 'Reload remote', + 'fv.conflict_copy': 'Copy mine, then reload', + 'fv.conflict_overwrite': 'Overwrite', // ── Marketplace ───────────────────────────────────────────────────────────── 'marketplace.title': 'Marketplace', diff --git a/web/i18n/fr.js b/web/i18n/fr.js index 237add5..92b0d98 100644 --- a/web/i18n/fr.js +++ b/web/i18n/fr.js @@ -954,6 +954,19 @@ export default { 'fv.mode_source': 'Afficher la source', 'fv.binary_unavailable': 'Aperçu non disponible pour ce type de fichier.', 'fv.latex_failed': 'Échec de la compilation LaTeX — affichage de la source à la place', + 'fv.tab_view': 'Afficher', + 'fv.tab_edit': 'Modifier', + 'fv.dirty_badge': 'Modifications non enregistrées', + 'fv.dirty_warn': 'Abandonner les modifications non enregistrées ?', + 'fv.save': 'Enregistrer', + 'fv.saving': 'Enregistrement…', + 'fv.cancel': 'Annuler', + 'fv.edit_placeholder': 'Écrire en Markdown…', + 'fv.edit_hint': 'Basculer vers Afficher pour l\'aperçu', + 'fv.conflict_title': 'Le fichier a été modifié à distance pendant votre modification. Que voulez-vous faire ?', + 'fv.conflict_reload': 'Recharger la version distante', + 'fv.conflict_copy': 'Copier les miennes, puis recharger', + 'fv.conflict_overwrite': 'Écraser', // ── Marketplace ───────────────────────────────────────────────────────────── 'marketplace.title': 'Marketplace', diff --git a/web/i18n/it.js b/web/i18n/it.js index 2ce1a1a..85f318d 100644 --- a/web/i18n/it.js +++ b/web/i18n/it.js @@ -954,6 +954,19 @@ export default { 'fv.mode_source': 'Mostra sorgente', 'fv.binary_unavailable': 'Anteprima non disponibile per questo tipo di file.', 'fv.latex_failed': 'Compilazione LaTeX fallita — mostra il sorgente', + 'fv.tab_view': 'Visualizza', + 'fv.tab_edit': 'Modifica', + 'fv.dirty_badge': 'Modifiche non salvate', + 'fv.dirty_warn': 'Scartare le modifiche non salvate?', + 'fv.save': 'Salva', + 'fv.saving': 'Salvataggio…', + 'fv.cancel': 'Annulla', + 'fv.edit_placeholder': 'Scrivi in Markdown…', + 'fv.edit_hint': 'Passa a Visualizza per l\'anteprima', + 'fv.conflict_title': 'Il file è stato modificato da remoto mentre stavi scrivendo. Cosa vuoi fare?', + 'fv.conflict_reload': 'Ricarica remoto', + 'fv.conflict_copy': 'Copia le mie, poi ricarica', + 'fv.conflict_overwrite': 'Sovrascrivi', // ── Marketplace ────────────────────────────────────────────────────────────── 'marketplace.title': 'Marketplace',