From dc6e9bac3f104dcb8745816000731d8ab72e01e3 Mon Sep 17 00:00:00 2001 From: Max Tesla <58265349+boom-bang@users.noreply.github.com> Date: Wed, 4 Nov 2026 22:52:18 +0100 Subject: [PATCH 01/10] feat(server): report entry mtime in projects.listEntries Directory listings now carry each entry's modified time (stat, following symlinks) so clients can sort the Files panel by date. A failed stat leaves mtimeMs unset instead of dropping the entry. Co-Authored-By: Claude Sonnet 5.5 --- .../src/workspace/WorkspaceEntries.test.ts | 86 +++++++++++++++---- apps/server/src/workspace/WorkspaceEntries.ts | 42 +++++---- packages/contracts/src/project.ts | 1 + 3 files changed, 99 insertions(+), 30 deletions(-) diff --git a/apps/server/src/workspace/WorkspaceEntries.test.ts b/apps/server/src/workspace/WorkspaceEntries.test.ts index eedff4fc79c9a..12e58419841f6 100644 --- a/apps/server/src/workspace/WorkspaceEntries.test.ts +++ b/apps/server/src/workspace/WorkspaceEntries.test.ts @@ -121,29 +121,38 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceEntries", (it) => { const root = yield* workspaceEntries.list({ cwd, directoryPath: "" }); expect(root.entries).toEqual( expect.arrayContaining([ - { path: ".env", kind: "file", ignored: true }, - { path: "node_modules", kind: "directory", ignored: true }, - { path: "src", kind: "directory" }, - { path: "empty", kind: "directory" }, - { path: "tracked.txt", kind: "file" }, + { path: ".env", kind: "file", ignored: true, mtimeMs: expect.any(Number) }, + { path: "node_modules", kind: "directory", ignored: true, mtimeMs: expect.any(Number) }, + { path: "src", kind: "directory", mtimeMs: expect.any(Number) }, + { path: "empty", kind: "directory", mtimeMs: expect.any(Number) }, + { path: "tracked.txt", kind: "file", mtimeMs: expect.any(Number) }, ]), ); expect(root.entries.some((entry) => entry.path.includes("/"))).toBe(false); expect(root.entries.some((entry) => entry.path === ".git")).toBe(false); expect(root.truncated).toBe(false); expect(yield* workspaceEntries.list({ cwd, directoryPath: "node_modules/pkg" })).toEqual({ - entries: [{ path: "node_modules/pkg/index.js", kind: "file", ignored: true }], + entries: [ + { + path: "node_modules/pkg/index.js", + kind: "file", + ignored: true, + mtimeMs: expect.any(Number), + }, + ], truncated: false, }); // Git refuses pathspecs inside a submodule from the outer repository. expect((yield* workspaceEntries.list({ cwd, directoryPath: "sub" })).entries).toEqual( expect.arrayContaining([ - { path: "sub/.gitignore", kind: "file" }, - { path: "sub/dist", kind: "directory", ignored: true }, + { path: "sub/.gitignore", kind: "file", mtimeMs: expect.any(Number) }, + { path: "sub/dist", kind: "directory", ignored: true, mtimeMs: expect.any(Number) }, ]), ); expect(yield* workspaceEntries.list({ cwd, directoryPath: "sub/dist" })).toEqual({ - entries: [{ path: "sub/dist/out.js", kind: "file", ignored: true }], + entries: [ + { path: "sub/dist/out.js", kind: "file", ignored: true, mtimeMs: expect.any(Number) }, + ], truncated: false, }); expect(yield* workspaceEntries.list({ cwd, directoryPath: "empty" })).toEqual({ @@ -173,18 +182,65 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceEntries", (it) => { const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; const root = yield* workspaceEntries.list({ cwd, directoryPath: "" }); expect(root.entries.toSorted((a, b) => a.path.localeCompare(b.path))).toEqual([ - { path: "link.txt", kind: "file" }, - { path: "linkdir", kind: "directory" }, - { path: "real.txt", kind: "file" }, - { path: "realdir", kind: "directory" }, + { path: "link.txt", kind: "file", mtimeMs: expect.any(Number) }, + { path: "linkdir", kind: "directory", mtimeMs: expect.any(Number) }, + { path: "real.txt", kind: "file", mtimeMs: expect.any(Number) }, + { path: "realdir", kind: "directory", mtimeMs: expect.any(Number) }, ]); expect(yield* workspaceEntries.list({ cwd, directoryPath: "linkdir" })).toEqual({ - entries: [{ path: "linkdir/nested.txt", kind: "file" }], + entries: [{ path: "linkdir/nested.txt", kind: "file", mtimeMs: expect.any(Number) }], truncated: false, }); }), ); + it.effect("reports the modified time of each entry, following links to their target", () => + Effect.gen(function* () { + const platform = yield* HostProcess.Platform; + if (platform === "win32") return; + const cwd = yield* makeTempDir(); + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* writeTextFile(cwd, "file.txt"); + yield* writeTextFile(cwd, "dir/nested.txt"); + yield* writeTextFile(cwd, "unreadable.txt"); + yield* fileSystem.symlink("file.txt", path.join(cwd, "link.txt")); + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const mtimes = () => + workspaceEntries + .list({ cwd, directoryPath: "" }) + .pipe( + Effect.map(({ entries }) => + Object.fromEntries(entries.map((entry) => [entry.path, entry.mtimeMs])), + ), + ); + + const fileTime = 1_600_000_000; + const dirTime = 1_500_000_000; + yield* Effect.promise(() => NodeFSP.utimes(path.join(cwd, "file.txt"), fileTime, fileTime)); + yield* Effect.promise(() => NodeFSP.utimes(path.join(cwd, "dir"), dirTime, dirTime)); + const actual = yield* Effect.promise(() => NodeFSP.stat(path.join(cwd, "unreadable.txt"))); + expect(yield* mtimes()).toEqual({ + "file.txt": 1_600_000_000_000, + "link.txt": 1_600_000_000_000, + dir: 1_500_000_000_000, + "unreadable.txt": actual.mtimeMs, + }); + + const stat = NodeFSP.stat; + vi.spyOn(NodeFSP, "stat").mockImplementation((async (target: string) => { + if (target.endsWith("unreadable.txt")) throw new Error("EACCES"); + return stat(target); + }) as typeof NodeFSP.stat); + const { entries } = yield* workspaceEntries.list({ cwd, directoryPath: "" }); + expect(entries.find((entry) => entry.path === "unreadable.txt")).toEqual({ + path: "unreadable.txt", + kind: "file", + }); + expect(entries).toHaveLength(4); + }), + ); + it.effect("hides folder links that lead back to a folder on the listed path", () => // "Expand all folders" loads every folder it expands, so a link that loops would // keep nesting the same entries without end. @@ -338,7 +394,7 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceEntries", (it) => { const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; expect(yield* workspaceEntries.list({ cwd: linked, directoryPath: "" })).toEqual({ - entries: [{ path: "notes.md", kind: "file" }], + entries: [{ path: "notes.md", kind: "file", mtimeMs: expect.any(Number) }], truncated: false, }); const up = yield* workspaceEntries.list({ cwd: parent, directoryPath: "" }); diff --git a/apps/server/src/workspace/WorkspaceEntries.ts b/apps/server/src/workspace/WorkspaceEntries.ts index d388170f93f5e..d4e2f7c7c83bb 100644 --- a/apps/server/src/workspace/WorkspaceEntries.ts +++ b/apps/server/src/workspace/WorkspaceEntries.ts @@ -117,9 +117,14 @@ const IGNORED_FILES_LIMIT = 200; /** Paths sent to one `git check-ignore` call when labelling a folder's children. */ const IGNORE_CHECK_CHUNK_SIZE = 1000; -/** Children classified at once when listing a folder; each symlink costs a realpath and a stat. */ +/** Children classified at once when listing a folder; each costs a stat and each symlink a realpath. */ const SYMLINK_RESOLVE_BATCH_SIZE = 64; +interface ListedChild { + readonly kind: ProjectEntry["kind"]; + readonly mtimeMs: number | undefined; +} + const resolveBrowseTarget = Effect.fn("WorkspaceEntries.resolveBrowseTarget")(function* ( input: FilesystemBrowseInput, path: Path.Path, @@ -341,9 +346,7 @@ export const make = Effect.gen(function* () { } return folders; })()); - const resolveSymlinkKind = async ( - name: string, - ): Promise => { + const resolveSymlink = async (name: string): Promise => { const resolved = await NodeFSP.realpath(path.join(directory, name)).catch( () => undefined, ); @@ -355,25 +358,33 @@ export const make = Effect.gen(function* () { return undefined; } const stat = await NodeFSP.stat(resolved).catch(() => undefined); - if (stat?.isFile()) return "file"; + if (stat?.isFile()) return { kind: "file", mtimeMs: stat.mtimeMs }; if (!stat?.isDirectory()) return undefined; const folders = await loadVisitedFolders(); return folders.some((folder) => isSameOrInside(resolved, folder)) ? undefined - : "directory"; + : { kind: "directory", mtimeMs: stat.mtimeMs }; }; + // A failed stat only loses the timestamp, never the entry. + const statMtime = async (name: string) => + (await NodeFSP.stat(path.join(directory, name)).catch(() => undefined))?.mtimeMs; // Bound filesystem work for directories such as pnpm node_modules. - const kinds: Array = []; + const listed: Array = []; for (let offset = 0; offset < children.length; offset += SYMLINK_RESOLVE_BATCH_SIZE) { - kinds.push( + listed.push( ...(await Promise.all( children .slice(offset, offset + SYMLINK_RESOLVE_BATCH_SIZE) - .map(async (child): Promise => { + .map(async (child): Promise => { if (child.name === ".git") return undefined; - if (child.isDirectory()) return "directory"; - if (child.isFile()) return "file"; - return child.isSymbolicLink() ? resolveSymlinkKind(child.name) : undefined; + if (child.isSymbolicLink()) return resolveSymlink(child.name); + const kind = child.isDirectory() + ? "directory" + : child.isFile() + ? "file" + : undefined; + if (kind === undefined) return undefined; + return { kind, mtimeMs: await statMtime(child.name) }; }), )), ); @@ -390,12 +401,13 @@ export const make = Effect.gen(function* () { return { nestedGitRoots, entries: children.flatMap((child, index): ProjectEntry[] => { - const kind = kinds[index]; - if (kind === undefined) return []; + const item = listed[index]; + if (item === undefined) return []; return [ { path: target.relativePath ? `${target.relativePath}/${child.name}` : child.name, - kind, + kind: item.kind, + ...(item.mtimeMs === undefined ? {} : { mtimeMs: item.mtimeMs }), }, ]; }), diff --git a/packages/contracts/src/project.ts b/packages/contracts/src/project.ts index 39696c62e4c99..6f810f253291f 100644 --- a/packages/contracts/src/project.ts +++ b/packages/contracts/src/project.ts @@ -249,6 +249,7 @@ export const ProjectEntry = Schema.Struct({ path: TrimmedNonEmptyString, kind: ProjectEntryKind, ignored: Schema.optional(Schema.Boolean), + mtimeMs: Schema.optional(Schema.Number), }); export type ProjectEntry = typeof ProjectEntry.Type; From 4f840b09b9047b9a56c0456a4748ddfba2113493 Mon Sep 17 00:00:00 2001 From: Max Tesla <58265349+boom-bang@users.noreply.github.com> Date: Wed, 4 Nov 2026 22:52:23 +0100 Subject: [PATCH 02/10] feat(web): add file tree sort comparator Pure comparator for the Files panel sort order. Name ascending reproduces @pierre/trees' default order (case-insensitive, digit runs numeric, folders first); name descending and date modified build on it. Co-Authored-By: Claude Sonnet 5.5 --- .../src/components/files/fileTreeSort.test.ts | 139 ++++++++++++++++++ apps/web/src/components/files/fileTreeSort.ts | 108 ++++++++++++++ 2 files changed, 247 insertions(+) create mode 100644 apps/web/src/components/files/fileTreeSort.test.ts create mode 100644 apps/web/src/components/files/fileTreeSort.ts diff --git a/apps/web/src/components/files/fileTreeSort.test.ts b/apps/web/src/components/files/fileTreeSort.test.ts new file mode 100644 index 0000000000000..619606dabb3ec --- /dev/null +++ b/apps/web/src/components/files/fileTreeSort.test.ts @@ -0,0 +1,139 @@ +import { prepareFileTreeInput } from "@pierre/trees"; +import { describe, expect, it } from "@effect/vitest"; + +import { + DEFAULT_FILE_TREE_SORT_ORDER, + createFileTreeSortComparator, + type FileTreeSortOrder, +} from "./fileTreeSort.ts"; + +const sortPaths = ( + paths: readonly string[], + order: FileTreeSortOrder, + mtimes: Readonly> = {}, + lookups: string[] = [], +) => + prepareFileTreeInput(paths, { + sort: createFileTreeSortComparator(order, (path) => { + lookups.push(path); + return mtimes[path]; + }), + }).paths; + +const name = (direction: FileTreeSortOrder["direction"]): FileTreeSortOrder => ({ + key: "name", + direction, +}); +const modified = (direction: FileTreeSortOrder["direction"]): FileTreeSortOrder => ({ + key: "modified", + direction, +}); + +describe("createFileTreeSortComparator", () => { + it("reproduces the tree's default order for name ascending", () => { + const paths = [ + "z.txt", + "src/lib/util.ts", + "src/Lib.ts", + "src/lib/", + "src/index.ts", + ".github/workflows/ci.yml", + ".env", + "README.md", + "readme.md", + "Readme.md", + "docs/", + "Docs.md", + "file10.txt", + "file2.txt", + "file02.txt", + "_private.ts", + "B.txt", + "b.txt", + "é.txt", + "a/b/c/deep.txt", + "a/z.txt", + "a-b/x.txt", + ]; + + expect(sortPaths(paths, DEFAULT_FILE_TREE_SORT_ORDER)).toEqual( + prepareFileTreeInput(paths).paths, + ); + }); + + it("reverses names within each group for name descending and keeps folders first", () => { + const paths = ["b.txt", "a.txt", "c/x.txt", "a/y.txt", "c/a.txt"]; + + expect(sortPaths(paths, name("desc"))).toEqual([ + "c/x.txt", + "c/a.txt", + "a/y.txt", + "b.txt", + "a.txt", + ]); + }); + + it("sorts by modified time with folders first and name ascending for ties and missing times", () => { + const paths = ["old.txt", "new.txt", "tie-b.txt", "tie-a.txt", "none-b.txt", "none-a.txt"]; + const mtimes = { "old.txt": 10, "new.txt": 30, "tie-a.txt": 20, "tie-b.txt": 20 }; + + expect(sortPaths(paths, modified("desc"), mtimes)).toEqual([ + "new.txt", + "tie-a.txt", + "tie-b.txt", + "old.txt", + "none-a.txt", + "none-b.txt", + ]); + expect(sortPaths(paths, modified("asc"), mtimes)).toEqual([ + "old.txt", + "tie-a.txt", + "tie-b.txt", + "new.txt", + "none-a.txt", + "none-b.txt", + ]); + }); + + it("keeps folders before files and orders them by their own mtime in modified mode", () => { + const paths = ["file-new.txt", "dir-old/", "dir-new/", ".dot-dir/", ".dot-file"]; + const mtimes = { + "file-new.txt": 99, + "dir-old": 1, + "dir-new": 50, + ".dot-dir": 10, + ".dot-file": 100, + }; + + expect(sortPaths(paths, modified("desc"), mtimes)).toEqual([ + "dir-new/", + ".dot-dir/", + "dir-old/", + ".dot-file", + "file-new.txt", + ]); + }); + + it("looks up nested entries and ancestors by their path without a trailing slash", () => { + const paths = ["a/old.txt", "a/new.txt", "b/inner/", "b/inner/x.txt", "b/file.txt"]; + const mtimes = { + a: 1, + b: 2, + "a/old.txt": 5, + "a/new.txt": 6, + "b/inner": 7, + "b/file.txt": 8, + }; + const lookups: string[] = []; + + expect(sortPaths(paths, modified("desc"), mtimes, lookups)).toEqual([ + "b/inner/", + "b/inner/x.txt", + "b/file.txt", + "a/new.txt", + "a/old.txt", + ]); + expect(lookups.length).toBeGreaterThan(0); + expect(lookups.filter((path) => path.endsWith("/"))).toEqual([]); + }); +}); diff --git a/apps/web/src/components/files/fileTreeSort.ts b/apps/web/src/components/files/fileTreeSort.ts new file mode 100644 index 0000000000000..358969b2f1da4 --- /dev/null +++ b/apps/web/src/components/files/fileTreeSort.ts @@ -0,0 +1,108 @@ +import type { FileTreeSortComparator } from "@pierre/trees"; +import * as Schema from "effect/Schema"; + +export const FileTreeSortKey = Schema.Literals(["name", "modified"]); +export type FileTreeSortKey = typeof FileTreeSortKey.Type; +export const FileTreeSortDirection = Schema.Literals(["asc", "desc"]); +export type FileTreeSortDirection = typeof FileTreeSortDirection.Type; +export const FileTreeSortOrder = Schema.Struct({ + key: FileTreeSortKey, + direction: FileTreeSortDirection, +}); +export type FileTreeSortOrder = typeof FileTreeSortOrder.Type; +export const DEFAULT_FILE_TREE_SORT_ORDER: FileTreeSortOrder = { key: "name", direction: "asc" }; + +interface SegmentSortKey { + readonly lower: string; + readonly tokens: readonly (string | number)[]; +} + +// Mirrors @pierre/trees' path-store sort (dist/path-store/src/sort.js), which orders +// the default tree: case-insensitive, digit runs compared as numbers. The package +// does not export it, and its utils/sortChildren.js dot-first comparator is not used. +function createSegmentSortKey(segment: string): SegmentSortKey { + const lower = segment.toLowerCase(); + const tokens = lower + .split(/(\d+)/) + .flatMap((part, index) => + part === "" + ? [] + : [index % 2 === 1 ? [...part].reduce((n, digit) => n * 10 + Number(digit), 0) : part], + ); + return { lower, tokens }; +} + +function compareSegmentSortKeys(left: SegmentSortKey, right: SegmentSortKey): number { + const shared = Math.min(left.tokens.length, right.tokens.length); + for (let index = 0; index < shared; index++) { + const leftToken = left.tokens[index]!; + const rightToken = right.tokens[index]!; + if (leftToken === rightToken) continue; + if (typeof leftToken === "number" && typeof rightToken === "number") { + return leftToken < rightToken ? -1 : 1; + } + const leftText = String(leftToken); + const rightText = String(rightToken); + if (leftText !== rightText) return leftText < rightText ? -1 : 1; + } + if (left.tokens.length !== right.tokens.length) { + return left.tokens.length < right.tokens.length ? -1 : 1; + } + if (left.lower !== right.lower) return left.lower < right.lower ? -1 : 1; + return 0; +} + +// lookupMtime receives the entry path with any trailing "/" removed. +export function createFileTreeSortComparator( + order: FileTreeSortOrder, + lookupMtime: (path: string) => number | undefined, +): FileTreeSortComparator { + const sortKeys = new Map(); + const sortKeyOf = (segment: string): SegmentSortKey => { + let key = sortKeys.get(segment); + if (key === undefined) { + key = createSegmentSortKey(segment); + sortKeys.set(segment, key); + } + return key; + }; + const compareNamesAsc = (left: string, right: string): number => + compareSegmentSortKeys(sortKeyOf(left), sortKeyOf(right)) || + (left === right ? 0 : left < right ? -1 : 1); + + const compareMtimes = (left: number | undefined, right: number | undefined): number => { + if (left === right) return 0; + if (left === undefined) return 1; + if (right === undefined) return -1; + return (order.direction === "asc" ? left - right : right - left) < 0 ? -1 : 1; + }; + + // The tree sorts whole paths at build time and sibling pairs afterwards, so compare + // at the first segment where the two paths diverge. + return (left, right) => { + const shared = Math.min(left.segments.length, right.segments.length); + for (let depth = 0; depth < shared; depth++) { + const leftSegment = left.segments[depth]!; + const rightSegment = right.segments[depth]!; + if (leftSegment === rightSegment) continue; + const leftIsDirectory = depth < left.segments.length - 1 || left.isDirectory; + const rightIsDirectory = depth < right.segments.length - 1 || right.isDirectory; + if (leftIsDirectory !== rightIsDirectory) return leftIsDirectory ? -1 : 1; + if (order.key === "modified") { + const byTime = compareMtimes( + lookupMtime(left.segments.slice(0, depth + 1).join("/")), + lookupMtime(right.segments.slice(0, depth + 1).join("/")), + ); + if (byTime !== 0) return byTime; + return compareNamesAsc(leftSegment, rightSegment); + } + const byName = compareNamesAsc(leftSegment, rightSegment); + return order.direction === "asc" ? byName : -byName; + } + if (left.segments.length !== right.segments.length) { + return left.segments.length < right.segments.length ? -1 : 1; + } + if (left.isDirectory === right.isDirectory) return 0; + return left.isDirectory ? -1 : 1; + }; +} From bde091aa0ad6096e3b06d9255fb983e7c8324f7d Mon Sep 17 00:00:00 2001 From: Max Tesla <58265349+boom-bang@users.noreply.github.com> Date: Wed, 4 Nov 2026 22:54:21 +0100 Subject: [PATCH 03/10] feat(web): sort the Files panel by name or date modified Add two header buttons to the Files panel: one toggles the sort key (name or date modified), one toggles ascending or descending. The order persists in local storage. Re-sorting rebuilds the tree model with the open folders passed back in, because `resetPaths` closes every folder otherwise. Co-Authored-By: Claude Sonnet 5.5 --- .../files/FileBrowserPanel.browser.test.tsx | 70 ++++++++++- .../src/components/files/FileBrowserPanel.tsx | 114 +++++++++++++++++- 2 files changed, 176 insertions(+), 8 deletions(-) diff --git a/apps/web/src/components/files/FileBrowserPanel.browser.test.tsx b/apps/web/src/components/files/FileBrowserPanel.browser.test.tsx index 1c0e1bc914bed..408bda068b604 100644 --- a/apps/web/src/components/files/FileBrowserPanel.browser.test.tsx +++ b/apps/web/src/components/files/FileBrowserPanel.browser.test.tsx @@ -8,8 +8,12 @@ import { render } from "../../../test/render"; import FileBrowserPanel from "./FileBrowserPanel"; const entries: readonly ProjectEntry[] = [ - { path: "src", kind: "directory" }, - { path: "README.md", kind: "file" }, + { path: "docs", kind: "directory", mtimeMs: 100 }, + { path: "src", kind: "directory", mtimeMs: 200 }, + { path: "src/a.ts", kind: "file", mtimeMs: 10 }, + { path: "src/b.ts", kind: "file", mtimeMs: 20 }, + { path: "README.md", kind: "file", mtimeMs: 50 }, + { path: "zeta.md", kind: "file", mtimeMs: 300 }, ]; const loadingDirectories: ReadonlySet = new Set(); const directory = { @@ -36,6 +40,14 @@ vi.mock("~/fileContextMenu", () => ({ })); vi.mock("~/hooks/useWorkspaceMutationRefresh", () => ({ useWorkspaceMutationRefresh: () => {} })); +function treeRowPaths(): string[] { + for (const host of document.querySelectorAll("*")) { + const rows = host.shadowRoot?.querySelectorAll("[data-item-path]"); + if (rows?.length) return [...rows].map((row) => row.dataset.itemPath!); + } + return []; +} + function findTreeRow(path: string): HTMLElement | null { for (const host of document.querySelectorAll("*")) { const row = host.shadowRoot?.querySelector(`[data-item-path="${path}"]`); @@ -82,6 +94,60 @@ describe("FileBrowserPanel", () => { await vi.waitFor(() => expect((row.getRootNode() as ShadowRoot).activeElement).toBe(row)); }); + it("re-sorts by date modified and direction while keeping folders open and the selection", async () => { + using _sortOrder = { + [Symbol.dispose]: () => localStorage.removeItem("t3code:file-browser-sort"), + }; + await using _screen = await render( + {}} + workspaceMutationId={null} + />, + ); + await vi.waitFor(() => expect(findTreeRow("src/")).not.toBeNull()); + expect(treeRowPaths()).toEqual(["docs/", "src/", "README.md", "zeta.md"]); + findTreeRow("src/")!.click(); + await vi.waitFor(() => expect(findTreeRow("src/b.ts")).not.toBeNull()); + findTreeRow("src/b.ts")!.click(); + await vi.waitFor(() => + expect(findTreeRow("src/b.ts")!.getAttribute("aria-selected")).toBe("true"), + ); + + await userEvent.click(page.getByRole("button", { name: "Sort by date modified" })); + await vi.waitFor(() => + expect(treeRowPaths()).toEqual([ + "docs/", + "src/", + "src/a.ts", + "src/b.ts", + "README.md", + "zeta.md", + ]), + ); + expect(findTreeRow("src/b.ts")!.getAttribute("aria-selected")).toBe("true"); + + await userEvent.click(page.getByRole("button", { name: "Sort descending" })); + await expect.element(page.getByRole("button", { name: "Sort ascending" })).toBeVisible(); + await vi.waitFor(() => + expect(treeRowPaths()).toEqual([ + "src/", + "src/b.ts", + "src/a.ts", + "docs/", + "zeta.md", + "README.md", + ]), + ); + expect(localStorage.getItem("t3code:file-browser-sort")).toBe( + '{"key":"modified","direction":"desc"}', + ); + }); + it("dragging a file row carries a composer mention without opening it, and dragend releases it", async () => { const onOpenFile = vi.fn(); await using _screen = await render( diff --git a/apps/web/src/components/files/FileBrowserPanel.tsx b/apps/web/src/components/files/FileBrowserPanel.tsx index b02baf31b0acf..32dcdb449eaa8 100644 --- a/apps/web/src/components/files/FileBrowserPanel.tsx +++ b/apps/web/src/components/files/FileBrowserPanel.tsx @@ -9,7 +9,7 @@ import { settlePromise } from "@t3tools/client-runtime/state/runtime"; import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger"; import * as AsyncResult from "effect/reactivity/AsyncResult"; import * as Cause from "effect/Cause"; -import { ChevronsDownUp, ChevronsUpDown } from "lucide"; +import { ArrowDown, ArrowDownAZ, ArrowUp, ChevronsDownUp, ChevronsUpDown, Clock } from "lucide"; import { useEffect, useRef, useState } from "react"; import { Button } from "~/components/ui/button"; @@ -19,6 +19,7 @@ import { toastManager } from "~/components/ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; import { useComposerHandleContext } from "~/composerHandleContext"; import { writeTextToClipboard } from "~/hooks/useCopyToClipboard"; +import { useLocalStorage } from "~/hooks/useLocalStorage"; import { useStableCallback } from "~/hooks/useStableCallback"; import { useTheme } from "~/hooks/useTheme"; import { useWorkspaceMutationRefresh } from "~/hooks/useWorkspaceMutationRefresh"; @@ -31,6 +32,11 @@ import { joinHostPath } from "./filePath"; import { createFileTreeDragMentionController } from "./fileTreeDragMention"; import { areAllDirectoriesExpanded, setAllDirectoriesExpanded } from "./fileTreeExpansion"; import { buildFileTreePathUpdates } from "./fileTreePathReconciliation"; +import { + createFileTreeSortComparator, + DEFAULT_FILE_TREE_SORT_ORDER, + FileTreeSortOrder, +} from "./fileTreeSort"; import { loadFileAncestors } from "./loadFileAncestors"; import { useDirectoryEntries } from "./useDirectoryEntries"; import { useProjectPathSearch } from "~/state/queries"; @@ -53,6 +59,16 @@ function treePath(entry: ProjectEntry): string { return entry.kind === "directory" ? `${entry.path}/` : entry.path; } +const FILE_BROWSER_SORT_STORAGE_KEY = "t3code:file-browser-sort"; + +function hasMtimeChanged(previous: ReadonlyMap, next: ReadonlyMap) { + for (const [path, mtimeMs] of previous) { + const nextMtimeMs = next.get(path); + if (nextMtimeMs !== undefined && nextMtimeMs !== mtimeMs) return true; + } + return false; +} + function RefreshFilesButton(props: { isPending: boolean; onRefresh: () => void }) { return ( @@ -74,6 +90,53 @@ function RefreshFilesButton(props: { isPending: boolean; onRefresh: () => void } ); } +function FileSortButtons(props: { + order: FileTreeSortOrder; + onChange: (order: FileTreeSortOrder) => void; +}) { + const { order, onChange } = props; + const sortsByName = order.key === "name"; + const ascending = order.direction === "asc"; + const keyLabel = sortsByName ? "Sort by date modified" : "Sort by name"; + const directionLabel = ascending ? "Sort descending" : "Sort ascending"; + return ( + <> + + onChange({ ...order, key: sortsByName ? "modified" : "name" })} + /> + } + > + + + {keyLabel} + + + onChange({ ...order, direction: ascending ? "desc" : "asc" })} + /> + } + > + + + {directionLabel} + + + ); +} + function FileSearchField(props: { ariaLabel: string; name: string; @@ -124,13 +187,17 @@ function buildTreeEntries( const entryKinds: ReadonlyMap = new Map( entries.map((entry) => [entry.path, entry.kind] as const), ); + const mtimes = new Map(); + for (const entry of directoryEntries) { + if (entry.mtimeMs !== undefined) mtimes.set(entry.path, entry.mtimeMs); + } const treePaths = entries.map(treePath); const directories = entries.filter((entry) => entry.kind === "directory"); const directoryPaths = directories.map(treePath); // Expanding a folder lists it, and an ignored one such as `node_modules` can hold tens of // thousands, so expand all skips ignored folders. They still open by hand. const expandableDirectoryPaths = directories.filter((entry) => !entry.ignored).map(treePath); - return { entries, entryKinds, treePaths, directoryPaths, expandableDirectoryPaths }; + return { entries, entryKinds, mtimes, treePaths, directoryPaths, expandableDirectoryPaths }; } export default function FileBrowserPanel({ @@ -164,9 +231,22 @@ export default function FileBrowserPanel({ { environmentId, cwd: hostFolder ? null : cwd, query: query.slice(0, 256) }, 200, ); - const { entries, entryKinds, treePaths, directoryPaths, expandableDirectoryPaths } = + const { entries, entryKinds, mtimes, treePaths, directoryPaths, expandableDirectoryPaths } = buildTreeEntries(directoryEntries, pathSearch.entries, pathSearch.isPending, query); + const [sortOrder, setSortOrder] = useLocalStorage( + FILE_BROWSER_SORT_STORAGE_KEY, + DEFAULT_FILE_TREE_SORT_ORDER, + FileTreeSortOrder, + ); + const { key: sortKey, direction: sortDirection } = sortOrder; const entryKindsRef = useRef(entryKinds); + // The tree model is built once, so its comparator reads the latest order and mtimes from here. + const sortComparatorRef = useRef(createFileTreeSortComparator(sortOrder, () => undefined)); + const appliedSortRef = useRef<{ + key: typeof sortKey; + direction: typeof sortDirection; + mtimes: ReadonlyMap; + } | null>(null); const previousTreePathsRef = useRef(null); const syncingSelectionRef = useRef(false); const treeSelectionPathRef = useRef(null); @@ -293,6 +373,7 @@ export default function FileBrowserPanel({ ? { icon: "t3-tree-icon-loading", title: "Loading…" } : null, search: false, + sort: (left, right) => sortComparatorRef.current(left, right), onSearchChange: (value) => setQuery(value ?? ""), unsafeCSS: PIERRE_TREE_UNSAFE_CSS, }); @@ -388,10 +469,21 @@ export default function FileBrowserPanel({ resourceKey: `files:${environmentId}:${cwd}`, }); - // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- applies tree path changes to the imperative Pierre tree model as a reset or batch update + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- applies tree path and sort order changes to the imperative Pierre tree model as a reset or batch update useEffect(() => { if (!ready) return; - if (previousTreePathsRef.current === treePaths) return; + const applied = appliedSortRef.current; + const resort = + applied !== null && + (applied.key !== sortKey || + applied.direction !== sortDirection || + (sortKey === "modified" && hasMtimeChanged(applied.mtimes, mtimes))); + appliedSortRef.current = { key: sortKey, direction: sortDirection, mtimes }; + sortComparatorRef.current = createFileTreeSortComparator( + { key: sortKey, direction: sortDirection }, + (path) => mtimes.get(path), + ); + if (previousTreePathsRef.current === treePaths && !resort) return; entryKindsRef.current = entryKinds; const previousTreePaths = previousTreePathsRef.current; previousTreePathsRef.current = treePaths; @@ -399,9 +491,18 @@ export default function FileBrowserPanel({ model.resetPaths(treePaths); return; } + if (resort) { + // A reset rebuilds the store with every folder closed unless told which stay open. + const expandedDirectoryPaths = directoryPaths.filter((path) => { + const item = model.getItem(path); + return item?.isDirectory() && "isExpanded" in item && item.isExpanded(); + }); + model.resetPaths(treePaths, { initialExpandedPaths: expandedDirectoryPaths }); + return; + } const updates = buildFileTreePathUpdates(previousTreePaths, treePaths); if (updates.length > 0) model.batch(updates); - }, [ready, entryKinds, model, treePaths]); + }, [ready, entryKinds, model, treePaths, directoryPaths, mtimes, sortKey, sortDirection]); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- expands every directory in the Pierre tree model while expand-all is on and no search is active useEffect(() => { @@ -496,6 +597,7 @@ export default function FileBrowserPanel({ onValueChange={handleSearchValueChange} onClose={closeSearch} /> + {directoryPaths.length > 0 && !hostFolder ? ( Date: Wed, 4 Nov 2026 22:55:06 +0100 Subject: [PATCH 04/10] test(web): cover Files panel sorting by name and date modified Co-Authored-By: Claude Sonnet 5.5 --- .../files/FileBrowserPanel.browser.test.tsx | 259 +++++++++++++----- 1 file changed, 191 insertions(+), 68 deletions(-) diff --git a/apps/web/src/components/files/FileBrowserPanel.browser.test.tsx b/apps/web/src/components/files/FileBrowserPanel.browser.test.tsx index 408bda068b604..a5a13be8b3194 100644 --- a/apps/web/src/components/files/FileBrowserPanel.browser.test.tsx +++ b/apps/web/src/components/files/FileBrowserPanel.browser.test.tsx @@ -8,12 +8,15 @@ import { render } from "../../../test/render"; import FileBrowserPanel from "./FileBrowserPanel"; const entries: readonly ProjectEntry[] = [ - { path: "docs", kind: "directory", mtimeMs: 100 }, - { path: "src", kind: "directory", mtimeMs: 200 }, - { path: "src/a.ts", kind: "file", mtimeMs: 10 }, - { path: "src/b.ts", kind: "file", mtimeMs: 20 }, - { path: "README.md", kind: "file", mtimeMs: 50 }, - { path: "zeta.md", kind: "file", mtimeMs: 300 }, + { path: "src", kind: "directory", mtimeMs: 100 }, + { path: ".config", kind: "directory", mtimeMs: 200 }, + { path: "alpha", kind: "directory", mtimeMs: 300 }, + { path: "alpha/a-new.ts", kind: "file", mtimeMs: 900 }, + { path: "alpha/b-old.ts", kind: "file", mtimeMs: 800 }, + { path: "README.md", kind: "file", mtimeMs: 400 }, + { path: ".env", kind: "file", mtimeMs: 200 }, + { path: "b.txt", kind: "file", mtimeMs: 100 }, + { path: "c.txt", kind: "file", mtimeMs: 300 }, ]; const loadingDirectories: ReadonlySet = new Set(); const directory = { @@ -40,14 +43,6 @@ vi.mock("~/fileContextMenu", () => ({ })); vi.mock("~/hooks/useWorkspaceMutationRefresh", () => ({ useWorkspaceMutationRefresh: () => {} })); -function treeRowPaths(): string[] { - for (const host of document.querySelectorAll("*")) { - const rows = host.shadowRoot?.querySelectorAll("[data-item-path]"); - if (rows?.length) return [...rows].map((row) => row.dataset.itemPath!); - } - return []; -} - function findTreeRow(path: string): HTMLElement | null { for (const host of document.querySelectorAll("*")) { const row = host.shadowRoot?.querySelector(`[data-item-path="${path}"]`); @@ -56,6 +51,55 @@ function findTreeRow(path: string): HTMLElement | null { return null; } +function visibleRowPaths(): string[] { + for (const host of document.querySelectorAll("*")) { + const rows = host.shadowRoot?.querySelectorAll("[data-item-path]"); + if (rows?.length) return Array.from(rows, (row) => row.dataset.itemPath ?? ""); + } + return []; +} + +function tooltipTexts(): string[] { + return Array.from( + document.querySelectorAll('[data-slot="tooltip-popup"]'), + (popup) => popup.textContent ?? "", + ); +} + +const SORT_STORAGE_KEY = "t3code:file-browser-sort"; + +function setupSort(): Disposable { + localStorage.removeItem(SORT_STORAGE_KEY); + return { [Symbol.dispose]: () => localStorage.removeItem(SORT_STORAGE_KEY) }; +} + +function renderPanel() { + return render( + {}} + workspaceMutationId={null} + />, + ); +} + +const sortKeyButton = (name: "Sort by date modified" | "Sort by name") => + page.getByRole("button", { name, exact: true }); +const sortDirectionButton = (name: "Sort descending" | "Sort ascending") => + page.getByRole("button", { name, exact: true }); + +const expectRows = (expected: readonly string[]) => + vi.waitFor(() => expect(visibleRowPaths()).toEqual(expected)); + +const NAME_ASC = [".config/", "alpha/", "src/", ".env", "b.txt", "c.txt", "README.md"]; +const NAME_DESC = ["src/", "alpha/", ".config/", "README.md", "c.txt", "b.txt", ".env"]; +const MODIFIED_ASC = ["src/", ".config/", "alpha/", "b.txt", ".env", "c.txt", "README.md"]; +const MODIFIED_DESC = ["alpha/", ".config/", "src/", "README.md", "c.txt", ".env", "b.txt"]; + describe("FileBrowserPanel", () => { it("copies with the pointer and returns to the row after keyboard dismissal", async () => { await using _screen = await render( @@ -94,60 +138,6 @@ describe("FileBrowserPanel", () => { await vi.waitFor(() => expect((row.getRootNode() as ShadowRoot).activeElement).toBe(row)); }); - it("re-sorts by date modified and direction while keeping folders open and the selection", async () => { - using _sortOrder = { - [Symbol.dispose]: () => localStorage.removeItem("t3code:file-browser-sort"), - }; - await using _screen = await render( - {}} - workspaceMutationId={null} - />, - ); - await vi.waitFor(() => expect(findTreeRow("src/")).not.toBeNull()); - expect(treeRowPaths()).toEqual(["docs/", "src/", "README.md", "zeta.md"]); - findTreeRow("src/")!.click(); - await vi.waitFor(() => expect(findTreeRow("src/b.ts")).not.toBeNull()); - findTreeRow("src/b.ts")!.click(); - await vi.waitFor(() => - expect(findTreeRow("src/b.ts")!.getAttribute("aria-selected")).toBe("true"), - ); - - await userEvent.click(page.getByRole("button", { name: "Sort by date modified" })); - await vi.waitFor(() => - expect(treeRowPaths()).toEqual([ - "docs/", - "src/", - "src/a.ts", - "src/b.ts", - "README.md", - "zeta.md", - ]), - ); - expect(findTreeRow("src/b.ts")!.getAttribute("aria-selected")).toBe("true"); - - await userEvent.click(page.getByRole("button", { name: "Sort descending" })); - await expect.element(page.getByRole("button", { name: "Sort ascending" })).toBeVisible(); - await vi.waitFor(() => - expect(treeRowPaths()).toEqual([ - "src/", - "src/b.ts", - "src/a.ts", - "docs/", - "zeta.md", - "README.md", - ]), - ); - expect(localStorage.getItem("t3code:file-browser-sort")).toBe( - '{"key":"modified","direction":"desc"}', - ); - }); - it("dragging a file row carries a composer mention without opening it, and dragend releases it", async () => { const onOpenFile = vi.fn(); await using _screen = await render( @@ -182,3 +172,136 @@ describe("FileBrowserPanel", () => { await vi.waitFor(() => expect(onOpenFile).toHaveBeenCalledTimes(1)); }); }); + +describe("FileBrowserPanel sorting", () => { + it("keeps folders first by name ascending and offers both sort buttons", async () => { + using _sort = setupSort(); + await using _screen = await renderPanel(); + await expectRows(NAME_ASC); + + await expect.element(sortKeyButton("Sort by date modified")).toBeVisible(); + await expect.element(sortDirectionButton("Sort descending")).toBeVisible(); + + await userEvent.hover(sortKeyButton("Sort by date modified")); + await vi.waitFor(() => expect(tooltipTexts()).toContain("Sort by date modified")); + await userEvent.hover(sortDirectionButton("Sort descending")); + await vi.waitFor(() => expect(tooltipTexts()).toContain("Sort descending")); + }); + + it("sorts by date modified, oldest first, with folders before files", async () => { + using _sort = setupSort(); + await using _screen = await renderPanel(); + await expectRows(NAME_ASC); + + await userEvent.click(sortKeyButton("Sort by date modified")); + + await expectRows(MODIFIED_ASC); + await expect.element(sortKeyButton("Sort by name")).toBeVisible(); + await expect.element(sortDirectionButton("Sort descending")).toBeVisible(); + + await userEvent.hover(sortKeyButton("Sort by name")); + await vi.waitFor(() => expect(tooltipTexts()).toContain("Sort by name")); + }); + + it("reverses the order in name mode when the direction is toggled", async () => { + using _sort = setupSort(); + await using _screen = await renderPanel(); + await expectRows(NAME_ASC); + + await userEvent.click(sortDirectionButton("Sort descending")); + await expectRows(NAME_DESC); + await expect.element(sortDirectionButton("Sort ascending")).toBeVisible(); + + await userEvent.click(sortDirectionButton("Sort ascending")); + await expectRows(NAME_ASC); + await expect.element(sortDirectionButton("Sort descending")).toBeVisible(); + }); + + it("reverses the order in modified mode when the direction is toggled", async () => { + using _sort = setupSort(); + await using _screen = await renderPanel(); + await userEvent.click(sortKeyButton("Sort by date modified")); + await expectRows(MODIFIED_ASC); + + await userEvent.click(sortDirectionButton("Sort descending")); + await expectRows(MODIFIED_DESC); + await expect.element(sortDirectionButton("Sort ascending")).toBeVisible(); + + await userEvent.click(sortDirectionButton("Sort ascending")); + await expectRows(MODIFIED_ASC); + }); + + it("returns to name order when the key is toggled back", async () => { + using _sort = setupSort(); + await using _screen = await renderPanel(); + await userEvent.click(sortKeyButton("Sort by date modified")); + await expectRows(MODIFIED_ASC); + + await userEvent.click(sortKeyButton("Sort by name")); + + await expectRows(NAME_ASC); + await expect.element(sortKeyButton("Sort by date modified")).toBeVisible(); + }); + + it("keeps the chosen order after the panel is mounted again", async () => { + using _sort = setupSort(); + { + await using _first = await renderPanel(); + await userEvent.click(sortKeyButton("Sort by date modified")); + await userEvent.click(sortDirectionButton("Sort descending")); + await expectRows(MODIFIED_DESC); + expect(localStorage.getItem(SORT_STORAGE_KEY)).not.toBeNull(); + } + + await using _second = await renderPanel(); + + await expectRows(MODIFIED_DESC); + await expect.element(sortKeyButton("Sort by name")).toBeVisible(); + await expect.element(sortDirectionButton("Sort ascending")).toBeVisible(); + }); + + it("keeps expanded folders open and sorts their children when the order changes", async () => { + using _sort = setupSort(); + await using _screen = await renderPanel(); + await expectRows(NAME_ASC); + + findTreeRow("alpha/")!.click(); + await expectRows([ + ".config/", + "alpha/", + "alpha/a-new.ts", + "alpha/b-old.ts", + "src/", + ".env", + "b.txt", + "c.txt", + "README.md", + ]); + + await userEvent.click(sortKeyButton("Sort by date modified")); + await expectRows([ + "src/", + ".config/", + "alpha/", + "alpha/b-old.ts", + "alpha/a-new.ts", + "b.txt", + ".env", + "c.txt", + "README.md", + ]); + + await userEvent.click(sortDirectionButton("Sort descending")); + await expectRows([ + "alpha/", + "alpha/a-new.ts", + "alpha/b-old.ts", + ".config/", + "src/", + "README.md", + "c.txt", + ".env", + "b.txt", + ]); + }); +}); From 8bdd11047ffef4f819774b1050b160b25d14eb26 Mon Sep 17 00:00:00 2001 From: Max Tesla <58265349+boom-bang@users.noreply.github.com> Date: Wed, 4 Nov 2026 23:01:07 +0100 Subject: [PATCH 05/10] fix(web): keep folders open when the Files panel re-sorts `resetPaths` with `initialExpandedPaths` finds each folder by walking siblings in the library's default order, so under a custom sort it stops early and reopens nothing. Reopen the folders through item handles after the reset instead. Co-Authored-By: Claude Opus 5.5 --- .../files/FileBrowserPanel.browser.test.tsx | 1 + apps/web/src/components/files/FileBrowserPanel.tsx | 11 ++++++++--- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/files/FileBrowserPanel.browser.test.tsx b/apps/web/src/components/files/FileBrowserPanel.browser.test.tsx index a5a13be8b3194..53f6a9a824f6d 100644 --- a/apps/web/src/components/files/FileBrowserPanel.browser.test.tsx +++ b/apps/web/src/components/files/FileBrowserPanel.browser.test.tsx @@ -199,6 +199,7 @@ describe("FileBrowserPanel sorting", () => { await expect.element(sortKeyButton("Sort by name")).toBeVisible(); await expect.element(sortDirectionButton("Sort descending")).toBeVisible(); + await userEvent.unhover(sortKeyButton("Sort by name")); await userEvent.hover(sortKeyButton("Sort by name")); await vi.waitFor(() => expect(tooltipTexts()).toContain("Sort by name")); }); diff --git a/apps/web/src/components/files/FileBrowserPanel.tsx b/apps/web/src/components/files/FileBrowserPanel.tsx index 32dcdb449eaa8..5e75caa5067bd 100644 --- a/apps/web/src/components/files/FileBrowserPanel.tsx +++ b/apps/web/src/components/files/FileBrowserPanel.tsx @@ -492,12 +492,17 @@ export default function FileBrowserPanel({ return; } if (resort) { - // A reset rebuilds the store with every folder closed unless told which stay open. const expandedDirectoryPaths = directoryPaths.filter((path) => { const item = model.getItem(path); - return item?.isDirectory() && "isExpanded" in item && item.isExpanded(); + return item !== null && "isExpanded" in item && item.isExpanded(); }); - model.resetPaths(treePaths, { initialExpandedPaths: expandedDirectoryPaths }); + model.resetPaths(treePaths); + // A reset closes every folder, and `initialExpandedPaths` assumes default sibling order, so + // under a custom sort it misses folders. Reopening through item handles works in any order. + for (const path of expandedDirectoryPaths) { + const item = model.getItem(path); + if (item && "expand" in item) item.expand(); + } return; } const updates = buildFileTreePathUpdates(previousTreePaths, treePaths); From 3858bfcb4bbdf4371789cb7ed2e112c6310bf033 Mon Sep 17 00:00:00 2001 From: Max Tesla <58265349+boom-bang@users.noreply.github.com> Date: Wed, 4 Nov 2026 23:05:42 +0100 Subject: [PATCH 06/10] fix(web): re-sort when timestamps appear or disappear The resort check only compared timestamps present before and after, so a refresh that first delivered an mtime, or lost one, left the modified order stale. Co-Authored-By: Claude Opus 5.5 --- .../files/FileBrowserPanel.browser.test.tsx | 42 +++++++++++++++++++ .../src/components/files/FileBrowserPanel.tsx | 4 +- 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/files/FileBrowserPanel.browser.test.tsx b/apps/web/src/components/files/FileBrowserPanel.browser.test.tsx index 53f6a9a824f6d..f425d97b479a2 100644 --- a/apps/web/src/components/files/FileBrowserPanel.browser.test.tsx +++ b/apps/web/src/components/files/FileBrowserPanel.browser.test.tsx @@ -305,4 +305,46 @@ describe("FileBrowserPanel sorting", () => { "b.txt", ]); }); + + it("re-sorts by date modified when a refresh adds or drops timestamps", async () => { + using _sort = setupSort(); + using _entries = { + [Symbol.dispose]: () => { + directory.entries = entries; + }, + }; + const rerenderPanel = (screen: Awaited>) => + screen.rerender( + {}} + workspaceMutationId={null} + />, + ); + directory.entries = [ + { path: "a.txt", kind: "file" }, + { path: "z.txt", kind: "file" }, + ]; + await using screen = await renderPanel(); + await userEvent.click(sortKeyButton("Sort by date modified")); + await expectRows(["a.txt", "z.txt"]); + + directory.entries = [ + { path: "a.txt", kind: "file", mtimeMs: 20 }, + { path: "z.txt", kind: "file", mtimeMs: 10 }, + ]; + await rerenderPanel(screen); + await expectRows(["z.txt", "a.txt"]); + + directory.entries = [ + { path: "a.txt", kind: "file", mtimeMs: 5 }, + { path: "z.txt", kind: "file" }, + ]; + await rerenderPanel(screen); + await expectRows(["a.txt", "z.txt"]); + }); }); diff --git a/apps/web/src/components/files/FileBrowserPanel.tsx b/apps/web/src/components/files/FileBrowserPanel.tsx index 5e75caa5067bd..acdcd6bcd3929 100644 --- a/apps/web/src/components/files/FileBrowserPanel.tsx +++ b/apps/web/src/components/files/FileBrowserPanel.tsx @@ -62,9 +62,9 @@ function treePath(entry: ProjectEntry): string { const FILE_BROWSER_SORT_STORAGE_KEY = "t3code:file-browser-sort"; function hasMtimeChanged(previous: ReadonlyMap, next: ReadonlyMap) { + if (previous.size !== next.size) return true; for (const [path, mtimeMs] of previous) { - const nextMtimeMs = next.get(path); - if (nextMtimeMs !== undefined && nextMtimeMs !== mtimeMs) return true; + if (next.get(path) !== mtimeMs) return true; } return false; } From 1952145c61bd9ae0c3755ac09e698c515532f46c Mon Sep 17 00:00:00 2001 From: Max Tesla <58265349+boom-bang@users.noreply.github.com> Date: Wed, 4 Nov 2026 23:18:11 +0100 Subject: [PATCH 07/10] perf(server): stat entry mtimes only when the Files panel sorts by date projects.listEntries stated every child on every listing, which cost about 650ms instead of 30ms on a 40,000-file directory even though the default name sort never reads mtimes. Add an optional includeModifiedTimes input. The server stats children only when it is true, and the Files panel sends it while sorted by date modified, re-listing loaded folders when it turns on. Co-Authored-By: Claude Sonnet 5.5 --- .../src/workspace/WorkspaceEntries.test.ts | 60 ++++++++-------- apps/server/src/workspace/WorkspaceEntries.ts | 13 +++- .../files/FileBrowserPanel.browser.test.tsx | 20 +++++- .../src/components/files/FileBrowserPanel.tsx | 14 ++-- .../useDirectoryEntries.browser.test.tsx | 71 +++++++++++++++++-- .../components/files/useDirectoryEntries.ts | 24 ++++++- packages/contracts/src/project.ts | 1 + 7 files changed, 156 insertions(+), 47 deletions(-) diff --git a/apps/server/src/workspace/WorkspaceEntries.test.ts b/apps/server/src/workspace/WorkspaceEntries.test.ts index 12e58419841f6..753813ea265e7 100644 --- a/apps/server/src/workspace/WorkspaceEntries.test.ts +++ b/apps/server/src/workspace/WorkspaceEntries.test.ts @@ -121,38 +121,29 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceEntries", (it) => { const root = yield* workspaceEntries.list({ cwd, directoryPath: "" }); expect(root.entries).toEqual( expect.arrayContaining([ - { path: ".env", kind: "file", ignored: true, mtimeMs: expect.any(Number) }, - { path: "node_modules", kind: "directory", ignored: true, mtimeMs: expect.any(Number) }, - { path: "src", kind: "directory", mtimeMs: expect.any(Number) }, - { path: "empty", kind: "directory", mtimeMs: expect.any(Number) }, - { path: "tracked.txt", kind: "file", mtimeMs: expect.any(Number) }, + { path: ".env", kind: "file", ignored: true }, + { path: "node_modules", kind: "directory", ignored: true }, + { path: "src", kind: "directory" }, + { path: "empty", kind: "directory" }, + { path: "tracked.txt", kind: "file" }, ]), ); expect(root.entries.some((entry) => entry.path.includes("/"))).toBe(false); expect(root.entries.some((entry) => entry.path === ".git")).toBe(false); expect(root.truncated).toBe(false); expect(yield* workspaceEntries.list({ cwd, directoryPath: "node_modules/pkg" })).toEqual({ - entries: [ - { - path: "node_modules/pkg/index.js", - kind: "file", - ignored: true, - mtimeMs: expect.any(Number), - }, - ], + entries: [{ path: "node_modules/pkg/index.js", kind: "file", ignored: true }], truncated: false, }); // Git refuses pathspecs inside a submodule from the outer repository. expect((yield* workspaceEntries.list({ cwd, directoryPath: "sub" })).entries).toEqual( expect.arrayContaining([ - { path: "sub/.gitignore", kind: "file", mtimeMs: expect.any(Number) }, - { path: "sub/dist", kind: "directory", ignored: true, mtimeMs: expect.any(Number) }, + { path: "sub/.gitignore", kind: "file" }, + { path: "sub/dist", kind: "directory", ignored: true }, ]), ); expect(yield* workspaceEntries.list({ cwd, directoryPath: "sub/dist" })).toEqual({ - entries: [ - { path: "sub/dist/out.js", kind: "file", ignored: true, mtimeMs: expect.any(Number) }, - ], + entries: [{ path: "sub/dist/out.js", kind: "file", ignored: true }], truncated: false, }); expect(yield* workspaceEntries.list({ cwd, directoryPath: "empty" })).toEqual({ @@ -182,19 +173,19 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceEntries", (it) => { const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; const root = yield* workspaceEntries.list({ cwd, directoryPath: "" }); expect(root.entries.toSorted((a, b) => a.path.localeCompare(b.path))).toEqual([ - { path: "link.txt", kind: "file", mtimeMs: expect.any(Number) }, - { path: "linkdir", kind: "directory", mtimeMs: expect.any(Number) }, - { path: "real.txt", kind: "file", mtimeMs: expect.any(Number) }, - { path: "realdir", kind: "directory", mtimeMs: expect.any(Number) }, + { path: "link.txt", kind: "file" }, + { path: "linkdir", kind: "directory" }, + { path: "real.txt", kind: "file" }, + { path: "realdir", kind: "directory" }, ]); expect(yield* workspaceEntries.list({ cwd, directoryPath: "linkdir" })).toEqual({ - entries: [{ path: "linkdir/nested.txt", kind: "file", mtimeMs: expect.any(Number) }], + entries: [{ path: "linkdir/nested.txt", kind: "file" }], truncated: false, }); }), ); - it.effect("reports the modified time of each entry, following links to their target", () => + it.effect("reports modified times only when asked, following links to their target", () => Effect.gen(function* () { const platform = yield* HostProcess.Platform; if (platform === "win32") return; @@ -208,7 +199,7 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceEntries", (it) => { const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; const mtimes = () => workspaceEntries - .list({ cwd, directoryPath: "" }) + .list({ cwd, directoryPath: "", includeModifiedTimes: true }) .pipe( Effect.map(({ entries }) => Object.fromEntries(entries.map((entry) => [entry.path, entry.mtimeMs])), @@ -228,16 +219,29 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceEntries", (it) => { }); const stat = NodeFSP.stat; - vi.spyOn(NodeFSP, "stat").mockImplementation((async (target: string) => { + const statSpy = vi.spyOn(NodeFSP, "stat").mockImplementation((async (target: string) => { if (target.endsWith("unreadable.txt")) throw new Error("EACCES"); return stat(target); }) as typeof NodeFSP.stat); - const { entries } = yield* workspaceEntries.list({ cwd, directoryPath: "" }); + const { entries } = yield* workspaceEntries.list({ + cwd, + directoryPath: "", + includeModifiedTimes: true, + }); expect(entries.find((entry) => entry.path === "unreadable.txt")).toEqual({ path: "unreadable.txt", kind: "file", }); expect(entries).toHaveLength(4); + + statSpy.mockClear(); + for (const input of [{}, { includeModifiedTimes: false }]) { + const plain = yield* workspaceEntries.list({ cwd, directoryPath: "", ...input }); + expect(plain.entries.some((entry) => "mtimeMs" in entry)).toBe(false); + expect(plain.entries).toHaveLength(4); + } + // Only the symlink's existing target stat remains, once per listing. + expect(statSpy).toHaveBeenCalledTimes(2); }), ); @@ -394,7 +398,7 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceEntries", (it) => { const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; expect(yield* workspaceEntries.list({ cwd: linked, directoryPath: "" })).toEqual({ - entries: [{ path: "notes.md", kind: "file", mtimeMs: expect.any(Number) }], + entries: [{ path: "notes.md", kind: "file" }], truncated: false, }); const up = yield* workspaceEntries.list({ cwd: parent, directoryPath: "" }); diff --git a/apps/server/src/workspace/WorkspaceEntries.ts b/apps/server/src/workspace/WorkspaceEntries.ts index d4e2f7c7c83bb..fda3af632a7f2 100644 --- a/apps/server/src/workspace/WorkspaceEntries.ts +++ b/apps/server/src/workspace/WorkspaceEntries.ts @@ -117,7 +117,7 @@ const IGNORED_FILES_LIMIT = 200; /** Paths sent to one `git check-ignore` call when labelling a folder's children. */ const IGNORE_CHECK_CHUNK_SIZE = 1000; -/** Children classified at once when listing a folder; each costs a stat and each symlink a realpath. */ +/** Children classified at once when listing a folder; each symlink costs a realpath and a stat, and each child a stat when mtimes are requested. */ const SYMLINK_RESOLVE_BATCH_SIZE = 64; interface ListedChild { @@ -384,7 +384,12 @@ export const make = Effect.gen(function* () { ? "file" : undefined; if (kind === undefined) return undefined; - return { kind, mtimeMs: await statMtime(child.name) }; + return { + kind, + mtimeMs: input.includeModifiedTimes + ? await statMtime(child.name) + : undefined, + }; }), )), ); @@ -407,7 +412,9 @@ export const make = Effect.gen(function* () { { path: target.relativePath ? `${target.relativePath}/${child.name}` : child.name, kind: item.kind, - ...(item.mtimeMs === undefined ? {} : { mtimeMs: item.mtimeMs }), + ...(input.includeModifiedTimes && item.mtimeMs !== undefined + ? { mtimeMs: item.mtimeMs } + : {}), }, ]; }), diff --git a/apps/web/src/components/files/FileBrowserPanel.browser.test.tsx b/apps/web/src/components/files/FileBrowserPanel.browser.test.tsx index f425d97b479a2..d89a595665c32 100644 --- a/apps/web/src/components/files/FileBrowserPanel.browser.test.tsx +++ b/apps/web/src/components/files/FileBrowserPanel.browser.test.tsx @@ -36,7 +36,9 @@ const pathSearch = { refresh: () => {}, }; -vi.mock("./useDirectoryEntries", () => ({ useDirectoryEntries: () => directory })); +const { useDirectoryEntries } = vi.hoisted(() => ({ useDirectoryEntries: vi.fn() })); +useDirectoryEntries.mockImplementation(() => directory); +vi.mock("./useDirectoryEntries", () => ({ useDirectoryEntries })); vi.mock("~/state/queries", () => ({ useProjectPathSearch: () => pathSearch })); vi.mock("~/fileContextMenu", () => ({ useFileContextMenu: () => ({ buildItems: () => [], activate: () => Promise.resolve() }), @@ -306,6 +308,22 @@ describe("FileBrowserPanel sorting", () => { ]); }); + it("asks for modified times only while sorting by date modified", async () => { + using _sort = setupSort(); + const requested = () => useDirectoryEntries.mock.lastCall?.[2]; + await using _screen = await renderPanel(); + await expectRows(NAME_ASC); + expect(requested()).toBe(false); + + await userEvent.click(sortKeyButton("Sort by date modified")); + await expectRows(MODIFIED_ASC); + expect(requested()).toBe(true); + + await userEvent.click(sortKeyButton("Sort by name")); + await expectRows(NAME_ASC); + expect(requested()).toBe(false); + }); + it("re-sorts by date modified when a refresh adds or drops timestamps", async () => { using _sort = setupSort(); using _entries = { diff --git a/apps/web/src/components/files/FileBrowserPanel.tsx b/apps/web/src/components/files/FileBrowserPanel.tsx index acdcd6bcd3929..1074a64bbeee3 100644 --- a/apps/web/src/components/files/FileBrowserPanel.tsx +++ b/apps/web/src/components/files/FileBrowserPanel.tsx @@ -216,6 +216,12 @@ export default function FileBrowserPanel({ const { resolvedTheme } = useTheme(); const composerRef = useComposerHandleContext(); const fileContextMenu = useFileContextMenu(environmentId); + const [sortOrder, setSortOrder] = useLocalStorage( + FILE_BROWSER_SORT_STORAGE_KEY, + DEFAULT_FILE_TREE_SORT_ORDER, + FileTreeSortOrder, + ); + const { key: sortKey, direction: sortDirection } = sortOrder; const { entries: directoryEntries, load, @@ -224,7 +230,7 @@ export default function FileBrowserPanel({ error, isPending, loadingDirectories, - } = useDirectoryEntries(environmentId, cwd); + } = useDirectoryEntries(environmentId, cwd, sortKey === "modified"); const [query, setQuery] = useState(""); const [expandAll, setExpandAll] = useState(false); const pathSearch = useProjectPathSearch( @@ -233,12 +239,6 @@ export default function FileBrowserPanel({ ); const { entries, entryKinds, mtimes, treePaths, directoryPaths, expandableDirectoryPaths } = buildTreeEntries(directoryEntries, pathSearch.entries, pathSearch.isPending, query); - const [sortOrder, setSortOrder] = useLocalStorage( - FILE_BROWSER_SORT_STORAGE_KEY, - DEFAULT_FILE_TREE_SORT_ORDER, - FileTreeSortOrder, - ); - const { key: sortKey, direction: sortDirection } = sortOrder; const entryKindsRef = useRef(entryKinds); // The tree model is built once, so its comparator reads the latest order and mtimes from here. const sortComparatorRef = useRef(createFileTreeSortComparator(sortOrder, () => undefined)); diff --git a/apps/web/src/components/files/useDirectoryEntries.browser.test.tsx b/apps/web/src/components/files/useDirectoryEntries.browser.test.tsx index 046563bcb22f4..c11c56d166066 100644 --- a/apps/web/src/components/files/useDirectoryEntries.browser.test.tsx +++ b/apps/web/src/components/files/useDirectoryEntries.browser.test.tsx @@ -44,11 +44,19 @@ function setup() { mocks.reachable = Atom.make(false); mocks.connectedSession = Atom.make(1); let view!: ReturnType; - const calls: Array<{ path: string; resolve: (entries: readonly ProjectEntry[]) => void }> = []; + const calls: Array<{ + path: string; + includeModifiedTimes: boolean | undefined; + resolve: (entries: readonly ProjectEntry[]) => void; + }> = []; let activeQueries = 0; let peakQueries = 0; - function HookProbe() { - const result = useDirectoryEntries(EnvironmentId.make("files-test"), "/workspace"); + function HookProbe(props: { includeModifiedTimes: boolean }) { + const result = useDirectoryEntries( + EnvironmentId.make("files-test"), + "/workspace", + props.includeModifiedTimes, + ); useEffect(() => { view = result; }); @@ -65,6 +73,7 @@ function setup() { return new Promise((resolve) => { calls.push({ path: request.input.directoryPath, + includeModifiedTimes: request.input.includeModifiedTimes, resolve: (entries) => { activeQueries--; resolve({ entries }); @@ -73,9 +82,9 @@ function setup() { }); }); return { - Probe: () => ( + Probe: (props: { includeModifiedTimes?: boolean }) => ( - + ), settle, @@ -191,7 +200,7 @@ it("does not start a queued follow-up after unmount", async () => { }); function ReconnectProbe() { - const directory = useDirectoryEntries(EnvironmentId.make("env-files"), "/repo"); + const directory = useDirectoryEntries(EnvironmentId.make("env-files"), "/repo", false); return ( {directory.error ?? directory.entries.map((entry) => entry.path).join(",")} ); @@ -214,3 +223,53 @@ it("reads a folder that failed while unreachable again once a new session is up" }); await expect.element(screen.getByText("README.md")).toBeInTheDocument(); }); + +it("sends the modified-time flag in every listing request", async () => { + const state = setup(); + // The hook probe does not navigate. + await using _screen = await render(, { router: false }); + await state.settle(0, [{ path: "src", kind: "directory" }]); + await act(async () => void state.view.load("src")); + await state.settle(1); + expect(state.calls.map((call) => [call.path, call.includeModifiedTimes])).toEqual([ + ["", true], + ["src", true], + ]); +}); + +it("lists loaded folders again when modified times are first needed", async () => { + const state = setup(); + // The hook probe does not navigate. + await using screen = await render(, { router: false }); + await state.settle(0, [{ path: "src", kind: "directory" }]); + await act(async () => void state.view.load("src")); + await state.settle(1, [{ path: "src/a.ts", kind: "file" }]); + expect(state.calls.map((call) => call.includeModifiedTimes)).toEqual([false, false]); + + await screen.rerender(); + expect( + state.calls + .slice(2) + .map((call) => [call.path, call.includeModifiedTimes]) + .toSorted(), + ).toEqual([ + ["", true], + ["src", true], + ]); + await state.settle(2, [{ path: "src", kind: "directory", mtimeMs: 10 }]); + await state.settle(3, [{ path: "src/a.ts", kind: "file", mtimeMs: 20 }]); + expect(state.view.entries.map((entry) => entry.mtimeMs)).toEqual([10, 20]); + + await screen.rerender(); + expect(state.calls).toHaveLength(4); +}); + +it("lists a folder with modified times when the flag turns on during its listing", async () => { + const state = setup(); + // The hook probe does not navigate. + await using screen = await render(, { router: false }); + await screen.rerender(); + await state.settle(0); + await state.settle(1); + expect(state.calls.map((call) => call.includeModifiedTimes)).toEqual([false, true]); +}); diff --git a/apps/web/src/components/files/useDirectoryEntries.ts b/apps/web/src/components/files/useDirectoryEntries.ts index 32aa2fe466753..f1301a25cd159 100644 --- a/apps/web/src/components/files/useDirectoryEntries.ts +++ b/apps/web/src/components/files/useDirectoryEntries.ts @@ -8,7 +8,11 @@ import { environmentCatalog } from "~/connection/catalog"; import { appAtomRegistry } from "~/rpc/atomRegistry"; import { projectEnvironment } from "~/state/projects"; -export function useDirectoryEntries(environmentId: EnvironmentId, cwd: string) { +export function useDirectoryEntries( + environmentId: EnvironmentId, + cwd: string, + includeModifiedTimes: boolean, +) { const [directories, setDirectories] = useState(new Map()); const [errors, setErrors] = useState(new Map()); const [pending, setPending] = useState(1); @@ -22,11 +26,20 @@ export function useDirectoryEntries(environmentId: EnvironmentId, cwd: string) { const active = useRef(true); const running = useRef(0); const waiting = useRef void>>([]); + // Read when a request starts so a queued follow-up refresh sees the latest value, not its render's. + const includeModifiedTimesRef = useRef(includeModifiedTimes); const startRequest = (directoryPath: string, refresh: boolean): Promise => { loaded.current.add(directoryPath); requested.current.add(directoryPath); - const atom = projectEnvironment.listEntries({ environmentId, input: { cwd, directoryPath } }); + const atom = projectEnvironment.listEntries({ + environmentId, + input: { + cwd, + directoryPath, + includeModifiedTimes: includeModifiedTimesRef.current, + }, + }); const request = (async () => { if (running.current >= 4) await new Promise((resolve) => waiting.current.push(resolve)); else running.current++; @@ -145,6 +158,13 @@ export function useDirectoryEntries(environmentId: EnvironmentId, cwd: string) { } }; + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- folders listed without mtimes must be listed again once a date sort asks for them + useEffect(() => { + if (includeModifiedTimesRef.current === includeModifiedTimes) return; + includeModifiedTimesRef.current = includeModifiedTimes; + if (includeModifiedTimes) refresh(); + }, [includeModifiedTimes, refresh]); + return { entries, load, diff --git a/packages/contracts/src/project.ts b/packages/contracts/src/project.ts index 6f810f253291f..3949128cf1c54 100644 --- a/packages/contracts/src/project.ts +++ b/packages/contracts/src/project.ts @@ -294,6 +294,7 @@ export type ProjectSearchContentsResult = typeof ProjectSearchContentsResult.Typ export const ProjectListEntriesInput = Schema.Struct({ cwd: TrimmedNonEmptyString, directoryPath: Schema.optional(TrimmedString), + includeModifiedTimes: Schema.optional(Schema.Boolean), }); export type ProjectListEntriesInput = typeof ProjectListEntriesInput.Type; From 207ab14d3f4030943db3e2309fc1f7188ac3c27c Mon Sep 17 00:00:00 2001 From: Max Tesla <58265349+boom-bang@users.noreply.github.com> Date: Wed, 4 Nov 2026 23:40:01 +0100 Subject: [PATCH 08/10] chore: drop comments that restate the Files panel sort code Co-Authored-By: Claude Opus 5.5 --- apps/server/src/workspace/WorkspaceEntries.test.ts | 1 - apps/server/src/workspace/WorkspaceEntries.ts | 3 +-- apps/web/src/components/files/fileTreeSort.ts | 1 - apps/web/src/components/files/useDirectoryEntries.ts | 1 - 4 files changed, 1 insertion(+), 5 deletions(-) diff --git a/apps/server/src/workspace/WorkspaceEntries.test.ts b/apps/server/src/workspace/WorkspaceEntries.test.ts index 753813ea265e7..6b5abcdc34308 100644 --- a/apps/server/src/workspace/WorkspaceEntries.test.ts +++ b/apps/server/src/workspace/WorkspaceEntries.test.ts @@ -240,7 +240,6 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceEntries", (it) => { expect(plain.entries.some((entry) => "mtimeMs" in entry)).toBe(false); expect(plain.entries).toHaveLength(4); } - // Only the symlink's existing target stat remains, once per listing. expect(statSpy).toHaveBeenCalledTimes(2); }), ); diff --git a/apps/server/src/workspace/WorkspaceEntries.ts b/apps/server/src/workspace/WorkspaceEntries.ts index fda3af632a7f2..2096b7ee3b6cf 100644 --- a/apps/server/src/workspace/WorkspaceEntries.ts +++ b/apps/server/src/workspace/WorkspaceEntries.ts @@ -117,7 +117,7 @@ const IGNORED_FILES_LIMIT = 200; /** Paths sent to one `git check-ignore` call when labelling a folder's children. */ const IGNORE_CHECK_CHUNK_SIZE = 1000; -/** Children classified at once when listing a folder; each symlink costs a realpath and a stat, and each child a stat when mtimes are requested. */ +/** Children classified at once when listing a folder; each symlink costs a realpath and a stat. */ const SYMLINK_RESOLVE_BATCH_SIZE = 64; interface ListedChild { @@ -365,7 +365,6 @@ export const make = Effect.gen(function* () { ? undefined : { kind: "directory", mtimeMs: stat.mtimeMs }; }; - // A failed stat only loses the timestamp, never the entry. const statMtime = async (name: string) => (await NodeFSP.stat(path.join(directory, name)).catch(() => undefined))?.mtimeMs; // Bound filesystem work for directories such as pnpm node_modules. diff --git a/apps/web/src/components/files/fileTreeSort.ts b/apps/web/src/components/files/fileTreeSort.ts index 358969b2f1da4..0ba4eb5aac493 100644 --- a/apps/web/src/components/files/fileTreeSort.ts +++ b/apps/web/src/components/files/fileTreeSort.ts @@ -52,7 +52,6 @@ function compareSegmentSortKeys(left: SegmentSortKey, right: SegmentSortKey): nu return 0; } -// lookupMtime receives the entry path with any trailing "/" removed. export function createFileTreeSortComparator( order: FileTreeSortOrder, lookupMtime: (path: string) => number | undefined, diff --git a/apps/web/src/components/files/useDirectoryEntries.ts b/apps/web/src/components/files/useDirectoryEntries.ts index f1301a25cd159..f998304672c98 100644 --- a/apps/web/src/components/files/useDirectoryEntries.ts +++ b/apps/web/src/components/files/useDirectoryEntries.ts @@ -26,7 +26,6 @@ export function useDirectoryEntries( const active = useRef(true); const running = useRef(0); const waiting = useRef void>>([]); - // Read when a request starts so a queued follow-up refresh sees the latest value, not its render's. const includeModifiedTimesRef = useRef(includeModifiedTimes); const startRequest = (directoryPath: string, refresh: boolean): Promise => { From 29e19618029e807420b386fdc566630fc15c3f79 Mon Sep 17 00:00:00 2001 From: Max Tesla <58265349+boom-bang@users.noreply.github.com> Date: Fri, 6 Nov 2026 14:40:15 +0100 Subject: [PATCH 09/10] fix(web): keep folder, search and focus state when the Files panel re-sorts by date Review fixes for the date-modified sort: - Opening a folder in date mode no longer rebuilds the whole tree. Only a changed timestamp on an entry that was already listed forces a re-sort; new entries are inserted in place with the current comparator. - A re-sort no longer turns "Expand all folders" off, no longer reopens a parent the user collapsed, re-runs an open search so new matches show, and keeps keyboard focus on the row it was on. - Tests follow docs/internals/testing.md: tooltip copy and mock-call assertions are gone, the useDirectoryEntries mock returns timestamps only when asked, and the re-sort decision is unit tested in fileTreeSort. Co-Authored-By: Claude Opus 5.5 --- .../files/FileBrowserPanel.browser.test.tsx | 244 ++++++++++++------ .../src/components/files/FileBrowserPanel.tsx | 67 +++-- .../src/components/files/fileTreeSort.test.ts | 30 +++ apps/web/src/components/files/fileTreeSort.ts | 15 ++ .../useDirectoryEntries.browser.test.tsx | 30 +-- 5 files changed, 271 insertions(+), 115 deletions(-) diff --git a/apps/web/src/components/files/FileBrowserPanel.browser.test.tsx b/apps/web/src/components/files/FileBrowserPanel.browser.test.tsx index d89a595665c32..2a126213c1323 100644 --- a/apps/web/src/components/files/FileBrowserPanel.browser.test.tsx +++ b/apps/web/src/components/files/FileBrowserPanel.browser.test.tsx @@ -37,7 +37,12 @@ const pathSearch = { }; const { useDirectoryEntries } = vi.hoisted(() => ({ useDirectoryEntries: vi.fn() })); -useDirectoryEntries.mockImplementation(() => directory); +useDirectoryEntries.mockImplementation( + (_environmentId: EnvironmentId, _cwd: string, includeModifiedTimes: boolean) => + includeModifiedTimes + ? directory + : { ...directory, entries: directory.entries.map(({ mtimeMs: _, ...entry }) => entry) }, +); vi.mock("./useDirectoryEntries", () => ({ useDirectoryEntries })); vi.mock("~/state/queries", () => ({ useProjectPathSearch: () => pathSearch })); vi.mock("~/fileContextMenu", () => ({ @@ -61,13 +66,6 @@ function visibleRowPaths(): string[] { return []; } -function tooltipTexts(): string[] { - return Array.from( - document.querySelectorAll('[data-slot="tooltip-popup"]'), - (popup) => popup.textContent ?? "", - ); -} - const SORT_STORAGE_KEY = "t3code:file-browser-sort"; function setupSort(): Disposable { @@ -75,18 +73,29 @@ function setupSort(): Disposable { return { [Symbol.dispose]: () => localStorage.removeItem(SORT_STORAGE_KEY) }; } +const panel = () => ( + {}} + workspaceMutationId={null} + /> +); + function renderPanel() { - return render( - {}} - workspaceMutationId={null} - />, - ); + return render(panel()); +} + +function setEntries(next: readonly ProjectEntry[]): Disposable { + directory.entries = next; + return { + [Symbol.dispose]: () => { + directory.entries = entries; + }, + }; } const sortKeyButton = (name: "Sort by date modified" | "Sort by name") => @@ -176,20 +185,6 @@ describe("FileBrowserPanel", () => { }); describe("FileBrowserPanel sorting", () => { - it("keeps folders first by name ascending and offers both sort buttons", async () => { - using _sort = setupSort(); - await using _screen = await renderPanel(); - await expectRows(NAME_ASC); - - await expect.element(sortKeyButton("Sort by date modified")).toBeVisible(); - await expect.element(sortDirectionButton("Sort descending")).toBeVisible(); - - await userEvent.hover(sortKeyButton("Sort by date modified")); - await vi.waitFor(() => expect(tooltipTexts()).toContain("Sort by date modified")); - await userEvent.hover(sortDirectionButton("Sort descending")); - await vi.waitFor(() => expect(tooltipTexts()).toContain("Sort descending")); - }); - it("sorts by date modified, oldest first, with folders before files", async () => { using _sort = setupSort(); await using _screen = await renderPanel(); @@ -200,10 +195,6 @@ describe("FileBrowserPanel sorting", () => { await expectRows(MODIFIED_ASC); await expect.element(sortKeyButton("Sort by name")).toBeVisible(); await expect.element(sortDirectionButton("Sort descending")).toBeVisible(); - - await userEvent.unhover(sortKeyButton("Sort by name")); - await userEvent.hover(sortKeyButton("Sort by name")); - await vi.waitFor(() => expect(tooltipTexts()).toContain("Sort by name")); }); it("reverses the order in name mode when the direction is toggled", async () => { @@ -308,61 +299,158 @@ describe("FileBrowserPanel sorting", () => { ]); }); - it("asks for modified times only while sorting by date modified", async () => { - using _sort = setupSort(); - const requested = () => useDirectoryEntries.mock.lastCall?.[2]; - await using _screen = await renderPanel(); - await expectRows(NAME_ASC); - expect(requested()).toBe(false); - - await userEvent.click(sortKeyButton("Sort by date modified")); - await expectRows(MODIFIED_ASC); - expect(requested()).toBe(true); - - await userEvent.click(sortKeyButton("Sort by name")); - await expectRows(NAME_ASC); - expect(requested()).toBe(false); - }); - it("re-sorts by date modified when a refresh adds or drops timestamps", async () => { using _sort = setupSort(); - using _entries = { - [Symbol.dispose]: () => { - directory.entries = entries; - }, - }; - const rerenderPanel = (screen: Awaited>) => - screen.rerender( - {}} - workspaceMutationId={null} - />, - ); - directory.entries = [ + using _entries = setEntries([ { path: "a.txt", kind: "file" }, { path: "z.txt", kind: "file" }, - ]; + ]); await using screen = await renderPanel(); await userEvent.click(sortKeyButton("Sort by date modified")); await expectRows(["a.txt", "z.txt"]); - directory.entries = [ + setEntries([ { path: "a.txt", kind: "file", mtimeMs: 20 }, { path: "z.txt", kind: "file", mtimeMs: 10 }, - ]; - await rerenderPanel(screen); + ]); + await screen.rerender(panel()); await expectRows(["z.txt", "a.txt"]); - directory.entries = [ + setEntries([ { path: "a.txt", kind: "file", mtimeMs: 5 }, { path: "z.txt", kind: "file" }, - ]; - await rerenderPanel(screen); + ]); + await screen.rerender(panel()); await expectRows(["a.txt", "z.txt"]); }); + + it("sorts a folder's children by date modified when they load", async () => { + using _sort = setupSort(); + const root: readonly ProjectEntry[] = [ + { path: "alpha", kind: "directory", mtimeMs: 1 }, + { path: "zeta.txt", kind: "file", mtimeMs: 2 }, + ]; + using _entries = setEntries(root); + await using screen = await renderPanel(); + await userEvent.click(sortKeyButton("Sort by date modified")); + await userEvent.click(sortDirectionButton("Sort descending")); + findTreeRow("alpha/")!.click(); + + setEntries([ + ...root, + { path: "alpha/a.ts", kind: "file", mtimeMs: 10 }, + { path: "alpha/b.ts", kind: "file", mtimeMs: 30 }, + { path: "alpha/c.ts", kind: "file", mtimeMs: 20 }, + ]); + await screen.rerender(panel()); + + await expectRows(["alpha/", "alpha/b.ts", "alpha/c.ts", "alpha/a.ts", "zeta.txt"]); + }); + + it("keeps expanding all folders after a re-sort by date modified", async () => { + using _sort = setupSort(); + const loaded: ProjectEntry[] = [ + { path: "alpha", kind: "directory", mtimeMs: 1 }, + { path: "alpha/a.ts", kind: "file", mtimeMs: 1 }, + { path: "beta", kind: "directory", mtimeMs: 2 }, + { path: "beta/b.ts", kind: "file", mtimeMs: 2 }, + ]; + using _entries = setEntries(loaded); + await using screen = await renderPanel(); + await userEvent.click(sortKeyButton("Sort by date modified")); + await userEvent.click(page.getByRole("button", { name: "Expand all folders", exact: true })); + await expectRows(["alpha/", "alpha/a.ts", "beta/", "beta/b.ts"]); + + loaded[0] = { path: "alpha", kind: "directory", mtimeMs: 3 }; + setEntries([...loaded]); + await screen.rerender(panel()); + await expectRows(["beta/", "beta/b.ts", "alpha/", "alpha/a.ts"]); + + setEntries([ + ...loaded, + { path: "alpha/inner", kind: "directory", mtimeMs: 4 }, + { path: "alpha/inner/x.ts", kind: "file", mtimeMs: 4 }, + ]); + await screen.rerender(panel()); + + await expectRows([ + "beta/", + "beta/b.ts", + "alpha/", + "alpha/inner/", + "alpha/inner/x.ts", + "alpha/a.ts", + ]); + }); + + it("keeps a collapsed parent closed over an open child when the order changes", async () => { + using _sort = setupSort(); + const tree: ProjectEntry[] = [ + { path: "alpha", kind: "directory", mtimeMs: 1 }, + { path: "alpha/inner", kind: "directory", mtimeMs: 1 }, + { path: "alpha/inner/x.ts", kind: "file", mtimeMs: 1 }, + { path: "alpha/z.ts", kind: "file", mtimeMs: 1 }, + { path: "beta.txt", kind: "file", mtimeMs: 2 }, + ]; + using _entries = setEntries(tree); + await using screen = await renderPanel(); + await userEvent.click(sortKeyButton("Sort by date modified")); + findTreeRow("alpha/")!.click(); + await vi.waitFor(() => expect(findTreeRow("alpha/inner/")).not.toBeNull()); + findTreeRow("alpha/inner/")!.click(); + await vi.waitFor(() => expect(findTreeRow("alpha/inner/x.ts")).not.toBeNull()); + findTreeRow("alpha/")!.click(); + await expectRows(["alpha/", "beta.txt"]); + + tree[4] = { path: "beta.txt", kind: "file", mtimeMs: 3 }; + setEntries([...tree]); + await screen.rerender(panel()); + await userEvent.click(sortDirectionButton("Sort descending")); + + await expectRows(["alpha/", "beta.txt"]); + }); + + it("shows new search matches after a refresh re-sorts by date modified", async () => { + using _sort = setupSort(); + using _entries = setEntries([ + { path: "a.txt", kind: "file", mtimeMs: 1 }, + { path: "b.md", kind: "file", mtimeMs: 2 }, + ]); + await using screen = await renderPanel(); + await userEvent.click(sortKeyButton("Sort by date modified")); + await userEvent.fill(page.getByRole("searchbox", { name: "Search repo files" }), "txt"); + await expectRows(["a.txt"]); + + setEntries([ + { path: "a.txt", kind: "file", mtimeMs: 3 }, + { path: "b.md", kind: "file", mtimeMs: 2 }, + { path: "c.txt", kind: "file", mtimeMs: 4 }, + ]); + await screen.rerender(panel()); + + await expectRows(["a.txt", "c.txt"]); + }); + + it("keeps the focused row when a refresh re-sorts by date modified", async () => { + using _sort = setupSort(); + const tree: ProjectEntry[] = [ + { path: "alpha", kind: "directory", mtimeMs: 1 }, + { path: "alpha/a.ts", kind: "file", mtimeMs: 1 }, + { path: "beta.txt", kind: "file", mtimeMs: 2 }, + ]; + using _entries = setEntries(tree); + await using screen = await renderPanel(); + await userEvent.click(sortKeyButton("Sort by date modified")); + findTreeRow("alpha/")!.click(); + await vi.waitFor(() => expect(findTreeRow("alpha/a.ts")).not.toBeNull()); + findTreeRow("alpha/a.ts")!.click(); + await vi.waitFor(() => expect(findTreeRow("alpha/a.ts")?.dataset.itemFocused).toBe("true")); + + tree[2] = { path: "beta.txt", kind: "file", mtimeMs: 0 }; + setEntries([...tree]); + await screen.rerender(panel()); + + await expectRows(["alpha/", "alpha/a.ts", "beta.txt"]); + await vi.waitFor(() => expect(findTreeRow("alpha/a.ts")?.dataset.itemFocused).toBe("true")); + }); }); diff --git a/apps/web/src/components/files/FileBrowserPanel.tsx b/apps/web/src/components/files/FileBrowserPanel.tsx index 1074a64bbeee3..89a92704b6b54 100644 --- a/apps/web/src/components/files/FileBrowserPanel.tsx +++ b/apps/web/src/components/files/FileBrowserPanel.tsx @@ -36,6 +36,8 @@ import { createFileTreeSortComparator, DEFAULT_FILE_TREE_SORT_ORDER, FileTreeSortOrder, + hasKeptEntryMtimeChanged, + type FileTreeTimestamps, } from "./fileTreeSort"; import { loadFileAncestors } from "./loadFileAncestors"; import { useDirectoryEntries } from "./useDirectoryEntries"; @@ -61,14 +63,6 @@ function treePath(entry: ProjectEntry): string { const FILE_BROWSER_SORT_STORAGE_KEY = "t3code:file-browser-sort"; -function hasMtimeChanged(previous: ReadonlyMap, next: ReadonlyMap) { - if (previous.size !== next.size) return true; - for (const [path, mtimeMs] of previous) { - if (next.get(path) !== mtimeMs) return true; - } - return false; -} - function RefreshFilesButton(props: { isPending: boolean; onRefresh: () => void }) { return ( @@ -245,8 +239,9 @@ export default function FileBrowserPanel({ const appliedSortRef = useRef<{ key: typeof sortKey; direction: typeof sortDirection; - mtimes: ReadonlyMap; + timestamps: FileTreeTimestamps; } | null>(null); + const resettingTreeRef = useRef(false); const previousTreePathsRef = useRef(null); const syncingSelectionRef = useRef(false); const treeSelectionPathRef = useRef(null); @@ -410,7 +405,7 @@ export default function FileBrowserPanel({ if (!currentPaths.has(path)) expandedPathsRef.current.delete(path); } const loadExpanded = () => { - if (model.isSearchOpen()) return; + if (model.isSearchOpen() || resettingTreeRef.current) return; for (const path of directoryPaths) { const item = model.getItem(path); if (item?.isDirectory() && "isExpanded" in item && item.isExpanded()) { @@ -477,8 +472,13 @@ export default function FileBrowserPanel({ applied !== null && (applied.key !== sortKey || applied.direction !== sortDirection || - (sortKey === "modified" && hasMtimeChanged(applied.mtimes, mtimes))); - appliedSortRef.current = { key: sortKey, direction: sortDirection, mtimes }; + (sortKey === "modified" && + hasKeptEntryMtimeChanged(applied.timestamps, { entryKinds, mtimes }))); + appliedSortRef.current = { + key: sortKey, + direction: sortDirection, + timestamps: { entryKinds, mtimes }, + }; sortComparatorRef.current = createFileTreeSortComparator( { key: sortKey, direction: sortDirection }, (path) => mtimes.get(path), @@ -492,16 +492,41 @@ export default function FileBrowserPanel({ return; } if (resort) { - const expandedDirectoryPaths = directoryPaths.filter((path) => { - const item = model.getItem(path); - return item !== null && "isExpanded" in item && item.isExpanded(); - }); - model.resetPaths(treePaths); - // A reset closes every folder, and `initialExpandedPaths` assumes default sibling order, so - // under a custom sort it misses folders. Reopening through item handles works in any order. - for (const path of expandedDirectoryPaths) { + const expandedDirectoryPaths: string[] = []; + const collapsedDirectoryPaths: string[] = []; + for (const path of directoryPaths) { const item = model.getItem(path); - if (item && "expand" in item) item.expand(); + if (item === null || !("isExpanded" in item)) continue; + (item.isExpanded() ? expandedDirectoryPaths : collapsedDirectoryPaths).push(path); + } + const searchOpen = model.isSearchOpen(); + const focusedPath = model.getFocusedPath(); + // The reset briefly closes folders; keep `loadExpanded` from reading that as a user collapse. + resettingTreeRef.current = true; + try { + model.resetPaths(treePaths); + if (searchOpen) { + // A reset keeps the previous search matches, and setting the same query is a no-op. + // Reopening the search also restores the folders that were open before it. + model.closeSearch(); + model.setSearch(query); + } else { + // A reset closes every folder, and `initialExpandedPaths` assumes default sibling order, + // so under a custom sort it misses folders. Reopening through item handles works in any + // order, but also opens ancestors, so close again the folders that were closed. + for (const path of expandedDirectoryPaths) { + const item = model.getItem(path); + if (item && "expand" in item) item.expand(); + } + for (const path of collapsedDirectoryPaths) { + const item = model.getItem(path); + if (item && "collapse" in item) item.collapse(); + } + } + // While a folder is closed the reset moves focus to it; return focus to the row it was on. + if (focusedPath !== null) model.focusPath(focusedPath); + } finally { + resettingTreeRef.current = false; } return; } diff --git a/apps/web/src/components/files/fileTreeSort.test.ts b/apps/web/src/components/files/fileTreeSort.test.ts index 619606dabb3ec..0a50e1beab01c 100644 --- a/apps/web/src/components/files/fileTreeSort.test.ts +++ b/apps/web/src/components/files/fileTreeSort.test.ts @@ -5,6 +5,7 @@ import { DEFAULT_FILE_TREE_SORT_ORDER, createFileTreeSortComparator, type FileTreeSortOrder, + hasKeptEntryMtimeChanged, } from "./fileTreeSort.ts"; const sortPaths = ( @@ -137,3 +138,32 @@ describe("createFileTreeSortComparator", () => { expect(lookups.filter((path) => path.endsWith("/"))).toEqual([]); }); }); + +describe("hasKeptEntryMtimeChanged", () => { + const timestamps = (entries: Readonly>) => ({ + entryKinds: new Map(Object.keys(entries).map((path) => [path, "file"])), + mtimes: new Map( + Object.entries(entries).flatMap(([path, mtimeMs]) => + mtimeMs === undefined ? [] : [[path, mtimeMs] as const], + ), + ), + }); + const before = timestamps({ "a.ts": 1, "b.ts": 2 }); + + it("ignores added and removed paths", () => { + expect(hasKeptEntryMtimeChanged(before, timestamps({ "a.ts": 1, "b.ts": 2, "c.ts": 3 }))).toBe( + false, + ); + expect(hasKeptEntryMtimeChanged(before, timestamps({ "a.ts": 1 }))).toBe(false); + }); + + it("detects a kept path whose time changes, appears or disappears", () => { + expect(hasKeptEntryMtimeChanged(before, timestamps({ "a.ts": 5, "b.ts": 2 }))).toBe(true); + expect(hasKeptEntryMtimeChanged(before, timestamps({ "a.ts": undefined, "b.ts": 2 }))).toBe( + true, + ); + expect( + hasKeptEntryMtimeChanged(timestamps({ "a.ts": undefined }), timestamps({ "a.ts": 1 })), + ).toBe(true); + }); +}); diff --git a/apps/web/src/components/files/fileTreeSort.ts b/apps/web/src/components/files/fileTreeSort.ts index 0ba4eb5aac493..6635bb0707112 100644 --- a/apps/web/src/components/files/fileTreeSort.ts +++ b/apps/web/src/components/files/fileTreeSort.ts @@ -105,3 +105,18 @@ export function createFileTreeSortComparator( return left.isDirectory ? -1 : 1; }; } + +export interface FileTreeTimestamps { + readonly entryKinds: ReadonlyMap; + readonly mtimes: ReadonlyMap; +} + +/** New paths can be inserted in place; only a kept path's changed time needs a full re-sort. */ +export function hasKeptEntryMtimeChanged(previous: FileTreeTimestamps, next: FileTreeTimestamps) { + for (const path of next.entryKinds.keys()) { + if (previous.entryKinds.has(path) && previous.mtimes.get(path) !== next.mtimes.get(path)) { + return true; + } + } + return false; +} diff --git a/apps/web/src/components/files/useDirectoryEntries.browser.test.tsx b/apps/web/src/components/files/useDirectoryEntries.browser.test.tsx index c11c56d166066..fb588b2216882 100644 --- a/apps/web/src/components/files/useDirectoryEntries.browser.test.tsx +++ b/apps/web/src/components/files/useDirectoryEntries.browser.test.tsx @@ -224,19 +224,6 @@ it("reads a folder that failed while unreachable again once a new session is up" await expect.element(screen.getByText("README.md")).toBeInTheDocument(); }); -it("sends the modified-time flag in every listing request", async () => { - const state = setup(); - // The hook probe does not navigate. - await using _screen = await render(, { router: false }); - await state.settle(0, [{ path: "src", kind: "directory" }]); - await act(async () => void state.view.load("src")); - await state.settle(1); - expect(state.calls.map((call) => [call.path, call.includeModifiedTimes])).toEqual([ - ["", true], - ["src", true], - ]); -}); - it("lists loaded folders again when modified times are first needed", async () => { const state = setup(); // The hook probe does not navigate. @@ -256,12 +243,23 @@ it("lists loaded folders again when modified times are first needed", async () = ["", true], ["src", true], ]); - await state.settle(2, [{ path: "src", kind: "directory", mtimeMs: 10 }]); + await state.settle(2, [ + { path: "src", kind: "directory", mtimeMs: 10 }, + { path: "lib", kind: "directory", mtimeMs: 5 }, + ]); await state.settle(3, [{ path: "src/a.ts", kind: "file", mtimeMs: 20 }]); - expect(state.view.entries.map((entry) => entry.mtimeMs)).toEqual([10, 20]); + expect(state.view.entries.map((entry) => [entry.path, entry.mtimeMs]).toSorted()).toEqual([ + ["lib", 5], + ["src", 10], + ["src/a.ts", 20], + ]); + + await act(async () => void state.view.load("lib")); + expect(state.calls.at(-1)).toMatchObject({ path: "lib", includeModifiedTimes: true }); + await state.settle(4); await screen.rerender(); - expect(state.calls).toHaveLength(4); + expect(state.calls).toHaveLength(5); }); it("lists a folder with modified times when the flag turns on during its listing", async () => { From 6c5c803fd6d68b17d78537f5e6ac67fabe8ac928 Mon Sep 17 00:00:00 2001 From: Max Tesla <58265349+boom-bang@users.noreply.github.com> Date: Fri, 6 Nov 2026 15:00:07 +0100 Subject: [PATCH 10/10] fix(web): keep the Files panel compiled by React Compiler React Compiler cannot compile `try`/`finally`, so the re-sort reset moves into a module-level helper. With the component compiled again, the effect lists `query` as a dependency. Co-Authored-By: Claude Opus 5.5 --- .../src/components/files/FileBrowserPanel.tsx | 93 +++++++++++-------- 1 file changed, 55 insertions(+), 38 deletions(-) diff --git a/apps/web/src/components/files/FileBrowserPanel.tsx b/apps/web/src/components/files/FileBrowserPanel.tsx index 89a92704b6b54..a89466c803dfa 100644 --- a/apps/web/src/components/files/FileBrowserPanel.tsx +++ b/apps/web/src/components/files/FileBrowserPanel.tsx @@ -4,7 +4,13 @@ import type { ContextMenuOpenContext as TreeContextMenuOpenContext, } from "@pierre/trees"; import type { EnvironmentId, ProjectEntry } from "@t3tools/contracts"; -import { FileTree, useFileTree, useFileTreeSearch, useFileTreeSelector } from "@pierre/trees/react"; +import { + FileTree, + type UseFileTreeResult, + useFileTree, + useFileTreeSearch, + useFileTreeSelector, +} from "@pierre/trees/react"; import { settlePromise } from "@t3tools/client-runtime/state/runtime"; import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger"; import * as AsyncResult from "effect/reactivity/AsyncResult"; @@ -63,6 +69,52 @@ function treePath(entry: ProjectEntry): string { const FILE_BROWSER_SORT_STORAGE_KEY = "t3code:file-browser-sort"; +/** `try`/`finally` stays outside the component because React Compiler cannot compile it. */ +function resetTreeKeepingViewState( + model: UseFileTreeResult["model"], + treePaths: readonly string[], + directoryPaths: readonly string[], + query: string, + resettingTreeRef: { current: boolean }, +) { + const expandedDirectoryPaths: string[] = []; + const collapsedDirectoryPaths: string[] = []; + for (const path of directoryPaths) { + const item = model.getItem(path); + if (item === null || !("isExpanded" in item)) continue; + (item.isExpanded() ? expandedDirectoryPaths : collapsedDirectoryPaths).push(path); + } + const searchOpen = model.isSearchOpen(); + const focusedPath = model.getFocusedPath(); + // The reset briefly closes folders; keep `loadExpanded` from reading that as a user collapse. + resettingTreeRef.current = true; + try { + model.resetPaths(treePaths); + if (searchOpen) { + // A reset keeps the previous search matches, and setting the same query is a no-op. + // Reopening the search also restores the folders that were open before it. + model.closeSearch(); + model.setSearch(query); + } else { + // A reset closes every folder, and `initialExpandedPaths` assumes default sibling order, + // so under a custom sort it misses folders. Reopening through item handles works in any + // order, but also opens ancestors, so close again the folders that were closed. + for (const path of expandedDirectoryPaths) { + const item = model.getItem(path); + if (item && "expand" in item) item.expand(); + } + for (const path of collapsedDirectoryPaths) { + const item = model.getItem(path); + if (item && "collapse" in item) item.collapse(); + } + } + // While a folder is closed the reset moves focus to it; return focus to the row it was on. + if (focusedPath !== null) model.focusPath(focusedPath); + } finally { + resettingTreeRef.current = false; + } +} + function RefreshFilesButton(props: { isPending: boolean; onRefresh: () => void }) { return ( @@ -492,47 +544,12 @@ export default function FileBrowserPanel({ return; } if (resort) { - const expandedDirectoryPaths: string[] = []; - const collapsedDirectoryPaths: string[] = []; - for (const path of directoryPaths) { - const item = model.getItem(path); - if (item === null || !("isExpanded" in item)) continue; - (item.isExpanded() ? expandedDirectoryPaths : collapsedDirectoryPaths).push(path); - } - const searchOpen = model.isSearchOpen(); - const focusedPath = model.getFocusedPath(); - // The reset briefly closes folders; keep `loadExpanded` from reading that as a user collapse. - resettingTreeRef.current = true; - try { - model.resetPaths(treePaths); - if (searchOpen) { - // A reset keeps the previous search matches, and setting the same query is a no-op. - // Reopening the search also restores the folders that were open before it. - model.closeSearch(); - model.setSearch(query); - } else { - // A reset closes every folder, and `initialExpandedPaths` assumes default sibling order, - // so under a custom sort it misses folders. Reopening through item handles works in any - // order, but also opens ancestors, so close again the folders that were closed. - for (const path of expandedDirectoryPaths) { - const item = model.getItem(path); - if (item && "expand" in item) item.expand(); - } - for (const path of collapsedDirectoryPaths) { - const item = model.getItem(path); - if (item && "collapse" in item) item.collapse(); - } - } - // While a folder is closed the reset moves focus to it; return focus to the row it was on. - if (focusedPath !== null) model.focusPath(focusedPath); - } finally { - resettingTreeRef.current = false; - } + resetTreeKeepingViewState(model, treePaths, directoryPaths, query, resettingTreeRef); return; } const updates = buildFileTreePathUpdates(previousTreePaths, treePaths); if (updates.length > 0) model.batch(updates); - }, [ready, entryKinds, model, treePaths, directoryPaths, mtimes, sortKey, sortDirection]); + }, [ready, entryKinds, model, treePaths, directoryPaths, mtimes, sortKey, sortDirection, query]); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- expands every directory in the Pierre tree model while expand-all is on and no search is active useEffect(() => {