/** * Directory and preference handlers: pin, unpin, reorder, openspec, pi-gateway forwards. */ import type { BrowserToServerMessage } from "@blackbelt-technology/pi-dashboard-shared/browser-protocol.js"; import type { BrowserHandlerContext } from "./handler-context.js"; import { safeRealpathSync } from "../resolve-path.js"; import { archiveCompleted as openspecArchiveCompleted } from "@blackbelt-technology/pi-dashboard-shared/platform/openspec.js"; import { normalizePath } from "@blackbelt-technology/pi-dashboard-shared/platform/paths.js"; /** * Canonicalize a user-supplied path before storage: normalize separator / * trailing-sep / case variants first, then resolve symlinks. Order matters * — `realpath` can fail for not-yet-existing paths, so we keep its * best-effort fallback but ensure we first have a sane string. * See change: platform-path-normalization. */ function canonicalizePath(input: string): string { return safeRealpathSync(normalizePath(input)); } export function handlePinDirectory( msg: Extract, ctx: BrowserHandlerContext, ): void { const { preferencesStore, directoryService, sessionManager, broadcast } = ctx; if (!preferencesStore) return; const resolved = canonicalizePath(msg.path); preferencesStore.pinDirectory(resolved); broadcast({ type: "pinned_dirs_updated", paths: preferencesStore.getPinnedDirectories() }); if (directoryService) { directoryService.onDirectoryAdded(resolved).then(({ sessions, openspecData }) => { for (const hist of sessions) { if (!sessionManager.get(hist.id)) { sessionManager.register({ id: hist.id, cwd: hist.cwd, name: hist.name, source: "tui", sessionFile: hist.sessionFile, sessionDir: hist.sessionDir, firstMessage: hist.firstMessage, startedAt: hist.startedAt, }); sessionManager.unregister(hist.id); sessionManager.update(hist.id, { hidden: true }); const s = sessionManager.get(hist.id); if (s) broadcast({ type: "session_added", session: s }); } } broadcast({ type: "openspec_update", cwd: resolved, data: openspecData } as any); }).catch(() => {}); } } export function handleUnpinDirectory( msg: Extract, ctx: BrowserHandlerContext, ): void { if (ctx.preferencesStore) { ctx.preferencesStore.unpinDirectory(canonicalizePath(msg.path)); ctx.broadcast({ type: "pinned_dirs_updated", paths: ctx.preferencesStore.getPinnedDirectories() }); } } export function handleReorderPinnedDirs( msg: Extract, ctx: BrowserHandlerContext, ): void { if (ctx.preferencesStore) { // Wrap in arrow fn: map's (elem, index, array) callback would pass // the array index as canonicalizePath's 2nd arg, silently breaking // platform detection. See platform-path-normalization. ctx.preferencesStore.reorderPinnedDirs(msg.paths.map((p) => canonicalizePath(p))); ctx.broadcast({ type: "pinned_dirs_updated", paths: ctx.preferencesStore.getPinnedDirectories() }); } } // ── folder-workspaces handlers ────────────────────────────────── // // Each handler dispatches to PreferencesStore which returns true on // mutation. Broadcast `workspaces_updated` only on actual mutation // (no broadcast for no-op / invalid / unknown-id calls). See spec // folder-workspaces. function broadcastWorkspaces(ctx: BrowserHandlerContext): void { if (!ctx.preferencesStore) return; ctx.broadcast({ type: "workspaces_updated", workspaces: ctx.preferencesStore.getWorkspaces() }); } export function handleCreateWorkspace( msg: Extract, ctx: BrowserHandlerContext, ): void { const ws = ctx.preferencesStore?.createWorkspace(msg.name); if (ws) broadcastWorkspaces(ctx); } export function handleRenameWorkspace( msg: Extract, ctx: BrowserHandlerContext, ): void { if (ctx.preferencesStore?.renameWorkspace(msg.id, msg.name)) broadcastWorkspaces(ctx); } export function handleDeleteWorkspace( msg: Extract, ctx: BrowserHandlerContext, ): void { if (ctx.preferencesStore?.deleteWorkspace(msg.id)) broadcastWorkspaces(ctx); } export function handleSetWorkspaceCollapsed( msg: Extract, ctx: BrowserHandlerContext, ): void { if (ctx.preferencesStore?.setWorkspaceCollapsed(msg.id, msg.collapsed)) broadcastWorkspaces(ctx); } export function handleAddFolderToWorkspace( msg: Extract, ctx: BrowserHandlerContext, ): void { if (ctx.preferencesStore?.addFolderToWorkspace(msg.id, msg.path)) broadcastWorkspaces(ctx); } export function handleRemoveFolderFromWorkspace( msg: Extract, ctx: BrowserHandlerContext, ): void { if (ctx.preferencesStore?.removeFolderFromWorkspace(msg.id, msg.path)) broadcastWorkspaces(ctx); } export function handleReorderWorkspaceFolders( msg: Extract, ctx: BrowserHandlerContext, ): void { if (ctx.preferencesStore?.reorderWorkspaceFolders(msg.id, msg.paths)) broadcastWorkspaces(ctx); } export function handleReorderWorkspaces( msg: Extract, ctx: BrowserHandlerContext, ): void { if (ctx.preferencesStore?.reorderWorkspaces(msg.ids)) broadcastWorkspaces(ctx); } export function handleReorderSessions( msg: Extract, ctx: BrowserHandlerContext, ): void { if (ctx.sessionOrderManager) { ctx.sessionOrderManager.reorder(msg.cwd, msg.sessionIds); ctx.broadcast({ type: "sessions_reordered", cwd: msg.cwd, sessionIds: msg.sessionIds }); } } export function handleOpenSpecRefresh( msg: Extract, ctx: BrowserHandlerContext, ): void { if (ctx.directoryService) { ctx.directoryService.refreshOpenSpec(msg.cwd).then((data) => { ctx.broadcast({ type: "openspec_update", cwd: msg.cwd, data }); }); } } export function handleOpenSpecBulkArchive( msg: Extract, ctx: BrowserHandlerContext, ): void { if (ctx.directoryService) { // Delegate to the shared openspec tool module. The runner handles // windowsHide, timeout, and argv-array escaping. // See change: platform-command-executor. openspecArchiveCompleted({ cwd: msg.cwd }); // Post-archive refresh stays gated: bulk-archive bumps `/` // mtime once (entry removal), so the gate naturally re-runs `list` and // any per-change CLI calls whose effective mtime advanced. Skipping // the user-facing `refreshOpenSpec` (which now force-bypasses the gate) // avoids O(N) status spawns after every bulk archive. // See change: fix-openspec-mtime-gate-toctou. Promise.resolve() .then(() => ctx.directoryService!.pollDirectoryGated(msg.cwd)) .then((data) => { if (data) ctx.broadcast({ type: "openspec_update", cwd: msg.cwd, data }); }); } } export function handleExtensionUiResponse( msg: Extract, ctx: BrowserHandlerContext, ): void { ctx.piGateway.sendToSession(msg.sessionId, { type: "extension_ui_response", sessionId: msg.sessionId, requestId: msg.requestId, result: msg.result, cancelled: msg.cancelled, }); } /** Forward simple pi-gateway commands (request_commands, list_files, request_models, set_model, set_thinking_level) */ export function handlePiGatewayForward( msg: BrowserToServerMessage, ctx: BrowserHandlerContext, ): void { const { piGateway } = ctx; switch (msg.type) { case "request_commands": piGateway.sendToSession(msg.sessionId, { type: "request_commands", sessionId: msg.sessionId }); break; case "list_files": piGateway.sendToSession(msg.sessionId, { type: "list_files", sessionId: msg.sessionId, query: msg.query }); break; case "request_models": piGateway.sendToSession(msg.sessionId, { type: "request_models", sessionId: msg.sessionId }); break; case "request_providers": // See change: replace-hardcoded-provider-lists. piGateway.sendToSession(msg.sessionId, { type: "request_providers", sessionId: msg.sessionId }); break; case "set_thinking_level": piGateway.sendToSession(msg.sessionId, { type: "set_thinking_level", sessionId: msg.sessionId, level: msg.level }); break; case "set_model": piGateway.sendToSession(msg.sessionId, { type: "set_model", sessionId: msg.sessionId, provider: msg.provider, modelId: msg.modelId }); break; } }