Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ jobs:
- name: Run tests
run: bun test

- name: Run file browser DOM tests
run: DOM_TESTS=1 bun test packages/ui/hooks/useFileBrowser.test.tsx

pi-extension-ai-runtime-windows:
# Exercises the Pi extension's Node/jiti server mirror on Windows with an
# npm-style `pi` shim pair. This catches regressions where `where pi`
Expand Down
99 changes: 99 additions & 0 deletions packages/ui/hooks/useFileBrowser.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,22 @@ function installFetchResponses(responses: Response[]): string[] {
return calls;
}

function installDeferredFetch(): {
calls: string[];
resolve: (response: Response) => void;
} {
const calls: string[] = [];
let resolve: (response: Response) => void = () => {};
const pending = new Promise<Response>((next) => {
resolve = next;
});
globalThis.fetch = (async (input: RequestInfo | URL) => {
calls.push(String(input));
return pending;
}) as unknown as typeof fetch;
return { calls, resolve };
}

function installMockEventSource(): void {
MockEventSource.instances = [];
globalThis.EventSource = MockEventSource as unknown as typeof EventSource;
Expand Down Expand Up @@ -146,6 +162,57 @@ describe("useFileBrowser", () => {
await session.unmount();
});

test.skipIf(!hasDom)("waits for all initial folder snapshots before opening the live watcher", async () => {
installMockEventSource();
const firstDir = "/tmp/plannotator-docs-a";
const secondDir = "/tmp/plannotator-docs-b";
const first = deferred<Response>();
const second = deferred<Response>();
const pending = [first.promise, second.promise];
const calls: string[] = [];
globalThis.fetch = (async (input: RequestInfo | URL) => {
calls.push(String(input));
return pending.shift() ?? response({ error: "unexpected fetch" }, 500);
}) as unknown as typeof fetch;

const session = await mountHook();
await act(async () => {
session.result.current!.fetchAll([firstDir, secondDir]);
});
await tick(0);

expect(calls).toHaveLength(2);
expect(MockEventSource.instances).toHaveLength(0);

await act(async () => {
first.resolve(response({ tree: [{ type: "file", name: "a.md", path: "a.md" }] }));
await Promise.resolve();
});
await tick(0);

expect(session.result.current!.dirs.find((dir) => dir.path === firstDir)).toMatchObject({
isLoading: false,
hasLoadedTree: true,
});
expect(session.result.current!.dirs.find((dir) => dir.path === secondDir)).toMatchObject({
isLoading: true,
hasLoadedTree: false,
});
expect(MockEventSource.instances).toHaveLength(0);

await act(async () => {
second.resolve(response({ tree: [{ type: "file", name: "b.md", path: "b.md" }] }));
await Promise.resolve();
});
await tick(0);

expect(MockEventSource.instances).toHaveLength(1);
expect(MockEventSource.instances[0]?.url).toContain(encodeURIComponent(firstDir));
expect(MockEventSource.instances[0]?.url).toContain(encodeURIComponent(secondDir));

await session.unmount();
});

test.skipIf(!hasDom)("quiet invalid-directory refresh clears stale files", async () => {
const dirPath = "/tmp/plannotator-docs";
const tree: VaultNode[] = [{ type: "file", name: "a.md", path: "a.md" }];
Expand Down Expand Up @@ -233,4 +300,36 @@ describe("useFileBrowser", () => {

await session.unmount();
});

test.skipIf(!hasDom)("waits for the first tree snapshot before opening the live stream", async () => {
installMockEventSource();
const dirPath = "/tmp/plannotator-docs";
const tree: VaultNode[] = [{ type: "file", name: "a.md", path: "a.md" }];
const deferred = installDeferredFetch();

const session = await mountHook();
await act(async () => {
session.result.current!.fetchTree(dirPath);
await Promise.resolve();
});

expect(deferred.calls).toHaveLength(1);
expect(session.result.current!.dirs[0]).toMatchObject({
path: dirPath,
isLoading: true,
});
expect(MockEventSource.instances).toHaveLength(0);

deferred.resolve(response({ tree }));
await tick(0);

expect(session.result.current!.dirs[0]).toMatchObject({
path: dirPath,
isLoading: false,
hasLoadedTree: true,
});
expect(MockEventSource.instances).toHaveLength(1);

await session.unmount();
});
});
27 changes: 19 additions & 8 deletions packages/ui/hooks/useFileBrowser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ export interface DirState {
isLoading: boolean;
error: string | null;
workspaceStatus?: WorkspaceStatusPayload;
/** True after the first successful snapshot; live watching waits for this so watcher setup cannot block initial load. */
hasLoadedTree?: boolean;
/** When true, fetches via /api/reference/obsidian/files and opens docs via /api/reference/obsidian/doc */
isVault?: boolean;
}
Expand Down Expand Up @@ -107,7 +109,7 @@ export function useFileBrowser(): UseFileBrowserReturn {
: d
);
}
return [...prev, { path: dirPath, name, tree: [], isLoading: true, error: null }];
return [...prev, { path: dirPath, name, tree: [], isLoading: true, error: null, hasLoadedTree: false }];
});

try {
Expand All @@ -128,6 +130,7 @@ export function useFileBrowser(): UseFileBrowserReturn {
tree: options.quiet ? [] : d.tree,
workspaceStatus: options.quiet ? undefined : d.workspaceStatus,
isLoading: false,
hasLoadedTree: false,
error,
}
: { ...d, isLoading: false, error: d.error }
Expand All @@ -146,6 +149,7 @@ export function useFileBrowser(): UseFileBrowserReturn {
tree: data.tree,
workspaceStatus,
isLoading: false,
hasLoadedTree: true,
error: null,
}
: d
Expand Down Expand Up @@ -193,6 +197,7 @@ export function useFileBrowser(): UseFileBrowserReturn {
// that the visible tree sits on "Loading..." for seconds.
isLoading: true,
error: null,
hasLoadedTree: false,
}));
return [...regularDirs, ...vaultDirs];
});
Expand Down Expand Up @@ -265,13 +270,19 @@ export function useFileBrowser(): UseFileBrowserReturn {
}, []);

const watchDirsKey = useMemo(
() => dirs
// Subscribe only after the initial snapshot is visible. Live updates are
// for future freshness; they must not compete with first paint.
.filter((dir) => !dir.isVault && !dir.error && !dir.isLoading)
.map((dir) => dir.path)
.sort()
.join("\n"),
() => {
const regularDirs = dirs.filter((dir) => !dir.isVault);
const initialLoadPending = regularDirs.some((dir) => dir.isLoading && !dir.hasLoadedTree);
if (initialLoadPending) return "";

return regularDirs
// Subscribe only after the initial snapshot is visible. Live updates are
// for future freshness; they must not compete with first paint.
.filter((dir) => !dir.error && dir.hasLoadedTree)
.map((dir) => dir.path)
.sort()
.join("\n");
},
[dirs]
);

Expand Down