From 3ee1ce2b0461a50dc035f1706d3aa9dc38e7238d Mon Sep 17 00:00:00 2001 From: Jason Penilla <11360596+jpenilla@users.noreply.github.com> Date: Sat, 20 Dec 2025 10:57:24 -0800 Subject: [PATCH 1/4] 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?) --- .../diff-filtering/DiffFilterDialog.svelte | 116 +++++++++++++ .../components/diff-filtering/index.svelte.ts | 70 ++++++++ .../lib/components/menu-bar/MenuBar.svelte | 14 +- .../components/sidebar}/Sidebar.svelte | 21 +-- .../lib/components/sidebar/index.svelte.ts | 94 ++++++++++ web/src/lib/components/tree/Tree.svelte | 10 +- web/src/lib/diff-viewer.svelte.ts | 160 +++++++++--------- web/src/lib/github.svelte.ts | 1 + web/src/lib/util.ts | 72 -------- web/src/routes/+page.svelte | 8 +- web/src/routes/FileHeader.svelte | 4 +- 11 files changed, 392 insertions(+), 178 deletions(-) create mode 100644 web/src/lib/components/diff-filtering/DiffFilterDialog.svelte create mode 100644 web/src/lib/components/diff-filtering/index.svelte.ts rename web/src/{routes => lib/components/sidebar}/Sidebar.svelte (90%) create mode 100644 web/src/lib/components/sidebar/index.svelte.ts diff --git a/web/src/lib/components/diff-filtering/DiffFilterDialog.svelte b/web/src/lib/components/diff-filtering/DiffFilterDialog.svelte new file mode 100644 index 0000000..4064798 --- /dev/null +++ b/web/src/lib/components/diff-filtering/DiffFilterDialog.svelte @@ -0,0 +1,116 @@ + + + + + + +
+ Edit Filters + + + +
+ +
+
File Status
+ + {#each FILE_STATUSES as status (status)} + {@const statusProps = getFileStatusProps(status)} + + + {statusProps.title} + + {/each} + +
+ +
+
File Path
+
+
{ + e.preventDefault(); + if (newFileNameFilterInput === "") return; + // TODO error handling + instance.addFileNameFilter(newFileNameFilterInput, newFileNameFilterMode); + newFileNameFilterInput = ""; + }} + > + + { + newFileNameFilterMode = newFileNameFilterMode === "exclude" ? "include" : "exclude"; + }} + > + {#if newFileNameFilterMode === "exclude"} + + {:else} + + {/if} + + + + +
+
    + {#each instance.reverseFileNameFilters as filter, i (i)} +
  • + + {filter.text} + +
    + {#if filter.mode === "exclude"} + + {:else} + + {/if} +
    + { + instance.fileNameFilters.delete(filter); + }} + > + + +
  • + {/each} + {#if instance.reverseFileNameFilters.length === 0} +
  • No file path filters. Add one using the above form.
  • + {/if} +
+
+
+
+
+
diff --git a/web/src/lib/components/diff-filtering/index.svelte.ts b/web/src/lib/components/diff-filtering/index.svelte.ts new file mode 100644 index 0000000..2a4217f --- /dev/null +++ b/web/src/lib/components/diff-filtering/index.svelte.ts @@ -0,0 +1,70 @@ +import type { FileDetails } from "$lib/diff-viewer.svelte"; +import { FILE_STATUSES } from "$lib/github.svelte"; +import { SvelteSet } from "svelte/reactivity"; + +export type FileNameFilterMode = "include" | "exclude"; +export class FileNameFilter { + text: string; + regex: RegExp; + mode: FileNameFilterMode; + + constructor(text: string, regex: RegExp, mode: FileNameFilterMode) { + this.text = $state(text); + this.regex = $state.raw(regex); + this.mode = $state(mode); + } +} + +function tryCompileRegex(pattern: string): RegExp | undefined { + try { + return new RegExp(pattern); + } catch { + return undefined; + } +} + +export class DiffFilterDialogState { + fileNameFilters = new SvelteSet(); + reverseFileNameFilters = $derived([...this.fileNameFilters].toReversed()); + + selectedFileStatuses: string[] = $state([...FILE_STATUSES]); + + addFileNameFilter(filterString: string, mode: FileNameFilterMode): { invalidRegex: boolean } { + const compiled = tryCompileRegex(filterString); + if (!compiled) { + return { invalidRegex: true }; + } + const newFilter = new FileNameFilter(filterString, compiled, mode); + this.fileNameFilters.add(newFilter); + return { invalidRegex: false }; + } + + setDefaults() { + this.fileNameFilters.clear(); + this.selectedFileStatuses = [...FILE_STATUSES]; + } + + filterFile(file: FileDetails): boolean { + const statusAllowed = this.selectedFileStatuses.includes(file.status); + if (!statusAllowed) { + return false; + } + const pathFilterArray = [...this.fileNameFilters]; + const includes = pathFilterArray.filter((f) => f.mode === "include"); + const excludes = pathFilterArray.filter((f) => f.mode === "exclude"); + for (const exclude of excludes) { + if (exclude.regex.test(file.toFile) || exclude.regex.test(file.fromFile)) { + return false; + } + } + if (includes.length > 0) { + for (const include of includes) { + if (include.regex.test(file.toFile) || include.regex.test(file.fromFile)) { + return true; + } + } + return false; + } + return true; + } +} diff --git a/web/src/lib/components/menu-bar/MenuBar.svelte b/web/src/lib/components/menu-bar/MenuBar.svelte index e376d8a..8da419c 100644 --- a/web/src/lib/components/menu-bar/MenuBar.svelte +++ b/web/src/lib/components/menu-bar/MenuBar.svelte @@ -53,7 +53,7 @@ { - viewer.openSettingsDialog(); + viewer.openDialog("settings"); }} > Open Settings @@ -69,7 +69,7 @@ { - viewer.openOpenDiffDialog(); + viewer.openDialog("open-diff"); }} > Open @@ -82,6 +82,14 @@ View + { + viewer.openDialog("diff-filter"); + }} + > + Edit Filters + { @@ -137,7 +145,7 @@ } }} > - Go to Selection + Jump to Selection diff --git a/web/src/routes/Sidebar.svelte b/web/src/lib/components/sidebar/Sidebar.svelte similarity index 90% rename from web/src/routes/Sidebar.svelte rename to web/src/lib/components/sidebar/Sidebar.svelte index b083aac..6040951 100644 --- a/web/src/routes/Sidebar.svelte +++ b/web/src/lib/components/sidebar/Sidebar.svelte @@ -1,16 +1,17 @@ @@ -41,6 +42,9 @@ {/each} + {#if instance.selectedFileStatuses.length === 0} +

No file statuses selected; all files will be excluded.

+ {/if}
@@ -50,27 +54,27 @@ class="mb-1 flex w-full items-center gap-1" onsubmit={(e) => { e.preventDefault(); - if (newFileNameFilterInput === "") return; + if (newFilePathFilterInput === "") return; // TODO error handling - instance.addFileNameFilter(newFileNameFilterInput, newFileNameFilterMode); - newFileNameFilterInput = ""; + instance.addFilePathFilter(newFilePathFilterInput, newFilePathFilterMode); + newFilePathFilterInput = ""; }} > { - newFileNameFilterMode = newFileNameFilterMode === "exclude" ? "include" : "exclude"; + newFilePathFilterMode = newFilePathFilterMode === "exclude" ? "include" : "exclude"; }} > - {#if newFileNameFilterMode === "exclude"} + {#if newFilePathFilterMode === "exclude"} {:else} @@ -81,7 +85,7 @@
    - {#each instance.reverseFileNameFilters as filter, i (i)} + {#each instance.reverseFilePathFilters as filter, i (i)}
  • {filter.text} @@ -98,14 +102,14 @@ title="Delete filter" class="flex size-6 items-center justify-center rounded-sm btn-ghost-danger" onclick={() => { - instance.fileNameFilters.delete(filter); + instance.filePathFilters.delete(filter); }} >
  • {/each} - {#if instance.reverseFileNameFilters.length === 0} + {#if instance.reverseFilePathFilters.length === 0}
  • No file path filters. Add one using the above form.
  • {/if}
diff --git a/web/src/lib/components/diff-filtering/index.svelte.ts b/web/src/lib/components/diff-filtering/index.svelte.ts index 2a4217f..df3654b 100644 --- a/web/src/lib/components/diff-filtering/index.svelte.ts +++ b/web/src/lib/components/diff-filtering/index.svelte.ts @@ -2,13 +2,13 @@ import type { FileDetails } from "$lib/diff-viewer.svelte"; import { FILE_STATUSES } from "$lib/github.svelte"; import { SvelteSet } from "svelte/reactivity"; -export type FileNameFilterMode = "include" | "exclude"; -export class FileNameFilter { +export type FilePathFilterMode = "include" | "exclude"; +export class FilePathFilter { text: string; regex: RegExp; - mode: FileNameFilterMode; + mode: FilePathFilterMode; - constructor(text: string, regex: RegExp, mode: FileNameFilterMode) { + constructor(text: string, regex: RegExp, mode: FilePathFilterMode) { this.text = $state(text); this.regex = $state.raw(regex); this.mode = $state(mode); @@ -24,23 +24,25 @@ function tryCompileRegex(pattern: string): RegExp | undefined { } export class DiffFilterDialogState { - fileNameFilters = new SvelteSet(); - reverseFileNameFilters = $derived([...this.fileNameFilters].toReversed()); + filePathFilters = new SvelteSet(); + reverseFilePathFilters = $derived([...this.filePathFilters].toReversed()); + filePathInclusions = $derived(this.reverseFilePathFilters.filter((f) => f.mode === "include")); + filePathExclusions = $derived(this.reverseFilePathFilters.filter((f) => f.mode === "exclude")); selectedFileStatuses: string[] = $state([...FILE_STATUSES]); - addFileNameFilter(filterString: string, mode: FileNameFilterMode): { invalidRegex: boolean } { + addFilePathFilter(filterString: string, mode: FilePathFilterMode): { invalidRegex: boolean } { const compiled = tryCompileRegex(filterString); if (!compiled) { return { invalidRegex: true }; } - const newFilter = new FileNameFilter(filterString, compiled, mode); - this.fileNameFilters.add(newFilter); + const newFilter = new FilePathFilter(filterString, compiled, mode); + this.filePathFilters.add(newFilter); return { invalidRegex: false }; } setDefaults() { - this.fileNameFilters.clear(); + this.filePathFilters.clear(); this.selectedFileStatuses = [...FILE_STATUSES]; } @@ -49,16 +51,13 @@ export class DiffFilterDialogState { if (!statusAllowed) { return false; } - const pathFilterArray = [...this.fileNameFilters]; - const includes = pathFilterArray.filter((f) => f.mode === "include"); - const excludes = pathFilterArray.filter((f) => f.mode === "exclude"); - for (const exclude of excludes) { + for (const exclude of this.filePathExclusions) { if (exclude.regex.test(file.toFile) || exclude.regex.test(file.fromFile)) { return false; } } - if (includes.length > 0) { - for (const include of includes) { + if (this.filePathInclusions.length > 0) { + for (const include of this.filePathInclusions) { if (include.regex.test(file.toFile) || include.regex.test(file.fromFile)) { return true; } diff --git a/web/src/lib/components/diff/concise-diff-view.svelte.ts b/web/src/lib/components/diff/concise-diff-view.svelte.ts index a19de04..a8a636b 100644 --- a/web/src/lib/components/diff/concise-diff-view.svelte.ts +++ b/web/src/lib/components/diff/concise-diff-view.svelte.ts @@ -1076,6 +1076,7 @@ async function getTheme(theme: BundledTheme | undefined): Promise; + cacheKey: unknown; syntaxHighlighting: boolean; syntaxHighlightingTheme: BundledTheme | undefined; omitPatchHeaderOnlyHunks: boolean; @@ -1083,6 +1084,7 @@ export class ConciseDiffViewCachedState { constructor(diffViewerPatch: Promise, props: ConciseDiffViewStateProps) { this.diffViewerPatch = diffViewerPatch; + this.cacheKey = props.cacheKey.current; this.syntaxHighlighting = props.syntaxHighlighting.current; this.syntaxHighlightingTheme = props.syntaxHighlightingTheme.current; this.omitPatchHeaderOnlyHunks = props.omitPatchHeaderOnlyHunks.current; @@ -1188,8 +1190,8 @@ export class ConciseDiffViewState { }); 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); } }); } From 61d4269ed035c2cb3004fe6b4aa5ffbc0aaf92a4 Mon Sep 17 00:00:00 2001 From: Jason Penilla <11360596+jpenilla@users.noreply.github.com> Date: Sun, 21 Dec 2025 11:46:34 -0800 Subject: [PATCH 3/4] Improve regex input validation --- .../diff-filtering/DiffFilterDialog.svelte | 26 ++++++-- .../components/diff-filtering/index.svelte.ts | 59 +++++++++---------- web/src/lib/open-diff-dialog.svelte.ts | 12 ++-- web/src/lib/util.ts | 12 ++++ 4 files changed, 65 insertions(+), 44 deletions(-) diff --git a/web/src/lib/components/diff-filtering/DiffFilterDialog.svelte b/web/src/lib/components/diff-filtering/DiffFilterDialog.svelte index a583b0c..a9956e5 100644 --- a/web/src/lib/components/diff-filtering/DiffFilterDialog.svelte +++ b/web/src/lib/components/diff-filtering/DiffFilterDialog.svelte @@ -1,14 +1,30 @@ @@ -54,9 +70,8 @@ class="mb-1 flex w-full items-center gap-1" onsubmit={(e) => { e.preventDefault(); - if (newFilePathFilterInput === "") return; - // TODO error handling - instance.addFilePathFilter(newFilePathFilterInput, newFilePathFilterMode); + if (!newFilePathFilterInputResult.success) return; + instance.addFilePathFilter(newFilePathFilterInputResult, newFilePathFilterMode); newFilePathFilterInput = ""; }} > @@ -65,6 +80,7 @@ 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} /> {/each} {#if instance.reverseFilePathFilters.length === 0} -
  • No file path filters. Add one using the above form.
  • +
  • No file path filters. Add one using the above form.
  • {/if} diff --git a/web/src/lib/components/diff-filtering/index.svelte.ts b/web/src/lib/components/diff-filtering/index.svelte.ts index df3654b..a101ef8 100644 --- a/web/src/lib/components/diff-filtering/index.svelte.ts +++ b/web/src/lib/components/diff-filtering/index.svelte.ts @@ -1,5 +1,6 @@ import type { FileDetails } from "$lib/diff-viewer.svelte"; import { FILE_STATUSES } from "$lib/github.svelte"; +import type { TryCompileRegexSuccess } from "$lib/util"; import { SvelteSet } from "svelte/reactivity"; export type FilePathFilterMode = "include" | "exclude"; @@ -15,30 +16,16 @@ export class FilePathFilter { } } -function tryCompileRegex(pattern: string): RegExp | undefined { - try { - return new RegExp(pattern); - } catch { - return undefined; - } -} - export class DiffFilterDialogState { filePathFilters = new SvelteSet(); reverseFilePathFilters = $derived([...this.filePathFilters].toReversed()); - filePathInclusions = $derived(this.reverseFilePathFilters.filter((f) => f.mode === "include")); - filePathExclusions = $derived(this.reverseFilePathFilters.filter((f) => f.mode === "exclude")); + filterFunction = $derived(this.createFilterFunction()); selectedFileStatuses: string[] = $state([...FILE_STATUSES]); - addFilePathFilter(filterString: string, mode: FilePathFilterMode): { invalidRegex: boolean } { - const compiled = tryCompileRegex(filterString); - if (!compiled) { - return { invalidRegex: true }; - } - const newFilter = new FilePathFilter(filterString, compiled, mode); + addFilePathFilter(regex: TryCompileRegexSuccess, mode: FilePathFilterMode) { + const newFilter = new FilePathFilter(regex.input, regex.regex, mode); this.filePathFilters.add(newFilter); - return { invalidRegex: false }; } setDefaults() { @@ -47,23 +34,33 @@ export class DiffFilterDialogState { } filterFile(file: FileDetails): boolean { - const statusAllowed = this.selectedFileStatuses.includes(file.status); - if (!statusAllowed) { - return false; - } - for (const exclude of this.filePathExclusions) { - if (exclude.regex.test(file.toFile) || exclude.regex.test(file.fromFile)) { + 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; } - } - if (this.filePathInclusions.length > 0) { - for (const include of this.filePathInclusions) { - if (include.regex.test(file.toFile) || include.regex.test(file.fromFile)) { - return true; + 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 false; - } - return true; + return true; + }; } } diff --git a/web/src/lib/open-diff-dialog.svelte.ts b/web/src/lib/open-diff-dialog.svelte.ts index b2bdf6b..19983bd 100644 --- a/web/src/lib/open-diff-dialog.svelte.ts +++ b/web/src/lib/open-diff-dialog.svelte.ts @@ -3,7 +3,7 @@ import { DirectoryEntry, FileEntry, MultimodalFileInputState, type MultimodalFil import { SvelteSet } from "svelte/reactivity"; import { type FileStatus } from "$lib/github.svelte"; import { makeImageDetails, makeTextDetails, MultiFileDiffViewerState, type LoadPatchesOptions } from "$lib/diff-viewer.svelte"; -import { binaryFileDummyDetails, bytesEqual, isBinaryFile, isImageFile, parseMultiFilePatch } from "$lib/util"; +import { binaryFileDummyDetails, bytesEqual, isBinaryFile, isImageFile, parseMultiFilePatch, tryCompileRegex } from "$lib/util"; import { createTwoFilesPatch } from "diff"; export interface OpenDiffDialogProps { @@ -47,13 +47,9 @@ export class OpenDiffDialogState { } addBlacklistEntry() { - if (this.dirBlacklistInput === "") { - return; - } - try { - new RegExp(this.dirBlacklistInput); // Validate regex - } catch (e) { - alert("'" + this.dirBlacklistInput + "' is not a valid regex pattern. Error: " + e); + const result = tryCompileRegex(this.dirBlacklistInput); + if (!result.success) { + alert(result.error); return; } this.dirBlacklist.add(this.dirBlacklistInput); diff --git a/web/src/lib/util.ts b/web/src/lib/util.ts index 22e337a..d66b00d 100644 --- a/web/src/lib/util.ts +++ b/web/src/lib/util.ts @@ -420,3 +420,15 @@ export function animationFramePromise() { export async function yieldToBrowser() { await new Promise((resolve) => setTimeout(resolve, 0)); } + +export type TryCompileRegexSuccess = { success: true; regex: RegExp; input: string }; +export type TryCompileRegexFailure = { success: false; error: string; input: string }; +export type TryCompileRegexResult = TryCompileRegexSuccess | TryCompileRegexFailure; + +export function tryCompileRegex(pattern: string): TryCompileRegexResult { + try { + return { success: true, regex: new RegExp(pattern), input: pattern }; + } catch (e) { + return { success: false, error: e instanceof Error ? e.message : String(e), input: pattern }; + } +} From aeb52e1630046db2dc2febe479c821e66306a425 Mon Sep 17 00:00:00 2001 From: Jason Penilla <11360596+jpenilla@users.noreply.github.com> Date: Sun, 21 Dec 2025 11:50:58 -0800 Subject: [PATCH 4/4] Move empty pattern check --- .../components/diff-filtering/DiffFilterDialog.svelte | 9 ++------- web/src/lib/util.ts | 6 +++++- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/web/src/lib/components/diff-filtering/DiffFilterDialog.svelte b/web/src/lib/components/diff-filtering/DiffFilterDialog.svelte index a9956e5..a15d82c 100644 --- a/web/src/lib/components/diff-filtering/DiffFilterDialog.svelte +++ b/web/src/lib/components/diff-filtering/DiffFilterDialog.svelte @@ -1,7 +1,7 @@