mirror of
https://github.com/go-gitea/gitea.git
synced 2026-08-26 21:39:42 +09:00
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:
@@ -33,7 +33,7 @@ const uniqueIdShowAll = generateElemId('diff-commit-selector-show-all-');
|
||||
|
||||
const menuVisible = shallowRef(false);
|
||||
const isLoading = shallowRef(false);
|
||||
const locale = shallowRef<Record<string, string>>({filter_changes_by_commit: elMount.getAttribute('data-filter_changes_by_commit')!});
|
||||
const locale = shallowRef<Record<string, string>>({filter_changes_by_commit: elMount.getAttribute('data-text-filter-changes-by-commit')!});
|
||||
const commits = ref<Array<Commit>>([]); // deep, the commit objects are mutated in place
|
||||
const hoverActivated = shallowRef(false);
|
||||
const lastReviewCommitSha = shallowRef<string | null>(null);
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
<script lang="ts" setup>
|
||||
import {computed, onMounted, onUnmounted, useTemplateRef} from 'vue';
|
||||
import type {Instance} from 'tippy.js';
|
||||
import SvgIcon from './SvgIcon.vue';
|
||||
import type {SvgName} from '../svg.ts';
|
||||
import {createTippy} from '../modules/tippy.ts';
|
||||
import {diffTreeStore, extDotfile, getDiffTreeExtensionStats, type DiffExtensionFilterLocale} from '../modules/diff-file.ts';
|
||||
|
||||
const props = defineProps<{locale: DiffExtensionFilterLocale}>();
|
||||
|
||||
const store = diffTreeStore();
|
||||
const triggerEl = useTemplateRef<HTMLButtonElement>('triggerEl');
|
||||
const panelEl = useTemplateRef<HTMLDivElement>('panelEl');
|
||||
let tippyInstance: Instance;
|
||||
|
||||
const allExtensions = computed(() => getDiffTreeExtensionStats(store));
|
||||
const isFiltering = computed(() => store.activeExtensions !== 'all' || Boolean(store.filenameFilterQuery));
|
||||
const allIcon = computed<SvgName | null>(() => {
|
||||
if (store.activeExtensions === 'all') return 'octicon-check';
|
||||
return store.activeExtensions.length ? 'octicon-dash' : null;
|
||||
});
|
||||
|
||||
function isChecked(ext: string): boolean {
|
||||
return store.activeExtensions === 'all' || store.activeExtensions.includes(ext);
|
||||
}
|
||||
|
||||
function extLabel(ext: string): string {
|
||||
if (ext === extDotfile) return props.locale.dotfileExtension;
|
||||
return ext || props.locale.noFileExtension;
|
||||
}
|
||||
|
||||
function toggleExt(ext: string) {
|
||||
const all = allExtensions.value.map((e) => e.ext);
|
||||
const next = new Set(store.activeExtensions === 'all' ? all : store.activeExtensions);
|
||||
if (next.has(ext)) next.delete(ext); else next.add(ext);
|
||||
store.activeExtensions = next.size === all.length ? 'all' : Array.from(next);
|
||||
}
|
||||
|
||||
function toggleAll() {
|
||||
store.activeExtensions = store.activeExtensions === 'all' ? [] : 'all';
|
||||
}
|
||||
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') tippyInstance.hide();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
tippyInstance = createTippy(triggerEl.value!, {
|
||||
content: panelEl.value!,
|
||||
trigger: 'click',
|
||||
interactive: true,
|
||||
hideOnClick: true,
|
||||
placement: 'bottom-end',
|
||||
theme: 'menu',
|
||||
arrow: false,
|
||||
limitSizeToViewport: {vertical: true},
|
||||
onShow: () => document.addEventListener('keydown', onKeyDown),
|
||||
onHide: () => document.removeEventListener('keydown', onKeyDown),
|
||||
});
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
tippyInstance.destroy();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
ref="triggerEl"
|
||||
type="button"
|
||||
class="diff-ext-filter-trigger"
|
||||
:class="{'indicator-dot': isFiltering}"
|
||||
:aria-label="props.locale.filterByFileExtension"
|
||||
>
|
||||
<SvgIcon name="octicon-filter"/>
|
||||
</button>
|
||||
<div ref="panelEl" class="tippy-target">
|
||||
<div class="diff-ext-filter-menu" role="menu" :aria-label="props.locale.fileExtensions">
|
||||
<div class="diff-ext-filter-header">{{ props.locale.fileExtensions }}</div>
|
||||
<div class="diff-ext-filter-list">
|
||||
<button
|
||||
v-for="ext in allExtensions" :key="ext.ext"
|
||||
type="button" class="item" role="menuitemcheckbox"
|
||||
:aria-checked="isChecked(ext.ext)" @click="toggleExt(ext.ext)"
|
||||
>
|
||||
<span class="diff-ext-filter-check">
|
||||
<SvgIcon v-if="isChecked(ext.ext)" name="octicon-check"/>
|
||||
</span>
|
||||
<span class="gt-ellipsis">{{ extLabel(ext.ext) }}</span>
|
||||
<span class="diff-ext-filter-count">{{ ext.count }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="divider"/>
|
||||
<button
|
||||
type="button" class="item" role="menuitemcheckbox"
|
||||
:aria-checked="store.activeExtensions === 'all'" @click="toggleAll"
|
||||
>
|
||||
<span class="diff-ext-filter-check">
|
||||
<SvgIcon v-if="allIcon" :name="allIcon"/>
|
||||
</span>
|
||||
<span class="gt-ellipsis">{{ props.locale.allFileExtensions }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.diff-ext-filter-menu {
|
||||
min-width: 192px;
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
.diff-ext-filter-header {
|
||||
padding: 6px 16px;
|
||||
color: var(--color-text-light-2);
|
||||
font-size: 12px;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.diff-ext-filter-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.diff-ext-filter-menu .item {
|
||||
width: auto; /* buttons are shrink-to-fit, the flex column parent stretches them */
|
||||
margin: 0 4px; /* matches the menu's vertical padding so the inset is even on all sides */
|
||||
padding: 6px 12px;
|
||||
gap: 8px;
|
||||
border: none;
|
||||
border-radius: var(--border-radius-medium);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.diff-ext-filter-check {
|
||||
display: flex;
|
||||
flex: 0 0 16px;
|
||||
color: var(--color-text-light-2);
|
||||
}
|
||||
|
||||
.diff-ext-filter-count {
|
||||
margin-left: auto;
|
||||
padding: 2px 6px;
|
||||
border-radius: var(--border-radius-full);
|
||||
background: var(--color-label-bg);
|
||||
font-size: 12px;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
line-height: 12px;
|
||||
}
|
||||
|
||||
.diff-ext-filter-trigger {
|
||||
height: 32px;
|
||||
width: 32px;
|
||||
padding: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid var(--color-secondary);
|
||||
border-radius: var(--border-radius-medium);
|
||||
background: var(--color-button);
|
||||
color: var(--color-text-light-2);
|
||||
}
|
||||
|
||||
.diff-ext-filter-trigger:hover {
|
||||
background: var(--color-hover);
|
||||
}
|
||||
</style>
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import DiffFileTreeItem from './DiffFileTreeItem.vue';
|
||||
import {createApp, h} from 'vue';
|
||||
import type {DiffStatus, DiffTreeEntry} from '../modules/diff-file.ts';
|
||||
|
||||
function renderItem(diffStatus: string): string {
|
||||
const item: DiffTreeEntry = {
|
||||
FullName: 'a.txt', OldFullName: '', DisplayName: 'a.txt', NameHash: 'hash',
|
||||
DiffStatus: diffStatus as DiffStatus, EntryMode: '', IsViewed: false, Children: null, FileIcon: '',
|
||||
};
|
||||
const root = document.createElement('div');
|
||||
createApp({render: () => h(DiffFileTreeItem, {item})}).mount(root);
|
||||
return root.innerHTML;
|
||||
}
|
||||
|
||||
test('DiffFileTreeItem diff status icon', () => {
|
||||
window.config.pageData.DiffFileTree = {TreeRoot: {
|
||||
FullName: '', OldFullName: '', DisplayName: '', NameHash: 'root',
|
||||
DiffStatus: '', EntryMode: 'tree', IsViewed: false, Children: [], FileIcon: '',
|
||||
}};
|
||||
expect(renderItem('typechanged')).toContain('octicon-diff-modified');
|
||||
// a status the frontend does not know must fall back instead of failing to render
|
||||
expect(renderItem('something-new')).toContain('octicon-blocked');
|
||||
});
|
||||
@@ -11,18 +11,17 @@ const props = defineProps<{
|
||||
const store = diffTreeStore();
|
||||
const collapsed = shallowRef(props.item.IsViewed);
|
||||
|
||||
function getIconForDiffStatus(pType: DiffStatus) {
|
||||
const diffTypes: Record<DiffStatus, { name: SvgName, classes: Array<string> }> = {
|
||||
'': {name: 'octicon-blocked', classes: ['tw-text-red']}, // unknown case
|
||||
'added': {name: 'octicon-diff-added', classes: ['tw-text-green']},
|
||||
'modified': {name: 'octicon-diff-modified', classes: ['tw-text-yellow']},
|
||||
'deleted': {name: 'octicon-diff-removed', classes: ['tw-text-red']},
|
||||
'renamed': {name: 'octicon-diff-renamed', classes: ['tw-text-teal']},
|
||||
'copied': {name: 'octicon-diff-renamed', classes: ['tw-text-green']},
|
||||
'typechange': {name: 'octicon-diff-modified', classes: ['tw-text-green']}, // there is no octicon for copied, so renamed should be ok
|
||||
};
|
||||
return diffTypes[pType] ?? diffTypes[''];
|
||||
}
|
||||
const diffStatusIcons: Record<DiffStatus, {name: SvgName, class: string}> = {
|
||||
'': {name: 'octicon-blocked', class: 'tw-text-red'},
|
||||
'added': {name: 'octicon-diff-added', class: 'tw-text-green'},
|
||||
'modified': {name: 'octicon-diff-modified', class: 'tw-text-yellow'},
|
||||
'deleted': {name: 'octicon-diff-removed', class: 'tw-text-red'},
|
||||
'renamed': {name: 'octicon-diff-renamed', class: 'tw-text-teal'},
|
||||
'copied': {name: 'octicon-diff-renamed', class: 'tw-text-green'}, // there is no octicon for copied, so renamed should be ok
|
||||
'typechanged': {name: 'octicon-diff-modified', class: 'tw-text-green'},
|
||||
'unmerged': {name: 'octicon-blocked', class: 'tw-text-red'},
|
||||
'unknown': {name: 'octicon-blocked', class: 'tw-text-red'},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -36,7 +35,7 @@ function getIconForDiffStatus(pType: DiffStatus) {
|
||||
</div>
|
||||
|
||||
<div v-show="!collapsed" class="sub-items">
|
||||
<DiffFileTreeItem v-for="childItem in item.Children" :key="childItem.DisplayName" :item="childItem"/>
|
||||
<DiffFileTreeItem v-for="childItem in item.Children!" :key="childItem.DisplayName" :item="childItem"/>
|
||||
</div>
|
||||
</template>
|
||||
<a
|
||||
@@ -48,10 +47,7 @@ function getIconForDiffStatus(pType: DiffStatus) {
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<span class="tw-contents" v-html="item.FileIcon"/>
|
||||
<span class="gt-ellipsis tw-flex-1">{{ item.DisplayName }}</span>
|
||||
<SvgIcon
|
||||
:name="getIconForDiffStatus(item.DiffStatus).name"
|
||||
:class="getIconForDiffStatus(item.DiffStatus).classes"
|
||||
/>
|
||||
<SvgIcon v-bind="diffStatusIcons[item.DiffStatus] ?? diffStatusIcons['']"/>
|
||||
</a>
|
||||
</template>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user