diff --git a/apps/server/src/workspace/WorkspaceEntries.test.ts b/apps/server/src/workspace/WorkspaceEntries.test.ts index 9c3b2e18a74f0..c4fee2c10b9ed 100644 --- a/apps/server/src/workspace/WorkspaceEntries.test.ts +++ b/apps/server/src/workspace/WorkspaceEntries.test.ts @@ -185,6 +185,65 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceEntries", (it) => { }), ); + 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; + 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: "", includeModifiedTimes: true }) + .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; + 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: "", + 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); + } + expect(statSpy).toHaveBeenCalledTimes(2); + }), + ); + 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. diff --git a/apps/server/src/workspace/WorkspaceEntries.ts b/apps/server/src/workspace/WorkspaceEntries.ts index 7b2a0aa0a7c7c..173306dfa9446 100644 --- a/apps/server/src/workspace/WorkspaceEntries.ts +++ b/apps/server/src/workspace/WorkspaceEntries.ts @@ -124,6 +124,11 @@ const IGNORE_CHECK_CHUNK_SIZE = 1000; /** Children classified at once when listing a folder; each symlink costs a realpath and a stat. */ 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, @@ -345,9 +350,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, ); @@ -359,25 +362,37 @@ 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 }; }; + 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: input.includeModifiedTimes + ? await statMtime(child.name) + : undefined, + }; }), )), ); @@ -394,12 +409,15 @@ 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, + ...(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 7da02e86eac1d..dfd65c179e9a4 100644 --- a/apps/web/src/components/files/FileBrowserPanel.browser.test.tsx +++ b/apps/web/src/components/files/FileBrowserPanel.browser.test.tsx @@ -10,8 +10,15 @@ import FileBrowserPanel from "./FileBrowserPanel"; import { dispatchPreviewAction } from "../preview/previewActionBus"; const entries: readonly ProjectEntry[] = [ - { path: "src", kind: "directory" }, - { path: "README.md", kind: "file" }, + { 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 = { @@ -31,7 +38,14 @@ const pathSearch = { refresh: () => {}, }; -vi.mock("./useDirectoryEntries", () => ({ useDirectoryEntries: () => directory })); +const { useDirectoryEntries } = vi.hoisted(() => ({ useDirectoryEntries: vi.fn() })); +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", () => ({ useFileContextMenu: () => ({ buildItems: () => [], activate: () => Promise.resolve() }), @@ -46,6 +60,59 @@ 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 []; +} + +const SORT_STORAGE_KEY = "t3code:file-browser-sort"; + +function setupSort(): Disposable { + localStorage.removeItem(SORT_STORAGE_KEY); + return { [Symbol.dispose]: () => localStorage.removeItem(SORT_STORAGE_KEY) }; +} + +const panel = () => ( + {}} + workspaceMutationId={null} + /> +); + +function renderPanel() { + 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") => + 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( @@ -145,3 +212,274 @@ describe("FileBrowserPanel", () => { await expect.element(search).toHaveFocus(); }); }); + +describe("FileBrowserPanel sorting", () => { + 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(); + }); + + 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", + ]); + }); + + it("re-sorts by date modified when a refresh adds or drops timestamps", async () => { + using _sort = setupSort(); + 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"]); + + setEntries([ + { path: "a.txt", kind: "file", mtimeMs: 20 }, + { path: "z.txt", kind: "file", mtimeMs: 10 }, + ]); + await screen.rerender(panel()); + await expectRows(["z.txt", "a.txt"]); + + setEntries([ + { path: "a.txt", kind: "file", mtimeMs: 5 }, + { path: "z.txt", kind: "file" }, + ]); + 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 292535e7bb1d0..9e2184907e47a 100644 --- a/apps/web/src/components/files/FileBrowserPanel.tsx +++ b/apps/web/src/components/files/FileBrowserPanel.tsx @@ -4,12 +4,25 @@ 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"; import * as Cause from "effect/Cause"; -import { ChevronsDownUpIcon, ChevronsUpDownIcon } from "lucide-react"; +import { + ArrowDownAZIcon, + ArrowDownIcon, + ArrowUpIcon, + ChevronsDownUpIcon, + ChevronsUpDownIcon, + ClockIcon, +} from "lucide-react"; import { type Ref, useEffect, useEffectEvent, useRef, useState } from "react"; import { Button } from "~/components/ui/button"; @@ -18,6 +31,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"; @@ -30,6 +44,13 @@ 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, + hasKeptEntryMtimeChanged, + type FileTreeTimestamps, +} from "./fileTreeSort"; import { loadFileAncestors } from "./loadFileAncestors"; import { useDirectoryEntries } from "./useDirectoryEntries"; import { useProjectPathSearch } from "~/state/queries"; @@ -55,6 +76,54 @@ function treePath(entry: ProjectEntry): string { return entry.kind === "directory" ? `${entry.path}/` : entry.path; } +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 ( @@ -76,6 +145,61 @@ 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" })} + /> + } + > + {sortsByName ? ( + + ) : ( + + )} + + {keyLabel} + + + onChange({ ...order, direction: ascending ? "desc" : "asc" })} + /> + } + > + {ascending ? ( + + ) : ( + + )} + + {directionLabel} + + + ); +} + function FileSearchField({ ref, ...props @@ -131,13 +255,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({ @@ -157,6 +285,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, @@ -165,16 +299,24 @@ 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( { 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 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; + timestamps: FileTreeTimestamps; + } | null>(null); + const resettingTreeRef = useRef(false); const previousTreePathsRef = useRef(null); const syncingSelectionRef = useRef(false); const treeSelectionPathRef = useRef(null); @@ -301,6 +443,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, }); @@ -351,7 +494,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()) { @@ -410,10 +553,26 @@ 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" && + 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), + ); + if (previousTreePathsRef.current === treePaths && !resort) return; entryKindsRef.current = entryKinds; const previousTreePaths = previousTreePathsRef.current; previousTreePathsRef.current = treePaths; @@ -421,9 +580,13 @@ export default function FileBrowserPanel({ model.resetPaths(treePaths); return; } + if (resort) { + resetTreeKeepingViewState(model, treePaths, directoryPaths, query, resettingTreeRef); + 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, 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(() => { @@ -526,6 +689,7 @@ export default function FileBrowserPanel({ onValueChange={handleSearchValueChange} onClose={closeSearch} /> + {directoryPaths.length > 0 && !hostFolder ? ( > = {}, + 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([]); + }); +}); + +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 new file mode 100644 index 0000000000000..6635bb0707112 --- /dev/null +++ b/apps/web/src/components/files/fileTreeSort.ts @@ -0,0 +1,122 @@ +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; +} + +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; + }; +} + +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 046563bcb22f4..fb588b2216882 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,51 @@ it("reads a folder that failed while unreachable again once a new session is up" }); await expect.element(screen.getByText("README.md")).toBeInTheDocument(); }); + +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 }, + { 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.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(5); +}); + +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..f998304672c98 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,19 @@ export function useDirectoryEntries(environmentId: EnvironmentId, cwd: string) { const active = useRef(true); const running = useRef(0); const waiting = useRef void>>([]); + 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 +157,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 b987d073cdfdc..d84bf6c4ee057 100644 --- a/packages/contracts/src/project.ts +++ b/packages/contracts/src/project.ts @@ -250,6 +250,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; @@ -294,6 +295,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;