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