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
+3 -3
View File
@@ -1,6 +1,6 @@
import {env} from 'node:process';
import {expect, test} from '@playwright/test';
import {login, apiCreateRepo, apiCreateFile, assertFlushWithParent, assertNoJsError, randomString} from './utils.ts';
import {login, apiCreateRepo, apiCreateFiles, assertFlushWithParent, assertNoJsError, randomString} from './utils.ts';
test('external file', async ({page, request}) => {
const repoName = `e2e-external-render-${randomString(8)}`;
@@ -9,7 +9,7 @@ test('external file', async ({page, request}) => {
apiCreateRepo(request, {name: repoName}),
login(page),
]);
await apiCreateFile(request, owner, repoName, 'test.external', '<p>rendered content</p>');
await apiCreateFiles(request, owner, repoName, [{path: 'test.external', content: '<p>rendered content</p>'}]);
await page.goto(`/${owner}/${repoName}/src/branch/main/test.external`);
const iframe = page.locator('iframe.external-render-iframe');
await expect(iframe).toBeVisible();
@@ -34,7 +34,7 @@ test('openapi file', async ({page, request}) => {
paths: {'/pets': {get: {responses: {'200': {description: 'OK', content: {'application/json': {schema: {$ref: '#/components/schemas/Pet'}}}}}}}},
components: {schemas: {Pet: {type: 'object', properties: {children: {type: 'array', items: {$ref: '#/components/schemas/Pet'}}}}}},
});
await apiCreateFile(request, owner, repoName, 'openapi.json', spec);
await apiCreateFiles(request, owner, repoName, [{path: 'openapi.json', content: spec}]);
await page.goto(`/${owner}/${repoName}/src/branch/main/openapi.json`);
const iframe = page.locator('iframe.external-render-iframe');
await expect(iframe).toBeVisible();
+4 -4
View File
@@ -1,6 +1,6 @@
import {env} from 'node:process';
import {expect, test} from '@playwright/test';
import {apiCreateRepo, apiCreateFile, assertFlushWithParent, assertNoJsError, login, randomString} from './utils.ts';
import {apiCreateRepo, apiCreateFiles, assertFlushWithParent, assertNoJsError, login, randomString} from './utils.ts';
test('3d model file', async ({page, request, browserName}) => {
test.skip(browserName === 'firefox', 'unclear firefox-only CI-only failure'); // eslint-disable-line playwright/no-skipped-test -- conditional skip, the reason is in the message
@@ -8,7 +8,7 @@ test('3d model file', async ({page, request, browserName}) => {
const owner = env.GITEA_TEST_E2E_USER;
await apiCreateRepo(request, {name: repoName});
const stl = 'solid test\nfacet normal 0 0 1\nouter loop\nvertex 0 0 0\nvertex 1 0 0\nvertex 0 1 0\nendloop\nendfacet\nendsolid test\n';
await apiCreateFile(request, owner, repoName, 'test.stl', stl);
await apiCreateFiles(request, owner, repoName, [{path: 'test.stl', content: stl}]);
await page.goto(`/${owner}/${repoName}/src/branch/main/test.stl?display=rendered`);
const iframe = page.locator('iframe.external-render-iframe');
await expect(iframe).toBeVisible();
@@ -31,7 +31,7 @@ test('pdf file', async ({page, request}) => {
const repoName = `e2e-pdf-render-${randomString(8)}`;
const owner = env.GITEA_TEST_E2E_USER;
await apiCreateRepo(request, {name: repoName});
await apiCreateFile(request, owner, repoName, 'test.pdf', '%PDF-1.0\n%%EOF\n');
await apiCreateFiles(request, owner, repoName, [{path: 'test.pdf', content: '%PDF-1.0\n%%EOF\n'}]);
await page.goto(`/${owner}/${repoName}/src/branch/main/test.pdf`);
const container = page.locator('.file-view-render-container');
await expect(container).toHaveAttribute('data-render-name', 'pdf-viewer');
@@ -47,7 +47,7 @@ test('asciicast file', async ({page, request}) => {
await Promise.all([apiCreateRepo(request, {name: repoName, autoInit: false}), login(page)]);
const cast = '{"version": 2, "width": 80, "height": 24}\n[0.0, "o", "test-content"]\n';
// on an empty repo, apiCreateFile with newBranch creates that branch as the initial commit
await apiCreateFile(request, owner, repoName, 'test.cast', cast, {newBranch: branch});
await apiCreateFiles(request, owner, repoName, [{path: 'test.cast', content: cast}], {newBranch: branch});
await page.goto(`/${owner}/${repoName}/src/branch/${branchEnc}/test.cast`);
const iframe = page.locator('iframe.external-render-iframe');
const frame = iframe.contentFrame();
+2 -2
View File
@@ -1,13 +1,13 @@
import {env} from 'node:process';
import {test, expect} from '@playwright/test';
import {login, apiCreateRepo, apiCreateFile, randomString} from './utils.ts';
import {login, apiCreateRepo, apiCreateFiles, randomString} from './utils.ts';
test('create a pull request from the compare page', async ({page, request}) => {
const repoName = `e2e-pr-create-${randomString(8)}`;
const owner = env.GITEA_TEST_E2E_USER;
await apiCreateRepo(request, {name: repoName});
await Promise.all([
apiCreateFile(request, owner, repoName, 'feat.txt', 'feature content\n', {branch: 'main', newBranch: 'feat'}),
apiCreateFiles(request, owner, repoName, [{path: 'feat.txt', content: 'feature content\n'}], {branch: 'main', newBranch: 'feat'}),
login(page),
]);
// expand=1 renders the PR form directly, skipping the "New Pull Request" toggle click
+87
View File
@@ -0,0 +1,87 @@
import {test, expect} from '@playwright/test';
import {apiCreateFiles, apiCreatePR, apiCreateRepo, apiCreateUser, apiUserHeaders, loginUser, randomString} from './utils.ts';
test('diff sidebar filtering', async ({page, request}) => {
const user = `df-${randomString(8)}`;
await apiCreateUser(request, user);
const headers = apiUserHeaders(user);
const repo = `e2e-difffilter-${randomString(8)}`;
await apiCreateRepo(request, {name: repo, headers});
await apiCreateFiles(request, user, repo, [
{path: 'src/a.ts', content: 'a\n'},
{path: 'src/b.ts', content: 'b\n'},
{path: 'src/.eslintrc', content: 'e\n'}, // dotfiles count as "No extension"
{path: 'styles/x.css', content: 'x\n'},
{path: 'docs/intro.md', content: 'r\n'},
{path: 'Makefile', content: 'm\n'},
], {branch: 'main', newBranch: 'feat', headers});
const prIndex = await apiCreatePR(request, user, repo, 'feat', 'main', 'diff filter test', {headers});
await loginUser(page, user);
await page.goto(`/${user}/${repo}/pulls/${prIndex}/files`);
const tree = page.locator('#diff-file-tree');
const items = tree.locator('.item-file');
const search = tree.getByRole('textbox');
const filterTrigger = tree.getByRole('button', {name: 'Filter by file extension'});
// every PR file is listed
await expect(items).toHaveCount(6);
// sidebar leaves the diff column the bulk of the viewport
const boxesWidth = (await page.locator('#diff-file-boxes').boundingBox())!.width;
const treeWidth = (await tree.boundingBox())!.width;
expect(boxesWidth).toBeGreaterThan(treeWidth * 2);
// search filters tree and file boxes
await search.fill('a.ts');
await expect(items).toHaveText([/a\.ts/]);
await expect(page.locator('.diff-file-box[data-new-filename="src/a.ts"]')).toBeVisible();
await tree.getByRole('button', {name: 'Clear filter'}).click();
await expect(items).toHaveCount(6);
// empty-result placeholder
await search.fill('zzz-no-such-file');
await expect(page.locator('#diff-no-matches')).toBeVisible();
await search.fill('');
// extensions sort alphabetically, then dotfiles, then files without extension
await filterTrigger.click();
const extItems = page.getByRole('menuitemcheckbox');
await expect(extItems).toHaveText(['.css1', '.md1', '.ts2', 'Dotfiles1', 'No extension1', 'All extensions']);
// deselecting .ts leaves the other extensions
const allExtensions = page.getByRole('menuitemcheckbox', {name: 'All extensions'});
await page.getByRole('menuitemcheckbox', {name: '.ts'}).click();
await expect(items).toHaveCount(4);
await expect(filterTrigger).toHaveClass(/\bindicator-dot\b/);
await expect(allExtensions).toHaveAttribute('aria-checked', 'false');
// "All extensions" cycles through select all, select none and back
await allExtensions.click();
await expect(allExtensions).toHaveAttribute('aria-checked', 'true');
await expect(items).toHaveCount(6);
await allExtensions.click();
await expect(items).toHaveCount(0);
await allExtensions.click();
await expect(items).toHaveCount(6);
await expect(filterTrigger).not.toHaveClass(/\bindicator-dot\b/);
// the extension filter lives in the URL and survives a reload
await page.getByRole('menuitemcheckbox', {name: '.ts'}).click();
await expect(page).toHaveURL(/file-filters/);
await page.reload();
await expect(items).toHaveCount(4);
await expect(filterTrigger).toHaveClass(/\bindicator-dot\b/);
// hiding the file tree drops the filter
await expect(page.locator('.diff-file-box[data-new-filename="src/a.ts"]')).toBeHidden();
await filterTrigger.click();
await page.locator('.diff-toggle-file-tree-button').click();
await expect(tree).toBeHidden();
await expect(page.locator('.diff-file-box[data-new-filename="src/a.ts"]')).toBeVisible();
});
+2 -2
View File
@@ -1,5 +1,5 @@
import {test, expect} from '@playwright/test';
import {apiCreateFile, apiCreatePR, apiCreateRepo, apiCreateReview, apiCreateUser, apiUserHeaders, loginUser, randomString} from './utils.ts';
import {apiCreateFiles, apiCreatePR, apiCreateRepo, apiCreateReview, apiCreateUser, apiUserHeaders, loginUser, randomString} from './utils.ts';
test('pr review flow', async ({page, request}) => {
const poster = `rv-poster-${randomString(8)}`;
@@ -8,7 +8,7 @@ test('pr review flow', async ({page, request}) => {
const posterHeaders = apiUserHeaders(poster);
const repoName = `e2e-prreview-${randomString(8)}`;
await apiCreateRepo(request, {name: repoName, headers: posterHeaders});
await apiCreateFile(request, poster, repoName, 'added.txt', 'new content\n', {branch: 'main', newBranch: 'feat'});
await apiCreateFiles(request, poster, repoName, [{path: 'added.txt', content: 'new content\n'}], {branch: 'main', newBranch: 'feat', headers: posterHeaders});
const prIndex = await apiCreatePR(request, poster, repoName, 'feat', 'main', 'review test', {headers: posterHeaders});
// reviewer seeds an inline comment via API so the poster's UI reply exercises the reply-to-review path (#35994)
+2 -2
View File
@@ -1,6 +1,6 @@
import {env} from 'node:process';
import {test, expect} from '@playwright/test';
import {apiCreateFile, apiCreatePR, apiCreateRepo, assertNoJsError, login, randomString} from './utils.ts';
import {apiCreateFiles, apiCreatePR, apiCreateRepo, assertNoJsError, login, randomString} from './utils.ts';
const owner = env.GITEA_TEST_E2E_USER;
@@ -8,7 +8,7 @@ test('merge box merges a pull request', async ({page, request}) => {
const repo = `e2e-merge-box-${randomString(8)}`;
const createPR = (async () => {
await apiCreateRepo(request, {name: repo});
await apiCreateFile(request, owner, repo, 'feat.txt', 'feature\n', {branch: 'main', newBranch: 'feat'});
await apiCreateFiles(request, owner, repo, [{path: 'feat.txt', content: 'feature\n'}], {branch: 'main', newBranch: 'feat'});
return apiCreatePR(request, owner, repo, 'feat', 'main', 'merge box test');
})();
const [index] = await Promise.all([createPR, login(page)]);
+11 -16
View File
@@ -12,8 +12,6 @@ export function randomString(length: number): string {
return result;
}
export const timeoutFactor = Number(env.GITEA_TEST_E2E_TIMEOUT_FACTOR) || 1;
export function baseUrl() {
return env.GITEA_TEST_E2E_URL?.replace(/\/$/g, '');
}
@@ -67,6 +65,17 @@ export async function apiStartStopwatch(requestContext: APIRequestContext, owner
}), 'apiStartStopwatch');
}
/** Commit one or more files in a single API call. */
export async function apiCreateFiles(requestContext: APIRequestContext, owner: string, repo: string, files: Array<{path: string; content: string}>, {branch, newBranch, headers}: {branch?: string; newBranch?: string; headers?: Record<string, string>} = {}) {
await apiRetry(() => requestContext.post(`${baseUrl()}/api/v1/repos/${owner}/${repo}/contents`, {
headers: headers || apiHeaders(),
data: {
branch, new_branch: newBranch,
files: files.map((file) => ({operation: 'create', path: file.path, content: Buffer.from(file.content, 'utf8').toString('base64')})),
},
}), 'apiCreateFiles');
}
export async function apiCancelStopwatch(requestContext: APIRequestContext, owner: string, repo: string, issueIndex: number, {headers}: {headers?: Record<string, string>} = {}) {
await apiRetry(() => requestContext.delete(`${baseUrl()}/api/v1/repos/${owner}/${repo}/issues/${issueIndex}/stopwatch/delete`, {
headers: headers || apiHeaders(),
@@ -80,20 +89,6 @@ export async function apiCloseIssue(requestContext: APIRequestContext, owner: st
}), 'apiCloseIssue');
}
export async function apiCreateFile(requestContext: APIRequestContext, owner: string, repo: string, filepath: string, content: string, {branch, newBranch, message}: {branch?: string; newBranch?: string; message?: string} = {}) {
await apiRetry(() => requestContext.post(`${baseUrl()}/api/v1/repos/${owner}/${repo}/contents/${filepath}`, {
headers: apiHeaders(),
data: {content: Buffer.from(content, 'utf8').toString('base64'), branch, new_branch: newBranch, message},
}), 'apiCreateFile');
}
export async function apiCreateBranch(requestContext: APIRequestContext, owner: string, repo: string, newBranch: string) {
await apiRetry(() => requestContext.post(`${baseUrl()}/api/v1/repos/${owner}/${repo}/branches`, {
headers: apiHeaders(),
data: {new_branch_name: newBranch},
}), 'apiCreateBranch');
}
/** Create a PR via API. Returns the PR index for subsequent operations. */
export async function apiCreatePR(requestContext: APIRequestContext, owner: string, repo: string, head: string, base: string, title: string, {headers}: {headers?: Record<string, string>} = {}): Promise<number> {
let prIndex = 0;