From 01097e7a45b09ca48b186f2547de3a58dfb3bbec Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Date: Sat, 7 Nov 2026 23:15:05 +0100 Subject: [PATCH 1/3] harness: claim card branch Harness-Claim: true Agent: switchboard-dev Host: ANCHOR Base-Ref: origin/main Base-SHA: 7b3a4b0fb052dbd16bb85be7fd8085030e07b06e Claimed-At: 2026-11-07T22:15:04.8580104Z Nonce: 1ce6b5e6c3b78333 From e52a26ecee83ab574bc1da65f7d88912c55b306a Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Date: Sun, 8 Nov 2026 05:59:24 +0100 Subject: [PATCH 2/3] (touched): show images in the Touched panel A touched image file now opens as a preview in the file panel instead of a text editor. --- .ai/contexts/touched-files.md | 32 +++++++- .ai/contexts/viewer-panel.md | 11 +++ CHANGELOG.md | 4 + eslint.config.js | 2 + main.js | 24 +++++- preload.js | 3 +- public/file-panel.js | 61 ++++++++++++++- public/style.css | 5 ++ public/touched-files-view.js | 37 +++++++-- src/main/files/panel-image.js | 16 ++++ test/dom-file-panel-touched.test.js | 100 ++++++++++++++++++++++++- test/panel-image.test.js | 112 ++++++++++++++++++++++++++++ test/touched-editor-ipc.test.js | 50 ++++++++++++- test/touched-files-wiring.test.js | 6 +- 14 files changed, 444 insertions(+), 19 deletions(-) create mode 100644 src/main/files/panel-image.js create mode 100644 test/panel-image.test.js diff --git a/.ai/contexts/touched-files.md b/.ai/contexts/touched-files.md index 1860aaae1..70e5028ca 100644 --- a/.ai/contexts/touched-files.md +++ b/.ai/contexts/touched-files.md @@ -76,7 +76,7 @@ listing *does* with it. it. - **Listing is not opening.** Only a row with `openable === true` and `state === 'present'` has a click handler. It calls `readFileForPanel` - with `{editor: true}`; the main handler retains its sensitive-path, + with `{editor: true, image: true}` locally; the main handler retains its sensitive-path, regular-file, size and binary guards. An unsuccessful read leaves the list visible with the refusal. Gone, unreadable, refused and non-file rows keep their existing state and cannot be opened. The absolute path is one main @@ -579,3 +579,33 @@ real `marked` and `DOMPurify` builds; `window.marked` is set only by the The tool entry point is the right-hand bar, using the shown terminal owner. Changes and Touched park an unanswered Diff; its conditional button restores the same editor. See [tool-bar.md](tool-bar.md). + +## Images + +Local Touched opens request { editor: true, image: true }; remote reads retain +their session ID and never request images. The main read preserves the existing +Git metadata, sensitive-path, regular-file and 2 MiB guards before sniffing bytes. +panel-image.js recognizes PNG, JPEG, GIF, WebP and BMP signatures, independently +of the extension. SVG stays editable text; ICO and other binary formats are refused. + +openTouchedImage shares the Changes tab layout and Touched return list. It sets +image, editable=false and noDiff=true, watches the local file and re-reads with +the image flag on file-changed. Rendering uses an img.panel-image with a data: +source; it changes neither CSP nor ViewerPanel (#827). Images fit the pane and +click to toggle intrinsic size with scrolling. Format, Mode, Save and Reload +are hidden. A decode failure shows the same refusal as an unsupported format. + +Binary, encoding and size refusals show "This file can't be shown here." with +one action. PNG/JPG/JPEG/GIF/WebP/BMP/PDF extensions, case insensitive, offer +Open with system viewer via openPath(path, { viewer: true }); all others offer +Show in folder. SYSTEM_VIEWER_EXTENSIONS lives in the pure main module and a +classic-script copy in touched-files-view.js; panel-image.test.js compares them. +Main enforces the same viewer allowlist. Unflagged terminal openPath is unchanged. + +show-item-in-folder validates string and absolute path before resolving, then +refuses raw and native-realpath Git segments, sensitive paths, and non-regular +files, in that order, before calling shell.showItemInFolder. IO failures refuse +silently. Other Touched read failures keep their existing message with no action. +Source-extracted IPC tests inject the sniff module and sensitivity guard; DOM +fixtures forward read options and record both system actions. Named mutations +and their byte-restoration hashes are recorded in .work-files/codex-report-1025.md. diff --git a/.ai/contexts/viewer-panel.md b/.ai/contexts/viewer-panel.md index 0c2083f23..dcf41ae49 100644 --- a/.ai/contexts/viewer-panel.md +++ b/.ai/contexts/viewer-panel.md @@ -601,3 +601,14 @@ inline styles permitted, no eval permission. stubbing only the lazy Mermaid module edge. It covers strict config, laziness, frontmatter, raw-HTML lookalikes, errors, hostile SVG, ID/reference isolation, async replacement, cleanup, theme changes, scrolling CSS and build wiring. + +## Bounds + +read-file-for-panel refuses non-regular files and files above 2 MiB after the +Git metadata and sensitive-path guards, before reading. Binary and invalid +UTF-8 files are refused. Its optional image flag enables signature-based raster +responses after those guards and before the binary check; only local Touched +requests that flag. Responses contain MIME, base64 bytes and byte size. +Without image, the text route is retained. Size and binary errors retain their +messages and add reasons too-large and binary. ViewerPanel does not request +images; its preview route remains owned by #827. diff --git a/CHANGELOG.md b/CHANGELOG.md index b2dd31cbd..04f1881c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ What changes for you in each release of Switchboard. How to write an entry: [doc ## Unreleased +### New + +- Images opened from a link or from the Touched list now show in the panel. Other files the panel can't show offer **Open with system viewer** for pictures and PDFs, and **Show in folder** for the rest. (#1025) + ### Fixed - A conversation's message count in the sidebar now grows as the conversation does, including remote conversations without recounting their entire transcript on each fetch. Conversations sharing copied history keep duplicate messages out of their counts. (#1017) diff --git a/eslint.config.js b/eslint.config.js index e4f6c5688..e0851cb29 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -24,6 +24,8 @@ const ACTIVITY_CLASS_MESSAGE = 'Only public/session-activity-dom.js may write .c // public/sidebar.js (and equivalents). Kept readonly so reassignment // from another file gets flagged. const rendererCrossFileGlobals = { + openTouchedImage: 'readonly', + renderPanelFileRefusal: 'readonly', validateSessionStopPlan: 'readonly', executeSessionStopTargets: 'readonly', confirmAndStopProjectFolder: 'readonly', diff --git a/main.js b/main.js index 20d017a82..d69a6171c 100644 --- a/main.js +++ b/main.js @@ -5,6 +5,7 @@ const { createMainLoopMonitor } = require('./src/main/app/main-loop-monitor'); const { execFile, spawn: spawnChild } = require('child_process'); const path = require('path'); const fs = require('fs'); +const { sniffPanelImage, SYSTEM_VIEWER_EXTENSIONS } = require('./src/main/files/panel-image'); const os = require('os'); const pty = require('node-pty'); const log = require('electron-log'); @@ -1183,15 +1184,28 @@ ipcMain.handle('open-main-log', async () => { }); // --- IPC: open a local file in the OS default app (terminal right-click menu) --- -ipcMain.handle('open-path', (_event, filePath) => { +ipcMain.handle('open-path', (_event, filePath, opts) => { if (typeof filePath !== 'string' || !filePath) return; const resolved = path.resolve(filePath); + if (opts?.viewer && !SYSTEM_VIEWER_EXTENSIONS.includes(path.extname(resolved).toLowerCase())) return; // Mirror the read/save-file-for-panel guard: never hand a sensitive path // (~/.ssh, credentials, etc.) to the OS default opener. if (isSensitivePath(resolved)) return; return shell.openPath(resolved); }); +ipcMain.handle('show-item-in-folder', (_event, filePath) => { + try { + if (typeof filePath !== 'string' || !path.isAbsolute(filePath)) return; + const resolved = path.resolve(filePath); + if (gitChangesFile.hasGitSegment(filePath)) return; + if (gitChangesFile.hasGitSegment(fs.realpathSync.native(resolved))) return; + if (isSensitivePath(resolved)) return; + if (!fs.statSync(resolved).isFile()) return; + shell.showItemInFolder(resolved); + } catch {} +}); + // --- IPC: read clipboard (terminal right-click paste; see clipboard-write-text) --- ipcMain.handle('read-clipboard', () => clipboard.readText()); @@ -1248,10 +1262,14 @@ ipcMain.handle('read-file-for-panel', async (_event, filePath, opts) => { const stat = fs.statSync(resolved); if (!stat.isFile()) return { ok: false, error: 'not a regular file' }; if (stat.size > PANEL_FILE_MAX_BYTES) { - return { ok: false, error: 'file too large to display' }; + return { ok: false, error: 'file too large to display', reason: 'too-large' }; } const buf = fs.readFileSync(resolved); - if (buf.includes(0)) return { ok: false, error: 'binary file' }; + if (opts?.image) { + const mime = sniffPanelImage(buf); + if (mime) return { ok: true, image: { mime, base64: buf.toString('base64'), size: buf.length } }; + } + if (buf.includes(0)) return { ok: false, error: 'binary file', reason: 'binary' }; const content = gitChangesFile.decodeUtf8(buf); if (content === null) return { ok: false, error: 'file is not valid UTF-8', reason: 'encoding' }; if (opts?.editor) { diff --git a/preload.js b/preload.js index 3e88897f3..75747ff58 100644 --- a/preload.js +++ b/preload.js @@ -111,7 +111,8 @@ contextBridge.exposeInMainWorld('api', { checkDeleteWorktree: (worktreePath) => ipcRenderer.invoke('check-delete-worktree', worktreePath), worktreeStatus: (worktreePath) => ipcRenderer.invoke('worktree-status', worktreePath), openExternal: (url) => ipcRenderer.invoke('open-external', url), - openPath: (filePath) => ipcRenderer.invoke('open-path', filePath), + openPath: (filePath, opts) => ipcRenderer.invoke('open-path', filePath, opts), + showItemInFolder: (filePath) => ipcRenderer.invoke('show-item-in-folder', filePath), toggleFullScreen: () => ipcRenderer.invoke('toggle-full-screen'), popupAppMenu: (x, y) => ipcRenderer.invoke('popup-app-menu', { x, y }), writeClipboard: (text) => ipcRenderer.invoke('clipboard-write-text', text), diff --git a/public/file-panel.js b/public/file-panel.js index a96dc161a..93806355b 100644 --- a/public/file-panel.js +++ b/public/file-panel.js @@ -1367,6 +1367,22 @@ function openTouchedEditor(sessionId, absolutePath, pair, returnList, { line = n } } +function openTouchedImage(sessionId, absolutePath, image, returnList) { + const state = getSessionState(sessionId); + if (currentPanelSessionId === sessionId) snapshotPanelList(document.getElementById('touched-list'), returnList); + destroyCurrentTab(state, { stash: false }); + const tab = createChangesTab(); + Object.assign(tab, { returnList, label: 'Touched files', absolutePath, filePath: absolutePath, + selectedFile: { path: absolutePath }, loading: false, image, editable: false, noDiff: true }); + state.currentTab = tab; + state.panelVisible = true; + watchChangesFile(sessionId, tab, absolutePath); + if (currentPanelSessionId === sessionId) { + showPanel(state); + renderPanel(sessionId); + } +} + function applyChangesPair(tab, pair) { tab.diffLoading = false; tab.editable = true; @@ -1385,7 +1401,7 @@ function applyChangesPair(tab, pair) { async function readChangesPair(sessionId, tab, file) { try { return tab.absolutePath - ? await window.api.readFileForPanel(tab.absolutePath, { editor: true, ...(tab.remote ? { sessionId } : {}) }) + ? await window.api.readFileForPanel(tab.absolutePath, { editor: true, ...(tab.image ? { image: true } : {}), ...(tab.remote ? { sessionId } : {}) }) : await window.api.gitChangesFile(sessionId, file.path, { staged: !!file.staged }); } catch (err) { return { ok: false, error: err?.message || 'this file could not be read' }; @@ -1428,7 +1444,7 @@ async function refreshChanges(sessionId) { // A refresh may never replace what the user is typing into — see .ai/contexts/changes-view.md async function syncOpenChangesFile(sessionId, tab) { - if (!tab.selectedFile || !tab.editable) return; + if (!tab.selectedFile || (!tab.editable && !tab.image)) return; if (!isChangesBufferDirty(tab)) repointSelectedFile(tab); @@ -1440,11 +1456,19 @@ async function syncOpenChangesFile(sessionId, tab) { if (!result || result.ok === false) { tab.fileError = (result && result.error) || 'this file could not be read'; + if (tab.image) tab.imageRefusal = ['binary', 'encoding', 'too-large'].includes(result?.reason); if (currentPanelSessionId === sessionId) renderPanel(sessionId); return; } tab.fileError = null; + if (tab.image) { + tab.image = result.image || tab.image; + tab.imageRefusal = !result.image; + if (currentPanelSessionId === sessionId) renderPanel(sessionId); + return; + } + if (isChangesBufferDirty(tab)) { tab.externalChange = tab.absolutePath && !tab.gitFile && !tab.version ? result.current !== tab.savedContent @@ -1479,7 +1503,7 @@ function repointSelectedFile(tab) { function handleChangesFileChanged(sessionId, filePath) { const state = filePanelState.get(sessionId); const tab = state && state.currentTab; - if (!tab || tab.type !== 'changes' || !tab.editable || !tab.selectedFile) return; + if (!tab || tab.type !== 'changes' || (!tab.editable && !tab.image) || !tab.selectedFile) return; if (tab.selectedFile.path !== filePath || tab.saving) return; return syncOpenChangesFile(sessionId, tab).catch(() => {}); } @@ -2017,6 +2041,37 @@ function buildChangesDiffChrome() { function renderChangesDiff(sessionId, tab) { window.setViewerPath(changesDiffTitleEl, tab.selectedFile.path); + changesDiffHostEl.classList.toggle('panel-image-host', !!tab.image); + if (tab.image) { + for (const button of [changesDiffFormatBtn, changesDiffModeBtn, changesDiffSaveBtn, changesDiffReloadBtn]) button.style.display = 'none'; + changesDiffHostEl.style.display = ''; + changesDiffPreviewEl.style.display = 'none'; + changesDiffNoticeEl.style.display = 'none'; + changesDiffHostEl.replaceChildren(); + destroyChangesEditor(tab); + if (tab.imageRefusal) { + renderPanelFileRefusal(changesDiffHostEl, tab.absolutePath); + } else if (tab.fileError) { + changesDiffHostEl.textContent = `This file can no longer be read: ${tab.fileError}`; + } else { + const img = document.createElement('img'); + img.className = 'panel-image'; + img.alt = tab.absolutePath.split(/[/\\]/).pop(); + img.src = `data:${tab.image.mime};base64,${tab.image.base64}`; + img.classList.toggle('panel-image-actual', !!tab.imageActual); + img.addEventListener('click', () => { + tab.imageActual = !tab.imageActual; + img.classList.toggle('panel-image-actual', tab.imageActual); + }); + img.onerror = () => { + tab.imageRefusal = true; + if (filePanelState.get(sessionId)?.currentTab === tab && currentPanelSessionId === sessionId) renderPanel(sessionId); + }; + changesDiffHostEl.appendChild(img); + } + return; + } + const markdown = isMarkdownPath(tab.absolutePath); const formatted = markdown && !!tab.formatted; changesDiffFormatBtn.style.display = markdown ? '' : 'none'; diff --git a/public/style.css b/public/style.css index b614e7316..00646e3fa 100644 --- a/public/style.css +++ b/public/style.css @@ -5868,3 +5868,8 @@ body.window-frameless :is(.new-session-popover, .terminal-context-menu, .new-ses .mermaid-diagram > svg { display: block; max-width: none !important; } .mermaid-error { color: var(--text-muted); font-size: 12px; } .mermaid-render-scratch { position: fixed; left: -100000px; top: 0; visibility: hidden; pointer-events: none; } + +#changes-diff-host.panel-image-host { overflow: auto; } +.panel-image { align-self: flex-start; flex-shrink: 0; max-width: 100%; max-height: 100%; object-fit: contain; cursor: zoom-in; } +.panel-image.panel-image-actual { max-width: none; max-height: none; cursor: zoom-out; } +.panel-file-refusal { display: flex; align-items: center; gap: 8px; padding: 8px; } diff --git a/public/touched-files-view.js b/public/touched-files-view.js index c78f50d5a..92428052f 100644 --- a/public/touched-files-view.js +++ b/public/touched-files-view.js @@ -16,6 +16,27 @@ const TOUCHED_DAY_MS = 24 * 60 * 60 * 1000; const TOUCHED_RELATIVE_THRESHOLD_MS = TOUCHED_DAY_MS; const TOUCHED_MINUTE_MS = 60 * 1000; const TOUCHED_OPENED_MAX = 50; +const SYSTEM_VIEWER_EXTENSIONS = Object.freeze(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.pdf']); + +function renderPanelFileRefusal(host, filePath) { + const refusal = document.createElement('div'); + refusal.className = 'panel-file-refusal'; + const message = document.createElement('span'); + message.textContent = "This file can't be shown here."; + refusal.appendChild(message); + const basename = filePath.split(/[/\\]/).pop(); + const match = /\.[^.]+$/.exec(basename); + const extension = match?.index > 0 ? match[0].toLowerCase() : ''; + const viewer = SYSTEM_VIEWER_EXTENSIONS.includes(extension); + const button = document.createElement('button'); + button.className = 'viewer-toolbar-btn fp-toolbar-btn'; + button.textContent = viewer ? 'Open with system viewer' : 'Show in folder'; + button.addEventListener('click', () => { + Promise.resolve(viewer ? window.api.openPath(filePath, { viewer: true }) : window.api.showItemInFolder(filePath)).catch(() => {}); + }); + refusal.appendChild(button); + host.appendChild(refusal); +} function formatTouchedTime(timestamp, now = Date.now()) { if (!Number.isFinite(timestamp)) return 'Time unknown'; @@ -322,7 +343,7 @@ async function openTouchedFile(sessionId, tab, filePath, { line = null, origin = tab.opening = true; let result; try { - result = await window.api.readFileForPanel(filePath, { editor: true, ...(tab.data?.kind === 'remote' ? { sessionId } : {}) }); + result = await window.api.readFileForPanel(filePath, { editor: true, ...(tab.data?.kind === 'remote' ? { sessionId } : { image: true }) }); } catch (err) { result = { ok: false, error: (err && err.message) || 'could not read the file' }; } @@ -330,10 +351,13 @@ async function openTouchedFile(sessionId, tab, filePath, { line = null, origin = if (state.currentTab !== current) return; if (!result || !result.ok) { const reason = (result && result.error) || 'could not read the file'; - tab.openError = origin === 'row' ? `${filePath}: ${reason}` : `Could not open the file: ${reason}`; + tab.openRefusal = tab.data?.kind !== 'remote' && ['binary', 'encoding', 'too-large'].includes(result?.reason) ? filePath : null; + tab.openError = tab.openRefusal ? "This file can't be shown here." : origin === 'row' ? `${filePath}: ${reason}` : `Could not open the file: ${reason}`; if (currentPanelSessionId === sessionId) renderPanel(sessionId); return; } + tab.openError = null; + if (tab.openRefusal) tab.openRefusal = null; if (origin !== 'row') addTouchedOpened(state, filePath); const keep = asks ? readChangesEditorContent(current) !== agreedContent && (hasUnsavedChangesEdits(current) && !await confirmDiscardChangesEdits(current)) @@ -344,7 +368,8 @@ async function openTouchedFile(sessionId, tab, filePath, { line = null, origin = } tab.selection = filePath; if (currentPanelSessionId === sessionId) markTouchedSelection(tab); - openTouchedEditor(sessionId, filePath, result, tab, { line }); + if (result.image) openTouchedImage(sessionId, filePath, result.image, tab); + else openTouchedEditor(sessionId, filePath, result, tab, { line }); } function addTouchedOpened(state, filePath) { @@ -478,14 +503,16 @@ function buildTouchedUnresolvedRow(entry) { function renderTouchedContent(sessionId, tab) { const state = filePanelState.get(sessionId); - const signature = [tab.data, tab.loading, tab.error, tab.openError, tab.sort, tab.windowStart, state?.touchedOpenedRevision]; + const signature = [tab.data, tab.loading, tab.error, tab.openError, tab.openRefusal, tab.sort, tab.windowStart, state?.touchedOpenedRevision]; document.getElementById('touched-sort').value = tab.sort; if (window.reusePanelList(touchedListEl, touchedSummaryEl, tab, signature)) { markTouchedSelection(tab); return; } touchedSummaryEl.textContent = touchedSummaryText(tab); - if (tab.openError) { + if (tab.openRefusal) { + renderPanelFileRefusal(touchedSummaryEl, tab.openRefusal); + } else if (tab.openError) { const err = document.createElement('div'); err.className = 'changes-error'; err.textContent = tab.openError; diff --git a/src/main/files/panel-image.js b/src/main/files/panel-image.js new file mode 100644 index 000000000..3fe570176 --- /dev/null +++ b/src/main/files/panel-image.js @@ -0,0 +1,16 @@ +'use strict'; + +const SYSTEM_VIEWER_EXTENSIONS = Object.freeze(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.pdf']); + +function sniffPanelImage(buf) { + if (buf.length >= 8 && buf.subarray(0, 8).equals(Buffer.from('89504e470d0a1a0a', 'hex'))) return 'image/png'; + if (buf.length >= 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) return 'image/jpeg'; + const gif = buf.subarray(0, 6).toString('latin1'); + if (gif === 'GIF87a' || gif === 'GIF89a') return 'image/gif'; + if (buf.length >= 12 && buf.subarray(0, 4).toString('latin1') === 'RIFF' && buf.subarray(8, 12).toString('latin1') === 'WEBP') return 'image/webp'; + if (buf.length >= 18 && buf[0] === 0x42 && buf[1] === 0x4d + && buf.readUInt32LE(6) === 0 && [12, 40, 56, 108, 124].includes(buf.readUInt32LE(14))) return 'image/bmp'; + return null; +} + +module.exports = { sniffPanelImage, SYSTEM_VIEWER_EXTENSIONS }; diff --git a/test/dom-file-panel-touched.test.js b/test/dom-file-panel-touched.test.js index 01810cdd2..48b5c0080 100644 --- a/test/dom-file-panel-touched.test.js +++ b/test/dom-file-panel-touched.test.js @@ -273,10 +273,12 @@ function setupDom({ touchedImpl, readImpl, confirmImpl, storedRatio, storageThro observe(element) { observed.set(element, this.callback); this.targets.add(element); } disconnect() { for (const element of this.targets) observed.delete(element); } }; - const calls = { touched: [], readFile: [], readOptions: [], save: [], gitFile: [], status: [], resolve: [], diffResponse: [], revealed: [], confirm: 0 }; + const calls = { touched: [], readFile: [], readOptions: [], save: [], gitFile: [], status: [], resolve: [], diffResponse: [], revealed: [], confirm: 0, openPath: [], showItem: [] }; let mcpOpenFile = null; window.api = { + openPath: (...args) => { calls.openPath.push(args); return Promise.resolve(); }, + showItemInFolder: (...args) => { calls.showItem.push(args); return Promise.resolve(); }, onMcpOpenDiff: () => {}, onMcpOpenFile: (handler) => { mcpOpenFile = handler; }, onMcpCloseAllDiffs: () => {}, @@ -293,7 +295,7 @@ function setupDom({ touchedImpl, readImpl, confirmImpl, storedRatio, storageThro readFileForPanel: (filePath, options) => { calls.readOptions.push(options); calls.readFile.push(filePath); - return Promise.resolve((readImpl || (() => ({ ok: true, original: 'before\n', current: 'file body', version: 'v1', git: true })))(filePath)); + return Promise.resolve((readImpl || (() => ({ ok: true, original: 'before\n', current: 'file body', version: 'v1', git: true })))(filePath, options)); }, }; const watchCalls = []; @@ -393,6 +395,98 @@ function clickRow(ctx, filePath) { el.dispatchEvent(new ctx.window.Event('click', { bubbles: true })); } +function imageResult(base64 = 'cG5n') { + return { ok: true, image: { mime: 'image/png', base64, size: 3 } }; +} + +test('T6: a linked image opens as data in an image tab with an Opened row', async () => { + const ctx = setupDom({ readImpl: (_p, opts) => opts?.image ? imageResult() : { ok: false, reason: 'binary', error: 'binary file' } }); + try { + ctx.window.switchPanel('s1'); + await openLink(ctx, '/work/shot.png'); + const imgs = ctx.document.querySelectorAll('img.panel-image'); + assert.equal(imgs.length, 1); + assert.match(imgs[0].src, /^data:image\/png;base64,/); + assert.equal(imgs[0].alt, 'shot.png'); + assert.equal(ctx.document.querySelector('.cm-editor'), null); + assert.ok(rows(ctx).some(r => r.dataset.path === '/work/shot.png')); + assert.equal(ctx.stateOf('s1').currentTab.editable, false); + for (const id of ['changes-diff-format-btn', 'changes-diff-mode-btn', 'changes-diff-save-btn', 'changes-diff-reload-btn']) { + assert.equal(ctx.document.getElementById(id)?.style.display, 'none', id); + } + imgs[0].click(); + assert.ok(imgs[0].classList.contains('panel-image-actual')); + imgs[0].click(); + assert.equal(imgs[0].classList.contains('panel-image-actual'), false); + assert.deepEqual(ctx.watchCalls, [['watch', '/work/shot.png']]); + } finally { ctx.destroy(); } +}); + +test('T7: a refused PDF offers the system viewer with its flag', async () => { + const ctx = setupDom({ readImpl: () => ({ ok: false, reason: 'binary', error: 'binary file' }) }); + try { + ctx.window.switchPanel('s1'); + await openLink(ctx, '/work/doc.pdf'); + const refusal = ctx.document.querySelector('.panel-file-refusal'); + assert.ok(refusal); + assert.equal(refusal.firstChild.textContent, "This file can't be shown here."); + const button = refusal.querySelector('button'); + assert.equal(button.textContent, 'Open with system viewer'); + button.click(); + assert.equal(JSON.stringify(ctx.calls.openPath), JSON.stringify([['/work/doc.pdf', { viewer: true }]])); + assert.equal(ctx.calls.showItem.length, 0); + } finally { ctx.destroy(); } +}); + +test('T8: non-viewer files only offer Show in folder', async () => { + for (const target of ['/work/tool.exe', '/work/x.pif', '/work/site.url', '/work/a.bin', '/work/no-extension', '/work/.pdf']) { + const ctx = setupDom({ readImpl: () => ({ ok: false, reason: 'binary', error: 'binary file' }) }); + try { + ctx.window.switchPanel('s1'); + await openLink(ctx, target); + const button = ctx.document.querySelector('.panel-file-refusal button'); + assert.ok(button, target); + assert.equal(button.textContent, 'Show in folder', target); + button.click(); + assert.deepEqual(ctx.calls.showItem, [[target]]); + assert.equal(ctx.calls.openPath.length, 0); + } finally { ctx.destroy(); } + } +}); + +test('T9: file-changed rereads an image with the image flag and replaces its source', async () => { + let bytes = 'b2xk'; + const ctx = setupDom({ readImpl: (_p, opts) => opts?.image ? imageResult(bytes) : { ok: false, reason: 'binary', error: 'binary file' } }); + try { + ctx.window.switchPanel('s1'); + await openLink(ctx, '/work/shot.png'); + const before = ctx.document.querySelector('img.panel-image'); + assert.ok(before); + bytes = 'bmV3'; + ctx.changed('/work/shot.png'); + await flush(); + assert.equal(ctx.calls.readFile.length, 2); + assert.equal(ctx.calls.readOptions.at(-1).image, true); + assert.equal(ctx.document.querySelector('img.panel-image')?.src, 'data:image/png;base64,bmV3'); + } finally { ctx.destroy(); } +}); + +test('T10: image decode failure shows the refusal and system action', async () => { + const ctx = setupDom({ readImpl: () => imageResult() }); + try { + ctx.window.switchPanel('s1'); + await openLink(ctx, '/work/shot.png'); + const img = ctx.document.querySelector('img.panel-image'); + assert.ok(img); + img.dispatchEvent(new ctx.window.Event('error')); + assert.equal(ctx.document.querySelector('img.panel-image'), null); + const refusal = ctx.document.querySelector('#changes-diff-host .panel-file-refusal'); + assert.ok(refusal); + assert.equal(refusal.firstChild.textContent, "This file can't be shown here."); + assert.equal(refusal.querySelector('button').textContent, 'Open with system viewer'); + } finally { ctx.destroy(); } +}); + test('Touched uses the shared select style', async () => { const ctx = setupDom(); try { @@ -2356,7 +2450,7 @@ test('a file the session opens over a clean editor replaces it without asking', await flush(); assert.equal(ctx.calls.confirm, 0); assert.equal(ctx.stateOf('s1').currentTab?.absolutePath, '/work/b.txt'); - assert.deepEqual({ ...ctx.calls.readOptions.at(-1) }, { editor: true }); + assert.deepEqual({ ...ctx.calls.readOptions.at(-1) }, { editor: true, image: true }); } finally { ctx.destroy(); } }); diff --git a/test/panel-image.test.js b/test/panel-image.test.js new file mode 100644 index 000000000..b2bc07128 --- /dev/null +++ b/test/panel-image.test.js @@ -0,0 +1,112 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); +const modulePath = path.join(__dirname, '../src/main/files/panel-image.js'); +const panelImage = require(modulePath); + +test('T1: magic bytes identify the five raster image formats', () => { + assert.equal(typeof panelImage.sniffPanelImage, 'function'); + const bmp = Buffer.alloc(26); + bmp.write('BM'); + bmp.writeUInt32LE(12, 14); + for (const [buf, mime] of [ + [Buffer.from('89504e470d0a1a0a', 'hex'), 'image/png'], + [Buffer.from('ffd8ff', 'hex'), 'image/jpeg'], + [Buffer.from('GIF87a'), 'image/gif'], + [Buffer.from('GIF89a'), 'image/gif'], + [Buffer.from('RIFF1234WEBP'), 'image/webp'], + [bmp, 'image/bmp'], + ]) assert.equal(panelImage.sniffPanelImage(buf), mime); + for (const size of [40, 56, 108, 124]) { + bmp.writeUInt32LE(size, 14); + assert.equal(panelImage.sniffPanelImage(bmp), 'image/bmp'); + } +}); + +test('T1: non-images and truncated signatures are refused', () => { + assert.equal(typeof panelImage.sniffPanelImage, 'function'); + const bmp = Buffer.alloc(26); + bmp.write('BM'); + bmp.writeUInt32LE(40, 14); + bmp[6] = 1; + for (const buf of [Buffer.from([3, 7, 42]), Buffer.from('RIFF1234WAVE'), Buffer.from(''), + Buffer.from('BM this is text only'), Buffer.alloc(0), Buffer.from([0, 0, 1, 0]), bmp, + Buffer.from('89504e47', 'hex'), Buffer.from('ffd8', 'hex'), Buffer.from('GIF89'), Buffer.from('RIFF1234WEB')]) { + assert.equal(panelImage.sniffPanelImage(buf), null, buf.toString('hex')); + } +}); + +function shellHandlers(overrides = {}) { + const source = fs.readFileSync(path.join(__dirname, '../main.js'), 'utf8'); + const calls = { open: [], show: [], order: [] }; + const handlers = new Map(); + const absolute = path.resolve('fixture.png'); + const context = { + path, SYSTEM_VIEWER_EXTENSIONS: panelImage.SYSTEM_VIEWER_EXTENSIONS, + fs: { + realpathSync: { native: p => { calls.order.push('realpath'); return p; } }, + statSync: () => { calls.order.push('stat'); return { isFile: () => true }; }, + ...overrides.fs, + }, + gitChangesFile: require('../src/main/changes/git-changes-file'), + isSensitivePath: p => { calls.order.push('sensitive'); return overrides.sensitive?.(p) || false; }, + shell: { openPath: p => calls.open.push(p), showItemInFolder: p => calls.show.push(p) }, + ipcMain: { handle: (name, handler) => handlers.set(name, handler) }, + }; + for (const name of ['open-path', 'show-item-in-folder']) { + const start = source.indexOf("ipcMain.handle('" + name + "'"); + if (start === -1) continue; + const end = source.indexOf('\n});', start) + '\n});'.length; + vm.runInNewContext(source.slice(start, end), context, { filename: 'main.js' }); + } + return { calls, absolute, open: (...args) => handlers.get('open-path')?.(null, ...args), + show: (...args) => handlers.get('show-item-in-folder')?.(null, ...args) }; +} + +test('T8b: main enforces the system-viewer allowlist and keeps the terminal route', async () => { + const api = shellHandlers(); + for (const ext of ['.exe', '.pif', '.url']) await api.open(path.resolve('tool' + ext), { viewer: true }); + assert.equal(api.calls.open.length, 0); + await api.open(api.absolute, { viewer: true }); + assert.deepEqual(api.calls.open, [api.absolute]); + await api.open(path.resolve('tool.exe')); + assert.equal(api.calls.open.length, 2); +}); + +for (const kind of ['relative', 'raw git', 'real git', 'sensitive', 'directory', 'regular']) { + test('T8c: show in folder guards ' + kind, async () => { + const api = shellHandlers({ + sensitive: () => kind === 'sensitive', + fs: { + realpathSync: { native: p => { + api.calls.order.push('realpath'); + return kind === 'real git' ? path.resolve('.git/config') : p; + } }, + statSync: () => { + api.calls.order.push('stat'); + return { isFile: () => kind !== 'directory' }; + }, + }, + }); + const target = kind === 'relative' ? 'file.txt' : kind === 'raw git' ? path.resolve('.git/config') : api.absolute; + await api.show(target); + assert.deepEqual(api.calls.show, kind === 'regular' ? [target] : []); + const order = { relative: [], 'raw git': [], 'real git': ['realpath'], sensitive: ['realpath', 'sensitive'], + directory: ['realpath', 'sensitive', 'stat'], regular: ['realpath', 'sensitive', 'stat'] }; + assert.deepEqual(api.calls.order, order[kind]); + }); +} + +test('T8d: renderer and main system-viewer extensions match', () => { + assert.ok(Array.isArray(panelImage.SYSTEM_VIEWER_EXTENSIONS)); + const renderer = path.join(__dirname, '../public/touched-files-view.js'); + assert.ok(fs.existsSync(renderer)); + const context = vm.createContext({}); + vm.runInContext(fs.readFileSync(renderer, 'utf8'), context, { filename: renderer }); + const extensions = vm.runInContext('SYSTEM_VIEWER_EXTENSIONS', context); + assert.deepEqual(Array.from(extensions), panelImage.SYSTEM_VIEWER_EXTENSIONS); +}); diff --git a/test/touched-editor-ipc.test.js b/test/touched-editor-ipc.test.js index eaa8fb8d4..388c454b3 100644 --- a/test/touched-editor-ipc.test.js +++ b/test/touched-editor-ipc.test.js @@ -25,6 +25,51 @@ const fixtureGitFiles = { const source = fs.readFileSync(path.join(__dirname, '..', 'main.js'), 'utf8'); const { fakeTouchedShell } = require('./remote-touched-shell'); +const PNG_1025 = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+ip1sAAAAASUVORK5CYII=', 'base64'); + +test('T2: an image read returns the exact PNG bytes', async t => { + const { dir } = fixture(t); + const target = path.join(dir, 'shot.png'); + fs.writeFileSync(target, PNG_1025); + const result = await panelHandlers().read(target, { editor: true, image: true }); + assert.equal(result.ok, true, result.error); + assert.equal(result.image?.mime, 'image/png'); + assert.equal(result.image.size, PNG_1025.length); + assert.deepEqual(Buffer.from(result.image.base64, 'base64'), PNG_1025); +}); + +test('T3: the same PNG without the image flag stays binary', async t => { + const { dir } = fixture(t); + const target = path.join(dir, 'shot.png'); + fs.writeFileSync(target, PNG_1025); + const result = await panelHandlers().read(target, { editor: true }); + assert.equal(result.ok, false); + assert.equal(result.error, 'binary file'); + assert.equal(result.reason, 'binary'); +}); + +test('T4: sensitive image paths are refused before reading', async t => { + const { dir } = fixture(t); + const target = path.join(dir, 'shot.png'); + fs.writeFileSync(target, PNG_1025); + let reads = 0; + const api = panelHandlers({ ...fs, readFileSync: () => { reads++; throw new Error('must not read'); } }, + fixtureGitFiles, null, { isSensitivePath: () => true }); + const result = await api.read(target, { editor: true, image: true }); + assert.equal(result.error, 'access to sensitive path denied'); + assert.equal(reads, 0); +}); + +test('T5: oversized PNG is refused before image sniffing', async t => { + const { dir } = fixture(t); + const target = path.join(dir, 'shot.png'); + fs.writeFileSync(target, Buffer.concat([PNG_1025, Buffer.alloc(MAX_BYTES)])); + const result = await panelHandlers().read(target, { editor: true, image: true }); + assert.equal(result.ok, false); + assert.match(result.error, /too large/); + assert.equal(result.reason, 'too-large'); +}); + for (const [label, probe] of [ ['removed cwd', { code: 128, stderr: 'fatal: cannot change to cwd: No such file or directory' }], ['missing git', { code: 127, stderr: 'git: command not found' }], @@ -272,11 +317,12 @@ test('round 2 s1: resolved remote key paths are refused by the shell guard', asy } }); -function panelHandlers(fsOps = fs, gitOps = fixtureGitFiles, remoteExec = null) { +function panelHandlers(fsOps = fs, gitOps = fixtureGitFiles, remoteExec = null, overrides = {}) { const handlers = new Map(); const context = { ipcMain: { handle: (channel, handler) => handlers.set(channel, handler) }, - fs: fsOps, path, isSensitivePath, gitChangesFile: gitOps, PANEL_FILE_MAX_BYTES: MAX_BYTES, + fs: fsOps, path, isSensitivePath: overrides.isSensitivePath || isSensitivePath, gitChangesFile: gitOps, PANEL_FILE_MAX_BYTES: MAX_BYTES, + sniffPanelImage: require('../src/main/files/panel-image').sniffPanelImage, panelSaves: createMainPanelSaves({ getKnownProjectPaths: () => [], invalidateFtsSignature() {} }), invalidateFtsSignature() {}, remotePanelPaths: new Set(), diff --git a/test/touched-files-wiring.test.js b/test/touched-files-wiring.test.js index 7fc785deb..6cd206212 100644 --- a/test/touched-files-wiring.test.js +++ b/test/touched-files-wiring.test.js @@ -9,6 +9,7 @@ const fs = require('node:fs'); const path = require('node:path'); const vm = require('node:vm'); const os = require('node:os'); +const { extractFunction } = require('./app-source'); const { listSessionTouchedFiles } = require('../src/main/changes/session-touched-files'); const { isRemoteFolder } = require('../src/main/remote/remote-hosts'); @@ -75,7 +76,10 @@ test('index.html loads the touched tab after the file panel it hooks into', () = test('the touched tab opens a file only through readFileForPanel', () => { const src = read('public/touched-files-view.js'); const apis = [...src.matchAll(/window\.api\.(\w+)/g)].map((m) => m[1]); - assert.deepEqual([...new Set(apis)].sort(), ['readFileForPanel', 'resolveTerminalPaths', 'sessionTouchedFiles']); + assert.deepEqual([...new Set(apis)].sort(), ['openPath', 'readFileForPanel', 'resolveTerminalPaths', 'sessionTouchedFiles', 'showItemInFolder']); + const opening = extractFunction(src, 'openTouchedFile'); + const openingApis = [...opening.matchAll(/window\.api\.(\w+)/g)].map(m => m[1]); + assert.deepEqual([...new Set(openingApis)], ['readFileForPanel']); }); test('a path is shown in its own bidi isolate, so an override in it cannot reorder the row', () => { From 983180a3b51e2bbe4ed0a14f728183a7e737f560 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Date: Sun, 8 Nov 2026 13:30:17 +0100 Subject: [PATCH 3/3] (touched): list the image IPC in the inventory and report failed panel actions Show in folder and Open with the default app now say when they fail; a refused open clears on refresh; a stale image error no longer replaces a valid image; text files that start like GIF or WebP open as text. --- .ai/contexts/ipc-bridge.md | 11 +- .ai/contexts/touched-files.md | 15 +- main.js | 17 +- public/file-panel.js | 1 + public/touched-files-view.js | 13 +- test/dom-file-panel-images.test.js | 169 +++++++++++++++++ test/dom-file-panel-touched.test.js | 272 +--------------------------- test/panel-image.test.js | 12 +- test/touched-editor-ipc.test.js | 22 +++ test/touched-panel-harness.js | 188 +++++++++++++++++++ 10 files changed, 434 insertions(+), 286 deletions(-) create mode 100644 test/dom-file-panel-images.test.js create mode 100644 test/touched-panel-harness.js diff --git a/.ai/contexts/ipc-bridge.md b/.ai/contexts/ipc-bridge.md index 64dc879ed..de5bc07d1 100644 --- a/.ai/contexts/ipc-bridge.md +++ b/.ai/contexts/ipc-bridge.md @@ -419,6 +419,8 @@ An unknown remote target never falls back to local disk IO. |---|---| | `open-external` | Opens https:// URLs in OS browser | | `open-main-log` | No arguments; opens the main-selected, validated application log path and returns `{ok, error?}`. | +| `open-path` | Resolves a non-empty string path, applies the extension allowlist when `viewer` is set, then `isSensitivePath`, before `shell.openPath`. Returns the OS error string (empty on success), or `undefined` on refusal. | +| `show-item-in-folder` | Requires a string absolute path, resolves it, checks raw Git segments, native-realpath Git segments, `isSensitivePath`, then regular-file status, in that order, before `shell.showItemInFolder`. Returns `{ok:true}` or `{ok:false, reason}` including IO failures. | | `clipboard-write-text` | Main-process clipboard write (Wayland fix, PR #18) | | `get-app-version` | From package.json | | `whats-new-startup` / `whats-new-dismissed` | The What's new dialog: the `CHANGELOG.md` sections to show on startup (or `null`), and recording the running version as `lastSeenVersion` when it closes. Help → What's new sends `show-whats-new` with the running version's section. See `docs/changelog.md` | @@ -485,6 +487,11 @@ adapter applies it. ### IPC path-guard inventory +Main applies the `open-path` extension allowlist only when the caller sends +`viewer`. It guards against a renderer bug, not a hostile renderer, which can +omit the flag. The terminal menu's unflagged call retains its existing behavior +(#1025 spec section 3). + Every handler that takes a renderer-supplied path or derives a spawn location from one, and what guards it as of this pass: | IPC | Guard | Kind | @@ -492,7 +499,9 @@ Every handler that takes a renderer-supplied path or derives a spawn location fr | `read-memory` / `save-memory` | `resolveAllowedMemoryPath` (read/write the returned path, not the caller's own re-resolved one) | disk-resolved allowlist | | `get-memories` (the list, and the FTS bodies it indexes) | `acceptMdFile` (`scan-md-files.js`) applies `isSensitivePath` itself and the `isAllowedMemoryPath` predicate `get-memories` passes in; `scanMdFiles` is the directory-wide form of it, and the project-root `CLAUDE.md`/`GEMINI.md`/`agents.md` go through it by name | disk-resolved denylist + allowlist — a listed file is read twice more downstream, once by `read-memory` behind `resolveAllowedMemoryPath` and once by the FTS indexer behind nothing at all, so the guard belongs on the list. The denylist is not the caller's to choose: containment in an allowed project root says nothing about a cloned repo's own `.ssh`/`.env` | | `open-main-log` | Main chooses electron-log's path; requires a string absolute path and `!isSensitivePath` before `shell.openPath`. | no renderer-supplied path; disk-resolved denylist | -| `open-path` / `read-file-for-panel` / `save-file-for-panel` / `watch-file` | Local: `isSensitivePath`. Remote panel reads resolve the session target and guard the literal and canonical host path. Saves refuse a resolved remote target and any path already opened through the remote panel route, even without renderer options. | disk-resolved denylist + main-side remote write refusal | +| `open-path` | Non-empty string, `path.resolve`, then, only with `opts.viewer`, an extension allowlist (`.png .jpg .jpeg .gif .webp .bmp .pdf`, case insensitive), then `isSensitivePath`, before `shell.openPath`. | caller-selected allowlist + disk-resolved denylist | +| `show-item-in-folder` | String and `path.isAbsolute`, `path.resolve`, raw `hasGitSegment`, `hasGitSegment(fs.realpathSync.native(resolved))`, `isSensitivePath`, then `fs.statSync(resolved).isFile()`, before `shell.showItemInFolder`. IO failures return `{ok:false, reason}`. | shape + raw/canonical Git metadata refusal + disk-resolved denylist + regular-file check | +| `read-file-for-panel` / `save-file-for-panel` / `watch-file` | Local: `isSensitivePath`. Remote panel reads resolve the session target and guard the literal and canonical host path. Saves refuse a resolved remote target and any path already opened through the remote panel route, even without renderer options. | disk-resolved denylist + main-side remote write refusal | | `resolve-terminal-paths` | `resolveTerminalPaths` / `resolveTerminalPathTarget` (`src/main/terminals/terminal-path-target.js`, async, 8 checks in flight): the session cwd for a relative path, existence, then `isSensitivePath` on the resolved path, then regular-file-ness, `PANEL_FILE_MAX_BYTES` and a NUL-byte sniff | disk-resolved denylist, strictly narrower than `read-file-for-panel` — its whole input is untrusted terminal output, and its answer decides whether a path is even offered as a link. Existence is tested before the denylist because most candidates on a line of prose are not files; a path that exists still passes the denylist before anything else is decided. A remote session is refused outright, and so is one whose working directory cannot be resolved — otherwise an unresolvable remote would fall back to stat-ing the local disk. A panel shell (`panelFor`) resolves through its owning session | | `check-delete-worktree` / `delete-worktree` / `worktree-status` | Shared `checkDeleteWorktree`: `WORKTREE_PATH_RE` (shape) + `isKnownProjectRoot` (disk-resolved exact match) | shape + disk-resolved allowlist | | `delete-session-preview` / `delete-session` | `deleteSessionGuard` then `resolveRowDeletionTargets` (`delete-session-target.js`) | full live/background/unknown-state guard + disk-resolved containment | diff --git a/.ai/contexts/touched-files.md b/.ai/contexts/touched-files.md index 70e5028ca..d8ceb9fc7 100644 --- a/.ai/contexts/touched-files.md +++ b/.ai/contexts/touched-files.md @@ -587,6 +587,8 @@ their session ID and never request images. The main read preserves the existing Git metadata, sensitive-path, regular-file and 2 MiB guards before sniffing bytes. panel-image.js recognizes PNG, JPEG, GIF, WebP and BMP signatures, independently of the extension. SVG stays editable text; ICO and other binary formats are refused. +GIF and WebP signatures become images only when the read buffer also contains +a NUL byte; NUL-free UTF-8 text beginning with those signatures stays editable. openTouchedImage shares the Changes tab layout and Touched return list. It sets image, editable=false and noDiff=true, watches the local file and re-reads with @@ -594,6 +596,8 @@ the image flag on file-changed. Rendering uses an img.panel-image with a data: source; it changes neither CSP nor ViewerPanel (#827). Images fit the pane and click to toggle intrinsic size with scrolling. Format, Mode, Save and Reload are hidden. A decode failure shows the same refusal as an unsupported format. +Errors from detached images are ignored so an earlier render cannot refuse the +currently displayed image. Refresh clears both earlier open errors and refusals. Binary, encoding and size refusals show "This file can't be shown here." with one action. PNG/JPG/JPEG/GIF/WebP/BMP/PDF extensions, case insensitive, offer @@ -604,8 +608,11 @@ Main enforces the same viewer allowlist. Unflagged terminal openPath is unchange show-item-in-folder validates string and absolute path before resolving, then refuses raw and native-realpath Git segments, sensitive paths, and non-regular -files, in that order, before calling shell.showItemInFolder. IO failures refuse -silently. Other Touched read failures keep their existing message with no action. +files, in that order, before calling shell.showItemInFolder. The handler returns +{ ok: true } or { ok: false, reason }, including IO failures. Either action shows +a short inline error if refused, rejected, or unsuccessful; a successful retry +clears it. Other Touched read failures keep their existing message with no action. Source-extracted IPC tests inject the sniff module and sensitivity guard; DOM -fixtures forward read options and record both system actions. Named mutations -and their byte-restoration hashes are recorded in .work-files/codex-report-1025.md. +fixtures forward read options and record both system actions. Image DOM cases +live in test/dom-file-panel-images.test.js and share touched-panel-harness.js +with test/dom-file-panel-touched.test.js. diff --git a/main.js b/main.js index d69a6171c..4bbf12a81 100644 --- a/main.js +++ b/main.js @@ -1196,14 +1196,17 @@ ipcMain.handle('open-path', (_event, filePath, opts) => { ipcMain.handle('show-item-in-folder', (_event, filePath) => { try { - if (typeof filePath !== 'string' || !path.isAbsolute(filePath)) return; + if (typeof filePath !== 'string' || !path.isAbsolute(filePath)) return { ok: false, reason: 'invalid-path' }; const resolved = path.resolve(filePath); - if (gitChangesFile.hasGitSegment(filePath)) return; - if (gitChangesFile.hasGitSegment(fs.realpathSync.native(resolved))) return; - if (isSensitivePath(resolved)) return; - if (!fs.statSync(resolved).isFile()) return; + if (gitChangesFile.hasGitSegment(filePath)) return { ok: false, reason: 'git-dir' }; + if (gitChangesFile.hasGitSegment(fs.realpathSync.native(resolved))) return { ok: false, reason: 'git-dir' }; + if (isSensitivePath(resolved)) return { ok: false, reason: 'sensitive' }; + if (!fs.statSync(resolved).isFile()) return { ok: false, reason: 'not-file' }; shell.showItemInFolder(resolved); - } catch {} + return { ok: true }; + } catch (error) { + return { ok: false, reason: error.message || String(error) }; + } }); // --- IPC: read clipboard (terminal right-click paste; see clipboard-write-text) --- @@ -1267,7 +1270,7 @@ ipcMain.handle('read-file-for-panel', async (_event, filePath, opts) => { const buf = fs.readFileSync(resolved); if (opts?.image) { const mime = sniffPanelImage(buf); - if (mime) return { ok: true, image: { mime, base64: buf.toString('base64'), size: buf.length } }; + if (mime && (!['image/gif', 'image/webp'].includes(mime) || buf.includes(0))) return { ok: true, image: { mime, base64: buf.toString('base64'), size: buf.length } }; } if (buf.includes(0)) return { ok: false, error: 'binary file', reason: 'binary' }; const content = gitChangesFile.decodeUtf8(buf); diff --git a/public/file-panel.js b/public/file-panel.js index 93806355b..1dd7dce8a 100644 --- a/public/file-panel.js +++ b/public/file-panel.js @@ -2064,6 +2064,7 @@ function renderChangesDiff(sessionId, tab) { img.classList.toggle('panel-image-actual', tab.imageActual); }); img.onerror = () => { + if (!img.isConnected) return; tab.imageRefusal = true; if (filePanelState.get(sessionId)?.currentTab === tab && currentPanelSessionId === sessionId) renderPanel(sessionId); }; diff --git a/public/touched-files-view.js b/public/touched-files-view.js index 92428052f..bfefdb314 100644 --- a/public/touched-files-view.js +++ b/public/touched-files-view.js @@ -31,8 +31,16 @@ function renderPanelFileRefusal(host, filePath) { const button = document.createElement('button'); button.className = 'viewer-toolbar-btn fp-toolbar-btn'; button.textContent = viewer ? 'Open with system viewer' : 'Show in folder'; - button.addEventListener('click', () => { - Promise.resolve(viewer ? window.api.openPath(filePath, { viewer: true }) : window.api.showItemInFolder(filePath)).catch(() => {}); + button.addEventListener('click', async () => { + refusal.querySelector('.changes-error')?.remove(); + try { + const result = await (viewer ? window.api.openPath(filePath, { viewer: true }) : window.api.showItemInFolder(filePath)); + if (viewer ? result === '' : result?.ok === true) return; + } catch {} + const error = document.createElement('div'); + error.className = 'changes-error'; + error.textContent = viewer ? 'Could not open the file in the system viewer.' : 'Could not show the file in its folder.'; + refusal.appendChild(error); }); refusal.appendChild(button); host.appendChild(refusal); @@ -244,6 +252,7 @@ async function refreshTouched(sessionId) { tab.loading = true; tab.openError = null; + tab.openRefusal = null; if (currentPanelSessionId === sessionId) renderPanel(sessionId); let result; diff --git a/test/dom-file-panel-images.test.js b/test/dom-file-panel-images.test.js new file mode 100644 index 000000000..de2ac577d --- /dev/null +++ b/test/dom-file-panel-images.test.js @@ -0,0 +1,169 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { setupDom, flush, rows, openLink } = require('./touched-panel-harness'); + +test('fix1025: Refresh clears an earlier open refusal and its action', async () => { + const ctx = setupDom({ readImpl: () => ({ ok: false, reason: 'binary', error: 'binary file' }) }); + try { + ctx.window.switchPanel('s1'); + await openLink(ctx, '/work/a.bin'); + assert.ok(ctx.document.querySelector('.panel-file-refusal')); + ctx.document.getElementById('touched-refresh-btn').click(); + await flush(); + assert.equal(ctx.document.querySelector('.panel-file-refusal'), null, 'Refresh must remove the stale refusal'); + } finally { ctx.destroy(); } +}); + +test('fix1025: a detached old image error cannot refuse its replacement', async () => { + const ctx = setupDom({ readImpl: () => imageResult() }); + try { + ctx.window.switchPanel('s1'); + await openLink(ctx, '/work/shot.png'); + const old = ctx.document.querySelector('img.panel-image'); + ctx.changed('/work/shot.png'); + await flush(); + const current = ctx.document.querySelector('img.panel-image'); + assert.notEqual(old, current); + assert.equal(old.isConnected, false); + old.dispatchEvent(new ctx.window.Event('error')); + assert.equal(ctx.document.querySelector('img.panel-image'), current, 'the replacement must remain visible'); + assert.equal(ctx.document.querySelector('.panel-file-refusal'), null); + } finally { ctx.destroy(); } +}); + +for (const [action, target, answer] of [ + ['showItemInFolder', '/work/a.bin', { ok: false, reason: 'missing' }], + ['showItemInFolder', '/work/a.bin', undefined], + ['openPath', '/work/doc.pdf', 'No associated app'], + ['openPath', '/work/doc.pdf', undefined], + ['showItemInFolder', '/work/a.bin', new Error('IPC failed')], + ['openPath', '/work/doc.pdf', new Error('IPC failed')], +]) { + test(`fix1025: ${action} failure is visible (${String(answer?.reason || answer)})`, async () => { + const ctx = setupDom({ readImpl: () => ({ ok: false, reason: 'binary', error: 'binary file' }) }); + ctx.window.api[action] = async () => { if (answer instanceof Error) throw answer; return answer; }; + try { + ctx.window.switchPanel('s1'); + await openLink(ctx, target); + ctx.document.querySelector('.panel-file-refusal button').click(); + await flush(); + const error = ctx.document.querySelector('.panel-file-refusal .changes-error'); + assert.ok(error, 'the failed system action must show an error'); + assert.match(error.textContent, /Could not/); + } finally { ctx.destroy(); } + }); +} + +for (const [action, target, answer] of [ + ['showItemInFolder', '/work/a.bin', { ok: true }], ['openPath', '/work/doc.pdf', ''], +]) { + test(`fix1025: successful ${action} clears a prior action error`, async () => { + const ctx = setupDom({ readImpl: () => ({ ok: false, reason: 'binary', error: 'binary file' }) }); + let fail = true; + ctx.window.api[action] = async () => { if (fail) throw new Error('IPC failed'); return answer; }; + try { + ctx.window.switchPanel('s1'); + await openLink(ctx, target); + const button = ctx.document.querySelector('.panel-file-refusal button'); + button.click(); await flush(); + assert.ok(ctx.document.querySelector('.panel-file-refusal .changes-error'), 'the first failure must be visible'); + fail = false; + button.click(); await flush(); + assert.equal(ctx.document.querySelector('.panel-file-refusal .changes-error'), null); + } finally { ctx.destroy(); } + }); +} + +function imageResult(base64 = 'cG5n') { + return { ok: true, image: { mime: 'image/png', base64, size: 3 } }; +} + +test('T6: a linked image opens as data in an image tab with an Opened row', async () => { + const ctx = setupDom({ readImpl: (_p, opts) => opts?.image ? imageResult() : { ok: false, reason: 'binary', error: 'binary file' } }); + try { + ctx.window.switchPanel('s1'); + await openLink(ctx, '/work/shot.png'); + const imgs = ctx.document.querySelectorAll('img.panel-image'); + assert.equal(imgs.length, 1); + assert.match(imgs[0].src, /^data:image\/png;base64,/); + assert.equal(imgs[0].alt, 'shot.png'); + assert.equal(ctx.document.querySelector('.cm-editor'), null); + assert.ok(rows(ctx).some(r => r.dataset.path === '/work/shot.png')); + assert.equal(ctx.stateOf('s1').currentTab.editable, false); + for (const id of ['changes-diff-format-btn', 'changes-diff-mode-btn', 'changes-diff-save-btn', 'changes-diff-reload-btn']) { + assert.equal(ctx.document.getElementById(id)?.style.display, 'none', id); + } + imgs[0].click(); + assert.ok(imgs[0].classList.contains('panel-image-actual')); + imgs[0].click(); + assert.equal(imgs[0].classList.contains('panel-image-actual'), false); + assert.deepEqual(ctx.watchCalls, [['watch', '/work/shot.png']]); + } finally { ctx.destroy(); } +}); + +test('T7: a refused PDF offers the system viewer with its flag', async () => { + const ctx = setupDom({ readImpl: () => ({ ok: false, reason: 'binary', error: 'binary file' }) }); + try { + ctx.window.switchPanel('s1'); + await openLink(ctx, '/work/doc.pdf'); + const refusal = ctx.document.querySelector('.panel-file-refusal'); + assert.ok(refusal); + assert.equal(refusal.firstChild.textContent, "This file can't be shown here."); + const button = refusal.querySelector('button'); + assert.equal(button.textContent, 'Open with system viewer'); + button.click(); + assert.equal(JSON.stringify(ctx.calls.openPath), JSON.stringify([['/work/doc.pdf', { viewer: true }]])); + assert.equal(ctx.calls.showItem.length, 0); + } finally { ctx.destroy(); } +}); + +test('T8: non-viewer files only offer Show in folder', async () => { + for (const target of ['/work/tool.exe', '/work/x.pif', '/work/site.url', '/work/a.bin', '/work/no-extension', '/work/.pdf']) { + const ctx = setupDom({ readImpl: () => ({ ok: false, reason: 'binary', error: 'binary file' }) }); + try { + ctx.window.switchPanel('s1'); + await openLink(ctx, target); + const button = ctx.document.querySelector('.panel-file-refusal button'); + assert.ok(button, target); + assert.equal(button.textContent, 'Show in folder', target); + button.click(); + assert.deepEqual(ctx.calls.showItem, [[target]]); + assert.equal(ctx.calls.openPath.length, 0); + } finally { ctx.destroy(); } + } +}); + +test('T9: file-changed rereads an image with the image flag and replaces its source', async () => { + let bytes = 'b2xk'; + const ctx = setupDom({ readImpl: (_p, opts) => opts?.image ? imageResult(bytes) : { ok: false, reason: 'binary', error: 'binary file' } }); + try { + ctx.window.switchPanel('s1'); + await openLink(ctx, '/work/shot.png'); + const before = ctx.document.querySelector('img.panel-image'); + assert.ok(before); + bytes = 'bmV3'; + ctx.changed('/work/shot.png'); + await flush(); + assert.equal(ctx.calls.readFile.length, 2); + assert.equal(ctx.calls.readOptions.at(-1).image, true); + assert.equal(ctx.document.querySelector('img.panel-image')?.src, 'data:image/png;base64,bmV3'); + } finally { ctx.destroy(); } +}); + +test('T10: image decode failure shows the refusal and system action', async () => { + const ctx = setupDom({ readImpl: () => imageResult() }); + try { + ctx.window.switchPanel('s1'); + await openLink(ctx, '/work/shot.png'); + const img = ctx.document.querySelector('img.panel-image'); + assert.ok(img); + img.dispatchEvent(new ctx.window.Event('error')); + assert.equal(ctx.document.querySelector('img.panel-image'), null); + const refusal = ctx.document.querySelector('#changes-diff-host .panel-file-refusal'); + assert.ok(refusal); + assert.equal(refusal.firstChild.textContent, "This file can't be shown here."); + assert.equal(refusal.querySelector('button').textContent, 'Open with system viewer'); + } finally { ctx.destroy(); } +}); diff --git a/test/dom-file-panel-touched.test.js b/test/dom-file-panel-touched.test.js index 48b5c0080..48a89fa22 100644 --- a/test/dom-file-panel-touched.test.js +++ b/test/dom-file-panel-touched.test.js @@ -10,45 +10,8 @@ const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); -const vm = require('node:vm'); -const { JSDOM } = require('jsdom'); -const { registerPanelTerminals } = require('./terminal-manager-harness'); - const PUBLIC_DIR = path.join(__dirname, '..', 'public'); -const NODE_MODULES = path.join(__dirname, '..', 'node_modules'); - -const INDEX_HTML = ` - - -
-
-
- - -`; - -function evalInWindow(dom, file) { - vm.runInContext(fs.readFileSync(file, 'utf8'), dom.getInternalVMContext(), { filename: file }); -} - -function row(over = {}) { - return { path: '/work/a.txt', state: 'present', openable: true, tools: ['Write'], count: 1, sources: ['session'], ...over }; -} - -function result(over = {}) { - return { - ok: true, - files: [row()], - unresolved: [], - omitted: 0, - coverage: { transcripts: 1, subagents: 0, malformedLines: 0, truncated: false }, - ...over, - }; -} +const { setupDom, flush, openTab, rows, clickRow, openLink, openedRows, row, result } = require('./touched-panel-harness'); test('round 2 M2: the mirror renders before delayed disk info and Refresh does not overlap it', async () => { let release; @@ -255,122 +218,6 @@ test('an unreachable remote Touched list shows unknown rows without reads or aut } finally { ctx.destroy(); } }); -function setupDom({ touchedImpl, readImpl, confirmImpl, storedRatio, storageThrows = false, resolveImpl, ownerOf, saveImpl } = {}) { - const dom = new JSDOM(INDEX_HTML, { url: 'http://localhost/', runScripts: 'outside-only', pretendToBeVisual: true }); - const { window } = dom; - if (storedRatio != null) window.localStorage.setItem('touchedListRatio', storedRatio); - if (storageThrows) { - const getItem = window.Storage.prototype.getItem; - window.Storage.prototype.getItem = function (key) { - if (key === 'touchedListRatio' || key === 'touchedMarkdownFormatted') throw new Error('storage unavailable'); - return getItem.call(this, key); - }; - window.Storage.prototype.setItem = () => { throw new Error('storage unavailable'); }; - } - const observed = new Map(); - window.ResizeObserver = class { - constructor(callback) { this.callback = callback; this.targets = new Set(); } - observe(element) { observed.set(element, this.callback); this.targets.add(element); } - disconnect() { for (const element of this.targets) observed.delete(element); } - }; - const calls = { touched: [], readFile: [], readOptions: [], save: [], gitFile: [], status: [], resolve: [], diffResponse: [], revealed: [], confirm: 0, openPath: [], showItem: [] }; - let mcpOpenFile = null; - - window.api = { - openPath: (...args) => { calls.openPath.push(args); return Promise.resolve(); }, - showItemInFolder: (...args) => { calls.showItem.push(args); return Promise.resolve(); }, - onMcpOpenDiff: () => {}, - onMcpOpenFile: (handler) => { mcpOpenFile = handler; }, - onMcpCloseAllDiffs: () => {}, - onMcpCloseTab: () => {}, - mcpDiffResponse: (...args) => { calls.diffResponse.push(args); }, - resolveTerminalPaths: (sessionId, texts) => { - calls.resolve.push([sessionId, texts]); - return Promise.resolve((resolveImpl || ((_id, t) => t.map(p => ({ ok: true, path: p }))))(sessionId, texts)); - }, - sessionTouchedFiles: (sessionId, options) => { - calls.touched.push(sessionId); - return Promise.resolve((touchedImpl || (() => result()))(sessionId, options)); - }, - readFileForPanel: (filePath, options) => { - calls.readOptions.push(options); - calls.readFile.push(filePath); - return Promise.resolve((readImpl || (() => ({ ok: true, original: 'before\n', current: 'file body', version: 'v1', git: true })))(filePath, options)); - }, - }; - const watchCalls = []; - const changeListeners = []; - window.api.watchFile = filePath => { watchCalls.push(['watch', filePath]); return Promise.resolve({ ok: true }); }; - window.api.unwatchFile = filePath => { watchCalls.push(['unwatch', filePath]); return Promise.resolve({ ok: true }); }; - window.api.onFileChanged = handler => changeListeners.push(handler); - window.api.gitChangesStatus = () => { calls.status.push('status'); return Promise.resolve({ ok: true, branch: {}, files: [], totals: {} }); }; - window.api.gitChangesFile = (id, filePath, options) => { calls.gitFile.push({ id, filePath, options }); return Promise.resolve({ ok: true, original: 'before\n', current: 'file body', version: 'v1' }); }; - window.api.saveFileForPanel = (filePath, content, expected, options) => { - calls.save.push({ filePath, content, expected, options }); - return Promise.resolve(saveImpl ? saveImpl(calls.save.length - 1) : { ok: true, version: 'v2' }); - }; - window.appConfirm = async ({ message }) => { calls.confirm++; return (confirmImpl || (() => true))(message); }; - window.cmRevealLine = (_view, line) => calls.revealed.push(line); - if (ownerOf) window.panelTerminalOwnerOf = ownerOf; - const editors = []; - const editor = (parent, original, current, mode, options) => { - const dom = window.document.createElement('div'); - dom.className = 'cm-editor test-' + mode; - parent.appendChild(dom); - const view = { dom, original, current, mode, options, setText(value) { view.current = value; options.onChange(); }, destroy() { dom.remove(); } }; - const state = { doc: { toString: () => view.current } }; - if (mode === 'side-by-side') view.b = { state }; else view.state = state; - editors.push(view); - return view; - }; - window.createMergeViewer = (parent, original, current, _name, options) => editor(parent, original, current, 'side-by-side', options); - window.createUnifiedMergeViewer = (parent, original, current, _name, options) => editor(parent, original, current, 'inline', options); - window.createEditableViewer = (parent, current, _name, options) => editor(parent, null, current, 'plain', options); - window.createReadOnlyViewer = (parent, current) => editor(parent, null, current, 'read-only', { onChange() {} }); - Object.defineProperty(window, 'activeSessionId', { value: null, writable: true, configurable: true }); - - evalInWindow(dom, path.join(NODE_MODULES, 'marked', 'lib', 'marked.umd.js')); - evalInWindow(dom, path.join(NODE_MODULES, 'dompurify', 'dist', 'purify.min.js')); - const bundledMarked = window.marked; - delete window.marked; - window.loadCodeMirrorBundle = () => { window.marked = bundledMarked; return Promise.resolve(); }; - for (const file of ['modal-stack.js', 'viewer-toolbar.js', 'splitter.js', 'session-state.js', 'session-activity-dom.js', 'session-activity.js', 'shortcuts.js', 'header-controls.js', 'tool-bar.js', 'file-panel.js', 'touched-files-view.js']) { - evalInWindow(dom, path.join(PUBLIC_DIR, file)); - } - window.openSessions = new Map(); - window.gridViewActive = false; - window.gridCards = new Map(); - window.isMac = false; - window.appShortcuts = {}; - window.initFilePanel(); - registerPanelTerminals(dom, ['owner', 'r1', 's1', 's2']); - - const ctx = dom.getInternalVMContext(); - return { - window, - document: window.document, - calls, - editors, - watchCalls, - resize: element => { - if (element) observed.get(element)?.(); - else for (const callback of new Set(observed.values())) callback(); - }, - changed: filePath => changeListeners.forEach(handler => handler(filePath)), - mcpOpenFile: (sessionId, data) => mcpOpenFile(sessionId, data), - stateOf: (sessionId) => vm.runInContext('filePanelState', ctx).get(sessionId), - evalSource: source => vm.runInContext(source, ctx), - bundledMarked, - destroy: () => window.close(), - }; -} - -function flush() { - let p = Promise.resolve(); - for (let i = 0; i < 12; i++) p = p.then(() => Promise.resolve()); - return p; -} - async function waitForUnsavedDialog(ctx) { for (let i = 0; i < 100; i++) { if (ctx.document.getElementById('unsaved-edits-dialog')) return; @@ -379,114 +226,6 @@ async function waitForUnsavedDialog(ctx) { assert.fail('the queued unsaved dialog did not open'); } -async function openTab(ctx, sessionId = 's1') { - ctx.window.switchPanel(sessionId); - ctx.document.getElementById('touched-toggle-btn').click(); - await flush(); -} - -function rows(ctx) { - return [...ctx.document.querySelectorAll('.touched-file-row')]; -} - -function clickRow(ctx, filePath) { - const el = rows(ctx).find((r) => r.dataset.path === filePath); - assert.ok(el, `a row for ${filePath}`); - el.dispatchEvent(new ctx.window.Event('click', { bubbles: true })); -} - -function imageResult(base64 = 'cG5n') { - return { ok: true, image: { mime: 'image/png', base64, size: 3 } }; -} - -test('T6: a linked image opens as data in an image tab with an Opened row', async () => { - const ctx = setupDom({ readImpl: (_p, opts) => opts?.image ? imageResult() : { ok: false, reason: 'binary', error: 'binary file' } }); - try { - ctx.window.switchPanel('s1'); - await openLink(ctx, '/work/shot.png'); - const imgs = ctx.document.querySelectorAll('img.panel-image'); - assert.equal(imgs.length, 1); - assert.match(imgs[0].src, /^data:image\/png;base64,/); - assert.equal(imgs[0].alt, 'shot.png'); - assert.equal(ctx.document.querySelector('.cm-editor'), null); - assert.ok(rows(ctx).some(r => r.dataset.path === '/work/shot.png')); - assert.equal(ctx.stateOf('s1').currentTab.editable, false); - for (const id of ['changes-diff-format-btn', 'changes-diff-mode-btn', 'changes-diff-save-btn', 'changes-diff-reload-btn']) { - assert.equal(ctx.document.getElementById(id)?.style.display, 'none', id); - } - imgs[0].click(); - assert.ok(imgs[0].classList.contains('panel-image-actual')); - imgs[0].click(); - assert.equal(imgs[0].classList.contains('panel-image-actual'), false); - assert.deepEqual(ctx.watchCalls, [['watch', '/work/shot.png']]); - } finally { ctx.destroy(); } -}); - -test('T7: a refused PDF offers the system viewer with its flag', async () => { - const ctx = setupDom({ readImpl: () => ({ ok: false, reason: 'binary', error: 'binary file' }) }); - try { - ctx.window.switchPanel('s1'); - await openLink(ctx, '/work/doc.pdf'); - const refusal = ctx.document.querySelector('.panel-file-refusal'); - assert.ok(refusal); - assert.equal(refusal.firstChild.textContent, "This file can't be shown here."); - const button = refusal.querySelector('button'); - assert.equal(button.textContent, 'Open with system viewer'); - button.click(); - assert.equal(JSON.stringify(ctx.calls.openPath), JSON.stringify([['/work/doc.pdf', { viewer: true }]])); - assert.equal(ctx.calls.showItem.length, 0); - } finally { ctx.destroy(); } -}); - -test('T8: non-viewer files only offer Show in folder', async () => { - for (const target of ['/work/tool.exe', '/work/x.pif', '/work/site.url', '/work/a.bin', '/work/no-extension', '/work/.pdf']) { - const ctx = setupDom({ readImpl: () => ({ ok: false, reason: 'binary', error: 'binary file' }) }); - try { - ctx.window.switchPanel('s1'); - await openLink(ctx, target); - const button = ctx.document.querySelector('.panel-file-refusal button'); - assert.ok(button, target); - assert.equal(button.textContent, 'Show in folder', target); - button.click(); - assert.deepEqual(ctx.calls.showItem, [[target]]); - assert.equal(ctx.calls.openPath.length, 0); - } finally { ctx.destroy(); } - } -}); - -test('T9: file-changed rereads an image with the image flag and replaces its source', async () => { - let bytes = 'b2xk'; - const ctx = setupDom({ readImpl: (_p, opts) => opts?.image ? imageResult(bytes) : { ok: false, reason: 'binary', error: 'binary file' } }); - try { - ctx.window.switchPanel('s1'); - await openLink(ctx, '/work/shot.png'); - const before = ctx.document.querySelector('img.panel-image'); - assert.ok(before); - bytes = 'bmV3'; - ctx.changed('/work/shot.png'); - await flush(); - assert.equal(ctx.calls.readFile.length, 2); - assert.equal(ctx.calls.readOptions.at(-1).image, true); - assert.equal(ctx.document.querySelector('img.panel-image')?.src, 'data:image/png;base64,bmV3'); - } finally { ctx.destroy(); } -}); - -test('T10: image decode failure shows the refusal and system action', async () => { - const ctx = setupDom({ readImpl: () => imageResult() }); - try { - ctx.window.switchPanel('s1'); - await openLink(ctx, '/work/shot.png'); - const img = ctx.document.querySelector('img.panel-image'); - assert.ok(img); - img.dispatchEvent(new ctx.window.Event('error')); - assert.equal(ctx.document.querySelector('img.panel-image'), null); - const refusal = ctx.document.querySelector('#changes-diff-host .panel-file-refusal'); - assert.ok(refusal); - assert.equal(refusal.firstChild.textContent, "This file can't be shown here."); - assert.equal(refusal.querySelector('button').textContent, 'Open with system viewer'); - } finally { ctx.destroy(); } -}); - test('Touched uses the shared select style', async () => { const ctx = setupDom(); try { @@ -1864,15 +1603,6 @@ test('markdown: a restored Touched stash recomputes formatted from the preferenc // --- Every clicked file opens in Touched — see .ai/contexts/touched-files.md ("Opened rows") -async function openLink(ctx, filePath, opts, sessionId = 's1') { - await ctx.window.openFileInPanel(sessionId, filePath, opts); - await flush(); -} - -function openedRows(ctx) { - return rows(ctx).filter(r => r.classList.contains('touched-opened')); -} - function summaryText(ctx) { return ctx.document.getElementById('touched-summary').textContent; } diff --git a/test/panel-image.test.js b/test/panel-image.test.js index b2bc07128..70faa2cf9 100644 --- a/test/panel-image.test.js +++ b/test/panel-image.test.js @@ -93,14 +93,24 @@ for (const kind of ['relative', 'raw git', 'real git', 'sensitive', 'directory', }, }); const target = kind === 'relative' ? 'file.txt' : kind === 'raw git' ? path.resolve('.git/config') : api.absolute; - await api.show(target); + const result = await api.show(target); assert.deepEqual(api.calls.show, kind === 'regular' ? [target] : []); const order = { relative: [], 'raw git': [], 'real git': ['realpath'], sensitive: ['realpath', 'sensitive'], directory: ['realpath', 'sensitive', 'stat'], regular: ['realpath', 'sensitive', 'stat'] }; assert.deepEqual(api.calls.order, order[kind]); + assert.equal(result?.ok, kind === 'regular', 'the handler must report success or refusal'); + if (kind !== 'regular') assert.equal(typeof result.reason, 'string'); }); } +test('fix1025: Show in folder reports an IO failure', () => { + const api = shellHandlers({ fs: { realpathSync: { native() { throw new Error('missing file'); } } } }); + const result = api.show(api.absolute); + assert.equal(result?.ok, false, 'IO failure must be reported'); + assert.equal(result.reason, 'missing file'); + assert.deepEqual(api.calls.show, []); +}); + test('T8d: renderer and main system-viewer extensions match', () => { assert.ok(Array.isArray(panelImage.SYSTEM_VIEWER_EXTENSIONS)); const renderer = path.join(__dirname, '../public/touched-files-view.js'); diff --git a/test/touched-editor-ipc.test.js b/test/touched-editor-ipc.test.js index 388c454b3..ae1109d41 100644 --- a/test/touched-editor-ipc.test.js +++ b/test/touched-editor-ipc.test.js @@ -27,6 +27,28 @@ const { fakeTouchedShell } = require('./remote-touched-shell'); const PNG_1025 = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+ip1sAAAAASUVORK5CYII=', 'base64'); +for (const prefix of ['GIF87a', 'GIF89a', 'RIFF1234WEBP']) { + test('fix1025: NUL-free signature text stays editable: ' + prefix, async t => { + const { dir } = fixture(t); + const target = path.join(dir, 'notes.txt'); + const text = prefix + ' is ordinary text\n'; + fs.writeFileSync(target, text); + const result = await panelHandlers().read(target, { editor: true, image: true }); + assert.equal(result.ok, true, result.error); + assert.equal(result.image, undefined, 'NUL-free text must not become an image'); + assert.equal(result.current, text); + }); + test('fix1025: binary signature remains an image: ' + prefix, async t => { + const { dir } = fixture(t); + const target = path.join(dir, 'shot.bin'); + const bytes = Buffer.from(prefix + '\0'); + fs.writeFileSync(target, bytes); + const result = await panelHandlers().read(target, { editor: true, image: true }); + assert.equal(result.image?.mime, prefix.startsWith('GIF') ? 'image/gif' : 'image/webp'); + assert.deepEqual(Buffer.from(result.image.base64, 'base64'), bytes); + }); +} + test('T2: an image read returns the exact PNG bytes', async t => { const { dir } = fixture(t); const target = path.join(dir, 'shot.png'); diff --git a/test/touched-panel-harness.js b/test/touched-panel-harness.js new file mode 100644 index 000000000..30de095e0 --- /dev/null +++ b/test/touched-panel-harness.js @@ -0,0 +1,188 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); +const { JSDOM } = require('jsdom'); +const { registerPanelTerminals } = require('./terminal-manager-harness'); + +const PUBLIC_DIR = path.join(__dirname, '..', 'public'); +const NODE_MODULES = path.join(__dirname, '..', 'node_modules'); + +const INDEX_HTML = ` + + +
+
+
+ + +`; + +function evalInWindow(dom, file) { + vm.runInContext(fs.readFileSync(file, 'utf8'), dom.getInternalVMContext(), { filename: file }); +} + +function row(over = {}) { + return { path: '/work/a.txt', state: 'present', openable: true, tools: ['Write'], count: 1, sources: ['session'], ...over }; +} + +function result(over = {}) { + return { + ok: true, + files: [row()], + unresolved: [], + omitted: 0, + coverage: { transcripts: 1, subagents: 0, malformedLines: 0, truncated: false }, + ...over, + }; +} + +function setupDom({ touchedImpl, readImpl, confirmImpl, storedRatio, storageThrows = false, resolveImpl, ownerOf, saveImpl } = {}) { + const dom = new JSDOM(INDEX_HTML, { url: 'http://localhost/', runScripts: 'outside-only', pretendToBeVisual: true }); + const { window } = dom; + if (storedRatio != null) window.localStorage.setItem('touchedListRatio', storedRatio); + if (storageThrows) { + const getItem = window.Storage.prototype.getItem; + window.Storage.prototype.getItem = function (key) { + if (key === 'touchedListRatio' || key === 'touchedMarkdownFormatted') throw new Error('storage unavailable'); + return getItem.call(this, key); + }; + window.Storage.prototype.setItem = () => { throw new Error('storage unavailable'); }; + } + const observed = new Map(); + window.ResizeObserver = class { + constructor(callback) { this.callback = callback; this.targets = new Set(); } + observe(element) { observed.set(element, this.callback); this.targets.add(element); } + disconnect() { for (const element of this.targets) observed.delete(element); } + }; + const calls = { touched: [], readFile: [], readOptions: [], save: [], gitFile: [], status: [], resolve: [], diffResponse: [], revealed: [], confirm: 0, openPath: [], showItem: [] }; + let mcpOpenFile = null; + + window.api = { + openPath: (...args) => { calls.openPath.push(args); return Promise.resolve(''); }, + showItemInFolder: (...args) => { calls.showItem.push(args); return Promise.resolve({ ok: true }); }, + onMcpOpenDiff: () => {}, + onMcpOpenFile: (handler) => { mcpOpenFile = handler; }, + onMcpCloseAllDiffs: () => {}, + onMcpCloseTab: () => {}, + mcpDiffResponse: (...args) => { calls.diffResponse.push(args); }, + resolveTerminalPaths: (sessionId, texts) => { + calls.resolve.push([sessionId, texts]); + return Promise.resolve((resolveImpl || ((_id, t) => t.map(p => ({ ok: true, path: p }))))(sessionId, texts)); + }, + sessionTouchedFiles: (sessionId, options) => { + calls.touched.push(sessionId); + return Promise.resolve((touchedImpl || (() => result()))(sessionId, options)); + }, + readFileForPanel: (filePath, options) => { + calls.readOptions.push(options); + calls.readFile.push(filePath); + return Promise.resolve((readImpl || (() => ({ ok: true, original: 'before\n', current: 'file body', version: 'v1', git: true })))(filePath, options)); + }, + }; + const watchCalls = []; + const changeListeners = []; + window.api.watchFile = filePath => { watchCalls.push(['watch', filePath]); return Promise.resolve({ ok: true }); }; + window.api.unwatchFile = filePath => { watchCalls.push(['unwatch', filePath]); return Promise.resolve({ ok: true }); }; + window.api.onFileChanged = handler => changeListeners.push(handler); + window.api.gitChangesStatus = () => { calls.status.push('status'); return Promise.resolve({ ok: true, branch: {}, files: [], totals: {} }); }; + window.api.gitChangesFile = (id, filePath, options) => { calls.gitFile.push({ id, filePath, options }); return Promise.resolve({ ok: true, original: 'before\n', current: 'file body', version: 'v1' }); }; + window.api.saveFileForPanel = (filePath, content, expected, options) => { + calls.save.push({ filePath, content, expected, options }); + return Promise.resolve(saveImpl ? saveImpl(calls.save.length - 1) : { ok: true, version: 'v2' }); + }; + window.appConfirm = async ({ message }) => { calls.confirm++; return (confirmImpl || (() => true))(message); }; + window.cmRevealLine = (_view, line) => calls.revealed.push(line); + if (ownerOf) window.panelTerminalOwnerOf = ownerOf; + const editors = []; + const editor = (parent, original, current, mode, options) => { + const dom = window.document.createElement('div'); + dom.className = 'cm-editor test-' + mode; + parent.appendChild(dom); + const view = { dom, original, current, mode, options, setText(value) { view.current = value; options.onChange(); }, destroy() { dom.remove(); } }; + const state = { doc: { toString: () => view.current } }; + if (mode === 'side-by-side') view.b = { state }; else view.state = state; + editors.push(view); + return view; + }; + window.createMergeViewer = (parent, original, current, _name, options) => editor(parent, original, current, 'side-by-side', options); + window.createUnifiedMergeViewer = (parent, original, current, _name, options) => editor(parent, original, current, 'inline', options); + window.createEditableViewer = (parent, current, _name, options) => editor(parent, null, current, 'plain', options); + window.createReadOnlyViewer = (parent, current) => editor(parent, null, current, 'read-only', { onChange() {} }); + Object.defineProperty(window, 'activeSessionId', { value: null, writable: true, configurable: true }); + + evalInWindow(dom, path.join(NODE_MODULES, 'marked', 'lib', 'marked.umd.js')); + evalInWindow(dom, path.join(NODE_MODULES, 'dompurify', 'dist', 'purify.min.js')); + const bundledMarked = window.marked; + delete window.marked; + window.loadCodeMirrorBundle = () => { window.marked = bundledMarked; return Promise.resolve(); }; + for (const file of ['modal-stack.js', 'viewer-toolbar.js', 'splitter.js', 'session-state.js', 'session-activity-dom.js', 'session-activity.js', 'shortcuts.js', 'header-controls.js', 'tool-bar.js', 'file-panel.js', 'touched-files-view.js']) { + evalInWindow(dom, path.join(PUBLIC_DIR, file)); + } + window.openSessions = new Map(); + window.gridViewActive = false; + window.gridCards = new Map(); + window.isMac = false; + window.appShortcuts = {}; + window.initFilePanel(); + registerPanelTerminals(dom, ['owner', 'r1', 's1', 's2']); + + const ctx = dom.getInternalVMContext(); + return { + window, + document: window.document, + calls, + editors, + watchCalls, + resize: element => { + if (element) observed.get(element)?.(); + else for (const callback of new Set(observed.values())) callback(); + }, + changed: filePath => changeListeners.forEach(handler => handler(filePath)), + mcpOpenFile: (sessionId, data) => mcpOpenFile(sessionId, data), + stateOf: (sessionId) => vm.runInContext('filePanelState', ctx).get(sessionId), + evalSource: source => vm.runInContext(source, ctx), + bundledMarked, + destroy: () => window.close(), + }; +} + +function flush() { + let p = Promise.resolve(); + for (let i = 0; i < 12; i++) p = p.then(() => Promise.resolve()); + return p; +} + +async function openTab(ctx, sessionId = 's1') { + ctx.window.switchPanel(sessionId); + ctx.document.getElementById('touched-toggle-btn').click(); + await flush(); +} + +function rows(ctx) { + return [...ctx.document.querySelectorAll('.touched-file-row')]; +} + +function clickRow(ctx, filePath) { + const el = rows(ctx).find((r) => r.dataset.path === filePath); + assert.ok(el, `a row for ${filePath}`); + el.dispatchEvent(new ctx.window.Event('click', { bubbles: true })); +} + +async function openLink(ctx, filePath, opts, sessionId = 's1') { + await ctx.window.openFileInPanel(sessionId, filePath, opts); + await flush(); +} + +function openedRows(ctx) { + return rows(ctx).filter(r => r.classList.contains('touched-opened')); +} + + +module.exports = { setupDom, flush, openTab, rows, clickRow, openLink, openedRows, row, result };