feat(diff): Add search and extension filter to diff sidebar (#37068)

Adds a search box and a file-extension filter to the pull request diff
sidebar, so reviewers can narrow a large diff down to the files they
care about.

Both filters apply to the file tree and to the diff itself. The
extension menu follows GitHub: extensions sorted alphabetically,
dotfiles and extension-less files in their own buckets, and the
selection kept in the same `file-filters[]` query parameter, so a
filtered view is shareable and survives a reload.

The menu can list every extension in a diff, so `createTippy` gains an
opt-in `limitSizeToViewport` option that caps a popup to the space left
in the viewport and scrolls its content. Popups that do not ask for it
are unchanged.

Closes https://github.com/go-gitea/gitea/issues/27256
Signed-off-by: silverwind <me@silverwind.io>
Signed-off-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: Claude (Opus 4.7) <noreply@anthropic.com>
Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: Nicolas <bircni@icloud.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
McMichalK
2026-08-23 18:31:22 +02:00
committed by GitHub
parent 4852091e85
commit 2bcf950b78
28 changed files with 914 additions and 133 deletions

View File

@@ -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",

View File

@@ -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})

View File

@@ -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)

View File

@@ -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>

View File

@@ -1,6 +1,6 @@
import {env} from 'node:process';
import {expect, test} from '@playwright/test';
import {login, apiCreateRepo, apiCreateFile, assertFlushWithParent, assertNoJsError, randomString} from './utils.ts';
import {login, apiCreateRepo, apiCreateFiles, assertFlushWithParent, assertNoJsError, randomString} from './utils.ts';
test('external file', async ({page, request}) => {
const repoName = `e2e-external-render-${randomString(8)}`;
@@ -9,7 +9,7 @@ test('external file', async ({page, request}) => {
apiCreateRepo(request, {name: repoName}),
login(page),
]);
await apiCreateFile(request, owner, repoName, 'test.external', '<p>rendered content</p>');
await apiCreateFiles(request, owner, repoName, [{path: 'test.external', content: '<p>rendered content</p>'}]);
await page.goto(`/${owner}/${repoName}/src/branch/main/test.external`);
const iframe = page.locator('iframe.external-render-iframe');
await expect(iframe).toBeVisible();
@@ -34,7 +34,7 @@ test('openapi file', async ({page, request}) => {
paths: {'/pets': {get: {responses: {'200': {description: 'OK', content: {'application/json': {schema: {$ref: '#/components/schemas/Pet'}}}}}}}},
components: {schemas: {Pet: {type: 'object', properties: {children: {type: 'array', items: {$ref: '#/components/schemas/Pet'}}}}}},
});
await apiCreateFile(request, owner, repoName, 'openapi.json', spec);
await apiCreateFiles(request, owner, repoName, [{path: 'openapi.json', content: spec}]);
await page.goto(`/${owner}/${repoName}/src/branch/main/openapi.json`);
const iframe = page.locator('iframe.external-render-iframe');
await expect(iframe).toBeVisible();

View File

@@ -1,6 +1,6 @@
import {env} from 'node:process';
import {expect, test} from '@playwright/test';
import {apiCreateRepo, apiCreateFile, assertFlushWithParent, assertNoJsError, login, randomString} from './utils.ts';
import {apiCreateRepo, apiCreateFiles, assertFlushWithParent, assertNoJsError, login, randomString} from './utils.ts';
test('3d model file', async ({page, request, browserName}) => {
test.skip(browserName === 'firefox', 'unclear firefox-only CI-only failure'); // eslint-disable-line playwright/no-skipped-test -- conditional skip, the reason is in the message
@@ -8,7 +8,7 @@ test('3d model file', async ({page, request, browserName}) => {
const owner = env.GITEA_TEST_E2E_USER;
await apiCreateRepo(request, {name: repoName});
const stl = 'solid test\nfacet normal 0 0 1\nouter loop\nvertex 0 0 0\nvertex 1 0 0\nvertex 0 1 0\nendloop\nendfacet\nendsolid test\n';
await apiCreateFile(request, owner, repoName, 'test.stl', stl);
await apiCreateFiles(request, owner, repoName, [{path: 'test.stl', content: stl}]);
await page.goto(`/${owner}/${repoName}/src/branch/main/test.stl?display=rendered`);
const iframe = page.locator('iframe.external-render-iframe');
await expect(iframe).toBeVisible();
@@ -31,7 +31,7 @@ test('pdf file', async ({page, request}) => {
const repoName = `e2e-pdf-render-${randomString(8)}`;
const owner = env.GITEA_TEST_E2E_USER;
await apiCreateRepo(request, {name: repoName});
await apiCreateFile(request, owner, repoName, 'test.pdf', '%PDF-1.0\n%%EOF\n');
await apiCreateFiles(request, owner, repoName, [{path: 'test.pdf', content: '%PDF-1.0\n%%EOF\n'}]);
await page.goto(`/${owner}/${repoName}/src/branch/main/test.pdf`);
const container = page.locator('.file-view-render-container');
await expect(container).toHaveAttribute('data-render-name', 'pdf-viewer');
@@ -47,7 +47,7 @@ test('asciicast file', async ({page, request}) => {
await Promise.all([apiCreateRepo(request, {name: repoName, autoInit: false}), login(page)]);
const cast = '{"version": 2, "width": 80, "height": 24}\n[0.0, "o", "test-content"]\n';
// on an empty repo, apiCreateFile with newBranch creates that branch as the initial commit
await apiCreateFile(request, owner, repoName, 'test.cast', cast, {newBranch: branch});
await apiCreateFiles(request, owner, repoName, [{path: 'test.cast', content: cast}], {newBranch: branch});
await page.goto(`/${owner}/${repoName}/src/branch/${branchEnc}/test.cast`);
const iframe = page.locator('iframe.external-render-iframe');
const frame = iframe.contentFrame();

View File

@@ -1,13 +1,13 @@
import {env} from 'node:process';
import {test, expect} from '@playwright/test';
import {login, apiCreateRepo, apiCreateFile, randomString} from './utils.ts';
import {login, apiCreateRepo, apiCreateFiles, randomString} from './utils.ts';
test('create a pull request from the compare page', async ({page, request}) => {
const repoName = `e2e-pr-create-${randomString(8)}`;
const owner = env.GITEA_TEST_E2E_USER;
await apiCreateRepo(request, {name: repoName});
await Promise.all([
apiCreateFile(request, owner, repoName, 'feat.txt', 'feature content\n', {branch: 'main', newBranch: 'feat'}),
apiCreateFiles(request, owner, repoName, [{path: 'feat.txt', content: 'feature content\n'}], {branch: 'main', newBranch: 'feat'}),
login(page),
]);
// expand=1 renders the PR form directly, skipping the "New Pull Request" toggle click

View File

@@ -0,0 +1,87 @@
import {test, expect} from '@playwright/test';
import {apiCreateFiles, apiCreatePR, apiCreateRepo, apiCreateUser, apiUserHeaders, loginUser, randomString} from './utils.ts';
test('diff sidebar filtering', async ({page, request}) => {
const user = `df-${randomString(8)}`;
await apiCreateUser(request, user);
const headers = apiUserHeaders(user);
const repo = `e2e-difffilter-${randomString(8)}`;
await apiCreateRepo(request, {name: repo, headers});
await apiCreateFiles(request, user, repo, [
{path: 'src/a.ts', content: 'a\n'},
{path: 'src/b.ts', content: 'b\n'},
{path: 'src/.eslintrc', content: 'e\n'}, // dotfiles count as "No extension"
{path: 'styles/x.css', content: 'x\n'},
{path: 'docs/intro.md', content: 'r\n'},
{path: 'Makefile', content: 'm\n'},
], {branch: 'main', newBranch: 'feat', headers});
const prIndex = await apiCreatePR(request, user, repo, 'feat', 'main', 'diff filter test', {headers});
await loginUser(page, user);
await page.goto(`/${user}/${repo}/pulls/${prIndex}/files`);
const tree = page.locator('#diff-file-tree');
const items = tree.locator('.item-file');
const search = tree.getByRole('textbox');
const filterTrigger = tree.getByRole('button', {name: 'Filter by file extension'});
// every PR file is listed
await expect(items).toHaveCount(6);
// sidebar leaves the diff column the bulk of the viewport
const boxesWidth = (await page.locator('#diff-file-boxes').boundingBox())!.width;
const treeWidth = (await tree.boundingBox())!.width;
expect(boxesWidth).toBeGreaterThan(treeWidth * 2);
// search filters tree and file boxes
await search.fill('a.ts');
await expect(items).toHaveText([/a\.ts/]);
await expect(page.locator('.diff-file-box[data-new-filename="src/a.ts"]')).toBeVisible();
await tree.getByRole('button', {name: 'Clear filter'}).click();
await expect(items).toHaveCount(6);
// empty-result placeholder
await search.fill('zzz-no-such-file');
await expect(page.locator('#diff-no-matches')).toBeVisible();
await search.fill('');
// extensions sort alphabetically, then dotfiles, then files without extension
await filterTrigger.click();
const extItems = page.getByRole('menuitemcheckbox');
await expect(extItems).toHaveText(['.css1', '.md1', '.ts2', 'Dotfiles1', 'No extension1', 'All extensions']);
// deselecting .ts leaves the other extensions
const allExtensions = page.getByRole('menuitemcheckbox', {name: 'All extensions'});
await page.getByRole('menuitemcheckbox', {name: '.ts'}).click();
await expect(items).toHaveCount(4);
await expect(filterTrigger).toHaveClass(/\bindicator-dot\b/);
await expect(allExtensions).toHaveAttribute('aria-checked', 'false');
// "All extensions" cycles through select all, select none and back
await allExtensions.click();
await expect(allExtensions).toHaveAttribute('aria-checked', 'true');
await expect(items).toHaveCount(6);
await allExtensions.click();
await expect(items).toHaveCount(0);
await allExtensions.click();
await expect(items).toHaveCount(6);
await expect(filterTrigger).not.toHaveClass(/\bindicator-dot\b/);
// the extension filter lives in the URL and survives a reload
await page.getByRole('menuitemcheckbox', {name: '.ts'}).click();
await expect(page).toHaveURL(/file-filters/);
await page.reload();
await expect(items).toHaveCount(4);
await expect(filterTrigger).toHaveClass(/\bindicator-dot\b/);
// hiding the file tree drops the filter
await expect(page.locator('.diff-file-box[data-new-filename="src/a.ts"]')).toBeHidden();
await filterTrigger.click();
await page.locator('.diff-toggle-file-tree-button').click();
await expect(tree).toBeHidden();
await expect(page.locator('.diff-file-box[data-new-filename="src/a.ts"]')).toBeVisible();
});

View File

@@ -1,5 +1,5 @@
import {test, expect} from '@playwright/test';
import {apiCreateFile, apiCreatePR, apiCreateRepo, apiCreateReview, apiCreateUser, apiUserHeaders, loginUser, randomString} from './utils.ts';
import {apiCreateFiles, apiCreatePR, apiCreateRepo, apiCreateReview, apiCreateUser, apiUserHeaders, loginUser, randomString} from './utils.ts';
test('pr review flow', async ({page, request}) => {
const poster = `rv-poster-${randomString(8)}`;
@@ -8,7 +8,7 @@ test('pr review flow', async ({page, request}) => {
const posterHeaders = apiUserHeaders(poster);
const repoName = `e2e-prreview-${randomString(8)}`;
await apiCreateRepo(request, {name: repoName, headers: posterHeaders});
await apiCreateFile(request, poster, repoName, 'added.txt', 'new content\n', {branch: 'main', newBranch: 'feat'});
await apiCreateFiles(request, poster, repoName, [{path: 'added.txt', content: 'new content\n'}], {branch: 'main', newBranch: 'feat', headers: posterHeaders});
const prIndex = await apiCreatePR(request, poster, repoName, 'feat', 'main', 'review test', {headers: posterHeaders});
// reviewer seeds an inline comment via API so the poster's UI reply exercises the reply-to-review path (#35994)

View File

@@ -1,6 +1,6 @@
import {env} from 'node:process';
import {test, expect} from '@playwright/test';
import {apiCreateFile, apiCreatePR, apiCreateRepo, assertNoJsError, login, randomString} from './utils.ts';
import {apiCreateFiles, apiCreatePR, apiCreateRepo, assertNoJsError, login, randomString} from './utils.ts';
const owner = env.GITEA_TEST_E2E_USER;
@@ -8,7 +8,7 @@ test('merge box merges a pull request', async ({page, request}) => {
const repo = `e2e-merge-box-${randomString(8)}`;
const createPR = (async () => {
await apiCreateRepo(request, {name: repo});
await apiCreateFile(request, owner, repo, 'feat.txt', 'feature\n', {branch: 'main', newBranch: 'feat'});
await apiCreateFiles(request, owner, repo, [{path: 'feat.txt', content: 'feature\n'}], {branch: 'main', newBranch: 'feat'});
return apiCreatePR(request, owner, repo, 'feat', 'main', 'merge box test');
})();
const [index] = await Promise.all([createPR, login(page)]);

View File

@@ -12,8 +12,6 @@ export function randomString(length: number): string {
return result;
}
export const timeoutFactor = Number(env.GITEA_TEST_E2E_TIMEOUT_FACTOR) || 1;
export function baseUrl() {
return env.GITEA_TEST_E2E_URL?.replace(/\/$/g, '');
}
@@ -67,6 +65,17 @@ export async function apiStartStopwatch(requestContext: APIRequestContext, owner
}), 'apiStartStopwatch');
}
/** Commit one or more files in a single API call. */
export async function apiCreateFiles(requestContext: APIRequestContext, owner: string, repo: string, files: Array<{path: string; content: string}>, {branch, newBranch, headers}: {branch?: string; newBranch?: string; headers?: Record<string, string>} = {}) {
await apiRetry(() => requestContext.post(`${baseUrl()}/api/v1/repos/${owner}/${repo}/contents`, {
headers: headers || apiHeaders(),
data: {
branch, new_branch: newBranch,
files: files.map((file) => ({operation: 'create', path: file.path, content: Buffer.from(file.content, 'utf8').toString('base64')})),
},
}), 'apiCreateFiles');
}
export async function apiCancelStopwatch(requestContext: APIRequestContext, owner: string, repo: string, issueIndex: number, {headers}: {headers?: Record<string, string>} = {}) {
await apiRetry(() => requestContext.delete(`${baseUrl()}/api/v1/repos/${owner}/${repo}/issues/${issueIndex}/stopwatch/delete`, {
headers: headers || apiHeaders(),
@@ -80,20 +89,6 @@ export async function apiCloseIssue(requestContext: APIRequestContext, owner: st
}), 'apiCloseIssue');
}
export async function apiCreateFile(requestContext: APIRequestContext, owner: string, repo: string, filepath: string, content: string, {branch, newBranch, message}: {branch?: string; newBranch?: string; message?: string} = {}) {
await apiRetry(() => requestContext.post(`${baseUrl()}/api/v1/repos/${owner}/${repo}/contents/${filepath}`, {
headers: apiHeaders(),
data: {content: Buffer.from(content, 'utf8').toString('base64'), branch, new_branch: newBranch, message},
}), 'apiCreateFile');
}
export async function apiCreateBranch(requestContext: APIRequestContext, owner: string, repo: string, newBranch: string) {
await apiRetry(() => requestContext.post(`${baseUrl()}/api/v1/repos/${owner}/${repo}/branches`, {
headers: apiHeaders(),
data: {new_branch_name: newBranch},
}), 'apiCreateBranch');
}
/** Create a PR via API. Returns the PR index for subsequent operations. */
export async function apiCreatePR(requestContext: APIRequestContext, owner: string, repo: string, head: string, base: string, title: string, {headers}: {headers?: Record<string, string>} = {}): Promise<number> {
let prIndex = 0;

View File

@@ -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 {

View File

@@ -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"] {

View File

@@ -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 {

View File

@@ -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);

View File

@@ -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>

View File

@@ -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>

View File

@@ -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');
});

View File

@@ -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>

View File

@@ -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);
}

View File

@@ -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) {

View File

@@ -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');
});

View File

@@ -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;
}

View File

@@ -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);
});

View File

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

View File

@@ -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,

View File

@@ -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('');

View File

@@ -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 */