From b0186e1c7027490f30c4e465a046d492100c4a6e Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Sun, 23 Aug 2026 12:07:31 -0700 Subject: [PATCH] fix(desktop): stop a folder rename from throwing out of the Files tab fs handler The rename branch of the Files tab's fs-event handler looked up the renamed folder's handle with an unguarded `model.getItem`. Pierre's `getItem` is typed to return null for a path it doesn't hold, but it throws when the lookup walks through a node with no child index, so the throw escaped the workspace event socket callback and aborted the rest of that watcher batch. Contain the lookup: a path the model can't resolve now yields no handle, which is the same answer as "not expanded". Refs DESKTOP-11E --- .../useFilesTabBridge/useFilesTabBridge.ts | 6 ++-- .../FilesTab/utils/treePath/index.ts | 1 + .../FilesTab/utils/treePath/treePath.test.ts | 32 ++++++++++++++++++- .../FilesTab/utils/treePath/treePath.ts | 23 +++++++++++++ 4 files changed, 59 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/hooks/useFilesTabBridge/useFilesTabBridge.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/hooks/useFilesTabBridge/useFilesTabBridge.ts index c98cd40026d..e664f15fc92 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/hooks/useFilesTabBridge/useFilesTabBridge.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/hooks/useFilesTabBridge/useFilesTabBridge.ts @@ -10,6 +10,7 @@ import type { TreeBookkeeping } from "../../utils/treeBookkeeping"; import { purgeDirectory, rekeyDirectory } from "../../utils/treeBookkeeping"; import { asDirectoryHandle, + lookupDirectory, resolveDeleteTreePath, stripTrailingSlash, toAbs, @@ -484,8 +485,9 @@ export function useFilesTabBridge({ if (!loadedDirsRef.current.has(newDir)) { unloadedDirCandidatesRef.current.add(newDir); } - const handle = asDirectoryHandle(model.getItem(newKey)); - if (handle?.isExpanded()) void fetchDir(newDir); + if (lookupDirectory(model, newKey)?.isExpanded()) { + void fetchDir(newDir); + } } return; } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/utils/treePath/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/utils/treePath/index.ts index 68e783fa028..93d05d12377 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/utils/treePath/index.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/utils/treePath/index.ts @@ -1,6 +1,7 @@ export { asDirectoryHandle, basename, + lookupDirectory, parentRel, resolveDeleteTreePath, stripTrailingSlash, diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/utils/treePath/treePath.test.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/utils/treePath/treePath.test.ts index eb10bcea6f1..ed373e59b1b 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/utils/treePath/treePath.test.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/utils/treePath/treePath.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "bun:test"; -import { resolveDeleteTreePath } from "./treePath"; +import { FileTree } from "@pierre/trees"; +import { lookupDirectory, resolveDeleteTreePath } from "./treePath"; describe("resolveDeleteTreePath", () => { it("infers a tracked directory when watcher metadata is absent", () => { @@ -33,3 +34,32 @@ describe("resolveDeleteTreePath", () => { }); }); }); + +describe("lookupDirectory", () => { + // The watcher stats a path to decide `isDirectory` and reports false when + // the stat loses a race with the rename that produced the event, so a + // directory can enter the tree as a file. Every later lookup beneath it + // then walks into a node that has no child index. + const treeHoldingBuildAsAFile = () => { + const model = new FileTree({ paths: ["src/out/keep.ts"] }); + model.add("build"); + return model; + }; + + it("yields no handle for a path the model cannot resolve", () => { + const model = treeHoldingBuildAsAFile(); + expect(() => model.getItem("build/out/")).toThrow(); + expect(lookupDirectory(model, "build/out/")).toBeNull(); + }); + + it("still reports an expanded directory the model does hold", () => { + const model = treeHoldingBuildAsAFile(); + lookupDirectory(model, "src/out/")?.expand(); + expect(lookupDirectory(model, "src/out/")?.isExpanded()).toBe(true); + }); + + it("yields no handle for a file", () => { + const model = treeHoldingBuildAsAFile(); + expect(lookupDirectory(model, "src/out/keep.ts")).toBeNull(); + }); +}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/utils/treePath/treePath.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/utils/treePath/treePath.ts index d460b0c9f38..d13e7c98ea7 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/utils/treePath/treePath.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/utils/treePath/treePath.ts @@ -1,4 +1,5 @@ import type { + FileTree, FileTreeDirectoryHandle, FileTreeItemHandle, } from "@pierre/trees"; @@ -62,3 +63,25 @@ export function asDirectoryHandle( ): FileTreeDirectoryHandle | null { return handle?.isDirectory() ? (handle as FileTreeDirectoryHandle) : null; } + +/** + * The directory handle for `treePath`, or null when Pierre can't produce one. + * + * `getItem` is typed to return null for a path Pierre doesn't hold, but it + * throws instead when the lookup walks through a node with no child index — + * a path whose ancestor Pierre holds as a file. That happens when the watcher + * loses a stat race and reports a directory with `isDirectory: false`, so the + * fs-event handler adds it as a file and every later lookup beneath it throws. + * Callers use the handle only to ask whether a directory is already expanded, + * and not being able to ask is the same answer as "not expanded". + */ +export function lookupDirectory( + model: Pick, + treePath: string, +): FileTreeDirectoryHandle | null { + try { + return asDirectoryHandle(model.getItem(treePath)); + } catch { + return null; + } +}