feat(files): render markdown in the File Viewer, with Lines/Wrap toggles

Clicking a .md in the Files panel showed wrapped source with an Edit
pencil and no way to see it rendered, although marked + DOMPurify were
already on the page for the Response Viewer. The viewer now renders
.md/.markdown through that same pipeline (one parser, one click
delegate) with an MD pill back to source, and the plain-text view gains
Lines (CSS-counter gutter) and Wrap toggles. All three persist per device
in their own localStorage keys.

- Relative images are rebased onto the workspace-confined file-raw route
  under the document's directory, built inside a <template> so no fetch
  fires before the rewrite; a failed load degrades to alt text. Relative
  links become a.rv-path so the existing delegate opens them in the
  viewer; fragment and http(s) links are untouched.
- The rendered container carries data-i18n-skip so the translator does
  not rewrite the document's prose.
- Markdown fetches the route's 10000-line ceiling; other text keeps 500.
- avif renders inline (file-content image set, file-raw MIME map), and
  avif/ico printed paths open the viewer instead of tailing bytes. .md
  deliberately stays with the tail viewer for printed paths.
This commit is contained in:
JD
2026-09-28 01:49:37 -04:00
parent 45ea2e1d32
commit 5e27043bf7
10 changed files with 609 additions and 12 deletions
+2 -2
View File
@@ -1458,7 +1458,7 @@ function computeRewriteScrollLine(input) {
* a `/g` regex, so {@link absoluteFilePathPattern} mints a fresh one per call.
*/
const FILE_PATH_LINK_PATTERN =
/(\/(?:home|Users|tmp|var|private|opt|mnt|srv|media|data|workspace)\/[^\s"'<>|;&\n\x00-\x1f]*\.(?:log|txt|json|md|ya?ml|csv|xml|sh|py|tsx|ts|jsx|js|mjs|cjs|css|html|toml|ini|sql|png|jpe?g|gif|webp|bmp|svg|pdf|docx|pptx|mp4|webm|mov|mp3|wav))\b/g;
/(\/(?:home|Users|tmp|var|private|opt|mnt|srv|media|data|workspace)\/[^\s"'<>|;&\n\x00-\x1f]*\.(?:log|txt|json|md|ya?ml|csv|xml|sh|py|tsx|ts|jsx|js|mjs|cjs|css|html|toml|ini|sql|png|jpe?g|gif|webp|avif|bmp|ico|svg|pdf|docx|pptx|mp4|webm|mov|mp3|wav))\b/g;
/** A fresh, zero-state instance of {@link FILE_PATH_LINK_PATTERN}. */
function absoluteFilePathPattern() {
@@ -1476,7 +1476,7 @@ function absoluteFilePathPattern() {
* file in /tmp played fine. test/media-extension-parity.test.ts pins the sync.
*/
const FILE_PREVIEW_EXTENSIONS = new Set(
('png jpg jpeg gif webp bmp svg pdf docx pptx mp4 webm mov m4v ogv mp3 wav ogg oga m4a aac flac opus').split(' ')
('png jpg jpeg gif webp avif bmp ico svg pdf docx pptx mp4 webm mov m4v ogv mp3 wav ogg oga m4a aac flac opus').split(' ')
);
/** Whether a path's extension is one {@link FILE_PREVIEW_EXTENSIONS} covers. */
+3
View File
@@ -573,6 +573,9 @@
<div class="file-preview-header">
<span class="file-preview-title" id="filePreviewTitle">file.ts</span>
<div class="file-preview-actions">
<button class="btn-icon-sm file-preview-pill" id="filePreviewMdBtn" onclick="app.toggleFilePreviewMd()" title="Rendered markdown" aria-label="Rendered markdown" aria-pressed="true" hidden>MD</button>
<button class="btn-icon-sm" id="filePreviewLinesBtn" onclick="app.toggleFilePreviewLines()" title="Line numbers" aria-label="Line numbers" aria-pressed="false" hidden><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="10" y1="6" x2="21" y2="6"/><line x1="10" y1="12" x2="21" y2="12"/><line x1="10" y1="18" x2="21" y2="18"/><path d="M4 6h1v4"/><path d="M4 10h2"/><path d="M6 18H4c0-1 2-2 2-3s-1-1.5-2-1"/></svg></button>
<button class="btn-icon-sm" id="filePreviewWrapBtn" onclick="app.toggleFilePreviewWrap()" title="Wrap lines" aria-label="Wrap lines" aria-pressed="true" hidden><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="3" y1="6" x2="21" y2="6"/><path d="M3 12h15a3 3 0 1 1 0 6h-4"/><polyline points="16 16 14 18 16 20"/><line x1="3" y1="18" x2="10" y2="18"/></svg></button>
<button class="btn-icon-sm file-preview-edit-btn" id="filePreviewEditBtn" onclick="app.enterFilePreviewEdit()" title="Edit file" aria-label="Edit file" hidden><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5z"/></svg></button>
<button class="btn-icon-sm" onclick="app.copyFilePreviewContent()" title="Copy content">&#x2398;</button>
<button class="btn-icon-sm" id="filePreviewDetachBtn" onclick="app.detachFilePreview()" title="Open in new tab" aria-label="Open in new tab" hidden><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg></button>
+203 -8
View File
@@ -20,6 +20,20 @@ const FILE_BROWSER_SHOW_HIDDEN_KEY = 'codeman:fileBrowserShowHidden';
// a huge log is a partial read rather than a download the viewer throws away.
const TEXT_PREVIEW_MAX_BYTES = 512 * 1024;
const TEXT_PREVIEW_MAX_LINES = 500;
// A markdown document gets the route's ceiling instead of the 500-line preview
// cap: a rendered README cut mid-way reads as the whole document.
const MARKDOWN_PREVIEW_MAX_LINES = 10000;
const MARKDOWN_EXTS = new Set(['md', 'markdown']);
// File Viewer text-view prefs: per-device, in their own localStorage keys for
// the same reason as FILE_BROWSER_SHOW_HIDDEN_KEY (the app-settings object is
// rebuilt from the settings modal on save, so a key toggled from the viewer
// would be dropped on the next save).
const FILE_PREVIEW_PREF_KEYS = {
mdRendered: 'codeman:filePreviewMdRendered',
lineNumbers: 'codeman:filePreviewLineNumbers',
wrap: 'codeman:filePreviewWrap',
};
const FILE_PREVIEW_PREF_DEFAULTS = { mdRendered: true, lineNumbers: false, wrap: true };
const AWAY_DIGEST_SECTIONS = [
['needsAttention', 'Needs Attention'],
['completed', 'Completed'],
@@ -4012,6 +4026,10 @@ Object.assign(CodemanApp.prototype, {
this.filePreviewDetachUrl = '';
const detachBtn = this.$('filePreviewDetachBtn');
if (detachBtn) detachBtn.hidden = true;
// Same for the text-view toggles: they act on the text this load has not
// fetched yet, and an image or PDF has nothing for them to toggle.
this.filePreviewText = null;
this._updateFilePreviewToolbar('none');
// Show overlay with loading state
overlay.classList.add('visible');
@@ -4095,12 +4113,17 @@ Object.assign(CodemanApp.prototype, {
const text = await res.text();
const clippedByBytes = res.status === 206 && text.length >= TEXT_PREVIEW_MAX_BYTES;
const lines = text.split('\n');
const clippedByLines = lines.length > TEXT_PREVIEW_MAX_LINES;
const shown = clippedByLines ? lines.slice(0, TEXT_PREVIEW_MAX_LINES).join('\n') : text;
bodyEl.innerHTML = `<pre><code>${escapeHtml(shown)}</code></pre>`;
// Markdown keeps every line the Range read returned: the byte bound is
// what protects the tab, and a rendered document cut at 500 lines
// reads as the whole document.
const lineCap = MARKDOWN_EXTS.has(ext) ? Infinity : TEXT_PREVIEW_MAX_LINES;
const clippedByLines = lines.length > lineCap;
const shown = clippedByLines ? lines.slice(0, lineCap).join('\n') : text;
this.filePreviewContent = shown;
this.filePreviewText = { ext, sessionId, filePath };
this._renderFilePreviewText();
if (clippedByLines || clippedByBytes) {
const note = clippedByLines ? `showing first ${TEXT_PREVIEW_MAX_LINES} lines` : 'showing the start of the file';
const note = clippedByLines ? `showing first ${lineCap} lines` : 'showing the start of the file';
footerEl.textContent = `${footerEl.textContent} (${note})`;
}
} catch (err) {
@@ -4146,8 +4169,13 @@ Object.assign(CodemanApp.prototype, {
return;
}
// 500 lines is what keeps a huge log from locking the tab in one <pre>;
// markdown is rendered as a document and takes the route's ceiling instead.
const lineCap = MARKDOWN_EXTS.has(ext) ? MARKDOWN_PREVIEW_MAX_LINES : TEXT_PREVIEW_MAX_LINES;
try {
const res = await fetch(`/api/sessions/${sessionId}/file-content?path=${encodeURIComponent(filePath)}&lines=500`);
const res = await fetch(
`/api/sessions/${sessionId}/file-content?path=${encodeURIComponent(filePath)}&lines=${lineCap}`
);
if (!res.ok) throw new Error('Failed to load file');
const result = await res.json();
@@ -4172,10 +4200,11 @@ Object.assign(CodemanApp.prototype, {
bodyEl.innerHTML = `<div class="binary-message">Binary file (${this.formatFileSize(data.size)})<br>Cannot preview<br><a href="${escapeHtml(downloadHref)}" download>Download</a></div>`;
footerEl.textContent = data.extension || 'binary';
} else {
// Text content
// Text content: rendered markdown or plain text, per the viewer's toggles.
this.filePreviewContent = data.content;
bodyEl.innerHTML = `<pre><code>${escapeHtml(data.content)}</code></pre>`;
const truncNote = data.truncated ? ` (showing 500/${data.totalLines} lines)` : '';
this.filePreviewText = { ext, sessionId, filePath };
this._renderFilePreviewText();
const truncNote = data.truncated ? ` (showing ${lineCap}/${data.totalLines} lines)` : '';
footerEl.textContent = `${data.totalLines} lines \u2022 ${this.formatFileSize(data.size)}${truncNote}`;
// Edit affordance only when the server says an edit=1 re-fetch would
// succeed (workspace text file inside the allowlist and size cap).
@@ -4203,6 +4232,8 @@ Object.assign(CodemanApp.prototype, {
// audible and keeps streaming from the server. Closing has to stop it.
this._stopFilePreviewMedia();
this.filePreviewContent = '';
this.filePreviewText = null;
this._updateFilePreviewToolbar('none');
this.filePreviewDetachUrl = '';
const detachBtn = this.$('filePreviewDetachBtn');
if (detachBtn) detachBtn.hidden = true;
@@ -4251,6 +4282,168 @@ Object.assign(CodemanApp.prototype, {
bodyEl.innerHTML = '';
},
// ═══════════════════════════════════════════════════════════════
// File Viewer text view: rendered markdown, line numbers, wrap
// ═══════════════════════════════════════════════════════════════
_filePreviewPref(name) {
try {
const stored = localStorage.getItem(FILE_PREVIEW_PREF_KEYS[name]);
if (stored === '1') return true;
if (stored === '0') return false;
} catch {
/* private mode: fall through to the default */
}
return FILE_PREVIEW_PREF_DEFAULTS[name];
},
_setFilePreviewPref(name, on) {
try {
localStorage.setItem(FILE_PREVIEW_PREF_KEYS[name], on ? '1' : '0');
} catch {
/* private mode: the toggle still applies for this page load */
}
},
/**
* Paint the loaded text (filePreviewContent) into the preview body: a
* rendered document for .md/.markdown while the MD toggle is on, otherwise
* plain text with one span per line so the Lines toggle can number them.
* The MD toggle re-runs this without a refetch.
*
* Markdown goes through the same pipeline as the Response Viewer
* (`_renderMarkdown`: marked + the DOMPurify allowlist), never a second
* parser, and is built inside a <template>: a detached div with innerHTML
* already set starts fetching every <img src>, so the document's relative
* image paths would hit the server as /docs/img.png 404s before
* `_rebaseFilePreviewMarkdownRefs` rewrote them.
*/
_renderFilePreviewText() {
const info = this.filePreviewText;
const bodyEl = this.$('filePreviewBody');
if (!info || !bodyEl) return;
const isMarkdown = MARKDOWN_EXTS.has(info.ext);
const rendered = isMarkdown && this._filePreviewPref('mdRendered');
if (rendered) {
// data-i18n-skip: the translator's MutationObserver would otherwise
// rewrite the document's own headings and paragraphs.
const tmpl = document.createElement('template');
tmpl.innerHTML = `<div class="rv-text file-preview-md" data-i18n-skip>${this._renderMarkdown(this.filePreviewContent)}</div>`;
const doc = tmpl.content.firstElementChild;
this._rebaseFilePreviewMarkdownRefs(doc, info);
this._linkifyFilePaths(doc);
bodyEl.replaceChildren(tmpl.content);
// The Response Viewer's click delegate (path links, code-block copy
// buttons, loopback links): container-bound and idempotent, so binding it
// on the body once serves every preview.
this._bindResponseViewerInteractions(bodyEl);
} else {
const pre = document.createElement('pre');
pre.className = 'file-preview-text';
pre.classList.toggle('wrap', this._filePreviewPref('wrap'));
pre.classList.toggle('show-lines', this._filePreviewPref('lineNumbers'));
const code = document.createElement('code');
// One span per line joined by real newlines: empty lines survive, select
// and copy return the exact text, and the gutter counter hangs off the
// spans' ::before so the numbers are never part of the text.
code.innerHTML = this.filePreviewContent
.split('\n')
.map((line) => `<span class="fp-line">${escapeHtml(line)}</span>`)
.join('\n');
pre.appendChild(code);
bodyEl.replaceChildren(pre);
}
this._updateFilePreviewToolbar(rendered ? 'markdown' : 'text');
},
/**
* Point a rendered document's relative references at the file it came from.
*
* Images are rebased onto the workspace-confined file-raw route under the
* document's directory (the server refuses escapes, so `..` is safe to
* forward). Whatever fails to load degrades to its alt text with one error
* handler: a remote image the page CSP blocks, a 404 for a document outside
* the workspace, an SVG that file-raw serves as a download. Relative links
* take the `a.rv-path` shape the Response Viewer delegate already opens in
* this overlay, minus the target/rel `_renderMarkdown` gave them, which
* would otherwise open <origin>/docs/x.md in a new tab.
*/
_rebaseFilePreviewMarkdownRefs(root, { sessionId, filePath }) {
const dir = filePath.includes('/') ? filePath.slice(0, filePath.lastIndexOf('/') + 1) : '';
// Relative = no scheme, not root-relative (which includes //host), not a fragment.
const isRelative = (ref) => !!ref && !/^[a-z][a-z0-9+.-]*:/i.test(ref) && !ref.startsWith('/') && !ref.startsWith('#');
// GitHub-style `img.png#gh-dark-mode-only` and `doc.md#section`: the
// fragment is not part of the path. `.` and `..` segments are collapsed so
// the title reads `README.md`, not `docs/../README.md`; a `..` that climbs
// past the start is kept and left for the server to refuse.
const resolveRef = (ref) => {
const parts = [];
for (const seg of (dir + ref.split('#')[0]).split('/')) {
if (seg === '.' || (seg === '' && parts.length)) continue;
if (seg === '..' && parts.length && parts[parts.length - 1] !== '..' && parts[parts.length - 1] !== '') parts.pop();
else parts.push(seg);
}
return parts.join('/');
};
for (const img of root.querySelectorAll('img[src]')) {
const src = img.getAttribute('src') || '';
if (isRelative(src)) {
const path = resolveRef(src);
img.setAttribute('src', CodemanBase.url(`/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(path)}`));
}
img.addEventListener('error', () => img.replaceWith(img.getAttribute('alt') || src), { once: true });
}
for (const a of root.querySelectorAll('a[href]')) {
const href = a.getAttribute('href') || '';
if (!isRelative(href)) continue;
a.className = 'rv-path';
a.dataset.path = resolveRef(href);
a.setAttribute('href', '#');
a.removeAttribute('target');
a.removeAttribute('rel');
}
},
/**
* Show the toggles that apply to the current view: MD for a markdown file in
* either view, Lines/Wrap for the plain-text view only; `'none'` (loading,
* image, media, PDF, edit mode) hides all three.
*/
_updateFilePreviewToolbar(view) {
const set = (id, shown, pressed) => {
const btn = this.$(id);
if (!btn) return;
btn.hidden = !shown;
if (shown) btn.setAttribute('aria-pressed', String(pressed));
};
const isMarkdown = view !== 'none' && MARKDOWN_EXTS.has(this.filePreviewText?.ext || '');
set('filePreviewMdBtn', isMarkdown, view === 'markdown');
set('filePreviewLinesBtn', view === 'text', this._filePreviewPref('lineNumbers'));
set('filePreviewWrapBtn', view === 'text', this._filePreviewPref('wrap'));
},
toggleFilePreviewMd() {
this._setFilePreviewPref('mdRendered', !this._filePreviewPref('mdRendered'));
this._renderFilePreviewText();
},
toggleFilePreviewLines() {
this._toggleFilePreviewTextClass('lineNumbers', 'show-lines');
},
toggleFilePreviewWrap() {
this._toggleFilePreviewTextClass('wrap', 'wrap');
},
/** Lines and Wrap are pure class flips on the <pre>; no re-render needed. */
_toggleFilePreviewTextClass(pref, className) {
const on = !this._filePreviewPref(pref);
this._setFilePreviewPref(pref, on);
const pre = this.$('filePreviewBody')?.querySelector(':scope > pre.file-preview-text');
if (pre) pre.classList.toggle(className, on);
this._updateFilePreviewToolbar('text');
},
// ═══════════════════════════════════════════════════════════════
// File Viewer edit mode (issue #212 — docs/file-viewer-edit-plan.md)
// ═══════════════════════════════════════════════════════════════
@@ -4318,6 +4511,8 @@ Object.assign(CodemanApp.prototype, {
textarea.addEventListener('input', () => this._onFilePreviewEditInput());
bodyEl.innerHTML = '';
bodyEl.appendChild(textarea);
// The MD/Lines/Wrap toggles act on the text view this textarea replaced.
this._updateFilePreviewToolbar('none');
// Deliberately no autofocus: on phones that would pop the OS keyboard
// before the user has scrolled to the line they want to change.
+65
View File
@@ -10984,6 +10984,19 @@ kbd {
.file-preview-actions {
display: flex;
gap: 0.25rem;
/* The title yields on a phone, not the buttons. */
flex-shrink: 0;
}
.file-preview-actions .btn-icon-sm[aria-pressed='true'] {
color: var(--accent, #4ea1ff);
background: var(--bg-hover, rgba(255, 255, 255, 0.08));
}
.file-preview-actions .file-preview-pill {
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 0.02em;
}
.file-preview-body {
@@ -11008,6 +11021,58 @@ kbd {
font-family: inherit;
}
/* ---- File Viewer text view: Lines / Wrap toggles ----
Child-combinator scoped so none of this leaks into the code blocks of a
rendered markdown document, which are <pre>s too. */
.file-preview-body > pre.file-preview-text {
counter-reset: fp-line;
tab-size: 4;
}
.file-preview-body > pre.file-preview-text:not(.wrap) {
white-space: pre;
word-break: normal;
overflow-x: auto;
}
/* Numbers hug the left edge (4px, left-aligned) instead of sitting behind the
pre's own padding right-aligned in a 4ch column, where "1" landed 40px in. */
.file-preview-body > pre.file-preview-text.show-lines {
padding-left: 4px;
}
.file-preview-body > pre.file-preview-text.show-lines .fp-line::before {
counter-increment: fp-line;
content: counter(fp-line);
display: inline-block;
min-width: 4ch;
margin-right: 1ch;
text-align: left;
color: var(--text-muted);
user-select: none;
}
/* ---- File Viewer rendered markdown ----
Styling comes from the Response Viewer's .rv-text rules; .rv-text itself
carries no padding or base font (the chat card supplies those). */
.file-preview-body > .file-preview-md {
padding: 1rem 1.25rem 2rem;
font-size: 15px;
line-height: 1.55;
max-width: 960px;
}
/* Relative links in the document are rebased onto a.rv-path so the Response
Viewer delegate opens them here; keep them reading as prose, not as paths.
Three-class selector on purpose: `.rv-text a.rv-path` (the monospace path
style) is declared later in this file and ties on specificity otherwise. */
.file-preview-body .file-preview-md a.rv-path {
font: inherit;
word-break: normal;
}
.file-preview-body img {
max-width: 100%;
max-height: 100%;
+2 -1
View File
@@ -1724,7 +1724,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
// breadth of formats the attachments viewer renders (image/audio/video/pdf)
// so the file viewer can open the same files.
const ext = filePath.split('.').pop()?.toLowerCase() || '';
const imageExts = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico']);
const imageExts = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'avif', 'svg', 'bmp', 'ico']);
// Shared with the attachment registry so a video plays the same whether it
// sits in the workspace or is reached by id from outside it.
const videoExts = VIDEO_ATTACHMENT_EXTENSIONS;
@@ -2045,6 +2045,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
jpeg: 'image/jpeg',
gif: 'image/gif',
webp: 'image/webp',
avif: 'image/avif',
ico: 'image/x-icon',
bmp: 'image/bmp',
mp4: 'video/mp4',