Skip to content

Commit 3316da6

Browse files
authored
Bring some consistency to PRs and Issues views (#6771)
* Bring some consistency to PRs and Issues views - avatars in the issues view - rich hover in the PRs view - remove number from issue items * Fix test
1 parent 7712446 commit 3316da6

File tree

11 files changed

+313
-300
lines changed

11 files changed

+313
-300
lines changed

package.json

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2555,11 +2555,6 @@
25552555
"command": "pr.editQuery",
25562556
"when": "view == pr:github && viewItem == query"
25572557
},
2558-
{
2559-
"command": "issue.openIssue",
2560-
"when": "view == issues:github && viewItem =~ /^(link)?(current|continue)?issue/",
2561-
"group": "inline@1"
2562-
},
25632558
{
25642559
"command": "issue.openIssue",
25652560
"when": "view == issues:github && viewItem =~ /^(link)?(current|continue)?issue/",

src/common/markdownUtils.ts

Lines changed: 289 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,289 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
import * as marked from 'marked';
7+
import 'url-search-params-polyfill';
8+
import * as vscode from 'vscode';
9+
import { PullRequestDefaults } from '../github/folderRepositoryManager';
10+
import { GithubItemStateEnum, User } from '../github/interface';
11+
import { IssueModel } from '../github/issueModel';
12+
import { PullRequestModel } from '../github/pullRequestModel';
13+
import { RepositoriesManager } from '../github/repositoriesManager';
14+
import { getIssueNumberLabelFromParsed, ISSUE_OR_URL_EXPRESSION, makeLabel, parseIssueExpressionOutput } from '../github/utils';
15+
import { CODE_PERMALINK, findCodeLinkLocally } from '../issues/issueLinkLookup';
16+
import Logger from './logger';
17+
18+
function getIconString(issue: IssueModel) {
19+
switch (issue.state) {
20+
case GithubItemStateEnum.Open: {
21+
return issue instanceof PullRequestModel ? '$(git-pull-request)' : '$(issues)';
22+
}
23+
case GithubItemStateEnum.Closed: {
24+
return issue instanceof PullRequestModel ? '$(git-pull-request)' : '$(issue-closed)';
25+
}
26+
case GithubItemStateEnum.Merged:
27+
return '$(git-merge)';
28+
}
29+
}
30+
31+
function getIconMarkdown(issue: IssueModel) {
32+
if (issue instanceof PullRequestModel) {
33+
return getIconString(issue);
34+
}
35+
switch (issue.state) {
36+
case GithubItemStateEnum.Open: {
37+
return `<span style="color:#22863a;">$(issues)</span>`;
38+
}
39+
case GithubItemStateEnum.Closed: {
40+
return `<span style="color:#cb2431;">$(issue-closed)</span>`;
41+
}
42+
}
43+
}
44+
45+
function repoCommitDate(user: User, repoNameWithOwner: string): string | undefined {
46+
let date: string | undefined = undefined;
47+
user.commitContributions.forEach(element => {
48+
if (repoNameWithOwner.toLowerCase() === element.repoNameWithOwner.toLowerCase()) {
49+
date = element.createdAt.toLocaleString('default', { day: 'numeric', month: 'short', year: 'numeric' });
50+
}
51+
});
52+
return date;
53+
}
54+
55+
export function userMarkdown(origin: PullRequestDefaults, user: User): vscode.MarkdownString {
56+
const markdown: vscode.MarkdownString = new vscode.MarkdownString(undefined, true);
57+
markdown.appendMarkdown(
58+
`![Avatar](${user.avatarUrl}|height=50,width=50) ${user.name ? `**${user.name}** ` : ''}[${user.login}](${user.url})`,
59+
);
60+
if (user.bio) {
61+
markdown.appendText(' \r\n' + user.bio.replace(/\r\n/g, ' '));
62+
}
63+
64+
const date = repoCommitDate(user, origin.owner + '/' + origin.repo);
65+
if (user.location || date) {
66+
markdown.appendMarkdown(' \r\n\r\n---');
67+
}
68+
if (user.location) {
69+
markdown.appendMarkdown(` \r\n${vscode.l10n.t('{0} {1}', '$(location)', user.location)}`);
70+
}
71+
if (date) {
72+
markdown.appendMarkdown(` \r\n${vscode.l10n.t('{0} Committed to this repository on {1}', '$(git-commit)', date)}`);
73+
}
74+
if (user.company) {
75+
markdown.appendMarkdown(` \r\n${vscode.l10n.t({ message: '{0} Member of {1}', args: ['$(jersey)', user.company], comment: ['An organization that the user is a member of.', 'The first placeholder is an icon and shouldn\'t be localized.', 'The second placeholder is the name of the organization.'] })}`);
76+
}
77+
return markdown;
78+
}
79+
80+
async function findAndModifyString(
81+
text: string,
82+
find: RegExp,
83+
transformer: (match: RegExpMatchArray) => Promise<string | undefined>,
84+
): Promise<string> {
85+
let searchResult = text.search(find);
86+
let position = 0;
87+
while (searchResult >= 0 && searchResult < text.length) {
88+
let newBodyFirstPart: string | undefined;
89+
if (searchResult === 0 || text.charAt(searchResult - 1) !== '&') {
90+
const match = text.substring(searchResult).match(find)!;
91+
if (match) {
92+
const transformed = await transformer(match);
93+
if (transformed) {
94+
newBodyFirstPart = text.slice(0, searchResult) + transformed;
95+
text = newBodyFirstPart + text.slice(searchResult + match[0].length);
96+
}
97+
}
98+
}
99+
position = newBodyFirstPart ? newBodyFirstPart.length : searchResult + 1;
100+
const newSearchResult = text.substring(position).search(find);
101+
searchResult = newSearchResult > 0 ? position + newSearchResult : newSearchResult;
102+
}
103+
return text;
104+
}
105+
106+
function findLinksInIssue(body: string, issue: IssueModel): Promise<string> {
107+
return findAndModifyString(body, ISSUE_OR_URL_EXPRESSION, async (match: RegExpMatchArray) => {
108+
const tryParse = parseIssueExpressionOutput(match);
109+
if (tryParse) {
110+
const issueNumberLabel = getIssueNumberLabelFromParsed(tryParse); // get label before setting owner and name.
111+
if (!tryParse.owner || !tryParse.name) {
112+
tryParse.owner = issue.remote.owner;
113+
tryParse.name = issue.remote.repositoryName;
114+
}
115+
return `[${issueNumberLabel}](https://github.com/${tryParse.owner}/${tryParse.name}/issues/${tryParse.issueNumber})`;
116+
}
117+
return undefined;
118+
});
119+
}
120+
121+
async function findCodeLinksInIssue(body: string, repositoriesManager: RepositoriesManager) {
122+
return findAndModifyString(body, CODE_PERMALINK, async (match: RegExpMatchArray) => {
123+
const codeLink = await findCodeLinkLocally(match, repositoriesManager);
124+
if (codeLink) {
125+
Logger.trace('finding code links in issue', 'Issues');
126+
const textDocument = await vscode.workspace.openTextDocument(codeLink?.file);
127+
const endingTextDocumentLine = textDocument.lineAt(
128+
codeLink.end < textDocument.lineCount ? codeLink.end : textDocument.lineCount - 1,
129+
);
130+
const query = [
131+
codeLink.file,
132+
{
133+
selection: {
134+
start: {
135+
line: codeLink.start,
136+
character: 0,
137+
},
138+
end: {
139+
line: codeLink.end,
140+
character: endingTextDocumentLine.text.length,
141+
},
142+
},
143+
},
144+
];
145+
const openCommand = vscode.Uri.parse(`command:vscode.open?${encodeURIComponent(JSON.stringify(query))}`);
146+
return `[${match[0]}](${openCommand} "Open ${codeLink.file.fsPath}")`;
147+
}
148+
return undefined;
149+
});
150+
}
151+
152+
export const ISSUE_BODY_LENGTH: number = 200;
153+
export async function issueMarkdown(
154+
issue: IssueModel,
155+
context: vscode.ExtensionContext,
156+
repositoriesManager: RepositoriesManager,
157+
commentNumber?: number,
158+
): Promise<vscode.MarkdownString> {
159+
const markdown: vscode.MarkdownString = new vscode.MarkdownString(undefined, true);
160+
markdown.supportHtml = true;
161+
const date = new Date(issue.createdAt);
162+
const ownerName = `${issue.remote.owner}/${issue.remote.repositoryName}`;
163+
markdown.appendMarkdown(
164+
`[${ownerName}](https://github.com/${ownerName}) on ${date.toLocaleString('default', {
165+
day: 'numeric',
166+
month: 'short',
167+
year: 'numeric',
168+
})} \n`,
169+
);
170+
const title = marked
171+
.parse(issue.title, {
172+
renderer: new PlainTextRenderer(),
173+
})
174+
.trim();
175+
markdown.appendMarkdown(
176+
`${getIconMarkdown(issue)} **${title}** [#${issue.number}](${issue.html_url}) \n`,
177+
);
178+
let body = marked.parse(issue.body, {
179+
renderer: new PlainTextRenderer(),
180+
});
181+
markdown.appendMarkdown(' \n');
182+
body = body.length > ISSUE_BODY_LENGTH ? body.substr(0, ISSUE_BODY_LENGTH) + '...' : body;
183+
body = await findLinksInIssue(body, issue);
184+
body = await findCodeLinksInIssue(body, repositoriesManager);
185+
186+
markdown.appendMarkdown(body + ' \n');
187+
markdown.appendMarkdown('&nbsp; \n');
188+
189+
if (issue.item.labels.length > 0) {
190+
issue.item.labels.forEach(label => {
191+
markdown.appendMarkdown(
192+
`[${makeLabel(label)}](https://github.com/${ownerName}/labels/${encodeURIComponent(
193+
label.name,
194+
)}) `,
195+
);
196+
});
197+
}
198+
199+
if (issue.item.comments && commentNumber) {
200+
for (const comment of issue.item.comments) {
201+
if (comment.databaseId === commentNumber) {
202+
markdown.appendMarkdown(' \r\n\r\n---\r\n');
203+
markdown.appendMarkdown('&nbsp; \n');
204+
markdown.appendMarkdown(
205+
`![Avatar](${comment.author.avatarUrl}|height=15,width=15) &nbsp;&nbsp;**${comment.author.login}** commented`,
206+
);
207+
markdown.appendMarkdown('&nbsp; \n');
208+
let commentText = marked.parse(
209+
comment.body.length > ISSUE_BODY_LENGTH
210+
? comment.body.substr(0, ISSUE_BODY_LENGTH) + '...'
211+
: comment.body,
212+
{ renderer: new PlainTextRenderer() },
213+
);
214+
commentText = await findLinksInIssue(commentText, issue);
215+
markdown.appendMarkdown(commentText);
216+
}
217+
}
218+
}
219+
return markdown;
220+
}
221+
222+
export class PlainTextRenderer extends marked.Renderer {
223+
override code(code: string, _infostring: string | undefined): string {
224+
return code;
225+
}
226+
override blockquote(quote: string): string {
227+
return quote;
228+
}
229+
override html(_html: string): string {
230+
return '';
231+
}
232+
override heading(text: string, _level: 1 | 2 | 3 | 4 | 5 | 6, _raw: string, _slugger: marked.Slugger): string {
233+
return text + ' ';
234+
}
235+
override hr(): string {
236+
return '';
237+
}
238+
override list(body: string, _ordered: boolean, _start: number): string {
239+
return body;
240+
}
241+
override listitem(text: string): string {
242+
return ' ' + text;
243+
}
244+
override checkbox(_checked: boolean): string {
245+
return '';
246+
}
247+
override paragraph(text: string): string {
248+
return text.replace(/\</g, '\\\<').replace(/\>/g, '\\\>') + ' ';
249+
}
250+
override table(header: string, body: string): string {
251+
return header + ' ' + body;
252+
}
253+
override tablerow(content: string): string {
254+
return content;
255+
}
256+
override tablecell(
257+
content: string,
258+
_flags: {
259+
header: boolean;
260+
align: 'center' | 'left' | 'right' | null;
261+
},
262+
): string {
263+
return content;
264+
}
265+
override strong(text: string): string {
266+
return text;
267+
}
268+
override em(text: string): string {
269+
return text;
270+
}
271+
override codespan(code: string): string {
272+
return `\\\`${code}\\\``;
273+
}
274+
override br(): string {
275+
return ' ';
276+
}
277+
override del(text: string): string {
278+
return text;
279+
}
280+
override image(_href: string, _title: string, _text: string): string {
281+
return '';
282+
}
283+
override text(text: string): string {
284+
return text;
285+
}
286+
override link(href: string, title: string, text: string): string {
287+
return text + ' ';
288+
}
289+
}

src/issues/issueCompletionProvider.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
*--------------------------------------------------------------------------------------------*/
55

66
import * as vscode from 'vscode';
7+
import { issueMarkdown } from '../common/markdownUtils';
78
import {
89
IGNORE_COMPLETION_TRIGGER,
910
ISSUE_COMPLETION_FORMAT_SCM,
@@ -19,7 +20,6 @@ import { IssueQueryResult, StateManager } from './stateManager';
1920
import {
2021
getRootUriFromScmInputUri,
2122
isComment,
22-
issueMarkdown,
2323
} from './util';
2424

2525
class IssueCompletionItem extends vscode.CompletionItem {

src/issues/issueHoverProvider.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,14 @@
44
*--------------------------------------------------------------------------------------------*/
55

66
import * as vscode from 'vscode';
7+
import { issueMarkdown } from '../common/markdownUtils';
78
import { ITelemetry } from '../common/telemetry';
89
import { FolderRepositoryManager } from '../github/folderRepositoryManager';
910
import { RepositoriesManager } from '../github/repositoriesManager';
1011
import { ISSUE_OR_URL_EXPRESSION, ParsedIssue, parseIssueExpressionOutput } from '../github/utils';
1112
import { StateManager } from './stateManager';
1213
import {
1314
getIssue,
14-
issueMarkdown,
1515
shouldShowHover,
1616
} from './util';
1717

src/issues/issuesView.ts

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,14 @@
66
import * as path from 'path';
77
import * as vscode from 'vscode';
88
import { commands, contexts } from '../common/executeCommands';
9+
import { issueMarkdown } from '../common/markdownUtils';
10+
import { DataUri } from '../common/uri';
911
import { groupBy } from '../common/utils';
1012
import { FolderRepositoryManager, ReposManagerState } from '../github/folderRepositoryManager';
1113
import { IssueModel } from '../github/issueModel';
1214
import { RepositoriesManager } from '../github/repositoriesManager';
1315
import { issueBodyHasLink } from './issueLinkLookup';
1416
import { IssueItem, QueryGroup, StateManager } from './stateManager';
15-
import { issueMarkdown } from './util';
1617

1718
export class QueryNode {
1819
constructor(
@@ -74,11 +75,12 @@ export class IssuesTreeData
7475
return new vscode.TreeItem(element.group, getQueryExpandState(this.context, element, element.isInFirstQuery ? vscode.TreeItemCollapsibleState.Expanded : vscode.TreeItemCollapsibleState.Collapsed));
7576
}
7677

77-
private getIssueTreeItem(element: IssueItem): vscode.TreeItem {
78-
const treeItem = new vscode.TreeItem(`${element.number}: ${element.title}`, vscode.TreeItemCollapsibleState.None);
79-
treeItem.iconPath = element.isOpen
80-
? new vscode.ThemeIcon('issues', new vscode.ThemeColor('issues.open'))
81-
: new vscode.ThemeIcon('issue-closed', new vscode.ThemeColor('issues.closed'));
78+
private async getIssueTreeItem(element: IssueItem): Promise<vscode.TreeItem> {
79+
const treeItem = new vscode.TreeItem(element.title, vscode.TreeItemCollapsibleState.None);
80+
treeItem.iconPath = (await DataUri.avatarCirclesAsImageDataUris(this.context, [element.author], 16, 16))[0] ??
81+
(element.isOpen
82+
? new vscode.ThemeIcon('issues', new vscode.ThemeColor('issues.open'))
83+
: new vscode.ThemeIcon('issue-closed', new vscode.ThemeColor('issues.closed')));
8284

8385
treeItem.command = {
8486
command: 'issue.openDescription',
@@ -103,7 +105,7 @@ export class IssuesTreeData
103105
return treeItem;
104106
}
105107

106-
getTreeItem(element: FolderRepositoryManager | QueryNode | IssueGroupNode | IssueItem): vscode.TreeItem {
108+
async getTreeItem(element: FolderRepositoryManager | QueryNode | IssueGroupNode | IssueItem): Promise<vscode.TreeItem> {
107109
if (element instanceof FolderRepositoryManager) {
108110
return this.getFolderRepoItem(element);
109111
} else if (element instanceof QueryNode) {

src/issues/userCompletionProvider.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import * as path from 'path';
77
import * as vscode from 'vscode';
88
import Logger from '../common/logger';
9+
import { userMarkdown } from '../common/markdownUtils';
910
import { IGNORE_USER_COMPLETION_TRIGGER, ISSUES_SETTINGS_NAMESPACE } from '../common/settingKeys';
1011
import { TimelineEvent } from '../common/timelineEvent';
1112
import { fromNewIssueUri, fromPRUri, Schemes } from '../common/uri';
@@ -17,7 +18,7 @@ import { RepositoriesManager } from '../github/repositoriesManager';
1718
import { getRelatedUsersFromTimelineEvents } from '../github/utils';
1819
import { ASSIGNEES } from './issueFile';
1920
import { StateManager } from './stateManager';
20-
import { getRootUriFromScmInputUri, isComment, UserCompletion, userMarkdown } from './util';
21+
import { getRootUriFromScmInputUri, isComment, UserCompletion } from './util';
2122

2223
export class UserCompletionProvider implements vscode.CompletionItemProvider {
2324
private static readonly ID: string = 'UserCompletionProvider';

0 commit comments

Comments
 (0)