From 49c92e4723f0b03e2a8086d1c7a13af7ec8392d3 Mon Sep 17 00:00:00 2001 From: Aamer Akhter Date: Fri, 12 Jun 2026 09:09:14 -0400 Subject: [PATCH] COD-38 document attachment previews + thumbnails (attachment cards) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds on the COD-37 registry: surfaces detected/registered attachments as dismissible cards with a first-page thumbnail and an inline preview — the consumer the registry PR deliberately deferred. Backend: - document-thumbnailer: first-page PNG thumbnails (PNG passthrough; PDF via pdftoppm; Office via the preview cache). - document-preview-cache: disk-cached DOCX/PPTX -> PDF conversion (LibreOffice / PowerShell COM), in-flight dedup, multi-converter fallback. - file-routes: serveConvertedPreview / serveThumbnail + four routes — GET .../attachments/:id/preview, .../thumbnail and the workspace-path file-preview / file-thumbnail. Reuses the registry's TOCTOU-safe resolveServableAttachmentPath, so previews stream the freshly-resolved path. - server: enrich detected attachment events with a thumbnail route. - image-watcher: .png now routes to attachment:detected — this PR adds the card consumer, so the screenshot popup is no longer its only handler. Frontend: - panels-ui: attachment cards (addAttachmentCard, lazy stack, Clear-all, per-session cleanup) plus a 3-arg openFilePreview that renders registered attachments inline (image/PDF) or via the server-converted PDF (docx/pptx). - app.js: wire attachment:detected -> _onAttachmentDetected and card state. - styles: attachment-card + stack styling. Verified: tsc / eslint / prettier / frontend-syntax clean; new thumbnailer + preview-cache unit tests pass; full test:ci green (2861 passed); card render + preview overlay + dismiss verified in-browser. --- src/document-preview-cache.ts | 256 ++++++++++++++++++++++++++++ src/document-thumbnailer.ts | 89 ++++++++++ src/image-watcher.ts | 8 +- src/web/public/app.js | 4 + src/web/public/panels-ui.js | 211 ++++++++++++++++++++++- src/web/public/styles.css | 169 ++++++++++++++++++ src/web/routes/file-routes.ts | 171 ++++++++++++++++++- src/web/server.ts | 9 +- test/document-preview-cache.test.ts | 109 ++++++++++++ test/document-thumbnailer.test.ts | 96 +++++++++++ test/image-watcher.test.ts | 11 +- 11 files changed, 1116 insertions(+), 17 deletions(-) create mode 100644 src/document-preview-cache.ts create mode 100644 src/document-thumbnailer.ts create mode 100644 test/document-preview-cache.test.ts create mode 100644 test/document-thumbnailer.test.ts diff --git a/src/document-preview-cache.ts b/src/document-preview-cache.ts new file mode 100644 index 00000000..5bd206ca --- /dev/null +++ b/src/document-preview-cache.ts @@ -0,0 +1,256 @@ +/** + * @fileoverview Shared disk cache for expensive Office document previews. + */ + +import { createHash } from 'node:crypto'; +import { execFile } from 'node:child_process'; +import fs from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { basename, dirname, extname, join } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { promisify } from 'node:util'; + +const execFileAsync = promisify(execFile); + +const OFFICE_CONVERSION_TIMEOUT_MS = 5 * 60_000; +const DOCUMENT_PREVIEW_CACHE_DIR = join(tmpdir(), 'codeman-document-preview-cache'); +function buildWordExportPdfScript(sourcePath: string, outputPath: string): string { + return ` +$ErrorActionPreference = "Stop" +$source = ${toPowerShellSingleQuotedString(sourcePath)} +$output = ${toPowerShellSingleQuotedString(outputPath)} +$word = $null +$doc = $null +try { + $word = New-Object -ComObject Word.Application + $word.Visible = $false + $word.DisplayAlerts = 0 + $doc = $word.Documents.Open($source) + $doc.ExportAsFixedFormat($output, 17) +} finally { + if ($null -ne $doc) { + $doc.Close($false) | Out-Null + [System.Runtime.InteropServices.Marshal]::ReleaseComObject($doc) | Out-Null + } + if ($null -ne $word) { + $word.Quit() | Out-Null + [System.Runtime.InteropServices.Marshal]::ReleaseComObject($word) | Out-Null + } + [System.GC]::Collect() + [System.GC]::WaitForPendingFinalizers() +} +`.trim(); +} + +type OfficePreviewConverter = 'msword' | 'libreoffice'; + +const inFlightOfficeConversions = new Map>(); + +export function clearDocumentPreviewCache(): void { + inFlightOfficeConversions.clear(); +} + +export async function getOfficePreviewPdfPath(filePath: string, extension: string): Promise { + const ext = extension.toLowerCase().replace(/^\./, ''); + if (ext !== 'docx' && ext !== 'pptx') return null; + + let sourceStat; + try { + sourceStat = await fs.stat(filePath); + } catch { + return null; + } + + for (const converter of getOfficePreviewConverters(filePath, ext)) { + const cacheKey = createDocumentPreviewCacheKey(filePath, ext, sourceStat.size, sourceStat.mtimeMs ?? 0, converter); + const cachePath = getOfficePreviewCachePath(filePath, cacheKey, converter); + + if (await fileExists(cachePath)) { + return cachePath; + } + + const inFlightKey = `${converter}:${cacheKey}`; + const inFlight = inFlightOfficeConversions.get(inFlightKey); + if (inFlight) { + const converted = await inFlight; + if (converted) return converted; + continue; + } + + const conversion = + converter === 'msword' + ? convertWordDocumentToCachedPdf(filePath, cachePath) + : convertLibreOfficeDocumentToCachedPdf(filePath, cachePath); + inFlightOfficeConversions.set(inFlightKey, conversion); + try { + const converted = await conversion; + if (converted) return converted; + } finally { + inFlightOfficeConversions.delete(inFlightKey); + } + } + + return null; +} + +function getOfficePreviewConverters(filePath: string, extension: string): OfficePreviewConverter[] { + if (extension === 'docx' && wslMountPathToWindowsPath(filePath)) { + return ['msword', 'libreoffice']; + } + return ['libreoffice']; +} + +function createDocumentPreviewCacheKey( + filePath: string, + extension: string, + size: number, + mtimeMs: number, + converter: OfficePreviewConverter +): string { + return createHash('sha256') + .update(JSON.stringify({ cacheVersion: 2, converter, filePath, extension, size, mtimeMs })) + .digest('hex') + .slice(0, 32); +} + +function getOfficePreviewCachePath(filePath: string, cacheKey: string, converter: OfficePreviewConverter): string { + if (converter === 'msword') { + const windowsCacheDir = getWindowsUserTempCacheDir(filePath); + if (windowsCacheDir) { + return join(windowsCacheDir, `${cacheKey}.pdf`); + } + } + + return join(DOCUMENT_PREVIEW_CACHE_DIR, `${cacheKey}.pdf`); +} + +async function fileExists(filePath: string): Promise { + try { + const stat = await fs.stat(filePath); + return typeof stat.isFile !== 'function' || stat.isFile(); + } catch { + return false; + } +} + +async function convertWordDocumentToCachedPdf(filePath: string, cachePath: string): Promise { + const outputPath = wslMountPathToWindowsPath(cachePath); + if (!outputPath) return null; + + let sourceCopyPath: string | undefined; + + try { + await fs.mkdir(dirname(cachePath), { recursive: true }); + sourceCopyPath = join(dirname(cachePath), `${basename(cachePath, '.pdf')}.docx`); + await fs.copyFile(filePath, sourceCopyPath); + + const sourcePath = wslMountPathToWindowsPath(sourceCopyPath); + if (!sourcePath) return null; + + await execFileAsync( + 'powershell.exe', + [ + '-NoProfile', + '-NonInteractive', + '-ExecutionPolicy', + 'Bypass', + '-EncodedCommand', + encodePowerShellCommand(buildWordExportPdfScript(sourcePath, outputPath)), + ], + { + timeout: OFFICE_CONVERSION_TIMEOUT_MS, + maxBuffer: 1024 * 1024, + } + ); + + if (await fileExists(cachePath)) { + return cachePath; + } + + console.warn(`[DocumentPreviewCache] Microsoft Word did not produce PDF output for ${filePath}`); + return null; + } catch (err) { + console.warn( + `[DocumentPreviewCache] Failed to convert DOCX with Microsoft Word (${filePath}):`, + getCacheErrorMessage(err) + ); + return null; + } finally { + if (sourceCopyPath) { + await fs.rm(sourceCopyPath, { force: true }).catch(() => {}); + } + } +} + +async function convertLibreOfficeDocumentToCachedPdf(filePath: string, cachePath: string): Promise { + let workDir: string | undefined; + try { + await fs.mkdir(DOCUMENT_PREVIEW_CACHE_DIR, { recursive: true }); + workDir = await fs.mkdtemp(join(DOCUMENT_PREVIEW_CACHE_DIR, 'work-')); + const profileDir = join(workDir, 'profile'); + await fs.mkdir(profileDir, { recursive: true }); + + await execFileAsync( + 'soffice', + [ + '--headless', + '--nologo', + '--nofirststartwizard', + `-env:UserInstallation=${pathToFileURL(profileDir).href}`, + '--convert-to', + 'pdf', + '--outdir', + workDir, + filePath, + ], + { + timeout: OFFICE_CONVERSION_TIMEOUT_MS, + maxBuffer: 1024 * 1024, + } + ); + + const converted = (await fs.readdir(workDir)).find((name) => name.toLowerCase().endsWith('.pdf')); + if (!converted) return null; + + await fs.rename(join(workDir, converted), cachePath); + return cachePath; + } catch (err) { + console.warn( + `[DocumentPreviewCache] Failed to convert Office file to PDF (${filePath}):`, + getCacheErrorMessage(err) + ); + return null; + } finally { + if (workDir) { + await fs.rm(workDir, { recursive: true, force: true }).catch(() => {}); + } + } +} + +function wslMountPathToWindowsPath(filePath: string): string | null { + const match = filePath.match(/^\/mnt\/([a-zA-Z])\/(.+)$/); + if (!match) return null; + return `${match[1].toUpperCase()}:\\${match[2].replace(/\//g, '\\')}`; +} + +function getWindowsUserTempCacheDir(filePath: string): string | null { + const match = filePath.match(/^\/mnt\/([a-zA-Z])\/Users\/([^/]+)\//); + if (!match) return null; + return `/mnt/${match[1].toLowerCase()}/Users/${match[2]}/AppData/Local/Temp/codeman-document-preview-cache`; +} + +function toPowerShellSingleQuotedString(value: string): string { + return `'${value.replace(/'/g, "''")}'`; +} + +function encodePowerShellCommand(script: string): string { + return Buffer.from(script, 'utf16le').toString('base64'); +} + +export function getPreviewPdfDownloadName(fileName: string, extension: string): string { + return `${basename(fileName, extname(fileName) || `.${extension}`)}.pdf`; +} + +function getCacheErrorMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} diff --git a/src/document-thumbnailer.ts b/src/document-thumbnailer.ts new file mode 100644 index 00000000..9c84e839 --- /dev/null +++ b/src/document-thumbnailer.ts @@ -0,0 +1,89 @@ +/** + * @fileoverview Best-effort first-page thumbnails for attachment cards. + */ + +import { execFile } from 'node:child_process'; +import fs from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { basename, extname, join } from 'node:path'; +import { promisify } from 'node:util'; +import { getOfficePreviewPdfPath } from './document-preview-cache.js'; + +const execFileAsync = promisify(execFile); +const THUMBNAIL_CONVERSION_TIMEOUT_MS = 5 * 60_000; + +export interface ThumbnailResult { + content: Buffer; + contentType: 'image/png'; +} + +export async function generateFirstPageThumbnail(filePath: string, extension: string): Promise { + const ext = extension.toLowerCase().replace(/^\./, ''); + + try { + await fs.stat(filePath); + + if (ext === 'png') { + return { content: await fs.readFile(filePath), contentType: 'image/png' }; + } + + if (ext === 'pdf') { + return renderPdfFirstPage(filePath); + } + + if (ext === 'docx' || ext === 'pptx') { + return renderOfficeFirstPage(filePath); + } + } catch (err) { + console.warn(`[Thumbnailer] Failed to generate ${ext} thumbnail for ${filePath}:`, getThumbnailErrorMessage(err)); + return null; + } + + return null; +} + +async function renderOfficeFirstPage(filePath: string): Promise { + try { + const previewPdfPath = await getOfficePreviewPdfPath(filePath, extname(filePath).toLowerCase().replace(/^\./, '')); + if (!previewPdfPath) return null; + return await renderPdfFirstPage(previewPdfPath); + } catch (err) { + console.warn( + `[Thumbnailer] Failed to convert Office file to PDF for thumbnail (${filePath}):`, + getThumbnailErrorMessage(err) + ); + return null; + } +} + +async function renderPdfFirstPage(filePath: string): Promise { + let previewDir: string | undefined; + try { + previewDir = await fs.mkdtemp(join(tmpdir(), 'codeman-thumb-pdf-')); + const prefix = join(previewDir, basename(filePath, extname(filePath))); + await execFileAsync( + 'pdftoppm', + ['-png', '-singlefile', '-f', '1', '-l', '1', '-scale-to', '520', filePath, prefix], + { + timeout: THUMBNAIL_CONVERSION_TIMEOUT_MS, + maxBuffer: 1024 * 1024, + } + ); + const content = await fs.readFile(`${prefix}.png`); + return { content, contentType: 'image/png' }; + } catch (err) { + console.warn( + `[Thumbnailer] Failed to render PDF first page for thumbnail (${filePath}):`, + getThumbnailErrorMessage(err) + ); + return null; + } finally { + if (previewDir) { + await fs.rm(previewDir, { recursive: true, force: true }).catch(() => {}); + } + } +} + +function getThumbnailErrorMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} diff --git a/src/image-watcher.ts b/src/image-watcher.ts index 0d1bb667..e0f8c7ae 100644 --- a/src/image-watcher.ts +++ b/src/image-watcher.ts @@ -20,12 +20,8 @@ import { KeyedDebouncer } from './utils/index.js'; // ========== Constants ========== /** Supported image file extensions (lowercase) */ -// PNG stays on the image-popup path: it's the dominant screenshot format and the -// frontend only wires the `image:detected` popup today. The attachment-card UI -// that would consume `attachment:detected` for images is out of scope for this -// PR, so routing PNG to it would silently break the dropped-screenshot popup. -const IMAGE_POPUP_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg']); -const ATTACHMENT_EXTENSIONS = new Set(['.pdf', '.docx', '.pptx']); +const IMAGE_POPUP_EXTENSIONS = new Set(['.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg']); +const ATTACHMENT_EXTENSIONS = new Set(['.png', '.pdf', '.docx', '.pptx']); const DETECTED_FILE_EXTENSIONS = new Set([...IMAGE_POPUP_EXTENSIONS, ...ATTACHMENT_EXTENSIONS]); /** Time to wait for file writes to stabilize (ms) */ diff --git a/src/web/public/app.js b/src/web/public/app.js index 29f76167..73863efc 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -222,6 +222,7 @@ const _SSE_HANDLER_MAP = [ // Images [SSE_EVENTS.IMAGE_DETECTED, '_onImageDetected'], + [SSE_EVENTS.ATTACHMENT_DETECTED, '_onAttachmentDetected'], // Tunnel [SSE_EVENTS.TUNNEL_STARTED, '_onTunnelStarted'], @@ -386,6 +387,8 @@ class CodemanApp { // Image popup windows (auto-open for detected screenshots/images) this.imagePopups = new Map(); // Map this.imagePopupZIndex = ZINDEX_IMAGE_POPUP_BASE; + this.attachmentCards = new Map(); // Map + this.attachmentCardStack = null; // File browser state (methods in panels-ui.js) this.fileBrowserData = null; @@ -3536,6 +3539,7 @@ class CodemanApp { this.clearCountdownTimers(sessionId); this.closeSessionLogViewerWindows(sessionId); this.closeSessionImagePopups(sessionId); + this.closeSessionAttachmentCards(sessionId); this.closeSessionSubagentWindows(sessionId, true); // Clean up idle timer diff --git a/src/web/public/panels-ui.js b/src/web/public/panels-ui.js index 4ad46faa..62c2a0bd 100644 --- a/src/web/public/panels-ui.js +++ b/src/web/public/panels-ui.js @@ -2465,8 +2465,8 @@ Object.assign(CodemanApp.prototype, { this.saveAppSettingsToStorage(settings); }, - async openFilePreview(filePath) { - if (!this.activeSessionId || !filePath) return; + async openFilePreview(filePath, sessionId = this.activeSessionId, attachmentId = null) { + if (!sessionId || !filePath) return; const overlay = this.$('filePreviewOverlay'); const titleEl = this.$('filePreviewTitle'); @@ -2481,8 +2481,36 @@ Object.assign(CodemanApp.prototype, { bodyEl.innerHTML = '
Loading...
'; footerEl.textContent = ''; + const ext = (filePath.split('.').pop() || '').toLowerCase(); + + // 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 = `/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}`; + const IMAGE_EXTS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg']); + footerEl.textContent = ext.toUpperCase(); + if (IMAGE_EXTS.has(ext)) { + bodyEl.innerHTML = `${escapeHtml(filePath)}`; + } else if (ext === 'pdf') { + bodyEl.innerHTML = ``; + } else if (ext === 'docx' || ext === 'pptx') { + bodyEl.innerHTML = ``; + } else { + try { + const res = await fetch(`${base}/raw`); + if (!res.ok) throw new Error('Failed to load attachment'); + const text = await res.text(); + bodyEl.innerHTML = `
${escapeHtml(text)}
`; + } catch (err) { + bodyEl.innerHTML = `
Error: ${escapeHtml(err.message)}
`; + } + } + return; + } + try { - const res = await fetch(`/api/sessions/${this.activeSessionId}/file-content?path=${encodeURIComponent(filePath)}&lines=500`); + const res = await fetch(`/api/sessions/${sessionId}/file-content?path=${encodeURIComponent(filePath)}&lines=500`); if (!res.ok) throw new Error('Failed to load file'); const result = await res.json(); @@ -2520,6 +2548,183 @@ Object.assign(CodemanApp.prototype, { this.filePreviewContent = ''; }, + // ═══════════════════════════════════════════════════════════════ + // Attachment Cards (detected documents/images) + // ═══════════════════════════════════════════════════════════════ + + // SSE `attachment:detected` consumer: surface a dismissible card for the file. + _onAttachmentDetected(data) { + console.log('[Attachment Detected]', data); + this.addAttachmentCard(data); + }, + + // Lazily create the floating stack the cards live in (appended to ). + ensureAttachmentCardStack() { + let stack = this.attachmentCardStack || document.getElementById('attachmentCardStack'); + if (!stack) { + stack = document.createElement('div'); + stack.id = 'attachmentCardStack'; + stack.className = 'attachment-card-stack'; + document.body.appendChild(stack); + } + this.attachmentCardStack = stack; + return stack; + }, + + openAttachmentInNewTab(sessionId, filePath, attachmentId = null) { + const url = attachmentId + ? `/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}/raw` + : `/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}`; + window.open(url, '_blank'); + }, + + addAttachmentCard(attachmentEvent) { + const { + sessionId, + relativePath, + fileName, + timestamp, + size, + attachmentType, + extension, + attachmentId, + rawUrl, + previewUrl, + thumbnailUrl, + } = attachmentEvent; + const filePath = relativePath || fileName; + const cardId = attachmentId || `${sessionId}-${timestamp}-${fileName}`; + + if (this.attachmentCards.has(cardId)) { + const existing = this.attachmentCards.get(cardId); + existing.element.focus?.(); + return; + } + + const MAX_ATTACHMENT_CARDS = 10; + if (this.attachmentCards.size >= MAX_ATTACHMENT_CARDS) { + const oldestId = this.attachmentCards.keys().next().value; + if (oldestId) this.closeAttachmentCard(oldestId); + } + + const stack = this.ensureAttachmentCardStack(); + const session = this.sessions.get(sessionId); + const sessionName = session?.name || sessionId.substring(0, 8); + const attachmentRawUrl = + rawUrl || + (attachmentId + ? `/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}/raw` + : `/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}`); + const attachmentPreviewUrl = + previewUrl || + (attachmentId ? `/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}/preview` : null); + const attachmentThumbnailUrl = + thumbnailUrl || + (attachmentId + ? `/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}/thumbnail` + : `/api/sessions/${sessionId}/file-thumbnail?path=${encodeURIComponent(filePath)}`); + const downloadUrl = attachmentId ? `${attachmentRawUrl}?download=true` : `${attachmentRawUrl}&download=true`; + const typeLabel = (extension || attachmentType || 'file').toUpperCase(); + + const card = document.createElement('article'); + card.className = `attachment-card attachment-${escapeHtml(attachmentType || 'file')}`; + card.tabIndex = 0; + card.dataset.attachmentId = cardId; + card.dataset.previewUrl = attachmentPreviewUrl || ''; + card.innerHTML = ` +
+ ${attachmentThumbnailUrl ? `` : ''} +
${escapeHtml(typeLabel)}
+
+
+
${escapeHtml(fileName)}
+
+ ${escapeHtml(sessionName)} + ${this.formatFileSize(size || 0)} +
+
+ + Download + +
+
+ + `; + + const attachmentThumbnailImg = card.querySelector('.attachment-thumbnail-img'); + if (attachmentThumbnailImg) { + attachmentThumbnailImg.onerror = () => { + attachmentThumbnailImg.remove(); + card.querySelector('.attachment-thumbnail-fallback')?.classList.add('visible'); + }; + } + + card.querySelector('.attachment-preview-btn')?.addEventListener('click', () => { + this.openFilePreview(filePath, sessionId, attachmentId || null); + }); + card.querySelector('.attachment-open-btn')?.addEventListener('click', () => { + this.openAttachmentInNewTab(sessionId, filePath, attachmentId || null); + }); + card.querySelector('.attachment-close-btn')?.addEventListener('click', () => { + this.closeAttachmentCard(cardId); + }); + + stack.prepend(card); + this.attachmentCards.set(cardId, { element: card, sessionId, filePath }); + this._refreshAttachmentClearAll(); + }, + + // Centralized show/hide for the stack's "Clear all" control. Both addAttachmentCard and + // closeAttachmentCard call this so the control appears on the 2nd card and hides at <=1. + _refreshAttachmentClearAll() { + const stack = this.attachmentCardStack; + if (!stack) return; + let control = stack.querySelector('.attachment-clear-all'); + if (this.attachmentCards.size < 2) { + if (control) control.hidden = true; + return; + } + if (!control) { + control = document.createElement('button'); + control.type = 'button'; + control.className = 'attachment-clear-all'; + control.textContent = 'Clear all'; + control.title = 'Dismiss all attachment cards'; + control.addEventListener('click', () => this.closeAllAttachmentCards()); + stack.prepend(control); + } + control.hidden = false; + }, + + closeAttachmentCard(attachmentId) { + const cardData = this.attachmentCards.get(attachmentId); + if (!cardData) return; + cardData.element.remove(); + this.attachmentCards.delete(attachmentId); + if (this.attachmentCardStack && this.attachmentCards.size === 0) { + this.attachmentCardStack.remove(); + this.attachmentCardStack = null; + } else { + this._refreshAttachmentClearAll(); + } + }, + + closeAllAttachmentCards() { + for (const attachmentId of [...this.attachmentCards.keys()]) { + this.closeAttachmentCard(attachmentId); + } + }, + + closeSessionAttachmentCards(sessionId) { + const toClose = []; + for (const [attachmentId, data] of this.attachmentCards) { + if (data.sessionId === sessionId) toClose.push(attachmentId); + } + for (const attachmentId of toClose) { + this.closeAttachmentCard(attachmentId); + } + }, + copyFilePreviewContent() { if (this.filePreviewContent) { navigator.clipboard.writeText(this.filePreviewContent).then(() => { diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 2b5a0e91..724d7085 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -9057,3 +9057,172 @@ body.touch-device.cjk-input-visible .main { pointer-events: none; z-index: 100; } + +.attachment-card-stack { + position: fixed; + right: 18px; + bottom: calc(var(--toolbar-height) + 18px); + z-index: 1900; + display: flex; + flex-direction: column; + gap: 8px; + width: min(540px, calc(100vw - 32px)); + pointer-events: none; +} + +.attachment-clear-all { + align-self: flex-end; + padding: 3px 10px; + border: 1px solid var(--border-light); + border-radius: 5px; + background: rgba(19, 19, 22, 0.96); + color: var(--text-dim); + font-size: 0.68rem; + font-weight: 600; + cursor: pointer; + box-shadow: 0 8px 28px rgba(0, 0, 0, 0.35); + pointer-events: auto; +} + +.attachment-clear-all:hover { + border-color: var(--accent); + color: var(--accent-hover); +} + +.attachment-clear-all[hidden] { + display: none; +} + +.attachment-card { + display: grid; + grid-template-columns: 96px minmax(0, 1fr) 24px; + gap: 10px; + align-items: center; + padding: 10px; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 8px; + background: rgba(19, 19, 22, 0.96); + box-shadow: 0 8px 28px rgba(0, 0, 0, 0.35); + pointer-events: auto; +} + +.attachment-thumbnail { + position: relative; + display: flex; + align-items: center; + justify-content: center; + width: 96px; + height: auto; + aspect-ratio: 16 / 9; + overflow: hidden; + border-radius: 6px; + background: #f8f8fb; + border: 1px solid var(--border-light); +} + +.attachment-thumbnail-img { + width: 100%; + height: 100%; + object-fit: contain; + object-position: center; + display: block; +} + +.attachment-thumbnail-fallback { + display: none; + align-items: center; + justify-content: center; + position: absolute; + inset: 0; + color: var(--text); + font-size: 0.7rem; + font-weight: 700; + background: #20202a; +} + +.attachment-thumbnail-fallback.visible { + display: flex; +} + +.attachment-type-badge { + display: flex; + align-items: center; + justify-content: center; + width: 44px; + height: 44px; + border-radius: 6px; + background: #20202a; + color: var(--text); + border: 1px solid var(--border-light); + font-size: 0.7rem; + font-weight: 700; +} + +.attachment-card-main { + min-width: 0; +} + +.attachment-file-name { + color: var(--text); + font-size: 0.82rem; + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.attachment-file-meta { + display: flex; + gap: 8px; + margin-top: 2px; + color: var(--text-dim); + font-size: 0.68rem; +} + +.attachment-actions { + display: flex; + align-items: center; + gap: 6px; + margin-top: 7px; +} + +.attachment-actions button, +.attachment-actions a { + padding: 3px 8px; + border: 1px solid var(--border-light); + border-radius: 5px; + background: var(--bg-input); + color: var(--text); + text-decoration: none; + font-size: 0.68rem; + cursor: pointer; +} + +.attachment-actions button:hover, +.attachment-actions a:hover { + border-color: var(--accent); + color: var(--accent-hover); +} + +.attachment-close-btn { + align-self: start; + width: 22px; + height: 22px; + border: none; + background: transparent; + color: var(--text-muted); + font-size: 1rem; + cursor: pointer; +} + +.attachment-close-btn:hover { + color: var(--text); +} + +/* PDF/Office previews render in an iframe inside the preview body. */ +.file-preview-body iframe { + width: 100%; + height: 100%; + border: 0; + background: #fff; +} diff --git a/src/web/routes/file-routes.ts b/src/web/routes/file-routes.ts index 149e28f7..3aeb46ae 100644 --- a/src/web/routes/file-routes.ts +++ b/src/web/routes/file-routes.ts @@ -12,14 +12,17 @@ import { fileStreamManager } from '../../file-stream-manager.js'; import { AttachmentRegistrationError, attachmentRegistry, + isSupportedAttachmentExtension, registerExternalAttachment, type AttachmentRecord, } from '../../attachment-registry.js'; +import { generateFirstPageThumbnail } from '../../document-thumbnailer.js'; +import { getOfficePreviewPdfPath, getPreviewPdfDownloadName } from '../../document-preview-cache.js'; import { isBlockedAttachmentPath, loadAttachmentGuardConfig } from '../../config/attachment-guard.js'; import { findSessionOrFail, validateSessionFilePath } from '../route-helpers.js'; import { isSensitivePath } from '../sensitive-path.js'; import { SseEvent } from '../sse-events.js'; -import type { EventPort, SessionPort } from '../ports/index.js'; +import type { ConfigPort, EventPort, SessionPort } from '../ports/index.js'; const MIME_TYPES: Record = { png: 'image/png', @@ -159,7 +162,80 @@ async function resolveServableAttachmentPath( return resolved ? pathToCheck : record.filePath; } -export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & EventPort): void { +/** + * 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 + * redirect those to the raw route instead. + */ +async function serveConvertedPreview( + reply: FastifyReply, + resolvedPath: string, + fileName: string, + extension: string +): Promise { + if (extension !== 'docx' && extension !== 'pptx') { + reply + .code(400) + .send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Preview is not supported for this file type')); + return; + } + + try { + const previewPath = await getOfficePreviewPdfPath(resolvedPath, extension); + if (!previewPath) { + reply.code(500).send(createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Document preview conversion failed')); + return; + } + + const content = await fs.readFile(previewPath); + reply.header('Content-Type', 'application/pdf'); + reply.header('Content-Disposition', `inline; filename="${getPreviewPdfDownloadName(fileName, extension)}"`); + reply.header('Cache-Control', 'no-cache'); + reply.header('Content-Length', content.length); + reply.header('X-Content-Type-Options', 'nosniff'); + reply.send(content); + } catch (err) { + reply + .code(500) + .send(createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to generate preview: ${getErrorMessage(err)}`)); + } +} + +/** Generate and stream a first-page thumbnail (PNG) for a supported attachment. */ +async function serveThumbnail(reply: FastifyReply, resolvedPath: string, extension: string): Promise { + const thumbnail = await generateFirstPageThumbnail(resolvedPath, extension); + if (!thumbnail) { + reply.code(204).send(); + return; + } + + reply.header('Content-Type', thumbnail.contentType); + reply.header('Cache-Control', 'no-cache'); + reply.header('X-Content-Type-Options', 'nosniff'); + reply.send(thumbnail.content); +} + +/** + * Resolve a session's working dir from the live session, falling back to the + * persisted record so preview/thumbnail requests keep working for a session + * that has since detached. Sends a 404 and returns undefined when unknown. + */ +function getKnownSessionWorkingDir( + ctx: SessionPort & ConfigPort, + sessionId: string, + reply: FastifyReply +): string | undefined { + const liveSession = ctx.sessions.get(sessionId); + if (liveSession) return liveSession.workingDir; + + const stored = ctx.store.getSession(sessionId); + if (stored) return stored.workingDir; + + reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, `Session ${sessionId} not found`)); + return undefined; +} + +export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & EventPort & ConfigPort): void { // File tree listing app.get('/api/sessions/:id/files', async (req) => { const { id } = req.params as { id: string }; @@ -515,6 +591,97 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even } }); + // Serve a converted PDF preview of a registered attachment by id. Office docs + // convert server-side; PDF/PNG/text redirect to the raw route. + app.get('/api/sessions/:id/attachments/:attachmentId/preview', async (req, reply) => { + const { id, attachmentId } = req.params as { id: string; attachmentId: string }; + const workingDir = getKnownSessionWorkingDir(ctx, id, reply); + if (!workingDir) return; + const record = getAttachmentOr404(reply, id, attachmentId); + if (!record) return; + const servePath = await resolveServableAttachmentPath(reply, record, workingDir); + if (!servePath) return; + + // Only Office formats need server-side conversion; PDF/PNG and text formats + // (md/txt) preview directly from their raw bytes. + if (record.extension !== 'docx' && record.extension !== 'pptx') { + reply.redirect(`/api/sessions/${id}/attachments/${encodeURIComponent(attachmentId)}/raw`); + return; + } + + await serveConvertedPreview(reply, servePath, record.fileName, record.extension); + }); + + // Serve a first-page thumbnail of a registered attachment by id. + app.get('/api/sessions/:id/attachments/:attachmentId/thumbnail', async (req, reply) => { + const { id, attachmentId } = req.params as { id: string; attachmentId: string }; + const workingDir = getKnownSessionWorkingDir(ctx, id, reply); + if (!workingDir) return; + const record = getAttachmentOr404(reply, id, attachmentId); + if (!record) return; + const servePath = await resolveServableAttachmentPath(reply, record, workingDir); + if (!servePath) return; + await serveThumbnail(reply, servePath, record.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) => { + const { id } = req.params as { id: string }; + const { path: filePath } = req.query as { path?: string }; + const workingDir = getKnownSessionWorkingDir(ctx, id, reply); + if (!workingDir) return; + + if (!filePath) { + reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing path parameter')); + return; + } + + const validated = validateSessionFilePath(workingDir, filePath); + if (!validated) { + reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found')); + return; + } + const { resolvedPath } = validated; + const ext = filePath.split('.').pop()?.toLowerCase() || ''; + + if (ext !== 'docx' && ext !== 'pptx') { + reply.redirect(`/api/sessions/${id}/file-raw?path=${encodeURIComponent(filePath)}`); + return; + } + + await serveConvertedPreview(reply, resolvedPath, filePath, ext); + }); + + // Serve a first-page thumbnail for a workspace-relative path. + app.get('/api/sessions/:id/file-thumbnail', async (req, reply) => { + const { id } = req.params as { id: string }; + const { path: filePath } = req.query as { path?: string }; + const workingDir = getKnownSessionWorkingDir(ctx, id, reply); + if (!workingDir) return; + + if (!filePath) { + reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing path parameter')); + return; + } + + const validated = validateSessionFilePath(workingDir, filePath); + if (!validated) { + reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found')); + return; + } + + const ext = filePath.split('.').pop()?.toLowerCase() || ''; + if (!isSupportedAttachmentExtension(ext)) { + reply + .code(400) + .send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Thumbnail is not supported for this file type')); + return; + } + + await serveThumbnail(reply, validated.resolvedPath, ext); + }); + // Stream file content via tail -f (SSE endpoint) app.get('/api/sessions/:id/tail-file', async (req, reply) => { const { id } = req.params as { id: string }; diff --git a/src/web/server.ts b/src/web/server.ts index b5678534..306d495a 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -60,7 +60,7 @@ import { type SubagentToolResult, } from '../subagent-watcher.js'; import { imageWatcher } from '../image-watcher.js'; -import { attachmentRegistry, registerExternalAttachment } from '../attachment-registry.js'; +import { attachmentRegistry, buildFileThumbnailRoute, registerExternalAttachment } from '../attachment-registry.js'; import { TranscriptWatcher } from '../transcript-watcher.js'; import { TeamWatcher } from '../team-watcher.js'; import { TunnelManager } from '../tunnel-manager.js'; @@ -440,7 +440,12 @@ export class WebServer extends EventEmitter { this.imageWatcherHandlers = { detected: (event: ImageDetectedEvent) => this.broadcast(SseEvent.ImageDetected, event), attachmentDetected: (event: AttachmentDetectedEvent) => - this.broadcast(SseEvent.AttachmentDetected, { ...event, source: event.source || 'detected' }), + this.broadcast(SseEvent.AttachmentDetected, { + ...event, + source: event.source || 'detected', + thumbnailUrl: + event.thumbnailUrl || buildFileThumbnailRoute(event.sessionId, event.relativePath || event.fileName), + }), error: (error: Error, sessionId?: string) => { console.error(`[ImageWatcher] Error${sessionId ? ` for ${sessionId}` : ''}:`, error.message); }, diff --git a/test/document-preview-cache.test.ts b/test/document-preview-cache.test.ts new file mode 100644 index 00000000..49c86d48 --- /dev/null +++ b/test/document-preview-cache.test.ts @@ -0,0 +1,109 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { execFile } from 'node:child_process'; +import fs from 'node:fs/promises'; +import { clearDocumentPreviewCache, getOfficePreviewPdfPath } from '../src/document-preview-cache.js'; + +vi.mock('node:child_process', () => ({ + execFile: vi.fn((_cmd, _args, _options, callback) => { + setTimeout(() => callback(null, { stdout: '', stderr: '' }), 1); + return {}; + }), +})); + +vi.mock('node:fs/promises', () => ({ + default: { + stat: vi.fn(), + mkdir: vi.fn(async () => undefined), + copyFile: vi.fn(async () => undefined), + mkdtemp: vi.fn(async () => '/tmp/codeman-document-preview-cache/work-test'), + readdir: vi.fn(async () => ['deck.pdf']), + rename: vi.fn(async () => undefined), + rm: vi.fn(async () => undefined), + }, +})); + +const mockedExecFile = vi.mocked(execFile); +const mockedStat = vi.mocked(fs.stat); +const mockedRename = vi.mocked(fs.rename); + +describe('document-preview-cache', () => { + beforeEach(() => { + vi.clearAllMocks(); + clearDocumentPreviewCache(); + + let cachedPdfExists = false; + mockedStat.mockImplementation(async (path) => { + const pathText = String(path); + if (pathText.includes('codeman-document-preview-cache') && pathText.endsWith('.pdf')) { + if (!cachedPdfExists) throw new Error('ENOENT'); + return { size: 4096, isFile: () => true } as never; + } + + return { size: 1845494, mtimeMs: 12345, isFile: () => true } as never; + }); + mockedRename.mockImplementation(async () => { + cachedPdfExists = true; + }); + mockedExecFile.mockImplementation((cmd, _args, _options, callback) => { + if (cmd === 'powershell.exe') { + cachedPdfExists = true; + } + + setTimeout(() => callback(null, { stdout: '', stderr: '' }), 1); + return {} as never; + }); + }); + + it('deduplicates concurrent Office preview conversions and reuses the cached PDF', async () => { + const [firstPath, secondPath] = await Promise.all([ + getOfficePreviewPdfPath('/tmp/deck.pptx', 'pptx'), + getOfficePreviewPdfPath('/tmp/deck.pptx', 'pptx'), + ]); + const thirdPath = await getOfficePreviewPdfPath('/tmp/deck.pptx', 'pptx'); + + expect(firstPath).toBeTruthy(); + expect(secondPath).toBe(firstPath); + expect(thirdPath).toBe(firstPath); + expect(mockedExecFile).toHaveBeenCalledTimes(1); + expect(mockedExecFile).toHaveBeenCalledWith( + 'soffice', + expect.arrayContaining([ + '--headless', + '--convert-to', + 'pdf', + expect.stringMatching(/^-env:UserInstallation=file:/), + ]), + expect.any(Object), + expect.any(Function) + ); + }); + + it('prefers Microsoft Word for DOCX files on Windows-mounted paths', async () => { + const result = await getOfficePreviewPdfPath( + '/mnt/c/Users/aakhter/Documents/codeman-inline-viewer-test.docx', + 'docx' + ); + + expect(result).toContain('/mnt/c/Users/aakhter/AppData/Local/Temp/codeman-document-preview-cache/'); + expect(mockedExecFile).toHaveBeenCalledWith( + 'powershell.exe', + expect.arrayContaining(['-NoProfile', '-NonInteractive', '-EncodedCommand']), + expect.any(Object), + expect.any(Function) + ); + const powerShellCall = mockedExecFile.mock.calls.find(([cmd]) => cmd === 'powershell.exe'); + const encodedCommand = powerShellCall?.[1]?.at(-1); + const decodedCommand = Buffer.from(String(encodedCommand), 'base64').toString('utf16le'); + expect(decodedCommand).toContain('$source = '); + expect(decodedCommand).toContain('C:\\Users\\aakhter\\AppData\\Local\\Temp\\codeman-document-preview-cache\\'); + expect(decodedCommand).toContain('.docx'); + expect(decodedCommand).toContain('$output = '); + expect(decodedCommand).toContain('.pdf'); + expect(mockedExecFile).not.toHaveBeenCalledWith( + 'soffice', + expect.any(Array), + expect.any(Object), + expect.any(Function) + ); + }); +}); diff --git a/test/document-thumbnailer.test.ts b/test/document-thumbnailer.test.ts new file mode 100644 index 00000000..1652afaf --- /dev/null +++ b/test/document-thumbnailer.test.ts @@ -0,0 +1,96 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { execFile } from 'node:child_process'; +import fs from 'node:fs/promises'; +import { generateFirstPageThumbnail } from '../src/document-thumbnailer.js'; +import { clearDocumentPreviewCache } from '../src/document-preview-cache.js'; + +vi.mock('node:child_process', () => ({ + execFile: vi.fn((_cmd, _args, _options, callback) => { + callback(null, { stdout: '', stderr: '' }); + return {}; + }), +})); + +vi.mock('node:fs/promises', () => ({ + default: { + stat: vi.fn(async () => ({ size: 100, isFile: () => true })), + readFile: vi.fn(async () => Buffer.from('png')), + readdir: vi.fn(async () => ['converted.pdf']), + mkdir: vi.fn(async () => undefined), + mkdtemp: vi.fn(async () => '/tmp/codeman-thumb-test'), + rename: vi.fn(async () => undefined), + rm: vi.fn(async () => undefined), + }, +})); + +const mockedExecFile = vi.mocked(execFile); +const mockedStat = vi.mocked(fs.stat); +const mockedReadFile = vi.mocked(fs.readFile); +const mockedReaddir = vi.mocked(fs.readdir); +const mockedMkdir = vi.mocked(fs.mkdir); +const mockedMkdtemp = vi.mocked(fs.mkdtemp); +const mockedRename = vi.mocked(fs.rename); +const mockedRm = vi.mocked(fs.rm); + +describe('document-thumbnailer', () => { + beforeEach(() => { + vi.clearAllMocks(); + clearDocumentPreviewCache(); + mockedStat.mockImplementation(async (path) => { + const pathText = String(path); + if (pathText.includes('codeman-document-preview-cache') && pathText.endsWith('.pdf')) { + throw new Error('ENOENT'); + } + + return { size: 500 * 1024 * 1024, mtimeMs: 12345, isFile: () => true } as never; + }); + mockedReadFile.mockResolvedValue(Buffer.from('large thumbnail') as never); + mockedReaddir.mockResolvedValue(['converted.pdf'] as never); + mockedMkdir.mockResolvedValue(undefined as never); + mockedMkdtemp.mockResolvedValue('/tmp/codeman-thumb-test' as never); + mockedRename.mockResolvedValue(undefined as never); + mockedRm.mockResolvedValue(undefined as never); + mockedExecFile.mockImplementation((_cmd, _args, _options, callback) => { + callback(null, { stdout: '', stderr: '' }); + return {} as never; + }); + }); + + it('renders first-page thumbnails for large documents without an app-level size cap', async () => { + const result = await generateFirstPageThumbnail('/tmp/large-deck.pdf', 'pdf'); + + expect(result).toEqual({ + content: Buffer.from('large thumbnail'), + contentType: 'image/png', + }); + expect(mockedExecFile).toHaveBeenCalledWith( + 'pdftoppm', + expect.arrayContaining(['-png', '-singlefile', '-f', '1', '-l', '1']), + expect.any(Object), + expect.any(Function) + ); + }); + + it('renders Office thumbnails from the cached converted PDF after conversion cleanup', async () => { + mockedMkdtemp.mockImplementation(async (prefix) => + String(prefix).includes('codeman-document-preview-cache') + ? '/tmp/codeman-document-preview-cache/work-test' + : '/tmp/codeman-thumb-pdf-test' + ); + mockedExecFile.mockImplementation((_cmd, _args, _options, callback) => { + callback(null, { stdout: '', stderr: '' }); + return {} as never; + }); + + const result = await generateFirstPageThumbnail('/tmp/deck.pptx', 'pptx'); + + expect(result).toEqual({ + content: Buffer.from('large thumbnail'), + contentType: 'image/png', + }); + const pdftoppmCall = mockedExecFile.mock.calls.find(([cmd]) => cmd === 'pdftoppm'); + expect(pdftoppmCall?.[1]).toEqual( + expect.arrayContaining([expect.stringContaining('codeman-document-preview-cache')]) + ); + }); +}); diff --git a/test/image-watcher.test.ts b/test/image-watcher.test.ts index 716f509e..c7e97955 100644 --- a/test/image-watcher.test.ts +++ b/test/image-watcher.test.ts @@ -145,9 +145,9 @@ describe('ImageWatcher', () => { // ========== Image Detection ========== describe('image detection', () => { - it('should emit image:detected (popup) for .png files', () => { + it('should emit attachment:detected for .png files', () => { const handler = vi.fn(); - watcher.on('image:detected', handler); + watcher.on('attachment:detected', handler); watcher.watchSession('session-1', '/home/user/project'); const chokidarWatcher = mockWatchers.get('/home/user/project')!; @@ -161,11 +161,14 @@ describe('ImageWatcher', () => { expect(event.fileName).toBe('screenshot.png'); expect(event.filePath).toBe('/home/user/project/screenshot.png'); expect(event.relativePath).toBe('screenshot.png'); + expect(event.extension).toBe('png'); + expect(event.attachmentType).toBe('image'); + expect(event.size).toBe(2048); }); - it('should not emit attachment:detected for .png (stays on the popup path)', () => { + it('should not emit legacy image:detected for .png attachment cards', () => { const handler = vi.fn(); - watcher.on('attachment:detected', handler); + watcher.on('image:detected', handler); watcher.watchSession('session-1', '/home/user/project'); mockWatchers.get('/home/user/project')!.emit('add', '/home/user/project/screenshot.png');