diff --git a/.changeset/file-viewer-window.md b/.changeset/file-viewer-window.md new file mode 100644 index 000000000..e3b3ae980 --- /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/docs/wiki/Keyboard-Shortcuts.md b/docs/wiki/Keyboard-Shortcuts.md index 768085c19..66212940f 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 ef7c5697e..4135e9d93 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 14db14899..1dc592faf 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/index.html b/src/web/public/index.html index abbd2cd9d..46ac82477 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -614,6 +614,8 @@

Resume Conversation

file.ts
+ + @@ -624,6 +626,7 @@

Resume Conversation

+
@@ -635,6 +638,7 @@

Resume Conversation

+ diff --git a/src/web/public/mobile.css b/src/web/public/mobile.css index 675b02c0e..6dcd57d29 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 fa7f6bb0e..ed026845f 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,152 @@ 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 { + // 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; + } + }, + + /** 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; + // 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; + 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) => { + 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; + 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 +4770,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 624364d91..4f3b926e8 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 000000000..c29d25c4d --- /dev/null +++ b/test/file-preview-window.test.ts @@ -0,0 +1,382 @@ +/** + * @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, + * 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. + */ + +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(), innerWidth: 1000, innerHeight: 800 }, + 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; + 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', () => { + 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)'); + }); +}); + +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], button = 0) { + 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, 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' }); + }); + + 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;/); + }); +});