refactor(file-preview): one rendersAsPage() for the terminal and the preview

What counts as an HTML page lived in three places: HTML_VIEW_EXTENSIONS
on the server, a /\.html?$/ regex in the terminal link provider and a
HTML_PAGE_EXTS set in panels-ui.js. The two frontend copies are now one
rendersAsPage() next to previewsInFileViewer() in constants.js, used by
the link provider (and through it every grid tile and the split's Pane
B, which share registerFilePathLinkProvider) and by openFilePreview()
and its toolbar. A new test pins it equal to the server's list.

toggleFilePreviewPage() now returns the preview load, so a caller (and
the coming frontend test) can await the flip.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-10-13 17:14:53 +02:00
parent 7b4832b3e4
commit 8f62faf130
4 changed files with 67 additions and 6 deletions
+22 -1
View File
@@ -1976,6 +1976,21 @@ function previewsInFileViewer(filePath) {
return FILE_PREVIEW_EXTENSIONS.has(ext);
}
/**
* Extensions the file preview renders as a PAGE, from the sandboxed
* `/html-view/<cap>/` route (src/web/routes/html-view-routes.ts, whose
* HTML_VIEW_EXTENSIONS this mirrors; test/file-preview-html-page.test.ts pins
* the two equal). The terminal sends a click on one to the preview, never to
* the log viewer, and the preview renders it with a Page pill back to source.
*/
const HTML_PAGE_EXTENSIONS = new Set(['html', 'htm']);
/** Whether a path names a file the preview renders as a page ({@link HTML_PAGE_EXTENSIONS}). */
function rendersAsPage(filePath) {
const ext = String(filePath || '').split('.').pop().toLowerCase();
return HTML_PAGE_EXTENSIONS.has(ext);
}
/**
* The LOGICAL line a terminal row belongs to — the rows it spans, its text as one
@@ -2992,7 +3007,13 @@ function describeSessionHarness(session, catalog) {
if (typeof window !== 'undefined') {
window.CodemanSessionHarness = { describeSessionHarness, SESSION_MODEL_MAX_CHARS };
window.CodemanHistoryFormat = { formatHistoryBytes, computeHistoryTruncationNotice, computeRewriteScrollLine };
window.CodemanFilePaths = { absoluteFilePathPattern, previewsInFileViewer, FILE_PREVIEW_EXTENSIONS };
window.CodemanFilePaths = {
absoluteFilePathPattern,
previewsInFileViewer,
FILE_PREVIEW_EXTENSIONS,
rendersAsPage,
HTML_PAGE_EXTENSIONS,
};
window.CodemanTerminalLines = { terminalLogicalLine };
window.CodemanUrlSession = { sessionIdFromFragment };
window.CodemanSplitPane = {
+3 -4
View File
@@ -24,7 +24,6 @@ const TEXT_PREVIEW_MAX_LINES = 500;
// 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']);
const HTML_PAGE_EXTS = new Set(['html', 'htm']);
// 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
@@ -4126,7 +4125,7 @@ Object.assign(CodemanApp.prototype, {
// The Page pill switches to the source view below (with its Edit button), and
// a refused mint (remote case, broad folder, hidden directory) falls through
// to that source view on its own.
if (!attachmentId && HTML_PAGE_EXTS.has(ext) && this._filePreviewPref('htmlRendered')) {
if (!attachmentId && rendersAsPage(filePath) && this._filePreviewPref('htmlRendered')) {
const pageUrl = await this._mintHtmlViewUrl(filePath, sessionId);
if (pageUrl) {
footerEl.textContent = 'HTML';
@@ -4615,7 +4614,7 @@ Object.assign(CodemanApp.prototype, {
const htmlSource =
view === 'text' &&
!this.filePreviewPageRefused &&
HTML_PAGE_EXTS.has(text?.ext || '') &&
rendersAsPage(text?.filePath) &&
(!text.attachmentId || text.filePath.startsWith('/'));
set('filePreviewPageBtn', view === 'page' || htmlSource, view === 'page');
set('filePreviewLinesBtn', view === 'text', this._filePreviewPref('lineNumbers'));
@@ -4642,7 +4641,7 @@ Object.assign(CodemanApp.prototype, {
const target = this.filePreviewPage || this.filePreviewText;
if (!target) return;
this._setFilePreviewPref('htmlRendered', !this._filePreviewPref('htmlRendered'));
this.openFilePreview(target.filePath, target.sessionId);
return this.openFilePreview(target.filePath, target.sessionId);
},
toggleFilePreviewMd() {
+1 -1
View File
@@ -2091,7 +2091,7 @@ Object.assign(CodemanApp.prototype, {
const sessionId = getSessionId();
// An HTML file renders as a page in the preview, never as source
// in the log viewer.
if (previewsInFileViewer(text) || /\.html?$/i.test(text) || self._isExternalPreviewPath(text, sessionId)) {
if (previewsInFileViewer(text) || rendersAsPage(text) || self._isExternalPreviewPath(text, sessionId)) {
self.openFilePreview(text, sessionId);
return;
}
+41
View File
@@ -0,0 +1,41 @@
/**
* @fileoverview File Viewer: an `.html` file renders as a sandboxed page.
*
* The server half (`/html-view/<cap>/...`, html-view-routes.ts) is pinned by
* test/routes/html-view-routes.test.ts. This file pins the frontend half:
*
* 1. `rendersAsPage()` (constants.js) and the server's HTML_VIEW_EXTENSIONS
* name the same extensions, so the terminal, the preview and the route
* agree on what a page is.
*
* Loaded via `vm` with a jsdom document injected, like
* file-preview-markdown.test.ts: constants.js + panels-ui.js only.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it } from 'vitest';
import { HTML_VIEW_EXTENSIONS } from '../src/web/routes/html-view-routes.js';
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
const constantsJs = readFileSync(resolve(PUBLIC, 'constants.js'), 'utf8');
describe('rendersAsPage', () => {
it('names exactly the extensions the html-view route renders', () => {
const context = vm.createContext({});
vm.runInContext(`${constantsJs}\nglobalThis.__page = { rendersAsPage, HTML_PAGE_EXTENSIONS };`, context, {
filename: 'constants.js',
});
const { rendersAsPage, HTML_PAGE_EXTENSIONS } = (
context as { __page: { rendersAsPage: (p: string) => boolean; HTML_PAGE_EXTENSIONS: Set<string> } }
).__page;
expect([...HTML_PAGE_EXTENSIONS].sort()).toEqual([...HTML_VIEW_EXTENSIONS].sort());
expect(rendersAsPage('/tmp/out/report.html')).toBe(true);
expect(rendersAsPage('docs/INDEX.HTM')).toBe(true);
expect(rendersAsPage('notes.md')).toBe(false);
expect(rendersAsPage('page.html.txt')).toBe(false);
expect(rendersAsPage(undefined as unknown as string)).toBe(false);
});
});