Skip to content
Source
Sign inSign up
Open History to see the last commit

diffFileTree.logic.ts

178 lines6.4 KB

1import type { FileDiffMetadata } from "@pierre/diffs";
2import type {
3 FileTreeBatchOperation,
4 FileTreeIconConfig,
5 FileTreeSortComparator,
6 GitStatus,
7} from "@pierre/trees";
8​
9import { resolveFileDiffPath } from "~/lib/diffRendering";
10import { basenameOfPath, resolvePierreIconForEntry, T3_PIERRE_ICONS } from "~/pierre-icons";
11​
12export interface DiffFileTreeEntry {
13 readonly path: string;
14 readonly status: GitStatus;
16​
17function toGitStatus(file: FileDiffMetadata): GitStatus {
18 switch (file.type) {
19 case "new":
20 return "added";
21 case "deleted":
22 return "deleted";
23 case "rename-pure":
24 case "rename-changed":
25 return "renamed";
26 case "change":
27 return "modified";
28 }
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 }));
43​
44// Pierre cannot store a file and a directory at the same path. Give only those
45// files an invisible suffix, keeping real paths at the diff/viewer boundary.
46export function diffFileTreeModel(paths: ReadonlyArray<string>) {
47 const directories = new Set(collectDirectoryPaths(paths));
48 // Icon overrides match basenames case-insensitively, so aliases must also avoid
49 // real names in other directories, including names that already contain the suffix.
50 const occupiedNames = new Set(paths.flatMap((path) => path.toLowerCase().split("/")));
51 const selectionByModelPath = new Map<string, string>();
52 const modelBySelectionPath = new Map<string, string>();
53 const byFileName: FileTreeIconConfig["byFileName"] = { ...T3_PIERRE_ICONS.byFileName };
54 const modelPaths = paths.map((path) => {
55 if (!directories.has(`${path}/`)) return path;
56 let modelPath = path;
57 do {
58 modelPath += "\u200b";
59 } while (occupiedNames.has(basenameOfPath(modelPath).toLowerCase()));
60 occupiedNames.add(basenameOfPath(modelPath).toLowerCase());
61 selectionByModelPath.set(modelPath, path);
62 modelBySelectionPath.set(path, modelPath);
63 const icon = resolvePierreIconForEntry(path, "file");
64 if (icon !== null) byFileName[basenameOfPath(modelPath)] = icon;
65 return modelPath;
66 });
67 return {
68 paths: modelBySelectionPath.size === 0 ? paths : modelPaths,
69 selectionPath: (modelPath: string) => selectionByModelPath.get(modelPath) ?? modelPath,
70 modelPath: (path: string) => modelBySelectionPath.get(path) ?? path,
71 icons: modelBySelectionPath.size === 0 ? T3_PIERRE_ICONS : { ...T3_PIERRE_ICONS, byFileName },
72 };
74​
75export function collectDirectoryPaths(paths: ReadonlyArray<string>): ReadonlyArray<string> {
76 const directories = new Set<string>();
77 for (const path of paths) {
78 const segments = path.split("/");
79 let directory = "";
80 for (const segment of segments.slice(0, -1)) {
81 directory += `${segment}/`;
82 directories.add(directory);
83 }
84 }
85 return [...directories];
87​
88export function diffFileTreePositions(paths: ReadonlyArray<string>): ReadonlyMap<string, number> {
89 const positions = new Map<string, number>();
90 paths.forEach((path, index) => {
91 positions.set(path, index);
92 let directory = "";
93 for (const segment of path.split("/").slice(0, -1)) {
94 directory += `${segment}/`;
95 if (!positions.has(directory)) positions.set(directory, index);
96 }
97 });
98 return positions;
100​
101// A flat list in tree order: each directory stays contiguous, siblings ranked as
102// compareDiffFileTreeEntries ranks them, so the list never disagrees with the tree.
103export function orderDiffFilesLikeTree(
104 files: ReadonlyArray<FileDiffMetadata>,
105): ReadonlyArray<FileDiffMetadata> {
106 const paths = files.map(resolveFileDiffPath);
107 const positions = diffFileTreePositions(paths);
108 const keys = paths.map((path) => {
109 const key: number[] = [];
110 let directory = "";
111 for (const segment of path.split("/").slice(0, -1)) {
112 directory += `${segment}/`;
113 key.push(positions.get(directory)!);
114 }
115 key.push(positions.get(path)!);
116 return key;
117 });
118 return files
119 .map((file, index) => ({ file, key: keys[index]! }))
120 .toSorted((left, right) => {
121 const length = Math.min(left.key.length, right.key.length);
122 for (let index = 0; index < length; index++) {
123 const difference = left.key[index]! - right.key[index]!;
124 if (difference !== 0) return difference;
125 }
126 return left.key.length - right.key.length;
127 })
128 .map(({ file }) => file);
130​
131export function compareDiffFileTreeEntries(
132 getPositions: () => ReadonlyMap<string, number>,
133): FileTreeSortComparator {
134 return (left, right) => {
135 const positions = getPositions();
136 return (
137 (positions.get(left.path) ?? Number.MAX_SAFE_INTEGER) -
138 (positions.get(right.path) ?? Number.MAX_SAFE_INTEGER) ||
139 left.depth - right.depth ||
140 left.path.localeCompare(right.path)
141 );
142 };
144​
145function pathDepth(path: string): number {
146 return path.split("/").filter(Boolean).length;
148​
149export function buildDiffFileTreeUpdates(
150 previousPaths: ReadonlyArray<string>,
151 nextPaths: ReadonlyArray<string>,
152): FileTreeBatchOperation[] {
153 const previousDirectories = new Set(collectDirectoryPaths(previousPaths));
154 const nextDirectories = new Set(collectDirectoryPaths(nextPaths));
155 const previous = new Set(previousPaths);
156 const next = new Set(nextPaths);
157 const updates: FileTreeBatchOperation[] = [];
158​
159 for (const path of previousPaths) {
160 if (!next.has(path)) updates.push({ type: "remove", path });
161 }
162 const removedDirectories = [...previousDirectories]
163 .filter((directory) => !nextDirectories.has(directory))
164 .toSorted((left, right) => pathDepth(right) - pathDepth(left));
165 for (const directory of removedDirectories) {
166 updates.push({ type: "remove", path: directory, recursive: true });
167 }
168​
169 const addedDirectories = [...nextDirectories]
170 .filter((directory) => !previousDirectories.has(directory))
171 .toSorted((left, right) => pathDepth(left) - pathDepth(right));
172 for (const directory of addedDirectories) updates.push({ type: "add", path: directory });
173 for (const path of nextPaths) {
174 if (!previous.has(path)) updates.push({ type: "add", path });
175 }
176​
177 return updates;