diff --git a/web/src/lib/components/progress-bar/ProgressBar.svelte b/web/src/lib/components/progress-bar/ProgressBar.svelte
index ec945e9..56f9bfc 100644
--- a/web/src/lib/components/progress-bar/ProgressBar.svelte
+++ b/web/src/lib/components/progress-bar/ProgressBar.svelte
@@ -20,7 +20,7 @@
{@const percent = state.getPercent()}
{#if percent !== undefined}
{:else}
diff --git a/web/src/lib/diff-viewer-multi-file.svelte.ts b/web/src/lib/diff-viewer-multi-file.svelte.ts
index 524dfa9..f23602e 100644
--- a/web/src/lib/diff-viewer-multi-file.svelte.ts
+++ b/web/src/lib/diff-viewer-multi-file.svelte.ts
@@ -20,11 +20,20 @@ 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, animationFramePromise } from "$lib/util";
+import {
+ countOccurrences,
+ type FileTreeNodeData,
+ makeFileTree,
+ type LazyPromise,
+ lazyPromise,
+ watchLocalStorage,
+ animationFramePromise,
+ yieldToBrowser,
+} 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 { Context, Debounced, watch } from "runed";
import { MediaQuery } from "svelte/reactivity";
import { ProgressBarState } from "$lib/components/progress-bar/index.svelte";
@@ -338,8 +347,7 @@ 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 loadingState: LoadingState = $state(new LoadingState());
readonly fileTreeFilterDebounced = new Debounced(() => this.fileTreeFilter, 500);
readonly searchQueryDebounced = new Debounced(() => this.searchQuery, 500);
@@ -479,30 +487,51 @@ export class MultiFileDiffViewerState {
}
async loadPatches(meta: () => Promise, patches: () => Promise>) {
- if (this.loading) {
+ if (this.loadingState.loading) {
alert("Already loading patches, please wait.");
return false;
}
try {
- this.progressBar.setSpinning();
- this.loading = true;
+ // Show progress bar
+ this.loadingState.start();
await tick();
await animationFramePromise();
- this.diffMetadata = await meta();
+ // Start potential multiple web requests in parallel
+ const metaPromise = meta();
+ const generatorPromise = patches();
+
+ // Update metadata
+ this.diffMetadata = await metaPromise;
await tick();
await animationFramePromise();
+ // Clear previous state
this.clear(false);
await tick();
await animationFramePromise();
- const generator = await patches();
+ // Setup generator
+ const generator = await generatorPromise;
+ await tick();
+ await animationFramePromise();
+ // Load patches
const tempDetails: FileDetails[] = [];
+ let lastYield = performance.now();
+ let i = 0;
for await (const details of generator) {
+ i++;
+ this.loadingState.loadedCount++;
+
// Pushing directly to the main array causes too many signals to update (lag)
tempDetails.push(details);
+
+ if (performance.now() - lastYield > 50 || i % 100 === 0) {
+ await tick();
+ await yieldToBrowser();
+ lastYield = performance.now();
+ }
}
if (tempDetails.length === 0) {
throw new Error("No valid patches found in the provided data.");
@@ -516,18 +545,23 @@ export class MultiFileDiffViewerState {
alert("Failed to load patches: " + e);
return false;
} finally {
- this.loading = false;
+ // Let the last progress update render before closing the loading state
+ await tick();
+ await animationFramePromise();
+
+ this.loadingState.done();
}
}
- private async loadPatchesGithub(resultPromise: Promise) {
+ private async loadPatchesGithub(resultOrPromise: Promise | GithubDiffResult) {
return await this.loadPatches(
async () => {
- return { type: "github", details: (await resultPromise).info };
+ const result = resultOrPromise instanceof Promise ? await resultOrPromise : resultOrPromise;
+ return { type: "github", details: await result.info };
},
async () => {
- const result = await resultPromise;
- return parseMultiFilePatchGithub(result.info, await result.response);
+ const result = resultOrPromise instanceof Promise ? await resultOrPromise : resultOrPromise;
+ return parseMultiFilePatchGithub(await result.info, await result.response, this.loadingState);
},
);
}
@@ -661,6 +695,34 @@ export class MultiFileDiffViewerState {
}
}
+export class LoadingState {
+ loading: boolean = $state(false);
+ loadedCount: number = $state(0);
+ totalCount: number | null = $state(0);
+ readonly progressBar = $state(new ProgressBarState(null, 100));
+
+ constructor() {
+ watch([() => this.loadedCount, () => this.totalCount], ([loadedCount, totalCount]) => {
+ if (totalCount === null || totalCount <= 0) {
+ this.progressBar.setSpinning();
+ } else {
+ this.progressBar.setProgress(loadedCount, totalCount);
+ }
+ });
+ }
+
+ start() {
+ this.loadedCount = 0;
+ this.totalCount = null;
+ this.progressBar.setSpinning();
+ this.loading = true;
+ }
+
+ done() {
+ this.loading = false;
+ }
+}
+
export type ActiveSearchResult = {
file: FileDetails;
idx: number;
diff --git a/web/src/lib/github.svelte.ts b/web/src/lib/github.svelte.ts
index 7c3a072..980c06e 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 { parseMultiFilePatch, trimCommitHash } from "$lib/util";
-import { makeImageDetails } from "$lib/diff-viewer-multi-file.svelte";
+import { LoadingState, 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";
@@ -21,7 +21,7 @@ export type GithubDiff = {
};
export type GithubDiffResult = {
- info: GithubDiff;
+ info: Promise;
response: Promise;
};
@@ -112,7 +112,7 @@ export async function fetchGithubPRComparison(token: string | null, owner: strin
const base = prInfo.base.sha;
const head = prInfo.head.sha;
const title = `${prInfo.title} (#${prInfo.number})`;
- return await fetchGithubComparison(token, owner, repo, base, head, title, prInfo.html_url);
+ return fetchGithubComparison(token, owner, repo, base, head, title, prInfo.html_url);
}
function injectOptionalToken(token: string | null, opts: RequestInit) {
@@ -124,7 +124,7 @@ function injectOptionalToken(token: string | null, opts: RequestInit) {
}
}
-export async function fetchGithubPRInfo(token: string | null, owner: string, repo: string, prNumber: string): Promise {
+async function fetchGithubPRInfo(token: string | null, owner: string, repo: string, prNumber: string): Promise {
const opts: RequestInit = {
headers: {
Accept: "application/json",
@@ -139,8 +139,8 @@ export async function fetchGithubPRInfo(token: string | null, owner: string, rep
}
}
-export function parseMultiFilePatchGithub(details: GithubDiff, patch: string) {
- return parseMultiFilePatch(patch, (from, to, status) => {
+export function parseMultiFilePatchGithub(details: GithubDiff, patch: string, loadingState: LoadingState) {
+ return parseMultiFilePatch(patch, loadingState, (from, to, status) => {
const token = getGithubToken();
return makeImageDetails(
from,
@@ -152,7 +152,7 @@ export function parseMultiFilePatchGithub(details: GithubDiff, patch: string) {
});
}
-export async function fetchGithubComparison(
+export function fetchGithubComparison(
token: string | null,
owner: string,
repo: string,
@@ -160,59 +160,66 @@ export async function fetchGithubComparison(
head: string,
description?: string,
url?: string,
-): Promise {
- const opts: RequestInit = {
- headers: {
- Accept: "application/vnd.github.v3.diff",
- },
+): GithubDiffResult {
+ return {
+ info: (async () => {
+ if (!url) {
+ url = `https://github.com/${owner}/${repo}/compare/${base}...${head}`;
+ }
+ if (!description) {
+ description = `Comparing ${trimCommitHash(base)}...${trimCommitHash(head)}`;
+ }
+ return { owner, repo, base, head, description, backlink: url };
+ })(),
+ response: (async () => {
+ const opts: RequestInit = {
+ headers: {
+ Accept: "application/vnd.github.v3.diff",
+ },
+ };
+ injectOptionalToken(token, opts);
+ const response = await fetch(`https://api.github.com/repos/${owner}/${repo}/compare/${base}...${head}`, opts);
+ if (!response.ok) {
+ throw Error(`Failed to retrieve comparison (${response.status}): ${await response.text()}`);
+ }
+ return await response.text();
+ })(),
};
- injectOptionalToken(token, opts);
- const response = await fetch(`https://api.github.com/repos/${owner}/${repo}/compare/${base}...${head}`, opts);
- if (response.ok) {
- if (!description) {
- description = `Comparing ${trimCommitHash(base)}...${trimCommitHash(head)}`;
- }
- if (!url) {
- url = `https://github.com/${owner}/${repo}/compare/${base}...${head}`;
- }
- const info = { owner, repo, base, head, description, backlink: url };
- return { response: response.text(), info };
- } else {
- throw Error(`Failed to retrieve comparison (${response.status}): ${await response.text()}`);
- }
}
-export async function fetchGithubCommitDiff(token: string | null, owner: string, repo: string, commit: string): Promise {
- const diffOpts: RequestInit = {
- headers: {
- Accept: "application/vnd.github.v3.diff",
- },
- };
- injectOptionalToken(token, diffOpts);
+export function fetchGithubCommitDiff(token: string | null, owner: string, repo: string, commit: string): GithubDiffResult {
const url = `https://api.github.com/repos/${owner}/${repo}/commits/${commit}`;
- const response = await fetch(url, diffOpts);
- if (response.ok) {
- const metaOpts: RequestInit = {
- headers: {
- Accept: "application/vnd.github+json",
- },
- };
- injectOptionalToken(token, metaOpts);
- const metaResponse = await fetch(url, metaOpts);
- if (!metaResponse.ok) {
- throw Error(`Failed to retrieve commit meta (${metaResponse.status}): ${await metaResponse.text()}`);
- }
- const meta: GithubCommitDetails = await metaResponse.json();
- const firstParent = meta.parents[0].sha;
- const description = `${meta.commit.message.split("\n")[0]} (${trimCommitHash(commit)})`;
- const info = { owner, repo, base: firstParent, head: commit, description, backlink: meta.html_url };
- return {
- response: response.text(),
- info,
- };
- } else {
- throw Error(`Failed to retrieve commit diff (${response.status}): ${await response.text()}`);
- }
+ return {
+ info: (async () => {
+ const metaOpts: RequestInit = {
+ headers: {
+ Accept: "application/vnd.github+json",
+ },
+ };
+ injectOptionalToken(token, metaOpts);
+ const metaResponse = await fetch(url, metaOpts);
+ if (!metaResponse.ok) {
+ throw Error(`Failed to retrieve commit meta (${metaResponse.status}): ${await metaResponse.text()}`);
+ }
+ const meta: GithubCommitDetails = await metaResponse.json();
+ const firstParent = meta.parents[0].sha;
+ const description = `${meta.commit.message.split("\n")[0]} (${trimCommitHash(commit)})`;
+ return { owner, repo, base: firstParent, head: commit, description, backlink: meta.html_url };
+ })(),
+ response: (async () => {
+ const diffOpts: RequestInit = {
+ headers: {
+ Accept: "application/vnd.github.v3.diff",
+ },
+ };
+ injectOptionalToken(token, diffOpts);
+ const response = await fetch(url, diffOpts);
+ if (!response.ok) {
+ throw Error(`Failed to retrieve commit diff (${response.status}): ${await response.text()}`);
+ }
+ return await response.text();
+ })(),
+ };
}
export async function fetchGithubFile(token: string | null, owner: string, repo: string, path: string, ref: string): Promise {
diff --git a/web/src/lib/util.ts b/web/src/lib/util.ts
index c86b414..b5218e0 100644
--- a/web/src/lib/util.ts
+++ b/web/src/lib/util.ts
@@ -1,4 +1,4 @@
-import { type FileDetails, type ImageFileDetails, makeTextDetails } from "./diff-viewer-multi-file.svelte";
+import { type FileDetails, type ImageFileDetails, LoadingState, makeTextDetails } from "./diff-viewer-multi-file.svelte";
import type { FileStatus } from "./github.svelte";
import type { TreeNode } from "$lib/components/tree/index.svelte";
import type { BundledLanguage, SpecialLanguage } from "shiki";
@@ -146,9 +146,11 @@ function parseHeader(patch: string, fromFile: string, toFile: string): BasicHead
export function parseMultiFilePatch(
patchContent: string,
+ loadingState: LoadingState,
imageFactory?: (fromFile: string, toFile: string, status: FileStatus) => ImageFileDetails | null,
): AsyncGenerator {
const split = splitMultiFilePatch(patchContent);
+ loadingState.totalCount = split.length;
async function* detailsGenerator() {
for (const [header, content] of split) {
if (header.binary) {
@@ -469,6 +471,10 @@ export function animationFramePromise() {
});
}
+export async function yieldToBrowser() {
+ await new Promise((resolve) => setTimeout(resolve, 0));
+}
+
// 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 a2be111..8e4e9fc 100644
--- a/web/src/routes/+page.svelte
+++ b/web/src/routes/+page.svelte
@@ -138,9 +138,9 @@
{/snippet}
-{#if viewer.loading}
+{#if viewer.loadingState.loading}
{/if}
diff --git a/web/src/routes/OpenDiffDialog.svelte b/web/src/routes/OpenDiffDialog.svelte
index 9f45c2f..800bcdd 100644
--- a/web/src/routes/OpenDiffDialog.svelte
+++ b/web/src/routes/OpenDiffDialog.svelte
@@ -181,6 +181,8 @@
const entriesB: ProtoFileDetails[] = flatten(dirB).filter(blacklist);
const entriesBMap = new Map(entriesB.map((entry) => [entry.path, entry]));
+ viewer.loadingState.totalCount = new Set([...entriesAMap.keys(), ...entriesBMap.keys()]).size;
+
for (const entry of entriesA) {
const entryB = entriesBMap.get(entry.path);
if (entryB) {
@@ -190,6 +192,7 @@
if (aBinary || bBinary) {
if (await bytesEqual(entry.file, entryB.file)) {
// Files are identical
+ viewer.loadingState.loadedCount++;
continue;
}
if (isImageFile(entry.file.name) && isImageFile(entryB.file.name)) {
@@ -201,6 +204,7 @@
const [textA, textB] = await Promise.all([entry.file.text(), entryB.file.text()]);
if (textA === textB) {
// Files are identical
+ viewer.loadingState.loadedCount++;
continue;
}
yield makeTextDetails(entry.path, entryB.path, "modified", createTwoFilesPatch(entry.path, entryB.path, textA, textB));
@@ -282,7 +286,7 @@
return { type: "file", fileName: meta.name };
},
async () => {
- return parseMultiFilePatch(text);
+ return parseMultiFilePatch(text, viewer.loadingState);
},
);
if (!success) {