Skip to content

Commit 74f3bf5

Browse files
fixfonclaude
andcommitted
fix(web): the Files tree remembers expanded folders
The Files panel unmounts with its right-panel tab and on thread switch or reload, and its expansion lived only in memory, so every remount collapsed the tree. Persist the expanded folders per environment and workspace in localStorage and restore them parent-before-child as each folder is listed. Refs #12041 Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
1 parent 812b700 commit 74f3bf5

5 files changed

Lines changed: 243 additions & 2 deletions

File tree

‎apps/web/src/components/files/FileBrowserPanel.tsx‎

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,12 @@ import { PIERRE_TREE_UNSAFE_CSS, pierreTreeStyle } from "~/pierre-tree-theme";
3030
import { joinHostPath } from "./filePath";
3131
import { createFileTreeDragMentionController } from "./fileTreeDragMention";
3232
import { areAllDirectoriesExpanded, setAllDirectoriesExpanded } from "./fileTreeExpansion";
33+
import {
34+
fileTreeExpansionStorageKey,
35+
readExpandedPaths,
36+
syncPersistedExpansion,
37+
writeExpandedPaths,
38+
} from "./fileTreeExpansionStorage";
3339
import { buildFileTreePathUpdates } from "./fileTreePathReconciliation";
3440
import { useDirectoryEntries } from "./useDirectoryEntries";
3541
import { useProjectPathSearch } from "~/state/queries";
@@ -157,6 +163,7 @@ export default function FileBrowserPanel({
157163
error,
158164
isPending,
159165
loadingDirectories,
166+
loadedDirectories,
160167
} = useDirectoryEntries(environmentId, cwd);
161168
const [query, setQuery] = useState("");
162169
const [expandAll, setExpandAll] = useState(false);
@@ -330,13 +337,32 @@ export default function FileBrowserPanel({
330337
search.close();
331338
};
332339
const expandedPathsRef = useRef(new Set<string>());
340+
// The panel unmounts with its right-panel tab, so the remembered folders live
341+
// in storage. The panel is keyed by workspace, so these are read once.
342+
const expansionStorageKey = fileTreeExpansionStorageKey(environmentId, cwd);
343+
const [persistedExpansion] = useState(() => {
344+
const persisted = readExpandedPaths(expansionStorageKey);
345+
return { persisted, pendingRestore: new Set(persisted) };
346+
});
333347
useEffect(() => {
334348
const currentPaths = new Set(directoryPaths);
335349
for (const path of expandedPathsRef.current) {
336350
if (!currentPaths.has(path)) expandedPathsRef.current.delete(path);
337351
}
338352
const loadExpanded = () => {
339353
if (model.isSearchOpen()) return;
354+
const { persisted, pendingRestore } = persistedExpansion;
355+
if (
356+
syncPersistedExpansion({
357+
model,
358+
directoryPaths,
359+
loadedDirectories,
360+
persisted,
361+
pendingRestore,
362+
})
363+
) {
364+
writeExpandedPaths(expansionStorageKey, persisted);
365+
}
340366
for (const path of directoryPaths) {
341367
const item = model.getItem(path);
342368
if (item?.isDirectory() && "isExpanded" in item && item.isExpanded()) {
@@ -352,7 +378,7 @@ export default function FileBrowserPanel({
352378
};
353379
loadExpanded();
354380
return model.subscribe(loadExpanded);
355-
}, [directoryPaths, load, model]);
381+
}, [directoryPaths, expansionStorageKey, load, loadedDirectories, model, persistedExpansion]);
356382
useEffect(() => {
357383
model.setGitStatus(
358384
entries

‎apps/web/src/components/files/fileTreeExpansion.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ type DirectoryHandle = {
99
collapse(): void;
1010
};
1111

12-
function asDirectoryHandle(item: unknown): DirectoryHandle | null {
12+
export function asDirectoryHandle(item: unknown): DirectoryHandle | null {
1313
if (
1414
typeof item !== "object" ||
1515
item === null ||
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import { EnvironmentId } from "@t3tools/contracts";
2+
import { describe, expect, it } from "@effect/vitest";
3+
import * as Schema from "effect/Schema";
4+
5+
import { setLocalStorageItem } from "~/hooks/useLocalStorage";
6+
7+
import {
8+
fileTreeExpansionStorageKey,
9+
readExpandedPaths,
10+
syncPersistedExpansion,
11+
writeExpandedPaths,
12+
} from "./fileTreeExpansionStorage";
13+
14+
/** Rows exist only for listed directories, like the real tree. */
15+
function makeTree(rows: Record<string, boolean>) {
16+
const item = (path: string) => ({
17+
isDirectory: () => true,
18+
isExpanded: () => rows[path] ?? false,
19+
expand: () => {
20+
rows[path] = true;
21+
},
22+
collapse: () => {
23+
rows[path] = false;
24+
},
25+
});
26+
return {
27+
rows,
28+
model: { getItem: (path: string) => (path in rows ? item(path) : null) },
29+
};
30+
}
31+
32+
function sync(args: {
33+
tree: ReturnType<typeof makeTree>;
34+
loaded: string[];
35+
persisted: string[];
36+
pending?: string[];
37+
}) {
38+
const persisted = new Set(args.persisted);
39+
const pendingRestore = new Set(args.pending ?? args.persisted);
40+
const changed = syncPersistedExpansion({
41+
model: args.tree.model,
42+
directoryPaths: Object.keys(args.tree.rows),
43+
loadedDirectories: new Set(args.loaded),
44+
persisted,
45+
pendingRestore,
46+
});
47+
return { changed, persisted, pendingRestore };
48+
}
49+
50+
describe("file tree expansion storage", () => {
51+
const key = fileTreeExpansionStorageKey(EnvironmentId.make("env-1"), "/work/app");
52+
53+
it("round-trips the expanded set and treats missing or malformed storage as empty", () => {
54+
expect(readExpandedPaths(key).size).toBe(0);
55+
writeExpandedPaths(key, new Set(["apps", "apps/web"]));
56+
expect([...readExpandedPaths(key)]).toEqual(["apps", "apps/web"]);
57+
writeExpandedPaths(key, new Set());
58+
expect(readExpandedPaths(key).size).toBe(0);
59+
setLocalStorageItem(key, 5, Schema.Number);
60+
expect(readExpandedPaths(key).size).toBe(0);
61+
});
62+
63+
it("keeps the shallowest folders when the set is larger than the bound", () => {
64+
const deep = Array.from({ length: 600 }, (_, index) => `a/b/c/${index}`);
65+
writeExpandedPaths(key, new Set([...deep, "a", "a/b"]));
66+
const stored = readExpandedPaths(key);
67+
expect(stored.size).toBe(500);
68+
expect(stored.has("a")).toBe(true);
69+
expect(stored.has("a/b")).toBe(true);
70+
});
71+
});
72+
73+
describe("syncPersistedExpansion", () => {
74+
it("restores a remembered folder once its row appears and keeps the rest pending", () => {
75+
// `apps/web` is listed only after `apps` is expanded.
76+
const tree = makeTree({ "apps/": false, "docs/": false });
77+
const first = sync({ tree, loaded: [""], persisted: ["apps", "apps/web"] });
78+
expect(tree.rows["apps/"]).toBe(true);
79+
expect([...first.pendingRestore]).toEqual(["apps/web"]);
80+
expect([...first.persisted]).toEqual(["apps", "apps/web"]);
81+
82+
tree.rows["apps/web/"] = false;
83+
const second = sync({
84+
tree,
85+
loaded: ["", "apps"],
86+
persisted: [...first.persisted],
87+
pending: [...first.pendingRestore],
88+
});
89+
expect(tree.rows["apps/web/"]).toBe(true);
90+
expect(second.pendingRestore.size).toBe(0);
91+
expect(second.changed).toBe(false);
92+
});
93+
94+
it("does not forget remembered folders before they have been listed", () => {
95+
const tree = makeTree({ "apps/": false });
96+
const result = sync({ tree, loaded: [""], persisted: ["apps", "apps/web"] });
97+
expect(result.persisted.has("apps/web")).toBe(true);
98+
expect(result.changed).toBe(false);
99+
});
100+
101+
it("forgets folders their parent no longer lists, and ones the user collapses", () => {
102+
const tree = makeTree({ "apps/": true, "docs/": false });
103+
const result = sync({
104+
tree,
105+
loaded: ["", "apps"],
106+
persisted: ["apps", "docs", "apps/gone"],
107+
pending: [],
108+
});
109+
expect([...result.persisted]).toEqual(["apps"]);
110+
expect(result.changed).toBe(true);
111+
});
112+
113+
it("remembers folders the user expands", () => {
114+
const tree = makeTree({ "apps/": true });
115+
const result = sync({ tree, loaded: [""], persisted: [], pending: [] });
116+
expect([...result.persisted]).toEqual(["apps"]);
117+
expect(result.changed).toBe(true);
118+
});
119+
});
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import type { EnvironmentId } from "@t3tools/contracts";
2+
import * as Schema from "effect/Schema";
3+
4+
import {
5+
getLocalStorageItem,
6+
removeLocalStorageItem,
7+
setLocalStorageItem,
8+
} from "~/hooks/useLocalStorage";
9+
10+
import { asDirectoryHandle, type FileTreeExpansionModel } from "./fileTreeExpansion";
11+
12+
/** Bounds what a huge "expand all" can write; shallow folders win so restore still reaches them. */
13+
const MAX_PERSISTED_EXPANDED_PATHS = 500;
14+
15+
const ExpandedPathsSchema = Schema.Array(Schema.String);
16+
17+
/** Expansion is workspace-scoped, like the tree itself, not per thread. */
18+
export function fileTreeExpansionStorageKey(environmentId: EnvironmentId, cwd: string): string {
19+
return `t3code.fileTreeExpanded:${environmentId}:${cwd}`;
20+
}
21+
22+
/** Expanded directory paths without the trailing slash; unreadable storage restores nothing. */
23+
export function readExpandedPaths(storageKey: string): Set<string> {
24+
try {
25+
return new Set(getLocalStorageItem(storageKey, ExpandedPathsSchema) ?? []);
26+
} catch {
27+
return new Set();
28+
}
29+
}
30+
31+
export function writeExpandedPaths(storageKey: string, paths: ReadonlySet<string>): void {
32+
try {
33+
if (paths.size === 0) {
34+
removeLocalStorageItem(storageKey);
35+
return;
36+
}
37+
const depth = (path: string) => path.split("/").length;
38+
const bounded = [...paths]
39+
.toSorted((a, b) => depth(a) - depth(b))
40+
.slice(0, MAX_PERSISTED_EXPANDED_PATHS);
41+
setLocalStorageItem(storageKey, bounded, ExpandedPathsSchema);
42+
} catch {
43+
// Remembering folders is a convenience; a full or blocked storage must not break the tree.
44+
}
45+
}
46+
47+
function parentDirectory(path: string): string {
48+
return path.slice(0, Math.max(0, path.lastIndexOf("/")));
49+
}
50+
51+
/**
52+
* Reconciles the remembered folders with the tree rows. `persisted` is the set
53+
* to store; `pendingRestore` holds remembered folders whose row has not
54+
* appeared yet. Rows only exist once their parent is listed, so expanding each
55+
* as it appears restores parent-before-child. A folder is forgotten when it is
56+
* collapsed or when its parent was listed and no longer has it. Returns whether
57+
* `persisted` changed.
58+
*/
59+
export function syncPersistedExpansion(args: {
60+
readonly model: FileTreeExpansionModel;
61+
/** Tree paths of known directories, with the trailing slash. */
62+
readonly directoryPaths: readonly string[];
63+
/** Directories whose listing succeeded. */
64+
readonly loadedDirectories: ReadonlySet<string>;
65+
readonly persisted: Set<string>;
66+
readonly pendingRestore: Set<string>;
67+
}): boolean {
68+
const { model, directoryPaths, loadedDirectories, persisted, pendingRestore } = args;
69+
const knownDirectories = new Set<string>();
70+
let changed = false;
71+
for (const treePath of directoryPaths) {
72+
const path = treePath.slice(0, -1);
73+
knownDirectories.add(path);
74+
const item = asDirectoryHandle(model.getItem(treePath));
75+
if (item === null) continue;
76+
if (pendingRestore.delete(path) && !item.isExpanded()) item.expand();
77+
if (item.isExpanded()) {
78+
if (!persisted.has(path)) {
79+
persisted.add(path);
80+
changed = true;
81+
}
82+
} else if (persisted.delete(path)) {
83+
changed = true;
84+
}
85+
}
86+
for (const path of persisted) {
87+
if (knownDirectories.has(path) || !loadedDirectories.has(parentDirectory(path))) continue;
88+
persisted.delete(path);
89+
pendingRestore.delete(path);
90+
changed = true;
91+
}
92+
return changed;
93+
}

‎apps/web/src/components/files/useDirectoryEntries.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,8 @@ export function useDirectoryEntries(environmentId: EnvironmentId, cwd: string) {
117117
...entries.filter((entry) => entry.kind === "directory").map((entry) => entry.path),
118118
]);
119119

120+
const loadedDirectories = new Set(directories.keys());
121+
120122
const refresh = () => {
121123
const paths = [...requested.current].filter((path) => reachableDirectories.has(path));
122124
const workerCount = Math.min(4, paths.length);
@@ -136,6 +138,7 @@ export function useDirectoryEntries(environmentId: EnvironmentId, cwd: string) {
136138
refresh,
137139
isPending: pending > 0,
138140
loadingDirectories,
141+
loadedDirectories,
139142
ready: directories.has(""),
140143
error: [...errors].find(([path]) => reachableDirectories.has(path))?.[1] ?? null,
141144
};

0 commit comments

Comments
 (0)