mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-02 05:29:42 +02:00
Merge pull request #503 from JDProfresh/feat/file-viewer-markdown
feat(files): render markdown in the File Viewer, with Lines/Wrap toggles
This commit is contained in:
@@ -295,6 +295,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 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)
|
||||
|
||||
**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)
|
||||
|
||||
@@ -388,6 +388,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, 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.
|
||||
- ⚠️ **`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`).
|
||||
|
||||
@@ -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 (root-relative ones resolve from the workspace root, as on GitHub). 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. |
|
||||
|
||||
@@ -2399,7 +2399,9 @@ class CodemanApp {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
const filePath = pathLink.dataset.path;
|
||||
if (filePath) this.openFilePreview(filePath, this.activeSessionId);
|
||||
// A rendered document's links name the session the preview was opened
|
||||
// for (_rebaseFilePreviewMarkdownRefs), which need not be the active tab.
|
||||
if (filePath) this.openFilePreview(filePath, pathLink.dataset.sessionId || this.activeSessionId);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -727,6 +727,9 @@
|
||||
'Source type filter': '来源类型筛选',
|
||||
'Copy content': '复制内容',
|
||||
'Edit file': '编辑文件',
|
||||
'Rendered markdown': '渲染 Markdown',
|
||||
'Line numbers': '行号',
|
||||
'Wrap lines': '自动换行',
|
||||
'Unsaved changes': '未保存的更改',
|
||||
Saved: '已保存',
|
||||
'Export as JSON': '导出为 JSON',
|
||||
|
||||
@@ -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">⎘</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>
|
||||
|
||||
+218
-8
@@ -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'],
|
||||
@@ -4080,6 +4094,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');
|
||||
@@ -4163,12 +4181,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) {
|
||||
@@ -4214,8 +4237,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();
|
||||
@@ -4240,10 +4268,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).
|
||||
@@ -4271,6 +4300,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;
|
||||
@@ -4319,6 +4350,183 @@ 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 workspace references at the file it came from.
|
||||
*
|
||||
* Images are rebased onto the workspace-confined file-raw route under the
|
||||
* document's directory, root-relative ones (`/docs/x.png`) under the
|
||||
* workspace root as on GitHub (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. 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, 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.
|
||||
*/
|
||||
_rebaseFilePreviewMarkdownRefs(root, { sessionId, filePath }) {
|
||||
const dir = filePath.includes('/') ? filePath.slice(0, filePath.lastIndexOf('/') + 1) : '';
|
||||
// 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('#');
|
||||
// GitHub-style `img.png#gh-dark-mode-only`, `doc.md#section` and
|
||||
// `img.png?raw=true`: neither fragment nor query is part of the path.
|
||||
// marked percent-encodes destinations (`my image.png` arrives as
|
||||
// `my%20image.png`), so decode before the route encodes again, or file-raw
|
||||
// looks for a file literally named `my%20image.png`; a malformed escape
|
||||
// keeps the ref as written. `.` 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) => {
|
||||
let rel = ref.split('#')[0].split('?')[0];
|
||||
try {
|
||||
rel = decodeURIComponent(rel);
|
||||
} catch {
|
||||
/* malformed escape: keep the ref as written */
|
||||
}
|
||||
const parts = [];
|
||||
for (const seg of (rel.startsWith('/') ? rel.slice(1) : dir + rel).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 (isWorkspaceRef(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 (!isWorkspaceRef(href)) continue;
|
||||
a.className = 'rv-path';
|
||||
a.dataset.path = resolveRef(href);
|
||||
a.dataset.sessionId = sessionId;
|
||||
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)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
@@ -4386,6 +4594,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.
|
||||
|
||||
|
||||
@@ -84,7 +84,10 @@
|
||||
/**
|
||||
* Attributes allowed on the tags above. `style` is intentionally absent (CSS-based vectors).
|
||||
* `class`/`id` survive because the response viewer adds wrapper classes downstream and code
|
||||
* blocks may carry `language-*` classes from marked.
|
||||
* blocks may carry `language-*` classes from marked. `name` is absent on purpose: marked never
|
||||
* emits it, and `<img name="app">` would make `document.app` that image, which every inline
|
||||
* `onclick="app.…()"` handler resolves before the global (DOM clobbering), so one rendered
|
||||
* README could break every button until a reload.
|
||||
*/
|
||||
var ALLOWED_ATTR = [
|
||||
'href',
|
||||
@@ -93,7 +96,6 @@
|
||||
'title',
|
||||
'class',
|
||||
'id',
|
||||
'name',
|
||||
'colspan',
|
||||
'rowspan',
|
||||
'align',
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
/**
|
||||
* @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>' +
|
||||
// marked percent-encodes destinations; a query rides along on GitHub-style refs.
|
||||
'<img src="my%20image.png" alt="space">' +
|
||||
'<img src="raw.png?raw=true" alt="raw">' +
|
||||
'<img src="bad%zz.png" alt="bad">' +
|
||||
'<img src="/assets/root.png" alt="root">' +
|
||||
'<img src="//cdn.example.com/p.png" alt="protorel">' +
|
||||
'<a href="%E5%9B%BE%E7%89%87/%E6%88%AA%E5%9B%BE.md" target="_blank" rel="noopener noreferrer">cjk</a>' +
|
||||
'<a href="/docs/root.md" target="_blank" rel="noopener noreferrer">rootlink</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('decodes percent-encoded refs, drops the query, and resolves root-relative refs against the workspace', async () => {
|
||||
const { app, body } = loadApp();
|
||||
|
||||
await app.openFilePreview('docs/README.md', 's1');
|
||||
const src = (alt: string) => body.querySelector(`img[alt="${alt}"]`)!.getAttribute('src');
|
||||
const raw = (path: string) => `/api/sessions/s1/file-raw?path=${encodeURIComponent(path)}`;
|
||||
|
||||
// Decoded once here, encoded once for the route: never `my%2520image.png`.
|
||||
expect(src('space')).toBe(raw('docs/my image.png'));
|
||||
expect(src('raw')).toBe(raw('docs/raw.png'));
|
||||
// A malformed escape keeps the ref as written.
|
||||
expect(src('bad')).toBe(raw('docs/bad%zz.png'));
|
||||
// Root-relative is the workspace root, as on GitHub; protocol-relative is remote.
|
||||
expect(src('root')).toBe(raw('assets/root.png'));
|
||||
expect(src('protorel')).toBe('//cdn.example.com/p.png');
|
||||
|
||||
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.
|
||||
const rebased = body.querySelectorAll('a.rv-path');
|
||||
expect(rebased.length).toBe(4);
|
||||
for (const a of rebased) expect(a.getAttribute('data-session-id')).toBe('s1');
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -165,6 +165,16 @@ describe('COD-56 markdown sanitizer (DOMPurify allowlist)', () => {
|
||||
expect(sanitize(html).toLowerCase()).not.toContain(tag);
|
||||
});
|
||||
}
|
||||
|
||||
// DOM clobbering: <img name="app"> makes document.app that image, and inline
|
||||
// onclick="app.…()" handlers resolve `app` on the document before the global,
|
||||
// so a rendered README could break every button until a reload.
|
||||
it('drops name= (marked never emits it; it clobbers document.<name>)', () => {
|
||||
const out = sanitize('<img name="app" src="https://example.com/x.png" alt="x"><a name="app" href="#a">a</a>');
|
||||
expect(out).not.toMatch(/\sname\s*=/i);
|
||||
expect(out).toContain('src="https://example.com/x.png"');
|
||||
expect(out).toContain('href="#a"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('legitimate markdown-rendered HTML survives', () => {
|
||||
|
||||
@@ -145,6 +145,6 @@ describe('response viewer file-path linkifier', () => {
|
||||
// either leaves inert paths (no linkify) or dead links (no handler).
|
||||
expect(APP_SOURCE).toContain('this._linkifyFilePaths(renderedText)');
|
||||
expect(APP_SOURCE).toMatch(/closest\('a\.rv-path'\)/);
|
||||
expect(APP_SOURCE).toMatch(/openFilePreview\(filePath, this\.activeSessionId\)/);
|
||||
expect(APP_SOURCE).toMatch(/openFilePreview\(filePath, pathLink\.dataset\.sessionId \|\| this\.activeSessionId\)/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user