diff --git a/package.json b/package.json index f2bb9ca..ec6afdb 100644 --- a/package.json +++ b/package.json @@ -7207,6 +7207,18 @@ "icon": "$(list-flat)" }, { + "command": "gitlens.views.setResultsCommitsFilterAuthors", + "title": "Filter Commits by Author...", + "category": "GitLens", + "icon": "$(filter)" + }, + { + "command": "gitlens.views.setResultsCommitsFilterOff", + "title": "Clear Filter", + "category": "GitLens", + "icon": "$(filter-filled)" + }, + { "command": "gitlens.views.searchAndCompare.setShowAvatarsOn", "title": "Show Avatars", "category": "GitLens" @@ -7221,7 +7233,7 @@ "title": "Swap Comparison", "category": "GitLens", "icon": "$(arrow-swap)", - "enablement": "viewItem =~ /gitlens:compare:results(?!:)\\b(?!.*?\\b\\+working\\b)/" + "enablement": "viewItem =~ /gitlens:compare:results\\b(?!.*?\\b\\+working\\b)/" }, { "command": "gitlens.views.searchAndCompare.setFilesFilterOnLeft", @@ -9890,6 +9902,14 @@ "when": "false" }, { + "command": "gitlens.views.setResultsCommitsFilterAuthors", + "when": "false" + }, + { + "command": "gitlens.views.setResultsCommitsFilterOff", + "when": "false" + }, + { "command": "gitlens.views.searchAndCompare.setShowAvatarsOn", "when": "false" }, @@ -12828,7 +12848,7 @@ { "command": "gitlens.views.editNode", "when": "viewItem =~ /gitlens:compare:branch\\b(?=.*?\\b\\+comparing\\b)/", - "group": "inline@98" + "group": "inline@96" }, { "command": "gitlens.views.setBranchComparisonToWorking", @@ -12841,21 +12861,26 @@ "group": "inline@2" }, { - "command": "gitlens.views.editNode", - "when": "viewItem =~ /gitlens:compare:branch\\b(?=.*?\\b\\+comparing\\b)/", - "group": "1_gitlens@1" - }, - { "command": "gitlens.views.setBranchComparisonToWorking", "when": "viewItem =~ /gitlens:compare:branch\\b(?=.*?\\b\\+root\\b)(?=.*?\\b\\+current\\b)(?=.*?\\b\\+branch\\b)/", - "group": "1_gitlens@2" + "group": "1_gitlens@1" }, { "command": "gitlens.views.setBranchComparisonToBranch", "when": "viewItem =~ /gitlens:compare:branch\\b(?=.*?\\b\\+root\\b)(?=.*?\\b\\+current\\b)(?=.*?\\b\\+working\\b)/", + "group": "1_gitlens@1" + }, + { + "command": "gitlens.views.editNode", + "when": "viewItem =~ /gitlens:compare:branch\\b(?=.*?\\b\\+comparing\\b)/", "group": "1_gitlens@2" }, { + "command": "gitlens.views.clearNode", + "when": "viewItem =~ /gitlens:compare:branch\\b(?=.*?\\b\\+comparing\\b)/", + "group": "1_gitlens@3" + }, + { "command": "gitlens.views.branches.setShowBranchComparisonOff", "when": "view =~ /gitlens\\.views\\.branches\\b/ && viewItem =~ /gitlens:compare:branch\\b/", "group": "8_gitlens_toggles@1" @@ -12876,13 +12901,8 @@ "group": "8_gitlens_toggles@99" }, { - "command": "gitlens.views.clearNode", - "when": "viewItem =~ /gitlens:compare:branch\\b(?=.*?\\b\\+comparing\\b)/", - "group": "9_gitlens@1" - }, - { "command": "gitlens.views.searchAndCompare.swapComparison", - "when": "viewItem =~ /gitlens:compare:results(?!:)\\b/", + "when": "viewItem =~ /gitlens:compare:results\\b/", "group": "inline@1" }, { @@ -12897,32 +12917,47 @@ }, { "command": "gitlens.views.refreshNode", - "when": "viewItem =~ /gitlens:compare:(branch(?=.*?\\b\\+comparing\\b)|results(?!:))\\b/", + "when": "viewItem =~ /gitlens:compare:(branch(?=.*?\\b\\+comparing\\b)|results)\\b/", "group": "inline@97" }, { "command": "gitlens.views.refreshNode", - "when": "viewItem =~ /gitlens:search:results(?!:)\\b/", + "when": "viewItem =~ /gitlens:search:results\\b/", "group": "inline@97" }, { + "command": "gitlens.views.setResultsCommitsFilterOff", + "when": "viewItem =~ /gitlens:compare:(results|branch)\\b(?=.*?\\b\\+filtered\\b)/", + "group": "inline@96" + }, + { "command": "gitlens.views.searchAndCompare.swapComparison", - "when": "viewItem =~ /gitlens:compare:results(?!:)\\b(?!.*?\\b\\+working\\b)/", + "when": "viewItem =~ /gitlens:compare:results\\b(?!.*?\\b\\+working\\b)/", "group": "1_gitlens_actions@2" }, { "command": "gitlens.views.openDirectoryDiff", - "when": "viewItem =~ /gitlens:compare:results(?!:)\\b/", + "when": "viewItem =~ /gitlens:compare:results\\b/", "group": "2_gitlens_quickopen@1" }, { + "command": "gitlens.views.setResultsCommitsFilterOff", + "when": "viewItem =~ /gitlens:compare:(results|branch)\\b(?=.*?\\b\\+filtered\\b)/", + "group": "7_gitlens_filter@1" + }, + { + "command": "gitlens.views.setResultsCommitsFilterAuthors", + "when": "viewItem =~ /gitlens:compare:(results|branch)\\b/", + "group": "7_gitlens_filter@2" + }, + { "command": "gitlens.views.editNode", - "when": "viewItem =~ /gitlens:search:results(?!:)\\b/", + "when": "viewItem =~ /gitlens:search:results\\b/", "group": "inline@1" }, { "command": "gitlens.views.editNode", - "when": "viewItem =~ /gitlens:search:results(?!:)\\b/", + "when": "viewItem =~ /gitlens:search:results\\b/", "group": "1_gitlens_actions@1" }, { @@ -13102,7 +13137,7 @@ { "command": "gitlens.views.dismissNode", "when": "viewItem =~ /gitlens:(compare:picker:ref|(compare|search):results(?!:)\\b)\\b(?!:(commits|files))/", - "group": "8_gitlens_actions@98" + "group": "1_gitlens_actions@98" }, { "command": "gitlens.views.collapseNode", @@ -13117,7 +13152,7 @@ { "command": "gitlens.views.refreshNode", "when": "viewItem =~ /gitlens:(?!(file|message|date-marker)\\b)/", - "group": "9_gitlens@99" + "group": "9_gitlens_z@99" }, { "command": "gitlens.views.loadAllChildren", diff --git a/src/env/node/git/git.ts b/src/env/node/git/git.ts index 52169a0..e522129 100644 --- a/src/env/node/git/git.ts +++ b/src/env/node/git/git.ts @@ -1660,12 +1660,17 @@ export class Git { async rev_list__left_right( repoPath: string, refs: string[], + authors?: GitUser[] | undefined, ): Promise<{ ahead: number; behind: number } | undefined> { + const params = ['rev-list', '--left-right', '--count']; + + if (authors?.length) { + params.push(...authors.map(a => `--author=^${a.name} <${a.email}>$`)); + } + const data = await this.git( { cwd: repoPath, errors: GitErrorHandling.Ignore }, - 'rev-list', - '--left-right', - '--count', + ...params, ...refs, '--', ); diff --git a/src/env/node/git/localGitProvider.ts b/src/env/node/git/localGitProvider.ts index 79844e7..75107fa 100644 --- a/src/env/node/git/localGitProvider.ts +++ b/src/env/node/git/localGitProvider.ts @@ -1396,8 +1396,9 @@ export class LocalGitProvider implements GitProvider, Disposable { getAheadBehindCommitCount( repoPath: string, refs: string[], + options?: { authors?: GitUser[] | undefined }, ): Promise<{ ahead: number; behind: number } | undefined> { - return this.git.rev_list__left_right(repoPath, refs); + return this.git.rev_list__left_right(repoPath, refs, options?.authors); } @gate((u, d) => `${u.toString()}|${d?.isDirty}`) diff --git a/src/git/gitProvider.ts b/src/git/gitProvider.ts index 508f976..b69fe57 100644 --- a/src/git/gitProvider.ts +++ b/src/git/gitProvider.ts @@ -172,7 +172,11 @@ export interface GitProvider extends Disposable { }, ): Promise; findRepositoryUri(uri: Uri, isDirectory?: boolean): Promise; - getAheadBehindCommitCount(repoPath: string, refs: string[]): Promise<{ ahead: number; behind: number } | undefined>; + getAheadBehindCommitCount( + repoPath: string, + refs: string[], + options?: { authors?: GitUser[] | undefined }, + ): Promise<{ ahead: number; behind: number } | undefined>; /** * Returns the blame of a file * @param uri Uri of the file to blame diff --git a/src/git/gitProviderService.ts b/src/git/gitProviderService.ts index 1cfcc4b..9a691e5 100644 --- a/src/git/gitProviderService.ts +++ b/src/git/gitProviderService.ts @@ -1403,9 +1403,10 @@ export class GitProviderService implements Disposable { getAheadBehindCommitCount( repoPath: string | Uri, refs: string[], + options?: { authors?: GitUser[] | undefined }, ): Promise<{ ahead: number; behind: number } | undefined> { const { provider, path } = this.getProvider(repoPath); - return provider.getAheadBehindCommitCount(path, refs); + return provider.getAheadBehindCommitCount(path, refs, options); } @log({ args: { 1: d => d?.isDirty } }) diff --git a/src/plus/github/githubGitProvider.ts b/src/plus/github/githubGitProvider.ts index c057f66..6df75da 100644 --- a/src/plus/github/githubGitProvider.ts +++ b/src/plus/github/githubGitProvider.ts @@ -521,6 +521,7 @@ export class GitHubGitProvider implements GitProvider, Disposable { async getAheadBehindCommitCount( _repoPath: string, _refs: string[], + _options?: { authors?: GitUser[] | undefined }, ): Promise<{ ahead: number; behind: number } | undefined> { return undefined; } diff --git a/src/views/nodes/compareBranchNode.ts b/src/views/nodes/compareBranchNode.ts index 9ae97fd..9f8f889 100644 --- a/src/views/nodes/compareBranchNode.ts +++ b/src/views/nodes/compareBranchNode.ts @@ -6,6 +6,7 @@ import { GlyphChars } from '../../constants'; import type { GitUri } from '../../git/gitUri'; import type { GitBranch } from '../../git/models/branch'; import { createRevisionRange, shortenRevision } from '../../git/models/reference'; +import type { GitUser } from '../../git/models/user'; import { CommandQuickPickItem } from '../../quickpicks/items/common'; import { showReferencePicker } from '../../quickpicks/referencePicker'; import { gate } from '../../system/decorators/gate'; @@ -27,7 +28,15 @@ import { ResultsFilesNode } from './resultsFilesNode'; import type { ViewNode } from './viewNode'; import { ContextValues, getViewNodeId, SubscribeableViewNode } from './viewNode'; -export class CompareBranchNode extends SubscribeableViewNode<'compare-branch', ViewsWithBranches | WorktreesView> { +type State = { + filterCommits: GitUser[] | undefined; +}; + +export class CompareBranchNode extends SubscribeableViewNode< + 'compare-branch', + ViewsWithBranches | WorktreesView, + State +> { private _children: ViewNode[] | undefined; private _compareWith: StoredBranchComparison | undefined; @@ -65,6 +74,19 @@ export class CompareBranchNode extends SubscribeableViewNode<'compare-branch', V }; } + private _isFiltered: boolean | undefined; + private get filterByAuthors(): GitUser[] | undefined { + const authors = this.getState('filterCommits'); + + const isFiltered = Boolean(authors?.length); + if (this._isFiltered != null && this._isFiltered !== isFiltered) { + this.updateContext({ comparisonFiltered: isFiltered }); + } + this._isFiltered = isFiltered; + + return authors; + } + get repoPath(): string { return this.branch.repoPath; } @@ -87,9 +109,11 @@ export class CompareBranchNode extends SubscribeableViewNode<'compare-branch', V const ahead = this.ahead; const behind = this.behind; - const aheadBehindCounts = await this.view.container.git.getAheadBehindCommitCount(this.branch.repoPath, [ - createRevisionRange(behind.ref1, behind.ref2, '...'), - ]); + const aheadBehindCounts = await this.view.container.git.getAheadBehindCommitCount( + this.branch.repoPath, + [createRevisionRange(behind.ref1, behind.ref2, '...')], + { authors: this.filterByAuthors }, + ); const mergeBase = (await this.view.container.git.getMergeBase(this.repoPath, behind.ref1, behind.ref2, { forkPoint: true, @@ -138,17 +162,23 @@ export class CompareBranchNode extends SubscribeableViewNode<'compare-branch', V expand: false, }, ), - new ResultsFilesNode( - this.view, - this, - this.repoPath, - this._compareWith.ref || 'HEAD', - this.compareWithWorkingTree ? '' : this.branch.ref, - this.getFilesQuery.bind(this), - undefined, - { expand: false }, - ), ]; + + // Can't support showing files when commits are filtered + if (!this.filterByAuthors?.length) { + this._children.push( + new ResultsFilesNode( + this.view, + this, + this.repoPath, + this._compareWith.ref || 'HEAD', + this.compareWithWorkingTree ? '' : this.branch.ref, + this.getFilesQuery.bind(this), + undefined, + { expand: false }, + ), + ); + } } return this._children; } @@ -179,7 +209,9 @@ export class CompareBranchNode extends SubscribeableViewNode<'compare-branch', V item.id = this.id; item.contextValue = `${ContextValues.CompareBranch}${this.branch.current ? '+current' : ''}+${ this.comparisonType - }${this._compareWith == null ? '' : '+comparing'}${this.root ? '+root' : ''}`; + }${this._compareWith == null ? '' : '+comparing'}${this.root ? '+root' : ''}${ + this.filterByAuthors?.length ? '+filtered' : '' + }`; if (this._compareWith == null) { item.command = { @@ -338,6 +370,7 @@ export class CompareBranchNode extends SubscribeableViewNode<'compare-branch', V const log = await this.view.container.git.getLog(repoPath, { limit: limit, ref: range, + authors: this.filterByAuthors, }); const results: Mutable> = { diff --git a/src/views/nodes/compareResultsNode.ts b/src/views/nodes/compareResultsNode.ts index aff9370..ed77afd 100644 --- a/src/views/nodes/compareResultsNode.ts +++ b/src/views/nodes/compareResultsNode.ts @@ -4,6 +4,7 @@ import { md5 } from '@env/crypto'; import type { StoredNamedRef } from '../../constants'; import { GitUri } from '../../git/gitUri'; import { createRevisionRange, shortenRevision } from '../../git/models/reference'; +import type { GitUser } from '../../git/models/user'; import { gate } from '../../system/decorators/gate'; import { debug, log } from '../../system/decorators/log'; import { getSettledValue } from '../../system/promise'; @@ -19,7 +20,11 @@ import { ContextValues, getViewNodeId, SubscribeableViewNode } from './viewNode' let instanceId = 0; -export class CompareResultsNode extends SubscribeableViewNode<'compare-results', SearchAndCompareView> { +type State = { + filterCommits: GitUser[] | undefined; +}; + +export class CompareResultsNode extends SubscribeableViewNode<'compare-results', SearchAndCompareView, State> { private _instanceId: number; constructor( @@ -80,6 +85,19 @@ export class CompareResultsNode extends SubscribeableViewNode<'compare-results', return this._compareWith; } + private _isFiltered: boolean | undefined; + private get filterByAuthors(): GitUser[] | undefined { + const authors = this.getState('filterCommits'); + + const isFiltered = Boolean(authors?.length); + if (this._isFiltered != null && this._isFiltered !== isFiltered) { + this.updateContext({ comparisonFiltered: isFiltered }); + } + this._isFiltered = isFiltered; + + return authors; + } + protected override subscribe(): Disposable | Promise | undefined { return this.view.onDidChangeNodesCheckedState(this.onNodesCheckedStateChanged, this); } @@ -102,9 +120,12 @@ export class CompareResultsNode extends SubscribeableViewNode<'compare-results', const ahead = this.ahead; const behind = this.behind; - const aheadBehindCounts = await this.view.container.git.getAheadBehindCommitCount(this.repoPath, [ - createRevisionRange(behind.ref1 || 'HEAD', behind.ref2, '...'), - ]); + const aheadBehindCounts = await this.view.container.git.getAheadBehindCommitCount( + this.repoPath, + [createRevisionRange(behind.ref1 || 'HEAD', behind.ref2, '...')], + { authors: this.filterByAuthors }, + ); + const mergeBase = (await this.view.container.git.getMergeBase(this.repoPath, behind.ref1, behind.ref2, { forkPoint: true, @@ -151,17 +172,23 @@ export class CompareResultsNode extends SubscribeableViewNode<'compare-results', expand: false, }, ), - new ResultsFilesNode( - this.view, - this, - this.repoPath, - this._compareWith.ref, - this._ref.ref, - this.getFilesQuery.bind(this), - undefined, - { expand: false }, - ), ]; + + // Can't support showing files when commits are filtered + if (!this.filterByAuthors?.length) { + this._children.push( + new ResultsFilesNode( + this.view, + this, + this.repoPath, + this._compareWith.ref, + this._ref.ref, + this.getFilesQuery.bind(this), + undefined, + { expand: false }, + ), + ); + } } return this._children; } @@ -183,7 +210,9 @@ export class CompareResultsNode extends SubscribeableViewNode<'compare-results', TreeItemCollapsibleState.Collapsed, ); item.id = this.id; - item.contextValue = `${ContextValues.CompareResults}${this._ref.ref === '' ? '+working' : ''}`; + item.contextValue = `${ContextValues.CompareResults}${this._ref.ref === '' ? '+working' : ''}${ + this.filterByAuthors?.length ? '+filtered' : '' + }`; item.description = description; item.iconPath = new ThemeIcon('compare-changes'); @@ -299,6 +328,7 @@ export class CompareResultsNode extends SubscribeableViewNode<'compare-results', const log = await this.view.container.git.getLog(repoPath, { limit: limit, ref: range, + authors: this.filterByAuthors, }); const results: Mutable> = { diff --git a/src/views/nodes/resultsCommitsNode.ts b/src/views/nodes/resultsCommitsNode.ts index 06c660f..7eae290 100644 --- a/src/views/nodes/resultsCommitsNode.ts +++ b/src/views/nodes/resultsCommitsNode.ts @@ -77,6 +77,10 @@ export class ResultsCommitsNode | undefined; async getChildren(): Promise { @@ -96,7 +100,8 @@ export class ResultsCommitsNode extends ViewNode { + State extends object = any, +> extends ViewNode { protected disposable: Disposable; protected subscription: Promise | undefined; @@ -831,24 +835,41 @@ export function getNodeRepoPath(node?: ViewNode): string | undefined { } type TreeViewNodesByType = { - [T in TreeViewNodeTypes]: ViewNode; -} & { - ['branch']: BranchNode; - ['commit']: CommitNode; - ['commit-file']: CommitFileNode; - ['conflict-file']: MergeConflictFileNode; - ['file-commit']: FileRevisionAsCommitNode; - ['folder']: FolderNode; - ['line-history-tracker']: LineHistoryTrackerNode; - ['repository']: RepositoryNode; - ['repo-folder']: RepositoryFolderNode; - ['results-file']: ResultsFileNode; - ['stash']: StashNode; - ['stash-file']: StashFileNode; - ['status-file']: StatusFileNode; - ['tag']: TagNode; - ['uncommitted-file']: UncommittedFileNode; - // Add more real types as needed + [T in TreeViewNodeTypes]: T extends 'branch' + ? BranchNode + : T extends 'commit' + ? CommitNode + : T extends 'commit-file' + ? CommitFileNode + : T extends 'compare-branch' + ? CompareBranchNode + : T extends 'compare-results' + ? CompareResultsNode + : T extends 'conflict-file' + ? MergeConflictFileNode + : T extends 'file-commit' + ? FileRevisionAsCommitNode + : T extends 'folder' + ? FolderNode + : T extends 'line-history-tracker' + ? LineHistoryTrackerNode + : T extends 'repository' + ? RepositoryNode + : T extends 'repo-folder' + ? RepositoryFolderNode + : T extends 'results-file' + ? ResultsFileNode + : T extends 'stash' + ? StashNode + : T extends 'stash-file' + ? StashFileNode + : T extends 'status-file' + ? StatusFileNode + : T extends 'tag' + ? TagNode + : T extends 'uncommitted-file' + ? UncommittedFileNode + : ViewNode; }; export function isViewNode(node: unknown): node is ViewNode; diff --git a/src/views/viewCommands.ts b/src/views/viewCommands.ts index 846bd56..310f6e5 100644 --- a/src/views/viewCommands.ts +++ b/src/views/viewCommands.ts @@ -19,8 +19,10 @@ import * as TagActions from '../git/actions/tag'; import * as WorktreeActions from '../git/actions/worktree'; import { GitUri } from '../git/gitUri'; import { deletedOrMissing } from '../git/models/constants'; +import { matchContributor } from '../git/models/contributor'; import type { GitStashReference } from '../git/models/reference'; import { createReference, getReferenceLabel, shortenRevision } from '../git/models/reference'; +import { showContributorsPicker } from '../quickpicks/contributorsPicker'; import { executeActionCommand, executeCommand, @@ -297,7 +299,19 @@ export class ViewCommands { n => this.openWorktree(n, { location: 'newWindow' }), this, ); + + registerViewCommand( + 'gitlens.views.setResultsCommitsFilterAuthors', + n => this.setResultsCommitsFilter(n, true), + this, + ); + registerViewCommand( + 'gitlens.views.setResultsCommitsFilterOff', + n => this.setResultsCommitsFilter(n, false), + this, + ); } + @debug() private addAuthors(node?: ViewNode) { return ContributorActions.addAuthors(getNodeRepoPath(node)); @@ -1316,4 +1330,47 @@ export class ViewCommands { return CommitActions.openFilesAtRevision(node.commit); } + + @debug() + private async setResultsCommitsFilter(node: ViewNode, filter: boolean) { + if (!node?.is('compare-results') && !node?.is('compare-branch')) return; + + const repo = this.container.git.getRepository(node.repoPath); + if (repo == null) return; + + if (filter) { + let authors = node.getState('filterCommits'); + if (authors == null) { + const current = await this.container.git.getCurrentUser(repo.uri); + authors = current != null ? [current] : undefined; + } + + const result = await showContributorsPicker( + this.container, + repo, + 'Filter Commits', + repo.virtual ? 'Choose a contributor to show commits from' : 'Choose contributors to show commits from', + { + appendReposToTitle: true, + clearButton: true, + multiselect: !repo.virtual, + picked: c => authors?.some(u => matchContributor(c, u)) ?? false, + }, + ); + if (result == null) return; + + if (result.length === 0) { + filter = false; + node.deleteState('filterCommits'); + } else { + node.storeState('filterCommits', result); + } + } else if (repo != null) { + node.deleteState('filterCommits'); + } else { + node.deleteState('filterCommits'); + } + + void node.triggerChange(true); + } }