diff --git a/src/common/comment.ts b/src/common/comment.ts index ae3da88742..409608f168 100644 --- a/src/common/comment.ts +++ b/src/common/comment.ts @@ -27,5 +27,8 @@ export interface Comment { created_at: string; updated_at: string; html_url: string; + absolutePosition?: number; + canEdit: boolean; + canDelete: boolean; } diff --git a/src/github/credentials.ts b/src/github/credentials.ts index 05333d3645..53812257b0 100644 --- a/src/github/credentials.ts +++ b/src/github/credentials.ts @@ -71,7 +71,7 @@ export class CredentialStore { if (octokit) { this._octokits.set(host, octokit); } - this.updateAuthenticationStatusBar(remote); + await this.updateAuthenticationStatusBar(remote); return this._octokits.has(host); } @@ -145,6 +145,11 @@ export class CredentialStore { return octokit; } + public isCurrentUser(username: string, remote: Remote): boolean { + const octokit = this.getOctokit(remote); + return octokit && (octokit as any).currentUser && (octokit as any).currentUser.login === username; + } + private createOctokit(type: string, creds: IHostConfiguration): Octokit { const octokit = new Octokit({ baseUrl: `${HostHelper.getApiHost(creds).toString().slice(0, -1)}${HostHelper.getApiPath(creds, '')}`, @@ -176,6 +181,7 @@ export class CredentialStore { if (octokit) { try { const user = await octokit.users.get({}); + (octokit as any).currentUser = user.data; text = `$(mark-github) ${user.data.login}`; } catch (e) { text = '$(mark-github) Signed in'; diff --git a/src/github/interface.ts b/src/github/interface.ts index cd3508fe32..d3fd2ed67a 100644 --- a/src/github/interface.ts +++ b/src/github/interface.ts @@ -178,6 +178,8 @@ export interface IPullRequestManager { createCommentReply(pullRequest: IPullRequestModel, body: string, reply_to: string): Promise; createComment(pullRequest: IPullRequestModel, body: string, path: string, position: number): Promise; mergePullRequest(pullRequest: IPullRequestModel): Promise; + editComment(pullRequest: IPullRequestModel, commentId: string, text: string): Promise; + deleteComment(pullRequest: IPullRequestModel, commentId: string): Promise; closePullRequest(pullRequest: IPullRequestModel): Promise; approvePullRequest(pullRequest: IPullRequestModel, message?: string): Promise; requestChanges(pullRequest: IPullRequestModel, message?: string): Promise; diff --git a/src/github/pullRequestManager.ts b/src/github/pullRequestManager.ts index 64256d2b18..4df4c60e71 100644 --- a/src/github/pullRequestManager.ts +++ b/src/github/pullRequestManager.ts @@ -270,7 +270,7 @@ export class PullRequestManager implements IPullRequestManager { number: pullRequest.prNumber, per_page: 100 }); - const rawComments = reviewData.data; + const rawComments = reviewData.data.map(comment => this.addCommentPermissions(comment, remote)); return parserCommentDiffHunk(rawComments); } @@ -316,7 +316,7 @@ export class PullRequestManager implements IPullRequestManager { review_id: reviewId }); - const rawComments = reviewData.data; + const rawComments = reviewData.data.map(comment => this.addCommentPermissions(comment, remote)); return parserCommentDiffHunk(rawComments); } @@ -356,7 +356,7 @@ export class PullRequestManager implements IPullRequestManager { repo: remote.repositoryName }); - return promise.data; + return this.addCommentPermissions(promise.data, remote); } async createCommentReply(pullRequest: IPullRequestModel, body: string, reply_to: string): Promise { @@ -371,7 +371,7 @@ export class PullRequestManager implements IPullRequestManager { in_reply_to: Number(reply_to) }); - return ret.data; + return this.addCommentPermissions(ret.data, remote); } catch (e) { this.handleError(e); } @@ -391,12 +391,52 @@ export class PullRequestManager implements IPullRequestManager { position: position }); - return ret.data; + return this.addCommentPermissions(ret.data, remote); } catch (e) { this.handleError(e); } } + async editComment(pullRequest: IPullRequestModel, commentId: string, text: string): Promise { + try { + const { octokit, remote } = await (pullRequest as PullRequestModel).githubRepository.ensure(); + + const ret = await octokit.pullRequests.editComment({ + owner: remote.owner, + repo: remote.repositoryName, + body: text, + comment_id: commentId + }); + + return this.addCommentPermissions(ret.data, remote); + } catch (e) { + throw new Error(formatError(e)); + } + } + + async deleteComment(pullRequest: IPullRequestModel, commentId: string): Promise { + try { + const { octokit, remote } = await (pullRequest as PullRequestModel).githubRepository.ensure(); + + await octokit.pullRequests.deleteComment({ + owner: remote.owner, + repo: remote.repositoryName, + comment_id: commentId + }); + } catch (e) { + throw new Error(formatError(e)); + } + } + + private addCommentPermissions(rawComment: Comment, remote: Remote): Comment { + const isCurrentUser = this._credentialStore.isCurrentUser(rawComment.user.login, remote); + const notOutdated = rawComment.position !== null; + rawComment.canEdit = isCurrentUser && notOutdated; + rawComment.canDelete = isCurrentUser && notOutdated; + + return rawComment; + } + private async changePullRequestState(state: 'open' | 'closed', pullRequest: IPullRequestModel): Promise { const { octokit, remote } = await (pullRequest as PullRequestModel).githubRepository.ensure(); diff --git a/src/typings/vscode.proposed.d.ts b/src/typings/vscode.proposed.d.ts index 9b3a074851..415aa81b9c 100644 --- a/src/typings/vscode.proposed.d.ts +++ b/src/typings/vscode.proposed.d.ts @@ -518,7 +518,14 @@ declare module 'vscode' { */ interface CommentInfo { + /** + * All of the comment threads associated with the document. + */ threads: CommentThread[]; + + /** + * The ranges of the document which support commenting. + */ commentingRanges?: Range[]; } @@ -533,19 +540,85 @@ declare module 'vscode' { Expanded = 1 } + /** + * A collection of comments representing a conversation at a particular range in a document. + */ interface CommentThread { + /** + * A unique identifier of the comment thread. + */ threadId: string; + + /** + * The uri of the document the thread has been created on. + */ resource: Uri; + + /** + * The range the comment thread is located within the document. The thread icon will be shown + * at the first line of the range. + */ range: Range; + + /** + * The ordered comments of the thread. + */ comments: Comment[]; + + /** + * Whether the thread should be collapsed or expanded when opening the document. Defaults to Collapsed. + */ collapsibleState?: CommentThreadCollapsibleState; } + /** + * A comment is displayed within the editor or the Comments Panel, depending on how it is provided. + */ interface Comment { + /** + * The id of the comment + */ commentId: string; + + /** + * The text of the comment + */ body: MarkdownString; + + /** + * The display name of the user who created the comment + */ userName: string; - gravatar: string; + + /** + * The icon path for the user who created the comment + */ + userIconPath?: Uri; + + /** + * @deprecated Use userIconPath instead. The avatar src of the user who created the comment + */ + gravatar?: string; + + /** + * Whether the current user has permission to edit the comment. + * + * This will be treated as false if the comment is provided by a `WorkspaceCommentProvider`, or + * if it is provided by a `DocumentCommentProvider` and no `editComment` method is given. + */ + canEdit?: boolean; + + /** + * Whether the current user has permission to delete the comment. + * + * This will be treated as false if the comment is provided by a `WorkspaceCommentProvider`, or + * if it is provided by a `DocumentCommentProvider` and no `deleteComment` method is given. + */ + canDelete?: boolean; + + /** + * The command to be executed if the comment is selected in the Comments Panel + */ command?: Command; } @@ -567,18 +640,48 @@ declare module 'vscode' { } interface DocumentCommentProvider { + /** + * Provide the commenting ranges and comment threads for the given document. The comments are displayed within the editor. + */ provideDocumentComments(document: TextDocument, token: CancellationToken): Promise; - createNewCommentThread?(document: TextDocument, range: Range, text: string, token: CancellationToken): Promise; - replyToCommentThread?(document: TextDocument, range: Range, commentThread: CommentThread, text: string, token: CancellationToken): Promise; - onDidChangeCommentThreads?: Event; + + /** + * Called when a user adds a new comment thread in the document at the specified range, with body text. + */ + createNewCommentThread(document: TextDocument, range: Range, text: string, token: CancellationToken): Promise; + + /** + * Called when a user replies to a new comment thread in the document at the specified range, with body text. + */ + replyToCommentThread(document: TextDocument, range: Range, commentThread: CommentThread, text: string, token: CancellationToken): Promise; + + /** + * Called when a user edits the comment body to the be new text text. + */ + editComment?(document: TextDocument, comment: Comment, text: string, token: CancellationToken): Promise; + + /** + * Called when a user deletes the comment. + */ + deleteComment?(document: TextDocument, comment: Comment, token: CancellationToken): Promise; + + /** + * Notify of updates to comment threads. + */ + onDidChangeCommentThreads: Event; } interface WorkspaceCommentProvider { + /** + * Provide all comments for the workspace. Comments are shown within the comments panel. Selecting a comment + * from the panel runs the comment's command. + */ provideWorkspaceComments(token: CancellationToken): Promise; - createNewCommentThread?(document: TextDocument, range: Range, text: string, token: CancellationToken): Promise; - replyToCommentThread?(document: TextDocument, range: Range, commentThread: CommentThread, text: string, token: CancellationToken): Promise; - onDidChangeCommentThreads?: Event; + /** + * Notify of updates to comment threads. + */ + onDidChangeCommentThreads: Event; } namespace workspace { diff --git a/src/view/prDocumentCommentProvider.ts b/src/view/prDocumentCommentProvider.ts index 830ab14065..0750c7e7da 100644 --- a/src/view/prDocumentCommentProvider.ts +++ b/src/view/prDocumentCommentProvider.ts @@ -10,7 +10,7 @@ import { fromPRUri } from '../common/uri'; export class PRDocumentCommentProvider implements vscode.DocumentCommentProvider { private _onDidChangeCommentThreads: vscode.EventEmitter = new vscode.EventEmitter(); - public onDidChangeCommentThreads?: vscode.Event = this._onDidChangeCommentThreads.event; + public onDidChangeCommentThreads: vscode.Event = this._onDidChangeCommentThreads.event; private _prDocumentCommentProviders: {[key: number]: vscode.DocumentCommentProvider} = {}; @@ -64,6 +64,28 @@ export class PRDocumentCommentProvider implements vscode.DocumentCommentProvider return await this._prDocumentCommentProviders[params.prNumber].replyToCommentThread(document, range, commentThread, text, token); } + + async editComment(document: vscode.TextDocument, comment: vscode.Comment, text: string, token: vscode.CancellationToken): Promise { + const params = fromPRUri(document.uri); + const commentProvider = this._prDocumentCommentProviders[params.prNumber]; + + if (!commentProvider) { + throw new Error(`Couldn't find document provider`); + } + + return await commentProvider.editComment(document, comment, text, token); + } + + async deleteComment(document: vscode.TextDocument, comment: vscode.Comment, token: vscode.CancellationToken): Promise { + const params = fromPRUri(document.uri); + const commentProvider = this._prDocumentCommentProviders[params.prNumber]; + + if (!commentProvider) { + throw new Error(`Couldn't find document provider`); + } + + return await commentProvider.deleteComment(document, comment, token); + } } const prDocumentCommentProvider = new PRDocumentCommentProvider(); diff --git a/src/view/reviewManager.ts b/src/view/reviewManager.ts index ed791ae029..cdb96b3b21 100644 --- a/src/view/reviewManager.ts +++ b/src/view/reviewManager.ts @@ -301,7 +301,9 @@ export class ReviewManager implements vscode.DecorationProvider { commentId: comment.id, body: new vscode.MarkdownString(comment.body), userName: comment.user.login, - gravatar: comment.user.avatar_url + gravatar: comment.user.avatar_url, + canEdit: comment.canEdit, + canDelete: comment.canDelete }); matchedFile.comments.push(comment); @@ -342,7 +344,9 @@ export class ReviewManager implements vscode.DecorationProvider { commentId: rawComment.id, body: new vscode.MarkdownString(rawComment.body), userName: rawComment.user.login, - gravatar: rawComment.user.avatar_url + gravatar: rawComment.user.avatar_url, + canEdit: rawComment.canEdit, + canDelete: rawComment.canDelete }; let commentThread: vscode.CommentThread = { @@ -368,6 +372,82 @@ export class ReviewManager implements vscode.DecorationProvider { } } + private async editComment(document: vscode.TextDocument, comment: vscode.Comment, text: string): Promise { + try { + const matchedFile = this.findMatchedFileByUri(document); + if (!matchedFile) { + throw new Error('Unable to find matching file'); + } + + const editedComment = await this._prManager.editComment(this._prManager.activePullRequest, comment.commentId, text); + + // Update the cached comments of the file + const matchingCommentIndex = matchedFile.comments.findIndex(c => c.id === comment.commentId); + if (matchingCommentIndex > -1) { + matchedFile.comments.splice(matchingCommentIndex, 1, editedComment); + const changedThreads = this.fileCommentsToCommentThreads(matchedFile, matchedFile.comments.filter(c => c.position === editedComment.position), vscode.CommentThreadCollapsibleState.Expanded); + + this._onDidChangeWorkspaceCommentThreads.fire({ + added: [], + changed: changedThreads, + removed: [] + }); + } + + // Also update this._comments + const indexInAllComments = this._comments.findIndex(c => c.id === comment.commentId); + if (indexInAllComments > -1) { + this._comments.splice(indexInAllComments, 1, editedComment); + } + } catch (e) { + throw new Error(formatError(e)); + } + } + + private async deleteComment(document: vscode.TextDocument, comment: vscode.Comment): Promise { + try { + const matchedFile = this.findMatchedFileByUri(document); + if (!matchedFile) { + throw new Error('Unable to find matching file'); + } + + await this._prManager.deleteComment(this._prManager.activePullRequest, comment.commentId); + const matchingCommentIndex = matchedFile.comments.findIndex(c => c.id === comment.commentId); + if (matchingCommentIndex > -1) { + const [ deletedComment ] = matchedFile.comments.splice(matchingCommentIndex, 1); + const updatedThreadComments = matchedFile.comments.filter(c => c.position === deletedComment.position); + + // If the deleted comment was the last in its thread, remove the thread + if (updatedThreadComments.length) { + const changedThreads = this.fileCommentsToCommentThreads(matchedFile, updatedThreadComments, vscode.CommentThreadCollapsibleState.Expanded); + this._onDidChangeWorkspaceCommentThreads.fire({ + added: [], + changed: changedThreads, + removed: [] + }); + } else { + this._onDidChangeWorkspaceCommentThreads.fire({ + added: [], + changed: [], + removed: [{ + threadId: deletedComment.id, + resource: vscode.Uri.file(nodePath.resolve(this._repository.rootUri.fsPath, deletedComment.path)), + comments: [], + range: null + }] + }); + } + } + + const indexInAllComments = this._comments.findIndex(c => c.id === comment.commentId); + if (indexInAllComments > -1) { + this._comments.splice(indexInAllComments, 1); + } + } catch (e) { + throw new Error(formatError(e)); + } + } + private async updateComments(): Promise { const branch = this._repository.state.HEAD; if (!branch) { return; } @@ -596,7 +676,9 @@ export class ReviewManager implements vscode.DecorationProvider { arguments: [ fileChange ] - } + }, + canEdit: comment.canEdit, + canDelete: comment.canDelete }; }), collapsibleState: collapsibleState @@ -646,7 +728,9 @@ export class ReviewManager implements vscode.DecorationProvider { body: new vscode.MarkdownString(comment.body), userName: comment.user.login, gravatar: comment.user.avatar_url, - command: command + command: command, + canEdit: comment.canEdit, + canDelete: comment.canDelete }; }), collapsibleState: collapsibleState @@ -831,7 +915,9 @@ export class ReviewManager implements vscode.DecorationProvider { commentId: comment.id, body: new vscode.MarkdownString(comment.body), userName: comment.user.login, - gravatar: comment.user.avatar_url + gravatar: comment.user.avatar_url, + canEdit: comment.canEdit, + canDelete: comment.canDelete }; }), collapsibleState: vscode.CommentThreadCollapsibleState.Expanded @@ -844,7 +930,9 @@ export class ReviewManager implements vscode.DecorationProvider { } }, createNewCommentThread: this.createNewCommentThread.bind(this), - replyToCommentThread: this.replyToCommentThread.bind(this) + replyToCommentThread: this.replyToCommentThread.bind(this), + editComment: this.editComment.bind(this), + deleteComment: this.deleteComment.bind(this) }); this._workspaceCommentProvider = vscode.workspace.registerWorkspaceCommentProvider({ @@ -857,8 +945,7 @@ export class ReviewManager implements vscode.DecorationProvider { return this.outdatedCommentsToCommentThreads(fileChange, fileChange.comments, vscode.CommentThreadCollapsibleState.Expanded); }); return [...comments, ...outdatedComments].reduce((prev, curr) => prev.concat(curr), []); - }, - createNewCommentThread: this.createNewCommentThread.bind(this), replyToCommentThread: this.replyToCommentThread.bind(this) + } }); } diff --git a/src/view/treeNodes/pullRequestNode.ts b/src/view/treeNodes/pullRequestNode.ts index 0b571eac45..94004f710f 100644 --- a/src/view/treeNodes/pullRequestNode.ts +++ b/src/view/treeNodes/pullRequestNode.ts @@ -99,7 +99,9 @@ export function providePRDocumentComments( commentId: comment.id, body: new vscode.MarkdownString(comment.body), userName: comment.user.login, - gravatar: comment.user.avatar_url + gravatar: comment.user.avatar_url, + canEdit: comment.canEdit, + canDelete: comment.canDelete }; }), collapsibleState: vscode.CommentThreadCollapsibleState.Expanded, @@ -140,7 +142,9 @@ function commentsToCommentThreads(fileChange: InMemFileChangeNode, comments: Com commentId: comment.id, body: new vscode.MarkdownString(comment.body), userName: comment.user.login, - gravatar: comment.user.avatar_url + gravatar: comment.user.avatar_url, + canEdit: comment.canEdit, + canDelete: comment.canDelete }; }), collapsibleState: vscode.CommentThreadCollapsibleState.Expanded, @@ -278,7 +282,9 @@ export class PRNode extends TreeNode { onDidChangeCommentThreads: this._onDidChangeCommentThreads.event, provideDocumentComments: this.provideDocumentComments.bind(this), createNewCommentThread: this.createNewCommentThread.bind(this), - replyToCommentThread: this.replyToCommentThread.bind(this) + replyToCommentThread: this.replyToCommentThread.bind(this), + editComment: this.editComment.bind(this), + deleteComment: this.deleteComment.bind(this) }); } } else { @@ -434,6 +440,21 @@ export class PRNode extends TreeNode { return ''; } + private findMatchingFileNode(uri: vscode.Uri): InMemFileChangeNode { + const params = fromPRUri(uri); + const fileChange = this._fileChanges.find(change => change.fileName === params.fileName); + + if (!fileChange) { + throw new Error('No matching file found'); + } + + if (fileChange instanceof RemoteFileChangeNode) { + throw new Error('Comments not supported on remote file changes'); + } + + return fileChange; + } + private async createNewCommentThread(document: vscode.TextDocument, range: vscode.Range, text: string) { try { let uri = document.uri; @@ -443,15 +464,7 @@ export class PRNode extends TreeNode { return null; } - let fileChange = this._fileChanges.find(change => change.fileName === params.fileName); - - if (!fileChange) { - throw new Error('No matching file found'); - } - - if (fileChange instanceof RemoteFileChangeNode) { - throw new Error('Cannot add comment to this file'); - } + const fileChange = this.findMatchingFileNode(uri); let isBase = params && params.isBase; let position = mapHeadLineToDiffHunkPosition(fileChange.diffHunks, '', range.start.line + 1, isBase); @@ -466,7 +479,9 @@ export class PRNode extends TreeNode { commentId: rawComment.id, body: new vscode.MarkdownString(rawComment.body), userName: rawComment.user.login, - gravatar: rawComment.user.avatar_url + gravatar: rawComment.user.avatar_url, + canEdit: rawComment.canEdit, + canDelete: rawComment.canDelete }; fileChange.comments.push(rawComment); @@ -484,26 +499,38 @@ export class PRNode extends TreeNode { } } - private async replyToCommentThread(document: vscode.TextDocument, _range: vscode.Range, thread: vscode.CommentThread, text: string) { - try { - const uri = document.uri; - const params = fromPRUri(uri); - const fileChange = this._fileChanges.find(change => change.fileName === params.fileName); + private async editComment(document: vscode.TextDocument, comment: vscode.Comment, text: string): Promise { + const fileChange = this.findMatchingFileNode(document.uri); + const rawComment = await this._prManager.editComment(this.pullRequestModel, comment.commentId, text); - if (!fileChange) { - throw new Error('No matching file found'); - } + const index = fileChange.comments.findIndex(c => c.id === comment.commentId); + if (index > -1) { + fileChange.comments.splice(index, 1, rawComment); + } + } - if (fileChange instanceof RemoteFileChangeNode) { - throw new Error('Cannot add comment to this file'); - } + private async deleteComment(document: vscode.TextDocument, comment: vscode.Comment): Promise { + const fileChange = this.findMatchingFileNode(document.uri); + + await this._prManager.deleteComment(this.pullRequestModel, comment.commentId); + const index = fileChange.comments.findIndex(c => c.id === comment.commentId); + if (index > -1) { + fileChange.comments.splice(index, 1); + } + } + + private async replyToCommentThread(document: vscode.TextDocument, _range: vscode.Range, thread: vscode.CommentThread, text: string) { + try { + const fileChange = this.findMatchingFileNode(document.uri); const rawComment = await this._prManager.createCommentReply(this.pullRequestModel, text, thread.threadId); thread.comments.push({ commentId: rawComment.id, body: new vscode.MarkdownString(rawComment.body), userName: rawComment.user.login, - gravatar: rawComment.user.avatar_url + gravatar: rawComment.user.avatar_url, + canEdit: rawComment.canEdit, + canDelete: rawComment.canDelete }); fileChange.comments.push(rawComment);