diff --git a/CHANGELOG.md b/CHANGELOG.md index 1365ccfa..5a277bbd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ For the full list of merged PRs per release, see [GitHub Releases](https://githu ## [Unreleased] ### Added +- Session scratchpad viewer: open a session's Claude Code scratchpad directory (`/tmp/claude-///scratchpad/`) in its own tab from the session context menu or the `⋯` menu, with a lazily-loaded file tree, text / Markdown / image preview, and live refresh. - `general.autoExpandAIGroups` setting: automatically expands all AI response groups when opening a transcript or when new AI responses arrive in a live session. Defaults to off. Persists across restarts. - Strict IPC input validation guards for project / session / subagent / search limits. - `get-waterfall-data` IPC endpoint implementation. diff --git a/README.md b/README.md index 3f979ea2..a54cf30e 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,7 @@ The only workaround is `--verbose`, which dumps raw JSON, internal system prompt | Nothing about thinking | Extended thinking content, fully visible | | `--verbose` JSON dump | Structured, filterable, navigable interface — no noise | | Per-project Claude memory hidden in `~/.claude/projects/.../memory/` | `MEMORY.md` rendered as a clickable index of layers; open any layer in your editor | +| Session scratch files buried in `/tmp/claude-/...` | Each session's scratchpad as a live file tree, with code, Markdown and images previewed inline | | Copy from terminal = wrapped lines, ANSI codes, broken Markdown | Real selectable text, one-click copy on every message and code block | **Zero configuration. No API keys. No wrappers. Works with every session you've ever run.** @@ -131,6 +132,10 @@ Copying Claude Code output from the terminal mangles it — selection wraps at t Claude Code stores per-project memory at `~/.claude/projects//memory/` — a `MEMORY.md` index plus one `.md` file per layer (working style, architecture notes, etc.). claude-devtools surfaces this as a sidebar entry that opens a dedicated pane: layer list on the left, full markdown rendering on the right with frontmatter shown as a metadata card, Obsidian-style `[[wikilinks]]` for cross-layer navigation, and an icon-driven "Open in…" launcher that hands any layer (or the whole memory folder) off to Finder/Explorer, Cursor, VS Code, Zed, Xcode, iTerm, Ghostty, Terminal — or copies the absolute path. +### Session Scratchpad + +Claude Code gives every session a private scratch directory at `/tmp/claude-///scratchpad/` (or under `CLAUDE_CODE_TMPDIR`), where it drops drafts, downloaded data, screenshots and throwaway scripts. Right-click a session in the sidebar, or use the `⋯` menu of an open session, and choose **Open Scratchpad** to browse it: a lazily-expanded file tree on the left, and the selected file on the right — syntax-highlighted code, Markdown with a code/preview toggle, or an inline image. The view refreshes while it is open, so files a live session writes appear as they land. Local sessions only. + ### [Team & Subagent Trees](https://claude-dev.tools/docs/subagents) Isolated execution trees per agent with tool traces, token metrics, duration, and cost. Nested agents render recursively. diff --git a/src/main/http/index.ts b/src/main/http/index.ts index 3f647e50..64d019e4 100644 --- a/src/main/http/index.ts +++ b/src/main/http/index.ts @@ -12,6 +12,7 @@ import { registerEventRoutes } from './events'; import { registerMemoryRoutes } from './memory'; import { registerNotificationRoutes } from './notifications'; import { registerProjectRoutes } from './projects'; +import { registerScratchpadRoutes } from './scratchpad'; import { registerSearchRoutes } from './search'; import { registerSessionRoutes } from './sessions'; import { registerSshRoutes } from './ssh'; @@ -61,6 +62,7 @@ export function registerHttpRoutes( registerSshRoutes(app, services.sshConnectionManager, sshModeSwitchCallback); registerUpdaterRoutes(app, services); registerMemoryRoutes(app, services); + registerScratchpadRoutes(app); registerEventRoutes(app); logger.info('All HTTP routes registered'); diff --git a/src/main/http/scratchpad.ts b/src/main/http/scratchpad.ts new file mode 100644 index 00000000..9dd8c136 --- /dev/null +++ b/src/main/http/scratchpad.ts @@ -0,0 +1,64 @@ +/** + * HTTP route handlers for the per-session Scratchpad viewer. + * + * Mirrors the read paths of the IPC surface. Opening files in the OS file + * manager has no meaning for a browser client, so it is not exposed here. + */ + +import { scratchpadReader } from '@main/services/discovery/ScratchpadReader'; +import { createLogger } from '@shared/utils/logger'; + +import { validateScratchpadArgs } from '../ipc/guards'; + +import type { ScratchpadListResult, ScratchpadReadFileResult } from '@shared/types'; +import type { FastifyInstance } from 'fastify'; + +const logger = createLogger('HTTP:scratchpad'); + +interface ScratchpadQuery { + projectId?: string; + sessionId?: string; + path?: string; +} + +export function registerScratchpadRoutes(app: FastifyInstance): void { + app.get<{ Querystring: ScratchpadQuery }>( + '/api/scratchpad/list', + async (request): Promise => { + const { projectId, sessionId, path } = request.query; + const args = validateScratchpadArgs(projectId, sessionId, path); + if (!args.valid) return { success: false, error: args.error }; + try { + const listing = await scratchpadReader.list( + args.projectId, + args.sessionId, + args.relativePath + ); + return { success: true, ...listing }; + } catch (error) { + logger.error('Error in GET /api/scratchpad/list:', error); + return { success: false, error: error instanceof Error ? error.message : String(error) }; + } + } + ); + + app.get<{ Querystring: ScratchpadQuery }>( + '/api/scratchpad/file', + async (request): Promise => { + const { projectId, sessionId, path } = request.query; + const args = validateScratchpadArgs(projectId, sessionId, path); + if (!args.valid) return { success: false, error: args.error }; + try { + const result = await scratchpadReader.readFile( + args.projectId, + args.sessionId, + args.relativePath + ); + return { success: true, ...result }; + } catch (error) { + logger.error('Error in GET /api/scratchpad/file:', error); + return { success: false, error: error instanceof Error ? error.message : String(error) }; + } + } + ); +} diff --git a/src/main/ipc/guards.ts b/src/main/ipc/guards.ts index b544c151..1b0ce7fa 100644 --- a/src/main/ipc/guards.ts +++ b/src/main/ipc/guards.ts @@ -146,3 +146,35 @@ export function coerceSearchMaxResults(value: unknown, defaultValue: number = 50 export function coercePageLimit(value: unknown, defaultValue: number = 20): number { return coerceLimit(value, defaultValue, MAX_PAGE_LIMIT); } + +type ScratchpadArgsResult = + | { valid: true; projectId: string; sessionId: string; relativePath: string } + | { valid: false; error: string }; + +/** + * Validate the (projectId, sessionId, relativePath) triple shared by every + * scratchpad call. A missing relativePath means the scratchpad root. + */ +export function validateScratchpadArgs( + projectId: unknown, + sessionId: unknown, + relativePath: unknown +): ScratchpadArgsResult { + const projectIdResult = validateProjectId(projectId); + if (!projectIdResult.valid || !projectIdResult.value) { + return { valid: false, error: projectIdResult.error ?? 'Invalid projectId' }; + } + const sessionIdResult = validateSessionId(sessionId); + if (!sessionIdResult.valid || !sessionIdResult.value) { + return { valid: false, error: sessionIdResult.error ?? 'Invalid sessionId' }; + } + if (relativePath !== undefined && relativePath !== null && typeof relativePath !== 'string') { + return { valid: false, error: 'relativePath must be a string' }; + } + return { + valid: true, + projectId: projectIdResult.value, + sessionId: sessionIdResult.value, + relativePath: relativePath ?? '', + }; +} diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index fc1cb1c6..110c0f76 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -31,6 +31,11 @@ import { registerProjectHandlers, removeProjectHandlers, } from './projects'; +import { + initializeScratchpadHandlers, + registerScratchpadHandlers, + removeScratchpadHandlers, +} from './scratchpad'; import { initializeSearchHandlers, registerSearchHandlers, removeSearchHandlers } from './search'; import { initializeSessionHandlers, @@ -81,6 +86,7 @@ export function initializeIpcHandlers( initializeSshHandlers(sshManager, registry, contextCallbacks.rewire); initializeContextHandlers(registry, contextCallbacks.rewire); initializeMemoryHandlers(registry); + initializeScratchpadHandlers(registry); initializeConfigHandlers({ onClaudeRootPathUpdated: contextCallbacks.onClaudeRootPathUpdated, }); @@ -98,6 +104,7 @@ export function initializeIpcHandlers( registerSshHandlers(ipcMain); registerContextHandlers(ipcMain); registerMemoryHandlers(ipcMain); + registerScratchpadHandlers(ipcMain); registerWindowHandlers(ipcMain); logger.info('All handlers registered'); @@ -120,6 +127,7 @@ export function removeIpcHandlers(): void { removeSshHandlers(ipcMain); removeContextHandlers(ipcMain); removeMemoryHandlers(ipcMain); + removeScratchpadHandlers(ipcMain); removeWindowHandlers(ipcMain); logger.info('All handlers removed'); diff --git a/src/main/ipc/scratchpad.ts b/src/main/ipc/scratchpad.ts new file mode 100644 index 00000000..19257a1f --- /dev/null +++ b/src/main/ipc/scratchpad.ts @@ -0,0 +1,123 @@ +/** + * IPC Handlers for the per-session Scratchpad viewer. + * + * Surface: + * - scratchpad:list → ScratchpadListResult (one directory level) + * - scratchpad:readFile → ScratchpadReadFileResult (text, image or binary stub) + * - scratchpad:openPath → reveal a file in the OS file manager, or open a directory + * + * Read-only. Scratchpads live in the local temp directory, so SSH contexts are + * rejected rather than resolved against the wrong machine. + */ + +import { scratchpadReader } from '@main/services/discovery/ScratchpadReader'; +import { createLogger } from '@shared/utils/logger'; +import { type IpcMain, type IpcMainInvokeEvent, shell } from 'electron'; +import * as fs from 'fs'; + +import { validateScratchpadArgs } from './guards'; + +import type { ServiceContextRegistry } from '../services'; +import type { + ScratchpadListResult, + ScratchpadOpenResult, + ScratchpadReadFileResult, +} from '@shared/types'; + +const SCRATCHPAD_LIST = 'scratchpad:list'; +const SCRATCHPAD_READ_FILE = 'scratchpad:readFile'; +const SCRATCHPAD_OPEN_PATH = 'scratchpad:openPath'; + +const logger = createLogger('IPC:scratchpad'); + +let registry: ServiceContextRegistry; + +export function initializeScratchpadHandlers(contextRegistry: ServiceContextRegistry): void { + registry = contextRegistry; +} + +export function registerScratchpadHandlers(ipcMain: IpcMain): void { + ipcMain.handle(SCRATCHPAD_LIST, handleList); + ipcMain.handle(SCRATCHPAD_READ_FILE, handleReadFile); + ipcMain.handle(SCRATCHPAD_OPEN_PATH, handleOpenPath); + logger.info('Scratchpad handlers registered'); +} + +export function removeScratchpadHandlers(ipcMain: IpcMain): void { + ipcMain.removeHandler(SCRATCHPAD_LIST); + ipcMain.removeHandler(SCRATCHPAD_READ_FILE); + ipcMain.removeHandler(SCRATCHPAD_OPEN_PATH); +} + +function sshUnsupported(): { success: false; error: string } | null { + return registry?.getActive().type === 'ssh' + ? { success: false, error: 'Scratchpads are only available for local sessions' } + : null; +} + +async function handleList( + _event: IpcMainInvokeEvent, + projectId: unknown, + sessionId: unknown, + relativeDir: unknown +): Promise { + const args = validateScratchpadArgs(projectId, sessionId, relativeDir); + if (!args.valid) return { success: false, error: args.error }; + const unsupported = sshUnsupported(); + if (unsupported) return unsupported; + try { + const listing = await scratchpadReader.list(args.projectId, args.sessionId, args.relativePath); + return { success: true, ...listing }; + } catch (error) { + logger.error('Error in scratchpad:list:', error); + return { success: false, error: error instanceof Error ? error.message : String(error) }; + } +} + +async function handleReadFile( + _event: IpcMainInvokeEvent, + projectId: unknown, + sessionId: unknown, + relativePath: unknown +): Promise { + const args = validateScratchpadArgs(projectId, sessionId, relativePath); + if (!args.valid) return { success: false, error: args.error }; + const unsupported = sshUnsupported(); + if (unsupported) return unsupported; + try { + const result = await scratchpadReader.readFile( + args.projectId, + args.sessionId, + args.relativePath + ); + return { success: true, ...result }; + } catch (error) { + logger.error('Error in scratchpad:readFile:', error); + return { success: false, error: error instanceof Error ? error.message : String(error) }; + } +} + +async function handleOpenPath( + _event: IpcMainInvokeEvent, + projectId: unknown, + sessionId: unknown, + relativePath: unknown +): Promise { + const args = validateScratchpadArgs(projectId, sessionId, relativePath); + if (!args.valid) return { success: false, error: args.error }; + const unsupported = sshUnsupported(); + if (unsupported) return unsupported; + try { + const rootPath = scratchpadReader.getRootPath(args.projectId, args.sessionId); + const target = await scratchpadReader.resolveInside(rootPath, args.relativePath); + if ((await fs.promises.stat(target)).isDirectory()) { + const error = await shell.openPath(target); + return error ? { success: false, error } : { success: true }; + } + shell.showItemInFolder(target); + return { success: true }; + } catch (error) { + logger.error('Error in scratchpad:openPath:', error); + return { success: false, error: error instanceof Error ? error.message : String(error) }; + } +} diff --git a/src/main/services/discovery/ScratchpadReader.ts b/src/main/services/discovery/ScratchpadReader.ts new file mode 100644 index 00000000..570be9dd --- /dev/null +++ b/src/main/services/discovery/ScratchpadReader.ts @@ -0,0 +1,207 @@ +/** + * ScratchpadReader - Reads a Claude Code session's scratchpad directory. + * + * Claude Code gives every session a private scratch directory: + * /claude-///scratchpad/ + * + * where is `CLAUDE_CODE_TMPDIR` when set, otherwise `/tmp`. The + * segment matches the directory name under + * ~/.claude/projects, so it is taken from the project ID verbatim. + * + * The directory can be large and deeply nested, so it is listed one level at + * a time. Every read is resolved against the real path of the scratchpad root + * and rejected if it escapes it (including via symlinks). + */ + +import { extractBaseDir } from '@main/utils/pathDecoder'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +import type { ScratchpadEntry, ScratchpadFileContent } from '@shared/types'; + +const SCRATCHPAD_DIR_NAME = 'scratchpad'; +const MAX_DIR_ENTRIES = 1000; +const MAX_TEXT_BYTES = 512 * 1024; +const MAX_IMAGE_BYTES = 10 * 1024 * 1024; +const BINARY_SNIFF_BYTES = 8000; + +const IMAGE_MIME_TYPES: Record = { + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.bmp': 'image/bmp', + '.ico': 'image/x-icon', + '.svg': 'image/svg+xml', +}; + +export interface ScratchpadDirListing { + rootPath: string; + exists: boolean; + entries: ScratchpadEntry[]; + truncated: boolean; +} + +/** + * Candidate per-uid temp directories, in the order Claude Code prefers them. + */ +export function getClaudeTmpRoots(): string[] { + const uid = process.getuid?.() ?? 0; + const bases = [process.env.CLAUDE_CODE_TMPDIR, '/tmp', os.tmpdir()].filter( + (base): base is string => typeof base === 'string' && base.length > 0 + ); + return [...new Set(bases.map((base) => path.join(base, `claude-${uid}`)))]; +} + +export class ScratchpadReader { + private readonly tmpRoots: string[]; + + constructor(tmpRoots?: string[]) { + this.tmpRoots = tmpRoots ?? getClaudeTmpRoots(); + } + + /** + * Absolute scratchpad path for a session. Falls back to the preferred + * location when the directory does not exist under any candidate root. + */ + getRootPath(projectId: string, sessionId: string): string { + const candidates = this.tmpRoots.map((root) => + path.join(root, extractBaseDir(projectId), sessionId, SCRATCHPAD_DIR_NAME) + ); + return candidates.find((candidate) => fs.existsSync(candidate)) ?? candidates[0]; + } + + async list( + projectId: string, + sessionId: string, + relativeDir: string + ): Promise { + const rootPath = this.getRootPath(projectId, sessionId); + if (!fs.existsSync(rootPath)) { + return { rootPath, exists: false, entries: [], truncated: false }; + } + + const dirPath = await this.resolveInside(rootPath, relativeDir); + const dirents = await fs.promises.readdir(dirPath, { withFileTypes: true }); + const truncated = dirents.length > MAX_DIR_ENTRIES; + const prefix = toPosix(path.relative(await fs.promises.realpath(rootPath), dirPath)); + + const entries = await Promise.all( + dirents.slice(0, MAX_DIR_ENTRIES).map(async (dirent): Promise => { + const relativePath = prefix ? `${prefix}/${dirent.name}` : dirent.name; + try { + const stats = await fs.promises.stat(path.join(dirPath, dirent.name)); + return { + name: dirent.name, + relativePath, + isDirectory: stats.isDirectory(), + size: stats.size, + mtimeMs: stats.mtimeMs, + }; + } catch { + return { + name: dirent.name, + relativePath, + isDirectory: dirent.isDirectory(), + size: 0, + mtimeMs: 0, + }; + } + }) + ); + + entries.sort((a, b) => + a.isDirectory === b.isDirectory + ? a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' }) + : a.isDirectory + ? -1 + : 1 + ); + + return { rootPath, exists: true, entries, truncated }; + } + + async readFile( + projectId: string, + sessionId: string, + relativePath: string + ): Promise<{ path: string; file: ScratchpadFileContent }> { + const rootPath = this.getRootPath(projectId, sessionId); + const filePath = await this.resolveInside(rootPath, relativePath); + const stats = await fs.promises.stat(filePath); + if (!stats.isFile()) { + throw new Error(`Not a file: ${relativePath}`); + } + const { size, mtimeMs } = stats; + + const mimeType = IMAGE_MIME_TYPES[path.extname(filePath).toLowerCase()]; + if (mimeType && size <= MAX_IMAGE_BYTES) { + const data = await fs.promises.readFile(filePath); + return { + path: filePath, + file: { + kind: 'image', + dataUrl: `data:${mimeType};base64,${data.toString('base64')}`, + size, + mtimeMs, + }, + }; + } + + const head = await readHead(filePath, Math.min(size, MAX_TEXT_BYTES)); + if (mimeType || head.subarray(0, BINARY_SNIFF_BYTES).includes(0)) { + return { path: filePath, file: { kind: 'binary', size, mtimeMs } }; + } + + return { + path: filePath, + file: { + kind: 'text', + content: head.toString('utf8'), + size, + mtimeMs, + truncated: size > MAX_TEXT_BYTES, + }, + }; + } + + /** + * Resolve a relative path against the scratchpad root, returning its real + * path. Throws if the target (or anything it links to) lies outside the root. + */ + async resolveInside(rootPath: string, relativePath: string): Promise { + if (path.isAbsolute(relativePath)) { + throw new Error(`Scratchpad path must be relative: ${relativePath}`); + } + const realRoot = await fs.promises.realpath(rootPath); + const realTarget = await fs.promises + .realpath(path.resolve(realRoot, relativePath || '.')) + .catch((error: NodeJS.ErrnoException) => { + if (error.code === 'ENOENT') throw new Error(`No longer exists: ${relativePath}`); + throw error; + }); + if (realTarget !== realRoot && !realTarget.startsWith(realRoot + path.sep)) { + throw new Error(`Path escapes the scratchpad directory: ${relativePath}`); + } + return realTarget; + } +} + +async function readHead(filePath: string, length: number): Promise { + const handle = await fs.promises.open(filePath, 'r'); + try { + const buffer = Buffer.alloc(length); + const { bytesRead } = await handle.read(buffer, 0, length, 0); + return buffer.subarray(0, bytesRead); + } finally { + await handle.close(); + } +} + +function toPosix(p: string): string { + return p.split(path.sep).join('/'); +} + +export const scratchpadReader = new ScratchpadReader(); diff --git a/src/main/services/discovery/index.ts b/src/main/services/discovery/index.ts index 773bfc30..e463bbe8 100644 --- a/src/main/services/discovery/index.ts +++ b/src/main/services/discovery/index.ts @@ -13,6 +13,7 @@ export * from './MemoryReader'; export * from './ProjectPathResolver'; export * from './ProjectScanner'; +export * from './ScratchpadReader'; export * from './SearchTextCache'; export * from './SearchTextExtractor'; export * from './SessionContentFilter'; diff --git a/src/preload/constants/ipcChannels.ts b/src/preload/constants/ipcChannels.ts index b717dce6..66c9fcf4 100644 --- a/src/preload/constants/ipcChannels.ts +++ b/src/preload/constants/ipcChannels.ts @@ -210,5 +210,18 @@ export const MEMORY_COPY_PATH = 'memory:copyPath'; /** Memory file change event (main → renderer) */ export const MEMORY_CHANGED = 'memory:changed'; +// ============================================================================= +// Scratchpad API Channels +// ============================================================================= + +/** List one directory level of a session's scratchpad */ +export const SCRATCHPAD_LIST = 'scratchpad:list'; + +/** Read a single scratchpad file (text, image or binary stub) */ +export const SCRATCHPAD_READ_FILE = 'scratchpad:readFile'; + +/** Reveal a scratchpad file, or open a scratchpad directory, in the OS file manager */ +export const SCRATCHPAD_OPEN_PATH = 'scratchpad:openPath'; + /** Find sessions whose IDs contain a given hex fragment */ export const FIND_SESSIONS_BY_PARTIAL_ID = 'find-sessions-by-partial-id'; diff --git a/src/preload/index.ts b/src/preload/index.ts index 0d162235..03dfc1e2 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -19,6 +19,9 @@ import { MEMORY_LIST_OPENERS, MEMORY_OPEN_IN, MEMORY_READ_FILE, + SCRATCHPAD_LIST, + SCRATCHPAD_OPEN_PATH, + SCRATCHPAD_READ_FILE, SESSION_REFRESH, SSH_CONNECT, SSH_DISCONNECT, @@ -78,6 +81,9 @@ import type { NotificationTrigger, OpenTarget, OpenTargetId, + ScratchpadListResult, + ScratchpadOpenResult, + ScratchpadReadFileResult, SessionsByIdsOptions, SessionsPaginationOptions, SshConfigHostEntry, @@ -525,6 +531,43 @@ const electronAPI: ElectronAPI = { }; }, }, + + // Scratchpad API + scratchpad: { + list: ( + projectId: string, + sessionId: string, + relativeDir: string + ): Promise => + ipcRenderer.invoke( + SCRATCHPAD_LIST, + projectId, + sessionId, + relativeDir + ) as Promise, + readFile: ( + projectId: string, + sessionId: string, + relativePath: string + ): Promise => + ipcRenderer.invoke( + SCRATCHPAD_READ_FILE, + projectId, + sessionId, + relativePath + ) as Promise, + openPath: ( + projectId: string, + sessionId: string, + relativePath: string + ): Promise => + ipcRenderer.invoke( + SCRATCHPAD_OPEN_PATH, + projectId, + sessionId, + relativePath + ) as Promise, + }, }; // Use contextBridge to securely expose the API to the renderer process diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index 74a258a0..6809ffa1 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -30,6 +30,9 @@ import type { PaginatedSessionsResult, Project, RepositoryGroup, + ScratchpadListResult, + ScratchpadOpenResult, + ScratchpadReadFileResult, SearchSessionsResult, Session, SessionAPI, @@ -644,6 +647,27 @@ export class HttpAPIClient implements ElectronAPI { (): void => {}, }; + scratchpad: ElectronAPI['scratchpad'] = { + list: ( + projectId: string, + sessionId: string, + relativeDir: string + ): Promise => + this.get( + `/api/scratchpad/list?${new URLSearchParams({ projectId, sessionId, path: relativeDir }).toString()}` + ), + readFile: ( + projectId: string, + sessionId: string, + relativePath: string + ): Promise => + this.get( + `/api/scratchpad/file?${new URLSearchParams({ projectId, sessionId, path: relativePath }).toString()}` + ), + openPath: (): Promise => + Promise.resolve({ success: false, error: 'Opening files is unsupported in standalone mode' }), + }; + // HTTP Server API — in browser mode, server is already running (we're using it) httpServer: HttpServerAPI = { start: (): Promise => diff --git a/src/renderer/components/layout/MoreMenu.tsx b/src/renderer/components/layout/MoreMenu.tsx index af40cc52..f818ba54 100644 --- a/src/renderer/components/layout/MoreMenu.tsx +++ b/src/renderer/components/layout/MoreMenu.tsx @@ -11,7 +11,15 @@ import { api } from '@renderer/api'; import { useStore } from '@renderer/store'; import { triggerDownload } from '@renderer/utils/sessionExporter'; import { formatShortcut } from '@renderer/utils/stringUtils'; -import { Braces, FileText, MoreHorizontal, Search, Settings, Type } from 'lucide-react'; +import { + Braces, + FileText, + MoreHorizontal, + NotebookPen, + Search, + Settings, + Type, +} from 'lucide-react'; import type { Tab } from '@renderer/types/tabs'; import type { ExportFormat } from '@renderer/utils/sessionExporter'; @@ -42,6 +50,7 @@ export const MoreMenu = ({ const openCommandPalette = useStore((s) => s.openCommandPalette); const openSettingsTab = useStore((s) => s.openSettingsTab); + const openScratchpadTab = useStore((s) => s.openScratchpadTab); // Close on outside click useEffect(() => { @@ -110,7 +119,30 @@ export const MoreMenu = ({ }, ]; - const sessionItems: MenuItem[] = isSessionWithData + const scratchpadTarget = + activeTab?.type === 'session' && activeTab.projectId && activeTab.sessionId + ? { projectId: activeTab.projectId, sessionId: activeTab.sessionId, label: activeTab.label } + : null; + + const scratchpadItems: MenuItem[] = scratchpadTarget + ? [ + { + id: 'scratchpad', + label: 'Open Scratchpad', + icon: NotebookPen, + onClick: () => { + openScratchpadTab( + scratchpadTarget.projectId, + scratchpadTarget.sessionId, + scratchpadTarget.label + ); + setIsOpen(false); + }, + }, + ] + : []; + + const exportItems: MenuItem[] = isSessionWithData ? [ { id: 'export-md', @@ -136,6 +168,8 @@ export const MoreMenu = ({ ] : []; + const sessionItems = [...scratchpadItems, ...exportItems]; + const bottomItems: MenuItem[] = [ { id: 'settings', diff --git a/src/renderer/components/layout/PaneContent.tsx b/src/renderer/components/layout/PaneContent.tsx index 17b669e5..4968b1ce 100644 --- a/src/renderer/components/layout/PaneContent.tsx +++ b/src/renderer/components/layout/PaneContent.tsx @@ -8,6 +8,7 @@ import { TabUIProvider } from '@renderer/contexts/TabUIContext'; import { DashboardView } from '../dashboard/DashboardView'; import { MemoryView } from '../memory/MemoryView'; import { NotificationsView } from '../notifications/NotificationsView'; +import { ScratchpadView } from '../scratchpad/ScratchpadView'; import { SettingsView } from '../settings/SettingsView'; import { SessionTabContent } from './SessionTabContent'; @@ -44,6 +45,13 @@ export const PaneContent = ({ pane }: PaneContentProps): React.JSX.Element => { {tab.type === 'notifications' && } {tab.type === 'settings' && } {tab.type === 'memory' && tab.projectId && } + {tab.type === 'scratchpad' && tab.projectId && tab.sessionId && ( + + )} {tab.type === 'session' && ( diff --git a/src/renderer/components/layout/SortableTab.tsx b/src/renderer/components/layout/SortableTab.tsx index c18e9091..3ef21897 100644 --- a/src/renderer/components/layout/SortableTab.tsx +++ b/src/renderer/components/layout/SortableTab.tsx @@ -8,7 +8,17 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { useSortable } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; import { useStore } from '@renderer/store'; -import { Bell, BookOpen, FileText, LayoutDashboard, Pin, Search, Settings, X } from 'lucide-react'; +import { + Bell, + BookOpen, + FileText, + LayoutDashboard, + NotebookPen, + Pin, + Search, + Settings, + X, +} from 'lucide-react'; import { useShallow } from 'zustand/react/shallow'; import type { Tab } from '@renderer/types/tabs'; @@ -34,6 +44,7 @@ const TAB_ICONS = { settings: Settings, session: FileText, memory: BookOpen, + scratchpad: NotebookPen, } as const; export const SortableTab = ({ diff --git a/src/renderer/components/scratchpad/ScratchpadView.tsx b/src/renderer/components/scratchpad/ScratchpadView.tsx new file mode 100644 index 00000000..cb8ae05f --- /dev/null +++ b/src/renderer/components/scratchpad/ScratchpadView.tsx @@ -0,0 +1,516 @@ +/** + * ScratchpadView — full-pane view of a session's Claude Code scratchpad directory. + * + * Layout: master/detail. + * Left: lazily-expanded file tree (one directory level fetched at a time) + * Right: the selected file — syntax-highlighted text, markdown preview, + * inline image, or a stub for binaries. + * + * While the tab is visible, expanded directories are re-listed on an interval + * so files a live session writes show up without a manual refresh. + */ + +import { useCallback, useEffect, useState } from 'react'; + +import { api, isElectronMode } from '@renderer/api'; +import { CodeBlockViewer, MarkdownViewer } from '@renderer/components/chat/viewers'; +import { formatDistanceToNow } from 'date-fns'; +import { + ChevronDown, + ChevronRight, + File, + FileImage, + Folder, + FolderOpen, + RefreshCw, +} from 'lucide-react'; + +import { splitFrontmatter } from '../memory/frontmatter'; +import { FrontmatterCard } from '../memory/FrontmatterCard'; + +import type { + ScratchpadEntry, + ScratchpadFileContent, + ScratchpadReadFileResult, +} from '@shared/types'; + +interface ScratchpadViewProps { + projectId: string; + sessionId: string; + isActive: boolean; +} + +interface DirListing { + entries: ScratchpadEntry[]; + truncated: boolean; + signature: string; +} + +interface LoadedFile { + relativePath: string; + result: ScratchpadReadFileResult; +} + +const ROOT_DIR = ''; +const POLL_INTERVAL_MS = 3000; +const TREE_DEFAULT_WIDTH = 288; +const TREE_MIN_WIDTH = 180; +const TREE_MAX_WIDTH = 720; +const IMAGE_EXTENSION_RE = /\.(png|jpe?g|gif|webp|bmp|ico|svg)$/i; +const MARKDOWN_EXTENSION_RE = /\.mdx?$/i; + +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + const units = ['KB', 'MB', 'GB']; + let value = bytes / 1024; + let unit = 0; + while (value >= 1024 && unit < units.length - 1) { + value /= 1024; + unit++; + } + return `${value.toFixed(value < 10 ? 1 : 0)} ${units[unit]}`; +} + +function parentDir(relativePath: string): string { + const slash = relativePath.lastIndexOf('/'); + return slash === -1 ? ROOT_DIR : relativePath.slice(0, slash); +} + +function listingSignature(entries: ScratchpadEntry[]): string { + return entries.map((e) => `${e.name}|${e.isDirectory ? 'd' : e.size}|${e.mtimeMs}`).join('\n'); +} + +function fileStamp(file: ScratchpadFileContent): string { + return `${file.size}|${file.mtimeMs}`; +} + +function resultKey(result: ScratchpadReadFileResult): string { + return result.success ? `ok|${fileStamp(result.file)}` : `error|${result.error}`; +} + +export const ScratchpadView = ({ + projectId, + sessionId, + isActive, +}: ScratchpadViewProps): React.JSX.Element => { + const [rootPath, setRootPath] = useState(null); + const [exists, setExists] = useState(undefined); + const [error, setError] = useState(null); + const [listings, setListings] = useState>({}); + const [expanded, setExpanded] = useState([]); + const [selectedPath, setSelectedPath] = useState(null); + const [loadedFile, setLoadedFile] = useState(null); + const [markdownMode, setMarkdownMode] = useState<'preview' | 'code'>('preview'); + const [refreshing, setRefreshing] = useState(false); + const [treeWidth, setTreeWidth] = useState(TREE_DEFAULT_WIDTH); + const [resizeOrigin, setResizeOrigin] = useState<{ x: number; width: number } | null>(null); + + useEffect(() => { + if (!resizeOrigin) return; + const handleMouseMove = (e: MouseEvent): void => { + const next = resizeOrigin.width + e.clientX - resizeOrigin.x; + setTreeWidth(Math.min(TREE_MAX_WIDTH, Math.max(TREE_MIN_WIDTH, next))); + }; + const handleMouseUp = (): void => setResizeOrigin(null); + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + document.body.style.cursor = 'col-resize'; + document.body.style.userSelect = 'none'; + return (): void => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + }; + }, [resizeOrigin]); + + const loadDir = useCallback( + async (relativeDir: string): Promise => { + const result = await api.scratchpad.list(projectId, sessionId, relativeDir); + if (!result.success) { + if (relativeDir === ROOT_DIR) { + setError(result.error); + setExists(false); + } else { + setExpanded((prev) => prev.filter((dir) => dir !== relativeDir)); + setListings((prev) => ({ ...prev, [relativeDir]: undefined })); + } + return; + } + if (relativeDir === ROOT_DIR) { + setError(null); + setRootPath(result.rootPath); + setExists(result.exists); + } + const signature = listingSignature(result.entries); + setListings((prev) => + prev[relativeDir]?.signature === signature + ? prev + : { + ...prev, + [relativeDir]: { entries: result.entries, truncated: result.truncated, signature }, + } + ); + }, + [projectId, sessionId] + ); + + const loadFile = useCallback( + async (relativePath: string): Promise => { + const result = await api.scratchpad.readFile(projectId, sessionId, relativePath); + setLoadedFile((prev) => + prev?.relativePath === relativePath && resultKey(prev.result) === resultKey(result) + ? prev + : { relativePath, result } + ); + }, + [projectId, sessionId] + ); + + const refreshAll = useCallback(async (): Promise => { + await Promise.all([ROOT_DIR, ...expanded].map((dir) => loadDir(dir))); + }, [expanded, loadDir]); + + useEffect(() => { + void loadDir(ROOT_DIR); + }, [loadDir]); + + useEffect(() => { + if (!isActive || !exists) return; + const timer = setInterval(() => { + void refreshAll(); + }, POLL_INTERVAL_MS); + return (): void => clearInterval(timer); + }, [isActive, exists, refreshAll]); + + useEffect(() => { + if (!selectedPath || loadedFile?.relativePath !== selectedPath) return; + const siblings = listings[parentDir(selectedPath)]; + if (!siblings) return; + const entry = siblings.entries.find((e) => e.relativePath === selectedPath); + const current = loadedFile.result.success ? fileStamp(loadedFile.result.file) : null; + if (!entry || current !== `${entry.size}|${entry.mtimeMs}`) { + void loadFile(selectedPath); + } + }, [listings, selectedPath, loadedFile, loadFile]); + + const handleRefresh = useCallback(async (): Promise => { + setRefreshing(true); + try { + await refreshAll(); + if (selectedPath) await loadFile(selectedPath); + } finally { + setRefreshing(false); + } + }, [refreshAll, loadFile, selectedPath]); + + const toggleDir = useCallback( + (relativeDir: string): void => { + if (expanded.includes(relativeDir)) { + setExpanded((prev) => prev.filter((dir) => dir !== relativeDir)); + return; + } + setExpanded((prev) => [...prev, relativeDir]); + if (!listings[relativeDir]) void loadDir(relativeDir); + }, + [expanded, listings, loadDir] + ); + + const selectFile = useCallback( + (relativePath: string): void => { + setSelectedPath(relativePath); + setMarkdownMode('preview'); + void loadFile(relativePath); + }, + [loadFile] + ); + + const openInFileManager = useCallback( + (relativePath: string): void => { + void api.scratchpad.openPath(projectId, sessionId, relativePath); + }, + [projectId, sessionId] + ); + + const fileCount = listings[ROOT_DIR]?.entries.length ?? 0; + + const renderEntries = (relativeDir: string, depth: number): React.JSX.Element => { + const listing = listings[relativeDir]; + if (!listing) { + return ( +
+ Loading… +
+ ); + } + if (listing.entries.length === 0) { + return ( +
+ Empty +
+ ); + } + return ( + <> + {listing.entries.map((entry) => { + const isOpen = entry.isDirectory && expanded.includes(entry.relativePath); + const isSelected = !entry.isDirectory && entry.relativePath === selectedPath; + const Icon = entry.isDirectory + ? isOpen + ? FolderOpen + : Folder + : IMAGE_EXTENSION_RE.test(entry.name) + ? FileImage + : File; + const Chevron = isOpen ? ChevronDown : ChevronRight; + return ( +
+ + {isOpen && renderEntries(entry.relativePath, depth + 1)} +
+ ); + })} + {listing.truncated && ( +
+ Showing the first {listing.entries.length} entries +
+ )} + + ); + }; + + if (exists === undefined) { + return
Loading…
; + } + + if (!exists) { + return ( +
+
{error ?? 'This session has no scratchpad directory.'}
+ {rootPath && {rootPath}} +
+ ); + } + + const selectedFile = + selectedPath !== null && loadedFile?.relativePath === selectedPath ? loadedFile.result : null; + const canOpenInFileManager = isElectronMode(); + + return ( +
+
+
+ + Scratchpad {fileCount > 0 && ({fileCount})} + + {canOpenInFileManager && ( + + )} + +
+ {rootPath && ( +
+ {rootPath} +
+ )} +
{renderEntries(ROOT_DIR, 0)}
+
+ +
+ {selectedPath && ( +
+ + {selectedPath} + + {selectedFile?.success && ( + + {formatBytes(selectedFile.file.size)} · modified{' '} + {formatDistanceToNow(selectedFile.file.mtimeMs, { addSuffix: true })} + + )} + {selectedFile?.success && + selectedFile.file.kind === 'text' && + MARKDOWN_EXTENSION_RE.test(selectedPath) && ( +
+ {(['code', 'preview'] as const).map((mode) => ( + + ))} +
+ )} + {canOpenInFileManager && ( + + )} +
+ )} +
+ {!selectedPath ? ( +
Select a file to view its content.
+ ) : !selectedFile ? ( +
Loading…
+ ) : !selectedFile.success ? ( +
Could not read this file: {selectedFile.error}
+ ) : ( + + )} +
+
+
+ ); +}; + +const ScratchpadFileBody = ({ + relativePath, + file, + markdownMode, +}: { + relativePath: string; + file: ScratchpadFileContent; + markdownMode: 'preview' | 'code'; +}): React.JSX.Element => { + if (file.kind === 'image') { + return ( + {relativePath} + ); + } + + if (file.kind === 'binary') { + return ( +
+ Binary file ({formatBytes(file.size)}) — no preview available. +
+ ); + } + + return ( +
+ {file.truncated && ( +
+ Showing the first {formatBytes(file.content.length)} of {formatBytes(file.size)}. +
+ )} + {MARKDOWN_EXTENSION_RE.test(relativePath) && markdownMode === 'preview' ? ( + + ) : ( + + )} +
+ ); +}; + +const MarkdownPreview = ({ content }: { content: string }): React.JSX.Element => { + const { frontmatter, body } = splitFrontmatter(content); + const hasCardFields = + frontmatter !== null && + (Boolean(frontmatter.name) || + Boolean(frontmatter.description) || + Object.keys(frontmatter.metadata).length > 0); + const card = hasCardFields ? frontmatter : null; + return ( + <> + {card && } + + + ); +}; diff --git a/src/renderer/components/sidebar/SessionContextMenu.tsx b/src/renderer/components/sidebar/SessionContextMenu.tsx index 626cc6a5..3fdb236e 100644 --- a/src/renderer/components/sidebar/SessionContextMenu.tsx +++ b/src/renderer/components/sidebar/SessionContextMenu.tsx @@ -8,7 +8,16 @@ import { useEffect, useRef, useState } from 'react'; import { MAX_PANES } from '@renderer/types/panes'; import { formatShortcut } from '@renderer/utils/stringUtils'; -import { Check, ClipboardCopy, Eye, EyeOff, Pin, PinOff, Terminal } from 'lucide-react'; +import { + Check, + ClipboardCopy, + Eye, + EyeOff, + NotebookPen, + Pin, + PinOff, + Terminal, +} from 'lucide-react'; interface SessionContextMenuProps { x: number; @@ -23,6 +32,7 @@ interface SessionContextMenuProps { onOpenInCurrentPane: () => void; onOpenInNewTab: () => void; onSplitRightAndOpen: () => void; + onOpenScratchpad: () => void; onTogglePin: () => void; onToggleHide: () => void; } @@ -38,6 +48,7 @@ export const SessionContextMenu = ({ onOpenInCurrentPane, onOpenInNewTab, onSplitRightAndOpen, + onOpenScratchpad, onTogglePin, onToggleHide, }: SessionContextMenuProps): React.JSX.Element => { @@ -62,7 +73,7 @@ export const SessionContextMenu = ({ }, [onClose]); const menuWidth = 240; - const menuHeight = 290; + const menuHeight = 330; const clampedX = Math.min(x, window.innerWidth - menuWidth - 8); const clampedY = Math.min(y, window.innerHeight - menuHeight - 8); @@ -106,6 +117,11 @@ export const SessionContextMenu = ({ onClick={handleClick(onSplitRightAndOpen)} disabled={atMaxPanes} /> + } + onClick={handleClick(onOpenScratchpad)} + />
({ openTab: s.openTab, @@ -156,6 +157,7 @@ export const SessionItem = React.memo(function SessionItem({ splitPane: s.splitPane, togglePinSession: s.togglePinSession, toggleHideSession: s.toggleHideSession, + openScratchpadTab: s.openScratchpadTab, })) ); @@ -318,6 +320,7 @@ export const SessionItem = React.memo(function SessionItem({ onOpenInCurrentPane={handleOpenInCurrentPane} onOpenInNewTab={handleOpenInNewTab} onSplitRightAndOpen={handleSplitRightAndOpen} + onOpenScratchpad={() => openScratchpadTab(activeProjectId, session.id, sessionLabel)} onTogglePin={() => void togglePinSession(session.id)} onToggleHide={() => void toggleHideSession(session.id)} />, diff --git a/src/renderer/store/slices/tabSlice.ts b/src/renderer/store/slices/tabSlice.ts index bf7971b8..de1d1e25 100644 --- a/src/renderer/store/slices/tabSlice.ts +++ b/src/renderer/store/slices/tabSlice.ts @@ -47,6 +47,7 @@ export interface TabSlice { closeTab: (tabId: string) => void; setActiveTab: (tabId: string) => void; openDashboard: () => void; + openScratchpadTab: (projectId: string, sessionId: string, sessionLabel?: string) => void; getActiveTab: () => Tab | null; isSessionOpen: (sessionId: string) => boolean; enqueueTabNavigation: (tabId: string, request: TabNavigationRequest) => void; @@ -381,6 +382,25 @@ export const createTabSlice: StateCreator = (set, ge set(syncFromLayout(newLayout)); }, + openScratchpadTab: (projectId: string, sessionId: string, sessionLabel?: string) => { + if (!projectId || !sessionId) return; + const state = get(); + const existing = getAllTabs(state.paneLayout).find( + (t) => t.type === 'scratchpad' && t.sessionId === sessionId && t.projectId === projectId + ); + if (existing) { + state.setActiveTab(existing.id); + return; + } + const label = `Scratchpad · ${sessionLabel ?? sessionId.slice(0, 8)}`; + state.openTab({ + type: 'scratchpad', + projectId, + sessionId, + label: label.length > 50 ? `${label.slice(0, 49)}…` : label, + }); + }, + // Get the currently active tab (from the focused pane) getActiveTab: () => { const state = get(); diff --git a/src/renderer/types/tabs.ts b/src/renderer/types/tabs.ts index 1cc9a3df..d73f8934 100644 --- a/src/renderer/types/tabs.ts +++ b/src/renderer/types/tabs.ts @@ -78,12 +78,12 @@ export interface Tab { id: string; /** Type of content displayed in this tab */ - type: 'session' | 'dashboard' | 'notifications' | 'settings' | 'memory'; + type: 'session' | 'dashboard' | 'notifications' | 'settings' | 'memory' | 'scratchpad'; - /** Session ID (required when type === 'session') */ + /** Session ID (required when type === 'session' or 'scratchpad') */ sessionId?: string; - /** Project ID (required when type === 'session', or when type === 'memory') */ + /** Project ID (required when type === 'session', 'memory' or 'scratchpad') */ projectId?: string; /** Display name for the tab (max 50 chars) */ diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index ddeac691..22bd7b23 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -478,6 +478,9 @@ export interface ElectronAPI { // Memory API — per-project Claude memory viewer memory: MemoryAPI; + + // Scratchpad API — per-session Claude Code scratchpad directory viewer + scratchpad: ScratchpadAPI; } // ============================================================================= @@ -538,6 +541,58 @@ export interface MemoryAPI { onChanged: (callback: (event: { projectId: string }) => void) => () => void; } +// ============================================================================= +// Scratchpad API types +// ============================================================================= + +export interface ScratchpadEntry { + name: string; + /** Path relative to the scratchpad root, using `/` separators */ + relativePath: string; + isDirectory: boolean; + size: number; + mtimeMs: number; +} + +export type ScratchpadListResult = + | { + success: true; + rootPath: string; + exists: boolean; + entries: ScratchpadEntry[]; + truncated: boolean; + } + | { success: false; error: string }; + +export type ScratchpadFileContent = + | { kind: 'text'; content: string; size: number; mtimeMs: number; truncated: boolean } + | { kind: 'image'; dataUrl: string; size: number; mtimeMs: number } + | { kind: 'binary'; size: number; mtimeMs: number }; + +export type ScratchpadReadFileResult = + | { success: true; path: string; file: ScratchpadFileContent } + | { success: false; error: string }; + +export type ScratchpadOpenResult = { success: true } | { success: false; error: string }; + +export interface ScratchpadAPI { + list: ( + projectId: string, + sessionId: string, + relativeDir: string + ) => Promise; + readFile: ( + projectId: string, + sessionId: string, + relativePath: string + ) => Promise; + openPath: ( + projectId: string, + sessionId: string, + relativePath: string + ) => Promise; +} + // ============================================================================= // Window Type Extension // ============================================================================= diff --git a/test/main/services/discovery/ScratchpadReader.test.ts b/test/main/services/discovery/ScratchpadReader.test.ts new file mode 100644 index 00000000..e4b8e1c1 --- /dev/null +++ b/test/main/services/discovery/ScratchpadReader.test.ts @@ -0,0 +1,162 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { ScratchpadReader } from '../../../../src/main/services/discovery/ScratchpadReader'; + +const PROJECT_ID = '-Users-test-proj'; +const SESSION_ID = '5b345536-76de-41e2-8b92-b5f7e0aeff36'; + +describe('ScratchpadReader', () => { + const tempDirs: string[] = []; + + afterEach(() => { + for (const dir of tempDirs) { + fs.rmSync(dir, { recursive: true, force: true }); + } + tempDirs.length = 0; + }); + + function setup(files: Record = {}): { + tmpRoot: string; + scratchpad: string; + reader: ScratchpadReader; + } { + const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'scratchpad-reader-')); + tempDirs.push(tmpRoot); + const scratchpad = path.join(tmpRoot, PROJECT_ID, SESSION_ID, 'scratchpad'); + fs.mkdirSync(scratchpad, { recursive: true }); + for (const [relativePath, content] of Object.entries(files)) { + const target = path.join(scratchpad, relativePath); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, content); + } + return { tmpRoot, scratchpad, reader: new ScratchpadReader([tmpRoot]) }; + } + + it('reports a missing scratchpad without throwing', async () => { + const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'scratchpad-reader-')); + tempDirs.push(tmpRoot); + const reader = new ScratchpadReader([tmpRoot]); + + const listing = await reader.list(PROJECT_ID, SESSION_ID, ''); + + expect(listing.exists).toBe(false); + expect(listing.entries).toEqual([]); + expect(listing.rootPath).toBe(path.join(tmpRoot, PROJECT_ID, SESSION_ID, 'scratchpad')); + }); + + it('uses the base dir of a composite project id', () => { + const { reader, scratchpad } = setup(); + + expect(reader.getRootPath(`${PROJECT_ID}::abcd1234`, SESSION_ID)).toBe(scratchpad); + }); + + it('picks the first candidate root that holds the session', () => { + const { tmpRoot, scratchpad } = setup(); + const reader = new ScratchpadReader([path.join(tmpRoot, 'absent'), tmpRoot]); + + expect(reader.getRootPath(PROJECT_ID, SESSION_ID)).toBe(scratchpad); + }); + + it('lists one level, directories first, with posix relative paths', async () => { + const { reader } = setup({ + 'b.txt': 'b', + 'a.md': '# a', + 'sub/inner/deep.json': '{}', + 'sub/file10.txt': 'x', + 'sub/file2.txt': 'y', + }); + + const root = await reader.list(PROJECT_ID, SESSION_ID, ''); + expect(root.exists).toBe(true); + expect(root.entries.map((e) => [e.name, e.isDirectory])).toEqual([ + ['sub', true], + ['a.md', false], + ['b.txt', false], + ]); + + const sub = await reader.list(PROJECT_ID, SESSION_ID, 'sub'); + expect(sub.entries.map((e) => e.relativePath)).toEqual([ + 'sub/inner', + 'sub/file2.txt', + 'sub/file10.txt', + ]); + }); + + it('rejects paths that escape the scratchpad', async () => { + const { reader, tmpRoot } = setup({ 'ok.txt': 'ok' }); + fs.writeFileSync(path.join(tmpRoot, 'secret.txt'), 'secret'); + + await expect(reader.readFile(PROJECT_ID, SESSION_ID, '../../../secret.txt')).rejects.toThrow( + /escapes/ + ); + await expect( + reader.readFile(PROJECT_ID, SESSION_ID, path.join(tmpRoot, 'secret.txt')) + ).rejects.toThrow(/relative/); + await expect(reader.list(PROJECT_ID, SESSION_ID, '..')).rejects.toThrow(/escapes/); + }); + + it('rejects symlinks that point outside the scratchpad', async () => { + const { reader, tmpRoot, scratchpad } = setup(); + fs.writeFileSync(path.join(tmpRoot, 'secret.txt'), 'secret'); + fs.symlinkSync(path.join(tmpRoot, 'secret.txt'), path.join(scratchpad, 'link.txt')); + + await expect(reader.readFile(PROJECT_ID, SESSION_ID, 'link.txt')).rejects.toThrow(/escapes/); + }); + + it('reads text files', async () => { + const { reader } = setup({ 'notes/plan.md': '# Plan\n\nstep one' }); + + const { file } = await reader.readFile(PROJECT_ID, SESSION_ID, 'notes/plan.md'); + + expect(file).toMatchObject({ kind: 'text', content: '# Plan\n\nstep one', truncated: false }); + }); + + it('truncates large text files', async () => { + const { reader } = setup({ 'big.log': 'x'.repeat(600 * 1024) }); + + const { file } = await reader.readFile(PROJECT_ID, SESSION_ID, 'big.log'); + + expect(file.kind).toBe('text'); + if (file.kind !== 'text') return; + expect(file.truncated).toBe(true); + expect(file.content.length).toBe(512 * 1024); + expect(file.size).toBe(600 * 1024); + }); + + it('flags binary files instead of decoding them', async () => { + const { reader } = setup({ 'data.bin': Buffer.from([0x50, 0x4b, 0x03, 0x04, 0x00, 0x01]) }); + + const { file } = await reader.readFile(PROJECT_ID, SESSION_ID, 'data.bin'); + + expect(file).toMatchObject({ kind: 'binary', size: 6 }); + }); + + it('returns images as data URLs', async () => { + const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + const { reader } = setup({ 'shot.png': png }); + + const { file } = await reader.readFile(PROJECT_ID, SESSION_ID, 'shot.png'); + + expect(file).toMatchObject({ + kind: 'image', + dataUrl: `data:image/png;base64,${png.toString('base64')}`, + }); + }); + + it('reports a missing file plainly', async () => { + const { reader } = setup(); + + await expect(reader.readFile(PROJECT_ID, SESSION_ID, 'gone.md')).rejects.toThrow( + 'No longer exists: gone.md' + ); + }); + + it('refuses to read a directory as a file', async () => { + const { reader } = setup({ 'sub/x.txt': 'x' }); + + await expect(reader.readFile(PROJECT_ID, SESSION_ID, 'sub')).rejects.toThrow(/Not a file/); + }); +});