Merge pull request #659: feat(file-viewer): resize and expand the window, back/forward between previewed files

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-10-22 19:23:17 +02:00
commit cef64dc052
11 files changed
+629 -10

No files matched your search

+8
View File
@@ -685,6 +685,14 @@ const DEFAULT_SHORTCUTS = [
label: 'Close Panels',
displayBindings: ['Escape'],
},
// Handled by the File Viewer's own capture-phase listener while it is open
// (_bindFilePreviewChrome, panels-ui.js), never through the registry.
{
id: 'file-preview-history',
group: 'Panels',
label: 'File Viewer Back / Forward',
displayBindings: ['Alt/Option+ArrowLeft', 'Alt/Option+ArrowRight'],
},
];
+7
View File
@@ -1039,6 +1039,12 @@
'Source type filter': '来源类型筛选',
'Copy content': '复制内容',
'Edit file': '编辑文件',
'Back (Alt+←)': '后退 (Alt+←)',
'Forward (Alt+→)': '前进 (Alt+→)',
'Back to the previous file': '返回上一个文件',
'Forward to the next file': '前进到下一个文件',
Expand: '展开',
'Drag to resize': '拖动以调整大小',
'Rendered markdown': '渲染 Markdown',
'Line numbers': '行号',
'Wrap lines': '自动换行',
@@ -1235,6 +1241,7 @@
'Voice input (Ctrl+Shift+V)': '语音输入(Ctrl+Shift+V)',
'Insert Newline': '插入换行',
'Close Panels': '关闭面板',
'File Viewer Back / Forward': '文件查看器后退 / 前进',
'Previous / Next Session': '上一个 / 下一个会话',
'Next Session': '下一个会话',
'Previous Session': '上一个会话',
+5
View File
@@ -614,6 +614,8 @@
<div class="file-preview-header">
<span class="file-preview-title" id="filePreviewTitle">file.ts</span>
<div class="file-preview-actions">
<button class="btn-icon-sm" id="filePreviewBackBtn" onclick="app.stepFilePreviewHistory(-1)" title="Back (Alt+←)" aria-label="Back to the previous file" hidden disabled>&larr;</button>
<button class="btn-icon-sm" id="filePreviewForwardBtn" onclick="app.stepFilePreviewHistory(1)" title="Forward (Alt+→)" aria-label="Forward to the next file" hidden disabled>&rarr;</button>
<button class="btn-icon-sm" id="filePreviewPrevBtn" onclick="app.stepFolderPreview(-1)" title="Previous file in folder (←)" aria-label="Previous file in folder" hidden>&lsaquo;</button>
<button class="btn-icon-sm" id="filePreviewNextBtn" onclick="app.stepFolderPreview(1)" title="Next file in folder (→)" aria-label="Next file in folder" hidden>&rsaquo;</button>
<button class="btn-icon-sm" id="filePreviewFolderBtn" onclick="app.openFolderOfPreview()" title="Show folder" aria-label="Show folder" hidden><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg></button>
@@ -624,6 +626,7 @@
<button class="btn-icon-sm file-preview-edit-btn" id="filePreviewEditBtn" onclick="app.enterFilePreviewEdit()" title="Edit file" aria-label="Edit file" hidden><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5z"/></svg></button>
<button class="btn-icon-sm" onclick="app.copyFilePreviewContent()" title="Copy content">&#x2398;</button>
<button class="btn-icon-sm" id="filePreviewDetachBtn" onclick="app.detachFilePreview()" title="Open in new tab" aria-label="Open in new tab" hidden><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg></button>
<button class="btn-icon-sm" id="filePreviewExpandBtn" onclick="app.toggleFilePreviewExpanded()" title="Expand" aria-label="Expand" aria-pressed="false"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></svg></button>
<button class="btn-icon-sm" onclick="app.closeFilePreview()" title="Close">&times;</button>
</div>
</div>
@@ -635,6 +638,7 @@
<button class="file-preview-editbar-btn file-preview-editbar-btn--save" id="filePreviewSaveBtn" onclick="app.saveFilePreviewEdit()" disabled>Save</button>
</div>
<div class="file-preview-footer" id="filePreviewFooter"></div>
<div class="file-preview-resize" id="filePreviewResize" title="Drag to resize" aria-hidden="true"></div>
</div>
</div>
@@ -922,6 +926,7 @@
<div><kbd>Ctrl</kbd>+<kbd>?</kbd></div><div>Show Shortcuts</div>
<div><kbd>Alt/Option</kbd>+<kbd>?</kbd></div><div>Show Shortcuts</div>
<div><kbd>Escape</kbd></div><div>Close Panels</div>
<div><kbd>Alt/Option</kbd>+<kbd>ArrowLeft</kbd> / <kbd>Alt/Option</kbd>+<kbd>ArrowRight</kbd></div><div>File Viewer Back / Forward</div>
</div>
</section>
</div>
+13
View File
@@ -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));
}
+152 -1
View File
@@ -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 = '<div class="binary-message">Loading...</div>';
footerEl.textContent = '';
@@ -4537,6 +4544,148 @@ 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);
// The label stays "Expand" and aria-pressed carries the state, like MD, Lines and
// Wrap, so a screen reader never announces "Restore size, pressed".
this.$('filePreviewExpandBtn')?.setAttribute('aria-pressed', on ? 'true' : 'false');
},
/**
* 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 +4766,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');
+39 -4
View File
@@ -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,36 @@ 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. Only phones hide it (with Expand, mobile.css under 600px), since
the window is full screen there; tablets show and use it. */
.file-preview-resize {
position: absolute;
right: 0;
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;