From da01c2415094abeaec5558caf85b5bb7be568a9d Mon Sep 17 00:00:00 2001 From: Eric Amodio Date: Sun, 13 Jun 2021 03:52:36 -0400 Subject: [PATCH] Adds rich hovers for commits in trees Adds rich footnotes in markdown Adds branch/tag tips to file/line history --- src/annotations/autolinks.ts | 37 ++++++++++---- src/git/formatters/commitFormatter.ts | 51 ++++++++++++++++--- src/git/gitService.ts | 30 ++++++++--- src/views/nodes/commitNode.ts | 73 ++++++++++++++++++--------- src/views/nodes/fileHistoryNode.ts | 6 ++- src/views/nodes/fileRevisionAsCommitNode.ts | 78 +++++++++++++++++++++-------- src/views/nodes/lineHistoryNode.ts | 6 ++- src/views/nodes/viewNode.ts | 4 ++ src/views/viewBase.ts | 5 ++ 9 files changed, 215 insertions(+), 75 deletions(-) diff --git a/src/annotations/autolinks.ts b/src/annotations/autolinks.ts index b6b6676..cb1e05a 100644 --- a/src/annotations/autolinks.ts +++ b/src/annotations/autolinks.ts @@ -167,10 +167,15 @@ export class Autolinks implements Disposable { } ref.linkify = (text: string, markdown: boolean, footnotes?: Map) => { + const includeFootnotes = footnotes == null; + let index; + if (markdown) { return text.replace(ref.messageMarkdownRegex!, (_substring, linkText, num) => { const issue = issuesOrPullRequests?.get(num); + const issueUrl = ref.url.replace(numRegex, num); + let title = ''; if (ref.title) { title = ` "${ref.title.replace(numRegex, num)}`; @@ -179,24 +184,34 @@ export class Autolinks implements Disposable { if (issue instanceof Promises.CancellationError) { title += `\n${GlyphChars.Dash.repeat(2)}\nDetails timed out`; } else { - title += `\n${GlyphChars.Dash.repeat(2)}\n${issue.title.replace( - /([")\\])/g, - '\\$1', - )}\n${issue.closed ? 'Closed' : 'Opened'}, ${Dates.getFormatter( - issue.closedDate ?? issue.date, - ).fromNow()}`; + const issueTitle = issue.title.replace(/([")\\])/g, '\\$1').trim(); + + if (footnotes != null) { + index = footnotes.size + 1; + footnotes.set( + index, + `[**${ + issue.type === 'PullRequest' ? '$(git-pull-request)' : '$(info)' + } ${issueTitle}**](${issueUrl}${title}")\\\n${GlyphChars.Space.repeat( + 5, + )}${linkText} ${issue.closed ? 'closed' : 'opened'} ${Dates.getFormatter( + issue.closedDate ?? issue.date, + ).fromNow()}`, + ); + } + + title += `\n${GlyphChars.Dash.repeat(2)}\n${issueTitle}\n${ + issue.closed ? 'Closed' : 'Opened' + }, ${Dates.getFormatter(issue.closedDate ?? issue.date).fromNow()}`; } } title += '"'; } - return `[${linkText}](${ref.url.replace(numRegex, num)}${title})`; + return `[${linkText}](${issueUrl}${title})`; }); } - const includeFootnotes = footnotes == null; - let index; - text = text.replace(ref.messageRegex!, (_substring, linkText, num) => { const issue = issuesOrPullRequests?.get(num); if (issue == null) return linkText; @@ -207,7 +222,7 @@ export class Autolinks implements Disposable { index = footnotes.size + 1; footnotes.set( - footnotes.size + 1, + index, `${linkText}: ${ issue instanceof Promises.CancellationError ? 'Details timed out' diff --git a/src/git/formatters/commitFormatter.ts b/src/git/formatters/commitFormatter.ts index ecfd113..cfc490d 100644 --- a/src/git/formatters/commitFormatter.ts +++ b/src/git/formatters/commitFormatter.ts @@ -38,7 +38,7 @@ export interface CommitFormatOptions extends FormatOptions { dateStyle?: DateStyle; editor?: { line: number; uri: Uri }; footnotes?: Map; - getBranchAndTagTips?: (sha: string) => string | undefined; + getBranchAndTagTips?: (sha: string, options?: { compact?: boolean; icons?: boolean }) => string | undefined; markdown?: boolean; messageAutolinks?: boolean; messageIndent?: number; @@ -48,6 +48,7 @@ export interface CommitFormatOptions extends FormatOptions { presence?: ContactPresence; previousLineDiffUris?: { current: GitUri; previous: GitUri | undefined }; remotes?: GitRemote[]; + unpublished?: boolean; tokenOptions?: { ago?: Strings.TokenOptions; @@ -61,6 +62,7 @@ export interface CommitFormatOptions extends FormatOptions { authorNotYou?: Strings.TokenOptions; avatar?: Strings.TokenOptions; changes?: Strings.TokenOptions; + changesDetail?: Strings.TokenOptions; changesShort?: Strings.TokenOptions; commands?: Strings.TokenOptions; committerAgo?: Strings.TokenOptions; @@ -248,6 +250,15 @@ export class CommitFormatter extends Formatter { ); } + get changesDetail(): string { + return this._padOrTruncate( + GitLogCommit.is(this._item) + ? this._item.getFormattedDiffStatus({ expand: true, separator: ', ' }) + : emptyStr, + this._options.tokenOptions.changesDetail, + ); + } + get changesShort(): string { return this._padOrTruncate( GitLogCommit.is(this._item) @@ -421,18 +432,22 @@ export class CommitFormatter extends Formatter { this._options.footnotes == null || this._options.footnotes.size === 0 ? emptyStr : Iterables.join( - Iterables.map( - this._options.footnotes, - ([i, footnote]) => `${Strings.getSuperscript(i)} ${footnote}`, + Iterables.map(this._options.footnotes, ([i, footnote]) => + this._options.markdown ? footnote : `${Strings.getSuperscript(i)} ${footnote}`, ), - '\n', + this._options.markdown ? '\\\n' : '\n', ), this._options.tokenOptions.footnotes, ); } get id(): string { - return this._padOrTruncate(this._item.shortSha ?? emptyStr, this._options.tokenOptions.id); + const sha = this._padOrTruncate(this._item.shortSha ?? emptyStr, this._options.tokenOptions.id); + if (this._options.markdown && this._options.unpublished) { + return `${sha} (unpublished)`; + } + + return sha; } get message(): string { @@ -485,7 +500,9 @@ export class CommitFormatter extends Formatter { let text; if (PullRequest.is(pr)) { if (this._options.markdown) { - text = `[PR #${pr.id}](${getMarkdownActionCommand('openPullRequest', { + const prTitle = Strings.escapeMarkdown(pr.title).replace(/"/g, '\\"').trim(); + + text = `PR [**#${pr.id}**](${getMarkdownActionCommand('openPullRequest', { repoPath: this._item.repoPath, provider: { id: pr.provider.id, name: pr.provider.name, domain: pr.provider.domain }, pullRequest: { id: pr.id, url: pr.url }, @@ -494,6 +511,18 @@ export class CommitFormatter extends Formatter { }\n${GlyphChars.Dash.repeat(2)}\n${Strings.escapeMarkdown(pr.title).replace(/"/g, '\\"')}\n${ pr.state }, ${pr.formatDateFromNow()}")`; + + if (this._options.footnotes != null) { + const index = this._options.footnotes.size + 1; + this._options.footnotes.set( + index, + `[**$(git-pull-request) ${prTitle}**](pr.url "Open Pull Request \\#${pr.id} on ${ + pr.provider.name + }")\\\n${GlyphChars.Space.repeat(4)} #${ + pr.id + } ${pr.state.toLocaleLowerCase()} ${pr.formatDateFromNow()}`, + ); + } } else if (this._options.footnotes != null) { const index = this._options.footnotes.size + 1; this._options.footnotes.set( @@ -541,7 +570,13 @@ export class CommitFormatter extends Formatter { } get tips(): string { - const branchAndTagTips = this._options.getBranchAndTagTips?.(this._item.sha); + let branchAndTagTips = this._options.getBranchAndTagTips?.(this._item.sha, { icons: this._options.markdown }); + if (branchAndTagTips != null && this._options.markdown) { + const tips = branchAndTagTips.split(', '); + branchAndTagTips = tips + .map(t => `  ${t}  `) + .join(GlyphChars.Space.repeat(3)); + } return this._padOrTruncate(branchAndTagTips ?? emptyStr, this._options.tokenOptions.tips); } diff --git a/src/git/gitService.ts b/src/git/gitService.ts index 78d7dc5..3f8c5db 100644 --- a/src/git/gitService.ts +++ b/src/git/gitService.ts @@ -1422,27 +1422,41 @@ export class GitService implements Disposable { if (currentName) { if (bt.name === currentName) return undefined; if (bt.refType === 'branch' && bt.getNameWithoutRemote() === currentName) { - return { name: bt.name, compactName: bt.getRemoteName() }; + return { name: bt.name, compactName: bt.getRemoteName(), type: bt.refType }; } } - return { name: bt.name }; + return { name: bt.name, compactName: undefined, type: bt.refType }; }, ); - return (sha: string, compact?: boolean): string | undefined => { + return (sha: string, options?: { compact?: boolean; icons?: boolean }): string | undefined => { const branchesAndTags = branchesAndTagsBySha.get(sha); if (branchesAndTags == null || branchesAndTags.length === 0) return undefined; - if (!compact) return branchesAndTags.map(bt => bt.name).join(', '); + if (!options?.compact) { + return branchesAndTags + .map( + bt => `${options?.icons ? `${bt.type === 'tag' ? '$(tag)' : '$(git-branch)'} ` : ''}${bt.name}`, + ) + .join(', '); + } if (branchesAndTags.length > 1) { - return [branchesAndTags[0], { name: GlyphChars.Ellipsis }] - .map(bt => bt.compactName ?? bt.name) - .join(', '); + const [bt] = branchesAndTags; + return `${options?.icons ? `${bt.type === 'tag' ? '$(tag)' : '$(git-branch)'} ` : ''}${ + bt.compactName ?? bt.name + }, ${GlyphChars.Ellipsis}`; } - return branchesAndTags.map(bt => bt.compactName ?? bt.name).join(', '); + return branchesAndTags + .map( + bt => + `${options?.icons ? `${bt.type === 'tag' ? '$(tag)' : '$(git-branch)'} ` : ''}${ + bt.compactName ?? bt.name + }`, + ) + .join(', '); }; } diff --git a/src/views/nodes/commitNode.ts b/src/views/nodes/commitNode.ts index 0abfc76..3393291 100644 --- a/src/views/nodes/commitNode.ts +++ b/src/views/nodes/commitNode.ts @@ -1,6 +1,6 @@ 'use strict'; import * as paths from 'path'; -import { Command, ThemeColor, ThemeIcon, TreeItem, TreeItemCollapsibleState } from 'vscode'; +import { Command, MarkdownString, ThemeColor, ThemeIcon, TreeItem, TreeItemCollapsibleState } from 'vscode'; import { Commands, DiffWithPreviousCommandArgs } from '../../commands'; import { ViewFilesLayout } from '../../configuration'; import { Colors, GlyphChars } from '../../constants'; @@ -22,7 +22,7 @@ export class CommitNode extends ViewRefNode string | undefined, + private readonly getBranchAndTagTips?: (sha: string, options?: { compact?: boolean }) => string | undefined, private readonly _options: { expand?: boolean } = {}, ) { super(commit.toGitUri(), view, parent); @@ -46,27 +46,6 @@ export class CommitNode extends ViewRefNode { const commit = this.commit; @@ -105,7 +84,7 @@ export class CommitNode extends ViewRefNode { const label = CommitFormatter.fromTemplate(this.view.config.formats.commits.label, this.commit, { dateFormat: Container.config.defaultDateFormat, - getBranchAndTagTips: (sha: string) => this.getBranchAndTagTips?.(sha, true), + getBranchAndTagTips: (sha: string) => this.getBranchAndTagTips?.(sha, { compact: true }), messageTruncateAtNewLine: true, }); @@ -120,6 +99,7 @@ export class CommitNode extends ViewRefNode this.getBranchAndTagTips?.(sha, { compact: true }), messageTruncateAtNewLine: true, }); item.iconPath = this.unpublished @@ -127,7 +107,7 @@ export class CommitNode extends ViewRefNode { + if (item.tooltip == null) { + item.tooltip = await this.getTooltip(); + } + return item; + } + + private async getTooltip() { + const remotes = await Container.git.getRemotes(this.commit.repoPath); + const remote = await Container.git.getRichRemoteProvider(remotes); + + let autolinkedIssuesOrPullRequests; + let pr; + + if (remote?.provider != null) { + [autolinkedIssuesOrPullRequests, pr] = await Promise.all([ + Container.autolinks.getIssueOrPullRequestLinks(this.commit.message, remote), + Container.git.getPullRequestForCommit(this.commit.ref, remote.provider), + ]); + } + + const tooltip = await CommitFormatter.fromTemplateAsync( + `\${'$(git-commit) 'id}\${' via 'pullRequest}\${ \u2022 changesDetail}\${'   'tips}\n\n\${avatar}  __\${author}__, \${ago}   _(\${date})_ \n\n\${message}\${\n\n---\n\nfootnotes}`, + this.commit, + { + autolinkedIssuesOrPullRequests: autolinkedIssuesOrPullRequests, + dateFormat: Container.config.defaultDateFormat, + getBranchAndTagTips: this.getBranchAndTagTips, + markdown: true, + messageAutolinks: true, + messageIndent: 4, + pullRequestOrRemote: pr, + remotes: remotes, + unpublished: this.unpublished, + }, + ); + + const markdown = new MarkdownString(tooltip, true); + markdown.isTrusted = true; + + return markdown; + } } diff --git a/src/views/nodes/fileHistoryNode.ts b/src/views/nodes/fileHistoryNode.ts index f01a7fb..8e48dbc 100644 --- a/src/views/nodes/fileHistoryNode.ts +++ b/src/views/nodes/fileHistoryNode.ts @@ -59,12 +59,15 @@ export class FileHistoryNode extends SubscribeableViewNode impl const children: ViewNode[] = []; const range = this.branch != null ? await Container.git.getBranchAheadRange(this.branch) : undefined; - const [log, fileStatuses, currentUser, unpublishedCommits] = await Promise.all([ + const [log, fileStatuses, currentUser, getBranchAndTagTips, unpublishedCommits] = await Promise.all([ this.getLog(), this.uri.sha == null ? Container.git.getStatusForFiles(this.uri.repoPath!, this.getPathOrGlob()) : undefined, this.uri.sha == null ? Container.git.getCurrentUser(this.uri.repoPath!) : undefined, + this.branch != null + ? Container.git.getBranchesAndTagsTipsFn(this.uri.repoPath, this.branch.name) + : undefined, range ? Container.git.getLogRefsOnly(this.uri.repoPath!, { limit: 0, @@ -112,6 +115,7 @@ export class FileHistoryNode extends SubscribeableViewNode impl ) : new FileRevisionAsCommitNode(this.view, this, c.files[0], c, { branch: this.branch, + getBranchAndTagTips: getBranchAndTagTips, unpublished: unpublishedCommits?.has(c.ref), }), ), diff --git a/src/views/nodes/fileRevisionAsCommitNode.ts b/src/views/nodes/fileRevisionAsCommitNode.ts index f53d9e3..60c20d5 100644 --- a/src/views/nodes/fileRevisionAsCommitNode.ts +++ b/src/views/nodes/fileRevisionAsCommitNode.ts @@ -1,6 +1,15 @@ 'use strict'; import * as paths from 'path'; -import { Command, Selection, ThemeColor, ThemeIcon, TreeItem, TreeItemCollapsibleState, Uri } from 'vscode'; +import { + Command, + MarkdownString, + Selection, + ThemeColor, + ThemeIcon, + TreeItem, + TreeItemCollapsibleState, + Uri, +} from 'vscode'; import { Commands, DiffWithPreviousCommandArgs } from '../../commands'; import { Colors, GlyphChars } from '../../constants'; import { Container } from '../../container'; @@ -28,6 +37,7 @@ export class FileRevisionAsCommitNode extends ViewRefFileNode string | undefined; selection?: Selection; unpublished?: boolean; } = {}, @@ -92,6 +102,7 @@ export class FileRevisionAsCommitNode extends ViewRefFileNode this._options.getBranchAndTagTips?.(sha, { compact: true }), messageTruncateAtNewLine: true, }), this.commit.hasConflicts ? TreeItemCollapsibleState.Expanded : TreeItemCollapsibleState.None, @@ -101,31 +112,12 @@ export class FileRevisionAsCommitNode extends ViewRefFileNode this._options.getBranchAndTagTips?.(sha, { compact: true }), messageTruncateAtNewLine: true, }); item.resourceUri = Uri.parse(`gitlens-view://commit-file/status/${this.file.status}`); - // eslint-disable-next-line no-template-curly-in-string - const status = StatusFileFormatter.fromTemplate('${status}${ (originalPath)}', this.file); // lgtm [js/template-syntax-in-string-literal] - item.tooltip = CommitFormatter.fromTemplate( - this.commit.isUncommitted - ? `\${author} ${GlyphChars.Dash} \${id}\n${status}\n\${ago} (\${date})` - : `\${author}\${ (email)} ${GlyphChars.Dash} \${id}${ - this._options.unpublished ? ' (unpublished)' : '' - }\n${status}\n\${ago} (\${date})\${\n\nmessage}${this.commit.getFormattedDiffStatus({ - expand: true, - prefix: '\n\n', - separator: '\n', - })}\${\n\n${GlyphChars.Dash.repeat(2)}\nfootnotes}`, - this.commit, - { - dateFormat: Container.config.defaultDateFormat, - // messageAutolinks: true, - messageIndent: 4, - }, - ); - if (!this.commit.isUncommitted && this.view.config.avatars) { item.iconPath = this._options.unpublished ? new ThemeIcon('arrow-up', new ThemeColor(Colors.UnpublishedCommitIconColor)) @@ -208,10 +200,54 @@ export class FileRevisionAsCommitNode extends ViewRefFileNode { + if (item.tooltip == null) { + item.tooltip = await this.getTooltip(); + } + return item; + } + async getConflictBaseUri(): Promise { if (!this.commit.hasConflicts) return undefined; const mergeBase = await Container.git.getMergeBase(this.repoPath, 'MERGE_HEAD', 'HEAD'); return GitUri.fromFile(this.file, this.repoPath, mergeBase ?? 'HEAD'); } + + private async getTooltip() { + const remotes = await Container.git.getRemotes(this.commit.repoPath); + const remote = await Container.git.getRichRemoteProvider(remotes); + + let autolinkedIssuesOrPullRequests; + let pr; + + if (remote?.provider != null) { + [autolinkedIssuesOrPullRequests, pr] = await Promise.all([ + Container.autolinks.getIssueOrPullRequestLinks(this.commit.message, remote), + Container.git.getPullRequestForCommit(this.commit.ref, remote.provider), + ]); + } + + const status = StatusFileFormatter.fromTemplate(`\${status}\${ (originalPath)}`, this.file); + const tooltip = await CommitFormatter.fromTemplateAsync( + `\${'$(git-commit) 'id}\${' via 'pullRequest} \u2022 ${status}\${ \u2022 changesDetail}\${'   'tips}\n\n\${avatar}  __\${author}__, \${ago}   _(\${date})_ \n\n\${message}\${\n\n---\n\nfootnotes}`, + this.commit, + { + autolinkedIssuesOrPullRequests: autolinkedIssuesOrPullRequests, + dateFormat: Container.config.defaultDateFormat, + getBranchAndTagTips: this._options.getBranchAndTagTips, + markdown: true, + messageAutolinks: true, + messageIndent: 4, + pullRequestOrRemote: pr, + remotes: remotes, + unpublished: this._options.unpublished, + }, + ); + + const markdown = new MarkdownString(tooltip, true); + markdown.isTrusted = true; + + return markdown; + } } diff --git a/src/views/nodes/lineHistoryNode.ts b/src/views/nodes/lineHistoryNode.ts index 10a3375..dd5056f 100644 --- a/src/views/nodes/lineHistoryNode.ts +++ b/src/views/nodes/lineHistoryNode.ts @@ -68,13 +68,16 @@ export class LineHistoryNode let selection = this.selection; const range = this.branch != null ? await Container.git.getBranchAheadRange(this.branch) : undefined; - const [log, blame, unpublishedCommits] = await Promise.all([ + const [log, blame, getBranchAndTagTips, unpublishedCommits] = await Promise.all([ this.getLog(selection), this.uri.sha == null ? this.editorContents ? await Container.git.getBlameForRangeContents(this.uri, selection, this.editorContents) : await Container.git.getBlameForRange(this.uri, selection) : undefined, + this.branch != null + ? Container.git.getBranchesAndTagsTipsFn(this.uri.repoPath, this.branch.name) + : undefined, range ? Container.git.getLogRefsOnly(this.uri.repoPath!, { limit: 0, @@ -207,6 +210,7 @@ export class LineHistoryNode c => new FileRevisionAsCommitNode(this.view, this, c.files[0], c, { branch: this.branch, + getBranchAndTagTips: getBranchAndTagTips, selection: selection, unpublished: unpublishedCommits?.has(c.ref), }), diff --git a/src/views/nodes/viewNode.ts b/src/views/nodes/viewNode.ts index cc98a08..b421370 100644 --- a/src/views/nodes/viewNode.ts +++ b/src/views/nodes/viewNode.ts @@ -119,6 +119,10 @@ export abstract class ViewNode { abstract getTreeItem(): TreeItem | Promise; + resolveTreeItem(item: TreeItem): TreeItem | Promise { + return item; + } + getCommand(): Command | undefined { return undefined; } diff --git a/src/views/viewBase.ts b/src/views/viewBase.ts index efafb15..596db1f 100644 --- a/src/views/viewBase.ts +++ b/src/views/viewBase.ts @@ -112,6 +112,7 @@ export abstract class ViewBase< const getTreeItem = this.getTreeItem; this.getTreeItem = async function (this: ViewBase, node: ViewNode) { const item = await getTreeItem.apply(this, [node]); + if (node.resolveTreeItem != null) return item; const parent = node.getParent(); @@ -259,6 +260,10 @@ export abstract class ViewBase< return node.getTreeItem(); } + resolveTreeItem(item: TreeItem, node: ViewNode): TreeItem | Promise { + return node.resolveTreeItem(item); + } + protected onElementCollapsed(e: TreeViewExpansionEvent) { this._onDidChangeNodeCollapsibleState.fire({ ...e, state: TreeItemCollapsibleState.Collapsed }); }