Files
paseo/packages/app/src/file-explorer/tree.test.ts
T
paseo-bot[bot]andhadan8977 e06adf7a3e Show new files in collapsed folders after Refresh (#8582)
* fix(app): show current contents of collapsed folders after Files refresh

Refresh only re-listed expanded folders, and expanding a folder skips the
request when a listing is cached, so a folder collapsed during Refresh
kept showing its old contents. Drop the cached listings Refresh does not
re-list. The refresh helper moves to file-explorer/tree.ts so it can be
tested.

* test(app): cover refreshing collapsed folders through the browser

Exercise the reported file creation, refresh and reopen sequence through a real daemon, then check refresh while expanded.

Co-authored-by: hadan8977 <zc-huang23@mails.tsinghua.edu.cn>

* fix(app): ignore directory replies invalidated by Refresh

A directory request started before Refresh could finish afterwards and restore a stale collapsed listing. Track the refresh generation in explorer state and accept completion only from the current generation. Keep directory loading and invalidation in the file-explorer model.

* fix(app): preserve expansion when Refresh cancels restoration

Keep an invalidated directory operation distinct from a failed listing. Propagate cancellation through saved-folder restoration so it preserves the current expansion state instead of committing an incomplete restored path set.

---------

Co-authored-by: hadan8977 <zc-huang23@mails.tsinghua.edu.cn>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-02 21:59:28 +00:00

272 lines
8.9 KiB
TypeScript

import { describe, expect, it } from "vitest";
import type { ExplorerDirectory, ExplorerEntry } from "@/stores/session-store";
import {
MAX_AUTO_EXPANDED_DIRECTORY_DEPTH,
flattenExplorerTree,
reconcileRestoredExpandedPaths,
refreshExplorerDirectories,
restoreExpandedDirectories,
setExpandedDirectoryPath,
showHiddenFilesAndRestoreExpandedDirectories,
} from "./tree";
function makeDirectoryEntry(name: string, path: string): ExplorerEntry {
return {
name,
path,
kind: "directory",
size: 0,
modifiedAt: "2026-01-01T00:00:00.000Z",
};
}
describe("file explorer tree", () => {
it("flattens a deeply expanded tree without consuming the call stack", () => {
const depth = 10_000;
const directories = new Map<string, { path: string; entries: ExplorerEntry[] }>();
const expandedPaths = new Set<string>(["."]);
let parentPath = ".";
for (let index = 1; index <= depth; index += 1) {
const childPath = `directory-${index}`;
directories.set(parentPath, {
path: parentPath,
entries: [makeDirectoryEntry(childPath, childPath)],
});
expandedPaths.add(childPath);
parentPath = childPath;
}
directories.set(parentPath, { path: parentPath, entries: [] });
const rows = flattenExplorerTree({
directories,
expandedPaths,
sortOption: "name",
showHiddenFiles: true,
});
expect(rows).toHaveLength(depth);
expect(rows[0]).toEqual({
entry: makeDirectoryEntry("directory-1", "directory-1"),
depth: 0,
});
expect(rows.at(-1)).toEqual({
entry: makeDirectoryEntry(`directory-${depth}`, `directory-${depth}`),
depth: depth - 1,
});
});
it("flattens a large expanded directory without spreading its rows into the parent", () => {
const fileCount = 150_000;
const files = Array.from(
{ length: fileCount },
(_, index): ExplorerEntry => ({
name: `file-${index.toString().padStart(6, "0")}`,
path: `generated/file-${index}`,
kind: "file",
size: index,
modifiedAt: "2026-01-01T00:00:00.000Z",
}),
);
const child = makeDirectoryEntry("generated", "generated");
const directories = new Map([
[".", { path: ".", entries: [child] }],
["generated", { path: "generated", entries: files }],
]);
const rows = flattenExplorerTree({
directories,
expandedPaths: new Set([".", "generated"]),
sortOption: "name",
showHiddenFiles: true,
});
expect(rows).toHaveLength(fileCount + 1);
expect(rows[0]).toEqual({ entry: child, depth: 0 });
expect(rows.at(-1)).toEqual({ entry: files[fileCount - 1], depth: 1 });
});
it("restores five rendered directory levels rather than counting path segments", async () => {
const paths = [
"generated/cache/level-1",
"generated/cache/level-1/level-2",
"generated/cache/level-1/level-2/level-3",
"generated/cache/level-1/level-2/level-3/level-4",
"generated/cache/level-1/level-2/level-3/level-4/level-5",
"generated/cache/level-1/level-2/level-3/level-4/level-5/level-6",
];
const directories = new Map<string, { path: string; entries: ExplorerEntry[] }>();
const rootDirectory = {
path: ".",
entries: [makeDirectoryEntry("level-1", paths[0])],
};
directories.set(".", rootDirectory);
for (let index = 0; index < paths.length - 1; index += 1) {
directories.set(paths[index], {
path: paths[index],
entries: [makeDirectoryEntry(`level-${index + 2}`, paths[index + 1])],
});
}
const requestedPaths: string[] = [];
const expandedPaths = await restoreExpandedDirectories({
rootDirectory,
persistedExpandedPaths: new Set(paths),
showHiddenFiles: true,
requestDirectoryListing: async (path) => {
requestedPaths.push(path);
return directories.get(path) ?? null;
},
});
expect(MAX_AUTO_EXPANDED_DIRECTORY_DEPTH).toBe(5);
expect(requestedPaths).toEqual(paths.slice(0, 5));
expect(expandedPaths).toEqual([".", ...paths.slice(0, 5)]);
});
it("does not restore persisted descendants beneath a collapsed rendered directory", async () => {
const rootDirectory = {
path: ".",
entries: [makeDirectoryEntry("parent", "parent")],
};
const requestedPaths: string[] = [];
const expandedPaths = await restoreExpandedDirectories({
rootDirectory,
persistedExpandedPaths: new Set(["parent/child", "parent/child/grandchild"]),
showHiddenFiles: true,
requestDirectoryListing: async (path) => {
requestedPaths.push(path);
return null;
},
});
expect(requestedPaths).toEqual([]);
expect(expandedPaths).toEqual(["."]);
});
it("preserves expansion changes made while persisted directories are restoring", () => {
const paths = reconcileRestoredExpandedPaths({
persistedExpandedPaths: new Set([".", "parent", "parent/child"]),
currentExpandedPaths: new Set([".", "parent/child", "manual"]),
restoredExpandedPaths: [".", "parent"],
});
expect(paths).toEqual([".", "manual"]);
});
it("applies a directory click to the latest restored expansion paths", () => {
const expanded = setExpandedDirectoryPath({
currentExpandedPaths: [".", "restored"],
directoryPath: "manual",
expanded: true,
});
const collapsed = setExpandedDirectoryPath({
currentExpandedPaths: expanded,
directoryPath: "manual",
expanded: false,
});
expect(expanded).toEqual([".", "restored", "manual"]);
expect(collapsed).toEqual([".", "restored"]);
});
it("shows hidden files before waiting for expanded directories to restore", async () => {
const rootDirectory = {
path: ".",
entries: [makeDirectoryEntry(".hidden", ".hidden")],
};
let resolveDirectory!: (directory: { path: string; entries: ExplorerEntry[] }) => void;
const directoryListing = new Promise<{ path: string; entries: ExplorerEntry[] }>((resolve) => {
resolveDirectory = resolve;
});
let hiddenFilesAreShown = false;
const restoration = showHiddenFilesAndRestoreExpandedDirectories({
rootDirectory,
persistedExpandedPaths: new Set([".hidden"]),
showHiddenFiles: () => {
hiddenFilesAreShown = true;
},
requestDirectoryListing: () => directoryListing,
});
expect(hiddenFilesAreShown).toBe(true);
resolveDirectory({ path: ".hidden", entries: [] });
await expect(restoration).resolves.toEqual([".", ".hidden"]);
});
});
describe("refreshExplorerDirectories", () => {
function makeListing(path: string, names: string[]): ExplorerDirectory {
return {
path,
entries: names.map((name) => ({
name,
path: path === "." ? name : `${path}/${name}`,
kind: "file" as const,
size: 0,
modifiedAt: "2026-01-01T00:00:00.000Z",
})),
};
}
function createExplorer(disk: Record<string, string[]>, cached: Record<string, string[]>) {
const directories = new Map(
Object.entries(cached).map(([path, names]) => [path, makeListing(path, names)]),
);
return {
directories,
requestDirectoryListing: async (path: string) => {
const listing = makeListing(path, disk[path] ?? []);
directories.set(path, listing);
return listing;
},
invalidateDirectoryListings: (retainedPaths: readonly string[]) => {
const retained = new Set(retainedPaths);
for (const path of directories.keys()) if (!retained.has(path)) directories.delete(path);
},
};
}
it("does not keep a stale listing for a collapsed folder", async () => {
// docs/ was expanded and collapsed earlier, then NEW.md was created in it.
const explorer = createExplorer(
{ ".": ["docs"], docs: ["README.md", "NEW.md"] },
{ ".": ["docs"], docs: ["README.md"] },
);
await refreshExplorerDirectories({
hasWorkspaceScope: true,
expandedPaths: new Set(["."]),
showHiddenFiles: false,
requestDirectoryListing: explorer.requestDirectoryListing,
invalidateDirectoryListings: explorer.invalidateDirectoryListings,
});
// Expanding docs/ only fetches when it has no cached listing.
expect(explorer.directories.has("docs")).toBe(false);
expect(explorer.directories.get(".")?.entries.map((entry) => entry.name)).toEqual(["docs"]);
});
it("refreshes expanded folders and keeps their listings", async () => {
const explorer = createExplorer(
{ ".": ["src"], src: ["a.ts", "b.ts"] },
{ ".": ["src"], src: ["a.ts"] },
);
await refreshExplorerDirectories({
hasWorkspaceScope: true,
expandedPaths: new Set([".", "src"]),
showHiddenFiles: false,
requestDirectoryListing: explorer.requestDirectoryListing,
invalidateDirectoryListings: explorer.invalidateDirectoryListings,
});
expect(explorer.directories.get("src")?.entries.map((entry) => entry.name)).toEqual([
"a.ts",
"b.ts",
]);
});
});