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 @@ - - - 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 @@ + + + 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" - /> - +
{ + e.preventDefault(); + addBlacklistEntry(); + }} + > + + -
+
    - {#each dirBlacklist as entry (entry)} -
  • - {entry} -
    + {#each dirBlacklist as entry, index (entry)} +
  • + {entry} +
    - (dragActive = false)}>Load another diff + Load another diff -
    +
    { + e.preventDefault(); + handleGithubUrl(); + }} + > { - if (event.key === "Enter") { - handleGithubUrl(); - } - }} /> - Go -
    + 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()} - - - -
    +
    { + e.preventDefault(); + compareFiles(); + }} + > +

    + + From Files +

    +
    + +
    +
    -
    -
    + + Go + { + if (!fileA || !fileB) return; + fileA.swapState(fileB); + }} + > + + +
    + + + + +
    { + e.preventDefault(); + compareDirs(); + }} + > +

    + + From 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}

    -
    - -
    -
    -