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
+45 -2
View File
@@ -7,8 +7,11 @@ import {stripTags} from '../utils.ts';
type TippyOpts = {
role?: string,
theme?: 'default' | 'tooltip' | 'menu' | 'box-with-header' | 'bare',
limitSizeToViewport?: {horizontal?: boolean, vertical?: boolean}, // cap to the viewport and scroll the content
} & Partial<Props>;
type PopperModifier = NonNullable<NonNullable<Props['popperOptions']>['modifiers']>[number];
const visibleInstances = new Set<Instance>();
const arrowSvg = html`<svg width="16" height="7"><path d="m0 7 8-7 8 7Z" class="tippy-svg-arrow-outer"/><path d="m0 8 8-7 8 7Z" class="tippy-svg-arrow-inner"/></svg>`;
@@ -20,10 +23,46 @@ function arrowPadding({placement, reference}: {placement: Placement, reference:
return Math.max(0, Math.min(3, referenceLength / 2 - 8)); // 8 = half of arrow width
}
const viewportPadding = 8;
// space left on the side the popup opens towards, the viewport on the other axis
export function availableSizeForPlacement(referenceRect: DOMRect, placement: string, offset: number): {width: number, height: number} {
const gap = Math.abs(offset) + viewportPadding;
const spanWidth = window.innerWidth - viewportPadding * 2;
const spanHeight = window.innerHeight - viewportPadding * 2;
const side = placement.split('-')[0];
if (side === 'top') return {width: spanWidth, height: referenceRect.top - gap};
if (side === 'bottom') return {width: spanWidth, height: window.innerHeight - referenceRect.bottom - gap};
if (side === 'left') return {width: referenceRect.left - gap, height: spanHeight};
return {width: window.innerWidth - referenceRect.right - gap, height: spanHeight};
}
// popper does not constrain size, publish it for the styles. Replaced by floating-ui's "size" on migration
function sizeModifier(limit: {horizontal?: boolean, vertical?: boolean}): PopperModifier {
return {
name: 'tippyLimitSize',
enabled: true,
phase: 'beforeWrite',
requires: ['computeStyles'], // runs after "flip" and "offset", so both are final
fn({state}) {
const offset = state.modifiersData.offset?.[state.placement];
const isVertical = state.placement.startsWith('top') || state.placement.startsWith('bottom');
const available = availableSizeForPlacement(
state.elements.reference.getBoundingClientRect(),
state.placement,
(isVertical ? offset?.y : offset?.x) ?? 0,
);
const {style} = state.elements.popper;
if (limit.horizontal) style.setProperty('--tippy-max-width', `${Math.max(0, Math.floor(available.width))}px`);
if (limit.vertical) style.setProperty('--tippy-max-height', `${Math.max(0, Math.floor(available.height))}px`);
},
};
}
export function createTippy(target: Element, opts: TippyOpts = {}): Instance {
// the callback functions should be destructured from opts,
// because we should use our own wrapper functions to handle them, do not let the user override them
const {onHide, onShow, onDestroy, role, theme, arrow, ...other} = opts;
const {onHide, onShow, onDestroy, role, theme, arrow, limitSizeToViewport, ...other} = opts;
// CSS theme, either "default", "tooltip", "menu", "box-with-header" or "bare"
const resolvedTheme = theme || role || 'default';
const resolvedArrow = arrow ?? (resolvedTheme === 'bare' ? false : arrowSvg);
@@ -56,7 +95,10 @@ export function createTippy(target: Element, opts: TippyOpts = {}): Instance {
return onShow?.(instance);
},
arrow: resolvedArrow,
popperOptions: {modifiers: [{name: 'arrow', options: {padding: arrowPadding}}]},
popperOptions: {modifiers: [
{name: 'arrow', options: {padding: arrowPadding}},
...limitSizeToViewport ? [sizeModifier(limitSizeToViewport)] : [],
]},
// HTML role attribute, ideally the default role would be "popover" but it does not exist
role: role || 'menu',
theme: resolvedTheme,
@@ -68,6 +110,7 @@ export function createTippy(target: Element, opts: TippyOpts = {}): Instance {
if (instance.props.role === 'menu') {
target.setAttribute('aria-haspopup', 'true');
}
if (limitSizeToViewport) instance.popper.setAttribute('data-tippy-limit-size', '');
return instance;
}