0
0
mirror of https://github.com/go-gitea/gitea.git synced 2026-04-03 21:12:09 +02:00

Merge 2547f59aa99269806d43dd0ca92f97e5985658c6 into 4fa319b9dca46d3d553d4d4e8f74ca0e009693c6

This commit is contained in:
McMichalK 2026-04-03 02:55:17 +02:00 committed by GitHub
commit 76d31250f3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 431 additions and 0 deletions

View File

@ -1780,6 +1780,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,409 @@
<script lang="ts" setup>
import {ref, computed, onMounted, onUnmounted} from 'vue';
import {SvgIcon} from '../svg.ts';
import {generateElemId} from '../utils/dom.ts';
/**
* Represents a file extension entry in the filter dropdown
* @property ext - Extension with dot (e.g., ".ts", ".go") or "(no extension)"
* @property checked - Whether this extension is currently selected for display
* @property count - Total number of diff files with this extension
*/
type Extension = {
ext: string,
checked: boolean,
count: number,
}
const el = document.querySelector<HTMLElement>('#diff-extension-filter')!;
const menuVisible = ref(false);
const extensions = ref<Array<Extension>>([]);
const isFiltering = ref(false);
const appliedExtensions = ref<Array<string> | null>(null);
const searchQuery = ref('');
const mutationObserver = ref<MutationObserver | null>(null);
const uniqueIdMenu = generateElemId('diff-extension-filter-menu-');
const locale = {
filter_by_file_extension: el.getAttribute('data-filter_by_file_extension') ?? 'Filter by extension',
select_all: el.getAttribute('data-select_all') ?? 'Select all',
deselect_all: el.getAttribute('data-deselect_all') ?? 'Deselect all',
apply: el.getAttribute('data-apply') ?? 'Apply',
search: el.getAttribute('data-search') ?? 'Search extensions...',
} as Record<string, string>;
/**
* Filter extensions based on search query
* Matches against extension name (e.g., ".ts", ".go")
*/
const filteredExtensions = computed(() => {
if (!searchQuery.value.trim()) {
return extensions.value;
}
const query = searchQuery.value.toLowerCase();
return extensions.value.filter((ext) => ext.ext.toLowerCase().includes(query));
});
/**
* Extract file extension from filename
* Returns the extension with dot (e.g., ".ts", ".go")
* Returns "(no extension)" for files without extension
*/
function 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
* Checks current visibility state and sets checked state accordingly
* Updates the extensions array sorted by file count (descending)
*/
function scanExtensions() {
const extensionMap = new Map<string, {total: number, visible: number}>();
const fileBoxes = document.querySelectorAll<HTMLElement>('#diff-file-boxes .diff-file-box[data-new-filename]');
let hiddenCount = 0;
fileBoxes.forEach((box) => {
const filename = box.getAttribute('data-new-filename') || '';
const ext = getExtension(filename);
const isHidden = box.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;
} else {
hiddenCount += 1;
}
});
extensions.value = Array.from(extensionMap.entries())
.map(([ext, stats]) => ({
ext,
checked: appliedExtensions.value ? appliedExtensions.value.includes(ext) : stats.visible > 0,
count: stats.total,
}))
.sort((a, b) => b.count - a.count);
isFiltering.value = hiddenCount > 0;
}
/**
* Apply filter to all diff file boxes by adding/removing tw-hidden class
* Updates isFiltering state and persists applied extensions for load-more sync
* @param checkedExtensions Set of extensions that should be visible
*/
function applyFilterToFileBoxes(checkedExtensions: Set<string>) {
const fileBoxes = document.querySelectorAll<HTMLElement>('#diff-file-boxes .diff-file-box[data-new-filename]');
let hiddenCount = 0;
fileBoxes.forEach((box) => {
const filename = box.getAttribute('data-new-filename') || '';
const ext = getExtension(filename);
const isChecked = checkedExtensions.has(ext);
if (isChecked) {
box.classList.remove('tw-hidden');
} else {
box.classList.add('tw-hidden');
hiddenCount += 1;
}
});
isFiltering.value = hiddenCount > 0;
appliedExtensions.value = hiddenCount > 0 ? Array.from(checkedExtensions) : null;
}
/**
* Focus a menu item element, updating tabIndex for keyboard navigation
* Focuses the first input or button within the element if available
* @param elem Element to focus
* @param prevElem Previous focused element to remove from tab order
*/
function focusElem(elem: HTMLElement | null, prevElem: HTMLElement | null) {
if (elem) {
elem.tabIndex = 0;
if (prevElem) prevElem.tabIndex = -1;
// Focus the input/button inside the menuitem if it exists, otherwise focus the item itself
const focusTarget = elem.querySelector('input, button') as HTMLElement || elem;
focusTarget.focus();
}
}
/**
* Toggle dropdown menu visibility
* Rescans extensions when opening, clears search when closing
*/
function toggleMenu() {
menuVisible.value = !menuVisible.value;
if (menuVisible.value) {
searchQuery.value = '';
scanExtensions();
setTimeout(() => {
const searchInput = el.querySelector('.diff-ext-search-input') as HTMLInputElement;
if (searchInput) searchInput.focus();
}, 0);
}
}
/**
* Select all file extensions
*/
function selectAll() {
for (const ext of extensions.value) {
ext.checked = true;
}
}
/**
* Deselect all file extensions
*/
function deselectAll() {
for (const ext of extensions.value) {
ext.checked = false;
}
}
/**
* Apply the current filter selection to diff files and close the dropdown
* Hides/shows diff-file-box elements based on checked extensions
*/
function applyFilter() {
const checkedExtensions = new Set(extensions.value.filter((e) => e.checked).map((e) => e.ext));
applyFilterToFileBoxes(checkedExtensions);
toggleMenu();
}
/**
* Close dropdown when clicking outside the component
* @param event Click event
*/
function onBodyClick(event: MouseEvent) {
if (!el.contains(event.target as Node)) {
if (menuVisible.value) {
toggleMenu();
}
}
}
/**
* Handle keyboard navigation within the dropdown menu
* Arrow Up/Down: navigate through checkboxes and buttons
* Space/Enter: toggle checkboxes or activate buttons
* Escape: close the dropdown
* @param event Keyboard event
*/
function onKeyDown(event: KeyboardEvent) {
if (!menuVisible.value) return;
const currentFocused = document.activeElement as HTMLElement;
if (!el.contains(currentFocused)) return;
const menu = el.querySelector('.menu') as HTMLElement;
const focusableItems = Array.from(menu.querySelectorAll('[role="menuitem"]')) as HTMLElement[];
if (!focusableItems.length) return;
const currentIndex = focusableItems.indexOf(currentFocused.closest('[role="menuitem"]') as HTMLElement);
switch (event.key) {
case 'ArrowDown': {
event.preventDefault();
const nextIndex = currentIndex === -1 ? 0 : Math.min(currentIndex + 1, focusableItems.length - 1);
focusElem(focusableItems[nextIndex], currentIndex >= 0 ? focusableItems[currentIndex] : null);
break;
}
case 'ArrowUp': {
event.preventDefault();
const prevIndex = currentIndex === -1 ? focusableItems.length - 1 : Math.max(currentIndex - 1, 0);
focusElem(focusableItems[prevIndex], currentIndex >= 0 ? focusableItems[currentIndex] : null);
break;
}
case ' ':
case 'Enter': {
event.preventDefault();
const currentElement = document.activeElement as HTMLElement;
// Try to find and toggle a checkbox (currentElement may be the input itself or a parent)
const checkbox = (currentElement?.matches('input[type="checkbox"]')
? currentElement
: currentElement?.querySelector('input[type="checkbox"]')) as HTMLInputElement | null;
if (checkbox) {
checkbox.checked = !checkbox.checked;
checkbox.dispatchEvent(new Event('change', {bubbles: true}));
break;
}
// If focused element is a button, click it
if (currentElement?.tagName === 'BUTTON') {
currentElement.click();
}
break;
}
case 'Escape':
event.preventDefault();
if (currentIndex >= 0) {
focusableItems[currentIndex].tabIndex = -1;
}
toggleMenu();
break;
}
}
onMounted(() => {
document.body.addEventListener('click', onBodyClick, true);
el.addEventListener('keydown', onKeyDown);
// Watch for new files being added (e.g., when "load more" is clicked)
const fileBoxesContainer = document.querySelector('#diff-file-boxes');
if (fileBoxesContainer) {
mutationObserver.value = new MutationObserver(() => {
if (appliedExtensions.value) {
applyFilterToFileBoxes(new Set(appliedExtensions.value));
}
if (menuVisible.value) {
scanExtensions();
}
});
mutationObserver.value.observe(fileBoxesContainer, {childList: true, subtree: false});
}
});
onUnmounted(() => {
document.body.removeEventListener('click', onBodyClick, true);
el.removeEventListener('keydown', onKeyDown);
if (mutationObserver.value) {
mutationObserver.value.disconnect();
}
});
</script>
<template>
<div class="ui scrolling dropdown custom diff-file-extension-filter">
<button
class="ui tiny basic button"
:class="{'diff-ext-filter-btn-active': isFiltering}"
@click="toggleMenu()"
:data-tooltip-content="locale.filter_by_file_extension"
aria-haspopup="true"
:aria-label="locale.filter_by_file_extension"
:aria-controls="uniqueIdMenu"
>
<SvgIcon name="octicon-filter"/>
</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="header">{{ locale.filter_by_file_extension }}</div>
<div class="ui divider tw-mt-2 tw-mb-0"/>
<!-- Search input -->
<div class="ui form tw-mb-2">
<div class="ui input fluid field tw-mb-0">
<input
type="text"
v-model="searchQuery"
class="diff-ext-search-input"
:placeholder="locale.search"
@keydown.escape="toggleMenu()"
>
</div>
</div>
<div class="ui divider tw-mt-2 tw-mb-0"/>
<div class="ui form">
<!-- Extension checkboxes -->
<div class="grouped fields">
<div v-if="filteredExtensions.length > 0">
<template v-for="ext in filteredExtensions" :key="ext.ext">
<div class="field" role="menuitem" tabindex="-1">
<div class="ui checkbox">
<input
type="checkbox"
:id="`ext-filter-${ext.ext}`"
v-model="ext.checked"
>
<label :for="`ext-filter-${ext.ext}`" class="tw-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 v-if="filteredExtensions.length === 0" class="tw-py-4 tw-text-center tw-text-text-light-2">
{{ locale.no_results ?? 'No extensions found' }}
</div>
</div>
</div>
<!-- Select all / Deselect all buttons -->
<div class="ui divider tw-my-2"/>
<div class="tw-flex tw-items-center tw-justify-center tw-gap-4 tw-px-2 tw-py-1">
<button type="button" class="diff-ext-text-btn" tabindex="-1" role="menuitem" @click="selectAll()">{{ locale.select_all }}</button>
<button type="button" class="diff-ext-text-btn" tabindex="-1" role="menuitem" @click="deselectAll()">{{ locale.deselect_all }}</button>
</div>
<!-- Apply button -->
<div class="ui divider tw-my-2"/>
<button type="button" class="ui button fluid" tabindex="-1" role="menuitem" @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;
padding-top: 0.5rem;
}
.ui.dropdown.diff-file-extension-filter .menu > .header {
margin-top: 0;
padding-top: 0;
}
.ui.dropdown.diff-file-extension-filter .menu .ui.form {
margin: 0;
}
.ui.dropdown.diff-file-extension-filter .grouped.fields > div > .field {
margin-bottom: 0.5rem;
}
.ui.dropdown.diff-file-extension-filter .grouped.fields > div > .field:last-child {
margin-bottom: 0;
}
.ui.dropdown.diff-file-extension-filter .diff-ext-filter-btn-active {
outline: 1px solid var(--color-primary);
outline-offset: -1px;
}
.ui.dropdown.diff-file-extension-filter .diff-ext-text-btn {
background: none;
border: none;
padding: 0;
color: var(--color-primary);
cursor: pointer;
font-size: inherit;
text-align: center;
}
.ui.dropdown.diff-file-extension-filter .diff-ext-text-btn:hover {
text-decoration: underline;
}
.ui.dropdown.diff-file-extension-filter .diff-ext-search-input {
width: 100%;
}
.ui.dropdown.diff-file-extension-filter .ui.input {
margin-bottom: 0.5rem;
}
</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();