Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
37 changes: 15 additions & 22 deletions web/src/lib/components/files/DirectoryInput.svelte
Original file line number Diff line number Diff line change
@@ -1,31 +1,24 @@
<script lang="ts">
import { type RestProps } from "$lib/types";
import { type Snippet } from "svelte";
import { Button, mergeProps } from "bits-ui";
import { type DirectoryEntry, pickDirectory } from "$lib/components/files/index.svelte";
import { type DirectoryEntry, type DirectoryInputProps, DirectoryInputState } from "$lib/components/files/index.svelte";
import { box } from "svelte-toolbelt";

type Props = {
children?: Snippet<[{ directory?: DirectoryEntry }]>;
directory?: DirectoryEntry;
} & RestProps;
let { children, directory = $bindable<DirectoryEntry | undefined>(), loading = $bindable(false), ...restProps }: DirectoryInputProps = $props();

let { children, directory = $bindable<DirectoryEntry | undefined>(undefined), ...restProps }: Props = $props();
const instance = new DirectoryInputState({
directory: box.with(
() => directory,
(v) => (directory = v),
),
loading: box.with(
() => loading,
(v) => (loading = v),
),
});

async function onclick() {
try {
directory = await pickDirectory();
} catch (e) {
if (e instanceof Error && e.name === "AbortError") {
return;
} else {
console.error("Failed to pick directory", e);
}
}
}

const mergedProps = mergeProps({ onclick }, restProps);
const mergedProps = $derived(mergeProps(instance.props, restProps));
</script>

<Button.Root type="button" {...mergedProps}>
{@render children?.({ directory })}
{@render children?.({ directory, loading })}
</Button.Root>
8 changes: 6 additions & 2 deletions web/src/lib/components/files/DirectorySelect.svelte
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<script lang="ts">
import DirectoryInput from "$lib/components/files/DirectoryInput.svelte";
import { type DirectoryEntry } from "$lib/components/files/index.svelte";
import Spinner from "$lib/components/Spinner.svelte";

interface Props {
placeholder: string;
Expand All @@ -11,13 +12,16 @@
</script>

<DirectoryInput class="flex max-w-full items-center gap-2 rounded-md border btn-ghost px-2 py-1" bind:directory>
{#snippet children({ directory })}
{#snippet children({ directory, loading })}
<span class="iconify size-4 shrink-0 text-em-disabled octicon--file-directory-16"></span>
{#if directory}
{#if !loading && directory}
<span class="truncate">{directory.fileName}</span>
{:else}
<span class="font-light">{placeholder}</span>
{/if}
{#if loading}
<Spinner size={4} />
{/if}
<span class="iconify size-4 shrink-0 text-em-disabled octicon--triangle-down-16"></span>
{/snippet}
</DirectoryInput>
50 changes: 48 additions & 2 deletions web/src/lib/components/files/index.svelte.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -26,7 +28,51 @@ export class FileEntry implements FileSystemEntry {
}
}

export async function pickDirectory(): Promise<DirectoryEntry> {
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<DirectoryEntry> {
if (!window.showDirectoryPicker) {
return await pickDirectoryLegacy();
}
Expand Down
44 changes: 44 additions & 0 deletions web/src/lib/components/progress-bar/ProgressBar.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<script lang="ts">
import { mergeProps, Progress } from "bits-ui";
import { type ProgressBarProps, useProgressBarState } from "$lib/components/progress-bar/index.svelte";
let { state = $bindable(), ...restProps }: ProgressBarProps = $props();
state = useProgressBarState(state);
const mergedProps = $derived(
mergeProps(
{
class: "bg-em-disabled/30 inset-shadow-xs relative overflow-hidden rounded-full",
},
restProps,
),
);
</script>

<Progress.Root value={state.value} max={state.max} {...mergedProps}>
{@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"
style={`transform: translateX(-${100 - percent}%)`}
></div>
{:else}
<div id="spinner" class="h-full w-[20%] rounded-full bg-primary drop-shadow-sm drop-shadow-primary/50"></div>
{/if}
</Progress.Root>

<style>
#spinner {
animation: slide 1s linear infinite alternate;
}
@keyframes slide {
0% {
transform: translateX(0%);
}
100% {
transform: translateX(400%);
}
}
</style>
47 changes: 47 additions & 0 deletions web/src/lib/components/progress-bar/index.svelte.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
91 changes: 74 additions & 17 deletions web/src/lib/diff-viewer-multi-file.svelte.ts
Original file line number Diff line number Diff line change
@@ -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,
splitMultiFilePatchGithub,
} from "./github.svelte";
import { type StructuredPatch } from "diff";
import {
ConciseDiffViewCachedState,
Expand All @@ -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";

Expand Down Expand Up @@ -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([]);
Expand All @@ -327,6 +338,7 @@ export class MultiFileDiffViewerState {
activeSearchResult: ActiveSearchResult | null = $state(null);
sidebarCollapsed = $state(false);
diffMetadata: DiffMetadata | null = $state(null);
readonly progressBar = $state(new ProgressBarState(100, 100));

readonly fileTreeFilterDebounced = new Debounced(() => this.fileTreeFilter, 500);
readonly searchQueryDebounced = new Debounced(() => this.searchQuery, 500);
Expand Down Expand Up @@ -453,19 +465,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<DiffMetadata>, patches: () => Promise<AsyncGenerator<FileDetails, void>>) {
try {
this.progressBar.setSpinning();
await tick();
await animationFramePromise();

this.diffMetadata = await meta();
await tick();
await animationFramePromise();

patches.sort(compareFileDetails);
this.clear(false);
await tick();
await animationFramePromise();

// Set this last since it's what the VList loads
this.fileDetails.push(...patches);
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);
}
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 {
if (!this.progressBar.isDone()) {
this.progressBar.setProgress(100, 100);
}
}
}

private async loadPatchesGithub(resultPromise: Promise<GithubDiffResult>) {
return await this.loadPatches(
async () => {
return { type: "github", details: (await resultPromise).info };
},
async () => {
const result = await resultPromise;
const split = splitMultiFilePatchGithub(result.info, result.response);
async function* generatePatches() {
for (const patch of split) {
yield patch;
}
}
return generatePatches();
},
);
}

// TODO fails for initial commit?
Expand All @@ -476,13 +539,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) {
Expand All @@ -494,9 +553,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);
Expand Down
Loading