diff --git a/.ai/contexts/ipc-bridge.md b/.ai/contexts/ipc-bridge.md index 535694ca..2c1be1d0 100644 --- a/.ai/contexts/ipc-bridge.md +++ b/.ai/contexts/ipc-bridge.md @@ -441,6 +441,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` | @@ -507,6 +509,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 | @@ -514,7 +521,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 1860aaae..d8ceb9fc 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,40 @@ 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. +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 +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 +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. 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. 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/.ai/contexts/viewer-panel.md b/.ai/contexts/viewer-panel.md index 0c2083f2..dcf41ae4 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 b2dd31cb..04f1881c 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 e4f6c568..e0851cb2 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 9bb0fa02..a32eac31 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'); @@ -1184,15 +1185,31 @@ 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 { ok: false, reason: 'invalid-path' }; + const resolved = path.resolve(filePath); + 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); + return { ok: true }; + } catch (error) { + return { ok: false, reason: error.message || String(error) }; + } +}); + // --- IPC: read clipboard (terminal right-click paste; see clipboard-write-text) --- ipcMain.handle('read-clipboard', () => clipboard.readText()); @@ -1249,10 +1266,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 && (!['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); 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 4e71070b..e65edcf7 100644 --- a/preload.js +++ b/preload.js @@ -112,7 +112,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 a96dc161..1dd7dce8 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,38 @@ 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 = () => { + if (!img.isConnected) return; + 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 b614e731..00646e3f 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 c78f50d5..bfefdb31 100644 --- a/public/touched-files-view.js +++ b/public/touched-files-view.js @@ -16,6 +16,35 @@ 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', 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); +} function formatTouchedTime(timestamp, now = Date.now()) { if (!Number.isFinite(timestamp)) return 'Time unknown'; @@ -223,6 +252,7 @@ async function refreshTouched(sessionId) { tab.loading = true; tab.openError = null; + tab.openRefusal = null; if (currentPanelSessionId === sessionId) renderPanel(sessionId); let result; @@ -322,7 +352,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 +360,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 +377,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 +512,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 00000000..3fe57017 --- /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-images.test.js b/test/dom-file-panel-images.test.js new file mode 100644 index 00000000..de2ac577 --- /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 01810cdd..48a89fa2 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 = ` - -
-