Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/workbench/contrib/comments/browser/commentThreadZoneWidget.ts
5245 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import * as dom from '../../../../base/browser/dom.js';
7
import { Color } from '../../../../base/common/color.js';
8
import { Emitter, Event } from '../../../../base/common/event.js';
9
import { IDisposable, DisposableStore } from '../../../../base/common/lifecycle.js';
10
import { ICodeEditor, IEditorMouseEvent, isCodeEditor, MouseTargetType } from '../../../../editor/browser/editorBrowser.js';
11
import { IPosition } from '../../../../editor/common/core/position.js';
12
import { IRange, Range } from '../../../../editor/common/core/range.js';
13
import * as languages from '../../../../editor/common/languages.js';
14
import { ZoneWidget } from '../../../../editor/contrib/zoneWidget/browser/zoneWidget.js';
15
import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js';
16
import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';
17
import { IColorTheme, IThemeService } from '../../../../platform/theme/common/themeService.js';
18
import { CommentGlyphWidget } from './commentGlyphWidget.js';
19
import { ICommentService } from './commentService.js';
20
import { ICommentThreadWidget } from '../common/commentThreadWidget.js';
21
import { EditorOption } from '../../../../editor/common/config/editorOptions.js';
22
import { ServiceCollection } from '../../../../platform/instantiation/common/serviceCollection.js';
23
import { CommentThreadWidget } from './commentThreadWidget.js';
24
import { commentThreadStateBackgroundColorVar, commentThreadStateColorVar, getCommentThreadStateBorderColor } from './commentColors.js';
25
import { peekViewBorder } from '../../../../editor/contrib/peekView/browser/peekView.js';
26
import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';
27
import { StableEditorScrollState } from '../../../../editor/browser/stableEditorScroll.js';
28
import Severity from '../../../../base/common/severity.js';
29
import * as nls from '../../../../nls.js';
30
import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js';
31
32
function getCommentThreadWidgetStateColor(thread: languages.CommentThreadState | undefined, theme: IColorTheme): Color | undefined {
33
return getCommentThreadStateBorderColor(thread, theme) ?? theme.getColor(peekViewBorder);
34
}
35
36
/**
37
* Check if a comment thread has any draft comments
38
*/
39
function commentThreadHasDraft(commentThread: languages.CommentThread): boolean {
40
const comments = commentThread.comments;
41
if (!comments) {
42
return false;
43
}
44
return comments.some(comment => comment.state === languages.CommentState.Draft);
45
}
46
47
export enum CommentWidgetFocus {
48
None = 0,
49
Widget = 1,
50
Editor = 2
51
}
52
53
export function parseMouseDownInfoFromEvent(e: IEditorMouseEvent) {
54
const range = e.target.range;
55
56
if (!range) {
57
return null;
58
}
59
60
if (!e.event.leftButton) {
61
return null;
62
}
63
64
if (e.target.type !== MouseTargetType.GUTTER_LINE_DECORATIONS) {
65
return null;
66
}
67
68
const data = e.target.detail;
69
const gutterOffsetX = data.offsetX - data.glyphMarginWidth - data.lineNumbersWidth - data.glyphMarginLeft;
70
71
// don't collide with folding and git decorations
72
if (gutterOffsetX > 20) {
73
return null;
74
}
75
76
return { lineNumber: range.startLineNumber };
77
}
78
79
export function isMouseUpEventDragFromMouseDown(mouseDownInfo: { lineNumber: number } | null, e: IEditorMouseEvent) {
80
if (!mouseDownInfo) {
81
return null;
82
}
83
84
const { lineNumber } = mouseDownInfo;
85
86
const range = e.target.range;
87
88
if (!range) {
89
return null;
90
}
91
92
return lineNumber;
93
}
94
95
export function isMouseUpEventMatchMouseDown(mouseDownInfo: { lineNumber: number } | null, e: IEditorMouseEvent) {
96
if (!mouseDownInfo) {
97
return null;
98
}
99
100
const { lineNumber } = mouseDownInfo;
101
102
const range = e.target.range;
103
104
if (!range || range.startLineNumber !== lineNumber) {
105
return null;
106
}
107
108
if (e.target.type !== MouseTargetType.GUTTER_LINE_DECORATIONS) {
109
return null;
110
}
111
112
return lineNumber;
113
}
114
115
export class ReviewZoneWidget extends ZoneWidget implements ICommentThreadWidget {
116
private _commentThreadWidget!: CommentThreadWidget;
117
private readonly _onDidClose = new Emitter<ReviewZoneWidget | undefined>();
118
private readonly _onDidCreateThread = new Emitter<ReviewZoneWidget>();
119
private readonly _onDidChangeExpandedState = new Emitter<boolean>();
120
private _isExpanded?: boolean;
121
private _initialCollapsibleState?: languages.CommentThreadCollapsibleState;
122
private _commentGlyph?: CommentGlyphWidget;
123
private readonly _globalToDispose = new DisposableStore();
124
private _commentThreadDisposables: IDisposable[] = [];
125
private _contextKeyService: IContextKeyService;
126
private _scopedInstantiationService: IInstantiationService;
127
128
public get uniqueOwner(): string {
129
return this._uniqueOwner;
130
}
131
public get commentThread(): languages.CommentThread {
132
return this._commentThread;
133
}
134
135
public get expanded(): boolean | undefined {
136
return this._isExpanded;
137
}
138
139
private _commentOptions: languages.CommentOptions | undefined;
140
141
constructor(
142
editor: ICodeEditor,
143
private _uniqueOwner: string,
144
private _commentThread: languages.CommentThread,
145
private _pendingComment: languages.PendingComment | undefined,
146
private _pendingEdits: { [key: number]: languages.PendingComment } | undefined,
147
@IInstantiationService instantiationService: IInstantiationService,
148
@IThemeService private themeService: IThemeService,
149
@ICommentService private commentService: ICommentService,
150
@IContextKeyService contextKeyService: IContextKeyService,
151
@IConfigurationService private readonly configurationService: IConfigurationService,
152
@IDialogService private readonly dialogService: IDialogService
153
) {
154
super(editor, { keepEditorSelection: true, isAccessible: true, showArrow: !!_commentThread.range });
155
this._contextKeyService = contextKeyService.createScoped(this.domNode);
156
157
this._scopedInstantiationService = this._globalToDispose.add(instantiationService.createChild(new ServiceCollection(
158
[IContextKeyService, this._contextKeyService]
159
)));
160
161
const controller = this.commentService.getCommentController(this._uniqueOwner);
162
if (controller) {
163
this._commentOptions = controller.options;
164
}
165
166
this._initialCollapsibleState = _pendingComment ? languages.CommentThreadCollapsibleState.Expanded : _commentThread.initialCollapsibleState;
167
_commentThread.initialCollapsibleState = this._initialCollapsibleState;
168
this._commentThreadDisposables = [];
169
this.create();
170
171
this._globalToDispose.add(this.themeService.onDidColorThemeChange(this._applyTheme, this));
172
this._globalToDispose.add(this.editor.onDidChangeConfiguration(e => {
173
if (e.hasChanged(EditorOption.fontInfo)) {
174
this._applyTheme();
175
}
176
}));
177
this._applyTheme();
178
179
}
180
181
public get onDidClose(): Event<ReviewZoneWidget | undefined> {
182
return this._onDidClose.event;
183
}
184
185
public get onDidCreateThread(): Event<ReviewZoneWidget> {
186
return this._onDidCreateThread.event;
187
}
188
189
public get onDidChangeExpandedState(): Event<boolean> {
190
return this._onDidChangeExpandedState.event;
191
}
192
193
public getPosition(): IPosition | undefined {
194
if (this.position) {
195
return this.position;
196
}
197
198
if (this._commentGlyph) {
199
return this._commentGlyph.getPosition().position ?? undefined;
200
}
201
return undefined;
202
}
203
204
protected override revealRange() {
205
// we don't do anything here as we always do the reveal ourselves.
206
}
207
208
public reveal(commentUniqueId?: number, focus: CommentWidgetFocus = CommentWidgetFocus.None) {
209
this.makeVisible(commentUniqueId, focus);
210
const comment = this._commentThread.comments?.find(comment => comment.uniqueIdInThread === commentUniqueId) ?? this._commentThread.comments?.[0];
211
this.commentService.setActiveCommentAndThread(this.uniqueOwner, { thread: this._commentThread, comment });
212
}
213
214
private _expandAndShowZoneWidget() {
215
if (!this._isExpanded) {
216
this.show(this.arrowPosition(this._commentThread.range), 2);
217
}
218
}
219
220
private _setFocus(commentUniqueId: number | undefined, focus: CommentWidgetFocus) {
221
if (focus === CommentWidgetFocus.Widget) {
222
this._commentThreadWidget.focus(commentUniqueId);
223
} else if (focus === CommentWidgetFocus.Editor) {
224
this._commentThreadWidget.focusCommentEditor();
225
}
226
}
227
228
private _goToComment(commentUniqueId: number, focus: CommentWidgetFocus) {
229
const height = this.editor.getLayoutInfo().height;
230
const coords = this._commentThreadWidget.getCommentCoords(commentUniqueId);
231
if (coords) {
232
let scrollTop: number = 1;
233
if (this._commentThread.range) {
234
const commentThreadCoords = coords.thread;
235
const commentCoords = coords.comment;
236
scrollTop = this.editor.getTopForLineNumber(this._commentThread.range.startLineNumber) - height / 2 + commentCoords.top - commentThreadCoords.top;
237
}
238
this.editor.setScrollTop(scrollTop);
239
this._setFocus(commentUniqueId, focus);
240
} else {
241
this._goToThread(focus);
242
}
243
}
244
245
private _goToThread(focus: CommentWidgetFocus) {
246
const rangeToReveal = this._commentThread.range
247
? new Range(this._commentThread.range.startLineNumber, this._commentThread.range.startColumn, this._commentThread.range.endLineNumber + 1, 1)
248
: new Range(1, 1, 1, 1);
249
250
this.editor.revealRangeInCenter(rangeToReveal);
251
this._setFocus(undefined, focus);
252
}
253
254
public makeVisible(commentUniqueId?: number, focus: CommentWidgetFocus = CommentWidgetFocus.None) {
255
this._expandAndShowZoneWidget();
256
257
if (commentUniqueId !== undefined) {
258
this._goToComment(commentUniqueId, focus);
259
} else {
260
this._goToThread(focus);
261
}
262
}
263
264
public getPendingComments(): { newComment: languages.PendingComment | undefined; edits: { [key: number]: languages.PendingComment } } {
265
return {
266
newComment: this._commentThreadWidget.getPendingComment(),
267
edits: this._commentThreadWidget.getPendingEdits()
268
};
269
}
270
271
public setPendingComment(pending: languages.PendingComment) {
272
this._pendingComment = pending;
273
this.expand();
274
this._commentThreadWidget.setPendingComment(pending);
275
}
276
277
protected _fillContainer(container: HTMLElement): void {
278
this.setCssClass('review-widget');
279
this._commentThreadWidget = this._scopedInstantiationService.createInstance(
280
CommentThreadWidget<IRange>,
281
container,
282
this.editor,
283
this._uniqueOwner,
284
this.editor.getModel()!.uri,
285
this._contextKeyService,
286
this._scopedInstantiationService,
287
this._commentThread,
288
this._pendingComment,
289
this._pendingEdits,
290
{ context: this.editor, },
291
this._commentOptions,
292
{
293
actionRunner: async () => {
294
if (!this._commentThread.comments || !this._commentThread.comments.length) {
295
const newPosition = this.getPosition();
296
297
if (newPosition) {
298
const originalRange = this._commentThread.range;
299
if (!originalRange) {
300
return;
301
}
302
let range: Range;
303
304
if (newPosition.lineNumber !== originalRange.endLineNumber) {
305
// The widget could have moved as a result of editor changes.
306
// We need to try to calculate the new, more correct, range for the comment.
307
const distance = newPosition.lineNumber - originalRange.endLineNumber;
308
range = new Range(originalRange.startLineNumber + distance, originalRange.startColumn, originalRange.endLineNumber + distance, originalRange.endColumn);
309
} else {
310
range = new Range(originalRange.startLineNumber, originalRange.startColumn, originalRange.endLineNumber, originalRange.endColumn);
311
}
312
await this.commentService.updateCommentThreadTemplate(this.uniqueOwner, this._commentThread.commentThreadHandle, range);
313
}
314
}
315
},
316
collapse: () => {
317
return this.collapse(true);
318
}
319
}
320
);
321
322
this._disposables.add(this._commentThreadWidget);
323
}
324
325
private arrowPosition(range: IRange | undefined): IPosition | undefined {
326
if (!range) {
327
return undefined;
328
}
329
// 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)
330
return { lineNumber: range.endLineNumber, column: range.endLineNumber === range.startLineNumber ? (range.startColumn + range.endColumn + 1) / 2 : 1 };
331
}
332
333
private deleteCommentThread(): void {
334
this.dispose();
335
this.commentService.disposeCommentThread(this.uniqueOwner, this._commentThread.threadId);
336
}
337
338
private doCollapse() {
339
this._commentThread.collapsibleState = languages.CommentThreadCollapsibleState.Collapsed;
340
}
341
342
public async collapse(confirm: boolean = false): Promise<boolean> {
343
if (!confirm || (await this.confirmCollapse())) {
344
this.doCollapse();
345
return true;
346
} else {
347
return false;
348
}
349
}
350
351
private async confirmCollapse(): Promise<boolean> {
352
const confirmSetting = this.configurationService.getValue<'whenHasUnsubmittedComments' | 'never'>('comments.thread.confirmOnCollapse');
353
354
if (confirmSetting === 'whenHasUnsubmittedComments' && this._commentThreadWidget.hasUnsubmittedComments) {
355
const result = await this.dialogService.confirm({
356
message: nls.localize('confirmCollapse', "Collapsing this comment thread will discard unsubmitted comments. Are you sure you want to discard these comments?"),
357
primaryButton: nls.localize('discard', "Discard"),
358
type: Severity.Warning,
359
checkbox: { label: nls.localize('neverAskAgain', "Never ask me again"), checked: false }
360
});
361
if (result.checkboxChecked) {
362
await this.configurationService.updateValue('comments.thread.confirmOnCollapse', 'never');
363
}
364
return result.confirmed;
365
}
366
return true;
367
}
368
369
public expand(setActive?: boolean) {
370
this._commentThread.collapsibleState = languages.CommentThreadCollapsibleState.Expanded;
371
if (setActive) {
372
this.commentService.setActiveCommentAndThread(this.uniqueOwner, { thread: this._commentThread });
373
}
374
}
375
376
public getGlyphPosition(): number {
377
if (this._commentGlyph) {
378
return this._commentGlyph.getPosition().position!.lineNumber;
379
}
380
return 0;
381
}
382
383
async update(commentThread: languages.CommentThread<IRange>) {
384
if (this._commentThread !== commentThread) {
385
this._commentThreadDisposables.forEach(disposable => disposable.dispose());
386
this._commentThread = commentThread;
387
this._commentThreadDisposables = [];
388
this.bindCommentThreadListeners();
389
}
390
391
await this._commentThreadWidget.updateCommentThread(commentThread);
392
393
// Move comment glyph widget and show position if the line has changed.
394
const lineNumber = this._commentThread.range?.endLineNumber ?? 1;
395
let shouldMoveWidget = false;
396
if (this._commentGlyph) {
397
const hasDraft = commentThreadHasDraft(commentThread);
398
this._commentGlyph.setThreadState(commentThread.state, hasDraft);
399
if (this._commentGlyph.getPosition().position!.lineNumber !== lineNumber) {
400
shouldMoveWidget = true;
401
this._commentGlyph.setLineNumber(lineNumber);
402
}
403
}
404
405
if ((shouldMoveWidget && this._isExpanded) || (this._commentThread.collapsibleState === languages.CommentThreadCollapsibleState.Expanded && !this._isExpanded)) {
406
this.show(this.arrowPosition(this._commentThread.range), 2);
407
} else if (this._commentThread.collapsibleState !== languages.CommentThreadCollapsibleState.Expanded) {
408
this.hide();
409
}
410
}
411
412
protected override _onWidth(widthInPixel: number): void {
413
this._commentThreadWidget.layout(widthInPixel);
414
}
415
416
protected override _doLayout(heightInPixel: number, widthInPixel: number): void {
417
this._commentThreadWidget.layout(widthInPixel);
418
}
419
420
async display(range: IRange | undefined, shouldReveal: boolean) {
421
if (range) {
422
this._commentGlyph = new CommentGlyphWidget(this.editor, range?.endLineNumber ?? -1);
423
const hasDraft = commentThreadHasDraft(this._commentThread);
424
this._commentGlyph.setThreadState(this._commentThread.state, hasDraft);
425
this._globalToDispose.add(this._commentGlyph.onDidChangeLineNumber(async e => {
426
if (!this._commentThread.range) {
427
return;
428
}
429
const shift = e - (this._commentThread.range.endLineNumber);
430
const newRange = new Range(this._commentThread.range.startLineNumber + shift, this._commentThread.range.startColumn, this._commentThread.range.endLineNumber + shift, this._commentThread.range.endColumn);
431
this._commentThread.range = newRange;
432
}));
433
}
434
435
await this._commentThreadWidget.display(this.editor.getOption(EditorOption.lineHeight), shouldReveal);
436
this._disposables.add(this._commentThreadWidget.onDidResize(dimension => {
437
this._refresh(dimension);
438
}));
439
if (this._commentThread.collapsibleState === languages.CommentThreadCollapsibleState.Expanded) {
440
this.show(this.arrowPosition(range), 2);
441
}
442
443
// If this is a new comment thread awaiting user input then we need to reveal it.
444
if (shouldReveal) {
445
this.makeVisible();
446
}
447
448
this.bindCommentThreadListeners();
449
}
450
451
private bindCommentThreadListeners() {
452
this._commentThreadDisposables.push(this._commentThread.onDidChangeComments(async _ => {
453
await this.update(this._commentThread);
454
}));
455
456
this._commentThreadDisposables.push(this._commentThread.onDidChangeCollapsibleState(state => {
457
if (state === languages.CommentThreadCollapsibleState.Expanded && !this._isExpanded) {
458
this.show(this.arrowPosition(this._commentThread.range), 2);
459
this._commentThreadWidget.ensureFocusIntoNewEditingComment();
460
return;
461
}
462
463
if (state === languages.CommentThreadCollapsibleState.Collapsed && this._isExpanded) {
464
this.hide();
465
return;
466
}
467
}));
468
469
if (this._initialCollapsibleState === undefined) {
470
const onDidChangeInitialCollapsibleState = this._commentThread.onDidChangeInitialCollapsibleState(state => {
471
// File comments always start expanded
472
this._initialCollapsibleState = state;
473
this._commentThread.collapsibleState = this._initialCollapsibleState;
474
onDidChangeInitialCollapsibleState.dispose();
475
});
476
this._commentThreadDisposables.push(onDidChangeInitialCollapsibleState);
477
}
478
479
480
this._commentThreadDisposables.push(this._commentThread.onDidChangeState(() => {
481
const borderColor =
482
getCommentThreadWidgetStateColor(this._commentThread.state, this.themeService.getColorTheme()) || Color.transparent;
483
this.style({
484
frameColor: borderColor,
485
arrowColor: borderColor,
486
});
487
this.container?.style.setProperty(commentThreadStateColorVar, `${borderColor}`);
488
this.container?.style.setProperty(commentThreadStateBackgroundColorVar, `${borderColor.transparent(.1)}`);
489
}));
490
}
491
492
async submitComment(): Promise<void> {
493
return this._commentThreadWidget.submitComment();
494
}
495
496
_refresh(dimensions: dom.Dimension) {
497
if ((this._isExpanded === undefined) && (dimensions.height === 0) && (dimensions.width === 0)) {
498
this.commentThread.collapsibleState = languages.CommentThreadCollapsibleState.Collapsed;
499
return;
500
}
501
if (this._isExpanded) {
502
this._commentThreadWidget.layout();
503
504
const headHeight = Math.ceil(this.editor.getOption(EditorOption.lineHeight) * 1.2);
505
const lineHeight = this.editor.getOption(EditorOption.lineHeight);
506
const arrowHeight = Math.round(lineHeight / 3);
507
const frameThickness = Math.round(lineHeight / 9) * 2;
508
509
const computedLinesNumber = Math.ceil((headHeight + dimensions.height + arrowHeight + frameThickness + 8 /** margin bottom to avoid margin collapse */) / lineHeight);
510
511
if (this._viewZone?.heightInLines === computedLinesNumber) {
512
return;
513
}
514
515
const currentPosition = this.getPosition();
516
517
if (this._viewZone && currentPosition && currentPosition.lineNumber !== this._viewZone.afterLineNumber && this._viewZone.afterLineNumber !== 0) {
518
this._viewZone.afterLineNumber = currentPosition.lineNumber;
519
}
520
521
const capture = StableEditorScrollState.capture(this.editor);
522
this._relayout(computedLinesNumber);
523
capture.restore(this.editor);
524
}
525
}
526
527
private _applyTheme() {
528
const borderColor = getCommentThreadWidgetStateColor(this._commentThread.state, this.themeService.getColorTheme()) || Color.transparent;
529
this.style({
530
arrowColor: borderColor,
531
frameColor: borderColor
532
});
533
const fontInfo = this.editor.getOption(EditorOption.fontInfo);
534
535
this._commentThreadWidget.applyTheme(fontInfo);
536
}
537
538
override show(rangeOrPos: IRange | IPosition | undefined, heightInLines: number): void {
539
const glyphPosition = this._commentGlyph?.getPosition();
540
let range = Range.isIRange(rangeOrPos) ? rangeOrPos : (rangeOrPos ? Range.fromPositions(rangeOrPos) : undefined);
541
if (glyphPosition?.position && range && glyphPosition.position.lineNumber !== range.endLineNumber) {
542
// The widget could have moved as a result of editor changes.
543
// We need to try to calculate the new, more correct, range for the comment.
544
const distance = glyphPosition.position.lineNumber - range.endLineNumber;
545
range = new Range(range.startLineNumber + distance, range.startColumn, range.endLineNumber + distance, range.endColumn);
546
}
547
548
const wasExpanded = this._isExpanded;
549
this._isExpanded = true;
550
super.show(range ?? new Range(0, 0, 0, 0), heightInLines);
551
this._commentThread.collapsibleState = languages.CommentThreadCollapsibleState.Expanded;
552
this._refresh(this._commentThreadWidget.getDimensions());
553
if (!wasExpanded) {
554
this._onDidChangeExpandedState.fire(true);
555
}
556
}
557
558
async collapseAndFocusRange() {
559
if (await this.collapse(true) && Range.isIRange(this.commentThread.range) && isCodeEditor(this.editor)) {
560
this.editor.setSelection(this.commentThread.range);
561
}
562
}
563
564
override hide() {
565
if (this._isExpanded) {
566
this._isExpanded = false;
567
// Focus the container so that the comment editor will be blurred before it is hidden
568
if (this.editor.hasWidgetFocus()) {
569
this.editor.focus();
570
}
571
572
if (!this._commentThread.comments || !this._commentThread.comments.length) {
573
this.deleteCommentThread();
574
}
575
this._onDidChangeExpandedState.fire(false);
576
}
577
super.hide();
578
}
579
580
override dispose() {
581
super.dispose();
582
583
if (this._commentGlyph) {
584
this._commentGlyph.dispose();
585
this._commentGlyph = undefined;
586
}
587
588
this._globalToDispose.dispose();
589
this._commentThreadDisposables.forEach(global => global.dispose());
590
this._onDidClose.fire(undefined);
591
this._onDidClose.dispose();
592
this._onDidCreateThread.dispose();
593
this._onDidChangeExpandedState.dispose();
594
}
595
}
596
597