Path: blob/main/src/vs/workbench/contrib/comments/browser/commentThreadZoneWidget.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 { 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 { EDITOR_FONT_DEFAULTS, EditorOption, IEditorOptions } from '../../../../editor/common/config/editorOptions.js';21import { ServiceCollection } from '../../../../platform/instantiation/common/serviceCollection.js';22import { CommentThreadWidget } from './commentThreadWidget.js';23import { ICellRange } from '../../notebook/common/notebookRange.js';24import { commentThreadStateBackgroundColorVar, commentThreadStateColorVar, getCommentThreadStateBorderColor } from './commentColors.js';25import { peekViewBorder } from '../../../../editor/contrib/peekView/browser/peekView.js';26import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';27import { StableEditorScrollState } from '../../../../editor/browser/stableEditorScroll.js';28import Severity from '../../../../base/common/severity.js';29import * as nls from '../../../../nls.js';30import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js';3132function getCommentThreadWidgetStateColor(thread: languages.CommentThreadState | undefined, theme: IColorTheme): Color | undefined {33return getCommentThreadStateBorderColor(thread, theme) ?? theme.getColor(peekViewBorder);34}3536export enum CommentWidgetFocus {37None = 0,38Widget = 1,39Editor = 240}4142export function parseMouseDownInfoFromEvent(e: IEditorMouseEvent) {43const range = e.target.range;4445if (!range) {46return null;47}4849if (!e.event.leftButton) {50return null;51}5253if (e.target.type !== MouseTargetType.GUTTER_LINE_DECORATIONS) {54return null;55}5657const data = e.target.detail;58const gutterOffsetX = data.offsetX - data.glyphMarginWidth - data.lineNumbersWidth - data.glyphMarginLeft;5960// don't collide with folding and git decorations61if (gutterOffsetX > 20) {62return null;63}6465return { lineNumber: range.startLineNumber };66}6768export function isMouseUpEventDragFromMouseDown(mouseDownInfo: { lineNumber: number } | null, e: IEditorMouseEvent) {69if (!mouseDownInfo) {70return null;71}7273const { lineNumber } = mouseDownInfo;7475const range = e.target.range;7677if (!range) {78return null;79}8081return lineNumber;82}8384export function isMouseUpEventMatchMouseDown(mouseDownInfo: { lineNumber: number } | null, e: IEditorMouseEvent) {85if (!mouseDownInfo) {86return null;87}8889const { lineNumber } = mouseDownInfo;9091const range = e.target.range;9293if (!range || range.startLineNumber !== lineNumber) {94return null;95}9697if (e.target.type !== MouseTargetType.GUTTER_LINE_DECORATIONS) {98return null;99}100101return lineNumber;102}103104export class ReviewZoneWidget extends ZoneWidget implements ICommentThreadWidget {105private _commentThreadWidget!: CommentThreadWidget;106private readonly _onDidClose = new Emitter<ReviewZoneWidget | undefined>();107private readonly _onDidCreateThread = new Emitter<ReviewZoneWidget>();108private _isExpanded?: boolean;109private _initialCollapsibleState?: languages.CommentThreadCollapsibleState;110private _commentGlyph?: CommentGlyphWidget;111private readonly _globalToDispose = new DisposableStore();112private _commentThreadDisposables: IDisposable[] = [];113private _contextKeyService: IContextKeyService;114private _scopedInstantiationService: IInstantiationService;115116public get uniqueOwner(): string {117return this._uniqueOwner;118}119public get commentThread(): languages.CommentThread {120return this._commentThread;121}122123public get expanded(): boolean | undefined {124return this._isExpanded;125}126127private _commentOptions: languages.CommentOptions | undefined;128129constructor(130editor: ICodeEditor,131private _uniqueOwner: string,132private _commentThread: languages.CommentThread,133private _pendingComment: languages.PendingComment | undefined,134private _pendingEdits: { [key: number]: languages.PendingComment } | undefined,135@IInstantiationService instantiationService: IInstantiationService,136@IThemeService private themeService: IThemeService,137@ICommentService private commentService: ICommentService,138@IContextKeyService contextKeyService: IContextKeyService,139@IConfigurationService private readonly configurationService: IConfigurationService,140@IDialogService private readonly dialogService: IDialogService141) {142super(editor, { keepEditorSelection: true, isAccessible: true, showArrow: !!_commentThread.range });143this._contextKeyService = contextKeyService.createScoped(this.domNode);144145this._scopedInstantiationService = this._globalToDispose.add(instantiationService.createChild(new ServiceCollection(146[IContextKeyService, this._contextKeyService]147)));148149const controller = this.commentService.getCommentController(this._uniqueOwner);150if (controller) {151this._commentOptions = controller.options;152}153154this._initialCollapsibleState = _pendingComment ? languages.CommentThreadCollapsibleState.Expanded : _commentThread.initialCollapsibleState;155_commentThread.initialCollapsibleState = this._initialCollapsibleState;156this._commentThreadDisposables = [];157this.create();158159this._globalToDispose.add(this.themeService.onDidColorThemeChange(this._applyTheme, this));160this._globalToDispose.add(this.editor.onDidChangeConfiguration(e => {161if (e.hasChanged(EditorOption.fontInfo)) {162this._applyTheme(this.themeService.getColorTheme());163}164}));165this._applyTheme(this.themeService.getColorTheme());166167}168169public get onDidClose(): Event<ReviewZoneWidget | undefined> {170return this._onDidClose.event;171}172173public get onDidCreateThread(): Event<ReviewZoneWidget> {174return this._onDidCreateThread.event;175}176177public getPosition(): IPosition | undefined {178if (this.position) {179return this.position;180}181182if (this._commentGlyph) {183return this._commentGlyph.getPosition().position ?? undefined;184}185return undefined;186}187188protected override revealRange() {189// we don't do anything here as we always do the reveal ourselves.190}191192public reveal(commentUniqueId?: number, focus: CommentWidgetFocus = CommentWidgetFocus.None) {193this.makeVisible(commentUniqueId, focus);194const comment = this._commentThread.comments?.find(comment => comment.uniqueIdInThread === commentUniqueId) ?? this._commentThread.comments?.[0];195this.commentService.setActiveCommentAndThread(this.uniqueOwner, { thread: this._commentThread, comment });196}197198private _expandAndShowZoneWidget() {199if (!this._isExpanded) {200this.show(this.arrowPosition(this._commentThread.range), 2);201}202}203204private _setFocus(commentUniqueId: number | undefined, focus: CommentWidgetFocus) {205if (focus === CommentWidgetFocus.Widget) {206this._commentThreadWidget.focus(commentUniqueId);207} else if (focus === CommentWidgetFocus.Editor) {208this._commentThreadWidget.focusCommentEditor();209}210}211212private _goToComment(commentUniqueId: number, focus: CommentWidgetFocus) {213const height = this.editor.getLayoutInfo().height;214const coords = this._commentThreadWidget.getCommentCoords(commentUniqueId);215if (coords) {216let scrollTop: number = 1;217if (this._commentThread.range) {218const commentThreadCoords = coords.thread;219const commentCoords = coords.comment;220scrollTop = this.editor.getTopForLineNumber(this._commentThread.range.startLineNumber) - height / 2 + commentCoords.top - commentThreadCoords.top;221}222this.editor.setScrollTop(scrollTop);223this._setFocus(commentUniqueId, focus);224} else {225this._goToThread(focus);226}227}228229private _goToThread(focus: CommentWidgetFocus) {230const rangeToReveal = this._commentThread.range231? new Range(this._commentThread.range.startLineNumber, this._commentThread.range.startColumn, this._commentThread.range.endLineNumber + 1, 1)232: new Range(1, 1, 1, 1);233234this.editor.revealRangeInCenter(rangeToReveal);235this._setFocus(undefined, focus);236}237238public makeVisible(commentUniqueId?: number, focus: CommentWidgetFocus = CommentWidgetFocus.None) {239this._expandAndShowZoneWidget();240241if (commentUniqueId !== undefined) {242this._goToComment(commentUniqueId, focus);243} else {244this._goToThread(focus);245}246}247248public getPendingComments(): { newComment: languages.PendingComment | undefined; edits: { [key: number]: languages.PendingComment } } {249return {250newComment: this._commentThreadWidget.getPendingComment(),251edits: this._commentThreadWidget.getPendingEdits()252};253}254255public setPendingComment(pending: languages.PendingComment) {256this._pendingComment = pending;257this.expand();258this._commentThreadWidget.setPendingComment(pending);259}260261protected _fillContainer(container: HTMLElement): void {262this.setCssClass('review-widget');263this._commentThreadWidget = this._scopedInstantiationService.createInstance(264CommentThreadWidget,265container,266this.editor,267this._uniqueOwner,268this.editor.getModel()!.uri,269this._contextKeyService,270this._scopedInstantiationService,271this._commentThread as unknown as languages.CommentThread<IRange | ICellRange>,272this._pendingComment,273this._pendingEdits,274{ editor: this.editor, codeBlockFontSize: '', codeBlockFontFamily: this.configurationService.getValue<IEditorOptions>('editor').fontFamily || EDITOR_FONT_DEFAULTS.fontFamily },275this._commentOptions,276{277actionRunner: async () => {278if (!this._commentThread.comments || !this._commentThread.comments.length) {279const newPosition = this.getPosition();280281if (newPosition) {282const originalRange = this._commentThread.range;283if (!originalRange) {284return;285}286let range: Range;287288if (newPosition.lineNumber !== originalRange.endLineNumber) {289// The widget could have moved as a result of editor changes.290// We need to try to calculate the new, more correct, range for the comment.291const distance = newPosition.lineNumber - originalRange.endLineNumber;292range = new Range(originalRange.startLineNumber + distance, originalRange.startColumn, originalRange.endLineNumber + distance, originalRange.endColumn);293} else {294range = new Range(originalRange.startLineNumber, originalRange.startColumn, originalRange.endLineNumber, originalRange.endColumn);295}296await this.commentService.updateCommentThreadTemplate(this.uniqueOwner, this._commentThread.commentThreadHandle, range);297}298}299},300collapse: () => {301return this.collapse(true);302}303}304) as unknown as CommentThreadWidget<IRange>;305306this._disposables.add(this._commentThreadWidget);307}308309private arrowPosition(range: IRange | undefined): IPosition | undefined {310if (!range) {311return undefined;312}313// 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)314return { lineNumber: range.endLineNumber, column: range.endLineNumber === range.startLineNumber ? (range.startColumn + range.endColumn + 1) / 2 : 1 };315}316317private deleteCommentThread(): void {318this.dispose();319this.commentService.disposeCommentThread(this.uniqueOwner, this._commentThread.threadId);320}321322private doCollapse() {323this._commentThread.collapsibleState = languages.CommentThreadCollapsibleState.Collapsed;324}325326public async collapse(confirm: boolean = false): Promise<boolean> {327if (!confirm || (await this.confirmCollapse())) {328this.doCollapse();329return true;330} else {331return false;332}333}334335private async confirmCollapse(): Promise<boolean> {336const confirmSetting = this.configurationService.getValue<'whenHasUnsubmittedComments' | 'never'>('comments.thread.confirmOnCollapse');337338if (confirmSetting === 'whenHasUnsubmittedComments' && this._commentThreadWidget.hasUnsubmittedComments) {339const result = await this.dialogService.confirm({340message: nls.localize('confirmCollapse', "Collapsing this comment thread will discard unsubmitted comments. Are you sure you want to discard these comments?"),341primaryButton: nls.localize('discard', "Discard"),342type: Severity.Warning,343checkbox: { label: nls.localize('neverAskAgain', "Never ask me again"), checked: false }344});345if (result.checkboxChecked) {346await this.configurationService.updateValue('comments.thread.confirmOnCollapse', 'never');347}348return result.confirmed;349}350return true;351}352353public expand(setActive?: boolean) {354this._commentThread.collapsibleState = languages.CommentThreadCollapsibleState.Expanded;355if (setActive) {356this.commentService.setActiveCommentAndThread(this.uniqueOwner, { thread: this._commentThread });357}358}359360public getGlyphPosition(): number {361if (this._commentGlyph) {362return this._commentGlyph.getPosition().position!.lineNumber;363}364return 0;365}366367async update(commentThread: languages.CommentThread<IRange>) {368if (this._commentThread !== commentThread) {369this._commentThreadDisposables.forEach(disposable => disposable.dispose());370this._commentThread = commentThread;371this._commentThreadDisposables = [];372this.bindCommentThreadListeners();373}374375await this._commentThreadWidget.updateCommentThread(commentThread);376377// Move comment glyph widget and show position if the line has changed.378const lineNumber = this._commentThread.range?.endLineNumber ?? 1;379let shouldMoveWidget = false;380if (this._commentGlyph) {381this._commentGlyph.setThreadState(commentThread.state);382if (this._commentGlyph.getPosition().position!.lineNumber !== lineNumber) {383shouldMoveWidget = true;384this._commentGlyph.setLineNumber(lineNumber);385}386}387388if ((shouldMoveWidget && this._isExpanded) || (this._commentThread.collapsibleState === languages.CommentThreadCollapsibleState.Expanded && !this._isExpanded)) {389this.show(this.arrowPosition(this._commentThread.range), 2);390} else if (this._commentThread.collapsibleState !== languages.CommentThreadCollapsibleState.Expanded) {391this.hide();392}393}394395protected override _onWidth(widthInPixel: number): void {396this._commentThreadWidget.layout(widthInPixel);397}398399protected override _doLayout(heightInPixel: number, widthInPixel: number): void {400this._commentThreadWidget.layout(widthInPixel);401}402403async display(range: IRange | undefined, shouldReveal: boolean) {404if (range) {405this._commentGlyph = new CommentGlyphWidget(this.editor, range?.endLineNumber ?? -1);406this._commentGlyph.setThreadState(this._commentThread.state);407this._globalToDispose.add(this._commentGlyph.onDidChangeLineNumber(async e => {408if (!this._commentThread.range) {409return;410}411const shift = e - (this._commentThread.range.endLineNumber);412const newRange = new Range(this._commentThread.range.startLineNumber + shift, this._commentThread.range.startColumn, this._commentThread.range.endLineNumber + shift, this._commentThread.range.endColumn);413this._commentThread.range = newRange;414}));415}416417await this._commentThreadWidget.display(this.editor.getOption(EditorOption.lineHeight), shouldReveal);418this._disposables.add(this._commentThreadWidget.onDidResize(dimension => {419this._refresh(dimension);420}));421if (this._commentThread.collapsibleState === languages.CommentThreadCollapsibleState.Expanded) {422this.show(this.arrowPosition(range), 2);423}424425// If this is a new comment thread awaiting user input then we need to reveal it.426if (shouldReveal) {427this.makeVisible();428}429430this.bindCommentThreadListeners();431}432433private bindCommentThreadListeners() {434this._commentThreadDisposables.push(this._commentThread.onDidChangeComments(async _ => {435await this.update(this._commentThread);436}));437438this._commentThreadDisposables.push(this._commentThread.onDidChangeCollapsibleState(state => {439if (state === languages.CommentThreadCollapsibleState.Expanded && !this._isExpanded) {440this.show(this.arrowPosition(this._commentThread.range), 2);441this._commentThreadWidget.ensureFocusIntoNewEditingComment();442return;443}444445if (state === languages.CommentThreadCollapsibleState.Collapsed && this._isExpanded) {446this.hide();447return;448}449}));450451if (this._initialCollapsibleState === undefined) {452const onDidChangeInitialCollapsibleState = this._commentThread.onDidChangeInitialCollapsibleState(state => {453// File comments always start expanded454this._initialCollapsibleState = state;455this._commentThread.collapsibleState = this._initialCollapsibleState;456onDidChangeInitialCollapsibleState.dispose();457});458this._commentThreadDisposables.push(onDidChangeInitialCollapsibleState);459}460461462this._commentThreadDisposables.push(this._commentThread.onDidChangeState(() => {463const borderColor =464getCommentThreadWidgetStateColor(this._commentThread.state, this.themeService.getColorTheme()) || Color.transparent;465this.style({466frameColor: borderColor,467arrowColor: borderColor,468});469this.container?.style.setProperty(commentThreadStateColorVar, `${borderColor}`);470this.container?.style.setProperty(commentThreadStateBackgroundColorVar, `${borderColor.transparent(.1)}`);471}));472}473474async submitComment(): Promise<void> {475return this._commentThreadWidget.submitComment();476}477478_refresh(dimensions: dom.Dimension) {479if ((this._isExpanded === undefined) && (dimensions.height === 0) && (dimensions.width === 0)) {480this.commentThread.collapsibleState = languages.CommentThreadCollapsibleState.Collapsed;481return;482}483if (this._isExpanded) {484this._commentThreadWidget.layout();485486const headHeight = Math.ceil(this.editor.getOption(EditorOption.lineHeight) * 1.2);487const lineHeight = this.editor.getOption(EditorOption.lineHeight);488const arrowHeight = Math.round(lineHeight / 3);489const frameThickness = Math.round(lineHeight / 9) * 2;490491const computedLinesNumber = Math.ceil((headHeight + dimensions.height + arrowHeight + frameThickness + 8 /** margin bottom to avoid margin collapse */) / lineHeight);492493if (this._viewZone?.heightInLines === computedLinesNumber) {494return;495}496497const currentPosition = this.getPosition();498499if (this._viewZone && currentPosition && currentPosition.lineNumber !== this._viewZone.afterLineNumber && this._viewZone.afterLineNumber !== 0) {500this._viewZone.afterLineNumber = currentPosition.lineNumber;501}502503const capture = StableEditorScrollState.capture(this.editor);504this._relayout(computedLinesNumber);505capture.restore(this.editor);506}507}508509private _applyTheme(theme: IColorTheme) {510const borderColor = getCommentThreadWidgetStateColor(this._commentThread.state, this.themeService.getColorTheme()) || Color.transparent;511this.style({512arrowColor: borderColor,513frameColor: borderColor514});515const fontInfo = this.editor.getOption(EditorOption.fontInfo);516517// Editor decorations should also be responsive to theme changes518this._commentThreadWidget.applyTheme(theme, fontInfo);519}520521override show(rangeOrPos: IRange | IPosition | undefined, heightInLines: number): void {522const glyphPosition = this._commentGlyph?.getPosition();523let range = Range.isIRange(rangeOrPos) ? rangeOrPos : (rangeOrPos ? Range.fromPositions(rangeOrPos) : undefined);524if (glyphPosition?.position && range && glyphPosition.position.lineNumber !== range.endLineNumber) {525// The widget could have moved as a result of editor changes.526// We need to try to calculate the new, more correct, range for the comment.527const distance = glyphPosition.position.lineNumber - range.endLineNumber;528range = new Range(range.startLineNumber + distance, range.startColumn, range.endLineNumber + distance, range.endColumn);529}530531this._isExpanded = true;532super.show(range ?? new Range(0, 0, 0, 0), heightInLines);533this._commentThread.collapsibleState = languages.CommentThreadCollapsibleState.Expanded;534this._refresh(this._commentThreadWidget.getDimensions());535}536537async collapseAndFocusRange() {538if (await this.collapse(true) && Range.isIRange(this.commentThread.range) && isCodeEditor(this.editor)) {539this.editor.setSelection(this.commentThread.range);540}541}542543override hide() {544if (this._isExpanded) {545this._isExpanded = false;546// Focus the container so that the comment editor will be blurred before it is hidden547if (this.editor.hasWidgetFocus()) {548this.editor.focus();549}550551if (!this._commentThread.comments || !this._commentThread.comments.length) {552this.deleteCommentThread();553}554}555super.hide();556}557558override dispose() {559super.dispose();560561if (this._commentGlyph) {562this._commentGlyph.dispose();563this._commentGlyph = undefined;564}565566this._globalToDispose.dispose();567this._commentThreadDisposables.forEach(global => global.dispose());568this._onDidClose.fire(undefined);569}570}571572573