Files
kandev/apps/web/components/task/file-browser-toggle-expand.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

130 lines
4.1 KiB
TypeScript

import { describe, it, expect, vi } from "vitest";
import { toggleFolderExpand } from "./file-browser-hooks";
import type { FileTreeNode } from "@/lib/types/backend";
import type { useFileBrowserTree } from "./file-browser-hooks";
type TreeState = ReturnType<typeof useFileBrowserTree>;
function makeTreeState(initialExpanded: Set<string> = new Set()): {
state: TreeState;
expanded: { current: Set<string> };
} {
const expanded = { current: initialExpanded };
const setExpandedPaths = vi.fn((updater: unknown) => {
if (typeof updater === "function") {
expanded.current = (updater as (p: Set<string>) => Set<string>)(expanded.current);
} else {
expanded.current = updater as Set<string>;
}
});
const state = {
tree: null,
setTree: vi.fn(),
get expandedPaths() {
return expanded.current as ReadonlySet<string>;
},
setExpandedPaths,
visibleRows: [],
visibleLoadingPaths: new Set<string>(),
isLoadingTree: false,
loadState: "loaded",
loadError: null,
loadTree: vi.fn(),
showLoading: vi.fn(),
hideLoading: vi.fn(),
isLoading: vi.fn(() => false),
collapseAll: vi.fn(),
} as unknown as TreeState;
return { state, expanded };
}
const FOLDER: FileTreeNode = { name: "src", path: "src", is_dir: true, size: 0 };
const FILE: FileTreeNode = { name: "a.ts", path: "src/a.ts", is_dir: false, size: 0 };
describe("toggleFolderExpand", () => {
it("expands a collapsed folder synchronously, before the async load resolves", async () => {
const { state, expanded } = makeTreeState();
let resolveLoad: () => void = () => {};
const loadChildren = vi.fn(
() =>
new Promise<void>((resolve) => {
resolveLoad = resolve;
}),
);
const setActiveFolderPath = vi.fn();
const promise = toggleFolderExpand({
node: FOLDER,
sessionId: "session-1",
treeState: state,
setActiveFolderPath,
loadChildren,
});
// The expanded set must already include the folder even though loadChildren
// has not resolved yet — that is the regression we are guarding against.
expect(expanded.current.has("src")).toBe(true);
expect(setActiveFolderPath).toHaveBeenCalledWith("src");
expect(loadChildren).toHaveBeenCalledTimes(1);
resolveLoad();
await promise;
expect(expanded.current.has("src")).toBe(true);
});
it("collapses an already-expanded folder and skips the async load", async () => {
const { state, expanded } = makeTreeState(new Set(["src"]));
const loadChildren = vi.fn(() => Promise.resolve());
await toggleFolderExpand({
node: FOLDER,
sessionId: "session-1",
treeState: state,
setActiveFolderPath: vi.fn(),
loadChildren,
});
expect(expanded.current.has("src")).toBe(false);
expect(loadChildren).not.toHaveBeenCalled();
});
it("ignores file nodes", async () => {
const { state, expanded } = makeTreeState();
const loadChildren = vi.fn(() => Promise.resolve());
const setActiveFolderPath = vi.fn();
await toggleFolderExpand({
node: FILE,
sessionId: "session-1",
treeState: state,
setActiveFolderPath,
loadChildren,
});
expect(expanded.current.size).toBe(0);
expect(setActiveFolderPath).not.toHaveBeenCalled();
expect(loadChildren).not.toHaveBeenCalled();
});
it("uses functional setState so the latest expanded set is mutated", async () => {
const { state, expanded } = makeTreeState();
const loadChildren = vi.fn(() => Promise.resolve());
// Simulate another path being added while the toggle is in flight.
const promise = toggleFolderExpand({
node: FOLDER,
sessionId: "session-1",
treeState: state,
setActiveFolderPath: vi.fn(),
loadChildren,
});
// The toggle ran setExpandedPaths via a functional setter — adding another
// path concurrently must not be lost.
state.setExpandedPaths((prev) => new Set(prev).add("docs"));
await promise;
expect(expanded.current.has("src")).toBe(true);
expect(expanded.current.has("docs")).toBe(true);
});
});