diff --git a/src/web/public/constants.js b/src/web/public/constants.js index 8a1d500f..bdf6ca0e 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -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//` 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 = { diff --git a/src/web/public/panels-ui.js b/src/web/public/panels-ui.js index 93b6cfc7..dd77afbe 100644 --- a/src/web/public/panels-ui.js +++ b/src/web/public/panels-ui.js @@ -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() { diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index fc3bc94f..b83d6f0f 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -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; } diff --git a/test/file-preview-html-page.test.ts b/test/file-preview-html-page.test.ts new file mode 100644 index 00000000..c50c7574 --- /dev/null +++ b/test/file-preview-html-page.test.ts @@ -0,0 +1,41 @@ +/** + * @fileoverview File Viewer: an `.html` file renders as a sandboxed page. + * + * The server half (`/html-view//...`, 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 } } + ).__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); + }); +});