Path: blob/main/src/vs/workbench/contrib/comments/browser/commentThreadWidget.ts
3296 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import './media/review.css';6import * as dom from '../../../../base/browser/dom.js';7import * as domStylesheets from '../../../../base/browser/domStylesheets.js';8import { Emitter } from '../../../../base/common/event.js';9import { Disposable, dispose, IDisposable, toDisposable } from '../../../../base/common/lifecycle.js';10import { URI } from '../../../../base/common/uri.js';11import * as languages from '../../../../editor/common/languages.js';12import { IMarkdownRendererOptions } from '../../../../editor/browser/widget/markdownRenderer/browser/markdownRenderer.js';13import { IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js';14import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';15import { CommentMenus } from './commentMenus.js';16import { CommentReply } from './commentReply.js';17import { ICommentService } from './commentService.js';18import { CommentThreadBody } from './commentThreadBody.js';19import { CommentThreadHeader } from './commentThreadHeader.js';20import { CommentThreadAdditionalActions } from './commentThreadAdditionalActions.js';21import { CommentContextKeys } from '../common/commentContextKeys.js';22import { ICommentThreadWidget } from '../common/commentThreadWidget.js';23import { IColorTheme } from '../../../../platform/theme/common/themeService.js';24import { contrastBorder, focusBorder, inputValidationErrorBackground, inputValidationErrorBorder, inputValidationErrorForeground, textBlockQuoteBackground, textBlockQuoteBorder, textLinkActiveForeground, textLinkForeground } from '../../../../platform/theme/common/colorRegistry.js';25import { PANEL_BORDER } from '../../../common/theme.js';26import { IRange, Range } from '../../../../editor/common/core/range.js';27import { commentThreadStateBackgroundColorVar, commentThreadStateColorVar } from './commentColors.js';28import { ICellRange } from '../../notebook/common/notebookRange.js';29import { FontInfo } from '../../../../editor/common/config/fontInfo.js';30import { registerNavigableContainer } from '../../../browser/actions/widgetNavigationCommands.js';31import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';32import { COMMENTS_SECTION, ICommentsConfiguration } from '../common/commentsConfiguration.js';33import { localize } from '../../../../nls.js';34import { AccessibilityVerbositySettingId } from '../../accessibility/browser/accessibilityConfiguration.js';35import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js';36import { AccessibilityCommandId } from '../../accessibility/common/accessibilityCommands.js';37import { LayoutableEditor } from './simpleCommentEditor.js';38import { isCodeEditor } from '../../../../editor/browser/editorBrowser.js';3940export const COMMENTEDITOR_DECORATION_KEY = 'commenteditordecoration';4142export class CommentThreadWidget<T extends IRange | ICellRange = IRange> extends Disposable implements ICommentThreadWidget {43private _header!: CommentThreadHeader<T>;44private _body: CommentThreadBody<T>;45private _commentReply?: CommentReply<T>;46private _additionalActions?: CommentThreadAdditionalActions<T>;47private _commentMenus: CommentMenus;48private _commentThreadDisposables: IDisposable[] = [];49private _threadIsEmpty: IContextKey<boolean>;50private _styleElement: HTMLStyleElement;51private _commentThreadContextValue: IContextKey<string | undefined>;52private _focusedContextKey: IContextKey<boolean>;53private _onDidResize = new Emitter<dom.Dimension>();54onDidResize = this._onDidResize.event;5556private _commentThreadState: languages.CommentThreadState | undefined;5758get commentThread() {59return this._commentThread;60}61constructor(62readonly container: HTMLElement,63readonly _parentEditor: LayoutableEditor,64private _owner: string,65private _parentResourceUri: URI,66private _contextKeyService: IContextKeyService,67private _scopedInstantiationService: IInstantiationService,68private _commentThread: languages.CommentThread<T>,69private _pendingComment: languages.PendingComment | undefined,70private _pendingEdits: { [key: number]: languages.PendingComment } | undefined,71private _markdownOptions: IMarkdownRendererOptions,72private _commentOptions: languages.CommentOptions | undefined,73private _containerDelegate: {74actionRunner: (() => void) | null;75collapse: () => Promise<boolean>;76},77@ICommentService private readonly commentService: ICommentService,78@IConfigurationService private readonly configurationService: IConfigurationService,79@IKeybindingService private readonly _keybindingService: IKeybindingService80) {81super();8283this._threadIsEmpty = CommentContextKeys.commentThreadIsEmpty.bindTo(this._contextKeyService);84this._threadIsEmpty.set(!_commentThread.comments || !_commentThread.comments.length);85this._focusedContextKey = CommentContextKeys.commentFocused.bindTo(this._contextKeyService);8687this._commentMenus = this.commentService.getCommentMenus(this._owner);8889this._register(this._header = this._scopedInstantiationService.createInstance(90CommentThreadHeader,91container,92{93collapse: this._containerDelegate.collapse.bind(this)94},95this._commentMenus,96this._commentThread97));9899this._header.updateCommentThread(this._commentThread);100101const bodyElement = <HTMLDivElement>dom.$('.body');102container.appendChild(bodyElement);103this._register(toDisposable(() => bodyElement.remove()));104105const tracker = this._register(dom.trackFocus(bodyElement));106this._register(registerNavigableContainer({107name: 'commentThreadWidget',108focusNotifiers: [tracker],109focusNextWidget: () => {110if (!this._commentReply?.isCommentEditorFocused()) {111this._commentReply?.expandReplyAreaAndFocusCommentEditor();112}113},114focusPreviousWidget: () => {115if (this._commentReply?.isCommentEditorFocused() && this._commentThread.comments?.length) {116this._body.focus();117}118}119}));120this._register(tracker.onDidFocus(() => this._focusedContextKey.set(true)));121this._register(tracker.onDidBlur(() => this._focusedContextKey.reset()));122this._register(this.configurationService.onDidChangeConfiguration(e => {123if (e.affectsConfiguration(AccessibilityVerbositySettingId.Comments)) {124this._setAriaLabel();125}126}));127this._body = this._scopedInstantiationService.createInstance(128CommentThreadBody,129this._parentEditor,130this._owner,131this._parentResourceUri,132bodyElement,133this._markdownOptions,134this._commentThread,135this._pendingEdits,136this._scopedInstantiationService,137this138) as unknown as CommentThreadBody<T>;139this._register(this._body);140this._setAriaLabel();141this._styleElement = domStylesheets.createStyleSheet(this.container);142143144this._commentThreadContextValue = CommentContextKeys.commentThreadContext.bindTo(this._contextKeyService);145this._commentThreadContextValue.set(_commentThread.contextValue);146147const commentControllerKey = CommentContextKeys.commentControllerContext.bindTo(this._contextKeyService);148const controller = this.commentService.getCommentController(this._owner);149150if (controller?.contextValue) {151commentControllerKey.set(controller.contextValue);152}153154this.currentThreadListeners();155}156157get hasUnsubmittedComments(): boolean {158return !!this._commentReply?.commentEditor.getValue() || this._body.hasCommentsInEditMode();159}160161private _setAriaLabel(): void {162let ariaLabel = localize('commentLabel', "Comment");163let keybinding: string | undefined;164const verbose = this.configurationService.getValue(AccessibilityVerbositySettingId.Comments);165if (verbose) {166keybinding = this._keybindingService.lookupKeybinding(AccessibilityCommandId.OpenAccessibilityHelp, this._contextKeyService)?.getLabel() ?? undefined;167}168if (keybinding) {169ariaLabel = localize('commentLabelWithKeybinding', "{0}, use ({1}) for accessibility help", ariaLabel, keybinding);170} else if (verbose) {171ariaLabel = localize('commentLabelWithKeybindingNoKeybinding', "{0}, run the command Open Accessibility Help which is currently not triggerable via keybinding.", ariaLabel);172}173this._body.container.ariaLabel = ariaLabel;174}175176private updateCurrentThread(hasMouse: boolean, hasFocus: boolean) {177if (hasMouse || hasFocus) {178this.commentService.setCurrentCommentThread(this.commentThread);179} else {180this.commentService.setCurrentCommentThread(undefined);181}182}183184private currentThreadListeners() {185let hasMouse = false;186let hasFocus = false;187this._register(dom.addDisposableListener(this.container, dom.EventType.MOUSE_ENTER, (e) => {188if ((<any>e).toElement === this.container) {189hasMouse = true;190this.updateCurrentThread(hasMouse, hasFocus);191}192}, true));193this._register(dom.addDisposableListener(this.container, dom.EventType.MOUSE_LEAVE, (e) => {194if ((<any>e).fromElement === this.container) {195hasMouse = false;196this.updateCurrentThread(hasMouse, hasFocus);197}198}, true));199this._register(dom.addDisposableListener(this.container, dom.EventType.FOCUS_IN, () => {200hasFocus = true;201this.updateCurrentThread(hasMouse, hasFocus);202}, true));203this._register(dom.addDisposableListener(this.container, dom.EventType.FOCUS_OUT, () => {204hasFocus = false;205this.updateCurrentThread(hasMouse, hasFocus);206}, true));207}208209async updateCommentThread(commentThread: languages.CommentThread<T>) {210const shouldCollapse = (this._commentThread.collapsibleState === languages.CommentThreadCollapsibleState.Expanded) && (this._commentThreadState === languages.CommentThreadState.Unresolved)211&& (commentThread.state === languages.CommentThreadState.Resolved);212this._commentThreadState = commentThread.state;213this._commentThread = commentThread;214dispose(this._commentThreadDisposables);215this._commentThreadDisposables = [];216this._bindCommentThreadListeners();217218await this._body.updateCommentThread(commentThread, this._commentReply?.isCommentEditorFocused() ?? false);219this._threadIsEmpty.set(!this._body.length);220this._header.updateCommentThread(commentThread);221this._commentReply?.updateCommentThread(commentThread);222223if (this._commentThread.contextValue) {224this._commentThreadContextValue.set(this._commentThread.contextValue);225} else {226this._commentThreadContextValue.reset();227}228229if (shouldCollapse && this.configurationService.getValue<ICommentsConfiguration>(COMMENTS_SECTION).collapseOnResolve) {230this.collapse();231}232}233234async display(lineHeight: number, focus: boolean) {235const headHeight = Math.max(23, Math.ceil(lineHeight * 1.2)); // 23 is the value of `Math.ceil(lineHeight * 1.2)` with the default editor font size236this._header.updateHeight(headHeight);237238await this._body.display();239240// create comment thread only when it supports reply241if (this._commentThread.canReply) {242this._createCommentForm(focus);243}244this._createAdditionalActions();245246this._register(this._body.onDidResize(dimension => {247this._refresh(dimension);248}));249250// If there are no existing comments, place focus on the text area. This must be done after show, which also moves focus.251// if this._commentThread.comments is undefined, it doesn't finish initialization yet, so we don't focus the editor immediately.252if (this._commentThread.canReply && this._commentReply) {253this._commentReply.focusIfNeeded();254}255256this._bindCommentThreadListeners();257}258259private _refresh(dimension: dom.Dimension) {260this._body.layout();261this._onDidResize.fire(dimension);262}263264override dispose() {265super.dispose();266dispose(this._commentThreadDisposables);267this.updateCurrentThread(false, false);268}269270private _bindCommentThreadListeners() {271this._commentThreadDisposables.push(this._commentThread.onDidChangeCanReply(() => {272if (this._commentReply) {273this._commentReply.updateCanReply();274} else {275if (this._commentThread.canReply) {276this._createCommentForm(false);277}278}279}));280281this._commentThreadDisposables.push(this._commentThread.onDidChangeComments(async _ => {282await this.updateCommentThread(this._commentThread);283}));284285this._commentThreadDisposables.push(this._commentThread.onDidChangeLabel(_ => {286this._header.createThreadLabel();287}));288}289290private _createCommentForm(focus: boolean) {291this._commentReply = this._scopedInstantiationService.createInstance(292CommentReply,293this._owner,294this._body.container,295this._parentEditor,296this._commentThread,297this._scopedInstantiationService,298this._contextKeyService,299this._commentMenus,300this._commentOptions,301this._pendingComment,302this,303focus,304this._containerDelegate.actionRunner305);306307this._register(this._commentReply);308}309310private _createAdditionalActions() {311this._additionalActions = this._scopedInstantiationService.createInstance(312CommentThreadAdditionalActions,313this._body.container,314this._commentThread,315this._contextKeyService,316this._commentMenus,317this._containerDelegate.actionRunner,318);319320this._register(this._additionalActions);321}322323getCommentCoords(commentUniqueId: number) {324return this._body.getCommentCoords(commentUniqueId);325}326327getPendingEdits(): { [key: number]: languages.PendingComment } {328return this._body.getPendingEdits();329}330331getPendingComment(): languages.PendingComment | undefined {332if (this._commentReply) {333return this._commentReply.getPendingComment();334}335336return undefined;337}338339setPendingComment(pending: languages.PendingComment) {340this._pendingComment = pending;341this._commentReply?.setPendingComment(pending);342}343344getDimensions() {345return this._body.getDimensions();346}347348layout(widthInPixel?: number) {349this._body.layout(widthInPixel);350351if (widthInPixel !== undefined) {352this._commentReply?.layout(widthInPixel);353}354}355356ensureFocusIntoNewEditingComment() {357this._body.ensureFocusIntoNewEditingComment();358}359360focusCommentEditor() {361this._commentReply?.expandReplyAreaAndFocusCommentEditor();362}363364focus(commentUniqueId: number | undefined) {365this._body.focus(commentUniqueId);366}367368async submitComment() {369const activeComment = this._body.activeComment;370if (activeComment) {371return activeComment.submitComment();372} else if ((this._commentReply?.getPendingComment()?.body.length ?? 0) > 0) {373return this._commentReply?.submitComment();374}375}376377async collapse() {378if ((await this._containerDelegate.collapse()) && Range.isIRange(this.commentThread.range) && isCodeEditor(this._parentEditor)) {379this._parentEditor.setSelection(this.commentThread.range);380}381382}383384applyTheme(theme: IColorTheme, fontInfo: FontInfo) {385const content: string[] = [];386387content.push(`.monaco-editor .review-widget > .body { border-top: 1px solid var(${commentThreadStateColorVar}) }`);388content.push(`.monaco-editor .review-widget > .head { background-color: var(${commentThreadStateBackgroundColorVar}) }`);389390const linkColor = theme.getColor(textLinkForeground);391if (linkColor) {392content.push(`.review-widget .body .comment-body a { color: ${linkColor} }`);393}394395const linkActiveColor = theme.getColor(textLinkActiveForeground);396if (linkActiveColor) {397content.push(`.review-widget .body .comment-body a:hover, a:active { color: ${linkActiveColor} }`);398}399400const focusColor = theme.getColor(focusBorder);401if (focusColor) {402content.push(`.review-widget .body .comment-body a:focus { outline: 1px solid ${focusColor}; }`);403content.push(`.review-widget .body .monaco-editor.focused { outline: 1px solid ${focusColor}; }`);404}405406const blockQuoteBackground = theme.getColor(textBlockQuoteBackground);407if (blockQuoteBackground) {408content.push(`.review-widget .body .review-comment blockquote { background: ${blockQuoteBackground}; }`);409}410411const blockQuoteBOrder = theme.getColor(textBlockQuoteBorder);412if (blockQuoteBOrder) {413content.push(`.review-widget .body .review-comment blockquote { border-color: ${blockQuoteBOrder}; }`);414}415416const border = theme.getColor(PANEL_BORDER);417if (border) {418content.push(`.review-widget .body .review-comment .review-comment-contents .comment-reactions .action-item a.action-label { border-color: ${border}; }`);419}420421const hcBorder = theme.getColor(contrastBorder);422if (hcBorder) {423content.push(`.review-widget .body .comment-form .review-thread-reply-button { outline-color: ${hcBorder}; }`);424content.push(`.review-widget .body .monaco-editor { outline: 1px solid ${hcBorder}; }`);425}426427const errorBorder = theme.getColor(inputValidationErrorBorder);428if (errorBorder) {429content.push(`.review-widget .validation-error { border: 1px solid ${errorBorder}; }`);430}431432const errorBackground = theme.getColor(inputValidationErrorBackground);433if (errorBackground) {434content.push(`.review-widget .validation-error { background: ${errorBackground}; }`);435}436437const errorForeground = theme.getColor(inputValidationErrorForeground);438if (errorForeground) {439content.push(`.review-widget .body .comment-form .validation-error { color: ${errorForeground}; }`);440}441442const fontFamilyVar = '--comment-thread-editor-font-family';443const fontSizeVar = '--comment-thread-editor-font-size';444const fontWeightVar = '--comment-thread-editor-font-weight';445this.container?.style.setProperty(fontFamilyVar, fontInfo.fontFamily);446this.container?.style.setProperty(fontSizeVar, `${fontInfo.fontSize}px`);447this.container?.style.setProperty(fontWeightVar, fontInfo.fontWeight);448449content.push(`.review-widget .body code {450font-family: var(${fontFamilyVar});451font-weight: var(${fontWeightVar});452}`);453454this._styleElement.textContent = content.join('\n');455this._commentReply?.setCommentEditorDecorations();456}457}458459460