mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-06 07:29:42 +02:00
Merge PR #120: document attachment previews + thumbnails (COD-38)
Adds attachment cards with first-page thumbnails and inline document previews (PDF/Office via pdftoppm + LibreOffice), plus review hardening (converter concurrency limiter, bounded preview cache, fixed detected-doc preview routing). See PR #120. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> # Conflicts: # src/web/public/styles.css
This commit is contained in:
@@ -0,0 +1,67 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Global concurrency limiter for spawning external document
|
||||||
|
* converters (pdftoppm / LibreOffice `soffice` / Word-COM `powershell.exe`).
|
||||||
|
*
|
||||||
|
* Without a cap, N simultaneous thumbnail/preview requests for *distinct*
|
||||||
|
* documents fork N converter processes at once — each held open for up to the
|
||||||
|
* multi-minute conversion timeout. That is a localhost resource-exhaustion
|
||||||
|
* (fork-bomb-shaped) vector: a handful of large PDFs detected at once can pin
|
||||||
|
* CPU and RAM. This module serializes converter spawns down to a small fixed
|
||||||
|
* pool; excess spawns queue (FIFO) until a slot frees. The in-flight cache in
|
||||||
|
* `document-preview-cache.ts` already de-dups *identical* inputs; this bounds
|
||||||
|
* the *distinct* case the cache can't.
|
||||||
|
*
|
||||||
|
* Permit accounting transfers the slot directly to the next waiter on release
|
||||||
|
* (rather than decrement-then-reacquire) so the active count can never exceed
|
||||||
|
* the cap even under interleaved async resumption.
|
||||||
|
*
|
||||||
|
* NOT re-entrant: never call `runWithConversionLimit` from inside a task that is
|
||||||
|
* already holding a slot — a nested acquire under a full pool would deadlock.
|
||||||
|
* The converter call sites only ever acquire once per request (the office path
|
||||||
|
* acquires for `soffice` and `pdftoppm` sequentially, not nested).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Max converter processes allowed to run concurrently across the whole process.
|
||||||
|
* Override with CODEMAN_MAX_DOCUMENT_CONVERSIONS (clamped to >= 1).
|
||||||
|
*/
|
||||||
|
const MAX_CONCURRENT_DOCUMENT_CONVERSIONS = (() => {
|
||||||
|
const raw = Number(process.env.CODEMAN_MAX_DOCUMENT_CONVERSIONS);
|
||||||
|
return Number.isFinite(raw) && raw >= 1 ? Math.floor(raw) : 3;
|
||||||
|
})();
|
||||||
|
|
||||||
|
let active = 0;
|
||||||
|
const waiters: Array<() => void> = [];
|
||||||
|
|
||||||
|
/** Test/diagnostic hook: converters currently holding a slot. */
|
||||||
|
export function getActiveConversionCount(): number {
|
||||||
|
return active;
|
||||||
|
}
|
||||||
|
|
||||||
|
function acquire(): Promise<void> {
|
||||||
|
if (active < MAX_CONCURRENT_DOCUMENT_CONVERSIONS) {
|
||||||
|
active++;
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
return new Promise<void>((resolve) => waiters.push(resolve));
|
||||||
|
}
|
||||||
|
|
||||||
|
function release(): void {
|
||||||
|
const next = waiters.shift();
|
||||||
|
if (next) {
|
||||||
|
// Hand the slot straight to the next waiter — `active` stays at the cap.
|
||||||
|
next();
|
||||||
|
} else {
|
||||||
|
active--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Run `task` once a converter slot is free, releasing the slot afterward. */
|
||||||
|
export async function runWithConversionLimit<T>(task: () => Promise<T>): Promise<T> {
|
||||||
|
await acquire();
|
||||||
|
try {
|
||||||
|
return await task();
|
||||||
|
} finally {
|
||||||
|
release();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,308 @@
|
|||||||
|
/**
|
||||||
|
* @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';
|
||||||
|
import { runWithConversionLimit } from './document-conversion-limiter.js';
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
|
const OFFICE_CONVERSION_TIMEOUT_MS = 5 * 60_000;
|
||||||
|
const DOCUMENT_PREVIEW_CACHE_DIR = join(tmpdir(), 'codeman-document-preview-cache');
|
||||||
|
/**
|
||||||
|
* Cap on persistent converted-PDF files kept in DOCUMENT_PREVIEW_CACHE_DIR.
|
||||||
|
* The cache key embeds the source mtime, so every edit to a doc orphans its
|
||||||
|
* prior PDF; without a cap the dir grows unbounded across long-running sessions.
|
||||||
|
* Override with CODEMAN_MAX_PREVIEW_CACHE_FILES (clamped to >= 1).
|
||||||
|
*/
|
||||||
|
const MAX_PREVIEW_CACHE_FILES = (() => {
|
||||||
|
const raw = Number(process.env.CODEMAN_MAX_PREVIEW_CACHE_FILES);
|
||||||
|
return Number.isFinite(raw) && raw >= 1 ? Math.floor(raw) : 100;
|
||||||
|
})();
|
||||||
|
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<string, Promise<string | null>>();
|
||||||
|
|
||||||
|
export function clearDocumentPreviewCache(): void {
|
||||||
|
inFlightOfficeConversions.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Best-effort LRU-ish eviction for the persistent converted-PDF cache: keeps at
|
||||||
|
* most MAX_PREVIEW_CACHE_FILES `*.pdf` files in `cacheDir`, deleting the oldest
|
||||||
|
* by mtime once over the cap. Never throws — a pruning failure must not fail the
|
||||||
|
* conversion that triggered it. Only `*.pdf` files are considered, so the
|
||||||
|
* transient `work-*` mkdtemp dirs are ignored.
|
||||||
|
*/
|
||||||
|
export async function pruneDocumentPreviewCache(cacheDir: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const entries = await fs.readdir(cacheDir);
|
||||||
|
const pdfs = entries.filter((name) => name.toLowerCase().endsWith('.pdf'));
|
||||||
|
if (pdfs.length <= MAX_PREVIEW_CACHE_FILES) return;
|
||||||
|
|
||||||
|
const stats = await Promise.all(
|
||||||
|
pdfs.map(async (name) => {
|
||||||
|
const fullPath = join(cacheDir, name);
|
||||||
|
try {
|
||||||
|
const stat = await fs.stat(fullPath);
|
||||||
|
return { fullPath, mtimeMs: stat.mtimeMs ?? 0 };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const sorted = stats.filter((s): s is { fullPath: string; mtimeMs: number } => s !== null);
|
||||||
|
sorted.sort((a, b) => a.mtimeMs - b.mtimeMs); // oldest first
|
||||||
|
const toRemove = sorted.slice(0, Math.max(0, sorted.length - MAX_PREVIEW_CACHE_FILES));
|
||||||
|
await Promise.all(toRemove.map((entry) => fs.rm(entry.fullPath, { force: true }).catch(() => {})));
|
||||||
|
} catch {
|
||||||
|
// Best-effort: pruning must never break a conversion.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getOfficePreviewPdfPath(filePath: string, extension: string): Promise<string | null> {
|
||||||
|
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<boolean> {
|
||||||
|
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<string | null> {
|
||||||
|
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 runWithConversionLimit(() =>
|
||||||
|
execFileAsync(
|
||||||
|
'powershell.exe',
|
||||||
|
[
|
||||||
|
'-NoProfile',
|
||||||
|
'-NonInteractive',
|
||||||
|
'-ExecutionPolicy',
|
||||||
|
'Bypass',
|
||||||
|
'-EncodedCommand',
|
||||||
|
encodePowerShellCommand(buildWordExportPdfScript(sourcePath, outputPath)),
|
||||||
|
],
|
||||||
|
{
|
||||||
|
timeout: OFFICE_CONVERSION_TIMEOUT_MS,
|
||||||
|
maxBuffer: 1024 * 1024,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (await fileExists(cachePath)) {
|
||||||
|
await pruneDocumentPreviewCache(dirname(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<string | null> {
|
||||||
|
let workDir: string | undefined;
|
||||||
|
try {
|
||||||
|
await fs.mkdir(DOCUMENT_PREVIEW_CACHE_DIR, { recursive: true });
|
||||||
|
const outDir = await fs.mkdtemp(join(DOCUMENT_PREVIEW_CACHE_DIR, 'work-'));
|
||||||
|
workDir = outDir;
|
||||||
|
const profileDir = join(outDir, 'profile');
|
||||||
|
await fs.mkdir(profileDir, { recursive: true });
|
||||||
|
|
||||||
|
await runWithConversionLimit(() =>
|
||||||
|
execFileAsync(
|
||||||
|
'soffice',
|
||||||
|
[
|
||||||
|
'--headless',
|
||||||
|
'--nologo',
|
||||||
|
'--nofirststartwizard',
|
||||||
|
`-env:UserInstallation=${pathToFileURL(profileDir).href}`,
|
||||||
|
'--convert-to',
|
||||||
|
'pdf',
|
||||||
|
'--outdir',
|
||||||
|
outDir,
|
||||||
|
filePath,
|
||||||
|
],
|
||||||
|
{
|
||||||
|
timeout: OFFICE_CONVERSION_TIMEOUT_MS,
|
||||||
|
maxBuffer: 1024 * 1024,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const converted = (await fs.readdir(outDir)).find((name) => name.toLowerCase().endsWith('.pdf'));
|
||||||
|
if (!converted) return null;
|
||||||
|
|
||||||
|
await fs.rename(join(workDir, converted), cachePath);
|
||||||
|
await pruneDocumentPreviewCache(DOCUMENT_PREVIEW_CACHE_DIR);
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
/**
|
||||||
|
* @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';
|
||||||
|
import { runWithConversionLimit } from './document-conversion-limiter.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<ThumbnailResult | null> {
|
||||||
|
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<ThumbnailResult | null> {
|
||||||
|
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<ThumbnailResult | null> {
|
||||||
|
let previewDir: string | undefined;
|
||||||
|
try {
|
||||||
|
previewDir = await fs.mkdtemp(join(tmpdir(), 'codeman-thumb-pdf-'));
|
||||||
|
const prefix = join(previewDir, basename(filePath, extname(filePath)));
|
||||||
|
await runWithConversionLimit(() =>
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -20,12 +20,8 @@ import { KeyedDebouncer } from './utils/index.js';
|
|||||||
// ========== Constants ==========
|
// ========== Constants ==========
|
||||||
|
|
||||||
/** Supported image file extensions (lowercase) */
|
/** Supported image file extensions (lowercase) */
|
||||||
// PNG stays on the image-popup path: it's the dominant screenshot format and the
|
const IMAGE_POPUP_EXTENSIONS = new Set(['.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg']);
|
||||||
// frontend only wires the `image:detected` popup today. The attachment-card UI
|
const ATTACHMENT_EXTENSIONS = new Set(['.png', '.pdf', '.docx', '.pptx']);
|
||||||
// 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 DETECTED_FILE_EXTENSIONS = new Set([...IMAGE_POPUP_EXTENSIONS, ...ATTACHMENT_EXTENSIONS]);
|
const DETECTED_FILE_EXTENSIONS = new Set([...IMAGE_POPUP_EXTENSIONS, ...ATTACHMENT_EXTENSIONS]);
|
||||||
|
|
||||||
/** Time to wait for file writes to stabilize (ms) */
|
/** Time to wait for file writes to stabilize (ms) */
|
||||||
|
|||||||
@@ -223,6 +223,7 @@ const _SSE_HANDLER_MAP = [
|
|||||||
|
|
||||||
// Images
|
// Images
|
||||||
[SSE_EVENTS.IMAGE_DETECTED, '_onImageDetected'],
|
[SSE_EVENTS.IMAGE_DETECTED, '_onImageDetected'],
|
||||||
|
[SSE_EVENTS.ATTACHMENT_DETECTED, '_onAttachmentDetected'],
|
||||||
|
|
||||||
// Tunnel
|
// Tunnel
|
||||||
[SSE_EVENTS.TUNNEL_STARTED, '_onTunnelStarted'],
|
[SSE_EVENTS.TUNNEL_STARTED, '_onTunnelStarted'],
|
||||||
@@ -387,6 +388,8 @@ class CodemanApp {
|
|||||||
// Image popup windows (auto-open for detected screenshots/images)
|
// Image popup windows (auto-open for detected screenshots/images)
|
||||||
this.imagePopups = new Map(); // Map<imageId, { element, sessionId, filePath }>
|
this.imagePopups = new Map(); // Map<imageId, { element, sessionId, filePath }>
|
||||||
this.imagePopupZIndex = ZINDEX_IMAGE_POPUP_BASE;
|
this.imagePopupZIndex = ZINDEX_IMAGE_POPUP_BASE;
|
||||||
|
this.attachmentCards = new Map(); // Map<attachmentId, { element, sessionId, filePath }>
|
||||||
|
this.attachmentCardStack = null;
|
||||||
|
|
||||||
// File browser state (methods in panels-ui.js)
|
// File browser state (methods in panels-ui.js)
|
||||||
this.fileBrowserData = null;
|
this.fileBrowserData = null;
|
||||||
@@ -3596,6 +3599,7 @@ class CodemanApp {
|
|||||||
this.clearCountdownTimers(sessionId);
|
this.clearCountdownTimers(sessionId);
|
||||||
this.closeSessionLogViewerWindows(sessionId);
|
this.closeSessionLogViewerWindows(sessionId);
|
||||||
this.closeSessionImagePopups(sessionId);
|
this.closeSessionImagePopups(sessionId);
|
||||||
|
this.closeSessionAttachmentCards(sessionId);
|
||||||
this.closeSessionSubagentWindows(sessionId, true);
|
this.closeSessionSubagentWindows(sessionId, true);
|
||||||
|
|
||||||
// Clean up idle timer
|
// Clean up idle timer
|
||||||
|
|||||||
+225
-3
@@ -2443,8 +2443,8 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
this.saveAppSettingsToStorage(settings);
|
this.saveAppSettingsToStorage(settings);
|
||||||
},
|
},
|
||||||
|
|
||||||
async openFilePreview(filePath) {
|
async openFilePreview(filePath, sessionId = this.activeSessionId, attachmentId = null) {
|
||||||
if (!this.activeSessionId || !filePath) return;
|
if (!sessionId || !filePath) return;
|
||||||
|
|
||||||
const overlay = this.$('filePreviewOverlay');
|
const overlay = this.$('filePreviewOverlay');
|
||||||
const titleEl = this.$('filePreviewTitle');
|
const titleEl = this.$('filePreviewTitle');
|
||||||
@@ -2459,8 +2459,53 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
bodyEl.innerHTML = '<div class="binary-message">Loading...</div>';
|
bodyEl.innerHTML = '<div class="binary-message">Loading...</div>';
|
||||||
footerEl.textContent = '';
|
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 = `<img src="${escapeHtml(`${base}/raw`)}" alt="${escapeHtml(filePath)}">`;
|
||||||
|
} else if (ext === 'pdf') {
|
||||||
|
bodyEl.innerHTML = `<iframe src="${escapeHtml(`${base}/raw`)}" title="${escapeHtml(filePath)}"></iframe>`;
|
||||||
|
} else if (ext === 'docx' || ext === 'pptx') {
|
||||||
|
bodyEl.innerHTML = `<iframe src="${escapeHtml(`${base}/preview`)}" title="${escapeHtml(filePath)}"></iframe>`;
|
||||||
|
} 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 = `<pre><code>${escapeHtml(text)}</code></pre>`;
|
||||||
|
} catch (err) {
|
||||||
|
bodyEl.innerHTML = `<div class="binary-message">Error: ${escapeHtml(err.message)}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Workspace-path (auto-detected, unregistered) attachments: Office docs are
|
||||||
|
// converted to PDF server-side via the file-preview route; PDFs stream raw.
|
||||||
|
// Both render inline in an iframe. Without this, docx/pptx/pdf fall through
|
||||||
|
// to file-content below, which would dump the binary bytes as mojibake.
|
||||||
|
if (ext === 'docx' || ext === 'pptx') {
|
||||||
|
footerEl.textContent = ext.toUpperCase();
|
||||||
|
const previewSrc = `/api/sessions/${sessionId}/file-preview?path=${encodeURIComponent(filePath)}`;
|
||||||
|
bodyEl.innerHTML = `<iframe src="${escapeHtml(previewSrc)}" title="${escapeHtml(filePath)}"></iframe>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (ext === 'pdf') {
|
||||||
|
footerEl.textContent = 'PDF';
|
||||||
|
const rawSrc = `/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}`;
|
||||||
|
bodyEl.innerHTML = `<iframe src="${escapeHtml(rawSrc)}" title="${escapeHtml(filePath)}"></iframe>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
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');
|
if (!res.ok) throw new Error('Failed to load file');
|
||||||
|
|
||||||
const result = await res.json();
|
const result = await res.json();
|
||||||
@@ -2498,6 +2543,183 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
this.filePreviewContent = '';
|
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 <body>).
|
||||||
|
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 = `
|
||||||
|
<div class="attachment-thumbnail">
|
||||||
|
${attachmentThumbnailUrl ? `<img class="attachment-thumbnail-img" src="${escapeHtml(attachmentThumbnailUrl)}" alt="">` : ''}
|
||||||
|
<div class="attachment-thumbnail-fallback ${attachmentThumbnailUrl ? '' : 'visible'}">${escapeHtml(typeLabel)}</div>
|
||||||
|
</div>
|
||||||
|
<div class="attachment-card-main">
|
||||||
|
<div class="attachment-file-name" title="${escapeHtml(filePath)}">${escapeHtml(fileName)}</div>
|
||||||
|
<div class="attachment-file-meta">
|
||||||
|
<span>${escapeHtml(sessionName)}</span>
|
||||||
|
<span>${this.formatFileSize(size || 0)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="attachment-actions">
|
||||||
|
<button type="button" class="attachment-preview-btn">Preview</button>
|
||||||
|
<a href="${escapeHtml(downloadUrl)}">Download</a>
|
||||||
|
<button type="button" class="attachment-open-btn">Open</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="attachment-close-btn" title="Dismiss">×</button>
|
||||||
|
`;
|
||||||
|
|
||||||
|
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() {
|
copyFilePreviewContent() {
|
||||||
if (this.filePreviewContent) {
|
if (this.filePreviewContent) {
|
||||||
navigator.clipboard.writeText(this.filePreviewContent).then(() => {
|
navigator.clipboard.writeText(this.filePreviewContent).then(() => {
|
||||||
|
|||||||
@@ -9232,6 +9232,175 @@ body.touch-device.cjk-input-visible .main {
|
|||||||
z-index: 100;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
=== v1.0 Carbon Aurora · Daylight overrides ===
|
=== v1.0 Carbon Aurora · Daylight overrides ===
|
||||||
Single appended block — trivially removable. Retones the hardcoded
|
Single appended block — trivially removable. Retones the hardcoded
|
||||||
|
|||||||
@@ -12,14 +12,17 @@ import { fileStreamManager } from '../../file-stream-manager.js';
|
|||||||
import {
|
import {
|
||||||
AttachmentRegistrationError,
|
AttachmentRegistrationError,
|
||||||
attachmentRegistry,
|
attachmentRegistry,
|
||||||
|
isSupportedAttachmentExtension,
|
||||||
registerExternalAttachment,
|
registerExternalAttachment,
|
||||||
type AttachmentRecord,
|
type AttachmentRecord,
|
||||||
} from '../../attachment-registry.js';
|
} 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 { isBlockedAttachmentPath, loadAttachmentGuardConfig } from '../../config/attachment-guard.js';
|
||||||
import { findSessionOrFail, validateSessionFilePath } from '../route-helpers.js';
|
import { findSessionOrFail, validateSessionFilePath } from '../route-helpers.js';
|
||||||
import { isSensitivePath } from '../sensitive-path.js';
|
import { isSensitivePath } from '../sensitive-path.js';
|
||||||
import { SseEvent } from '../sse-events.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<string, string> = {
|
const MIME_TYPES: Record<string, string> = {
|
||||||
png: 'image/png',
|
png: 'image/png',
|
||||||
@@ -159,7 +162,80 @@ async function resolveServableAttachmentPath(
|
|||||||
return resolved ? pathToCheck : record.filePath;
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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
|
// File tree listing
|
||||||
app.get('/api/sessions/:id/files', async (req) => {
|
app.get('/api/sessions/:id/files', async (req) => {
|
||||||
const { id } = req.params as { id: string };
|
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)
|
// Stream file content via tail -f (SSE endpoint)
|
||||||
app.get('/api/sessions/:id/tail-file', async (req, reply) => {
|
app.get('/api/sessions/:id/tail-file', async (req, reply) => {
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
|
|||||||
+7
-2
@@ -60,7 +60,7 @@ import {
|
|||||||
type SubagentToolResult,
|
type SubagentToolResult,
|
||||||
} from '../subagent-watcher.js';
|
} from '../subagent-watcher.js';
|
||||||
import { imageWatcher } from '../image-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 { TranscriptWatcher } from '../transcript-watcher.js';
|
||||||
import { TeamWatcher } from '../team-watcher.js';
|
import { TeamWatcher } from '../team-watcher.js';
|
||||||
import { TunnelManager } from '../tunnel-manager.js';
|
import { TunnelManager } from '../tunnel-manager.js';
|
||||||
@@ -442,7 +442,12 @@ export class WebServer extends EventEmitter {
|
|||||||
this.imageWatcherHandlers = {
|
this.imageWatcherHandlers = {
|
||||||
detected: (event: ImageDetectedEvent) => this.broadcast(SseEvent.ImageDetected, event),
|
detected: (event: ImageDetectedEvent) => this.broadcast(SseEvent.ImageDetected, event),
|
||||||
attachmentDetected: (event: AttachmentDetectedEvent) =>
|
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) => {
|
error: (error: Error, sessionId?: string) => {
|
||||||
console.error(`[ImageWatcher] Error${sessionId ? ` for ${sessionId}` : ''}:`, error.message);
|
console.error(`[ImageWatcher] Error${sessionId ? ` for ${sessionId}` : ''}:`, error.message);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { runWithConversionLimit, getActiveConversionCount } from '../src/document-conversion-limiter.js';
|
||||||
|
|
||||||
|
describe('document-conversion-limiter', () => {
|
||||||
|
it('never runs more converters than the cap (default 3) concurrently', async () => {
|
||||||
|
let running = 0;
|
||||||
|
let maxObserved = 0;
|
||||||
|
|
||||||
|
const task = () => async () => {
|
||||||
|
running++;
|
||||||
|
maxObserved = Math.max(maxObserved, running);
|
||||||
|
// The module's own accounting must also stay within the cap.
|
||||||
|
expect(getActiveConversionCount()).toBeLessThanOrEqual(3);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||||
|
running--;
|
||||||
|
};
|
||||||
|
|
||||||
|
await Promise.all(Array.from({ length: 12 }, () => runWithConversionLimit(task())));
|
||||||
|
|
||||||
|
expect(maxObserved).toBeLessThanOrEqual(3);
|
||||||
|
expect(maxObserved).toBeGreaterThan(1); // proves it genuinely parallelizes, not serializes
|
||||||
|
expect(getActiveConversionCount()).toBe(0); // every slot released
|
||||||
|
});
|
||||||
|
|
||||||
|
it('processes every queued task even when far more are submitted than the cap', async () => {
|
||||||
|
let completed = 0;
|
||||||
|
await Promise.all(
|
||||||
|
Array.from({ length: 25 }, () =>
|
||||||
|
runWithConversionLimit(async () => {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1));
|
||||||
|
completed++;
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
expect(completed).toBe(25);
|
||||||
|
expect(getActiveConversionCount()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('releases the slot when a task throws', async () => {
|
||||||
|
await expect(
|
||||||
|
runWithConversionLimit(async () => {
|
||||||
|
throw new Error('boom');
|
||||||
|
})
|
||||||
|
).rejects.toThrow('boom');
|
||||||
|
expect(getActiveConversionCount()).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import { pruneDocumentPreviewCache } from '../src/document-preview-cache.js';
|
||||||
|
|
||||||
|
vi.mock('node:fs/promises', () => ({
|
||||||
|
default: {
|
||||||
|
readdir: vi.fn(),
|
||||||
|
stat: vi.fn(),
|
||||||
|
rm: vi.fn(async () => undefined),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockedReaddir = vi.mocked(fs.readdir);
|
||||||
|
const mockedStat = vi.mocked(fs.stat);
|
||||||
|
const mockedRm = vi.mocked(fs.rm);
|
||||||
|
|
||||||
|
describe('pruneDocumentPreviewCache', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
// mtime encoded in the filename (f0 oldest ... f101 newest)
|
||||||
|
mockedStat.mockImplementation(async (p) => {
|
||||||
|
const m = /f(\d+)\.pdf$/.exec(String(p));
|
||||||
|
return { mtimeMs: m ? Number(m[1]) : 0, isFile: () => true } as never;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('evicts the oldest *.pdf files once the cache exceeds the cap (default 100)', async () => {
|
||||||
|
const names = Array.from({ length: 102 }, (_, i) => `f${i}.pdf`);
|
||||||
|
mockedReaddir.mockResolvedValue(names as never);
|
||||||
|
|
||||||
|
await pruneDocumentPreviewCache('/tmp/codeman-document-preview-cache');
|
||||||
|
|
||||||
|
// 102 - 100 = 2 oldest removed
|
||||||
|
expect(mockedRm).toHaveBeenCalledTimes(2);
|
||||||
|
const removed = mockedRm.mock.calls.map((c) => String(c[0]));
|
||||||
|
expect(removed.some((p) => p.endsWith('f0.pdf'))).toBe(true);
|
||||||
|
expect(removed.some((p) => p.endsWith('f1.pdf'))).toBe(true);
|
||||||
|
expect(removed.some((p) => p.endsWith('f101.pdf'))).toBe(false); // newest kept
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores non-pdf entries (e.g. transient work-* dirs) when counting', async () => {
|
||||||
|
const names = [...Array.from({ length: 50 }, (_, i) => `f${i}.pdf`), 'work-abc', 'work-def'];
|
||||||
|
mockedReaddir.mockResolvedValue(names as never);
|
||||||
|
|
||||||
|
await pruneDocumentPreviewCache('/tmp/codeman-document-preview-cache');
|
||||||
|
|
||||||
|
expect(mockedRm).not.toHaveBeenCalled(); // 50 pdfs <= cap
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never throws when the cache dir cannot be read', async () => {
|
||||||
|
mockedReaddir.mockRejectedValue(new Error('ENOENT'));
|
||||||
|
await expect(pruneDocumentPreviewCache('/tmp/missing')).resolves.toBeUndefined();
|
||||||
|
expect(mockedRm).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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')])
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -145,9 +145,9 @@ describe('ImageWatcher', () => {
|
|||||||
// ========== Image Detection ==========
|
// ========== Image Detection ==========
|
||||||
|
|
||||||
describe('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();
|
const handler = vi.fn();
|
||||||
watcher.on('image:detected', handler);
|
watcher.on('attachment:detected', handler);
|
||||||
|
|
||||||
watcher.watchSession('session-1', '/home/user/project');
|
watcher.watchSession('session-1', '/home/user/project');
|
||||||
const chokidarWatcher = mockWatchers.get('/home/user/project')!;
|
const chokidarWatcher = mockWatchers.get('/home/user/project')!;
|
||||||
@@ -161,11 +161,14 @@ describe('ImageWatcher', () => {
|
|||||||
expect(event.fileName).toBe('screenshot.png');
|
expect(event.fileName).toBe('screenshot.png');
|
||||||
expect(event.filePath).toBe('/home/user/project/screenshot.png');
|
expect(event.filePath).toBe('/home/user/project/screenshot.png');
|
||||||
expect(event.relativePath).toBe('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();
|
const handler = vi.fn();
|
||||||
watcher.on('attachment:detected', handler);
|
watcher.on('image:detected', handler);
|
||||||
|
|
||||||
watcher.watchSession('session-1', '/home/user/project');
|
watcher.watchSession('session-1', '/home/user/project');
|
||||||
mockWatchers.get('/home/user/project')!.emit('add', '/home/user/project/screenshot.png');
|
mockWatchers.get('/home/user/project')!.emit('add', '/home/user/project/screenshot.png');
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Route coverage for the document preview/thumbnail endpoints
|
||||||
|
* added in COD-38 (PR #120): the by-attachmentId routes
|
||||||
|
* (/attachments/:id/preview|thumbnail) and the workspace-path routes
|
||||||
|
* (/file-preview|/file-thumbnail). Converters are mocked, so no real
|
||||||
|
* pdftoppm/LibreOffice is needed. Uses app.inject() — no real ports.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
|
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
|
||||||
|
import { registerFileRoutes } from '../../src/web/routes/file-routes.js';
|
||||||
|
|
||||||
|
vi.mock('node:fs/promises', () => ({
|
||||||
|
default: {
|
||||||
|
readFile: vi.fn(async () => Buffer.from('%PDF-1.4 fake pdf bytes')),
|
||||||
|
stat: vi.fn(async () => ({ size: 100, isFile: () => true, mtimeMs: 1 })),
|
||||||
|
readdir: vi.fn(async () => []),
|
||||||
|
mkdir: vi.fn(async () => undefined),
|
||||||
|
mkdtemp: vi.fn(async () => '/tmp/codeman-preview-test'),
|
||||||
|
rename: vi.fn(async () => undefined),
|
||||||
|
rm: vi.fn(async () => undefined),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('node:fs', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('node:fs')>();
|
||||||
|
return { ...actual, realpathSync: vi.fn((p: string) => p) };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock the converters so the routes don't shell out to real binaries.
|
||||||
|
vi.mock('../../src/document-thumbnailer.js', () => ({
|
||||||
|
generateFirstPageThumbnail: vi.fn(async () => ({ content: Buffer.from('\x89PNG fake'), contentType: 'image/png' })),
|
||||||
|
}));
|
||||||
|
vi.mock('../../src/document-preview-cache.js', () => ({
|
||||||
|
getOfficePreviewPdfPath: vi.fn(async () => '/tmp/codeman-document-preview-cache/out.pdf'),
|
||||||
|
getPreviewPdfDownloadName: vi.fn((name: string) => `${name.replace(/\.[^.]+$/, '')}.pdf`),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { generateFirstPageThumbnail } from '../../src/document-thumbnailer.js';
|
||||||
|
import { getOfficePreviewPdfPath } from '../../src/document-preview-cache.js';
|
||||||
|
import { attachmentRegistry, type AttachmentRecord } from '../../src/attachment-registry.js';
|
||||||
|
|
||||||
|
const SID = 'test-session-1';
|
||||||
|
const WORKDIR = '/tmp/test-workdir';
|
||||||
|
|
||||||
|
function makeRecord(over: Partial<AttachmentRecord>): AttachmentRecord {
|
||||||
|
return {
|
||||||
|
attachmentId: 'att_x',
|
||||||
|
sessionId: SID,
|
||||||
|
filePath: `${WORKDIR}/file`,
|
||||||
|
fileName: 'file',
|
||||||
|
extension: 'pdf',
|
||||||
|
attachmentType: 'document',
|
||||||
|
size: 100,
|
||||||
|
mtimeMs: 1,
|
||||||
|
timestamp: 1,
|
||||||
|
source: 'detected',
|
||||||
|
...over,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('file-routes preview/thumbnail (COD-38)', () => {
|
||||||
|
let harness: RouteTestHarness;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
harness = await createRouteTestHarness(registerFileRoutes, { sessionId: SID });
|
||||||
|
vi.clearAllMocks();
|
||||||
|
attachmentRegistry.clearSession(SID);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await harness.app.close();
|
||||||
|
attachmentRegistry.clearSession(SID);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- workspace-path routes ----
|
||||||
|
|
||||||
|
it('file-preview converts a workspace DOCX to an inline PDF', async () => {
|
||||||
|
const res = await harness.app.inject({ method: 'GET', url: `/api/sessions/${SID}/file-preview?path=deck.docx` });
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.headers['content-type']).toContain('application/pdf');
|
||||||
|
expect(res.headers['content-disposition']).toContain('inline');
|
||||||
|
expect(getOfficePreviewPdfPath).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('file-preview redirects a non-Office workspace file (PDF) to the raw route', async () => {
|
||||||
|
const res = await harness.app.inject({ method: 'GET', url: `/api/sessions/${SID}/file-preview?path=report.pdf` });
|
||||||
|
expect(res.statusCode).toBeGreaterThanOrEqual(300);
|
||||||
|
expect(res.statusCode).toBeLessThan(400);
|
||||||
|
expect(res.headers.location).toContain('/file-raw?path=report.pdf');
|
||||||
|
expect(getOfficePreviewPdfPath).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('file-preview 400s for a missing path parameter', async () => {
|
||||||
|
const res = await harness.app.inject({ method: 'GET', url: `/api/sessions/${SID}/file-preview` });
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('file-thumbnail returns a PNG for a supported workspace file', async () => {
|
||||||
|
const res = await harness.app.inject({ method: 'GET', url: `/api/sessions/${SID}/file-thumbnail?path=deck.pdf` });
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.headers['content-type']).toContain('image/png');
|
||||||
|
expect(res.headers['x-content-type-options']).toBe('nosniff');
|
||||||
|
expect(generateFirstPageThumbnail).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('file-thumbnail 400s for an unsupported extension', async () => {
|
||||||
|
const res = await harness.app.inject({ method: 'GET', url: `/api/sessions/${SID}/file-thumbnail?path=notes.exe` });
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
expect(generateFirstPageThumbnail).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- by-attachmentId routes ----
|
||||||
|
|
||||||
|
it('by-id preview converts a registered DOCX attachment', async () => {
|
||||||
|
attachmentRegistry.register(
|
||||||
|
makeRecord({
|
||||||
|
attachmentId: 'att_docx',
|
||||||
|
filePath: `${WORKDIR}/deck.docx`,
|
||||||
|
fileName: 'deck.docx',
|
||||||
|
extension: 'docx',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const res = await harness.app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/api/sessions/${SID}/attachments/att_docx/preview`,
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.headers['content-type']).toContain('application/pdf');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('by-id preview redirects a non-Office attachment (PNG) to its raw route', async () => {
|
||||||
|
attachmentRegistry.register(
|
||||||
|
makeRecord({ attachmentId: 'att_png', filePath: `${WORKDIR}/shot.png`, fileName: 'shot.png', extension: 'png' })
|
||||||
|
);
|
||||||
|
const res = await harness.app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/api/sessions/${SID}/attachments/att_png/preview`,
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBeGreaterThanOrEqual(300);
|
||||||
|
expect(res.statusCode).toBeLessThan(400);
|
||||||
|
expect(res.headers.location).toContain('/attachments/att_png/raw');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('by-id thumbnail returns a PNG for a registered attachment', async () => {
|
||||||
|
attachmentRegistry.register(
|
||||||
|
makeRecord({ attachmentId: 'att_pdf', filePath: `${WORKDIR}/deck.pdf`, fileName: 'deck.pdf', extension: 'pdf' })
|
||||||
|
);
|
||||||
|
const res = await harness.app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/api/sessions/${SID}/attachments/att_pdf/thumbnail`,
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.headers['content-type']).toContain('image/png');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user