0
0
mirror of https://github.com/go-gitea/gitea.git synced 2026-05-16 21:17:26 +02:00

Implemented filtering pull request diff files by file extension

There is new button in pull request diff template, which opens dropbox containing list of all unique extensions that the diff contains.
List is created with checkmarks that can be checked/unchecked to filter out files with specific extension. There is also "select all" and "deselect all" buttons.
To apply the change and filter out the files user have to click on "Apply" button. If any extensions were filtered out, then button which opens the dropbox is outlined to signal some files were filtered out.
This commit is contained in:
Michał Krela 2026-03-31 08:30:34 +02:00
parent 2633f9677d
commit 2ad19a714b
5 changed files with 264 additions and 0 deletions

View File

@ -1777,6 +1777,10 @@
"repo.pulls.select_commit_hold_shift_for_range": "Select commit. Hold Shift and click to select a range.",
"repo.pulls.review_only_possible_for_full_diff": "Review is only possible when viewing the full diff",
"repo.pulls.filter_changes_by_commit": "Filter by commit",
"repo.pulls.filter_by_file_extension": "Filter by file extension",
"repo.pulls.select_all_file_extensions": "Select all",
"repo.pulls.deselect_all_file_extensions": "Deselect all",
"repo.pulls.apply_file_extension_filter": "Apply",
"repo.pulls.nothing_to_compare": "These branches are equal. There is no need to create a pull request.",
"repo.pulls.nothing_to_compare_have_tag": "The selected branches/tags are equal.",
"repo.pulls.nothing_to_compare_and_allow_empty_pr": "These branches are equal. This PR will be empty.",

View File

@ -35,6 +35,12 @@
{{template "repo/diff/whitespace_dropdown" .}}
{{template "repo/diff/options_dropdown" .}}
{{if .PageIsPullFiles}}
<div id="diff-extension-filter" data-filter_by_file_extension="{{ctx.Locale.Tr "repo.pulls.filter_by_file_extension"}}" data-select_all="{{ctx.Locale.Tr "repo.pulls.select_all_file_extensions"}}" data-deselect_all="{{ctx.Locale.Tr "repo.pulls.deselect_all_file_extensions"}}" data-apply="{{ctx.Locale.Tr "repo.pulls.apply_file_extension_filter"}}">
{{/* the following will be replaced by vue component, but this avoids any loading artifacts till the vue component is initialized */}}
<div class="ui jump dropdown tiny basic button custom">
{{svg "octicon-filter"}}
</div>
</div>
<div id="diff-commit-select" data-merge-base="{{.MergeBase}}" data-issuelink="{{$.Issue.Link}}" data-queryparams="?style={{if $.IsSplitStyle}}split{{else}}unified{{end}}&whitespace={{$.WhitespaceBehavior}}&show-outdated={{$.ShowOutdatedComments}}" data-filter_changes_by_commit="{{ctx.Locale.Tr "repo.pulls.filter_changes_by_commit"}}">
{{/* the following will be replaced by vue component, but this avoids any loading artifacts till the vue component is initialized */}}
<div class="ui jump dropdown tiny basic button custom">

View File

@ -0,0 +1,242 @@
<script lang="ts">
import {defineComponent} from 'vue';
import {SvgIcon} from '../svg.ts';
import {generateElemId} from '../utils/dom.ts';
type Extension = {
ext: string,
checked: boolean,
count: number,
}
export default defineComponent({
components: {SvgIcon},
data: () => {
const el = document.querySelector('#diff-extension-filter')!;
return {
menuVisible: false,
extensions: [] as Array<Extension>,
isFiltering: false,
locale: {
filter_by_file_extension: el.getAttribute('data-filter_by_file_extension'),
select_all: el.getAttribute('data-select_all'),
deselect_all: el.getAttribute('data-deselect_all'),
apply: el.getAttribute('data-apply'),
} as Record<string, string>,
uniqueIdMenu: generateElemId('diff-extension-filter-menu-'),
};
},
mounted() {
document.body.addEventListener('click', this.onBodyClick);
},
unmounted() {
document.body.removeEventListener('click', this.onBodyClick);
},
methods: {
onBodyClick(event: MouseEvent) {
// close this menu on click outside of this element when the dropdown is currently visible opened
if (this.$el.contains(event.target)) return;
if (this.menuVisible) {
this.toggleMenu();
}
},
/**
* Extract file extension from filename
* Returns the extension with dot (e.g., ".ts", ".go")
* Returns "(no extension)" for files without extension
*/
getExtension(filename: string): string {
const lastDot = filename.lastIndexOf('.');
if (lastDot === -1 || lastDot === 0) {
return '(no extension)';
}
return filename.substring(lastDot);
},
/**
* Scan all diff-file-box elements and build extension list
* Check current visibility state and set checked state accordingly
*/
scanExtensions() {
const extensionMap = new Map<string, {total: number, visible: number}>();
const fileBoxes = document.querySelectorAll('#diff-file-boxes .diff-file-box[data-new-filename]');
// Count extensions and track visibility
fileBoxes.forEach((box) => {
const filename = (box as HTMLElement).getAttribute('data-new-filename') || '';
const ext = this.getExtension(filename);
const isHidden = (box as HTMLElement).classList.contains('tw-hidden');
if (!extensionMap.has(ext)) {
extensionMap.set(ext, {total: 0, visible: 0});
}
const stats = extensionMap.get(ext)!;
stats.total += 1;
if (!isHidden) {
stats.visible += 1;
}
});
// Convert to array and sort by count descending
// checked = true if any files of this extension are visible
this.extensions = Array.from(extensionMap.entries())
.map(([ext, stats]) => ({
ext,
checked: stats.visible > 0,
count: stats.total,
}))
.sort((a, b) => b.count - a.count);
// Update filtering state based on current visibility
let hiddenCount = 0;
fileBoxes.forEach((box) => {
if ((box as HTMLElement).classList.contains('tw-hidden')) {
hiddenCount += 1;
}
});
this.isFiltering = hiddenCount > 0;
},
/**
* Open dropdown, rescan extensions
*/
toggleMenu() {
this.menuVisible = !this.menuVisible;
if (this.menuVisible) {
this.scanExtensions();
}
},
/**
* Select all extensions
*/
selectAll() {
for (const ext of this.extensions) {
ext.checked = true;
}
},
/**
* Deselect all extensions
*/
deselectAll() {
for (const ext of this.extensions) {
ext.checked = false;
}
},
/**
* Apply the filter: hide/show diff-file-box elements based on checked extensions
*/
applyFilter() {
const checkedExtensions = new Set(this.extensions.filter((e) => e.checked).map((e) => e.ext));
const fileBoxes = document.querySelectorAll('#diff-file-boxes .diff-file-box[data-new-filename]');
let hiddenCount = 0;
fileBoxes.forEach((box) => {
const filename = (box as HTMLElement).getAttribute('data-new-filename') || '';
const ext = this.getExtension(filename);
const isChecked = checkedExtensions.has(ext);
if (isChecked) {
(box as HTMLElement).classList.remove('tw-hidden');
} else {
(box as HTMLElement).classList.add('tw-hidden');
hiddenCount += 1;
}
});
// Update filtering state
this.isFiltering = hiddenCount > 0;
// Close the menu after applying
this.menuVisible = false;
},
},
});
</script>
<template>
<div class="ui scrolling dropdown custom diff-file-extension-filter">
<button
ref="expandBtn"
class="ui tiny basic button tw-relative"
:class="{red: isFiltering}"
@click.stop="toggleMenu()"
:data-tooltip-content="locale.filter_by_file_extension"
aria-haspopup="true"
:aria-label="locale.filter_by_file_extension"
:aria-controls="uniqueIdMenu"
>
<svg-icon name="octicon-filter"/>
<span v-if="isFiltering" class="filter-indicator-dot"/>
</button>
<!-- this dropdown is not managed by Fomantic UI, so it needs some classes like "transition" explicitly -->
<div class="left menu transition" :id="uniqueIdMenu" :class="{visible: menuVisible}" v-show="menuVisible" v-cloak :aria-expanded="menuVisible ? 'true': 'false'">
<div class="ui form">
<!-- Extension checkboxes -->
<div class="grouped fields">
<template v-for="ext in extensions" :key="ext.ext">
<div class="field">
<div class="ui checkbox">
<input
type="checkbox"
:id="`ext-filter-${ext.ext}`"
v-model="ext.checked"
/>
<label :for="`ext-filter-${ext.ext}`" style="cursor: pointer">
<span class="tw-font-mono">{{ ext.ext }}</span>
<span class="tw-text-text-light-2">({{ ext.count }})</span>
</label>
</div>
</div>
</template>
</div>
</div>
<!-- Select all / Deselect all buttons -->
<div class="ui divider" style="margin: 0.5rem 0"/>
<div class="tw-flex tw-gap-2 tw-px-2">
<a href="#" class="tw-flex-1" @click.prevent="selectAll()">{{ locale.select_all }}</a>
<a href="#" class="tw-flex-1" @click.prevent="deselectAll()">{{ locale.deselect_all }}</a>
</div>
<!-- Apply button -->
<div class="ui divider" style="margin: 0.5rem 0"/>
<button class="ui button fluid" @click="applyFilter()">
{{ locale.apply }}
</button>
</div>
</div>
</template>
<style scoped>
.ui.dropdown.diff-file-extension-filter .menu {
margin-top: 0.25em;
overflow-x: hidden;
max-height: 450px;
padding: 0.75rem;
}
.ui.dropdown.diff-file-extension-filter .menu .ui.form {
margin: 0;
}
.ui.dropdown.diff-file-extension-filter .grouped.fields > .field {
margin-bottom: 0.5rem;
}
.ui.dropdown.diff-file-extension-filter .grouped.fields > .field:last-child {
margin-bottom: 0;
}
.ui.dropdown.diff-file-extension-filter .button.red {
color: var(--color-red-700);
border-color: var(--color-red-300);
background: var(--color-red-50);
}
.ui.dropdown.diff-file-extension-filter .filter-indicator-dot {
position: absolute;
top: 0.15rem;
right: 0.15rem;
width: 0.5rem;
height: 0.5rem;
border-radius: 9999px;
background: var(--color-red-600);
box-shadow: 0 0 0 2px var(--color-body);
}
</style>

View File

@ -0,0 +1,10 @@
import {createApp} from 'vue';
import DiffFileExtensionFilter from '../components/DiffFileExtensionFilter.vue';
export function initDiffFileExtensionFilter() {
const el = document.querySelector('#diff-extension-filter');
if (!el) return;
const extensionFilter = createApp(DiffFileExtensionFilter);
extensionFilter.mount(el);
}

View File

@ -1,6 +1,7 @@
import {initRepoIssueContentHistory} from './repo-issue-content.ts';
import {initDiffFileTree} from './repo-diff-filetree.ts';
import {initDiffCommitSelect} from './repo-diff-commitselect.ts';
import {initDiffFileExtensionFilter} from './repo-diff-extensionfilter.ts';
import {validateTextareaNonEmpty} from './comp/ComboMarkdownEditor.ts';
import {initViewedCheckboxListenerFor, initExpandAndCollapseFilesButton} from './pull-view-file.ts';
import {initImageDiff} from './imagediff.ts';
@ -281,6 +282,7 @@ export function initRepoDiffView() {
initRepoDiffConversationNav(); // "previous" and "next" buttons only appear on "diff" page
initDiffFileTree();
initDiffCommitSelect();
initDiffFileExtensionFilter();
initRepoDiffShowMore();
initDiffHeaderPopup();
initViewedCheckboxListenerFor();