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:
Codeman maintainer
2026-10-01 11:09:26 +02:00
15 changed files with 679 additions and 16 deletions
+3 -1
View File
@@ -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;
}
+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
@@ -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',
+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>
+218 -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'],
@@ -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.
+4 -2
View File
@@ -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',
+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',