diff --git a/apps/desktop/docs/SINGLE_ACTIVE_FS_WATCHER.md b/apps/desktop/docs/SINGLE_ACTIVE_FS_WATCHER.md new file mode 100644 index 00000000000..4dbb786889c --- /dev/null +++ b/apps/desktop/docs/SINGLE_ACTIVE_FS_WATCHER.md @@ -0,0 +1,280 @@ +# Single Active Workspace FsWatcher + +## Problem + +The `FsWatcher` maintains one `@parcel/watcher` native subscription per workspace in a `Map`. On app boot it starts watchers for **all** active workspaces. Since users can only view one workspace at a time, this wastes native file descriptor resources and OS kernel watch capacity for every inactive workspace. + +## Solution + +Refactor `FsWatcher` to watch **only the active workspace**, switching the watcher when the active workspace changes. The single chokepoint is `setLastActiveWorkspace()` — every workspace switch (create, setActive, delete/close fallback, project open) flows through this function. + +## Architecture + +### Data Flow + +``` +User action (create / switch / delete / close / open project) + └─> setLastActiveWorkspace(workspaceId) [db-helpers.ts] + ├─> DB upsert: settings.lastActiveWorkspaceId = workspaceId + └─> fsWatcher.switchTo({ workspaceId, rootPath }) [fire-and-forget] + ├─> flush pending events from old watcher + ├─> unsubscribe old @parcel/watcher subscription + ├─> subscribe new @parcel/watcher subscription + └─> emit "switched" event (clears search cache) + +App boot + └─> startFileWatcherForActiveWorkspace() [main/index.ts] + ├─> query settings.lastActiveWorkspaceId + ├─> look up workspace root path via DB + └─> fsWatcher.switchTo({ workspaceId, rootPath }) + +Workspace init completes (worktree now exists on disk) + └─> check settings.lastActiveWorkspaceId === workspaceId + └─> if yes: fsWatcher.switchTo({ workspaceId, rootPath }) + (handles timing edge case where setLastActiveWorkspace was called + before the worktree directory existed) +``` + +### Why `setLastActiveWorkspace` is the Right Chokepoint + +Every code path that changes which workspace is "active" calls this function: + +| Call site | Count | Context | +|-----------|-------|---------| +| `procedures/create.ts` | ~10 | All workspace creation flows (new, existing branch, reopen, etc.) | +| `procedures/status.ts` | 1 | `setActive` mutation | +| `projects.ts` (open) | 2 | Opening a project switches to its workspace | +| `db-helpers.ts` (internal) | 1 | `updateActiveWorkspaceIfRemoved()` after delete/close | + +Total: ~14 call sites, all funneled through one function. + +## FsWatcher API + +### Before (multi-watcher) + +```typescript +class FsWatcher extends EventEmitter { + private watchers = new Map(); + + async watch({ workspaceId, rootPath }): Promise; + async unwatch(workspaceId: string): Promise; + async unwatchAll(): Promise; + getRootPath(workspaceId: string): string | undefined; +} +``` + +### After (single-watcher) + +```typescript +class FsWatcher extends EventEmitter { + private active: WatcherState | null = null; + + async switchTo({ workspaceId, rootPath }): Promise; // NEW + async unwatch(workspaceId: string): Promise; // only acts if ID matches active + async stop(): Promise; // replaces unwatchAll() + getRootPath(workspaceId: string): string | undefined; // only returns if ID matches active + getActiveWorkspaceId(): string | undefined; // NEW +} +``` + +### Key Behaviors + +- **`switchTo` no-ops for same workspace** — if `active.workspaceId === workspaceId`, returns immediately. Prevents redundant unsubscribe/resubscribe when the same workspace is set active multiple times. +- **`switchTo` flushes before stopping** — pending batched events from the old workspace are emitted before the subscription is torn down, so no events are silently lost. +- **`switchTo` emits `"switched"` event** — consumers (search cache) listen for this to clear stale state. +- **`unwatch(id)` is a conditional stop** — only acts if `id` matches the active watcher. This makes existing `unwatch` calls in the delete flow safe no-ops when the workspace isn't active. + +## Changes Per File + +### 1. `src/main/lib/fs-watcher/fs-watcher.ts` — Core Refactor + +Replace `Map` with `private active: WatcherState | null`. + +```typescript +interface WatcherState { + workspaceId: string; + subscription: AsyncSubscription; + rootPath: string; + pendingEvents: Map; + debounceTimer: ReturnType | null; + maxWindowTimer: ReturnType | null; +} +``` + +Methods: +- **`switchTo({ workspaceId, rootPath })`** — no-op if same workspace ID; otherwise call `stop()`, create new `@parcel/watcher` subscription, set `this.active`, emit `"switched"`. +- **`unwatch(workspaceId)`** — guard: `if (!this.active || this.active.workspaceId !== workspaceId) return;` then call `stopInternal()`. +- **`stop()`** — public wrapper for `stopInternal()`. Replaces `unwatchAll()`. +- **`stopInternal()`** — flush pending events, clear timers, unsubscribe, set `this.active = null`. +- **`getRootPath(workspaceId)`** — return `this.active.rootPath` only if IDs match. +- **`getActiveWorkspaceId()`** — return `this.active?.workspaceId`. +- **`handleEvents`** — guard: `if (!this.active || this.active.workspaceId !== workspaceId) return;` then operate on `this.active` directly. +- **`flush()`** — operate on `this.active` directly instead of Map lookup. + +All batching/debounce/dedup logic (100ms debounce, 2s max batch window, last-write-wins dedup by path) is unchanged. + +### 2. `src/lib/trpc/routers/workspaces/utils/db-helpers.ts` — Wire the Chokepoint + +Add to `setLastActiveWorkspace()` after the DB upsert: + +```typescript +import { fsWatcher } from "main/lib/fs-watcher"; +import { getWorkspacePath } from "./worktree"; + +// After DB upsert: +if (workspaceId) { + const workspace = localDb.select().from(workspaces).where(eq(workspaces.id, workspaceId)).get(); + if (workspace) { + const rootPath = getWorkspacePath(workspace); + if (rootPath) { + fsWatcher.switchTo({ workspaceId, rootPath }).catch((err) => { + console.error("[db-helpers] Failed to switch fs watcher:", err); + }); + } + // If rootPath is null, worktree isn't created yet — + // workspace-init will call switchTo() when it finishes + } +} else { + fsWatcher.stop().catch((err) => { + console.error("[db-helpers] Failed to stop fs watcher:", err); + }); +} +``` + +`getWorkspacePath(workspace)` (from `worktree.ts`) already handles both workspace types: +- **worktree type**: looks up `worktrees.path` via `workspace.worktreeId` +- **branch type**: looks up `projects.mainRepoPath` via `workspace.projectId` + +### 3. `src/main/index.ts` — Simplify Boot + +Replace `startFileWatchersForActiveWorkspaces()` with `startFileWatcherForActiveWorkspace()`: + +```typescript +import { getWorkspace } from "lib/trpc/routers/workspaces/utils/db-helpers"; +import { getWorkspacePath } from "lib/trpc/routers/workspaces/utils/worktree"; + +function startFileWatcherForActiveWorkspace(): void { + const row = localDb.select().from(settings).get(); + const workspaceId = row?.lastActiveWorkspaceId; + if (!workspaceId) return; + + const workspace = getWorkspace(workspaceId); + if (!workspace) return; + + const rootPath = getWorkspacePath(workspace); + if (!rootPath) return; + + fsWatcher.switchTo({ workspaceId, rootPath }).catch(console.error); +} +``` + +Remove the `workspaces` and `worktrees` imports from `@superset/local-db` (no longer needed). Keep `settings` import. + +### 4. `src/lib/trpc/routers/workspaces/utils/workspace-init.ts` — Conditional switchTo + +At both places where `fsWatcher.watch()` is called (existing branch path and new branch path), replace with: + +```typescript +import { settings } from "@superset/local-db"; + +// At end of init, after "ready": +if (!manager.isCancellationRequested(workspaceId)) { + const activeId = localDb.select().from(settings).get()?.lastActiveWorkspaceId; + if (activeId === workspaceId) { + fsWatcher.switchTo({ workspaceId, rootPath: worktreePath }).catch(console.error); + } +} +``` + +This handles the timing edge case where `setLastActiveWorkspace()` was called before the worktree directory existed on disk. By the time init finishes, the directory exists, so `switchTo` can proceed. + +### 5. `src/lib/trpc/routers/workspaces/procedures/delete.ts` — DB Path Lookup + +**Line ~167**: Replace `fsWatcher.getRootPath(input.id)` with `getWorkspacePath(workspace)`: +```typescript +import { getWorkspacePath } from "../utils/worktree"; + +// workspace is already fetched at the start of the mutation +const savedRootPath = getWorkspacePath(workspace); +``` + +**Three re-attach sites** (init cancel failure, teardown failure, disk removal failure): Replace `fsWatcher.watch()` with `fsWatcher.switchTo()`: +```typescript +if (savedRootPath) { + fsWatcher.switchTo({ workspaceId: input.id, rootPath: savedRootPath }).catch(console.error); +} +``` + +**Keep `fsWatcher.unwatch(input.id)` calls** — they now no-op if the workspace isn't the active one. + +**`close` mutation**: Keep `fsWatcher.unwatch(input.id)`. The subsequent `updateActiveWorkspaceIfRemoved()` calls `setLastActiveWorkspace()` which triggers `switchTo` for the next workspace. + +### 6. `src/lib/trpc/routers/filesystem/search.ts` — Clear Cache on Switch + +Add a listener for the `"switched"` event: + +```typescript +fsWatcher.on("switched", () => { + searchIndexCache.clear(); + searchIndexBuilds.clear(); +}); +``` + +The existing `"batch"` listener still works for incremental invalidation within the same workspace. + +### 7. Tests — Update for Single-Watcher Semantics + +**`fs-watcher.test.ts`**: +- `watch()` calls → `switchTo()` calls +- `unwatchAll()` → `stop()` +- Add test: `switchTo` no-ops for same workspace ID +- Add test: `switchTo` emits `"switched"` event +- `unwatch("ws-other")` no-ops for non-active workspace + +**`fs-watcher.lifecycle.test.ts`**: +- Same API renames +- Remove "concurrent workspace operations" test (no longer applicable) +- Add test: `switchTo` properly cleans up old watcher when switching +- Keep race condition tests (subscribe resolves late), cancellation guard, re-attach tests + +## Files Modified (Summary) + +| File | Change | +|------|--------| +| `src/main/lib/fs-watcher/fs-watcher.ts` | Core refactor: Map → single active state | +| `src/lib/trpc/routers/workspaces/utils/db-helpers.ts` | Wire `setLastActiveWorkspace` → `fsWatcher.switchTo/stop` | +| `src/main/index.ts` | Boot only active workspace | +| `src/lib/trpc/routers/workspaces/utils/workspace-init.ts` | Conditional `switchTo` after init | +| `src/lib/trpc/routers/workspaces/procedures/delete.ts` | DB path lookup, `switchTo` re-attach | +| `src/lib/trpc/routers/filesystem/search.ts` | Clear cache on `"switched"` event | +| `src/main/lib/fs-watcher/fs-watcher.test.ts` | Updated tests | +| `src/main/lib/fs-watcher/fs-watcher.lifecycle.test.ts` | Updated tests | + +## No Changes Needed + +| File | Why | +|------|-----| +| `src/lib/trpc/routers/filesystem/subscription.ts` | workspaceId filter in tRPC subscription still correct | +| `src/lib/trpc/routers/ports/ports.ts` | Already uses `getWorkspacePath()` for DB lookups; `subscribeStatic` filters by workspaceId | +| `src/renderer/**` | Consumers (`useFsSubscription`, `FilesView`, `ChangesView`) all pass workspaceId already | + +## Edge Cases + +### Worktree not yet created when workspace is set active +`setLastActiveWorkspace()` calls `getWorkspacePath()` which returns null if the worktree record doesn't exist yet. In this case, no `switchTo` happens. When `workspace-init.ts` finishes creating the worktree, it checks `settings.lastActiveWorkspaceId === workspaceId` and calls `switchTo` if it matches. + +### Delete cancellation race +The delete flow: +1. `unwatch(id)` — no-op if not active +2. `markWorkspaceAsDeleting(id)` +3. `updateActiveWorkspaceIfRemoved(id)` → `setLastActiveWorkspace(nextId)` → `switchTo(nextId)` +4. If deletion fails: `clearWorkspaceDeletingStatus(id)` then `switchTo(id, savedRootPath)` to re-attach + +### Init watch resolves after delete unwatch +Same race as before: delete's first `unwatch` is a no-op because `switchTo` hasn't resolved yet. After `waitForInit`, the second `unwatch` cleans up the late watcher. No behavioral change — the guard in `unwatch` (`if active.workspaceId !== id return`) handles this. + +## Verification + +1. `bun test` in `apps/desktop` — run updated fs-watcher tests +2. `bun run typecheck` — ensure all callers compile +3. Manual test: open app with multiple workspaces, verify only active workspace gets fs events (check `[fs-watcher]` console logs), switch workspaces and verify watcher switches diff --git a/apps/desktop/electron.vite.config.ts b/apps/desktop/electron.vite.config.ts index a26d52e019b..90ce0c4276f 100644 --- a/apps/desktop/electron.vite.config.ts +++ b/apps/desktop/electron.vite.config.ts @@ -85,7 +85,7 @@ export default defineConfig({ output: { dir: resolve(devPath, "main"), }, - external: ["electron", "better-sqlite3", "node-pty"], + external: ["electron", "better-sqlite3", "node-pty", "@parcel/watcher"], plugins: [sentryPlugin].filter(Boolean), }, }, diff --git a/apps/desktop/package.json b/apps/desktop/package.json index b936144e8f0..45a13647f32 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -45,6 +45,7 @@ "@headless-tree/react": "^1.6.3", "@hookform/resolvers": "^5.2.2", "@monaco-editor/react": "^4.7.0", + "@parcel/watcher": "^2.5.0", "@pierre/diffs": "^1.0.10", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-label": "^2.1.8", diff --git a/apps/desktop/scripts/copy-native-modules.ts b/apps/desktop/scripts/copy-native-modules.ts index 0f28a56b4bd..1497643e93b 100644 --- a/apps/desktop/scripts/copy-native-modules.ts +++ b/apps/desktop/scripts/copy-native-modules.ts @@ -17,7 +17,11 @@ import { cpSync, existsSync, lstatSync, realpathSync, rmSync } from "node:fs"; import { dirname, join } from "node:path"; // Native modules that must exist for the app to work -const NATIVE_MODULES = ["better-sqlite3", "node-pty"] as const; +const NATIVE_MODULES = [ + "better-sqlite3", + "node-pty", + "@parcel/watcher", +] as const; // Dependencies of native modules that need to be copied (may be hoisted or symlinked) const NATIVE_MODULE_DEPS = ["bindings", "file-uri-to-path"] as const; diff --git a/apps/desktop/src/lib/trpc/routers/filesystem/index.ts b/apps/desktop/src/lib/trpc/routers/filesystem/index.ts index 1c5b0a43cb1..5adbb7f1a18 100644 --- a/apps/desktop/src/lib/trpc/routers/filesystem/index.ts +++ b/apps/desktop/src/lib/trpc/routers/filesystem/index.ts @@ -1,465 +1,16 @@ -import fs from "node:fs/promises"; -import path from "node:path"; -import { shell } from "electron"; -import fg from "fast-glob"; -import Fuse from "fuse.js"; -import type { DirectoryEntry } from "shared/file-tree-types"; -import { z } from "zod"; -import { publicProcedure, router } from "../.."; - -const SEARCH_INDEX_TTL_MS = 30_000; -const MAX_SEARCH_RESULTS = 500; -const DEFAULT_IGNORE_PATTERNS = [ - "**/node_modules/**", - "**/.git/**", - "**/dist/**", - "**/build/**", - "**/.next/**", - "**/.turbo/**", - "**/coverage/**", -]; - -interface FileSearchItem { - id: string; - name: string; - relativePath: string; - path: string; - isDirectory: boolean; -} - -interface FileSearchIndex { - items: FileSearchItem[]; - fuse: Fuse; -} - -interface FileSearchCacheEntry { - index: FileSearchIndex; - builtAt: number; -} - -const searchIndexCache = new Map(); -const searchIndexBuilds = new Map>(); - -function getSearchCacheKey({ - rootPath, - includeHidden, -}: { - rootPath: string; - includeHidden: boolean; -}) { - return `${rootPath}::${includeHidden ? "hidden" : "visible"}`; -} - -async function buildSearchIndex({ - rootPath, - includeHidden, -}: { - rootPath: string; - includeHidden: boolean; -}): Promise { - const entries = await fg("**/*", { - cwd: rootPath, - onlyFiles: true, - dot: includeHidden, - followSymbolicLinks: false, - unique: true, - suppressErrors: true, - ignore: DEFAULT_IGNORE_PATTERNS, - }); - - const items = entries.map((relativePath) => ({ - id: relativePath, - name: path.basename(relativePath), - relativePath, - path: path.join(rootPath, relativePath), - isDirectory: false, - })); - - const fuse = new Fuse(items, { - keys: [ - { name: "name", weight: 2 }, - { name: "relativePath", weight: 1 }, - ], - threshold: 0.4, - includeScore: true, - ignoreLocation: true, - }); - - return { items, fuse }; -} - -async function getSearchIndex({ - rootPath, - includeHidden, -}: { - rootPath: string; - includeHidden: boolean; -}): Promise { - const cacheKey = getSearchCacheKey({ rootPath, includeHidden }); - const cached = searchIndexCache.get(cacheKey); - const now = Date.now(); - const inFlight = searchIndexBuilds.get(cacheKey); - - if (cached && now - cached.builtAt < SEARCH_INDEX_TTL_MS) { - return cached.index; - } - - if (cached && !inFlight) { - const buildPromise = buildSearchIndex({ rootPath, includeHidden }) - .then((index) => { - searchIndexCache.set(cacheKey, { index, builtAt: Date.now() }); - searchIndexBuilds.delete(cacheKey); - return index; - }) - .catch((error) => { - searchIndexBuilds.delete(cacheKey); - throw error; - }); - searchIndexBuilds.set(cacheKey, buildPromise); - return cached.index; - } - - if (cached) { - return cached.index; - } - - if (inFlight) { - return await inFlight; - } - - const buildPromise = buildSearchIndex({ rootPath, includeHidden }) - .then((index) => { - searchIndexCache.set(cacheKey, { index, builtAt: Date.now() }); - searchIndexBuilds.delete(cacheKey); - return index; - }) - .catch((error) => { - searchIndexBuilds.delete(cacheKey); - throw error; - }); - searchIndexBuilds.set(cacheKey, buildPromise); - - return await buildPromise; -} +import { router } from "../.."; +import { createOperationsRouter } from "./operations"; +import { createSearchRouter } from "./search"; +import { createSubscriptionRouter } from "./subscription"; export const createFilesystemRouter = () => { - return router({ - readDirectory: publicProcedure - .input( - z.object({ - dirPath: z.string(), - rootPath: z.string(), - includeHidden: z.boolean().default(false), - }), - ) - .query(async ({ input }): Promise => { - const { dirPath, rootPath, includeHidden } = input; - - try { - const entries = await fs.readdir(dirPath, { withFileTypes: true }); - - return entries - .filter((entry) => includeHidden || !entry.name.startsWith(".")) - .map((entry) => { - const fullPath = path.join(dirPath, entry.name); - const relativePath = path.relative(rootPath, fullPath); - return { - id: relativePath, - name: entry.name, - path: fullPath, - relativePath, - isDirectory: entry.isDirectory(), - }; - }) - .sort((a, b) => { - if (a.isDirectory !== b.isDirectory) { - return a.isDirectory ? -1 : 1; - } - return a.name.localeCompare(b.name); - }); - } catch (error) { - console.error("[filesystem/readDirectory] Failed:", { - dirPath, - error, - }); - return []; - } - }), - - searchFiles: publicProcedure - .input( - z.object({ - rootPath: z.string(), - query: z.string(), - includeHidden: z.boolean().default(false), - limit: z.number().default(200), - }), - ) - .query(async ({ input }) => { - const { rootPath, query, includeHidden, limit } = input; - const trimmedQuery = query.trim(); - - if (!trimmedQuery) { - return []; - } - - try { - const index = await getSearchIndex({ rootPath, includeHidden }); - const safeLimit = Math.max(1, Math.min(limit, MAX_SEARCH_RESULTS)); - const results = index.fuse.search(trimmedQuery, { - limit: safeLimit, - }); + const operationsRouter = createOperationsRouter(); + const searchRouter = createSearchRouter(); + const subscriptionRouter = createSubscriptionRouter(); - return results.map((result) => ({ - id: result.item.id, - name: result.item.name, - relativePath: result.item.relativePath, - path: result.item.path, - isDirectory: false, - score: 1 - (result.score ?? 0), - })); - } catch (error) { - console.error("[filesystem/searchFiles] Failed:", { - rootPath, - query, - error, - }); - return []; - } - }), - - createFile: publicProcedure - .input( - z.object({ - dirPath: z.string(), - fileName: z.string(), - content: z.string().default(""), - }), - ) - .mutation(async ({ input }) => { - const filePath = path.join(input.dirPath, input.fileName); - - try { - await fs.access(filePath); - throw new Error(`File already exists: ${input.fileName}`); - } catch (error) { - if ( - error instanceof Error && - error.message.includes("already exists") - ) { - throw error; - } - } - - await fs.writeFile(filePath, input.content, "utf-8"); - return { path: filePath }; - }), - - createDirectory: publicProcedure - .input( - z.object({ - parentPath: z.string(), - dirName: z.string(), - }), - ) - .mutation(async ({ input }) => { - const dirPath = path.join(input.parentPath, input.dirName); - - try { - await fs.access(dirPath); - throw new Error(`Directory already exists: ${input.dirName}`); - } catch (error) { - if ( - error instanceof Error && - error.message.includes("already exists") - ) { - throw error; - } - } - - await fs.mkdir(dirPath, { recursive: true }); - return { path: dirPath }; - }), - - rename: publicProcedure - .input( - z.object({ - oldPath: z.string(), - newName: z.string(), - }), - ) - .mutation(async ({ input }) => { - const newPath = path.join(path.dirname(input.oldPath), input.newName); - - try { - await fs.access(newPath); - throw new Error(`Target already exists: ${input.newName}`); - } catch (error) { - if ( - error instanceof Error && - error.message.includes("already exists") - ) { - throw error; - } - } - - await fs.rename(input.oldPath, newPath); - return { oldPath: input.oldPath, newPath }; - }), - - delete: publicProcedure - .input( - z.object({ - paths: z.array(z.string()), - permanent: z.boolean().default(false), - }), - ) - .mutation(async ({ input }) => { - const deleted: string[] = []; - const errors: { path: string; error: string }[] = []; - - for (const filePath of input.paths) { - try { - if (input.permanent) { - await fs.rm(filePath, { recursive: true, force: true }); - } else { - await shell.trashItem(filePath); - } - deleted.push(filePath); - } catch (error) { - errors.push({ - path: filePath, - error: error instanceof Error ? error.message : String(error), - }); - } - } - - return { deleted, errors }; - }), - - move: publicProcedure - .input( - z.object({ - sourcePaths: z.array(z.string()), - destinationDir: z.string(), - }), - ) - .mutation(async ({ input }) => { - const moved: { from: string; to: string }[] = []; - const errors: { path: string; error: string }[] = []; - - for (const sourcePath of input.sourcePaths) { - try { - const fileName = path.basename(sourcePath); - const destPath = path.join(input.destinationDir, fileName); - - try { - await fs.access(destPath); - throw new Error(`Target already exists: ${fileName}`); - } catch (accessError) { - if ( - accessError instanceof Error && - accessError.message.includes("already exists") - ) { - throw accessError; - } - } - - await fs.rename(sourcePath, destPath); - moved.push({ from: sourcePath, to: destPath }); - } catch (error) { - errors.push({ - path: sourcePath, - error: error instanceof Error ? error.message : String(error), - }); - } - } - - return { moved, errors }; - }), - - copy: publicProcedure - .input( - z.object({ - sourcePaths: z.array(z.string()), - destinationDir: z.string(), - }), - ) - .mutation(async ({ input }) => { - const copied: { from: string; to: string }[] = []; - const errors: { path: string; error: string }[] = []; - - for (const sourcePath of input.sourcePaths) { - try { - const fileName = path.basename(sourcePath); - let destPath = path.join(input.destinationDir, fileName); - - let counter = 1; - while (true) { - try { - await fs.access(destPath); - const ext = path.extname(fileName); - const base = path.basename(fileName, ext); - destPath = path.join( - input.destinationDir, - `${base} (${counter})${ext}`, - ); - counter++; - } catch { - break; - } - } - - await fs.cp(sourcePath, destPath, { recursive: true }); - copied.push({ from: sourcePath, to: destPath }); - } catch (error) { - errors.push({ - path: sourcePath, - error: error instanceof Error ? error.message : String(error), - }); - } - } - - return { copied, errors }; - }), - - exists: publicProcedure - .input(z.object({ path: z.string() })) - .query(async ({ input }) => { - try { - await fs.access(input.path); - const stats = await fs.stat(input.path); - return { - exists: true, - isDirectory: stats.isDirectory(), - isFile: stats.isFile(), - }; - } catch { - return { exists: false, isDirectory: false, isFile: false }; - } - }), - - stat: publicProcedure - .input(z.object({ path: z.string() })) - .query(async ({ input }) => { - try { - const stats = await fs.stat(input.path); - return { - size: stats.size, - isDirectory: stats.isDirectory(), - isFile: stats.isFile(), - isSymbolicLink: stats.isSymbolicLink(), - createdAt: stats.birthtime.toISOString(), - modifiedAt: stats.mtime.toISOString(), - accessedAt: stats.atime.toISOString(), - }; - } catch (error) { - console.error("[filesystem/stat] Failed:", { - path: input.path, - error, - }); - return null; - } - }), + return router({ + ...operationsRouter._def.procedures, + ...searchRouter._def.procedures, + ...subscriptionRouter._def.procedures, }); }; diff --git a/apps/desktop/src/lib/trpc/routers/filesystem/operations.ts b/apps/desktop/src/lib/trpc/routers/filesystem/operations.ts new file mode 100644 index 00000000000..cf7b095b15b --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/filesystem/operations.ts @@ -0,0 +1,286 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { shell } from "electron"; +import type { DirectoryEntry } from "shared/file-tree-types"; +import { z } from "zod"; +import { publicProcedure, router } from "../.."; + +export const createOperationsRouter = () => { + return router({ + readDirectory: publicProcedure + .input( + z.object({ + dirPath: z.string(), + rootPath: z.string(), + includeHidden: z.boolean().default(false), + }), + ) + .query(async ({ input }): Promise => { + const { dirPath, rootPath, includeHidden } = input; + + try { + const entries = await fs.readdir(dirPath, { withFileTypes: true }); + + return entries + .filter((entry) => includeHidden || !entry.name.startsWith(".")) + .map((entry) => { + const fullPath = path.join(dirPath, entry.name); + const relativePath = path.relative(rootPath, fullPath); + return { + id: relativePath, + name: entry.name, + path: fullPath, + relativePath, + isDirectory: entry.isDirectory(), + }; + }) + .sort((a, b) => { + if (a.isDirectory !== b.isDirectory) { + return a.isDirectory ? -1 : 1; + } + return a.name.localeCompare(b.name); + }); + } catch (error) { + console.error("[filesystem/readDirectory] Failed:", { + dirPath, + error, + }); + return []; + } + }), + + createFile: publicProcedure + .input( + z.object({ + dirPath: z.string(), + fileName: z.string(), + content: z.string().default(""), + }), + ) + .mutation(async ({ input }) => { + const filePath = path.join(input.dirPath, input.fileName); + + try { + await fs.access(filePath); + throw new Error(`File already exists: ${input.fileName}`); + } catch (error) { + if ( + error instanceof Error && + error.message.includes("already exists") + ) { + throw error; + } + } + + await fs.writeFile(filePath, input.content, "utf-8"); + return { path: filePath }; + }), + + createDirectory: publicProcedure + .input( + z.object({ + parentPath: z.string(), + dirName: z.string(), + }), + ) + .mutation(async ({ input }) => { + const dirPath = path.join(input.parentPath, input.dirName); + + try { + await fs.access(dirPath); + throw new Error(`Directory already exists: ${input.dirName}`); + } catch (error) { + if ( + error instanceof Error && + error.message.includes("already exists") + ) { + throw error; + } + } + + await fs.mkdir(dirPath, { recursive: true }); + return { path: dirPath }; + }), + + rename: publicProcedure + .input( + z.object({ + oldPath: z.string(), + newName: z.string(), + }), + ) + .mutation(async ({ input }) => { + const newPath = path.join(path.dirname(input.oldPath), input.newName); + + try { + await fs.access(newPath); + throw new Error(`Target already exists: ${input.newName}`); + } catch (error) { + if ( + error instanceof Error && + error.message.includes("already exists") + ) { + throw error; + } + } + + await fs.rename(input.oldPath, newPath); + return { oldPath: input.oldPath, newPath }; + }), + + delete: publicProcedure + .input( + z.object({ + paths: z.array(z.string()), + permanent: z.boolean().default(false), + }), + ) + .mutation(async ({ input }) => { + const deleted: string[] = []; + const errors: { path: string; error: string }[] = []; + + for (const filePath of input.paths) { + try { + if (input.permanent) { + await fs.rm(filePath, { recursive: true, force: true }); + } else { + await shell.trashItem(filePath); + } + deleted.push(filePath); + } catch (error) { + errors.push({ + path: filePath, + error: error instanceof Error ? error.message : String(error), + }); + } + } + + return { deleted, errors }; + }), + + move: publicProcedure + .input( + z.object({ + sourcePaths: z.array(z.string()), + destinationDir: z.string(), + }), + ) + .mutation(async ({ input }) => { + const moved: { from: string; to: string }[] = []; + const errors: { path: string; error: string }[] = []; + + for (const sourcePath of input.sourcePaths) { + try { + const fileName = path.basename(sourcePath); + const destPath = path.join(input.destinationDir, fileName); + + try { + await fs.access(destPath); + throw new Error(`Target already exists: ${fileName}`); + } catch (accessError) { + if ( + accessError instanceof Error && + accessError.message.includes("already exists") + ) { + throw accessError; + } + } + + await fs.rename(sourcePath, destPath); + moved.push({ from: sourcePath, to: destPath }); + } catch (error) { + errors.push({ + path: sourcePath, + error: error instanceof Error ? error.message : String(error), + }); + } + } + + return { moved, errors }; + }), + + copy: publicProcedure + .input( + z.object({ + sourcePaths: z.array(z.string()), + destinationDir: z.string(), + }), + ) + .mutation(async ({ input }) => { + const copied: { from: string; to: string }[] = []; + const errors: { path: string; error: string }[] = []; + + for (const sourcePath of input.sourcePaths) { + try { + const fileName = path.basename(sourcePath); + let destPath = path.join(input.destinationDir, fileName); + + let counter = 1; + while (true) { + try { + await fs.access(destPath); + const ext = path.extname(fileName); + const base = path.basename(fileName, ext); + destPath = path.join( + input.destinationDir, + `${base} (${counter})${ext}`, + ); + counter++; + } catch { + break; + } + } + + await fs.cp(sourcePath, destPath, { recursive: true }); + copied.push({ from: sourcePath, to: destPath }); + } catch (error) { + errors.push({ + path: sourcePath, + error: error instanceof Error ? error.message : String(error), + }); + } + } + + return { copied, errors }; + }), + + exists: publicProcedure + .input(z.object({ path: z.string() })) + .query(async ({ input }) => { + try { + await fs.access(input.path); + const stats = await fs.stat(input.path); + return { + exists: true, + isDirectory: stats.isDirectory(), + isFile: stats.isFile(), + }; + } catch { + return { exists: false, isDirectory: false, isFile: false }; + } + }), + + stat: publicProcedure + .input(z.object({ path: z.string() })) + .query(async ({ input }) => { + try { + const stats = await fs.stat(input.path); + return { + size: stats.size, + isDirectory: stats.isDirectory(), + isFile: stats.isFile(), + isSymbolicLink: stats.isSymbolicLink(), + createdAt: stats.birthtime.toISOString(), + modifiedAt: stats.mtime.toISOString(), + accessedAt: stats.atime.toISOString(), + }; + } catch (error) { + console.error("[filesystem/stat] Failed:", { + path: input.path, + error, + }); + return null; + } + }), + }); +}; diff --git a/apps/desktop/src/lib/trpc/routers/filesystem/search.ts b/apps/desktop/src/lib/trpc/routers/filesystem/search.ts new file mode 100644 index 00000000000..611cc09299d --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/filesystem/search.ts @@ -0,0 +1,212 @@ +import path from "node:path"; +import fg from "fast-glob"; +import Fuse from "fuse.js"; +import { fsWatcher } from "main/lib/fs-watcher"; +import type { FileSystemBatchEvent } from "shared/file-tree-types"; +import { z } from "zod"; +import { publicProcedure, router } from "../.."; + +const SEARCH_INDEX_TTL_MS = 30_000; +const MAX_SEARCH_RESULTS = 500; +const DEFAULT_IGNORE_PATTERNS = [ + "**/node_modules/**", + "**/.git/**", + "**/dist/**", + "**/build/**", + "**/.next/**", + "**/.turbo/**", + "**/coverage/**", +]; + +interface FileSearchItem { + id: string; + name: string; + relativePath: string; + path: string; + isDirectory: boolean; +} + +interface FileSearchIndex { + items: FileSearchItem[]; + fuse: Fuse; +} + +interface FileSearchCacheEntry { + index: FileSearchIndex; + builtAt: number; +} + +const searchIndexCache = new Map(); +const searchIndexBuilds = new Map>(); + +// Invalidate search index cache when files change +fsWatcher.on("batch", (batch: FileSystemBatchEvent) => { + const rootPath = fsWatcher.getRootPath(batch.workspaceId); + if (rootPath) { + searchIndexCache.delete( + getSearchCacheKey({ rootPath, includeHidden: true }), + ); + searchIndexCache.delete( + getSearchCacheKey({ rootPath, includeHidden: false }), + ); + } +}); + +// Clear all caches when switching workspaces (stale entries from previous workspace are useless) +fsWatcher.on("switched", () => { + searchIndexCache.clear(); + searchIndexBuilds.clear(); +}); + +function getSearchCacheKey({ + rootPath, + includeHidden, +}: { + rootPath: string; + includeHidden: boolean; +}) { + return `${rootPath}::${includeHidden ? "hidden" : "visible"}`; +} + +async function buildSearchIndex({ + rootPath, + includeHidden, +}: { + rootPath: string; + includeHidden: boolean; +}): Promise { + const entries = await fg("**/*", { + cwd: rootPath, + onlyFiles: true, + dot: includeHidden, + followSymbolicLinks: false, + unique: true, + suppressErrors: true, + ignore: DEFAULT_IGNORE_PATTERNS, + }); + + const items = entries.map((relativePath) => ({ + id: relativePath, + name: path.basename(relativePath), + relativePath, + path: path.join(rootPath, relativePath), + isDirectory: false, + })); + + const fuse = new Fuse(items, { + keys: [ + { name: "name", weight: 2 }, + { name: "relativePath", weight: 1 }, + ], + threshold: 0.4, + includeScore: true, + ignoreLocation: true, + }); + + return { items, fuse }; +} + +async function getSearchIndex({ + rootPath, + includeHidden, +}: { + rootPath: string; + includeHidden: boolean; +}): Promise { + const cacheKey = getSearchCacheKey({ rootPath, includeHidden }); + const cached = searchIndexCache.get(cacheKey); + const now = Date.now(); + const inFlight = searchIndexBuilds.get(cacheKey); + + if (cached && now - cached.builtAt < SEARCH_INDEX_TTL_MS) { + return cached.index; + } + + if (cached && !inFlight) { + const staleIndex = cached.index; + const buildPromise = buildSearchIndex({ rootPath, includeHidden }) + .then((index) => { + searchIndexCache.set(cacheKey, { index, builtAt: Date.now() }); + searchIndexBuilds.delete(cacheKey); + return index; + }) + .catch((error) => { + searchIndexBuilds.delete(cacheKey); + console.error( + "[filesystem/search] Background index rebuild failed:", + error, + ); + return staleIndex; + }); + searchIndexBuilds.set(cacheKey, buildPromise); + return staleIndex; + } + + if (cached) { + return cached.index; + } + + if (inFlight) { + return await inFlight; + } + + const buildPromise = buildSearchIndex({ rootPath, includeHidden }) + .then((index) => { + searchIndexCache.set(cacheKey, { index, builtAt: Date.now() }); + searchIndexBuilds.delete(cacheKey); + return index; + }) + .catch((error) => { + searchIndexBuilds.delete(cacheKey); + throw error; + }); + searchIndexBuilds.set(cacheKey, buildPromise); + + return await buildPromise; +} + +export const createSearchRouter = () => { + return router({ + searchFiles: publicProcedure + .input( + z.object({ + rootPath: z.string(), + query: z.string(), + includeHidden: z.boolean().default(false), + limit: z.number().default(200), + }), + ) + .query(async ({ input }) => { + const { rootPath, query, includeHidden, limit } = input; + const trimmedQuery = query.trim(); + + if (!trimmedQuery) { + return []; + } + + try { + const index = await getSearchIndex({ rootPath, includeHidden }); + const safeLimit = Math.max(1, Math.min(limit, MAX_SEARCH_RESULTS)); + const results = index.fuse.search(trimmedQuery, { + limit: safeLimit, + }); + + return results.map((result) => ({ + id: result.item.id, + name: result.item.name, + relativePath: result.item.relativePath, + path: result.item.path, + isDirectory: false, + score: 1 - (result.score ?? 0), + })); + } catch (error) { + console.error("[filesystem/searchFiles] Failed:", { + rootPath, + query, + error, + }); + return []; + } + }), + }); +}; diff --git a/apps/desktop/src/lib/trpc/routers/filesystem/subscription.ts b/apps/desktop/src/lib/trpc/routers/filesystem/subscription.ts new file mode 100644 index 00000000000..74e847f918e --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/filesystem/subscription.ts @@ -0,0 +1,27 @@ +import { observable } from "@trpc/server/observable"; +import { fsWatcher } from "main/lib/fs-watcher"; +import type { FileSystemBatchEvent } from "shared/file-tree-types"; +import { z } from "zod"; +import { publicProcedure, router } from "../.."; + +export const createSubscriptionRouter = () => { + return router({ + subscribe: publicProcedure + .input(z.object({ workspaceId: z.string() })) + .subscription(({ input }) => { + return observable((emit) => { + const onBatch = (batch: FileSystemBatchEvent) => { + if (batch.workspaceId === input.workspaceId) { + emit.next(batch); + } + }; + + fsWatcher.on("batch", onBatch); + + return () => { + fsWatcher.off("batch", onBatch); + }; + }); + }), + }); +}; diff --git a/apps/desktop/src/lib/trpc/routers/ports/ports.ts b/apps/desktop/src/lib/trpc/routers/ports/ports.ts index de7774189d6..17e1c6ddc99 100644 --- a/apps/desktop/src/lib/trpc/routers/ports/ports.ts +++ b/apps/desktop/src/lib/trpc/routers/ports/ports.ts @@ -1,13 +1,13 @@ +import path from "node:path"; import { workspaces } from "@superset/local-db"; import { observable } from "@trpc/server/observable"; import { eq } from "drizzle-orm"; +import { fsWatcher } from "main/lib/fs-watcher"; import { localDb } from "main/lib/local-db"; -import { - hasStaticPortsConfig, - loadStaticPorts, - staticPortsWatcher, -} from "main/lib/static-ports"; +import { hasStaticPortsConfig, loadStaticPorts } from "main/lib/static-ports"; import { portManager } from "main/lib/terminal/port-manager"; +import { PORTS_FILE_NAME, PROJECT_SUPERSET_DIR_NAME } from "shared/constants"; +import type { FileSystemBatchEvent } from "shared/file-tree-types"; import type { DetectedPort, StaticPort } from "shared/types"; import { z } from "zod"; import { publicProcedure, router } from "../.."; @@ -154,35 +154,29 @@ export const createPortsRouter = () => { subscribeStatic: publicProcedure .input(z.object({ workspaceId: z.string() })) .subscription(({ input }) => { - return observable<{ type: "change" }>((emit) => { - const workspace = localDb - .select() - .from(workspaces) - .where(eq(workspaces.id, input.workspaceId)) - .get(); + const portsRelativePath = path.join( + PROJECT_SUPERSET_DIR_NAME, + PORTS_FILE_NAME, + ); - if (!workspace) { - return () => {}; - } - - const workspacePath = getWorkspacePath(workspace); - if (!workspacePath) { - return () => {}; - } - - staticPortsWatcher.watch(input.workspaceId, workspacePath); - - const onChange = (changedWorkspaceId: string) => { - if (changedWorkspaceId === input.workspaceId) { + return observable<{ type: "change" }>((emit) => { + const onBatch = (batch: FileSystemBatchEvent) => { + if (batch.workspaceId !== input.workspaceId) return; + + const hasPortsChange = batch.events.some( + (e) => + e.relativePath === portsRelativePath || + e.relativePath === PROJECT_SUPERSET_DIR_NAME, + ); + if (hasPortsChange) { emit.next({ type: "change" }); } }; - staticPortsWatcher.on("change", onChange); + fsWatcher.on("batch", onBatch); return () => { - staticPortsWatcher.off("change", onChange); - staticPortsWatcher.unwatch(input.workspaceId); + fsWatcher.off("batch", onBatch); }; }); }), diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/delete.ts b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/delete.ts index 998931910b2..6a91d505ee4 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/delete.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/delete.ts @@ -1,6 +1,7 @@ import { existsSync } from "node:fs"; import type { SelectWorktree } from "@superset/local-db"; import { track } from "main/lib/analytics"; +import { fsWatcher } from "main/lib/fs-watcher"; import { workspaceInitManager } from "main/lib/workspace-init-manager"; import { getWorkspaceRuntimeRegistry } from "main/lib/workspace-runtime"; import { z } from "zod"; @@ -23,6 +24,7 @@ import { worktreeExists, } from "../utils/git"; import { removeWorktreeFromDisk, runTeardown } from "../utils/teardown"; +import { getWorkspacePath } from "../utils/worktree"; export const createDeleteProcedures = () => { return router({ @@ -163,6 +165,8 @@ export const createDeleteProcedures = () => { `[workspace/delete] Starting deletion of "${workspace.name}" (${input.id})`, ); + const savedRootPath = getWorkspacePath(workspace); + await fsWatcher.unwatch(input.id); markWorkspaceAsDeleting(input.id); updateActiveWorkspaceIfRemoved(input.id); @@ -173,12 +177,25 @@ export const createDeleteProcedures = () => { workspaceInitManager.cancel(input.id); try { await workspaceInitManager.waitForInit(input.id, 30000); + // Init's fire-and-forget fsWatcher.watch() may have resolved + // after our initial unwatch. Clean up again. + await fsWatcher.unwatch(input.id); } catch (error) { console.error( `[workspace/delete] Failed to wait for init cancellation:`, error, ); clearWorkspaceDeletingStatus(input.id); + if (savedRootPath) { + fsWatcher + .switchTo({ workspaceId: input.id, rootPath: savedRootPath }) + .catch((err) => { + console.error( + "[workspace/delete] Failed to re-attach watcher:", + err, + ); + }); + } return { success: false, error: @@ -230,6 +247,16 @@ export const createDeleteProcedures = () => { teardownResult.error, ); clearWorkspaceDeletingStatus(input.id); + if (savedRootPath) { + fsWatcher + .switchTo({ workspaceId: input.id, rootPath: savedRootPath }) + .catch((err) => { + console.error( + "[workspace/delete] Failed to re-attach watcher:", + err, + ); + }); + } return { success: false, error: `Teardown failed: ${teardownResult.error}`, @@ -247,6 +274,16 @@ export const createDeleteProcedures = () => { }); if (!removeResult.success) { clearWorkspaceDeletingStatus(input.id); + if (savedRootPath) { + fsWatcher + .switchTo({ workspaceId: input.id, rootPath: savedRootPath }) + .catch((err) => { + console.error( + "[workspace/delete] Failed to re-attach watcher:", + err, + ); + }); + } return removeResult; } } finally { @@ -299,6 +336,8 @@ export const createDeleteProcedures = () => { throw new Error("Workspace not found"); } + await fsWatcher.unwatch(input.id); + const terminalResult = await getWorkspaceRuntimeRegistry() .getForWorkspaceId(input.id) .terminal.killByWorkspaceId(input.id); diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/db-helpers.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/db-helpers.ts index 5142e4044bf..50c55a2b497 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/db-helpers.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/db-helpers.ts @@ -8,11 +8,14 @@ import { worktrees, } from "@superset/local-db"; import { and, desc, eq, isNotNull, isNull } from "drizzle-orm"; +import { fsWatcher } from "main/lib/fs-watcher"; import { localDb } from "main/lib/local-db"; +import { getWorkspacePath } from "./worktree"; /** * Set the last active workspace in settings. * Uses upsert to handle both initial and subsequent calls. + * Also switches the filesystem watcher to the new active workspace. */ export function setLastActiveWorkspace(workspaceId: string | null): void { localDb @@ -23,6 +26,32 @@ export function setLastActiveWorkspace(workspaceId: string | null): void { set: { lastActiveWorkspaceId: workspaceId }, }) .run(); + + // Switch filesystem watcher to the new active workspace + if (workspaceId) { + const workspace = localDb + .select() + .from(workspaces) + .where(eq(workspaces.id, workspaceId)) + .get(); + if (workspace) { + const rootPath = getWorkspacePath(workspace); + if (rootPath) { + fsWatcher.switchTo({ workspaceId, rootPath }).catch((err) => { + console.error( + "[db-helpers] Failed to switch fs watcher:", + err, + ); + }); + } + // If rootPath is null, the worktree isn't created yet — + // workspace-init will call switchTo() when it finishes + } + } else { + fsWatcher.stop().catch((err) => { + console.error("[db-helpers] Failed to stop fs watcher:", err); + }); + } } /** diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/workspace-init.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/workspace-init.ts index e819e7844bc..746e93301ed 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/workspace-init.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/workspace-init.ts @@ -1,6 +1,7 @@ -import { projects, worktrees } from "@superset/local-db"; +import { projects, settings, worktrees } from "@superset/local-db"; import { eq } from "drizzle-orm"; import { track } from "main/lib/analytics"; +import { fsWatcher } from "main/lib/fs-watcher"; import { localDb } from "main/lib/local-db"; import { workspaceInitManager } from "main/lib/workspace-init-manager"; import { @@ -128,6 +129,18 @@ export async function initializeWorkspaceWorktree({ manager.updateProgress(workspaceId, "ready", "Ready"); + if (!manager.isCancellationRequested(workspaceId)) { + // Only switch watcher if this workspace is still the active one + const activeId = localDb.select().from(settings).get()?.lastActiveWorkspaceId; + if (activeId === workspaceId) { + fsWatcher + .switchTo({ workspaceId, rootPath: worktreePath }) + .catch((err) => { + console.error("[workspace-init] Failed to start fs watcher:", err); + }); + } + } + track("workspace_initialized", { workspace_id: workspaceId, project_id: projectId, @@ -458,6 +471,16 @@ export async function initializeWorkspaceWorktree({ manager.updateProgress(workspaceId, "ready", "Ready"); + if (!manager.isCancellationRequested(workspaceId)) { + // Only switch watcher if this workspace is still the active one + const activeId = localDb.select().from(settings).get()?.lastActiveWorkspaceId; + if (activeId === workspaceId) { + fsWatcher.switchTo({ workspaceId, rootPath: worktreePath }).catch((err) => { + console.error("[workspace-init] Failed to start fs watcher:", err); + }); + } + } + track("workspace_initialized", { workspace_id: workspaceId, project_id: projectId, diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 09c0d42fc90..0fdd6ba366a 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -7,9 +7,12 @@ import { parseAuthDeepLink, } from "lib/trpc/routers/auth/utils/auth-functions"; import { DEFAULT_CONFIRM_ON_QUIT, PROTOCOL_SCHEME } from "shared/constants"; +import { getWorkspace } from "lib/trpc/routers/workspaces/utils/db-helpers"; +import { getWorkspacePath } from "lib/trpc/routers/workspaces/utils/worktree"; import { setupAgentHooks } from "./lib/agent-setup"; import { initAppState } from "./lib/app-state"; import { setupAutoUpdater } from "./lib/auto-updater"; +import { fsWatcher } from "./lib/fs-watcher"; import { localDb } from "./lib/local-db"; import { initSentry } from "./lib/sentry"; import { reconcileDaemonSessions } from "./lib/terminal"; @@ -210,6 +213,33 @@ if (process.env.NODE_ENV === "development") { parentCheckInterval.unref(); } +function startFileWatcherForActiveWorkspace(): void { + try { + const row = localDb.select().from(settings).get(); + const workspaceId = row?.lastActiveWorkspaceId; + if (!workspaceId) return; + + const workspace = getWorkspace(workspaceId); + if (!workspace) return; + + const rootPath = getWorkspacePath(workspace); + if (!rootPath) return; + + fsWatcher.switchTo({ workspaceId, rootPath }).catch((err) => { + console.error( + `[main] Failed to start fs watcher for active workspace ${workspaceId}:`, + err, + ); + }); + + console.log( + `[main] Started fs watcher for active workspace ${workspaceId}`, + ); + } catch (error) { + console.error("[main] Failed to start fs watcher on boot:", error); + } +} + // Single instance lock - required for second-instance event on Windows/Linux const gotTheLock = app.requestSingleInstanceLock(); @@ -237,6 +267,9 @@ if (!gotTheLock) { // Must happen BEFORE renderer restore runs await reconcileDaemonSessions(); + // Start filesystem watcher for the active workspace + startFileWatcherForActiveWorkspace(); + try { setupAgentHooks(); } catch (error) { diff --git a/apps/desktop/src/main/lib/fs-watcher/fs-watcher.lifecycle.test.ts b/apps/desktop/src/main/lib/fs-watcher/fs-watcher.lifecycle.test.ts new file mode 100644 index 00000000000..8fb8bffb96e --- /dev/null +++ b/apps/desktop/src/main/lib/fs-watcher/fs-watcher.lifecycle.test.ts @@ -0,0 +1,283 @@ +import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"; + +// --- Mock state for @parcel/watcher --- + +interface DeferredSubscription { + resolve: (sub: { unsubscribe: () => Promise }) => void; + rootPath: string; + callback: ( + err: Error | null, + events: Array<{ type: string; path: string }>, + ) => void; +} + +let pendingSubscriptions: DeferredSubscription[] = []; +let unsubscribeCalls: string[] = []; +let subscribeCallCount = 0; + +/** + * Mock @parcel/watcher with deferred subscribe resolution. + * This lets us control when `subscribe` resolves to simulate race conditions. + */ +mock.module("@parcel/watcher", () => ({ + subscribe: ( + rootPath: string, + callback: ( + err: Error | null, + events: Array<{ type: string; path: string }>, + ) => void, + _options: unknown, + ): Promise<{ unsubscribe: () => Promise }> => { + subscribeCallCount++; + return new Promise((resolve) => { + pendingSubscriptions.push({ + resolve: (sub) => resolve(sub), + rootPath, + callback, + }); + }); + }, +})); + +// Dynamic imports AFTER mocks +const fsWatcherMod = await import("./fs-watcher"); +const initManagerMod = await import("main/lib/workspace-init-manager"); + +// Get class constructors from singletons for fresh instances +const FsWatcherClass = Object.getPrototypeOf(fsWatcherMod.fsWatcher) + .constructor as new () => typeof fsWatcherMod.fsWatcher; + +const WorkspaceInitManagerClass = Object.getPrototypeOf( + initManagerMod.workspaceInitManager, +).constructor as new () => typeof initManagerMod.workspaceInitManager; + +let fsWatcher: typeof fsWatcherMod.fsWatcher; +let manager: typeof initManagerMod.workspaceInitManager; + +/** Flush microtask queue so switchTo() progresses past its awaits to subscribe. */ +function flushMicrotasks() { + return new Promise((r) => setTimeout(r, 0)); +} + +/** + * Wait until at least `count` subscriptions are pending (with a short timeout). + * Needed because switchTo() has two awaits before calling subscribe. + */ +async function waitForPendingSubscriptions(count = 1) { + const deadline = Date.now() + 2000; + while (pendingSubscriptions.length < count && Date.now() < deadline) { + await flushMicrotasks(); + } + if (pendingSubscriptions.length < count) { + throw new Error( + `Timed out waiting for ${count} pending subscriptions (have ${pendingSubscriptions.length})`, + ); + } +} + +/** + * Resolve the next pending @parcel/watcher.subscribe call. + * Returns the mock subscription and callback for triggering events. + */ +function resolveNextSubscription() { + const pending = pendingSubscriptions.shift(); + if (!pending) throw new Error("No pending subscription to resolve"); + + const id = `resolved-${pending.rootPath}`; + const sub = { + unsubscribe: async () => { + unsubscribeCalls.push(id); + }, + }; + pending.resolve(sub); + return { sub, callback: pending.callback, rootPath: pending.rootPath }; +} + +beforeEach(() => { + pendingSubscriptions = []; + unsubscribeCalls = []; + subscribeCallCount = 0; + fsWatcher = new FsWatcherClass(); + manager = new WorkspaceInitManagerClass(); +}); + +afterEach(async () => { + // Resolve any dangling subscriptions to avoid hanging promises + while (pendingSubscriptions.length > 0) { + resolveNextSubscription(); + } + await fsWatcher.stop(); +}); + +describe("FsWatcher lifecycle integration", () => { + describe("happy path: switchTo watches, unwatch stops", () => { + it("after unwatch, no active watcher remains", async () => { + // Simulate init: switch to the workspace + const switchPromise = fsWatcher.switchTo({ + workspaceId: "ws-1", + rootPath: "/tmp/project", + }); + + // Wait for subscribe to be called, then resolve it + await waitForPendingSubscriptions(1); + resolveNextSubscription(); + await switchPromise; + + expect(fsWatcher.getRootPath("ws-1")).toBe("/tmp/project"); + expect(fsWatcher.getActiveWorkspaceId()).toBe("ws-1"); + + // Simulate delete: unwatch + await fsWatcher.unwatch("ws-1"); + + expect(fsWatcher.getRootPath("ws-1")).toBeUndefined(); + expect(fsWatcher.getActiveWorkspaceId()).toBeUndefined(); + expect(unsubscribeCalls).toHaveLength(1); + }); + }); + + describe("switchTo properly cleans up old watcher", () => { + it("stops old watcher before starting new one", async () => { + // Start watching ws-1 + const switch1 = fsWatcher.switchTo({ + workspaceId: "ws-1", + rootPath: "/tmp/a", + }); + await waitForPendingSubscriptions(1); + resolveNextSubscription(); + await switch1; + + expect(fsWatcher.getActiveWorkspaceId()).toBe("ws-1"); + + // Switch to ws-2 — should stop ws-1 first + const switch2 = fsWatcher.switchTo({ + workspaceId: "ws-2", + rootPath: "/tmp/b", + }); + await waitForPendingSubscriptions(1); + resolveNextSubscription(); + await switch2; + + expect(fsWatcher.getActiveWorkspaceId()).toBe("ws-2"); + expect(fsWatcher.getRootPath("ws-1")).toBeUndefined(); + expect(fsWatcher.getRootPath("ws-2")).toBe("/tmp/b"); + // Old subscription was unsubscribed + expect(unsubscribeCalls).toHaveLength(1); + }); + }); + + describe("race: init switchTo resolves after delete unwatch", () => { + it("second unwatch after waitForInit cleans up the late watcher", async () => { + // 1. Start an init job + manager.startJob("ws-1", "proj-1"); + + // 2. Start switching (subscribe is deferred — not yet resolved) + const switchPromise = fsWatcher.switchTo({ + workspaceId: "ws-1", + rootPath: "/tmp/project", + }); + + // Wait for subscribe to be called (but don't resolve yet) + await waitForPendingSubscriptions(1); + + // 3. Meanwhile, the delete flow runs: + // a) First unwatch — but switchTo hasn't set state yet (subscribe pending) + await fsWatcher.unwatch("ws-1"); + expect(fsWatcher.getRootPath("ws-1")).toBeUndefined(); + + // b) Cancel the init job + manager.cancel("ws-1"); + expect(manager.isCancellationRequested("ws-1")).toBe(true); + + // 4. Now the deferred subscribe resolves (init's switchTo completes late) + resolveNextSubscription(); + await switchPromise; + + // The watcher is now active (the late switchTo completed) + expect(fsWatcher.getRootPath("ws-1")).toBe("/tmp/project"); + + // 5. Finalize the init job (allows waitForInit to unblock) + manager.finalizeJob("ws-1"); + await manager.waitForInit("ws-1"); + + // 6. The delete flow does a second unwatch to clean up the late watcher + await fsWatcher.unwatch("ws-1"); + + // 7. Assert: no active watcher + expect(fsWatcher.getRootPath("ws-1")).toBeUndefined(); + expect(fsWatcher.getActiveWorkspaceId()).toBeUndefined(); + expect(unsubscribeCalls).toHaveLength(1); // Only one actual unsubscribe (the late one) + }); + }); + + describe("cancellation guard: switchTo skipped when cancelled", () => { + it("does not switch when cancellation was requested before switchTo", async () => { + manager.startJob("ws-1", "proj-1"); + manager.cancel("ws-1"); + + // The init flow should check isCancellationRequested before calling switchTo. + // Simulate what a well-behaved init flow does: + if (manager.isCancellationRequested("ws-1")) { + // Skip the switchTo — this is the guard + manager.finalizeJob("ws-1"); + } + + // No subscribe call should have been made + expect(pendingSubscriptions).toHaveLength(0); + expect(subscribeCallCount).toBe(0); + expect(fsWatcher.getActiveWorkspaceId()).toBeUndefined(); + }); + + it("isCancellationRequested remains true through the full delete flow", async () => { + manager.startJob("ws-1", "proj-1"); + manager.cancel("ws-1"); + manager.finalizeJob("ws-1"); + + // Even after finalize, the cancellation flag persists + expect(manager.isCancellationRequested("ws-1")).toBe(true); + + // Only clearJob removes it + manager.clearJob("ws-1"); + expect(manager.isCancellationRequested("ws-1")).toBe(false); + }); + }); + + describe("re-attach on failed deletion", () => { + it("re-attaches watcher using saved rootPath when teardown fails", async () => { + // 1. Init: switchTo succeeds + const switchPromise = fsWatcher.switchTo({ + workspaceId: "ws-1", + rootPath: "/tmp/project", + }); + await waitForPendingSubscriptions(1); + resolveNextSubscription(); + await switchPromise; + + const savedRootPath = fsWatcher.getRootPath("ws-1"); + expect(savedRootPath).toBe("/tmp/project"); + + // 2. Delete begins: unwatch + await fsWatcher.unwatch("ws-1"); + expect(fsWatcher.getRootPath("ws-1")).toBeUndefined(); + + // 3. Simulate deletion failure (e.g., DB or filesystem error) + const deletionFailed = true; + + // 4. Re-attach watcher using the saved rootPath + if (deletionFailed && savedRootPath) { + const reattachPromise = fsWatcher.switchTo({ + workspaceId: "ws-1", + rootPath: savedRootPath, + }); + await waitForPendingSubscriptions(1); + resolveNextSubscription(); + await reattachPromise; + } + + // 5. Watcher is back + expect(fsWatcher.getRootPath("ws-1")).toBe("/tmp/project"); + expect(fsWatcher.getActiveWorkspaceId()).toBe("ws-1"); + // Total subscribes: 2 (original + re-attach) + expect(subscribeCallCount).toBe(2); + }); + }); +}); diff --git a/apps/desktop/src/main/lib/fs-watcher/fs-watcher.test.ts b/apps/desktop/src/main/lib/fs-watcher/fs-watcher.test.ts new file mode 100644 index 00000000000..4a108763787 --- /dev/null +++ b/apps/desktop/src/main/lib/fs-watcher/fs-watcher.test.ts @@ -0,0 +1,379 @@ +import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"; +import type { FileSystemBatchEvent } from "shared/file-tree-types"; + +// --- Mock state --- + +let subscribeCalls: Array<{ + rootPath: string; + options: unknown; + callback: ( + err: Error | null, + events: Array<{ type: string; path: string }>, + ) => void; +}> = []; +let unsubscribeCalls: string[] = []; + +function createMockSubscription(id: string) { + return { + unsubscribe: async () => { + unsubscribeCalls.push(id); + }, + }; +} + +let subscribeCallCount = 0; + +mock.module("@parcel/watcher", () => ({ + subscribe: async ( + rootPath: string, + callback: ( + err: Error | null, + events: Array<{ type: string; path: string }>, + ) => void, + options: unknown, + ) => { + const id = `sub-${subscribeCallCount++}`; + subscribeCalls.push({ rootPath, options, callback }); + return createMockSubscription(id); + }, +})); + +// Dynamic import AFTER mocks are installed +const fsWatcherMod = await import("./fs-watcher"); + +// Get the class constructor from the singleton for fresh instances per test +const FsWatcherClass = Object.getPrototypeOf(fsWatcherMod.fsWatcher) + .constructor as new () => typeof fsWatcherMod.fsWatcher; + +let fsWatcher: typeof fsWatcherMod.fsWatcher; + +beforeEach(() => { + subscribeCalls = []; + unsubscribeCalls = []; + subscribeCallCount = 0; + fsWatcher = new FsWatcherClass(); +}); + +afterEach(async () => { + await fsWatcher.stop(); +}); + +describe("FsWatcher", () => { + describe("switchTo()", () => { + it("creates a subscription with rootPath and ignore dirs", async () => { + await fsWatcher.switchTo({ + workspaceId: "ws-1", + rootPath: "/tmp/project", + }); + + expect(subscribeCalls).toHaveLength(1); + expect(subscribeCalls[0].rootPath).toBe("/tmp/project"); + expect(subscribeCalls[0].options).toEqual({ + ignore: [ + "node_modules", + ".git", + "dist", + "build", + ".next", + ".turbo", + "coverage", + ], + }); + }); + + it("no-ops when switching to the same workspace", async () => { + await fsWatcher.switchTo({ + workspaceId: "ws-1", + rootPath: "/tmp/project", + }); + expect(subscribeCalls).toHaveLength(1); + + await fsWatcher.switchTo({ + workspaceId: "ws-1", + rootPath: "/tmp/project", + }); + // Should NOT create a second subscription + expect(subscribeCalls).toHaveLength(1); + expect(unsubscribeCalls).toHaveLength(0); + }); + + it("stops old watcher when switching to a different workspace", async () => { + await fsWatcher.switchTo({ + workspaceId: "ws-1", + rootPath: "/tmp/old", + }); + expect(subscribeCalls).toHaveLength(1); + expect(unsubscribeCalls).toHaveLength(0); + + await fsWatcher.switchTo({ + workspaceId: "ws-2", + rootPath: "/tmp/new", + }); + expect(subscribeCalls).toHaveLength(2); + // Old subscription should have been unsubscribed + expect(unsubscribeCalls).toHaveLength(1); + expect(subscribeCalls[1].rootPath).toBe("/tmp/new"); + }); + + it("emits 'switched' event with new workspace ID", async () => { + const switchedIds: string[] = []; + fsWatcher.on("switched", (id: string) => { + switchedIds.push(id); + }); + + await fsWatcher.switchTo({ + workspaceId: "ws-1", + rootPath: "/tmp/project", + }); + + expect(switchedIds).toEqual(["ws-1"]); + }); + }); + + describe("unwatch()", () => { + it("stops the active watcher if ID matches", async () => { + await fsWatcher.switchTo({ + workspaceId: "ws-1", + rootPath: "/tmp/project", + }); + expect(fsWatcher.getRootPath("ws-1")).toBe("/tmp/project"); + + await fsWatcher.unwatch("ws-1"); + expect(unsubscribeCalls).toHaveLength(1); + expect(fsWatcher.getRootPath("ws-1")).toBeUndefined(); + }); + + it("no-ops for non-active workspace id", async () => { + await fsWatcher.switchTo({ + workspaceId: "ws-1", + rootPath: "/tmp/project", + }); + + await fsWatcher.unwatch("ws-other"); + expect(unsubscribeCalls).toHaveLength(0); + // ws-1 is still active + expect(fsWatcher.getRootPath("ws-1")).toBe("/tmp/project"); + }); + + it("no-ops when no watcher is active", async () => { + await fsWatcher.unwatch("nonexistent"); + expect(unsubscribeCalls).toHaveLength(0); + }); + }); + + describe("stop()", () => { + it("stops the active watcher", async () => { + await fsWatcher.switchTo({ + workspaceId: "ws-1", + rootPath: "/tmp/a", + }); + + await fsWatcher.stop(); + + expect(unsubscribeCalls).toHaveLength(1); + expect(fsWatcher.getRootPath("ws-1")).toBeUndefined(); + expect(fsWatcher.getActiveWorkspaceId()).toBeUndefined(); + }); + + it("no-ops when no watcher is active", async () => { + await fsWatcher.stop(); + expect(unsubscribeCalls).toHaveLength(0); + }); + }); + + describe("getRootPath()", () => { + it("returns stored path for active workspace", async () => { + await fsWatcher.switchTo({ + workspaceId: "ws-1", + rootPath: "/tmp/project", + }); + expect(fsWatcher.getRootPath("ws-1")).toBe("/tmp/project"); + }); + + it("returns undefined for non-active workspace", async () => { + await fsWatcher.switchTo({ + workspaceId: "ws-1", + rootPath: "/tmp/project", + }); + expect(fsWatcher.getRootPath("ws-other")).toBeUndefined(); + }); + + it("returns undefined when no watcher is active", () => { + expect(fsWatcher.getRootPath("ws-unknown")).toBeUndefined(); + }); + }); + + describe("getActiveWorkspaceId()", () => { + it("returns the active workspace ID", async () => { + await fsWatcher.switchTo({ + workspaceId: "ws-1", + rootPath: "/tmp/project", + }); + expect(fsWatcher.getActiveWorkspaceId()).toBe("ws-1"); + }); + + it("returns undefined when no watcher is active", () => { + expect(fsWatcher.getActiveWorkspaceId()).toBeUndefined(); + }); + }); + + describe("event batching", () => { + function triggerEvents(events: Array<{ type: string; path: string }>) { + // Get the most recent subscribe callback + const lastCall = subscribeCalls[subscribeCalls.length - 1]; + lastCall.callback(null, events); + } + + it("batches events within debounce window into a single batch", async () => { + await fsWatcher.switchTo({ + workspaceId: "ws-1", + rootPath: "/tmp/project", + }); + + const batches: FileSystemBatchEvent[] = []; + fsWatcher.on("batch", (batch: FileSystemBatchEvent) => { + batches.push(batch); + }); + + // Fire 3 events rapidly (well within 100ms debounce) + triggerEvents([{ type: "create", path: "/tmp/project/a.ts" }]); + triggerEvents([{ type: "update", path: "/tmp/project/b.ts" }]); + triggerEvents([{ type: "delete", path: "/tmp/project/c.ts" }]); + + // No batch yet (still within debounce window) + expect(batches).toHaveLength(0); + + // Wait for debounce to fire (100ms + buffer) + await new Promise((r) => setTimeout(r, 150)); + + expect(batches).toHaveLength(1); + expect(batches[0].events).toHaveLength(3); + expect(batches[0].workspaceId).toBe("ws-1"); + }); + + it("resets debounce timer on new events", async () => { + await fsWatcher.switchTo({ + workspaceId: "ws-1", + rootPath: "/tmp/project", + }); + + const batches: FileSystemBatchEvent[] = []; + fsWatcher.on("batch", (batch: FileSystemBatchEvent) => { + batches.push(batch); + }); + + triggerEvents([{ type: "create", path: "/tmp/project/a.ts" }]); + + // Wait 50ms (less than 100ms debounce), then fire another + await new Promise((r) => setTimeout(r, 50)); + triggerEvents([{ type: "update", path: "/tmp/project/b.ts" }]); + + // Wait another 50ms — still haven't hit 100ms since last event + await new Promise((r) => setTimeout(r, 50)); + expect(batches).toHaveLength(0); + + // Wait for debounce to complete (100ms since last event) + await new Promise((r) => setTimeout(r, 80)); + expect(batches).toHaveLength(1); + expect(batches[0].events).toHaveLength(2); + }); + + it("forces flush at max batch window regardless of new events", async () => { + await fsWatcher.switchTo({ + workspaceId: "ws-1", + rootPath: "/tmp/project", + }); + + const batches: FileSystemBatchEvent[] = []; + fsWatcher.on("batch", (batch: FileSystemBatchEvent) => { + batches.push(batch); + }); + + // Continuously trigger events every 80ms to keep resetting debounce + // The max window (2s) should force a flush + const interval = setInterval(() => { + triggerEvents([ + { type: "update", path: `/tmp/project/file-${Date.now()}.ts` }, + ]); + }, 80); + + // Wait for max window to fire (~2s + buffer) + await new Promise((r) => setTimeout(r, 2200)); + clearInterval(interval); + + // At least one batch should have been emitted by the max window timer + expect(batches.length).toBeGreaterThanOrEqual(1); + }); + + it("deduplicates by path with last write wins", async () => { + await fsWatcher.switchTo({ + workspaceId: "ws-1", + rootPath: "/tmp/project", + }); + + const batches: FileSystemBatchEvent[] = []; + fsWatcher.on("batch", (batch: FileSystemBatchEvent) => { + batches.push(batch); + }); + + // Same file: first create, then update + triggerEvents([{ type: "create", path: "/tmp/project/file.ts" }]); + triggerEvents([{ type: "update", path: "/tmp/project/file.ts" }]); + + await new Promise((r) => setTimeout(r, 150)); + + expect(batches).toHaveLength(1); + // Only 1 event for that path (last write wins) + expect(batches[0].events).toHaveLength(1); + expect(batches[0].events[0].type).toBe("change"); // "update" maps to "change" + }); + + it("emits correct batch event shape", async () => { + await fsWatcher.switchTo({ + workspaceId: "ws-1", + rootPath: "/tmp/project", + }); + + const batches: FileSystemBatchEvent[] = []; + fsWatcher.on("batch", (batch: FileSystemBatchEvent) => { + batches.push(batch); + }); + + triggerEvents([{ type: "create", path: "/tmp/project/src/file.ts" }]); + + await new Promise((r) => setTimeout(r, 150)); + + expect(batches).toHaveLength(1); + const batch = batches[0]; + expect(batch.workspaceId).toBe("ws-1"); + expect(typeof batch.timestamp).toBe("number"); + expect(batch.events[0]).toEqual({ + type: "add", // "create" maps to "add" + path: "/tmp/project/src/file.ts", + relativePath: "src/file.ts", + }); + }); + + it("clears pending events after flush", async () => { + await fsWatcher.switchTo({ + workspaceId: "ws-1", + rootPath: "/tmp/project", + }); + + const batches: FileSystemBatchEvent[] = []; + fsWatcher.on("batch", (batch: FileSystemBatchEvent) => { + batches.push(batch); + }); + + triggerEvents([{ type: "create", path: "/tmp/project/a.ts" }]); + + // Wait for flush + await new Promise((r) => setTimeout(r, 150)); + expect(batches).toHaveLength(1); + + // Wait more — no additional batches should appear (events cleared) + await new Promise((r) => setTimeout(r, 200)); + expect(batches).toHaveLength(1); + }); + }); +}); diff --git a/apps/desktop/src/main/lib/fs-watcher/fs-watcher.ts b/apps/desktop/src/main/lib/fs-watcher/fs-watcher.ts new file mode 100644 index 00000000000..4569efc5df8 --- /dev/null +++ b/apps/desktop/src/main/lib/fs-watcher/fs-watcher.ts @@ -0,0 +1,225 @@ +import { EventEmitter } from "node:events"; +import path from "node:path"; +import type { AsyncSubscription, Event } from "@parcel/watcher"; +import type { + FileSystemBatchEvent, + FileSystemChangeEvent, +} from "shared/file-tree-types"; + +const DEBOUNCE_MS = 100; +const MAX_BATCH_WINDOW_MS = 2000; + +const IGNORE_DIRS = [ + "node_modules", + ".git", + "dist", + "build", + ".next", + ".turbo", + "coverage", +]; + +interface WatcherState { + workspaceId: string; + subscription: AsyncSubscription; + rootPath: string; + pendingEvents: Map; + debounceTimer: ReturnType | null; + maxWindowTimer: ReturnType | null; +} + +function mapEventType(type: Event["type"]): FileSystemChangeEvent["type"] { + switch (type) { + case "create": + return "add"; + case "update": + return "change"; + case "delete": + return "unlink"; + default: + return "change"; + } +} + +class FsWatcher extends EventEmitter { + private active: WatcherState | null = null; + + /** + * Switch to watching a different workspace directory. + * If already watching the same workspace, this is a no-op. + * Flushes + stops the old watcher before starting the new one. + * + * Called from: + * - setLastActiveWorkspace() in db-helpers.ts (on workspace switch) + * - workspace-init.ts (on workspace ready, if still active) + * - main/index.ts (on app boot for the active workspace) + */ + async switchTo({ + workspaceId, + rootPath, + }: { + workspaceId: string; + rootPath: string; + }): Promise { + // No-op if already watching this workspace + if (this.active?.workspaceId === workspaceId) { + return; + } + + // Stop old watcher (flush pending events first) + await this.stop(); + + // Dynamic import to avoid issues with native module bundling + const watcher = await import("@parcel/watcher"); + + const subscription = await watcher.subscribe( + rootPath, + (err, events) => { + if (err) { + console.error( + `[fs-watcher] Error for workspace ${workspaceId}:`, + err, + ); + return; + } + + this.handleEvents(workspaceId, rootPath, events); + }, + { + ignore: IGNORE_DIRS, + }, + ); + + this.active = { + workspaceId, + subscription, + rootPath, + pendingEvents: new Map(), + debounceTimer: null, + maxWindowTimer: null, + }; + + this.emit("switched", workspaceId); + + console.log( + `[fs-watcher] Watching workspace ${workspaceId} at ${rootPath}`, + ); + } + + /** + * Stop watching the active workspace if it matches the given ID. + * No-op if the given workspace is not the active one. + */ + async unwatch(workspaceId: string): Promise { + if (!this.active || this.active.workspaceId !== workspaceId) return; + await this.stopInternal(); + } + + /** + * Stop the active watcher (flush pending events, unsubscribe, clear state). + */ + async stop(): Promise { + await this.stopInternal(); + } + + /** + * Get the root path for a workspace, only if it's the active one. + */ + getRootPath(workspaceId: string): string | undefined { + if (this.active?.workspaceId === workspaceId) { + return this.active.rootPath; + } + return undefined; + } + + /** + * Get the currently active workspace ID. + */ + getActiveWorkspaceId(): string | undefined { + return this.active?.workspaceId; + } + + private async stopInternal(): Promise { + const state = this.active; + if (!state) return; + + // Flush any pending events before stopping + this.flush(); + + if (state.debounceTimer) { + clearTimeout(state.debounceTimer); + } + if (state.maxWindowTimer) { + clearTimeout(state.maxWindowTimer); + } + + await state.subscription.unsubscribe(); + this.active = null; + + console.log( + `[fs-watcher] Stopped watching workspace ${state.workspaceId}`, + ); + } + + private handleEvents( + workspaceId: string, + rootPath: string, + events: Event[], + ): void { + if (!this.active || this.active.workspaceId !== workspaceId) return; + + const state = this.active; + + for (const event of events) { + const relativePath = path.relative(rootPath, event.path); + const changeEvent: FileSystemChangeEvent = { + type: mapEventType(event.type), + path: event.path, + relativePath, + }; + // Last write wins for dedup (keyed by path) + state.pendingEvents.set(event.path, changeEvent); + } + + // Reset debounce timer + if (state.debounceTimer) { + clearTimeout(state.debounceTimer); + } + + state.debounceTimer = setTimeout(() => { + this.flush(); + }, DEBOUNCE_MS); + + // Start max-window timer on first event in a batch + if (!state.maxWindowTimer) { + state.maxWindowTimer = setTimeout(() => { + this.flush(); + }, MAX_BATCH_WINDOW_MS); + } + } + + private flush(): void { + const state = this.active; + if (!state || state.pendingEvents.size === 0) return; + + if (state.debounceTimer) { + clearTimeout(state.debounceTimer); + state.debounceTimer = null; + } + if (state.maxWindowTimer) { + clearTimeout(state.maxWindowTimer); + state.maxWindowTimer = null; + } + + const batch: FileSystemBatchEvent = { + workspaceId: state.workspaceId, + events: [...state.pendingEvents.values()], + timestamp: Date.now(), + }; + + state.pendingEvents.clear(); + this.emit("batch", batch); + } +} + +export const fsWatcher = new FsWatcher(); diff --git a/apps/desktop/src/main/lib/fs-watcher/index.ts b/apps/desktop/src/main/lib/fs-watcher/index.ts new file mode 100644 index 00000000000..7392a5ddf41 --- /dev/null +++ b/apps/desktop/src/main/lib/fs-watcher/index.ts @@ -0,0 +1 @@ +export { fsWatcher } from "./fs-watcher"; diff --git a/apps/desktop/src/main/lib/static-ports/index.ts b/apps/desktop/src/main/lib/static-ports/index.ts index d437a2c59d7..f7a8c0003e0 100644 --- a/apps/desktop/src/main/lib/static-ports/index.ts +++ b/apps/desktop/src/main/lib/static-ports/index.ts @@ -1,2 +1 @@ export { hasStaticPortsConfig, loadStaticPorts } from "./loader"; -export { staticPortsWatcher } from "./watcher"; diff --git a/apps/desktop/src/main/lib/static-ports/watcher.ts b/apps/desktop/src/main/lib/static-ports/watcher.ts deleted file mode 100644 index 5843a52a428..00000000000 --- a/apps/desktop/src/main/lib/static-ports/watcher.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { EventEmitter } from "node:events"; -import { existsSync, type FSWatcher, statSync, watch } from "node:fs"; -import { join } from "node:path"; -import { PORTS_FILE_NAME, PROJECT_SUPERSET_DIR_NAME } from "shared/constants"; - -/** - * Watches for changes to ports.json files across workspaces. - * Emits 'change' event with workspaceId when a watched file changes. - */ -class StaticPortsWatcher extends EventEmitter { - private watchers = new Map(); - private debounceTimers = new Map>(); - private lastMtimes = new Map(); - - /** - * Start watching ports.json for a workspace. - * If the file doesn't exist, we'll still set up the watch on the directory - * to detect when it's created. - */ - watch(workspaceId: string, worktreePath: string): void { - // Clean up existing watcher for this workspace - this.unwatch(workspaceId); - - const portsPath = join( - worktreePath, - PROJECT_SUPERSET_DIR_NAME, - PORTS_FILE_NAME, - ); - const supersetDir = join(worktreePath, PROJECT_SUPERSET_DIR_NAME); - - // Determine what to watch: - // 1. If ports.json exists, watch it directly - // 2. If .superset dir exists, watch it for ports.json creation - // 3. If neither exists, watch the worktree root for .superset creation - let watchPath: string; - let watchingFor: "file" | "dir" | "root"; - - if (existsSync(portsPath)) { - watchPath = portsPath; - watchingFor = "file"; - // Store initial mtime to detect actual changes - try { - const stat = statSync(portsPath); - this.lastMtimes.set(workspaceId, stat.mtimeMs); - } catch { - // File may have been deleted between check and stat - } - } else if (existsSync(supersetDir)) { - watchPath = supersetDir; - watchingFor = "dir"; - } else if (existsSync(worktreePath)) { - watchPath = worktreePath; - watchingFor = "root"; - } else { - return; - } - - try { - const watcher = watch(watchPath, (_eventType, filename) => { - // Filter events based on what we're watching for - if (watchingFor === "dir") { - // Watching .superset dir - only care about ports.json - if (filename && filename !== PORTS_FILE_NAME) { - return; - } - } else if (watchingFor === "root") { - // Watching worktree root - only care about .superset dir creation - if (filename && filename !== PROJECT_SUPERSET_DIR_NAME) { - return; - } - // .superset was created, switch to watching it - if (existsSync(supersetDir)) { - this.watch(workspaceId, worktreePath); - return; - } - } else if (watchingFor === "file") { - // Check if file actually changed by comparing mtime - // This prevents spurious events from atime updates when reading the file - try { - if (!existsSync(portsPath)) { - // File was deleted - clear mtime and emit change - this.lastMtimes.delete(workspaceId); - } else { - const stat = statSync(portsPath); - const lastMtime = this.lastMtimes.get(workspaceId); - if (lastMtime !== undefined && stat.mtimeMs === lastMtime) { - // mtime unchanged - this is a spurious event (e.g., atime update) - return; - } - this.lastMtimes.set(workspaceId, stat.mtimeMs); - } - } catch { - // Error getting stat - file may have been deleted, continue with emit - } - } - - // Debounce to avoid multiple rapid events - const existingTimer = this.debounceTimers.get(workspaceId); - if (existingTimer) { - clearTimeout(existingTimer); - } - - const timer = setTimeout(() => { - this.debounceTimers.delete(workspaceId); - this.emit("change", workspaceId); - - // If we were watching the directory and the file now exists, - // switch to watching the file directly for more precise events - if (watchingFor === "dir" && existsSync(portsPath)) { - this.watch(workspaceId, worktreePath); - } - }, 100); - timer.unref(); - - this.debounceTimers.set(workspaceId, timer); - }); - - // Don't keep Electron alive just for file watching - watcher.unref(); - this.watchers.set(workspaceId, watcher); - } catch (error) { - console.error( - `[StaticPortsWatcher] Failed to watch ${watchPath}:`, - error, - ); - } - } - - /** - * Stop watching ports.json for a workspace. - */ - unwatch(workspaceId: string): void { - const watcher = this.watchers.get(workspaceId); - if (watcher) { - watcher.close(); - this.watchers.delete(workspaceId); - } - - const timer = this.debounceTimers.get(workspaceId); - if (timer) { - clearTimeout(timer); - this.debounceTimers.delete(workspaceId); - } - - this.lastMtimes.delete(workspaceId); - } - - /** - * Stop all watchers. - */ - unwatchAll(): void { - for (const workspaceId of this.watchers.keys()) { - this.unwatch(workspaceId); - } - } -} - -export const staticPortsWatcher = new StaticPortsWatcher(); diff --git a/apps/desktop/src/main/lib/workspace-init-manager.test.ts b/apps/desktop/src/main/lib/workspace-init-manager.test.ts new file mode 100644 index 00000000000..329381e5e69 --- /dev/null +++ b/apps/desktop/src/main/lib/workspace-init-manager.test.ts @@ -0,0 +1,260 @@ +import { beforeEach, describe, expect, it } from "bun:test"; + +// WorkspaceInitManager is a pure in-memory class — no mocks needed. +// We get the class constructor from the exported singleton to create fresh instances. +const mod = await import("./workspace-init-manager"); +const WorkspaceInitManagerClass = Object.getPrototypeOf( + mod.workspaceInitManager, +).constructor as new () => typeof mod.workspaceInitManager; + +let manager: typeof mod.workspaceInitManager; + +beforeEach(() => { + manager = new WorkspaceInitManagerClass(); +}); + +describe("WorkspaceInitManager", () => { + describe("startJob + isInitializing", () => { + it("returns true during init (pending step)", () => { + manager.startJob("ws-1", "proj-1"); + expect(manager.isInitializing("ws-1")).toBe(true); + }); + + it("returns true during intermediate steps", () => { + manager.startJob("ws-1", "proj-1"); + manager.updateProgress("ws-1", "creating_worktree", "Creating..."); + expect(manager.isInitializing("ws-1")).toBe(true); + }); + + it("returns false for unknown workspace", () => { + expect(manager.isInitializing("ws-unknown")).toBe(false); + }); + }); + + describe("isInitializing false for ready/failed", () => { + it("returns false after reaching ready step", () => { + manager.startJob("ws-1", "proj-1"); + manager.updateProgress("ws-1", "ready", "Ready"); + expect(manager.isInitializing("ws-1")).toBe(false); + }); + + it("returns false after reaching failed step", () => { + manager.startJob("ws-1", "proj-1"); + manager.updateProgress("ws-1", "failed", "Error", "some error"); + expect(manager.isInitializing("ws-1")).toBe(false); + }); + }); + + describe("hasFailed", () => { + it("returns true when step is failed", () => { + manager.startJob("ws-1", "proj-1"); + manager.updateProgress("ws-1", "failed", "Error"); + expect(manager.hasFailed("ws-1")).toBe(true); + }); + + it("returns false when step is not failed", () => { + manager.startJob("ws-1", "proj-1"); + expect(manager.hasFailed("ws-1")).toBe(false); + }); + }); + + describe("cancel + isCancellationRequested", () => { + it("sets durable cancellation flag", () => { + manager.startJob("ws-1", "proj-1"); + expect(manager.isCancellationRequested("ws-1")).toBe(false); + + manager.cancel("ws-1"); + expect(manager.isCancellationRequested("ws-1")).toBe(true); + }); + + it("isCancellationRequested survives finalizeJob", () => { + manager.startJob("ws-1", "proj-1"); + manager.cancel("ws-1"); + manager.finalizeJob("ws-1"); + + // The cancellation flag persists even after finalize + expect(manager.isCancellationRequested("ws-1")).toBe(true); + }); + + it("cancel works even without an active job", () => { + // cancel adds to the durable Set even without a job + manager.cancel("ws-1"); + expect(manager.isCancellationRequested("ws-1")).toBe(true); + }); + }); + + describe("clearJob", () => { + it("clears cancellation flag", () => { + manager.startJob("ws-1", "proj-1"); + manager.cancel("ws-1"); + expect(manager.isCancellationRequested("ws-1")).toBe(true); + + manager.clearJob("ws-1"); + expect(manager.isCancellationRequested("ws-1")).toBe(false); + }); + + it("clears job progress", () => { + manager.startJob("ws-1", "proj-1"); + expect(manager.getProgress("ws-1")).toBeDefined(); + + manager.clearJob("ws-1"); + expect(manager.getProgress("ws-1")).toBeUndefined(); + }); + }); + + describe("waitForInit", () => { + it("blocks until finalizeJob is called", async () => { + manager.startJob("ws-1", "proj-1"); + + let resolved = false; + const waitPromise = manager.waitForInit("ws-1").then(() => { + resolved = true; + }); + + // Give a tick — should still be blocked + await new Promise((r) => setTimeout(r, 10)); + expect(resolved).toBe(false); + + // Finalize the job + manager.finalizeJob("ws-1"); + + await waitPromise; + expect(resolved).toBe(true); + }); + + it("returns immediately when no job is in progress", async () => { + // No startJob called — should return immediately + const start = Date.now(); + await manager.waitForInit("ws-1"); + const elapsed = Date.now() - start; + + // Should be nearly instant (well under 100ms) + expect(elapsed).toBeLessThan(100); + }); + + it("times out when finalizeJob is never called", async () => { + manager.startJob("ws-1", "proj-1"); + + const start = Date.now(); + await manager.waitForInit("ws-1", 200); // 200ms timeout + const elapsed = Date.now() - start; + + // Should have waited ~200ms for the timeout + expect(elapsed).toBeGreaterThanOrEqual(180); + expect(elapsed).toBeLessThan(500); + }); + + it("returns immediately after job already finalized", async () => { + manager.startJob("ws-1", "proj-1"); + manager.finalizeJob("ws-1"); + + // Done promise was removed by finalizeJob, so this should return immediately + const start = Date.now(); + await manager.waitForInit("ws-1"); + const elapsed = Date.now() - start; + expect(elapsed).toBeLessThan(100); + }); + }); + + describe("acquireProjectLock / releaseProjectLock", () => { + it("acquires and releases lock", async () => { + expect(manager.hasProjectLock("proj-1")).toBe(false); + + await manager.acquireProjectLock("proj-1"); + expect(manager.hasProjectLock("proj-1")).toBe(true); + + manager.releaseProjectLock("proj-1"); + expect(manager.hasProjectLock("proj-1")).toBe(false); + }); + + it("serializes concurrent lock requests", async () => { + const order: string[] = []; + + // First lock holder + await manager.acquireProjectLock("proj-1"); + + // Second lock request — will block until first is released + const secondLock = manager.acquireProjectLock("proj-1").then(() => { + order.push("second-acquired"); + }); + + // Give a tick to ensure second is waiting + await new Promise((r) => setTimeout(r, 10)); + expect(order).toEqual([]); + + // Release first lock + order.push("first-released"); + manager.releaseProjectLock("proj-1"); + + await secondLock; + + expect(order).toEqual(["first-released", "second-acquired"]); + + // Clean up + manager.releaseProjectLock("proj-1"); + }); + + it("releaseProjectLock no-ops for unheld lock", () => { + // Should not throw + manager.releaseProjectLock("proj-nonexistent"); + }); + }); + + describe("getProgress / getAllProgress", () => { + it("returns progress for active job", () => { + manager.startJob("ws-1", "proj-1"); + const progress = manager.getProgress("ws-1"); + + expect(progress).toBeDefined(); + expect(progress?.workspaceId).toBe("ws-1"); + expect(progress?.projectId).toBe("proj-1"); + expect(progress?.step).toBe("pending"); + }); + + it("returns all progress entries", () => { + manager.startJob("ws-1", "proj-1"); + manager.startJob("ws-2", "proj-2"); + + const all = manager.getAllProgress(); + expect(all).toHaveLength(2); + }); + + it("returns undefined for unknown workspace", () => { + expect(manager.getProgress("ws-unknown")).toBeUndefined(); + }); + }); + + describe("markWorktreeCreated / wasWorktreeCreated", () => { + it("tracks worktree creation for cleanup", () => { + manager.startJob("ws-1", "proj-1"); + expect(manager.wasWorktreeCreated("ws-1")).toBe(false); + + manager.markWorktreeCreated("ws-1"); + expect(manager.wasWorktreeCreated("ws-1")).toBe(true); + }); + + it("returns false for unknown workspace", () => { + expect(manager.wasWorktreeCreated("ws-unknown")).toBe(false); + }); + }); + + describe("event emission", () => { + it("emits progress events on startJob", () => { + const events: unknown[] = []; + manager.on("progress", (p: unknown) => events.push(p)); + + manager.startJob("ws-1", "proj-1"); + expect(events).toHaveLength(1); + }); + + it("emits progress events on updateProgress", () => { + const events: unknown[] = []; + manager.on("progress", (p: unknown) => events.push(p)); + + manager.startJob("ws-1", "proj-1"); + manager.updateProgress("ws-1", "creating_worktree", "Creating..."); + + expect(events).toHaveLength(2); // startJob + updateProgress + }); + }); +}); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/ChangesView.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/ChangesView.tsx index 7ed0aeb75e6..35d04cbcd45 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/ChangesView.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/ChangesView.tsx @@ -17,6 +17,7 @@ import { electronTrpc } from "renderer/lib/electron-trpc"; import { useBranchSyncInvalidation } from "renderer/screens/main/hooks/useBranchSyncInvalidation"; import { useChangesStore } from "renderer/stores/changes"; import type { ChangeCategory, ChangedFile } from "shared/changes-types"; +import { useFsSubscription } from "../hooks/useFsSubscription"; import { CategorySection } from "./components/CategorySection"; import { ChangesHeader } from "./components/ChangesHeader"; import { CommitInput } from "./components/CommitInput"; @@ -57,11 +58,20 @@ export function ChangesView({ onFileOpen, isExpandedView }: ChangesViewProps) { { worktreePath: worktreePath || "", defaultBranch: effectiveBaseBranch }, { enabled: !!worktreePath, - refetchInterval: 2500, refetchOnWindowFocus: true, + // Safety net: .git internals are ignored by @parcel/watcher, so + // operations like staging, committing, and rebasing won't trigger + // fs events. Poll infrequently to catch those. + refetchInterval: 5000, }, ); + useFsSubscription({ + workspaceId, + onData: () => refetch(), + debounceMs: 500, + }); + const { data: githubStatus, refetch: refetchGithubStatus } = electronTrpc.workspaces.getGitHubStatus.useQuery( { workspaceId: workspaceId ?? "" }, diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/FilesView.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/FilesView.tsx index a7f288e1075..0fcdd20a53d 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/FilesView.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/FilesView.tsx @@ -18,6 +18,7 @@ import { electronTrpc } from "renderer/lib/electron-trpc"; import { useFileExplorerStore } from "renderer/stores/file-explorer"; import { useTabsStore } from "renderer/stores/tabs/store"; import type { DirectoryEntry } from "shared/file-tree-types"; +import { useFsSubscription } from "../hooks/useFsSubscription"; import { DeleteConfirmDialog } from "./components/DeleteConfirmDialog"; import { FileSearchResultItem } from "./components/FileSearchResultItem"; import { FileTreeItem } from "./components/FileTreeItem"; @@ -117,6 +118,20 @@ export function FilesView() { prevWorktreePathRef.current = worktreePath; }, [worktreePath, tree]); + useFsSubscription({ + workspaceId, + onData: () => { + tree.getItemInstance("root")?.invalidateChildrenIds(); + // invalidateChildrenIds does NOT cascade, so explicitly + // invalidate every expanded directory so nested changes appear. + for (const item of tree.getItems()) { + if (item.getItemData()?.isDirectory) { + item.invalidateChildrenIds(); + } + } + }, + }); + const { createFile, createDirectory, rename, deleteItems, isDeleting } = useFileTreeActions({ worktreePath, diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/hooks/useFileTree.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/hooks/useFileTree.ts deleted file mode 100644 index 476607bf9a7..00000000000 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/hooks/useFileTree.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; -import { electronTrpc } from "renderer/lib/electron-trpc"; -import { useFileExplorerStore } from "renderer/stores/file-explorer"; -import type { FileTreeNode } from "shared/file-tree-types"; - -interface UseFileTreeProps { - worktreePath: string | undefined; -} - -interface UseFileTreeReturn { - treeData: FileTreeNode[]; - isLoading: boolean; - error: Error | null; - refetch: () => void; - loadChildren: (nodeId: string, nodePath: string) => Promise; -} - -export function useFileTree({ - worktreePath, -}: UseFileTreeProps): UseFileTreeReturn { - const [treeData, setTreeData] = useState([]); - const [childrenCache, setChildrenCache] = useState< - Record - >({}); - - const { showHiddenFiles, expandedFolders } = useFileExplorerStore(); - const includeHidden = worktreePath - ? (showHiddenFiles[worktreePath] ?? false) - : false; - const currentExpandedFolders = worktreePath - ? expandedFolders[worktreePath] || [] - : []; - - const trpcUtils = electronTrpc.useUtils(); - - const { - data: rootEntries, - isLoading, - error, - refetch, - } = electronTrpc.filesystem.readDirectory.useQuery( - { - dirPath: worktreePath || "", - rootPath: worktreePath || "", - includeHidden, - }, - { - enabled: !!worktreePath, - staleTime: 5000, - }, - ); - - const rootNodes = useMemo((): FileTreeNode[] => { - if (!rootEntries) return []; - - return rootEntries.map((entry) => ({ - ...entry, - children: entry.isDirectory ? null : undefined, - })); - }, [rootEntries]); - - const buildTree = useCallback( - (nodes: FileTreeNode[]): FileTreeNode[] => { - return nodes.map((node) => { - if (!node.isDirectory) { - return node; - } - - const isExpanded = currentExpandedFolders.includes(node.id); - const cachedChildren = childrenCache[node.id]; - - if (!isExpanded) { - return { ...node, children: null }; - } - - if (cachedChildren) { - return { - ...node, - children: buildTree(cachedChildren), - }; - } - - return { ...node, children: null, isLoading: true }; - }); - }, - [currentExpandedFolders, childrenCache], - ); - - useEffect(() => { - setTreeData(buildTree(rootNodes)); - }, [rootNodes, buildTree]); - - const loadChildren = useCallback( - async (nodeId: string, nodePath: string): Promise => { - if (!worktreePath) return []; - - if (childrenCache[nodeId]) { - return childrenCache[nodeId]; - } - - try { - const entries = await trpcUtils.filesystem.readDirectory.fetch({ - dirPath: nodePath, - rootPath: worktreePath, - includeHidden, - }); - - const childNodes: FileTreeNode[] = entries.map((entry) => ({ - ...entry, - children: entry.isDirectory ? null : undefined, - })); - - setChildrenCache((prev) => ({ - ...prev, - [nodeId]: childNodes, - })); - - return childNodes; - } catch (err) { - console.error("[useFileTree] Failed to load children:", { - nodeId, - nodePath, - error: err, - }); - return []; - } - }, - [worktreePath, includeHidden, childrenCache, trpcUtils], - ); - - return { - treeData, - isLoading, - error: error as Error | null, - refetch: () => { - setChildrenCache({}); - refetch(); - }, - loadChildren, - }; -} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/hooks/useFsSubscription/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/hooks/useFsSubscription/index.ts new file mode 100644 index 00000000000..5bad97dd5b5 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/hooks/useFsSubscription/index.ts @@ -0,0 +1 @@ +export { useFsSubscription } from "./useFsSubscription"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/hooks/useFsSubscription/useFsSubscription.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/hooks/useFsSubscription/useFsSubscription.ts new file mode 100644 index 00000000000..f8c7360ed48 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/hooks/useFsSubscription/useFsSubscription.ts @@ -0,0 +1,44 @@ +import { useCallback, useEffect, useRef } from "react"; +import { electronTrpc } from "renderer/lib/electron-trpc"; + +export function useFsSubscription({ + workspaceId, + onData, + debounceMs, +}: { + workspaceId: string | undefined; + onData: () => void; + debounceMs?: number; +}): void { + const timerRef = useRef | null>(null); + const onDataRef = useRef(onData); + onDataRef.current = onData; + + const handler = useCallback(() => { + if (!debounceMs) { + onDataRef.current(); + return; + } + if (timerRef.current) { + clearTimeout(timerRef.current); + } + timerRef.current = setTimeout(() => { + timerRef.current = null; + onDataRef.current(); + }, debounceMs); + }, [debounceMs]); + + useEffect(() => { + return () => { + if (timerRef.current) clearTimeout(timerRef.current); + }; + }, []); + + electronTrpc.filesystem.subscribe.useSubscription( + { workspaceId: workspaceId ?? "" }, + { + enabled: !!workspaceId, + onData: handler, + }, + ); +} diff --git a/apps/desktop/src/shared/file-tree-types.ts b/apps/desktop/src/shared/file-tree-types.ts index 946377e02e1..1e76693c14d 100644 --- a/apps/desktop/src/shared/file-tree-types.ts +++ b/apps/desktop/src/shared/file-tree-types.ts @@ -1,19 +1,15 @@ -export interface FileTreeNode { - id: string; - name: string; - isDirectory: boolean; - path: string; - relativePath: string; - children?: FileTreeNode[] | null; - isLoading?: boolean; -} - export interface FileSystemChangeEvent { type: "add" | "addDir" | "unlink" | "unlinkDir" | "change"; path: string; relativePath: string; } +export interface FileSystemBatchEvent { + workspaceId: string; + events: FileSystemChangeEvent[]; + timestamp: number; +} + export interface DirectoryEntry { id: string; name: string; diff --git a/bun.lock b/bun.lock index 940caff94bf..451eb53b042 100644 --- a/bun.lock +++ b/bun.lock @@ -144,6 +144,7 @@ "@headless-tree/react": "^1.6.3", "@hookform/resolvers": "^5.2.2", "@monaco-editor/react": "^4.7.0", + "@parcel/watcher": "^2.5.0", "@pierre/diffs": "^1.0.10", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-label": "^2.1.8", @@ -1568,6 +1569,34 @@ "@orama/orama": ["@orama/orama@3.1.18", "", {}, "sha512-a61ljmRVVyG5MC/698C8/FfFDw5a8LOIvyOLW5fztgUXqUpc1jOfQzOitSCbge657OgXXThmY3Tk8fpiDb4UcA=="], + "@parcel/watcher": ["@parcel/watcher@2.5.6", "", { "dependencies": { "detect-libc": "^2.0.3", "is-glob": "^4.0.3", "node-addon-api": "^7.0.0", "picomatch": "^4.0.3" }, "optionalDependencies": { "@parcel/watcher-android-arm64": "2.5.6", "@parcel/watcher-darwin-arm64": "2.5.6", "@parcel/watcher-darwin-x64": "2.5.6", "@parcel/watcher-freebsd-x64": "2.5.6", "@parcel/watcher-linux-arm-glibc": "2.5.6", "@parcel/watcher-linux-arm-musl": "2.5.6", "@parcel/watcher-linux-arm64-glibc": "2.5.6", "@parcel/watcher-linux-arm64-musl": "2.5.6", "@parcel/watcher-linux-x64-glibc": "2.5.6", "@parcel/watcher-linux-x64-musl": "2.5.6", "@parcel/watcher-win32-arm64": "2.5.6", "@parcel/watcher-win32-ia32": "2.5.6", "@parcel/watcher-win32-x64": "2.5.6" } }, "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ=="], + + "@parcel/watcher-android-arm64": ["@parcel/watcher-android-arm64@2.5.6", "", { "os": "android", "cpu": "arm64" }, "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A=="], + + "@parcel/watcher-darwin-arm64": ["@parcel/watcher-darwin-arm64@2.5.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA=="], + + "@parcel/watcher-darwin-x64": ["@parcel/watcher-darwin-x64@2.5.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg=="], + + "@parcel/watcher-freebsd-x64": ["@parcel/watcher-freebsd-x64@2.5.6", "", { "os": "freebsd", "cpu": "x64" }, "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng=="], + + "@parcel/watcher-linux-arm-glibc": ["@parcel/watcher-linux-arm-glibc@2.5.6", "", { "os": "linux", "cpu": "arm" }, "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ=="], + + "@parcel/watcher-linux-arm-musl": ["@parcel/watcher-linux-arm-musl@2.5.6", "", { "os": "linux", "cpu": "arm" }, "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg=="], + + "@parcel/watcher-linux-arm64-glibc": ["@parcel/watcher-linux-arm64-glibc@2.5.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA=="], + + "@parcel/watcher-linux-arm64-musl": ["@parcel/watcher-linux-arm64-musl@2.5.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA=="], + + "@parcel/watcher-linux-x64-glibc": ["@parcel/watcher-linux-x64-glibc@2.5.6", "", { "os": "linux", "cpu": "x64" }, "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ=="], + + "@parcel/watcher-linux-x64-musl": ["@parcel/watcher-linux-x64-musl@2.5.6", "", { "os": "linux", "cpu": "x64" }, "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg=="], + + "@parcel/watcher-win32-arm64": ["@parcel/watcher-win32-arm64@2.5.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q=="], + + "@parcel/watcher-win32-ia32": ["@parcel/watcher-win32-ia32@2.5.6", "", { "os": "win32", "cpu": "ia32" }, "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g=="], + + "@parcel/watcher-win32-x64": ["@parcel/watcher-win32-x64@2.5.6", "", { "os": "win32", "cpu": "x64" }, "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw=="], + "@pierre/diffs": ["@pierre/diffs@1.0.10", "", { "dependencies": { "@shikijs/core": "^3.0.0", "@shikijs/engine-javascript": "^3.0.0", "@shikijs/transformers": "^3.0.0", "diff": "8.0.3", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-ahkpfS30NfaB+PBxnf0/Mc20ySBRTQmM28a7Ojpd0UZixmTyhGhJfBFjvmhX8dSzR22lB3h3OIMMxpB4yYTIOQ=="], "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="],