Path: blob/main/src/vs/workbench/contrib/comments/browser/commentThreadZoneWidget.ts
5245 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 { Color } from '../../../../base/common/color.js';7import { Emitter, Event } from '../../../../base/common/event.js';8import { IDisposable, DisposableStore } from '../../../../base/common/lifecycle.js';9import { ICodeEditor, IEditorMouseEvent, isCodeEditor, MouseTargetType } from '../../../../editor/browser/editorBrowser.js';10import { IPosition } from '../../../../editor/common/core/position.js';11import { IRange, Range } from '../../../../editor/common/core/range.js';12import * as languages from '../../../../editor/common/languages.js';13import { ZoneWidget } from '../../../../editor/contrib/zoneWidget/browser/zoneWidget.js';14import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js';15import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';16import { IColorTheme, IThemeService } from '../../../../platform/theme/common/themeService.js';17import { CommentGlyphWidget } from './commentGlyphWidget.js';18import { ICommentService } from './commentService.js';19import { ICommentThreadWidget } from '../common/commentThreadWidget.js';20import { EditorOption } from '../../../../editor/common/config/editorOptions.js';21import { ServiceCollection } from '../../../../platform/instantiation/common/serviceCollection.js';22import { CommentThreadWidget } from './commentThreadWidget.js';23import { commentThreadStateBackgroundColorVar, commentThreadStateColorVar, getCommentThreadStateBorderColor } from './commentColors.js';24import { peekViewBorder } from '../../../../editor/contrib/peekView/browser/peekView.js';25import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';26import { StableEditorScrollState } from '../../../../editor/browser/stableEditorScroll.js';27import Severity from '../../../../base/common/severity.js';28import * as nls from '../../../../nls.js';29import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js';3031function getCommentThreadWidgetStateColor(thread: languages.CommentThreadState | undefined, theme: IColorTheme): Color | undefined {32return getCommentThreadStateBorderColor(thread, theme) ?? theme.getColor(peekViewBorder);33}3435/**36* Check if a comment thread has any draft comments37*/38function commentThreadHasDraft(commentThread: languages.CommentThread): boolean {39const comments = commentThread.comments;40if (!comments) {41return false;42}43return comments.some(comment => comment.state === languages.CommentState.Draft);44}4546export enum CommentWidgetFocus {47None = 0,48Widget = 1,49Editor = 250}5152export function parseMouseDownInfoFromEvent(e: IEditorMouseEvent) {53const range = e.target.range;5455if (!range) {56return null;57}5859if (!e.event.leftButton) {60return null;61}6263if (e.target.type !== MouseTargetType.GUTTER_LINE_DECORATIONS) {64return null;65}6667const data = e.target.detail;68const gutterOffsetX = data.offsetX - data.glyphMarginWidth - data.lineNumbersWidth - data.glyphMarginLeft;6970// don't collide with folding and git decorations71if (gutterOffsetX > 20) {72return null;73}7475return { lineNumber: range.startLineNumber };76}7778export function isMouseUpEventDragFromMouseDown(mouseDownInfo: { lineNumber: number } | null, e: IEditorMouseEvent) {79if (!mouseDownInfo) {80return null;81}8283const { lineNumber } = mouseDownInfo;8485const range = e.target.range;8687if (!range) {88return null;89}9091return lineNumber;92}9394export function isMouseUpEventMatchMouseDown(mouseDownInfo: { lineNumber: number } | null, e: IEditorMouseEvent) {95if (!mouseDownInfo) {96return null;97}9899const { lineNumber } = mouseDownInfo;100101const range = e.target.range;102103if (!range || range.startLineNumber !== lineNumber) {104return null;105}106107if (e.target.type !== MouseTargetType.GUTTER_LINE_DECORATIONS) {108return null;109}110111return lineNumber;112}113114export class ReviewZoneWidget extends ZoneWidget implements ICommentThreadWidget {115private _commentThreadWidget!: CommentThreadWidget;116private readonly _onDidClose = new Emitter<ReviewZoneWidget | undefined>();117private readonly _onDidCreateThread = new Emitter<ReviewZoneWidget>();118private readonly _onDidChangeExpandedState = new Emitter<boolean>();119private _isExpanded?: boolean;120private _initialCollapsibleState?: languages.CommentThreadCollapsibleState;121private _commentGlyph?: CommentGlyphWidget;122private readonly _globalToDispose = new DisposableStore();123private _commentThreadDisposables: IDisposable[] = [];124private _contextKeyService: IContextKeyService;125private _scopedInstantiationService: IInstantiationService;126127public get uniqueOwner(): string {128return this._uniqueOwner;129}130public get commentThread(): languages.CommentThread {131return this._commentThread;132}133134public get expanded(): boolean | undefined {135return this._isExpanded;136}137138private _commentOptions: languages.CommentOptions | undefined;139140constructor(141editor: ICodeEditor,142private _uniqueOwner: string,143private _commentThread: languages.CommentThread,144private _pendingComment: languages.PendingComment | undefined,145private _pendingEdits: { [key: number]: languages.PendingComment } | undefined,146@IInstantiationService instantiationService: IInstantiationService,147@IThemeService private themeService: IThemeService,148@ICommentService private commentService: ICommentService,149@IContextKeyService contextKeyService: IContextKeyService,150@IConfigurationService private readonly configurationService: IConfigurationService,151@IDialogService private readonly dialogService: IDialogService152) {153super(editor, { keepEditorSelection: true, isAccessible: true, showArrow: !!_commentThread.range });154this._contextKeyService = contextKeyService.createScoped(this.domNode);155156this._scopedInstantiationService = this._globalToDispose.add(instantiationService.createChild(new ServiceCollection(157[IContextKeyService, this._contextKeyService]158)));159160const controller = this.commentService.getCommentController(this._uniqueOwner);161if (controller) {162this._commentOptions = controller.options;163}164165this._initialCollapsibleState = _pendingComment ? languages.CommentThreadCollapsibleState.Expanded : _commentThread.initialCollapsibleState;166_commentThread.initialCollapsibleState = this._initialCollapsibleState;167this._commentThreadDisposables = [];168this.create();169170this._globalToDispose.add(this.themeService.onDidColorThemeChange(this._applyTheme, this));171this._globalToDispose.add(this.editor.onDidChangeConfiguration(e => {172if (e.hasChanged(EditorOption.fontInfo)) {173this._applyTheme();174}175}));176this._applyTheme();177178}179180public get onDidClose(): Event<ReviewZoneWidget | undefined> {181return this._onDidClose.event;182}183184public get onDidCreateThread(): Event<ReviewZoneWidget> {185return this._onDidCreateThread.event;186}187188public get onDidChangeExpandedState(): Event<boolean> {189return this._onDidChangeExpandedState.event;190}191192public getPosition(): IPosition | undefined {193if (this.position) {194return this.position;195}196197if (this._commentGlyph) {198return this._commentGlyph.getPosition().position ?? undefined;199}200return undefined;201}202203protected override revealRange() {204// we don't do anything here as we always do the reveal ourselves.205}206207public reveal(commentUniqueId?: number, focus: CommentWidgetFocus = CommentWidgetFocus.None) {208this.makeVisible(commentUniqueId, focus);209const comment = this._commentThread.comments?.find(comment => comment.uniqueIdInThread === commentUniqueId) ?? this._commentThread.comments?.[0];210this.commentService.setActiveCommentAndThread(this.uniqueOwner, { thread: this._commentThread, comment });211}212213private _expandAndShowZoneWidget() {214if (!this._isExpanded) {215this.show(this.arrowPosition(this._commentThread.range), 2);216}217}218219private _setFocus(commentUniqueId: number | undefined, focus: CommentWidgetFocus) {220if (focus === CommentWidgetFocus.Widget) {221this._commentThreadWidget.focus(commentUniqueId);222} else if (focus === CommentWidgetFocus.Editor) {223this._commentThreadWidget.focusCommentEditor();224}225}226227private _goToComment(commentUniqueId: number, focus: CommentWidgetFocus) {228const height = this.editor.getLayoutInfo().height;229const coords = this._commentThreadWidget.getCommentCoords(commentUniqueId);230if (coords) {231let scrollTop: number = 1;232if (this._commentThread.range) {233const commentThreadCoords = coords.thread;234const commentCoords = coords.comment;235scrollTop = this.editor.getTopForLineNumber(this._commentThread.range.startLineNumber) - height / 2 + commentCoords.top - commentThreadCoords.top;236}237this.editor.setScrollTop(scrollTop);238this._setFocus(commentUniqueId, focus);239} else {240this._goToThread(focus);241}242}243244private _goToThread(focus: CommentWidgetFocus) {245const rangeToReveal = this._commentThread.range246? new Range(this._commentThread.range.startLineNumber, this._commentThread.range.startColumn, this._commentThread.range.endLineNumber + 1, 1)247: new Range(1, 1, 1, 1);248249this.editor.revealRangeInCenter(rangeToReveal);250this._setFocus(undefined, focus);251}252253public makeVisible(commentUniqueId?: number, focus: CommentWidgetFocus = CommentWidgetFocus.None) {254this._expandAndShowZoneWidget();255256if (commentUniqueId !== undefined) {257this._goToComment(commentUniqueId, focus);258} else {259this._goToThread(focus);260}261}262263public getPendingComments(): { newComment: languages.PendingComment | undefined; edits: { [key: number]: languages.PendingComment } } {264return {265newComment: this._commentThreadWidget.getPendingComment(),266edits: this._commentThreadWidget.getPendingEdits()267};268}269270public setPendingComment(pending: languages.PendingComment) {271this._pendingComment = pending;272this.expand();273this._commentThreadWidget.setPendingComment(pending);274}275276protected _fillContainer(container: HTMLElement): void {277this.setCssClass('review-widget');278this._commentThreadWidget = this._scopedInstantiationService.createInstance(279CommentThreadWidget<IRange>,280container,281this.editor,282this._uniqueOwner,283this.editor.getModel()!.uri,284this._contextKeyService,285this._scopedInstantiationService,286this._commentThread,287this._pendingComment,288this._pendingEdits,289{ context: this.editor, },290this._commentOptions,291{292actionRunner: async () => {293if (!this._commentThread.comments || !this._commentThread.comments.length) {294const newPosition = this.getPosition();295296if (newPosition) {297const originalRange = this._commentThread.range;298if (!originalRange) {299return;300}301let range: Range;302303if (newPosition.lineNumber !== originalRange.endLineNumber) {304// The widget could have moved as a result of editor changes.305// We need to try to calculate the new, more correct, range for the comment.306const distance = newPosition.lineNumber - originalRange.endLineNumber;307range = new Range(originalRange.startLineNumber + distance, originalRange.startColumn, originalRange.endLineNumber + distance, originalRange.endColumn);308} else {309range = new Range(originalRange.startLineNumber, originalRange.startColumn, originalRange.endLineNumber, originalRange.endColumn);310}311await this.commentService.updateCommentThreadTemplate(this.uniqueOwner, this._commentThread.commentThreadHandle, range);312}313}314},315collapse: () => {316return this.collapse(true);317}318}319);320321this._disposables.add(this._commentThreadWidget);322}323324private arrowPosition(range: IRange | undefined): IPosition | undefined {325if (!range) {326return undefined;327}328// Arrow on top edge of zone widget will be at the start of the line if range is multi-line, else at midpoint of range (rounding rightwards)329return { lineNumber: range.endLineNumber, column: range.endLineNumber === range.startLineNumber ? (range.startColumn + range.endColumn + 1) / 2 : 1 };330}331332private deleteCommentThread(): void {333this.dispose();334this.commentService.disposeCommentThread(this.uniqueOwner, this._commentThread.threadId);335}336337private doCollapse() {338this._commentThread.collapsibleState = languages.CommentThreadCollapsibleState.Collapsed;339}340341public async collapse(confirm: boolean = false): Promise<boolean> {342if (!confirm || (await this.confirmCollapse())) {343this.doCollapse();344return true;345} else {346return false;347}348}349350private async confirmCollapse(): Promise<boolean> {351const confirmSetting = this.configurationService.getValue<'whenHasUnsubmittedComments' | 'never'>('comments.thread.confirmOnCollapse');352353if (confirmSetting === 'whenHasUnsubmittedComments' && this._commentThreadWidget.hasUnsubmittedComments) {354const result = await this.dialogService.confirm({355message: nls.localize('confirmCollapse', "Collapsing this comment thread will discard unsubmitted comments. Are you sure you want to discard these comments?"),356primaryButton: nls.localize('discard', "Discard"),357type: Severity.Warning,358checkbox: { label: nls.localize('neverAskAgain', "Never ask me again"), checked: false }359});360if (result.checkboxChecked) {361await this.configurationService.updateValue('comments.thread.confirmOnCollapse', 'never');362}363return result.confirmed;364}365return true;366}367368public expand(setActive?: boolean) {369this._commentThread.collapsibleState = languages.CommentThreadCollapsibleState.Expanded;370if (setActive) {371this.commentService.setActiveCommentAndThread(this.uniqueOwner, { thread: this._commentThread });372}373}374375public getGlyphPosition(): number {376if (this._commentGlyph) {377return this._commentGlyph.getPosition().position!.lineNumber;378}379return 0;380}381382async update(commentThread: languages.CommentThread<IRange>) {383if (this._commentThread !== commentThread) {384this._commentThreadDisposables.forEach(disposable => disposable.dispose());385this._commentThread = commentThread;386this._commentThreadDisposables = [];387this.bindCommentThreadListeners();388}389390await this._commentThreadWidget.updateCommentThread(commentThread);391392// Move comment glyph widget and show position if the line has changed.393const lineNumber = this._commentThread.range?.endLineNumber ?? 1;394let shouldMoveWidget = false;395if (this._commentGlyph) {396const hasDraft = commentThreadHasDraft(commentThread);397this._commentGlyph.setThreadState(commentThread.state, hasDraft);398if (this._commentGlyph.getPosition().position!.lineNumber !== lineNumber) {399shouldMoveWidget = true;400this._commentGlyph.setLineNumber(lineNumber);401}402}403404if ((shouldMoveWidget && this._isExpanded) || (this._commentThread.collapsibleState === languages.CommentThreadCollapsibleState.Expanded && !this._isExpanded)) {405this.show(this.arrowPosition(this._commentThread.range), 2);406} else if (this._commentThread.collapsibleState !== languages.CommentThreadCollapsibleState.Expanded) {407this.hide();408}409}410411protected override _onWidth(widthInPixel: number): void {412this._commentThreadWidget.layout(widthInPixel);413}414415protected override _doLayout(heightInPixel: number, widthInPixel: number): void {416this._commentThreadWidget.layout(widthInPixel);417}418419async display(range: IRange | undefined, shouldReveal: boolean) {420if (range) {421this._commentGlyph = new CommentGlyphWidget(this.editor, range?.endLineNumber ?? -1);422const hasDraft = commentThreadHasDraft(this._commentThread);423this._commentGlyph.setThreadState(this._commentThread.state, hasDraft);424this._globalToDispose.add(this._commentGlyph.onDidChangeLineNumber(async e => {425if (!this._commentThread.range) {426return;427}428const shift = e - (this._commentThread.range.endLineNumber);429const newRange = new Range(this._commentThread.range.startLineNumber + shift, this._commentThread.range.startColumn, this._commentThread.range.endLineNumber + shift, this._commentThread.range.endColumn);430this._commentThread.range = newRange;431}));432}433434await this._commentThreadWidget.display(this.editor.getOption(EditorOption.lineHeight), shouldReveal);435this._disposables.add(this._commentThreadWidget.onDidResize(dimension => {436this._refresh(dimension);437}));438if (this._commentThread.collapsibleState === languages.CommentThreadCollapsibleState.Expanded) {439this.show(this.arrowPosition(range), 2);440}441442// If this is a new comment thread awaiting user input then we need to reveal it.443if (shouldReveal) {444this.makeVisible();445}446447this.bindCommentThreadListeners();448}449450private bindCommentThreadListeners() {451this._commentThreadDisposables.push(this._commentThread.onDidChangeComments(async _ => {452await this.update(this._commentThread);453}));454455this._commentThreadDisposables.push(this._commentThread.onDidChangeCollapsibleState(state => {456if (state === languages.CommentThreadCollapsibleState.Expanded && !this._isExpanded) {457this.show(this.arrowPosition(this._commentThread.range), 2);458this._commentThreadWidget.ensureFocusIntoNewEditingComment();459return;460}461462if (state === languages.CommentThreadCollapsibleState.Collapsed && this._isExpanded) {463this.hide();464return;465}466}));467468if (this._initialCollapsibleState === undefined) {469const onDidChangeInitialCollapsibleState = this._commentThread.onDidChangeInitialCollapsibleState(state => {470// File comments always start expanded471this._initialCollapsibleState = state;472this._commentThread.collapsibleState = this._initialCollapsibleState;473onDidChangeInitialCollapsibleState.dispose();474});475this._commentThreadDisposables.push(onDidChangeInitialCollapsibleState);476}477478479this._commentThreadDisposables.push(this._commentThread.onDidChangeState(() => {480const borderColor =481getCommentThreadWidgetStateColor(this._commentThread.state, this.themeService.getColorTheme()) || Color.transparent;482this.style({483frameColor: borderColor,484arrowColor: borderColor,485});486this.container?.style.setProperty(commentThreadStateColorVar, `${borderColor}`);487this.container?.style.setProperty(commentThreadStateBackgroundColorVar, `${borderColor.transparent(.1)}`);488}));489}490491async submitComment(): Promise<void> {492return this._commentThreadWidget.submitComment();493}494495_refresh(dimensions: dom.Dimension) {496if ((this._isExpanded === undefined) && (dimensions.height === 0) && (dimensions.width === 0)) {497this.commentThread.collapsibleState = languages.CommentThreadCollapsibleState.Collapsed;498return;499}500if (this._isExpanded) {501this._commentThreadWidget.layout();502503const headHeight = Math.ceil(this.editor.getOption(EditorOption.lineHeight) * 1.2);504const lineHeight = this.editor.getOption(EditorOption.lineHeight);505const arrowHeight = Math.round(lineHeight / 3);506const frameThickness = Math.round(lineHeight / 9) * 2;507508const computedLinesNumber = Math.ceil((headHeight + dimensions.height + arrowHeight + frameThickness + 8 /** margin bottom to avoid margin collapse */) / lineHeight);509510if (this._viewZone?.heightInLines === computedLinesNumber) {511return;512}513514const currentPosition = this.getPosition();515516if (this._viewZone && currentPosition && currentPosition.lineNumber !== this._viewZone.afterLineNumber && this._viewZone.afterLineNumber !== 0) {517this._viewZone.afterLineNumber = currentPosition.lineNumber;518}519520const capture = StableEditorScrollState.capture(this.editor);521this._relayout(computedLinesNumber);522capture.restore(this.editor);523}524}525526private _applyTheme() {527const borderColor = getCommentThreadWidgetStateColor(this._commentThread.state, this.themeService.getColorTheme()) || Color.transparent;528this.style({529arrowColor: borderColor,530frameColor: borderColor531});532const fontInfo = this.editor.getOption(EditorOption.fontInfo);533534this._commentThreadWidget.applyTheme(fontInfo);535}536537override show(rangeOrPos: IRange | IPosition | undefined, heightInLines: number): void {538const glyphPosition = this._commentGlyph?.getPosition();539let range = Range.isIRange(rangeOrPos) ? rangeOrPos : (rangeOrPos ? Range.fromPositions(rangeOrPos) : undefined);540if (glyphPosition?.position && range && glyphPosition.position.lineNumber !== range.endLineNumber) {541// The widget could have moved as a result of editor changes.542// We need to try to calculate the new, more correct, range for the comment.543const distance = glyphPosition.position.lineNumber - range.endLineNumber;544range = new Range(range.startLineNumber + distance, range.startColumn, range.endLineNumber + distance, range.endColumn);545}546547const wasExpanded = this._isExpanded;548this._isExpanded = true;549super.show(range ?? new Range(0, 0, 0, 0), heightInLines);550this._commentThread.collapsibleState = languages.CommentThreadCollapsibleState.Expanded;551this._refresh(this._commentThreadWidget.getDimensions());552if (!wasExpanded) {553this._onDidChangeExpandedState.fire(true);554}555}556557async collapseAndFocusRange() {558if (await this.collapse(true) && Range.isIRange(this.commentThread.range) && isCodeEditor(this.editor)) {559this.editor.setSelection(this.commentThread.range);560}561}562563override hide() {564if (this._isExpanded) {565this._isExpanded = false;566// Focus the container so that the comment editor will be blurred before it is hidden567if (this.editor.hasWidgetFocus()) {568this.editor.focus();569}570571if (!this._commentThread.comments || !this._commentThread.comments.length) {572this.deleteCommentThread();573}574this._onDidChangeExpandedState.fire(false);575}576super.hide();577}578579override dispose() {580super.dispose();581582if (this._commentGlyph) {583this._commentGlyph.dispose();584this._commentGlyph = undefined;585}586587this._globalToDispose.dispose();588this._commentThreadDisposables.forEach(global => global.dispose());589this._onDidClose.fire(undefined);590this._onDidClose.dispose();591this._onDidCreateThread.dispose();592this._onDidChangeExpandedState.dispose();593}594}595596597