diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d079f1..a7f05f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,36 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## [Unreleased] + +### Added + +- Adds a new _Contributors_ node to each repository in the _Repositories_ view + - **Contributors** — lists the contributors in the repository, sorted by contributed commits + - Provides the avatar (if enabled), name, and email address of each contributor + - An inline toolbar provides quick access to the _Copy to Clipboard_ command + - A context menu provides access to the _Copy to Clipboard_, _Add as Co-author_, and _Refresh_ commands + - Each contributor expands to list the repository's revision (commit) history filtered by the contributor + - An inline toolbar provides quick access to the _Compare with HEAD_ (`alt-click` for _Compare with Working Tree_), _Copy Commit ID to Clipboard_ (`alt-click` for _Copy Commit Message to Clipboard_), and _Open Commit on Remote_ (if available) commands + - A context menu provides access to more common revision (commit) commands + - Each revision (commit) expands to list its set of changed files, complete with status indicators for adds, changes, renames, and deletes + - An inline toolbar provides quick access to the _Open File_, _Copy Commit ID to Clipboard_ (`alt-click` for _Copy Commit Message to Clipboard_), and _Open File on Remote_ (if available) commands + - A context menu provides access to more common file revision commands +- Adds a _Collapse All_ command to the _Repositories_ view — closes [#688](https://github.com/eamodio/vscode-gitlens/issues/688) +- Adds version links to CHANGELOG — closes [#617](https://github.com/eamodio/vscode-gitlens/issues/617) thanks to [PR #600](https://github.com/eamodio/vscode-gitlens/pull/660) by John Gee ([@shadowspawn](https://github.com/shadowspawn)) + +### Changed + +- Updates the invite link to the [VS Code Development Community Slack](https://vscode-slack.amod.io) +- Improves the behavior of the _Open Changes with Next Revision_ (`gitlens.diffWithNext`) command when in the diff editor +- Improves the behavior of the _Open Changes with Previous Revision_ (`gitlens.diffWithPrevious`) command when in the diff editor +- Improves the behavior of the _Open Changes with Working File_ (`gitlens.diffWithWorking`) command when in the diff editor + +### Fixed + +- Fixes [#683](https://github.com/eamodio/vscode-gitlens/issues/683) - log.showSignature leads to stray files being displayed +- Fixes the behavior of the _Open Line Changes with Previous Revision_ (`gitlens.diffLineWithPrevious`) command to follow the line history much better + ## [9.5.1] - 2019-02-13 ### Added diff --git a/README.md b/README.md index 9c6c1cc..47df13d 100644 --- a/README.md +++ b/README.md @@ -304,12 +304,24 @@ The repositories view provides the following features, - An inline toolbar provides quick access to the _Open File_, _Copy Commit ID to Clipboard_ (`alt-click` for _Copy Commit Message to Clipboard_), and _Open File on Remote_ (if available) commands - A context menu provides access to more common file revision commands +- **Contributors** — lists the contributors in the repository, sorted by contributed commits + + - Provides the avatar (if enabled), name, and email address of each contributor + - An inline toolbar provides quick access to the _Copy to Clipboard_ command + - A context menu provides access to the _Copy to Clipboard_, _Add as Co-author_, and _Refresh_ commands + - Each contributor expands to list the repository's revision (commit) history filtered by the contributor + - An inline toolbar provides quick access to the _Compare with HEAD_ (`alt-click` for _Compare with Working Tree_), _Copy Commit ID to Clipboard_ (`alt-click` for _Copy Commit Message to Clipboard_), and _Open Commit on Remote_ (if available) commands + - A context menu provides access to more common revision (commit) commands + - Each revision (commit) expands to list its set of changed files, complete with status indicators for adds, changes, renames, and deletes + - An inline toolbar provides quick access to the _Open File_, _Copy Commit ID to Clipboard_ (`alt-click` for _Copy Commit Message to Clipboard_), and _Open File on Remote_ (if available) commands + - A context menu provides access to more common file revision commands + - **Remotes** — lists the remotes in the repository - Provides the name of each remote, an indicator of the direction of the remote (fetch, push, both), remote service (if applicable), and repository path - An inline toolbar provides quick access to the _Fetch_, and _Open Repository on Remote_ (if available) commands - A context menu provides access to more common repository and remote commands - - Each remote expands list its remote branches + - Each remote expands to list its remote branches - See the **Branches** above for additional details - **Stashes** — lists the stashed changes in the repository @@ -888,6 +900,7 @@ A big thanks to the people that have contributed to this project: - Matt Cooper ([@vtbassmatt](https://github.com/vtbassmatt)) — [contributions](https://github.com/eamodio/vscode-gitlens/commits?author=vtbassmatt) - Segev Finer ([@segevfiner](https://github.com/segevfiner)) — [contributions](https://github.com/eamodio/vscode-gitlens/commits?author=segevfiner) - Cory Forsyth ([@bantic](https://github.com/bantic)) — [contributions](https://github.com/eamodio/vscode-gitlens/commits?author=bantic) +- John Gee ([@shadowspawn](https://github.com/shadowspawn)) — [contributions](https://github.com/eamodio/vscode-gitlens/commits?author=shadowspawn) - Geoffrey ([@g3offrey](https://github.com/g3offrey)) — [contributions](https://github.com/eamodio/vscode-gitlens/commits?author=g3offrey) - Yukai Huang ([@Yukaii](https://github.com/Yukaii)) — [contributions](https://github.com/eamodio/vscode-gitlens/commits?author=Yukaii) - Roy Ivy III ([@rivy](https://github.com/rivy)) — [contributions](https://github.com/eamodio/vscode-gitlens/commits?author=rivy) diff --git a/images/dark/icon-people.svg b/images/dark/icon-people.svg new file mode 100644 index 0000000..62af7d3 --- /dev/null +++ b/images/dark/icon-people.svg @@ -0,0 +1,4 @@ + + + + diff --git a/images/light/icon-people.svg b/images/light/icon-people.svg new file mode 100644 index 0000000..3d7e760 --- /dev/null +++ b/images/light/icon-people.svg @@ -0,0 +1,4 @@ + + + + diff --git a/package.json b/package.json index 3464790..46b16ab 100644 --- a/package.json +++ b/package.json @@ -2477,6 +2477,20 @@ } }, { + "command": "gitlens.views.contributor.addCoauthoredBy", + "title": "Add as Co-author", + "category": "GitLens" + }, + { + "command": "gitlens.views.contributor.copyToClipboard", + "title": "Copy to Clipboard", + "category": "GitLens", + "icon": { + "dark": "images/dark/icon-clipboard.svg", + "light": "images/light/icon-clipboard.svg" + } + }, + { "command": "gitlens.views.terminalCheckoutBranch", "title": "Checkout Branch (via Terminal)", "category": "GitLens" @@ -3256,6 +3270,14 @@ "when": "false" }, { + "command": "gitlens.views.contributor.addCoauthoredBy", + "when": "false" + }, + { + "command": "gitlens.views.contributor.copyToClipboard", + "when": "false" + }, + { "command": "gitlens.views.terminalCheckoutBranch", "when": "false" }, @@ -4105,6 +4127,21 @@ "group": "8_gitlens_@1" }, { + "command": "gitlens.views.contributor.copyToClipboard", + "when": "viewItem =~ /gitlens:contributor\\b/", + "group": "inline@98" + }, + { + "command": "gitlens.views.contributor.copyToClipboard", + "when": "viewItem =~ /gitlens:contributor\\b/", + "group": "1_gitlens@1" + }, + { + "command": "gitlens.views.contributor.addCoauthoredBy", + "when": "viewItem =~ /gitlens:contributor\\b/", + "group": "2_gitlens@1" + }, + { "command": "gitlens.openCommitInRemote", "when": "viewItem =~ /gitlens:commit\\b/ && gitlens:hasRemotes", "group": "inline@98" diff --git a/src/container.ts b/src/container.ts index 0c4a396..535ec04 100644 --- a/src/container.ts +++ b/src/container.ts @@ -6,7 +6,8 @@ import { GitCodeLensController } from './codelens/codeLensController'; import { Commands, ToggleFileBlameCommandArgs } from './commands'; import { AnnotationsToggleMode, Config, configuration, ConfigurationWillChangeEvent } from './configuration'; import { GitFileSystemProvider } from './git/fsProvider'; -import { clearGravatarCache, GitService } from './git/gitService'; +import { GitService } from './git/gitService'; +import { clearGravatarCache } from './gravatar'; import { LineHoverController } from './hovers/lineHoverController'; import { Keyboard } from './keyboard'; import { Logger, TraceLevel } from './logger'; diff --git a/src/git/git.ts b/src/git/git.ts index 8202c07..eb07399 100644 --- a/src/git/git.ts +++ b/src/git/git.ts @@ -811,6 +811,10 @@ export class Git { return data.length === 0 ? undefined : data.trim(); } + static shortlog(repoPath: string) { + return git({ cwd: repoPath }, 'shortlog', '-sne', '--all', '--no-merges'); + } + static async show( repoPath: string | undefined, fileName: string, diff --git a/src/git/gitService.ts b/src/git/gitService.ts index bace35c..3ca92fa 100644 --- a/src/git/gitService.ts +++ b/src/git/gitService.ts @@ -6,6 +6,7 @@ import { Disposable, Event, EventEmitter, + Extension, extensions, ProgressLocation, Range, @@ -18,7 +19,7 @@ import { WorkspaceFoldersChangeEvent } from 'vscode'; // eslint-disable-next-line import/no-unresolved -import { GitExtension } from '../@types/git'; +import { API as BuiltInGitApi, GitExtension } from '../@types/git'; import { configuration, RemotesConfig } from '../configuration'; import { CommandContext, DocumentSchemes, GlyphChars, setCommandContext } from '../constants'; import { Container } from '../container'; @@ -40,6 +41,7 @@ import { GitBranchParser, GitCommit, GitCommitType, + GitContributor, GitDiff, GitDiffChunkLine, GitDiffParser, @@ -64,6 +66,7 @@ import { } from './git'; import { GitUri } from './gitUri'; import { RemoteProviderFactory, RemoteProviders } from './remotes/factory'; +import { GitShortLogParser } from './parsers/parsers'; export * from './gitUri'; export * from './models/models'; @@ -1117,6 +1120,15 @@ export class GitService implements Disposable { } @log() + async getContributors(repoPath: string): Promise { + if (repoPath === undefined) return []; + + const data = await Git.shortlog(repoPath); + const shortlog = GitShortLogParser.parse(data, repoPath); + return shortlog === undefined ? [] : shortlog.contributors; + } + + @log() async getCurrentUser(repoPath: string) { let user = this._userMapCache.get(repoPath); if (user != null) return user; @@ -2218,18 +2230,27 @@ export class GitService implements Disposable { static async initialize(): Promise { // Try to use the same git as the built-in vscode git extension let gitPath; + const gitApi = await GitService.getBuiltInGitApi(); + if (gitApi !== undefined) { + gitPath = gitApi.git.path; + } + + await Git.setOrFindGitPath(gitPath || workspace.getConfiguration('git').get('path')); + } + + @log() + static async getBuiltInGitApi(): Promise { try { - const gitExtension = extensions.getExtension('vscode.git'); - if (gitExtension !== undefined) { - const gitApi = ((gitExtension.isActive - ? gitExtension.exports - : await gitExtension.activate()) as GitExtension).getAPI(1); - gitPath = gitApi.git.path; + const extension = extensions.getExtension('vscode.git') as Extension; + if (extension !== undefined) { + const gitExtension = extension.isActive ? extension.exports : await extension.activate(); + + return gitExtension.getAPI(1); } } catch {} - await Git.setOrFindGitPath(gitPath || workspace.getConfiguration('git').get('path')); + return undefined; } static getGitPath(): string { diff --git a/src/git/models/commit.ts b/src/git/models/commit.ts index 2d8e7dd..70e1411 100644 --- a/src/git/models/commit.ts +++ b/src/git/models/commit.ts @@ -2,17 +2,11 @@ import { Uri } from 'vscode'; import { configuration, DateStyle, GravatarDefaultStyle } from '../../configuration'; import { Container } from '../../container'; -import { Dates, Strings } from '../../system'; +import { Dates } from '../../system'; import { CommitFormatter } from '../formatters/formatters'; import { Git } from '../git'; import { GitUri } from '../gitUri'; - -const gravatarCache: Map = new Map(); -const missingGravatarHash = '00000000000000000000000000000000'; - -export function clearGravatarCache() { - gravatarCache.clear(); -} +import { getGravatarUri } from '../../gravatar'; export interface GitAuthor { name: string; @@ -180,22 +174,11 @@ export abstract class GitCommit { } getGravatarUri(fallback: GravatarDefaultStyle, size: number = 16): Uri { - const hash = - this.email != null && this.email.length !== 0 - ? Strings.md5(this.email.trim().toLowerCase(), 'hex') - : missingGravatarHash; - - const key = `${hash}:${size}`; - let gravatar = gravatarCache.get(key); - if (gravatar !== undefined) return gravatar; - - gravatar = Uri.parse(`https://www.gravatar.com/avatar/${hash}.jpg?s=${size}&d=${fallback}`); - gravatarCache.set(key, gravatar); - - return gravatar; + return getGravatarUri(this.email, fallback, size); } getShortMessage() { + // eslint-disable-next-line no-template-curly-in-string return CommitFormatter.fromTemplate('${message}', this, { truncateMessageAtNewLine: true }); } diff --git a/src/git/models/contributor.ts b/src/git/models/contributor.ts new file mode 100644 index 0000000..21c1377 --- /dev/null +++ b/src/git/models/contributor.ts @@ -0,0 +1,17 @@ +'use strict'; +import { Uri } from 'vscode'; +import { GravatarDefaultStyle } from '../../configuration'; +import { getGravatarUri } from '../../gravatar'; + +export class GitContributor { + constructor( + public readonly repoPath: string, + public readonly name: string, + public readonly email: string, + public readonly count: number + ) {} + + getGravatarUri(fallback: GravatarDefaultStyle, size: number = 16): Uri { + return getGravatarUri(this.email, fallback, size); + } +} diff --git a/src/git/models/models.ts b/src/git/models/models.ts index 6eaa582..5a486b1 100644 --- a/src/git/models/models.ts +++ b/src/git/models/models.ts @@ -4,12 +4,14 @@ export * from './blame'; export * from './blameCommit'; export * from './branch'; export * from './commit'; +export * from './contributor'; export * from './diff'; export * from './file'; export * from './log'; export * from './logCommit'; export * from './remote'; export * from './repository'; +export * from './shortlog'; export * from './stash'; export * from './stashCommit'; export * from './status'; diff --git a/src/git/models/repository.ts b/src/git/models/repository.ts index 49e0352..3c97e30 100644 --- a/src/git/models/repository.ts +++ b/src/git/models/repository.ts @@ -18,7 +18,7 @@ import { configuration, RemotesConfig } from '../../configuration'; import { StarredRepositories, WorkspaceState } from '../../constants'; import { Container } from '../../container'; import { Functions, gate, log } from '../../system'; -import { GitBranch, GitDiffShortStat, GitRemote, GitStash, GitStatus, GitTag } from '../git'; +import { GitBranch, GitContributor, GitDiffShortStat, GitRemote, GitStash, GitStatus, GitTag } from '../git'; import { GitUri } from '../gitUri'; import { RemoteProviderFactory, RemoteProviders } from '../remotes/factory'; @@ -272,6 +272,10 @@ export class Repository implements Disposable { return Container.git.getChangedFilesCount(this.path, sha); } + getContributors(): Promise { + return Container.git.getContributors(this.path); + } + async getLastFetched(): Promise { const hasRemotes = await this.hasRemotes(); if (!hasRemotes || Container.vsls.isMaybeGuest) return 0; diff --git a/src/git/models/shortlog.ts b/src/git/models/shortlog.ts new file mode 100644 index 0000000..aadd78b --- /dev/null +++ b/src/git/models/shortlog.ts @@ -0,0 +1,7 @@ +'use strict'; +import { GitContributor } from './contributor'; + +export interface GitShortLog { + readonly repoPath: string; + readonly contributors: GitContributor[]; +} diff --git a/src/git/parsers/parsers.ts b/src/git/parsers/parsers.ts index bac644d..07d0952 100644 --- a/src/git/parsers/parsers.ts +++ b/src/git/parsers/parsers.ts @@ -5,6 +5,7 @@ export * from './branchParser'; export * from './diffParser'; export * from './logParser'; export * from './remoteParser'; +export * from './shortlogParser'; export * from './stashParser'; export * from './statusParser'; export * from './tagParser'; diff --git a/src/git/parsers/shortlogParser.ts b/src/git/parsers/shortlogParser.ts new file mode 100644 index 0000000..2655f91 --- /dev/null +++ b/src/git/parsers/shortlogParser.ts @@ -0,0 +1,37 @@ +'use strict'; +import { GitContributor, GitShortLog } from '../git'; + +const shortlogRegex = /^(.*?)\t(.*?) <(.*?)>$/gm; + +export class GitShortLogParser { + static parse(data: string, repoPath: string): GitShortLog | undefined { + if (!data) return undefined; + + const contributors: GitContributor[] = []; + + let count; + let name; + let email; + let match: RegExpExecArray | null = null; + do { + match = shortlogRegex.exec(data); + if (match == null) break; + + [, count, name, email] = match; + contributors.push( + new GitContributor( + repoPath, + // Stops excessive memory usage -- https://bugs.chromium.org/p/v8/issues/detail?id=2869 + ` ${name}`.substr(1), + // Stops excessive memory usage -- https://bugs.chromium.org/p/v8/issues/detail?id=2869 + ` ${email}`.substr(1), + parseInt(count, 10) + ) + ); + } while (match != null); + + if (!contributors.length) return undefined; + + return { repoPath: repoPath, contributors: contributors }; + } +} diff --git a/src/gravatar.ts b/src/gravatar.ts new file mode 100644 index 0000000..32dd147 --- /dev/null +++ b/src/gravatar.ts @@ -0,0 +1,25 @@ +'use strict'; +import { Uri } from 'vscode'; +import { GravatarDefaultStyle } from './config'; +import { Strings } from './system'; + +const gravatarCache: Map = new Map(); +const missingGravatarHash = '00000000000000000000000000000000'; + +export function clearGravatarCache() { + gravatarCache.clear(); +} + +export function getGravatarUri(email: string | undefined, fallback: GravatarDefaultStyle, size: number = 16): Uri { + const hash = + email != null && email.length !== 0 ? Strings.md5(email.trim().toLowerCase(), 'hex') : missingGravatarHash; + + const key = `${hash}:${size}`; + let gravatar = gravatarCache.get(key); + if (gravatar !== undefined) return gravatar; + + gravatar = Uri.parse(`https://www.gravatar.com/avatar/${hash}.jpg?s=${size}&d=${fallback}`); + gravatarCache.set(key, gravatar); + + return gravatar; +} diff --git a/src/views/nodes.ts b/src/views/nodes.ts index d703778..f518d5d 100644 --- a/src/views/nodes.ts +++ b/src/views/nodes.ts @@ -6,6 +6,8 @@ export * from './nodes/branchNode'; export * from './nodes/branchTrackingStatusNode'; export * from './nodes/commitFileNode'; export * from './nodes/commitNode'; +export * from './nodes/contributorNode'; +export * from './nodes/contributorsNode'; export * from './nodes/fileHistoryNode'; export * from './nodes/fileHistoryTrackerNode'; export * from './nodes/folderNode'; diff --git a/src/views/nodes/contributorNode.ts b/src/views/nodes/contributorNode.ts new file mode 100644 index 0000000..267888f --- /dev/null +++ b/src/views/nodes/contributorNode.ts @@ -0,0 +1,66 @@ +'use strict'; +import { TreeItem, TreeItemCollapsibleState } from 'vscode'; +import { GitContributor, GitUri } from '../../git/gitService'; +import { Iterables, Strings } from '../../system'; +import { RepositoriesView } from '../repositoriesView'; +import { PageableViewNode, ResourceType, ViewNode } from './viewNode'; +import { Container } from '../../container'; +import { MessageNode, ShowMoreNode } from './common'; +import { getBranchesAndTagTipsFn, insertDateMarkers } from './helpers'; +import { CommitNode } from './commitNode'; + +export class ContributorNode extends ViewNode implements PageableViewNode { + readonly supportsPaging: boolean = true; + maxCount: number | undefined; + + constructor(uri: GitUri, view: RepositoriesView, parent: ViewNode, public readonly contributor: GitContributor) { + super(uri, view, parent); + } + + get id(): string { + return `${this._instanceId}:gitlens:repository(${this.contributor.repoPath}):contributor(${ + this.contributor.name + }|${this.contributor.email}}`; + } + + async getChildren(): Promise { + const log = await Container.git.getLog(this.uri.repoPath!, { + maxCount: this.maxCount || this.view.config.defaultItemLimit, + authors: [this.contributor.name] + }); + if (log === undefined) return [new MessageNode(this.view, this, 'No commits could be found.')]; + + const getBranchAndTagTips = await getBranchesAndTagTipsFn(this.uri.repoPath); + const children = [ + ...insertDateMarkers( + Iterables.map( + log.commits.values(), + c => new CommitNode(this.view, this, c, undefined, getBranchAndTagTips) + ), + this + ) + ]; + + if (log.truncated) { + children.push(new ShowMoreNode(this.view, this, 'Commits')); + } + return children; + } + + getTreeItem(): TreeItem { + const item = new TreeItem(this.contributor.name, TreeItemCollapsibleState.Collapsed); + item.id = this.id; + item.contextValue = ResourceType.Contributor; + item.description = this.contributor.email; + item.tooltip = `${this.contributor.name} <${this.contributor.email}>\n${Strings.pluralize( + 'commit', + this.contributor.count + )}`; + + if (this.view.config.avatars) { + item.iconPath = this.contributor.getGravatarUri(Container.config.defaultGravatarsStyle); + } + + return item; + } +} diff --git a/src/views/nodes/contributorsNode.ts b/src/views/nodes/contributorsNode.ts new file mode 100644 index 0000000..206e5f0 --- /dev/null +++ b/src/views/nodes/contributorsNode.ts @@ -0,0 +1,41 @@ +'use strict'; +import { TreeItem, TreeItemCollapsibleState } from 'vscode'; +import { GitUri, Repository } from '../../git/gitService'; +import { RepositoriesView } from '../repositoriesView'; +import { MessageNode } from './common'; +import { ContributorNode } from './contributorNode'; +import { ResourceType, ViewNode } from './viewNode'; +import { Container } from '../../container'; + +export class ContributorsNode extends ViewNode { + constructor(uri: GitUri, view: RepositoriesView, parent: ViewNode, public readonly repo: Repository) { + super(uri, view, parent); + } + + get id(): string { + return `${this._instanceId}:gitlens:repository(${this.repo.path}):contributors`; + } + + async getChildren(): Promise { + const contributors = await this.repo.getContributors(); + if (contributors.length === 0) return [new MessageNode(this.view, this, 'No contributors could be found.')]; + + contributors.sort((a, b) => b.count - a.count); + + const children = contributors.map(c => new ContributorNode(this.uri, this.view, this, c)); + return children; + } + + getTreeItem(): TreeItem { + const item = new TreeItem('Contributors', TreeItemCollapsibleState.Collapsed); + item.id = this.id; + item.contextValue = ResourceType.Contributors; + + item.iconPath = { + dark: Container.context.asAbsolutePath('images/dark/icon-people.svg'), + light: Container.context.asAbsolutePath('images/light/icon-people.svg') + }; + + return item; + } +} diff --git a/src/views/nodes/repositoryNode.ts b/src/views/nodes/repositoryNode.ts index c856817..67846bf 100644 --- a/src/views/nodes/repositoryNode.ts +++ b/src/views/nodes/repositoryNode.ts @@ -22,6 +22,7 @@ import { StashesNode } from './stashesNode'; import { StatusFilesNode } from './statusFilesNode'; import { TagsNode } from './tagsNode'; import { ResourceType, SubscribeableViewNode, ViewNode } from './viewNode'; +import { ContributorsNode } from './contributorsNode'; const hasTimeRegex = /[hHm]/; @@ -78,6 +79,7 @@ export class RepositoryNode extends SubscribeableViewNode { children.push( new BranchesNode(this.uri, this.view, this, this.repo), + new ContributorsNode(this.uri, this.view, this, this.repo), new RemotesNode(this.uri, this.view, this, this.repo), new StashesNode(this.uri, this.view, this, this.repo), new TagsNode(this.uri, this.view, this, this.repo) diff --git a/src/views/nodes/viewNode.ts b/src/views/nodes/viewNode.ts index 37559e7..08a7b0b 100644 --- a/src/views/nodes/viewNode.ts +++ b/src/views/nodes/viewNode.ts @@ -19,6 +19,8 @@ export enum ResourceType { ComparePicker = 'gitlens:compare:picker', ComparePickerWithRef = 'gitlens:compare:picker:ref', CompareResults = 'gitlens:compare:results', + Contributor = 'gitlens:contributor', + Contributors = 'gitlens:contributors', File = 'gitlens:file', FileHistory = 'gitlens:history:file', Folder = 'gitlens:folder', diff --git a/src/views/viewCommands.ts b/src/views/viewCommands.ts index 74d1f01..74a9f74 100644 --- a/src/views/viewCommands.ts +++ b/src/views/viewCommands.ts @@ -1,6 +1,6 @@ 'use strict'; import * as paths from 'path'; -import { commands, Disposable, Terminal, TextDocumentShowOptions, Uri, window } from 'vscode'; +import { commands, Disposable, env, Terminal, TextDocumentShowOptions, Uri, window } from 'vscode'; import { Commands, DiffWithCommandArgs, @@ -34,6 +34,8 @@ import { ViewRefNode, viewSupportsNodeDismissal } from './nodes'; +import { ContributorNode } from './nodes/contributorNode'; +import { Strings } from '../system/string'; export interface RefreshNodeCommandArgs { maxCount?: number; @@ -83,6 +85,9 @@ export class ViewCommands implements Disposable { commands.registerCommand('gitlens.views.exploreRepoRevision', this.exploreRepoRevision, this); + commands.registerCommand('gitlens.views.contributor.addCoauthoredBy', this.contributorAddCoauthoredBy, this); + commands.registerCommand('gitlens.views.contributor.copyToClipboard', this.contributorCopyToClipboard, this); + commands.registerCommand('gitlens.views.openChanges', this.openChanges, this); commands.registerCommand('gitlens.views.openChangesWithWorking', this.openChangesWithWorking, this); commands.registerCommand('gitlens.views.openFile', this.openFile, this); @@ -139,6 +144,44 @@ export class ViewCommands implements Disposable { this._disposable && this._disposable.dispose(); } + private async contributorAddCoauthoredBy(node: ContributorNode) { + if (!(node instanceof ContributorNode)) return; + + const gitApi = await GitService.getBuiltInGitApi(); + if (gitApi === undefined) return; + + const repo = gitApi.repositories.find( + r => Strings.normalizePath(r.rootUri.fsPath) === node.contributor.repoPath + ); + if (repo === undefined) return; + + const coauthor = `${node.contributor.name}${node.contributor.email ? ` <${node.contributor.email}>` : ''}`; + + const message = repo.inputBox.value; + if (message.includes(coauthor)) return; + + let newlines; + if (message.includes('Co-authored-by: ')) { + newlines = '\n'; + } + else if (message.length !== 0 && message[message.length - 1] === '\n') { + newlines = '\n\n'; + } + else { + newlines = '\n\n\n'; + } + + repo.inputBox.value = `${message}${newlines}Co-authored-by: ${coauthor}`; + } + + private async contributorCopyToClipboard(node: ContributorNode) { + if (!(node instanceof ContributorNode)) return; + + await env.clipboard.writeText( + `${node.contributor.name}${node.contributor.email ? ` <${node.contributor.email}>` : ''}` + ); + } + private fetch(node: RemoteNode | RepositoryNode) { if (node instanceof RemoteNode) return node.fetch(); if (node instanceof RepositoryNode) return node.fetch(); diff --git a/src/vsls/host.ts b/src/vsls/host.ts index db156ad..5519ec0 100644 --- a/src/vsls/host.ts +++ b/src/vsls/host.ts @@ -34,6 +34,7 @@ const gitWhitelist = new Map boolean>([ ['merge-base', defaultWhitelistFn], ['remote', args => args[1] === '-v' || args[1] === 'get-url'], ['rev-parse', defaultWhitelistFn], + ['shortlog', defaultWhitelistFn], ['show', defaultWhitelistFn], ['show-ref', defaultWhitelistFn], ['stash', args => args[1] === 'list'],