| 1 | import type { FileDiffMetadata } from "@pierre/diffs"; |
| 2 | import type { FileTreeBatchOperation, FileTreeSortComparator, GitStatus } from "@pierre/trees"; |
| 3 | |
| 4 | import { resolveFileDiffPath } from "~/lib/diffRendering"; |
| 5 | |
| 6 | /** One changed file as the tree shows it: its current path and how it changed. */ |
| 7 | export interface DiffFileTreeEntry { |
| 8 | readonly path: string; |
| 9 | readonly status: GitStatus; |
| 10 | } |
| 11 | |
| 12 | function toGitStatus(file: FileDiffMetadata): GitStatus { |
| 13 | switch (file.type) { |
| 14 | case "new": |
| 15 | return "added"; |
| 16 | case "deleted": |
| 17 | return "deleted"; |
| 18 | case "rename-pure": |
| 19 | case "rename-changed": |
| 20 | return "renamed"; |
| 21 | case "change": |
| 22 | return "modified"; |
| 23 | } |
| 24 | } |
| 25 | |
| 26 | /** |
| 27 | * Maps parsed diff files to tree entries, keeping the diff's own order. A path |
| 28 | * appears once: a type change (regular file to symlink) is a deletion plus an |
| 29 | * addition of the same path, and the tree shows the surviving file as modified. |
| 30 | */ |
| 31 | export function diffFileTreeEntries( |
| 32 | files: ReadonlyArray<FileDiffMetadata>, |
| 33 | ): ReadonlyArray<DiffFileTreeEntry> { |
| 34 | const statusByPath = new Map<string, GitStatus>(); |
| 35 | for (const file of files) { |
| 36 | const path = resolveFileDiffPath(file); |
| 37 | const status = toGitStatus(file); |
| 38 | const previous = statusByPath.get(path); |
| 39 | statusByPath.set(path, previous === undefined || previous === status ? status : "modified"); |
| 40 | } |
| 41 | return [...statusByPath].map(([path, status]) => ({ path, status })); |
| 42 | } |
| 43 | |
| 44 | /** |
| 45 | * Every directory on the way to each file, registered with the trailing slash Pierre uses for |
| 46 | * directory ids. Parents come before children so the tree can add them in order. |
| 47 | */ |
| 48 | export function collectDirectoryPaths(paths: ReadonlyArray<string>): ReadonlyArray<string> { |
| 49 | const directories = new Set<string>(); |
| 50 | for (const path of paths) { |
| 51 | const segments = path.split("/"); |
| 52 | let directory = ""; |
| 53 | for (const segment of segments.slice(0, -1)) { |
| 54 | directory += `${segment}/`; |
| 55 | directories.add(directory); |
| 56 | } |
| 57 | } |
| 58 | return [...directories]; |
| 59 | } |
| 60 | |
| 61 | /** A folder takes the position of its first file in the diff. */ |
| 62 | export function diffFileTreePositions(paths: ReadonlyArray<string>): ReadonlyMap<string, number> { |
| 63 | const positions = new Map<string, number>(); |
| 64 | paths.forEach((path, index) => { |
| 65 | positions.set(path, index); |
| 66 | let directory = ""; |
| 67 | for (const segment of path.split("/").slice(0, -1)) { |
| 68 | directory += `${segment}/`; |
| 69 | if (!positions.has(directory)) positions.set(directory, index); |
| 70 | } |
| 71 | }); |
| 72 | return positions; |
| 73 | } |
| 74 | |
| 75 | export function compareDiffFileTreeEntries( |
| 76 | getPositions: () => ReadonlyMap<string, number>, |
| 77 | ): FileTreeSortComparator { |
| 78 | return (left, right) => { |
| 79 | const positions = getPositions(); |
| 80 | return ( |
| 81 | (positions.get(left.path) ?? Number.MAX_SAFE_INTEGER) - |
| 82 | (positions.get(right.path) ?? Number.MAX_SAFE_INTEGER) || |
| 83 | left.depth - right.depth || |
| 84 | left.path.localeCompare(right.path) |
| 85 | ); |
| 86 | }; |
| 87 | } |
| 88 | |
| 89 | function pathDepth(path: string): number { |
| 90 | return path.split("/").filter(Boolean).length; |
| 91 | } |
| 92 | |
| 93 | /** |
| 94 | * The adds and removes that turn one set of file paths into another, so a diff that changes |
| 95 | * under the reader (a new slice, a refresh after an agent edit) keeps the directories they |
| 96 | * have already opened or closed instead of rebuilding the tree from scratch. |
| 97 | * |
| 98 | * Directories are removed only once no file needs them; a directory that gains its first file |
| 99 | * is added before that file. |
| 100 | */ |
| 101 | export function buildDiffFileTreeUpdates( |
| 102 | previousPaths: ReadonlyArray<string>, |
| 103 | nextPaths: ReadonlyArray<string>, |
| 104 | ): FileTreeBatchOperation[] { |
| 105 | const previousDirectories = new Set(collectDirectoryPaths(previousPaths)); |
| 106 | const nextDirectories = new Set(collectDirectoryPaths(nextPaths)); |
| 107 | const previous = new Set(previousPaths); |
| 108 | const next = new Set(nextPaths); |
| 109 | const updates: FileTreeBatchOperation[] = []; |
| 110 | |
| 111 | for (const path of previousPaths) { |
| 112 | if (!next.has(path)) updates.push({ type: "remove", path }); |
| 113 | } |
| 114 | // Deepest first: a directory can only go once everything under it has. |
| 115 | const removedDirectories = [...previousDirectories] |
| 116 | .filter((directory) => !nextDirectories.has(directory)) |
| 117 | .toSorted((left, right) => pathDepth(right) - pathDepth(left)); |
| 118 | for (const directory of removedDirectories) { |
| 119 | updates.push({ type: "remove", path: directory, recursive: true }); |
| 120 | } |
| 121 | |
| 122 | // Shallowest first: a file's directory has to exist before the file does. |
| 123 | const addedDirectories = [...nextDirectories] |
| 124 | .filter((directory) => !previousDirectories.has(directory)) |
| 125 | .toSorted((left, right) => pathDepth(left) - pathDepth(right)); |
| 126 | for (const directory of addedDirectories) updates.push({ type: "add", path: directory }); |
| 127 | for (const path of nextPaths) { |
| 128 | if (!previous.has(path)) updates.push({ type: "add", path }); |
| 129 | } |
| 130 | |
| 131 | return updates; |
| 132 | } |