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
View File
@@ -290,6 +290,8 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**File Viewer edit mode** (issue #212): the file-preview overlay edits workspace text files in place — `GET .../file-content?edit=1` + `PUT /api/sessions/:id/file-content`, policy in `src/config/file-editing.ts`. This is a **third file surface and the only one that WRITES**: read-path confinement (realpath + workspace + ownership) plus sensitive/blocked/`.git` denies and an extension **allowlist**; writes are `wx`-temp + rename (no `O_CREAT` anywhere = edit-in-place is structural); optimistic concurrency via sha256 `baseHash` → 409. ⚠️ `edit=1` never truncates and the client must never save a plain-preview buffer (the 500-line truncation would silently delete the rest). ⚠️ CRLF/UTF-8 guards: EOL re-applied server-side, non-UTF-8 refused via round-trip compare. → [architecture-invariants#file-viewer-edit-mode](docs/architecture-invariants.md#file-viewer-edit-mode), `docs/file-viewer-edit-plan.md`
**File Viewer text view: rendered markdown + Lines/Wrap toggles** (`_renderFilePreviewText()` in panels-ui.js): a `.md`/`.markdown` opens RENDERED by default with an `MD` pill back to source; the plain-text view has `Lines` (CSS-counter gutter) and `Wrap` toggles. ⚠️ ONE markdown pipeline: the viewer calls `_renderMarkdown()` (marked + the DOMPurify allowlist, the Response Viewer's) and binds the Response Viewer's click delegate (`_bindResponseViewerInteractions`) on the preview body for code-copy buttons and path links; never a second parser or handler. ⚠️ The document is built inside a `<template>` (a detached div with `innerHTML` set starts fetching every `<img src>` before the rewrite), then `_rebaseFilePreviewMarkdownRefs()` points relative images at the workspace-confined `file-raw` under the document's directory (never a widened route; a failed load degrades to alt text) and turns relative links into `a.rv-path` for the delegate, stripping the `target` marked gave them. ⚠️ The container carries `data-i18n-skip` or the translator rewrites the document's prose. ⚠️ Toggles are per-device localStorage keys (`codeman:filePreview*`), never `SettingsUpdateSchema`; Lines/Wrap are class flips on the ONE `<pre>`, with rules scoped `.file-preview-body > pre.file-preview-text` so they never leak into the document's code blocks. Markdown fetches `lines=10000` (the route ceiling), other text keeps 500. ⚠️ `md` stays OUT of `FILE_PREVIEW_EXTENSIONS`: a printed `.md` path keeps the tail viewer (live follow); the rendered view is the Files panel's. Tests: `test/file-preview-markdown.test.ts`. → [architecture-invariants#file-viewer-text-view-rendered-markdown-and-text-toggles](docs/architecture-invariants.md#file-viewer-text-view-rendered-markdown-and-text-toggles)
**Files panel search** (COD-236, the `q` param on `GET /api/sessions/:id/files`): `compileFileQuery()` (`utils/file-query.ts`, pure) compiles the query into a predicate the server-side walk prunes with; a query returns a FLAT match list and the walk recurses past non-matching directories. An empty, whitespace-only or overlong (`MAX_QUERY_LENGTH`, 256) query compiles to `null`, keeping the default tree response byte-identical. ⚠️ **Never compile a glob into a RegExp** (`*a*a*a…` backtracks and freezes the event loop for the whole server): `globMatch()` is a two-pointer wildcard walk. → [architecture-invariants#files-panel-search](docs/architecture-invariants.md#files-panel-search)
**Raw file bodies are streamed and range-aware**: `file-raw`, the attachments `/raw` route and `GET /api/download` share `sendFileBody()`, advertise `Accept-Ranges: bytes` and answer `Range` with `206` + `Content-Range` (single-range, parser in `src/web/http-range.ts`); without it `<video>` cannot seek. The size cap (`MAX_FILE_DOWNLOAD_BYTES`, default 2GB, env `CODEMAN_MAX_DOWNLOAD_BYTES`, `0` = unlimited) is a sanity bound, not memory protection; never reintroduce a whole-file buffer. ⚠️ Bodies go out via `reply.hijack()`, so `sendRawStream` must copy the status onto `reply.raw` by hand or a partial body ships as `200`. ⚠️ Closing the preview must pause and unload media (`_stopFilePreviewMedia`), since a detached `HTMLMediaElement` keeps playing. → [architecture-invariants#raw-file-bodies-streamed-and-range-aware](docs/architecture-invariants.md#raw-file-bodies-streamed-and-range-aware)
+13
View File
@@ -384,6 +384,19 @@ The general rule: **any new endpoint that turns a caller-supplied `sessionId` in
Tests: `test/file-editing-policy.test.ts` (pure policy), `test/routes/file-write-routes.test.ts` (deliberately **unmocked fs** against a real temp workspace — symlink/TOCTOU/mode behavior must be exercised for real).
### File Viewer text view: rendered markdown and text toggles
**Rendered markdown + Lines/Wrap** (`_renderFilePreviewText()` and its helpers in `panels-ui.js`, buttons in the `.file-preview-actions` row): a `.md`/`.markdown` opened in the File Viewer renders as a document by default, with an `MD` pill back to source; the plain-text view has `Lines` (a CSS-counter gutter) and `Wrap` toggles. Codeman already had `marked` + DOMPurify behind `_renderMarkdown()` for the Response Viewer, so the viewer reuses that and the codebase keeps ONE markdown pipeline.
- ⚠️ **One pipeline, one delegate.** The viewer calls `_renderMarkdown()` (marked + the `sanitize-html.js` allowlist) and binds `_bindResponseViewerInteractions()` on `#filePreviewBody` (container-bound and idempotent, so once per page) for the code-block copy buttons and `a.rv-path` opening. Never a second parser, never a second click handler for the same markup.
- ⚠️ **Build inside a `<template>`, then rebase.** A detached div whose `innerHTML` is set starts fetching every `<img src>` at once, so the document's relative image paths would hit the server as `/docs/img.png` 404s before being rewritten. `_rebaseFilePreviewMarkdownRefs()` runs on the template content: relative images go to the workspace-confined `file-raw` under the document's directory (the server refuses escapes, so `..` is forwarded as-is), and one `error` handler per image degrades it to alt text, which covers a remote image the page CSP blocks, a 404 for a document outside the workspace, and an SVG that `file-raw` serves as a download. Never widen a route for this. Relative links become `a.rv-path` with `data-path` and lose the `target`/`rel` that `_renderMarkdown` gives every link, which would otherwise open `<origin>/docs/x.md` in a new tab; fragment and http(s) links are untouched.
- ⚠️ **`data-i18n-skip` on the container.** The translator's MutationObserver translates inserted headings and paragraphs, and the `.file-preview-content` entry in its skip list matches nothing (no element has that class), so the attribute is what keeps a Chinese UI from rewriting a README.
- ⚠️ **Toggles are per-device, in their own localStorage keys** (`codeman:filePreviewMdRendered` / `LineNumbers` / `Wrap`), for the same reason as the Files panel's show-hidden toggle: the app-settings object is rebuilt from the settings modal on every save, and they are display state, not synced settings (`SettingsUpdateSchema` is `.strict()`). MD re-renders from the kept source (`filePreviewContent`, which is also what Copy copies) without a refetch; Lines/Wrap are class flips on the one `<pre>`, whose rules are scoped `.file-preview-body > pre.file-preview-text` so they never leak into the document's own code blocks. Lines are one inline `<span class="fp-line">` per line joined by real newlines, the counter in `::before` with `user-select: none`, so select and copy return the exact text.
- ⚠️ **Caps.** Markdown fetches `lines=10000` (the route's `MAX_LINES_LIMIT`), because a rendered document cut at 500 lines reads as the whole document; other text keeps 500, which is what stops a huge log locking the tab in one `<pre>`. The attachment (out-of-workspace) branch keeps its 512 KB Range read and skips the 500-line clip for markdown. Edit mode is unchanged and still re-fetches `edit=1`; the three toggles hide while editing and for images, media and PDFs.
- ⚠️ **`md` is NOT in `FILE_PREVIEW_EXTENSIONS`.** A `.md` path printed in the terminal or chat still opens the tail viewer (see File-path links above: in-workspace text keeps live follow, which is what Ralph's `fix_plan.md` needs); the rendered view is reached from the Files panel. `avif` and `ico` were added there (a printed `favicon.ico` used to tail binary noise), with `avif` also in file-content's image set and file-raw's MIME map; out-of-workspace avif/ico stay unregistrable, like svg/bmp.
Tests: `test/file-preview-markdown.test.ts` (jsdom-in-vm, pins every rule above), `test/routes/file-routes.test.ts` (avif).
### Clone a repository as a case
**Clone Repo tab** (issue #236, proposed by @DodgyBadger): `POST /api/cases/clone` clones a public repository into the caller's case space and registers it as a normal local case; `POST /api/cases/clone-preflight` answers "can this be cloned anonymously, and what refs does it have?" while the user is still typing. Core in `src/git-clone.ts`, split into a PURE half (URL parse, argv/env, `ls-remote` parse, stderr classification) and a thin IO half (`probeGitRemote`, `cloneRepository`).
+2 -1
View File
@@ -13,7 +13,8 @@ It renders what it can:
| Kind | Behaviour |
| ------------------------ | ------------------------------------------------------------------------- |
| Text and code | Syntax-aware preview. Long files are truncated in plain preview. |
| Text and code | Plain preview with Lines (line numbers) and Wrap toggles in the header. Long files are truncated in plain preview. |
| Markdown | Rendered by default: headings, tables, code blocks with copy buttons, images and links relative to the file. The MD pill in the header flips to source. |
| Images | Inline. |
| Audio and video | Inline with a working scrub bar, because range requests are supported. |
| PDF and Office documents | Converted for preview when a converter is available. |
+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',
+292
View File
@@ -0,0 +1,292 @@
/**
* @fileoverview File Viewer text view: rendered markdown plus Lines/Wrap toggles.
*
* Clicking a `.md` in the Files panel showed wrapped source with no way to see
* it rendered, although the Response Viewer's marked + DOMPurify pipeline
* (`_renderMarkdown`) was already on the page. The viewer now renders markdown
* through that same pipeline, with an MD toggle back to source, and the
* plain-text view gained Lines and Wrap toggles. Pinned here:
*
* 1. `.md` renders into `.rv-text.file-preview-md[data-i18n-skip]` while the
* pref is on and into a `<pre>` of per-line spans while it is off; the MD
* toggle re-renders WITHOUT a second fetch and persists per device.
* 2. Relative image refs are rebased onto the workspace-confined file-raw
* route under the document's directory and a failed load degrades to alt
* text; relative links become `a.rv-path` for the Response Viewer delegate
* and lose the `target` marked gave them, while fragment and http(s) links
* stay untouched.
* 3. Markdown fetches the route's line ceiling; other text keeps 500.
* 4. Lines/Wrap flip classes on the <pre> and persist, and the text the <pre>
* holds is byte-identical to the file; every toggle is hidden for an image
* and while editing.
* 5. `FILE_PREVIEW_EXTENSIONS` gained avif/ico and still has no `md`
* (in-workspace text keeps the tail viewer, see architecture-invariants).
*
* Loaded via `vm` with a jsdom document injected (the technique from
* response-viewer-file-links.test.ts): constants.js + panels-ui.js only, with
* the app.js markdown pipeline stubbed to a fixed fragment.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { JSDOM } from 'jsdom';
import { describe, expect, it, vi } from 'vitest';
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
const constantsJs = readFileSync(resolve(PUBLIC, 'constants.js'), 'utf8');
const panelsJs = readFileSync(resolve(PUBLIC, 'panels-ui.js'), 'utf8');
// A real origin: vitest's equality walker reaches the window through a node's
// ownerDocument, and jsdom's localStorage getter throws on an opaque one.
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', { url: 'http://localhost/' });
const { document } = dom.window;
/** What the stubbed `_renderMarkdown` hands back: every ref shape the rebase pass must classify. */
const MARKDOWN_HTML =
'<h1>Title</h1><p>x</p>' +
'<img src="img/a.png#gh-dark-mode-only" alt="Alt A">' +
'<img src="https://cdn.example.com/r.png" alt="remote">' +
'<a href="guide/x.md#sec" target="_blank" rel="noopener noreferrer">x</a>' +
'<a href="../CHANGELOG.md" target="_blank" rel="noopener noreferrer">up</a>' +
'<a href="#top">t</a>' +
'<a href="https://e.com" target="_blank" rel="noopener noreferrer">e</a>';
const MD_CONTENT = '# Title\n\nx\n';
const TXT_CONTENT = 'one\n\n three\tfour\n';
function jsonResponse(body: unknown) {
return { ok: true, status: 200, json: async () => body, text: async () => JSON.stringify(body) };
}
/** Answer file-content like the route does: text as JSON, an image as metadata. */
function fetchStub(url: string) {
const path = decodeURIComponent(new URL(url, 'http://x').searchParams.get('path') || '');
const ext = path.split('.').pop() || '';
if (ext === 'png') {
return jsonResponse({
success: true,
data: { type: 'image', url: `/file-raw?path=${path}`, size: 5, extension: ext },
});
}
const content = ext === 'md' ? MD_CONTENT : TXT_CONTENT;
if (url.includes('edit=1')) {
return jsonResponse({
success: true,
data: { content, hash: 'h', eol: 'lf', totalLines: 3, size: content.length },
});
}
return jsonResponse({
success: true,
data: { path, content, totalLines: 3, size: content.length, truncated: false, extension: ext, editable: true },
});
}
function loadApp(prefs: Record<string, string> = {}) {
const store = new Map(Object.entries(prefs));
const CodemanApp = function CodemanApp(this: unknown) {} as unknown as new () => Record<string, any>;
const fetchMock = vi.fn(async (url: string) => fetchStub(url));
const context = vm.createContext({
CodemanApp,
console: { ...console, warn: vi.fn(), error: vi.fn() },
localStorage: {
getItem: (k: string) => (store.has(k) ? store.get(k) : null),
setItem: (k: string, v: string) => store.set(k, v),
removeItem: (k: string) => store.delete(k),
},
document,
window: { addEventListener: vi.fn(), removeEventListener: vi.fn(), open: vi.fn() },
MobileDetection: {},
setTimeout,
clearTimeout,
confirm: () => true,
fetch: fetchMock,
});
vm.runInContext(`${constantsJs}\n${panelsJs}\nglobalThis.__exts = FILE_PREVIEW_EXTENSIONS;`, context, {
filename: 'panels-ui.js',
});
document.body.innerHTML = `
<div id="filePreviewOverlay"></div><span id="filePreviewTitle"></span>
<button id="filePreviewMdBtn" hidden></button>
<button id="filePreviewLinesBtn" hidden></button>
<button id="filePreviewWrapBtn" hidden></button>
<button id="filePreviewEditBtn" hidden></button>
<button id="filePreviewDetachBtn" hidden></button>
<div id="filePreviewBody"></div><div id="filePreviewFooter"></div>`;
const app = new CodemanApp();
app.$ = (id: string) => document.getElementById(id);
app._resetFilePreviewEdit = () => {};
app._isExternalPreviewPath = () => false;
app.formatFileSize = (n: number) => `${n} B`;
app.showToast = vi.fn();
app.filePreviewContent = '';
app._renderMarkdown = vi.fn(() => MARKDOWN_HTML);
app._linkifyFilePaths = vi.fn();
app._bindResponseViewerInteractions = vi.fn();
const byId = (id: string) => document.getElementById(id) as HTMLButtonElement;
return {
app,
fetchMock,
store,
body: byId('filePreviewBody'),
exts: (context as { __exts: Set<string> }).__exts,
btn: { md: byId('filePreviewMdBtn'), lines: byId('filePreviewLinesBtn'), wrap: byId('filePreviewWrapBtn') },
};
}
describe('file viewer rendered markdown', () => {
it('renders .md through the shared markdown pipeline, inert to i18n, with the viewer delegate bound', async () => {
const { app, body, btn } = loadApp();
await app.openFilePreview('docs/README.md', 's1');
const doc = body.firstElementChild as HTMLElement;
expect(doc.matches('.rv-text.file-preview-md[data-i18n-skip]')).toBe(true);
expect(doc.querySelector('h1')?.textContent).toBe('Title');
expect(app._renderMarkdown).toHaveBeenCalledWith(MD_CONTENT);
// Identity, not deep equality: DOM nodes are compared by reference here.
expect(app._linkifyFilePaths.mock.calls[0][0]).toBe(doc);
expect(app._bindResponseViewerInteractions.mock.calls[0][0]).toBe(body);
// The source stays what Copy copies.
expect(app.filePreviewContent).toBe(MD_CONTENT);
// MD is the only toggle that applies to a rendered document; Edit still offered.
expect(btn.md.hidden).toBe(false);
expect(btn.md.getAttribute('aria-pressed')).toBe('true');
expect(btn.lines.hidden).toBe(true);
expect(btn.wrap.hidden).toBe(true);
expect(document.getElementById('filePreviewEditBtn')!.hidden).toBe(false);
});
it('fetches the route ceiling for markdown and the 500-line cap for other text', async () => {
const { app, fetchMock } = loadApp();
await app.openFilePreview('docs/README.md', 's1');
await app.openFilePreview('notes.txt', 's1');
const urls = fetchMock.mock.calls.map((c) => c[0]);
expect(urls[0]).toContain('lines=10000');
expect(urls[1]).toContain('lines=500');
});
it('rebases relative images and links onto the document directory and leaves the rest alone', async () => {
const { app, body } = loadApp();
await app.openFilePreview('docs/README.md', 's1');
const local = body.querySelector('img[alt="Alt A"]')!;
expect(local.getAttribute('src')).toBe(`/api/sessions/s1/file-raw?path=${encodeURIComponent('docs/img/a.png')}`);
expect(body.querySelector('img[alt="remote"]')!.getAttribute('src')).toBe('https://cdn.example.com/r.png');
const rel = body.querySelector('a.rv-path')!;
expect(rel.getAttribute('data-path')).toBe('docs/guide/x.md');
expect(rel.getAttribute('href')).toBe('#');
expect(rel.hasAttribute('target')).toBe(false);
expect(rel.hasAttribute('rel')).toBe(false);
const anchors = Array.from(body.querySelectorAll('a'));
// `..` is collapsed against the document directory, so the title reads
// CHANGELOG.md rather than docs/../CHANGELOG.md.
const up = anchors.find((a) => a.textContent === 'up')!;
expect(up.classList.contains('rv-path')).toBe(true);
expect(up.getAttribute('data-path')).toBe('CHANGELOG.md');
const fragment = anchors.find((a) => a.textContent === 't')!;
expect(fragment.getAttribute('href')).toBe('#top');
expect(fragment.classList.contains('rv-path')).toBe(false);
const external = anchors.find((a) => a.textContent === 'e')!;
expect(external.getAttribute('href')).toBe('https://e.com');
expect(external.getAttribute('target')).toBe('_blank');
});
it('degrades an image that fails to load to its alt text', async () => {
const { app, body } = loadApp();
await app.openFilePreview('docs/README.md', 's1');
const remote = body.querySelector('img[alt="remote"]')!;
remote.dispatchEvent(new dom.window.Event('error'));
expect(body.querySelector('img[alt="remote"]')).toBeNull();
expect(body.textContent).toContain('remote');
});
it('MD toggle flips to per-line source and back without refetching, and persists', async () => {
const { app, body, btn, fetchMock, store } = loadApp();
await app.openFilePreview('docs/README.md', 's1');
app.toggleFilePreviewMd();
const pre = body.firstElementChild as HTMLElement;
expect(pre.matches('pre.file-preview-text')).toBe(true);
expect(pre.querySelectorAll('.fp-line')).toHaveLength(MD_CONTENT.split('\n').length);
expect(pre.textContent).toBe(MD_CONTENT);
expect(store.get('codeman:filePreviewMdRendered')).toBe('0');
expect(btn.md.getAttribute('aria-pressed')).toBe('false');
expect(btn.lines.hidden).toBe(false);
expect(btn.wrap.hidden).toBe(false);
app.toggleFilePreviewMd();
expect((body.firstElementChild as HTMLElement).matches('.file-preview-md')).toBe(true);
expect(store.get('codeman:filePreviewMdRendered')).toBe('1');
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('opens as source when the device pref says so', async () => {
const { app, body, btn } = loadApp({ 'codeman:filePreviewMdRendered': '0' });
await app.openFilePreview('docs/README.md', 's1');
expect((body.firstElementChild as HTMLElement).matches('pre.file-preview-text')).toBe(true);
expect(btn.md.hidden).toBe(false);
expect(btn.md.getAttribute('aria-pressed')).toBe('false');
});
});
describe('file viewer Lines and Wrap toggles', () => {
it('flip classes on the <pre>, persist, and never alter the text', async () => {
const { app, body, btn, store } = loadApp();
await app.openFilePreview('notes.txt', 's1');
const pre = body.firstElementChild as HTMLElement;
expect(pre.matches('pre.file-preview-text.wrap:not(.show-lines)')).toBe(true);
expect(pre.textContent).toBe(TXT_CONTENT);
expect(btn.md.hidden).toBe(true);
app.toggleFilePreviewLines();
expect(pre.classList.contains('show-lines')).toBe(true);
expect(store.get('codeman:filePreviewLineNumbers')).toBe('1');
expect(btn.lines.getAttribute('aria-pressed')).toBe('true');
app.toggleFilePreviewWrap();
expect(pre.classList.contains('wrap')).toBe(false);
expect(store.get('codeman:filePreviewWrap')).toBe('0');
expect(btn.wrap.getAttribute('aria-pressed')).toBe('false');
// Same element, no re-render: the counter gutter is CSS, not text.
expect(body.firstElementChild).toBe(pre);
expect(pre.textContent).toBe(TXT_CONTENT);
});
it('are hidden for an image and while editing', async () => {
const { app, btn, body } = loadApp();
await app.openFilePreview('shot.png', 's1');
expect(btn.md.hidden && btn.lines.hidden && btn.wrap.hidden).toBe(true);
await app.openFilePreview('notes.txt', 's1');
expect(btn.lines.hidden).toBe(false);
await app.enterFilePreviewEdit();
expect(body.querySelector('textarea.file-preview-editor')).not.toBeNull();
expect(btn.md.hidden && btn.lines.hidden && btn.wrap.hidden).toBe(true);
});
});
describe('FILE_PREVIEW_EXTENSIONS', () => {
it('routes avif and ico paths to the viewer and leaves .md with the tail viewer', () => {
const { exts } = loadApp();
expect(exts.has('avif')).toBe(true);
expect(exts.has('ico')).toBe(true);
expect(exts.has('md')).toBe(false);
});
});
+25
View File
@@ -681,6 +681,18 @@ describe('file-routes', () => {
expect(body.data.url).toContain('file-raw');
});
it('classifies avif as an image so the viewer renders it instead of dumping bytes', async () => {
mockedStat.mockResolvedValue({ size: 1024 } as never);
const res = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${harness.ctx._sessionId}/file-content?path=photo.avif`,
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.data.type).toBe('image');
});
it('returns audio metadata for audio files', async () => {
mockedStat.mockResolvedValue({ size: 2048 } as never);
@@ -817,6 +829,19 @@ describe('file-routes', () => {
expect(res.headers['content-type']).toBe('image/png');
});
it('serves avif with its image type, since <img> refuses an octet-stream', async () => {
const content = Buffer.from('fake avif data');
mockedCreateReadStream.mockReturnValue(Readable.from([content]) as never);
mockedStat.mockResolvedValue({ size: content.length } as never);
const res = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${harness.ctx._sessionId}/file-raw?path=photo.avif`,
});
expect(res.statusCode).toBe(200);
expect(res.headers['content-type']).toBe('image/avif');
});
it('serves workspace SVG as an untrusted attachment instead of inline image/svg+xml', async () => {
const content = Buffer.from('<svg><script>alert("xss")</script></svg>');
mockedCreateReadStream.mockReturnValue(Readable.from([content]) as never);