mirror of
https://github.com/go-gitea/gitea.git
synced 2026-08-24 12:29:42 +09: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:
@@ -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",
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -35,7 +35,9 @@
|
||||
{{template "repo/diff/whitespace_dropdown" .}}
|
||||
{{template "repo/diff/options_dropdown" .}}
|
||||
{{if .PageIsPullFiles}}
|
||||
<div id="diff-commit-select" data-merge-base="{{$.CompareInfo.CompareBase}}" 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"}}">
|
||||
<div id="diff-commit-select" data-merge-base="{{$.CompareInfo.CompareBase}}" data-issuelink="{{$.Issue.Link}}"
|
||||
data-queryparams="?style={{if $.IsSplitStyle}}split{{else}}unified{{end}}&whitespace={{$.WhitespaceBehavior}}&show-outdated={{$.ShowOutdatedComments}}"
|
||||
data-text-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">
|
||||
{{svg "octicon-git-commit"}}
|
||||
@@ -61,7 +63,15 @@
|
||||
<div id="diff-container">
|
||||
{{if $showFileTree}}
|
||||
{{$.FileIconPoolHTML}}
|
||||
<div id="diff-file-tree" class="tw-hidden not-mobile"></div>
|
||||
<div id="diff-file-tree" class="tw-hidden not-mobile" data-locale="{{JsonUtils.EncodeToString (dict
|
||||
"filterFiles" (ctx.Locale.Tr "repo.diff.filter_files")
|
||||
"filterFilesClear" (ctx.Locale.Tr "repo.diff.filter_files_clear")
|
||||
"filterByFileExtension" (ctx.Locale.Tr "repo.diff.filter_by_file_extension")
|
||||
"fileExtensions" (ctx.Locale.Tr "repo.diff.file_extensions")
|
||||
"noFileExtension" (ctx.Locale.Tr "repo.diff.no_file_extension")
|
||||
"dotfileExtension" (ctx.Locale.Tr "repo.diff.dotfile_extension")
|
||||
"allFileExtensions" (ctx.Locale.Tr "repo.diff.all_file_extensions")
|
||||
)}}"></div>
|
||||
<script nonce="{{ctx.CspScriptNonce}}">
|
||||
if (diffTreeVisible) document.getElementById('diff-file-tree').classList.remove('tw-hidden');
|
||||
</script>
|
||||
@@ -69,7 +79,13 @@
|
||||
{{if .DiffNotAvailable}}
|
||||
<h4>{{ctx.Locale.Tr "repo.diff.data_not_available"}}</h4>
|
||||
{{else}}
|
||||
<div id="diff-file-boxes" class="sixteen wide column">
|
||||
<div id="diff-boxes-column">
|
||||
{{if $showFileTree}}
|
||||
<div id="diff-no-matches" class="empty-placeholder tw-hidden">
|
||||
<h2>{{ctx.Locale.Tr "repo.diff.no_files_matched"}}</h2>
|
||||
</div>
|
||||
{{end}}
|
||||
<div id="diff-file-boxes">
|
||||
{{range $i, $file := .Diff.Files}}
|
||||
{{/*notice: the index of Diff.Files should not be used for element ID, because the index will be restarted from 0 when doing load-more for PRs with a lot of files*/}}
|
||||
{{$isImage:= $file.IsBlobTypeImage}}
|
||||
@@ -206,13 +222,17 @@
|
||||
{{end}}
|
||||
|
||||
{{if .Diff.IsIncomplete}}
|
||||
<div class="diff-file-box file-content tw-mt-2" id="diff-incomplete">
|
||||
<h4 class="ui top attached header tw-font-normal flex-left-right">
|
||||
{{ctx.Locale.Tr "repo.diff.too_many_files"}}
|
||||
<a class="ui basic tiny button" id="diff-show-more-files" data-global-click="diffLoadMoreFiles" data-href="?skip-to={{.Diff.End}}&file-only=true">{{ctx.Locale.Tr "repo.diff.show_more"}}</a>
|
||||
</h4>
|
||||
<div class="ui segment flex-left-right" id="diff-incomplete">
|
||||
<span id="diff-load-progress" class="tw-text-text-light-2" data-text-too-many-files="{{ctx.Locale.Tr "repo.diff.too_many_files"}}">
|
||||
{{ctx.Locale.Tr "repo.diff.too_many_files" (len .Diff.Files) .DiffShortStat.NumFiles}}
|
||||
</span>
|
||||
<a class="ui basic tiny button" id="diff-show-more-files" data-global-click="diffLoadMoreFiles" data-href="?skip-to={{.Diff.End}}&file-only=true"
|
||||
data-text-default="{{ctx.Locale.Tr "repo.diff.show_more"}}"
|
||||
data-text-matching="{{ctx.Locale.Tr "repo.diff.show_more_matching"}}"
|
||||
>{{ctx.Locale.Tr "repo.diff.show_more"}}</a>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -30,6 +30,24 @@ Gitea's private styles use `g-` prefix.
|
||||
.interact-bg:hover { background: var(--color-hover) !important; }
|
||||
.interact-bg:active { background: var(--color-active) !important; }
|
||||
|
||||
/* primary-color dot centered on the top-right corner of the element */
|
||||
.indicator-dot {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.indicator-dot::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-primary);
|
||||
border: 1px solid var(--color-secondary);
|
||||
transform: translate(50%, -50%);
|
||||
}
|
||||
|
||||
@media (max-width: 767.98px) {
|
||||
/* double selector so it wins over .tw-flex (old .gt-df) etc */
|
||||
.not-mobile.not-mobile {
|
||||
|
||||
@@ -14,6 +14,18 @@
|
||||
max-width: calc(100vw - 32px);
|
||||
}
|
||||
|
||||
/* opt-in via createTippy's "limitSizeToViewport" */
|
||||
[data-tippy-limit-size] .tippy-box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: var(--tippy-max-width, none);
|
||||
max-height: var(--tippy-max-height, none);
|
||||
}
|
||||
|
||||
[data-tippy-limit-size] .tippy-content {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.tippy-box {
|
||||
position: relative;
|
||||
background-color: var(--color-menu);
|
||||
@@ -91,7 +103,7 @@
|
||||
background: var(--color-hover);
|
||||
}
|
||||
|
||||
.tippy-box[data-theme="menu"] .item:focus {
|
||||
.tippy-box[data-theme="menu"] .item:focus-visible {
|
||||
background: var(--color-hover);
|
||||
}
|
||||
|
||||
@@ -99,6 +111,10 @@
|
||||
background: var(--color-active);
|
||||
}
|
||||
|
||||
.tippy-box[data-theme="menu"] .divider {
|
||||
margin: 4px 0; /* fit the menu's vertical padding */
|
||||
}
|
||||
|
||||
/* box-with-header theme to look like .ui.attached.segment. can contain .ui.attached.header */
|
||||
|
||||
.tippy-box[data-theme="box-with-header"] {
|
||||
|
||||
+16
-5
@@ -1549,27 +1549,38 @@ tbody.commit-list {
|
||||
|
||||
#diff-container {
|
||||
display: flex;
|
||||
padding-top: 4px; /* for box-shadow from .diff-file-box */
|
||||
gap: var(--page-spacing);
|
||||
}
|
||||
|
||||
#diff-file-boxes {
|
||||
#diff-boxes-column {
|
||||
flex: 1;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
#diff-file-boxes {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
#diff-file-tree {
|
||||
flex: 0 0 20%;
|
||||
max-width: 380px;
|
||||
flex: 0 0 256px;
|
||||
min-width: 0;
|
||||
line-height: inherit;
|
||||
position: sticky;
|
||||
padding-top: 0;
|
||||
top: 47px;
|
||||
max-height: calc(100vh - 47px);
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
@media (min-width: 1201px) {
|
||||
#diff-file-tree {
|
||||
flex-basis: 320px;
|
||||
}
|
||||
}
|
||||
|
||||
.ui.message.unicode-escape-prompt {
|
||||
|
||||
@@ -33,7 +33,7 @@ const uniqueIdShowAll = generateElemId('diff-commit-selector-show-all-');
|
||||
|
||||
const menuVisible = shallowRef(false);
|
||||
const isLoading = shallowRef(false);
|
||||
const locale = shallowRef<Record<string, string>>({filter_changes_by_commit: elMount.getAttribute('data-filter_changes_by_commit')!});
|
||||
const locale = shallowRef<Record<string, string>>({filter_changes_by_commit: elMount.getAttribute('data-text-filter-changes-by-commit')!});
|
||||
const commits = ref<Array<Commit>>([]); // deep, the commit objects are mutated in place
|
||||
const hoverActivated = shallowRef(false);
|
||||
const lastReviewCommitSha = shallowRef<string | null>(null);
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
<script lang="ts" setup>
|
||||
import {computed, onMounted, onUnmounted, useTemplateRef} from 'vue';
|
||||
import type {Instance} from 'tippy.js';
|
||||
import SvgIcon from './SvgIcon.vue';
|
||||
import type {SvgName} from '../svg.ts';
|
||||
import {createTippy} from '../modules/tippy.ts';
|
||||
import {diffTreeStore, extDotfile, getDiffTreeExtensionStats, type DiffExtensionFilterLocale} from '../modules/diff-file.ts';
|
||||
|
||||
const props = defineProps<{locale: DiffExtensionFilterLocale}>();
|
||||
|
||||
const store = diffTreeStore();
|
||||
const triggerEl = useTemplateRef<HTMLButtonElement>('triggerEl');
|
||||
const panelEl = useTemplateRef<HTMLDivElement>('panelEl');
|
||||
let tippyInstance: Instance;
|
||||
|
||||
const allExtensions = computed(() => getDiffTreeExtensionStats(store));
|
||||
const isFiltering = computed(() => store.activeExtensions !== 'all' || Boolean(store.filenameFilterQuery));
|
||||
const allIcon = computed<SvgName | null>(() => {
|
||||
if (store.activeExtensions === 'all') return 'octicon-check';
|
||||
return store.activeExtensions.length ? 'octicon-dash' : null;
|
||||
});
|
||||
|
||||
function isChecked(ext: string): boolean {
|
||||
return store.activeExtensions === 'all' || store.activeExtensions.includes(ext);
|
||||
}
|
||||
|
||||
function extLabel(ext: string): string {
|
||||
if (ext === extDotfile) return props.locale.dotfileExtension;
|
||||
return ext || props.locale.noFileExtension;
|
||||
}
|
||||
|
||||
function toggleExt(ext: string) {
|
||||
const all = allExtensions.value.map((e) => e.ext);
|
||||
const next = new Set(store.activeExtensions === 'all' ? all : store.activeExtensions);
|
||||
if (next.has(ext)) next.delete(ext); else next.add(ext);
|
||||
store.activeExtensions = next.size === all.length ? 'all' : Array.from(next);
|
||||
}
|
||||
|
||||
function toggleAll() {
|
||||
store.activeExtensions = store.activeExtensions === 'all' ? [] : 'all';
|
||||
}
|
||||
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') tippyInstance.hide();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
tippyInstance = createTippy(triggerEl.value!, {
|
||||
content: panelEl.value!,
|
||||
trigger: 'click',
|
||||
interactive: true,
|
||||
hideOnClick: true,
|
||||
placement: 'bottom-end',
|
||||
theme: 'menu',
|
||||
arrow: false,
|
||||
limitSizeToViewport: {vertical: true},
|
||||
onShow: () => document.addEventListener('keydown', onKeyDown),
|
||||
onHide: () => document.removeEventListener('keydown', onKeyDown),
|
||||
});
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
tippyInstance.destroy();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
ref="triggerEl"
|
||||
type="button"
|
||||
class="diff-ext-filter-trigger"
|
||||
:class="{'indicator-dot': isFiltering}"
|
||||
:aria-label="props.locale.filterByFileExtension"
|
||||
>
|
||||
<SvgIcon name="octicon-filter"/>
|
||||
</button>
|
||||
<div ref="panelEl" class="tippy-target">
|
||||
<div class="diff-ext-filter-menu" role="menu" :aria-label="props.locale.fileExtensions">
|
||||
<div class="diff-ext-filter-header">{{ props.locale.fileExtensions }}</div>
|
||||
<div class="diff-ext-filter-list">
|
||||
<button
|
||||
v-for="ext in allExtensions" :key="ext.ext"
|
||||
type="button" class="item" role="menuitemcheckbox"
|
||||
:aria-checked="isChecked(ext.ext)" @click="toggleExt(ext.ext)"
|
||||
>
|
||||
<span class="diff-ext-filter-check">
|
||||
<SvgIcon v-if="isChecked(ext.ext)" name="octicon-check"/>
|
||||
</span>
|
||||
<span class="gt-ellipsis">{{ extLabel(ext.ext) }}</span>
|
||||
<span class="diff-ext-filter-count">{{ ext.count }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="divider"/>
|
||||
<button
|
||||
type="button" class="item" role="menuitemcheckbox"
|
||||
:aria-checked="store.activeExtensions === 'all'" @click="toggleAll"
|
||||
>
|
||||
<span class="diff-ext-filter-check">
|
||||
<SvgIcon v-if="allIcon" :name="allIcon"/>
|
||||
</span>
|
||||
<span class="gt-ellipsis">{{ props.locale.allFileExtensions }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.diff-ext-filter-menu {
|
||||
min-width: 192px;
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
.diff-ext-filter-header {
|
||||
padding: 6px 16px;
|
||||
color: var(--color-text-light-2);
|
||||
font-size: 12px;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.diff-ext-filter-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.diff-ext-filter-menu .item {
|
||||
width: auto; /* buttons are shrink-to-fit, the flex column parent stretches them */
|
||||
margin: 0 4px; /* matches the menu's vertical padding so the inset is even on all sides */
|
||||
padding: 6px 12px;
|
||||
gap: 8px;
|
||||
border: none;
|
||||
border-radius: var(--border-radius-medium);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.diff-ext-filter-check {
|
||||
display: flex;
|
||||
flex: 0 0 16px;
|
||||
color: var(--color-text-light-2);
|
||||
}
|
||||
|
||||
.diff-ext-filter-count {
|
||||
margin-left: auto;
|
||||
padding: 2px 6px;
|
||||
border-radius: var(--border-radius-full);
|
||||
background: var(--color-label-bg);
|
||||
font-size: 12px;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
line-height: 12px;
|
||||
}
|
||||
|
||||
.diff-ext-filter-trigger {
|
||||
height: 32px;
|
||||
width: 32px;
|
||||
padding: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid var(--color-secondary);
|
||||
border-radius: var(--border-radius-medium);
|
||||
background: var(--color-button);
|
||||
color: var(--color-text-light-2);
|
||||
}
|
||||
|
||||
.diff-ext-filter-trigger:hover {
|
||||
background: var(--color-hover);
|
||||
}
|
||||
</style>
|
||||
@@ -1,20 +1,33 @@
|
||||
<script lang="ts" setup>
|
||||
import SvgIcon from './SvgIcon.vue';
|
||||
import DiffFileTreeItem from './DiffFileTreeItem.vue';
|
||||
import {toggleElem} from '../utils/dom.ts';
|
||||
import {diffTreeStore} from '../modules/diff-file.ts';
|
||||
import DiffFileExtensionFilter from './DiffFileExtensionFilter.vue';
|
||||
import {onInputDebounce, toggleElem} from '../utils/dom.ts';
|
||||
import {diffTreeStore, filterDiffTree, applyFiltersToFileBoxes, extensionFilterToUrl, type DiffFileTreeLocale} from '../modules/diff-file.ts';
|
||||
import {setFileFolding} from '../features/file-fold.ts';
|
||||
import {onMounted, onUnmounted} from 'vue';
|
||||
import {onMounted, onUnmounted, computed, watch} from 'vue';
|
||||
import {localUserSettings} from '../modules/user-settings.ts';
|
||||
|
||||
const LOCAL_STORAGE_KEY = 'diff_file_tree_visible';
|
||||
|
||||
const props = defineProps<{locale: DiffFileTreeLocale}>();
|
||||
|
||||
const store = diffTreeStore();
|
||||
|
||||
const visibleTreeItems = computed(() => filterDiffTree(store)?.Children ?? []);
|
||||
|
||||
watch(() => store.filenameFilterQuery, onInputDebounce(() => applyFiltersToFileBoxes(store)));
|
||||
watch(() => store.activeExtensions, () => {
|
||||
applyFiltersToFileBoxes(store);
|
||||
window.history.replaceState(null, '', extensionFilterToUrl(store.activeExtensions, window.location.href));
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
// Default to true if unset
|
||||
store.fileTreeIsVisible = localUserSettings.getBoolean(LOCAL_STORAGE_KEY, true);
|
||||
// while the tree is hidden there is no control to clear a filter restored from the URL
|
||||
if (store.fileTreeIsVisible) applyFiltersToFileBoxes(store); else store.activeExtensions = 'all';
|
||||
document.querySelector('.diff-toggle-file-tree-button')!.addEventListener('click', toggleVisibility);
|
||||
|
||||
hashChangeListener();
|
||||
window.addEventListener('hashchange', hashChangeListener);
|
||||
});
|
||||
@@ -44,6 +57,11 @@ function toggleVisibility() {
|
||||
|
||||
function updateVisibility(visible: boolean) {
|
||||
store.fileTreeIsVisible = visible;
|
||||
if (!visible) {
|
||||
store.filenameFilterQuery = '';
|
||||
store.activeExtensions = 'all';
|
||||
applyFiltersToFileBoxes(store);
|
||||
}
|
||||
localUserSettings.setBoolean(LOCAL_STORAGE_KEY, store.fileTreeIsVisible);
|
||||
updateState(store.fileTreeIsVisible);
|
||||
}
|
||||
@@ -62,16 +80,110 @@ function updateState(visible: boolean) {
|
||||
|
||||
<template>
|
||||
<!-- only render the tree if we're visible. in many cases this is something that doesn't change very often -->
|
||||
<div v-if="store.fileTreeIsVisible" class="diff-file-tree-items">
|
||||
<DiffFileTreeItem v-for="item in store.diffFileTree.TreeRoot.Children" :key="item.FullName" :item="item"/>
|
||||
<div v-if="store.fileTreeIsVisible" class="diff-file-tree-wrapper">
|
||||
<div class="diff-file-tree-search-row">
|
||||
<div class="diff-file-search-wrapper">
|
||||
<SvgIcon name="octicon-search" :size="14" class="diff-file-search-icon"/>
|
||||
<input
|
||||
type="text"
|
||||
v-model="store.filenameFilterQuery"
|
||||
class="diff-file-search-input"
|
||||
:placeholder="props.locale.filterFiles"
|
||||
:aria-label="props.locale.filterFiles"
|
||||
>
|
||||
<button
|
||||
v-if="store.filenameFilterQuery"
|
||||
type="button"
|
||||
class="diff-file-search-clear"
|
||||
@click="store.filenameFilterQuery = ''"
|
||||
:aria-label="props.locale.filterFilesClear"
|
||||
>
|
||||
<SvgIcon name="octicon-x" :size="14"/>
|
||||
</button>
|
||||
</div>
|
||||
<DiffFileExtensionFilter :locale="props.locale"/>
|
||||
</div>
|
||||
<div class="diff-file-tree-items">
|
||||
<DiffFileTreeItem v-for="item in visibleTreeItems" :key="item.FullName" :item="item"/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.diff-file-tree-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin-right: .5rem;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.diff-file-tree-search-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding-top: 1px; /* match .diff-file-box's top border so this row aligns with .diff-file-header */
|
||||
padding-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.diff-file-search-wrapper {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.diff-file-search-icon {
|
||||
position: absolute;
|
||||
left: 8px;
|
||||
color: var(--color-text-light-2);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.diff-file-search-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 32px;
|
||||
padding: 0 28px;
|
||||
border: 1px solid var(--color-secondary);
|
||||
border-radius: var(--border-radius-medium);
|
||||
background: var(--color-input-background);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.diff-file-search-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.diff-file-search-clear {
|
||||
position: absolute;
|
||||
right: 4px;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 20px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-light);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: auto 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.diff-file-search-clear:hover {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.diff-file-tree-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
margin-right: .5rem;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import DiffFileTreeItem from './DiffFileTreeItem.vue';
|
||||
import {createApp, h} from 'vue';
|
||||
import type {DiffStatus, DiffTreeEntry} from '../modules/diff-file.ts';
|
||||
|
||||
function renderItem(diffStatus: string): string {
|
||||
const item: DiffTreeEntry = {
|
||||
FullName: 'a.txt', OldFullName: '', DisplayName: 'a.txt', NameHash: 'hash',
|
||||
DiffStatus: diffStatus as DiffStatus, EntryMode: '', IsViewed: false, Children: null, FileIcon: '',
|
||||
};
|
||||
const root = document.createElement('div');
|
||||
createApp({render: () => h(DiffFileTreeItem, {item})}).mount(root);
|
||||
return root.innerHTML;
|
||||
}
|
||||
|
||||
test('DiffFileTreeItem diff status icon', () => {
|
||||
window.config.pageData.DiffFileTree = {TreeRoot: {
|
||||
FullName: '', OldFullName: '', DisplayName: '', NameHash: 'root',
|
||||
DiffStatus: '', EntryMode: 'tree', IsViewed: false, Children: [], FileIcon: '',
|
||||
}};
|
||||
expect(renderItem('typechanged')).toContain('octicon-diff-modified');
|
||||
// a status the frontend does not know must fall back instead of failing to render
|
||||
expect(renderItem('something-new')).toContain('octicon-blocked');
|
||||
});
|
||||
@@ -11,18 +11,17 @@ const props = defineProps<{
|
||||
const store = diffTreeStore();
|
||||
const collapsed = shallowRef(props.item.IsViewed);
|
||||
|
||||
function getIconForDiffStatus(pType: DiffStatus) {
|
||||
const diffTypes: Record<DiffStatus, { name: SvgName, classes: Array<string> }> = {
|
||||
'': {name: 'octicon-blocked', classes: ['tw-text-red']}, // unknown case
|
||||
'added': {name: 'octicon-diff-added', classes: ['tw-text-green']},
|
||||
'modified': {name: 'octicon-diff-modified', classes: ['tw-text-yellow']},
|
||||
'deleted': {name: 'octicon-diff-removed', classes: ['tw-text-red']},
|
||||
'renamed': {name: 'octicon-diff-renamed', classes: ['tw-text-teal']},
|
||||
'copied': {name: 'octicon-diff-renamed', classes: ['tw-text-green']},
|
||||
'typechange': {name: 'octicon-diff-modified', classes: ['tw-text-green']}, // there is no octicon for copied, so renamed should be ok
|
||||
};
|
||||
return diffTypes[pType] ?? diffTypes[''];
|
||||
}
|
||||
const diffStatusIcons: Record<DiffStatus, {name: SvgName, class: string}> = {
|
||||
'': {name: 'octicon-blocked', class: 'tw-text-red'},
|
||||
'added': {name: 'octicon-diff-added', class: 'tw-text-green'},
|
||||
'modified': {name: 'octicon-diff-modified', class: 'tw-text-yellow'},
|
||||
'deleted': {name: 'octicon-diff-removed', class: 'tw-text-red'},
|
||||
'renamed': {name: 'octicon-diff-renamed', class: 'tw-text-teal'},
|
||||
'copied': {name: 'octicon-diff-renamed', class: 'tw-text-green'}, // there is no octicon for copied, so renamed should be ok
|
||||
'typechanged': {name: 'octicon-diff-modified', class: 'tw-text-green'},
|
||||
'unmerged': {name: 'octicon-blocked', class: 'tw-text-red'},
|
||||
'unknown': {name: 'octicon-blocked', class: 'tw-text-red'},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -36,7 +35,7 @@ function getIconForDiffStatus(pType: DiffStatus) {
|
||||
</div>
|
||||
|
||||
<div v-show="!collapsed" class="sub-items">
|
||||
<DiffFileTreeItem v-for="childItem in item.Children" :key="childItem.DisplayName" :item="childItem"/>
|
||||
<DiffFileTreeItem v-for="childItem in item.Children!" :key="childItem.DisplayName" :item="childItem"/>
|
||||
</div>
|
||||
</template>
|
||||
<a
|
||||
@@ -48,10 +47,7 @@ function getIconForDiffStatus(pType: DiffStatus) {
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<span class="tw-contents" v-html="item.FileIcon"/>
|
||||
<span class="gt-ellipsis tw-flex-1">{{ item.DisplayName }}</span>
|
||||
<SvgIcon
|
||||
:name="getIconForDiffStatus(item.DiffStatus).name"
|
||||
:class="getIconForDiffStatus(item.DiffStatus).classes"
|
||||
/>
|
||||
<SvgIcon v-bind="diffStatusIcons[item.DiffStatus] ?? diffStatusIcons['']"/>
|
||||
</a>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import {createApp} from 'vue';
|
||||
import DiffFileTree from '../components/DiffFileTree.vue';
|
||||
import type {DiffFileTreeLocale} from '../modules/diff-file.ts';
|
||||
|
||||
export function initDiffFileTree() {
|
||||
const el = document.querySelector('#diff-file-tree');
|
||||
if (!el) return;
|
||||
|
||||
const fileTreeView = createApp(DiffFileTree);
|
||||
fileTreeView.mount(el);
|
||||
const locale = JSON.parse(el.getAttribute('data-locale')!) as DiffFileTreeLocale;
|
||||
createApp(DiffFileTree, {locale}).mount(el);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {invertFileFolding} from './file-fold.ts';
|
||||
import {parseDom} from '../utils.ts';
|
||||
import {registerGlobalEventFunc, registerGlobalInitFunc} from '../modules/observer.ts';
|
||||
import {performFetchActionTrigger} from '../modules/fetch-action.ts';
|
||||
import {applyFiltersToFileBoxes, diffTreeStore} from '../modules/diff-file.ts';
|
||||
import {initImageDiff} from './imagediff.ts';
|
||||
|
||||
function initDiffFileViewToggle(el: HTMLElement) {
|
||||
@@ -158,12 +159,11 @@ async function diffLoadMoreFiles(btn: Element): Promise<boolean> {
|
||||
const resp = await GET(url);
|
||||
if (!resp.ok) return false;
|
||||
const respText = await resp.text();
|
||||
const respDoc = parseDom(respText, 'text/html');
|
||||
const respDoc = parseDom(respText, 'text/html'); // the response is a full HTML page, extract the new file boxes from it
|
||||
const respFileBoxes = respDoc.querySelector('#diff-file-boxes')!;
|
||||
// the response is a full HTML page, we need to extract the relevant contents:
|
||||
// * append the newly loaded file list items to the existing list
|
||||
const respFileBoxesChildren = Array.from(respFileBoxes.children); // "children:HTMLCollection" will be empty after replaceWith
|
||||
document.querySelector('#diff-incomplete')!.replaceWith(...respFileBoxesChildren);
|
||||
applyFiltersToFileBoxes(diffTreeStore());
|
||||
onDiffFileBodyChange();
|
||||
return true;
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,51 +1,140 @@
|
||||
import {diffTreeStoreSetViewed, reactiveDiffTreeStore} from './diff-file.ts';
|
||||
import {countMatchingFiles, diffTreeStoreSetViewed, extensionFilterFromUrl, extensionFilterToUrl, filterDiffTree, getDiffTreeExtensionStats, reactiveDiffTreeStore, type DiffTreeEntry} from './diff-file.ts';
|
||||
|
||||
test('diff-tree', () => {
|
||||
const store = reactiveDiffTreeStore({
|
||||
'TreeRoot': {
|
||||
'FullName': '',
|
||||
'DisplayName': '',
|
||||
'EntryMode': '',
|
||||
'IsViewed': false,
|
||||
'NameHash': '....',
|
||||
'DiffStatus': '',
|
||||
'FileIcon': '',
|
||||
'Children': [
|
||||
{
|
||||
'FullName': 'dir1',
|
||||
'DisplayName': 'dir1',
|
||||
'EntryMode': 'tree',
|
||||
'IsViewed': false,
|
||||
'NameHash': '....',
|
||||
'DiffStatus': '',
|
||||
'FileIcon': '',
|
||||
'Children': [
|
||||
{
|
||||
'FullName': 'dir1/test.txt',
|
||||
'DisplayName': 'test.txt',
|
||||
'DiffStatus': 'added',
|
||||
'NameHash': '....',
|
||||
'EntryMode': '',
|
||||
'IsViewed': false,
|
||||
'FileIcon': '',
|
||||
'Children': null,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
'FullName': 'other.txt',
|
||||
'DisplayName': 'other.txt',
|
||||
'NameHash': '........',
|
||||
'DiffStatus': 'added',
|
||||
'EntryMode': '',
|
||||
'IsViewed': false,
|
||||
'FileIcon': '',
|
||||
'Children': null,
|
||||
},
|
||||
],
|
||||
function file(name: string, oldName: string = ''): DiffTreeEntry {
|
||||
return {
|
||||
FullName: name,
|
||||
OldFullName: oldName,
|
||||
DisplayName: name.split('/').pop()!,
|
||||
DiffStatus: 'added',
|
||||
NameHash: name,
|
||||
EntryMode: '',
|
||||
IsViewed: false,
|
||||
FileIcon: '',
|
||||
Children: null,
|
||||
};
|
||||
}
|
||||
|
||||
function dir(name: string, children: DiffTreeEntry[]): DiffTreeEntry {
|
||||
return {
|
||||
FullName: name,
|
||||
OldFullName: '',
|
||||
DisplayName: name.split('/').pop()!,
|
||||
EntryMode: 'tree',
|
||||
IsViewed: false,
|
||||
NameHash: name,
|
||||
DiffStatus: '',
|
||||
FileIcon: '',
|
||||
Children: children,
|
||||
};
|
||||
}
|
||||
|
||||
function makeStore(children: DiffTreeEntry[]) {
|
||||
return reactiveDiffTreeStore({
|
||||
TreeRoot: {
|
||||
FullName: '', OldFullName: '', DisplayName: '', EntryMode: 'tree', IsViewed: false,
|
||||
NameHash: 'root', DiffStatus: '', FileIcon: '', Children: children,
|
||||
},
|
||||
}, '', '');
|
||||
}
|
||||
|
||||
function visibleNames(root: DiffTreeEntry | null): string[] {
|
||||
if (!root) return [];
|
||||
const out: string[] = [];
|
||||
const visit = (e: DiffTreeEntry) => {
|
||||
if (e.EntryMode !== 'tree') out.push(e.FullName);
|
||||
for (const c of e.Children ?? []) visit(c);
|
||||
};
|
||||
visit(root);
|
||||
return out;
|
||||
}
|
||||
|
||||
test('diff-tree', () => {
|
||||
const store = makeStore([
|
||||
dir('dir1', [file('dir1/test.txt')]),
|
||||
file('other.txt'),
|
||||
]);
|
||||
diffTreeStoreSetViewed(store, 'dir1/test.txt', true);
|
||||
expect(store.fullNameMap['dir1/test.txt'].IsViewed).toBe(true);
|
||||
expect(store.fullNameMap['dir1'].IsViewed).toBe(true);
|
||||
});
|
||||
|
||||
test('filterDiffTree', () => {
|
||||
const store = makeStore([
|
||||
dir('dir1', [file('dir1/test.txt')]),
|
||||
file('other.ts'),
|
||||
file('other.TS'),
|
||||
]);
|
||||
|
||||
store.filenameFilterQuery = 'TesT';
|
||||
expect(visibleNames(filterDiffTree(store))).toEqual(['dir1/test.txt']);
|
||||
|
||||
store.filenameFilterQuery = '';
|
||||
store.activeExtensions = ['.ts'];
|
||||
expect(visibleNames(filterDiffTree(store))).toEqual(['other.ts', 'other.TS']);
|
||||
|
||||
store.activeExtensions = [];
|
||||
expect(visibleNames(filterDiffTree(store))).toEqual([]);
|
||||
|
||||
store.activeExtensions = 'all';
|
||||
expect(visibleNames(filterDiffTree(store))).toEqual(['dir1/test.txt', 'other.ts', 'other.TS']);
|
||||
});
|
||||
|
||||
test('getDiffTreeExtensionStats', () => {
|
||||
const store = makeStore([
|
||||
dir('dir1', [file('dir1/test.txt'), file('dir1/Makefile'), file('dir1/.gitignore')]),
|
||||
file('.eslintrc.json'), // a dotfile with an extension keeps that extension
|
||||
file('other.ts'),
|
||||
file('other.TXT'), // case-insensitive
|
||||
]);
|
||||
expect(getDiffTreeExtensionStats(store)).toEqual([
|
||||
{ext: '.json', count: 1},
|
||||
{ext: '.ts', count: 1},
|
||||
{ext: '.txt', count: 2},
|
||||
{ext: 'dotfile', count: 1},
|
||||
{ext: '', count: 1},
|
||||
]);
|
||||
});
|
||||
|
||||
test('countMatchingFiles', () => {
|
||||
const store = makeStore([
|
||||
dir('dir1', [file('dir1/new-name.md', 'dir1/old-name.txt')]),
|
||||
file('other.ts'),
|
||||
]);
|
||||
|
||||
expect(countMatchingFiles(store)).toBe(2);
|
||||
|
||||
// search query also matches the pre-rename path
|
||||
store.filenameFilterQuery = 'old-name';
|
||||
expect(visibleNames(filterDiffTree(store))).toEqual(['dir1/new-name.md']);
|
||||
expect(countMatchingFiles(store)).toBe(1);
|
||||
|
||||
// extension filter only applies to new name
|
||||
store.filenameFilterQuery = '';
|
||||
store.activeExtensions = ['.txt'];
|
||||
expect(visibleNames(filterDiffTree(store))).toEqual([]);
|
||||
expect(countMatchingFiles(store)).toBe(0);
|
||||
store.activeExtensions = ['.md'];
|
||||
expect(visibleNames(filterDiffTree(store))).toEqual(['dir1/new-name.md']);
|
||||
|
||||
store.activeExtensions = ['.md', '.ts'];
|
||||
expect(countMatchingFiles(store)).toBe(2);
|
||||
});
|
||||
|
||||
test('extensionFilter url round-trip', () => {
|
||||
const known = ['.go', '.ts', 'dotfile', ''];
|
||||
const url = 'http://localhost/owner/repo/pulls/1/files?style=split';
|
||||
const roundTrip = (filter: Parameters<typeof extensionFilterToUrl>[0]) =>
|
||||
extensionFilterFromUrl(new URL(extensionFilterToUrl(filter, url)).search, known);
|
||||
|
||||
expect(extensionFilterToUrl('all', url)).toEqual(url);
|
||||
expect(roundTrip('all')).toEqual('all');
|
||||
expect(roundTrip(['.go', ''])).toEqual(['.go', '']);
|
||||
expect(roundTrip([])).toEqual([]);
|
||||
|
||||
// other query parameters survive, "no extension" gets a stable token
|
||||
expect(extensionFilterToUrl(['.go', ''], url)).toEqual(`${url}&file-filters%5B%5D=.go&file-filters%5B%5D=noextension`);
|
||||
|
||||
// unknown extensions are dropped, selecting every known one is the same as no filter
|
||||
expect(extensionFilterFromUrl('?file-filters[]=.go&file-filters[]=.nope', known)).toEqual(['.go']);
|
||||
expect(extensionFilterFromUrl('?file-filters[]=.go&file-filters[]=.ts&file-filters[]=dotfile&file-filters[]=noextension', known)).toEqual('all');
|
||||
});
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import {reactive} from 'vue';
|
||||
import type {Reactive} from 'vue';
|
||||
import {toggleElem} from '../utils/dom.ts';
|
||||
import {trString} from './i18n.ts';
|
||||
import {basename, extname} from '../utils.ts';
|
||||
|
||||
const {pageData} = window.config;
|
||||
|
||||
export type DiffStatus = '' | 'added' | 'modified' | 'deleted' | 'renamed' | 'copied' | 'typechange';
|
||||
// matches statusFromLetter in services/gitdiff/git_diff_tree.go
|
||||
export type DiffStatus = '' | 'added' | 'modified' | 'deleted' | 'renamed' | 'copied' | 'typechanged' | 'unmerged' | 'unknown';
|
||||
|
||||
export type DiffTreeEntry = {
|
||||
FullName: string,
|
||||
OldFullName: string,
|
||||
DisplayName: string,
|
||||
NameHash: string,
|
||||
DiffStatus: DiffStatus,
|
||||
@@ -21,6 +26,9 @@ export type DiffFileTreeData = {
|
||||
TreeRoot: DiffTreeEntry,
|
||||
};
|
||||
|
||||
// activeExtensions: 'all' = no filter (every extension passes); string[] = exact set of extensions allowed (empty = nothing passes).
|
||||
type ExtensionFilter = 'all' | string[];
|
||||
|
||||
type DiffFileTree = {
|
||||
folderIcon: string;
|
||||
folderOpenIcon: string;
|
||||
@@ -28,12 +36,34 @@ type DiffFileTree = {
|
||||
fullNameMap: Record<string, DiffTreeEntry>
|
||||
fileTreeIsVisible: boolean;
|
||||
selectedItem: string;
|
||||
filenameFilterQuery: string;
|
||||
activeExtensions: ExtensionFilter;
|
||||
};
|
||||
|
||||
type DiffExtensionStats = {
|
||||
ext: string,
|
||||
count: number,
|
||||
};
|
||||
|
||||
export type DiffExtensionFilterLocale = {
|
||||
filterByFileExtension: string,
|
||||
fileExtensions: string,
|
||||
noFileExtension: string,
|
||||
dotfileExtension: string,
|
||||
allFileExtensions: string,
|
||||
};
|
||||
|
||||
export type DiffFileTreeLocale = DiffExtensionFilterLocale & {
|
||||
filterFiles: string,
|
||||
filterFilesClear: string,
|
||||
};
|
||||
|
||||
let diffTreeStoreReactive: Reactive<DiffFileTree>;
|
||||
export function diffTreeStore() {
|
||||
if (!diffTreeStoreReactive) {
|
||||
diffTreeStoreReactive = reactiveDiffTreeStore(pageData.DiffFileTree!, pageData.FolderIcon!, pageData.FolderOpenIcon!);
|
||||
const knownExtensions = getDiffTreeExtensionStats(diffTreeStoreReactive).map((stat) => stat.ext);
|
||||
diffTreeStoreReactive.activeExtensions = extensionFilterFromUrl(window.location.search, knownExtensions);
|
||||
}
|
||||
return diffTreeStoreReactive;
|
||||
}
|
||||
@@ -58,25 +88,149 @@ function fillFullNameMap(map: Record<string, DiffTreeEntry>, entry: DiffTreeEntr
|
||||
}
|
||||
|
||||
export function reactiveDiffTreeStore(data: DiffFileTreeData, folderIcon: string, folderOpenIcon: string): Reactive<DiffFileTree> {
|
||||
const store = reactive({
|
||||
const store = reactive<DiffFileTree>({
|
||||
diffFileTree: data,
|
||||
folderIcon,
|
||||
folderOpenIcon,
|
||||
fileTreeIsVisible: false,
|
||||
selectedItem: '',
|
||||
filenameFilterQuery: '',
|
||||
activeExtensions: 'all',
|
||||
fullNameMap: {},
|
||||
});
|
||||
fillFullNameMap(store.fullNameMap, data.TreeRoot);
|
||||
return store;
|
||||
}
|
||||
|
||||
export const extDotfile = 'dotfile'; // bucket for ".gitignore" and friends, real extensions always start with a dot
|
||||
|
||||
const urlParamFileFilters = 'file-filters[]'; // same parameter GitHub uses, lists the selected extensions
|
||||
const urlValueNoExtension = 'noextension';
|
||||
|
||||
export function extensionFilterFromUrl(search: string, knownExtensions: string[]): ExtensionFilter {
|
||||
const params = new URLSearchParams(search);
|
||||
if (!params.has(urlParamFileFilters)) return 'all';
|
||||
const extensions = params.getAll(urlParamFileFilters)
|
||||
.filter(Boolean)
|
||||
.map((ext) => ext === urlValueNoExtension ? '' : ext)
|
||||
.filter((ext) => knownExtensions.includes(ext));
|
||||
return extensions.length === knownExtensions.length ? 'all' : extensions;
|
||||
}
|
||||
|
||||
export function extensionFilterToUrl(filter: ExtensionFilter, url: string): string {
|
||||
const parsed = new URL(url);
|
||||
parsed.searchParams.delete(urlParamFileFilters);
|
||||
if (filter !== 'all') {
|
||||
if (!filter.length) parsed.searchParams.append(urlParamFileFilters, '');
|
||||
for (const ext of filter) parsed.searchParams.append(urlParamFileFilters, ext || urlValueNoExtension);
|
||||
}
|
||||
return parsed.href;
|
||||
}
|
||||
|
||||
function getFileExtension(filename: string): string {
|
||||
const ext = extname(filename).toLowerCase();
|
||||
if (ext) return ext;
|
||||
return basename(filename).startsWith('.') ? extDotfile : '';
|
||||
}
|
||||
|
||||
function extensionRank(ext: string): number {
|
||||
if (!ext) return 2;
|
||||
return ext === extDotfile ? 1 : 0;
|
||||
}
|
||||
|
||||
export function getDiffTreeExtensionStats(store: Reactive<DiffFileTree>): DiffExtensionStats[] {
|
||||
const extensionMap = new Map<string, number>();
|
||||
for (const entry of Object.values(store.fullNameMap)) {
|
||||
if (entry.EntryMode === 'tree' || !entry.FullName) continue;
|
||||
const ext = getFileExtension(entry.FullName);
|
||||
extensionMap.set(ext, (extensionMap.get(ext) ?? 0) + 1);
|
||||
}
|
||||
return Array.from(extensionMap, ([ext, count]) => ({ext, count}))
|
||||
.sort((a, b) => extensionRank(a.ext) - extensionRank(b.ext) || a.ext.localeCompare(b.ext));
|
||||
}
|
||||
|
||||
function buildFilter(store: Reactive<DiffFileTree>) {
|
||||
const query = store.filenameFilterQuery.trim().toLowerCase();
|
||||
const exts = store.activeExtensions === 'all' ? null : new Set(store.activeExtensions);
|
||||
if (!query && !exts) return null;
|
||||
return (newName: string, oldName: string) => {
|
||||
if (query && !newName.toLowerCase().includes(query) && !oldName.toLowerCase().includes(query)) return false;
|
||||
return !exts || exts.has(getFileExtension(newName));
|
||||
};
|
||||
}
|
||||
|
||||
// Children===null marks a file leaf; everything else (incl. the root, which has EntryMode="") is recursed into.
|
||||
export function filterDiffTree(store: Reactive<DiffFileTree>): DiffTreeEntry | null {
|
||||
const matches = buildFilter(store);
|
||||
if (!matches) return store.diffFileTree.TreeRoot;
|
||||
const visit = (entry: DiffTreeEntry): DiffTreeEntry | null => {
|
||||
if (entry.Children === null) return matches(entry.FullName, entry.OldFullName) ? entry : null;
|
||||
const children = entry.Children.map(visit).filter((child): child is DiffTreeEntry => child !== null);
|
||||
if (!children.length) return null;
|
||||
return {...entry, Children: children};
|
||||
};
|
||||
return visit(store.diffFileTree.TreeRoot);
|
||||
}
|
||||
|
||||
export function countMatchingFiles(store: Reactive<DiffFileTree>): number {
|
||||
const matches = buildFilter(store);
|
||||
let totalMatchingFilesCount = 0;
|
||||
for (const entry of Object.values(store.fullNameMap)) {
|
||||
if (entry.EntryMode === 'tree' || !entry.FullName) continue;
|
||||
if (!matches || matches(entry.FullName, entry.OldFullName)) totalMatchingFilesCount++;
|
||||
}
|
||||
return totalMatchingFilesCount;
|
||||
}
|
||||
|
||||
function countEveryFileInDiff(store: Reactive<DiffFileTree>): number {
|
||||
let totalFilesCount = 0;
|
||||
for (const entry of Object.values(store.fullNameMap)) {
|
||||
if (entry.EntryMode !== 'tree' && entry.FullName) totalFilesCount++;
|
||||
}
|
||||
return totalFilesCount;
|
||||
}
|
||||
|
||||
function updateLoadProgress(loadedFiles: number, totalFiles: number) {
|
||||
const el = document.querySelector('#diff-load-progress');
|
||||
if (!el) return;
|
||||
el.textContent = trString(el.getAttribute('data-text-too-many-files')!, loadedFiles, totalFiles);
|
||||
}
|
||||
|
||||
function updateShowMoreButton(matchingBelow: number) {
|
||||
const btn = document.querySelector('#diff-show-more-files');
|
||||
if (!btn) return;
|
||||
if (matchingBelow > 0) {
|
||||
btn.textContent = trString(btn.getAttribute('data-text-matching')!, matchingBelow);
|
||||
} else {
|
||||
btn.textContent = btn.getAttribute('data-text-default')!;
|
||||
}
|
||||
}
|
||||
|
||||
export function applyFiltersToFileBoxes(store: Reactive<DiffFileTree>) {
|
||||
const boxes = document.querySelectorAll<HTMLElement>('#diff-file-boxes .diff-file-box[data-new-filename]');
|
||||
const matches = buildFilter(store);
|
||||
if (!matches) {
|
||||
for (const box of boxes) toggleElem(box, true);
|
||||
toggleElem('#diff-no-matches', false);
|
||||
updateShowMoreButton(0);
|
||||
updateLoadProgress(boxes.length, countEveryFileInDiff(store));
|
||||
return;
|
||||
}
|
||||
let visibleCount = 0;
|
||||
for (const box of boxes) {
|
||||
const matched = matches(box.getAttribute('data-new-filename')!, box.getAttribute('data-old-filename')!);
|
||||
if (matched) visibleCount++;
|
||||
toggleElem(box, matched);
|
||||
}
|
||||
const matchingCount = countMatchingFiles(store);
|
||||
updateShowMoreButton(matchingCount - visibleCount);
|
||||
updateLoadProgress(boxes.length, countEveryFileInDiff(store));
|
||||
toggleElem('#diff-no-matches', matchingCount === 0);
|
||||
}
|
||||
|
||||
function isEntryViewed(entry: DiffTreeEntry): boolean {
|
||||
if (entry.Children) {
|
||||
let count = 0;
|
||||
for (const child of entry.Children) {
|
||||
if (child.IsViewed) count++;
|
||||
}
|
||||
return count === entry.Children.length;
|
||||
return entry.Children.every((child) => child.IsViewed);
|
||||
}
|
||||
return entry.IsViewed;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import {availableSizeForPlacement} from './tippy.ts';
|
||||
|
||||
test('availableSizeForPlacement', () => {
|
||||
const rect = (values: Partial<DOMRect>) => values as DOMRect;
|
||||
vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(1000);
|
||||
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(800);
|
||||
|
||||
// on the placement axis it is the gap to the edge the popup opens towards, the viewport on the other
|
||||
expect(availableSizeForPlacement(rect({top: 400, bottom: 432}), 'bottom-end', 0)).toEqual({width: 784, height: 560});
|
||||
expect(availableSizeForPlacement(rect({top: 400, bottom: 432}), 'top-end', 0)).toEqual({width: 784, height: 392});
|
||||
expect(availableSizeForPlacement(rect({left: 100, right: 300}), 'right', 0)).toEqual({width: 492, height: 984});
|
||||
expect(availableSizeForPlacement(rect({left: 100, right: 300}), 'left', 0)).toEqual({width: 92, height: 984});
|
||||
|
||||
// the placement offset eats into the space on that axis
|
||||
expect(availableSizeForPlacement(rect({top: 400, bottom: 432}), 'bottom-end', -6).height).toEqual(554);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import octiconClock from '../../public/assets/img/svg/octicon-clock.svg';
|
||||
import octiconCode from '../../public/assets/img/svg/octicon-code.svg';
|
||||
import octiconColumns from '../../public/assets/img/svg/octicon-columns.svg';
|
||||
import octiconCopy from '../../public/assets/img/svg/octicon-copy.svg';
|
||||
import octiconDash from '../../public/assets/img/svg/octicon-dash.svg';
|
||||
import octiconDiffAdded from '../../public/assets/img/svg/octicon-diff-added.svg';
|
||||
import octiconDiffModified from '../../public/assets/img/svg/octicon-diff-modified.svg';
|
||||
import octiconDiffRemoved from '../../public/assets/img/svg/octicon-diff-removed.svg';
|
||||
@@ -112,6 +113,7 @@ const svgs = {
|
||||
'octicon-code': octiconCode,
|
||||
'octicon-columns': octiconColumns,
|
||||
'octicon-copy': octiconCopy,
|
||||
'octicon-dash': octiconDash,
|
||||
'octicon-diff-added': octiconDiffAdded,
|
||||
'octicon-diff-modified': octiconDiffModified,
|
||||
'octicon-diff-removed': octiconDiffRemoved,
|
||||
|
||||
@@ -17,6 +17,9 @@ test('basename', () => {
|
||||
});
|
||||
|
||||
test('extname', () => {
|
||||
expect(extname('.gitignore')).toEqual('');
|
||||
expect(extname('/path/to/.gitignore')).toEqual('');
|
||||
expect(extname('/path/to/.eslintrc.json')).toEqual('.json');
|
||||
expect(extname('/path/to/file.js')).toEqual('.js');
|
||||
expect(extname('/path/')).toEqual('');
|
||||
expect(extname('/path')).toEqual('');
|
||||
|
||||
+3
-4
@@ -13,12 +13,11 @@ export function basename(path: string): string {
|
||||
return lastSlashIndex < 0 ? path : path.substring(lastSlashIndex + 1);
|
||||
}
|
||||
|
||||
/** transform /path/to/file.ext to .ext */
|
||||
/** transform /path/to/file.ext to .ext, dotfiles like /path/to/.gitignore have no extension */
|
||||
export function extname(path: string): string {
|
||||
const lastSlashIndex = path.lastIndexOf('/');
|
||||
const lastPointIndex = path.lastIndexOf('.');
|
||||
if (lastSlashIndex > lastPointIndex) return '';
|
||||
return lastPointIndex < 0 ? '' : path.substring(lastPointIndex);
|
||||
if (lastPointIndex <= path.lastIndexOf('/') + 1) return '';
|
||||
return path.substring(lastPointIndex);
|
||||
}
|
||||
|
||||
/** test whether a variable is an object */
|
||||
|
||||
Reference in New Issue
Block a user