Skip to content
Source
Sign inSign up

useFileTreeEntries.test.tsx

725 lines27.5 KB

1import {
2 EnvironmentId,
3 ProjectListEntriesError,
4 type ProjectEntry,
5 type ProjectListEntriesResult,
6} from "@t3tools/contracts";
7import * as Cause from "effect/Cause";
8import * as Option from "effect/Option";
9import { AsyncResult } from "effect/reactivity";
10import { act, useLayoutEffect, type ReactNode } from "react";
11import { createRoot } from "react-dom/client";
12import { describe, expect, it, vi } from "vite-plus/test";
13​
14type Query = {
15 readonly environmentId: EnvironmentId;
16 readonly input: {
17 readonly cwd: string;
18 readonly query?: string;
19 readonly directoryPath?: string;
20 };
21};
22type Result =
23 | {
24 readonly _tag: "Success";
25 readonly value: ProjectListEntriesResult;
26 readonly timestamp: number;
27 }
28 | { readonly _tag: "Failure"; readonly cause: Cause.Cause<Error> };
29type QueryState = {
30 success: AsyncResult.Success<ProjectListEntriesResult, Error> | null;
31 data: ProjectListEntriesResult;
32 error: string | null;
33 failure: ProjectListEntriesError | null;
34 isPending: boolean;
35 refresh: () => void;
36};
37​
38const rootEntries: ProjectEntry[] = [{ kind: "directory", path: "src" }];
39const firstTarget = { cwd: "/first", environmentId: EnvironmentId.make("first") };
40​
41const mocks = vi.hoisted(() => {
42 const createState = () => ({
43 requests: [] as {
44 readonly query: Query;
45 readonly signal?: AbortSignal;
46 readonly resolve: (result: Result) => void;
47 }[],
48 responseTimestamp: 0,
49 reconnect: () => {},
50 rootQuery: null as unknown as QueryState,
51 searchQuery: null as unknown as QueryState,
52 results: new WeakMap<
53 object,
54 {
55 data: ProjectListEntriesResult;
56 error: string | null;
57 pending: boolean;
58 result: AsyncResult.AsyncResult<ProjectListEntriesResult, Error>;
59 }
60 >(),
61 });
62 return { createState, state: createState() };
63});
64​
65vi.mock("@t3tools/client-runtime/state/runtime", () => ({
66 executeAtomQuery: (_registry: unknown, query: Query, options: { signal?: AbortSignal }) =>
67 new Promise<Result>((resolve) =>
68 mocks.state.requests.push({ query, signal: options.signal, resolve }),
69 ),
70}));
71vi.mock("../../state/atom-registry", () => ({ appAtomRegistry: { refresh: () => {} } }));
72vi.mock("../../state/projects", () => ({
73 projectEnvironment: {
74 listEntries: (query: Query) => query,
75 searchEntries: (query: Query) => query,
76 },
77}));
78vi.mock("../../connection/catalog", () => ({
79 environmentCatalog: { sessionGenerationAtom: () => ({}) },
80}));
81vi.mock("../../state/queries", () => ({ useDebouncedValue: (value: string) => value }));
82vi.mock("@effect/atom-react", () => ({
83 useAtomValue: (atom: Partial<Query>) => {
84 const state = mocks.state;
85 const query = atom.input?.query === undefined ? state.rootQuery : state.searchQuery;
86 if (query.success) return query.success;
87 const cached = state.results.get(query);
88 if (
89 cached &&
90 cached.data === query.data &&
91 cached.error === query.error &&
92 cached.pending === query.isPending
93 ) {
94 return cached.result;
95 }
96 const result = query.error
97 ? AsyncResult.fail(query.failure ?? new Error(query.error), {
98 previousSuccess: Option.some(AsyncResult.success(query.data)),
99 })
100 : AsyncResult.success(query.data, {
101 waiting: query.isPending,
102 timestamp: ++state.responseTimestamp,
103 });
104 state.results.set(query, {
105 data: query.data,
106 error: query.error,
107 pending: query.isPending,
108 result,
109 });
110 return result;
111 },
112 useAtomRefresh: () => mocks.state.rootQuery.refresh,
113 useAtomMount: () => {},
114 useAtomSubscribe: (_atom: unknown, callback: () => void) => {
115 mocks.state.reconnect = callback;
116 },
117}));
118​
119const { Children, Nothing } = vi.hoisted(() => ({
120 Children: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
121 Nothing: () => null,
122}));
123vi.mock("react-native", () => ({
124 Platform: { OS: "ios" },
125 View: Children,
126 RefreshControl: Nothing,
127 Pressable: ({
128 children,
129 onPress,
130 accessibilityLabel,
131 }: {
132 children?: ReactNode;
133 onPress: () => void;
134 accessibilityLabel?: string;
135 }) => (
136 <button aria-label={accessibilityLabel} onClick={onPress}>
137 {children}
138 </button>
139 ),
140}));
141vi.mock("react-native-safe-area-context", () => ({ useSafeAreaInsets: () => ({ top: 0 }) }));
142vi.mock("../../native/NativeScrollViewMarker", () => ({ NativeScrollView: Nothing }));
143vi.mock("../../native/native-layout-metrics", () => ({ useNativeColumnLayoutMetrics: () => null }));
144vi.mock("../../components/ui/useBarInset", () => ({
145 useContentTopInset: () => 0,
146 useContentBottomInset: () => 0,
147}));
148vi.mock("@legendapp/list/react-native", () => ({
149 LegendList: <T,>(props: {
150 data: ReadonlyArray<T>;
151 keyExtractor: (item: T) => string;
152 renderItem: (info: { item: T }) => ReactNode;
153 ListHeaderComponent?: ReactNode;
154 ListEmptyComponent?: ReactNode;
155 }) => (
156 <div>
157 {props.ListHeaderComponent}
158 {props.data.length === 0
159 ? props.ListEmptyComponent
160 : props.data.map((item) => (
161 <div key={props.keyExtractor(item)}>{props.renderItem({ item })}</div>
162 ))}
163 </div>
164 ),
165}));
166vi.mock("../../components/ui/Button", () => ({
167 Button: ({
168 label,
169 onPress,
170 disabled,
171 }: {
172 label: string;
173 onPress: () => void;
174 disabled?: boolean;
175 }) => (
176 <button aria-label={label} disabled={disabled} onClick={onPress}>
177 {label}
178 </button>
179 ),
180}));
181vi.mock("../../components/ui/AppActivityIndicator", () => ({ AppActivityIndicator: Nothing }));
182vi.mock("../../components/ui/AppSymbol", () => ({ SymbolView: Nothing }));
183vi.mock("../../components/ui/AppText", () => ({ AppText: Children }));
184vi.mock("../../components/PierreEntryIcon", () => ({ PierreEntryIcon: Nothing }));
185vi.mock("../../lib/cn", () => ({ cn: () => "" }));
186​
187import { FileTreeBrowser } from "./FileTreeBrowser";
188import { useFileTreeEntries } from "./useFileTreeEntries";
189​
190type Hook = typeof useFileTreeEntries;
191type Input = Parameters<Hook>[0];
192​
193function setup(withBrowser = false) {
194 (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
195 const state = mocks.createState();
196 mocks.state = state;
197 const rootQuery: QueryState = {
198 success: null,
199 data: { entries: rootEntries, truncated: false },
200 error: null,
201 failure: null,
202 isPending: false,
203 refresh: vi.fn(),
204 };
205 state.rootQuery = rootQuery;
206 const searchQuery: QueryState = { ...rootQuery, data: { entries: [], truncated: false } };
207 state.searchQuery = searchQuery;
208 const { requests } = state;
209 const container = document.body.appendChild(document.createElement("div"));
210 const root = createRoot(container);
211 let current: ReturnType<Hook>;
212 let renders = 0;
213 let controlledSelection: string | undefined;
214 function Probe(input: Input) {
215 if (++renders > 100 && withBrowser) throw new Error("File tree did not settle");
216 const result = useFileTreeEntries(input);
217 useLayoutEffect(() => {
218 current = result;
219 });
220 return withBrowser ? (
221 <FileTreeBrowser
222 key={JSON.stringify([input.environmentId, input.cwd])}
223 {...result}
224 selectedPath={controlledSelection ?? result.selectedPath}
225 searchQuery=""
226 onLoadDirectory={result.loadDirectory}
227 onRefresh={result.refresh}
228 onSelectFile={result.selectPath}
229 />
230 ) : null;
231 }
232 const unmount = () => {
233 act(() => root.unmount());
234 container.remove();
235 };
236 return {
237 requests,
238 rootQuery,
239 searchQuery,
240 get searchSuccess() {
241 const result = searchQuery.success ?? state.results.get(searchQuery)?.result;
242 if (result?._tag !== "Success") throw new Error("Search has not succeeded");
243 return result;
244 },
245 get responseTimestamp() {
246 return state.responseTimestamp;
247 },
248 controlSelection(path: string) {
249 controlledSelection = path;
250 },
251 updateView(expanded: string[], selected: string) {
252 renders = 0;
253 act(() => {
254 current.setExpandedPaths(() => new Set(expanded));
255 current.selectPath(selected);
256 });
257 },
258 unmount,
259 get current() {
260 return current;
261 },
262 get rows() {
263 return [...container.querySelectorAll("button")].map((button) =>
264 button.getAttribute("aria-label"),
265 );
266 },
267 press(label: string) {
268 renders = 0;
269 const button = container.querySelector<HTMLButtonElement>(`button[aria-label="${label}"]`);
270 if (!button) throw new Error(`No row labelled ${label}`);
271 act(() => button.click());
272 },
273 render(target: Partial<Input> = {}) {
274 renders = 0;
275 const probe = <Probe {...firstTarget} searchQuery="" {...target} />;
276 act(() => root.render(probe));
277 },
278 load(path = "src", refresh = false) {
279 renders = 0;
280 act(() => {
281 void current.loadDirectory(path, refresh);
282 });
283 },
284 async succeed(index: number, entries: ProjectEntry[] = []) {
285 renders = 0;
286 await act(async () => {
287 requests[index]!.resolve({
288 _tag: "Success",
289 value: { entries, truncated: false },
290 timestamp: ++state.responseTimestamp,
291 });
292 });
293 },
294 async fail(index: number, message = "Directory unavailable") {
295 renders = 0;
296 await act(async () => {
297 requests[index]!.resolve({ _tag: "Failure", cause: Cause.fail(new Error(message)) });
298 });
299 },
300 [Symbol.dispose]: unmount,
301 };
303​
304describe("file tree workspace state", () => {
305 it("retries failed folders once on reconnect", async () => {
306 using screen = setup();
307 screen.render();
308 screen.load();
309 await screen.fail(0);
310 act(() => mocks.state.reconnect());
311 expect(screen.requests).toHaveLength(2);
312 act(() => mocks.state.reconnect());
313 expect(screen.requests).toHaveLength(2);
314 await screen.succeed(1);
315 act(() => mocks.state.reconnect());
316 expect(screen.requests).toHaveLength(2);
317 });
318​
319 it("publishes immutable loading and loaded snapshots", async () => {
320 using screen = setup();
321 screen.render();
322 const initial = screen.current;
323 screen.load();
324 const loading = screen.current;
325 expect(loading.isPending).toBe(true);
326 expect([...loading.loadingDirectories]).toEqual(["src"]);
327 expect([...initial.loadingDirectories]).toEqual([]);
328 await screen.succeed(0, [{ kind: "file", path: "src/first.ts" }]);
329 expect(screen.current.entries.map((entry) => entry.path)).toEqual(["src", "src/first.ts"]);
330 expect([...screen.current.loadedDirectories]).toEqual(["src"]);
331 expect(screen.current.isPending).toBe(false);
332 expect([...screen.current.loadingDirectories]).toEqual([]);
333 expect(loading.entries.map((entry) => entry.path)).toEqual(["src"]);
334 expect([...loading.loadedDirectories]).toEqual([]);
335 expect([...loading.loadingDirectories]).toEqual(["src"]);
336 });
337​
338 it.each([{ cwd: "/second" }, { environmentId: EnvironmentId.make("second") }])(
339 "isolates directory state and restores it on return from %j",
340 async (target) => {
341 using screen = setup();
342 screen.render();
343 screen.load();
344 await screen.succeed(0, [{ kind: "file", path: "src/first.ts" }]);
345​
346 screen.updateView(["src"], "src/first.ts");
347 const firstView = screen.current;
348 screen.render(target);
349 expect([...screen.current.expandedPaths]).toEqual([]);
350 expect(screen.current.selectedPath).toBeNull();
351 expect(screen.current.entries.map((entry) => entry.path)).toEqual(["src"]);
352 expect([...screen.current.loadedDirectories]).toEqual([]);
353 expect([...screen.current.loadingDirectories]).toEqual([]);
354 screen.load();
355 expect(screen.requests[1]!.query).toEqual({
356 environmentId: target.environmentId ?? firstTarget.environmentId,
357 input: { cwd: target.cwd ?? firstTarget.cwd, directoryPath: "src" },
358 });
359 await screen.succeed(1, [{ kind: "file", path: "src/second.ts" }]);
360​
361 screen.updateView([], "src/second.ts");
362 expect([...firstView.expandedPaths]).toEqual(["src"]);
363 expect(firstView.selectedPath).toBe("src/first.ts");
364 screen.render();
365 expect([...screen.current.expandedPaths]).toEqual(["src"]);
366 expect(screen.current.selectedPath).toBe("src/first.ts");
367 expect(screen.current.entries.map((entry) => entry.path)).toEqual(["src", "src/first.ts"]);
368 expect([...screen.current.loadedDirectories]).toEqual(["src"]);
369 screen.load();
370 expect(screen.requests).toHaveLength(3);
371 await screen.succeed(2, [{ kind: "file", path: "src/first.ts" }]);
372​
373 screen.render(target);
374 expect([...screen.current.expandedPaths]).toEqual([]);
375 expect(screen.current.selectedPath).toBe("src/second.ts");
376 expect(screen.current.entries.map((entry) => entry.path)).toEqual(["src", "src/second.ts"]);
377 expect([...screen.current.loadedDirectories]).toEqual(["src"]);
378 },
379 );
380​
381 it("ignores stale successes and failures after leaving and returning to a workspace", async () => {
382 using screen = setup();
383 screen.render();
384 screen.load();
385 screen.load("src/other");
386 screen.render({ cwd: "/second" });
387 screen.render();
388 screen.load();
389​
390 await screen.succeed(0, [{ kind: "file", path: "src/stale.ts" }]);
391 await screen.fail(1, "Stale error");
392 expect(screen.current.entries.map((entry) => entry.path)).toEqual(["src"]);
393 expect(screen.current.error).toBeNull();
394 expect([...screen.current.loadingDirectories]).toEqual(["src"]);
395 expect(screen.current.isPending).toBe(true);
396​
397 await screen.succeed(2, [{ kind: "file", path: "src/current.ts" }]);
398 expect(screen.current.entries.map((entry) => entry.path)).toEqual(["src", "src/current.ts"]);
399 expect(screen.current.error).toBeNull();
400 expect(screen.current.isPending).toBe(false);
401 expect([...screen.current.loadingDirectories]).toEqual([]);
402 });
403​
404 it("retains recent listings and expansion when leaving a large workspace", async () => {
405 using screen = setup();
406 screen.render();
407 const paths = Array.from({ length: 70 }, (_, index) => `src/directory-${index}`);
408 screen.load();
409 await screen.succeed(
410 0,
411 paths.map((path) => ({ kind: "directory", path })),
412 );
413 for (const path of paths) {
414 screen.load(path);
415 await screen.succeed(screen.requests.length - 1, [{ kind: "file", path: `${path}/file.ts` }]);
416 }
417 screen.updateView(["src", ...paths], `${paths[69]}/file.ts`);
418 screen.render({ cwd: "/other" });
419 screen.render();
420 expect(screen.current.loadedDirectories.size).toBe(64);
421 expect(screen.current.loadedDirectories.has(paths[69]!)).toBe(true);
422 expect(screen.current.expandedPaths.size).toBe(71);
423 expect(screen.current.selectedPath).toBe(`${paths[69]}/file.ts`);
424 for (let index = 1; index <= 8; index++) screen.render({ cwd: `/workspace-${index}` });
425 screen.render();
426 expect(screen.current.loadedDirectories.size).toBe(0);
427 expect(screen.current.expandedPaths.size).toBe(0);
428 expect(screen.current.selectedPath).toBeNull();
429 });
430​
431 it("retries a failed folder only when the user expands it again", async () => {
432 using screen = setup(true);
433 screen.render();
434 expect(screen.rows).toEqual(["src"]);
435 expect(screen.requests).toHaveLength(0);
436​
437 screen.press("src");
438 expect(screen.requests).toHaveLength(1);
439 await screen.fail(0);
440 expect(screen.current.error).toBe("Directory unavailable");
441 // Re-rendering and the browser's passive expansion effect never retry a failed listing.
442 screen.render();
443 screen.render();
444 expect(screen.requests).toHaveLength(1);
445 expect(screen.rows).toEqual(["src"]);
446​
447 screen.press("src");
448 expect([...screen.current.expandedPaths]).toEqual([]);
449 expect(screen.requests).toHaveLength(1);
450 screen.press("src");
451 expect([...screen.current.expandedPaths]).toEqual(["src"]);
452 expect(screen.requests).toHaveLength(2);
453 await screen.succeed(1, [{ kind: "file", path: "src/file.ts" }]);
454 expect(screen.current.error).toBeNull();
455 expect(screen.rows).toEqual(["src", "src/file.ts"]);
456​
457 // A loaded folder serves from cache on collapse and expand.
458 screen.press("src");
459 screen.press("src");
460 expect(screen.rows).toEqual(["src", "src/file.ts"]);
461 expect(screen.requests).toHaveLength(2);
462 });
463​
464 it("reveals a controlled leaf-only search result after search clears", () => {
465 using screen = setup(true);
466 screen.render();
467 screen.searchQuery.data = {
468 entries: [{ kind: "file", path: "src/new/file.ts" }],
469 truncated: false,
470 };
471 screen.controlSelection("src/new/file.ts");
472 screen.render({ searchQuery: "file" });
473 expect([...screen.current.expandedPaths]).toEqual(["src", "src/new"]);
474 screen.render();
475 expect(screen.current.entries.map((entry) => entry.path)).toContain("src/new/file.ts");
476 });
477​
478 it.each(["collapse", "delete"])("settles controlled selection after %s", async (action) => {
479 using screen = setup(true);
480 screen.render();
481 screen.updateView([], "src/nested/file.ts");
482 await screen.succeed(0, [{ kind: "directory", path: "src/nested" }]);
483 expect([...screen.current.expandedPaths]).toEqual(["src", "src/nested"]);
484 if (action === "collapse") {
485 screen.updateView([], "src/nested/file.ts");
486 expect([...screen.current.expandedPaths]).toEqual([]);
487 screen.render({ cwd: "/second" });
488 screen.render();
489 expect([...screen.current.expandedPaths]).toEqual([]);
490 }
491 screen.rootQuery.data = { entries: [], truncated: false };
492 screen.render();
493 expect([...screen.current.expandedPaths]).toEqual([]);
494 expect(screen.current.selectedPath).toBeNull();
495 if (action === "delete") {
496 screen.controlSelection("src/nested/file.ts");
497 screen.searchQuery.data = {
498 entries: [
499 { kind: "directory", path: "src" },
500 { kind: "directory", path: "src/nested" },
501 { kind: "file", path: "src/nested/file.ts" },
502 ],
503 truncated: false,
504 };
505 screen.rootQuery.failure = new ProjectListEntriesError({
506 cwd: "/first",
507 failure: "workspace_root_not_found",
508 });
509 screen.rootQuery.error = screen.rootQuery.failure.message;
510 const requestCount = screen.requests.length;
511 for (const error of [null, "Search unavailable"]) {
512 screen.searchQuery.error = error;
513 screen.render({ searchQuery: "nested" });
514 expect(screen.current.entries).toEqual([]);
515 expect([...screen.current.expandedPaths]).toEqual([]);
516 expect([...screen.current.loadedDirectories]).toEqual([]);
517 expect(screen.current.isPending).toBe(false);
518 expect(screen.requests).toHaveLength(requestCount);
519 }
520 }
521 });
522​
523 it.each(["return", "missing root"])(
524 "revalidates retained children after %s without clearing for transient errors",
525 async (scenario) => {
526 using screen = setup();
527 screen.render();
528 screen.load();
529 await screen.succeed(0, [{ kind: "file", path: "src/old.ts" }]);
530 screen.updateView(["src"], "src/old.ts");
531 screen.render({ cwd: "/second" });
532 screen.render();
533 screen.rootQuery.error = "Connection unavailable";
534 screen.render();
535 expect(screen.current.selectedPath).toBe("src/old.ts");
536 expect(screen.current.entries.map((entry) => entry.path)).toEqual(["src", "src/old.ts"]);
537 if (scenario === "missing root") {
538 screen.rootQuery.failure = new ProjectListEntriesError({
539 cwd: "/first",
540 failure: "workspace_root_not_found",
541 });
542 screen.rootQuery.error = screen.rootQuery.failure.message;
543 expect(screen.rootQuery.error).toBe("Failed to list workspace entries in '/first'.");
544 screen.render();
545 expect(screen.current.selectedPath).toBeNull();
546 expect([...screen.current.expandedPaths]).toEqual([]);
547 expect([...screen.current.loadedDirectories]).toEqual([]);
548 }
549 screen.rootQuery.error = null;
550 screen.rootQuery.failure = null;
551 screen.render();
552 if (scenario === "missing root") {
553 expect(screen.current.entries.map((entry) => entry.path)).toEqual(["src"]);
554 expect(screen.current.selectedPath).toBeNull();
555 }
556 screen.load();
557 expect(screen.requests).toHaveLength(2);
558 await screen.succeed(1, []);
559 expect(screen.current.entries.map((entry) => entry.path)).toEqual(["src"]);
560 expect(screen.current.selectedPath).toBeNull();
561 },
562 );
563​
564 it.each(["new", "src/new"])(
565 "keeps search discovery %s until its parent listing revalidates it",
566 async (path) => {
567 using screen = setup();
568 screen.render();
569 screen.load();
570 await screen.succeed(0, []);
571 screen.searchQuery.data = {
572 entries: [{ kind: "file", path: `${path}/file.ts` }],
573 truncated: false,
574 };
575 screen.render({ searchQuery: "new" });
576 const expanded = path.includes("/") ? ["src", path] : [path];
577 screen.updateView(expanded, `${path}/file.ts`);
578 expect(screen.current.selectedPath).toBe(`${path}/file.ts`);
579 expect([...screen.current.expandedPaths]).toEqual(expanded);
580 screen.load(path);
581 await screen.succeed(1, [
582 { kind: "file", path: `${path}/file.ts` },
583 { kind: "file", path: `${path}/other.ts` },
584 ]);
585 expect([...screen.current.loadedDirectories]).toEqual(["src", path]);
586​
587 const cachedSearch = screen.searchSuccess;
588 for (const searchQuery of ["", "other"]) {
589 screen.searchQuery.data = { entries: [], truncated: false };
590 screen.render({ searchQuery });
591 expect(screen.current.selectedPath).toBe(`${path}/file.ts`);
592 expect([...screen.current.expandedPaths]).toEqual(expanded);
593 expect([...screen.current.loadedDirectories]).toEqual(["src", path]);
594 expect(screen.current.entries).toContainEqual({ kind: "file", path: `${path}/file.ts` });
595 expect(screen.current.entries).toContainEqual({ kind: "file", path: `${path}/other.ts` });
596 }
597​
598 screen.render();
599 for (const target of [{ cwd: "/second" }, { environmentId: EnvironmentId.make("second") }]) {
600 screen.render(target);
601 screen.render();
602 expect(screen.current.selectedPath).toBe(`${path}/file.ts`);
603 expect([...screen.current.expandedPaths]).toEqual(expanded);
604 expect([...screen.current.loadedDirectories]).toEqual(["src", path]);
605 expect(screen.current.entries).toContainEqual({ kind: "file", path: `${path}/other.ts` });
606 }
607​
608 screen.searchQuery.data = {
609 entries: [{ kind: "file", path: `${path}/file.ts` }],
610 truncated: false,
611 };
612 screen.render({ searchQuery: "new" });
613 for (const equalContents of [false, true]) {
614 // A later parent listing wins over retained discovery evidence.
615 if (path.includes("/")) {
616 // Refresh re-lists the root and every reachable folder; the empty parent prunes the child.
617 const refreshed = screen.requests.length;
618 act(() => {
619 void screen.current.refresh();
620 });
621 expect(screen.requests.slice(refreshed).map((request) => request.query.input)).toEqual([
622 { cwd: "/first", directoryPath: "" },
623 { cwd: "/first", query: "new", limit: 200 },
624 { cwd: "/first", directoryPath: "src" },
625 { cwd: "/first", directoryPath: path },
626 ]);
627 // The parent settles last so its listing carries the newest response timestamp.
628 for (const index of [refreshed, refreshed + 1, refreshed + 3, refreshed + 2]) {
629 await screen.succeed(index, []);
630 }
631 } else {
632 const cachedData = screen.rootQuery.data;
633 screen.rootQuery.isPending = true;
634 screen.render({ searchQuery: "new" });
635 expect(screen.current.selectedPath).toBe(`${path}/file.ts`);
636 screen.rootQuery.isPending = false;
637 expect(screen.rootQuery.data).toBe(cachedData);
638 screen.render({ searchQuery: "new" });
639 }
640 expect(screen.current.selectedPath).toBeNull();
641 expect([...screen.current.expandedPaths]).toEqual(path.includes("/") ? ["src"] : []);
642 expect([...screen.current.loadedDirectories]).toEqual(["src"]);
643​
644 // Cover an older cache and the timestamp's ambiguous same-millisecond boundary.
645 for (const success of [
646 cachedSearch,
647 AsyncResult.success(cachedSearch.value, { timestamp: screen.responseTimestamp }),
648 ]) {
649 screen.searchQuery.data = { entries: [], truncated: false };
650 screen.render({ searchQuery: "other" });
651 screen.searchQuery.success = success;
652 screen.searchQuery.data = success.value;
653 screen.render({ searchQuery: "new" });
654 screen.updateView(expanded, `${path}/file.ts`);
655 const requestCount = screen.requests.length;
656 screen.load(path);
657 expect(screen.requests).toHaveLength(requestCount + 1);
658 await screen.fail(requestCount);
659 screen.searchQuery.success = null;
660 screen.render();
661 expect(screen.current.selectedPath).toBeNull();
662 expect([...screen.current.expandedPaths]).toEqual(path.includes("/") ? ["src"] : []);
663 expect([...screen.current.loadedDirectories]).toEqual(["src"]);
664 expect(
665 screen.current.entries.some(
666 (entry) => entry.path === path || entry.path.startsWith(`${path}/`),
667 ),
668 ).toBe(false);
669 }
670​
671 const cachedSearchData = screen.searchQuery.data;
672 screen.searchQuery.isPending = true;
673 screen.render({ searchQuery: "new" });
674 expect(screen.current.selectedPath).toBeNull();
675 expect([...screen.current.loadedDirectories]).toEqual(["src"]);
676 screen.searchQuery.isPending = false;
677 if (!equalContents) {
678 screen.searchQuery.data = {
679 ...cachedSearchData,
680 entries: [...cachedSearchData.entries, { kind: "file", path: `${path}/added.ts` }],
681 };
682 }
683 expect(screen.searchQuery.data === cachedSearchData).toBe(equalContents);
684 screen.render({ searchQuery: "new" });
685 screen.updateView(expanded, `${path}/file.ts`);
686 screen.load(path);
687 await screen.succeed(screen.requests.length - 1, [
688 { kind: "file", path: `${path}/file.ts` },
689 { kind: "file", path: `${path}/recreated.ts` },
690 ]);
691 expect(screen.current.selectedPath).toBe(`${path}/file.ts`);
692 expect([...screen.current.expandedPaths]).toEqual(expanded);
693 expect([...screen.current.loadedDirectories]).toEqual(["src", path]);
694 expect(screen.current.entries).toContainEqual({ kind: "file", path: `${path}/file.ts` });
695 expect(screen.current.entries).toContainEqual({
696 kind: "file",
697 path: `${path}/recreated.ts`,
698 });
699 }
700 },
701 );
702​
703 it("prunes deleted directory contents, expansion and selection after a fresh parent listing", async () => {
704 using screen = setup();
705 screen.render();
706 screen.load();
707 await screen.succeed(0, [{ kind: "directory", path: "src/nested" }]);
708 screen.load("src/nested");
709 await screen.succeed(1, [{ kind: "file", path: "src/nested/file.ts" }]);
710 screen.updateView(["src", "src/nested"], "src/nested/file.ts");
711 screen.rootQuery.data = { entries: [], truncated: true };
712 screen.render();
713 expect([...screen.current.loadedDirectories]).toEqual(["src", "src/nested"]);
714 expect(screen.current.selectedPath).toBe("src/nested/file.ts");
715 screen.rootQuery.data = { entries: [], truncated: false };
716 screen.render();
717 expect(screen.current.entries).toEqual([]);
718 expect([...screen.current.loadedDirectories]).toEqual([]);
719 expect([...screen.current.expandedPaths]).toEqual([]);
720 expect(screen.current.selectedPath).toBeNull();
721 screen.rootQuery.data = { entries: rootEntries, truncated: false };
722 screen.render();
723 expect(screen.current.entries.map((entry) => entry.path)).toEqual(["src"]);
724 });
725});