Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion web/src/lib/components/progress-bar/ProgressBar.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
{@const percent = state.getPercent()}
{#if percent !== undefined}
<div
class="h-full w-full rounded-full bg-primary drop-shadow-sm drop-shadow-primary/50 transition-all duration-250 ease-in-out"
class="h-full w-full rounded-full bg-primary drop-shadow-sm drop-shadow-primary/50 transition-all duration-50 ease-in-out will-change-transform"
style={`transform: translateX(-${100 - percent}%)`}
></div>
{:else}
Expand Down
90 changes: 76 additions & 14 deletions web/src/lib/diff-viewer-multi-file.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -479,30 +487,51 @@ export class MultiFileDiffViewerState {
}

async loadPatches(meta: () => Promise<DiffMetadata>, patches: () => Promise<AsyncGenerator<FileDetails, void>>) {
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.");
Expand All @@ -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<GithubDiffResult>) {
private async loadPatchesGithub(resultOrPromise: Promise<GithubDiffResult> | 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);
},
);
}
Expand Down Expand Up @@ -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;
Expand Down
119 changes: 63 additions & 56 deletions web/src/lib/github.svelte.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -21,7 +21,7 @@ export type GithubDiff = {
};

export type GithubDiffResult = {
info: GithubDiff;
info: Promise<GithubDiff>;
response: Promise<string>;
};

Expand Down Expand Up @@ -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) {
Expand All @@ -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<GithubPR> {
async function fetchGithubPRInfo(token: string | null, owner: string, repo: string, prNumber: string): Promise<GithubPR> {
const opts: RequestInit = {
headers: {
Accept: "application/json",
Expand All @@ -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,
Expand All @@ -152,67 +152,74 @@ export function parseMultiFilePatchGithub(details: GithubDiff, patch: string) {
});
}

export async function fetchGithubComparison(
export function fetchGithubComparison(
token: string | null,
owner: string,
repo: string,
base: string,
head: string,
description?: string,
url?: string,
): Promise<GithubDiffResult> {
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<GithubDiffResult> {
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<Blob> {
Expand Down
8 changes: 7 additions & 1 deletion web/src/lib/util.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<FileDetails> {
const split = splitMultiFilePatch(patchContent);
loadingState.totalCount = split.length;
async function* detailsGenerator() {
for (const [header, content] of split) {
if (header.binary) {
Expand Down Expand Up @@ -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<T> = {
[K in keyof T]: ReadableBox<T[K]>;
Expand Down
4 changes: 2 additions & 2 deletions web/src/routes/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -138,9 +138,9 @@
</SettingsPopover>
{/snippet}

{#if viewer.loading}
{#if viewer.loadingState.loading}
<div class="absolute bottom-1/2 left-1/2 z-50 -translate-x-1/2 translate-y-1/2 rounded-full border bg-neutral p-2 shadow-md">
<ProgressBar bind:state={viewer.progressBar} class="h-2 w-32" />
<ProgressBar bind:state={viewer.loadingState.progressBar} class="h-2 w-32" />
</div>
{/if}

Expand Down
Loading