diff --git a/CLAUDE.md b/CLAUDE.md index 70b1d84..d6edf12 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -190,22 +190,40 @@ black for primary content. When adding a new surface, step up the gray ramp --- -## 4b. App shell layout (Codex-style) +## 4b. App shell layout (floating app shell) The shell lives in [`src/renderer/app/AppShell.tsx`](src/renderer/app/AppShell.tsx), -composed by [`src/renderer/App.tsx`](src/renderer/App.tsx). It is a single column -with a custom title bar on top and a horizontal row of resizable regions below it: +composed by [`src/renderer/App.tsx`](src/renderer/App.tsx). It is a **two-layer +floating app shell**: persistent chrome sits on the pure-black root background, +and the workspace floats above it as one detached card: ``` ┌──────────────────────────────────────────────────────────────┐ -│ TitleBar (frameless, draggable) [search][_][□][x] │ -├────────┬──────────────────────────────────────────┬──────────┤ -│Sessions│ header / conversation / Composer │ drawer │▐ rail -└────────┴──────────────────────────────────────────┴──────────┘ +│ TitleBar (root bg, frameless, draggable) [search][_][□][x] │ +│ ╷ ┌────────────────────────────────────────┐ │ +│Sessions│ │ header / conversation / Composer │drawer│ ▐ rail │ +│(root bg)│ │ floating card (bg-surface) │ (root bg)│ +│ ╵ └────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────────────────┘ ``` +- **Root background layer** (`bg-base`, borderless): the TitleBar, the Sessions + sidebar (+ its collapsed rail), and the right `ActivityRail` icon strip. They + visually merge with the black canvas — architectural, not content. +- **Floating workspace card**: the center column **and the right `ActivityDrawer` + together** — `bg-surface`, `border-line`, **`rounded-md` (strictly 6px)**, + `overflow-hidden`, framed by an 8px side gutter and a 16px bottom gutter + (`px-2 pb-4`, `pt-1` under the title bar) so it never touches the window + edges. Separation comes from the gutter + + border + surface step, not shadows (invisible on `#000` anyway). +- The sidebar↔card gutter doubles as the resize grab area (`ResizeHandle ghost`); + the card↔drawer divider stays a 1px `bg-line` handle **inside** the card, so + the drawer (and the full-bleed Terminal/Git/Memory panels) carry no `border-l` + of their own. - **Left + right columns are full height**; the **Composer lives only inside the - center column** (it does not span the whole window). + center column** (it does not span the whole window). Center-column surfaces sit + on `bg-surface` (the card), not `bg-base` — e.g. the composer fade is + `from-surface`. - **Right side = a fixed ~48px icon rail (`ActivityRail`) + a collapsible drawer (`ActivityDrawer`)**. Tabs: Files / Changes / Tasks / Activity. Clicking the active tab toggles the drawer closed (`activeTab` in the layout store, nullable); @@ -484,8 +502,17 @@ the real (no-mock) UI. Each owns one responsibility: `@electron/rebuild` accordingly. `createForCommand` runs one command in a PTY (`origin: 'hook' | 'service'`) with an exit callback; terminal `cwd` is the session's effective root. -- **File System Layer** (`managers/FileSystemManager.ts`) — `chokidar` watch + - tree index + guarded reads; pushes live git status into sessions. +- **File System Layer** (`managers/FileSystemManager.ts`) — the single gateway + for workspace file operations: `chokidar` watch + tree index + guarded reads + (`fs/reader.ts`) **+ guarded writes** (`fs/writer.ts`: atomic write / create + file / create dir / delete / rename+move / copy — workspace-boundary, + symlink-escape, and `.git`-segment protected, bounded by `FS_LIMITS`); + records mutations in the File History ring and pushes live git status into + sessions. Watcher bursts are batched (`WatchBatch`) so small file changes + reindex search **incrementally** (`SearchManager.indexFiles`) while + structural/large batches fall back to a coalesced full pass. The Files tree + has per-language icons (`renderer/lib/fileIcons.tsx`) and a right-click File + Writer context menu (`FileTreeMenu.tsx`). - **Agent Manager** (`managers/AgentManager.ts`) — drives `@anthropic-ai/claude- agent-sdk` (plan/implement modes), risk-gated `canUseTool`, path-guarded to the workspace, persists transcript/activity/diagnostics, resumes SDK sessions. @@ -562,9 +589,9 @@ ones at query time (memory, git, sessions, commands). **Still open / future** — Repository clone/track UI, a dedicated Permission System beyond the agent's `canUseTool`, merge-conflict resolution UI, remote management, and -stash. True per-file incremental search indexing (v1 does a coalesced full reindex on -change) and local vector embeddings on top of BM25 (both Memory and Search rankings -are already fusion-ready) are natural follow-ups. +stash. Local vector embeddings on top of BM25 (both Memory and Search rankings are +already fusion-ready) and recording File Writer mutations into the session activity +timeline (today they land in the in-memory File History ring) are natural follow-ups. --- diff --git a/docs/reference/window-limboo-api.md b/docs/reference/window-limboo-api.md index 076a41a..425181c 100644 --- a/docs/reference/window-limboo-api.md +++ b/docs/reference/window-limboo-api.md @@ -92,11 +92,18 @@ Coding-agent orchestration and the structured event stream. ## fs -File System Layer: read, watch, index. +File System Layer: read, write, watch, index. - `index(workspaceId)`, `getTree(workspaceId)`, `readFile(workspaceId, relPath)` - `getHistory(workspaceId)`, `reveal(workspaceId, relPath?)` -- `onIndexProgress(cb)`, `onTreeChanged(cb)` — subscriptions. +- `writeFile(workspaceId, relPath, content, opts?)`, `createFile(workspaceId, relPath)`, + `createDir(workspaceId, relPath)` — guarded File Writer mutations (atomic writes, + workspace-boundary + symlink + `.git` protection in main) +- `remove(workspaceId, relPath, opts?)` (non-empty dirs need `{ recursive: true }`), + `rename(workspaceId, fromRel, toRel, opts?)` (rename AND move), + `copy(workspaceId, fromRel, toRel, opts?)` +- `onIndexProgress(cb)`, `onTreeChanged(cb)` — subscriptions (mutations surface + through `onTreeChanged`; no dedicated mutation events). ## terminal diff --git a/src/main/ipc/fsHandlers.ts b/src/main/ipc/fsHandlers.ts index 3059081..46260fc 100644 --- a/src/main/ipc/fsHandlers.ts +++ b/src/main/ipc/fsHandlers.ts @@ -1,14 +1,22 @@ /** * IPC handlers for the File System Manager. Registered through `handle()`, so * every call inherits sender-origin validation. Inputs are validated here in the - * main process (CLAUDE.md §6): workspace ids are type/length-checked and the - * `fs:readFile` relative path is constrained to a bounded, NUL-free, relative, - * non-`..`-traversing string before it ever reaches the reader — so a malicious - * renderer can never widen a read outside the workspace root. + * main process (CLAUDE.md §6): workspace ids are type/length-checked and every + * relative path (read AND write, source AND destination) is constrained to a + * bounded, NUL-free, relative, non-`..`-traversing string before it ever reaches + * the reader/writer — so a malicious renderer can never widen an operation + * outside the workspace root. Mutation options are re-built from known boolean + * flags only (never spread) so renderer objects cannot smuggle keys. */ import { IpcChannels } from '@shared/ipc-channels'; import { FS_LIMITS } from '@shared/constants'; -import type { FileHistoryEntry, FileReadResult, FileTree } from '@shared/types'; +import type { + FileHistoryEntry, + FileReadResult, + FileTree, + FileWriteResult, + FsMutationOptions, +} from '@shared/types'; import { handle } from './registry'; import type { FileSystemManager } from '../managers/FileSystemManager'; import { logger } from '../logger'; @@ -21,24 +29,41 @@ function assertValidId(id: unknown): asserts id is string { /** * Validate a renderer-supplied relative file path before it is joined to a - * workspace root and read. Mirrors the ignored-dirs guard in workspaceHandlers: + * workspace root. Mirrors the ignored-dirs guard in workspaceHandlers: * bounded, NUL-free, relative only, and no upward (`..`) traversal segment. */ function assertValidRelPath(p: unknown): asserts p is string { if (typeof p !== 'string' || p.length === 0 || p.length > FS_LIMITS.relPathMax) { - throw new Error('fs:readFile invalid path'); + throw new Error('fs: invalid path'); } if (p.includes('\0')) { - throw new Error('fs:readFile path contains an invalid character'); + throw new Error('fs: path contains an invalid character'); } if (p.startsWith('/') || p.startsWith('\\') || /^[a-zA-Z]:/.test(p)) { - throw new Error('fs:readFile path must be relative'); + throw new Error('fs: path must be relative'); } if (p.split(/[/\\]/).some((seg) => seg === '..')) { - throw new Error('fs:readFile path must not traverse upward'); + throw new Error('fs: path must not traverse upward'); } } +/** Validate write content: a string within the File Writer's byte cap. */ +function assertValidContent(c: unknown): asserts c is string { + if (typeof c !== 'string') throw new Error('fs: invalid content'); + if (Buffer.byteLength(c, 'utf8') > FS_LIMITS.maxWriteBytes) { + throw new Error('fs: content too large'); + } +} + +/** + * Rebuild mutation options from known boolean flags ONLY — the renderer object + * is never spread or merged (prototype-pollution guard, CLAUDE.md §6). + */ +function sanitizeOpts(o: unknown): FsMutationOptions { + const v = (o ?? {}) as Record; + return { overwrite: v.overwrite === true, recursive: v.recursive === true }; +} + export function registerFsHandlers(fs: FileSystemManager): void { handle<[string], FileTree>(IpcChannels.fsIndex, async (_e, id) => { assertValidId(id); @@ -74,4 +99,65 @@ export function registerFsHandlers(fs: FileSystemManager): void { if (relPath !== undefined && relPath !== '') assertValidRelPath(relPath); await fs.reveal(id, relPath); }); + + // --- File Writer mutations. Every path below is re-validated by the writer's + // own containment/symlink/.git guards; this layer rejects malformed input + // before it ever reaches the manager. + + handle<[string, string, string, unknown], FileWriteResult>( + IpcChannels.fsWriteFile, + async (_e, id, relPath, content, opts) => { + assertValidId(id); + assertValidRelPath(relPath); + assertValidContent(content); + return fs.writeFile(id, relPath, content, sanitizeOpts(opts)); + }, + ); + + handle<[string, string], FileWriteResult>( + IpcChannels.fsCreateFile, + async (_e, id, relPath) => { + assertValidId(id); + assertValidRelPath(relPath); + return fs.createFile(id, relPath); + }, + ); + + handle<[string, string], FileWriteResult>( + IpcChannels.fsCreateDir, + async (_e, id, relPath) => { + assertValidId(id); + assertValidRelPath(relPath); + return fs.createDir(id, relPath); + }, + ); + + handle<[string, string, unknown], void>( + IpcChannels.fsDelete, + async (_e, id, relPath, opts) => { + assertValidId(id); + assertValidRelPath(relPath); + await fs.deleteEntry(id, relPath, sanitizeOpts(opts)); + }, + ); + + handle<[string, string, string, unknown], FileWriteResult>( + IpcChannels.fsRename, + async (_e, id, fromRel, toRel, opts) => { + assertValidId(id); + assertValidRelPath(fromRel); + assertValidRelPath(toRel); + return fs.rename(id, fromRel, toRel, sanitizeOpts(opts)); + }, + ); + + handle<[string, string, string, unknown], FileWriteResult>( + IpcChannels.fsCopy, + async (_e, id, fromRel, toRel, opts) => { + assertValidId(id); + assertValidRelPath(fromRel); + assertValidRelPath(toRel); + return fs.copy(id, fromRel, toRel, sanitizeOpts(opts)); + }, + ); } diff --git a/src/main/managers/FileSystemManager.ts b/src/main/managers/FileSystemManager.ts index d2407e5..f315cb2 100644 --- a/src/main/managers/FileSystemManager.ts +++ b/src/main/managers/FileSystemManager.ts @@ -3,12 +3,13 @@ * workspace file operation passes (the File Explorer Service of CLAUDE.md §8). * Lives in the main process; the renderer and agent reach it only via IPC. * - * Responsibilities (read + watch + index foundation): build & cache the directory - * tree, read files through the centralized reader, watch the active workspace for - * external changes, maintain per-workspace File History, and broadcast progress + - * tree-changed events to every window. Mutating operations (write/create/delete/ - * rename/move/copy) are intentionally NOT implemented here yet — they are gated - * behind the future Permission Engine. + * Responsibilities (read + write + watch + index): build & cache the directory + * tree, read files through the centralized reader, mutate files through the + * guarded File Writer (write/create/delete/rename/copy — atomic, boundary- and + * symlink-checked, `.git` protected), watch the active workspace for external + * changes, maintain per-workspace File History, and broadcast progress + + * tree-changed events to every window. Watched change bursts are batched so the + * Search Engine can reindex incrementally (full pass on structural changes). * * Security (CLAUDE.md §6): all path/boundary/symlink/ignore/cap enforcement lives * in the modules under `fs/`; this class never logs file contents and disposes @@ -17,10 +18,13 @@ import path from 'node:path'; import { BrowserWindow, shell } from 'electron'; import { IpcEvents } from '@shared/ipc-channels'; +import { FS_LIMITS } from '@shared/constants'; import type { FileHistoryEntry, FileReadResult, FileTree, + FileWriteResult, + FsMutationOptions, IndexProgress, Workspace, } from '@shared/types'; @@ -30,8 +34,16 @@ import type { SessionManager } from './SessionManager'; import { buildIgnoreMatcher, type IgnoreMatcher } from './fs/ignore'; import { buildTree } from './fs/tree'; import { readWorkspaceFile } from './fs/reader'; +import { + copyWorkspaceEntry, + createWorkspaceDir, + createWorkspaceFile, + deleteWorkspaceEntry, + renameWorkspaceEntry, + writeWorkspaceFile, +} from './fs/writer'; import { FileHistory } from './fs/history'; -import { WorkspaceWatcher } from './fs/watcher'; +import { WorkspaceWatcher, type WatchBatch } from './fs/watcher'; import { gitStatus } from './git/status'; import type { GitManager } from './GitManager'; import type { SearchManager } from './search/SearchManager'; @@ -121,6 +133,92 @@ export class FileSystemManager { shell.showItemInFolder(target); } + /* ------------------------------------------------------- mutations */ + + /** Atomically write a UTF-8 file through the guarded File Writer. */ + async writeFile( + workspaceId: string, + relPath: string, + content: string, + opts: FsMutationOptions = {}, + ): Promise { + const ws = this.requireWorkspace(workspaceId); + const result = writeWorkspaceFile(this.rootFor(ws), relPath, content, { + overwrite: opts.overwrite, + }); + this.historyFor(workspaceId).record(result.path, 'write'); + this.afterMutation(ws, [result.path]); + return result; + } + + /** Create an empty file (fails if anything exists at the path). */ + async createFile(workspaceId: string, relPath: string): Promise { + const ws = this.requireWorkspace(workspaceId); + const result = createWorkspaceFile(this.rootFor(ws), relPath); + this.historyFor(workspaceId).record(result.path, 'create'); + this.afterMutation(ws, [result.path]); + return result; + } + + /** Create a directory (and missing intermediates) inside the workspace. */ + async createDir(workspaceId: string, relPath: string): Promise { + const ws = this.requireWorkspace(workspaceId); + const result = createWorkspaceDir(this.rootFor(ws), relPath); + this.historyFor(workspaceId).record(result.path, 'create'); + // Directory creation is structural — the search pass resolves it wholesale. + this.afterMutation(ws, []); + return result; + } + + /** Delete a file/symlink/directory (non-empty dirs require `recursive`). */ + async deleteEntry( + workspaceId: string, + relPath: string, + opts: FsMutationOptions = {}, + ): Promise { + const ws = this.requireWorkspace(workspaceId); + deleteWorkspaceEntry(this.rootFor(ws), relPath, { recursive: opts.recursive }); + const posix = relPath.split(path.sep).join('/'); + this.historyFor(workspaceId).record(posix, 'delete'); + // Deletes may remove whole subtrees — treat as structural for search. + this.afterMutation(ws, opts.recursive ? [] : [posix]); + } + + /** Rename or move an entry (destination is a full workspace-relative path). */ + async rename( + workspaceId: string, + fromRel: string, + toRel: string, + opts: FsMutationOptions = {}, + ): Promise { + const ws = this.requireWorkspace(workspaceId); + const result = renameWorkspaceEntry(this.rootFor(ws), fromRel, toRel, { + overwrite: opts.overwrite, + }); + this.historyFor(workspaceId).record(result.path, 'rename'); + // A dir rename moves every descendant path — only a file rename is a + // two-path incremental update. + const fromPosix = fromRel.split(path.sep).join('/'); + this.afterMutation(ws, result.size === undefined ? [] : [fromPosix, result.path]); + return result; + } + + /** Copy a file or (bounded) directory inside the workspace. */ + async copy( + workspaceId: string, + fromRel: string, + toRel: string, + opts: FsMutationOptions = {}, + ): Promise { + const ws = this.requireWorkspace(workspaceId); + const result = copyWorkspaceEntry(this.rootFor(ws), fromRel, toRel, { + overwrite: opts.overwrite, + }); + this.historyFor(workspaceId).record(result.path, 'copy'); + this.afterMutation(ws, result.size === undefined ? [] : [result.path]); + return result; + } + /* --------------------------------------------------------- indexing */ /** @@ -204,7 +302,7 @@ export class FileSystemManager { this.activeWatcher = new WorkspaceWatcher({ root: nextRoot, matcher: this.matcherFor(ws, nextRoot), - onChange: () => this.onWatchedChange(ws.id), + onChange: (batch) => this.onWatchedChange(ws.id, batch), }); this.activeWatcher.start(); @@ -235,7 +333,7 @@ export class FileSystemManager { /* --------------------------------------------------------- internals */ - private onWatchedChange(workspaceId: string): void { + private onWatchedChange(workspaceId: string, batch: WatchBatch): void { this.historyFor(workspaceId).record('', 'change'); // A change burst settled — rebuild and re-push the tree. Re-indexing also // re-emits progress, which the UI treats as a brief refresh. @@ -245,10 +343,35 @@ export class FileSystemManager { if (ws) this.refreshGitStatus(ws); // Refresh the Git workspace (status/diff lists) live as the tree changes. this.git?.notifyChanged(workspaceId); - // Rebuild the search index (coalesced; off the hot path — errors are logged). - void this.search?.indexWorkspace(workspaceId).catch((err) => - logger.warn('search reindex on change failed', err), - ); + // Refresh the search index: incremental for small file batches, full pass + // for structural/large ones (coalesced; off the hot path — errors logged). + this.scheduleSearchIndex(workspaceId, batch.paths, batch.structural); + } + + /** + * Post-mutation coordination — the same signal path a watched external change + * takes, fed with the known changed paths. The watcher will also echo the + * mutation; its debounce coalesces the double signal harmlessly. + */ + private afterMutation(ws: Workspace, changedRelPaths: string[]): void { + void this.index(ws.id).catch((err) => logger.warn('reindex after mutation failed', err)); + this.refreshGitStatus(ws); + this.git?.notifyChanged(ws.id); + this.scheduleSearchIndex(ws.id, changedRelPaths, changedRelPaths.length === 0); + } + + /** Route a change set to the incremental or full search index pass. */ + private scheduleSearchIndex(workspaceId: string, paths: string[], structural: boolean): void { + if (!this.search) return; + if (structural || paths.length === 0 || paths.length > FS_LIMITS.incrementalIndexMax) { + void this.search.indexWorkspace(workspaceId).catch((err) => + logger.warn('search reindex on change failed', err), + ); + } else { + void this.search.indexFiles(workspaceId, paths).catch((err) => + logger.warn('incremental search index failed', err), + ); + } } /** diff --git a/src/main/managers/fs/watcher.ts b/src/main/managers/fs/watcher.ts index 772df57..168a38f 100644 --- a/src/main/managers/fs/watcher.ts +++ b/src/main/managers/fs/watcher.ts @@ -15,11 +15,22 @@ import { FS_LIMITS } from '@shared/constants'; import { logger } from '../../logger'; import type { IgnoreMatcher } from './ignore'; +/** One settled burst of filesystem events, batched for incremental indexing. */ +export interface WatchBatch { + /** Workspace-relative POSIX file paths touched in the settle window. */ + paths: string[]; + /** + * True when a directory-level event (addDir/unlinkDir) occurred or the batch + * overflowed {@link FS_LIMITS.watchBatchMax} — callers should do a full pass. + */ + structural: boolean; +} + export interface WorkspaceWatcherOptions { root: string; matcher: IgnoreMatcher; /** Debounced callback fired after a burst of filesystem events settles. */ - onChange: () => void; + onChange: (batch: WatchBatch) => void; } /** @@ -30,6 +41,10 @@ export interface WorkspaceWatcherOptions { export class WorkspaceWatcher { private watcher: FSWatcher | null = null; private timer: NodeJS.Timeout | null = null; + /** Distinct rel-POSIX file paths accumulated in the current settle window. */ + private pending = new Set(); + /** Set on dir-level events / overflow — the batch needs a full pass. */ + private structural = false; constructor(private readonly options: WorkspaceWatcherOptions) {} @@ -54,20 +69,41 @@ export class WorkspaceWatcher { if (this.timer) clearTimeout(this.timer); this.timer = setTimeout(() => { this.timer = null; + const batch = { paths: [...this.pending], structural: this.structural }; + this.pending.clear(); + this.structural = false; try { - this.options.onChange(); + this.options.onChange(batch); } catch (err) { logger.warn('workspace watcher onChange failed', err); } }, FS_LIMITS.watchDebounceMs); }; + // Accumulate the workspace-relative POSIX path of a file-level event; past + // the batch cap the window degrades to a structural (full-pass) signal. + const collect = (entry: string) => { + const rel = path.relative(root, entry); + if (!rel || rel.startsWith('..')) { + this.structural = true; + } else if (this.pending.size >= FS_LIMITS.watchBatchMax) { + this.structural = true; + } else { + this.pending.add(rel.split(path.sep).join('/')); + } + schedule(); + }; + const collectStructural = () => { + this.structural = true; + schedule(); + }; + this.watcher - .on('add', schedule) - .on('change', schedule) - .on('unlink', schedule) - .on('addDir', schedule) - .on('unlinkDir', schedule) + .on('add', collect) + .on('change', collect) + .on('unlink', collect) + .on('addDir', collectStructural) + .on('unlinkDir', collectStructural) .on('error', (err) => logger.warn('workspace watcher error', err)); } @@ -76,6 +112,8 @@ export class WorkspaceWatcher { clearTimeout(this.timer); this.timer = null; } + this.pending.clear(); + this.structural = false; if (this.watcher) { const w = this.watcher; this.watcher = null; diff --git a/src/main/managers/fs/writer.ts b/src/main/managers/fs/writer.ts new file mode 100644 index 0000000..d85ac49 --- /dev/null +++ b/src/main/managers/fs/writer.ts @@ -0,0 +1,309 @@ +/** + * Centralized File Writer. The renderer never mutates files directly — every + * write/create/delete/rename/copy passes through here so validation, boundary + * enforcement, atomicity, and history recording all stay in one place (the + * mutation half of the File System Layer gateway). + * + * Security (CLAUDE.md §6): + * - the workspace root itself is never a valid mutation target; + * - every path (source AND destination) is resolved against the root and must + * stay inside it ({@link isInsideRoot}); + * - because a target may not exist yet, the deepest EXISTING ancestor is + * `realpath`ed and re-checked for containment, so a symlinked directory can + * never redirect a mutation outside the workspace; + * - writes refuse to go through a symlink; delete/rename operate on the link + * itself and never follow it; recursive copies skip symlinks entirely; + * - any path containing a `.git` segment is rejected (repo integrity); + * - writes are atomic (temp sibling + rename) and capped at + * {@link FS_LIMITS.maxWriteBytes}; recursive delete/copy are bounded by + * {@link FS_LIMITS.maxCopyEntries}. + * + * File contents are never logged. + */ +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import { FS_LIMITS } from '@shared/constants'; +import type { FileWriteResult } from '@shared/types'; +import { isInsideRoot } from '../workspace/validate'; + +/** Thrown when a mutation is rejected for a security/validation reason. */ +export class FileWriteError extends Error { + constructor(message: string) { + super(message); + this.name = 'FileWriteError'; + } +} + +/** Workspace-relative POSIX form of an absolute path under `root`. */ +function toPosixRel(root: string, target: string): string { + return path.relative(root, target).split(path.sep).join('/'); +} + +function lstatOrNull(target: string): fs.Stats | null { + try { + return fs.lstatSync(target); + } catch { + return null; + } +} + +/** + * Resolve a mutation target inside the workspace root, or throw. `relPath` is + * expected to already be a bounded, NUL-free, relative, non-traversing string + * (validated at the IPC boundary); every check here is defense in depth. + */ +function resolveMutationTarget(root: string, relPath: string): string { + const rel = (relPath ?? '').trim(); + if (!rel) throw new FileWriteError('Refusing to mutate the workspace root.'); + + const segments = rel.split(/[\\/]+/).filter((s) => s.length > 0 && s !== '.'); + if (segments.length === 0) throw new FileWriteError('Refusing to mutate the workspace root.'); + if (segments.some((s) => s === '..')) { + throw new FileWriteError('Path escapes the workspace root.'); + } + if (segments.some((s) => s.toLowerCase() === '.git')) { + throw new FileWriteError('Refusing to touch the .git directory.'); + } + + const target = path.resolve(root, segments.join(path.sep)); + if (!isInsideRoot(root, target) || target === path.resolve(root)) { + throw new FileWriteError('Path escapes the workspace root.'); + } + + // The target may not exist yet, so realpath the deepest EXISTING ancestor and + // re-assert containment against the REAL root — a symlinked directory in the + // chain can never redirect the mutation outside the workspace. + let realRoot: string; + try { + realRoot = fs.realpathSync(root); + } catch { + throw new FileWriteError('Workspace root is not accessible.'); + } + let probe = target; + for (;;) { + let real: string | null = null; + try { + real = fs.realpathSync(probe); + } catch { + real = null; + } + if (real !== null) { + if (!isInsideRoot(realRoot, real)) { + throw new FileWriteError('Resolved path escapes the workspace root.'); + } + break; + } + const parent = path.dirname(probe); + if (parent === probe) break; // hit the filesystem root — nothing existed + probe = parent; + } + return target; +} + +/** Write `content` to a temp sibling, then atomically rename it into place. */ +function atomicWrite(target: string, content: string): void { + const tmp = path.join( + path.dirname(target), + `.limboo-tmp-${crypto.randomBytes(6).toString('hex')}`, + ); + let committed = false; + try { + fs.writeFileSync(tmp, content, { encoding: 'utf8', flag: 'wx' }); + // rename() replaces an existing file atomically on both POSIX and Windows. + fs.renameSync(tmp, target); + committed = true; + } finally { + if (!committed) { + try { + fs.unlinkSync(tmp); + } catch { + /* temp never created or already gone */ + } + } + } +} + +/** Count entries under a directory, throwing past `FS_LIMITS.maxCopyEntries`. */ +function assertBoundedSubtree(dir: string, budget: { left: number }): void { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + budget.left -= 1; + if (budget.left < 0) { + throw new FileWriteError('Directory has too many entries for this operation.'); + } + if (entry.isDirectory() && !entry.isSymbolicLink()) { + assertBoundedSubtree(path.join(dir, entry.name), budget); + } + } +} + +/** True when `child` is the same path as `parent` or nested anywhere under it. */ +function isSameOrSubpath(parent: string, child: string): boolean { + const rel = path.relative(parent, child); + return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel)); +} + +function requireParentDir(target: string): void { + const parent = lstatOrNull(path.dirname(target)); + if (!parent || !parent.isDirectory()) { + throw new FileWriteError('Parent directory does not exist.'); + } +} + +/** Atomically write a UTF-8 file inside the workspace (temp sibling + rename). */ +export function writeWorkspaceFile( + root: string, + relPath: string, + content: string, + opts: { overwrite?: boolean } = {}, +): FileWriteResult { + if (Buffer.byteLength(content, 'utf8') > FS_LIMITS.maxWriteBytes) { + throw new FileWriteError('Content exceeds the write size cap.'); + } + const target = resolveMutationTarget(root, relPath); + const existing = lstatOrNull(target); + if (existing) { + if (existing.isSymbolicLink()) { + throw new FileWriteError('Refusing to write through a symlink.'); + } + if (!existing.isFile()) { + throw new FileWriteError('Destination exists and is not a regular file.'); + } + if (!opts.overwrite) { + throw new FileWriteError('File already exists.'); + } + } + requireParentDir(target); + atomicWrite(target, content); + return { path: toPosixRel(root, target), size: fs.statSync(target).size, created: !existing }; +} + +/** Create an empty file; fails if anything already exists at the path. */ +export function createWorkspaceFile(root: string, relPath: string): FileWriteResult { + const target = resolveMutationTarget(root, relPath); + if (lstatOrNull(target)) throw new FileWriteError('An entry already exists at that path.'); + requireParentDir(target); + atomicWrite(target, ''); + return { path: toPosixRel(root, target), size: 0, created: true }; +} + +/** Create a directory (and missing intermediates) inside the workspace. */ +export function createWorkspaceDir(root: string, relPath: string): FileWriteResult { + const target = resolveMutationTarget(root, relPath); + if (lstatOrNull(target)) throw new FileWriteError('An entry already exists at that path.'); + fs.mkdirSync(target, { recursive: true }); + return { path: toPosixRel(root, target), created: true }; +} + +/** + * Delete a file, symlink (the link itself, never its target), or directory. + * Non-empty directories require `recursive: true` and stay within the entry cap. + */ +export function deleteWorkspaceEntry( + root: string, + relPath: string, + opts: { recursive?: boolean } = {}, +): void { + const target = resolveMutationTarget(root, relPath); + const stat = lstatOrNull(target); + if (!stat) throw new FileWriteError('No such file or directory.'); + + if (stat.isDirectory()) { + if (!opts.recursive) { + if (fs.readdirSync(target).length > 0) { + throw new FileWriteError('Directory is not empty (pass recursive).'); + } + fs.rmdirSync(target); + return; + } + assertBoundedSubtree(target, { left: FS_LIMITS.maxCopyEntries }); + fs.rmSync(target, { recursive: true, force: false }); + return; + } + fs.rmSync(target, { force: false }); +} + +/** + * Rename or move an entry (the destination is a full workspace-relative path). + * Symlinks are moved as links; overwrite only ever replaces file with file. + */ +export function renameWorkspaceEntry( + root: string, + fromRel: string, + toRel: string, + opts: { overwrite?: boolean } = {}, +): FileWriteResult { + const from = resolveMutationTarget(root, fromRel); + const to = resolveMutationTarget(root, toRel); + const source = lstatOrNull(from); + if (!source) throw new FileWriteError('No such file or directory.'); + if (isSameOrSubpath(from, to)) { + throw new FileWriteError('Destination is inside the source path.'); + } + const dest = lstatOrNull(to); + if (dest) { + if (!opts.overwrite) throw new FileWriteError('Destination already exists.'); + if (!source.isFile() || !dest.isFile()) { + throw new FileWriteError('Only a file may replace an existing file.'); + } + } + requireParentDir(to); + fs.renameSync(from, to); + return { + path: toPosixRel(root, to), + size: source.isFile() ? source.size : undefined, + created: !dest, + }; +} + +/** + * Copy a file or directory inside the workspace. Directory copies are bounded + * by the entry cap and skip symlinks entirely; symlink sources are refused. + */ +export function copyWorkspaceEntry( + root: string, + fromRel: string, + toRel: string, + opts: { overwrite?: boolean } = {}, +): FileWriteResult { + const from = resolveMutationTarget(root, fromRel); + const to = resolveMutationTarget(root, toRel); + const source = lstatOrNull(from); + if (!source) throw new FileWriteError('No such file or directory.'); + if (source.isSymbolicLink()) throw new FileWriteError('Refusing to copy a symlink.'); + if (isSameOrSubpath(from, to)) { + throw new FileWriteError('Destination is inside the source path.'); + } + const dest = lstatOrNull(to); + + if (source.isFile()) { + if (dest) { + if (!opts.overwrite) throw new FileWriteError('Destination already exists.'); + if (!dest.isFile()) throw new FileWriteError('Only a file may replace an existing file.'); + } + requireParentDir(to); + fs.copyFileSync(from, to, opts.overwrite ? 0 : fs.constants.COPYFILE_EXCL); + return { path: toPosixRel(root, to), size: fs.statSync(to).size, created: !dest }; + } + + if (!source.isDirectory()) throw new FileWriteError('Source is not a file or directory.'); + if (dest) throw new FileWriteError('Destination already exists.'); + const budget = { left: FS_LIMITS.maxCopyEntries }; + assertBoundedSubtree(from, budget); + requireParentDir(to); + copyDirRecursive(from, to); + return { path: toPosixRel(root, to), created: true }; +} + +/** Bounded recursive directory copy (symlinks skipped, never followed). */ +function copyDirRecursive(from: string, to: string): void { + fs.mkdirSync(to); + for (const entry of fs.readdirSync(from, { withFileTypes: true })) { + if (entry.isSymbolicLink()) continue; + const src = path.join(from, entry.name); + const dst = path.join(to, entry.name); + if (entry.isDirectory()) copyDirRecursive(src, dst); + else if (entry.isFile()) fs.copyFileSync(src, dst, fs.constants.COPYFILE_EXCL); + } +} diff --git a/src/main/managers/search/SearchManager.ts b/src/main/managers/search/SearchManager.ts index dd1ce24..6d9e033 100644 --- a/src/main/managers/search/SearchManager.ts +++ b/src/main/managers/search/SearchManager.ts @@ -123,6 +123,8 @@ export class SearchManager { /** In-flight index passes, keyed by workspace id — concurrent callers coalesce. */ private readonly indexing = new Map>(); + /** Per-workspace incremental-pass chain so file batches apply in order. */ + private readonly incremental = new Map>(); /** Workspaces whose index is fresh this session (drives status). */ private readonly indexed = new Set(); private readonly gitCache = new Map(); @@ -177,6 +179,135 @@ export class SearchManager { } } + /** + * Incrementally upsert/delete individual files in the index — the fast path + * the watcher takes for small change batches (large/structural batches go + * through {@link indexWorkspace}). Per-workspace batches are chained so they + * apply in order, and a full pass in flight is awaited first so the + * incremental update never races the walk. + */ + async indexFiles(workspaceId: string, relPaths: string[]): Promise { + if (!this.settings.getAll().search.enabled) return; + const prev = this.incremental.get(workspaceId) ?? Promise.resolve(); + const run = prev + .catch(() => undefined) + .then(() => this.runIncremental(workspaceId, relPaths)); + this.incremental.set(workspaceId, run); + try { + await run; + } finally { + if (this.incremental.get(workspaceId) === run) this.incremental.delete(workspaceId); + } + } + + private async runIncremental(workspaceId: string, relPaths: string[]): Promise { + // A full pass in flight may predate these changes — let it finish, then + // re-apply the per-file updates on top. + const inFlight = this.indexing.get(workspaceId); + if (inFlight) await inFlight.catch(() => undefined); + + const ws = this.workspace.getById(workspaceId); + if (!ws) return; + // Never been indexed — an incremental patch has no base; do the full pass. + if (!this.indexed.has(workspaceId) && this.getStatus(workspaceId).files === 0) { + return this.indexWorkspace(workspaceId); + } + + const cfg = this.settings.getAll().search; + const root = this.activeRootResolver?.(workspaceId) ?? ws.path; + const matcher = cfg.includeIgnored ? null : buildIgnoreMatcher(root, ws.config); + const maxBytes = Math.min(cfg.maxFileSizeKb * 1024, FS_LIMITS.maxReadBytes); + const now = Date.now(); + + // Re-validate every path even though batches come from our own watcher + // (defense in depth): bounded, relative, contained, not always-ignored. + const paths: string[] = []; + for (const rel of new Set(relPaths)) { + if (paths.length >= FS_LIMITS.incrementalIndexMax) break; + if (typeof rel !== 'string' || !rel || rel.length > FS_LIMITS.relPathMax) continue; + const segments = rel.split('/'); + if (segments.some((s) => s === '..' || ALWAYS_IGNORE.has(s))) continue; + if (path.isAbsolute(rel) || /^[a-zA-Z]:/.test(rel)) continue; + if (!isInsideRoot(root, path.resolve(root, rel))) continue; + if (matcher && matcher.ignores(rel)) continue; + paths.push(rel); + } + if (paths.length === 0) return; + + const deleteFile = this.db.prepare( + 'DELETE FROM search_files WHERE workspace_id = ? AND path = ?', + ); + const deleteSymbols = this.db.prepare( + 'DELETE FROM search_symbols WHERE workspace_id = ? AND path = ?', + ); + const insertFile = this.db.prepare( + `INSERT INTO search_files (id, workspace_id, path, lang, size, content, updated_at) + VALUES (@id, @workspace_id, @path, @lang, @size, @content, @updated_at)`, + ); + const insertSymbol = this.db.prepare( + `INSERT INTO search_symbols (id, workspace_id, path, name, kind, lang, line, signature, updated_at) + VALUES (@id, @workspace_id, @path, @name, @kind, @lang, @line, @signature, @updated_at)`, + ); + + const tx = this.db.transaction(() => { + for (const rel of paths) { + // Delete-then-insert: a deleted/unreadable file simply stays deleted. + deleteFile.run(workspaceId, rel); + deleteSymbols.run(workspaceId, rel); + + let res: ReturnType; + try { + res = readWorkspaceFile(root, rel); + } catch { + continue; // gone or unreadable — rows stay removed + } + const lang = langForPath(rel) ?? null; + let content = ''; + if ( + cfg.indexContents && + res.content && + !res.isBinary && + !res.tooLarge && + res.size <= maxBytes + ) { + content = res.content.slice(0, SEARCH_LIMITS.contentIndexChars); + } + insertFile.run({ + id: crypto.randomUUID(), + workspace_id: workspaceId, + path: rel, + lang, + size: res.size, + content, + updated_at: now, + }); + if (content) { + for (const sym of extractSymbols(content, lang ?? undefined)) { + insertSymbol.run({ + id: crypto.randomUUID(), + workspace_id: workspaceId, + path: rel, + name: sym.name, + kind: sym.kind, + lang, + line: sym.line, + signature: sym.signature, + updated_at: now, + }); + } + } + } + }); + try { + tx(); + } catch (err) { + logger.warn('search: incremental index failed', err); + return; + } + // Sub-second and silent — no progress sweep; just refresh consumers. + this.notifyChanged(); + } + private async runIndex(workspaceId: string): Promise { const ws = this.workspace.getById(workspaceId); if (!ws) return; diff --git a/src/main/managers/search/query.ts b/src/main/managers/search/query.ts index 20654fc..8b17144 100644 --- a/src/main/managers/search/query.ts +++ b/src/main/managers/search/query.ts @@ -13,9 +13,17 @@ export { toFtsQuery }; /** Lowercased detected language for a workspace-relative path, or undefined. */ export function langForPath(path: string): string | undefined { - const dot = path.lastIndexOf('.'); + const slash = path.lastIndexOf('/'); + const base = (slash >= 0 ? path.slice(slash + 1) : path).toLowerCase(); + // Well-known basenames first (Dockerfile, Makefile, .env, …), including + // dotted variants like `dockerfile.dev` / `.env.local`. + const named = FILE_LANG[base]; + if (named) return named; + if (base.startsWith('dockerfile.')) return 'dockerfile'; + if (base.startsWith('.env.')) return 'ini'; + const dot = base.lastIndexOf('.'); if (dot < 0) return undefined; - const ext = path.slice(dot + 1).toLowerCase(); + const ext = base.slice(dot + 1); return EXT_LANG[ext]; } @@ -88,10 +96,78 @@ const EXT_LANG: Record = { sql: 'sql', vue: 'vue', svelte: 'svelte', + astro: 'astro', + dart: 'dart', + lua: 'lua', + r: 'r', + pl: 'perl', + pm: 'perl', + ex: 'elixir', + exs: 'elixir', + erl: 'erlang', + hs: 'haskell', + clj: 'clojure', + cljs: 'clojure', + groovy: 'groovy', + gradle: 'groovy', + ps1: 'powershell', + psm1: 'powershell', + psd1: 'powershell', + bat: 'batch', + cmd: 'batch', + fish: 'shell', + ini: 'ini', + cfg: 'ini', + conf: 'ini', + properties: 'ini', + env: 'ini', + graphql: 'graphql', + gql: 'graphql', + proto: 'protobuf', + prisma: 'prisma', + tf: 'terraform', + tfvars: 'terraform', + xml: 'xml', + plist: 'xml', + csproj: 'xml', + xaml: 'xml', + less: 'css', + styl: 'css', + sass: 'scss', + zig: 'zig', + nim: 'nim', + jl: 'julia', + m: 'objective-c', + mm: 'objective-c', + sol: 'solidity', + txt: 'text', + rst: 'restructuredtext', + tex: 'latex', + cmake: 'cmake', + mk: 'make', +}; + +/** + * Well-known basenames that carry a language without (or despite) an extension. + * Consulted before the extension map; keys are lowercased basenames. + */ +const FILE_LANG: Record = { + dockerfile: 'dockerfile', + makefile: 'make', + 'cmakelists.txt': 'cmake', + gemfile: 'ruby', + rakefile: 'ruby', + 'go.mod': 'go', + 'go.sum': 'go', + '.gitignore': 'gitignore', + '.gitattributes': 'gitignore', + '.env': 'ini', + 'limboo.json': 'json', }; /** True for text extensions treated as documentation (drives the `doc` kind). */ export function isDocPath(path: string): boolean { const lang = langForPath(path); - return lang === 'markdown' || /(^|\/)(readme|changelog|contributing|license)/i.test(path); + if (lang === 'markdown' || lang === 'restructuredtext') return true; + return /(^|\/)(readme|changelog|contributing|license|docs\/)/i.test(path); } diff --git a/src/main/managers/search/symbols.ts b/src/main/managers/search/symbols.ts index 91d1353..d7acc63 100644 --- a/src/main/managers/search/symbols.ts +++ b/src/main/managers/search/symbols.ts @@ -65,7 +65,8 @@ export function extractSymbols(content: string, lang: string | undefined): Extra } function isIdentifier(s: string): boolean { - return s.length > 0 && s.length <= 128 && /^[A-Za-z_$][\w$]*$/.test(s); + // Internal hyphens are allowed for PowerShell-style Verb-Noun names. + return s.length > 0 && s.length <= 128 && /^[A-Za-z_$][\w$]*(?:-[\w$]+)*$/.test(s); } /* -------------------------------------------------------- language rule sets */ @@ -121,6 +122,15 @@ const RUBY_RULES: Rule[] = [ { re: /^\s*def\s+([A-Za-z_][\w?!]*)/, kind: 'method' }, ]; +const POWERSHELL_RULES: Rule[] = [ + { re: /^\s*function\s+(?:[A-Za-z]+:)?([A-Za-z_][\w-]*)/i, kind: 'function' }, + { re: /^\s*class\s+([A-Za-z_][\w]*)/i, kind: 'class' }, +]; + +const LUA_RULES: Rule[] = [ + { re: /^\s*(?:local\s+)?function\s+(?:[\w.:]+[.:])?([A-Za-z_][\w]*)/, kind: 'function' }, +]; + /** Conservative fallback for languages without a dedicated rule set. */ const GENERIC_RULES: Rule[] = [ { re: /\b(?:class|struct|interface|enum|trait)\s+([A-Za-z_][\w]*)/, kind: 'class' }, @@ -140,4 +150,7 @@ const RULES: Record = { scala: JVM_RULES, csharp: JVM_RULES, ruby: RUBY_RULES, + astro: TS_RULES, + powershell: POWERSHELL_RULES, + lua: LUA_RULES, }; diff --git a/src/preload/index.ts b/src/preload/index.ts index 5c07cd3..e8cb6e9 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -25,6 +25,8 @@ import type { FileHistoryEntry, FileReadResult, FileTree, + FileWriteResult, + FsMutationOptions, GenerateCommitMessageResult, GitBlameLine, GitBranch, @@ -302,6 +304,39 @@ const fsApi = { /** Reveal the workspace root (no relPath) or a path in the OS file manager. */ reveal: (workspaceId: string, relPath?: string): Promise => ipcRenderer.invoke(IpcChannels.fsReveal, workspaceId, relPath), + /** Atomically write a UTF-8 file through the guarded File Writer. */ + writeFile: ( + workspaceId: string, + relPath: string, + content: string, + opts?: FsMutationOptions, + ): Promise => + ipcRenderer.invoke(IpcChannels.fsWriteFile, workspaceId, relPath, content, opts), + /** Create an empty file (fails if anything exists at the path). */ + createFile: (workspaceId: string, relPath: string): Promise => + ipcRenderer.invoke(IpcChannels.fsCreateFile, workspaceId, relPath), + /** Create a directory (and missing intermediates) inside the workspace. */ + createDir: (workspaceId: string, relPath: string): Promise => + ipcRenderer.invoke(IpcChannels.fsCreateDir, workspaceId, relPath), + /** Delete a file/symlink/directory (non-empty dirs need `recursive: true`). */ + remove: (workspaceId: string, relPath: string, opts?: FsMutationOptions): Promise => + ipcRenderer.invoke(IpcChannels.fsDelete, workspaceId, relPath, opts), + /** Rename or move an entry (destination is a full workspace-relative path). */ + rename: ( + workspaceId: string, + fromRel: string, + toRel: string, + opts?: FsMutationOptions, + ): Promise => + ipcRenderer.invoke(IpcChannels.fsRename, workspaceId, fromRel, toRel, opts), + /** Copy a file or (bounded) directory inside the workspace. */ + copy: ( + workspaceId: string, + fromRel: string, + toRel: string, + opts?: FsMutationOptions, + ): Promise => + ipcRenderer.invoke(IpcChannels.fsCopy, workspaceId, fromRel, toRel, opts), onIndexProgress: (cb: (progress: IndexProgress) => void): (() => void) => subscribe(IpcEvents.fsIndexProgress, cb), onTreeChanged: (cb: (tree: FileTree) => void): (() => void) => diff --git a/src/renderer/app/AppShell.tsx b/src/renderer/app/AppShell.tsx index f519e62..dc776bb 100644 --- a/src/renderer/app/AppShell.tsx +++ b/src/renderer/app/AppShell.tsx @@ -1,14 +1,19 @@ /** - * The application shell: a full-height column with the frameless title bar on - * top and a horizontal row of resizable regions below it. + * The application shell — a floating app shell in two visual layers: * - * TitleBar - * [SessionsSidebar][handle][CenterWorkspace][handle][ActivityDrawer] ActivityRail + * TitleBar ← root background + * [SessionsSidebar]│┌──────────────────────────┐ [ActivityRail] + * (root bg) ││ CenterWorkspace │ Drawer │ (root bg) + * │└──────────────────────────┘ + * ghost floating card (bg-surface, 6px radius) * - * The left sidebar and right drawer run full height; the Composer lives only - * inside the center column. The integrated terminal is one of the right-drawer - * tabs (it uses its own wider remembered width). Panel widths + the open drawer - * tab come from the layout store (persisted to settings). + * The title bar, sessions sidebar, and activity icon rail sit directly on the + * pure-black root background (persistent/architectural UI). The center + * workspace and the right drawer float together as one detached card — + * bg-surface, border-line, rounded 6px — framed by an 8px side gutter and a + * 16px bottom gutter so it never touches the window edges. The Composer lives only inside the center column; + * the integrated terminal is one of the drawer tabs (own remembered width). + * Panel widths + the open drawer tab come from the layout store (persisted). */ import { TitleBar } from '@/renderer/components/layout/TitleBar'; import { ResizeHandle } from '@/renderer/components/ui'; @@ -57,9 +62,9 @@ export function AppShell() { return (
-
+
{sessionsCollapsed ? ( -
+
) : ( @@ -67,23 +72,30 @@ export function AppShell() {
- + {/* The 8px gutter between sidebar and card IS the grab area. */} + )} -
- + {/* Floating workspace card — center column + drawer share one surface. */} +
+
+ +
+ + {activeTab && ( + <> + +
+ +
+ + )}
- {activeTab && ( - <> - -
- -
- - )} - +
+ +
); diff --git a/src/renderer/components/layout/TitleBar.tsx b/src/renderer/components/layout/TitleBar.tsx index c69efe8..4b62d07 100644 --- a/src/renderer/components/layout/TitleBar.tsx +++ b/src/renderer/components/layout/TitleBar.tsx @@ -29,7 +29,7 @@ export function TitleBar() { updateStage === 'downloaded'; return ( -
+
diff --git a/src/renderer/components/ui/ResizeHandle.tsx b/src/renderer/components/ui/ResizeHandle.tsx index cff3a6d..09ee603 100644 --- a/src/renderer/components/ui/ResizeHandle.tsx +++ b/src/renderer/components/ui/ResizeHandle.tsx @@ -1,21 +1,35 @@ /** - * 1px draggable divider between resizable columns. The visible bar is hairline, - * but an invisible wider hit area makes it easy to grab; it highlights with the - * accent on hover/drag. + * Draggable divider between resizable columns. Default: a 1px hairline with an + * invisible wider hit area. Ghost: an invisible 8px-wide handle that doubles as + * the gutter between a root-background panel and the floating workspace card — + * a centered indicator appears on hover so the affordance is discoverable. */ import type { MouseEvent } from 'react'; +import { cn } from '@/renderer/lib/cn'; export function ResizeHandle({ onMouseDown, + ghost = false, }: { onMouseDown: (event: MouseEvent) => void; + ghost?: boolean; }) { return (
-
+
); } diff --git a/src/renderer/features/activity/ActivityDrawer.tsx b/src/renderer/features/activity/ActivityDrawer.tsx index 640e870..d008b59 100644 --- a/src/renderer/features/activity/ActivityDrawer.tsx +++ b/src/renderer/features/activity/ActivityDrawer.tsx @@ -31,7 +31,7 @@ export function ActivityDrawer({ tab }: { tab: ActivityTab }) { if (tab === 'memory') return ; return ( -
+
{meta.label} diff --git a/src/renderer/features/activity/ActivityRail.tsx b/src/renderer/features/activity/ActivityRail.tsx index 3387187..df6c8ba 100644 --- a/src/renderer/features/activity/ActivityRail.tsx +++ b/src/renderer/features/activity/ActivityRail.tsx @@ -25,7 +25,7 @@ export function ActivityRail() { }; return ( -