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] 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;/); + }); +});