mirror of
https://github.com/go-gitea/gitea.git
synced 2026-08-26 10:13:34 +00: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:
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user