1import type { FileDiffMetadata } from "@pierre/diffs";
2import type { FileTreeBatchOperation, FileTreeSortComparator, GitStatus } from "@pierre/trees";
3
4import { resolveFileDiffPath } from "~/lib/diffRendering";
5
6/** One changed file as the tree shows it: its current path and how it changed. */
7export interface DiffFileTreeEntry {
8 readonly path: string;
9 readonly status: GitStatus;
10}
11
12function 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 */
31export 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 */
48export 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. */
62export 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
75export 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
89function 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 */
101export 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}