Skip to content

feat(desktop): add real-time filesystem watcher with @parcel/watcher#1351

Closed
Kitenite wants to merge 7 commits into
mainfrom
kitenite/file-dir-listener-pattern
Closed

feat(desktop): add real-time filesystem watcher with @parcel/watcher#1351
Kitenite wants to merge 7 commits into
mainfrom
kitenite/file-dir-listener-pattern

Conversation

@Kitenite
Copy link
Copy Markdown
Collaborator

@Kitenite Kitenite commented Feb 9, 2026

Summary

  • Replace polling-based file tree (5s staleTime) and git status (2.5s refetchInterval) with event-driven updates powered by a single @parcel/watcher service
  • Consolidate two separate watcher systems (StaticPortsWatcher + new FsWatcher) into one unified pattern
  • Decompose the 500-line filesystem/index.ts monolith into focused sub-routers

Changes

New: FsWatcher service (src/main/lib/fs-watcher/)

  • @parcel/watcher for native recursive directory watching per workspace
  • 100ms debounce + 2s max batch window for event batching
  • Deduplicates events by path (last write wins)
  • Ignores node_modules, .git, dist, build, .next, .turbo, coverage

New: tRPC subscription (filesystem.subscribe)

  • Observable-based subscription (required by trpc-electron)
  • Filters batch events by workspace ID
  • Search index cache auto-invalidated on filesystem changes

Watcher lifecycle

  • Start: on workspace init (ready state) + app boot (all active workspaces)
  • Stop: on workspace delete/close
  • @parcel/watcher added to rollup externals + native module copy script

Retired: StaticPortsWatcher

  • Ports router now consumes fsWatcher batch events, filtering for .superset/ports.json changes
  • Deleted static-ports/watcher.ts (158 lines)

Decomposed filesystem router

  • search.ts — search index with Fuse.js, caching, cache invalidation
  • operations.ts — CRUD procedures (readDirectory, createFile, rename, delete, move, copy, etc.)
  • subscription.ts — filesystem subscribe procedure
  • index.ts — thin composer using ..._def.procedures spread pattern

Shared useFsSubscription hook

  • Extracted from duplicated code in FilesView.tsx and ChangesView.tsx
  • Supports optional debounceMs parameter
  • Located at RightSidebar/hooks/useFsSubscription/

Renderer updates

  • FilesView: real-time tree invalidation on filesystem events
  • ChangesView: replaced 2.5s polling with 500ms debounced subscription

Dead code removed

  • useFileTree.ts (141 lines, never imported)
  • FileTreeNode interface from shared types

Test Plan

  • bun run typecheck passes across all packages
  • bun run lint:fix reports no issues
  • App starts, open a workspace — file tree loads normally
  • Create/edit/delete files in workspace → file tree updates within ~200ms
  • Edit a tracked file → ChangesView updates without manual refresh
  • Run npm install in terminal → events batch correctly, no UI stutter
  • Edit .superset/ports.json → ports panel reflects change
  • Close/delete a workspace → watcher stops (check console logs)
  • Restart app with existing workspaces → watchers auto-start on boot

Summary by CodeRabbit

  • New Features

    • Real-time filesystem change subscriptions for near-instant UI updates
    • Fast, indexed fuzzy file search with caching and incremental updates
    • Comprehensive file operations: create, rename, delete, move, copy, stat
  • Refactor

    • Split filesystem features into modular operations, search, and subscription components
    • Single active filesystem watcher replacing prior per-file watcher for more reliable events
  • Docs

    • Architectural doc describing single-watcher model and behaviors
  • Tests

    • Extensive unit/integration tests added for the watcher and init lifecycle

Replace polling-based file tree and git status updates with a single
event-driven watcher service. Consolidates StaticPortsWatcher into the
same pattern and decomposes the 500-line filesystem router.

- Add FsWatcher service using @parcel/watcher with 100ms debounce + 2s max batch window
- Add tRPC subscription (filesystem.subscribe) for renderer consumption
- Wire watcher lifecycle to workspace init/delete/close and app startup
- Retire StaticPortsWatcher — ports router now consumes FsWatcher batch events
- Split filesystem router into search.ts, operations.ts, subscription.ts
- Extract shared useFsSubscription hook with optional debounce
- Remove 2.5s polling from ChangesView, use subscription instead
- Add real-time file tree updates in FilesView via subscription
- Auto-invalidate search index cache on filesystem changes
- Delete unused useFileTree.ts and FileTreeNode type
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Feb 9, 2026

📝 Walkthrough

Walkthrough

The PR introduces a single-active FsWatcher (using @parcel/watcher) with debounced, batched per-workspace filesystem events; refactors the monolithic filesystem TRPC router into operations/search/subscription sub-routers; integrates fsWatcher into workspace lifecycle and app bootstrap; updates renderer hooks to subscribe to filesystem batches; and adds tests and docs.

Changes

Cohort / File(s) Summary
Build & Native Modules
apps/desktop/electron.vite.config.ts, apps/desktop/package.json, apps/desktop/scripts/copy-native-modules.ts
Add @parcel/watcher as a dependency, mark it external to the build, and include it in native module copy list.
FsWatcher Core
apps/desktop/src/main/lib/fs-watcher/fs-watcher.ts, apps/desktop/src/main/lib/fs-watcher/index.ts
New FsWatcher implementation (singleton) providing per-workspace watch, debounced batched events, switch/unwatch/stop API, and public export.
Static Ports Removal / Migration
apps/desktop/src/main/lib/static-ports/watcher.ts, apps/desktop/src/main/lib/static-ports/index.ts, apps/desktop/src/lib/trpc/routers/ports/ports.ts
Remove StaticPortsWatcher implementation and export; migrate ports file change detection to fsWatcher batch events and update ports subscription logic.
Filesystem TRPC Refactor
apps/desktop/src/lib/trpc/routers/filesystem/index.ts, .../operations.ts, .../search.ts, .../subscription.ts
Replace monolithic filesystem router with composed sub-routers: operations (CRUD/stat), search (Fuse-based cached search with invalidation), and subscription (exposes FileSystemBatchEvent stream).
Workspace Lifecycle Integration
apps/desktop/src/lib/trpc/routers/workspaces/utils/db-helpers.ts, .../procedures/delete.ts, .../utils/workspace-init.ts
Wire fsWatcher.switchTo/unwatch/stop into workspace active-setting, init, delete, and close flows; add re-attach logic on failure paths.
App Bootstrap
apps/desktop/src/main/index.ts
Start fsWatcher for the last-active workspace during app bootstrap (calls switchTo after resolving workspace path).
Renderer: Subscription & UI Hooks
apps/desktop/src/renderer/.../hooks/useFsSubscription/*, .../ChangesView/ChangesView.tsx, .../FilesView/FilesView.tsx, .../useFileTree.ts
Add useFsSubscription hook and integrate into ChangesView and FilesView to trigger debounced refetch/invalidation; remove legacy useFileTree hook.
Types & Public Shapes
apps/desktop/src/shared/file-tree-types.ts
Add FileSystemBatchEvent type and remove FileTreeNode type (tree model moved to renderer).
Tests & Docs
apps/desktop/src/main/lib/fs-watcher/*.test.ts, apps/desktop/src/main/lib/workspace-init-manager.test.ts, apps/desktop/docs/SINGLE_ACTIVE_FS_WATCHER.md
Add unit and lifecycle tests for FsWatcher and workspace-init manager; add design doc describing single-active watcher approach.

Sequence Diagram(s)

sequenceDiagram
    participant App as App Bootstrap
    participant DB as Workspace DB
    participant FSW as FsWatcher
    participant OS as OS (`@parcel/watcher`)
    participant TRPC as TRPC (subscription)
    participant UI as Renderer (useFsSubscription)

    App->>DB: query lastActiveWorkspaceId
    DB-->>App: return workspaceId
    App->>FSW: switchTo(workspaceId, rootPath)
    FSW->>OS: subscribe(rootPath) via `@parcel/watcher`
    OS-->>FSW: file events (create/update/delete)
    FSW->>FSW: collect & debounce events into batch
    alt debounce elapsed / max window
        FSW->>TRPC: emit FileSystemBatchEvent(workspaceId, events, ts)
        TRPC->>UI: push event to subscribers
        UI->>UI: debounced onData -> invalidate/refetch queries
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested reviewers

  • saddlepaddle

Poem

🐰 A watcher woke beneath my paws,
It batches paths and mends the claws,
Events hop in, then leap to view,
The UI dances, fresh and new! 🌿

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 33.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main change: introducing a real-time filesystem watcher using @parcel/watcher to replace polling-based file monitoring.
Description check ✅ Passed The description is comprehensive, well-structured, and covers all template sections. It includes clear summaries of changes, related context, type of change (Feature), testing steps, and additional notes.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch kitenite/file-dir-listener-pattern

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/desktop/src/lib/trpc/routers/workspaces/procedures/delete.ts (1)

409-471: ⚠️ Potential issue | 🔴 Critical

Missing filesystem watcher cleanup when deleting a worktree.

The deleteWorktree mutation does not unwatch filesystem watchers for workspaces that reference the worktree being deleted. Both the delete and close mutations properly call fsWatcher.unwatch() on their workspace ID before cleanup, but deleteWorktree skips this step. If any workspace has a worktreeId pointing to the worktree being deleted, its filesystem watcher would remain active on a path that no longer exists, likely causing continuous errors from @parcel/watcher.

Before calling deleteWorktreeRecord(input.worktreeId), find all workspaces with worktreeId === input.worktreeId and call fsWatcher.unwatch() for each workspace ID.

🤖 Fix all issues with AI agents
In `@apps/desktop/src/lib/trpc/routers/filesystem/search.ts`:
- Around line 119-132: The background rebuild in the stale-while-revalidate
branch creates buildPromise from buildSearchIndex and currently .catch() deletes
searchIndexBuilds and re-throws the error, which becomes an unhandled rejection
because the function already returns cached.index; update the .catch() on
buildPromise (the one assigned to searchIndexBuilds for cacheKey) to delete
searchIndexBuilds and log the error via the existing logger (or console.error)
instead of re-throwing; keep the searchIndexCache.set and
searchIndexBuilds.delete behavior on success and ensure buildPromise is still
stored in searchIndexBuilds so callers can observe it if needed.

In
`@apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/FilesView.tsx`:
- Around line 121-124: The filesystem subscription only invalidates the root
children so expanded subfolders don't refresh; change the onData passed to
useFsSubscription to reuse the same invalidation logic as handleRefresh: iterate
over the tree's currently expanded item ids (the same collection handleRefresh
uses), call tree.getItemInstance(id)?.invalidateChildrenIds() for each expanded
id and also invalidate the root, and then trigger the same refresh UI actions
handleRefresh does (e.g., setRefreshing/state updates) so newly created/deleted
files in expanded directories update immediately.
🧹 Nitpick comments (7)
apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/hooks/useFsSubscription/useFsSubscription.ts (1)

17-29: Pending debounce timer not cleared when debounceMs changes.

If debounceMs changes while a timer is in-flight, the old timer (from the previous handler closure) can still fire because the cleanup effect on lines 31-35 only runs on unmount. In practice this is unlikely to cause issues since debounceMs is probably constant per call site, but for correctness the cleanup could be tied to debounceMs:

♻️ Suggested improvement
 	useEffect(() => {
 		return () => {
 			if (timerRef.current) clearTimeout(timerRef.current);
 		};
-	}, []);
+	}, [debounceMs]);
apps/desktop/src/main/lib/fs-watcher/fs-watcher.ts (2)

43-44: Consider typing the EventEmitter to enforce the "batch" event contract.

The on("batch", ...) / emit("batch", ...) calls rely on string matching and manual casting at each listener site (e.g., ports.ts). A typed emitter would catch mismatches at compile time.

Example using a typed interface
+interface FsWatcherEvents {
+	batch: [batch: FileSystemBatchEvent];
+}
+
-class FsWatcher extends EventEmitter {
+class FsWatcher extends EventEmitter<FsWatcherEvents> {

Node's EventEmitter supports generic type parameters since @types/node v18+.


30-41: mapEventType never produces "addDir" or "unlinkDir" types.

FileSystemChangeEvent["type"] includes "addDir" | "unlinkDir", but @parcel/watcher doesn't distinguish file vs. directory events, so these variants are unreachable. If consumers ever need to filter by directory events, this will silently fail. Not blocking, but worth a note or a follow-up to remove unused variants from the shared type.

apps/desktop/src/main/index.ts (1)

215-246: Log message is slightly misleading — watchers haven't actually started yet.

At line 239 you log "Started fs watchers" immediately after kicking off the .watch() promises, but none of them have resolved at that point. Consider changing the wording to "Starting fs watchers" or awaiting with Promise.allSettled and logging actual success/failure counts.

♻️ Suggested wording fix (minimal)
 		if (activeWorkspaces.length > 0) {
 			console.log(
-				`[main] Started fs watchers for ${activeWorkspaces.length} active workspace(s)`,
+				`[main] Starting fs watchers for ${activeWorkspaces.length} active workspace(s)`,
 			);
 		}
apps/desktop/src/lib/trpc/routers/filesystem/operations.ts (2)

63-73: The fs.access existence-check pattern silently swallows non-ENOENT errors.

If fs.access fails for a reason other than the file not existing (e.g., EACCES), the error is caught and silently discarded. This same fragile pattern is repeated in createDirectory, rename, and move. A more robust approach is to check the error code directly.

Also, per coding guidelines, these should throw TRPCError with BAD_REQUEST instead of plain Error.

♻️ Suggested approach (apply similarly to createDirectory, rename, move)
-			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;
-				}
-			}
+			try {
+				await fs.access(filePath);
+				throw new TRPCError({
+					code: "BAD_REQUEST",
+					message: `File already exists: ${input.fileName}`,
+				});
+			} catch (error) {
+				if (error instanceof TRPCError) throw error;
+				// ENOENT means file doesn't exist — proceed to create
+				if (!(error instanceof Error) || (error as NodeJS.ErrnoException).code !== "ENOENT") {
+					throw error; // Re-throw unexpected errors (e.g., EACCES)
+				}
+			}

As per coding guidelines: "Use appropriate TRPCError codes consistently" and "Never swallow errors silently."


218-232: Unbounded collision counter in the copy loop.

The while (true) loop increments counter without an upper bound. While unlikely in practice, a pathological case could spin for a very long time. Consider adding a reasonable cap (e.g., 1000) and throwing an error if exceeded.

apps/desktop/src/lib/trpc/routers/filesystem/search.ts (1)

42-53: Cache invalidation looks correct but consider logging for observability.

The invalidation listener silently drops events when getRootPath returns undefined (e.g., workspace already stopped). This is fine functionally, but a debug-level log could help troubleshoot cases where search results appear stale after file changes.

Comment thread apps/desktop/src/lib/trpc/routers/filesystem/search.ts
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Feb 9, 2026

🚀 Preview Deployment

🔗 Preview Links

Service Status Link
Neon Database (Neon) View Branch
Fly.io Electric (Fly.io) View App
Fly.io Streams (Fly.io) Failed to deploy
Vercel API (Vercel) Open Preview
Vercel Web (Vercel) Open Preview
Vercel Marketing (Vercel) Open Preview
Vercel Admin (Vercel) Open Preview
Vercel Docs (Vercel) Open Preview

Preview updates automatically with new commits

- Add refetchInterval fallback for ChangesView since .git is ignored by @parcel/watcher
- Invalidate all directory items on fs events, not just root (no cascade)
- Re-attach watcher on failed workspace deletion to avoid orphaned state
- Guard fsWatcher.watch with cancellation check in workspace-init
The stale-while-revalidate path returns stale cache immediately, making
the background rebuild fire-and-forget. Re-throwing in .catch() caused
unhandled promise rejections.
Init's fire-and-forget fsWatcher.watch() can resolve after the initial
unwatch in the delete mutation. Adding a second unwatch after waitForInit
ensures no watcher leaks for a workspace being deleted.
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/desktop/src/lib/trpc/routers/workspaces/procedures/delete.ts (1)

329-356: ⚠️ Potential issue | 🟡 Minor

Consider re-attaching the watcher if close fails after unwatch.

In the delete procedure, every failure path after unwatch carefully re-attaches the watcher. In close, if killByWorkspaceId (line 340) or deleteWorkspace (line 344) throws after the unwatch on line 338, the workspace survives without its file watcher.

If this is intentional (close is always-destructive and partial failure is unlikely), a brief comment would help future readers. Otherwise, wrapping in try/catch with a re-attach fallback would match the defensive pattern used in delete.

🤖 Fix all issues with AI agents
In `@apps/desktop/src/lib/trpc/routers/filesystem/search.ts`:
- Around line 164-175: The searchFiles procedure currently accepts an arbitrary
rootPath allowing path traversal; update it (and the similar procedures
listDirectory, createFile, rename, delete in operations.ts) to validate rootPath
against the registered workspace roots before any filesystem ops: either require
a workspaceId and derive the canonical root via
fsWatcher.getRootPath(workspaceId) or check that the provided rootPath equals
one of fsWatcher’s known roots/registry, reject requests that don’t match, and
ensure all file operations use the validated/canonicalized root only.
🧹 Nitpick comments (5)
apps/desktop/src/lib/trpc/routers/workspaces/procedures/delete.ts (1)

188-197: Extract the duplicated re-attach-watcher block into a helper.

The same fire-and-forget re-attach pattern appears three times in this procedure. A small helper would reduce duplication and make the recovery intent clearer:

♻️ Suggested refactor

Add a local helper at the top of the delete mutation (or at module scope):

+const reattachWatcher = (workspaceId: string, rootPath: string | undefined) => {
+  if (!rootPath) return;
+  fsWatcher
+    .watch({ workspaceId, rootPath })
+    .catch((err) => {
+      console.error("[workspace/delete] Failed to re-attach watcher:", err);
+    });
+};

Then replace each duplicated block, e.g.:

 clearWorkspaceDeletingStatus(input.id);
-if (watcherRootPath) {
-  fsWatcher
-    .watch({ workspaceId: input.id, rootPath: watcherRootPath })
-    .catch((err) => {
-      console.error(
-        "[workspace/delete] Failed to re-attach watcher:",
-        err,
-      );
-    });
-}
+reattachWatcher(input.id, watcherRootPath);

Also applies to: 249-258, 276-285

apps/desktop/src/lib/trpc/routers/filesystem/search.ts (4)

11-19: Duplicated ignore patterns — consider sharing a single source of truth.

DEFAULT_IGNORE_PATTERNS is duplicated verbatim from apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/constants.ts (lines 7-15). If the list evolves, the two copies can silently diverge.

Consider extracting these into a shared constants file (e.g., under shared/) so both the renderer and the main-process search router reference the same list.


43-53: Module-level event listener is never removed.

The fsWatcher.on("batch", ...) listener is registered as a module-level side effect at import time and is never cleaned up. Since this module is imported once and lives for the app's lifetime this is functionally fine, but it makes the search module harder to test in isolation and couples cache invalidation to import order.

A lighter-touch improvement: move the listener registration into createSearchRouter() so the wiring is explicit and could be torn down if needed.


72-80: onlyFiles: true means directories are never searchable.

fast-glob is called with onlyFiles: true, so the index only contains files. The FileSearchItem type has an isDirectory field, and the procedure response includes isDirectory: false (line 193), but users can never find directories via search. If this is intentional, consider removing the isDirectory field from the search item shape to avoid confusion. If directory search is desired, you'd need onlyDirectories or a second pass.


82-88: id is relativePath — clarify or deduplicate the field.

id is set to relativePath (line 83), and both are returned in the response (lines 189-191). Having two fields with identical values adds noise. If id is meant to be a stable identifier, consider documenting why it duplicates relativePath, or simply drop one.

Comment on lines +164 to +175
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();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Search for rootPath usage in filesystem router procedures
rg -n 'rootPath' --type=ts -C3 'apps/desktop/src/lib/trpc/routers/filesystem/'

Repository: superset-sh/superset

Length of output: 10152


🏁 Script executed:

#!/bin/bash
# Search for workspace registry or validation mechanisms
rg -n 'workspace|fsWatcher' --type=ts 'apps/desktop/src/lib/trpc/routers/filesystem/' -B2 -A2

Repository: superset-sh/superset

Length of output: 3099


🏁 Script executed:

#!/bin/bash
# Check the full searchFiles implementation
head -220 apps/desktop/src/lib/trpc/routers/filesystem/search.ts | tail -80

Repository: superset-sh/superset

Length of output: 1898


🏁 Script executed:

#!/bin/bash
# Search for any validatePath or path validation utilities
fd -e ts -e tsx 'apps/desktop/src' | xargs rg -l 'validatePath|isValidPath|validateRootPath' --type=ts

Repository: superset-sh/superset

Length of output: 435


🏁 Script executed:

#!/bin/bash
# Find renderer/client code that calls searchFiles
rg -n 'searchFiles' --type=ts --type=tsx 'apps/desktop/src'

Repository: superset-sh/superset

Length of output: 91


🏁 Script executed:

#!/bin/bash
# Look for workspace registry or list of valid workspace roots
rg -n 'workspace.*root|rootPath.*workspace|validateWorkspace' --type=ts

Repository: superset-sh/superset

Length of output: 1642


🏁 Script executed:

#!/bin/bash
# Check fsWatcher implementation to understand getRootPath
fd -name 'fs-watcher*' -o -name '*fs-watcher*' | head -5 | xargs head -100

Repository: superset-sh/superset

Length of output: 294


🏁 Script executed:

#!/bin/bash
# Find all calls to searchFiles in the codebase
rg -n 'searchFiles' --type=ts

Repository: superset-sh/superset

Length of output: 444


🏁 Script executed:

#!/bin/bash
# Look for fs-watcher implementation
find apps/desktop/src/main/lib -name '*fs-watcher*' -type f

Repository: superset-sh/superset

Length of output: 114


🏁 Script executed:

#!/bin/bash
# Check if renderer code has access to workspace registry
rg -n 'workspace.*list|getWorkspaces|listWorkspaces' --type=ts 'apps/desktop/src/renderer' -A2 -B2

Repository: superset-sh/superset

Length of output: 8983


🏁 Script executed:

#!/bin/bash
# Examine how rootPath is obtained in useFileSearch
cat -n apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/hooks/useFileSearch/useFileSearch.ts

Repository: superset-sh/superset

Length of output: 1208


🏁 Script executed:

#!/bin/bash
# Check the fs-watcher implementation to understand getRootPath method
cat -n apps/desktop/src/main/lib/fs-watcher/fs-watcher.ts | head -150

Repository: superset-sh/superset

Length of output: 4575


🏁 Script executed:

#!/bin/bash
# Check where worktreePath comes from in the component that uses useFileSearch
rg -n 'worktreePath' --type=ts apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView -B5 -A5

Repository: superset-sh/superset

Length of output: 50376


🏁 Script executed:

#!/bin/bash
# Check what SelectWorkspace and workspace data look like
rg -n 'worktreePath' --type=ts 'apps/desktop/src/renderer' -B2 -A2 | head -60

Repository: superset-sh/superset

Length of output: 5002


🏁 Script executed:

#!/bin/bash
# Verify whether searchFiles validates rootPath against fsWatcher
cat -n apps/desktop/src/lib/trpc/routers/filesystem/search.ts | sed -n '164,210p'

Repository: superset-sh/superset

Length of output: 1406


🏁 Script executed:

#!/bin/bash
# Check if there's validation in createFile, rename, delete procedures in operations
rg -n 'createFile|deleteFile|rename' --type=ts 'apps/desktop/src/lib/trpc/routers/filesystem/operations.ts' -A10 | head -80

Repository: superset-sh/superset

Length of output: 1270


🏁 Script executed:

#!/bin/bash
# Check if workspace.get validates workspaceId belongs to user
rg -n 'workspaces\.get' --type=ts 'apps/desktop/src/lib/trpc/routers/workspaces' -A15 | head -60

Repository: superset-sh/superset

Length of output: 46


🏁 Script executed:

#!/bin/bash
# Check the actual workspaces router structure
cat -n apps/desktop/src/lib/trpc/routers/workspaces/index.ts | head -40

Repository: superset-sh/superset

Length of output: 186


🏁 Script executed:

#!/bin/bash
# Find the workspaces router implementation
find apps/desktop/src/lib/trpc/routers/workspaces -name '*.ts' -type f

Repository: superset-sh/superset

Length of output: 1477


🏁 Script executed:

#!/bin/bash
# Check authentication/authorization - are tRPC procedures protected?
rg -n 'publicProcedure|protectedProcedure|middleware' --type=ts 'apps/desktop/src/lib/trpc' -B2 -A2 | head -80

Repository: superset-sh/superset

Length of output: 5582


Validate rootPath against known workspace roots before filesystem operations.

The searchFiles procedure (and similarly listDirectory, createFile, rename, delete in operations.ts) accept rootPath as an arbitrary string with no validation that it corresponds to a known workspace root. If the renderer process is compromised or misused, this allows enumeration or modification of files outside intended workspace boundaries via path traversal.

Validate that rootPath matches a workspace registered with fsWatcher (e.g., by checking fsWatcher.getRootPath(workspaceId) or maintaining a registry of allowed roots), or refactor procedures to accept workspaceId and derive the path internally.

🤖 Prompt for AI Agents
In `@apps/desktop/src/lib/trpc/routers/filesystem/search.ts` around lines 164 -
175, The searchFiles procedure currently accepts an arbitrary rootPath allowing
path traversal; update it (and the similar procedures listDirectory, createFile,
rename, delete in operations.ts) to validate rootPath against the registered
workspace roots before any filesystem ops: either require a workspaceId and
derive the canonical root via fsWatcher.getRootPath(workspaceId) or check that
the provided rootPath equals one of fsWatcher’s known roots/registry, reject
requests that don’t match, and ensure all file operations use the
validated/canonicalized root only.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@apps/desktop/src/main/lib/workspace-init-manager.test.ts`:
- Around line 125-156: The timing assertions in the tests that call
manager.waitForInit (and use manager.startJob/manager.finalizeJob) are flaky;
change the assertions to avoid tight wall-clock checks—either widen tolerances
(e.g., replace expect(elapsed).toBeLessThan(100) with a much larger threshold
like <500 for "instant" checks and relax the timeout lower bound to >=150 for
the timeout test) or refactor to assert promise resolution order (e.g., await
Promise.race or use jest fake timers to advance timers) so tests rely on
deterministic resolution of manager.waitForInit rather than precise elapsed
milliseconds.
🧹 Nitpick comments (4)
apps/desktop/src/main/lib/workspace-init-manager.test.ts (1)

5-8: Constructor extraction via getPrototypeOf is clever but couples tests to the export shape.

If workspace-init-manager ever switches from exporting a singleton instance to exporting the class directly (or wraps it in a factory), this will silently break. Consider exporting the class alongside the singleton (even as a named _WorkspaceInitManagerClass for test use) to make this contract explicit.

This is a minor coupling concern and the pattern is used consistently in this PR, so it's not blocking.

apps/desktop/src/main/lib/fs-watcher/fs-watcher.test.ts (2)

138-142: triggerEvents assumes a single active watcher — fine here, but fragile if copied.

The helper always fires events on subscribeCalls[subscribeCalls.length - 1]. If a future test watches multiple workspaces and uses this helper, events will only reach the last-subscribed watcher. Consider parameterizing by subscribe index or workspace if you extend these tests later.


192-214: Slow test: ~2.2 s wall-clock for max batch window.

This is a valid behavioral test of the 2 s max window, but it's the slowest test in the suite. If test-suite runtime becomes a concern, consider making the max batch window configurable (injected in the constructor) so tests can use a shorter window.

apps/desktop/src/main/lib/fs-watcher/fs-watcher.lifecycle.test.ts (1)

179-209: Cancellation guard tests validate calling-code discipline, not FsWatcher enforcement.

Lines 186-189 simulate the guard (if (manager.isCancellationRequested(...)) { ... }) in test code. This verifies the pattern works but doesn't guarantee production callers actually perform the check. If this invariant is critical, consider adding a guard inside FsWatcher.watch() itself (accepting an optional cancellation token or check callback) so it's impossible to forget.

Not blocking — the tests are valuable as a pattern reference and for documenting the intended protocol.

Comment on lines +125 to +156
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);
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Timing-based assertions are CI-flaky.

expect(elapsed).toBeLessThan(100) (Lines 132, 155) and expect(elapsed).toBeGreaterThanOrEqual(180) (Line 143) rely on wall-clock timing that can easily fail under CI load. Consider widening the tolerances (e.g., < 500 for "instant" and >= 150 for the timeout) or restructuring to assert on promise resolution order rather than elapsed milliseconds.

🤖 Prompt for AI Agents
In `@apps/desktop/src/main/lib/workspace-init-manager.test.ts` around lines 125 -
156, The timing assertions in the tests that call manager.waitForInit (and use
manager.startJob/manager.finalizeJob) are flaky; change the assertions to avoid
tight wall-clock checks—either widen tolerances (e.g., replace
expect(elapsed).toBeLessThan(100) with a much larger threshold like <500 for
"instant" checks and relax the timeout lower bound to >=150 for the timeout
test) or refactor to assert promise resolution order (e.g., await Promise.race
or use jest fake timers to advance timers) so tests rely on deterministic
resolution of manager.waitForInit rather than precise elapsed milliseconds.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/desktop/src/lib/trpc/routers/workspaces/procedures/delete.ts (1)

330-347: ⚠️ Potential issue | 🟡 Minor

close mutation: unwatch failure prevents workspace cleanup.

If fsWatcher.unwatch(input.id) throws (e.g., native unsubscribe failure), the close mutation aborts before deleting the workspace from the DB or killing terminals. The workspace will remain in a partially-closed state. Consider wrapping the unwatch in a try/catch so that cleanup proceeds even if the watcher teardown fails.

🛡️ Proposed fix
-			await fsWatcher.unwatch(input.id);
-
+			try {
+				await fsWatcher.unwatch(input.id);
+			} catch (err) {
+				console.error("[workspace/close] Failed to unwatch, continuing cleanup:", err);
+			}
🤖 Fix all issues with AI agents
In `@apps/desktop/src/lib/trpc/routers/filesystem/search.ts`:
- Around line 56-59: When handling the fsWatcher "switched" event, increment a
generation counter (e.g., cacheGeneration) instead of blindly relying on
searchIndexBuilds.clear() to avoid orphaning in-flight promises; in the
fsWatcher.on("switched", ...) handler increment cacheGeneration and clear
searchIndexCache and searchIndexBuilds as before. In getSearchIndex capture
const gen = cacheGeneration immediately before creating and storing the build
promise in searchIndexBuilds, and in the promise .then/.catch handlers verify
that gen === cacheGeneration before calling searchIndexCache.set(...) or
otherwise mutating shared state so results from a prior workspace are discarded.
Ensure all places that create build promises (the function getSearchIndex and
any helpers) use this gen-check before writing to
searchIndexCache/searchIndexBuilds.

In `@apps/desktop/src/main/lib/fs-watcher/fs-watcher.test.ts`:
- Around line 254-279: The test "resets debounce timer on new events" is
timing-sensitive and can flake under CI; update the waits around triggerEvents
to give a larger safety margin: after the first triggerEvents keep the short
wait at ~30ms, then after the second triggerEvents keep ~30ms and assert
batches.length === 0, and then wait a longer period (e.g., ~120ms) to ensure the
100ms debounce window elapses before asserting batches length and contents;
adjust the setTimeout durations in this test (references: fsWatcher.switchTo,
triggerEvents, debounce 100ms) to these safer values so CI timing variance won't
cause premature debounce firing.

In `@apps/desktop/src/main/lib/fs-watcher/fs-watcher.ts`:
- Around line 63-67: The switchTo method currently returns early when
this.active?.workspaceId === workspaceId, which ignores changes to rootPath;
update the early-return check in switchTo to compare both workspaceId and
rootPath (e.g., this.active?.workspaceId === workspaceId &&
this.active?.rootPath === rootPath) so it only no-ops when both match, and
ensure any path comparison normalizes paths before comparing; if rootPath
differs, allow the method to proceed to stop the old watcher and start a new
watcher for the new rootPath (references: switchTo, this.active, workspaceId,
rootPath).
- Around line 142-162: The stopInternal method must guard against unsubscribe
throwing: ensure you capture/unsubscribe in a try/catch/finally (or set
this.active = null before unsubscribe) so that this.active is always cleared and
timers are cleared even if state.subscription.unsubscribe() throws;
specifically, keep the existing flush() and timer clears, then either set
this.active = null immediately (to prevent handleEvents from processing stale
events) and call state.subscription.unsubscribe() inside a try/catch, or call
unsubscribe in a try block and move this.active = null into a finally block so
the watcher cannot remain in an inconsistent active state.
🧹 Nitpick comments (7)
apps/desktop/src/lib/trpc/routers/workspaces/procedures/delete.ts (1)

189-198: Consider extracting the repeated re-attach pattern into a helper.

The fsWatcher.switchTo(...).catch(...) re-attach block is copy-pasted three times with identical logic. A small helper would reduce duplication and ensure consistent error handling if the pattern changes.

♻️ Suggested helper
+/** Re-attach the fs watcher after a failed deletion rollback. */
+function reattachWatcher(workspaceId: string, savedRootPath: string | null): void {
+	if (!savedRootPath) return;
+	fsWatcher
+		.switchTo({ workspaceId, rootPath: savedRootPath })
+		.catch((err) => {
+			console.error("[workspace/delete] Failed to re-attach watcher:", err);
+		});
+}

Then replace each occurrence with reattachWatcher(input.id, savedRootPath);.

Also applies to: 250-259, 277-286

apps/desktop/src/main/index.ts (1)

228-237: Log message is premature — switchTo hasn't resolved yet.

Line 235 logs "Started fs watcher" immediately after the fire-and-forget switchTo, but the watcher isn't actually started until the promise resolves. This could confuse debugging if the watcher fails to start. Minor nit.

♻️ Suggested fix
 		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}`,
-		);
+		console.log(
+			`[main] Starting fs watcher for active workspace ${workspaceId}`,
+		);
apps/desktop/docs/SINGLE_ACTIVE_FS_WATCHER.md (1)

15-36: Add a language identifier to the fenced code block.

The ASCII-art data-flow diagram uses a fenced code block without a language specifier (flagged by markdownlint MD040). Use text or plaintext to silence the lint warning.

-```
+```text
 User action (create / switch / delete / close / open project)
apps/desktop/src/main/lib/fs-watcher/fs-watcher.test.ts (4)

44-46: Fragile constructor extraction via prototype chain.

This pattern tightly couples the test to the fact that fsWatcher is a class instance. If the implementation ever switches to a plain object, factory function, or freezes the prototype, these tests silently break. A cleaner alternative is to export the class directly (even if only for testing) alongside the singleton.

That said, this is a pragmatic workaround for obtaining fresh instances when only a singleton is exported, and it works fine today.


281-306: 2.2-second real-time wait slows the suite and amplifies flakiness.

This test waits over 2 seconds of wall-clock time to validate the max-batch-window flush. In a large test suite this adds up, and the setInterval + real setTimeout combination can behave unpredictably under load. If feasible, consider allowing the max window duration to be configurable (e.g., via constructor options) so this test can use a much shorter window (e.g., 200 ms).


220-225: No test covers the error callback path.

triggerEvents always passes null as the error argument. There is no test verifying how FsWatcher behaves when @parcel/watcher invokes the callback with an error. Consider adding a test that calls lastCall.callback(new Error("watch failed"), []) to verify the error is logged/handled gracefully and doesn't crash the batch pipeline.


63-81: Export IGNORE_DIRS and import it in the test to avoid duplication.

The ignore list in the implementation (fs-watcher.ts, line 12) is defined as a non-exported constant IGNORE_DIRS but duplicated as a hardcoded list in this test expectation. Exporting the constant and importing it here keeps the single source of truth, preventing silent drift if the ignore list evolves.

Comment on lines +56 to +59
fsWatcher.on("switched", () => {
searchIndexCache.clear();
searchIndexBuilds.clear();
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Clearing searchIndexBuilds on switch can orphan in-flight promises.

When "switched" fires, searchIndexBuilds.clear() removes references to in-flight build promises, but those promises continue executing. When they resolve, their .then handler calls searchIndexCache.set(cacheKey, ...) — populating the cache with an index from the previous workspace. This stale entry persists until the next batch or switch event.

Consider a generation counter or check in the .then callback to discard results from a prior workspace.

♻️ One approach: generation counter
+let cacheGeneration = 0;
+
 fsWatcher.on("switched", () => {
+	cacheGeneration++;
 	searchIndexCache.clear();
 	searchIndexBuilds.clear();
 });

Then in getSearchIndex, capture const gen = cacheGeneration before creating the build promise, and in .then:

 .then((index) => {
+	if (cacheGeneration !== gen) {
+		// Workspace switched — discard stale index
+		searchIndexBuilds.delete(cacheKey);
+		return index;
+	}
 	searchIndexCache.set(cacheKey, { index, builtAt: Date.now() });
 	searchIndexBuilds.delete(cacheKey);
 	return index;
 })
🤖 Prompt for AI Agents
In `@apps/desktop/src/lib/trpc/routers/filesystem/search.ts` around lines 56 - 59,
When handling the fsWatcher "switched" event, increment a generation counter
(e.g., cacheGeneration) instead of blindly relying on searchIndexBuilds.clear()
to avoid orphaning in-flight promises; in the fsWatcher.on("switched", ...)
handler increment cacheGeneration and clear searchIndexCache and
searchIndexBuilds as before. In getSearchIndex capture const gen =
cacheGeneration immediately before creating and storing the build promise in
searchIndexBuilds, and in the promise .then/.catch handlers verify that gen ===
cacheGeneration before calling searchIndexCache.set(...) or otherwise mutating
shared state so results from a prior workspace are discarded. Ensure all places
that create build promises (the function getSearchIndex and any helpers) use
this gen-check before writing to searchIndexCache/searchIndexBuilds.

Comment on lines +254 to +279
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);
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Timing-sensitive debounce-reset test may be flaky under CI load.

The assertion at line 273 expects zero batches after ~100 ms total elapsed (50 + 50), relying on the debounce timer (100 ms from last event at ~50 ms) not having fired yet. Under CI load or GC pauses, the actual wall-clock time can overshoot, causing the debounce to fire before the assertion runs.

Consider either:

  • Increasing the margin (e.g., first wait only 30 ms instead of 50 ms), or
  • Using a fake timer / deterministic scheduler if bun:test supports it.
🤖 Prompt for AI Agents
In `@apps/desktop/src/main/lib/fs-watcher/fs-watcher.test.ts` around lines 254 -
279, The test "resets debounce timer on new events" is timing-sensitive and can
flake under CI; update the waits around triggerEvents to give a larger safety
margin: after the first triggerEvents keep the short wait at ~30ms, then after
the second triggerEvents keep ~30ms and assert batches.length === 0, and then
wait a longer period (e.g., ~120ms) to ensure the 100ms debounce window elapses
before asserting batches length and contents; adjust the setTimeout durations in
this test (references: fsWatcher.switchTo, triggerEvents, debounce 100ms) to
these safer values so CI timing variance won't cause premature debounce firing.

Comment on lines +63 to +67
}): Promise<void> {
// No-op if already watching this workspace
if (this.active?.workspaceId === workspaceId) {
return;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

switchTo no-ops when the same workspaceId is passed, even if rootPath changed.

If, for example, a worktree is recreated at a different path (e.g., after a failed init + retry), calling switchTo with the same workspaceId but a new rootPath will be silently ignored. Consider comparing rootPath as well.

♻️ Suggested fix
-		if (this.active?.workspaceId === workspaceId) {
+		if (
+			this.active?.workspaceId === workspaceId &&
+			this.active.rootPath === rootPath
+		) {
 			return;
 		}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
}): Promise<void> {
// No-op if already watching this workspace
if (this.active?.workspaceId === workspaceId) {
return;
}
}): Promise<void> {
// No-op if already watching this workspace
if (
this.active?.workspaceId === workspaceId &&
this.active.rootPath === rootPath
) {
return;
}
🤖 Prompt for AI Agents
In `@apps/desktop/src/main/lib/fs-watcher/fs-watcher.ts` around lines 63 - 67, The
switchTo method currently returns early when this.active?.workspaceId ===
workspaceId, which ignores changes to rootPath; update the early-return check in
switchTo to compare both workspaceId and rootPath (e.g.,
this.active?.workspaceId === workspaceId && this.active?.rootPath === rootPath)
so it only no-ops when both match, and ensure any path comparison normalizes
paths before comparing; if rootPath differs, allow the method to proceed to stop
the old watcher and start a new watcher for the new rootPath (references:
switchTo, this.active, workspaceId, rootPath).

Comment on lines +142 to +162
private async stopInternal(): Promise<void> {
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}`,
);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

stopInternal doesn't guard against unsubscribe failure.

If state.subscription.unsubscribe() throws, this.active is never set to null, leaving the watcher in an inconsistent state where it appears active but isn't subscribed. Consider wrapping the unsubscribe in a try/finally.

🛡️ Proposed fix
 	private async stopInternal(): Promise<void> {
 		const state = this.active;
 		if (!state) return;
 
+		// Null out active first to prevent re-entrant calls
+		this.active = null;
+
 		// 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;
+		try {
+			await state.subscription.unsubscribe();
+		} catch (err) {
+			console.error(
+				`[fs-watcher] Failed to unsubscribe workspace ${state.workspaceId}:`,
+				err,
+			);
+		}
 
 		console.log(
 			`[fs-watcher] Stopped watching workspace ${state.workspaceId}`,
 		);
 	}

Note: Moving this.active = null before unsubscribe() also prevents handleEvents callbacks (which guard on this.active) from processing stale events during teardown.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
private async stopInternal(): Promise<void> {
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 async stopInternal(): Promise<void> {
const state = this.active;
if (!state) return;
// Null out active first to prevent re-entrant calls
this.active = null;
// Flush any pending events before stopping
this.flush();
if (state.debounceTimer) {
clearTimeout(state.debounceTimer);
}
if (state.maxWindowTimer) {
clearTimeout(state.maxWindowTimer);
}
try {
await state.subscription.unsubscribe();
} catch (err) {
console.error(
`[fs-watcher] Failed to unsubscribe workspace ${state.workspaceId}:`,
err,
);
}
console.log(
`[fs-watcher] Stopped watching workspace ${state.workspaceId}`,
);
}
🤖 Prompt for AI Agents
In `@apps/desktop/src/main/lib/fs-watcher/fs-watcher.ts` around lines 142 - 162,
The stopInternal method must guard against unsubscribe throwing: ensure you
capture/unsubscribe in a try/catch/finally (or set this.active = null before
unsubscribe) so that this.active is always cleared and timers are cleared even
if state.subscription.unsubscribe() throws; specifically, keep the existing
flush() and timer clears, then either set this.active = null immediately (to
prevent handleEvents from processing stale events) and call
state.subscription.unsubscribe() inside a try/catch, or call unsubscribe in a
try block and move this.active = null into a finally block so the watcher cannot
remain in an inconsistent active state.

@Kitenite
Copy link
Copy Markdown
Collaborator Author

Kitenite commented May 6, 2026

Closing as stale: created in Jan-Mar with no recent activity. If still relevant, re-open or re-create from a fresh branch. Bulk audit 2026-05-06.

@Kitenite Kitenite closed this May 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant