From 08e57ed7084835c48f544b021058e32b1fcfae35 Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Thu, 15 Oct 2026 11:18:57 +0800 Subject: [PATCH 1/5] feat(file-viewer): resize and expand the window, back/forward between previewed files --- .changeset/file-viewer-window.md | 5 + src/web/public/index.html | 4 + src/web/public/panels-ui.js | 147 ++++++++++++++++++++- src/web/public/styles.css | 42 +++++- test/file-preview-window.test.ts | 220 +++++++++++++++++++++++++++++++ 5 files changed, 413 insertions(+), 5 deletions(-) create mode 100644 .changeset/file-viewer-window.md create mode 100644 test/file-preview-window.test.ts diff --git a/.changeset/file-viewer-window.md b/.changeset/file-viewer-window.md new file mode 100644 index 00000000..e3b3ae98 --- /dev/null +++ b/.changeset/file-viewer-window.md @@ -0,0 +1,5 @@ +--- +'aicodeman': minor +--- + +The File Viewer window can be resized and expanded, and jumping between files now has Back and Forward. Drag the corner grip to resize it, or use the Expand button to fill the screen (remembered across opens). Following a link from one document to another adds it to a list that the ← and → buttons, or Alt+← and Alt+→, walk through; opening something new from the middle of the list drops the forward entries, and closing the viewer clears it. Leaving unsaved edits still asks first. diff --git a/src/web/public/index.html b/src/web/public/index.html index abbd2cd9..46ac8247 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -614,6 +614,8 @@
file.ts
+ + @@ -624,6 +626,7 @@ +
@@ -635,6 +638,7 @@ + diff --git a/src/web/public/panels-ui.js b/src/web/public/panels-ui.js index fa7f6bb0..5d5d4f97 100644 --- a/src/web/public/panels-ui.js +++ b/src/web/public/panels-ui.js @@ -33,8 +33,11 @@ const FILE_PREVIEW_PREF_KEYS = { htmlRendered: 'codeman:filePreviewHtmlRendered', lineNumbers: 'codeman:filePreviewLineNumbers', wrap: 'codeman:filePreviewWrap', + expanded: 'codeman:filePreviewExpanded', }; -const FILE_PREVIEW_PREF_DEFAULTS = { mdRendered: true, htmlRendered: true, lineNumbers: false, wrap: true }; +const FILE_PREVIEW_PREF_DEFAULTS = { mdRendered: true, htmlRendered: true, lineNumbers: false, wrap: true, expanded: false }; +// Back/forward list of previews opened from one overlay session (links between documents). +const FILE_PREVIEW_HISTORY_MAX = 50; const AWAY_DIGEST_SECTIONS = [ ['needsAttention', 'Needs Attention'], ['completed', 'Completed'], @@ -4108,6 +4111,9 @@ Object.assign(CodemanApp.prototype, { if (!overlay || !bodyEl) return; + this._recordFilePreviewHistory(filePath, sessionId, attachmentId, options); + this._bindFilePreviewChrome(); + // Only the newest load may write: paging ←/→ through a folder starts the next // load while the previous one still awaits its fetch, and its late answer would // put the previous file's text under the next file's title. @@ -4133,6 +4139,7 @@ Object.assign(CodemanApp.prototype, { // Show overlay with loading state overlay.classList.add('visible'); + this._applyFilePreviewExpanded(); titleEl.textContent = filePath; bodyEl.innerHTML = '
Loading...
'; footerEl.textContent = ''; @@ -4537,6 +4544,142 @@ Object.assign(CodemanApp.prototype, { this._openFolderGridFile(next); }, + // ═══════════════════════════════════════════════════════════════ + // File Viewer window: back/forward between previews, expand, resize + // ═══════════════════════════════════════════════════════════════ + + /** + * Remember a preview in the back/forward list. A navigation that came FROM the + * list (stepFilePreviewHistory) is not recorded again, and neither is a reload + * of the file already showing (the MD/Page toggles reopen the same file). Opening + * something new while standing mid-list drops the forward entries, as a browser does. + */ + _recordFilePreviewHistory(filePath, sessionId, attachmentId, options) { + const h = (this.filePreviewHistory = this.filePreviewHistory || { entries: [], index: -1 }); + if (!this._filePreviewHistoryStepping) { + const cur = h.entries[h.index]; + const same = + cur && cur.filePath === filePath && cur.sessionId === sessionId && (cur.attachmentId || null) === (attachmentId || null); + if (!same) { + h.entries.length = h.index + 1; + h.entries.push({ filePath, sessionId, attachmentId: attachmentId || null, options }); + if (h.entries.length > FILE_PREVIEW_HISTORY_MAX) h.entries.shift(); + h.index = h.entries.length - 1; + } + } + this._updateFilePreviewHistoryNav(); + }, + + /** Back (-1) / forward (+1) through the previews opened in this overlay session. */ + stepFilePreviewHistory(delta) { + const h = this.filePreviewHistory; + if (!h) return; + const target = h.index + delta; + if (target < 0 || target >= h.entries.length) return; + if (this.filePreviewEdit?.dirty && !confirm('Discard unsaved changes?')) return; + h.index = target; + const e = h.entries[target]; + // openFilePreview records synchronously, before its first await, so the flag + // only needs to cover the call itself. + this._filePreviewHistoryStepping = true; + try { + return this.openFilePreview(e.filePath, e.sessionId, e.attachmentId, e.options || {}); + } finally { + this._filePreviewHistoryStepping = false; + } + }, + + /** Buttons show once there is somewhere to go; each is disabled at its end of the list. */ + _updateFilePreviewHistoryNav() { + const h = this.filePreviewHistory; + const multi = !!h && h.entries.length > 1; + const back = this.$('filePreviewBackBtn'); + const fwd = this.$('filePreviewForwardBtn'); + if (back) { + back.hidden = !multi; + back.disabled = !multi || h.index <= 0; + } + if (fwd) { + fwd.hidden = !multi; + fwd.disabled = !multi || h.index >= h.entries.length - 1; + } + }, + + /** Expand button: fill the screen, remembered across opens. */ + toggleFilePreviewExpanded() { + this._setFilePreviewPref('expanded', !this._filePreviewPref('expanded')); + this._applyFilePreviewExpanded(); + }, + + _applyFilePreviewExpanded() { + const on = this._filePreviewPref('expanded'); + const win = this.$('filePreviewOverlay')?.querySelector?.('.file-preview-window'); + win?.classList?.toggle('expanded', on); + const btn = this.$('filePreviewExpandBtn'); + if (btn) { + btn.setAttribute('aria-pressed', on ? 'true' : 'false'); + const label = on ? 'Restore size' : 'Expand'; + btn.title = label; + btn.setAttribute('aria-label', label); + } + }, + + /** + * One-time wiring for the window chrome: Alt+←/→ for back/forward (capture phase + * so the terminal behind the overlay never types the arrow) and the corner grip. + */ + _bindFilePreviewChrome() { + if (this._filePreviewChromeBound) return; + this._filePreviewChromeBound = true; + document.addEventListener( + 'keydown', + (ev) => { + if (!ev.altKey || ev.ctrlKey || ev.metaKey || ev.shiftKey) return; + if (ev.key !== 'ArrowLeft' && ev.key !== 'ArrowRight') return; + if (!this.$('filePreviewOverlay')?.classList.contains('visible')) return; + const t = ev.target; + if (t && (t.isContentEditable || (/^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName) && !t.classList?.contains('xterm-helper-textarea')))) return; + const h = this.filePreviewHistory; + const delta = ev.key === 'ArrowLeft' ? -1 : 1; + if (!h || h.index + delta < 0 || h.index + delta >= h.entries.length) return; + ev.preventDefault(); + ev.stopPropagation(); + this.stepFilePreviewHistory(delta); + }, + true + ); + this._bindFilePreviewResize(); + }, + + /** Drag the corner grip to size the window; the window is centred, so the delta counts twice. */ + _bindFilePreviewResize() { + const grip = this.$('filePreviewResize'); + const win = grip?.parentElement; + if (!grip || !win) return; + grip.addEventListener('pointerdown', (ev) => { + ev.preventDefault(); + const startX = ev.clientX; + const startY = ev.clientY; + const startW = win.offsetWidth; + const startH = win.offsetHeight; + grip.setPointerCapture?.(ev.pointerId); + const move = (e) => { + const w = Math.max(320, Math.min(window.innerWidth * 0.96, startW + 2 * (e.clientX - startX))); + const h = Math.max(240, Math.min(window.innerHeight * 0.94, startH + 2 * (e.clientY - startY))); + win.style.width = `${Math.round(w)}px`; + win.style.height = `${Math.round(h)}px`; + }; + const up = () => { + grip.removeEventListener('pointermove', move); + grip.removeEventListener('pointerup', up); + grip.removeEventListener('pointercancel', up); + }; + grip.addEventListener('pointermove', move); + grip.addEventListener('pointerup', up); + grip.addEventListener('pointercancel', up); + }); + }, + /** "Show folder" in the preview: the grid of the previewed file's folder. */ openFolderOfPreview() { const target = this.filePreviewFolderTarget; @@ -4617,6 +4760,8 @@ Object.assign(CodemanApp.prototype, { // Back on the grid (if one is open underneath): its current tile stays marked. if (this.folderGrid) this.folderGrid.index = null; this._updateFolderPreviewNav(null); + this.filePreviewHistory = null; + this._updateFilePreviewHistoryNav(); const overlay = this.$('filePreviewOverlay'); if (overlay) { overlay.classList.remove('visible'); diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 624364d9..4f3b926e 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -12223,10 +12223,15 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat } .file-preview-window { - width: 80vw; - max-width: 900px; - height: 80vh; - max-height: 700px; + position: relative; + width: min(80vw, 900px); + height: min(80vh, 700px); + /* A dragged size (inline width/height) and the expanded state may exceed the + default 900x700, but never the screen. */ + max-width: 96vw; + max-height: 94vh; + min-width: min(320px, 96vw); + min-height: min(240px, 94vh); background: var(--floating-bg); backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px); @@ -12238,6 +12243,35 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat overflow: hidden; } +/* Expanded: fills the screen (a dragged size is ignored while it is on). */ +.file-preview-window.expanded { + width: 96vw !important; + height: 94vh !important; +} + +/* Corner grip (panels-ui.js _bindFilePreviewResize). The window is centred, so a + drag moves both edges: the handler doubles the pointer delta to keep the corner + under the pointer. Touch devices use the Expand button instead. */ +.file-preview-resize { + position: absolute; + right: 0; + bottom: 0; + width: 16px; + height: 16px; + cursor: nwse-resize; + touch-action: none; + background: linear-gradient(135deg, transparent 50%, var(--text-muted, #888) 50%, var(--text-muted, #888) 58%, transparent 58%, transparent 72%, var(--text-muted, #888) 72%, var(--text-muted, #888) 80%, transparent 80%); + opacity: 0.6; +} + +.file-preview-resize:hover { + opacity: 1; +} + +.file-preview-window.expanded .file-preview-resize { + display: none; +} + .file-preview-header { display: flex; align-items: center; diff --git a/test/file-preview-window.test.ts b/test/file-preview-window.test.ts new file mode 100644 index 00000000..cbbb2858 --- /dev/null +++ b/test/file-preview-window.test.ts @@ -0,0 +1,220 @@ +/** + * @fileoverview File Viewer window: back/forward between previews, expand, resize. + * + * Links between documents open the next file in the same overlay with no way back. + * Pinned here: + * 1. jumping between files fills a back/forward list and the buttons reveal, + * 2. back/forward reopen the right file WITHOUT recording a new entry, + * 3. opening a file mid-list drops the forward entries, + * 4. reopening the file already showing (MD/Page toggles) adds nothing, + * 5. closing the preview clears the list and hides the buttons, + * 6. unsaved edits ask before leaving, + * 7. Expand toggles the window class and the pref, and is re-applied on open, + * 8. the markup carries the buttons and the resize grip. + * + * Loaded via `vm` against a stub app, same harness style as file-preview-detach.test.ts. + */ + +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import vm from 'node:vm'; +import { describe, expect, it, vi } from 'vitest'; + +const PUBLIC = resolve(import.meta.dirname, '../src/web/public'); +const panelsJs = readFileSync(resolve(PUBLIC, 'panels-ui.js'), 'utf8'); +const indexHtml = readFileSync(resolve(PUBLIC, 'index.html'), 'utf8'); + +function btn() { + const attrs: Record = {}; + return { + hidden: true, + disabled: true, + title: '', + setAttribute: (k: string, v: string) => (attrs[k] = v), + attrs, + }; +} + +function loadApp(confirmResult = true) { + const CodemanApp = function CodemanApp(this: unknown) {} as unknown as new () => Record; + const store: Record = {}; + const context = vm.createContext({ + CodemanApp, + console: { ...console, warn: vi.fn(), error: vi.fn() }, + localStorage: { + getItem: (k: string) => store[k] ?? null, + setItem: (k: string, v: string) => (store[k] = v), + removeItem: () => {}, + }, + escapeHtml: (s: string) => String(s), + CodemanBase: { base: '', url: (p: string) => p }, + rendersAsPage: () => false, + document: { getElementById: () => null, addEventListener: vi.fn() }, + window: { addEventListener: vi.fn(), open: vi.fn() }, + setTimeout, + clearTimeout, + confirm: vi.fn(() => confirmResult), + fetch: () => { + throw new Error('fetch not stubbed'); + }, + }); + vm.runInContext(panelsJs, context, { filename: 'panels-ui.js' }); + + const winClasses = new Set(); + const windowEl = { + classList: { + toggle: (c: string, on: boolean) => (on ? winClasses.add(c) : winClasses.delete(c)), + contains: (c: string) => winClasses.has(c), + }, + }; + const overlayClasses = new Set(['visible']); + const overlay = { + classList: { + add: (c: string) => overlayClasses.add(c), + remove: (c: string) => overlayClasses.delete(c), + contains: (c: string) => overlayClasses.has(c), + }, + querySelector: () => windowEl, + }; + const back = btn(); + const fwd = btn(); + const expand = btn(); + const elements: Record = { + filePreviewBody: { innerHTML: '', querySelectorAll: () => [], querySelector: () => null }, + filePreviewOverlay: overlay, + filePreviewTitle: { textContent: '' }, + filePreviewFooter: { textContent: '' }, + filePreviewDetachBtn: { hidden: true }, + filePreviewBackBtn: back, + filePreviewForwardBtn: fwd, + filePreviewExpandBtn: expand, + }; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const app = new CodemanApp() as Record; + app.$ = (id: string) => elements[id] ?? null; + app._resetFilePreviewEdit = () => {}; + app._isExternalPreviewPath = () => false; + app.showToast = vi.fn(); + app.filePreviewContent = ''; + const confirmFn = (context as { confirm: ReturnType }).confirm; + return { app, overlay, back, fwd, expand, winClasses, store, confirmFn }; +} + +describe('file viewer back/forward', () => { + it('stays hidden for one file and reveals once a second one is opened', async () => { + const { app, back, fwd } = loadApp(); + await app.openFilePreview('/ws/a.pdf', 's1'); + expect(back.hidden).toBe(true); + + await app.openFilePreview('/ws/b.pdf', 's1'); + expect(back.hidden).toBe(false); + expect(back.disabled).toBe(false); + expect(fwd.hidden).toBe(false); + expect(fwd.disabled).toBe(true); + }); + + it('steps back and forward without recording the step as a new entry', async () => { + const { app, back, fwd } = loadApp(); + await app.openFilePreview('/ws/a.pdf', 's1'); + await app.openFilePreview('/ws/b.pdf', 's1'); + await app.openFilePreview('/ws/c.pdf', 's1'); + + await app.stepFilePreviewHistory(-1); + expect(app.filePreviewHistory.entries.map((e: { filePath: string }) => e.filePath)).toEqual([ + '/ws/a.pdf', + '/ws/b.pdf', + '/ws/c.pdf', + ]); + expect(app.filePreviewHistory.index).toBe(1); + expect(app.filePreviewDetachUrl).toContain(encodeURIComponent('/ws/b.pdf')); + expect(fwd.disabled).toBe(false); + + await app.stepFilePreviewHistory(-1); + expect(back.disabled).toBe(true); + await app.stepFilePreviewHistory(-1); // at the start: a no-op + expect(app.filePreviewHistory.index).toBe(0); + + await app.stepFilePreviewHistory(1); + expect(app.filePreviewHistory.index).toBe(1); + }); + + it('drops the forward entries when a new file is opened mid-list', async () => { + const { app } = loadApp(); + await app.openFilePreview('/ws/a.pdf', 's1'); + await app.openFilePreview('/ws/b.pdf', 's1'); + await app.stepFilePreviewHistory(-1); + await app.openFilePreview('/ws/c.pdf', 's1'); + + expect(app.filePreviewHistory.entries.map((e: { filePath: string }) => e.filePath)).toEqual([ + '/ws/a.pdf', + '/ws/c.pdf', + ]); + expect(app.filePreviewHistory.index).toBe(1); + }); + + it('does not add an entry when the file already showing is reopened', async () => { + const { app } = loadApp(); + await app.openFilePreview('/ws/a.pdf', 's1'); + await app.openFilePreview('/ws/a.pdf', 's1'); + expect(app.filePreviewHistory.entries).toHaveLength(1); + }); + + it('clears the list and hides the buttons on close', async () => { + const { app, back, fwd } = loadApp(); + await app.openFilePreview('/ws/a.pdf', 's1'); + await app.openFilePreview('/ws/b.pdf', 's1'); + app.closeFilePreview(); + + expect(app.filePreviewHistory).toBeNull(); + expect(back.hidden).toBe(true); + expect(fwd.hidden).toBe(true); + }); + + it('asks before leaving unsaved edits and stays put when declined', async () => { + const { app, confirmFn } = loadApp(false); + await app.openFilePreview('/ws/a.pdf', 's1'); + await app.openFilePreview('/ws/b.pdf', 's1'); + app.filePreviewEdit = { dirty: true }; + + await app.stepFilePreviewHistory(-1); + + expect(confirmFn).toHaveBeenCalled(); + expect(app.filePreviewHistory.index).toBe(1); + }); +}); + +describe('file viewer expand', () => { + it('toggles the window class, the pref and the button state', () => { + const { app, winClasses, store, expand } = loadApp(); + + app.toggleFilePreviewExpanded(); + expect(winClasses.has('expanded')).toBe(true); + expect(store['codeman:filePreviewExpanded']).toBe('1'); + expect(expand.attrs['aria-pressed']).toBe('true'); + expect(expand.title).toBe('Restore size'); + + app.toggleFilePreviewExpanded(); + expect(winClasses.has('expanded')).toBe(false); + expect(expand.attrs['aria-pressed']).toBe('false'); + }); + + it('re-applies a remembered expanded state when a preview opens', async () => { + const { app, winClasses, store } = loadApp(); + store['codeman:filePreviewExpanded'] = '1'; + + await app.openFilePreview('/ws/a.pdf', 's1'); + + expect(winClasses.has('expanded')).toBe(true); + }); +}); + +describe('file viewer window markup', () => { + it('carries the back, forward and expand buttons and the resize grip', () => { + for (const id of ['filePreviewBackBtn', 'filePreviewForwardBtn', 'filePreviewExpandBtn', 'filePreviewResize']) { + expect(indexHtml).toContain(`id="${id}"`); + } + expect(indexHtml).toContain('app.stepFilePreviewHistory(-1)'); + expect(indexHtml).toContain('app.stepFilePreviewHistory(1)'); + }); +}); From 2cd0d82544ea9e34cf805464946ada7f39a38c15 Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Sun, 18 Oct 2026 18:03:54 +0800 Subject: [PATCH 2/5] fix(file-viewer): phones ignore expand and the grip, folder paging follows history, swallow Alt+arrows Under 600px the window stays full screen whatever Expand or a drag says, and both controls are hidden. Stepping onto a folder-grid entry goes through the grid so its index follows. Alt+left/right is swallowed at the ends of the list too. Adds tests for the key handler, the grip clamp and the history/grid case, zh-CN titles and wiki lines. --- docs/wiki/Keyboard-Shortcuts.md | 1 + docs/wiki/Working-With-Files.md | 5 + src/web/public/i18n.js | 7 ++ src/web/public/mobile.css | 13 +++ src/web/public/panels-ui.js | 13 ++- test/file-preview-window.test.ts | 163 ++++++++++++++++++++++++++++++- 6 files changed, 197 insertions(+), 5 deletions(-) diff --git a/docs/wiki/Keyboard-Shortcuts.md b/docs/wiki/Keyboard-Shortcuts.md index 768085c1..66212940 100644 --- a/docs/wiki/Keyboard-Shortcuts.md +++ b/docs/wiki/Keyboard-Shortcuts.md @@ -10,6 +10,7 @@ Press `Ctrl+?` in the app for the same list in a floating overlay. | ------------------------------- | --------------------------------------------------------------- | | `Ctrl+K` (also `Cmd+K`, `Alt+K`)| Find an open session or start a new one. | | `Ctrl+Tab` | Next session. | +| `Alt+←` / `Alt+→` | File Viewer window: back / forward between previewed files. | | `Alt+[` / `Alt+]` | Previous / next tab. | | `Alt+1` to `Alt+9` | Switch to tab N. Physical keys, so macOS Option layouts work. | | `Ctrl+Shift+{` / `Ctrl+Shift+}` | Move the active tab left / right. | diff --git a/docs/wiki/Working-With-Files.md b/docs/wiki/Working-With-Files.md index ef7c5697..4135e9d9 100644 --- a/docs/wiki/Working-With-Files.md +++ b/docs/wiki/Working-With-Files.md @@ -27,6 +27,11 @@ stream rather than server memory). Sensitive paths (`.env`, anything matching credentials, `~/.ssh`, AWS credentials) are blocked from download, and SVG and HTML are served as downloads rather than rendered, so they cannot execute in the page. +The preview window has **Back** and **Forward** buttons (also `Alt+←` and `Alt+→`) once you +have opened a second file, so a link into another document is no longer one way. **Expand** +fills the screen and is remembered per device, and the corner grip resizes the window. On a +phone the window is always full screen, so Expand and the grip are hidden there. + Closing the preview pauses and unloads any playing media. A video that keeps playing after you close the panel means you are on an old version. diff --git a/src/web/public/i18n.js b/src/web/public/i18n.js index 14db1489..1dc592fa 100644 --- a/src/web/public/i18n.js +++ b/src/web/public/i18n.js @@ -1039,6 +1039,13 @@ 'Source type filter': '来源类型筛选', 'Copy content': '复制内容', 'Edit file': '编辑文件', + 'Back (Alt+←)': '后退 (Alt+←)', + 'Forward (Alt+→)': '前进 (Alt+→)', + 'Back to the previous file': '返回上一个文件', + 'Forward to the next file': '前进到下一个文件', + Expand: '展开', + 'Restore size': '恢复大小', + 'Drag to resize': '拖动以调整大小', 'Rendered markdown': '渲染 Markdown', 'Line numbers': '行号', 'Wrap lines': '自动换行', diff --git a/src/web/public/mobile.css b/src/web/public/mobile.css index 675b02c0..6dcd57d2 100644 --- a/src/web/public/mobile.css +++ b/src/web/public/mobile.css @@ -2298,6 +2298,19 @@ html.mobile-init .file-browser-panel { border-right: none; } + /* Expand and the resize grip would only shrink the full-screen window (96vw x 94vh, + or a px size left by a drag in landscape), so a phone ignores both. */ + .file-preview-window, + .file-preview-window.expanded { + width: 100vw !important; + height: var(--app-height, 100vh) !important; + } + + #filePreviewExpandBtn, + .file-preview-resize { + display: none; + } + .file-preview-editbar { padding-bottom: calc(0.4rem + var(--safe-area-bottom)); } diff --git a/src/web/public/panels-ui.js b/src/web/public/panels-ui.js index 5d5d4f97..a7d14b54 100644 --- a/src/web/public/panels-ui.js +++ b/src/web/public/panels-ui.js @@ -4583,6 +4583,13 @@ Object.assign(CodemanApp.prototype, { // only needs to cover the call itself. this._filePreviewHistoryStepping = true; try { + // A folder-grid page goes through the grid so index, lastIndex and the highlighted + // tile follow; otherwise the next ‹ › would continue from the newer file. + const folderFile = e.options?.folderFile; + if (folderFile && folderFile.folderId === this.folderGrid?.folderId) { + const index = this.folderGrid.files.findIndex((f) => f.name === folderFile.name); + if (index !== -1) return this._openFolderGridFile(index); + } return this.openFilePreview(e.filePath, e.sessionId, e.attachmentId, e.options || {}); } finally { this._filePreviewHistoryStepping = false; @@ -4639,11 +4646,13 @@ Object.assign(CodemanApp.prototype, { if (!this.$('filePreviewOverlay')?.classList.contains('visible')) return; const t = ev.target; if (t && (t.isContentEditable || (/^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName) && !t.classList?.contains('xterm-helper-textarea')))) return; + // Swallowed even at the ends of the list: otherwise the terminal behind the overlay + // types it as word-left/right, and on a header button the browser's own Alt+← goes Back. + ev.preventDefault(); + ev.stopPropagation(); const h = this.filePreviewHistory; const delta = ev.key === 'ArrowLeft' ? -1 : 1; if (!h || h.index + delta < 0 || h.index + delta >= h.entries.length) return; - ev.preventDefault(); - ev.stopPropagation(); this.stepFilePreviewHistory(delta); }, true diff --git a/test/file-preview-window.test.ts b/test/file-preview-window.test.ts index cbbb2858..26875efe 100644 --- a/test/file-preview-window.test.ts +++ b/test/file-preview-window.test.ts @@ -10,7 +10,11 @@ * 5. closing the preview clears the list and hides the buttons, * 6. unsaved edits ask before leaving, * 7. Expand toggles the window class and the pref, and is re-applied on open, - * 8. the markup carries the buttons and the resize grip. + * 8. the markup carries the buttons and the resize grip, + * 9. stepping onto a folder-grid page moves the grid's own index with it, + * 10. Alt+←/→ steps, ignores other chords and text fields, and is swallowed at the ends, + * 11. the corner grip doubles the pointer delta and clamps the size, + * 12. phones ignore Expand and the grip (mobile.css). * * Loaded via `vm` against a stub app, same harness style as file-preview-detach.test.ts. */ @@ -50,7 +54,7 @@ function loadApp(confirmResult = true) { CodemanBase: { base: '', url: (p: string) => p }, rendersAsPage: () => false, document: { getElementById: () => null, addEventListener: vi.fn() }, - window: { addEventListener: vi.fn(), open: vi.fn() }, + window: { addEventListener: vi.fn(), open: vi.fn(), innerWidth: 1000, innerHeight: 800 }, setTimeout, clearTimeout, confirm: vi.fn(() => confirmResult), @@ -98,7 +102,12 @@ function loadApp(confirmResult = true) { app.showToast = vi.fn(); app.filePreviewContent = ''; const confirmFn = (context as { confirm: ReturnType }).confirm; - return { app, overlay, back, fwd, expand, winClasses, store, confirmFn }; + const keydown = () => { + const calls = (context as { document: { addEventListener: ReturnType } }).document.addEventListener + .mock.calls; + return calls.find((c: unknown[]) => c[0] === 'keydown')?.[1] as (ev: unknown) => void; + }; + return { app, overlay, back, fwd, expand, winClasses, store, confirmFn, elements, overlayClasses, keydown }; } describe('file viewer back/forward', () => { @@ -218,3 +227,151 @@ describe('file viewer window markup', () => { expect(indexHtml).toContain('app.stepFilePreviewHistory(1)'); }); }); + +describe('file viewer folder paging in the history', () => { + it('moves the grid index with a back step, so the next page continues from there', async () => { + const { app } = loadApp(); + const names = ['a', 'b', 'c', 'd'].map((n) => ({ name: `${n}.pdf`, size: 1 })); + app.folderGrid = { + folderId: 'f1', + path: '/ws', + sessionId: 's1', + files: names, + folders: [], + index: null, + lastIndex: null, + }; + app._openFolderGridFile(0); + app._openFolderGridFile(1); + app._openFolderGridFile(2); + + await app.stepFilePreviewHistory(-1); + expect(app.folderGrid.index).toBe(1); + expect(app.folderGrid.lastIndex).toBe(1); + + app.stepFolderPreview(1); + const paths = app.filePreviewHistory.entries.map((e: { filePath: string }) => e.filePath); + expect(paths).toEqual(['/ws/a.pdf', '/ws/b.pdf', '/ws/c.pdf']); + expect(app.folderGrid.index).toBe(2); + }); + + it('falls back to a plain open when the entry is from another folder', async () => { + const { app } = loadApp(); + app.folderGrid = { + folderId: 'f1', + path: '/ws', + sessionId: 's1', + files: [{ name: 'a.pdf', size: 1 }], + folders: [], + index: 0, + lastIndex: 0, + }; + await app.openFilePreview('/other/x.pdf', 's1', null, { folderFile: { folderId: 'f2', name: 'x.pdf' } }); + await app.openFilePreview('/ws/y.pdf', 's1'); + await app.stepFilePreviewHistory(-1); + expect(app.filePreviewHistory.index).toBe(0); + expect(app.filePreviewHistory.entries).toHaveLength(2); + }); +}); + +describe('file viewer Alt+arrow chord', () => { + const key = (over: Record = {}) => ({ + altKey: true, + ctrlKey: false, + metaKey: false, + shiftKey: false, + key: 'ArrowLeft', + target: { tagName: 'DIV' }, + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + ...over, + }); + + async function setup() { + const h = loadApp(); + await h.app.openFilePreview('/ws/a.pdf', 's1'); + await h.app.openFilePreview('/ws/b.pdf', 's1'); + h.app._bindFilePreviewChrome(); + const step = vi.spyOn(h.app, 'stepFilePreviewHistory'); + return { ...h, step, fire: h.keydown() }; + } + + it('steps back and forward and swallows the chord', async () => { + const { fire, step } = await setup(); + const ev = key(); + fire(ev); + expect(step).toHaveBeenCalledWith(-1); + expect(ev.preventDefault).toHaveBeenCalled(); + expect(ev.stopPropagation).toHaveBeenCalled(); + }); + + it('swallows the chord at the ends of the list without stepping', async () => { + const { fire, step } = await setup(); + const fwd = key({ key: 'ArrowRight' }); // already at the newest entry + fire(fwd); + expect(step).not.toHaveBeenCalled(); + expect(fwd.preventDefault).toHaveBeenCalled(); + expect(fwd.stopPropagation).toHaveBeenCalled(); + }); + + it.each([ + ['a plain arrow', { altKey: false }], + ['Shift+Alt', { shiftKey: true }], + ['Ctrl+Alt', { ctrlKey: true }], + ['another key', { key: 'ArrowUp' }], + ['a textarea target', { target: { tagName: 'TEXTAREA', classList: { contains: () => false } } }], + ])('leaves %s alone', async (_name, over) => { + const { fire, step } = await setup(); + const ev = key(over); + fire(ev); + expect(step).not.toHaveBeenCalled(); + expect(ev.preventDefault).not.toHaveBeenCalled(); + }); + + it('leaves the chord alone while the overlay is hidden', async () => { + const { fire, step, overlayClasses } = await setup(); + overlayClasses.delete('visible'); + const ev = key(); + fire(ev); + expect(step).not.toHaveBeenCalled(); + expect(ev.preventDefault).not.toHaveBeenCalled(); + }); +}); + +describe('file viewer resize grip', () => { + function drag(to: [number, number]) { + const { app, elements } = loadApp(); + const listeners: Record void> = {}; + const win = { offsetWidth: 400, offsetHeight: 300, style: {} as Record }; + elements.filePreviewResize = { + parentElement: win, + setPointerCapture: () => {}, + addEventListener: (t: string, fn: (e: unknown) => void) => (listeners[t] = fn), + removeEventListener: () => {}, + }; + app._bindFilePreviewResize(); + listeners.pointerdown({ preventDefault: () => {}, clientX: 100, clientY: 100, pointerId: 1 }); + listeners.pointermove({ clientX: 100 + to[0], clientY: 100 + to[1] }); + return win.style; + } + + it('doubles the pointer delta, because the window is centred', () => { + expect(drag([100, 50])).toMatchObject({ width: '600px', height: '400px' }); + }); + + it('clamps to the minimum and to 96% x 94% of the viewport', () => { + expect(drag([-500, -500])).toMatchObject({ width: '320px', height: '240px' }); + expect(drag([900, 900])).toMatchObject({ width: '960px', height: '752px' }); + }); +}); + +describe('file viewer on phones', () => { + it('overrides Expand and any dragged size, and hides both controls, under 600px', () => { + const css = readFileSync(resolve(PUBLIC, 'mobile.css'), 'utf8'); + const phone = css.slice(css.indexOf('@media (max-width: 599px)')); + expect(phone).toMatch( + /\.file-preview-window,\s*\.file-preview-window\.expanded\s*\{[^}]*width: 100vw !important;[^}]*height: var\(--app-height, 100vh\) !important;/ + ); + expect(phone).toMatch(/#filePreviewExpandBtn,\s*\.file-preview-resize\s*\{\s*display: none;/); + }); +}); From e3c6f9f2c737ffa56481edb2d9d4cf31459cd1e1 Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Tue, 20 Oct 2026 11:30:38 +0800 Subject: [PATCH 3/5] fix(file-viewer): the resize grip only starts on the primary button --- src/web/public/panels-ui.js | 1 + test/file-preview-window.test.ts | 9 +++++++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/web/public/panels-ui.js b/src/web/public/panels-ui.js index a7d14b54..ed026845 100644 --- a/src/web/public/panels-ui.js +++ b/src/web/public/panels-ui.js @@ -4666,6 +4666,7 @@ Object.assign(CodemanApp.prototype, { const win = grip?.parentElement; if (!grip || !win) return; grip.addEventListener('pointerdown', (ev) => { + if (ev.button !== 0) return; // a right-click on the grip must not start a resize ev.preventDefault(); const startX = ev.clientX; const startY = ev.clientY; diff --git a/test/file-preview-window.test.ts b/test/file-preview-window.test.ts index 26875efe..c29d25c4 100644 --- a/test/file-preview-window.test.ts +++ b/test/file-preview-window.test.ts @@ -339,7 +339,7 @@ describe('file viewer Alt+arrow chord', () => { }); describe('file viewer resize grip', () => { - function drag(to: [number, number]) { + function drag(to: [number, number], button = 0) { const { app, elements } = loadApp(); const listeners: Record void> = {}; const win = { offsetWidth: 400, offsetHeight: 300, style: {} as Record }; @@ -350,11 +350,16 @@ describe('file viewer resize grip', () => { removeEventListener: () => {}, }; app._bindFilePreviewResize(); - listeners.pointerdown({ preventDefault: () => {}, clientX: 100, clientY: 100, pointerId: 1 }); + listeners.pointerdown({ preventDefault: () => {}, clientX: 100, clientY: 100, pointerId: 1, button }); + if (!listeners.pointermove) return win.style; // a refused pointerdown binds no move handler listeners.pointermove({ clientX: 100 + to[0], clientY: 100 + to[1] }); return win.style; } + it('ignores a pointerdown that is not the primary button', () => { + expect(drag([100, 50], 2)).toEqual({}); + }); + it('doubles the pointer delta, because the window is centred', () => { expect(drag([100, 50])).toMatchObject({ width: '600px', height: '400px' }); }); From df521267ae1c288c8d7ebcf94c7a8e07e4be9789 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Thu, 22 Oct 2026 19:17:05 +0200 Subject: [PATCH 4/5] fix(file-viewer): keep the Expand label fixed while it is pressed Merge-time fix for #659. The Expand button swapped its title and aria-label to "Restore size" while on, so a screen reader announced "Restore size, pressed". The label now stays "Expand" and aria-pressed alone carries the state, the way the MD, Lines and Wrap toggles work. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/web/public/panels-ui.js | 10 +++------- test/file-preview-window.test.ts | 8 +++++++- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/web/public/panels-ui.js b/src/web/public/panels-ui.js index ed026845..dedaea57 100644 --- a/src/web/public/panels-ui.js +++ b/src/web/public/panels-ui.js @@ -4622,13 +4622,9 @@ Object.assign(CodemanApp.prototype, { const on = this._filePreviewPref('expanded'); const win = this.$('filePreviewOverlay')?.querySelector?.('.file-preview-window'); win?.classList?.toggle('expanded', on); - const btn = this.$('filePreviewExpandBtn'); - if (btn) { - btn.setAttribute('aria-pressed', on ? 'true' : 'false'); - const label = on ? 'Restore size' : 'Expand'; - btn.title = label; - btn.setAttribute('aria-label', label); - } + // The label stays "Expand" and aria-pressed carries the state, like MD, Lines and + // Wrap, so a screen reader never announces "Restore size, pressed". + this.$('filePreviewExpandBtn')?.setAttribute('aria-pressed', on ? 'true' : 'false'); }, /** diff --git a/test/file-preview-window.test.ts b/test/file-preview-window.test.ts index c29d25c4..bd9347dd 100644 --- a/test/file-preview-window.test.ts +++ b/test/file-preview-window.test.ts @@ -196,16 +196,22 @@ describe('file viewer back/forward', () => { describe('file viewer expand', () => { it('toggles the window class, the pref and the button state', () => { const { app, winClasses, store, expand } = loadApp(); + // As index.html ships it. + expand.title = 'Expand'; + expand.attrs['aria-label'] = 'Expand'; app.toggleFilePreviewExpanded(); expect(winClasses.has('expanded')).toBe(true); expect(store['codeman:filePreviewExpanded']).toBe('1'); expect(expand.attrs['aria-pressed']).toBe('true'); - expect(expand.title).toBe('Restore size'); + // aria-pressed carries the state; the label stays fixed, like the MD/Lines/Wrap toggles. + expect(expand.title).toBe('Expand'); + expect(expand.attrs['aria-label']).toBe('Expand'); app.toggleFilePreviewExpanded(); expect(winClasses.has('expanded')).toBe(false); expect(expand.attrs['aria-pressed']).toBe('false'); + expect(expand.title).toBe('Expand'); }); it('re-applies a remembered expanded state when a preview opens', async () => { From 73ff2339d80247b1644932fb8abeb52f8e40e5ad Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Thu, 22 Oct 2026 19:17:05 +0200 Subject: [PATCH 5/5] docs(file-viewer): list Alt+Left/Right in the shortcut overlay and help Merge-time fix for #659. - Alt+Left/Right (File Viewer back/forward) is a display-only entry in DEFAULT_SHORTCUTS, so the Ctrl+? overlay lists it, and a row in the help modal's Panels group, pinned in help-modal-shortcuts.test.ts. zh-CN entry for the new label; the "Restore size" entry is dropped, since nothing shows that string any more. - The wiki row moves from "Sessions and tabs" to "Everything else". - The grip comment in styles.css says only phones hide the grip; tablets show and use it. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/wiki/Keyboard-Shortcuts.md | 12 ++++++------ src/web/public/app.js | 8 ++++++++ src/web/public/i18n.js | 2 +- src/web/public/index.html | 1 + src/web/public/styles.css | 3 ++- test/help-modal-shortcuts.test.ts | 1 + 6 files changed, 19 insertions(+), 8 deletions(-) diff --git a/docs/wiki/Keyboard-Shortcuts.md b/docs/wiki/Keyboard-Shortcuts.md index 66212940..d4d54c46 100644 --- a/docs/wiki/Keyboard-Shortcuts.md +++ b/docs/wiki/Keyboard-Shortcuts.md @@ -10,7 +10,6 @@ Press `Ctrl+?` in the app for the same list in a floating overlay. | ------------------------------- | --------------------------------------------------------------- | | `Ctrl+K` (also `Cmd+K`, `Alt+K`)| Find an open session or start a new one. | | `Ctrl+Tab` | Next session. | -| `Alt+←` / `Alt+→` | File Viewer window: back / forward between previewed files. | | `Alt+[` / `Alt+]` | Previous / next tab. | | `Alt+1` to `Alt+9` | Switch to tab N. Physical keys, so macOS Option layouts work. | | `Ctrl+Shift+{` / `Ctrl+Shift+}` | Move the active tab left / right. | @@ -58,11 +57,12 @@ default. See [Tile Grid](Tile-Grid). ## Everything else -| Shortcut | Action | -| -------------- | ------------------------------- | -| `Ctrl+Shift+V` | Toggle voice input. | -| `Ctrl+?` | Shortcut reference overlay. | -| `Escape` | Close panels and modals. | +| Shortcut | Action | +| ----------------- | ------------------------------------------------------------ | +| `Ctrl+Shift+V` | Toggle voice input. | +| `Ctrl+?` | Shortcut reference overlay. | +| `Escape` | Close panels and modals. | +| `Alt+←` / `Alt+→` | File Viewer window: back / forward between previewed files. | ## Rebinding diff --git a/src/web/public/app.js b/src/web/public/app.js index 62716497..29077b53 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -685,6 +685,14 @@ const DEFAULT_SHORTCUTS = [ label: 'Close Panels', displayBindings: ['Escape'], }, + // Handled by the File Viewer's own capture-phase listener while it is open + // (_bindFilePreviewChrome, panels-ui.js), never through the registry. + { + id: 'file-preview-history', + group: 'Panels', + label: 'File Viewer Back / Forward', + displayBindings: ['Alt/Option+ArrowLeft', 'Alt/Option+ArrowRight'], + }, ]; diff --git a/src/web/public/i18n.js b/src/web/public/i18n.js index 1dc592fa..d7951087 100644 --- a/src/web/public/i18n.js +++ b/src/web/public/i18n.js @@ -1044,7 +1044,6 @@ 'Back to the previous file': '返回上一个文件', 'Forward to the next file': '前进到下一个文件', Expand: '展开', - 'Restore size': '恢复大小', 'Drag to resize': '拖动以调整大小', 'Rendered markdown': '渲染 Markdown', 'Line numbers': '行号', @@ -1242,6 +1241,7 @@ 'Voice input (Ctrl+Shift+V)': '语音输入(Ctrl+Shift+V)', 'Insert Newline': '插入换行', 'Close Panels': '关闭面板', + 'File Viewer Back / Forward': '文件查看器后退 / 前进', 'Previous / Next Session': '上一个 / 下一个会话', 'Next Session': '下一个会话', 'Previous Session': '上一个会话', diff --git a/src/web/public/index.html b/src/web/public/index.html index 46ac8247..ceda80f3 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -926,6 +926,7 @@
Ctrl+?
Show Shortcuts
Alt/Option+?
Show Shortcuts
Escape
Close Panels
+
Alt/Option+ArrowLeft / Alt/Option+ArrowRight
File Viewer Back / Forward
diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 4f3b926e..aca188de 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -12251,7 +12251,8 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat /* Corner grip (panels-ui.js _bindFilePreviewResize). The window is centred, so a drag moves both edges: the handler doubles the pointer delta to keep the corner - under the pointer. Touch devices use the Expand button instead. */ + under the pointer. Only phones hide it (with Expand, mobile.css under 600px), since + the window is full screen there; tablets show and use it. */ .file-preview-resize { position: absolute; right: 0; diff --git a/test/help-modal-shortcuts.test.ts b/test/help-modal-shortcuts.test.ts index 20e54c94..25e9c0fd 100644 --- a/test/help-modal-shortcuts.test.ts +++ b/test/help-modal-shortcuts.test.ts @@ -51,6 +51,7 @@ describe('help modal shortcuts', () => { expect(helpModal).not.toMatch(/Ctrl<\/kbd>\s*\+\s*\/<\/kbd>.*?Show Shortcuts/i); expectShortcut(helpModal, ['Ctrl', 'Shift', 'V'], 'Voice Input'); expectShortcut(helpModal, ['Escape'], 'Close Panels'); + expectShortcut(helpModal, ['Alt/Option', 'ArrowLeft'], 'File Viewer Back / Forward'); }); it('documents the tile grid chords', () => {