diff --git a/web/src/lib/components/files/DirectoryInput.svelte b/web/src/lib/components/files/DirectoryInput.svelte index 087dd12..448d56d 100644 --- a/web/src/lib/components/files/DirectoryInput.svelte +++ b/web/src/lib/components/files/DirectoryInput.svelte @@ -1,31 +1,24 @@ - {@render children?.({ directory })} + {@render children?.({ directory, loading })} diff --git a/web/src/lib/components/files/DirectorySelect.svelte b/web/src/lib/components/files/DirectorySelect.svelte index c6f6419..66d7339 100644 --- a/web/src/lib/components/files/DirectorySelect.svelte +++ b/web/src/lib/components/files/DirectorySelect.svelte @@ -1,6 +1,7 @@ - {#snippet children({ directory })} + {#snippet children({ directory, loading })} - {#if directory} + {#if !loading && directory} {directory.fileName} {:else} {placeholder} {/if} + {#if loading} + + {/if} {/snippet} diff --git a/web/src/lib/components/files/index.svelte.ts b/web/src/lib/components/files/index.svelte.ts index 0f80fc2..9027c4f 100644 --- a/web/src/lib/components/files/index.svelte.ts +++ b/web/src/lib/components/files/index.svelte.ts @@ -1,6 +1,8 @@ -import { type ReadableBoxedValues } from "svelte-toolbelt"; +import { type ReadableBoxedValues, type WritableBoxedValues } from "svelte-toolbelt"; import { getExtensionForLanguage, lazyPromise } from "$lib/util"; import type { BundledLanguage, SpecialLanguage } from "shiki"; +import type { Snippet } from "svelte"; +import type { RestProps } from "$lib/types"; export interface FileSystemEntry { fileName: string; @@ -26,7 +28,51 @@ export class FileEntry implements FileSystemEntry { } } -export async function pickDirectory(): Promise { +export type DirectoryInputProps = { + children?: Snippet<[{ directory?: DirectoryEntry; loading: boolean }]>; + directory?: DirectoryEntry; + loading?: boolean; +} & RestProps; + +export type DirectoryInputStateProps = WritableBoxedValues<{ + directory: DirectoryEntry | undefined; + loading: boolean; +}>; + +export class DirectoryInputState { + private readonly opts: DirectoryInputStateProps; + + constructor(opts: DirectoryInputStateProps) { + this.opts = opts; + this.onclick = this.onclick.bind(this); + } + + get props() { + return { + onclick: this.onclick, + }; + } + + async onclick() { + if (this.opts.loading.current) { + return; + } + try { + this.opts.loading.current = true; + this.opts.directory.current = await pickDirectory(); + } catch (e) { + if (e instanceof Error && e.name === "AbortError") { + return; + } else { + console.error("Failed to pick directory", e); + } + } finally { + this.opts.loading.current = false; + } + } +} + +async function pickDirectory(): Promise { if (!window.showDirectoryPicker) { return await pickDirectoryLegacy(); } diff --git a/web/src/lib/components/progress-bar/ProgressBar.svelte b/web/src/lib/components/progress-bar/ProgressBar.svelte new file mode 100644 index 0000000..ec945e9 --- /dev/null +++ b/web/src/lib/components/progress-bar/ProgressBar.svelte @@ -0,0 +1,44 @@ + + + + {@const percent = state.getPercent()} + {#if percent !== undefined} +
+ {:else} +
+ {/if} +
+ + diff --git a/web/src/lib/components/progress-bar/index.svelte.ts b/web/src/lib/components/progress-bar/index.svelte.ts new file mode 100644 index 0000000..57a859d --- /dev/null +++ b/web/src/lib/components/progress-bar/index.svelte.ts @@ -0,0 +1,47 @@ +import type { RestProps } from "$lib/types"; + +export interface ProgressBarProps extends RestProps { + state?: ProgressBarState | undefined; +} + +export function useProgressBarState(state: ProgressBarState | undefined): ProgressBarState { + return state === undefined ? new ProgressBarState(0, 100) : state; +} + +export class ProgressBarState { + value: number | null = $state(null); + max: number = $state(100); + + constructor(value: number | null, max: number) { + this.value = value; + this.max = max; + } + + setProgress(value: number, max: number) { + this.value = value; + this.max = max; + } + + setSpinning() { + this.value = null; + this.max = 100; + } + + isSpinning(): boolean { + return this.value === null; + } + + isDone(): boolean { + return this.value !== null && this.value >= this.max; + } + + getPercent(): number | undefined { + if (this.value === null) { + return undefined; + } + if (this.max <= 0) { + return 0; + } + return (this.value / this.max) * 100; + } +} diff --git a/web/src/lib/diff-viewer-multi-file.svelte.ts b/web/src/lib/diff-viewer-multi-file.svelte.ts index 05030d5..524dfa9 100644 --- a/web/src/lib/diff-viewer-multi-file.svelte.ts +++ b/web/src/lib/diff-viewer-multi-file.svelte.ts @@ -1,4 +1,13 @@ -import { fetchGithubCommitDiff, fetchGithubComparison, fetchGithubPRComparison, type FileStatus, getGithubToken, type GithubDiff } from "./github.svelte"; +import { + fetchGithubCommitDiff, + fetchGithubComparison, + fetchGithubPRComparison, + type FileStatus, + getGithubToken, + type GithubDiff, + type GithubDiffResult, + parseMultiFilePatchGithub, +} from "./github.svelte"; import { type StructuredPatch } from "diff"; import { ConciseDiffViewCachedState, @@ -11,12 +20,13 @@ import { import type { BundledTheme } from "shiki"; import { browser } from "$app/environment"; import { getEffectiveGlobalTheme } from "$lib/theme.svelte"; -import { countOccurrences, type FileTreeNodeData, makeFileTree, type LazyPromise, lazyPromise, watchLocalStorage } from "$lib/util"; -import { onDestroy } from "svelte"; +import { countOccurrences, type FileTreeNodeData, makeFileTree, type LazyPromise, lazyPromise, watchLocalStorage, animationFramePromise } from "$lib/util"; +import { onDestroy, tick } from "svelte"; import { type TreeNode, TreeState } from "$lib/components/tree/index.svelte"; import { VList } from "virtua/svelte"; import { Context, Debounced } from "runed"; import { MediaQuery } from "svelte/reactivity"; +import { ProgressBarState } from "$lib/components/progress-bar/index.svelte"; export type SidebarLocation = "left" | "right"; @@ -318,6 +328,7 @@ export class MultiFileDiffViewerState { fileTreeFilter: string = $state(""); searchQuery: string = $state(""); + // TODO remove parallel arrays to fix order-dependency issues collapsed: boolean[] = $state([]); checked: boolean[] = $state([]); fileDetails: FileDetails[] = $state([]); @@ -327,6 +338,8 @@ export class MultiFileDiffViewerState { activeSearchResult: ActiveSearchResult | null = $state(null); sidebarCollapsed = $state(false); diffMetadata: DiffMetadata | null = $state(null); + loading: boolean = $state(false); + readonly progressBar = $state(new ProgressBarState(null, 100)); readonly fileTreeFilterDebounced = new Debounced(() => this.fileTreeFilter, 500); readonly searchQueryDebounced = new Debounced(() => this.searchQuery, 500); @@ -453,19 +466,70 @@ export class MultiFileDiffViewerState { } } - loadPatches(patches: FileDetails[], meta: DiffMetadata | null) { + private clear(clearMeta: boolean = true) { // Reset state this.collapsed = []; this.checked = []; + if (clearMeta) { + this.diffMetadata = null; + } this.fileDetails = []; this.clearImages(); this.vlist?.scrollToIndex(0, { align: "start" }); - this.diffMetadata = meta; + } + + async loadPatches(meta: () => Promise, patches: () => Promise>) { + if (this.loading) { + alert("Already loading patches, please wait."); + return false; + } + try { + this.progressBar.setSpinning(); + this.loading = true; + await tick(); + await animationFramePromise(); + + this.diffMetadata = await meta(); + await tick(); + await animationFramePromise(); - patches.sort(compareFileDetails); + this.clear(false); + await tick(); + await animationFramePromise(); + + const generator = await patches(); + + const tempDetails: FileDetails[] = []; + for await (const details of generator) { + // Pushing directly to the main array causes too many signals to update (lag) + tempDetails.push(details); + } + if (tempDetails.length === 0) { + throw new Error("No valid patches found in the provided data."); + } + tempDetails.sort(compareFileDetails); + this.fileDetails.push(...tempDetails); + return true; + } catch (e) { + this.clear(); // Clear any partially loaded state + console.error("Failed to load patches:", e); + alert("Failed to load patches: " + e); + return false; + } finally { + this.loading = false; + } + } - // Set this last since it's what the VList loads - this.fileDetails.push(...patches); + private async loadPatchesGithub(resultPromise: Promise) { + return await this.loadPatches( + async () => { + return { type: "github", details: (await resultPromise).info }; + }, + async () => { + const result = await resultPromise; + return parseMultiFilePatchGithub(result.info, await result.response); + }, + ); } // TODO fails for initial commit? @@ -476,13 +540,9 @@ export class MultiFileDiffViewerState { try { if (type === "commit") { - const { info, files } = await fetchGithubCommitDiff(token, owner, repo, id.split("/")[0]); - this.loadPatches(files, { type: "github", details: info }); - return true; + return await this.loadPatchesGithub(fetchGithubCommitDiff(token, owner, repo, id.split("/")[0])); } else if (type === "pull") { - const { info, files } = await fetchGithubPRComparison(token, owner, repo, id.split("/")[0]); - this.loadPatches(files, { type: "github", details: info }); - return true; + return await this.loadPatchesGithub(fetchGithubPRComparison(token, owner, repo, id.split("/")[0])); } else if (type === "compare") { let refs = id.split("..."); if (refs.length !== 2) { @@ -494,9 +554,7 @@ export class MultiFileDiffViewerState { } const base = refs[0]; const head = refs[1]; - const { info, files } = await fetchGithubComparison(token, owner, repo, base, head); - this.loadPatches(files, { type: "github", details: info }); - return true; + return await this.loadPatchesGithub(fetchGithubComparison(token, owner, repo, base, head)); } } catch (error) { console.error(error); diff --git a/web/src/lib/github.svelte.ts b/web/src/lib/github.svelte.ts index b2fab2e..7c3a072 100644 --- a/web/src/lib/github.svelte.ts +++ b/web/src/lib/github.svelte.ts @@ -1,7 +1,7 @@ import { browser } from "$app/environment"; import type { components } from "@octokit/openapi-types"; -import { splitMultiFilePatch, trimCommitHash } from "$lib/util"; -import { type FileDetails, makeImageDetails } from "$lib/diff-viewer-multi-file.svelte"; +import { parseMultiFilePatch, trimCommitHash } from "$lib/util"; +import { makeImageDetails } from "$lib/diff-viewer-multi-file.svelte"; import { PUBLIC_GITHUB_APP_NAME, PUBLIC_GITHUB_CLIENT_ID } from "$env/static/public"; export const GITHUB_USERNAME_KEY = "github_username"; @@ -22,7 +22,7 @@ export type GithubDiff = { export type GithubDiffResult = { info: GithubDiff; - files: FileDetails[]; + response: Promise; }; if (browser) { @@ -139,8 +139,8 @@ export async function fetchGithubPRInfo(token: string | null, owner: string, rep } } -function splitMultiFilePatchGithub(details: GithubDiff, patch: string) { - return splitMultiFilePatch(patch, (from, to, status) => { +export function parseMultiFilePatchGithub(details: GithubDiff, patch: string) { + return parseMultiFilePatch(patch, (from, to, status) => { const token = getGithubToken(); return makeImageDetails( from, @@ -176,7 +176,7 @@ export async function fetchGithubComparison( url = `https://github.com/${owner}/${repo}/compare/${base}...${head}`; } const info = { owner, repo, base, head, description, backlink: url }; - return { files: splitMultiFilePatchGithub(info, await response.text()), info }; + return { response: response.text(), info }; } else { throw Error(`Failed to retrieve comparison (${response.status}): ${await response.text()}`); } @@ -207,7 +207,7 @@ export async function fetchGithubCommitDiff(token: string | null, owner: string, const description = `${meta.commit.message.split("\n")[0]} (${trimCommitHash(commit)})`; const info = { owner, repo, base: firstParent, head: commit, description, backlink: meta.html_url }; return { - files: splitMultiFilePatchGithub(info, await response.text()), + response: response.text(), info, }; } else { diff --git a/web/src/lib/util.ts b/web/src/lib/util.ts index 9dcba93..c86b414 100644 --- a/web/src/lib/util.ts +++ b/web/src/lib/util.ts @@ -98,14 +98,14 @@ export function binaryFileDummyDetails(fromFile: string, toFile: string, status: const fileRegex = /diff --git a\/(\S+) b\/(\S+)\r?\n(?:.+\r?\n)*?(?=-- \r?\n|diff --git|$)/g; -function parseHeader( - patch: string, - fromFile: string, - toFile: string, -): { +type BasicHeader = { + fromFile: string; + toFile: string; status: FileStatus; binary: boolean; -} { +}; + +function parseHeader(patch: string, fromFile: string, toFile: string): BasicHeader { let status: FileStatus = "modified"; if (fromFile !== toFile) { status = "renamed_modified"; @@ -141,34 +141,42 @@ function parseHeader( lineStart = lineEnd + 1; } - return { status, binary }; + return { fromFile, toFile, status, binary }; } -export function splitMultiFilePatch( +export function parseMultiFilePatch( patchContent: string, imageFactory?: (fromFile: string, toFile: string, status: FileStatus) => ImageFileDetails | null, -): FileDetails[] { - const patches: FileDetails[] = []; - // Process each file in the diff - let fileMatch; - while ((fileMatch = fileRegex.exec(patchContent)) !== null) { - const [fullFileMatch, fromFile, toFile] = fileMatch; - const { status, binary } = parseHeader(fullFileMatch, fromFile, toFile); - - if (binary) { - if (imageFactory !== undefined && isImageFile(fromFile) && isImageFile(toFile)) { - const imageDetails = imageFactory(fromFile, toFile, status); - if (imageDetails != null) { - patches.push(imageDetails); +): AsyncGenerator { + const split = splitMultiFilePatch(patchContent); + async function* detailsGenerator() { + for (const [header, content] of split) { + if (header.binary) { + if (imageFactory !== undefined && isImageFile(header.fromFile) && isImageFile(header.toFile)) { + const imageDetails = imageFactory(header.fromFile, header.toFile, header.status); + if (imageDetails != null) { + yield imageDetails; + continue; + } + } else { + yield binaryFileDummyDetails(header.fromFile, header.toFile, header.status); continue; } - } else { - patches.push(binaryFileDummyDetails(fromFile, toFile, status)); - continue; } + + yield makeTextDetails(header.fromFile, header.toFile, header.status, content); } + } + return detailsGenerator(); +} - patches.push(makeTextDetails(fromFile, toFile, status, fullFileMatch)); +export function splitMultiFilePatch(patchContent: string): [BasicHeader, string][] { + const patches: [BasicHeader, string][] = []; + let fileMatch; + while ((fileMatch = fileRegex.exec(patchContent)) !== null) { + const [fullFileMatch, fromFile, toFile] = fileMatch; + const header = parseHeader(fullFileMatch, fromFile, toFile); + patches.push([header, fullFileMatch]); } return patches; } @@ -455,6 +463,12 @@ export const resizeObserver: Action = (node }; }; +export function animationFramePromise() { + return new Promise((resolve) => { + requestAnimationFrame(resolve); + }); +} + // from bits-ui internals export type ReadableBoxedValues = { [K in keyof T]: ReadableBox; diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte index aaeab24..5b80796 100644 --- a/web/src/routes/+page.svelte +++ b/web/src/routes/+page.svelte @@ -33,6 +33,7 @@ import { onClickOutside } from "runed"; import SidebarToggle from "./SidebarToggle.svelte"; import type { PageProps } from "./$types"; + import ProgressBar from "$lib/components/progress-bar/ProgressBar.svelte"; let { data }: PageProps = $props(); const globalOptions = GlobalOptions.init(data.globalOptions); @@ -137,6 +138,12 @@ {/snippet} +{#if viewer.loading} +
+ +
+{/if} +
{ + return { type: "file", fileName: `${fileAMeta.name}...${fileBMeta.name}.patch` }; + }, + async () => { + const isImageDiff = isImageFile(fileAMeta.name) && isImageFile(fileBMeta.name); + let blobA: Blob, blobB: Blob; + try { + [blobA, blobB] = await Promise.all([fileA.resolve(), fileB.resolve()]); + } catch (e) { + console.log("Failed to resolve files:", e); + throw new Error("Failed to resolve files", { cause: e }); + } + const [aBinary, bBinary] = await Promise.all([isBinaryFile(blobA), isBinaryFile(blobB)]); + if (aBinary || bBinary) { + if (!isImageDiff) { + throw new Error("Cannot compare binary files (except image-to-image comparisons)."); + } + } + if (isImageDiff) { + return generateSingleImagePatch(fileAMeta, fileBMeta, blobA, blobB); + } else { + return generateSingleTextPatch(fileAMeta, fileBMeta, blobA, blobB); + } + }, + ); + if (!success) { + modalOpen = true; return; } - const [aBinary, bBinary] = await Promise.all([isBinaryFile(blobA), isBinaryFile(blobB)]); - if (aBinary || bBinary) { - if (!isImageDiff) { - alert("Cannot compare binary files (except image-to-image comparisons)."); - return; - } - } - - const fileDetails: FileDetails[] = []; + await updateUrlParams(); + } - if (isImageDiff) { - if (await bytesEqual(blobA, blobB)) { - alert("The files are identical."); - return; - } + async function* generateSingleImagePatch( + fileAMeta: MultimodalFileInputValueMetadata, + fileBMeta: MultimodalFileInputValueMetadata, + blobA: Blob, + blobB: Blob, + ) { + if (await bytesEqual(blobA, blobB)) { + alert("The files are identical."); + return; + } - let status: FileStatus = "modified"; - if (fileA.metadata.name !== fileB.metadata.name) { - status = "renamed_modified"; - } + let status: FileStatus = "modified"; + if (fileAMeta.name !== fileBMeta.name) { + status = "renamed_modified"; + } - const img = makeImageDetails(fileA.metadata.name, fileB.metadata.name, status, blobA, blobB); - img.image.load = true; // load images by default when comparing two files directly - fileDetails.push(img); - } else { - const [textA, textB] = await Promise.all([blobA.text(), blobB.text()]); - if (textA === textB) { - alert("The files are identical."); - return; - } + const img = makeImageDetails(fileAMeta.name, fileBMeta.name, status, blobA, blobB); + img.image.load = true; // load images by default when comparing two files directly + yield img; + } - const diff = createTwoFilesPatch(fileA.metadata.name, fileB.metadata.name, textA, textB); - let status: FileStatus = "modified"; - if (fileA.metadata.name !== fileB.metadata.name) { - status = "renamed_modified"; - } + async function* generateSingleTextPatch( + fileAMeta: MultimodalFileInputValueMetadata, + fileBMeta: MultimodalFileInputValueMetadata, + blobA: Blob, + blobB: Blob, + ) { + const [textA, textB] = await Promise.all([blobA.text(), blobB.text()]); + if (textA === textB) { + alert("The files are identical."); + return; + } - fileDetails.push(makeTextDetails(fileA.metadata.name, fileB.metadata.name, status, diff)); + const diff = createTwoFilesPatch(fileAMeta.name, fileBMeta.name, textA, textB); + let status: FileStatus = "modified"; + if (fileAMeta.name !== fileBMeta.name) { + status = "renamed_modified"; } - viewer.loadPatches(fileDetails, { type: "file", fileName: `${fileA.metadata.name}...${fileB.metadata.name}.patch` }); - await updateUrlParams(); - modalOpen = false; + yield makeTextDetails(fileAMeta.name, fileBMeta.name, status, diff); } type ProtoFileDetails = { @@ -132,7 +156,23 @@ alert("Both directories must be selected to compare."); return; } + modalOpen = false; + const success = await viewer.loadPatches( + async () => { + return { type: "file", fileName: `${dirA.fileName}...${dirB.fileName}.patch` }; + }, + async () => { + return generateDirPatches(dirA, dirB); + }, + ); + if (!success) { + modalOpen = true; + return; + } + await updateUrlParams(); + } + async function* generateDirPatches(dirA: DirectoryEntry, dirB: DirectoryEntry) { const blacklist = (entry: ProtoFileDetails) => { return !dirBlacklistRegexes.some((pattern) => pattern.test(entry.path)); }; @@ -141,7 +181,6 @@ const entriesB: ProtoFileDetails[] = flatten(dirB).filter(blacklist); const entriesBMap = new Map(entriesB.map((entry) => [entry.path, entry])); - const fileDetails: FileDetails[] = []; for (const entry of entriesA) { const entryB = entriesBMap.get(entry.path); if (entryB) { @@ -154,9 +193,9 @@ continue; } if (isImageFile(entry.file.name) && isImageFile(entryB.file.name)) { - fileDetails.push(makeImageDetails(entry.path, entryB.path, "modified", entry.file, entryB.file)); + yield makeImageDetails(entry.path, entryB.path, "modified", entry.file, entryB.file); } else { - fileDetails.push(binaryFileDummyDetails(entry.path, entryB.path, "modified")); + yield binaryFileDummyDetails(entry.path, entryB.path, "modified"); } } else { const [textA, textB] = await Promise.all([entry.file.text(), entryB.file.text()]); @@ -164,17 +203,17 @@ // Files are identical continue; } - fileDetails.push(makeTextDetails(entry.path, entryB.path, "modified", createTwoFilesPatch(entry.path, entryB.path, textA, textB))); + yield makeTextDetails(entry.path, entryB.path, "modified", createTwoFilesPatch(entry.path, entryB.path, textA, textB)); } } else if (isImageFile(entry.file.name)) { // Image file removed - fileDetails.push(makeImageDetails(entry.path, entry.path, "removed", entry.file, entry.file)); + yield makeImageDetails(entry.path, entry.path, "removed", entry.file, entry.file); } else if (await isBinaryFile(entry.file)) { // Binary file removed - fileDetails.push(binaryFileDummyDetails(entry.path, entry.path, "removed")); + yield binaryFileDummyDetails(entry.path, entry.path, "removed"); } else { // Text file removed - fileDetails.push(makeTextDetails(entry.path, entry.path, "removed", createTwoFilesPatch(entry.path, "", await entry.file.text(), ""))); + yield makeTextDetails(entry.path, entry.path, "removed", createTwoFilesPatch(entry.path, "", await entry.file.text(), "")); } } @@ -183,18 +222,14 @@ const entryA = entriesAMap.get(entry.path); if (!entryA) { if (isImageFile(entry.file.name)) { - fileDetails.push(makeImageDetails(entry.path, entry.path, "added", entry.file, entry.file)); + yield makeImageDetails(entry.path, entry.path, "added", entry.file, entry.file); } else if (await isBinaryFile(entry.file)) { - fileDetails.push(binaryFileDummyDetails(entry.path, entry.path, "added")); + yield binaryFileDummyDetails(entry.path, entry.path, "added"); } else { - fileDetails.push(makeTextDetails(entry.path, entry.path, "added", createTwoFilesPatch("", entry.path, "", await entry.file.text()))); + yield makeTextDetails(entry.path, entry.path, "added", createTwoFilesPatch("", entry.path, "", await entry.file.text())); } } } - - viewer.loadPatches(fileDetails, { type: "file", fileName: `${dirA.fileName}...${dirB.fileName}.patch` }); - await updateUrlParams(); - modalOpen = false; } function flatten(dir: DirectoryEntry): ProtoFileDetails[] { @@ -231,6 +266,7 @@ alert("No patch file selected."); return; } + const meta = patchFile.metadata; let text: string; try { const blob = await patchFile.resolve(); @@ -240,13 +276,19 @@ alert("Failed to resolve patch file: " + e); return; } - const files = splitMultiFilePatch(text); - if (files.length === 0) { - alert("No valid patches found in the file."); + modalOpen = false; + const success = await viewer.loadPatches( + async () => { + return { type: "file", fileName: meta.name }; + }, + async () => { + return parseMultiFilePatch(text); + }, + ); + if (!success) { + modalOpen = true; return; } - modalOpen = false; - viewer.loadPatches(files, { type: "file", fileName: patchFile.metadata.name }); await updateUrlParams(); }