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
+161 -7
View File
@@ -1,12 +1,17 @@
import {reactive} from 'vue';
import type {Reactive} from 'vue';
import {toggleElem} from '../utils/dom.ts';
import {trString} from './i18n.ts';
import {basename, extname} from '../utils.ts';
const {pageData} = window.config;
export type DiffStatus = '' | 'added' | 'modified' | 'deleted' | 'renamed' | 'copied' | 'typechange';
// matches statusFromLetter in services/gitdiff/git_diff_tree.go
export type DiffStatus = '' | 'added' | 'modified' | 'deleted' | 'renamed' | 'copied' | 'typechanged' | 'unmerged' | 'unknown';
export type DiffTreeEntry = {
FullName: string,
OldFullName: string,
DisplayName: string,
NameHash: string,
DiffStatus: DiffStatus,
@@ -21,6 +26,9 @@ export type DiffFileTreeData = {
TreeRoot: DiffTreeEntry,
};
// activeExtensions: 'all' = no filter (every extension passes); string[] = exact set of extensions allowed (empty = nothing passes).
type ExtensionFilter = 'all' | string[];
type DiffFileTree = {
folderIcon: string;
folderOpenIcon: string;
@@ -28,12 +36,34 @@ type DiffFileTree = {
fullNameMap: Record<string, DiffTreeEntry>
fileTreeIsVisible: boolean;
selectedItem: string;
filenameFilterQuery: string;
activeExtensions: ExtensionFilter;
};
type DiffExtensionStats = {
ext: string,
count: number,
};
export type DiffExtensionFilterLocale = {
filterByFileExtension: string,
fileExtensions: string,
noFileExtension: string,
dotfileExtension: string,
allFileExtensions: string,
};
export type DiffFileTreeLocale = DiffExtensionFilterLocale & {
filterFiles: string,
filterFilesClear: string,
};
let diffTreeStoreReactive: Reactive<DiffFileTree>;
export function diffTreeStore() {
if (!diffTreeStoreReactive) {
diffTreeStoreReactive = reactiveDiffTreeStore(pageData.DiffFileTree!, pageData.FolderIcon!, pageData.FolderOpenIcon!);
const knownExtensions = getDiffTreeExtensionStats(diffTreeStoreReactive).map((stat) => stat.ext);
diffTreeStoreReactive.activeExtensions = extensionFilterFromUrl(window.location.search, knownExtensions);
}
return diffTreeStoreReactive;
}
@@ -58,25 +88,149 @@ function fillFullNameMap(map: Record<string, DiffTreeEntry>, entry: DiffTreeEntr
}
export function reactiveDiffTreeStore(data: DiffFileTreeData, folderIcon: string, folderOpenIcon: string): Reactive<DiffFileTree> {
const store = reactive({
const store = reactive<DiffFileTree>({
diffFileTree: data,
folderIcon,
folderOpenIcon,
fileTreeIsVisible: false,
selectedItem: '',
filenameFilterQuery: '',
activeExtensions: 'all',
fullNameMap: {},
});
fillFullNameMap(store.fullNameMap, data.TreeRoot);
return store;
}
export const extDotfile = 'dotfile'; // bucket for ".gitignore" and friends, real extensions always start with a dot
const urlParamFileFilters = 'file-filters[]'; // same parameter GitHub uses, lists the selected extensions
const urlValueNoExtension = 'noextension';
export function extensionFilterFromUrl(search: string, knownExtensions: string[]): ExtensionFilter {
const params = new URLSearchParams(search);
if (!params.has(urlParamFileFilters)) return 'all';
const extensions = params.getAll(urlParamFileFilters)
.filter(Boolean)
.map((ext) => ext === urlValueNoExtension ? '' : ext)
.filter((ext) => knownExtensions.includes(ext));
return extensions.length === knownExtensions.length ? 'all' : extensions;
}
export function extensionFilterToUrl(filter: ExtensionFilter, url: string): string {
const parsed = new URL(url);
parsed.searchParams.delete(urlParamFileFilters);
if (filter !== 'all') {
if (!filter.length) parsed.searchParams.append(urlParamFileFilters, '');
for (const ext of filter) parsed.searchParams.append(urlParamFileFilters, ext || urlValueNoExtension);
}
return parsed.href;
}
function getFileExtension(filename: string): string {
const ext = extname(filename).toLowerCase();
if (ext) return ext;
return basename(filename).startsWith('.') ? extDotfile : '';
}
function extensionRank(ext: string): number {
if (!ext) return 2;
return ext === extDotfile ? 1 : 0;
}
export function getDiffTreeExtensionStats(store: Reactive<DiffFileTree>): DiffExtensionStats[] {
const extensionMap = new Map<string, number>();
for (const entry of Object.values(store.fullNameMap)) {
if (entry.EntryMode === 'tree' || !entry.FullName) continue;
const ext = getFileExtension(entry.FullName);
extensionMap.set(ext, (extensionMap.get(ext) ?? 0) + 1);
}
return Array.from(extensionMap, ([ext, count]) => ({ext, count}))
.sort((a, b) => extensionRank(a.ext) - extensionRank(b.ext) || a.ext.localeCompare(b.ext));
}
function buildFilter(store: Reactive<DiffFileTree>) {
const query = store.filenameFilterQuery.trim().toLowerCase();
const exts = store.activeExtensions === 'all' ? null : new Set(store.activeExtensions);
if (!query && !exts) return null;
return (newName: string, oldName: string) => {
if (query && !newName.toLowerCase().includes(query) && !oldName.toLowerCase().includes(query)) return false;
return !exts || exts.has(getFileExtension(newName));
};
}
// Children===null marks a file leaf; everything else (incl. the root, which has EntryMode="") is recursed into.
export function filterDiffTree(store: Reactive<DiffFileTree>): DiffTreeEntry | null {
const matches = buildFilter(store);
if (!matches) return store.diffFileTree.TreeRoot;
const visit = (entry: DiffTreeEntry): DiffTreeEntry | null => {
if (entry.Children === null) return matches(entry.FullName, entry.OldFullName) ? entry : null;
const children = entry.Children.map(visit).filter((child): child is DiffTreeEntry => child !== null);
if (!children.length) return null;
return {...entry, Children: children};
};
return visit(store.diffFileTree.TreeRoot);
}
export function countMatchingFiles(store: Reactive<DiffFileTree>): number {
const matches = buildFilter(store);
let totalMatchingFilesCount = 0;
for (const entry of Object.values(store.fullNameMap)) {
if (entry.EntryMode === 'tree' || !entry.FullName) continue;
if (!matches || matches(entry.FullName, entry.OldFullName)) totalMatchingFilesCount++;
}
return totalMatchingFilesCount;
}
function countEveryFileInDiff(store: Reactive<DiffFileTree>): number {
let totalFilesCount = 0;
for (const entry of Object.values(store.fullNameMap)) {
if (entry.EntryMode !== 'tree' && entry.FullName) totalFilesCount++;
}
return totalFilesCount;
}
function updateLoadProgress(loadedFiles: number, totalFiles: number) {
const el = document.querySelector('#diff-load-progress');
if (!el) return;
el.textContent = trString(el.getAttribute('data-text-too-many-files')!, loadedFiles, totalFiles);
}
function updateShowMoreButton(matchingBelow: number) {
const btn = document.querySelector('#diff-show-more-files');
if (!btn) return;
if (matchingBelow > 0) {
btn.textContent = trString(btn.getAttribute('data-text-matching')!, matchingBelow);
} else {
btn.textContent = btn.getAttribute('data-text-default')!;
}
}
export function applyFiltersToFileBoxes(store: Reactive<DiffFileTree>) {
const boxes = document.querySelectorAll<HTMLElement>('#diff-file-boxes .diff-file-box[data-new-filename]');
const matches = buildFilter(store);
if (!matches) {
for (const box of boxes) toggleElem(box, true);
toggleElem('#diff-no-matches', false);
updateShowMoreButton(0);
updateLoadProgress(boxes.length, countEveryFileInDiff(store));
return;
}
let visibleCount = 0;
for (const box of boxes) {
const matched = matches(box.getAttribute('data-new-filename')!, box.getAttribute('data-old-filename')!);
if (matched) visibleCount++;
toggleElem(box, matched);
}
const matchingCount = countMatchingFiles(store);
updateShowMoreButton(matchingCount - visibleCount);
updateLoadProgress(boxes.length, countEveryFileInDiff(store));
toggleElem('#diff-no-matches', matchingCount === 0);
}
function isEntryViewed(entry: DiffTreeEntry): boolean {
if (entry.Children) {
let count = 0;
for (const child of entry.Children) {
if (child.IsViewed) count++;
}
return count === entry.Children.length;
return entry.Children.every((child) => child.IsViewed);
}
return entry.IsViewed;
}