| 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,6 +178,23 @@ describe("diff tree file activation", () => { |
| 178 | expect(targets).toEqual([]); | 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 | it("does not echo controlled selection, but lets the reader activate it", async () => { | 198 | it("does not echo controlled selection, but lets the reader activate it", async () => { |
| 182 | await mount({ selectedPath: "02-short.ts" }); | 199 | await mount({ selectedPath: "02-short.ts" }); |
| 183 | expect(model().getSelectedPaths()).toEqual(["02-short.ts"]); | 200 | expect(model().getSelectedPaths()).toEqual(["02-short.ts"]); |
apps/web/src/components/diffs/DiffFileTree.tsx+30-3| ... | @@ -1,7 +1,7 @@ | ... | @@ -1,7 +1,7 @@ |
| 1 | import type { GitStatusEntry } from "@pierre/trees"; | 1 | import type { GitStatusEntry } from "@pierre/trees"; |
| 2 | import { FileTree, useFileTree, useFileTreeSelector } from "@pierre/trees/react"; | 2 | import { FileTree, useFileTree, useFileTreeSelector } from "@pierre/trees/react"; |
| 3 | import { ChevronsDownUpIcon, ChevronsUpDownIcon } from "lucide-react"; | 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 | import { useTheme } from "~/hooks/useTheme"; | 6 | import { useTheme } from "~/hooks/useTheme"; |
| 7 | import { cn } from "~/lib/utils"; | 7 | import { cn } from "~/lib/utils"; |
| ... | @@ -13,7 +13,9 @@ import { Button } from "../ui/button"; | ... | @@ -13,7 +13,9 @@ import { Button } from "../ui/button"; |
| 13 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | 13 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 14 | import { | 14 | import { |
| 15 | buildDiffFileTreeUpdates, | 15 | buildDiffFileTreeUpdates, |
| 16 | compareDiffFileTreeEntries, | ||
| 16 | collectDirectoryPaths, | 17 | collectDirectoryPaths, |
| 18 | diffFileTreePositions, | ||
| 17 | type DiffFileTreeEntry, | 19 | type DiffFileTreeEntry, |
| 18 | } from "./diffFileTree.logic"; | 20 | } from "./diffFileTree.logic"; |
| 19 | 21 | ||
| ... | @@ -54,6 +56,16 @@ export function DiffFileTree({ | ... | @@ -54,6 +56,16 @@ export function DiffFileTree({ |
| 54 | const { resolvedTheme } = useTheme(); | 56 | const { resolvedTheme } = useTheme(); |
| 55 | const paths = useMemo(() => entries.map((entry) => entry.path), [entries]); | 57 | const paths = useMemo(() => entries.map((entry) => entry.path), [entries]); |
| 56 | const directoryPaths = useMemo(() => collectDirectoryPaths(paths), [paths]); | 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 | const gitStatus = useMemo<ReadonlyArray<GitStatusEntry>>( | 69 | const gitStatus = useMemo<ReadonlyArray<GitStatusEntry>>( |
| 58 | () => entries.map((entry) => ({ path: entry.path, status: entry.status })), | 70 | () => entries.map((entry) => ({ path: entry.path, status: entry.status })), |
| 59 | [entries], | 71 | [entries], |
| ... | @@ -83,6 +95,7 @@ export function DiffFileTree({ | ... | @@ -83,6 +95,7 @@ export function DiffFileTree({ |
| 83 | }, | 95 | }, |
| 84 | paths: [], | 96 | paths: [], |
| 85 | search: false, | 97 | search: false, |
| 98 | sort: ordering.sort, | ||
| 86 | unsafeCSS: PIERRE_TREE_UNSAFE_CSS, | 99 | unsafeCSS: PIERRE_TREE_UNSAFE_CSS, |
| 87 | }); | 100 | }); |
| 88 | const allDirectoriesExpanded = useFileTreeSelector(model, (currentModel) => | 101 | const allDirectoriesExpanded = useFileTreeSelector(model, (currentModel) => |
| ... | @@ -90,17 +103,31 @@ export function DiffFileTree({ | ... | @@ -90,17 +103,31 @@ export function DiffFileTree({ |
| 90 | ); | 103 | ); |
| 91 | 104 | ||
| 92 | useEffect(() => { | 105 | useEffect(() => { |
| 106 | ordering.update(positions); | ||
| 93 | const mountedPaths = mountedPathsRef.current; | 107 | const mountedPaths = mountedPathsRef.current; |
| 94 | if (mountedPaths === paths) return; | 108 | if (mountedPaths === paths) return; |
| 95 | mountedPathsRef.current = paths; | 109 | mountedPathsRef.current = paths; |
| 96 | if (mountedPaths === null) { | 110 | if (mountedPaths === null) { |
| 97 | model.resetPaths(paths); | 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 | const updates = buildDiffFileTreeUpdates(mountedPaths, paths); | 114 | const updates = buildDiffFileTreeUpdates(mountedPaths, paths); |
| 100 | if (updates.length > 0) model.batch(updates); | 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 | model.setGitStatus(gitStatus); | 129 | model.setGitStatus(gitStatus); |
| 103 | }, [gitStatus, model, paths]); | 130 | }, [directoryPaths, gitStatus, model, ordering, paths, positions]); |
| 104 | 131 | ||
| 105 | useEffect(() => { | 132 | useEffect(() => { |
| 106 | if (selectedPath === null) { | 133 | if (selectedPath === null) { |
apps/web/src/components/diffs/diffFileTree.logic.test.ts+31| ... | @@ -1,9 +1,12 @@ | ... | @@ -1,9 +1,12 @@ |
| 1 | import type { FileDiffMetadata } from "@pierre/diffs"; | 1 | import type { FileDiffMetadata } from "@pierre/diffs"; |
| 2 | import { preloadFileTree } from "@pierre/trees"; | ||
| 2 | import { describe, expect, it } from "vite-plus/test"; | 3 | import { describe, expect, it } from "vite-plus/test"; |
| 3 | 4 | ||
| 4 | import { | 5 | import { |
| 5 | buildDiffFileTreeUpdates, | 6 | buildDiffFileTreeUpdates, |
| 7 | compareDiffFileTreeEntries, | ||
| 6 | collectDirectoryPaths, | 8 | collectDirectoryPaths, |
| 9 | diffFileTreePositions, | ||
| 7 | diffFileTreeEntries, | 10 | diffFileTreeEntries, |
| 8 | } from "./diffFileTree.logic"; | 11 | } from "./diffFileTree.logic"; |
| 9 | 12 | ||
| ... | @@ -41,6 +44,34 @@ describe("collectDirectoryPaths", () => { | ... | @@ -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 | describe("buildDiffFileTreeUpdates", () => { | 75 | describe("buildDiffFileTreeUpdates", () => { |
| 45 | it("adds a new file's directories before the file", () => { | 76 | it("adds a new file's directories before the file", () => { |
| 46 | expect(buildDiffFileTreeUpdates(["README.md"], ["README.md", "src/lib/a.ts"])).toEqual([ | 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,5 +1,5 @@ |
| 1 | import type { FileDiffMetadata } from "@pierre/diffs"; | 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 | import { resolveFileDiffPath } from "~/lib/diffRendering"; | 4 | import { resolveFileDiffPath } from "~/lib/diffRendering"; |
| 5 | 5 | ||
| ... | @@ -47,6 +47,34 @@ export function collectDirectoryPaths(paths: ReadonlyArray<string>): ReadonlyArr | ... | @@ -47,6 +47,34 @@ export function collectDirectoryPaths(paths: ReadonlyArray<string>): ReadonlyArr |
| 47 | return [...directories]; | 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 | function pathDepth(path: string): number { | 78 | function pathDepth(path: string): number { |
| 51 | return path.split("/").filter(Boolean).length; | 79 | return path.split("/").filter(Boolean).length; |
| 52 | } | 80 | } |