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