diff --git a/package.json b/package.json index e6b6983..f2bb9ca 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "Supercharge Git within VS Code — Visualize code authorship at a glance via Git blame annotations and CodeLens, seamlessly navigate and explore Git repositories, gain valuable insights via rich visualizations and powerful comparison commands, and so much more", "version": "14.4.0", "engines": { - "vscode": "^1.80.0" + "vscode": "^1.81.0" }, "license": "SEE LICENSE IN LICENSE", "publisher": "eamodio", @@ -6680,14 +6680,14 @@ "icon": "$(list-flat)" }, { - "command": "gitlens.views.commits.setMyCommitsOnlyOn", - "title": "View Only My Commits", + "command": "gitlens.views.commits.setCommitsFilterAuthors", + "title": "Filter Commits by Author...", "category": "GitLens", "icon": "$(filter)" }, { - "command": "gitlens.views.commits.setMyCommitsOnlyOff", - "title": "View All Commits", + "command": "gitlens.views.commits.setCommitsFilterOff", + "title": "Clear Filter", "category": "GitLens", "icon": "$(filter-filled)" }, @@ -9502,11 +9502,11 @@ "when": "false" }, { - "command": "gitlens.views.commits.setMyCommitsOnlyOn", + "command": "gitlens.views.commits.setCommitsFilterAuthors", "when": "false" }, { - "command": "gitlens.views.commits.setMyCommitsOnlyOff", + "command": "gitlens.views.commits.setCommitsFilterOff", "when": "false" }, { @@ -11016,8 +11016,8 @@ "group": "navigation@99" }, { - "command": "gitlens.views.commits.setMyCommitsOnlyOff", - "when": "view =~ /^gitlens\\.views\\.commits/ && gitlens:views:commits:myCommitsOnly", + "command": "gitlens.views.commits.setCommitsFilterOff", + "when": "view =~ /^gitlens\\.views\\.commits/ && gitlens:views:commits:filtered", "group": "navigation@50" }, { @@ -11026,29 +11026,29 @@ "group": "navigation@99" }, { - "command": "gitlens.views.commits.setMyCommitsOnlyOn", - "when": "view =~ /^gitlens\\.views\\.commits/ && !gitlens:views:commits:myCommitsOnly", + "command": "gitlens.views.commits.setCommitsFilterOff", + "when": "view =~ /^gitlens\\.views\\.commits/ && gitlens:views:commits:filtered", "group": "3_gitlens@0" }, { - "command": "gitlens.views.commits.setMyCommitsOnlyOff", - "when": "view =~ /^gitlens\\.views\\.commits/ && gitlens:views:commits:myCommitsOnly", - "group": "3_gitlens@0" + "command": "gitlens.views.commits.setCommitsFilterAuthors", + "when": "view =~ /^gitlens\\.views\\.commits/", + "group": "3_gitlens@1" }, { "command": "gitlens.views.commits.setFilesLayoutToAuto", "when": "view =~ /^gitlens\\.views\\.commits/ && config.gitlens.views.commits.files.layout == tree", - "group": "3_gitlens@1" + "group": "3_gitlens@2" }, { "command": "gitlens.views.commits.setFilesLayoutToList", "when": "view =~ /^gitlens\\.views\\.commits/ && config.gitlens.views.commits.files.layout == auto", - "group": "3_gitlens@1" + "group": "3_gitlens@2" }, { "command": "gitlens.views.commits.setFilesLayoutToTree", "when": "view =~ /^gitlens\\.views\\.commits/ && config.gitlens.views.commits.files.layout == list", - "group": "3_gitlens@1" + "group": "3_gitlens@2" }, { "command": "gitlens.views.commits.setShowAvatarsOn", @@ -12652,6 +12652,11 @@ "group": "inline@100" }, { + "command": "gitlens.views.commits.setCommitsFilterOff", + "when": "view =~ /^gitlens\\.views\\.commits/ && viewItem =~ /gitlens:repo-folder\\b(?=.*?\\b\\+filtered\\b)/ && gitlens:views:commits:filtered", + "group": "inline@101" + }, + { "command": "gitlens.views.fetch", "when": "gitlens:hasRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:repo-folder\\b/", "group": "1_gitlens_actions@1" @@ -12718,6 +12723,16 @@ "group": "8_gitlens_actions_@2" }, { + "command": "gitlens.views.commits.setCommitsFilterOff", + "when": "view =~ /^gitlens\\.views\\.commits/ && viewItem =~ /gitlens:repo-folder\\b(?=.*?\\b\\+filtered\\b)/ && gitlens:views:commits:filtered", + "group": "8_gitlens_filter_@1" + }, + { + "command": "gitlens.views.commits.setCommitsFilterAuthors", + "when": "view =~ /^gitlens\\.views\\.commits/ && viewItem =~ /gitlens:repo-folder\\b/", + "group": "8_gitlens_filter_@2" + }, + { "command": "gitlens.views.publishRepository", "when": "!gitlens:hasRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:status(\\-branch)?:upstream:none/", "group": "inline@1" @@ -15196,7 +15211,7 @@ "@types/react": "17.0.69", "@types/react-dom": "17.0.21", "@types/sortablejs": "1.15.4", - "@types/vscode": "1.80.0", + "@types/vscode": "1.81.0", "@typescript-eslint/eslint-plugin": "6.8.0", "@typescript-eslint/parser": "6.8.0", "@vscode/test-electron": "2.3.5", diff --git a/src/commands/quickCommand.buttons.ts b/src/commands/quickCommand.buttons.ts index 4211ceb..67895c6 100644 --- a/src/commands/quickCommand.buttons.ts +++ b/src/commands/quickCommand.buttons.ts @@ -57,6 +57,11 @@ export class SelectableQuickInputButton extends ToggleQuickInputButton { } } +export const ClearQuickInputButton: QuickInputButton = { + iconPath: new ThemeIcon('clear-all'), + tooltip: 'Clear', +}; + export const FetchQuickInputButton: QuickInputButton = { iconPath: new ThemeIcon('sync'), tooltip: 'Fetch', diff --git a/src/commands/quickCommand.steps.ts b/src/commands/quickCommand.steps.ts index 2a0aae8..9d6184a 100644 --- a/src/commands/quickCommand.steps.ts +++ b/src/commands/quickCommand.steps.ts @@ -1285,16 +1285,19 @@ export async function* pickContributorsStep< ): AsyncStepResultGenerator { const message = (await Container.instance.git.getOrOpenScmRepository(state.repo.path))?.inputBox.value; + const contributors = await Container.instance.git.getContributors(state.repo.path); const step = createPickStep({ title: appendReposToTitle(context.title, state, context), allowEmpty: true, multiselect: true, placeholder: placeholder, matchOnDescription: true, - items: (await Container.instance.git.getContributors(state.repo.path)).map(c => - createContributorQuickPickItem(c, message?.includes(c.getCoauthor()), { - buttons: [RevealInSideBarQuickInputButton], - }), + items: await Promise.all( + contributors.map(c => + createContributorQuickPickItem(c, message?.includes(c.getCoauthor()), { + buttons: [RevealInSideBarQuickInputButton], + }), + ), ), onDidClickItemButton: (quickpick, button, { item }) => { if (button === RevealInSideBarQuickInputButton) { diff --git a/src/constants.ts b/src/constants.ts index c01fc51..58289ca 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -334,7 +334,7 @@ export type TreeViewCommands = `gitlens.views.${ | 'copy' | 'refresh' | `setFilesLayoutTo${'Auto' | 'List' | 'Tree'}` - | `setMyCommitsOnly${'On' | 'Off'}` + | `setCommitsFilter${'Authors' | 'Off'}` | `setShowAvatars${'On' | 'Off'}` | `setShowBranchComparison${'On' | 'Off'}` | `setShowBranchPullRequest${'On' | 'Off'}`}` @@ -527,7 +527,7 @@ export type TreeViewSubscribableNodeTypes = | 'line-history-tracker' | 'repositories' | 'repository' - | 'repository-folder' + | 'repo-folder' | 'search-results' | 'workspace'; export type TreeViewNodeTypes = @@ -587,7 +587,7 @@ export type ContextKeys = | `${typeof extensionPrefix}:untrusted` | `${typeof extensionPrefix}:views:canCompare` | `${typeof extensionPrefix}:views:canCompare:file` - | `${typeof extensionPrefix}:views:commits:myCommitsOnly` + | `${typeof extensionPrefix}:views:commits:filtered` | `${typeof extensionPrefix}:views:fileHistory:canPin` | `${typeof extensionPrefix}:views:fileHistory:cursorFollowing` | `${typeof extensionPrefix}:views:fileHistory:editorFollowing` diff --git a/src/git/models/contributor.ts b/src/git/models/contributor.ts index 4c35935..63cb05d 100644 --- a/src/git/models/contributor.ts +++ b/src/git/models/contributor.ts @@ -5,6 +5,7 @@ import { configuration } from '../../system/configuration'; import { formatDate, fromNow } from '../../system/date'; import { memoize } from '../../system/decorators/memoize'; import { sortCompare } from '../../system/string'; +import type { GitUser } from './user'; export interface ContributorSortOptions { current?: true; @@ -104,3 +105,7 @@ export class GitContributor { return `${this.name}${this.email ? ` <${this.email}>` : ''}`; } } + +export function matchContributor(c: GitContributor, user: GitUser): boolean { + return c.name === user.name && c.email === user.email && c.username === user.username; +} diff --git a/src/quickpicks/contributorsPicker.ts b/src/quickpicks/contributorsPicker.ts new file mode 100644 index 0000000..a0a1c3d --- /dev/null +++ b/src/quickpicks/contributorsPicker.ts @@ -0,0 +1,93 @@ +import type { Disposable } from 'vscode'; +import { window } from 'vscode'; +import { ClearQuickInputButton } from '../commands/quickCommand.buttons'; +import { GlyphChars, quickPickTitleMaxChars } from '../constants'; +import type { Container } from '../container'; +import type { GitContributor } from '../git/models/contributor'; +import type { Repository } from '../git/models/repository'; +import { defer } from '../system/promise'; +import { pad, truncate } from '../system/string'; +import { getQuickPickIgnoreFocusOut } from '../system/utils'; +import type { ContributorQuickPickItem } from './items/gitCommands'; +import { createContributorQuickPickItem } from './items/gitCommands'; + +export async function showContributorsPicker( + container: Container, + repository: Repository, + title: string, + placeholder: string, + options?: { + appendReposToTitle?: boolean; + clearButton?: boolean; + multiselect?: boolean; + picked?: (contributor: GitContributor) => boolean; + }, +): Promise { + const deferred = defer(); + const disposables: Disposable[] = []; + + try { + const quickpick = window.createQuickPick(); + disposables.push( + quickpick, + quickpick.onDidHide(() => deferred.fulfill(undefined)), + quickpick.onDidAccept(() => + !quickpick.busy ? deferred.fulfill(quickpick.selectedItems.map(c => c.item)) : undefined, + ), + quickpick.onDidTriggerButton(e => { + if (e === ClearQuickInputButton) { + if (quickpick.canSelectMany) { + quickpick.selectedItems = []; + } else { + deferred.fulfill([]); + } + } + }), + ); + + quickpick.ignoreFocusOut = getQuickPickIgnoreFocusOut(); + + quickpick.title = options?.appendReposToTitle ? appendRepoToTitle(container, title, repository) : title; + quickpick.placeholder = placeholder; + quickpick.matchOnDescription = true; + quickpick.matchOnDetail = true; + quickpick.canSelectMany = options?.multiselect ?? true; + + quickpick.buttons = options?.clearButton ? [ClearQuickInputButton] : []; + + quickpick.busy = true; + quickpick.show(); + + const contributors = await repository.getContributors(); + if (!deferred.pending) return; + + const items = await Promise.all( + contributors.map(c => createContributorQuickPickItem(c, options?.picked?.(c) ?? false)), + ); + + if (!deferred.pending) return; + + quickpick.items = items; + if (quickpick.canSelectMany) { + quickpick.selectedItems = items.filter(i => i.picked); + } else { + quickpick.activeItems = items.filter(i => i.picked); + } + + quickpick.busy = false; + + const picks = await deferred.promise; + return picks; + } finally { + disposables.forEach(d => void d.dispose()); + } +} + +function appendRepoToTitle(container: Container, title: string, repo: Repository) { + return container.git.openRepositoryCount <= 1 + ? title + : `${title}${truncate( + `${pad(GlyphChars.Dot, 2, 2)}${repo.formattedName}`, + quickPickTitleMaxChars - title.length, + )}`; +} diff --git a/src/quickpicks/items/gitCommands.ts b/src/quickpicks/items/gitCommands.ts index 4163b28..d9a6b58 100644 --- a/src/quickpicks/items/gitCommands.ts +++ b/src/quickpicks/items/gitCommands.ts @@ -219,18 +219,19 @@ export function createCommitQuickPickItem( export type ContributorQuickPickItem = QuickPickItemOfT; -export function createContributorQuickPickItem( +export async function createContributorQuickPickItem( contributor: GitContributor, picked?: boolean, options?: { alwaysShow?: boolean; buttons?: QuickInputButton[] }, -): ContributorQuickPickItem { +): Promise { const item: ContributorQuickPickItem = { label: contributor.label, - description: contributor.email, + description: contributor.current ? 'you' : contributor.email, alwaysShow: options?.alwaysShow, buttons: options?.buttons, picked: picked, item: contributor, + iconPath: await contributor.getAvatarUri(), }; return item; } diff --git a/src/views/commitsView.ts b/src/views/commitsView.ts index ad97fda..a770f85 100644 --- a/src/views/commitsView.ts +++ b/src/views/commitsView.ts @@ -6,10 +6,14 @@ import type { Container } from '../container'; import { GitUri } from '../git/gitUri'; import type { GitCommit } from '../git/models/commit'; import { isCommit } from '../git/models/commit'; +import { matchContributor } from '../git/models/contributor'; import type { GitRevisionReference } from '../git/models/reference'; import { getReferenceLabel } from '../git/models/reference'; import type { RepositoryChangeEvent } from '../git/models/repository'; import { Repository, RepositoryChange, RepositoryChangeComparisonMode } from '../git/models/repository'; +import type { GitUser } from '../git/models/user'; +import { showContributorsPicker } from '../quickpicks/contributorsPicker'; +import { getRepositoryOrShowPicker } from '../quickpicks/repositoryPicker'; import { createCommand, executeCommand } from '../system/command'; import { configuration } from '../system/configuration'; import { setContext } from '../system/context'; @@ -37,14 +41,7 @@ export class CommitsRepositoryNode extends RepositoryFolderNode; } export class CommitsView extends ViewBase<'commits', CommitsViewNode, CommitsViewConfig> { @@ -202,7 +199,7 @@ export class CommitsView extends ViewBase<'commits', CommitsViewNode, CommitsVie return this.config.reveal || !configuration.get('views.repositories.showCommits'); } - private readonly _state: CommitsViewState = {}; + private readonly _state: CommitsViewState = { filterCommits: new Map() }; get state(): CommitsViewState { return this._state; } @@ -244,13 +241,13 @@ export class CommitsView extends ViewBase<'commits', CommitsViewNode, CommitsVie this, ), registerViewCommand( - this.getQualifiedCommand('setMyCommitsOnlyOn'), - () => this.setMyCommitsOnly(true), + this.getQualifiedCommand('setCommitsFilterAuthors'), + n => this.setCommitsFilter(n, true), this, ), registerViewCommand( - this.getQualifiedCommand('setMyCommitsOnlyOff'), - () => this.setMyCommitsOnly(false), + this.getQualifiedCommand('setCommitsFilterOff'), + n => this.setCommitsFilter(n, false), this, ), registerViewCommand(this.getQualifiedCommand('setShowAvatarsOn'), () => this.setShowAvatars(true), this), @@ -395,9 +392,60 @@ export class CommitsView extends ViewBase<'commits', CommitsViewNode, CommitsVie return configuration.updateEffective(`views.${this.configKey}.files.layout` as const, layout); } - private setMyCommitsOnly(enabled: boolean) { - void setContext('gitlens:views:commits:myCommitsOnly', enabled); - this.state.myCommitsOnly = enabled; + private async setCommitsFilter(node: ViewNode, filter: boolean) { + let repo; + if (node != null) { + if (node.is('repo-folder')) { + repo = node.repo; + } else { + let parent: ViewNode | undefined = node; + do { + parent = parent.getParent(); + if (parent?.is('repo-folder')) { + repo = parent.repo; + break; + } + } while (parent != null); + } + } + + if (filter) { + repo ??= await getRepositoryOrShowPicker('Filter Commits', 'Choose a repository'); + if (repo == null) return; + + let authors = this.state.filterCommits.get(repo.id); + 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; + this.state.filterCommits.delete(repo.id); + } else { + this.state.filterCommits.set(repo.id, result); + } + } else if (repo != null) { + this.state.filterCommits.delete(repo.id); + } else { + this.state.filterCommits.clear(); + } + + void setContext('gitlens:views:commits:filtered', this.state.filterCommits.size !== 0); void this.refresh(true); } diff --git a/src/views/nodes/viewNode.ts b/src/views/nodes/viewNode.ts index 5841333..75b4c59 100644 --- a/src/views/nodes/viewNode.ts +++ b/src/views/nodes/viewNode.ts @@ -555,7 +555,7 @@ export abstract class SubscribeableViewNode< export abstract class RepositoryFolderNode< TView extends View = View, TChild extends ViewNode = ViewNode, -> extends SubscribeableViewNode<'repository-folder', TView> { +> extends SubscribeableViewNode<'repo-folder', TView> { protected override splatted = true; protected child: TChild | undefined; @@ -567,7 +567,7 @@ export abstract class RepositoryFolderNode< splatted: boolean, private readonly options?: { showBranchAndLastFetched?: boolean }, ) { - super('repository-folder', uri, view, parent); + super('repo-folder', uri, view, parent); this.updateContext({ repository: this.repo }); this._uniqueId = getViewNodeId(this.type, this.context); @@ -607,6 +607,9 @@ export abstract class RepositoryFolderNode< if (behind) { item.contextValue += '+behind'; } + if (this.view.type === 'commits' && this.view.state.filterCommits.get(this.repo.id)?.length) { + item.contextValue += '+filtered'; + } if (branch != null && this.options?.showBranchAndLastFetched) { const lastFetched = (await this.repo.getLastFetched()) ?? 0; @@ -838,6 +841,7 @@ type TreeViewNodesByType = { ['folder']: FolderNode; ['line-history-tracker']: LineHistoryTrackerNode; ['repository']: RepositoryNode; + ['repo-folder']: RepositoryFolderNode; ['results-file']: ResultsFileNode; ['stash']: StashNode; ['stash-file']: StashFileNode; diff --git a/yarn.lock b/yarn.lock index 58d3296..b598945 100644 --- a/yarn.lock +++ b/yarn.lock @@ -756,10 +756,10 @@ resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-2.0.5.tgz#5cac7e7df3275bb95f79594f192d97da3b4fd5fe" integrity sha512-I3pkr8j/6tmQtKV/ZzHtuaqYSQvyjGRKH4go60Rr0IDLlFxuRT5V32uvB1mecM5G1EVAUyF/4r4QZ1GHgz+mxA== -"@types/vscode@1.80.0": - version "1.80.0" - resolved "https://registry.yarnpkg.com/@types/vscode/-/vscode-1.80.0.tgz#e004dd6cde74dafdb7fab64a6e1754bf8165b981" - integrity sha512-qK/CmOdS2o7ry3k6YqU4zD3R2AYlJfbwBoSbKpBoP+GpXNE+0NEgJOli4n0bm0diK5kfBnchgCEj4igQz/44Hg== +"@types/vscode@1.81.0": + version "1.81.0" + resolved "https://registry.yarnpkg.com/@types/vscode/-/vscode-1.81.0.tgz#c27228dd063002e0e00611be70b0497beaa24d39" + integrity sha512-YIaCwpT+O2E7WOMq0eCgBEABE++SX3Yl/O02GoMIF2DO3qAtvw7m6BXFYsxnc6XyzwZgh6/s/UG78LSSombl2w== "@types/yargs-parser@*": version "21.0.2"