fix(files): keep attachment markdown refs from resolving into the workspace, and render files without chat line breaks (#503 review)

- A markdown preview opened by attachment id under a bare file name
  (attachment cards, history drawer) no longer resolves relative refs
  against the workspace root: filePreviewText carries attachmentId, and
  the rebase pass turns those images into their alt text and unwraps
  those links. Absolute-path and workspace previews are unchanged.
- _renderMarkdown(text, { breaks = true } = {}): the File Viewer passes
  breaks: false, so a hard-wrapped paragraph renders as one paragraph;
  the Response Viewer keeps a <br> per newline.
- Absolute paths linkified inside a rendered document now carry the
  preview's data-session-id.
- CLAUDE.md, architecture-invariants and the Working-With-Files wiki page
  now say that only an in-workspace path clicked in the terminal keeps
  the tail viewer.
- Tests in test/file-preview-markdown.test.ts for all three fixes,
  including an end-to-end run of the shipping app.js + marked + DOMPurify.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-10-01 11:19:46 +02:00
parent 3af1ff6fae
commit 73c0bfccc4
6 changed files with 205 additions and 29 deletions
+1 -1
View File
@@ -295,7 +295,7 @@ 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 and root-relative ones under the workspace root (never a widened route; a failed load degrades to alt text), after `decodeURIComponent`ing the ref and dropping `?query`/`#fragment` (marked percent-encodes destinations, and the route encodes again), and turns workspace links into `a.rv-path` carrying `data-session-id` 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)
**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(text, { breaks: false })` (marked + the DOMPurify allowlist, the Response Viewer's; chat keeps the default `breaks: true`, a file must not turn every hard wrap into a `<br>`) 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 and root-relative ones under the workspace root (never a widened route; a failed load degrades to alt text), after `decodeURIComponent`ing the ref and dropping `?query`/`#fragment` (marked percent-encodes destinations, and the route encodes again), and turns workspace links into `a.rv-path` carrying `data-session-id` for the delegate (the linkifier's absolute paths get it too), stripping the `target` marked gave them. ⚠️ A preview opened by attachment id under a bare file name (attachment cards, history drawer: the registry keeps no relative path) has no directory, so its workspace refs degrade (images to alt text, links to their text), never resolve against the workspace root. ⚠️ 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`: only an IN-WORKSPACE `.md` path clicked in the TERMINAL keeps the tail viewer (live follow); the Files panel, chat paths, out-of-workspace terminal paths and attachment cards all reach `openFilePreview()` and render it. 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)
+3 -3
View File
@@ -392,12 +392,12 @@ Tests: `test/file-editing-policy.test.ts` (pure policy), `test/routes/file-write
**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, root-relative ones (`/docs/x.png`) under the workspace root as on GitHub (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. ⚠️ marked percent-encodes destinations (`my image.png` arrives as `my%20image.png`, CJK names as `%E5…`), so the ref is `decodeURIComponent`ed (a malformed escape is kept as written) and stripped of `#fragment` and `?query` BEFORE the route encodes it again; without that `file-raw` looks for a file literally named `my%20image.png`. Workspace links become `a.rv-path` with `data-path` AND `data-session-id` (the preview's session, which the `app.js` delegate prefers over `activeSessionId`, since a preview opened from another session's attachment card must resolve links against that workspace) and lose the `target`/`rel` that `_renderMarkdown` gives every link, which would otherwise open `<origin>/docs/x.md` in a new tab; fragment, protocol-relative and http(s) links are untouched.
- ⚠️ **One pipeline, one delegate.** The viewer calls `_renderMarkdown(text, { breaks: false })` (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. `breaks` is the one option that differs: chat keeps the default `true` (a newline is the agent's line break), while a file passes `false`, because a README hard-wrapped at 80 columns would otherwise render every wrap as a `<br>`, unlike GitHub's file view.
- ⚠️ **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, root-relative ones (`/docs/x.png`) under the workspace root as on GitHub (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. ⚠️ marked percent-encodes destinations (`my image.png` arrives as `my%20image.png`, CJK names as `%E5…`), so the ref is `decodeURIComponent`ed (a malformed escape is kept as written) and stripped of `#fragment` and `?query` BEFORE the route encodes it again; without that `file-raw` looks for a file literally named `my%20image.png`. Workspace links become `a.rv-path` with `data-path` AND `data-session-id` (the preview's session, which the `app.js` delegate prefers over `activeSessionId`, since a preview opened from another session's attachment card must resolve links against that workspace) and lose the `target`/`rel` that `_renderMarkdown` gives every link, which would otherwise open `<origin>/docs/x.md` in a new tab; fragment, protocol-relative and http(s) links are untouched. The absolute paths `_linkifyFilePaths()` then finds in the prose get the same `data-session-id`, set on every `a.rv-path` still lacking one. ⚠️ **An attachment opened under a bare file name has no directory.** Attachment cards and the history drawer call `openFilePreview(relativePath || fileName, sessionId, attachmentId)`, and a registered attachment's `relativePath` is always `''` (`attachmentRecordToEvent`), so `filePath` is just `report.md`: resolving against it sent `docs/report.md`'s `img/chart.png` to the workspace root's `img/chart.png` (a missing image) and its `CONTRIBUTING.md` link to the root's (a silently different file), and an out-of-workspace attachment's refs all landed in the workspace. `filePreviewText` therefore carries `attachmentId`, and when it is set with a non-absolute `filePath` the rebase degrades every workspace ref instead: images become their alt text (as a text node), links are unwrapped to their text. An absolute-path attachment (one `_registerExternalPreview` minted for a clicked path) still resolves against its own directory, as does every workspace preview.
- ⚠️ **`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.
- ⚠️ **`md` is NOT in `FILE_PREVIEW_EXTENSIONS`.** Only an in-workspace `.md` path clicked in the terminal 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). Everything else reaches `openFilePreview()` and renders it: the Files panel, a path clicked in the Response Viewer (its delegate calls `openFilePreview()` directly), an out-of-workspace terminal path, and attachment cards. `avif` and `ico` were added to the set (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).
+4 -3
View File
@@ -14,7 +14,7 @@ It renders what it can:
| Kind | Behaviour |
| ------------------------ | ------------------------------------------------------------------------- |
| 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 (root-relative ones resolve from the workspace root, as on GitHub). The MD pill in the header flips to source. |
| Markdown | Rendered by default: headings, tables, code blocks with copy buttons, images and links relative to the file (root-relative ones resolve from the workspace root, as on GitHub). Opened from an attachment card, where the file's folder is unknown, relative images show their alt text and relative links show as plain text. 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. |
@@ -86,8 +86,9 @@ File paths in a session are links. That works in two places:
render as underlined monospace links.
Clicking one opens it in the preview: images and PDFs render, video and audio play with a
working scrub bar, documents convert, text and Markdown show inline. Log-shaped files open in
the tail viewer instead, which follows a file that is still being written.
working scrub bar, documents convert, text shows inline and Markdown renders. The exception is
a text or Markdown file inside the workspace clicked in the terminal: that opens in the tail
viewer instead, which follows a file that is still being written.
Paths **outside** the session's workspace work too, which matters because that is where most
of an agent's output lands: a screenshot in `/tmp`, a capture in its own scratchpad, a file in
+8 -3
View File
@@ -2296,13 +2296,18 @@ class CodemanApp {
return processed.replace(/__CODEMAN_FENCE_(\d+)__/g, (_m, i) => placeholders[Number(i)]);
}
/** Render markdown to sanitized HTML, falling back to plain text if marked.js unavailable */
_renderMarkdown(text) {
/**
* Render markdown to sanitized HTML, falling back to plain text if marked.js unavailable.
* `breaks` turns every source newline into a <br>: right for chat, where a
* newline is the agent's line break, wrong for a file (the File Viewer passes
* false), where a README hard-wrapped at 80 columns would break at every wrap.
*/
_renderMarkdown(text, { breaks = true } = {}) {
const src = text || '';
if (typeof marked !== 'undefined' && marked.parse) {
try {
const prepared = this._preprocessAsciiArt(src);
let html = this._sanitizeHtml(marked.parse(prepared, { breaks: true, gfm: true }));
let html = this._sanitizeHtml(marked.parse(prepared, { breaks, gfm: true }));
// Wrap tables in a horizontal-scroll container so they overflow gracefully
// on mobile without collapsing into block-level cells.
html = html.replace(/<table>/g, '<div class="rv-table-wrap"><table>')
+27 -4
View File
@@ -4197,7 +4197,9 @@ Object.assign(CodemanApp.prototype, {
const clippedByLines = lines.length > lineCap;
const shown = clippedByLines ? lines.slice(0, lineCap).join('\n') : text;
this.filePreviewContent = shown;
this.filePreviewText = { ext, sessionId, filePath };
// attachmentId: a card's filePath is the bare file name, so the
// rebase pass must know there is no directory to resolve against.
this.filePreviewText = { ext, sessionId, filePath, attachmentId };
this._renderFilePreviewText();
if (clippedByLines || clippedByBytes) {
const note = clippedByLines ? `showing first ${lineCap} lines` : 'showing the start of the file';
@@ -4393,7 +4395,9 @@ Object.assign(CodemanApp.prototype, {
* 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.
* `_rebaseFilePreviewMarkdownRefs` rewrote them. `breaks: false` because a
* file is not a chat message: a paragraph hard-wrapped in the source is one
* paragraph, as on GitHub.
*/
_renderFilePreviewText() {
const info = this.filePreviewText;
@@ -4405,10 +4409,13 @@ Object.assign(CodemanApp.prototype, {
// 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>`;
tmpl.innerHTML = `<div class="rv-text file-preview-md" data-i18n-skip>${this._renderMarkdown(this.filePreviewContent, { breaks: false })}</div>`;
const doc = tmpl.content.firstElementChild;
this._rebaseFilePreviewMarkdownRefs(doc, info);
this._linkifyFilePaths(doc);
// The linkifier's absolute paths name no session; give them the
// preview's, like the rebased links, or they open in the active tab's.
for (const a of doc.querySelectorAll('a.rv-path:not([data-session-id])')) a.dataset.sessionId = info.sessionId;
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
@@ -4447,9 +4454,17 @@ Object.assign(CodemanApp.prototype, {
* would otherwise open <origin>/docs/x.md in a new tab, and carry the
* preview's own session so a document opened from another session's
* attachment card resolves against that workspace, not the active tab's.
*
* A preview opened by attachment id under a bare file name (attachment
* cards and the history drawer: the registry keeps no relative path) has no
* directory to resolve against, and the workspace root is the wrong one for
* docs/report.md and for a file outside the workspace alike. Its workspace
* refs degrade instead: images to their alt text, links to their text,
* rather than a missing image or a silently different file.
*/
_rebaseFilePreviewMarkdownRefs(root, { sessionId, filePath }) {
_rebaseFilePreviewMarkdownRefs(root, { sessionId, filePath, attachmentId }) {
const dir = filePath.includes('/') ? filePath.slice(0, filePath.lastIndexOf('/') + 1) : '';
const unresolvable = !!attachmentId && !filePath.startsWith('/');
// Workspace ref = no scheme, not protocol-relative (//host), not a fragment.
const isWorkspaceRef = (ref) =>
!!ref && !/^[a-z][a-z0-9+.-]*:/i.test(ref) && !ref.startsWith('//') && !ref.startsWith('#');
@@ -4478,6 +4493,10 @@ Object.assign(CodemanApp.prototype, {
};
for (const img of root.querySelectorAll('img[src]')) {
const src = img.getAttribute('src') || '';
if (unresolvable && isWorkspaceRef(src)) {
img.replaceWith(img.getAttribute('alt') || src);
continue;
}
if (isWorkspaceRef(src)) {
const path = resolveRef(src);
img.setAttribute('src', CodemanBase.url(`/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(path)}`));
@@ -4487,6 +4506,10 @@ Object.assign(CodemanApp.prototype, {
for (const a of root.querySelectorAll('a[href]')) {
const href = a.getAttribute('href') || '';
if (!isWorkspaceRef(href)) continue;
if (unresolvable) {
a.replaceWith(...a.childNodes);
continue;
}
a.className = 'rv-path';
a.dataset.path = resolveRef(href);
a.dataset.sessionId = sessionId;
+162 -15
View File
@@ -21,21 +21,31 @@
* 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).
* 6. A preview opened by attachment id under a bare file name has no
* directory to resolve against, so its relative images degrade to alt text
* and its relative links to plain text instead of landing on the workspace
* root's files; an absolute-path attachment keeps resolving.
* 7. A file renders without chat line breaks (`breaks: false`): a paragraph
* hard-wrapped in the source is one paragraph, while the Response Viewer
* keeps a <br> per newline.
*
* 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.
* the app.js markdown pipeline stubbed to a fixed fragment, except for rule 7,
* which runs the shipping app.js + vendored marked + DOMPurify end to end.
*/
import { readFileSync } from 'node:fs';
import { performance } from 'node:perf_hooks';
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');
const publicFile = (name: string) => readFileSync(resolve(PUBLIC, name), 'utf8');
const constantsJs = publicFile('constants.js');
const panelsJs = publicFile('panels-ui.js');
// 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.
@@ -69,6 +79,8 @@ function jsonResponse(body: unknown) {
/** Answer file-content like the route does: text as JSON, an image as metadata. */
function fetchStub(url: string) {
// An attachment's by-id raw route answers the bytes themselves.
if (url.includes('/attachments/')) return { ok: true, status: 200, text: async () => MD_CONTENT };
const path = decodeURIComponent(new URL(url, 'http://x').searchParams.get('path') || '');
const ext = path.split('.').pop() || '';
if (ext === 'png') {
@@ -90,6 +102,18 @@ function fetchStub(url: string) {
});
}
/** The file-preview overlay's elements, as index.html ships them. */
function mountPreviewDom() {
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>`;
}
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>;
@@ -114,14 +138,7 @@ function loadApp(prefs: Record<string, string> = {}) {
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>`;
mountPreviewDom();
const app = new CodemanApp();
app.$ = (id: string) => document.getElementById(id);
@@ -154,7 +171,8 @@ describe('file viewer rendered markdown', () => {
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);
// A file, not a chat message: source newlines inside a paragraph are not breaks.
expect(app._renderMarkdown).toHaveBeenCalledWith(MD_CONTENT, { breaks: false });
// 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);
@@ -210,6 +228,14 @@ describe('file viewer rendered markdown', () => {
it('decodes percent-encoded refs, drops the query, and resolves root-relative refs against the workspace', async () => {
const { app, body } = loadApp();
// An absolute path in the document's prose, linked by the Response
// Viewer's linkifier, which knows nothing of the preview's session.
app._linkifyFilePaths.mockImplementation((root: HTMLElement) => {
const a = root.ownerDocument.createElement('a');
a.className = 'rv-path';
a.dataset.path = '/tmp/out/run.log';
root.appendChild(a);
});
await app.openFilePreview('docs/README.md', 's1');
const src = (alt: string) => body.querySelector(`img[alt="${alt}"]`)!.getAttribute('src');
@@ -227,13 +253,50 @@ describe('file viewer rendered markdown', () => {
const anchors = Array.from(body.querySelectorAll('a'));
expect(anchors.find((a) => a.textContent === 'cjk')!.getAttribute('data-path')).toBe('docs/图片/截图.md');
expect(anchors.find((a) => a.textContent === 'rootlink')!.getAttribute('data-path')).toBe('docs/root.md');
// Every rebased link names the preview's session, so the delegate opens it
// in that workspace even when another tab is active.
// Every rebased link, and every path the linkifier found in the prose,
// names the preview's session, so the delegate opens it in that workspace
// even when another tab is active.
const rebased = body.querySelectorAll('a.rv-path');
expect(rebased.length).toBe(4);
expect(rebased.length).toBe(5);
for (const a of rebased) expect(a.getAttribute('data-session-id')).toBe('s1');
});
it('degrades relative refs of an attachment opened by bare file name instead of resolving them in the workspace', async () => {
const { app, body, fetchMock } = loadApp();
// An attachment card passes the registry's bare file name: the document's
// directory is unknown, so `img/a.png` must not become the workspace root's.
await app.openFilePreview('report.md', 's1', 'att-1');
expect(fetchMock.mock.calls[0][0]).toContain('/attachments/att-1/raw');
expect(body.innerHTML).not.toContain('file-raw');
// Relative and root-relative images are their alt text, as a text node.
for (const alt of ['Alt A', 'space', 'raw', 'bad', 'root']) {
expect(body.querySelector(`img[alt="${alt}"]`)).toBeNull();
expect(body.textContent).toContain(alt);
}
// Remote images and links keep today's handling.
expect(body.querySelector('img[alt="remote"]')!.getAttribute('src')).toBe('https://cdn.example.com/r.png');
expect(body.querySelector('img[alt="protorel"]')!.getAttribute('src')).toBe('//cdn.example.com/p.png');
// Relative links are unwrapped to their text; fragment and http(s) links stay.
expect(body.querySelectorAll('a.rv-path')).toHaveLength(0);
const anchors = Array.from(body.querySelectorAll('a')).map((a) => a.textContent);
expect(anchors).toEqual(['t', 'e']);
for (const text of ['x', 'up', 'cjk', 'rootlink']) expect(body.textContent).toContain(text);
});
it('keeps resolving refs of an absolute-path attachment against its own directory', async () => {
const { app, body } = loadApp();
await app.openFilePreview('/tmp/out/report.md', 's1', 'att-2');
const raw = (path: string) => `/api/sessions/s1/file-raw?path=${encodeURIComponent(path)}`;
expect(body.querySelector('img[alt="Alt A"]')!.getAttribute('src')).toBe(raw('/tmp/out/img/a.png'));
const rel = Array.from(body.querySelectorAll('a.rv-path')).find((a) => a.textContent === 'x')!;
expect(rel.getAttribute('data-path')).toBe('/tmp/out/guide/x.md');
expect(rel.getAttribute('data-session-id')).toBe('s1');
});
it('degrades an image that fails to load to its alt text', async () => {
const { app, body } = loadApp();
@@ -316,6 +379,90 @@ describe('file viewer Lines and Wrap toggles', () => {
});
});
/** A vendored UMD build (or sanitize-html.js), evaluated as CommonJS the way the other suites do. */
function loadCommonJs<T>(name: string): T {
const module: { exports: unknown } = { exports: {} };
// eslint-disable-next-line @typescript-eslint/no-implied-eval, no-new-func
new Function('module', 'exports', publicFile(name))(module, module.exports);
return module.exports as T;
}
/**
* The SHIPPING pipeline end to end: app.js (`_renderMarkdown` and the Response
* Viewer's message builder) with panels-ui.js mixed in, the vendored marked,
* and DOMPurify behind the real sanitize-html.js config. `content` is what the
* file-content route answers for every path.
*/
function loadShippingApp(content: string) {
const createDOMPurify = loadCommonJs<(win: unknown) => unknown>('vendor/dompurify.min.js');
const { createMarkdownSanitizer } = loadCommonJs<{ createMarkdownSanitizer: (dp: unknown) => unknown }>(
'sanitize-html.js'
);
const context = vm.createContext({
console: { ...console, warn: vi.fn(), error: vi.fn() },
performance,
setInterval: vi.fn(),
clearInterval: vi.fn(),
setTimeout,
clearTimeout,
requestAnimationFrame: vi.fn(),
HTMLCanvasElement: class HTMLCanvasElement {},
document,
NodeFilter: dom.window.NodeFilter,
localStorage: { length: 0, key: vi.fn(), getItem: () => null, setItem: vi.fn(), removeItem: vi.fn() },
// _sanitizeHtml fails closed without the page's sanitizer, which would make
// every assertion below vacuous.
window: {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
sanitizeMarkdownHtml: createMarkdownSanitizer(createDOMPurify(dom.window)),
},
marked: loadCommonJs('vendor/marked.min.js'),
MobileDetection: {},
confirm: () => true,
fetch: vi.fn(async () =>
jsonResponse({
success: true,
data: { content, totalLines: 2, size: content.length, truncated: false, extension: 'md' },
})
),
});
vm.runInContext(
`${constantsJs}\n${publicFile('app.js')}\n${panelsJs}\nglobalThis.__CodemanApp = CodemanApp;`,
context,
{ filename: 'app.js' }
);
const CodemanApp = (context as { __CodemanApp: { prototype: object } }).__CodemanApp;
mountPreviewDom();
const app = Object.create(CodemanApp.prototype) as Record<string, any>;
app.$ = (id: string) => document.getElementById(id);
app.sessions = new Map();
app.filePreviewContent = '';
return app;
}
describe('file viewer markdown line breaks', () => {
// A README hard-wrapped at the column limit: one paragraph in the source.
const WRAPPED = 'A paragraph hard-wrapped\nat the column limit.';
it('renders a hard-wrapped paragraph as one paragraph in the file view, while chat keeps a break per newline', async () => {
const app = loadShippingApp(`${WRAPPED}\n`);
await app.openFilePreview('docs/README.md', 's1');
const para = document.querySelector('#filePreviewBody .file-preview-md p')!;
expect(para, 'the document rendered through marked').not.toBeNull();
expect(para.querySelector('br')).toBeNull();
expect(para.textContent).toBe(WRAPPED);
// The Response Viewer renders the same text the chat way, a <br> per newline.
const message = app._buildResponseViewerMessage(WRAPPED, 'assistant', 'Claude') as HTMLElement;
const chatPara = message.querySelector('.rv-text p')!;
expect(chatPara.querySelectorAll('br')).toHaveLength(1);
expect(chatPara.textContent).toBe(WRAPPED.replace('\n', ''));
});
});
describe('FILE_PREVIEW_EXTENSIONS', () => {
it('routes avif and ico paths to the viewer and leaves .md with the tail viewer', () => {
const { exts } = loadApp();