Skip to content
Merged
162 changes: 162 additions & 0 deletions web/src/lib/components/diff-filtering/DiffFilterDialog.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
<script lang="ts">
import { getFileStatusProps } from "$lib/diff-viewer.svelte";
import { Button, Dialog, ToggleGroup } from "bits-ui";
import { tryCompileRegex } from "$lib/util";
import { FILE_STATUSES } from "$lib/github.svelte";
import { slide } from "svelte/transition";
import { type DiffFilterDialogProps, type FilePathFilterMode } from "$lib/components/diff-filtering/index.svelte";
import { GlobalOptions } from "$lib/global-options.svelte";

let { instance, mode, open = $bindable() }: DiffFilterDialogProps = $props();

const globalOptions = GlobalOptions.get();

let newFilePathFilterElement: HTMLInputElement | undefined = $state();
let newFilePathFilterInput = $state("");
let newFilePathFilterInputResult = $derived(tryCompileRegex(newFilePathFilterInput));
$effect(() => {
if (newFilePathFilterElement && newFilePathFilterInputResult.success) {
newFilePathFilterElement.setCustomValidity("");
} else if (newFilePathFilterElement && !newFilePathFilterInputResult.success) {
newFilePathFilterElement.setCustomValidity(newFilePathFilterInputResult.error);
}
});

let newFilePathFilterMode: FilePathFilterMode = $state("exclude");
</script>

<Dialog.Root bind:open>
<Dialog.Portal>
<Dialog.Overlay
class="fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0"
/>
<Dialog.Content
class="fixed top-1/2 left-1/2 z-50 flex max-h-svh w-2xl max-w-full -translate-x-1/2 -translate-y-1/2 flex-col rounded-sm border bg-neutral shadow-md data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-[95%]"
>
<header class="flex shrink-0 flex-row items-center justify-between rounded-t-sm border-b bg-neutral-2 p-4">
<Dialog.Title class="text-xl font-semibold">
{#if mode === "session"}
Edit Filters
{:else}
Edit Default Filters
{/if}
</Dialog.Title>
<Dialog.Close title="Close dialog" class="flex size-6 items-center justify-center rounded-sm btn-ghost text-em-med">
<span class="iconify octicon--x-16" aria-hidden="true"></span>
</Dialog.Close>
</header>

<section class="m-4">
<header class="px-2 py-1 text-lg font-semibold">File Status</header>
<ToggleGroup.Root class="flex flex-wrap gap-0.5" type="multiple" bind:value={instance.selectedFileStatuses}>
{#each FILE_STATUSES as status (status)}
{@const statusProps = getFileStatusProps(status)}
<ToggleGroup.Item
aria-label="Toggle {statusProps.title} Files"
value={status}
class="flex cursor-pointer items-center gap-1 rounded-sm btn-ghost px-2 py-1 data-[state=off]:text-em-med data-[state=off]:hover:text-em-high data-[state=on]:btn-ghost-visible"
>
<span aria-hidden="true" class="size-4 {statusProps.iconClasses}"></span>
{statusProps.title}
</ToggleGroup.Item>
{/each}
</ToggleGroup.Root>
{#if instance.selectedFileStatuses.length === 0}
<p transition:slide class="mt-2 rounded-md border border-red-500 bg-red-500/10 px-2 py-1 font-medium">
No file statuses selected; all files will be excluded.
</p>
{/if}
</section>

<section class="m-4 mt-0">
<header class="px-2 py-1 text-lg font-semibold">File Path</header>
<div class="flex flex-col">
<form
class="mb-1 flex w-full items-center gap-1"
onsubmit={(e) => {
e.preventDefault();
if (!newFilePathFilterInputResult.success) return;
instance.addFilePathFilter(newFilePathFilterInputResult, newFilePathFilterMode);
newFilePathFilterInput = "";
}}
>
<input
type="text"
placeholder="Enter regular expression here..."
class="grow rounded-md border px-2 py-1 inset-shadow-xs ring-focus focus:outline-none focus-visible:ring-2"
bind:value={newFilePathFilterInput}
bind:this={newFilePathFilterElement}
/>
<Button.Root
type="button"
title="Toggle include/exclude mode (currently {newFilePathFilterMode})"
class="flex shrink-0 items-center justify-center rounded-md btn-fill-neutral p-2 text-em-med"
onclick={() => {
newFilePathFilterMode = newFilePathFilterMode === "exclude" ? "include" : "exclude";
}}
>
{#if newFilePathFilterMode === "exclude"}
<span aria-hidden="true" class="iconify octicon--filter-remove-16"></span>
{:else}
<span aria-hidden="true" class="iconify octicon--filter-16"></span>
{/if}
</Button.Root>
<Button.Root type="submit" title="Add filter" class="flex shrink-0 items-center justify-center rounded-md btn-fill-primary p-2">
<span class="iconify size-4 shrink-0 place-self-center octicon--plus-16" aria-hidden="true"></span>
</Button.Root>
</form>
<ul class="h-48 overflow-y-auto rounded-md border inset-shadow-xs">
{#each instance.reverseFilePathFilters as filter (filter)}
<li class="flex gap-1 border-b px-2 py-1">
<span class="grow">
{filter.text}
</span>
<div class="flex size-6 shrink-0 items-center justify-center">
{#if filter.mode === "exclude"}
<span aria-hidden="true" class="iconify size-4 text-em-med octicon--filter-remove-16"></span>
{:else}
<span aria-hidden="true" class="iconify size-4 text-em-med octicon--filter-16"></span>
{/if}
</div>
<Button.Root
type="button"
title="Delete filter"
class="flex size-6 items-center justify-center rounded-sm btn-ghost-danger"
onclick={() => {
instance.filePathFilters.delete(filter);
}}
>
<span class="iconify size-4 shrink-0 place-self-center octicon--trash-16" aria-hidden="true"></span>
</Button.Root>
</li>
{/each}
{#if instance.reverseFilePathFilters.length === 0}
<li class="flex size-full items-center justify-center px-4 text-em-med">No file path filters. Add one using the above form.</li>
{/if}
</ul>
</div>
</section>

<section class="m-4 flex flex-wrap gap-1">
<Button.Root
class="flex items-center gap-2 rounded-md btn-fill-danger px-2 py-1"
onclick={() => {
instance.setDefaults();
}}
>
<span class="iconify shrink-0 octicon--trash-16" aria-hidden="true"></span>Clear Filters
</Button.Root>
{#if mode === "session"}
<Button.Root
class="flex items-center gap-2 rounded-md btn-fill-danger px-2 py-1"
onclick={() => {
instance.setFrom(globalOptions.defaultFilters);
}}
>
<span class="iconify shrink-0 octicon--undo-16" aria-hidden="true"></span>Reset Filters To Defaults
</Button.Root>
{/if}
</section>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
23 changes: 23 additions & 0 deletions web/src/lib/components/diff-filtering/DiffFilterIndicator.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<script lang="ts">
import { MultiFileDiffViewerState } from "$lib/diff-viewer.svelte";
import { Button } from "bits-ui";

const viewer = MultiFileDiffViewerState.get();
let isFiltered = $derived.by(() => {
if (viewer.diffMetadata === null) {
return false;
}
return viewer.fileDetails.length !== viewer.filteredFileDetails.array.length;
});
</script>

{#if isFiltered}
<Button.Root
class="rounded-sm btn-fill-neutral border px-1 py-0.5 text-sm leading-none"
onclick={() => {
viewer.openDialog("diff-filter");
}}
>
Filtered
</Button.Root>
{/if}
123 changes: 123 additions & 0 deletions web/src/lib/components/diff-filtering/index.svelte.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import type { FileDetails } from "$lib/diff-viewer.svelte";
import { FILE_STATUSES, type FileStatus } from "$lib/github.svelte";
import type { TryCompileRegexSuccess } from "$lib/util";
import { SvelteSet } from "svelte/reactivity";

export type FilePathFilterMode = "include" | "exclude";
export class FilePathFilter {
text: string;
regex: RegExp;
mode: FilePathFilterMode;

constructor(text: string, regex: RegExp, mode: FilePathFilterMode) {
this.text = $state(text);
this.regex = $state.raw(regex);
this.mode = $state(mode);
}
}

export interface DiffFilterDialogProps {
instance: DiffFilterDialogState;
mode: "session" | "defaults";
open: boolean;
}

export class DiffFilterDialogState {
filePathFilters = new SvelteSet<FilePathFilter>();
reverseFilePathFilters = $derived([...this.filePathFilters].toReversed());
filterFunction = $derived(this.createFilterFunction());

selectedFileStatuses: string[] = $state([...FILE_STATUSES]);

addFilePathFilter(regex: TryCompileRegexSuccess, mode: FilePathFilterMode) {
const newFilter = new FilePathFilter(regex.input, regex.regex, mode);
this.filePathFilters.add(newFilter);
}

setDefaults() {
this.filePathFilters.clear();
this.selectedFileStatuses = [...FILE_STATUSES];
}

filterFile(file: FileDetails): boolean {
return this.filterFunction(file);
}

private createFilterFunction() {
const filePathInclusions = this.reverseFilePathFilters.filter((f) => f.mode === "include");
const filePathExclusions = this.reverseFilePathFilters.filter((f) => f.mode === "exclude");
const selectedFileStatuses = [...this.selectedFileStatuses];

return (file: FileDetails) => {
const statusAllowed = selectedFileStatuses.includes(file.status);
if (!statusAllowed) {
return false;
}
for (const exclude of filePathExclusions) {
if (exclude.regex.test(file.toFile) || exclude.regex.test(file.fromFile)) {
return false;
}
}
if (filePathInclusions.length > 0) {
for (const include of filePathInclusions) {
if (include.regex.test(file.toFile) || include.regex.test(file.fromFile)) {
return true;
}
}
return false;
}
return true;
};
}

serialize(): object | null {
if (this.filePathFilters.size === 0 && this.selectedFileStatuses.length === FILE_STATUSES.length) {
return null;
}
return {
filePathFilters: Array.from(this.filePathFilters).map((filter) => ({
text: filter.text,
regex: filter.regex.source,
mode: filter.mode,
})),
selectedFileStatuses: this.selectedFileStatuses,
};
}

loadFrom(data: object | undefined | null) {
if (data === undefined || data === null) {
this.setDefaults();
return;
}

const parsed = data as {
filePathFilters?: { text: string; regex: string; mode: FilePathFilterMode }[];
selectedFileStatuses?: string[];
};

this.filePathFilters.clear();
if (parsed.filePathFilters) {
for (const filter of parsed.filePathFilters) {
try {
const regex = new RegExp(filter.regex);
this.filePathFilters.add(new FilePathFilter(filter.text, regex, filter.mode));
} catch {
continue;
}
}
}

if (parsed.selectedFileStatuses) {
const validStatuses = parsed.selectedFileStatuses.filter((status) => FILE_STATUSES.includes(status as FileStatus));
this.selectedFileStatuses = validStatuses;
}
}

setFrom(other: DiffFilterDialogState) {
this.filePathFilters.clear();
for (const filter of other.filePathFilters) {
this.filePathFilters.add(new FilePathFilter(filter.text, filter.regex, filter.mode));
}
this.selectedFileStatuses = [...other.selectedFileStatuses];
}
}
6 changes: 4 additions & 2 deletions web/src/lib/components/diff/concise-diff-view.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1076,13 +1076,15 @@ async function getTheme(theme: BundledTheme | undefined): Promise<null | { defau

export class ConciseDiffViewCachedState {
diffViewerPatch: Promise<DiffViewerPatch>;
cacheKey: unknown;
syntaxHighlighting: boolean;
syntaxHighlightingTheme: BundledTheme | undefined;
omitPatchHeaderOnlyHunks: boolean;
wordDiffs: boolean;

constructor(diffViewerPatch: Promise<DiffViewerPatch>, props: ConciseDiffViewStateProps<unknown>) {
this.diffViewerPatch = diffViewerPatch;
this.cacheKey = props.cacheKey.current;
this.syntaxHighlighting = props.syntaxHighlighting.current;
this.syntaxHighlightingTheme = props.syntaxHighlightingTheme.current;
this.omitPatchHeaderOnlyHunks = props.omitPatchHeaderOnlyHunks.current;
Expand Down Expand Up @@ -1188,8 +1190,8 @@ export class ConciseDiffViewState<K> {
});

onDestroy(() => {
if (this.props.cache.current !== undefined && this.props.cacheKey.current !== undefined && this.cachedState !== undefined) {
this.props.cache.current.set(this.props.cacheKey.current, this.cachedState);
if (this.props.cache.current !== undefined && this.cachedState !== undefined) {
this.props.cache.current.set(this.cachedState.cacheKey as K, this.cachedState);
}
});
}
Expand Down
17 changes: 13 additions & 4 deletions web/src/lib/components/menu-bar/MenuBar.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@
<Menubar.Item
class="flex justify-between gap-2 btn-ghost px-2 py-1 select-none"
onSelect={() => {
viewer.openSettingsDialog();
viewer.openDialog("settings");
}}
>
Open Settings
Expand All @@ -69,7 +69,7 @@
<Menubar.Item
class="flex justify-between gap-2 btn-ghost px-2 py-1 select-none"
onSelect={() => {
viewer.openOpenDiffDialog();
viewer.openDialog("open-diff");
}}
>
Open
Expand All @@ -82,6 +82,15 @@
<Menubar.Trigger class="btn-ghost px-2 py-1 text-sm data-[state=open]:btn-ghost-hover">View</Menubar.Trigger>
<Menubar.Portal>
<Menubar.Content class="z-20 border bg-neutral text-sm" align="start">
<Menubar.Item
class="btn-ghost px-2 py-1 select-none data-disabled:pointer-events-none data-disabled:cursor-not-allowed data-disabled:text-em-disabled"
disabled={viewer.diffMetadata === null}
onSelect={() => {
viewer.openDialog("diff-filter");
}}
>
Edit Filters
</Menubar.Item>
<Menubar.Item
class="btn-ghost px-2 py-1 select-none"
onSelect={() => {
Expand Down Expand Up @@ -123,7 +132,7 @@
<Menubar.Portal>
<Menubar.Content class="z-20 border bg-neutral text-sm" align="start">
<Menubar.Item
class="data-disabled:cursor-notallowed btn-ghost px-2 py-1 select-none data-disabled:pointer-events-none data-disabled:text-em-disabled"
class="btn-ghost px-2 py-1 select-none data-disabled:pointer-events-none data-disabled:cursor-not-allowed data-disabled:text-em-disabled"
disabled={viewer.selection === undefined}
onSelect={() => {
if (viewer.selection) {
Expand All @@ -137,7 +146,7 @@
}
}}
>
Go to Selection
Jump to Selection
</Menubar.Item>
</Menubar.Content>
</Menubar.Portal>
Expand Down
Loading