Path: blob/main/src/vs/workbench/contrib/comments/browser/commentReply.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 * as dom from '../../../../base/browser/dom.js';6import { getDefaultHoverDelegate } from '../../../../base/browser/ui/hover/hoverDelegateFactory.js';7import { MOUSE_CURSOR_TEXT_CSS_CLASS_NAME } from '../../../../base/browser/ui/mouseCursor/mouseCursor.js';8import { IAction } from '../../../../base/common/actions.js';9import { Disposable, IDisposable, dispose } from '../../../../base/common/lifecycle.js';10import { MarshalledId } from '../../../../base/common/marshallingIds.js';11import { FileAccess, Schemas } from '../../../../base/common/network.js';12import { URI } from '../../../../base/common/uri.js';13import { generateUuid } from '../../../../base/common/uuid.js';14import { ICodeEditor } from '../../../../editor/browser/editorBrowser.js';15import { IRange } from '../../../../editor/common/core/range.js';16import * as languages from '../../../../editor/common/languages.js';17import { ITextModel } from '../../../../editor/common/model.js';18import { ITextModelService } from '../../../../editor/common/services/resolverService.js';19import * as nls from '../../../../nls.js';20import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';21import { IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js';22import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';23import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js';24import { CommentFormActions } from './commentFormActions.js';25import { CommentMenus } from './commentMenus.js';26import { ICommentService } from './commentService.js';27import { CommentContextKeys } from '../common/commentContextKeys.js';28import { ICommentThreadWidget } from '../common/commentThreadWidget.js';29import { ICellRange } from '../../notebook/common/notebookRange.js';30import { LayoutableEditor, MIN_EDITOR_HEIGHT, SimpleCommentEditor, calculateEditorHeight } from './simpleCommentEditor.js';31import { IHoverService } from '../../../../platform/hover/browser/hover.js';32import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js';33import { Position } from '../../../../editor/common/core/position.js';3435let INMEM_MODEL_ID = 0;36export const COMMENTEDITOR_DECORATION_KEY = 'commenteditordecoration';3738export class CommentReply<T extends IRange | ICellRange> extends Disposable {39commentEditor: ICodeEditor;40private _container: HTMLElement;41private _form: HTMLElement;42commentEditorIsEmpty: IContextKey<boolean>;43private avatar!: HTMLElement;44private _error!: HTMLElement;45private _formActions!: HTMLElement;46private _editorActions!: HTMLElement;47private _commentThreadDisposables: IDisposable[] = [];48private _commentFormActions!: CommentFormActions;49private _commentEditorActions!: CommentFormActions;50private _reviewThreadReplyButton!: HTMLElement;51private _editorHeight = MIN_EDITOR_HEIGHT;5253constructor(54readonly owner: string,55container: HTMLElement,56private readonly _parentEditor: LayoutableEditor,57private _commentThread: languages.CommentThread<T>,58private _scopedInstatiationService: IInstantiationService,59private _contextKeyService: IContextKeyService,60private _commentMenus: CommentMenus,61private _commentOptions: languages.CommentOptions | undefined,62private _pendingComment: languages.PendingComment | undefined,63private _parentThread: ICommentThreadWidget,64focus: boolean,65private _actionRunDelegate: (() => void) | null,66@ICommentService private commentService: ICommentService,67@IConfigurationService configurationService: IConfigurationService,68@IKeybindingService private keybindingService: IKeybindingService,69@IContextMenuService private contextMenuService: IContextMenuService,70@IHoverService private hoverService: IHoverService,71@ITextModelService private readonly textModelService: ITextModelService72) {73super();74this._container = dom.append(container, dom.$('.comment-form-container'));75this._form = dom.append(this._container, dom.$('.comment-form'));76this.commentEditor = this._register(this._scopedInstatiationService.createInstance(SimpleCommentEditor, this._form, SimpleCommentEditor.getEditorOptions(configurationService), _contextKeyService, this._parentThread));77this.commentEditorIsEmpty = CommentContextKeys.commentIsEmpty.bindTo(this._contextKeyService);78this.commentEditorIsEmpty.set(!this._pendingComment);7980this.initialize(focus);81}8283private async initialize(focus: boolean) {84this.avatar = dom.append(this._form, dom.$('.avatar-container'));85this.updateAuthorInfo();86const hasExistingComments = this._commentThread.comments && this._commentThread.comments.length > 0;87const modeId = generateUuid() + '-' + (hasExistingComments ? this._commentThread.threadId : ++INMEM_MODEL_ID);88const params = JSON.stringify({89extensionId: this._commentThread.extensionId,90commentThreadId: this._commentThread.threadId91});9293let resource = URI.from({94scheme: Schemas.commentsInput,95path: `/${this._commentThread.extensionId}/commentinput-${modeId}.md?${params}` // TODO. Remove params once extensions adopt authority.96});97const commentController = this.commentService.getCommentController(this.owner);98if (commentController) {99resource = resource.with({ authority: commentController.id });100}101102const model = await this.textModelService.createModelReference(resource);103model.object.textEditorModel.setValue(this._pendingComment?.body || '');104105this._register(model);106this.commentEditor.setModel(model.object.textEditorModel);107if (this._pendingComment) {108this.commentEditor.setPosition(this._pendingComment.cursor);109}110this.calculateEditorHeight();111112this._register(model.object.textEditorModel.onDidChangeContent(() => {113this.setCommentEditorDecorations();114this.commentEditorIsEmpty?.set(!this.commentEditor.getValue());115if (this.calculateEditorHeight()) {116this.commentEditor.layout({ height: this._editorHeight, width: this.commentEditor.getLayoutInfo().width });117this.commentEditor.render(true);118}119}));120121this.createTextModelListener(this.commentEditor, this._form);122123this.setCommentEditorDecorations();124125// Only add the additional step of clicking a reply button to expand the textarea when there are existing comments126if (this._pendingComment) {127this.expandReplyArea();128} else if (hasExistingComments) {129this.createReplyButton(this.commentEditor, this._form);130} else if (this._commentThread.comments && this._commentThread.comments.length === 0) {131this.expandReplyArea(focus);132}133this._error = dom.append(this._container, dom.$('.validation-error.hidden'));134const formActions = dom.append(this._container, dom.$('.form-actions'));135this._formActions = dom.append(formActions, dom.$('.other-actions'));136this.createCommentWidgetFormActions(this._formActions, model.object.textEditorModel);137this._editorActions = dom.append(formActions, dom.$('.editor-actions'));138this.createCommentWidgetEditorActions(this._editorActions, model.object.textEditorModel);139}140141private calculateEditorHeight(): boolean {142const newEditorHeight = calculateEditorHeight(this._parentEditor, this.commentEditor, this._editorHeight);143if (newEditorHeight !== this._editorHeight) {144this._editorHeight = newEditorHeight;145return true;146}147return false;148}149150public updateCommentThread(commentThread: languages.CommentThread<IRange | ICellRange>) {151const isReplying = this.commentEditor.hasTextFocus();152const oldAndNewBothEmpty = !this._commentThread.comments?.length && !commentThread.comments?.length;153154if (!this._reviewThreadReplyButton) {155this.createReplyButton(this.commentEditor, this._form);156}157158if (this._commentThread.comments && this._commentThread.comments.length === 0 && !oldAndNewBothEmpty) {159this.expandReplyArea();160}161162if (isReplying) {163this.commentEditor.focus();164}165}166167public getPendingComment(): languages.PendingComment | undefined {168const model = this.commentEditor.getModel();169170if (model && model.getValueLength() > 0) { // checking length is cheap171return { body: model.getValue(), cursor: this.commentEditor.getPosition() ?? new Position(1, 1) };172}173174return undefined;175}176177public setPendingComment(pending: languages.PendingComment) {178this._pendingComment = pending;179this.expandReplyArea();180this.commentEditor.setValue(pending.body);181this.commentEditor.setPosition(pending.cursor);182}183184public layout(widthInPixel: number) {185this.commentEditor.layout({ height: this._editorHeight, width: widthInPixel - 54 /* margin 20px * 10 + scrollbar 14px*/ });186}187188public focusIfNeeded() {189if (!this._commentThread.comments || !this._commentThread.comments.length) {190this.commentEditor.focus();191} else if ((this.commentEditor.getModel()?.getValueLength() ?? 0) > 0) {192this.expandReplyArea();193}194}195196public focusCommentEditor() {197this.commentEditor.focus();198}199200public expandReplyAreaAndFocusCommentEditor() {201this.expandReplyArea();202this.commentEditor.focus();203}204205public isCommentEditorFocused(): boolean {206return this.commentEditor.hasWidgetFocus();207}208209private updateAuthorInfo() {210this.avatar.textContent = '';211if (typeof this._commentThread.canReply !== 'boolean' && this._commentThread.canReply.iconPath) {212this.avatar.style.display = 'block';213const img = dom.append(this.avatar, dom.$('img.avatar')) as HTMLImageElement;214img.src = FileAccess.uriToBrowserUri(URI.revive(this._commentThread.canReply.iconPath)).toString(true);215} else {216this.avatar.style.display = 'none';217}218}219220public updateCanReply() {221this.updateAuthorInfo();222if (!this._commentThread.canReply) {223this._container.style.display = 'none';224} else {225this._container.style.display = 'block';226}227}228229async submitComment(): Promise<void> {230await this._commentFormActions?.triggerDefaultAction();231this._pendingComment = undefined;232}233234setCommentEditorDecorations() {235const hasExistingComments = this._commentThread.comments && this._commentThread.comments.length > 0;236const placeholder = hasExistingComments237? (this._commentOptions?.placeHolder || nls.localize('reply', "Reply..."))238: (this._commentOptions?.placeHolder || nls.localize('newComment', "Type a new comment"));239240this.commentEditor.updateOptions({ placeholder });241}242243private createTextModelListener(commentEditor: ICodeEditor, commentForm: HTMLElement) {244this._commentThreadDisposables.push(commentEditor.onDidFocusEditorWidget(() => {245this._commentThread.input = {246uri: commentEditor.getModel()!.uri,247value: commentEditor.getValue()248};249this.commentService.setActiveEditingCommentThread(this._commentThread);250this.commentService.setActiveCommentAndThread(this.owner, { thread: this._commentThread });251}));252253this._commentThreadDisposables.push(commentEditor.getModel()!.onDidChangeContent(() => {254const modelContent = commentEditor.getValue();255if (this._commentThread.input && this._commentThread.input.uri === commentEditor.getModel()!.uri && this._commentThread.input.value !== modelContent) {256const newInput: languages.CommentInput = this._commentThread.input;257newInput.value = modelContent;258this._commentThread.input = newInput;259}260this.commentService.setActiveEditingCommentThread(this._commentThread);261}));262263this._commentThreadDisposables.push(this._commentThread.onDidChangeInput(input => {264const thread = this._commentThread;265const model = commentEditor.getModel();266if (thread.input && model && (thread.input.uri !== model.uri)) {267return;268}269if (!input) {270return;271}272273if (commentEditor.getValue() !== input.value) {274commentEditor.setValue(input.value);275276if (input.value === '') {277this._pendingComment = { body: '', cursor: new Position(1, 1) };278commentForm.classList.remove('expand');279commentEditor.getDomNode()!.style.outline = '';280this._error.textContent = '';281this._error.classList.add('hidden');282}283}284}));285}286287/**288* Command based actions.289*/290private createCommentWidgetFormActions(container: HTMLElement, model: ITextModel) {291const menu = this._commentMenus.getCommentThreadActions(this._contextKeyService);292293this._register(menu);294this._register(menu.onDidChange(() => {295this._commentFormActions.setActions(menu);296}));297298this._commentFormActions = new CommentFormActions(this.keybindingService, this._contextKeyService, this.contextMenuService, container, async (action: IAction) => {299await this._actionRunDelegate?.();300301await action.run({302thread: this._commentThread,303text: this.commentEditor.getValue(),304$mid: MarshalledId.CommentThreadReply305});306307this.hideReplyArea();308});309310this._register(this._commentFormActions);311this._commentFormActions.setActions(menu);312}313314private createCommentWidgetEditorActions(container: HTMLElement, model: ITextModel) {315const editorMenu = this._commentMenus.getCommentEditorActions(this._contextKeyService);316this._register(editorMenu);317this._register(editorMenu.onDidChange(() => {318this._commentEditorActions.setActions(editorMenu, true);319}));320321this._commentEditorActions = new CommentFormActions(this.keybindingService, this._contextKeyService, this.contextMenuService, container, async (action: IAction) => {322this._actionRunDelegate?.();323324action.run({325thread: this._commentThread,326text: this.commentEditor.getValue(),327$mid: MarshalledId.CommentThreadReply328});329330this.focusCommentEditor();331});332333this._register(this._commentEditorActions);334this._commentEditorActions.setActions(editorMenu, true);335}336337private get isReplyExpanded(): boolean {338return this._container.classList.contains('expand');339}340341private expandReplyArea(focus: boolean = true) {342if (!this.isReplyExpanded) {343this._container.classList.add('expand');344if (focus) {345this.commentEditor.focus();346}347this.commentEditor.layout();348}349}350351private clearAndExpandReplyArea() {352if (!this.isReplyExpanded) {353this.commentEditor.setValue('');354this.expandReplyArea();355}356}357358private hideReplyArea() {359const domNode = this.commentEditor.getDomNode();360if (domNode) {361domNode.style.outline = '';362}363this.commentEditor.setValue('');364this._pendingComment = { body: '', cursor: new Position(1, 1) };365this._container.classList.remove('expand');366this._error.textContent = '';367this._error.classList.add('hidden');368}369370private createReplyButton(commentEditor: ICodeEditor, commentForm: HTMLElement) {371this._reviewThreadReplyButton = <HTMLButtonElement>dom.append(commentForm, dom.$(`button.review-thread-reply-button.${MOUSE_CURSOR_TEXT_CSS_CLASS_NAME}`));372this._register(this.hoverService.setupManagedHover(getDefaultHoverDelegate('mouse'), this._reviewThreadReplyButton, this._commentOptions?.prompt || nls.localize('reply', "Reply...")));373374this._reviewThreadReplyButton.textContent = this._commentOptions?.prompt || nls.localize('reply', "Reply...");375// bind click/escape actions for reviewThreadReplyButton and textArea376this._register(dom.addDisposableListener(this._reviewThreadReplyButton, 'click', _ => this.clearAndExpandReplyArea()));377this._register(dom.addDisposableListener(this._reviewThreadReplyButton, 'focus', _ => this.clearAndExpandReplyArea()));378379this._register(commentEditor.onDidBlurEditorWidget(() => {380if (commentEditor.getModel()!.getValueLength() === 0 && commentForm.classList.contains('expand')) {381commentForm.classList.remove('expand');382}383}));384}385386override dispose(): void {387super.dispose();388dispose(this._commentThreadDisposables);389}390}391392393