Two users editing the same file online could silently clobber each other:
the editor's save did a bare PUT with no precondition, even though the master
already enforces optimistic concurrency (fileapi.go checkIfMatch → 412). Now
the editor sends a precondition and surfaces a conflict UI instead of
overwriting.
- util.js: saveFile(node, content, contentType, opts) sends `If-Match: <etag>`
(or `If-Unmodified-Since` fallback) unless opts.force; returns {etag} from
the PUT response (so save→edit→save adopts the new version and doesn't
false-conflict); throws ConflictError (.status===412) on a precondition
failure so callers branch cleanly. New saveCopy() parks a conflicting edit
as `<stem>-conflict-<ts>.<ext>` (collision-probed) without losing either side.
- preview.js: getContentWithVersion(node) → {buf, etag, lastModified} captured
from the content GET (the listing JSON carries no per-file etag); threaded
into the editor ctx and exported. getArrayBuffer left untouched.
- conflict.js (new): shared, callback-driven dialog — mine-vs-theirs diff
(reuses zddc.diff + css/history.css) + Overwrite / Reload-theirs /
Save-a-copy / Cancel. Never calls saveFile/showFilePreview itself, so the
deferred Phase 5 cache-outbox conflict UI can reuse it with its own callbacks.
- preview-markdown.js / preview-yaml.js: capture + forward the version token,
adopt the returned etag on success, and on 412 open the dialog (Overwrite
re-fetches the current etag then re-saves — re-conflicts on a third writer
rather than blind-forcing; Reload clears dirty first so the renderInline
guard skips its confirm). FS-Access mode sends no precondition (no
concurrency) and never conflicts.
- build.sh: concat conflict.js after util.js.
- tests/conflict.spec.js (+ playwright project): If-Match sent, ConflictError
on 412, new-etag returned, force omits the precondition, dialog renders the
diff and each action resolves via its callback. Drives the fresh dist build
over file:// with a stubbed fetch (the test binary embeds the committed
browse.html, not dist, so a server-mode E2E would run stale code).
All browse + diff + conflict specs pass (18).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
475 lines
21 KiB
JavaScript
475 lines
21 KiB
JavaScript
// preview.js — file-preview rendering for the browse tool's right pane.
|
|
//
|
|
// Default flow: showFilePreview(node) renders into the inline preview
|
|
// pane (#previewBody). Popup flow: showFilePreview(node, {popup:true})
|
|
// opens a separate window — kept for users who want previews on a
|
|
// second monitor.
|
|
//
|
|
// Rendering uses shared/preview-lib.js for content types it handles
|
|
// (TIFF, ZIP listing, image-mime detection). PDF / HTML go in iframes;
|
|
// text into a <pre>; markdown into the dedicated markdown plugin
|
|
// (preview-markdown.js); unknown extensions show a download button.
|
|
(function () {
|
|
'use strict';
|
|
|
|
var state = window.app.state;
|
|
var loader = window.app.modules.loader;
|
|
var preview = window.zddc && window.zddc.preview;
|
|
if (!preview) {
|
|
console.error('[browse] zddc.preview not loaded — preview disabled.');
|
|
}
|
|
|
|
var util = window.app.modules.util;
|
|
var escapeHtml = util.escapeHtml;
|
|
|
|
var MIME = {
|
|
'pdf': 'application/pdf',
|
|
'html': 'text/html', 'htm': 'text/html',
|
|
'jpg': 'image/jpeg', 'jpeg': 'image/jpeg', 'png': 'image/png',
|
|
'gif': 'image/gif', 'webp': 'image/webp', 'svg': 'image/svg+xml',
|
|
'tif': 'image/tiff', 'tiff': 'image/tiff',
|
|
'zip': 'application/zip',
|
|
'txt': 'text/plain', 'md': 'text/markdown', 'json': 'application/json',
|
|
'xml': 'application/xml', 'csv': 'text/csv', 'log': 'text/plain',
|
|
'js': 'text/javascript', 'css': 'text/css',
|
|
'docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
'xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
'xls': 'application/vnd.ms-excel'
|
|
};
|
|
|
|
function getMime(ext) { return MIME[ext] || 'application/octet-stream'; }
|
|
|
|
var fmtSize = util.fmtSize;
|
|
|
|
async function getArrayBuffer(node) {
|
|
// A zip member node carries a ZipFileHandle in node.handle, so
|
|
// it falls through the same getFile() path as any local file.
|
|
if (state.source === 'server' && node.url) {
|
|
var resp = await fetch(node.url);
|
|
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
|
return await resp.arrayBuffer();
|
|
}
|
|
if (node.handle) {
|
|
var f = await node.handle.getFile();
|
|
return await f.arrayBuffer();
|
|
}
|
|
throw new Error('no source for file');
|
|
}
|
|
|
|
// Like getArrayBuffer, but also returns the server version token
|
|
// ({etag, lastModified}) captured from the content GET. The editors use
|
|
// it to send an If-Match precondition on save so a concurrent edit is
|
|
// rejected (412) instead of silently clobbered. FS-Access mode has no
|
|
// server version — etag/lastModified are null and the precondition is a
|
|
// clean no-op (a single locally-picked file has no concurrency).
|
|
async function getContentWithVersion(node) {
|
|
if (state.source === 'server' && node.url) {
|
|
var resp = await fetch(node.url, { credentials: 'same-origin' });
|
|
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
|
var buf = await resp.arrayBuffer();
|
|
return {
|
|
buf: buf,
|
|
etag: resp.headers.get('ETag') || null,
|
|
lastModified: resp.headers.get('Last-Modified') || null
|
|
};
|
|
}
|
|
if (node.handle) {
|
|
var f = await node.handle.getFile();
|
|
return { buf: await f.arrayBuffer(), etag: null, lastModified: null };
|
|
}
|
|
throw new Error('no source for file');
|
|
}
|
|
|
|
async function getBlobUrl(node) {
|
|
// Server-served files (including zip members at "<…>.zip/<member>"
|
|
// URLs) load straight from the server — preserves Content-Type
|
|
// and lets relative links inside HTML resolve back to the server.
|
|
if (state.source === 'server' && node.url) {
|
|
return { url: node.url, fromServer: true };
|
|
}
|
|
var buf = await getArrayBuffer(node);
|
|
var blob = new Blob([buf], { type: getMime(node.ext) });
|
|
return { url: URL.createObjectURL(blob), fromServer: false };
|
|
}
|
|
|
|
// ── Editor lifecycle helpers ─────────────────────────────────────────────
|
|
// The markdown and YAML plugins each mount a long-lived editor into the
|
|
// preview pane. Switching files (or clearing the pane) must dispose the
|
|
// live editor first — otherwise the Toast UI instance, its DOM, and its
|
|
// document-level resizer listeners leak when we overwrite the container.
|
|
|
|
function editorModules() {
|
|
var m = window.app.modules;
|
|
return [m.markdown, m.yamledit].filter(Boolean);
|
|
}
|
|
|
|
function disposeEditors() {
|
|
editorModules().forEach(function (mod) {
|
|
if (mod.dispose) { try { mod.dispose(); } catch (_) { /* ignore */ } }
|
|
});
|
|
}
|
|
|
|
// The editor module (if any) holding unsaved edits, else null.
|
|
function dirtyEditor() {
|
|
var mods = editorModules();
|
|
for (var i = 0; i < mods.length; i++) {
|
|
if (mods[i].isDirty && mods[i].isDirty()) return mods[i];
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function samePreviewNode(a, b) {
|
|
if (!a || !b) return false;
|
|
if (a === b) return true;
|
|
if (a.url && b.url) return a.url === b.url;
|
|
return a.name === b.name && a.parentId === b.parentId;
|
|
}
|
|
|
|
// Tear down any live editor and blank the pane. Used by callers that
|
|
// reset the preview directly (rescope, popstate) so they don't leak the
|
|
// editor or strand its dirty state.
|
|
function clearPreview() {
|
|
disposeEditors();
|
|
var container = document.getElementById('previewBody');
|
|
if (container) container.innerHTML = '';
|
|
}
|
|
|
|
// Warn before a full page unload (reload / close / external nav) drops
|
|
// unsaved editor changes. SPA-internal switches are guarded in
|
|
// renderInline; this catches the browser-level exit.
|
|
window.addEventListener('beforeunload', function (e) {
|
|
if (dirtyEditor()) { e.preventDefault(); e.returnValue = ''; }
|
|
});
|
|
|
|
// ── Inline rendering ────────────────────────────────────────────────────
|
|
|
|
// Bumped on every renderInline entry; a render that loses the race
|
|
// (a newer selection started while its bytes were in flight) bails
|
|
// before writing stale content into the shared pane.
|
|
var renderSeq = 0;
|
|
|
|
function renderEmpty(container, msg) {
|
|
container.innerHTML = '<div class="preview-empty">' + escapeHtml(msg) + '</div>';
|
|
}
|
|
|
|
function renderError(container, msg) {
|
|
container.innerHTML = '<div class="preview-empty" style="color:var(--danger)">'
|
|
+ escapeHtml(msg) + '</div>';
|
|
}
|
|
|
|
async function renderInline(node, opts) {
|
|
opts = opts || {};
|
|
var container = document.getElementById('previewBody');
|
|
var titleEl = document.getElementById('previewTitle');
|
|
var metaEl = document.getElementById('previewMeta');
|
|
var popoutBtn = document.getElementById('previewPopout');
|
|
if (!container) return;
|
|
|
|
// Guard unsaved editor edits before we tear the editor down.
|
|
var dm = dirtyEditor();
|
|
if (dm) {
|
|
var cur = dm.currentNode ? dm.currentNode() : null;
|
|
if (samePreviewNode(cur, node)) {
|
|
// Re-selecting the file we're already editing — don't reload
|
|
// and clobber the in-progress edits.
|
|
return;
|
|
}
|
|
if (opts.auto) {
|
|
// Keyboard/auto preview (cursor walking the tree): leave the
|
|
// dirty editor in place rather than prompting on every key.
|
|
return;
|
|
}
|
|
var label = cur ? cur.name : 'this file';
|
|
if (!window.confirm('Discard unsaved changes to ' + label + '?')) return;
|
|
}
|
|
// Safe to replace the pane now: dispose any live editor so its
|
|
// instance + document-level listeners don't leak.
|
|
disposeEditors();
|
|
|
|
var seq = ++renderSeq;
|
|
|
|
if (titleEl) titleEl.textContent = node.name;
|
|
if (metaEl) {
|
|
var meta = [];
|
|
if (!node.isDir && !node.isZip) meta.push(fmtSize(node.size));
|
|
if (node.ext) meta.push(node.ext.toUpperCase());
|
|
metaEl.textContent = meta.join(' · ');
|
|
}
|
|
if (popoutBtn) popoutBtn.classList.remove('hidden');
|
|
|
|
var ext = (node.ext || '').toLowerCase();
|
|
|
|
// Markdown plugin (if loaded) takes over for .md / .markdown.
|
|
if ((ext === 'md' || ext === 'markdown') &&
|
|
window.app.modules.markdown &&
|
|
typeof window.app.modules.markdown.render === 'function') {
|
|
try {
|
|
await window.app.modules.markdown.render(node, container, { getArrayBuffer: getArrayBuffer, getContentWithVersion: getContentWithVersion });
|
|
} catch (e) {
|
|
renderError(container, 'Markdown render failed: ' + (e.message || e));
|
|
}
|
|
return;
|
|
}
|
|
|
|
// YAML plugin: .yaml / .yml / .zddc / *.zddc.yaml route to a
|
|
// CodeMirror 5 editor with js-yaml linting; .zddc files also
|
|
// get a schema-aware lint pass.
|
|
var yamlMod = window.app.modules.yamledit;
|
|
if (yamlMod && yamlMod.handles(node)) {
|
|
try {
|
|
await yamlMod.render(node, container, { getArrayBuffer: getArrayBuffer, getContentWithVersion: getContentWithVersion });
|
|
} catch (e) {
|
|
renderError(container, 'YAML render failed: ' + (e.message || e));
|
|
}
|
|
return;
|
|
}
|
|
|
|
// PDF / HTML → iframe.
|
|
if (ext === 'pdf' || ext === 'html' || ext === 'htm') {
|
|
try {
|
|
var info = await getBlobUrl(node);
|
|
if (seq !== renderSeq) return;
|
|
var sandbox = (ext === 'pdf') ? '' : ' sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox"';
|
|
container.innerHTML = '<iframe class="preview-iframe" src="' + escapeHtml(info.url) + '"' + sandbox + '></iframe>';
|
|
} catch (e) {
|
|
renderError(container, e.message || String(e));
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Plain images (jpg/png/gif/webp/svg) → <img>. TIFF goes through preview-lib.
|
|
if (preview && preview.isImage(ext) && !preview.isTiff(ext)) {
|
|
try {
|
|
var imgInfo = await getBlobUrl(node);
|
|
if (seq !== renderSeq) return;
|
|
container.innerHTML = '<img class="preview-image" alt="' + escapeHtml(node.name)
|
|
+ '" src="' + escapeHtml(imgInfo.url) + '">';
|
|
} catch (e) {
|
|
renderError(container, e.message || String(e));
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (preview && preview.isTiff(ext)) {
|
|
try {
|
|
var tiffBuf = await getArrayBuffer(node);
|
|
if (seq !== renderSeq) return;
|
|
container.innerHTML = '';
|
|
await preview.renderTiff(document, container, tiffBuf, { fileName: node.name });
|
|
} catch (e) {
|
|
renderError(container, 'Failed to render TIFF: ' + (e.message || e));
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (preview && preview.isZip(ext)) {
|
|
try {
|
|
var zipBuf = await getArrayBuffer(node);
|
|
if (seq !== renderSeq) return;
|
|
container.innerHTML = '';
|
|
await preview.renderZipListing(document, container, zipBuf, { fileName: node.name });
|
|
} catch (e) {
|
|
renderError(container, 'Failed to read ZIP: ' + (e.message || e));
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Office docs (.docx via docx-preview, .xlsx/.xls via SheetJS) →
|
|
// shared/preview-lib renderers. .doc/.ppt etc. fall through to the
|
|
// download fallback below.
|
|
if (preview && preview.isOffice(ext)) {
|
|
try {
|
|
var officeBuf = await getArrayBuffer(node);
|
|
if (seq !== renderSeq) return;
|
|
container.innerHTML = '';
|
|
if (ext === 'docx') {
|
|
await preview.renderDocx(document, container, officeBuf, { fileName: node.name });
|
|
} else {
|
|
await preview.renderXlsx(document, container, officeBuf, { fileName: node.name });
|
|
}
|
|
} catch (e) {
|
|
renderError(container, 'Failed to render ' + ext.toUpperCase() + ': ' + (e.message || e));
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (preview && preview.isText(ext)) {
|
|
try {
|
|
var txtBuf = await getArrayBuffer(node);
|
|
if (seq !== renderSeq) return;
|
|
var text = new TextDecoder('utf-8', { fatal: false }).decode(txtBuf);
|
|
var MAX = 200000;
|
|
if (text.length > MAX) {
|
|
text = text.substring(0, MAX) + '\n\n... (truncated, '
|
|
+ (text.length - MAX) + ' more chars)';
|
|
}
|
|
container.innerHTML = '';
|
|
var pre = document.createElement('pre');
|
|
pre.className = 'preview-text';
|
|
pre.textContent = text;
|
|
container.appendChild(pre);
|
|
} catch (e) {
|
|
renderError(container, e.message || String(e));
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Unknown type — offer a download link.
|
|
try {
|
|
var fallbackInfo = await getBlobUrl(node);
|
|
if (seq !== renderSeq) return;
|
|
container.innerHTML =
|
|
'<div class="preview-empty">'
|
|
+ 'No inline preview for <code>.' + escapeHtml(ext) + '</code>. '
|
|
+ '<br><a class="btn btn-primary btn-sm" download="' + escapeHtml(node.name)
|
|
+ '" href="' + escapeHtml(fallbackInfo.url) + '" style="margin-top:1rem">'
|
|
+ 'Download ' + escapeHtml(node.name) + '</a>'
|
|
+ '</div>';
|
|
} catch (e) {
|
|
renderError(container, 'No source for ' + node.name);
|
|
}
|
|
}
|
|
|
|
// ── Popup window (kept for "Pop out" button) ────────────────────────────
|
|
|
|
function popupShell(node, primaryUrl) {
|
|
var safeName = escapeHtml(node.name);
|
|
var safeHref = escapeHtml(primaryUrl);
|
|
var ext = (node.ext || '').toLowerCase();
|
|
var contentHtml;
|
|
if (ext === 'pdf') {
|
|
contentHtml = '<iframe src="' + safeHref + '"></iframe>';
|
|
} else if (ext === 'html' || ext === 'htm') {
|
|
contentHtml = '<iframe src="' + safeHref + '" sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox"></iframe>';
|
|
} else if (preview && preview.isImage(ext) && !preview.isTiff(ext)) {
|
|
contentHtml = '<img class="preview-image" src="' + safeHref + '" alt="' + safeName + '">';
|
|
} else {
|
|
contentHtml = '<div id="previewContent"><div class="loading">Loading preview…</div></div>';
|
|
}
|
|
return '<!DOCTYPE html><html><head><meta charset="UTF-8">'
|
|
+ '<title>' + safeName + ' — preview</title><style>'
|
|
+ '*{margin:0;padding:0;box-sizing:border-box;}'
|
|
+ 'body{display:flex;flex-direction:column;height:100vh;'
|
|
+ 'font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;}'
|
|
+ '.toolbar{display:flex;align-items:center;gap:.5rem;padding:.5rem 1rem;'
|
|
+ 'background:#f5f5f5;border-bottom:1px solid #ddd;}'
|
|
+ '.toolbar h1{flex:1;font-size:.95rem;font-weight:500;overflow:hidden;'
|
|
+ 'text-overflow:ellipsis;white-space:nowrap;}'
|
|
+ '.btn{padding:.4rem .8rem;font-size:.85rem;border:1px solid #ccc;'
|
|
+ 'border-radius:4px;background:white;cursor:pointer;}'
|
|
+ '.btn:hover{background:#e8e8e8;}'
|
|
+ 'iframe{flex:1;width:100%;border:none;}'
|
|
+ '#previewContent{flex:1;overflow:auto;display:flex;flex-direction:column;}'
|
|
+ '.loading{display:flex;align-items:center;justify-content:center;height:100%;'
|
|
+ 'color:#666;font-size:1.1rem;}'
|
|
+ 'img.preview-image{max-width:100%;max-height:100%;object-fit:contain;'
|
|
+ 'margin:auto;display:block;}'
|
|
+ 'pre.preview-text{padding:1rem;font-family:Consolas,Monaco,monospace;'
|
|
+ 'font-size:.85rem;white-space:pre-wrap;word-wrap:break-word;}'
|
|
+ '</style></head><body>'
|
|
+ '<div class="toolbar"><h1>' + safeName + '</h1>'
|
|
+ '<button class="btn" onclick="downloadFile()">Download</button></div>'
|
|
+ contentHtml
|
|
+ '<script>'
|
|
+ 'var blobUrl=' + JSON.stringify(primaryUrl) + ';'
|
|
+ 'var fileName=' + JSON.stringify(node.name) + ';'
|
|
+ 'function downloadFile(){var a=document.createElement("a");'
|
|
+ 'a.href=blobUrl;a.download=fileName;document.body.appendChild(a);'
|
|
+ 'a.click();document.body.removeChild(a);}'
|
|
+ '</' + 'script></body></html>';
|
|
}
|
|
|
|
async function renderInPopupWindow(node, win, info) {
|
|
var ext = (node.ext || '').toLowerCase();
|
|
if (ext === 'pdf' || ext === 'html' || ext === 'htm') return;
|
|
if (preview && preview.isImage(ext) && !preview.isTiff(ext)) return;
|
|
var c = win.document.getElementById('previewContent');
|
|
if (!c) return;
|
|
try {
|
|
if (preview && preview.isTiff(ext)) {
|
|
var tb = await getArrayBuffer(node);
|
|
await preview.renderTiff(win.document, c, tb, { fileName: node.name });
|
|
} else if (preview && preview.isZip(ext)) {
|
|
var zb = await getArrayBuffer(node);
|
|
await preview.renderZipListing(win.document, c, zb, { fileName: node.name });
|
|
} else if (preview && preview.isOffice(ext)) {
|
|
var ob = await getArrayBuffer(node);
|
|
if (ext === 'docx') {
|
|
await preview.renderDocx(win.document, c, ob, { fileName: node.name });
|
|
} else {
|
|
await preview.renderXlsx(win.document, c, ob, { fileName: node.name });
|
|
}
|
|
} else if (preview && preview.isText(ext)) {
|
|
var txb = await getArrayBuffer(node);
|
|
var text = new TextDecoder('utf-8', { fatal: false }).decode(txb);
|
|
var MAX = 200000;
|
|
if (text.length > MAX) text = text.substring(0, MAX) + '\n\n... (truncated)';
|
|
var pre = win.document.createElement('pre');
|
|
pre.className = 'preview-text';
|
|
pre.textContent = text;
|
|
c.innerHTML = '';
|
|
c.appendChild(pre);
|
|
} else {
|
|
c.innerHTML = '<div class="loading">No inline preview for .'
|
|
+ escapeHtml(ext) + ' — click Download.</div>';
|
|
}
|
|
} catch (e) {
|
|
c.innerHTML = '<div class="loading">Error: ' + escapeHtml(e.message || e) + '</div>';
|
|
}
|
|
}
|
|
|
|
async function renderInPopup(node) {
|
|
var info;
|
|
try {
|
|
info = await getBlobUrl(node);
|
|
} catch (e) {
|
|
window.app.modules.events.statusError('Pop-out failed: ' + e.message);
|
|
return;
|
|
}
|
|
var html = popupShell(node, info.url);
|
|
var win = state.previewWindow;
|
|
if (win && !win.closed) {
|
|
win.document.open();
|
|
win.document.write(html);
|
|
win.document.close();
|
|
win.focus();
|
|
} else {
|
|
var w = Math.round(screen.width * 0.6);
|
|
var h = Math.round(screen.height * 0.8);
|
|
var left = Math.round((screen.width - w) / 2);
|
|
var top = Math.round((screen.height - h) / 2);
|
|
win = window.open('', 'browseFilePreview',
|
|
'width=' + w + ',height=' + h + ',left=' + left + ',top=' + top
|
|
+ ',resizable=yes,scrollbars=yes');
|
|
if (!win) {
|
|
window.open(info.url, '_blank', 'noopener');
|
|
return;
|
|
}
|
|
win.document.write(html);
|
|
win.document.close();
|
|
win.focus();
|
|
state.previewWindow = win;
|
|
}
|
|
await renderInPopupWindow(node, win, info);
|
|
}
|
|
|
|
// ── Public entry ────────────────────────────────────────────────────────
|
|
|
|
async function showFilePreview(node, opts) {
|
|
if (node.isDir) return;
|
|
opts = opts || {};
|
|
if (opts.popup) return renderInPopup(node);
|
|
return renderInline(node, opts);
|
|
}
|
|
|
|
window.app.modules.preview = {
|
|
showFilePreview: showFilePreview,
|
|
// Tear down any live editor + blank the pane (rescope / popstate).
|
|
clearPreview: clearPreview,
|
|
// Expose for the markdown plugin so it can read file bytes.
|
|
getArrayBuffer: getArrayBuffer,
|
|
// Like getArrayBuffer but also returns the {etag, lastModified}
|
|
// version token — the editors use it for optimistic-concurrency saves.
|
|
getContentWithVersion: getContentWithVersion
|
|
};
|
|
})();
|