Skip to content

Add directory comparison #50

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 6 commits into
base: master
Choose a base branch
from
Draft
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
12 changes: 7 additions & 5 deletions web/src/lib/components/diff/ImageDiff.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -102,14 +102,14 @@
{#snippet modeButton(forMode: Mode, iconClass: string)}
<button
type="button"
class="flex items-center justify-center rounded-sm btn-ghost p-1.5 text-primary data-[active=true]:btn-ghost-visible"
class="flex items-center justify-center rounded-sm btn-ghost px-2 py-1 text-primary data-[active=true]:btn-ghost-visible"
onclick={() => (mode = forMode)}
data-active={mode === forMode}
>
<span class="iconify {iconClass} me-1 size-4" aria-hidden="true"></span>{forMode}
</button>
{/snippet}
<div class="mb-4 flex flex-row gap-2 rounded-lg bg-neutral p-2 shadow-sm">
<div class="mb-4 flex flex-row gap-1 rounded-lg bg-neutral p-1.5 shadow-sm">
{@render modeButton("slide", "octicon--image-16")}
{@render modeButton("side-by-side", "octicon--columns-16")}
{@render modeButton("fade", "octicon--image-16")}
Expand Down Expand Up @@ -171,10 +171,12 @@
</div>
<AddedOrRemovedImageLabel mode="add" dims={dims.b} />
</div>
<div class="mt-4 flex w-full max-w-[280px] items-center">
<div class="mt-4 flex w-full max-w-[280px] items-center rounded-lg bg-neutral p-2.5 shadow-sm">
<Slider.Root type="single" bind:value={fadePercent} class="relative flex w-full touch-none items-center select-none">
<span class="relative h-0.5 w-full grow cursor-pointer overflow-hidden rounded-full bg-primary"> </span>
<Slider.Thumb index={0} class="block size-4 cursor-pointer rounded-full bg-neutral shadow-sm transition-colors hover:border active:scale-[0.98]" />
<span class="relative h-0.5 w-full grow cursor-pointer overflow-hidden rounded-full bg-em-disabled">
<Slider.Range class="absolute h-full bg-primary" />
</span>
<Slider.Thumb index={0} class="block size-4 cursor-pointer rounded-full border bg-neutral shadow-sm transition-colors" />
</Slider.Root>
</div>
{/snippet}
Expand Down
31 changes: 31 additions & 0 deletions web/src/lib/components/files/DirectoryInput.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<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";

type Props = {
children?: Snippet<[{ directory?: DirectoryEntry }]>;
directory?: DirectoryEntry;
} & RestProps;

let { children, directory = $bindable<DirectoryEntry | undefined>(undefined), ...restProps }: Props = $props();

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);
</script>

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

interface Props {
placeholder: string;
directory?: DirectoryEntry;
}

let { placeholder = "Select Directory", directory = $bindable<DirectoryEntry | undefined>(undefined) }: Props = $props();
</script>

<DirectoryInput class="flex items-center gap-2 rounded-md border btn-ghost px-2 py-1" bind:directory>
{#snippet children({ directory })}
<span class="iconify size-4 shrink-0 text-em-disabled octicon--file-directory-16"></span>
{#if directory}
{directory.fileName}
{:else}
{placeholder}
{/if}
<span class="iconify size-4 shrink-0 text-em-disabled octicon--triangle-down-16"></span>
{/snippet}
</DirectoryInput>
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script lang="ts">
import FileInput from "$lib/components/FileInput.svelte";
import FileInput from "$lib/components/files/FileInput.svelte";

interface Props {
placeholder: string;
Expand Down
134 changes: 134 additions & 0 deletions web/src/lib/components/files/index.svelte.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
export interface FileSystemEntry {
fileName: string;
}

export class DirectoryEntry implements FileSystemEntry {
fileName: string;
children: FileSystemEntry[];

constructor(fileName: string, children: FileSystemEntry[]) {
this.fileName = fileName;
this.children = children;
}
}

export class FileEntry implements FileSystemEntry {
fileName: string;
file: File;

constructor(fileName: string, file: File) {
this.fileName = fileName;
this.file = file;
}
}

export async function pickDirectory(): Promise<DirectoryEntry> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if (!(window as any).showDirectoryPicker) {
return await pickDirectoryLegacy();
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
const directoryHandle: FileSystemDirectoryHandle = await (window as any).showDirectoryPicker();

// eslint-disable-next-line @typescript-eslint/no-explicit-any
if (!(directoryHandle as any).entries) {
return await pickDirectoryLegacy();
}

return await handleToDirectoryEntry(directoryHandle);
}

async function handleToDirectoryEntry(directoryHandle: FileSystemDirectoryHandle): Promise<DirectoryEntry> {
const root = new DirectoryEntry(directoryHandle.name, []);

type StackEntry = [FileSystemDirectoryHandle, DirectoryEntry];
const stack: StackEntry[] = [[directoryHandle, root]];

while (stack.length > 0) {
const [dirHandle, dirEntry] = stack.shift()!;

// eslint-disable-next-line @typescript-eslint/no-explicit-any,@typescript-eslint/no-unused-vars
for await (const [name, handle] of (dirHandle as any).entries()) {
if (handle.kind === "directory") {
const subDir = new DirectoryEntry(handle.name, []);
dirEntry.children.push(subDir);
stack.push([handle, subDir]);
} else if (handle.kind === "file") {
dirEntry.children.push(await handleToFileEntry(handle));
}
}
}

return root;
}

async function handleToFileEntry(fileHandle: FileSystemFileHandle): Promise<FileEntry> {
const file = await fileHandle.getFile();
return new FileEntry(fileHandle.name, file);
}

async function pickDirectoryLegacy(): Promise<DirectoryEntry> {
const input = document.createElement("input");
input.type = "file";
input.webkitdirectory = true;
input.multiple = true;

return new Promise((resolve, reject) => {
input.addEventListener("change", (event) => {
const files = (event.target as HTMLInputElement).files;
if (!files) {
reject(new Error("No files selected"));
return;
}

resolve(filesToDirectory(files));
});

input.click();
});
}

function filesToDirectory(files: FileList): DirectoryEntry {
let ret: DirectoryEntry | null = null;

for (const file of files) {
const parts = file.webkitRelativePath.split("/");

if (parts.length === 1) {
throw Error("File has no path");
}

let current: DirectoryEntry | null = null;

for (let i = 0; i < parts.length; i++) {
const part = parts[i];

if (current === null) {
current = ret;
if (current === null) {
current = new DirectoryEntry(part, []);
ret = current;
}
continue;
}

if (i === parts.length - 1) {
current.children.push(new FileEntry(part, file));
} else {
let dirEntry = current.children.find((entry) => entry.fileName === part) as DirectoryEntry;
if (!dirEntry) {
dirEntry = new DirectoryEntry(part, []);
current.children.push(dirEntry);
}
current = dirEntry;
}
}
}

if (ret === null) {
throw Error("Selected empty directory");
}

return ret;
}
76 changes: 67 additions & 9 deletions web/src/lib/diff-viewer-multi-file.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,9 +135,51 @@ export type FileDetails = {
content: string;
fromFile: string;
toFile: string;
fromBlob?: Blob;
toBlob?: Blob;
status: FileStatus;
};

// Sort such that when displayed as a file tree, directories come before files and each level is sorted by name
function compareFileDetails(a: FileDetails, b: FileDetails): number {
const aName = a.toFile;
const bName = b.toFile;

// Split paths into components
const aParts = aName.split("/");
const bParts = bName.split("/");

// Compare component by component
const minLength = Math.min(aParts.length, bParts.length);

for (let i = 0; i < minLength; i++) {
// If we're not at the last component of both paths
if (i < aParts.length - 1 && i < bParts.length - 1) {
const comparison = aParts[i].localeCompare(bParts[i]);
if (comparison !== 0) {
return comparison;
}
continue;
}

// If one path is longer at this position (has subdirectories)
if (i === bParts.length - 1 && i < aParts.length - 1) {
// a has subdirectories, so it should come first
return -1;
}
if (i === aParts.length - 1 && i < bParts.length - 1) {
// b has subdirectories, so it should come first
return 1;
}

// Both are at their final component, compare them
return aParts[i].localeCompare(bParts[i]);
}

// If one path is a prefix of the other, shorter path comes first
return aParts.length - bParts.length;
}

export type FileStatusProps = {
iconClasses: string;
title: string;
Expand Down Expand Up @@ -406,28 +448,27 @@ export class MultiFileDiffViewerState {
this.diffMetadata = null;
}

patches.sort(compareFileDetails);

// Load new state
for (let i = 0; i < patches.length; i++) {
const patch = patches[i];

if (meta.githubDetails && isImageFile(patch.fromFile) && isImageFile(patch.toFile)) {
const isImageDiff = isImageFile(patch.fromFile) && isImageFile(patch.toFile);
if (isImageDiff && meta.githubDetails) {
const githubDetailsCopy = meta.githubDetails;

let fileA: LazyPromise<string> | null;
if (patch.status === "added") {
fileA = null;
} else {
let fileA: LazyPromise<string> | null = null;
if (patch.status !== "added") {
fileA = lazyPromise(async () =>
URL.createObjectURL(
await fetchGithubFile(getGithubToken(), githubDetailsCopy.owner, githubDetailsCopy.repo, patch.fromFile, githubDetailsCopy.base),
),
);
}

let fileB: LazyPromise<string> | null;
if (patch.status === "removed") {
fileB = null;
} else {
let fileB: LazyPromise<string> | null = null;
if (patch.status !== "removed") {
fileB = lazyPromise(async () =>
URL.createObjectURL(
await fetchGithubFile(getGithubToken(), githubDetailsCopy.owner, githubDetailsCopy.repo, patch.toFile, githubDetailsCopy.head),
Expand All @@ -438,6 +479,22 @@ export class MultiFileDiffViewerState {
this.images[i] = { fileA, fileB, load: false };
continue;
}
const fromBlob = patch.fromBlob;
const toBlob = patch.toBlob;
if (isImageDiff && fromBlob && toBlob) {
let fileA: LazyPromise<string> | null = null;
if (patch.status !== "added") {
fileA = lazyPromise(async () => URL.createObjectURL(fromBlob));
}

let fileB: LazyPromise<string> | null = null;
if (patch.status !== "removed") {
fileB = lazyPromise(async () => URL.createObjectURL(toBlob));
}

this.images[i] = { fileA, fileB, load: false };
continue;
}

this.diffText[i] = patch.content;
this.diffs[i] = (async () => {
Expand All @@ -449,6 +506,7 @@ export class MultiFileDiffViewerState {
this.fileDetails.push(...patches);
}

// TODO fails for initial commit?
// handle matched github url
async loadFromGithubApi(match: Array<string>): Promise<boolean> {
const [url, owner, repo, type, id] = match;
Expand Down
Loading
Loading