소스 검색

Adds rich hovers for commits in trees

Adds rich footnotes in markdown
Adds branch/tag tips to file/line history
main
Eric Amodio 3 년 전
부모
커밋
da01c24150
9개의 변경된 파일215개의 추가작업 그리고 75개의 파일을 삭제
  1. +26
    -11
      src/annotations/autolinks.ts
  2. +43
    -8
      src/git/formatters/commitFormatter.ts
  3. +22
    -8
      src/git/gitService.ts
  4. +48
    -25
      src/views/nodes/commitNode.ts
  5. +5
    -1
      src/views/nodes/fileHistoryNode.ts
  6. +57
    -21
      src/views/nodes/fileRevisionAsCommitNode.ts
  7. +5
    -1
      src/views/nodes/lineHistoryNode.ts
  8. +4
    -0
      src/views/nodes/viewNode.ts
  9. +5
    -0
      src/views/viewBase.ts

+ 26
- 11
src/annotations/autolinks.ts 파일 보기

@ -167,10 +167,15 @@ export class Autolinks implements Disposable {
}
ref.linkify = (text: string, markdown: boolean, footnotes?: Map<number, string>) => {
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'

+ 43
- 8
src/git/formatters/commitFormatter.ts 파일 보기

@ -38,7 +38,7 @@ export interface CommitFormatOptions extends FormatOptions {
dateStyle?: DateStyle;
editor?: { line: number; uri: Uri };
footnotes?: Map<number, string>;
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<RemoteProvider>[];
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 `<span style="color:#35b15e;">${sha} (unpublished)</span>`;
}
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<OpenPullRequestActionContext>('openPullRequest', {
const prTitle = Strings.escapeMarkdown(pr.title).replace(/"/g, '\\"').trim();
text = `PR [**#${pr.id}**](${getMarkdownActionCommand<OpenPullRequestActionContext>('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 => `<span style="color:#ffffff;background-color:#1d76db;">&nbsp;&nbsp;${t}&nbsp;&nbsp;</span>`)
.join(GlyphChars.Space.repeat(3));
}
return this._padOrTruncate(branchAndTagTips ?? emptyStr, this._options.tokenOptions.tips);
}

+ 22
- 8
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(', ');
};
}

+ 48
- 25
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
public readonly commit: GitLogCommit,
private readonly unpublished?: boolean,
public readonly branch?: GitBranch,
private readonly getBranchAndTagTips?: (sha: string, compact?: boolean) => 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
return this.commit;
}
private get tooltip() {
return CommitFormatter.fromTemplate(
this.commit.isUncommitted
? `\${author} ${GlyphChars.Dash} \${id}\n\${ago} (\${date})`
: `\${author}\${ (email)} ${GlyphChars.Dash} \${id}${
this.unpublished ? ' (unpublished)' : ''
}\${ (tips)}\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,
getBranchAndTagTips: this.getBranchAndTagTips,
// messageAutolinks: true,
messageIndent: 4,
},
);
}
async getChildren(): Promise<ViewNode[]> {
const commit = this.commit;
@ -105,7 +84,7 @@ export class CommitNode extends ViewRefNode
async getTreeItem(): Promise<TreeItem> {
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
item.description = CommitFormatter.fromTemplate(this.view.config.formats.commits.description, this.commit, {
dateFormat: Container.config.defaultDateFormat,
getBranchAndTagTips: (sha: string) => this.getBranchAndTagTips?.(sha, { compact: true }),
messageTruncateAtNewLine: true,
});
item.iconPath = this.unpublished
@ -127,7 +107,7 @@ export class CommitNode extends ViewRefNode
: this.view.config.avatars
? await this.commit.getAvatarUri({ defaultStyle: Container.config.defaultGravatarsStyle })
: new ThemeIcon('git-commit');
item.tooltip = this.tooltip;
// item.tooltip = this.tooltip;
return item;
}
@ -148,4 +128,47 @@ export class CommitNode extends ViewRefNode
arguments: [undefined, commandArgs],
};
}
override async resolveTreeItem(item: TreeItem): Promise<TreeItem> {
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}\${'&nbsp;&nbsp;&nbsp;'tips}\n\n\${avatar} &nbsp;__\${author}__, \${ago} &nbsp; _(\${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;
}
}

+ 5
- 1
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),
}),
),

+ 57
- 21
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
public commit: GitLogCommit,
private readonly _options: {
branch?: GitBranch;
getBranchAndTagTips?: (sha: string, options?: { compact?: boolean }) => string | undefined;
selection?: Selection;
unpublished?: boolean;
} = {},
@ -92,6 +102,7 @@ export class FileRevisionAsCommitNode extends ViewRefFileNode
const item = new TreeItem(
CommitFormatter.fromTemplate(this.view.config.formats.commits.label, this.commit, {
dateFormat: Container.config.defaultDateFormat,
getBranchAndTagTips: (sha: string) => this._options.getBranchAndTagTips?.(sha, { compact: true }),
messageTruncateAtNewLine: true,
}),
this.commit.hasConflicts ? TreeItemCollapsibleState.Expanded : TreeItemCollapsibleState.None,
@ -101,31 +112,12 @@ export class FileRevisionAsCommitNode extends ViewRefFileNode
item.description = CommitFormatter.fromTemplate(this.view.config.formats.commits.description, this.commit, {
dateFormat: Container.config.defaultDateFormat,
getBranchAndTagTips: (sha: string) => 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
};
}
override async resolveTreeItem(item: TreeItem): Promise<TreeItem> {
if (item.tooltip == null) {
item.tooltip = await this.getTooltip();
}
return item;
}
async getConflictBaseUri(): Promise<Uri | undefined> {
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}\${'&nbsp;&nbsp;&nbsp;'tips}\n\n\${avatar} &nbsp;__\${author}__, \${ago} &nbsp; _(\${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;
}
}

+ 5
- 1
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),
}),

+ 4
- 0
src/views/nodes/viewNode.ts 파일 보기

@ -119,6 +119,10 @@ export abstract class ViewNode {
abstract getTreeItem(): TreeItem | Promise<TreeItem>;
resolveTreeItem(item: TreeItem): TreeItem | Promise<TreeItem> {
return item;
}
getCommand(): Command | undefined {
return undefined;
}

+ 5
- 0
src/views/viewBase.ts 파일 보기

@ -112,6 +112,7 @@ export abstract class ViewBase<
const getTreeItem = this.getTreeItem;
this.getTreeItem = async function (this: ViewBase<RootNode, ViewConfig>, 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<TreeItem> {
return node.resolveTreeItem(item);
}
protected onElementCollapsed(e: TreeViewExpansionEvent<ViewNode>) {
this._onDidChangeNodeCollapsibleState.fire({ ...e, state: TreeItemCollapsibleState.Collapsed });
}

불러오는 중...
취소
저장