Skip to content

Commit 7ae99dc

Browse files
committed
Add progress indicator while loading patches and directories
1 parent 4d422a1 commit 7ae99dc

File tree

8 files changed

+201
-37
lines changed

8 files changed

+201
-37
lines changed
Lines changed: 15 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,24 @@
11
<script lang="ts">
2-
import { type RestProps } from "$lib/types";
3-
import { type Snippet } from "svelte";
42
import { Button, mergeProps } from "bits-ui";
5-
import { type DirectoryEntry, pickDirectory } from "$lib/components/files/index.svelte";
3+
import { type DirectoryEntry, type DirectoryInputProps, DirectoryInputState } from "$lib/components/files/index.svelte";
4+
import { box } from "svelte-toolbelt";
65
7-
type Props = {
8-
children?: Snippet<[{ directory?: DirectoryEntry }]>;
9-
directory?: DirectoryEntry;
10-
} & RestProps;
6+
let { children, directory = $bindable<DirectoryEntry | undefined>(), loading = $bindable(false), ...restProps }: DirectoryInputProps = $props();
117
12-
let { children, directory = $bindable<DirectoryEntry | undefined>(undefined), ...restProps }: Props = $props();
8+
const instance = new DirectoryInputState({
9+
directory: box.with(
10+
() => directory,
11+
(v) => (directory = v),
12+
),
13+
loading: box.with(
14+
() => loading,
15+
(v) => (loading = v),
16+
),
17+
});
1318
14-
async function onclick() {
15-
try {
16-
directory = await pickDirectory();
17-
} catch (e) {
18-
if (e instanceof Error && e.name === "AbortError") {
19-
return;
20-
} else {
21-
console.error("Failed to pick directory", e);
22-
}
23-
}
24-
}
25-
26-
const mergedProps = mergeProps({ onclick }, restProps);
19+
const mergedProps = $derived(mergeProps(instance.props, restProps));
2720
</script>
2821

2922
<Button.Root type="button" {...mergedProps}>
30-
{@render children?.({ directory })}
23+
{@render children?.({ directory, loading })}
3124
</Button.Root>

web/src/lib/components/files/DirectorySelect.svelte

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
<script lang="ts">
22
import DirectoryInput from "$lib/components/files/DirectoryInput.svelte";
33
import { type DirectoryEntry } from "$lib/components/files/index.svelte";
4+
import Spinner from "$lib/components/Spinner.svelte";
45
56
interface Props {
67
placeholder: string;
@@ -11,13 +12,16 @@
1112
</script>
1213

1314
<DirectoryInput class="flex max-w-full items-center gap-2 rounded-md border btn-ghost px-2 py-1" bind:directory>
14-
{#snippet children({ directory })}
15+
{#snippet children({ directory, loading })}
1516
<span class="iconify size-4 shrink-0 text-em-disabled octicon--file-directory-16"></span>
16-
{#if directory}
17+
{#if !loading && directory}
1718
<span class="truncate">{directory.fileName}</span>
1819
{:else}
1920
<span class="font-light">{placeholder}</span>
2021
{/if}
22+
{#if loading}
23+
<Spinner size={4} />
24+
{/if}
2125
<span class="iconify size-4 shrink-0 text-em-disabled octicon--triangle-down-16"></span>
2226
{/snippet}
2327
</DirectoryInput>

web/src/lib/components/files/index.svelte.ts

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1-
import { type ReadableBoxedValues } from "svelte-toolbelt";
1+
import { type ReadableBoxedValues, type WritableBoxedValues } from "svelte-toolbelt";
22
import { getExtensionForLanguage, lazyPromise } from "$lib/util";
33
import type { BundledLanguage, SpecialLanguage } from "shiki";
4+
import type { Snippet } from "svelte";
5+
import type { RestProps } from "$lib/types";
46

57
export interface FileSystemEntry {
68
fileName: string;
@@ -26,7 +28,51 @@ export class FileEntry implements FileSystemEntry {
2628
}
2729
}
2830

29-
export async function pickDirectory(): Promise<DirectoryEntry> {
31+
export type DirectoryInputProps = {
32+
children?: Snippet<[{ directory?: DirectoryEntry; loading: boolean }]>;
33+
directory?: DirectoryEntry;
34+
loading?: boolean;
35+
} & RestProps;
36+
37+
export type DirectoryInputStateProps = WritableBoxedValues<{
38+
directory: DirectoryEntry | undefined;
39+
loading: boolean;
40+
}>;
41+
42+
export class DirectoryInputState {
43+
private readonly opts: DirectoryInputStateProps;
44+
45+
constructor(opts: DirectoryInputStateProps) {
46+
this.opts = opts;
47+
this.onclick = this.onclick.bind(this);
48+
}
49+
50+
get props() {
51+
return {
52+
onclick: this.onclick,
53+
};
54+
}
55+
56+
async onclick() {
57+
if (this.opts.loading.current) {
58+
return;
59+
}
60+
try {
61+
this.opts.loading.current = true;
62+
this.opts.directory.current = await pickDirectory();
63+
} catch (e) {
64+
if (e instanceof Error && e.name === "AbortError") {
65+
return;
66+
} else {
67+
console.error("Failed to pick directory", e);
68+
}
69+
} finally {
70+
this.opts.loading.current = false;
71+
}
72+
}
73+
}
74+
75+
async function pickDirectory(): Promise<DirectoryEntry> {
3076
if (!window.showDirectoryPicker) {
3177
return await pickDirectoryLegacy();
3278
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
<script lang="ts">
2+
import { mergeProps, Progress } from "bits-ui";
3+
import { type ProgressBarProps, useProgressBarState } from "$lib/components/progress-bar/index.svelte";
4+
5+
let { state = $bindable(), ...restProps }: ProgressBarProps = $props();
6+
7+
state = useProgressBarState(state);
8+
9+
const mergedProps = $derived(
10+
mergeProps(
11+
{
12+
class: "bg-em-disabled/30 inset-shadow-xs relative overflow-hidden rounded-full",
13+
},
14+
restProps,
15+
),
16+
);
17+
</script>
18+
19+
<Progress.Root value={state.value} max={state.max} {...mergedProps}>
20+
{@const percent = state.getPercent()}
21+
{#if percent !== undefined}
22+
<div
23+
class="h-full w-full rounded-full bg-primary drop-shadow-sm drop-shadow-primary/50 transition-all duration-250 ease-in-out"
24+
style={`transform: translateX(-${100 - percent}%)`}
25+
></div>
26+
{:else}
27+
<div id="spinner" class="h-full w-[20%] rounded-full bg-primary drop-shadow-sm drop-shadow-primary/50"></div>
28+
{/if}
29+
</Progress.Root>
30+
31+
<style>
32+
#spinner {
33+
animation: slide 1s linear infinite alternate;
34+
}
35+
36+
@keyframes slide {
37+
0% {
38+
transform: translateX(0%);
39+
}
40+
100% {
41+
transform: translateX(400%);
42+
}
43+
}
44+
</style>
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import type { RestProps } from "$lib/types";
2+
3+
export interface ProgressBarProps extends RestProps {
4+
state?: ProgressBarState | undefined;
5+
}
6+
7+
export function useProgressBarState(state: ProgressBarState | undefined): ProgressBarState {
8+
return state === undefined ? new ProgressBarState(0, 100) : state;
9+
}
10+
11+
export class ProgressBarState {
12+
value: number | null = $state(null);
13+
max: number = $state(100);
14+
15+
constructor(value: number | null, max: number) {
16+
this.value = value;
17+
this.max = max;
18+
}
19+
20+
setProgress(value: number, max: number) {
21+
this.value = value;
22+
this.max = max;
23+
}
24+
25+
setSpinning() {
26+
this.value = null;
27+
this.max = 100;
28+
}
29+
30+
isSpinning(): boolean {
31+
return this.value === null;
32+
}
33+
34+
isDone(): boolean {
35+
return this.value !== null && this.value >= this.max;
36+
}
37+
38+
getPercent(): number | undefined {
39+
if (this.value === null) {
40+
return undefined;
41+
}
42+
if (this.max <= 0) {
43+
return 0;
44+
}
45+
return (this.value / this.max) * 100;
46+
}
47+
}

web/src/lib/diff-viewer-multi-file.svelte.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { type TreeNode, TreeState } from "$lib/components/tree/index.svelte";
1717
import { VList } from "virtua/svelte";
1818
import { Context, Debounced } from "runed";
1919
import { MediaQuery } from "svelte/reactivity";
20+
import { ProgressBarState } from "$lib/components/progress-bar/index.svelte";
2021

2122
export type SidebarLocation = "left" | "right";
2223

@@ -327,6 +328,7 @@ export class MultiFileDiffViewerState {
327328
activeSearchResult: ActiveSearchResult | null = $state(null);
328329
sidebarCollapsed = $state(false);
329330
diffMetadata: DiffMetadata | null = $state(null);
331+
readonly progressBar = $state(new ProgressBarState(100, 100));
330332

331333
readonly fileTreeFilterDebounced = new Debounced(() => this.fileTreeFilter, 500);
332334
readonly searchQueryDebounced = new Debounced(() => this.searchQuery, 500);
@@ -457,15 +459,20 @@ export class MultiFileDiffViewerState {
457459
// Reset state
458460
this.collapsed = [];
459461
this.checked = [];
462+
this.diffMetadata = null;
460463
this.fileDetails = [];
461464
this.clearImages();
462465
this.vlist?.scrollToIndex(0, { align: "start" });
463-
this.diffMetadata = meta;
464466

467+
// Load new state
468+
this.diffMetadata = meta;
465469
patches.sort(compareFileDetails);
466-
467-
// Set this last since it's what the VList loads
468470
this.fileDetails.push(...patches);
471+
472+
// in case the caller didn't close the progress
473+
if (!this.progressBar.isDone()) {
474+
this.progressBar.setProgress(100, 100);
475+
}
469476
}
470477

471478
// TODO fails for initial commit?
@@ -476,10 +483,12 @@ export class MultiFileDiffViewerState {
476483

477484
try {
478485
if (type === "commit") {
486+
this.progressBar.setSpinning();
479487
const { info, files } = await fetchGithubCommitDiff(token, owner, repo, id.split("/")[0]);
480488
this.loadPatches(files, { type: "github", details: info });
481489
return true;
482490
} else if (type === "pull") {
491+
this.progressBar.setSpinning();
483492
const { info, files } = await fetchGithubPRComparison(token, owner, repo, id.split("/")[0]);
484493
this.loadPatches(files, { type: "github", details: info });
485494
return true;
@@ -492,6 +501,7 @@ export class MultiFileDiffViewerState {
492501
return false;
493502
}
494503
}
504+
this.progressBar.setSpinning();
495505
const base = refs[0];
496506
const head = refs[1];
497507
const { info, files } = await fetchGithubComparison(token, owner, repo, base, head);

web/src/routes/+page.svelte

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
import { onClickOutside } from "runed";
3434
import SidebarToggle from "./SidebarToggle.svelte";
3535
import type { PageProps } from "./$types";
36+
import ProgressBar from "$lib/components/progress-bar/ProgressBar.svelte";
3637
3738
let { data }: PageProps = $props();
3839
const globalOptions = GlobalOptions.init(data.globalOptions);
@@ -137,6 +138,12 @@
137138
</SettingsPopover>
138139
{/snippet}
139140

141+
{#if !viewer.progressBar.isDone()}
142+
<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">
143+
<ProgressBar bind:state={viewer.progressBar} class="h-2 w-32" />
144+
</div>
145+
{/if}
146+
140147
<div class="relative flex min-h-screen flex-row justify-center">
141148
<div
142149
bind:this={sidebarElement}

web/src/routes/OpenDiffDialog.svelte

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import { goto } from "$app/navigation";
77
import { type FileDetails, makeImageDetails, makeTextDetails, MultiFileDiffViewerState } from "$lib/diff-viewer-multi-file.svelte";
88
import { binaryFileDummyDetails, bytesEqual, isBinaryFile, isImageFile, splitMultiFilePatch } from "$lib/util";
9-
import { onMount } from "svelte";
9+
import { onMount, tick } from "svelte";
1010
import { createTwoFilesPatch } from "diff";
1111
import DirectorySelect from "$lib/components/files/DirectorySelect.svelte";
1212
import { DirectoryEntry, FileEntry, MultimodalFileInputState } from "$lib/components/files/index.svelte";
@@ -96,6 +96,7 @@
9696
status = "renamed_modified";
9797
}
9898
99+
viewer.progressBar.setSpinning();
99100
const img = makeImageDetails(fileA.metadata.name, fileB.metadata.name, status, blobA, blobB);
100101
img.image.load = true; // load images by default when comparing two files directly
101102
fileDetails.push(img);
@@ -106,6 +107,7 @@
106107
return;
107108
}
108109
110+
viewer.progressBar.setSpinning();
109111
const diff = createTwoFilesPatch(fileA.metadata.name, fileB.metadata.name, textA, textB);
110112
let status: FileStatus = "modified";
111113
if (fileA.metadata.name !== fileB.metadata.name) {
@@ -132,6 +134,7 @@
132134
alert("Both directories must be selected to compare.");
133135
return;
134136
}
137+
viewer.progressBar.setSpinning();
135138
136139
const blacklist = (entry: ProtoFileDetails) => {
137140
return !dirBlacklistRegexes.some((pattern) => pattern.test(entry.path));
@@ -231,6 +234,7 @@
231234
alert("No patch file selected.");
232235
return;
233236
}
237+
const meta = patchFile.metadata;
234238
let text: string;
235239
try {
236240
const blob = await patchFile.resolve();
@@ -240,14 +244,23 @@
240244
alert("Failed to resolve patch file: " + e);
241245
return;
242246
}
243-
const files = splitMultiFilePatch(text);
244-
if (files.length === 0) {
245-
alert("No valid patches found in the file.");
246-
return;
247-
}
248247
modalOpen = false;
249-
viewer.loadPatches(files, { type: "file", fileName: patchFile.metadata.name });
250-
await updateUrlParams();
248+
viewer.progressBar.setSpinning();
249+
requestAnimationFrame(async () => {
250+
await tick();
251+
252+
requestAnimationFrame(async () => {
253+
const files = splitMultiFilePatch(text);
254+
if (files.length === 0) {
255+
modalOpen = true;
256+
viewer.progressBar.setProgress(100, 100);
257+
alert("No valid patches found in the file.");
258+
return;
259+
}
260+
viewer.loadPatches(files, { type: "file", fileName: meta.name });
261+
await updateUrlParams();
262+
});
263+
});
251264
}
252265
253266
async function handleGithubUrl() {

0 commit comments

Comments
 (0)