diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index a0b0b3eb5bc..f5b7ed1268b 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -2597,9 +2597,9 @@ "repo.diff.file_byte_size": "Size", "repo.diff.file_suppressed": "File diff suppressed because it is too large", "repo.diff.file_suppressed_line_too_long": "File diff suppressed because one or more lines are too long", - "repo.diff.too_many_files": "Some files were not shown because too many files have changed in this diff", - "repo.diff.show_more": "Show More", - "repo.diff.load": "Load Diff", + "repo.diff.too_many_files": "Loaded %[1]d of %[2]d files, more files were not shown because too many files have changed in this diff.", + "repo.diff.show_more": "Show more", + "repo.diff.load": "Load diff", "repo.diff.generated": "Generated", "repo.diff.vendored": "Vendored", "repo.diff.comment.add_line_comment": "Add line comment", @@ -2627,6 +2627,15 @@ "repo.diff.has_escaped": "This line has hidden Unicode characters", "repo.diff.show_file_tree": "Show file tree", "repo.diff.hide_file_tree": "Hide file tree", + "repo.diff.filter_files": "Filter files…", + "repo.diff.filter_files_clear": "Clear filter", + "repo.diff.filter_by_file_extension": "Filter by file extension", + "repo.diff.file_extensions": "File extensions", + "repo.diff.no_file_extension": "No extension", + "repo.diff.dotfile_extension": "Dotfiles", + "repo.diff.all_file_extensions": "All extensions", + "repo.diff.no_files_matched": "No files matched your search", + "repo.diff.show_more_matching": "Show more - %[1]d matching below", "repo.diff.submodule_added": "Submodule %[1]s added at %[2]s", "repo.diff.submodule_deleted": "Submodule %[1]s deleted from %[2]s", "repo.diff.submodule_updated": "Submodule %[1]s updated: %[2]s", diff --git a/routers/web/repo/treelist.go b/routers/web/repo/treelist.go index 206660ad5c7..d75d31444a6 100644 --- a/routers/web/repo/treelist.go +++ b/routers/web/repo/treelist.go @@ -63,6 +63,7 @@ func isExcludedEntry(entry *git.TreeEntry) bool { // WebDiffFileItem is used by frontend, check the field names in frontend before changing type WebDiffFileItem struct { FullName string + OldFullName string DisplayName string NameHash string DiffStatus string @@ -110,6 +111,9 @@ func transformDiffTreeForWeb(renderedIconPool *fileicon.RenderedIconPool, diffTr for _, file := range diffTree.Files { item := &WebDiffFileItem{FullName: file.HeadPath, DiffStatus: file.Status} + if file.BasePath != file.HeadPath { + item.OldFullName = file.BasePath + } item.IsViewed = filesViewedState[item.FullName] == pull_model.Viewed item.NameHash = git.HashFilePathForWebUI(item.FullName) item.FileIcon = fileicon.RenderEntryIconHTML(renderedIconPool, &fileicon.EntryInfo{BaseName: path.Base(file.HeadPath), EntryMode: file.HeadMode}) diff --git a/routers/web/repo/treelist_test.go b/routers/web/repo/treelist_test.go index be496933e86..171b32a66a3 100644 --- a/routers/web/repo/treelist_test.go +++ b/routers/web/repo/treelist_test.go @@ -28,6 +28,12 @@ func TestTransformDiffTreeForWeb(t *testing.T) { HeadPath: "file1", HeadMode: git.EntryModeBlob, }, + { + Status: "renamed", + BasePath: "file2-old", + HeadPath: "file2", + HeadMode: git.EntryModeBlob, + }, }}, map[string]pull_model.ViewedState{ "dir-a/dir-a-x/file-deep": pull_model.Viewed, }) @@ -62,6 +68,15 @@ func TestTransformDiffTreeForWeb(t *testing.T) { DiffStatus: "added", FileIcon: mockIconForFile(`svg-mfi-file`), }, + { + EntryMode: "", + DisplayName: "file2", + FullName: "file2", + OldFullName: "file2-old", + NameHash: "cb99b709a1978bd205ab9dfd4c5aaa1fc91c7523", + DiffStatus: "renamed", + FileIcon: mockIconForFile(`svg-mfi-file`), + }, }, }, }, ret) diff --git a/templates/repo/diff/box.tmpl b/templates/repo/diff/box.tmpl index 4ed7fe9ab7e..52204947d7c 100644 --- a/templates/repo/diff/box.tmpl +++ b/templates/repo/diff/box.tmpl @@ -35,7 +35,9 @@ {{template "repo/diff/whitespace_dropdown" .}} {{template "repo/diff/options_dropdown" .}} {{if .PageIsPullFiles}} -
rendered content
'); + await apiCreateFiles(request, owner, repoName, [{path: 'test.external', content: 'rendered content
'}]); 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(); diff --git a/tests/e2e/file-view-render.test.ts b/tests/e2e/file-view-render.test.ts index 6ebb2afceb9..89cca628010 100644 --- a/tests/e2e/file-view-render.test.ts +++ b/tests/e2e/file-view-render.test.ts @@ -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(); diff --git a/tests/e2e/pr-create.test.ts b/tests/e2e/pr-create.test.ts index 592512683fc..662bd42af1c 100644 --- a/tests/e2e/pr-create.test.ts +++ b/tests/e2e/pr-create.test.ts @@ -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 diff --git a/tests/e2e/pr-diff-filter.test.ts b/tests/e2e/pr-diff-filter.test.ts new file mode 100644 index 00000000000..9697ff17e6b --- /dev/null +++ b/tests/e2e/pr-diff-filter.test.ts @@ -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(); +}); diff --git a/tests/e2e/pr-review.test.ts b/tests/e2e/pr-review.test.ts index d1e7aaa4ca0..ea2d7fbaa82 100644 --- a/tests/e2e/pr-review.test.ts +++ b/tests/e2e/pr-review.test.ts @@ -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) diff --git a/tests/e2e/pull-merge-box.test.ts b/tests/e2e/pull-merge-box.test.ts index 3b29e4383e2..974836fe98d 100644 --- a/tests/e2e/pull-merge-box.test.ts +++ b/tests/e2e/pull-merge-box.test.ts @@ -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)]); diff --git a/tests/e2e/utils.ts b/tests/e2e/utils.ts index 8fe335bc85d..e230acc3ce0 100644 --- a/tests/e2e/utils.ts +++ b/tests/e2e/utils.ts @@ -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