Skip to content

Commit 3b5d752

Browse files
authored
Allow filtering diffs by file status, regular expression on file path (#58)
* Initial diff filtering implementation Still need to decide on a strategy for default/global filters in app settings (current is only per-session), as well as strategy for persisting filters within a session (i.e. url params vs history state?) * Add no statuses selected text, fix cached state restoration with component reuse * Improve regex input validation * Move empty pattern check * Add persisted default filters * Add filter reset buttons * Adjust icons to filter reset buttons * Fix aria-hiddens * Validate filter data on load * Change filter loop key * Avoid scrolling to filtered-out file * Make no files will show warning more prominent * Fix typo
1 parent 0bc8a19 commit 3b5d752

16 files changed

+582
-190
lines changed
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
<script lang="ts">
2+
import { getFileStatusProps } from "$lib/diff-viewer.svelte";
3+
import { Button, Dialog, ToggleGroup } from "bits-ui";
4+
import { tryCompileRegex } from "$lib/util";
5+
import { FILE_STATUSES } from "$lib/github.svelte";
6+
import { slide } from "svelte/transition";
7+
import { type DiffFilterDialogProps, type FilePathFilterMode } from "$lib/components/diff-filtering/index.svelte";
8+
import { GlobalOptions } from "$lib/global-options.svelte";
9+
10+
let { instance, mode, open = $bindable() }: DiffFilterDialogProps = $props();
11+
12+
const globalOptions = GlobalOptions.get();
13+
14+
let newFilePathFilterElement: HTMLInputElement | undefined = $state();
15+
let newFilePathFilterInput = $state("");
16+
let newFilePathFilterInputResult = $derived(tryCompileRegex(newFilePathFilterInput));
17+
$effect(() => {
18+
if (newFilePathFilterElement && newFilePathFilterInputResult.success) {
19+
newFilePathFilterElement.setCustomValidity("");
20+
} else if (newFilePathFilterElement && !newFilePathFilterInputResult.success) {
21+
newFilePathFilterElement.setCustomValidity(newFilePathFilterInputResult.error);
22+
}
23+
});
24+
25+
let newFilePathFilterMode: FilePathFilterMode = $state("exclude");
26+
</script>
27+
28+
<Dialog.Root bind:open>
29+
<Dialog.Portal>
30+
<Dialog.Overlay
31+
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"
32+
/>
33+
<Dialog.Content
34+
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%]"
35+
>
36+
<header class="flex shrink-0 flex-row items-center justify-between rounded-t-sm border-b bg-neutral-2 p-4">
37+
<Dialog.Title class="text-xl font-semibold">
38+
{#if mode === "session"}
39+
Edit Filters
40+
{:else}
41+
Edit Default Filters
42+
{/if}
43+
</Dialog.Title>
44+
<Dialog.Close title="Close dialog" class="flex size-6 items-center justify-center rounded-sm btn-ghost text-em-med">
45+
<span class="iconify octicon--x-16" aria-hidden="true"></span>
46+
</Dialog.Close>
47+
</header>
48+
49+
<section class="m-4">
50+
<header class="px-2 py-1 text-lg font-semibold">File Status</header>
51+
<ToggleGroup.Root class="flex flex-wrap gap-0.5" type="multiple" bind:value={instance.selectedFileStatuses}>
52+
{#each FILE_STATUSES as status (status)}
53+
{@const statusProps = getFileStatusProps(status)}
54+
<ToggleGroup.Item
55+
aria-label="Toggle {statusProps.title} Files"
56+
value={status}
57+
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"
58+
>
59+
<span aria-hidden="true" class="size-4 {statusProps.iconClasses}"></span>
60+
{statusProps.title}
61+
</ToggleGroup.Item>
62+
{/each}
63+
</ToggleGroup.Root>
64+
{#if instance.selectedFileStatuses.length === 0}
65+
<p transition:slide class="mt-2 rounded-md border border-red-500 bg-red-500/10 px-2 py-1 font-medium">
66+
No file statuses selected; all files will be excluded.
67+
</p>
68+
{/if}
69+
</section>
70+
71+
<section class="m-4 mt-0">
72+
<header class="px-2 py-1 text-lg font-semibold">File Path</header>
73+
<div class="flex flex-col">
74+
<form
75+
class="mb-1 flex w-full items-center gap-1"
76+
onsubmit={(e) => {
77+
e.preventDefault();
78+
if (!newFilePathFilterInputResult.success) return;
79+
instance.addFilePathFilter(newFilePathFilterInputResult, newFilePathFilterMode);
80+
newFilePathFilterInput = "";
81+
}}
82+
>
83+
<input
84+
type="text"
85+
placeholder="Enter regular expression here..."
86+
class="grow rounded-md border px-2 py-1 inset-shadow-xs ring-focus focus:outline-none focus-visible:ring-2"
87+
bind:value={newFilePathFilterInput}
88+
bind:this={newFilePathFilterElement}
89+
/>
90+
<Button.Root
91+
type="button"
92+
title="Toggle include/exclude mode (currently {newFilePathFilterMode})"
93+
class="flex shrink-0 items-center justify-center rounded-md btn-fill-neutral p-2 text-em-med"
94+
onclick={() => {
95+
newFilePathFilterMode = newFilePathFilterMode === "exclude" ? "include" : "exclude";
96+
}}
97+
>
98+
{#if newFilePathFilterMode === "exclude"}
99+
<span aria-hidden="true" class="iconify octicon--filter-remove-16"></span>
100+
{:else}
101+
<span aria-hidden="true" class="iconify octicon--filter-16"></span>
102+
{/if}
103+
</Button.Root>
104+
<Button.Root type="submit" title="Add filter" class="flex shrink-0 items-center justify-center rounded-md btn-fill-primary p-2">
105+
<span class="iconify size-4 shrink-0 place-self-center octicon--plus-16" aria-hidden="true"></span>
106+
</Button.Root>
107+
</form>
108+
<ul class="h-48 overflow-y-auto rounded-md border inset-shadow-xs">
109+
{#each instance.reverseFilePathFilters as filter (filter)}
110+
<li class="flex gap-1 border-b px-2 py-1">
111+
<span class="grow">
112+
{filter.text}
113+
</span>
114+
<div class="flex size-6 shrink-0 items-center justify-center">
115+
{#if filter.mode === "exclude"}
116+
<span aria-hidden="true" class="iconify size-4 text-em-med octicon--filter-remove-16"></span>
117+
{:else}
118+
<span aria-hidden="true" class="iconify size-4 text-em-med octicon--filter-16"></span>
119+
{/if}
120+
</div>
121+
<Button.Root
122+
type="button"
123+
title="Delete filter"
124+
class="flex size-6 items-center justify-center rounded-sm btn-ghost-danger"
125+
onclick={() => {
126+
instance.filePathFilters.delete(filter);
127+
}}
128+
>
129+
<span class="iconify size-4 shrink-0 place-self-center octicon--trash-16" aria-hidden="true"></span>
130+
</Button.Root>
131+
</li>
132+
{/each}
133+
{#if instance.reverseFilePathFilters.length === 0}
134+
<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>
135+
{/if}
136+
</ul>
137+
</div>
138+
</section>
139+
140+
<section class="m-4 flex flex-wrap gap-1">
141+
<Button.Root
142+
class="flex items-center gap-2 rounded-md btn-fill-danger px-2 py-1"
143+
onclick={() => {
144+
instance.setDefaults();
145+
}}
146+
>
147+
<span class="iconify shrink-0 octicon--trash-16" aria-hidden="true"></span>Clear Filters
148+
</Button.Root>
149+
{#if mode === "session"}
150+
<Button.Root
151+
class="flex items-center gap-2 rounded-md btn-fill-danger px-2 py-1"
152+
onclick={() => {
153+
instance.setFrom(globalOptions.defaultFilters);
154+
}}
155+
>
156+
<span class="iconify shrink-0 octicon--undo-16" aria-hidden="true"></span>Reset Filters To Defaults
157+
</Button.Root>
158+
{/if}
159+
</section>
160+
</Dialog.Content>
161+
</Dialog.Portal>
162+
</Dialog.Root>
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<script lang="ts">
2+
import { MultiFileDiffViewerState } from "$lib/diff-viewer.svelte";
3+
import { Button } from "bits-ui";
4+
5+
const viewer = MultiFileDiffViewerState.get();
6+
let isFiltered = $derived.by(() => {
7+
if (viewer.diffMetadata === null) {
8+
return false;
9+
}
10+
return viewer.fileDetails.length !== viewer.filteredFileDetails.array.length;
11+
});
12+
</script>
13+
14+
{#if isFiltered}
15+
<Button.Root
16+
class="rounded-sm btn-fill-neutral border px-1 py-0.5 text-sm leading-none"
17+
onclick={() => {
18+
viewer.openDialog("diff-filter");
19+
}}
20+
>
21+
Filtered
22+
</Button.Root>
23+
{/if}
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import type { FileDetails } from "$lib/diff-viewer.svelte";
2+
import { FILE_STATUSES, type FileStatus } from "$lib/github.svelte";
3+
import type { TryCompileRegexSuccess } from "$lib/util";
4+
import { SvelteSet } from "svelte/reactivity";
5+
6+
export type FilePathFilterMode = "include" | "exclude";
7+
export class FilePathFilter {
8+
text: string;
9+
regex: RegExp;
10+
mode: FilePathFilterMode;
11+
12+
constructor(text: string, regex: RegExp, mode: FilePathFilterMode) {
13+
this.text = $state(text);
14+
this.regex = $state.raw(regex);
15+
this.mode = $state(mode);
16+
}
17+
}
18+
19+
export interface DiffFilterDialogProps {
20+
instance: DiffFilterDialogState;
21+
mode: "session" | "defaults";
22+
open: boolean;
23+
}
24+
25+
export class DiffFilterDialogState {
26+
filePathFilters = new SvelteSet<FilePathFilter>();
27+
reverseFilePathFilters = $derived([...this.filePathFilters].toReversed());
28+
filterFunction = $derived(this.createFilterFunction());
29+
30+
selectedFileStatuses: string[] = $state([...FILE_STATUSES]);
31+
32+
addFilePathFilter(regex: TryCompileRegexSuccess, mode: FilePathFilterMode) {
33+
const newFilter = new FilePathFilter(regex.input, regex.regex, mode);
34+
this.filePathFilters.add(newFilter);
35+
}
36+
37+
setDefaults() {
38+
this.filePathFilters.clear();
39+
this.selectedFileStatuses = [...FILE_STATUSES];
40+
}
41+
42+
filterFile(file: FileDetails): boolean {
43+
return this.filterFunction(file);
44+
}
45+
46+
private createFilterFunction() {
47+
const filePathInclusions = this.reverseFilePathFilters.filter((f) => f.mode === "include");
48+
const filePathExclusions = this.reverseFilePathFilters.filter((f) => f.mode === "exclude");
49+
const selectedFileStatuses = [...this.selectedFileStatuses];
50+
51+
return (file: FileDetails) => {
52+
const statusAllowed = selectedFileStatuses.includes(file.status);
53+
if (!statusAllowed) {
54+
return false;
55+
}
56+
for (const exclude of filePathExclusions) {
57+
if (exclude.regex.test(file.toFile) || exclude.regex.test(file.fromFile)) {
58+
return false;
59+
}
60+
}
61+
if (filePathInclusions.length > 0) {
62+
for (const include of filePathInclusions) {
63+
if (include.regex.test(file.toFile) || include.regex.test(file.fromFile)) {
64+
return true;
65+
}
66+
}
67+
return false;
68+
}
69+
return true;
70+
};
71+
}
72+
73+
serialize(): object | null {
74+
if (this.filePathFilters.size === 0 && this.selectedFileStatuses.length === FILE_STATUSES.length) {
75+
return null;
76+
}
77+
return {
78+
filePathFilters: Array.from(this.filePathFilters).map((filter) => ({
79+
text: filter.text,
80+
regex: filter.regex.source,
81+
mode: filter.mode,
82+
})),
83+
selectedFileStatuses: this.selectedFileStatuses,
84+
};
85+
}
86+
87+
loadFrom(data: object | undefined | null) {
88+
if (data === undefined || data === null) {
89+
this.setDefaults();
90+
return;
91+
}
92+
93+
const parsed = data as {
94+
filePathFilters?: { text: string; regex: string; mode: FilePathFilterMode }[];
95+
selectedFileStatuses?: string[];
96+
};
97+
98+
this.filePathFilters.clear();
99+
if (parsed.filePathFilters) {
100+
for (const filter of parsed.filePathFilters) {
101+
try {
102+
const regex = new RegExp(filter.regex);
103+
this.filePathFilters.add(new FilePathFilter(filter.text, regex, filter.mode));
104+
} catch {
105+
continue;
106+
}
107+
}
108+
}
109+
110+
if (parsed.selectedFileStatuses) {
111+
const validStatuses = parsed.selectedFileStatuses.filter((status) => FILE_STATUSES.includes(status as FileStatus));
112+
this.selectedFileStatuses = validStatuses;
113+
}
114+
}
115+
116+
setFrom(other: DiffFilterDialogState) {
117+
this.filePathFilters.clear();
118+
for (const filter of other.filePathFilters) {
119+
this.filePathFilters.add(new FilePathFilter(filter.text, filter.regex, filter.mode));
120+
}
121+
this.selectedFileStatuses = [...other.selectedFileStatuses];
122+
}
123+
}

web/src/lib/components/diff/concise-diff-view.svelte.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1076,13 +1076,15 @@ async function getTheme(theme: BundledTheme | undefined): Promise<null | { defau
10761076

10771077
export class ConciseDiffViewCachedState {
10781078
diffViewerPatch: Promise<DiffViewerPatch>;
1079+
cacheKey: unknown;
10791080
syntaxHighlighting: boolean;
10801081
syntaxHighlightingTheme: BundledTheme | undefined;
10811082
omitPatchHeaderOnlyHunks: boolean;
10821083
wordDiffs: boolean;
10831084

10841085
constructor(diffViewerPatch: Promise<DiffViewerPatch>, props: ConciseDiffViewStateProps<unknown>) {
10851086
this.diffViewerPatch = diffViewerPatch;
1087+
this.cacheKey = props.cacheKey.current;
10861088
this.syntaxHighlighting = props.syntaxHighlighting.current;
10871089
this.syntaxHighlightingTheme = props.syntaxHighlightingTheme.current;
10881090
this.omitPatchHeaderOnlyHunks = props.omitPatchHeaderOnlyHunks.current;
@@ -1188,8 +1190,8 @@ export class ConciseDiffViewState<K> {
11881190
});
11891191

11901192
onDestroy(() => {
1191-
if (this.props.cache.current !== undefined && this.props.cacheKey.current !== undefined && this.cachedState !== undefined) {
1192-
this.props.cache.current.set(this.props.cacheKey.current, this.cachedState);
1193+
if (this.props.cache.current !== undefined && this.cachedState !== undefined) {
1194+
this.props.cache.current.set(this.cachedState.cacheKey as K, this.cachedState);
11931195
}
11941196
});
11951197
}

web/src/lib/components/menu-bar/MenuBar.svelte

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@
5353
<Menubar.Item
5454
class="flex justify-between gap-2 btn-ghost px-2 py-1 select-none"
5555
onSelect={() => {
56-
viewer.openSettingsDialog();
56+
viewer.openDialog("settings");
5757
}}
5858
>
5959
Open Settings
@@ -69,7 +69,7 @@
6969
<Menubar.Item
7070
class="flex justify-between gap-2 btn-ghost px-2 py-1 select-none"
7171
onSelect={() => {
72-
viewer.openOpenDiffDialog();
72+
viewer.openDialog("open-diff");
7373
}}
7474
>
7575
Open
@@ -82,6 +82,15 @@
8282
<Menubar.Trigger class="btn-ghost px-2 py-1 text-sm data-[state=open]:btn-ghost-hover">View</Menubar.Trigger>
8383
<Menubar.Portal>
8484
<Menubar.Content class="z-20 border bg-neutral text-sm" align="start">
85+
<Menubar.Item
86+
class="btn-ghost px-2 py-1 select-none data-disabled:pointer-events-none data-disabled:cursor-not-allowed data-disabled:text-em-disabled"
87+
disabled={viewer.diffMetadata === null}
88+
onSelect={() => {
89+
viewer.openDialog("diff-filter");
90+
}}
91+
>
92+
Edit Filters
93+
</Menubar.Item>
8594
<Menubar.Item
8695
class="btn-ghost px-2 py-1 select-none"
8796
onSelect={() => {
@@ -123,7 +132,7 @@
123132
<Menubar.Portal>
124133
<Menubar.Content class="z-20 border bg-neutral text-sm" align="start">
125134
<Menubar.Item
126-
class="data-disabled:cursor-notallowed btn-ghost px-2 py-1 select-none data-disabled:pointer-events-none data-disabled:text-em-disabled"
135+
class="btn-ghost px-2 py-1 select-none data-disabled:pointer-events-none data-disabled:cursor-not-allowed data-disabled:text-em-disabled"
127136
disabled={viewer.selection === undefined}
128137
onSelect={() => {
129138
if (viewer.selection) {
@@ -137,7 +146,7 @@
137146
}
138147
}}
139148
>
140-
Go to Selection
149+
Jump to Selection
141150
</Menubar.Item>
142151
</Menubar.Content>
143152
</Menubar.Portal>

0 commit comments

Comments
 (0)