Skip to content
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
131 changes: 131 additions & 0 deletions web/src/lib/components/diff-filtering/DiffFilterDialog.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
<script lang="ts">
import { getFileStatusProps, MultiFileDiffViewerState } 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 FilePathFilterMode } from "$lib/components/diff-filtering/index.svelte";

const viewer = MultiFileDiffViewerState.get();
const instance = viewer.filter;

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={viewer.diffFilterDialogOpen}>
<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">Edit Filters</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 class="aria-hidden size-4 {statusProps.iconClasses}"></span>
{statusProps.title}
</ToggleGroup.Item>
{/each}
</ToggleGroup.Root>
Comment on lines +43 to +55
Copy link

Copilot AI Dec 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ToggleGroup allows users to deselect all file statuses, which would result in all files being filtered out and an empty diff view. Consider either preventing the last status from being deselected, or displaying a helpful message when no files match the current filters.

Copilot uses AI. Check for mistakes.
{#if instance.selectedFileStatuses.length === 0}
<p transition:slide class="px-2 text-em-med italic">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 class="aria-hidden iconify octicon--filter-remove-16"></span>
{:else}
<span class="aria-hidden 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, i (i)}
<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 class="aria-hidden iconify size-4 text-em-med octicon--filter-remove-16"></span>
{:else}
<span class="aria-hidden 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>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
66 changes: 66 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,66 @@
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";
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 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;
};
}
}
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
14 changes: 11 additions & 3 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,14 @@
<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"
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 @@ -137,7 +145,7 @@
}
}}
>
Go to Selection
Jump to Selection
</Menubar.Item>
</Menubar.Content>
</Menubar.Portal>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
<script lang="ts">
import { type FileTreeNodeData } from "$lib/util";
import { type FileDetails, getFileStatusProps, MultiFileDiffViewerState, staticSidebar } from "$lib/diff-viewer.svelte";
import Tree from "$lib/components/tree/Tree.svelte";
import { type TreeNode } from "$lib/components/tree/index.svelte";
import { on } from "svelte/events";
import { createAttachmentKey, type Attachment } from "svelte/attachments";
import { boolAttr } from "runed";
import type { FileTreeNodeData } from "$lib/components/sidebar/index.svelte";

const viewer = MultiFileDiffViewerState.get();
const fileTree = viewer.fileTree;

function filterFileNode(file: TreeNode<FileTreeNodeData>): boolean {
return file.data.type === "file" && viewer.filterFile(file.data.file);
return file.data.type === "file" && fileTree.filterFile(file.data.file);
}

function shouldScrollToFile(nodeInteractionEvent: Event): boolean {
Expand Down Expand Up @@ -101,28 +102,28 @@
<input
type="text"
placeholder="Filter file tree..."
bind:value={viewer.fileTreeFilter}
bind:value={fileTree.filter}
class="w-full rounded-md border px-8 py-1 overflow-ellipsis focus:ring-2 focus:ring-focus focus:outline-none"
autocomplete="off"
/>
<span aria-hidden="true" class="absolute top-1/2 left-2 iconify size-4 -translate-y-1/2 text-em-med octicon--filter-16"></span>
{#if viewer.fileTreeFilterDebounced.current}
{#if fileTree.filterDebounced.current}
<button
class="absolute top-1/2 right-2 iconify size-4 -translate-y-1/2 text-gray-500 octicon--x-16 hover:text-gray-700"
onclick={() => viewer.clearSearch()}
class="absolute top-1/2 right-2 iconify size-4 -translate-y-1/2 text-em-med octicon--x-16 hover:text-em-high"
onclick={() => (fileTree.filter = "")}
aria-label="clear filter"
></button>
{/if}
</div>
</div>
{#if viewer.filteredFileDetails.length !== viewer.fileDetails.length}
<div class="ms-2 mb-2 text-sm text-gray-600">
Showing {viewer.filteredFileDetails.length} of {viewer.fileDetails.length} files
{#if fileTree.filteredFileDetails.length !== viewer.filteredFileDetails.array.length}
<div class="ms-2 mb-2 text-sm text-em-med">
Showing {fileTree.filteredFileDetails.length} of {viewer.filteredFileDetails.array.length} files
</div>
{/if}
<div class="flex h-full flex-col overflow-y-auto border-t">
<div class="h-100">
<Tree roots={viewer.fileTreeRoots} filter={filterFileNode} bind:instance={viewer.tree}>
<Tree roots={fileTree.roots} filter={filterFileNode} bind:instance={fileTree.tree}>
{#snippet nodeRenderer({ node, collapsed, toggleCollapse })}
<div
role="button"
Expand Down
Loading