From ad84cd956fa20eb9c91b3e70cacbbb0b5c782b86 Mon Sep 17 00:00:00 2001 From: Johnny Huynh <27847622+johnnyhuy@users.noreply.github.com> Date: Sat, 7 Nov 2026 23:03:02 +0900 Subject: [PATCH 1/3] feat: group workspace roots and preserve session ownership Add persisted workspace groups, root-scoped recovery and safe background switching. Preserve Pi single-cwd ownership and branch-aware continuation while covering migration and switching regressions. Co-authored-by: OpenAI Codex --- README.md | 11 +- docs/adr/0014-workspace-root-groups.md | 6 + docs/adr/README.md | 1 + docs/pidex-architecture.md | 12 +- src/main/index.ts | 439 ++++++++----- src/main/sessions.ts | 172 +++--- src/main/workbench-ipc.ts | 71 +-- src/main/workbench.ts | 36 +- src/main/workspaces.ts | 372 +++++++++++ src/preload/index.ts | 8 +- src/renderer/App.tsx | 245 ++++---- src/renderer/api.ts | 23 +- src/renderer/components/CommandPalette.tsx | 4 +- src/renderer/components/WorkbenchViews.tsx | 28 +- .../components/WorkspaceNavigation.tsx | 163 +++++ src/renderer/useWorkbench.ts | 302 +++++++-- src/renderer/workbench.css | 140 +++++ tests/e2e/app.test.ts | 10 +- tests/e2e/workspaces.test.ts | 583 ++++++++++++++++++ tests/fixtures/pi-rpc.cjs | 6 + tests/integration/CommandPalette.test.tsx | 26 + tests/integration/SessionTree.test.tsx | 1 + tests/integration/SettingsView.test.tsx | 70 +++ .../integration/WorkspaceNavigation.test.tsx | 98 +++ tests/integration/useWorkbench.test.tsx | 309 ++++++++++ tests/unit/workbench.test.ts | 38 ++ tests/unit/workspaces.test.ts | 271 ++++++++ 27 files changed, 2961 insertions(+), 484 deletions(-) create mode 100644 docs/adr/0014-workspace-root-groups.md create mode 100644 src/main/workspaces.ts create mode 100644 src/renderer/components/WorkspaceNavigation.tsx create mode 100644 tests/e2e/workspaces.test.ts create mode 100644 tests/integration/WorkspaceNavigation.test.tsx create mode 100644 tests/integration/useWorkbench.test.tsx create mode 100644 tests/unit/workspaces.test.ts diff --git a/README.md b/README.md index c3ad5d1..26a9793 100644 --- a/README.md +++ b/README.md @@ -42,19 +42,24 @@ These are captures of real Electron interactions with a temporary Git workspace ## What works today -- Add local workspaces and discover existing Pi sessions. +- Group existing local folders into named workspaces and discover Pi sessions by their original working directory. - Start, stop, steer, resume, and fork sessions; preview history before launching Pi. - Browse linked session archives and branch entries; preview and continue the selected path. - Render streamed Markdown, thinking, and expandable tool output. - Browse files, review diffs, stage changes, and commit. - Discover workspace and global skills, switch models, and save model and reasoning defaults per workspace. -- Use quick switch for workspaces, sessions, files, and commands. +- Switch roots while sessions run in the background; retain per-root drafts, views and model defaults. +- Use quick switch for roots, sessions, files, and commands. Session history shows source/fork relationships and an entry tree with selected and archive-tip markers. Select a branch tip or an earlier entry to preview its parent chain. Resume at the archive tip continues in place; **Resume in new fork** continues a historical selection in a separate archive and preserves the source. **Fork from here** uses the selected entry. Pi 1.5.0 RPC does not expose arbitrary-entry navigation, so forks extract a native branch archive before launching Pi so even runtime startup writes preserve the source. Continuation requires an intact Pi v3 archive within the 16 MiB browsing limit. See [ADR 0013](docs/adr/0013-session-branch-selection.md) for runtime semantics and limits. +Use **Create workspace** or **Edit workspace** in the sidebar to name a group and select its roots. **Add root** chooses an existing folder. Each session runs in one root; the composer shows its path. Switching roots leaves open sessions running and makes them available under **Open sessions**. Files, Changes, History and defaults remain scoped to the selected root. A missing folder retains readable history, but cannot start or resume Pi until its original path is available. Adding another folder does not relocate a session. Group membership does not change Pi's tool permissions or load sibling roots into its context. + +Existing `workspaces.json` and `session-defaults.json` records migrate to `workspace-registry.json` in Electron userData. Original files and `.bak` copies remain intact. Workspace groups and selection persist; live processes and drafts do not restart after app quit. A package folder shares its Git worktree's index: pidex refuses its commit when staged changes exist outside that folder. Open the repository top level to review the whole index. + Active sessions and history use a virtualized transcript. A local development benchmark with 4,500 mixed entries opened in about 1.4 seconds, down from 8.3 seconds, and mounted fewer than 100 transcript DOM nodes instead of 39,000. Timings vary by machine and load; see [ADR 0011](docs/adr/0011-virtualized-transcript.md) for the method and limits. -Recovery summaries and work-state signals, folder-to-folder context migration, and multi-root workflows remain on the [issue tracker](https://github.com/echohello-dev/pidex/issues). +Automatic repository/worktree grouping, group-wide history, recovery summaries, and folder-to-folder context transfer remain on the [issue tracker](https://github.com/echohello-dev/pidex/issues). ## Install diff --git a/docs/adr/0014-workspace-root-groups.md b/docs/adr/0014-workspace-root-groups.md new file mode 100644 index 0000000..a8c50d7 --- /dev/null +++ b/docs/adr/0014-workspace-root-groups.md @@ -0,0 +1,6 @@ +# ADR 0014 - Group roots without changing session execution scope + +- **Status**: Proposed (local implementation, 2026-11-07) +- **Context**: A workspace used to combine a folder path, Pi archive directory, settings key and navigation identity. That makes related repositories hard to keep together and can confuse a browsed folder with a session's actual execution directory. Pi 1.5.0 opens an archive in its stored header cwd, even when its process starts elsewhere. An umbrella parent folder cannot represent unrelated paths, and task-driven worktree orchestration adds lifecycle beyond the first useful slice of #5. +- **Decision**: Store local UUID root and group records in an atomic, versioned user-data registry. A group is an ordered collection of existing roots; a root can belong to multiple groups. Discover history using each archive header cwd rather than its storage directory. Keep model/reasoning defaults on roots. Validate requested root against archive header before resume/fork and recheck live ownership before prompts/model changes. Keep #33's exact-entry extraction and live archive-owner safeguards. Navigation never changes cwd or terminates a child; delayed opens become background tabs and unsent drafts remain at their original root. Persist group/root selection, but keep open children, views and drafts in memory. +- **Consequences**: Legacy workspace/default files are retained with backups; missing roots and their history stay visible. Invalid registry contents require recovery instead of silently resetting data. Root-scoped file previews retain containment; Pi tool execution still follows Pi/OS policy and is not sandboxed by group membership. Package-root commits are refused if the staged index includes changes outside the folder. Repository/worktree hierarchy, group-wide history, durable archive handles and cross-root context transfer remain later work. Relocating archives requires a separate #4 operation; a registry group does not move execution context. Filesystem replacement at an unchanged path cannot be reliably identified from path strings alone; automatic repair is outside this slice. diff --git a/docs/adr/README.md b/docs/adr/README.md index c96b1bf..ac1e427 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -26,3 +26,4 @@ Each ADR follows this shape: | [0011](0011-virtualized-transcript.md) | Virtualize the session transcript | Accepted | | [0012](0012-release-please-calver.md) | Release Please with a CalVer adapter | Accepted | | [0013](0013-session-branch-selection.md) | Explicit archive branch selection over Pi RPC | Proposed | +| [0014](0014-workspace-root-groups.md) | Group roots without changing session execution scope | Proposed | diff --git a/docs/pidex-architecture.md b/docs/pidex-architecture.md index f301527..2aca5f6 100644 --- a/docs/pidex-architecture.md +++ b/docs/pidex-architecture.md @@ -23,6 +23,7 @@ The renderer has context isolation enabled and Node integration disabled. Filesy | File | Responsibility | |---|---| +| `src/main/workspaces.ts` | Versioned root/group registry, legacy migration, root defaults and selection | | `src/main/index.ts` | Windows, workspace registration, active session lifecycle, prompt/abort/close IPC | | `src/main/rpc.ts` | Pi executable discovery, child processes, request IDs, response timeouts, event forwarding | | `src/main/sessions.ts` | Discover workspaces and read session metadata from Pi JSONL archives | @@ -40,7 +41,9 @@ Shared schemas and a separate runtime-adapter package were early proposals; the ## Session lifecycle -Opening or resuming a session starts a Pi subprocess in the workspace directory. Resume supplies a validated session file. Fork asks Pi to clone the selected archive before returning the new session state. New sessions apply the workspace's saved model and reasoning defaults. +New sessions start a Pi subprocess in the selected root and apply its saved model/reasoning defaults. Resume validates both the archive path and its header cwd against that root. Pi uses the archive's stored cwd, so browsing another root cannot relocate a session. Forks extract #33's selected chain before Pi starts, preserve the source and verify the loaded runtime leaf. Main serializes opens and reuses verified archive owners rather than launching competing writers. Before a prompt or model change, main refreshes runtime state and refuses a changed or unavailable execution root. + +Groups organize roots for navigation. Switching group/root leaves processes running and restores the target root's view and drafts. A launch finishing after navigation becomes a background tab; its original unsent prompt is not submitted elsewhere. Open sessions are reachable across groups. Late root requests cannot replace another root's files/defaults/history. No simultaneous multi-cwd execution or sibling instruction loading is added. Main requests `get_state`, `get_messages`, and available commands. The renderer converts the returned messages into user, assistant, and tool entries. Subsequent Pi events arrive through `pidex:session:event`; `timeline.ts` applies text/thinking deltas and tool start/update/end events. Main forwards the RPC events rather than translating them into a second event protocol. @@ -54,6 +57,7 @@ The authoritative channel definitions are in `src/preload/index.ts`. Important g | Bridge methods | Main channels | Purpose | |---|---|---| +| `workspaceCatalog`, `saveWorkspaceGroup`, `selectWorkspace` | `pidex:workspace:catalog`, `:group`, `:select` | Read groups/roots, edit membership and persist selection | | `workspaces`, `addWorkspace`, `sessions` | `pidex:workspaces`, `pidex:workspace:add`, `pidex:sessions` | Discover and select local work | | `openSession`, `sendPrompt`, `abortSession`, `closeSession` | `pidex:session/open`, `/send`, `/abort`, `/close` | Drive active Pi sessions | | `previewSession` | `pidex:session:preview` | Read archive messages without starting Pi | @@ -61,17 +65,17 @@ The authoritative channel definitions are in `src/preload/index.ts`. Important g | `models`, `setModel`, `settings`, `saveSettings` | `pidex:models:list`, `pidex:session:model`, `pidex:settings:get`, `:save` | Model discovery and per-workspace defaults | | `onSessionEvent`, `onSessionExit` | `pidex:session:event`, `pidex:session:exit` | Subscribe to runtime events; each subscription returns cleanup | -Workspace helpers validate known workspaces and file containment. Realpath checks reject previews outside the workspace, including symlinks that escape it. Text previews are limited to 1 MB and reject binary files. Git commands use argument arrays, literal pathspecs where needed, and disable external diff/textconv helpers. Session previews validate the Pi archive root and JSONL extension and reject archives larger than 16 MB; resuming uses Pi directly. +Workspace helpers validate known workspaces and file containment. Realpath checks reject previews outside the workspace, including symlinks that escape it. Text previews are limited to 1 MB and reject binary files. Git commands use argument arrays, literal pathspecs where needed, and disable external diff/textconv helpers. Session previews validate the Pi archive root and JSONL extension and reject archives larger than 16 MB; continuation preserves the validated selected archive chain. A nested folder can stage only its own files; a commit is blocked when the shared index includes staged sibling files. ## Local state -Pi session archives live under `~/.pi/agent/sessions`. pidex discovers workspace paths from session headers and merges manually added folders. Custom workspace paths and per-workspace session defaults live in Electron's user-data directory as `workspaces.json` and `session-defaults.json`. +Pi session archives live under `~/.pi/agent/sessions`. pidex discovers workspace paths from session headers and merges manually added folders. Stable UUID roots, named groups, per-root defaults and selected group/root live in Electron userData as `workspace-registry.json`. The first load migrates `workspaces.json` and `session-defaults.json`, retaining originals and `.bak` copies. Writes use a temporary file and atomic rename; invalid/unknown schema data fails closed. Synchronous main-process transactions serialize mutations, and group edits compare prior membership before saving. Available aliases deduplicate by realpath; missing paths remain as roots and preserve history. Independent nested folders remain separate roots. Initially missing paths are canonicalized when they become available, retaining their root IDs and original path aliases. Resolved paths stay pinned; retargeting one does not relocate its sessions. Each archive is indexed by its own cwd across all supported store subdirectories, with stat-based metadata caching. Native session ID and canonical archive locator remain separate; durable pidex archive handles are deferred. History preview follows the archive's current parent chain so abandoned branches are not mixed into the displayed conversation. Archive metadata scans read a bounded prefix, so displayed message counts and model metadata can be incomplete for large sessions. The full active branch is read separately for preview. Workspace inspection reads the file tree and Git branch/status. It refreshes on focus, view/runtime-state changes, and every 15 seconds. There is no filesystem watcher. File discovery skips common dependency/build directories and symlinks and is capped at 3,000 files and seven directory levels. -Drafts, open tabs, transcript selection, and expanded rows are in-memory UI state. Scroll position and expansion are not persisted when a transcript is reopened. +Per-root views, new-session/history drafts, unsaved settings drafts, open tabs, transcript selection, and expanded rows are in-memory UI state. Restoring an interrupted history load retries the saved archive/entry, while completed error states require explicit retry. Settings cache only dirty drafts; clean views follow saved defaults. Changes views remount per root and late mutation results are checked against their original navigation generation. Scroll position and expansion are not persisted when a transcript is reopened. ## Transcript rendering diff --git a/src/main/index.ts b/src/main/index.ts index 20c9d9e..2742b50 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -7,10 +7,18 @@ import { import { app, BrowserWindow, dialog, ipcMain } from 'electron'; import fs from 'fs'; import path from 'path'; -import { listSessions, listWorkspaces } from './sessions'; +import { + sessionsForRoot, + matchingSessions, + discoverSessions, + listWorkspaces, + canonicalCwd, + parseSession, +} from './sessions'; +import { WorkspaceRegistry, availability } from './workspaces'; import { sessionPath } from './workbench'; import { RpcSession } from './rpc'; -import { registerWorkbench, sessionDefaults } from './workbench-ipc'; +import { registerWorkbench } from './workbench-ipc'; const isDev = !app.isPackaged; if (isDev && process.env.PIDEX_E2E_USER_DATA) @@ -19,31 +27,34 @@ const sessions = new Map(); let sessionSeq = 0; let mainWindow: BrowserWindow | null = null; -function customWorkspacesFile(): string { - return path.join(app.getPath('userData'), 'workspaces.json'); -} - -function readCustomWorkspaces(): string[] { - if (process.env.PIDEX_E2E_CWD) return [process.env.PIDEX_E2E_CWD]; - try { - const raw = fs.readFileSync(customWorkspacesFile(), 'utf8'); - const parsed = JSON.parse(raw); - return Array.isArray(parsed) - ? parsed.filter((p): p is string => typeof p === 'string') - : []; - } catch { - return []; - } +let registry: WorkspaceRegistry | undefined; +function workspaceRegistry(): WorkspaceRegistry { + registry ??= new WorkspaceRegistry( + app.getPath('userData'), + discoverSessions(), + process.env.PIDEX_E2E_CWD ? [process.env.PIDEX_E2E_CWD] : [], + ); + return registry; } - -function addCustomWorkspace(cwd: string): void { - const existing = readCustomWorkspaces(); - if (!existing.includes(cwd)) { - fs.writeFileSync( - customWorkspacesFile(), - JSON.stringify([...existing, cwd], null, 2), - ); - } +async function catalog() { + const registry = workspaceRegistry(); + const archives = discoverSessions(); + registry.discover(archives.map((s) => s.cwd)); + const state = registry.snapshot(); + const summaries = await listWorkspaces(state.roots.map((r) => r.path)); + return { + groups: state.groups, + selected: state.selected, + roots: state.roots.map((r) => ({ + ...summaries.find((w) => w.cwd === canonicalCwd(r.path))!, + sessionCount: matchingSessions(r.path, r.aliases, archives).filter( + (s) => registry.resolve(s.cwd, false).id === r.id, + ).length, + dirName: r.id, + cwd: r.path, + availability: availability(r.path), + })), + }; } function sendToRenderer(channel: string, payload: unknown) { @@ -70,10 +81,10 @@ function createWindow() { if (isDev) { mainWindow.loadURL( - (process.env.PIDEX_E2E_CWD && process.env.PIDEX_E2E_RENDERER_URL) || + (process.env.PIDEX_E2E_USER_DATA && process.env.PIDEX_E2E_RENDERER_URL) || 'http://localhost:5173', ); - if (!process.env.PIDEX_E2E_CWD) + if (!process.env.PIDEX_E2E_USER_DATA) mainWindow.webContents.openDevTools({ mode: 'detach' }); } else { mainWindow.loadFile(path.join(__dirname, '..', 'renderer', 'index.html')); @@ -84,33 +95,69 @@ function createWindow() { }); } -ipcMain.handle('pidex:workspaces', () => - listWorkspaces(readCustomWorkspaces()), -); - -registerWorkbench(() => mainWindow, readCustomWorkspaces, sessions); +ipcMain.handle('pidex:workspaces', async () => (await catalog()).roots); +ipcMain.handle('pidex:workspace:catalog', () => catalog()); +ipcMain.handle('pidex:workspace:group', async (_e, req) => { + workspaceRegistry().saveGroup( + req?.id, + req?.name, + req?.rootIds, + req?.expectedRoots, + ); + return catalog(); +}); +ipcMain.handle('pidex:workspace:select', (_e, groupId, rootId) => { + workspaceRegistry().select(groupId, rootId); +}); +registerWorkbench(() => mainWindow, workspaceRegistry, sessions, runtimeRoot); -ipcMain.handle('pidex:workspace:add', async () => { +ipcMain.handle('pidex:workspace:add', async (_e, groupId?: string) => { if (!mainWindow) return { error: 'no window' }; const result = await dialog.showOpenDialog(mainWindow, { - title: 'Add workspace', - properties: ['openDirectory', 'createDirectory'], + title: 'Add root', + properties: ['openDirectory'], }); if (result.canceled || result.filePaths.length === 0) return { error: 'cancelled' }; - const cwd = result.filePaths[0]; - addCustomWorkspace(cwd); - return { cwd }; + try { + const root = workspaceRegistry().add(result.filePaths[0], groupId); + return { cwd: root.path }; + } catch (error) { + return { + error: error instanceof Error ? error.message : 'Could not add root.', + }; + } }); - -ipcMain.handle('pidex:sessions', (_e, dirName: string) => { - if (typeof dirName !== 'string') return []; - return listSessions(dirName); +ipcMain.handle('pidex:sessions', (_e, rootId: string) => { + const root = workspaceRegistry().resolve(rootId, false); + return sessionsForRoot(root.path, undefined, root.aliases).filter( + (s) => workspaceRegistry().resolve(s.cwd, false).id === root.id, + ); }); +// Serialize opens so concurrent IPC requests cannot start two writers for one archive. +let openingSession: Promise = Promise.resolve(); +async function runtimeRoot(runtime: RpcSession) { + const state = await runtime.getState(); + if (!state.success) + throw new Error(state.error ?? 'Could not verify session ownership.'); + const owner = workspaceRegistry().resolve(runtime.cwd); + if (runtime.archiveFile) { + const header = parseSession(runtime.archiveFile); + if ( + !header || + canonicalCwd(header.cwd) !== runtime.cwd || + workspaceRegistry().resolve(header.cwd, false).id !== owner.id + ) + throw new Error( + 'The runtime changed execution roots. Close this tab and resume from its owner root.', + ); + } +} + ipcMain.handle( 'pidex:session/open', - async ( + ( _e, req: { cwd: string; @@ -119,159 +166,201 @@ ipcMain.handle( fork?: boolean; }, ) => { - const cwd = typeof req?.cwd === 'string' ? req.cwd : ''; - let sessionFile = - typeof req?.sessionFile === 'string' ? req.sessionFile : undefined; - if (!cwd || !fs.existsSync(cwd)) { - return { error: 'workspace path does not exist' }; - } - let expectedEntryId: string | null | undefined; - let extractedFork = false; - if (sessionFile) { + const operation = openingSession.then(async () => { + let cwd: string; + let rootId: string; try { - sessionFile = await sessionPath(sessionFile); - const archive = await loadArchive(sessionFile); - if (!archive.header || archive.header.version !== 3 || archive.damaged) - throw new Error( - 'Continuing requires an intact Pi v3 archive. Open older archives in Pi to migrate them first.', - ); - if ( - req.entryId !== undefined && - req.entryId !== null && - typeof req.entryId !== 'string' - ) - throw new Error('Invalid selected entry.'); - const selected = selectedPath(archive, req.entryId).at(-1)?.id; - expectedEntryId = selected ?? null; - if (req.fork && !selected) - throw new Error('This session has no entry to fork.'); - if (selected && (req.fork || selected !== archive.entries.at(-1)?.id)) { - sessionFile = await forkArchive(sessionFile, selected); - extractedFork = true; - } + const root = workspaceRegistry().resolve(req?.cwd); + cwd = root.path; + rootId = root.id; } catch (error) { return { - error: - error instanceof Error ? error.message : 'Session unavailable.', + error: error instanceof Error ? error.message : 'Unknown root.', }; } - } - - // Archive ownership is authoritative here, rather than inferred from a - // renderer snapshot that becomes stale as soon as a prompt advances Pi. - async function readRuntime(runtime: RpcSession, reused = false) { - const state = await runtime.getState(); - if (!state.success || runtime.launchError()) - return { - error: - state.error ?? - runtime.launchError() ?? - 'Could not read Pi session state.', - }; - if (sessionFile && runtime.archiveFile !== sessionFile) - return { - error: - 'The open runtime switched archives. Select this archive again.', - }; - if ( - reused && - (state.data as { isStreaming?: boolean } | undefined)?.isStreaming - ) - return { - error: - 'This archive is running in an open tab. Stop it before resuming, or fork this selection.', - }; - if (expectedEntryId !== undefined) { - const position = await runtime.command({ - type: 'get_entries', - ...(expectedEntryId ? { since: expectedEntryId } : {}), - }); - if ( - !position.success || - !runtimeMatchesSelection(expectedEntryId, position.data) - ) + let sessionFile = + typeof req?.sessionFile === 'string' ? req.sessionFile : undefined; + if (!cwd || !fs.existsSync(cwd)) { + return { error: 'workspace path does not exist' }; + } + let expectedEntryId: string | null | undefined; + let extractedFork = false; + if (sessionFile) { + try { + sessionFile = await sessionPath(sessionFile); + const archive = await loadArchive(sessionFile); + if ( + !archive.header || + archive.header.version !== 3 || + archive.damaged + ) + throw new Error( + 'Continuing requires an intact Pi v3 archive. Open older archives in Pi to migrate them first.', + ); + if ( + typeof archive.header.cwd !== 'string' || + canonicalCwd(archive.header.cwd) !== cwd || + workspaceRegistry().resolve(archive.header.cwd, false).id !== rootId + ) + throw new Error( + 'This archive belongs to another root. Select its original folder before resuming or forking.', + ); + if ( + req.entryId !== undefined && + req.entryId !== null && + typeof req.entryId !== 'string' + ) + throw new Error('Invalid selected entry.'); + const selected = selectedPath(archive, req.entryId).at(-1)?.id; + expectedEntryId = selected ?? null; + if (req.fork && !selected) + throw new Error('This session has no entry to fork.'); + if ( + selected && + (req.fork || selected !== archive.entries.at(-1)?.id) + ) { + sessionFile = await forkArchive(sessionFile, selected); + extractedFork = true; + } + } catch (error) { return { error: - position.error ?? - 'The runtime branch moved since selection. Refresh history and select the branch again, or fork this entry.', + error instanceof Error ? error.message : 'Session unavailable.', }; + } } - const messages = await runtime.getMessages(); - if (!messages.success) - return { error: messages.error ?? 'Could not read Pi history.' }; - return { - sessionId: runtime.id, - state: state.data, - messages: - (messages.data as { messages?: unknown[] } | undefined)?.messages ?? - [], - }; - } - if (sessionFile && !extractedFork) { - for (const runtime of sessions.values()) { - // Extension commands can switch a runtime's archive. Refresh its real - // state before deciding whether this file already has a live owner. + + // Archive ownership is authoritative here, rather than inferred from a + // renderer snapshot that becomes stale as soon as a prompt advances Pi. + async function readRuntime(runtime: RpcSession, reused = false) { const state = await runtime.getState(); - if (!state.success) { - if (runtime.launchError()) continue; + if (!state.success || runtime.launchError()) + return { + error: + state.error ?? + runtime.launchError() ?? + 'Could not read Pi session state.', + }; + try { + await runtimeRoot(runtime); + } catch (error) { + return { + error: + error instanceof Error + ? error.message + : 'Session ownership changed.', + }; + } + if (sessionFile && runtime.archiveFile !== sessionFile) return { - error: `Could not verify archive ownership. Close the unresponsive session tab before resuming. ${state.error ?? ''}`, + error: + 'The open runtime switched archives. Select this archive again.', }; + if ( + reused && + (state.data as { isStreaming?: boolean } | undefined)?.isStreaming + ) + return { + error: + 'This archive is running in an open tab. Stop it before resuming, or fork this selection.', + }; + if (expectedEntryId !== undefined) { + const position = await runtime.command({ + type: 'get_entries', + ...(expectedEntryId ? { since: expectedEntryId } : {}), + }); + if ( + !position.success || + !runtimeMatchesSelection(expectedEntryId, position.data) + ) + return { + error: + position.error ?? + 'The runtime branch moved since selection. Refresh history and select the branch again, or fork this entry.', + }; + } + const messages = await runtime.getMessages(); + if (!messages.success) + return { error: messages.error ?? 'Could not read Pi history.' }; + return { + sessionId: runtime.id, + state: state.data, + messages: + (messages.data as { messages?: unknown[] } | undefined)?.messages ?? + [], + }; + } + if (sessionFile && !extractedFork) { + for (const runtime of sessions.values()) { + // Extension commands can switch a runtime's archive. Refresh its real + // state before deciding whether this file already has a live owner. + const state = await runtime.getState(); + if (!state.success) { + if (runtime.launchError()) continue; + return { + error: `Could not verify archive ownership. Close the unresponsive session tab before resuming. ${state.error ?? ''}`, + }; + } + if (runtime.archiveFile === sessionFile) + return readRuntime(runtime, true); } - if (runtime.archiveFile === sessionFile) - return readRuntime(runtime, true); } - } - const id = `s${++sessionSeq}`; - let session: RpcSession; - try { - session = new RpcSession(id, cwd, sessionFile); - } catch (err) { - const message = err instanceof Error ? err.message : 'could not start pi'; - return { error: message }; - } - session.onEvent = (sessionId, event) => - sendToRenderer('pidex:session:event', { sessionId, event }); - session.onExit = (sessionId) => { - sessions.delete(sessionId); - sendToRenderer('pidex:session:exit', { sessionId }); - }; - sessions.set(id, session); + const id = `s${++sessionSeq}`; + let session: RpcSession; + try { + session = new RpcSession(id, cwd, sessionFile); + } catch (err) { + const message = + err instanceof Error ? err.message : 'could not start pi'; + return { error: message }; + } + session.onEvent = (sessionId, event) => + sendToRenderer('pidex:session:event', { sessionId, event }); + session.onExit = (sessionId) => { + sessions.delete(sessionId); + sendToRenderer('pidex:session:exit', { sessionId }); + }; + sessions.set(id, session); - if (!sessionFile) { - const defaults = await sessionDefaults(cwd); - if (defaults.model && defaults.provider) { - const changed = await session.command({ - type: 'set_model', - provider: defaults.provider, - modelId: defaults.model, + if (!sessionFile) { + const defaults = workspaceRegistry().resolve(cwd).defaults; + if (defaults.model && defaults.provider) { + const changed = await session.command({ + type: 'set_model', + provider: defaults.provider, + modelId: defaults.model, + }); + if (!changed.success) { + sessions.delete(id); + session.dispose(); + return { + error: changed.error ?? 'Could not apply the default model.', + }; + } + } + const thinking = await session.command({ + type: 'set_thinking_level', + level: defaults.thinking, }); - if (!changed.success) { + if (!thinking.success) { sessions.delete(id); session.dispose(); return { - error: changed.error ?? 'Could not apply the default model.', + error: thinking.error ?? 'Could not apply reasoning effort.', }; } } - const thinking = await session.command({ - type: 'set_thinking_level', - level: defaults.thinking, - }); - if (!thinking.success) { + + const result = await readRuntime(session); + if ('error' in result) { sessions.delete(id); session.dispose(); - return { error: thinking.error ?? 'Could not apply reasoning effort.' }; } - } - - const result = await readRuntime(session); - if ('error' in result) { - sessions.delete(id); - session.dispose(); - } - return result; + return { ...result, cwd }; + }); + openingSession = operation.catch(() => {}); + return operation; }, ); @@ -280,6 +369,14 @@ ipcMain.handle( async (_e, req: { sessionId: string; text: string; streaming: boolean }) => { const session = sessions.get(req?.sessionId ?? ''); if (!session) return { error: 'no such session' }; + try { + await runtimeRoot(session); + } catch (error) { + return { + error: + error instanceof Error ? error.message : 'Session ownership changed.', + }; + } const response = await session.prompt( String(req.text ?? ''), Boolean(req.streaming), diff --git a/src/main/sessions.ts b/src/main/sessions.ts index 200686a..2384181 100644 --- a/src/main/sessions.ts +++ b/src/main/sessions.ts @@ -129,89 +129,113 @@ function gitBranch(cwd: string): Promise { }); } -async function listWorkspaces( - extraCwds: string[] = [], -): Promise { - const byDir = new Map(); - if (fs.existsSync(SESSIONS_ROOT)) { - const dirs = fs - .readdirSync(SESSIONS_ROOT, { withFileTypes: true }) - .filter((d) => d.isDirectory()) - .map((d) => d.name); - - for (const dirName of dirs) { - const dirPath = path.join(SESSIONS_ROOT, dirName); - const files = fs - .readdirSync(dirPath) - .filter((f) => f.endsWith('.jsonl')) - .map((f) => path.join(dirPath, f)); - if (files.length === 0) continue; +/** Paths identify locators, not registry roots. Missing paths remain discoverable. */ +function canonicalCwd(cwd: string): string { + try { + return fs.realpathSync(cwd); + } catch { + return path.resolve(cwd); + } +} - let cwd = ''; - let lastActive = ''; - for (const file of files) { - const stat = fs.statSync(file); - if (stat.mtime.toISOString() > lastActive) - lastActive = stat.mtime.toISOString(); - if (!cwd) { - for (const line of readLines(file, 64 * 1024)) { - if (!line) continue; - try { - const entry = JSON.parse(line); - if (entry.type === 'session' && typeof entry.cwd === 'string') { - cwd = entry.cwd; - break; - } - } catch { - break; - } - } - } +const metadata = new Map(); +function discoverSessions(root = SESSIONS_ROOT): SessionInfo[] { + const result = new Map(); + const seen = new Set(); + let canonicalRoot: string; + try { + canonicalRoot = fs.realpathSync(root); + } catch { + return []; + } + function scan(directory: string, descend: boolean) { + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(directory, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + const file = path.join(directory, entry.name); + if (entry.isDirectory() && descend) { + scan(file, false); + continue; + } + if (!entry.isFile() || !file.endsWith('.jsonl')) continue; + try { + const canonical = fs.realpathSync(file); + const relative = path.relative(canonicalRoot, canonical); + if (relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) + continue; + const stat = fs.statSync(canonical); + const stamp = `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeMs}`; + seen.add(canonical); + const cached = metadata.get(canonical); + const info = + cached?.stamp === stamp ? cached.info : parseSession(canonical); + metadata.set(canonical, { stamp, info }); + if (info && path.isAbsolute(info.cwd)) result.set(canonical, info); + } catch { + /* One unavailable archive must not hide its siblings. */ } - if (!cwd) continue; - const branch = fs.existsSync(cwd) ? await gitBranch(cwd) : ''; - byDir.set(dirName, { - dirName, - name: path.basename(cwd), - cwd, - branch, - sessionCount: files.length, - lastActive, - }); } } + scan(root, true); + for (const file of metadata.keys()) + if (!seen.has(file)) metadata.delete(file); + return [...result.values()].sort((a, b) => + b.timestamp.localeCompare(a.timestamp), + ); +} + +function matchingSessions( + cwd: string, + aliases: string[], + archives: SessionInfo[], +): SessionInfo[] { + const canonical = canonicalCwd(cwd); + const known = new Set([cwd, ...aliases].map((alias) => path.resolve(alias))); + return archives.filter( + (s) => known.has(path.resolve(s.cwd)) || canonicalCwd(s.cwd) === canonical, + ); +} +function sessionsForRoot( + cwd: string, + root = SESSIONS_ROOT, + aliases: string[] = [], +): SessionInfo[] { + return matchingSessions(cwd, aliases, discoverSessions(root)); +} - const knownCwds = new Set([...byDir.values()].map((w) => w.cwd)); - for (const cwd of extraCwds) { - if (knownCwds.has(cwd) || !fs.existsSync(cwd)) continue; - const dirName = `--${cwd.replace(/\//g, '-')}--`; - const branch = await gitBranch(cwd); - byDir.set(dirName, { - dirName, +async function listWorkspaces( + extraCwds: string[] = [], +): Promise { + const archives = discoverSessions(); + const cwds = new Set( + [...extraCwds, ...archives.map((s) => s.cwd)].map(canonicalCwd), + ); + const result: WorkspaceInfo[] = []; + for (const cwd of cwds) { + const owned = archives.filter((s) => canonicalCwd(s.cwd) === cwd); + result.push({ + dirName: cwd, name: path.basename(cwd), cwd, - branch, - sessionCount: 0, - lastActive: '', + branch: fs.existsSync(cwd) ? await gitBranch(cwd) : '', + sessionCount: owned.length, + lastActive: owned[0]?.timestamp ?? '', }); } - - const workspaces = [...byDir.values()]; - workspaces.sort((a, b) => b.lastActive.localeCompare(a.lastActive)); - return workspaces; + return result.sort((a, b) => b.lastActive.localeCompare(a.lastActive)); } -function listSessions(dirName: string): SessionInfo[] { - const dirPath = path.join(SESSIONS_ROOT, dirName); - if (!dirPath.startsWith(SESSIONS_ROOT) || !fs.existsSync(dirPath)) return []; - const sessions = fs - .readdirSync(dirPath) - .filter((f) => f.endsWith('.jsonl')) - .map((f) => parseSession(path.join(dirPath, f))) - .filter((s): s is SessionInfo => s !== null); - sessions.sort((a, b) => b.timestamp.localeCompare(a.timestamp)); - return sessions; -} - -export { listWorkspaces, listSessions, parseSession, SESSIONS_ROOT }; +export { + listWorkspaces, + sessionsForRoot, + matchingSessions, + discoverSessions, + canonicalCwd, + parseSession, + SESSIONS_ROOT, +}; export type { WorkspaceInfo, SessionInfo }; diff --git a/src/main/workbench-ipc.ts b/src/main/workbench-ipc.ts index aad931d..d7fbfa3 100644 --- a/src/main/workbench-ipc.ts +++ b/src/main/workbench-ipc.ts @@ -1,46 +1,29 @@ import { loadArchive, selectedPath, readSessionTree } from './session-tree'; -import { app, dialog, ipcMain, shell, type BrowserWindow } from 'electron'; +import { dialog, ipcMain, shell, type BrowserWindow } from 'electron'; import fs from 'fs/promises'; import os from 'os'; import path from 'path'; import { RpcSession } from './rpc'; -import { listWorkspaces } from './sessions'; +import type { WorkspaceRegistry } from './workspaces'; import { - DEFAULTS, + assertCommitScope, filePatch, git, inside, inspectWorkspace, listSkills, readText, - validateDefaults, workspacePath, - type Defaults, } from './workbench'; export function registerWorkbench( getWindow: () => BrowserWindow | null, - customWorkspaces: () => string[], + registry: () => WorkspaceRegistry, sessions: Map, + validateRuntime: (runtime: RpcSession) => Promise, ) { - const settingsFile = () => - path.join(app.getPath('userData'), 'session-defaults.json'); async function knownWorkspace(cwd: unknown): Promise { - if (typeof cwd !== 'string') throw new Error('Choose a workspace first.'); - const known = await listWorkspaces(customWorkspaces()); - if (!known.some((w) => w.cwd === cwd)) - throw new Error('Unknown workspace.'); - return cwd; - } - async function readDefaults(): Promise> { - try { - const parsed = JSON.parse(await fs.readFile(settingsFile(), 'utf8')); - return parsed && typeof parsed === 'object' && !Array.isArray(parsed) - ? parsed - : {}; - } catch { - return {}; - } + return registry().resolve(cwd).path; } const handle = ( channel: string, @@ -107,6 +90,7 @@ export function registerWorkbench( } else if (action === 'commit') { if (typeof file !== 'string' || !file.trim()) throw new Error('Enter a commit message.'); + await assertCommitScope(cwd); await git(cwd, ['commit', '-m', file.trim()]); } else throw new Error('Unsupported Git action.'); return inspectWorkspace(cwd); @@ -135,20 +119,11 @@ export function registerWorkbench( }); handle( 'settings:get', - async (cwd) => - (await readDefaults())[await knownWorkspace(cwd)] ?? DEFAULTS, + async (cwd) => registry().resolve(cwd, false).defaults, + ); + handle('settings:save', async (cwd, value) => + registry().saveDefaults(cwd, value), ); - handle('settings:save', async (cwd, value) => { - cwd = await knownWorkspace(cwd); - const settings = await readDefaults(); - const defaults = validateDefaults(value); - settings[cwd] = defaults; - await fs.mkdir(app.getPath('userData'), { recursive: true }); - const tmp = `${settingsFile()}.tmp`; - await fs.writeFile(tmp, JSON.stringify(settings, null, 2)); - await fs.rename(tmp, settingsFile()); - return defaults; - }); handle('settings:providers', async () => { const dir = path.join(os.homedir(), '.pi', 'agent'); await fs.mkdir(dir, { recursive: true }); @@ -157,7 +132,14 @@ export function registerWorkbench( }); handle('models:list', async (cwd) => { cwd = await knownWorkspace(cwd); - const existing = [...sessions.values()].find((s) => s.cwd === cwd); + let existing = [...sessions.values()].find((s) => s.cwd === cwd); + if (existing) { + try { + await validateRuntime(existing); + } catch { + existing = undefined; + } + } const runtime = existing ?? new RpcSession('model-probe', cwd, undefined, true); try { @@ -187,6 +169,7 @@ export function registerWorkbench( handle('session:model', async (id, provider, modelId) => { const runtime = sessions.get(id); if (!runtime) throw new Error('Resume the session to change its model.'); + await validateRuntime(runtime); const result = await runtime.command({ type: 'set_model', provider, @@ -196,17 +179,3 @@ export function registerWorkbench( throw new Error(result.error ?? 'Could not change model.'); }); } - -export async function sessionDefaults(cwd: string): Promise { - try { - const all = JSON.parse( - await fs.readFile( - path.join(app.getPath('userData'), 'session-defaults.json'), - 'utf8', - ), - ); - return validateDefaults(all[cwd] ?? DEFAULTS); - } catch { - return DEFAULTS; - } -} diff --git a/src/main/workbench.ts b/src/main/workbench.ts index f892fe0..488364f 100644 --- a/src/main/workbench.ts +++ b/src/main/workbench.ts @@ -29,6 +29,7 @@ export type WorkspaceDetails = { changes: GitChange[]; files: string[]; gitError?: string; + filesTruncated?: boolean; }; export type Defaults = { model: string; provider: string; thinking: string }; export type SkillInfo = { @@ -93,6 +94,21 @@ export async function git(cwd: string, args: string[]): Promise { return stdout; } +/** A folder narrows stage-all, but Git commit always consumes the whole index. */ +export async function assertCommitScope(cwd: string): Promise { + const top = (await git(cwd, ['rev-parse', '--show-toplevel'])).trim(); + const root = await fs.realpath(cwd); + const files = ( + await git(top, ['diff', '--cached', '--name-only', '-z', '--no-renames']) + ) + .split('\0') + .filter(Boolean); + if (files.some((file) => !inside(root, path.resolve(top, file)))) + throw new Error( + 'The staged index includes changes outside this root. Open the repository top-level folder to review and commit the whole index.', + ); +} + export function parseNumstat(text: string, staged: boolean): GitChange[] { return text .split('\0') @@ -113,10 +129,16 @@ export function parseNumstat(text: string, staged: boolean): GitChange[] { }); } -async function listFiles(cwd: string): Promise { +async function listFiles( + cwd: string, +): Promise<{ files: string[]; filesTruncated: boolean }> { const files: string[] = []; + let filesTruncated = false; async function walk(relative: string, depth: number) { - if (depth > 7 || files.length >= 3000) return; + if (depth > 7 || files.length >= 3000) { + filesTruncated = true; + return; + } const entries = await fs.readdir(path.join(cwd, relative), { withFileTypes: true, }); @@ -126,6 +148,10 @@ async function listFiles(cwd: string): Promise { a.name.localeCompare(b.name), ); for (const entry of entries) { + if (files.length >= 3000) { + filesTruncated = true; + break; + } if ( OMIT.has(entry.name) || entry.isSymbolicLink() || @@ -138,11 +164,11 @@ async function listFiles(cwd: string): Promise { } } await walk('', 0); - return files; + return { files, filesTruncated }; } export async function inspectWorkspace(cwd: string): Promise { - const files = await listFiles(cwd); + const { files, filesTruncated } = await listFiles(cwd); try { const [branch, working, staged, untracked] = await Promise.all([ git(cwd, ['branch', '--show-current']), @@ -174,6 +200,7 @@ export async function inspectWorkspace(cwd: string): Promise { return { branch: branch.trim(), files, + filesTruncated, changes: [ ...parseNumstat(working, false), ...parseNumstat(staged, true), @@ -194,6 +221,7 @@ export async function inspectWorkspace(cwd: string): Promise { branch: '', changes: [], files, + filesTruncated, gitError: 'Git changes are unavailable for this folder.', }; } diff --git a/src/main/workspaces.ts b/src/main/workspaces.ts new file mode 100644 index 0000000..4295db0 --- /dev/null +++ b/src/main/workspaces.ts @@ -0,0 +1,372 @@ +import fs from 'fs'; +import path from 'path'; +import { randomUUID } from 'crypto'; +import { canonicalCwd, type SessionInfo } from './sessions'; +import { DEFAULTS, validateDefaults, type Defaults } from './workbench'; + +export type WorkspaceRoot = { + id: string; + path: string; + pathResolved?: boolean; + aliases: string[]; + defaults: Defaults; +}; +export type WorkspaceGroup = { id: string; name: string; rootIds: string[] }; +export type Registry = { + schemaVersion: 1; + roots: WorkspaceRoot[]; + groups: WorkspaceGroup[]; + selected?: { groupId: string; rootId: string }; + legacyDefaults: Record; +}; +export type Availability = 'available' | 'missing' | 'unavailable'; + +export function availability(cwd: string): Availability { + try { + if (!fs.statSync(cwd).isDirectory()) return 'missing'; + fs.accessSync(cwd, fs.constants.R_OK | fs.constants.X_OK); + return 'available'; + } catch (error) { + return (error as NodeJS.ErrnoException).code === 'ENOENT' + ? 'missing' + : 'unavailable'; + } +} + +function readJson(file: string, fallback: unknown): unknown { + try { + return JSON.parse(fs.readFileSync(file, 'utf8')); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return fallback; + throw new Error( + `Cannot read ${path.basename(file)}. Restore a valid backup before continuing.`, + ); + } +} + +function validate(value: unknown): Registry { + const v = value as Registry | null; + if ( + !v || + v.schemaVersion !== 1 || + !Array.isArray(v.roots) || + !Array.isArray(v.groups) + ) + throw new Error( + 'Invalid workspace registry version or contents. Restore a valid backup before continuing.', + ); + const ids = new Set(); + for (const root of v.roots) { + if ( + !root || + typeof root.id !== 'string' || + !root.id || + ids.has(root.id) || + typeof root.path !== 'string' || + !path.isAbsolute(root.path) || + (root.pathResolved !== undefined && + typeof root.pathResolved !== 'boolean') || + !Array.isArray(root.aliases) || + !root.aliases.every((p) => typeof p === 'string' && path.isAbsolute(p)) + ) + throw new Error('Invalid workspace root record.'); + validateDefaults(root.defaults); + ids.add(root.id); + } + const groupIds = new Set(); + for (const group of v.groups) { + if ( + !group || + typeof group.id !== 'string' || + !group.id || + groupIds.has(group.id) || + typeof group.name !== 'string' || + !group.name.trim() || + !Array.isArray(group.rootIds) || + new Set(group.rootIds).size !== group.rootIds.length || + !group.rootIds.every((id) => ids.has(id)) + ) + throw new Error('Invalid workspace group record.'); + groupIds.add(group.id); + } + if ( + v.selected && + !v.groups.some( + (g) => + g.id === v.selected?.groupId && g.rootIds.includes(v.selected.rootId), + ) + ) + throw new Error('Invalid saved workspace selection.'); + if ( + !v.legacyDefaults || + typeof v.legacyDefaults !== 'object' || + Array.isArray(v.legacyDefaults) + ) + throw new Error('Invalid workspace migration provenance.'); + return v; +} + +/** Main owns synchronous, atomic registry transactions. Native archives are never written here. */ +export class WorkspaceRegistry { + private state: Registry; + private file: string; + constructor( + private directory: string, + discovered: SessionInfo[] = [], + extra: string[] = [], + ) { + this.file = path.join(directory, 'workspace-registry.json'); + const saved = readJson(this.file, undefined); + if (saved !== undefined) this.state = validate(saved); + else { + const folders = readJson(path.join(directory, 'workspaces.json'), []); + const defaults = readJson( + path.join(directory, 'session-defaults.json'), + {}, + ); + if ( + !Array.isArray(folders) || + !folders.every((p) => typeof p === 'string') || + !defaults || + typeof defaults !== 'object' || + Array.isArray(defaults) + ) + throw new Error( + 'Invalid legacy workspace records. Restore a valid backup before continuing.', + ); + const legacyDefaults: Record = {}; + for (const [cwd, value] of Object.entries(defaults)) + legacyDefaults[cwd] = validateDefaults(value); + this.state = { schemaVersion: 1, roots: [], groups: [], legacyDefaults }; + // Exact canonical key wins, then lexical alias order. Preserve all original values. + for (const cwd of [ + ...new Set([ + ...folders, + ...Object.keys(defaults), + ...extra, + ...discovered.map((s) => s.cwd), + ]), + ] + .filter((p) => path.isAbsolute(p)) + .sort()) + this.addRootTo(this.state, cwd); + for (const root of this.state.roots) { + const key = legacyDefaults[root.path] + ? root.path + : root.aliases.find((p) => legacyDefaults[p]); + root.defaults = key ? legacyDefaults[key] : { ...DEFAULTS }; + } + fs.mkdirSync(directory, { recursive: true }); + for (const name of ['workspaces.json', 'session-defaults.json']) { + try { + fs.copyFileSync( + path.join(directory, name), + path.join(directory, `${name}.bak`), + fs.constants.COPYFILE_EXCL, + ); + } catch (error) { + if ( + !['ENOENT', 'EEXIST'].includes( + (error as NodeJS.ErrnoException).code ?? '', + ) + ) + throw error; + } + } + this.commit(this.state); + } + this.discover([...extra, ...discovered.map((s) => s.cwd)]); + } + snapshot(): Registry { + return structuredClone(this.state); + } + private commit(next: Registry) { + validate(next); + fs.mkdirSync(this.directory, { recursive: true }); + const temporary = `${this.file}.tmp`; + fs.writeFileSync(temporary, JSON.stringify(next, null, 2)); + fs.renameSync(temporary, this.file); + this.state = next; + } + // Only paths that were unresolved may acquire their canonical spelling. + // A previously pinned path must never follow a newly retargeted symlink. + private normalizePaths(next: Registry) { + for (const root of next.roots) { + if (root.pathResolved) continue; + if (availability(root.path) !== 'available') { + root.pathResolved = false; + continue; + } + const previous = root.path; + try { + root.path = fs.realpathSync(previous); + root.pathResolved = true; + if (!root.aliases.includes(previous)) root.aliases.push(previous); + } catch { + root.pathResolved = false; + } + } + } + private addRootTo(next: Registry, cwd: string): WorkspaceRoot { + if (typeof cwd !== 'string' || !path.isAbsolute(cwd)) + throw new Error('Choose an absolute folder path.'); + let canonical = path.resolve(cwd); + let pathResolved = false; + try { + canonical = fs.realpathSync(cwd); + pathResolved = availability(canonical) === 'available'; + } catch { + /* Preserve a missing root's lexical path until it can be resolved. */ + } + const existing = next.roots.find( + (r) => + r.aliases.some((alias) => path.resolve(alias) === path.resolve(cwd)) || + canonicalCwd(r.path) === canonical, + ); + if (existing) { + if (!existing.aliases.includes(cwd)) existing.aliases.push(cwd); + return existing; + } + const root = { + id: randomUUID(), + path: canonical, + pathResolved, + aliases: [cwd], + defaults: { ...DEFAULTS }, + }; + next.roots.push(root); + next.groups.push({ + id: randomUUID(), + name: path.basename(canonical) || canonical, + rootIds: [root.id], + }); + return root; + } + discover(cwds: string[]) { + const next = this.snapshot(); + this.normalizePaths(next); + for (const cwd of cwds) if (path.isAbsolute(cwd)) this.addRootTo(next, cwd); + if (JSON.stringify(next) !== JSON.stringify(this.state)) this.commit(next); + } + add(cwd: string, groupId?: string): WorkspaceRoot { + if (availability(cwd) !== 'available') + throw new Error('Choose an available folder.'); + const next = this.snapshot(); + this.normalizePaths(next); + const group = groupId + ? next.groups.find((g) => g.id === groupId) + : undefined; + if (groupId && !group) throw new Error('Unknown workspace group.'); + const root = this.addRootTo(next, cwd); + if (canonicalCwd(root.path) !== canonicalCwd(cwd)) + throw new Error( + "This registered alias points to a different folder. Add the folder's real path instead.", + ); + if (group && !group.rootIds.includes(root.id)) group.rootIds.push(root.id); + this.commit(next); + return root; + } + resolve(cwdOrId: unknown, requireAvailable = true): WorkspaceRoot { + let root = + typeof cwdOrId === 'string' + ? (this.state.roots.find( + (r) => + r.id === cwdOrId || + r.path === cwdOrId || + (path.isAbsolute(cwdOrId) && + r.aliases.some( + (alias) => path.resolve(alias) === path.resolve(cwdOrId), + )), + ) ?? + (path.isAbsolute(cwdOrId) + ? this.state.roots.find( + (r) => canonicalCwd(r.path) === canonicalCwd(cwdOrId), + ) + : undefined)) + : undefined; + if (root && !root.pathResolved && availability(root.path) === 'available') { + const id = root.id; + const next = this.snapshot(); + this.normalizePaths(next); + this.commit(next); + root = next.roots.find((candidate) => candidate.id === id); + } + if (!root) throw new Error('Unknown workspace root.'); + if (requireAvailable && availability(root.path) !== 'available') + throw new Error( + 'Workspace root is missing or unavailable. Restore its original folder to continue.', + ); + if (requireAvailable && canonicalCwd(root.path) !== root.path) + throw new Error( + 'The registered root path now points to another folder. Restore its original location before continuing.', + ); + return structuredClone(root); + } + saveDefaults(cwd: unknown, value: unknown): Defaults { + const root = this.resolve(cwd, false); + const defaults = validateDefaults(value); + const next = this.snapshot(); + next.roots.find((r) => r.id === root.id)!.defaults = defaults; + this.commit(next); + return defaults; + } + saveGroup( + id: unknown, + name: unknown, + rootIds: unknown, + expectedRoots?: string[], + ): WorkspaceGroup { + if ( + typeof name !== 'string' || + !name.trim() || + name.length > 100 || + !Array.isArray(rootIds) || + !rootIds.every( + (r) => + typeof r === 'string' && + this.state.roots.some((root) => root.id === r), + ) + ) + throw new Error('Choose a workspace name and known roots.'); + const next = this.snapshot(); + let group = next.groups.find((g) => g.id === id); + if (id && !group) throw new Error('Unknown workspace group.'); + if ( + group && + JSON.stringify(group.rootIds) !== JSON.stringify(expectedRoots) + ) + throw new Error( + 'Workspace membership changed. Reopen its settings before saving.', + ); + if (!group) { + group = { id: randomUUID(), name: '', rootIds: [] }; + next.groups.push(group); + } + group.name = name.trim(); + group.rootIds = [...new Set(rootIds)]; + if ( + next.selected?.groupId === group.id && + !group.rootIds.includes(next.selected.rootId) + ) + next.selected = undefined; + // Orphaned roots remain reachable in a singleton group. + for (const root of next.roots) + if (!next.groups.some((g) => g.rootIds.includes(root.id))) + next.groups.push({ + id: randomUUID(), + name: path.basename(root.path), + rootIds: [root.id], + }); + this.commit(next); + return group; + } + select(groupId: string, rootId: string) { + const next = this.snapshot(); + if ( + !next.groups.some((g) => g.id === groupId && g.rootIds.includes(rootId)) + ) + throw new Error('Root is outside this workspace group.'); + next.selected = { groupId, rootId }; + this.commit(next); + } +} diff --git a/src/preload/index.ts b/src/preload/index.ts index d7817f8..7e537a8 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -12,7 +12,13 @@ contextBridge.exposeInMainWorld('pidex', { node: process.versions.node, }, workspaces: () => ipcRenderer.invoke('pidex:workspaces'), - addWorkspace: () => ipcRenderer.invoke('pidex:workspace:add'), + workspaceCatalog: () => ipcRenderer.invoke('pidex:workspace:catalog'), + saveWorkspaceGroup: (group: unknown) => + ipcRenderer.invoke('pidex:workspace:group', group), + selectWorkspace: (groupId: string, rootId: string) => + ipcRenderer.invoke('pidex:workspace:select', groupId, rootId), + addWorkspace: (groupId?: string) => + ipcRenderer.invoke('pidex:workspace:add', groupId), inspectWorkspace: (cwd: string) => ipcRenderer.invoke('pidex:workspace:inspect', cwd), readFile: (cwd: string, file: string) => diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 6155403..0d07f3e 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -1,3 +1,4 @@ +import { WorkspaceNavigation } from './components/WorkspaceNavigation'; import { SessionTree, EntryTree } from './components/SessionTree'; import { useEffect, useState } from 'react'; import { PidexMark } from './components/PidexMark'; @@ -28,7 +29,8 @@ function App() { const [palette, setPalette] = useState(false); const [sessionQuery, setSessionQuery] = useState(''); const [allHistory, setAllHistory] = useState(false); - const [paletteFile, setPaletteFile] = useState(''); + const [paletteFiles, setPaletteFiles] = useState>({}); + const paletteFile = paletteFiles[b.selected] ?? ''; const [resetSettings, setResetSettings] = useState(0); useEffect(() => { const onKey = (event: KeyboardEvent) => { @@ -59,29 +61,35 @@ function App() { }); const composer = (placeholder?: string, label?: string) => ( - active && b.run(() => api.abortSession(active.runtimeId))} - onAttach={b.attach} - model={b.currentModel} - models={b.models} - onModel={b.changeModel} - running={view === 'session' && active?.running} - disabled={ - !ws || - (view === 'session' && active?.exited) || - b.previewLoading || - (view === 'history' && !preview?.ready) - } - busy={b.busy} - placeholder={placeholder} - label={label} - commands={active?.commands ?? []} - modelDisabled={view === 'history'} - allowDefaultModel={view === 'new' || !active} - /> +
+

+ Run in {ws?.name ?? 'chosen root'} · {branch} · {ws?.cwd} +

+ active && b.run(() => api.abortSession(active.runtimeId))} + onAttach={b.attach} + model={b.currentModel} + models={b.models} + onModel={b.changeModel} + running={view === 'session' && active?.running} + disabled={ + !ws || + ws.availability !== 'available' || + (view === 'session' && active?.exited) || + b.previewLoading || + (view === 'history' && !preview?.ready) + } + busy={b.busy} + placeholder={placeholder} + label={label} + commands={active?.commands ?? []} + modelDisabled={view === 'history'} + allowDefaultModel={view === 'new' || !active} + /> +
); const filteredSessions = sessions.filter((s) => `${s.title} ${s.model}`.toLowerCase().includes(sessionQuery.toLowerCase()), @@ -97,7 +105,7 @@ function App() { }[view]; const branch = details.branch || ws?.branch || 'local'; const context = `${ws?.name ?? 'pidex'} / ${branch} / ${view === 'history' ? `${sessions.length} sessions` : view === 'changes' ? `${details.changes.length} changes` : view === 'settings' ? 'workspace defaults' : 'local workspace'}`; - const live = b.tabs.filter((t) => t.cwd === ws?.cwd && !t.exited); + const live = b.tabs; const exportSession = () => { if (!preview) return; const text = preview.entries @@ -165,15 +173,16 @@ function App() {
{sidebar && (