Skip to content

Commit 090296a

Browse files
committed
Add state class for open diff dialog
1 parent 5b516c1 commit 090296a

File tree

2 files changed

+378
-357
lines changed

2 files changed

+378
-357
lines changed
Lines changed: 335 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,335 @@
1+
import type { WritableBoxedValues } from "svelte-toolbelt";
2+
import { DirectoryEntry, FileEntry, MultimodalFileInputState, type MultimodalFileInputValueMetadata } from "./components/files/index.svelte";
3+
import { SvelteSet } from "svelte/reactivity";
4+
import { type FileStatus, GITHUB_URL_PARAM } from "$lib/github.svelte";
5+
import { page } from "$app/state";
6+
import { goto } from "$app/navigation";
7+
import { makeImageDetails, makeTextDetails, MultiFileDiffViewerState } from "$lib/diff-viewer-multi-file.svelte";
8+
import { binaryFileDummyDetails, bytesEqual, isBinaryFile, isImageFile, parseMultiFilePatch } from "$lib/util";
9+
import { createTwoFilesPatch } from "diff";
10+
11+
export const PATCH_URL_PARAM = "patch_url";
12+
13+
export interface OpenDiffDialogProps {
14+
open: boolean;
15+
}
16+
17+
export type OpenDiffDialogStateProps = WritableBoxedValues<{
18+
open: boolean;
19+
}>;
20+
21+
type ProtoFileDetails = {
22+
path: string;
23+
file: File;
24+
};
25+
26+
export class OpenDiffDialogState {
27+
private readonly props: OpenDiffDialogStateProps;
28+
private readonly viewer: MultiFileDiffViewerState;
29+
30+
githubUrl = $state("https://github.com/");
31+
32+
patchFile = $state<MultimodalFileInputState | undefined>();
33+
34+
fileOne = $state<MultimodalFileInputState | undefined>();
35+
fileTwo = $state<MultimodalFileInputState | undefined>();
36+
flipFiles = $state(["1", "arrow", "2"]);
37+
38+
dirOne = $state<DirectoryEntry | undefined>();
39+
dirTwo = $state<DirectoryEntry | undefined>();
40+
flipDirs = $state(["1", "arrow", "2"]);
41+
dirBlacklistInput = $state<string>("");
42+
static readonly DEFAULT_DIR_BLACKLIST = [".git/"];
43+
dirBlacklist = new SvelteSet(OpenDiffDialogState.DEFAULT_DIR_BLACKLIST);
44+
dirBlacklistRegexes = $derived.by(() => {
45+
return Array.from(this.dirBlacklist).map((pattern) => new RegExp(pattern));
46+
});
47+
48+
constructor(props: OpenDiffDialogStateProps) {
49+
this.props = props;
50+
this.viewer = MultiFileDiffViewerState.get();
51+
}
52+
53+
addBlacklistEntry() {
54+
if (this.dirBlacklistInput === "") {
55+
return;
56+
}
57+
try {
58+
new RegExp(this.dirBlacklistInput); // Validate regex
59+
} catch (e) {
60+
alert("'" + this.dirBlacklistInput + "' is not a valid regex pattern. Error: " + e);
61+
return;
62+
}
63+
this.dirBlacklist.add(this.dirBlacklistInput);
64+
this.dirBlacklistInput = "";
65+
}
66+
67+
resetBlacklist() {
68+
this.dirBlacklist.clear();
69+
OpenDiffDialogState.DEFAULT_DIR_BLACKLIST.forEach((pattern) => this.dirBlacklist.add(pattern));
70+
}
71+
72+
async compareFiles() {
73+
const fileA = this.flipFiles[0] === "1" ? this.fileOne : this.fileTwo;
74+
const fileB = this.flipFiles[0] === "1" ? this.fileTwo : this.fileOne;
75+
if (!fileA || !fileB || !fileA.metadata || !fileB.metadata) {
76+
alert("Both files must be selected to compare.");
77+
return;
78+
}
79+
const fileAMeta = fileA.metadata;
80+
const fileBMeta = fileB.metadata;
81+
this.props.open.current = false;
82+
const success = await this.viewer.loadPatches(
83+
async () => {
84+
return { type: "file", fileName: `${fileAMeta.name}...${fileBMeta.name}.patch` };
85+
},
86+
async () => {
87+
const isImageDiff = isImageFile(fileAMeta.name) && isImageFile(fileBMeta.name);
88+
let blobA: Blob, blobB: Blob;
89+
try {
90+
[blobA, blobB] = await Promise.all([fileA.resolve(), fileB.resolve()]);
91+
} catch (e) {
92+
console.log("Failed to resolve files:", e);
93+
throw new Error("Failed to resolve files", { cause: e });
94+
}
95+
const [aBinary, bBinary] = await Promise.all([isBinaryFile(blobA), isBinaryFile(blobB)]);
96+
if (aBinary || bBinary) {
97+
if (!isImageDiff) {
98+
throw new Error("Cannot compare binary files (except image-to-image comparisons).");
99+
}
100+
}
101+
if (isImageDiff) {
102+
return this.generateSingleImagePatch(fileAMeta, fileBMeta, blobA, blobB);
103+
} else {
104+
return this.generateSingleTextPatch(fileAMeta, fileBMeta, blobA, blobB);
105+
}
106+
},
107+
);
108+
if (!success) {
109+
this.props.open.current = true;
110+
return;
111+
}
112+
await this.updateUrlParams();
113+
}
114+
115+
async *generateSingleImagePatch(fileAMeta: MultimodalFileInputValueMetadata, fileBMeta: MultimodalFileInputValueMetadata, blobA: Blob, blobB: Blob) {
116+
if (await bytesEqual(blobA, blobB)) {
117+
alert("The files are identical.");
118+
return;
119+
}
120+
121+
let status: FileStatus = "modified";
122+
if (fileAMeta.name !== fileBMeta.name) {
123+
status = "renamed_modified";
124+
}
125+
126+
const img = makeImageDetails(fileAMeta.name, fileBMeta.name, status, blobA, blobB);
127+
img.image.load = true; // load images by default when comparing two files directly
128+
yield img;
129+
}
130+
131+
async *generateSingleTextPatch(fileAMeta: MultimodalFileInputValueMetadata, fileBMeta: MultimodalFileInputValueMetadata, blobA: Blob, blobB: Blob) {
132+
const [textA, textB] = await Promise.all([blobA.text(), blobB.text()]);
133+
if (textA === textB) {
134+
alert("The files are identical.");
135+
return;
136+
}
137+
138+
const diff = createTwoFilesPatch(fileAMeta.name, fileBMeta.name, textA, textB);
139+
let status: FileStatus = "modified";
140+
if (fileAMeta.name !== fileBMeta.name) {
141+
status = "renamed_modified";
142+
}
143+
144+
yield makeTextDetails(fileAMeta.name, fileBMeta.name, status, diff);
145+
}
146+
147+
async compareDirs() {
148+
const dirA = this.flipDirs[0] === "1" ? this.dirOne : this.dirTwo;
149+
const dirB = this.flipDirs[0] === "1" ? this.dirTwo : this.dirOne;
150+
if (!dirA || !dirB) {
151+
alert("Both directories must be selected to compare.");
152+
return;
153+
}
154+
this.props.open.current = false;
155+
const success = await this.viewer.loadPatches(
156+
async () => {
157+
return { type: "file", fileName: `${dirA.fileName}...${dirB.fileName}.patch` };
158+
},
159+
async () => {
160+
return this.generateDirPatches(dirA, dirB);
161+
},
162+
);
163+
if (!success) {
164+
this.props.open.current = true;
165+
return;
166+
}
167+
await this.updateUrlParams();
168+
}
169+
170+
async *generateDirPatches(dirA: DirectoryEntry, dirB: DirectoryEntry) {
171+
const blacklist = (entry: ProtoFileDetails) => {
172+
return !this.dirBlacklistRegexes.some((pattern) => pattern.test(entry.path));
173+
};
174+
const entriesA: ProtoFileDetails[] = this.flatten(dirA).filter(blacklist);
175+
const entriesAMap = new Map(entriesA.map((entry) => [entry.path, entry]));
176+
const entriesB: ProtoFileDetails[] = this.flatten(dirB).filter(blacklist);
177+
const entriesBMap = new Map(entriesB.map((entry) => [entry.path, entry]));
178+
179+
this.viewer.loadingState.totalCount = new Set([...entriesAMap.keys(), ...entriesBMap.keys()]).size;
180+
181+
for (const entry of entriesA) {
182+
const entryB = entriesBMap.get(entry.path);
183+
if (entryB) {
184+
// File exists in both directories
185+
const [aBinary, bBinary] = await Promise.all([isBinaryFile(entry.file), isBinaryFile(entryB.file)]);
186+
187+
if (aBinary || bBinary) {
188+
if (await bytesEqual(entry.file, entryB.file)) {
189+
// Files are identical
190+
this.viewer.loadingState.loadedCount++;
191+
continue;
192+
}
193+
if (isImageFile(entry.file.name) && isImageFile(entryB.file.name)) {
194+
yield makeImageDetails(entry.path, entryB.path, "modified", entry.file, entryB.file);
195+
} else {
196+
yield binaryFileDummyDetails(entry.path, entryB.path, "modified");
197+
}
198+
} else {
199+
const [textA, textB] = await Promise.all([entry.file.text(), entryB.file.text()]);
200+
if (textA === textB) {
201+
// Files are identical
202+
this.viewer.loadingState.loadedCount++;
203+
continue;
204+
}
205+
yield makeTextDetails(entry.path, entryB.path, "modified", createTwoFilesPatch(entry.path, entryB.path, textA, textB));
206+
}
207+
} else if (isImageFile(entry.file.name)) {
208+
// Image file removed
209+
yield makeImageDetails(entry.path, entry.path, "removed", entry.file, entry.file);
210+
} else if (await isBinaryFile(entry.file)) {
211+
// Binary file removed
212+
yield binaryFileDummyDetails(entry.path, entry.path, "removed");
213+
} else {
214+
// Text file removed
215+
yield makeTextDetails(entry.path, entry.path, "removed", createTwoFilesPatch(entry.path, "", await entry.file.text(), ""));
216+
}
217+
}
218+
219+
// Check for added files
220+
for (const entry of entriesB) {
221+
const entryA = entriesAMap.get(entry.path);
222+
if (!entryA) {
223+
if (isImageFile(entry.file.name)) {
224+
yield makeImageDetails(entry.path, entry.path, "added", entry.file, entry.file);
225+
} else if (await isBinaryFile(entry.file)) {
226+
yield binaryFileDummyDetails(entry.path, entry.path, "added");
227+
} else {
228+
yield makeTextDetails(entry.path, entry.path, "added", createTwoFilesPatch("", entry.path, "", await entry.file.text()));
229+
}
230+
}
231+
}
232+
}
233+
234+
flatten(dir: DirectoryEntry): ProtoFileDetails[] {
235+
type StackEntry = {
236+
directory: DirectoryEntry;
237+
prefix: string;
238+
};
239+
const into: ProtoFileDetails[] = [];
240+
const stack: StackEntry[] = [{ directory: dir, prefix: "" }];
241+
242+
while (stack.length > 0) {
243+
const { directory, prefix: currentPrefix } = stack.pop()!;
244+
245+
for (const entry of directory.children) {
246+
if (entry instanceof DirectoryEntry) {
247+
stack.push({
248+
directory: entry,
249+
prefix: currentPrefix + entry.fileName + "/",
250+
});
251+
} else if (entry instanceof FileEntry) {
252+
into.push({
253+
path: currentPrefix + entry.fileName,
254+
file: entry.file,
255+
});
256+
}
257+
}
258+
}
259+
260+
return into;
261+
}
262+
263+
async handlePatchFile() {
264+
if (!this.patchFile || !this.patchFile.metadata) {
265+
alert("No patch file selected.");
266+
return;
267+
}
268+
const meta = this.patchFile.metadata;
269+
let text: string;
270+
try {
271+
const blob = await this.patchFile.resolve();
272+
text = await blob.text();
273+
} catch (e) {
274+
console.error("Failed to resolve patch file:", e);
275+
alert("Failed to resolve patch file: " + e);
276+
return;
277+
}
278+
this.props.open.current = false;
279+
const success = await this.viewer.loadPatches(
280+
async () => {
281+
return { type: "file", fileName: meta.name };
282+
},
283+
async () => {
284+
return parseMultiFilePatch(text, this.viewer.loadingState);
285+
},
286+
);
287+
if (!success) {
288+
this.props.open.current = true;
289+
return;
290+
}
291+
let patchUrl: string | undefined;
292+
if (this.patchFile.mode === "url") {
293+
patchUrl = this.patchFile.url;
294+
}
295+
await this.updateUrlParams({ patchUrl });
296+
}
297+
298+
async handleGithubUrl() {
299+
const url = new URL(this.githubUrl);
300+
// exclude hash + query params
301+
const test = url.protocol + "//" + url.hostname + url.pathname;
302+
303+
const regex = /^https:\/\/github\.com\/([^/]+)\/([^/]+)\/(commit|pull|compare)\/(.+)/;
304+
const match = test.match(regex);
305+
306+
if (!match) {
307+
alert("Invalid GitHub URL. Use: https://github.com/owner/repo/(commit|pull|compare)/(id|ref_a...ref_b)");
308+
return;
309+
}
310+
311+
this.githubUrl = match[0];
312+
this.props.open.current = false;
313+
const success = await this.viewer.loadFromGithubApi(match);
314+
if (success) {
315+
await this.updateUrlParams({ githubUrl: this.githubUrl });
316+
return;
317+
}
318+
this.props.open.current = true;
319+
}
320+
321+
async updateUrlParams(opts: { githubUrl?: string; patchUrl?: string } = {}) {
322+
const newUrl = new URL(page.url);
323+
if (opts.githubUrl) {
324+
newUrl.searchParams.set(GITHUB_URL_PARAM, opts.githubUrl);
325+
} else {
326+
newUrl.searchParams.delete(GITHUB_URL_PARAM);
327+
}
328+
if (opts.patchUrl) {
329+
newUrl.searchParams.set(PATCH_URL_PARAM, opts.patchUrl);
330+
} else {
331+
newUrl.searchParams.delete(PATCH_URL_PARAM);
332+
}
333+
await goto(`?${newUrl.searchParams}`);
334+
}
335+
}

0 commit comments

Comments
 (0)