Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/file-viewer-window.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions docs/wiki/Keyboard-Shortcuts.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
5 changes: 5 additions & 0 deletions docs/wiki/Working-With-Files.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
7 changes: 7 additions & 0 deletions src/web/public/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -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': '自动换行',
Expand Down
4 changes: 4 additions & 0 deletions src/web/public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -614,6 +614,8 @@ <h3 class="history-title" id="historyTitle">Resume Conversation</h3>
<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>
Expand All @@ -624,6 +626,7 @@ <h3 class="history-title" id="historyTitle">Resume Conversation</h3>
<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>
Expand All @@ -635,6 +638,7 @@ <h3 class="history-title" id="historyTitle">Resume Conversation</h3>
<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>

Expand Down
13 changes: 13 additions & 0 deletions src/web/public/mobile.css
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
Expand Down
157 changes: 156 additions & 1 deletion src/web/public/panels-ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down Expand Up @@ -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.
Expand All @@ -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 = '';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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');
Expand Down
42 changes: 38 additions & 4 deletions src/web/public/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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;
Expand Down
Loading
Loading