Files
kandev/apps/web/components/task/file-browser-load-children.test.ts
T
João Salavisa 33a378a8dc fix(web): expand folder optimistically on first click in Files panel (#984)
* fix(web): expand folder optimistically on first click in Files panel

The first click on a folder in the Files panel previously deferred the
expanded-state update until after loadNodeChildren resolved, so the
chevron and children only appeared once the WebSocket round-trip
finished. Users perceived the click as a no-op and had to click again.

Flip the expanded set synchronously, then load children in the
background. Extracts the toggle logic into a testable helper and locks
in the regression with unit tests.

Closes #983

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* docs(web): collapse toggleFolderExpand docstring to a single line

Per the CLAUDE.md "no multi-line comment blocks" rule flagged by review.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(web): dedupe in-flight loadNodeChildren and collapse comment to one line

- Add `activeLoadsRef` + `isLoading(path)` to `useLoadingTimers`, and guard
  `loadNodeChildren` against duplicate fetches for the same path. Rapid
  double-clicks on a freshly collapsed folder no longer issue two
  identical WebSocket round-trips (Greptile P2).
- Collapse the `toggleFolderExpand` comment to a single line per
  CLAUDE.md (Claude review).
- Add `file-browser-load-children.test.ts` covering the dedupe contract,
  the post-finish re-fetch path, and the children-already-loaded
  short-circuit.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 14:01:31 +01:00

109 lines
3.8 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
import type { FileTreeNode } from "@/lib/types/backend";
import type {
useFileBrowserTree,
loadNodeChildren as LoadNodeChildren,
} from "./file-browser-hooks";
type TreeState = ReturnType<typeof useFileBrowserTree>;
const requestFileTreeMock = vi.fn();
const getWebSocketClientMock = vi.fn(() => ({}) as unknown);
vi.mock("@/lib/ws/connection", () => ({
getWebSocketClient: () => getWebSocketClientMock(),
}));
vi.mock("@/lib/ws/workspace-files", () => ({
requestFileTree: (...args: unknown[]) => requestFileTreeMock(...args),
requestFileContent: vi.fn(),
searchWorkspaceFiles: vi.fn(),
}));
let loadNodeChildren: typeof LoadNodeChildren;
beforeEach(async () => {
vi.resetModules();
requestFileTreeMock.mockReset();
getWebSocketClientMock.mockReset().mockReturnValue({});
({ loadNodeChildren } = await import("./file-browser-hooks"));
});
function makeTreeState(opts: { tree?: FileTreeNode | null; loadingPaths?: Set<string> } = {}): {
state: TreeState;
loadingPaths: Set<string>;
} {
const loadingPaths = opts.loadingPaths ?? new Set<string>();
const state = {
tree: opts.tree ?? { name: "", path: "", is_dir: true, size: 0, children: [] },
setTree: vi.fn(),
expandedPaths: new Set<string>(),
setExpandedPaths: vi.fn(),
visibleRows: [],
visibleLoadingPaths: new Set<string>(),
isLoadingTree: false,
loadState: "loaded",
loadError: null,
loadTree: vi.fn(),
showLoading: vi.fn((p: string) => loadingPaths.add(p)),
hideLoading: vi.fn((p: string) => loadingPaths.delete(p)),
isLoading: vi.fn((p: string) => loadingPaths.has(p)),
collapseAll: vi.fn(),
} as unknown as TreeState;
return { state, loadingPaths };
}
const FOLDER: FileTreeNode = { name: "src", path: "src", is_dir: true, size: 0, children: [] };
describe("loadNodeChildren — in-flight dedupe", () => {
it("skips duplicate fetches while a load is in flight for the same path", async () => {
const { state } = makeTreeState();
let resolveFetch: (v: { root: FileTreeNode }) => void = () => {};
requestFileTreeMock.mockImplementation(
() =>
new Promise((resolve) => {
resolveFetch = resolve;
}),
);
const p1 = loadNodeChildren(FOLDER, "session-1", state);
const p2 = loadNodeChildren(FOLDER, "session-1", state);
const p3 = loadNodeChildren(FOLDER, "session-1", state);
// Only the first call should reach the WS request — the dedupe guard
// catches the rest before they fire.
expect(requestFileTreeMock).toHaveBeenCalledTimes(1);
expect(state.showLoading).toHaveBeenCalledTimes(1);
resolveFetch({ root: { name: "src", path: "src", is_dir: true, size: 0, children: [] } });
await Promise.all([p1, p2, p3]);
expect(state.hideLoading).toHaveBeenCalledTimes(1);
});
it("allows a fresh fetch once the previous one has finished", async () => {
const { state } = makeTreeState();
requestFileTreeMock.mockResolvedValue({
root: { name: "src", path: "src", is_dir: true, size: 0, children: [] },
});
await loadNodeChildren(FOLDER, "session-1", state);
await loadNodeChildren(FOLDER, "session-1", state);
// First call clears showLoading/hideLoading state, so the second one
// (still operating on a node with no children) issues its own fetch.
expect(requestFileTreeMock).toHaveBeenCalledTimes(2);
});
it("short-circuits when the node already has children", async () => {
const { state } = makeTreeState();
const loaded: FileTreeNode = {
...FOLDER,
children: [{ name: "a.ts", path: "src/a.ts", is_dir: false, size: 0 }],
};
await loadNodeChildren(loaded, "session-1", state);
expect(requestFileTreeMock).not.toHaveBeenCalled();
expect(state.showLoading).not.toHaveBeenCalled();
});
});