Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/workbench/contrib/comments/browser/commentsController.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 { Action, IAction } from '../../../../base/common/actions.js';
7
import { coalesce } from '../../../../base/common/arrays.js';
8
import { findFirstIdxMonotonousOrArrLen } from '../../../../base/common/arraysFind.js';
9
import { CancelablePromise, createCancelablePromise, Delayer } from '../../../../base/common/async.js';
10
import { onUnexpectedError } from '../../../../base/common/errors.js';
11
import { DisposableStore, dispose, IDisposable } from '../../../../base/common/lifecycle.js';
12
import './media/review.css';
13
import { ICodeEditor, IEditorMouseEvent, isCodeEditor, isDiffEditor } from '../../../../editor/browser/editorBrowser.js';
14
import { ICodeEditorService } from '../../../../editor/browser/services/codeEditorService.js';
15
import { IRange, Range } from '../../../../editor/common/core/range.js';
16
import { EditorType, IDiffEditor, IEditor, IEditorContribution, IModelChangedEvent } from '../../../../editor/common/editorCommon.js';
17
import { IModelDecorationOptions, IModelDeltaDecoration } from '../../../../editor/common/model.js';
18
import { ModelDecorationOptions, TextModel } from '../../../../editor/common/model/textModel.js';
19
import * as languages from '../../../../editor/common/languages.js';
20
import * as nls from '../../../../nls.js';
21
import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js';
22
import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';
23
import { IQuickInputService, IQuickPickItem, QuickPickInput } from '../../../../platform/quickinput/common/quickInput.js';
24
import { CommentGlyphWidget } from './commentGlyphWidget.js';
25
import { ICommentInfo, ICommentService } from './commentService.js';
26
import { CommentWidgetFocus, isMouseUpEventDragFromMouseDown, parseMouseDownInfoFromEvent, ReviewZoneWidget } from './commentThreadZoneWidget.js';
27
import { ACTIVE_GROUP, IEditorService, SIDE_GROUP } from '../../../services/editor/common/editorService.js';
28
import { EmbeddedCodeEditorWidget } from '../../../../editor/browser/widget/codeEditor/embeddedCodeEditorWidget.js';
29
import { EditorOption } from '../../../../editor/common/config/editorOptions.js';
30
import { IViewsService } from '../../../services/views/common/viewsService.js';
31
import { COMMENTS_VIEW_ID } from './commentsTreeViewer.js';
32
import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';
33
import { COMMENTS_SECTION, ICommentsConfiguration } from '../common/commentsConfiguration.js';
34
import { COMMENTEDITOR_DECORATION_KEY } from './commentReply.js';
35
import { Emitter } from '../../../../base/common/event.js';
36
import { IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js';
37
import { Position } from '../../../../editor/common/core/position.js';
38
import { CommentThreadRangeDecorator } from './commentThreadRangeDecorator.js';
39
import { ICursorSelectionChangedEvent } from '../../../../editor/common/cursorEvents.js';
40
import { CommentsPanel } from './commentsView.js';
41
import { status } from '../../../../base/browser/ui/aria/aria.js';
42
import { CommentContextKeys } from '../common/commentContextKeys.js';
43
import { AccessibilityVerbositySettingId } from '../../accessibility/browser/accessibilityConfiguration.js';
44
import { AccessibilityCommandId } from '../../accessibility/common/accessibilityCommands.js';
45
import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js';
46
import { IAccessibilityService } from '../../../../platform/accessibility/common/accessibility.js';
47
import { URI } from '../../../../base/common/uri.js';
48
import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js';
49
import { threadHasMeaningfulComments } from './commentsModel.js';
50
import { INotificationService } from '../../../../platform/notification/common/notification.js';
51
52
export const ID = 'editor.contrib.review';
53
54
interface CommentRangeAction {
55
ownerId: string;
56
extensionId: string | undefined;
57
label: string | undefined;
58
commentingRangesInfo: languages.CommentingRanges;
59
}
60
61
interface MergedCommentRangeActions {
62
range?: Range;
63
action: CommentRangeAction;
64
}
65
66
class CommentingRangeDecoration implements IModelDeltaDecoration {
67
private _decorationId: string | undefined;
68
private _startLineNumber: number;
69
private _endLineNumber: number;
70
71
public get id(): string | undefined {
72
return this._decorationId;
73
}
74
75
public set id(id: string | undefined) {
76
this._decorationId = id;
77
}
78
79
public get range(): IRange {
80
return {
81
startLineNumber: this._startLineNumber, startColumn: 1,
82
endLineNumber: this._endLineNumber, endColumn: 1
83
};
84
}
85
86
constructor(private _editor: ICodeEditor, private _ownerId: string, private _extensionId: string | undefined, private _label: string | undefined, private _range: IRange, public readonly options: ModelDecorationOptions, private commentingRangesInfo: languages.CommentingRanges, public readonly isHover: boolean = false) {
87
this._startLineNumber = _range.startLineNumber;
88
this._endLineNumber = _range.endLineNumber;
89
}
90
91
public getCommentAction(): CommentRangeAction {
92
return {
93
extensionId: this._extensionId,
94
label: this._label,
95
ownerId: this._ownerId,
96
commentingRangesInfo: this.commentingRangesInfo
97
};
98
}
99
100
public getOriginalRange() {
101
return this._range;
102
}
103
104
public getActiveRange() {
105
return this.id ? this._editor.getModel()!.getDecorationRange(this.id) : undefined;
106
}
107
}
108
109
class CommentingRangeDecorator {
110
public static description = 'commenting-range-decorator';
111
private decorationOptions: ModelDecorationOptions;
112
private hoverDecorationOptions: ModelDecorationOptions;
113
private multilineDecorationOptions: ModelDecorationOptions;
114
private commentingRangeDecorations: CommentingRangeDecoration[] = [];
115
private decorationIds: string[] = [];
116
private _editor: ICodeEditor | undefined;
117
private _infos: ICommentInfo[] | undefined;
118
private _lastHover: number = -1;
119
private _lastSelection: Range | undefined;
120
private _lastSelectionCursor: number | undefined;
121
private _onDidChangeDecorationsCount: Emitter<number> = new Emitter();
122
public readonly onDidChangeDecorationsCount = this._onDidChangeDecorationsCount.event;
123
124
constructor() {
125
const decorationOptions: IModelDecorationOptions = {
126
description: CommentingRangeDecorator.description,
127
isWholeLine: true,
128
linesDecorationsClassName: 'comment-range-glyph comment-diff-added'
129
};
130
131
this.decorationOptions = ModelDecorationOptions.createDynamic(decorationOptions);
132
133
const hoverDecorationOptions: IModelDecorationOptions = {
134
description: CommentingRangeDecorator.description,
135
isWholeLine: true,
136
linesDecorationsClassName: `comment-range-glyph line-hover`
137
};
138
139
this.hoverDecorationOptions = ModelDecorationOptions.createDynamic(hoverDecorationOptions);
140
141
const multilineDecorationOptions: IModelDecorationOptions = {
142
description: CommentingRangeDecorator.description,
143
isWholeLine: true,
144
linesDecorationsClassName: `comment-range-glyph multiline-add`
145
};
146
147
this.multilineDecorationOptions = ModelDecorationOptions.createDynamic(multilineDecorationOptions);
148
}
149
150
public updateHover(hoverLine?: number) {
151
if (this._editor && this._infos && (hoverLine !== this._lastHover)) {
152
this._doUpdate(this._editor, this._infos, hoverLine);
153
}
154
this._lastHover = hoverLine ?? -1;
155
}
156
157
public updateSelection(cursorLine: number, range: Range = new Range(0, 0, 0, 0)) {
158
this._lastSelection = range.isEmpty() ? undefined : range;
159
this._lastSelectionCursor = range.isEmpty() ? undefined : cursorLine;
160
// Some scenarios:
161
// Selection is made. Emphasis should show on the drag/selection end location.
162
// Selection is made, then user clicks elsewhere. We should still show the decoration.
163
if (this._editor && this._infos) {
164
this._doUpdate(this._editor, this._infos, cursorLine, range);
165
}
166
}
167
168
public update(editor: ICodeEditor | undefined, commentInfos: ICommentInfo[], cursorLine?: number, range?: Range) {
169
if (editor) {
170
this._editor = editor;
171
this._infos = commentInfos;
172
this._doUpdate(editor, commentInfos, cursorLine, range);
173
}
174
}
175
176
private _lineHasThread(editor: ICodeEditor, lineRange: Range) {
177
return editor.getDecorationsInRange(lineRange)?.find(decoration => decoration.options.description === CommentGlyphWidget.description);
178
}
179
180
private _doUpdate(editor: ICodeEditor, commentInfos: ICommentInfo[], emphasisLine: number = -1, selectionRange: Range | undefined = this._lastSelection) {
181
const model = editor.getModel();
182
if (!model) {
183
return;
184
}
185
186
// If there's still a selection, use that.
187
emphasisLine = this._lastSelectionCursor ?? emphasisLine;
188
189
const commentingRangeDecorations: CommentingRangeDecoration[] = [];
190
for (const info of commentInfos) {
191
info.commentingRanges.ranges.forEach(range => {
192
const rangeObject = new Range(range.startLineNumber, range.startColumn, range.endLineNumber, range.endColumn);
193
let intersectingSelectionRange = selectionRange ? rangeObject.intersectRanges(selectionRange) : undefined;
194
if ((selectionRange && (emphasisLine >= 0) && intersectingSelectionRange)
195
// If there's only one selection line, then just drop into the else if and show an emphasis line.
196
&& !((intersectingSelectionRange.startLineNumber === intersectingSelectionRange.endLineNumber)
197
&& (emphasisLine === intersectingSelectionRange.startLineNumber))) {
198
// The emphasisLine should be within the commenting range, even if the selection range stretches
199
// outside of the commenting range.
200
// Clip the emphasis and selection ranges to the commenting range
201
let intersectingEmphasisRange: Range;
202
if (emphasisLine <= intersectingSelectionRange.startLineNumber) {
203
intersectingEmphasisRange = intersectingSelectionRange.collapseToStart();
204
intersectingSelectionRange = new Range(intersectingSelectionRange.startLineNumber + 1, 1, intersectingSelectionRange.endLineNumber, 1);
205
} else {
206
intersectingEmphasisRange = new Range(intersectingSelectionRange.endLineNumber, 1, intersectingSelectionRange.endLineNumber, 1);
207
intersectingSelectionRange = new Range(intersectingSelectionRange.startLineNumber, 1, intersectingSelectionRange.endLineNumber - 1, 1);
208
}
209
commentingRangeDecorations.push(new CommentingRangeDecoration(editor, info.uniqueOwner, info.extensionId, info.label, intersectingSelectionRange, this.multilineDecorationOptions, info.commentingRanges, true));
210
211
if (!this._lineHasThread(editor, intersectingEmphasisRange)) {
212
commentingRangeDecorations.push(new CommentingRangeDecoration(editor, info.uniqueOwner, info.extensionId, info.label, intersectingEmphasisRange, this.hoverDecorationOptions, info.commentingRanges, true));
213
}
214
215
const beforeRangeEndLine = Math.min(intersectingEmphasisRange.startLineNumber, intersectingSelectionRange.startLineNumber) - 1;
216
const hasBeforeRange = rangeObject.startLineNumber <= beforeRangeEndLine;
217
const afterRangeStartLine = Math.max(intersectingEmphasisRange.endLineNumber, intersectingSelectionRange.endLineNumber) + 1;
218
const hasAfterRange = rangeObject.endLineNumber >= afterRangeStartLine;
219
if (hasBeforeRange) {
220
const beforeRange = new Range(range.startLineNumber, 1, beforeRangeEndLine, 1);
221
commentingRangeDecorations.push(new CommentingRangeDecoration(editor, info.uniqueOwner, info.extensionId, info.label, beforeRange, this.decorationOptions, info.commentingRanges, true));
222
}
223
if (hasAfterRange) {
224
const afterRange = new Range(afterRangeStartLine, 1, range.endLineNumber, 1);
225
commentingRangeDecorations.push(new CommentingRangeDecoration(editor, info.uniqueOwner, info.extensionId, info.label, afterRange, this.decorationOptions, info.commentingRanges, true));
226
}
227
} else if ((rangeObject.startLineNumber <= emphasisLine) && (emphasisLine <= rangeObject.endLineNumber)) {
228
if (rangeObject.startLineNumber < emphasisLine) {
229
const beforeRange = new Range(range.startLineNumber, 1, emphasisLine - 1, 1);
230
commentingRangeDecorations.push(new CommentingRangeDecoration(editor, info.uniqueOwner, info.extensionId, info.label, beforeRange, this.decorationOptions, info.commentingRanges, true));
231
}
232
const emphasisRange = new Range(emphasisLine, 1, emphasisLine, 1);
233
if (!this._lineHasThread(editor, emphasisRange)) {
234
commentingRangeDecorations.push(new CommentingRangeDecoration(editor, info.uniqueOwner, info.extensionId, info.label, emphasisRange, this.hoverDecorationOptions, info.commentingRanges, true));
235
}
236
if (emphasisLine < rangeObject.endLineNumber) {
237
const afterRange = new Range(emphasisLine + 1, 1, range.endLineNumber, 1);
238
commentingRangeDecorations.push(new CommentingRangeDecoration(editor, info.uniqueOwner, info.extensionId, info.label, afterRange, this.decorationOptions, info.commentingRanges, true));
239
}
240
} else {
241
commentingRangeDecorations.push(new CommentingRangeDecoration(editor, info.uniqueOwner, info.extensionId, info.label, range, this.decorationOptions, info.commentingRanges));
242
}
243
});
244
}
245
246
editor.changeDecorations((accessor) => {
247
this.decorationIds = accessor.deltaDecorations(this.decorationIds, commentingRangeDecorations);
248
commentingRangeDecorations.forEach((decoration, index) => decoration.id = this.decorationIds[index]);
249
});
250
251
const rangesDifference = this.commentingRangeDecorations.length - commentingRangeDecorations.length;
252
this.commentingRangeDecorations = commentingRangeDecorations;
253
if (rangesDifference) {
254
this._onDidChangeDecorationsCount.fire(this.commentingRangeDecorations.length);
255
}
256
}
257
258
private areRangesIntersectingOrTouchingByLine(a: Range, b: Range) {
259
// Check if `a` is before `b`
260
if (a.endLineNumber < (b.startLineNumber - 1)) {
261
return false;
262
}
263
264
// Check if `b` is before `a`
265
if ((b.endLineNumber + 1) < a.startLineNumber) {
266
return false;
267
}
268
269
// These ranges must intersect
270
return true;
271
}
272
273
public getMatchedCommentAction(commentRange: Range | undefined): MergedCommentRangeActions[] {
274
if (commentRange === undefined) {
275
const foundInfos = this._infos?.filter(info => info.commentingRanges.fileComments);
276
if (foundInfos) {
277
return foundInfos.map(foundInfo => {
278
return {
279
action: {
280
ownerId: foundInfo.uniqueOwner,
281
extensionId: foundInfo.extensionId,
282
label: foundInfo.label,
283
commentingRangesInfo: foundInfo.commentingRanges
284
}
285
};
286
});
287
}
288
return [];
289
}
290
291
// keys is ownerId
292
const foundHoverActions = new Map<string, { range: Range; action: CommentRangeAction }>();
293
for (const decoration of this.commentingRangeDecorations) {
294
const range = decoration.getActiveRange();
295
if (range && this.areRangesIntersectingOrTouchingByLine(range, commentRange)) {
296
// We can have several commenting ranges that match from the same uniqueOwner because of how
297
// the line hover and selection decoration is done.
298
// The ranges must be merged so that we can see if the new commentRange fits within them.
299
const action = decoration.getCommentAction();
300
const alreadyFoundInfo = foundHoverActions.get(action.ownerId);
301
if (alreadyFoundInfo?.action.commentingRangesInfo === action.commentingRangesInfo) {
302
// Merge ranges.
303
const newRange = new Range(
304
range.startLineNumber < alreadyFoundInfo.range.startLineNumber ? range.startLineNumber : alreadyFoundInfo.range.startLineNumber,
305
range.startColumn < alreadyFoundInfo.range.startColumn ? range.startColumn : alreadyFoundInfo.range.startColumn,
306
range.endLineNumber > alreadyFoundInfo.range.endLineNumber ? range.endLineNumber : alreadyFoundInfo.range.endLineNumber,
307
range.endColumn > alreadyFoundInfo.range.endColumn ? range.endColumn : alreadyFoundInfo.range.endColumn
308
);
309
foundHoverActions.set(action.ownerId, { range: newRange, action });
310
} else {
311
foundHoverActions.set(action.ownerId, { range, action });
312
}
313
}
314
}
315
316
const seenOwners = new Set<string>();
317
return Array.from(foundHoverActions.values()).filter(action => {
318
if (seenOwners.has(action.action.ownerId)) {
319
return false;
320
} else {
321
seenOwners.add(action.action.ownerId);
322
return true;
323
}
324
});
325
}
326
327
public getNearestCommentingRange(findPosition: Position, reverse?: boolean): Range | undefined {
328
let findPositionContainedWithin: Range | undefined;
329
let decorations: CommentingRangeDecoration[];
330
if (reverse) {
331
decorations = [];
332
for (let i = this.commentingRangeDecorations.length - 1; i >= 0; i--) {
333
decorations.push(this.commentingRangeDecorations[i]);
334
}
335
} else {
336
decorations = this.commentingRangeDecorations;
337
}
338
for (const decoration of decorations) {
339
const range = decoration.getActiveRange();
340
if (!range) {
341
continue;
342
}
343
344
if (findPositionContainedWithin && this.areRangesIntersectingOrTouchingByLine(range, findPositionContainedWithin)) {
345
findPositionContainedWithin = Range.plusRange(findPositionContainedWithin, range);
346
continue;
347
}
348
349
if (range.startLineNumber <= findPosition.lineNumber && findPosition.lineNumber <= range.endLineNumber) {
350
findPositionContainedWithin = new Range(range.startLineNumber, range.startColumn, range.endLineNumber, range.endColumn);
351
continue;
352
}
353
354
if (!reverse && range.endLineNumber < findPosition.lineNumber) {
355
continue;
356
}
357
358
if (reverse && range.startLineNumber > findPosition.lineNumber) {
359
continue;
360
}
361
362
return range;
363
}
364
return (decorations.length > 0 ? (decorations[0].getActiveRange() ?? undefined) : undefined);
365
}
366
367
public dispose(): void {
368
this.commentingRangeDecorations = [];
369
}
370
}
371
372
/**
373
* Navigate to the next or previous comment in the current thread.
374
* @param type
375
*/
376
export function moveToNextCommentInThread(commentInfo: { thread: languages.CommentThread<IRange>; comment?: languages.Comment } | undefined, type: 'next' | 'previous') {
377
if (!commentInfo?.comment || !commentInfo?.thread?.comments) {
378
return;
379
}
380
const currentIndex = commentInfo.thread.comments?.indexOf(commentInfo.comment);
381
if (currentIndex === undefined || currentIndex < 0) {
382
return;
383
}
384
if (type === 'previous' && currentIndex === 0) {
385
return;
386
}
387
if (type === 'next' && currentIndex === commentInfo.thread.comments.length - 1) {
388
return;
389
}
390
const comment = commentInfo.thread.comments?.[type === 'previous' ? currentIndex - 1 : currentIndex + 1];
391
if (!comment) {
392
return;
393
}
394
return {
395
...commentInfo,
396
comment,
397
};
398
}
399
400
export function revealCommentThread(commentService: ICommentService, editorService: IEditorService, uriIdentityService: IUriIdentityService,
401
commentThread: languages.CommentThread<IRange>, comment: languages.Comment | undefined, focusReply?: boolean, pinned?: boolean, preserveFocus?: boolean, sideBySide?: boolean): void {
402
if (!commentThread.resource) {
403
return;
404
}
405
if (!commentService.isCommentingEnabled) {
406
commentService.enableCommenting(true);
407
}
408
409
const range = commentThread.range;
410
const focus = focusReply ? CommentWidgetFocus.Editor : (preserveFocus ? CommentWidgetFocus.None : CommentWidgetFocus.Widget);
411
412
const activeEditor = editorService.activeTextEditorControl;
413
// If the active editor is a diff editor where one of the sides has the comment,
414
// then we try to reveal the comment in the diff editor.
415
const currentActiveResources: IEditor[] = isDiffEditor(activeEditor) ? [activeEditor.getOriginalEditor(), activeEditor.getModifiedEditor()]
416
: (activeEditor ? [activeEditor] : []);
417
const threadToReveal = commentThread.threadId;
418
const commentToReveal = comment?.uniqueIdInThread;
419
const resource = URI.parse(commentThread.resource);
420
421
for (const editor of currentActiveResources) {
422
const model = editor.getModel();
423
if ((model instanceof TextModel) && uriIdentityService.extUri.isEqual(resource, model.uri)) {
424
425
if (threadToReveal && isCodeEditor(editor)) {
426
const controller = CommentController.get(editor);
427
controller?.revealCommentThread(threadToReveal, commentToReveal, true, focus);
428
}
429
return;
430
}
431
}
432
433
editorService.openEditor({
434
resource,
435
options: {
436
pinned: pinned,
437
preserveFocus: preserveFocus,
438
selection: range ?? new Range(1, 1, 1, 1)
439
}
440
}, sideBySide ? SIDE_GROUP : ACTIVE_GROUP).then(editor => {
441
if (editor) {
442
const control = editor.getControl();
443
if (threadToReveal && isCodeEditor(control)) {
444
const controller = CommentController.get(control);
445
controller?.revealCommentThread(threadToReveal, commentToReveal, true, focus);
446
}
447
}
448
});
449
}
450
451
export class CommentController implements IEditorContribution {
452
private readonly globalToDispose = new DisposableStore();
453
private readonly localToDispose = new DisposableStore();
454
private editor: ICodeEditor | undefined;
455
private _commentWidgets: ReviewZoneWidget[];
456
private _commentInfos: ICommentInfo[];
457
private _commentingRangeDecorator!: CommentingRangeDecorator;
458
private _commentThreadRangeDecorator!: CommentThreadRangeDecorator;
459
private mouseDownInfo: { lineNumber: number } | null = null;
460
private _commentingRangeSpaceReserved = false;
461
private _commentingRangeAmountReserved = 0;
462
private _computePromise: CancelablePromise<Array<ICommentInfo | null>> | null;
463
private _computeAndSetPromise: Promise<void> | undefined;
464
private _addInProgress!: boolean;
465
private _emptyThreadsToAddQueue: [Range | undefined, IEditorMouseEvent | undefined][] = [];
466
private _computeCommentingRangePromise!: CancelablePromise<ICommentInfo[]> | null;
467
private _computeCommentingRangeScheduler!: Delayer<Array<ICommentInfo | null>> | null;
468
private _pendingNewCommentCache: { [key: string]: { [key: string]: languages.PendingComment } };
469
private _pendingEditsCache: { [key: string]: { [key: string]: { [key: number]: languages.PendingComment } } }; // uniqueOwner -> threadId -> uniqueIdInThread -> pending comment
470
private _inProcessContinueOnComments: Map<string, languages.PendingCommentThread[]> = new Map();
471
private _editorDisposables: IDisposable[] = [];
472
private _activeCursorHasCommentingRange: IContextKey<boolean>;
473
private _activeCursorHasComment: IContextKey<boolean>;
474
private _activeEditorHasCommentingRange: IContextKey<boolean>;
475
private _hasRespondedToEditorChange: boolean = false;
476
477
constructor(
478
editor: ICodeEditor,
479
@ICommentService private readonly commentService: ICommentService,
480
@IInstantiationService private readonly instantiationService: IInstantiationService,
481
@ICodeEditorService private readonly codeEditorService: ICodeEditorService,
482
@IContextMenuService private readonly contextMenuService: IContextMenuService,
483
@IQuickInputService private readonly quickInputService: IQuickInputService,
484
@IViewsService private readonly viewsService: IViewsService,
485
@IConfigurationService private readonly configurationService: IConfigurationService,
486
@IContextKeyService contextKeyService: IContextKeyService,
487
@IEditorService private readonly editorService: IEditorService,
488
@IKeybindingService private readonly keybindingService: IKeybindingService,
489
@IAccessibilityService private readonly accessibilityService: IAccessibilityService,
490
@INotificationService private readonly notificationService: INotificationService
491
) {
492
this._commentInfos = [];
493
this._commentWidgets = [];
494
this._pendingNewCommentCache = {};
495
this._pendingEditsCache = {};
496
this._computePromise = null;
497
this._activeCursorHasCommentingRange = CommentContextKeys.activeCursorHasCommentingRange.bindTo(contextKeyService);
498
this._activeCursorHasComment = CommentContextKeys.activeCursorHasComment.bindTo(contextKeyService);
499
this._activeEditorHasCommentingRange = CommentContextKeys.activeEditorHasCommentingRange.bindTo(contextKeyService);
500
501
if (editor instanceof EmbeddedCodeEditorWidget) {
502
return;
503
}
504
505
this.editor = editor;
506
507
this._commentingRangeDecorator = new CommentingRangeDecorator();
508
this.globalToDispose.add(this._commentingRangeDecorator.onDidChangeDecorationsCount(count => {
509
if (count === 0) {
510
this.clearEditorListeners();
511
} else if (this._editorDisposables.length === 0) {
512
this.registerEditorListeners();
513
}
514
}));
515
516
this.globalToDispose.add(this._commentThreadRangeDecorator = new CommentThreadRangeDecorator(this.commentService));
517
518
this.globalToDispose.add(this.commentService.onDidDeleteDataProvider(ownerId => {
519
if (ownerId) {
520
delete this._pendingNewCommentCache[ownerId];
521
delete this._pendingEditsCache[ownerId];
522
} else {
523
this._pendingNewCommentCache = {};
524
this._pendingEditsCache = {};
525
}
526
this.beginCompute();
527
}));
528
this.globalToDispose.add(this.commentService.onDidSetDataProvider(_ => this.beginComputeAndHandleEditorChange()));
529
this.globalToDispose.add(this.commentService.onDidUpdateCommentingRanges(_ => this.beginComputeAndHandleEditorChange()));
530
531
this.globalToDispose.add(this.commentService.onDidSetResourceCommentInfos(async e => {
532
const editorURI = this.editor && this.editor.hasModel() && this.editor.getModel().uri;
533
if (editorURI && editorURI.toString() === e.resource.toString()) {
534
await this.setComments(e.commentInfos.filter(commentInfo => commentInfo !== null));
535
}
536
}));
537
538
this.globalToDispose.add(this.commentService.onDidChangeCommentingEnabled(e => {
539
if (e) {
540
this.registerEditorListeners();
541
this.beginCompute();
542
} else {
543
this.tryUpdateReservedSpace();
544
this.clearEditorListeners();
545
this._commentingRangeDecorator.update(this.editor, []);
546
this._commentThreadRangeDecorator.update(this.editor, []);
547
dispose(this._commentWidgets);
548
this._commentWidgets = [];
549
}
550
}));
551
552
this.globalToDispose.add(this.editor.onWillChangeModel(e => this.onWillChangeModel(e)));
553
this.globalToDispose.add(this.editor.onDidChangeModel(_ => this.onModelChanged()));
554
this.globalToDispose.add(this.configurationService.onDidChangeConfiguration(e => {
555
if (e.affectsConfiguration('diffEditor.renderSideBySide')) {
556
this.beginCompute();
557
}
558
}));
559
560
this.onModelChanged();
561
this.codeEditorService.registerDecorationType('comment-controller', COMMENTEDITOR_DECORATION_KEY, {});
562
this.globalToDispose.add(
563
this.commentService.registerContinueOnCommentProvider({
564
provideContinueOnComments: () => {
565
const pendingComments: languages.PendingCommentThread[] = [];
566
if (this._commentWidgets) {
567
for (const zone of this._commentWidgets) {
568
const zonePendingComments = zone.getPendingComments();
569
const pendingNewComment = zonePendingComments.newComment;
570
if (!pendingNewComment) {
571
continue;
572
}
573
let lastCommentBody;
574
if (zone.commentThread.comments && zone.commentThread.comments.length) {
575
const lastComment = zone.commentThread.comments[zone.commentThread.comments.length - 1];
576
if (typeof lastComment.body === 'string') {
577
lastCommentBody = lastComment.body;
578
} else {
579
lastCommentBody = lastComment.body.value;
580
}
581
}
582
583
if (pendingNewComment.body !== lastCommentBody) {
584
pendingComments.push({
585
uniqueOwner: zone.uniqueOwner,
586
uri: zone.editor.getModel()!.uri,
587
range: zone.commentThread.range,
588
comment: pendingNewComment,
589
isReply: (zone.commentThread.comments !== undefined) && (zone.commentThread.comments.length > 0)
590
});
591
}
592
}
593
}
594
return pendingComments;
595
}
596
})
597
);
598
599
}
600
601
private registerEditorListeners() {
602
this._editorDisposables = [];
603
if (!this.editor) {
604
return;
605
}
606
this._editorDisposables.push(this.editor.onMouseMove(e => this.onEditorMouseMove(e)));
607
this._editorDisposables.push(this.editor.onMouseLeave(() => this.onEditorMouseLeave()));
608
this._editorDisposables.push(this.editor.onDidChangeCursorPosition(e => this.onEditorChangeCursorPosition(e.position)));
609
this._editorDisposables.push(this.editor.onDidFocusEditorWidget(() => this.onEditorChangeCursorPosition(this.editor?.getPosition() ?? null)));
610
this._editorDisposables.push(this.editor.onDidChangeCursorSelection(e => this.onEditorChangeCursorSelection(e)));
611
this._editorDisposables.push(this.editor.onDidBlurEditorWidget(() => this.onEditorChangeCursorSelection()));
612
}
613
614
private clearEditorListeners() {
615
dispose(this._editorDisposables);
616
this._editorDisposables = [];
617
}
618
619
private onEditorMouseLeave() {
620
this._commentingRangeDecorator.updateHover();
621
}
622
623
private onEditorMouseMove(e: IEditorMouseEvent): void {
624
const position = e.target.position?.lineNumber;
625
if (e.event.leftButton.valueOf() && position && this.mouseDownInfo) {
626
this._commentingRangeDecorator.updateSelection(position, new Range(this.mouseDownInfo.lineNumber, 1, position, 1));
627
} else {
628
this._commentingRangeDecorator.updateHover(position);
629
}
630
}
631
632
private onEditorChangeCursorSelection(e?: ICursorSelectionChangedEvent): void {
633
const position = this.editor?.getPosition()?.lineNumber;
634
if (position) {
635
this._commentingRangeDecorator.updateSelection(position, e?.selection);
636
}
637
}
638
639
private onEditorChangeCursorPosition(e: Position | null) {
640
if (!e) {
641
return;
642
}
643
const range = Range.fromPositions(e, { column: -1, lineNumber: e.lineNumber });
644
const decorations = this.editor?.getDecorationsInRange(range);
645
let hasCommentingRange = false;
646
if (decorations) {
647
for (const decoration of decorations) {
648
if (decoration.options.description === CommentGlyphWidget.description) {
649
// We don't allow multiple comments on the same line.
650
hasCommentingRange = false;
651
break;
652
} else if (decoration.options.description === CommentingRangeDecorator.description) {
653
hasCommentingRange = true;
654
}
655
}
656
}
657
this._activeCursorHasCommentingRange.set(hasCommentingRange);
658
this._activeCursorHasComment.set(this.getCommentsAtLine(range).length > 0);
659
}
660
661
private isEditorInlineOriginal(testEditor: ICodeEditor): boolean {
662
if (this.configurationService.getValue<boolean>('diffEditor.renderSideBySide')) {
663
return false;
664
}
665
666
const foundEditor = this.editorService.visibleTextEditorControls.find(editor => {
667
if (editor.getEditorType() === EditorType.IDiffEditor) {
668
const diffEditor = editor as IDiffEditor;
669
return diffEditor.getOriginalEditor() === testEditor;
670
}
671
return false;
672
});
673
return !!foundEditor;
674
}
675
676
private beginCompute(): Promise<void> {
677
this._computePromise = createCancelablePromise(token => {
678
const editorURI = this.editor && this.editor.hasModel() && this.editor.getModel().uri;
679
680
if (editorURI) {
681
return this.commentService.getDocumentComments(editorURI);
682
}
683
684
return Promise.resolve([]);
685
});
686
687
this._computeAndSetPromise = this._computePromise.then(async commentInfos => {
688
await this.setComments(coalesce(commentInfos));
689
this._computePromise = null;
690
}, error => console.log(error));
691
this._computePromise.then(() => this._computeAndSetPromise = undefined);
692
return this._computeAndSetPromise;
693
}
694
695
private beginComputeCommentingRanges() {
696
if (this._computeCommentingRangeScheduler) {
697
if (this._computeCommentingRangePromise) {
698
this._computeCommentingRangePromise.cancel();
699
this._computeCommentingRangePromise = null;
700
}
701
702
this._computeCommentingRangeScheduler.trigger(() => {
703
const editorURI = this.editor && this.editor.hasModel() && this.editor.getModel().uri;
704
705
if (editorURI) {
706
return this.commentService.getDocumentComments(editorURI);
707
}
708
709
return Promise.resolve([]);
710
}).then(commentInfos => {
711
if (this.commentService.isCommentingEnabled) {
712
const meaningfulCommentInfos = coalesce(commentInfos);
713
this._commentingRangeDecorator.update(this.editor, meaningfulCommentInfos, this.editor?.getPosition()?.lineNumber, this.editor?.getSelection() ?? undefined);
714
}
715
}, (err) => {
716
onUnexpectedError(err);
717
return null;
718
});
719
}
720
}
721
722
public static get(editor: ICodeEditor): CommentController | null {
723
return editor.getContribution<CommentController>(ID);
724
}
725
726
public revealCommentThread(threadId: string, commentUniqueId: number | undefined, fetchOnceIfNotExist: boolean, focus: CommentWidgetFocus): void {
727
const commentThreadWidget = this._commentWidgets.filter(widget => widget.commentThread.threadId === threadId);
728
if (commentThreadWidget.length === 1) {
729
commentThreadWidget[0].reveal(commentUniqueId, focus);
730
} else if (fetchOnceIfNotExist) {
731
if (this._computeAndSetPromise) {
732
this._computeAndSetPromise.then(_ => {
733
this.revealCommentThread(threadId, commentUniqueId, false, focus);
734
});
735
} else {
736
this.beginCompute().then(_ => {
737
this.revealCommentThread(threadId, commentUniqueId, false, focus);
738
});
739
}
740
}
741
}
742
743
public collapseAll(): void {
744
for (const widget of this._commentWidgets) {
745
widget.collapse(true);
746
}
747
}
748
749
public expandAll(): void {
750
for (const widget of this._commentWidgets) {
751
widget.expand();
752
}
753
}
754
755
public expandUnresolved(): void {
756
for (const widget of this._commentWidgets) {
757
if (widget.commentThread.state === languages.CommentThreadState.Unresolved) {
758
widget.expand();
759
}
760
}
761
}
762
763
public nextCommentThread(focusThread: boolean): void {
764
this._findNearestCommentThread(focusThread);
765
}
766
767
private _findNearestCommentThread(focusThread: boolean, reverse?: boolean): void {
768
if (!this._commentWidgets.length || !this.editor?.hasModel()) {
769
return;
770
}
771
772
const after = reverse ? this.editor.getSelection().getStartPosition() : this.editor.getSelection().getEndPosition();
773
const sortedWidgets = this._commentWidgets.sort((a, b) => {
774
if (reverse) {
775
const temp = a;
776
a = b;
777
b = temp;
778
}
779
if (a.commentThread.range === undefined) {
780
return -1;
781
}
782
if (b.commentThread.range === undefined) {
783
return 1;
784
}
785
if (a.commentThread.range.startLineNumber < b.commentThread.range.startLineNumber) {
786
return -1;
787
}
788
789
if (a.commentThread.range.startLineNumber > b.commentThread.range.startLineNumber) {
790
return 1;
791
}
792
793
if (a.commentThread.range.startColumn < b.commentThread.range.startColumn) {
794
return -1;
795
}
796
797
if (a.commentThread.range.startColumn > b.commentThread.range.startColumn) {
798
return 1;
799
}
800
801
return 0;
802
});
803
804
const idx = findFirstIdxMonotonousOrArrLen(sortedWidgets, widget => {
805
const lineValueOne = reverse ? after.lineNumber : (widget.commentThread.range?.startLineNumber ?? 0);
806
const lineValueTwo = reverse ? (widget.commentThread.range?.startLineNumber ?? 0) : after.lineNumber;
807
const columnValueOne = reverse ? after.column : (widget.commentThread.range?.startColumn ?? 0);
808
const columnValueTwo = reverse ? (widget.commentThread.range?.startColumn ?? 0) : after.column;
809
if (lineValueOne > lineValueTwo) {
810
return true;
811
}
812
813
if (lineValueOne < lineValueTwo) {
814
return false;
815
}
816
817
if (columnValueOne > columnValueTwo) {
818
return true;
819
}
820
return false;
821
});
822
823
const nextWidget: ReviewZoneWidget | undefined = sortedWidgets[idx];
824
if (nextWidget !== undefined) {
825
this.editor.setSelection(nextWidget.commentThread.range ?? new Range(1, 1, 1, 1));
826
nextWidget.reveal(undefined, focusThread ? CommentWidgetFocus.Widget : CommentWidgetFocus.None);
827
}
828
}
829
830
public previousCommentThread(focusThread: boolean): void {
831
this._findNearestCommentThread(focusThread, true);
832
}
833
834
private _findNearestCommentingRange(reverse?: boolean): void {
835
if (!this.editor?.hasModel()) {
836
return;
837
}
838
839
const after = this.editor.getSelection().getEndPosition();
840
const range = this._commentingRangeDecorator.getNearestCommentingRange(after, reverse);
841
if (range) {
842
const position = reverse ? range.getEndPosition() : range.getStartPosition();
843
this.editor.setPosition(position);
844
this.editor.revealLineInCenterIfOutsideViewport(position.lineNumber);
845
}
846
if (this.accessibilityService.isScreenReaderOptimized()) {
847
const commentRangeStart = range?.getStartPosition().lineNumber;
848
const commentRangeEnd = range?.getEndPosition().lineNumber;
849
if (commentRangeStart && commentRangeEnd) {
850
const oneLine = commentRangeStart === commentRangeEnd;
851
oneLine ? status(nls.localize('commentRange', "Line {0}", commentRangeStart)) : status(nls.localize('commentRangeStart', "Lines {0} to {1}", commentRangeStart, commentRangeEnd));
852
}
853
}
854
}
855
856
public nextCommentingRange(): void {
857
this._findNearestCommentingRange();
858
}
859
860
public previousCommentingRange(): void {
861
this._findNearestCommentingRange(true);
862
}
863
864
public dispose(): void {
865
this.globalToDispose.dispose();
866
this.localToDispose.dispose();
867
dispose(this._editorDisposables);
868
dispose(this._commentWidgets);
869
870
this.editor = null!; // Strict null override - nulling out in dispose
871
}
872
873
private onWillChangeModel(e: IModelChangedEvent): void {
874
if (e.newModelUrl) {
875
this.tryUpdateReservedSpace(e.newModelUrl);
876
}
877
}
878
879
private async handleCommentAdded(editorId: string | undefined, uniqueOwner: string, thread: languages.AddedCommentThread): Promise<void> {
880
const matchedZones = this._commentWidgets.filter(zoneWidget => zoneWidget.uniqueOwner === uniqueOwner && zoneWidget.commentThread.threadId === thread.threadId);
881
if (matchedZones.length) {
882
return;
883
}
884
885
const matchedNewCommentThreadZones = this._commentWidgets.filter(zoneWidget => zoneWidget.uniqueOwner === uniqueOwner && zoneWidget.commentThread.commentThreadHandle === -1 && Range.equalsRange(zoneWidget.commentThread.range, thread.range));
886
887
if (matchedNewCommentThreadZones.length) {
888
matchedNewCommentThreadZones[0].update(thread);
889
return;
890
}
891
892
const continueOnCommentIndex = this._inProcessContinueOnComments.get(uniqueOwner)?.findIndex(pending => {
893
if (pending.range === undefined) {
894
return thread.range === undefined;
895
} else {
896
return Range.lift(pending.range).equalsRange(thread.range);
897
}
898
});
899
let continueOnCommentText: string | undefined;
900
if ((continueOnCommentIndex !== undefined) && continueOnCommentIndex >= 0) {
901
continueOnCommentText = this._inProcessContinueOnComments.get(uniqueOwner)?.splice(continueOnCommentIndex, 1)[0].comment.body;
902
}
903
904
const pendingCommentText = (this._pendingNewCommentCache[uniqueOwner] && this._pendingNewCommentCache[uniqueOwner][thread.threadId])
905
?? continueOnCommentText;
906
const pendingEdits = this._pendingEditsCache[uniqueOwner] && this._pendingEditsCache[uniqueOwner][thread.threadId];
907
const shouldReveal = thread.canReply && thread.isTemplate && (!thread.comments || (thread.comments.length === 0)) && (!thread.editorId || (thread.editorId === editorId));
908
await this.displayCommentThread(uniqueOwner, thread, shouldReveal, pendingCommentText, pendingEdits);
909
this._commentInfos.filter(info => info.uniqueOwner === uniqueOwner)[0].threads.push(thread);
910
this.tryUpdateReservedSpace();
911
}
912
913
public onModelChanged(): void {
914
this.localToDispose.clear();
915
this.tryUpdateReservedSpace();
916
917
this.removeCommentWidgetsAndStoreCache();
918
if (!this.editor) {
919
return;
920
}
921
922
this._hasRespondedToEditorChange = false;
923
924
this.localToDispose.add(this.editor.onMouseDown(e => this.onEditorMouseDown(e)));
925
this.localToDispose.add(this.editor.onMouseUp(e => this.onEditorMouseUp(e)));
926
if (this._editorDisposables.length) {
927
this.clearEditorListeners();
928
this.registerEditorListeners();
929
}
930
931
this._computeCommentingRangeScheduler = new Delayer<ICommentInfo[]>(200);
932
this.localToDispose.add({
933
dispose: () => {
934
this._computeCommentingRangeScheduler?.cancel();
935
this._computeCommentingRangeScheduler = null;
936
}
937
});
938
this.localToDispose.add(this.editor.onDidChangeModelContent(async () => {
939
this.beginComputeCommentingRanges();
940
}));
941
this.localToDispose.add(this.commentService.onDidUpdateCommentThreads(async e => {
942
const editorURI = this.editor && this.editor.hasModel() && this.editor.getModel().uri;
943
if (!editorURI || !this.commentService.isCommentingEnabled) {
944
return;
945
}
946
947
if (this._computePromise) {
948
await this._computePromise;
949
}
950
951
const commentInfo = this._commentInfos.filter(info => info.uniqueOwner === e.uniqueOwner);
952
if (!commentInfo || !commentInfo.length) {
953
return;
954
}
955
956
const added = e.added.filter(thread => thread.resource && thread.resource === editorURI.toString());
957
const removed = e.removed.filter(thread => thread.resource && thread.resource === editorURI.toString());
958
const changed = e.changed.filter(thread => thread.resource && thread.resource === editorURI.toString());
959
const pending = e.pending.filter(pending => pending.uri.toString() === editorURI.toString());
960
961
removed.forEach(thread => {
962
const matchedZones = this._commentWidgets.filter(zoneWidget => zoneWidget.uniqueOwner === e.uniqueOwner && zoneWidget.commentThread.threadId === thread.threadId && zoneWidget.commentThread.threadId !== '');
963
if (matchedZones.length) {
964
const matchedZone = matchedZones[0];
965
const index = this._commentWidgets.indexOf(matchedZone);
966
this._commentWidgets.splice(index, 1);
967
matchedZone.dispose();
968
}
969
const infosThreads = this._commentInfos.filter(info => info.uniqueOwner === e.uniqueOwner)[0].threads;
970
for (let i = 0; i < infosThreads.length; i++) {
971
if (infosThreads[i] === thread) {
972
infosThreads.splice(i, 1);
973
i--;
974
}
975
}
976
});
977
978
for (const thread of changed) {
979
const matchedZones = this._commentWidgets.filter(zoneWidget => zoneWidget.uniqueOwner === e.uniqueOwner && zoneWidget.commentThread.threadId === thread.threadId);
980
if (matchedZones.length) {
981
const matchedZone = matchedZones[0];
982
matchedZone.update(thread);
983
this.openCommentsView(thread);
984
}
985
}
986
const editorId = this.editor?.getId();
987
for (const thread of added) {
988
await this.handleCommentAdded(editorId, e.uniqueOwner, thread);
989
}
990
991
for (const thread of pending) {
992
await this.resumePendingComment(editorURI, thread);
993
}
994
this._commentThreadRangeDecorator.update(this.editor, commentInfo);
995
}));
996
997
this.beginComputeAndHandleEditorChange();
998
}
999
1000
private async resumePendingComment(editorURI: URI, thread: languages.PendingCommentThread) {
1001
const matchedZones = this._commentWidgets.filter(zoneWidget => zoneWidget.uniqueOwner === thread.uniqueOwner && Range.lift(zoneWidget.commentThread.range)?.equalsRange(thread.range));
1002
if (thread.isReply && matchedZones.length) {
1003
this.commentService.removeContinueOnComment({ uniqueOwner: thread.uniqueOwner, uri: editorURI, range: thread.range, isReply: true });
1004
matchedZones[0].setPendingComment(thread.comment);
1005
} else if (matchedZones.length) {
1006
this.commentService.removeContinueOnComment({ uniqueOwner: thread.uniqueOwner, uri: editorURI, range: thread.range, isReply: false });
1007
const existingPendingComment = matchedZones[0].getPendingComments().newComment;
1008
// We need to try to reconcile the existing pending comment with the incoming pending comment
1009
let pendingComment: languages.PendingComment;
1010
if (!existingPendingComment || thread.comment.body.includes(existingPendingComment.body)) {
1011
pendingComment = thread.comment;
1012
} else if (existingPendingComment.body.includes(thread.comment.body)) {
1013
pendingComment = existingPendingComment;
1014
} else {
1015
pendingComment = { body: `${existingPendingComment}\n${thread.comment.body}`, cursor: thread.comment.cursor };
1016
}
1017
matchedZones[0].setPendingComment(pendingComment);
1018
} else if (!thread.isReply) {
1019
const threadStillAvailable = this.commentService.removeContinueOnComment({ uniqueOwner: thread.uniqueOwner, uri: editorURI, range: thread.range, isReply: false });
1020
if (!threadStillAvailable) {
1021
return;
1022
}
1023
if (!this._inProcessContinueOnComments.has(thread.uniqueOwner)) {
1024
this._inProcessContinueOnComments.set(thread.uniqueOwner, []);
1025
}
1026
this._inProcessContinueOnComments.get(thread.uniqueOwner)?.push(thread);
1027
await this.commentService.createCommentThreadTemplate(thread.uniqueOwner, thread.uri, thread.range ? Range.lift(thread.range) : undefined);
1028
}
1029
}
1030
1031
private beginComputeAndHandleEditorChange(): void {
1032
this.beginCompute().then(() => {
1033
if (!this._hasRespondedToEditorChange) {
1034
if (this._commentInfos.some(commentInfo => commentInfo.commentingRanges.ranges.length > 0 || commentInfo.commentingRanges.fileComments)) {
1035
this._hasRespondedToEditorChange = true;
1036
const verbose = this.configurationService.getValue(AccessibilityVerbositySettingId.Comments);
1037
if (verbose) {
1038
const keybinding = this.keybindingService.lookupKeybinding(AccessibilityCommandId.OpenAccessibilityHelp)?.getAriaLabel();
1039
if (keybinding) {
1040
status(nls.localize('hasCommentRangesKb', "Editor has commenting ranges, run the command Open Accessibility Help ({0}), for more information.", keybinding));
1041
} else {
1042
status(nls.localize('hasCommentRangesNoKb', "Editor has commenting ranges, run the command Open Accessibility Help, which is currently not triggerable via keybinding, for more information."));
1043
}
1044
} else {
1045
status(nls.localize('hasCommentRanges', "Editor has commenting ranges."));
1046
}
1047
}
1048
}
1049
});
1050
}
1051
1052
private async openCommentsView(thread: languages.CommentThread) {
1053
if (thread.comments && (thread.comments.length > 0) && threadHasMeaningfulComments(thread)) {
1054
const openViewState = this.configurationService.getValue<ICommentsConfiguration>(COMMENTS_SECTION).openView;
1055
if (openViewState === 'file') {
1056
return this.viewsService.openView(COMMENTS_VIEW_ID);
1057
} else if (openViewState === 'firstFile' || (openViewState === 'firstFileUnresolved' && thread.state === languages.CommentThreadState.Unresolved)) {
1058
const hasShownView = this.viewsService.getViewWithId<CommentsPanel>(COMMENTS_VIEW_ID)?.hasRendered;
1059
if (!hasShownView) {
1060
return this.viewsService.openView(COMMENTS_VIEW_ID);
1061
}
1062
}
1063
}
1064
return undefined;
1065
}
1066
1067
private async displayCommentThread(uniqueOwner: string, thread: languages.CommentThread, shouldReveal: boolean, pendingComment: languages.PendingComment | undefined, pendingEdits: { [key: number]: languages.PendingComment } | undefined): Promise<void> {
1068
const editor = this.editor?.getModel();
1069
if (!editor) {
1070
return;
1071
}
1072
if (!this.editor || this.isEditorInlineOriginal(this.editor)) {
1073
return;
1074
}
1075
1076
let continueOnCommentReply: languages.PendingCommentThread | undefined;
1077
if (thread.range && !pendingComment) {
1078
continueOnCommentReply = this.commentService.removeContinueOnComment({ uniqueOwner, uri: editor.uri, range: thread.range, isReply: true });
1079
}
1080
const zoneWidget = this.instantiationService.createInstance(ReviewZoneWidget, this.editor, uniqueOwner, thread, pendingComment ?? continueOnCommentReply?.comment, pendingEdits);
1081
await zoneWidget.display(thread.range, shouldReveal);
1082
this._commentWidgets.push(zoneWidget);
1083
this.openCommentsView(thread);
1084
}
1085
1086
private onEditorMouseDown(e: IEditorMouseEvent): void {
1087
this.mouseDownInfo = this._activeEditorHasCommentingRange.get() ? parseMouseDownInfoFromEvent(e) : null;
1088
}
1089
1090
private onEditorMouseUp(e: IEditorMouseEvent): void {
1091
const matchedLineNumber = isMouseUpEventDragFromMouseDown(this.mouseDownInfo, e);
1092
this.mouseDownInfo = null;
1093
1094
if (!this.editor || matchedLineNumber === null || !e.target.element) {
1095
return;
1096
}
1097
const mouseUpIsOnDecorator = (e.target.element.className.indexOf('comment-range-glyph') >= 0);
1098
1099
const lineNumber = e.target.position!.lineNumber;
1100
let range: Range | undefined;
1101
let selection: Range | null | undefined;
1102
// Check for drag along gutter decoration
1103
if ((matchedLineNumber !== lineNumber)) {
1104
if (matchedLineNumber > lineNumber) {
1105
selection = new Range(matchedLineNumber, this.editor.getModel()!.getLineLength(matchedLineNumber) + 1, lineNumber, 1);
1106
} else {
1107
selection = new Range(matchedLineNumber, 1, lineNumber, this.editor.getModel()!.getLineLength(lineNumber) + 1);
1108
}
1109
} else if (mouseUpIsOnDecorator) {
1110
selection = this.editor.getSelection();
1111
}
1112
1113
// Check for selection at line number.
1114
if (selection && (selection.startLineNumber <= lineNumber) && (lineNumber <= selection.endLineNumber)) {
1115
range = selection;
1116
this.editor.setSelection(new Range(selection.endLineNumber, 1, selection.endLineNumber, 1));
1117
} else if (mouseUpIsOnDecorator) {
1118
range = new Range(lineNumber, 1, lineNumber, 1);
1119
}
1120
1121
if (range) {
1122
this.addOrToggleCommentAtLine(range, e);
1123
}
1124
}
1125
1126
public getCommentsAtLine(commentRange: Range | undefined): ReviewZoneWidget[] {
1127
return this._commentWidgets.filter(widget => widget.getGlyphPosition() === (commentRange ? commentRange.endLineNumber : 0));
1128
}
1129
1130
public async addOrToggleCommentAtLine(commentRange: Range | undefined, e: IEditorMouseEvent | undefined): Promise<void> {
1131
// If an add is already in progress, queue the next add and process it after the current one finishes to
1132
// prevent empty comment threads from being added to the same line.
1133
if (!this._addInProgress) {
1134
this._addInProgress = true;
1135
// The widget's position is undefined until the widget has been displayed, so rely on the glyph position instead
1136
const existingCommentsAtLine = this.getCommentsAtLine(commentRange);
1137
if (existingCommentsAtLine.length) {
1138
const allExpanded = existingCommentsAtLine.every(widget => widget.expanded);
1139
existingCommentsAtLine.forEach(allExpanded ? widget => widget.collapse(true) : widget => widget.expand(true));
1140
this.processNextThreadToAdd();
1141
return;
1142
} else {
1143
this.addCommentAtLine(commentRange, e);
1144
}
1145
} else {
1146
this._emptyThreadsToAddQueue.push([commentRange, e]);
1147
}
1148
}
1149
1150
private processNextThreadToAdd(): void {
1151
this._addInProgress = false;
1152
const info = this._emptyThreadsToAddQueue.shift();
1153
if (info) {
1154
this.addOrToggleCommentAtLine(info[0], info[1]);
1155
}
1156
}
1157
1158
private clipUserRangeToCommentRange(userRange: Range, commentRange: Range): Range {
1159
if (userRange.startLineNumber < commentRange.startLineNumber) {
1160
userRange = new Range(commentRange.startLineNumber, commentRange.startColumn, userRange.endLineNumber, userRange.endColumn);
1161
}
1162
if (userRange.endLineNumber > commentRange.endLineNumber) {
1163
userRange = new Range(userRange.startLineNumber, userRange.startColumn, commentRange.endLineNumber, commentRange.endColumn);
1164
}
1165
return userRange;
1166
}
1167
1168
public addCommentAtLine(range: Range | undefined, e: IEditorMouseEvent | undefined): Promise<void> {
1169
const newCommentInfos = this._commentingRangeDecorator.getMatchedCommentAction(range);
1170
if (!newCommentInfos.length || !this.editor?.hasModel()) {
1171
this._addInProgress = false;
1172
if (!newCommentInfos.length) {
1173
if (range) {
1174
this.notificationService.error(nls.localize('comments.addCommand.error', "The cursor must be within a commenting range to add a comment."));
1175
} else {
1176
this.notificationService.error(nls.localize('comments.addFileCommentCommand.error', "File comments are not allowed on this file."));
1177
}
1178
}
1179
return Promise.resolve();
1180
}
1181
1182
if (newCommentInfos.length > 1) {
1183
if (e && range) {
1184
this.contextMenuService.showContextMenu({
1185
getAnchor: () => e.event,
1186
getActions: () => this.getContextMenuActions(newCommentInfos, range),
1187
getActionsContext: () => newCommentInfos.length ? newCommentInfos[0] : undefined,
1188
onHide: () => { this._addInProgress = false; }
1189
});
1190
1191
return Promise.resolve();
1192
} else {
1193
const picks = this.getCommentProvidersQuickPicks(newCommentInfos);
1194
return this.quickInputService.pick(picks, { placeHolder: nls.localize('pickCommentService', "Select Comment Provider"), matchOnDescription: true }).then(pick => {
1195
if (!pick) {
1196
return;
1197
}
1198
1199
const commentInfos = newCommentInfos.filter(info => info.action.ownerId === pick.id);
1200
1201
if (commentInfos.length) {
1202
const { ownerId } = commentInfos[0].action;
1203
const clippedRange = range && commentInfos[0].range ? this.clipUserRangeToCommentRange(range, commentInfos[0].range) : range;
1204
this.addCommentAtLine2(clippedRange, ownerId);
1205
}
1206
}).then(() => {
1207
this._addInProgress = false;
1208
});
1209
}
1210
} else {
1211
const { ownerId } = newCommentInfos[0]!.action;
1212
const clippedRange = range && newCommentInfos[0].range ? this.clipUserRangeToCommentRange(range, newCommentInfos[0].range) : range;
1213
this.addCommentAtLine2(clippedRange, ownerId);
1214
}
1215
1216
return Promise.resolve();
1217
}
1218
1219
private getCommentProvidersQuickPicks(commentInfos: MergedCommentRangeActions[]) {
1220
const picks: QuickPickInput[] = commentInfos.map((commentInfo) => {
1221
const { ownerId, extensionId, label } = commentInfo.action;
1222
1223
return {
1224
label: label ?? extensionId ?? ownerId,
1225
id: ownerId
1226
} satisfies IQuickPickItem;
1227
});
1228
1229
return picks;
1230
}
1231
1232
private getContextMenuActions(commentInfos: MergedCommentRangeActions[], commentRange: Range): IAction[] {
1233
const actions: IAction[] = [];
1234
1235
commentInfos.forEach(commentInfo => {
1236
const { ownerId, extensionId, label } = commentInfo.action;
1237
1238
actions.push(new Action(
1239
'addCommentThread',
1240
`${label || extensionId}`,
1241
undefined,
1242
true,
1243
() => {
1244
const clippedRange = commentInfo.range ? this.clipUserRangeToCommentRange(commentRange, commentInfo.range) : commentRange;
1245
this.addCommentAtLine2(clippedRange, ownerId);
1246
return Promise.resolve();
1247
}
1248
));
1249
});
1250
return actions;
1251
}
1252
1253
public addCommentAtLine2(range: Range | undefined, ownerId: string) {
1254
if (!this.editor) {
1255
return;
1256
}
1257
this.commentService.createCommentThreadTemplate(ownerId, this.editor.getModel()!.uri, range, this.editor.getId());
1258
this.processNextThreadToAdd();
1259
return;
1260
}
1261
1262
private getExistingCommentEditorOptions(editor: ICodeEditor) {
1263
const lineDecorationsWidth: number = editor.getOption(EditorOption.lineDecorationsWidth);
1264
let extraEditorClassName: string[] = [];
1265
const configuredExtraClassName = editor.getRawOptions().extraEditorClassName;
1266
if (configuredExtraClassName) {
1267
extraEditorClassName = configuredExtraClassName.split(' ');
1268
}
1269
return { lineDecorationsWidth, extraEditorClassName };
1270
}
1271
1272
private getWithoutCommentsEditorOptions(editor: ICodeEditor, extraEditorClassName: string[], startingLineDecorationsWidth: number) {
1273
let lineDecorationsWidth = startingLineDecorationsWidth;
1274
const inlineCommentPos = extraEditorClassName.findIndex(name => name === 'inline-comment');
1275
if (inlineCommentPos >= 0) {
1276
extraEditorClassName.splice(inlineCommentPos, 1);
1277
}
1278
1279
const options = editor.getOptions();
1280
if (options.get(EditorOption.folding) && options.get(EditorOption.showFoldingControls) !== 'never') {
1281
lineDecorationsWidth += 11; // 11 comes from https://github.com/microsoft/vscode/blob/94ee5f58619d59170983f453fe78f156c0cc73a3/src/vs/workbench/contrib/comments/browser/media/review.css#L485
1282
}
1283
lineDecorationsWidth -= 24;
1284
return { extraEditorClassName, lineDecorationsWidth };
1285
}
1286
1287
private getWithCommentsLineDecorationWidth(editor: ICodeEditor, startingLineDecorationsWidth: number) {
1288
let lineDecorationsWidth = startingLineDecorationsWidth;
1289
const options = editor.getOptions();
1290
if (options.get(EditorOption.folding) && options.get(EditorOption.showFoldingControls) !== 'never') {
1291
lineDecorationsWidth -= 11;
1292
}
1293
lineDecorationsWidth += 24;
1294
this._commentingRangeAmountReserved = lineDecorationsWidth;
1295
return this._commentingRangeAmountReserved;
1296
}
1297
1298
private getWithCommentsEditorOptions(editor: ICodeEditor, extraEditorClassName: string[], startingLineDecorationsWidth: number) {
1299
extraEditorClassName.push('inline-comment');
1300
return { lineDecorationsWidth: this.getWithCommentsLineDecorationWidth(editor, startingLineDecorationsWidth), extraEditorClassName };
1301
}
1302
1303
private updateEditorLayoutOptions(editor: ICodeEditor, extraEditorClassName: string[], lineDecorationsWidth: number) {
1304
editor.updateOptions({
1305
extraEditorClassName: extraEditorClassName.join(' '),
1306
lineDecorationsWidth: lineDecorationsWidth
1307
});
1308
}
1309
1310
private ensureCommentingRangeReservedAmount(editor: ICodeEditor) {
1311
const existing = this.getExistingCommentEditorOptions(editor);
1312
if (existing.lineDecorationsWidth !== this._commentingRangeAmountReserved) {
1313
editor.updateOptions({
1314
lineDecorationsWidth: this.getWithCommentsLineDecorationWidth(editor, existing.lineDecorationsWidth)
1315
});
1316
}
1317
}
1318
1319
private tryUpdateReservedSpace(uri?: URI) {
1320
if (!this.editor) {
1321
return;
1322
}
1323
1324
const hasCommentsOrRangesInInfo = this._commentInfos.some(info => {
1325
const hasRanges = Boolean(info.commentingRanges && (Array.isArray(info.commentingRanges) ? info.commentingRanges : info.commentingRanges.ranges).length);
1326
return hasRanges || (info.threads.length > 0);
1327
});
1328
uri = uri ?? this.editor.getModel()?.uri;
1329
const resourceHasCommentingRanges = uri ? this.commentService.resourceHasCommentingRanges(uri) : false;
1330
1331
const hasCommentsOrRanges = hasCommentsOrRangesInInfo || resourceHasCommentingRanges;
1332
1333
if (hasCommentsOrRanges && this.commentService.isCommentingEnabled) {
1334
if (!this._commentingRangeSpaceReserved) {
1335
this._commentingRangeSpaceReserved = true;
1336
const { lineDecorationsWidth, extraEditorClassName } = this.getExistingCommentEditorOptions(this.editor);
1337
const newOptions = this.getWithCommentsEditorOptions(this.editor, extraEditorClassName, lineDecorationsWidth);
1338
this.updateEditorLayoutOptions(this.editor, newOptions.extraEditorClassName, newOptions.lineDecorationsWidth);
1339
} else {
1340
this.ensureCommentingRangeReservedAmount(this.editor);
1341
}
1342
} else if ((!hasCommentsOrRanges || !this.commentService.isCommentingEnabled) && this._commentingRangeSpaceReserved) {
1343
this._commentingRangeSpaceReserved = false;
1344
const { lineDecorationsWidth, extraEditorClassName } = this.getExistingCommentEditorOptions(this.editor);
1345
const newOptions = this.getWithoutCommentsEditorOptions(this.editor, extraEditorClassName, lineDecorationsWidth);
1346
this.updateEditorLayoutOptions(this.editor, newOptions.extraEditorClassName, newOptions.lineDecorationsWidth);
1347
}
1348
}
1349
1350
private async setComments(commentInfos: ICommentInfo[]): Promise<void> {
1351
if (!this.editor || !this.commentService.isCommentingEnabled) {
1352
return;
1353
}
1354
1355
this._commentInfos = commentInfos;
1356
this.tryUpdateReservedSpace();
1357
// create viewzones
1358
this.removeCommentWidgetsAndStoreCache();
1359
1360
let hasCommentingRanges = false;
1361
for (const info of this._commentInfos) {
1362
if (!hasCommentingRanges && (info.commentingRanges.ranges.length > 0 || info.commentingRanges.fileComments)) {
1363
hasCommentingRanges = true;
1364
}
1365
1366
const providerCacheStore = this._pendingNewCommentCache[info.uniqueOwner];
1367
const providerEditsCacheStore = this._pendingEditsCache[info.uniqueOwner];
1368
info.threads = info.threads.filter(thread => !thread.isDisposed);
1369
for (const thread of info.threads) {
1370
let pendingComment: languages.PendingComment | undefined = undefined;
1371
if (providerCacheStore) {
1372
pendingComment = providerCacheStore[thread.threadId];
1373
}
1374
1375
let pendingEdits: { [key: number]: languages.PendingComment } | undefined = undefined;
1376
if (providerEditsCacheStore) {
1377
pendingEdits = providerEditsCacheStore[thread.threadId];
1378
}
1379
1380
await this.displayCommentThread(info.uniqueOwner, thread, false, pendingComment, pendingEdits);
1381
}
1382
for (const thread of info.pendingCommentThreads ?? []) {
1383
this.resumePendingComment(this.editor!.getModel()!.uri, thread);
1384
}
1385
}
1386
1387
this._commentingRangeDecorator.update(this.editor, this._commentInfos);
1388
this._commentThreadRangeDecorator.update(this.editor, this._commentInfos);
1389
1390
if (hasCommentingRanges) {
1391
this._activeEditorHasCommentingRange.set(true);
1392
} else {
1393
this._activeEditorHasCommentingRange.set(false);
1394
}
1395
}
1396
1397
public collapseAndFocusRange(threadId: string): void {
1398
this._commentWidgets?.find(widget => widget.commentThread.threadId === threadId)?.collapseAndFocusRange();
1399
}
1400
1401
private removeCommentWidgetsAndStoreCache() {
1402
if (this._commentWidgets) {
1403
this._commentWidgets.forEach(zone => {
1404
const pendingComments = zone.getPendingComments();
1405
const pendingNewComment = pendingComments.newComment;
1406
const providerNewCommentCacheStore = this._pendingNewCommentCache[zone.uniqueOwner];
1407
1408
let lastCommentBody;
1409
if (zone.commentThread.comments && zone.commentThread.comments.length) {
1410
const lastComment = zone.commentThread.comments[zone.commentThread.comments.length - 1];
1411
if (typeof lastComment.body === 'string') {
1412
lastCommentBody = lastComment.body;
1413
} else {
1414
lastCommentBody = lastComment.body.value;
1415
}
1416
}
1417
if (pendingNewComment && (pendingNewComment.body !== lastCommentBody)) {
1418
if (!providerNewCommentCacheStore) {
1419
this._pendingNewCommentCache[zone.uniqueOwner] = {};
1420
}
1421
1422
this._pendingNewCommentCache[zone.uniqueOwner][zone.commentThread.threadId] = pendingNewComment;
1423
} else {
1424
if (providerNewCommentCacheStore) {
1425
delete providerNewCommentCacheStore[zone.commentThread.threadId];
1426
}
1427
}
1428
1429
const pendingEdits = pendingComments.edits;
1430
const providerEditsCacheStore = this._pendingEditsCache[zone.uniqueOwner];
1431
if (Object.keys(pendingEdits).length > 0) {
1432
if (!providerEditsCacheStore) {
1433
this._pendingEditsCache[zone.uniqueOwner] = {};
1434
}
1435
this._pendingEditsCache[zone.uniqueOwner][zone.commentThread.threadId] = pendingEdits;
1436
} else if (providerEditsCacheStore) {
1437
delete providerEditsCacheStore[zone.commentThread.threadId];
1438
}
1439
1440
zone.dispose();
1441
});
1442
}
1443
1444
this._commentWidgets = [];
1445
}
1446
}
1447
1448