|
| 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