From 5f9545bea8f5d87e6315baf9497f3f03b4cb5cdc Mon Sep 17 00:00:00 2001 From: Harshit Gupta Date: Sat, 26 Sep 2026 09:02:08 +0530 Subject: [PATCH 1/2] feat(scratchpad): add per-session scratchpad viewer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude Code gives each session a private scratch directory at /claude-///scratchpad, where is CLAUDE_CODE_TMPDIR or /tmp. The session writes drafts, downloaded data, screenshots and throwaway scripts there, and none of it is visible from the transcript. Adds a scratchpad tab, opened from the session context menu or the ⋯ menu. ScratchpadReader lists one directory level at a time, since scratchpads can hold thousands of files, and resolves every path against the realpath of the root so '..' and symlinks cannot escape it. Text is capped at 512 KB, images are returned as data URLs, and binaries are detected and not decoded. The view polls expanded directories while visible so a live session's writes appear. SSH contexts are refused because the directory lives on the local machine. --- CHANGELOG.md | 1 + README.md | 5 + src/main/http/index.ts | 2 + src/main/http/scratchpad.ts | 64 +++ src/main/ipc/guards.ts | 32 ++ src/main/ipc/handlers.ts | 8 + src/main/ipc/scratchpad.ts | 123 +++++ .../services/discovery/ScratchpadReader.ts | 207 ++++++++ src/main/services/discovery/index.ts | 1 + src/preload/constants/ipcChannels.ts | 13 + src/preload/index.ts | 43 ++ src/renderer/api/httpClient.ts | 24 + src/renderer/components/layout/MoreMenu.tsx | 38 +- .../components/layout/PaneContent.tsx | 8 + .../components/layout/SortableTab.tsx | 13 +- .../components/scratchpad/ScratchpadView.tsx | 478 ++++++++++++++++++ .../components/sidebar/SessionContextMenu.tsx | 20 +- .../components/sidebar/SessionItem.tsx | 3 + src/renderer/store/slices/tabSlice.ts | 20 + src/renderer/types/tabs.ts | 6 +- src/shared/types/api.ts | 55 ++ .../discovery/ScratchpadReader.test.ts | 162 ++++++ 22 files changed, 1318 insertions(+), 8 deletions(-) create mode 100644 src/main/http/scratchpad.ts create mode 100644 src/main/ipc/scratchpad.ts create mode 100644 src/main/services/discovery/ScratchpadReader.ts create mode 100644 src/renderer/components/scratchpad/ScratchpadView.tsx create mode 100644 test/main/services/discovery/ScratchpadReader.test.ts 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..63044618 --- /dev/null +++ b/src/renderer/components/scratchpad/ScratchpadView.tsx @@ -0,0 +1,478 @@ +/** + * 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 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 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/); + }); +}); From de8f6cfeb73d24f03811af9d00bf8337492dec29 Mon Sep 17 00:00:00 2001 From: Harshit Gupta Date: Sat, 26 Sep 2026 09:31:08 +0530 Subject: [PATCH 2/2] feat(scratchpad): make the file tree resizable Drag the tree's right edge to resize it between 180 and 720 px; double-click the handle to reset it. Mirrors the main sidebar's resize handle. The tree header truncates instead of wrapping at narrow widths. --- .../components/scratchpad/ScratchpadView.tsx | 42 ++++++++++++++++++- 1 file changed, 40 insertions(+), 2 deletions(-) diff --git a/src/renderer/components/scratchpad/ScratchpadView.tsx b/src/renderer/components/scratchpad/ScratchpadView.tsx index 63044618..cb8ae05f 100644 --- a/src/renderer/components/scratchpad/ScratchpadView.tsx +++ b/src/renderer/components/scratchpad/ScratchpadView.tsx @@ -53,6 +53,9 @@ interface LoadedFile { 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; @@ -99,6 +102,27 @@ export const ScratchpadView = ({ 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 => { @@ -303,14 +327,15 @@ export const ScratchpadView = ({ return (
- + Scratchpad {fileCount > 0 && ({fileCount})} {canOpenInFileManager && ( @@ -343,6 +368,19 @@ export const ScratchpadView = ({
)}
{renderEntries(ROOT_DIR, 0)}
+