Path: blob/main/src/vs/workbench/contrib/comments/browser/commentThreadWidget.ts
5243 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 { Emitter } from '../../../../base/common/event.js';8import { Disposable, dispose, IDisposable, toDisposable } from '../../../../base/common/lifecycle.js';9import { URI } from '../../../../base/common/uri.js';10import * as languages from '../../../../editor/common/languages.js';11import { IMarkdownRendererExtraOptions } from '../../../../platform/markdown/browser/markdownRenderer.js';12import { IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js';13import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';14import { CommentMenus } from './commentMenus.js';15import { CommentReply } from './commentReply.js';16import { ICommentService } from './commentService.js';17import { CommentThreadBody } from './commentThreadBody.js';18import { CommentThreadHeader } from './commentThreadHeader.js';19import { CommentThreadAdditionalActions } from './commentThreadAdditionalActions.js';20import { CommentContextKeys } from '../common/commentContextKeys.js';21import { ICommentThreadWidget } from '../common/commentThreadWidget.js';22import { IRange, Range } from '../../../../editor/common/core/range.js';23import { ICellRange } from '../../notebook/common/notebookRange.js';24import { FontInfo } from '../../../../editor/common/config/fontInfo.js';25import { registerNavigableContainer } from '../../../browser/actions/widgetNavigationCommands.js';26import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';27import { COMMENTS_SECTION, ICommentsConfiguration } from '../common/commentsConfiguration.js';28import { localize } from '../../../../nls.js';29import { AccessibilityVerbositySettingId } from '../../accessibility/browser/accessibilityConfiguration.js';30import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js';31import { AccessibilityCommandId } from '../../accessibility/common/accessibilityCommands.js';32import { LayoutableEditor } from './simpleCommentEditor.js';33import { isCodeEditor } from '../../../../editor/browser/editorBrowser.js';3435export const COMMENTEDITOR_DECORATION_KEY = 'commenteditordecoration';3637export class CommentThreadWidget<T extends IRange | ICellRange = IRange> extends Disposable implements ICommentThreadWidget {38private _header!: CommentThreadHeader<T>;39private _body: CommentThreadBody<T>;40private _commentReply?: CommentReply<T>;41private _additionalActions?: CommentThreadAdditionalActions<T>;42private _commentMenus: CommentMenus;43private _commentThreadDisposables: IDisposable[] = [];44private _threadIsEmpty: IContextKey<boolean>;45private _commentThreadContextValue: IContextKey<string | undefined>;46private _focusedContextKey: IContextKey<boolean>;47private _onDidResize = this._register(new Emitter<dom.Dimension>());48onDidResize = this._onDidResize.event;4950private _commentThreadState: languages.CommentThreadState | undefined;5152get commentThread() {53return this._commentThread;54}55constructor(56readonly container: HTMLElement,57readonly _parentEditor: LayoutableEditor,58private _owner: string,59private _parentResourceUri: URI,60private _contextKeyService: IContextKeyService,61private _scopedInstantiationService: IInstantiationService,62private _commentThread: languages.CommentThread<T>,63private _pendingComment: languages.PendingComment | undefined,64private _pendingEdits: { [key: number]: languages.PendingComment } | undefined,65private _markdownOptions: IMarkdownRendererExtraOptions,66private _commentOptions: languages.CommentOptions | undefined,67private _containerDelegate: {68actionRunner: (() => void) | null;69collapse: () => Promise<boolean>;70},71@ICommentService private readonly commentService: ICommentService,72@IConfigurationService private readonly configurationService: IConfigurationService,73@IKeybindingService private readonly _keybindingService: IKeybindingService74) {75super();7677this._threadIsEmpty = CommentContextKeys.commentThreadIsEmpty.bindTo(this._contextKeyService);78this._threadIsEmpty.set(!_commentThread.comments || !_commentThread.comments.length);79this._focusedContextKey = CommentContextKeys.commentFocused.bindTo(this._contextKeyService);8081this._commentMenus = this.commentService.getCommentMenus(this._owner);8283this._register(this._header = this._scopedInstantiationService.createInstance(84CommentThreadHeader,85container,86{87collapse: this._containerDelegate.collapse.bind(this)88},89this._commentMenus,90this._commentThread91));9293this._header.updateCommentThread(this._commentThread);9495const bodyElement = dom.$('.body');96container.appendChild(bodyElement);97this._register(toDisposable(() => bodyElement.remove()));9899const tracker = this._register(dom.trackFocus(bodyElement));100this._register(registerNavigableContainer({101name: 'commentThreadWidget',102focusNotifiers: [tracker],103focusNextWidget: () => {104if (!this._commentReply?.isCommentEditorFocused()) {105this._commentReply?.expandReplyAreaAndFocusCommentEditor();106}107},108focusPreviousWidget: () => {109if (this._commentReply?.isCommentEditorFocused() && this._commentThread.comments?.length) {110this._body.focus();111}112}113}));114this._register(tracker.onDidFocus(() => this._focusedContextKey.set(true)));115this._register(tracker.onDidBlur(() => this._focusedContextKey.reset()));116this._register(this.configurationService.onDidChangeConfiguration(e => {117if (e.affectsConfiguration(AccessibilityVerbositySettingId.Comments)) {118this._setAriaLabel();119}120}));121this._body = this._scopedInstantiationService.createInstance(122CommentThreadBody,123this._parentEditor,124this._owner,125this._parentResourceUri,126bodyElement,127this._markdownOptions,128this._commentThread,129this._pendingEdits,130this._scopedInstantiationService,131this132) as unknown as CommentThreadBody<T>;133this._register(this._body);134this._setAriaLabel();135136this._commentThreadContextValue = CommentContextKeys.commentThreadContext.bindTo(this._contextKeyService);137this._commentThreadContextValue.set(_commentThread.contextValue);138139const commentControllerKey = CommentContextKeys.commentControllerContext.bindTo(this._contextKeyService);140const controller = this.commentService.getCommentController(this._owner);141142if (controller?.contextValue) {143commentControllerKey.set(controller.contextValue);144}145146this.currentThreadListeners();147}148149get hasUnsubmittedComments(): boolean {150return !!this._commentReply?.commentEditor.getValue() || this._body.hasCommentsInEditMode();151}152153private _setAriaLabel(): void {154let ariaLabel = localize('commentLabel', "Comment");155let keybinding: string | undefined;156const verbose = this.configurationService.getValue(AccessibilityVerbositySettingId.Comments);157if (verbose) {158keybinding = this._keybindingService.lookupKeybinding(AccessibilityCommandId.OpenAccessibilityHelp, this._contextKeyService)?.getLabel() ?? undefined;159}160if (keybinding) {161ariaLabel = localize('commentLabelWithKeybinding', "{0}, use ({1}) for accessibility help", ariaLabel, keybinding);162} else if (verbose) {163ariaLabel = localize('commentLabelWithKeybindingNoKeybinding', "{0}, run the command Open Accessibility Help which is currently not triggerable via keybinding.", ariaLabel);164}165this._body.container.ariaLabel = ariaLabel;166}167168private updateCurrentThread(hasMouse: boolean, hasFocus: boolean) {169if (hasMouse || hasFocus) {170this.commentService.setCurrentCommentThread(this.commentThread);171} else {172this.commentService.setCurrentCommentThread(undefined);173}174}175176private currentThreadListeners() {177let hasMouse = false;178let hasFocus = false;179this._register(dom.addDisposableListener(this.container, dom.EventType.MOUSE_ENTER, (e) => {180if (e.relatedTarget === this.container) {181hasMouse = true;182this.updateCurrentThread(hasMouse, hasFocus);183}184}, true));185this._register(dom.addDisposableListener(this.container, dom.EventType.MOUSE_LEAVE, (e) => {186if (e.relatedTarget === this.container) {187hasMouse = false;188this.updateCurrentThread(hasMouse, hasFocus);189}190}, true));191this._register(dom.addDisposableListener(this.container, dom.EventType.FOCUS_IN, () => {192hasFocus = true;193this.updateCurrentThread(hasMouse, hasFocus);194}, true));195this._register(dom.addDisposableListener(this.container, dom.EventType.FOCUS_OUT, () => {196hasFocus = false;197this.updateCurrentThread(hasMouse, hasFocus);198}, true));199}200201async updateCommentThread(commentThread: languages.CommentThread<T>) {202const shouldCollapse = (this._commentThread.collapsibleState === languages.CommentThreadCollapsibleState.Expanded) && (this._commentThreadState === languages.CommentThreadState.Unresolved)203&& (commentThread.state === languages.CommentThreadState.Resolved);204this._commentThreadState = commentThread.state;205this._commentThread = commentThread;206dispose(this._commentThreadDisposables);207this._commentThreadDisposables = [];208this._bindCommentThreadListeners();209210await this._body.updateCommentThread(commentThread, this._commentReply?.isCommentEditorFocused() ?? false);211this._threadIsEmpty.set(!this._body.length);212this._header.updateCommentThread(commentThread);213this._commentReply?.updateCommentThread(commentThread);214215if (this._commentThread.contextValue) {216this._commentThreadContextValue.set(this._commentThread.contextValue);217} else {218this._commentThreadContextValue.reset();219}220221if (shouldCollapse && this.configurationService.getValue<ICommentsConfiguration>(COMMENTS_SECTION).collapseOnResolve) {222this.collapse();223}224}225226async display(lineHeight: number, focus: boolean) {227const headHeight = Math.max(23, Math.ceil(lineHeight * 1.2)); // 23 is the value of `Math.ceil(lineHeight * 1.2)` with the default editor font size228this._header.updateHeight(headHeight);229230await this._body.display();231232// create comment thread only when it supports reply233if (this._commentThread.canReply) {234this._createCommentForm(focus);235}236this._createAdditionalActions();237238this._register(this._body.onDidResize(dimension => {239this._refresh(dimension);240}));241242// If there are no existing comments, place focus on the text area. This must be done after show, which also moves focus.243// if this._commentThread.comments is undefined, it doesn't finish initialization yet, so we don't focus the editor immediately.244if (this._commentThread.canReply && this._commentReply) {245this._commentReply.focusIfNeeded();246}247248this._bindCommentThreadListeners();249}250251private _refresh(dimension: dom.Dimension) {252this._body.layout();253this._onDidResize.fire(dimension);254}255256override dispose() {257super.dispose();258dispose(this._commentThreadDisposables);259this.updateCurrentThread(false, false);260}261262private _bindCommentThreadListeners() {263this._commentThreadDisposables.push(this._commentThread.onDidChangeCanReply(() => {264if (this._commentReply) {265this._commentReply.updateCanReply();266} else {267if (this._commentThread.canReply) {268this._createCommentForm(false);269}270}271}));272273this._commentThreadDisposables.push(this._commentThread.onDidChangeComments(async _ => {274await this.updateCommentThread(this._commentThread);275}));276277this._commentThreadDisposables.push(this._commentThread.onDidChangeLabel(_ => {278this._header.createThreadLabel();279}));280}281282private _createCommentForm(focus: boolean) {283this._commentReply = this._scopedInstantiationService.createInstance(284CommentReply,285this._owner,286this._body.container,287this._parentEditor,288this._commentThread,289this._scopedInstantiationService,290this._contextKeyService,291this._commentMenus,292this._commentOptions,293this._pendingComment,294this,295focus,296this._containerDelegate.actionRunner297);298299this._register(this._commentReply);300}301302private _createAdditionalActions() {303this._additionalActions = this._scopedInstantiationService.createInstance(304CommentThreadAdditionalActions,305this._body.container,306this._commentThread,307this._contextKeyService,308this._commentMenus,309this._containerDelegate.actionRunner,310);311312this._register(this._additionalActions);313}314315getCommentCoords(commentUniqueId: number) {316return this._body.getCommentCoords(commentUniqueId);317}318319getPendingEdits(): { [key: number]: languages.PendingComment } {320return this._body.getPendingEdits();321}322323getPendingComment(): languages.PendingComment | undefined {324if (this._commentReply) {325return this._commentReply.getPendingComment();326}327328return undefined;329}330331setPendingComment(pending: languages.PendingComment) {332this._pendingComment = pending;333this._commentReply?.setPendingComment(pending);334}335336getDimensions() {337return this._body.getDimensions();338}339340layout(widthInPixel?: number) {341this._body.layout(widthInPixel);342343if (widthInPixel !== undefined) {344this._commentReply?.layout(widthInPixel);345}346}347348ensureFocusIntoNewEditingComment() {349this._body.ensureFocusIntoNewEditingComment();350}351352focusCommentEditor() {353this._commentReply?.expandReplyAreaAndFocusCommentEditor();354}355356focus(commentUniqueId: number | undefined) {357this._body.focus(commentUniqueId);358}359360async submitComment() {361const activeComment = this._body.activeComment;362if (activeComment) {363return activeComment.submitComment();364} else if ((this._commentReply?.getPendingComment()?.body.length ?? 0) > 0) {365return this._commentReply?.submitComment();366}367}368369async collapse() {370if ((await this._containerDelegate.collapse()) && Range.isIRange(this.commentThread.range) && isCodeEditor(this._parentEditor)) {371this._parentEditor.setSelection(this.commentThread.range);372}373374}375376applyTheme(fontInfo: FontInfo) {377const fontFamilyVar = '--comment-thread-editor-font-family';378const fontWeightVar = '--comment-thread-editor-font-weight';379this.container?.style.setProperty(fontFamilyVar, fontInfo.fontFamily);380this.container?.style.setProperty(fontWeightVar, fontInfo.fontWeight);381382this._commentReply?.setCommentEditorDecorations();383}384}385386387