feat(preview): folder grid — browse every previewable file of a folder

Folder paths in the terminal and the response viewer are now links
(rooted or ~/ without an extension, relative with a trailing slash); a
click opens a grid of the folder's supported files, newest first, with
subfolder chips and a parent button. A tile opens the file preview,
which pages through the folder with ‹ › / ←/→; the preview of any file
gets a "Show folder" button.

Server: POST /api/sessions/:id/folders grants + lists a folder (local
fs or one ssh round trip for a remote case) under the attachment guard;
GET …/folders/:folderId/files/:name/(raw|preview|thumbnail) serves one
file by bare name through resolveServableAttachmentPath, so browser
requests still never carry a path. Grants live in their own small
registry so a big folder cannot evict the session's attachments. The
attachment raw route's body is now the shared serveServableRaw helper.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
RandalixandClaude Opus 5.5 committed 2026-10-10 21:54:37 +02:00
1 parent 7d46a9d336
commit 1010d80b4f
16 files changed
+1706 -44

No files matched your search

+4 -2
View File
@@ -2749,7 +2749,8 @@ class CodemanApp {
// (rebased, no `linkified` mark) already are what the routes expect.
const sessionId = pathLink.dataset.sessionId || this.activeSessionId;
const target = pathLink.dataset.linkified ? this._resolveLinkedFilePath?.(filePath, sessionId) : filePath;
if (filePath) this.openFilePreview(target || filePath, sessionId);
if (filePath && pathLink.dataset.folder) this.openFolderGrid(target || filePath, sessionId);
else if (filePath) this.openFilePreview(target || filePath, sessionId);
return;
}
@@ -2902,13 +2903,14 @@ class CodemanApp {
const value = node.nodeValue;
const frag = document.createDocumentFragment();
let cursor = 0;
for (const { path, index } of found) {
for (const { path, index, folder } of found) {
if (index > cursor) frag.appendChild(document.createTextNode(value.slice(cursor, index)));
const link = document.createElement('a');
link.className = 'rv-path';
link.href = '#';
link.dataset.path = path;
link.dataset.linkified = '1';
if (folder) link.dataset.folder = '1';
link.title = path;
link.textContent = path;
frag.appendChild(link);
+42 -2
View File
@@ -2004,13 +2004,15 @@ const FILE_LINK_EXTENSION_GROUP = /\\\.(\(\?:[^()]*\))\)\\b$/.exec(FILE_PATH_LIN
/**
* Every linkable file path in `text`, in order: absolute (rooted) paths first,
* then `~/` and relative ones that do not overlap them or a URL.
* then `~/` and relative ones, then folder paths — each only where it overlaps
* neither an earlier one nor a URL.
*
* The one entry point for both consumers (terminal link provider, response
* viewer), so a path form is clickable in both or in neither.
*
* @param {string} text
* @returns {Array<{path: string, index: number}>} `index` is where `path` starts in `text`.
* @returns {Array<{path: string, index: number, folder?: true}>} `index` is where `path` starts in
* `text`; `folder` marks a folder path (folder grid) rather than a file.
*/
function findFilePathLinks(text) {
const value = String(text || '');
@@ -2036,9 +2038,47 @@ function findFilePathLinks(text) {
if (overlaps(index, path.length) || inUrl(index, path.length)) continue;
found.push({ path, index });
}
const folders = new RegExp(FOLDER_PATH_LINK_PATTERN.source, 'g');
while ((match = folders.exec(value)) !== null) {
const path = folderLinkPath(match[2]);
const index = match.index + match[1].length;
if (!path || overlaps(index, path.length) || inUrl(index, path.length)) continue;
found.push({ path, index, folder: true });
}
return found.sort((a, b) => a.index - b.index);
}
/**
* Folder paths, for the folder grid: rooted like {@link FILE_PATH_LINK_PATTERN}
* (`/mnt/raid1/neon_getaway/styleframes`), `~/renders`, or relative with a
* trailing slash (`builds/captures/`). Same boundary in front as the relative
* file pattern (group 1), the candidate itself is group 2 and goes through
* {@link folderLinkPath}, which decides whether it reads as a folder at all.
* File links are found first and win any overlap.
*
* ⚠ Like its siblings, never share an instance (`lastIndex`): use
* {@link findFilePathLinks}.
*/
const FOLDER_PATH_LINK_PATTERN =
/(^|[\s"'`(\[{<>=,:])((?:\/(?:home|Users|tmp|var|private|opt|mnt|srv|media|data|workspace)\/|~\/|(?![^\s"'`<>|;&()[\]{}=,\n\x00-\x1f]*:\/\/)[^\s"'`<>|;&()[\]{}=,\/~\n\x00-\x1f])[^\s"'`<>|;&()[\]{}=,\n\x00-\x1f]*)/g;
/**
* A {@link FOLDER_PATH_LINK_PATTERN} candidate as a folder link, or null.
*
* Sentence punctuation after the path is not part of it. A path ending in `/` is
* a folder; otherwise only a rooted or `~/` path whose last segment has no
* extension (`.cache` and `styleframes` do, `a.rs` does not) — a relative word
* without a slash at the end is prose, not a folder.
*/
function folderLinkPath(candidate) {
const path = String(candidate || '').replace(/[.,:;!?'"]+$/, '');
if (!path || path === '~/' || /^\/[^/]+\/$/.test(path)) return null;
if (path.endsWith('/')) return /[^/.]/.test(path) ? path : null;
if (!path.startsWith('/') && !path.startsWith('~/')) return null;
const last = path.slice(path.lastIndexOf('/') + 1);
return /^\.?[^.]+$/.test(last) ? path : null;
}
/**
* Where a clicked path points, as the preview routes need it.
*
+19
View File
@@ -614,6 +614,9 @@
<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="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>
<button class="btn-icon-sm file-preview-pill" id="filePreviewMdBtn" onclick="app.toggleFilePreviewMd()" title="Rendered markdown" aria-label="Rendered markdown" aria-pressed="true" hidden>MD</button>
<button class="btn-icon-sm" id="filePreviewLinesBtn" onclick="app.toggleFilePreviewLines()" title="Line numbers" aria-label="Line numbers" aria-pressed="false" 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"><line x1="10" y1="6" x2="21" y2="6"/><line x1="10" y1="12" x2="21" y2="12"/><line x1="10" y1="18" x2="21" y2="18"/><path d="M4 6h1v4"/><path d="M4 10h2"/><path d="M6 18H4c0-1 2-2 2-3s-1-1.5-2-1"/></svg></button>
<button class="btn-icon-sm" id="filePreviewWrapBtn" onclick="app.toggleFilePreviewWrap()" title="Wrap lines" aria-label="Wrap lines" aria-pressed="true" 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"><line x1="3" y1="6" x2="21" y2="6"/><path d="M3 12h15a3 3 0 1 1 0 6h-4"/><polyline points="16 16 14 18 16 20"/><line x1="3" y1="18" x2="10" y2="18"/></svg></button>
@@ -634,6 +637,22 @@
</div>
</div>
<!-- Folder Grid: every previewable file of one folder (panels-ui.js openFolderGrid) -->
<div class="folder-grid-overlay" id="folderGridOverlay">
<div class="folder-grid-window">
<div class="file-preview-header">
<span class="file-preview-title" id="folderGridTitle" data-i18n-skip></span>
<div class="file-preview-actions">
<button class="btn-icon-sm" id="folderGridUpBtn" onclick="app.folderGridUp()" title="Parent folder" aria-label="Parent folder">&uarr;</button>
<button class="btn-icon-sm" onclick="app.closeFolderGrid()" title="Close" aria-label="Close">&times;</button>
</div>
</div>
<div class="folder-grid-folders" id="folderGridFolders" data-i18n-skip></div>
<div class="folder-grid-body" id="folderGridBody" data-i18n-skip></div>
<div class="file-preview-footer" id="folderGridFooter" data-i18n-skip></div>
</div>
</div>
<!-- Response Viewer (mobile: native-scroll overlay for reading full Claude responses) -->
<div class="response-viewer" id="responseViewer">
<div class="response-viewer-header">
+270 -7
View File
@@ -4086,8 +4086,19 @@ Object.assign(CodemanApp.prototype, {
}
},
async openFilePreview(filePath, sessionId = this.activeSessionId, attachmentId = null) {
/**
* @param {object} [options]
* @param {{folderId: string, name: string}} [options.folderFile] A file of a
* granted folder (folder grid): rendered from the folder's by-name routes, so
* paging through 300 renders never touches the attachment registry.
*/
async openFilePreview(filePath, sessionId = this.activeSessionId, attachmentId = null, options = {}) {
if (!sessionId || !filePath) return;
const folderFile = options.folderFile || null;
// Prev/next only while browsing a folder grid; a preview opened from anywhere
// else ends that browsing.
if (!folderFile && this.folderGrid) this.folderGrid.index = null;
this._updateFolderPreviewNav(folderFile ? null : this._previewFolderPath(filePath, sessionId, attachmentId));
const overlay = this.$('filePreviewOverlay');
const titleEl = this.$('filePreviewTitle');
@@ -4125,7 +4136,7 @@ Object.assign(CodemanApp.prototype, {
// for a file that is sitting right there on disk.
let externalError = '';
let externalSize = 0;
if (!attachmentId && this._isExternalPreviewPath(filePath, sessionId)) {
if (!attachmentId && !folderFile && this._isExternalPreviewPath(filePath, sessionId)) {
const external = await this._registerExternalPreview(filePath, sessionId);
attachmentId = external.attachmentId || null;
externalError = external.error || '';
@@ -4143,9 +4154,14 @@ Object.assign(CodemanApp.prototype, {
// (html/htm arrive as a download there by design — file-raw serves them
// attachment-only so widening READ never widens RUN.)
const officeDoc = ext === 'docx' || ext === 'pptx';
const byIdBase = attachmentId
? `/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}`
: folderFile
? `/api/sessions/${sessionId}/folders/${encodeURIComponent(folderFile.folderId)}/files/${encodeURIComponent(folderFile.name)}`
: null;
this.filePreviewDetachUrl = CodemanBase.url(
attachmentId
? `/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}/${officeDoc ? 'preview' : 'raw'}`
byIdBase
? `${byIdBase}/${officeDoc ? 'preview' : 'raw'}`
: officeDoc
? `/api/sessions/${sessionId}/file-preview?path=${encodeURIComponent(filePath)}`
: `/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}`
@@ -4155,8 +4171,9 @@ Object.assign(CodemanApp.prototype, {
// Registered attachment: render straight from its by-id routes — images and
// PDFs inline, Office docs via the server-converted PDF preview, text fetched
// raw. (Workspace-path previews fall through to the file-content endpoint.)
if (attachmentId) {
const base = CodemanBase.url(`/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}`);
// A folder-grid file renders the same way, from its folder's by-name routes.
if (byIdBase) {
const base = CodemanBase.url(byIdBase);
const IMAGE_EXTS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg']);
// VIDEO/AUDIO mirror VIDEO_ATTACHMENT_EXTENSIONS/AUDIO_ATTACHMENT_EXTENSIONS
// (src/attachment-registry.ts, the single source); the frontend cannot import
@@ -4166,6 +4183,7 @@ Object.assign(CodemanApp.prototype, {
// Size when we just registered the file ourselves, so a path opened from a
// link reads like a workspace preview instead of a bare "PNG". History
// cards arrive with an id and no size and keep the short form.
if (folderFile && !externalSize) externalSize = options.size || 0;
footerEl.textContent = externalSize ? `${this.formatFileSize(externalSize)} • ${ext}` : ext.toUpperCase();
if (IMAGE_EXTS.has(ext)) {
bodyEl.innerHTML = `<img src="${escapeHtml(`${base}/raw`)}" alt="${escapeHtml(filePath)}">`;
@@ -4204,7 +4222,7 @@ Object.assign(CodemanApp.prototype, {
this.filePreviewContent = shown;
// attachmentId: a card's filePath is the bare file name, so the
// rebase pass must know there is no directory to resolve against.
this.filePreviewText = { ext, sessionId, filePath, attachmentId };
this.filePreviewText = { ext, sessionId, filePath, attachmentId: attachmentId || folderFile?.folderId };
this._renderFilePreviewText();
if (clippedByLines || clippedByBytes) {
const note = clippedByLines ? `showing first ${lineCap} lines` : 'showing the start of the file';
@@ -4307,9 +4325,254 @@ Object.assign(CodemanApp.prototype, {
}
},
// ===== Folder grid =====
/**
* Show every previewable file of a folder as a grid (absolute or `~/` path on the
* session's host). The server grants the folder an id (`POST …/folders`), and the
* grid and the previews it opens fetch files by `folderId + name` only.
*
* Opened from a clicked folder path: when the server finds a FILE there after all,
* it falls back to the file preview, i.e. what a click did before folders were links.
*/
async openFolderGrid(folderPath, sessionId = this.activeSessionId) {
if (!sessionId || !folderPath) return;
const overlay = this.$('folderGridOverlay');
const body = this.$('folderGridBody');
if (!overlay || !body) return;
this._bindFolderGridKeys();
let result = null;
let status = 0;
try {
const res = await fetch(`/api/sessions/${sessionId}/folders`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: folderPath }),
});
status = res.status;
result = await res.json().catch(() => null);
} catch (err) {
this.showToast(`Cannot open folder: ${err.message || err}`, 'error');
return;
}
if (!result?.success || !result.data) {
const reason = result?.error || `HTTP ${status}`;
if (status === 400 && /not a folder/i.test(reason)) {
this.openFilePreview(folderPath, sessionId);
return;
}
this.showToast(`Cannot open folder ${folderPath}: ${reason}`, 'error');
return;
}
this.folderGrid = { sessionId, ...result.data, index: null };
this._renderFolderGrid();
overlay.classList.add('visible');
body.scrollTop = 0;
},
closeFolderGrid() {
this.$('folderGridOverlay')?.classList.remove('visible');
const body = this.$('folderGridBody');
// Drop the tiles: lazy <img>s still loading would keep fetching otherwise.
if (body) body.innerHTML = '';
this.folderGrid = null;
this._updateFolderPreviewNav(null);
},
folderGridUp() {
const grid = this.folderGrid;
if (!grid || grid.path === '/') return;
this.openFolderGrid(grid.path.slice(0, grid.path.lastIndexOf('/')) || '/', grid.sessionId);
},
_renderFolderGrid() {
const grid = this.folderGrid;
if (!grid) return;
const title = this.$('folderGridTitle');
const foldersEl = this.$('folderGridFolders');
const body = this.$('folderGridBody');
const footer = this.$('folderGridFooter');
title.textContent = grid.path;
title.title = grid.path;
const upBtn = this.$('folderGridUpBtn');
if (upBtn) upBtn.disabled = grid.path === '/';
foldersEl.innerHTML = '';
for (const name of grid.folders) {
const chip = document.createElement('button');
chip.type = 'button';
chip.className = 'folder-grid-chip';
chip.textContent = `${name}/`;
chip.onclick = () => this.openFolderGrid(`${grid.path === '/' ? '' : grid.path}/${name}`, grid.sessionId);
foldersEl.appendChild(chip);
}
body.innerHTML = '';
if (grid.files.length === 0) {
const empty = document.createElement('div');
empty.className = 'folder-grid-empty';
empty.textContent = 'No previewable files in this folder.';
body.appendChild(empty);
}
grid.files.forEach((file, index) => {
const tile = document.createElement('button');
tile.type = 'button';
tile.className = 'folder-grid-tile';
tile.title = `${file.name} • ${this.formatFileSize(file.size)}`;
tile.dataset.index = String(index);
const thumb = document.createElement('div');
thumb.className = 'folder-grid-thumb';
const label = file.extension.toUpperCase();
const route = this._folderFileUrl(grid, file.name, '');
// Images load themselves (lazily — a folder can hold hundreds of renders);
// PDF/Office show their first page where the server can render one (local
// folders), everything else a type label.
const src =
file.attachmentType === 'image'
? `${route}/raw`
: ['pdf', 'docx', 'pptx'].includes(file.extension)
? `${route}/thumbnail`
: null;
if (src) {
const img = document.createElement('img');
img.loading = 'lazy';
img.decoding = 'async';
img.alt = file.name;
img.src = src;
img.onerror = () => {
thumb.textContent = label;
};
thumb.appendChild(img);
} else {
thumb.textContent = label;
}
const name = document.createElement('span');
name.className = 'folder-grid-name';
name.textContent = file.name;
tile.append(thumb, name);
tile.onclick = () => this._openFolderGridFile(index);
body.appendChild(tile);
});
const count = `${grid.files.length} file${grid.files.length === 1 ? '' : 's'}`;
const subfolders = grid.folders.length ? ` • ${grid.folders.length} folder${grid.folders.length === 1 ? '' : 's'}` : '';
footer.textContent = `${count}${subfolders}${grid.truncated ? ' (folder too large — showing the first entries)' : ''}`;
},
_folderFileUrl(grid, name, suffix) {
return CodemanBase.url(
`/api/sessions/${grid.sessionId}/folders/${encodeURIComponent(grid.folderId)}/files/${encodeURIComponent(name)}${suffix}`
);
},
_openFolderGridFile(index) {
const grid = this.folderGrid;
const file = grid?.files[index];
if (!file) return;
const body = this.$('folderGridBody');
body?.querySelectorAll('.folder-grid-tile.current').forEach((t) => t.classList.remove('current'));
const tile = body?.querySelector(`.folder-grid-tile[data-index="${index}"]`);
tile?.classList.add('current');
tile?.scrollIntoView({ block: 'nearest' });
this.openFilePreview(`${grid.path === '/' ? '' : grid.path}/${file.name}`, grid.sessionId, null, {
folderFile: { folderId: grid.folderId, name: file.name },
size: file.size,
});
// After openFilePreview: it resets the index for previews from elsewhere.
grid.index = index;
grid.lastIndex = index;
this._updateFolderPreviewNav(null);
},
/** Page through the open folder from its preview (‹ › buttons, ←/→). */
stepFolderPreview(delta) {
const grid = this.folderGrid;
if (!grid || grid.index === null || grid.files.length === 0) return;
if (this.filePreviewEdit?.dirty && !confirm('Discard unsaved changes?')) return;
const next = (grid.index + delta + grid.files.length) % grid.files.length;
this._openFolderGridFile(next);
},
/** "Show folder" in the preview: the grid of the previewed file's folder. */
openFolderOfPreview() {
const target = this.filePreviewFolderTarget;
if (!target) return;
this.closeFilePreview();
this.openFolderGrid(target.dir, target.sessionId);
},
/**
* The folder a previewed file lives in, or null when that is unknown — an
* attachment card carries only a bare file name.
*/
_previewFolderPath(filePath, sessionId, attachmentId) {
if (typeof filePath !== 'string' || (attachmentId && !filePath.includes('/'))) return null;
const workingDir = this.sessions?.get?.(sessionId)?.workingDir;
const resolved = typeof resolveLinkedFilePath === 'function' ? resolveLinkedFilePath(filePath, workingDir) : filePath;
if (!resolved.startsWith('/') && !resolved.startsWith('~/')) return null;
const cut = resolved.lastIndexOf('/');
return { dir: resolved.slice(0, cut) || '/', sessionId };
},
/** Show prev/next while browsing a folder grid, else "Show folder" when the file's folder is known. */
_updateFolderPreviewNav(folderTarget) {
const browsing = !!this.folderGrid && this.folderGrid.index !== null && this.folderGrid.files.length > 1;
const prev = this.$('filePreviewPrevBtn');
const next = this.$('filePreviewNextBtn');
if (prev) prev.hidden = !browsing;
if (next) next.hidden = !browsing;
if (folderTarget !== undefined) this.filePreviewFolderTarget = folderTarget;
const inGrid = !!this.folderGrid && this.folderGrid.index !== null;
const folderBtn = this.$('filePreviewFolderBtn');
if (folderBtn) folderBtn.hidden = inGrid || !this.filePreviewFolderTarget;
},
/**
* ←/→ page the folder preview, Escape steps back (preview → grid → closed).
* Capture phase + stopPropagation: the terminal keeps keyboard focus under the
* overlays, and an arrow key that reached xterm would be typed into the session.
*/
_bindFolderGridKeys() {
if (this._folderGridKeysBound) return;
this._folderGridKeysBound = true;
document.addEventListener(
'keydown',
(ev) => {
const grid = this.folderGrid;
if (!grid || ev.altKey || ev.ctrlKey || ev.metaKey) return;
const previewOpen = this.$('filePreviewOverlay')?.classList.contains('visible');
const gridOpen = this.$('folderGridOverlay')?.classList.contains('visible');
if (!gridOpen) return;
const t = ev.target;
const typing =
t && (t.isContentEditable || (/^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName) && !t.classList?.contains('xterm-helper-textarea')));
if (typing || this.filePreviewEdit) return;
let handled = true;
if (previewOpen && grid.index !== null && ev.key === 'ArrowLeft') this.stepFolderPreview(-1);
else if (previewOpen && grid.index !== null && ev.key === 'ArrowRight') this.stepFolderPreview(1);
// Grid alone: an arrow opens the last viewed tile (or the first), so it
// pages the folder instead of landing in the terminal behind the grid.
else if (!previewOpen && (ev.key === 'ArrowLeft' || ev.key === 'ArrowRight') && grid.files.length)
this._openFolderGridFile(grid.lastIndex ?? 0);
else if (ev.key === 'Escape') {
if (previewOpen) this.closeFilePreview();
else this.closeFolderGrid();
} else handled = false;
if (handled) {
ev.preventDefault();
ev.stopPropagation();
}
},
true
);
},
closeFilePreview() {
if (this.filePreviewEdit?.dirty && !confirm('Discard unsaved changes?')) return;
this._resetFilePreviewEdit();
// Back on the grid (if one is open underneath): its current tile stays marked.
if (this.folderGrid) this.folderGrid.index = null;
this._updateFolderPreviewNav(null);
const overlay = this.$('filePreviewOverlay');
if (overlay) {
overlay.classList.remove('visible');
+128 -1
View File
@@ -11375,6 +11375,132 @@ kbd {
display: flex;
}
/* Folder grid (panels-ui.js openFolderGrid): one step BELOW the file preview, so a
tile opens its preview on top and closing that preview lands back on the grid. */
.folder-grid-overlay {
position: fixed;
inset: 0;
background: var(--modal-backdrop);
z-index: 5090;
display: none;
align-items: center;
justify-content: center;
}
.folder-grid-overlay.visible {
display: flex;
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
}
.folder-grid-window {
width: 92vw;
max-width: 1400px;
height: 88vh;
background: var(--floating-bg);
border: 1px solid var(--control-border);
border-radius: 12px;
box-shadow: var(--elevated-shadow);
display: flex;
flex-direction: column;
overflow: hidden;
}
.folder-grid-folders {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
padding: 0.4rem 0.75rem;
border-bottom: 1px solid var(--border);
max-height: 6.5rem;
overflow-y: auto;
flex-shrink: 0;
}
.folder-grid-folders:empty {
display: none;
}
.folder-grid-chip {
font-family: var(--font-mono);
font-size: 0.75rem;
padding: 0.15rem 0.5rem;
border-radius: 999px;
border: 1px solid var(--control-border);
background: var(--bg-input);
color: var(--text);
cursor: pointer;
}
.folder-grid-chip:hover {
background: var(--bg-hover, rgba(255, 255, 255, 0.08));
}
.folder-grid-body {
flex: 1;
overflow-y: auto;
padding: 0.75rem;
background: var(--bg-dark);
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
grid-auto-rows: min-content;
gap: 0.6rem;
}
.folder-grid-tile {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 0.3rem;
border: 1px solid transparent;
border-radius: 8px;
background: none;
color: var(--text);
cursor: pointer;
text-align: left;
min-width: 0;
}
.folder-grid-tile:hover,
.folder-grid-tile.current {
border-color: var(--accent, #4ea1ff);
}
.folder-grid-thumb {
aspect-ratio: 4 / 3;
border-radius: 6px;
background: var(--bg-input);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
font-family: var(--font-mono);
font-size: 0.8rem;
font-weight: 600;
color: var(--text-muted, #888);
}
.folder-grid-thumb img {
width: 100%;
height: 100%;
object-fit: contain;
}
.folder-grid-name {
font-family: var(--font-mono);
font-size: 0.72rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.folder-grid-empty {
grid-column: 1 / -1;
padding: 2rem;
text-align: center;
color: var(--text-muted, #888);
}
.file-preview-window {
width: 80vw;
max-width: 900px;
@@ -19386,7 +19512,8 @@ html[data-session-list="sidebar"][data-sidebar="collapsed"] .btn-sidebar-toggle
/* No gutter of their own. */
.modal,
.file-preview-overlay {
.file-preview-overlay,
.folder-grid-overlay {
padding-right: var(--fold-inline-end);
padding-bottom: var(--fold-block-end);
}
+8 -2
View File
@@ -2079,7 +2079,7 @@ Object.assign(CodemanApp.prototype, {
// Pattern 3: Bash() tool output
const bashPattern = /Bash\([^)]*?(\/(?:home|tmp|var|etc|opt)[^\s"'<>|;&\)\n\x00-\x1f]+)/g;
const addLink = (filePath, matchIndex) => {
const addLink = (filePath, matchIndex, folder = false) => {
const startCol = lineText.indexOf(filePath, matchIndex);
if (startCol === -1) return;
@@ -2117,6 +2117,12 @@ Object.assign(CodemanApp.prototype, {
// server knows that host's home, and the log viewer cannot ask).
const sessionId = getSessionId();
const target = self._resolveLinkedFilePath(text, sessionId);
// A folder path opens the folder grid (which falls back to the
// preview if the server finds a file there after all).
if (folder) {
self.openFolderGrid(target, sessionId);
return;
}
if (previewsInFileViewer(target) || self._isExternalPreviewPath(target, sessionId)) {
self.openFilePreview(target, sessionId);
return;
@@ -2146,7 +2152,7 @@ Object.assign(CodemanApp.prototype, {
}
for (const found of findFilePathLinks(lineText)) {
addLink(found.path, found.index);
addLink(found.path, found.index, !!found.folder);
}
bashPattern.lastIndex = 0;
+182 -28
View File
@@ -30,12 +30,20 @@ import {
attachmentRecordToEvent,
attachmentRegistry,
buildFileThumbnailRoute,
getAttachmentType,
isSupportedAttachmentExtension,
registerExternalAttachment,
TEXT_ATTACHMENT_EXTENSIONS,
VIDEO_ATTACHMENT_EXTENSIONS,
type AttachmentRecord,
} from '../../attachment-registry.js';
import {
FolderListingError,
folderFilePath,
folderGrants,
listFolder,
validFolderFileName,
} from '../../folder-listing.js';
import { generateFirstPageThumbnail } from '../../document-thumbnailer.js';
import { getOfficePreviewPdfPath, getPreviewPdfDownloadName } from '../../document-preview-cache.js';
import { sanitizeAttachmentHistoryItem } from '../../session-attachment-history.js';
@@ -426,6 +434,40 @@ async function resolveServableRemoteAttachment(
return { path: probe.realPath, probe };
}
/**
* Stream a guard-checked file ({@link resolveServableAttachmentPath}) — the body of
* the attachment raw route, shared with the folder-grid file route.
*
* A remote file streams over ssh exactly like file-raw, with the same 200/206/416
* contract, and its size comes from the guard's own re-probe — so serving a remote
* file needs no stat the local branch would not also need.
*/
async function serveServableRaw(
reply: FastifyReply,
servable: ServableAttachment,
remote: SessionRemote | undefined,
fileName: string,
extension: string,
query: { download?: string; preview?: string; range?: string }
): Promise<void> {
try {
const target: FileTarget =
servable.probe && remote
? { kind: 'remote', resolvedPath: servable.path, relativePath: '', remote, probe: servable.probe }
: { kind: 'local', resolvedPath: servable.path, relativePath: '' };
const size = servable.probe ? servable.probe.size : (await fs.stat(servable.path)).size;
if (exceedsXlsxPreviewLimit(extension, query, size)) {
sendXlsxPreviewTooLarge(reply, size);
return;
}
await serveRawFile(reply, target, size, fileName, extension, query.download === 'true', query.range);
} catch (err) {
reply
.code(err instanceof RemoteFileAccessError ? 502 : 500)
.send(createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to read file: ${getErrorMessage(err)}`));
}
}
/**
* Convert a DOCX/PPTX to a single-PDF preview (LibreOffice when available) and
* stream it inline. PDF/PNG and text formats don't need conversion — callers
@@ -2263,34 +2305,11 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
});
if (!servable) return;
try {
// A remote record streams over ssh exactly like file-raw, with the same
// 200/206/416 contract, and its size comes from the guard's own re-probe — so
// serving a remote attachment needs no stat the local branch would not also need.
const remote = session.remote;
const target: FileTarget =
servable.probe && remote
? { kind: 'remote', resolvedPath: servable.path, relativePath: '', remote, probe: servable.probe }
: { kind: 'local', resolvedPath: servable.path, relativePath: '' };
const size = servable.probe ? servable.probe.size : (await fs.stat(servable.path)).size;
if (exceedsXlsxPreviewLimit(record.extension, { preview, download }, size)) {
sendXlsxPreviewTooLarge(reply, size);
return;
}
await serveRawFile(
reply,
target,
size,
record.fileName,
record.extension,
download === 'true',
req.headers.range
);
} catch (err) {
reply
.code(err instanceof RemoteFileAccessError ? 502 : 500)
.send(createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to read file: ${getErrorMessage(err)}`));
}
await serveServableRaw(reply, servable, session.remote, record.fileName, record.extension, {
download,
preview,
range: req.headers.range,
});
});
// Serve a converted PDF preview of a registered attachment by id. Office docs
@@ -2354,6 +2373,141 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
await serveThumbnail(reply, servable.path, record.extension);
});
// ===== Folder grid =====
// Grant + list a folder (absolute or `~/` on the session's host) for the preview
// grid. Same guard as a single external attachment; the grid then fetches files by
// `folderId + name`, so no browser request carries a path. See folder-listing.ts.
app.post('/api/sessions/:id/folders', async (req, reply) => {
const { id } = req.params as { id: string };
const scope = getKnownSessionFileScope(ctx, id, reply, req);
if (!scope) return;
const body = (req.body || {}) as { path?: unknown };
if (!body.path || typeof body.path !== 'string') {
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing folder path'));
return;
}
try {
const listing = await listFolder(id, body.path, { sessionWorkingDir: scope.workingDir, remote: scope.remote });
return { success: true, data: listing };
} catch (err) {
if (err instanceof FolderListingError) {
const code =
err.statusCode === 404
? ApiErrorCode.NOT_FOUND
: err.statusCode === 502
? ApiErrorCode.OPERATION_FAILED
: ApiErrorCode.INVALID_INPUT;
reply.code(err.statusCode).send(createErrorResponse(code, err.message));
return;
}
return reply
.code(500)
.send(createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to list folder: ${getErrorMessage(err)}`));
}
});
/**
* One file of a granted folder, guard-checked like an attachment: the name is a
* bare file name of a supported type, and the joined path goes through the same
* {@link resolveServableAttachmentPath} (blocklist, workspace confinement, symlink
* re-resolution, remote re-probe) an attachment by id does.
*/
async function resolveFolderFile(
req: FastifyRequest,
reply: FastifyReply
): Promise<{ scope: SessionFileScope; servable: ServableAttachment; name: string; extension: string } | null> {
const { id, folderId, name } = req.params as { id: string; folderId: string; name: string };
const scope = getKnownSessionFileScope(ctx, id, reply, req);
if (!scope) return null;
const grant = folderGrants.get(id, folderId);
if (!grant) {
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'Folder not found'));
return null;
}
const fileName = validFolderFileName(name);
if (!fileName) {
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid file name'));
return null;
}
const filePath = folderFilePath(grant.dir, fileName);
if (!scope.remote) {
try {
if (!(await fs.stat(filePath)).isFile()) throw new Error('not a file');
} catch {
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found'));
return null;
}
}
const extension = extname(fileName).toLowerCase().replace(/^\./, '');
const record: AttachmentRecord = {
attachmentId: '',
sessionId: id,
filePath,
fileName,
extension,
attachmentType: getAttachmentType(extension),
size: 0,
mtimeMs: 0,
timestamp: 0,
source: 'external',
};
const servable = await resolveServableAttachmentPath(reply, record, scope);
if (!servable) return null;
return { scope, servable, name: fileName, extension };
}
app.get('/api/sessions/:id/folders/:folderId/files/:name/raw', async (req, reply) => {
const file = await resolveFolderFile(req, reply);
if (!file) return;
const { download, preview } = req.query as { download?: string; preview?: string };
await serveServableRaw(reply, file.servable, file.scope.remote, file.name, file.extension, {
download,
preview,
range: req.headers.range,
});
});
app.get('/api/sessions/:id/folders/:folderId/files/:name/preview', async (req, reply) => {
const file = await resolveFolderFile(req, reply);
if (!file) return;
if (file.extension !== 'docx' && file.extension !== 'pptx') {
const { id, folderId } = req.params as { id: string; folderId: string };
reply.redirect(
`/api/sessions/${encodeURIComponent(id)}/folders/${encodeURIComponent(folderId)}/files/${encodeURIComponent(file.name)}/raw`
);
return;
}
if (file.servable.probe) {
reply
.code(400)
.send(
createErrorResponse(
ApiErrorCode.INVALID_INPUT,
'Office document preview is not available for files in a remote (SSH) case'
)
);
return;
}
await serveConvertedPreview(reply, file.servable.path, file.name, file.extension);
});
app.get('/api/sessions/:id/folders/:folderId/files/:name/thumbnail', async (req, reply) => {
const file = await resolveFolderFile(req, reply);
if (!file) return;
if (file.servable.probe) {
reply
.code(400)
.send(
createErrorResponse(
ApiErrorCode.INVALID_INPUT,
'Thumbnails are not available for files in a remote (SSH) case'
)
);
return;
}
await serveThumbnail(reply, file.servable.path, file.extension);
});
// Serve converted document previews for a workspace-relative path. DOCX/PPTX
// are converted to PDF via LibreOffice; PDF/PNG/text preview through file-raw.
app.get('/api/sessions/:id/file-preview', async (req, reply) => {
+3
View File
@@ -75,6 +75,7 @@ import {
import { imageWatcher } from '../image-watcher.js';
import { workflowRunWatcher, summarizeRun } from '../workflow-run-watcher.js';
import { attachmentRegistry, buildFileThumbnailRoute, registerExternalAttachment } from '../attachment-registry.js';
import { folderGrants } from '../folder-listing.js';
import { getCli, enabledClis, listClis } from '../config/cli-registry/registry.js';
import { isCliEntryInstalled, probeStockCliAvailability } from '../utils/cli-installed-probes.js';
import { readCustomModelHosts } from '../custom-model-hosts.js';
@@ -1526,6 +1527,7 @@ export class WebServer extends EventEmitter {
fileStreamManager.closeSessionStreams(sessionId);
// Drop live external attachment registrations for this session
attachmentRegistry.clearSession(sessionId);
folderGrants.clearSession(sessionId);
// Stop watching for images in this session's directory
imageWatcher.unwatchSession(sessionId);
// Clean up pasted images directory for this session. The dir belongs to the
@@ -3423,6 +3425,7 @@ export class WebServer extends EventEmitter {
// otherwise meet the retry, which reuses the same session id by design.
this.stopTranscriptWatcher(sessionId);
attachmentRegistry.clearSession(sessionId);
folderGrants.clearSession(sessionId);
sessionWaits.notifySignal(sessionId, 'exit');
sessionWaits.cancelAll(sessionId);
approvalInbox.resolveForSession(sessionId, 'session_ended');