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/3] 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 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/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/panels-ui.js b/src/web/public/panels-ui.js
index fa7f6bb0e..5d5d4f970 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 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..cbbb28588
--- /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/3] 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 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/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 5d5d4f970..a7d14b540 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 cbbb28588..26875efe6 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/3] 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 a7d14b540..ed026845f 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 26875efe6..c29d25c4d 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' });
});