diff --git a/src/vs/editor/common/modes.ts b/src/vs/editor/common/modes.ts index ae8a3b77861..1468afc9581 100644 --- a/src/vs/editor/common/modes.ts +++ b/src/vs/editor/common/modes.ts @@ -1229,6 +1229,14 @@ export interface NewCommentAction { actions: Command[]; } +/** + * @internal + */ +export interface CommentReaction { + readonly label?: string; + readonly hasReacted?: boolean; +} + /** * @internal */ @@ -1241,6 +1249,7 @@ export interface Comment { readonly canDelete?: boolean; readonly command?: Command; readonly isDraft?: boolean; + readonly commentReactions?: CommentReaction[]; } /** @@ -1284,6 +1293,11 @@ export interface DocumentCommentProvider { startDraftLabel?: string; deleteDraftLabel?: string; finishDraftLabel?: string; + + addReaction?(resource: URI, comment: Comment, reaction: CommentReaction, token: CancellationToken): Promise; + deleteReaction?(resource: URI, comment: Comment, reaction: CommentReaction, token: CancellationToken): Promise; + reactionGroup?: CommentReaction[]; + onDidChangeCommentThreads(): Event; } diff --git a/src/vs/vscode.proposed.d.ts b/src/vs/vscode.proposed.d.ts index 77b915859c9..40399883818 100644 --- a/src/vs/vscode.proposed.d.ts +++ b/src/vs/vscode.proposed.d.ts @@ -820,7 +820,7 @@ declare module 'vscode' { interface CommentReaction { readonly label?: string; - readonly iconPath?: string | Uri; + readonly hasReacted?: boolean; } interface DocumentCommentProvider { @@ -857,10 +857,9 @@ declare module 'vscode' { deleteDraftLabel?: string; finishDraftLabel?: string; - commentReactions?: CommentReaction[]; - - addReaction(comment: Comment, reaction: CommentReaction): Promise; - deleteReaction(comment: Comment, reaction: CommentReaction): Promise; + addReaction?(document: TextDocument, comment: Comment, reaction: CommentReaction): Promise; + deleteReaction?(document: TextDocument, comment: Comment, reaction: CommentReaction): Promise; + reactionGroup?: CommentReaction[]; /** * Notify of updates to comment threads. diff --git a/src/vs/workbench/api/electron-browser/mainThreadComments.ts b/src/vs/workbench/api/electron-browser/mainThreadComments.ts index 1c6ebef3589..2eec4b8c85f 100644 --- a/src/vs/workbench/api/electron-browser/mainThreadComments.ts +++ b/src/vs/workbench/api/electron-browser/mainThreadComments.ts @@ -30,6 +30,7 @@ export class MainThreadDocumentCommentProvider implements modes.DocumentCommentP get startDraftLabel(): string { return this._features.startDraftLabel; } get deleteDraftLabel(): string { return this._features.deleteDraftLabel; } get finishDraftLabel(): string { return this._features.finishDraftLabel; } + get reactionGroup(): modes.CommentReaction[] { return this._features.reactionGroup; } constructor(proxy: ExtHostCommentsShape, handle: number, features: CommentProviderFeatures) { this._proxy = proxy; @@ -66,6 +67,13 @@ export class MainThreadDocumentCommentProvider implements modes.DocumentCommentP async finishDraft(uri, token): Promise { return this._proxy.$finishDraft(this._handle, uri); } + async addReaction(uri, comment: modes.Comment, reaction: modes.CommentReaction, token): Promise { + return this._proxy.$addReaction(this._handle, uri, comment, reaction); + } + async deleteReaction(uri, comment: modes.Comment, reaction: modes.CommentReaction, token): Promise { + return this._proxy.$deleteReaction(this._handle, uri, comment, reaction); + } + onDidChangeCommentThreads = null; } diff --git a/src/vs/workbench/api/node/extHost.protocol.ts b/src/vs/workbench/api/node/extHost.protocol.ts index d9f02bbeb03..72e15943a45 100644 --- a/src/vs/workbench/api/node/extHost.protocol.ts +++ b/src/vs/workbench/api/node/extHost.protocol.ts @@ -110,6 +110,7 @@ export interface CommentProviderFeatures { startDraftLabel?: string; deleteDraftLabel?: string; finishDraftLabel?: string; + reactionGroup?: vscode.CommentReaction[]; } export interface MainThreadCommentsShape extends IDisposable { @@ -1063,6 +1064,8 @@ export interface ExtHostCommentsShape { $startDraft(handle: number, document: UriComponents): Promise; $deleteDraft(handle: number, document: UriComponents): Promise; $finishDraft(handle: number, document: UriComponents): Promise; + $addReaction(handle: number, document: UriComponents, comment: modes.Comment, reaction: modes.CommentReaction): Promise; + $deleteReaction(handle: number, document: UriComponents, comment: modes.Comment, reaction: modes.CommentReaction): Promise; $provideWorkspaceComments(handle: number): Promise; } diff --git a/src/vs/workbench/api/node/extHostComments.ts b/src/vs/workbench/api/node/extHostComments.ts index e814ea68a15..3e76d66a03e 100644 --- a/src/vs/workbench/api/node/extHostComments.ts +++ b/src/vs/workbench/api/node/extHostComments.ts @@ -69,7 +69,8 @@ export class ExtHostComments implements ExtHostCommentsShape { this._proxy.$registerDocumentCommentProvider(handle, { startDraftLabel: provider.startDraftLabel, deleteDraftLabel: provider.deleteDraftLabel, - finishDraftLabel: provider.finishDraftLabel + finishDraftLabel: provider.finishDraftLabel, + reactionGroup: provider.reactionGroup }); this.registerListeners(handle, extensionId, provider); @@ -174,6 +175,34 @@ export class ExtHostComments implements ExtHostCommentsShape { }); } + $addReaction(handle: number, uri: UriComponents, comment: modes.Comment, reaction: modes.CommentReaction): Promise { + const data = this._documents.getDocumentData(URI.revive(uri)); + + if (!data || !data.document) { + throw new Error('Unable to retrieve document from URI'); + } + + const handlerData = this._documentProviders.get(handle); + + return asPromise(() => { + return handlerData.provider.addReaction(data.document, convertFromComment(comment), reaction); + }); + } + + $deleteReaction(handle: number, uri: UriComponents, comment: modes.Comment, reaction: modes.CommentReaction): Promise { + const data = this._documents.getDocumentData(URI.revive(uri)); + + if (!data || !data.document) { + throw new Error('Unable to retrieve document from URI'); + } + + const handlerData = this._documentProviders.get(handle); + + return asPromise(() => { + return handlerData.provider.deleteReaction(data.document, convertFromComment(comment), reaction); + }); + } + $provideDocumentComments(handle: number, uri: UriComponents): Promise { const data = this._documents.getDocumentData(URI.revive(uri)); if (!data || !data.document) { @@ -259,7 +288,8 @@ function convertFromComment(comment: modes.Comment): vscode.Comment { userIconPath: userIconPath, canEdit: comment.canEdit, canDelete: comment.canDelete, - isDraft: comment.isDraft + isDraft: comment.isDraft, + commentReactions: comment.commentReactions }; } @@ -275,6 +305,7 @@ function convertToComment(provider: vscode.DocumentCommentProvider | vscode.Work canEdit: canEdit, canDelete: canDelete, command: vscodeComment.command ? commandsConverter.toInternal(vscodeComment.command) : null, - isDraft: vscodeComment.isDraft + isDraft: vscodeComment.isDraft, + commentReactions: vscodeComment.commentReactions }; } diff --git a/src/vs/workbench/parts/comments/electron-browser/commentNode.ts b/src/vs/workbench/parts/comments/electron-browser/commentNode.ts index f1338bd4507..d81030836ec 100644 --- a/src/vs/workbench/parts/comments/electron-browser/commentNode.ts +++ b/src/vs/workbench/parts/comments/electron-browser/commentNode.ts @@ -7,9 +7,9 @@ import * as nls from 'vs/nls'; import * as dom from 'vs/base/browser/dom'; import * as modes from 'vs/editor/common/modes'; import { IKeyboardEvent } from 'vs/base/browser/keyboardEvent'; -import { ActionBar } from 'vs/base/browser/ui/actionbar/actionbar'; +import { ActionsOrientation, ActionItem, ActionBar } from 'vs/base/browser/ui/actionbar/actionbar'; import { Button } from 'vs/base/browser/ui/button/button'; -import { Action } from 'vs/base/common/actions'; +import { Action, IActionRunner } from 'vs/base/common/actions'; import { Disposable } from 'vs/base/common/lifecycle'; import { URI } from 'vs/base/common/uri'; import { ITextModel } from 'vs/editor/common/model'; @@ -30,6 +30,8 @@ import { Emitter, Event } from 'vs/base/common/event'; import { INotificationService } from 'vs/platform/notification/common/notification'; import { assign } from 'vs/base/common/objects'; import { MarkdownString } from 'vs/base/common/htmlContent'; +import { ToolBar } from 'vs/base/browser/ui/toolbar/toolbar'; +import { IContextMenuService } from 'vs/platform/contextview/browser/contextView'; const UPDATE_COMMENT_LABEL = nls.localize('label.updateComment', "Update comment"); const UPDATE_IN_PROGRESS_LABEL = nls.localize('label.updatingComment', "Updating comment..."); @@ -49,6 +51,9 @@ export class CommentNode extends Disposable { private _isPendingLabel: HTMLElement; private _deleteAction: Action; + protected actionRunner?: IActionRunner; + protected toolbar: ToolBar; + private _onDidDelete = new Emitter(); public get domNode(): HTMLElement { @@ -66,7 +71,8 @@ export class CommentNode extends Disposable { private modelService: IModelService, private modeService: IModeService, private dialogService: IDialogService, - private notificationService: INotificationService + private notificationService: INotificationService, + private contextMenuService: IContextMenuService ) { super(); @@ -86,7 +92,9 @@ export class CommentNode extends Disposable { this._md = this.markdownRenderer.render(comment.body).element; this._body.appendChild(this._md); - this.createReactions(commentDetailsContainer); + if (this.comment.commentReactions) { + this.createReactions(commentDetailsContainer); + } this._domNode.setAttribute('aria-label', `${comment.userName}, ${comment.body.value}`); this._domNode.setAttribute('role', 'treeitem'); @@ -121,23 +129,79 @@ export class CommentNode extends Disposable { if (actions.length) { const actionsContainer = dom.append(header, dom.$('.comment-actions.hidden')); - const actionBar = new ActionBar(actionsContainer, {}); - this._toDispose.push(actionBar); + + this.toolbar = new ToolBar(actionsContainer, this.contextMenuService, { + actionItemProvider: action => this.actionItemProvider(action as Action), + orientation: ActionsOrientation.HORIZONTAL + }); + this.registerActionBarListeners(actionsContainer); - actions.forEach(action => actionBar.push(action, { label: false, icon: true })); + let reactionActions = []; + let reactionGroup = this.commentService.getReactionGroup(this.owner); + if (reactionGroup) { + reactionActions = reactionGroup.map((reaction) => { + return new Action(`reaction.command.${reaction.label}`, `${reaction.label}`, '', true, async () => { + try { + await this.commentService.addReaction(this.owner, this.resource, this.comment, reaction); + } catch (e) { + const error = e.message + ? nls.localize('commentAddReactionError', "Deleting the comment reaction failed: {0}.", e.message) + : nls.localize('commentAddReactionDefaultError', "Deleting the comment reaction failed"); + this.notificationService.error(error); + } + }); + }); + } + + this.toolbar.setActions(actions, reactionActions)(); + this._toDispose.push(this.toolbar); } } + actionItemProvider(action: Action) { + let options = {}; + if (action.id === 'comment.delete' || action.id === 'comment.edit') { + options = { label: false, icon: true }; + } else { + options = { label: true, icon: true }; + } + + let item = new ActionItem({}, action, options); + return item; + } + private createReactions(commentDetailsContainer: HTMLElement): void { - let reactions = ['❤️', '🎉', '😄']; + const actionsContainer = dom.append(commentDetailsContainer, dom.$('div.comment-reactions')); + const actionBar = new ActionBar(actionsContainer, {}); + this._toDispose.push(actionBar); - const reactionsBar = dom.append(commentDetailsContainer, dom.$('div.comment-reactions')); + let reactionActions = this.comment.commentReactions.map(reaction => { + return new Action(`reaction.${reaction.label}`, `${reaction.label}`, reaction.hasReacted ? 'active' : '', true, async () => { + try { + if (reaction.hasReacted) { + await this.commentService.deleteReaction(this.owner, this.resource, this.comment, reaction); + } else { + await this.commentService.addReaction(this.owner, this.resource, this.comment, reaction); + } + } catch (e) { + let error: string; - reactions.forEach(reaction => { - let btn = new Button(reactionsBar); - btn.label = reaction; + if (reaction.hasReacted) { + error = e.message + ? nls.localize('commentDeleteReactionError', "Deleting the comment reaction failed: {0}.", e.message) + : nls.localize('commentDeleteReactionDefaultError', "Deleting the comment reaction failed"); + } else { + error = e.message + ? nls.localize('commentAddReactionError', "Deleting the comment reaction failed: {0}.", e.message) + : nls.localize('commentAddReactionDefaultError', "Deleting the comment reaction failed"); + } + this.notificationService.error(error); + } + }); }); + + reactionActions.forEach(action => actionBar.push(action, { label: true, icon: true })); } private createCommentEditor(): void { @@ -272,7 +336,7 @@ export class CommentNode extends Disposable { actionsContainer.classList.remove('hidden'); })); - this._toDispose.push(dom.addDisposableListener(this._domNode, 'mouseleave', (e: MouseEvent) => { + this._toDispose.push(dom.addDisposableListener(this._domNode, 'mouseleave', () => { if (!this._domNode.contains(document.activeElement)) { actionsContainer.classList.add('hidden'); } diff --git a/src/vs/workbench/parts/comments/electron-browser/commentService.ts b/src/vs/workbench/parts/comments/electron-browser/commentService.ts index 0a806cc0332..7173a1b02db 100644 --- a/src/vs/workbench/parts/comments/electron-browser/commentService.ts +++ b/src/vs/workbench/parts/comments/electron-browser/commentService.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { CommentThread, DocumentCommentProvider, CommentThreadChangedEvent, CommentInfo, Comment } from 'vs/editor/common/modes'; +import { CommentThread, DocumentCommentProvider, CommentThreadChangedEvent, CommentInfo, Comment, CommentReaction } from 'vs/editor/common/modes'; import { createDecorator } from 'vs/platform/instantiation/common/instantiation'; import { Event, Emitter } from 'vs/base/common/event'; import { Disposable } from 'vs/base/common/lifecycle'; @@ -55,6 +55,9 @@ export interface ICommentService { getStartDraftLabel(owner: string): string; getDeleteDraftLabel(owner: string): string; getFinishDraftLabel(owner: string): string; + addReaction(owner: string, resource: URI, comment: Comment, reaction: CommentReaction): Promise; + deleteReaction(owner: string, resource: URI, comment: Comment, reaction: CommentReaction): Promise; + getReactionGroup(owner: string): CommentReaction[]; } export class CommentService extends Disposable implements ICommentService { @@ -178,6 +181,36 @@ export class CommentService extends Disposable implements ICommentService { } } + async addReaction(owner: string, resource: URI, comment: Comment, reaction: CommentReaction): Promise { + const commentProvider = this._commentProviders.get(owner); + + if (commentProvider && commentProvider.addReaction) { + return commentProvider.addReaction(resource, comment, reaction, CancellationToken.None); + } else { + throw new Error('Not supported'); + } + } + + async deleteReaction(owner: string, resource: URI, comment: Comment, reaction: CommentReaction): Promise { + const commentProvider = this._commentProviders.get(owner); + + if (commentProvider && commentProvider.deleteReaction) { + return commentProvider.deleteReaction(resource, comment, reaction, CancellationToken.None); + } else { + throw new Error('Not supported'); + } + } + + getReactionGroup(owner: string): CommentReaction[] { + const commentProvider = this._commentProviders.get(owner); + + if (commentProvider) { + return commentProvider.reactionGroup; + } + + return null; + } + getStartDraftLabel(owner: string): string | null { const commentProvider = this._commentProviders.get(owner); diff --git a/src/vs/workbench/parts/comments/electron-browser/commentThreadWidget.ts b/src/vs/workbench/parts/comments/electron-browser/commentThreadWidget.ts index b009344ee58..980e3d0d8b6 100644 --- a/src/vs/workbench/parts/comments/electron-browser/commentThreadWidget.ts +++ b/src/vs/workbench/parts/comments/electron-browser/commentThreadWidget.ts @@ -39,6 +39,7 @@ import { CommentNode } from 'vs/workbench/parts/comments/electron-browser/commen import { IDialogService } from 'vs/platform/dialogs/common/dialogs'; import { INotificationService } from 'vs/platform/notification/common/notification'; import { ITextModel } from 'vs/editor/common/model'; +import { IContextMenuService } from 'vs/platform/contextview/browser/contextView'; export const COMMENTEDITOR_DECORATION_KEY = 'commenteditordecoration'; const COLLAPSE_ACTION_CLASS = 'expand-review-action octicon octicon-x'; @@ -97,6 +98,7 @@ export class ReviewZoneWidget extends ZoneWidget { private openerService: IOpenerService, private dialogService: IDialogService, private notificationService: INotificationService, + private contextMenuService: IContextMenuService, editor: ICodeEditor, owner: string, commentThread: modes.CommentThread, @@ -491,7 +493,8 @@ export class ReviewZoneWidget extends ZoneWidget { this.modelService, this.modeService, this.dialogService, - this.notificationService); + this.notificationService, + this.contextMenuService); this._disposables.push(newCommentNode); this._disposables.push(newCommentNode.onDidDelete(deletedNode => { diff --git a/src/vs/workbench/parts/comments/electron-browser/commentsEditorContribution.ts b/src/vs/workbench/parts/comments/electron-browser/commentsEditorContribution.ts index c6f1b95096c..dd4b304492c 100644 --- a/src/vs/workbench/parts/comments/electron-browser/commentsEditorContribution.ts +++ b/src/vs/workbench/parts/comments/electron-browser/commentsEditorContribution.ts @@ -35,6 +35,8 @@ import { INotificationService } from 'vs/platform/notification/common/notificati import { IDialogService } from 'vs/platform/dialogs/common/dialogs'; import { CancelablePromise, createCancelablePromise } from 'vs/base/common/async'; import { overviewRulerCommentingRangeForeground } from 'vs/workbench/parts/comments/electron-browser/commentGlyphWidget'; +import { IContextMenuService } from 'vs/platform/contextview/browser/contextView'; +import { STATUS_BAR_ITEM_HOVER_BACKGROUND, STATUS_BAR_ITEM_ACTIVE_BACKGROUND } from 'vs/workbench/common/theme'; export const ctxReviewPanelVisible = new RawContextKey('reviewPanelVisible', false); @@ -175,7 +177,8 @@ export class ReviewController implements IEditorContribution { @IModelService private readonly modelService: IModelService, @ICodeEditorService private readonly codeEditorService: ICodeEditorService, @IOpenerService private readonly openerService: IOpenerService, - @IDialogService private readonly dialogService: IDialogService + @IDialogService private readonly dialogService: IDialogService, + @IContextMenuService private readonly contextMenuService: IContextMenuService, ) { this.editor = editor; this.globalToDispose = []; @@ -393,7 +396,7 @@ export class ReviewController implements IEditorContribution { } }); added.forEach(thread => { - let zoneWidget = new ReviewZoneWidget(this.instantiationService, this.modeService, this.modelService, this.themeService, this.commentService, this.openerService, this.dialogService, this.notificationService, this.editor, e.owner, thread, null, draftMode, {}); + let zoneWidget = new ReviewZoneWidget(this.instantiationService, this.modeService, this.modelService, this.themeService, this.commentService, this.openerService, this.dialogService, this.notificationService, this.contextMenuService, this.editor, e.owner, thread, null, draftMode, {}); zoneWidget.display(thread.range.startLineNumber); this._commentWidgets.push(zoneWidget); this._commentInfos.filter(info => info.owner === e.owner)[0].threads.push(thread); @@ -412,7 +415,7 @@ export class ReviewController implements IEditorContribution { // add new comment this._reviewPanelVisible.set(true); - this._newCommentWidget = new ReviewZoneWidget(this.instantiationService, this.modeService, this.modelService, this.themeService, this.commentService, this.openerService, this.dialogService, this.notificationService, this.editor, ownerId, { + this._newCommentWidget = new ReviewZoneWidget(this.instantiationService, this.modeService, this.modelService, this.themeService, this.commentService, this.openerService, this.dialogService, this.notificationService, this.contextMenuService, this.editor, ownerId, { extensionId: extensionId, threadId: null, resource: null, @@ -570,7 +573,7 @@ export class ReviewController implements IEditorContribution { thread.collapsibleState = modes.CommentThreadCollapsibleState.Expanded; } - let zoneWidget = new ReviewZoneWidget(this.instantiationService, this.modeService, this.modelService, this.themeService, this.commentService, this.openerService, this.dialogService, this.notificationService, this.editor, info.owner, thread, pendingComment, info.draftMode, {}); + let zoneWidget = new ReviewZoneWidget(this.instantiationService, this.modeService, this.modelService, this.themeService, this.commentService, this.openerService, this.dialogService, this.notificationService, this.contextMenuService, this.editor, info.owner, thread, pendingComment, info.draftMode, {}); zoneWidget.display(thread.range.startLineNumber); this._commentWidgets.push(zoneWidget); }); @@ -741,4 +744,16 @@ registerThemingParticipant((theme, collector) => { } `); } + + const statusBarItemHoverBackground = theme.getColor(STATUS_BAR_ITEM_HOVER_BACKGROUND); + if (statusBarItemHoverBackground) { + collector.addRule(`.monaco-editor .review-widget .body .review-comment .review-comment-contents .comment-reactions .action-item a.action-label:hover { background-color: ${statusBarItemHoverBackground}; border: 1px solid grey; + border-radius: 3px; }`); + } + + const statusBarItemActiveBackground = theme.getColor(STATUS_BAR_ITEM_ACTIVE_BACKGROUND); + if (statusBarItemActiveBackground) { + collector.addRule(`.monaco-editor .review-widget .body .review-comment .review-comment-contents .comment-reactions .action-item a.action-label:active { background-color: ${statusBarItemActiveBackground}; border: 1px solid grey; + border-radius: 3px;}`); + } }); diff --git a/src/vs/workbench/parts/comments/electron-browser/media/review.css b/src/vs/workbench/parts/comments/electron-browser/media/review.css index 11015f5d7e9..57347578cb3 100644 --- a/src/vs/workbench/parts/comments/electron-browser/media/review.css +++ b/src/vs/workbench/parts/comments/electron-browser/media/review.css @@ -56,6 +56,13 @@ line-height: 18px; } +.monaco-editor .review-widget .body .review-comment .comment-title .monaco-dropdown .toolbar-toggle-more { + width: 16px; + height: 18px; + line-height: 18px; + vertical-align: middle; +} + .monaco-editor .review-widget .body .comment-body blockquote { margin: 0 7px 0 5px; padding: 0 16px 0 10px; @@ -120,6 +127,32 @@ padding-top: 4px; } +.monaco-editor .review-widget .body .review-comment .review-comment-contents .comment-reactions { + margin-top: 8px; + min-height: 25px; +} + +.monaco-editor .review-widget .body .review-comment .review-comment-contents .comment-reactions .monaco-action-bar .actions-container { + justify-content: flex-start; +} + +.monaco-editor .review-widget .body .review-comment .review-comment-contents .comment-reactions .action-item .action-label { + padding: 2px 5px 0px 5px; + white-space: pre; + text-align: center; + font-size: 14px; + margin: 4px; +} + +.monaco-editor .review-widget .body .review-comment .review-comment-contents .comment-reactions .action-item a.action-label.active { + border: 1px solid grey; + border-radius: 3px; +} + +.monaco-editor .review-widget .body .review-comment .review-comment-contents .comment-reactions .action-item a.action-label.disabled { + opacity: 0.6; +} + .monaco-editor.vs-dark .review-widget .body span.created_at { color: #e0e0e0; }