diff --git a/web/src/app.css b/web/src/app.css
index 9285b79..c4176c9 100644
--- a/web/src/app.css
+++ b/web/src/app.css
@@ -70,6 +70,9 @@
* {
border-color: var(--color-edge);
}
+ textarea {
+ vertical-align: bottom;
+ }
}
@utility btn-ghost-hover {
diff --git a/web/src/lib/components/files/DirectorySelect.svelte b/web/src/lib/components/files/DirectorySelect.svelte
index 8d94bb0..da1e117 100644
--- a/web/src/lib/components/files/DirectorySelect.svelte
+++ b/web/src/lib/components/files/DirectorySelect.svelte
@@ -16,7 +16,7 @@
{#if directory}
{directory.fileName}
{:else}
- {placeholder}
+ {placeholder}
{/if}
{/snippet}
diff --git a/web/src/lib/components/files/FileInput.svelte b/web/src/lib/components/files/FileInput.svelte
deleted file mode 100644
index a2a2a24..0000000
--- a/web/src/lib/components/files/FileInput.svelte
+++ /dev/null
@@ -1,68 +0,0 @@
-
-
-
- {@render children?.({ files })}
-
-
diff --git a/web/src/lib/components/files/MultimodalFileInput.svelte b/web/src/lib/components/files/MultimodalFileInput.svelte
new file mode 100644
index 0000000..fed02b5
--- /dev/null
+++ b/web/src/lib/components/files/MultimodalFileInput.svelte
@@ -0,0 +1,152 @@
+
+
+{#snippet radioItem(name: string)}
+
+ {#snippet children({ checked })}
+
+ {name}
+
+ {/snippet}
+
+{/snippet}
+
+
+
+
+ {@render radioItem("File")}
+ {@render radioItem("URL")}
+ {@render radioItem("Text")}
+
+ {#if instance.mode === "file"}
+ {@render fileInput()}
+ {:else if instance.mode === "url"}
+ {@render urlInput()}
+ {:else if instance.mode === "text"}
+ {@render textInput()}
+ {/if}
+
+
+
+{#snippet fileInput()}
+
+
+ {#if instance.file}
+ {instance.file.name}
+ {:else}
+ {label}
+ {/if}
+
+
+{/snippet}
+
+{#snippet urlInput()}
+
+{/snippet}
+
+{#snippet textInput()}
+
+{/snippet}
+
+
diff --git a/web/src/lib/components/files/SingleFileInput.svelte b/web/src/lib/components/files/SingleFileInput.svelte
new file mode 100644
index 0000000..a495b57
--- /dev/null
+++ b/web/src/lib/components/files/SingleFileInput.svelte
@@ -0,0 +1,32 @@
+
+
+
+ {@render children?.({ file })}
+
+
diff --git a/web/src/lib/components/files/SingleFileSelect.svelte b/web/src/lib/components/files/SingleFileSelect.svelte
deleted file mode 100644
index baa4fff..0000000
--- a/web/src/lib/components/files/SingleFileSelect.svelte
+++ /dev/null
@@ -1,20 +0,0 @@
-
-
-
-
- {#if file}
- {file.name}
- {:else}
- {placeholder}
- {/if}
-
-
diff --git a/web/src/lib/components/files/index.svelte.ts b/web/src/lib/components/files/index.svelte.ts
index 0c5c43b..0aceded 100644
--- a/web/src/lib/components/files/index.svelte.ts
+++ b/web/src/lib/components/files/index.svelte.ts
@@ -1,3 +1,6 @@
+import { type ReadableBoxedValues } from "svelte-toolbelt";
+import { lazyPromise } from "$lib/util";
+
export interface FileSystemEntry {
fileName: string;
}
@@ -128,3 +131,117 @@ function filesToDirectory(files: FileList): DirectoryEntry {
return ret;
}
+
+export type FileInputMode = "file" | "url" | "text";
+
+export type MultimodalFileInputValueMetadata = {
+ type: FileInputMode;
+ name: string;
+};
+
+export type MultimodalFileInputProps = {
+ state?: MultimodalFileInputState | undefined;
+
+ label?: string | undefined;
+ required?: boolean | undefined;
+};
+
+export type MultimodalFileInputStateProps = {
+ state: MultimodalFileInputState | undefined;
+} & ReadableBoxedValues<{
+ label: string;
+ required: boolean;
+}>;
+
+export class MultimodalFileInputState {
+ private readonly opts: MultimodalFileInputStateProps;
+ mode: FileInputMode = $state("file");
+ text: string = $state("");
+ file: File | undefined = $state(undefined);
+ url: string = $state("");
+ private urlResolver = $derived.by(() => {
+ const url = this.url;
+ return lazyPromise(async () => {
+ let threw = false;
+ try {
+ const response = await fetch(url);
+ if (!response.ok) {
+ threw = true;
+ throw new Error(`Failed to fetch from URL: ${url}\nStatus: ${response.status}\nBody:\n${await response.text()}`);
+ }
+ return await response.blob();
+ } catch (e) {
+ if (threw) {
+ throw e;
+ }
+ throw new Error(`Failed to fetch from URL: ${url}\nSome errors, such as those caused by CORS, will only print in the console.\nCause: ${e}`);
+ }
+ });
+ });
+ dragActive = $state(false);
+
+ constructor(opts: MultimodalFileInputStateProps) {
+ this.opts = opts;
+ if (this.opts.state) {
+ this.mode = this.opts.state.mode;
+ this.text = this.opts.state.text;
+ this.file = this.opts.state.file;
+ this.url = this.opts.state.url;
+ this.urlResolver = this.opts.state.urlResolver;
+ }
+ }
+
+ get metadata(): MultimodalFileInputValueMetadata | null {
+ const mode = this.mode;
+ const label = this.opts.label.current;
+ if (mode === "file" && this.file !== undefined) {
+ const file = this.file;
+ return { type: "file", name: file.name };
+ } else if (mode === "url" && this.url !== "") {
+ return { type: "url", name: this.url };
+ } else if (mode === "text" && this.text !== "") {
+ return { type: "text", name: `${label} (Text Input)` };
+ } else {
+ return null;
+ }
+ }
+
+ async resolve(): Promise {
+ const mode = this.mode;
+ if (mode === "file" && this.file !== undefined) {
+ return this.file;
+ } else if (mode === "url" && this.url !== "") {
+ return this.urlResolver.getValue();
+ } else if (mode === "text" && this.text !== "") {
+ return new Blob([this.text], { type: "text/plain" });
+ } else {
+ throw Error("No value present");
+ }
+ }
+
+ reset() {
+ this.text = "";
+ this.file = undefined;
+ this.url = "";
+ }
+
+ swapState(other: MultimodalFileInputState) {
+ const mode = this.mode;
+ const text = this.text;
+ const file = this.file;
+ const url = this.url;
+ const urlResolver = this.urlResolver;
+
+ this.mode = other.mode;
+ this.text = other.text;
+ this.file = other.file;
+ this.url = other.url;
+ this.urlResolver = other.urlResolver;
+
+ other.mode = mode;
+ other.text = text;
+ other.file = file;
+ other.url = url;
+ other.urlResolver = urlResolver;
+ }
+}
diff --git a/web/src/lib/components/settings-popover/SettingsPopover.svelte b/web/src/lib/components/settings-popover/SettingsPopover.svelte
index 7e82799..b7d7af0 100644
--- a/web/src/lib/components/settings-popover/SettingsPopover.svelte
+++ b/web/src/lib/components/settings-popover/SettingsPopover.svelte
@@ -35,10 +35,9 @@
-
- {#if children}
- {@render children()}
- {/if}
+
+ {@render children?.()}
+
diff --git a/web/src/lib/util.ts b/web/src/lib/util.ts
index b13285d..59635b1 100644
--- a/web/src/lib/util.ts
+++ b/web/src/lib/util.ts
@@ -24,7 +24,7 @@ export function trimCommitHash(hash: string): string {
return hash;
}
-export async function isBinaryFile(file: File): Promise {
+export async function isBinaryFile(file: Blob): Promise {
const sampleSize = Math.min(file.size, 1024);
const buffer = await file.slice(0, sampleSize).arrayBuffer();
const decoder = new TextDecoder("utf-8", { fatal: true });
@@ -37,8 +37,8 @@ export async function isBinaryFile(file: File): Promise {
}
export async function bytesEqual(
- a: File,
- b: File,
+ a: Blob,
+ b: Blob,
chunkingThreshold: number = 4 * 1024 * 1024, // 4MB
chunkSize: number = chunkingThreshold,
): Promise {
diff --git a/web/src/routes/ActionsPopover.svelte b/web/src/routes/ActionsPopover.svelte
index a0c1575..34c8e54 100644
--- a/web/src/routes/ActionsPopover.svelte
+++ b/web/src/routes/ActionsPopover.svelte
@@ -12,7 +12,7 @@
-
+
{
@@ -31,6 +31,7 @@
>
Collapse All
+
diff --git a/web/src/routes/FileHeader.svelte b/web/src/routes/FileHeader.svelte
index ba0364a..382844b 100644
--- a/web/src/routes/FileHeader.svelte
+++ b/web/src/routes/FileHeader.svelte
@@ -71,7 +71,7 @@
-
+
Show in file tree
+
diff --git a/web/src/routes/InfoPopup.svelte b/web/src/routes/InfoPopup.svelte
index dcb140a..15aa820 100644
--- a/web/src/routes/InfoPopup.svelte
+++ b/web/src/routes/InfoPopup.svelte
@@ -12,8 +12,9 @@
-
+
{@render children?.()}
+
diff --git a/web/src/routes/LoadDiffDialog.svelte b/web/src/routes/LoadDiffDialog.svelte
index 6b7cda8..5928a05 100644
--- a/web/src/routes/LoadDiffDialog.svelte
+++ b/web/src/routes/LoadDiffDialog.svelte
@@ -7,23 +7,24 @@
import { type FileDetails, MultiFileDiffViewerState } from "$lib/diff-viewer-multi-file.svelte";
import { binaryFileDummyDetails, bytesEqual, isBinaryFile, isImageFile, splitMultiFilePatch } from "$lib/util";
import { onMount } from "svelte";
- import FileInput from "$lib/components/files/FileInput.svelte";
- import SingleFileSelect from "$lib/components/files/SingleFileSelect.svelte";
import { createTwoFilesPatch } from "diff";
import DirectorySelect from "$lib/components/files/DirectorySelect.svelte";
- import { DirectoryEntry, FileEntry } from "$lib/components/files/index.svelte";
+ import { DirectoryEntry, FileEntry, MultimodalFileInputState } from "$lib/components/files/index.svelte";
import { SvelteSet } from "svelte/reactivity";
+ import MultimodalFileInput from "$lib/components/files/MultimodalFileInput.svelte";
const viewer = MultiFileDiffViewerState.get();
-
let modalOpen = $state(false);
+
let githubUrl = $state("https://github.com/");
- let dragActive = $state(false);
- let fileA = $state(undefined);
- let fileB = $state(undefined);
- let dirA = $state(undefined);
- let dirB = $state(undefined);
+ let patchFile = $state();
+
+ let fileA = $state();
+ let fileB = $state();
+
+ let dirA = $state();
+ let dirB = $state();
let dirBlacklistInput = $state("");
const defaultDirBlacklist = [".git/"];
let dirBlacklist = new SvelteSet(defaultDirBlacklist);
@@ -56,16 +57,23 @@
});
async function compareFiles() {
- if (!fileA || !fileB) {
+ if (!fileA || !fileB || !fileA.metadata || !fileB.metadata) {
alert("Both files must be selected to compare.");
return;
}
-
- const isImageDiff = isImageFile(fileA.name) && isImageFile(fileB.name);
- const [aBinary, bBinary] = await Promise.all([isBinaryFile(fileA), isBinaryFile(fileB)]);
+ const isImageDiff = isImageFile(fileA.metadata.name) && isImageFile(fileB.metadata.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);
+ alert("Failed to resolve files: " + e);
+ return;
+ }
+ const [aBinary, bBinary] = await Promise.all([isBinaryFile(blobA), isBinaryFile(blobB)]);
if (aBinary || bBinary) {
if (!isImageDiff) {
- alert("Cannot compare binary files.");
+ alert("Cannot compare binary files (except image-to-image comparisons).");
return;
}
}
@@ -73,46 +81,46 @@
const fileDetails: FileDetails[] = [];
if (isImageDiff) {
- if (await bytesEqual(fileA, fileB)) {
+ if (await bytesEqual(blobA, blobB)) {
alert("The files are identical.");
return;
}
let status: FileStatus = "modified";
- if (fileA.name !== fileB.name) {
+ if (fileA.metadata.name !== fileB.metadata.name) {
status = "renamed_modified";
}
fileDetails.push({
content: "",
- fromFile: fileA.name,
- toFile: fileB.name,
- fromBlob: fileA,
- toBlob: fileB,
+ fromFile: fileA.metadata.name,
+ toFile: fileB.metadata.name,
+ fromBlob: blobA,
+ toBlob: blobB,
status,
});
} else {
- const [textA, textB] = await Promise.all([fileA.text(), fileB.text()]);
+ const [textA, textB] = await Promise.all([blobA.text(), blobB.text()]);
if (textA === textB) {
alert("The files are identical.");
return;
}
- const diff = createTwoFilesPatch(fileA.name, fileB.name, textA, textB);
+ const diff = createTwoFilesPatch(fileA.metadata.name, fileB.metadata.name, textA, textB);
let status: FileStatus = "modified";
- if (fileA.name !== fileB.name) {
+ if (fileA.metadata.name !== fileB.metadata.name) {
status = "renamed_modified";
}
fileDetails.push({
content: diff,
- fromFile: fileA.name,
- toFile: fileB.name,
+ fromFile: fileA.metadata.name,
+ toFile: fileB.metadata.name,
status,
});
}
- viewer.loadPatches(fileDetails, { fileName: `${fileA.name}...${fileB.name}.patch` });
+ viewer.loadPatches(fileDetails, { fileName: `${fileA.metadata.name}...${fileB.metadata.name}.patch` });
await updateUrlParams();
modalOpen = false;
}
@@ -257,52 +265,31 @@
return into;
}
- async function loadFromPatchFile(fileName: string, patchContent: string) {
- const files = splitMultiFilePatch(patchContent);
- if (files.length === 0) {
- alert("No valid patches found in the file.");
- modalOpen = true;
+ async function handlePatchFile() {
+ if (!patchFile || !patchFile.metadata) {
+ alert("No patch file selected.");
return;
}
- viewer.loadPatches(files, { fileName });
- await updateUrlParams();
- }
-
- async function handlePatchFile(file?: File) {
- if (!file) {
+ let text: string;
+ try {
+ const blob = await patchFile.resolve();
+ text = await blob.text();
+ } catch (e) {
+ console.error("Failed to resolve patch file:", e);
+ alert("Failed to resolve patch file: " + e);
return;
}
- modalOpen = false;
- await loadFromPatchFile(file.name, await file.text());
- }
-
- function handleDragOver(event: DragEvent) {
- dragActive = true;
- event.preventDefault();
- }
-
- function handleDragLeave(event: DragEvent) {
- if (event.currentTarget === event.target) {
- dragActive = false;
- }
- event.preventDefault();
- }
-
- async function handlePatchFileDrop(event: DragEvent) {
- dragActive = false;
- event.preventDefault();
- const files = event.dataTransfer?.files;
- if (!files || files.length !== 1) {
- alert("Only one file can be dropped at a time.");
+ const files = splitMultiFilePatch(text);
+ if (files.length === 0) {
+ alert("No valid patches found in the file.");
return;
}
modalOpen = false;
- const file = files[0];
- await loadFromPatchFile(file.name, await file.text());
+ viewer.loadPatches(files, { fileName: patchFile.metadata.name });
+ await updateUrlParams();
}
async function handleGithubUrl() {
- modalOpen = false;
const url = new URL(githubUrl);
// exclude hash + query params
const test = url.protocol + "//" + url.hostname + url.pathname;
@@ -312,11 +299,11 @@
if (!match) {
alert("Invalid GitHub URL. Use: https://github.com/owner/repo/(commit|pull|compare)/(id|ref_a...ref_b)");
- modalOpen = true;
return;
}
githubUrl = match[0];
+ modalOpen = false;
const success = await viewer.loadFromGithubApi(match);
if (success) {
await updateUrlParams({ githubUrl });
@@ -342,21 +329,18 @@
Regex patterns for directories and files to ignore.
-
- {
- if (e.key === "Enter") {
- addBlacklistEntry();
- }
- }}
- type="text"
- class="w-full rounded-l-md border-t border-b border-l px-2 py-1"
- />
-
+
+
- {#each dirBlacklist as entry (entry)}
-
- {entry}
-
+ {#each dirBlacklist as entry, index (entry)}
+
+ {entry}
+
- (dragActive = false)}>Load another diff
+ Load another diff
-
+
+ Go
+
Supports commit, PR, and comparison URLs
@@ -467,91 +453,94 @@
-
-
{
+ e.preventDefault();
+ handlePatchFile();
+ }}
>
-
- From Files
+
+ From Patch File
+
+ Go
+
-
-
- Load Patch File
-
-
-
- Compare Files
-
-
-
-
- Go
-
-
+
-
-
-
Compare Directories
-
- Compares the entire contents of the directories, including subdirectories. Does not attempt to detect renames. When possible,
- preparing a unified diff (.patch file) using Git or another tool and loading
- it with the above button should be preferred.
-
-
-
-
-
-
-
-
- Go
-
-
-
-
-
-
-
-
- {@render blacklistPopoverContent()}
-
-
-
-
+
+
+
+
+
-
-
diff --git a/web/src/routes/single-file/+page.svelte b/web/src/routes/single-file/+page.svelte
deleted file mode 100644
index fe8a5e6..0000000
--- a/web/src/routes/single-file/+page.svelte
+++ /dev/null
@@ -1,26 +0,0 @@
-
-
-
-
-
- Load Patch File
-
-
{fileName}
-
-
-
-
-