| author | |
| committer | |
| log | 3efdcc5296f1754e0f3bf7fee5fc2ada510e0438 |
| tree | 23baf83cb20152340b6313122673ba36525d81aa |
| parent | 7235701de052658cd78cb029b0df71af4b8dc21f |
| signature | Signed by PGP key B5690EEEBB952194 |
4 files changed, 107 insertions(+), 4 deletions(-)
apps/web/src/components/diffs/DiffFileTree.test.tsx+17| ... | ... | @@ -178,6 +178,23 @@ describe("diff tree file activation", () => { |
| 178 | 178 | expect(targets).toEqual([]); |
| 179 | 179 | }); |
| 180 | 180 | |
| 181 | it("reorders refreshed files without reopening a collapsed folder", async () => { | |
| 182 | const files: DiffFileTreeEntry[] = [ | |
| 183 | { path: "src/state/shell.ts", status: "modified" }, | |
| 184 | { path: "src/features/route.ts", status: "added" }, | |
| 185 | ]; | |
| 186 | await mount({ files }); | |
| 187 | const initialFolder = model().getItem("src/features/")!; | |
| 188 | if (!("collapse" in initialFolder)) throw new Error("Expected the directory handle"); | |
| 189 | await act(async () => initialFolder.collapse()); | |
| 190 | await act(async () => { | |
| 191 | renderer!.update(<Panel files={[files[1]!, files[0]!]} />); | |
| 192 | }); | |
| 193 | const folder = model().getItem("src/features/")!; | |
| 194 | if (!("isExpanded" in folder)) throw new Error("Expected the directory handle"); | |
| 195 | expect(folder.isExpanded()).toBe(false); | |
| 196 | }); | |
| 197 | ||
| 181 | 198 | it("does not echo controlled selection, but lets the reader activate it", async () => { |
| 182 | 199 | await mount({ selectedPath: "02-short.ts" }); |
| 183 | 200 | expect(model().getSelectedPaths()).toEqual(["02-short.ts"]); |
apps/web/src/components/diffs/DiffFileTree.tsx+30-3| ... | ... | @@ -1,7 +1,7 @@ |
| 1 | 1 | import type { GitStatusEntry } from "@pierre/trees"; |
| 2 | 2 | import { FileTree, useFileTree, useFileTreeSelector } from "@pierre/trees/react"; |
| 3 | 3 | import { ChevronsDownUpIcon, ChevronsUpDownIcon } from "lucide-react"; |
| 4 | import { useEffect, useMemo, useRef, type ReactNode } from "react"; | |
| 4 | import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; | |
| 5 | 5 | |
| 6 | 6 | import { useTheme } from "~/hooks/useTheme"; |
| 7 | 7 | import { cn } from "~/lib/utils"; |
| ... | ... | @@ -13,7 +13,9 @@ import { Button } from "../ui/button"; |
| 13 | 13 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 14 | 14 | import { |
| 15 | 15 | buildDiffFileTreeUpdates, |
| 16 | compareDiffFileTreeEntries, | |
| 16 | 17 | collectDirectoryPaths, |
| 18 | diffFileTreePositions, | |
| 17 | 19 | type DiffFileTreeEntry, |
| 18 | 20 | } from "./diffFileTree.logic"; |
| 19 | 21 | |
| ... | ... | @@ -54,6 +56,16 @@ export function DiffFileTree({ |
| 54 | 56 | const { resolvedTheme } = useTheme(); |
| 55 | 57 | const paths = useMemo(() => entries.map((entry) => entry.path), [entries]); |
| 56 | 58 | const directoryPaths = useMemo(() => collectDirectoryPaths(paths), [paths]); |
| 59 | const positions = useMemo(() => diffFileTreePositions(paths), [paths]); | |
| 60 | const [ordering] = useState(() => { | |
| 61 | let currentPositions: ReadonlyMap<string, number> = new Map(); | |
| 62 | return { | |
| 63 | sort: compareDiffFileTreeEntries(() => currentPositions), | |
| 64 | update: (nextPositions: ReadonlyMap<string, number>) => { | |
| 65 | currentPositions = nextPositions; | |
| 66 | }, | |
| 67 | }; | |
| 68 | }); | |
| 57 | 69 | const gitStatus = useMemo<ReadonlyArray<GitStatusEntry>>( |
| 58 | 70 | () => entries.map((entry) => ({ path: entry.path, status: entry.status })), |
| 59 | 71 | [entries], |
| ... | ... | @@ -83,6 +95,7 @@ export function DiffFileTree({ |
| 83 | 95 | }, |
| 84 | 96 | paths: [], |
| 85 | 97 | search: false, |
| 98 | sort: ordering.sort, | |
| 86 | 99 | unsafeCSS: PIERRE_TREE_UNSAFE_CSS, |
| 87 | 100 | }); |
| 88 | 101 | const allDirectoriesExpanded = useFileTreeSelector(model, (currentModel) => |
| ... | ... | @@ -90,17 +103,31 @@ export function DiffFileTree({ |
| 90 | 103 | ); |
| 91 | 104 | |
| 92 | 105 | useEffect(() => { |
| 106 | ordering.update(positions); | |
| 93 | 107 | const mountedPaths = mountedPathsRef.current; |
| 94 | 108 | if (mountedPaths === paths) return; |
| 95 | 109 | mountedPathsRef.current = paths; |
| 96 | 110 | if (mountedPaths === null) { |
| 97 | 111 | model.resetPaths(paths); |
| 98 | } else { | |
| 112 | } else if (mountedPaths.every((path, index) => paths[index] === path)) { | |
| 113 | // PR slices only append files, so keep the existing tree and its open folders. | |
| 99 | 114 | const updates = buildDiffFileTreeUpdates(mountedPaths, paths); |
| 100 | 115 | if (updates.length > 0) model.batch(updates); |
| 116 | } else { | |
| 117 | // A refreshed diff can change the rank of existing siblings. Mutations do not reorder | |
| 118 | // those rows, so rebuild while carrying the reader's folder expansion forward. | |
| 119 | const collapsedDirectories = directoryPaths.filter((path) => { | |
| 120 | const directory = model.getItem(path); | |
| 121 | return directory !== null && "isExpanded" in directory && !directory.isExpanded(); | |
| 122 | }); | |
| 123 | model.resetPaths(paths); | |
| 124 | for (const path of collapsedDirectories) { | |
| 125 | const directory = model.getItem(path); | |
| 126 | if (directory !== null && "collapse" in directory) directory.collapse(); | |
| 127 | } | |
| 101 | 128 | } |
| 102 | 129 | model.setGitStatus(gitStatus); |
| 103 | }, [gitStatus, model, paths]); | |
| 130 | }, [directoryPaths, gitStatus, model, ordering, paths, positions]); | |
| 104 | 131 | |
| 105 | 132 | useEffect(() => { |
| 106 | 133 | if (selectedPath === null) { |
apps/web/src/components/diffs/diffFileTree.logic.test.ts+31| ... | ... | @@ -1,9 +1,12 @@ |
| 1 | 1 | import type { FileDiffMetadata } from "@pierre/diffs"; |
| 2 | import { preloadFileTree } from "@pierre/trees"; | |
| 2 | 3 | import { describe, expect, it } from "vite-plus/test"; |
| 3 | 4 | |
| 4 | 5 | import { |
| 5 | 6 | buildDiffFileTreeUpdates, |
| 7 | compareDiffFileTreeEntries, | |
| 6 | 8 | collectDirectoryPaths, |
| 9 | diffFileTreePositions, | |
| 7 | 10 | diffFileTreeEntries, |
| 8 | 11 | } from "./diffFileTree.logic"; |
| 9 | 12 | |
| ... | ... | @@ -41,6 +44,34 @@ describe("collectDirectoryPaths", () => { |
| 41 | 44 | }); |
| 42 | 45 | }); |
| 43 | 46 | |
| 47 | describe("diff tree reading order", () => { | |
| 48 | it("places folders and files where their first diff appears", () => { | |
| 49 | const paths = [ | |
| 50 | "apps/mobile/src/state/shell.ts", | |
| 51 | "apps/mobile/src/features/threads/route.ts", | |
| 52 | "apps/mobile/src/features/threads/screen.tsx", | |
| 53 | ]; | |
| 54 | const positions = diffFileTreePositions(paths); | |
| 55 | const tree = preloadFileTree({ | |
| 56 | paths, | |
| 57 | initialExpansion: "open", | |
| 58 | flattenEmptyDirectories: true, | |
| 59 | sort: compareDiffFileTreeEntries(() => positions), | |
| 60 | }); | |
| 61 | const rows = [...tree.shadowHtml.matchAll(/data-item-path="([^"]+)"/g)].map( | |
| 62 | (match) => match[1], | |
| 63 | ); | |
| 64 | expect(rows).toEqual([ | |
| 65 | "apps/mobile/src/", | |
| 66 | "apps/mobile/src/state/", | |
| 67 | "apps/mobile/src/state/shell.ts", | |
| 68 | "apps/mobile/src/features/threads/", | |
| 69 | "apps/mobile/src/features/threads/route.ts", | |
| 70 | "apps/mobile/src/features/threads/screen.tsx", | |
| 71 | ]); | |
| 72 | }); | |
| 73 | }); | |
| 74 | ||
| 44 | 75 | describe("buildDiffFileTreeUpdates", () => { |
| 45 | 76 | it("adds a new file's directories before the file", () => { |
| 46 | 77 | expect(buildDiffFileTreeUpdates(["README.md"], ["README.md", "src/lib/a.ts"])).toEqual([ |
apps/web/src/components/diffs/diffFileTree.logic.ts+29-1| ... | ... | @@ -1,5 +1,5 @@ |
| 1 | 1 | import type { FileDiffMetadata } from "@pierre/diffs"; |
| 2 | import type { FileTreeBatchOperation, GitStatus } from "@pierre/trees"; | |
| 2 | import type { FileTreeBatchOperation, FileTreeSortComparator, GitStatus } from "@pierre/trees"; | |
| 3 | 3 | |
| 4 | 4 | import { resolveFileDiffPath } from "~/lib/diffRendering"; |
| 5 | 5 | |
| ... | ... | @@ -47,6 +47,34 @@ export function collectDirectoryPaths(paths: ReadonlyArray<string>): ReadonlyArr |
| 47 | 47 | return [...directories]; |
| 48 | 48 | } |
| 49 | 49 | |
| 50 | /** A folder takes the position of its first file in the diff. */ | |
| 51 | export function diffFileTreePositions(paths: ReadonlyArray<string>): ReadonlyMap<string, number> { | |
| 52 | const positions = new Map<string, number>(); | |
| 53 | paths.forEach((path, index) => { | |
| 54 | positions.set(path, index); | |
| 55 | let directory = ""; | |
| 56 | for (const segment of path.split("/").slice(0, -1)) { | |
| 57 | directory += `${segment}/`; | |
| 58 | if (!positions.has(directory)) positions.set(directory, index); | |
| 59 | } | |
| 60 | }); | |
| 61 | return positions; | |
| 62 | } | |
| 63 | ||
| 64 | export function compareDiffFileTreeEntries( | |
| 65 | getPositions: () => ReadonlyMap<string, number>, | |
| 66 | ): FileTreeSortComparator { | |
| 67 | return (left, right) => { | |
| 68 | const positions = getPositions(); | |
| 69 | return ( | |
| 70 | (positions.get(left.path) ?? Number.MAX_SAFE_INTEGER) - | |
| 71 | (positions.get(right.path) ?? Number.MAX_SAFE_INTEGER) || | |
| 72 | left.depth - right.depth || | |
| 73 | left.path.localeCompare(right.path) | |
| 74 | ); | |
| 75 | }; | |
| 76 | } | |
| 77 | ||
| 50 | 78 | function pathDepth(path: string): number { |
| 51 | 79 | return path.split("/").filter(Boolean).length; |
| 52 | 80 | } |