diff --git a/package.json b/package.json index c5995fe..e983528 100644 --- a/package.json +++ b/package.json @@ -1612,6 +1612,40 @@ "markdownDescription": "Specifies where to show the _Compare_ view", "scope": "window" }, + "gitlens.views.contributors.avatars": { + "type": "boolean", + "default": true, + "markdownDescription": "Specifies whether to show avatar images instead of commit (or status) icons in the _Contributors_ view", + "scope": "window" + }, + "gitlens.views.contributors.files.compact": { + "type": "boolean", + "default": true, + "markdownDescription": "Specifies whether to compact (flatten) unnecessary file nesting in the _Contributors_ view. Only applies when `#gitlens.views.contributors.files.layout#` is set to `tree` or `auto`", + "scope": "window" + }, + "gitlens.views.contributors.files.layout": { + "type": "string", + "default": "auto", + "enum": [ + "auto", + "list", + "tree" + ], + "enumDescriptions": [ + "Automatically switches between displaying files as a `tree` or `list` based on the `#gitlens.views.contributors.files.threshold#` value and the number of files at each nesting level", + "Displays files as a list", + "Displays files as a tree" + ], + "markdownDescription": "Specifies how the _Contributors_ view will display files", + "scope": "window" + }, + "gitlens.views.contributors.files.threshold": { + "type": "number", + "default": 5, + "markdownDescription": "Specifies when to switch between displaying files as a `tree` or `list` based on the number of files in a nesting level in the _Contributors_ view. Only applies when `#gitlens.views.contributors.files.layout#` is set to `auto`", + "scope": "window" + }, "gitlens.views.defaultItemLimit": { "type": "number", "default": 10, @@ -3641,6 +3675,48 @@ } }, { + "command": "gitlens.views.contributors.copy", + "title": "Copy", + "category": "GitLens" + }, + { + "command": "gitlens.views.contributors.refresh", + "title": "Refresh", + "category": "GitLens", + "icon": "$(refresh)" + }, + { + "command": "gitlens.views.contributors.setFilesLayoutToAuto", + "title": "Toggle File Layout (Tree)", + "category": "GitLens", + "icon": "$(list-tree)" + }, + { + "command": "gitlens.views.contributors.setFilesLayoutToList", + "title": "Toggle File Layout (Auto)", + "category": "GitLens", + "icon": { + "dark": "images/dark/icon-view-auto.svg", + "light": "images/light/icon-view-auto.svg" + } + }, + { + "command": "gitlens.views.contributors.setFilesLayoutToTree", + "title": "Toggle File Layout (List)", + "category": "GitLens", + "icon": "$(list-flat)" + }, + { + "command": "gitlens.views.contributors.setShowAvatarsOn", + "title": "Show Avatars", + "category": "GitLens" + }, + { + "command": "gitlens.views.contributors.setShowAvatarsOff", + "title": "Hide Avatars", + "category": "GitLens" + }, + { "command": "gitlens.views.remotes.copy", "title": "Copy", "category": "GitLens" @@ -4685,6 +4761,34 @@ "when": "false" }, { + "command": "gitlens.views.contributors.copy", + "when": "false" + }, + { + "command": "gitlens.views.contributors.refresh", + "when": "false" + }, + { + "command": "gitlens.views.contributors.setFilesLayoutToAuto", + "when": "false" + }, + { + "command": "gitlens.views.contributors.setFilesLayoutToList", + "when": "false" + }, + { + "command": "gitlens.views.contributors.setFilesLayoutToTree", + "when": "false" + }, + { + "command": "gitlens.views.contributors.setShowAvatarsOn", + "when": "false" + }, + { + "command": "gitlens.views.contributors.setShowAvatarsOff", + "when": "false" + }, + { "command": "gitlens.views.remotes.copy", "when": "false" }, @@ -5473,6 +5577,36 @@ "group": "1_gitlens@0" }, { + "command": "gitlens.views.contributors.setFilesLayoutToList", + "when": "view =~ /^gitlens\\.views\\.contributors/ && config.gitlens.views.contributors.files.layout == auto", + "group": "navigation@13" + }, + { + "command": "gitlens.views.contributors.setFilesLayoutToTree", + "when": "view =~ /^gitlens\\.views\\.contributors/ && config.gitlens.views.contributors.files.layout == list", + "group": "navigation@13" + }, + { + "command": "gitlens.views.contributors.setFilesLayoutToAuto", + "when": "view =~ /^gitlens\\.views\\.contributors/ && config.gitlens.views.contributors.files.layout == tree", + "group": "navigation@13" + }, + { + "command": "gitlens.views.contributors.refresh", + "when": "view =~ /^gitlens\\.views\\.contributors/", + "group": "navigation@99" + }, + { + "command": "gitlens.views.contributors.setShowAvatarsOn", + "when": "view =~ /^gitlens\\.views\\.contributors/ && !config.gitlens.views.contributors.avatars", + "group": "1_gitlens@0" + }, + { + "command": "gitlens.views.contributors.setShowAvatarsOff", + "when": "view =~ /^gitlens\\.views\\.contributors/ && config.gitlens.views.contributors.avatars", + "group": "1_gitlens@0" + }, + { "command": "gitlens.views.history.setFilesLayoutToList", "when": "view =~ /^gitlens\\.views\\.history/ && config.gitlens.views.history.files.layout == auto", "group": "navigation@13" diff --git a/src/container.ts b/src/container.ts index 776d014..2c923d0 100644 --- a/src/container.ts +++ b/src/container.ts @@ -25,6 +25,7 @@ import { GitDocumentTracker } from './trackers/gitDocumentTracker'; import { GitLineTracker } from './trackers/gitLineTracker'; import { BranchesView } from './views/branchesView'; import { CompareView } from './views/compareView'; +import { ContributorsView } from './views/contributorsView'; import { FileHistoryView } from './views/fileHistoryView'; import { HistoryView } from './views/historyView'; import { LineHistoryView } from './views/lineHistoryView'; @@ -67,6 +68,7 @@ export class Container { context.subscriptions.push((this._welcomeWebview = new WelcomeWebview())); context.subscriptions.push((this._branchesView = new BranchesView())); + context.subscriptions.push((this._contributorsView = new ContributorsView())); context.subscriptions.push((this._historyView = new HistoryView())); context.subscriptions.push((this._remotesView = new RemotesView())); context.subscriptions.push((this._tagsView = new TagsView())); @@ -213,6 +215,15 @@ export class Container { return this._context; } + private static _contributorsView: ContributorsView | undefined; + static get contributorsView() { + if (this._contributorsView === undefined) { + this._context.subscriptions.push((this._contributorsView = new ContributorsView())); + } + + return this._contributorsView; + } + private static _fileAnnotationController: FileAnnotationController; static get fileAnnotations() { return this._fileAnnotationController; diff --git a/src/views/contributorsView.ts b/src/views/contributorsView.ts new file mode 100644 index 0000000..483b844 --- /dev/null +++ b/src/views/contributorsView.ts @@ -0,0 +1,192 @@ +'use strict'; +import { commands, ConfigurationChangeEvent, TreeItem, TreeItemCollapsibleState } from 'vscode'; +import { configuration, ContributorsViewConfig, ViewFilesLayout } from '../configuration'; +import { Container } from '../container'; +import { Repository, RepositoryChange, RepositoryChangeEvent } from '../git/git'; +import { GitUri } from '../git/gitUri'; +import { ContextValues, ContributorsNode, MessageNode, SubscribeableViewNode, unknownGitUri, ViewNode } from './nodes'; +import { debug, gate } from '../system'; +import { ViewBase } from './viewBase'; + +export class ContributorsRepositoryNode extends SubscribeableViewNode { + private child: ContributorsNode | undefined; + + constructor( + uri: GitUri, + view: ContributorsView, + parent: ViewNode, + public readonly repo: Repository, + private readonly root: boolean, + ) { + super(uri, view, parent); + } + + async getChildren(): Promise { + if (this.child == null) { + this.child = new ContributorsNode(this.uri, this.view, this, this.repo); + + void this.ensureSubscription(); + } + return this.child.getChildren(); + } + + getTreeItem(): TreeItem { + const item = new TreeItem( + this.repo.formattedName ?? this.uri.repoPath ?? '', + TreeItemCollapsibleState.Expanded, + ); + item.contextValue = ContextValues.RepositoryFolder; + + void this.ensureSubscription(); + + return item; + } + + @gate() + @debug() + async refresh(reset: boolean = false) { + await this.child?.triggerChange(reset); + + await this.ensureSubscription(); + } + + @debug() + protected subscribe() { + return this.repo.onDidChange(this.onRepositoryChanged, this); + } + + @debug({ + args: { + 0: (e: RepositoryChangeEvent) => + `{ repository: ${e.repository ? e.repository.name : ''}, changes: ${e.changes.join()} }`, + }, + }) + private onRepositoryChanged(e: RepositoryChangeEvent) { + if (e.changed(RepositoryChange.Closed)) { + this.dispose(); + void this.parent?.triggerChange(true); + + return; + } + + if (e.changed(RepositoryChange.Heads)) { + void this.triggerChange(true); + if (this.root) { + void this.parent?.triggerChange(true); + } + } + } +} + +export class ContributorsViewNode extends ViewNode { + private children: ContributorsRepositoryNode[] | undefined; + + constructor(view: ContributorsView) { + super(unknownGitUri, view); + } + + async getChildren(): Promise { + if (this.children != null) { + for (const child of this.children) { + child.dispose?.(); + } + this.children = undefined; + } + + const repositories = await Container.git.getOrderedRepositories(); + if (repositories.length === 0) return [new MessageNode(this.view, this, 'No contributors could be found.')]; + + const root = repositories.length === 1; + this.children = repositories.map( + r => new ContributorsRepositoryNode(GitUri.fromRepoPath(r.path), this.view, this, r, root), + ); + + if (root) { + const [child] = this.children; + + const contributors = await child.repo.getContributors(); + this.view.description = contributors.length === 0 ? undefined : `(${contributors.length})`; + + return child.getChildren(); + } + return this.children; + } + + getTreeItem(): TreeItem { + const item = new TreeItem('Contributors', TreeItemCollapsibleState.Expanded); + return item; + } +} + +export class ContributorsView extends ViewBase { + protected readonly configKey = 'contributors'; + + constructor() { + super('gitlens.views.contributors', 'Contributors'); + } + + getRoot() { + return new ContributorsViewNode(this); + } + + protected registerCommands() { + void Container.viewCommands; + + commands.registerCommand( + this.getQualifiedCommand('copy'), + () => commands.executeCommand('gitlens.views.copy', this.selection), + this, + ); + commands.registerCommand(this.getQualifiedCommand('refresh'), () => this.refresh(true), this); + commands.registerCommand( + this.getQualifiedCommand('setFilesLayoutToAuto'), + () => this.setFilesLayout(ViewFilesLayout.Auto), + this, + ); + commands.registerCommand( + this.getQualifiedCommand('setFilesLayoutToList'), + () => this.setFilesLayout(ViewFilesLayout.List), + this, + ); + commands.registerCommand( + this.getQualifiedCommand('setFilesLayoutToTree'), + () => this.setFilesLayout(ViewFilesLayout.Tree), + this, + ); + commands.registerCommand(this.getQualifiedCommand('setShowAvatarsOn'), () => this.setShowAvatars(true), this); + commands.registerCommand(this.getQualifiedCommand('setShowAvatarsOff'), () => this.setShowAvatars(false), this); + } + + protected filterConfigurationChanged(e: ConfigurationChangeEvent) { + const changed = super.filterConfigurationChanged(e); + if ( + !changed && + !configuration.changed(e, 'defaultDateFormat') && + !configuration.changed(e, 'defaultDateSource') && + !configuration.changed(e, 'defaultDateStyle') && + !configuration.changed(e, 'defaultGravatarsStyle') + ) { + return false; + } + + return true; + } + + protected onConfigurationChanged(e: ConfigurationChangeEvent) { + if (configuration.initializing(e)) { + this.initialize(undefined, { showCollapseAll: true }); + } + + if (!configuration.initializing(e) && this._root != null) { + void this.refresh(true); + } + } + + private setFilesLayout(layout: ViewFilesLayout) { + return configuration.updateEffective('views', this.configKey, 'files', 'layout', layout); + } + + private setShowAvatars(enabled: boolean) { + return configuration.updateEffective('views', this.configKey, 'avatars', enabled); + } +} diff --git a/src/views/nodes/contributorNode.ts b/src/views/nodes/contributorNode.ts index 6ba2ed2..52c86fb 100644 --- a/src/views/nodes/contributorNode.ts +++ b/src/views/nodes/contributorNode.ts @@ -1,19 +1,20 @@ 'use strict'; import { TreeItem, TreeItemCollapsibleState } from 'vscode'; +import { CommitNode } from './commitNode'; +import { MessageNode, ShowMoreNode } from './common'; +import { GlyphChars } from '../../constants'; +import { Container } from '../../container'; +import { ContributorsView } from '../contributorsView'; import { GitContributor, GitLog } from '../../git/git'; import { GitUri } from '../../git/gitUri'; -import { debug, gate, Iterables, Strings } from '../../system'; -import { RepositoriesView } from '../repositoriesView'; -import { ContextValues, PageableViewNode, ViewNode } from './viewNode'; -import { Container } from '../../container'; -import { MessageNode, ShowMoreNode } from './common'; import { insertDateMarkers } from './helpers'; -import { CommitNode } from './commitNode'; -import { GlyphChars } from '../../constants'; +import { RepositoriesView } from '../repositoriesView'; import { RepositoryNode } from './repositoryNode'; +import { debug, gate, Iterables, Strings } from '../../system'; +import { ContextValues, PageableViewNode, ViewNode } from './viewNode'; import { ContactPresence } from '../../vsls/vsls'; -export class ContributorNode extends ViewNode implements PageableViewNode { +export class ContributorNode extends ViewNode implements PageableViewNode { static key = ':contributor'; static getId(repoPath: string, name: string, email: string): string { return `${RepositoryNode.getId(repoPath)}${this.key}(${name}|${email})`; @@ -21,7 +22,7 @@ export class ContributorNode extends ViewNode implements Pagea constructor( uri: GitUri, - view: RepositoriesView, + view: ContributorsView | RepositoriesView, parent: ViewNode, public readonly contributor: GitContributor, private readonly _presenceMap: Map | undefined, diff --git a/src/views/nodes/contributorsNode.ts b/src/views/nodes/contributorsNode.ts index 2415cde..d16c34b 100644 --- a/src/views/nodes/contributorsNode.ts +++ b/src/views/nodes/contributorsNode.ts @@ -1,22 +1,28 @@ 'use strict'; import { ThemeIcon, TreeItem, TreeItemCollapsibleState } from 'vscode'; -import { GitContributor, Repository } from '../../git/git'; -import { GitUri } from '../../git/gitUri'; -import { RepositoriesView } from '../repositoriesView'; import { MessageNode } from './common'; -import { ContributorNode } from './contributorNode'; -import { ContextValues, ViewNode } from './viewNode'; import { Container } from '../../container'; +import { ContributorNode } from './contributorNode'; +import { ContributorsView } from '../contributorsView'; +import { GitContributor, Repository } from '../../git/git'; +import { GitUri } from '../../git/gitUri'; import { RepositoryNode } from './repositoryNode'; +import { RepositoriesView } from '../repositoriesView'; import { debug, timeout } from '../../system'; +import { ContextValues, ViewNode } from './viewNode'; -export class ContributorsNode extends ViewNode { +export class ContributorsNode extends ViewNode { static key = ':contributors'; static getId(repoPath: string): string { return `${RepositoryNode.getId(repoPath)}${this.key}`; } - constructor(uri: GitUri, view: RepositoriesView, parent: ViewNode, public readonly repo: Repository) { + constructor( + uri: GitUri, + view: ContributorsView | RepositoriesView, + parent: ViewNode, + public readonly repo: Repository, + ) { super(uri, view, parent); } diff --git a/src/views/viewBase.ts b/src/views/viewBase.ts index 82679da..0f12294 100644 --- a/src/views/viewBase.ts +++ b/src/views/viewBase.ts @@ -48,10 +48,12 @@ import { RepositoriesView } from './repositoriesView'; import { SearchView } from './searchView'; import { debug, Functions, log, Promises, Strings } from '../system'; import { TagsView } from './tagsView'; +import { ContributorsView } from './contributorsView'; export type View = | BranchesView | CompareView + | ContributorsView | FileHistoryView | HistoryView | LineHistoryView @@ -62,6 +64,7 @@ export type View = export type ViewsWithFiles = | BranchesView | CompareView + | ContributorsView | HistoryView | RemotesView | RepositoriesView