file viewer: edit Markdown with optimistic-lock conflict detection
Nightly Build / build (push) Successful in 6m47s
Nightly Build / build (push) Successful in 6m47s
- 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
This commit is contained in:
@@ -30,6 +30,13 @@ A file explorer rooted at the project folder:
|
|||||||
- Click a **folder** to navigate into it.
|
- 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.
|
- 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:
|
If you have write access you can also, from the toolbar or each row:
|
||||||
|
|
||||||
- **New folder** — create a subfolder in the current location.
|
- **New folder** — create a subfolder in the current location.
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use axum::{
|
|||||||
Extension, Json,
|
Extension, Json,
|
||||||
body::Bytes,
|
body::Bytes,
|
||||||
extract::{Query, State},
|
extract::{Query, State},
|
||||||
http::{HeaderValue, StatusCode, header},
|
http::{HeaderValue, HeaderMap, StatusCode, header},
|
||||||
response::{IntoResponse, Response},
|
response::{IntoResponse, Response},
|
||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
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
|
/// GET /api/files/dir?path=… — the immediate children of a directory (dirs
|
||||||
/// first, then name), resolved and scoped exactly like `GET /api/file`.
|
/// first, then name), resolved and scoped exactly like `GET /api/file`.
|
||||||
pub async fn list_dir(
|
pub async fn list_dir(
|
||||||
@@ -182,10 +198,11 @@ pub async fn get_file(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let user_fs = ctx.fs.load();
|
let user_fs = ctx.fs.load();
|
||||||
let abs = match fs_tools::resolve_view_path(user_fs.as_ref(), &q.path) {
|
let (abs, agent) = match fs_tools::resolve_view_path(user_fs.as_ref(), &q.path) {
|
||||||
Ok((abs, _)) => abs,
|
Ok((abs, agent)) => (abs, agent),
|
||||||
Err(e) => return (StatusCode::BAD_REQUEST, format!("Invalid path: {e}")).into_response(),
|
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) {
|
if q.compile_latex && is_latex(&q.path) {
|
||||||
return match state.latex_compiler().compile(&abs).await {
|
return match state.latex_compiler().compile(&abs).await {
|
||||||
@@ -207,6 +224,20 @@ pub async fn get_file(
|
|||||||
header::CONTENT_TYPE,
|
header::CONTENT_TYPE,
|
||||||
HeaderValue::from_static(content_type_for(&q.path)),
|
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 {
|
if q.force_download {
|
||||||
set_attachment(&mut response, &basename(&q.path));
|
set_attachment(&mut response, &basename(&q.path));
|
||||||
}
|
}
|
||||||
@@ -327,6 +358,14 @@ fn content_type_for(path: &str) -> &'static str {
|
|||||||
pub struct SavePayload {
|
pub struct SavePayload {
|
||||||
pub path: String,
|
pub path: String,
|
||||||
pub content: 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<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@@ -389,7 +428,7 @@ pub async fn save_file(
|
|||||||
State(state): State<Arc<Skald>>,
|
State(state): State<Arc<Skald>>,
|
||||||
Extension(auth): Extension<AuthUser>,
|
Extension(auth): Extension<AuthUser>,
|
||||||
Json(body): Json<SavePayload>,
|
Json(body): Json<SavePayload>,
|
||||||
) -> Result<StatusCode, ApiError> {
|
) -> Result<(StatusCode, HeaderMap), ApiError> {
|
||||||
let ctx = require_context(&state, &auth.user_id).await?;
|
let ctx = require_context(&state, &auth.user_id).await?;
|
||||||
let fs = ctx.fs.load();
|
let fs = ctx.fs.load();
|
||||||
let (abs, display) = fs_tools::resolve_view_path(fs.as_ref(), &body.path)
|
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() {
|
if !abs.exists() {
|
||||||
return Err(anyhow::anyhow!("File not found: {display}").into());
|
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)?;
|
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)]
|
#[derive(Deserialize)]
|
||||||
|
|||||||
@@ -238,6 +238,13 @@ impl ApiError {
|
|||||||
pub fn payload_too_large(msg: impl Into<String>) -> Self {
|
pub fn payload_too_large(msg: impl Into<String>) -> Self {
|
||||||
Self { status: StatusCode::PAYLOAD_TOO_LARGE, message: msg.into() }
|
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<String>) -> Self {
|
||||||
|
Self { status: StatusCode::CONFLICT, message: msg.into() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolves the authenticated caller's per-user runtime context, or `401` when the
|
/// Resolves the authenticated caller's per-user runtime context, or `401` when the
|
||||||
|
|||||||
@@ -136,6 +136,14 @@ export class FileViewerBase extends LightElement {
|
|||||||
_error: { state: true },
|
_error: { state: true },
|
||||||
_compileError: { state: true },
|
_compileError: { state: true },
|
||||||
_htmlMode: { 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() {
|
constructor() {
|
||||||
@@ -148,6 +156,13 @@ export class FileViewerBase extends LightElement {
|
|||||||
this._error = null;
|
this._error = null;
|
||||||
this._compileError = null;
|
this._compileError = null;
|
||||||
this._htmlMode = 'preview'; // HTML view: 'preview' (live iframe) | 'source'
|
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._watchPath = null; // path currently being watched (async-verified)
|
||||||
this._watchUnsub = null; // unsubscribe function returned by fileWatcher
|
this._watchUnsub = null; // unsubscribe function returned by fileWatcher
|
||||||
this._reloadTimer = null; // debounce timer for change-triggered reloads
|
this._reloadTimer = null; // debounce timer for change-triggered reloads
|
||||||
@@ -166,6 +181,10 @@ export class FileViewerBase extends LightElement {
|
|||||||
_show(path) {
|
_show(path) {
|
||||||
if (!path) return;
|
if (!path) return;
|
||||||
if (path === this._path && !this._error) return; // already loaded
|
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._setupWatch(path);
|
||||||
this._load(path);
|
this._load(path);
|
||||||
}
|
}
|
||||||
@@ -210,6 +229,12 @@ export class FileViewerBase extends LightElement {
|
|||||||
this._error = null;
|
this._error = null;
|
||||||
this._compileError = null;
|
this._compileError = null;
|
||||||
this._htmlMode = 'preview';
|
this._htmlMode = 'preview';
|
||||||
|
this._mdMode = 'view';
|
||||||
|
this._editBuffer = '';
|
||||||
|
this._editDirty = false;
|
||||||
|
this._etag = null;
|
||||||
|
this._canWrite = false;
|
||||||
|
this._conflict = false;
|
||||||
this._revokeBlobUrl();
|
this._revokeBlobUrl();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,6 +245,11 @@ export class FileViewerBase extends LightElement {
|
|||||||
this._content = '';
|
this._content = '';
|
||||||
this._error = null;
|
this._error = null;
|
||||||
this._compileError = 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._revokeBlobUrl();
|
||||||
this._loading = true;
|
this._loading = true;
|
||||||
} else {
|
} else {
|
||||||
@@ -243,8 +273,16 @@ export class FileViewerBase extends LightElement {
|
|||||||
const res = await fetch(url);
|
const res = await fetch(url);
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
this._content = await res.text();
|
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
|
// 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) {
|
} catch (e) {
|
||||||
this._error = e.message || String(e);
|
this._error = e.message || String(e);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -326,16 +364,121 @@ export class FileViewerBase extends LightElement {
|
|||||||
this._reloadTimer = setTimeout(() => {
|
this._reloadTimer = setTimeout(() => {
|
||||||
this._reloadTimer = null;
|
this._reloadTimer = null;
|
||||||
const path = this._watchPath;
|
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);
|
}, 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 ──────────────────────────────────────────────
|
// ── HTML preview/source toggle ──────────────────────────────────────────────
|
||||||
|
|
||||||
_toggleHtmlMode() {
|
_toggleHtmlMode() {
|
||||||
this._htmlMode = this._htmlMode === 'preview' ? 'source' : 'preview';
|
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
|
* 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
|
* source. Returns `nothing` for every other kind, so subclasses can drop it
|
||||||
@@ -353,6 +496,45 @@ export class FileViewerBase extends LightElement {
|
|||||||
</button>`;
|
</button>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** View | Edit tab bar for Markdown. Only rendered when the caller can write. */
|
||||||
|
_renderMdTabs() {
|
||||||
|
const view = this._mdMode === 'view';
|
||||||
|
return html`<div class="fv-md-tabs" role="tablist">
|
||||||
|
<button class="fv-md-tab ${view ? 'active' : ''}" role="tab" aria-selected=${view}
|
||||||
|
@click=${() => this._setMdMode('view')}>${t('fv.tab_view')}</button>
|
||||||
|
<button class="fv-md-tab ${!view ? 'active' : ''}" role="tab" aria-selected=${!view}
|
||||||
|
@click=${() => this._setMdMode('edit')}>${t('fv.tab_edit')}</button>
|
||||||
|
${this._editDirty
|
||||||
|
? html`<span class="fv-md-dirty-dot" title=${t('fv.dirty_badge')}></span>`
|
||||||
|
: nothing}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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`<div class="fv-conflict-banner" role="alert">
|
||||||
|
<i class="bi bi-exclamation-triangle-fill"></i>
|
||||||
|
<span class="fv-conflict-text">${t('fv.conflict_title')}</span>
|
||||||
|
<div class="fv-conflict-actions">
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" @click=${() => this._reloadRemote()}>
|
||||||
|
${t('fv.conflict_reload')}
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" @click=${() => this._copyMyChanges()}>
|
||||||
|
${t('fv.conflict_copy')}
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-warning" @click=${() => this._overwrite()}>
|
||||||
|
${t('fv.conflict_overwrite')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Body rendering (shared by both chromes) ─────────────────────────────────
|
// ── Body rendering (shared by both chromes) ─────────────────────────────────
|
||||||
|
|
||||||
_renderBody() {
|
_renderBody() {
|
||||||
@@ -410,8 +592,35 @@ export class FileViewerBase extends LightElement {
|
|||||||
}
|
}
|
||||||
const ext = extOf(this._path);
|
const ext = extOf(this._path);
|
||||||
if (ext === 'md' || ext === 'markdown') {
|
if (ext === 'md' || ext === 'markdown') {
|
||||||
const rendered = rewriteMarkdownAssets(renderMarkdown(this._content), dirOf(this._path || ''));
|
const editing = this._mdMode === 'edit' && this._canWrite;
|
||||||
return html`<div class="fv-md">${unsafeHTML(rendered)}</div>`;
|
// 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`<div class="fv-md-wrap">
|
||||||
|
${this._canWrite ? this._renderMdTabs() : nothing}
|
||||||
|
${editing
|
||||||
|
? html`<div class="fv-edit">
|
||||||
|
${this._renderConflictBanner()}
|
||||||
|
<textarea class="fv-edit-textarea"
|
||||||
|
.value=${this._editBuffer}
|
||||||
|
spellcheck="false"
|
||||||
|
autocomplete="off"
|
||||||
|
autocapitalize="off"
|
||||||
|
placeholder=${t('fv.edit_placeholder')}
|
||||||
|
@input=${this._onEditInput}></textarea>
|
||||||
|
<div class="fv-edit-toolbar">
|
||||||
|
<button class="btn btn-sm btn-primary fv-edit-save"
|
||||||
|
?disabled=${!this._editDirty || this._saving}
|
||||||
|
@click=${() => this._save()}>${this._saving ? t('fv.saving') : t('fv.save')}</button>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary"
|
||||||
|
@click=${() => this._cancelEdit()}>${t('fv.cancel')}</button>
|
||||||
|
<span class="fv-edit-hint">${t('fv.edit_hint')}</span>
|
||||||
|
</div>
|
||||||
|
</div>`
|
||||||
|
: html`<div class="fv-md">${unsafeHTML(rendered)}</div>`}
|
||||||
|
</div>`;
|
||||||
}
|
}
|
||||||
if (this._kind === 'latex') {
|
if (this._kind === 'latex') {
|
||||||
// Compile failed — show why, then fall back to the source.
|
// Compile failed — show why, then fall back to the source.
|
||||||
|
|||||||
@@ -53,6 +53,124 @@
|
|||||||
|
|
||||||
/* ── Markdown preview ────────────────────────────────────────────────────────── */
|
/* ── 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 {
|
.fv-md {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
padding: 1.5rem 2rem;
|
padding: 1.5rem 2rem;
|
||||||
|
|||||||
@@ -964,6 +964,19 @@ export default {
|
|||||||
'fv.mode_source': 'Show source',
|
'fv.mode_source': 'Show source',
|
||||||
'fv.binary_unavailable': 'Preview not available for this file type.',
|
'fv.binary_unavailable': 'Preview not available for this file type.',
|
||||||
'fv.latex_failed': 'LaTeX compilation failed — showing source instead',
|
'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 ─────────────────────────────────────────────────────────────
|
||||||
'marketplace.title': 'Marketplace',
|
'marketplace.title': 'Marketplace',
|
||||||
|
|||||||
@@ -954,6 +954,19 @@ export default {
|
|||||||
'fv.mode_source': 'Afficher la source',
|
'fv.mode_source': 'Afficher la source',
|
||||||
'fv.binary_unavailable': 'Aperçu non disponible pour ce type de fichier.',
|
'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.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 ─────────────────────────────────────────────────────────────
|
||||||
'marketplace.title': 'Marketplace',
|
'marketplace.title': 'Marketplace',
|
||||||
|
|||||||
@@ -954,6 +954,19 @@ export default {
|
|||||||
'fv.mode_source': 'Mostra sorgente',
|
'fv.mode_source': 'Mostra sorgente',
|
||||||
'fv.binary_unavailable': 'Anteprima non disponibile per questo tipo di file.',
|
'fv.binary_unavailable': 'Anteprima non disponibile per questo tipo di file.',
|
||||||
'fv.latex_failed': 'Compilazione LaTeX fallita — mostra il sorgente',
|
'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 ──────────────────────────────────────────────────────────────
|
||||||
'marketplace.title': 'Marketplace',
|
'marketplace.title': 'Marketplace',
|
||||||
|
|||||||
Reference in New Issue
Block a user