feat(diff): Add search and extension filter to diff sidebar (#37068)

Adds a search box and a file-extension filter to the pull request diff
sidebar, so reviewers can narrow a large diff down to the files they
care about.

Both filters apply to the file tree and to the diff itself. The
extension menu follows GitHub: extensions sorted alphabetically,
dotfiles and extension-less files in their own buckets, and the
selection kept in the same `file-filters[]` query parameter, so a
filtered view is shareable and survives a reload.

The menu can list every extension in a diff, so `createTippy` gains an
opt-in `limitSizeToViewport` option that caps a popup to the space left
in the viewport and scrolls its content. Popups that do not ask for it
are unchanged.

Closes https://github.com/go-gitea/gitea/issues/27256
Signed-off-by: silverwind <me@silverwind.io>
Signed-off-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: Claude (Opus 4.7) <noreply@anthropic.com>
Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: Nicolas <bircni@icloud.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
McMichalK
2026-08-23 18:31:22 +02:00
committed by GitHub
parent 4852091e85
commit 2bcf950b78
28 changed files with 914 additions and 133 deletions
+119 -7
View File
@@ -1,20 +1,33 @@
<script lang="ts" setup>
import SvgIcon from './SvgIcon.vue';
import DiffFileTreeItem from './DiffFileTreeItem.vue';
import {toggleElem} from '../utils/dom.ts';
import {diffTreeStore} from '../modules/diff-file.ts';
import DiffFileExtensionFilter from './DiffFileExtensionFilter.vue';
import {onInputDebounce, toggleElem} from '../utils/dom.ts';
import {diffTreeStore, filterDiffTree, applyFiltersToFileBoxes, extensionFilterToUrl, type DiffFileTreeLocale} from '../modules/diff-file.ts';
import {setFileFolding} from '../features/file-fold.ts';
import {onMounted, onUnmounted} from 'vue';
import {onMounted, onUnmounted, computed, watch} from 'vue';
import {localUserSettings} from '../modules/user-settings.ts';
const LOCAL_STORAGE_KEY = 'diff_file_tree_visible';
const props = defineProps<{locale: DiffFileTreeLocale}>();
const store = diffTreeStore();
const visibleTreeItems = computed(() => filterDiffTree(store)?.Children ?? []);
watch(() => store.filenameFilterQuery, onInputDebounce(() => applyFiltersToFileBoxes(store)));
watch(() => store.activeExtensions, () => {
applyFiltersToFileBoxes(store);
window.history.replaceState(null, '', extensionFilterToUrl(store.activeExtensions, window.location.href));
});
onMounted(() => {
// Default to true if unset
store.fileTreeIsVisible = localUserSettings.getBoolean(LOCAL_STORAGE_KEY, true);
// while the tree is hidden there is no control to clear a filter restored from the URL
if (store.fileTreeIsVisible) applyFiltersToFileBoxes(store); else store.activeExtensions = 'all';
document.querySelector('.diff-toggle-file-tree-button')!.addEventListener('click', toggleVisibility);
hashChangeListener();
window.addEventListener('hashchange', hashChangeListener);
});
@@ -44,6 +57,11 @@ function toggleVisibility() {
function updateVisibility(visible: boolean) {
store.fileTreeIsVisible = visible;
if (!visible) {
store.filenameFilterQuery = '';
store.activeExtensions = 'all';
applyFiltersToFileBoxes(store);
}
localUserSettings.setBoolean(LOCAL_STORAGE_KEY, store.fileTreeIsVisible);
updateState(store.fileTreeIsVisible);
}
@@ -62,16 +80,110 @@ function updateState(visible: boolean) {
<template>
<!-- only render the tree if we're visible. in many cases this is something that doesn't change very often -->
<div v-if="store.fileTreeIsVisible" class="diff-file-tree-items">
<DiffFileTreeItem v-for="item in store.diffFileTree.TreeRoot.Children" :key="item.FullName" :item="item"/>
<div v-if="store.fileTreeIsVisible" class="diff-file-tree-wrapper">
<div class="diff-file-tree-search-row">
<div class="diff-file-search-wrapper">
<SvgIcon name="octicon-search" :size="14" class="diff-file-search-icon"/>
<input
type="text"
v-model="store.filenameFilterQuery"
class="diff-file-search-input"
:placeholder="props.locale.filterFiles"
:aria-label="props.locale.filterFiles"
>
<button
v-if="store.filenameFilterQuery"
type="button"
class="diff-file-search-clear"
@click="store.filenameFilterQuery = ''"
:aria-label="props.locale.filterFilesClear"
>
<SvgIcon name="octicon-x" :size="14"/>
</button>
</div>
<DiffFileExtensionFilter :locale="props.locale"/>
</div>
<div class="diff-file-tree-items">
<DiffFileTreeItem v-for="item in visibleTreeItems" :key="item.FullName" :item="item"/>
</div>
</div>
</template>
<style scoped>
.diff-file-tree-wrapper {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-right: .5rem;
flex: 1;
min-height: 0;
}
.diff-file-tree-search-row {
display: flex;
align-items: center;
gap: 8px;
padding-top: 1px; /* match .diff-file-box's top border so this row aligns with .diff-file-header */
padding-bottom: 0.25rem;
}
.diff-file-search-wrapper {
flex: 1;
min-width: 0;
position: relative;
display: flex;
align-items: center;
}
.diff-file-search-icon {
position: absolute;
left: 8px;
color: var(--color-text-light-2);
pointer-events: none;
}
.diff-file-search-input {
flex: 1;
min-width: 0;
height: 32px;
padding: 0 28px;
border: 1px solid var(--color-secondary);
border-radius: var(--border-radius-medium);
background: var(--color-input-background);
color: var(--color-text);
}
.diff-file-search-input:focus {
outline: none;
border-color: var(--color-primary);
}
.diff-file-search-clear {
position: absolute;
right: 4px;
top: 0;
bottom: 0;
width: 20px;
background: none;
border: none;
color: var(--color-text-light);
display: flex;
align-items: center;
justify-content: center;
margin: auto 0;
padding: 0;
}
.diff-file-search-clear:hover {
color: var(--color-text);
}
.diff-file-tree-items {
display: flex;
flex-direction: column;
gap: 1px;
margin-right: .5rem;
overflow-y: auto;
flex: 1;
min-height: 0;
}
</style>