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
5239 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 { Disposable, 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._onDidChangeDecorationsCount.dispose();
369
this.commentingRangeDecorations = [];
370
}
371
}
372
373
/**
374
* Navigate to the next or previous comment in the current thread.
375
* @param type
376
*/
377
export function moveToNextCommentInThread(commentInfo: { thread: languages.CommentThread<IRange>; comment?: languages.Comment } | undefined, type: 'next' | 'previous') {
378
if (!commentInfo?.comment || !commentInfo?.thread?.comments) {
379
return;
380
}
381
const currentIndex = commentInfo.thread.comments?.indexOf(commentInfo.comment);
382
if (currentIndex === undefined || currentIndex < 0) {
383
return;
384
}
385
if (type === 'previous' && currentIndex === 0) {
386
return;
387
}
388
if (type === 'next' && currentIndex === commentInfo.thread.comments.length - 1) {
389
return;
390
}
391
const comment = commentInfo.thread.comments?.[type === 'previous' ? currentIndex - 1 : currentIndex + 1];
392
if (!comment) {
393
return;
394
}
395
return {
396
...commentInfo,
397
comment,
398
};
399
}
400
401
export function revealCommentThread(commentService: ICommentService, editorService: IEditorService, uriIdentityService: IUriIdentityService,
402
commentThread: languages.CommentThread<IRange>, comment: languages.Comment | undefined, focusReply?: boolean, pinned?: boolean, preserveFocus?: boolean, sideBySide?: boolean): void {
403
if (!commentThread.resource) {
404
return;
405
}
406
if (!commentService.isCommentingEnabled) {
407
commentService.enableCommenting(true);
408
}
409
410
const range = commentThread.range;
411
const focus = focusReply ? CommentWidgetFocus.Editor : (preserveFocus ? CommentWidgetFocus.None : CommentWidgetFocus.Widget);
412
413
const activeEditor = editorService.activeTextEditorControl;
414
// If the active editor is a diff editor where one of the sides has the comment,
415
// then we try to reveal the comment in the diff editor.
416
const currentActiveResources: IEditor[] = isDiffEditor(activeEditor) ? [activeEditor.getOriginalEditor(), activeEditor.getModifiedEditor()]
417
: (activeEditor ? [activeEditor] : []);
418
const threadToReveal = commentThread.threadId;
419
const commentToReveal = comment?.uniqueIdInThread;
420
const resource = URI.parse(commentThread.resource);
421
422
for (const editor of currentActiveResources) {
423
const model = editor.getModel();
424
if ((model instanceof TextModel) && uriIdentityService.extUri.isEqual(resource, model.uri)) {
425
426
if (threadToReveal && isCodeEditor(editor)) {
427
const controller = CommentController.get(editor);
428
controller?.revealCommentThread(threadToReveal, commentToReveal, true, focus);
429
}
430
return;
431
}
432
}
433
434
editorService.openEditor({
435
resource,
436
options: {
437
pinned: pinned,
438
preserveFocus: preserveFocus,
439
selection: range ?? new Range(1, 1, 1, 1)
440
}
441
}, sideBySide ? SIDE_GROUP : ACTIVE_GROUP).then(editor => {
442
if (editor) {
443
const control = editor.getControl();
444
if (threadToReveal && isCodeEditor(control)) {
445
const controller = CommentController.get(control);
446
controller?.revealCommentThread(threadToReveal, commentToReveal, true, focus);
447
}
448
}
449
});
450
}
451
452
export class CommentController extends Disposable implements IEditorContribution {
453
private readonly localToDispose: DisposableStore = this._register(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 _computeCommentingRangeScheduler!: Delayer<Array<ICommentInfo | null>> | null;
467
private _pendingNewCommentCache: { [key: string]: { [key: string]: languages.PendingComment } };
468
private _pendingEditsCache: { [key: string]: { [key: string]: { [key: number]: languages.PendingComment } } }; // uniqueOwner -> threadId -> uniqueIdInThread -> pending comment
469
private _inProcessContinueOnComments: Map<string, languages.PendingCommentThread[]> = new Map();
470
private _editorDisposables: IDisposable[] = [];
471
private _activeCursorHasCommentingRange: IContextKey<boolean>;
472
private _activeCursorHasComment: IContextKey<boolean>;
473
private _activeEditorHasCommentingRange: IContextKey<boolean>;
474
private _commentWidgetVisible: 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
super();
493
this._commentInfos = [];
494
this._commentWidgets = [];
495
this._pendingNewCommentCache = {};
496
this._pendingEditsCache = {};
497
this._computePromise = null;
498
this._activeCursorHasCommentingRange = CommentContextKeys.activeCursorHasCommentingRange.bindTo(contextKeyService);
499
this._activeCursorHasComment = CommentContextKeys.activeCursorHasComment.bindTo(contextKeyService);
500
this._activeEditorHasCommentingRange = CommentContextKeys.activeEditorHasCommentingRange.bindTo(contextKeyService);
501
this._commentWidgetVisible = CommentContextKeys.commentWidgetVisible.bindTo(contextKeyService);
502
503
if (editor instanceof EmbeddedCodeEditorWidget) {
504
return;
505
}
506
507
this.editor = editor;
508
509
this._commentingRangeDecorator = new CommentingRangeDecorator();
510
this._register(this._commentingRangeDecorator.onDidChangeDecorationsCount(count => {
511
if (count === 0) {
512
this.clearEditorListeners();
513
} else if (this._editorDisposables.length === 0) {
514
this.registerEditorListeners();
515
}
516
}));
517
518
this._register(this._commentThreadRangeDecorator = new CommentThreadRangeDecorator(this.commentService));
519
520
this._register(this.commentService.onDidDeleteDataProvider(ownerId => {
521
if (ownerId) {
522
delete this._pendingNewCommentCache[ownerId];
523
delete this._pendingEditsCache[ownerId];
524
} else {
525
this._pendingNewCommentCache = {};
526
this._pendingEditsCache = {};
527
}
528
this.beginCompute();
529
}));
530
this._register(this.commentService.onDidSetDataProvider(_ => this.beginComputeAndHandleEditorChange()));
531
this._register(this.commentService.onDidUpdateCommentingRanges(_ => this.beginComputeAndHandleEditorChange()));
532
533
this._register(this.commentService.onDidSetResourceCommentInfos(async e => {
534
const editorURI = this.editor && this.editor.hasModel() && this.editor.getModel().uri;
535
if (editorURI && editorURI.toString() === e.resource.toString()) {
536
await this.setComments(e.commentInfos.filter(commentInfo => commentInfo !== null));
537
}
538
}));
539
540
this._register(this.commentService.onDidChangeCommentingEnabled(e => {
541
if (e) {
542
this.registerEditorListeners();
543
this.beginCompute();
544
} else {
545
this.tryUpdateReservedSpace();
546
this.clearEditorListeners();
547
this._commentingRangeDecorator.update(this.editor, []);
548
this._commentThreadRangeDecorator.update(this.editor, []);
549
dispose(this._commentWidgets);
550
this._commentWidgets = [];
551
}
552
}));
553
554
this._register(this.editor.onWillChangeModel(e => this.onWillChangeModel(e)));
555
this._register(this.editor.onDidChangeModel(_ => this.onModelChanged()));
556
this._register(this.configurationService.onDidChangeConfiguration(e => {
557
if (e.affectsConfiguration('diffEditor.renderSideBySide')) {
558
this.beginCompute();
559
}
560
}));
561
562
this.onModelChanged();
563
this._register(this.codeEditorService.registerDecorationType('comment-controller', COMMENTEDITOR_DECORATION_KEY, {}));
564
this._register(
565
this.commentService.registerContinueOnCommentProvider({
566
provideContinueOnComments: () => {
567
const pendingComments: languages.PendingCommentThread[] = [];
568
if (this._commentWidgets) {
569
for (const zone of this._commentWidgets) {
570
const zonePendingComments = zone.getPendingComments();
571
const pendingNewComment = zonePendingComments.newComment;
572
if (!pendingNewComment) {
573
continue;
574
}
575
let lastCommentBody;
576
if (zone.commentThread.comments && zone.commentThread.comments.length) {
577
const lastComment = zone.commentThread.comments[zone.commentThread.comments.length - 1];
578
if (typeof lastComment.body === 'string') {
579
lastCommentBody = lastComment.body;
580
} else {
581
lastCommentBody = lastComment.body.value;
582
}
583
}
584
585
if (pendingNewComment.body !== lastCommentBody) {
586
pendingComments.push({
587
uniqueOwner: zone.uniqueOwner,
588
uri: zone.editor.getModel()!.uri,
589
range: zone.commentThread.range,
590
comment: pendingNewComment,
591
isReply: (zone.commentThread.comments !== undefined) && (zone.commentThread.comments.length > 0)
592
});
593
}
594
}
595
}
596
return pendingComments;
597
}
598
})
599
);
600
601
}
602
603
private registerEditorListeners() {
604
this._editorDisposables = [];
605
if (!this.editor) {
606
return;
607
}
608
this._editorDisposables.push(this.editor.onMouseMove(e => this.onEditorMouseMove(e)));
609
this._editorDisposables.push(this.editor.onMouseLeave(() => this.onEditorMouseLeave()));
610
this._editorDisposables.push(this.editor.onDidChangeCursorPosition(e => this.onEditorChangeCursorPosition(e.position)));
611
this._editorDisposables.push(this.editor.onDidFocusEditorWidget(() => this.onEditorChangeCursorPosition(this.editor?.getPosition() ?? null)));
612
this._editorDisposables.push(this.editor.onDidChangeCursorSelection(e => this.onEditorChangeCursorSelection(e)));
613
this._editorDisposables.push(this.editor.onDidBlurEditorWidget(() => this.onEditorChangeCursorSelection()));
614
}
615
616
private clearEditorListeners() {
617
dispose(this._editorDisposables);
618
this._editorDisposables = [];
619
}
620
621
private onEditorMouseLeave() {
622
this._commentingRangeDecorator.updateHover();
623
}
624
625
private onEditorMouseMove(e: IEditorMouseEvent): void {
626
const position = e.target.position?.lineNumber;
627
if (e.event.leftButton.valueOf() && position && this.mouseDownInfo) {
628
this._commentingRangeDecorator.updateSelection(position, new Range(this.mouseDownInfo.lineNumber, 1, position, 1));
629
} else {
630
this._commentingRangeDecorator.updateHover(position);
631
}
632
}
633
634
private onEditorChangeCursorSelection(e?: ICursorSelectionChangedEvent): void {
635
const position = this.editor?.getPosition()?.lineNumber;
636
if (position) {
637
this._commentingRangeDecorator.updateSelection(position, e?.selection);
638
}
639
}
640
641
private onEditorChangeCursorPosition(e: Position | null) {
642
if (!e) {
643
return;
644
}
645
const range = Range.fromPositions(e, { column: -1, lineNumber: e.lineNumber });
646
const decorations = this.editor?.getDecorationsInRange(range);
647
let hasCommentingRange = false;
648
if (decorations) {
649
for (const decoration of decorations) {
650
if (decoration.options.description === CommentGlyphWidget.description) {
651
// We don't allow multiple comments on the same line.
652
hasCommentingRange = false;
653
break;
654
} else if (decoration.options.description === CommentingRangeDecorator.description) {
655
hasCommentingRange = true;
656
}
657
}
658
}
659
this._activeCursorHasCommentingRange.set(hasCommentingRange);
660
this._activeCursorHasComment.set(this.getCommentsAtLine(range).length > 0);
661
}
662
663
private isEditorInlineOriginal(testEditor: ICodeEditor): boolean {
664
if (this.configurationService.getValue<boolean>('diffEditor.renderSideBySide')) {
665
return false;
666
}
667
668
const foundEditor = this.editorService.visibleTextEditorControls.find(editor => {
669
if (editor.getEditorType() === EditorType.IDiffEditor) {
670
const diffEditor = editor as IDiffEditor;
671
return diffEditor.getOriginalEditor() === testEditor;
672
}
673
return false;
674
});
675
return !!foundEditor;
676
}
677
678
private beginCompute(): Promise<void> {
679
this._computePromise = createCancelablePromise(token => {
680
const editorURI = this.editor && this.editor.hasModel() && this.editor.getModel().uri;
681
682
if (editorURI) {
683
return this.commentService.getDocumentComments(editorURI);
684
}
685
686
return Promise.resolve([]);
687
});
688
689
this._computeAndSetPromise = this._computePromise.then(async commentInfos => {
690
await this.setComments(coalesce(commentInfos));
691
this._computePromise = null;
692
}, error => console.log(error));
693
this._computePromise.then(() => this._computeAndSetPromise = undefined);
694
return this._computeAndSetPromise;
695
}
696
697
private beginComputeCommentingRanges() {
698
if (this._computeCommentingRangeScheduler) {
699
this._computeCommentingRangeScheduler.trigger(() => {
700
const editorURI = this.editor && this.editor.hasModel() && this.editor.getModel().uri;
701
702
if (editorURI) {
703
return this.commentService.getDocumentComments(editorURI);
704
}
705
706
return Promise.resolve([]);
707
}).then(commentInfos => {
708
if (this.commentService.isCommentingEnabled) {
709
const meaningfulCommentInfos = coalesce(commentInfos);
710
this._commentingRangeDecorator.update(this.editor, meaningfulCommentInfos, this.editor?.getPosition()?.lineNumber, this.editor?.getSelection() ?? undefined);
711
}
712
}, (err) => {
713
onUnexpectedError(err);
714
return null;
715
});
716
}
717
}
718
719
public static get(editor: ICodeEditor): CommentController | null {
720
return editor.getContribution<CommentController>(ID);
721
}
722
723
public revealCommentThread(threadId: string, commentUniqueId: number | undefined, fetchOnceIfNotExist: boolean, focus: CommentWidgetFocus): void {
724
const commentThreadWidget = this._commentWidgets.filter(widget => widget.commentThread.threadId === threadId);
725
if (commentThreadWidget.length === 1) {
726
commentThreadWidget[0].reveal(commentUniqueId, focus);
727
} else if (fetchOnceIfNotExist) {
728
if (this._computeAndSetPromise) {
729
this._computeAndSetPromise.then(_ => {
730
this.revealCommentThread(threadId, commentUniqueId, false, focus);
731
});
732
} else {
733
this.beginCompute().then(_ => {
734
this.revealCommentThread(threadId, commentUniqueId, false, focus);
735
});
736
}
737
}
738
}
739
740
public collapseAll(): void {
741
for (const widget of this._commentWidgets) {
742
widget.collapse(true);
743
}
744
}
745
746
public async collapseVisibleComments(): Promise<void> {
747
if (!this.editor) {
748
return;
749
}
750
const visibleRanges = this.editor.getVisibleRanges();
751
for (const widget of this._commentWidgets) {
752
if (widget.expanded && widget.commentThread.range) {
753
const isVisible = visibleRanges.some(visibleRange =>
754
Range.areIntersectingOrTouching(visibleRange, widget.commentThread.range!)
755
);
756
if (isVisible) {
757
await widget.collapse(true);
758
}
759
}
760
}
761
}
762
763
private _updateCommentWidgetVisibleContext(): void {
764
const hasExpanded = this._commentWidgets.some(widget => widget.expanded);
765
this._commentWidgetVisible.set(hasExpanded);
766
}
767
768
public expandAll(): void {
769
for (const widget of this._commentWidgets) {
770
widget.expand();
771
}
772
}
773
774
public expandUnresolved(): void {
775
for (const widget of this._commentWidgets) {
776
if (widget.commentThread.state === languages.CommentThreadState.Unresolved) {
777
widget.expand();
778
}
779
}
780
}
781
782
public nextCommentThread(focusThread: boolean): void {
783
this._findNearestCommentThread(focusThread);
784
}
785
786
private _findNearestCommentThread(focusThread: boolean, reverse?: boolean): void {
787
if (!this._commentWidgets.length || !this.editor?.hasModel()) {
788
return;
789
}
790
791
const after = reverse ? this.editor.getSelection().getStartPosition() : this.editor.getSelection().getEndPosition();
792
const sortedWidgets = this._commentWidgets.sort((a, b) => {
793
if (reverse) {
794
const temp = a;
795
a = b;
796
b = temp;
797
}
798
if (a.commentThread.range === undefined) {
799
return -1;
800
}
801
if (b.commentThread.range === undefined) {
802
return 1;
803
}
804
if (a.commentThread.range.startLineNumber < b.commentThread.range.startLineNumber) {
805
return -1;
806
}
807
808
if (a.commentThread.range.startLineNumber > b.commentThread.range.startLineNumber) {
809
return 1;
810
}
811
812
if (a.commentThread.range.startColumn < b.commentThread.range.startColumn) {
813
return -1;
814
}
815
816
if (a.commentThread.range.startColumn > b.commentThread.range.startColumn) {
817
return 1;
818
}
819
820
return 0;
821
});
822
823
const idx = findFirstIdxMonotonousOrArrLen(sortedWidgets, widget => {
824
const lineValueOne = reverse ? after.lineNumber : (widget.commentThread.range?.startLineNumber ?? 0);
825
const lineValueTwo = reverse ? (widget.commentThread.range?.startLineNumber ?? 0) : after.lineNumber;
826
const columnValueOne = reverse ? after.column : (widget.commentThread.range?.startColumn ?? 0);
827
const columnValueTwo = reverse ? (widget.commentThread.range?.startColumn ?? 0) : after.column;
828
if (lineValueOne > lineValueTwo) {
829
return true;
830
}
831
832
if (lineValueOne < lineValueTwo) {
833
return false;
834
}
835
836
if (columnValueOne > columnValueTwo) {
837
return true;
838
}
839
return false;
840
});
841
842
const nextWidget: ReviewZoneWidget | undefined = sortedWidgets[idx];
843
if (nextWidget !== undefined) {
844
this.editor.setSelection(nextWidget.commentThread.range ?? new Range(1, 1, 1, 1));
845
nextWidget.reveal(undefined, focusThread ? CommentWidgetFocus.Widget : CommentWidgetFocus.None);
846
}
847
}
848
849
public previousCommentThread(focusThread: boolean): void {
850
this._findNearestCommentThread(focusThread, true);
851
}
852
853
private _findNearestCommentingRange(reverse?: boolean): void {
854
if (!this.editor?.hasModel()) {
855
return;
856
}
857
858
const after = this.editor.getSelection().getEndPosition();
859
const range = this._commentingRangeDecorator.getNearestCommentingRange(after, reverse);
860
if (range) {
861
const position = reverse ? range.getEndPosition() : range.getStartPosition();
862
this.editor.setPosition(position);
863
this.editor.revealLineInCenterIfOutsideViewport(position.lineNumber);
864
}
865
if (this.accessibilityService.isScreenReaderOptimized()) {
866
const commentRangeStart = range?.getStartPosition().lineNumber;
867
const commentRangeEnd = range?.getEndPosition().lineNumber;
868
if (commentRangeStart && commentRangeEnd) {
869
const oneLine = commentRangeStart === commentRangeEnd;
870
oneLine ? status(nls.localize('commentRange', "Line {0}", commentRangeStart)) : status(nls.localize('commentRangeStart', "Lines {0} to {1}", commentRangeStart, commentRangeEnd));
871
}
872
}
873
}
874
875
public nextCommentingRange(): void {
876
this._findNearestCommentingRange();
877
}
878
879
public previousCommentingRange(): void {
880
this._findNearestCommentingRange(true);
881
}
882
883
public override dispose(): void {
884
super.dispose();
885
dispose(this._editorDisposables);
886
dispose(this._commentWidgets);
887
888
this.editor = null!; // Strict null override - nulling out in dispose
889
}
890
891
private onWillChangeModel(e: IModelChangedEvent): void {
892
if (e.newModelUrl) {
893
this.tryUpdateReservedSpace(e.newModelUrl);
894
}
895
}
896
897
private async handleCommentAdded(editorId: string | undefined, uniqueOwner: string, thread: languages.AddedCommentThread): Promise<void> {
898
const matchedZones = this._commentWidgets.filter(zoneWidget => zoneWidget.uniqueOwner === uniqueOwner && zoneWidget.commentThread.threadId === thread.threadId);
899
if (matchedZones.length) {
900
return;
901
}
902
903
const matchedNewCommentThreadZones = this._commentWidgets.filter(zoneWidget => zoneWidget.uniqueOwner === uniqueOwner && zoneWidget.commentThread.commentThreadHandle === -1 && Range.equalsRange(zoneWidget.commentThread.range, thread.range));
904
905
if (matchedNewCommentThreadZones.length) {
906
matchedNewCommentThreadZones[0].update(thread);
907
return;
908
}
909
910
const continueOnCommentIndex = this._inProcessContinueOnComments.get(uniqueOwner)?.findIndex(pending => {
911
if (pending.range === undefined) {
912
return thread.range === undefined;
913
} else {
914
return Range.lift(pending.range).equalsRange(thread.range);
915
}
916
});
917
let continueOnCommentText: string | undefined;
918
if ((continueOnCommentIndex !== undefined) && continueOnCommentIndex >= 0) {
919
continueOnCommentText = this._inProcessContinueOnComments.get(uniqueOwner)?.splice(continueOnCommentIndex, 1)[0].comment.body;
920
}
921
922
const pendingCommentText = (this._pendingNewCommentCache[uniqueOwner] && this._pendingNewCommentCache[uniqueOwner][thread.threadId])
923
?? continueOnCommentText;
924
const pendingEdits = this._pendingEditsCache[uniqueOwner] && this._pendingEditsCache[uniqueOwner][thread.threadId];
925
const shouldReveal = thread.canReply && thread.isTemplate && (!thread.comments || (thread.comments.length === 0)) && (!thread.editorId || (thread.editorId === editorId));
926
await this.displayCommentThread(uniqueOwner, thread, shouldReveal, pendingCommentText, pendingEdits);
927
this._commentInfos.filter(info => info.uniqueOwner === uniqueOwner)[0].threads.push(thread);
928
this.tryUpdateReservedSpace();
929
}
930
931
public onModelChanged(): void {
932
this.localToDispose.clear();
933
this.tryUpdateReservedSpace();
934
935
this.removeCommentWidgetsAndStoreCache();
936
if (!this.editor) {
937
return;
938
}
939
940
this._hasRespondedToEditorChange = false;
941
942
this.localToDispose.add(this.editor.onMouseDown(e => this.onEditorMouseDown(e)));
943
this.localToDispose.add(this.editor.onMouseUp(e => this.onEditorMouseUp(e)));
944
if (this._editorDisposables.length) {
945
this.clearEditorListeners();
946
this.registerEditorListeners();
947
}
948
949
this._computeCommentingRangeScheduler = new Delayer<ICommentInfo[]>(200);
950
this.localToDispose.add({
951
dispose: () => {
952
this._computeCommentingRangeScheduler?.cancel();
953
this._computeCommentingRangeScheduler = null;
954
}
955
});
956
this.localToDispose.add(this.editor.onDidChangeModelContent(async () => {
957
this.beginComputeCommentingRanges();
958
}));
959
this.localToDispose.add(this.commentService.onDidUpdateCommentThreads(async e => {
960
const editorURI = this.editor && this.editor.hasModel() && this.editor.getModel().uri;
961
if (!editorURI || !this.commentService.isCommentingEnabled) {
962
return;
963
}
964
965
if (this._computePromise) {
966
await this._computePromise;
967
}
968
969
const commentInfo = this._commentInfos.filter(info => info.uniqueOwner === e.uniqueOwner);
970
if (!commentInfo || !commentInfo.length) {
971
return;
972
}
973
974
const added = e.added.filter(thread => thread.resource && thread.resource === editorURI.toString());
975
const removed = e.removed.filter(thread => thread.resource && thread.resource === editorURI.toString());
976
const changed = e.changed.filter(thread => thread.resource && thread.resource === editorURI.toString());
977
const pending = e.pending.filter(pending => pending.uri.toString() === editorURI.toString());
978
979
removed.forEach(thread => {
980
const matchedZones = this._commentWidgets.filter(zoneWidget => zoneWidget.uniqueOwner === e.uniqueOwner && zoneWidget.commentThread.threadId === thread.threadId && zoneWidget.commentThread.threadId !== '');
981
if (matchedZones.length) {
982
const matchedZone = matchedZones[0];
983
const index = this._commentWidgets.indexOf(matchedZone);
984
this._commentWidgets.splice(index, 1);
985
matchedZone.dispose();
986
}
987
const infosThreads = this._commentInfos.filter(info => info.uniqueOwner === e.uniqueOwner)[0].threads;
988
for (let i = 0; i < infosThreads.length; i++) {
989
if (infosThreads[i] === thread) {
990
infosThreads.splice(i, 1);
991
i--;
992
}
993
}
994
});
995
996
for (const thread of changed) {
997
const matchedZones = this._commentWidgets.filter(zoneWidget => zoneWidget.uniqueOwner === e.uniqueOwner && zoneWidget.commentThread.threadId === thread.threadId);
998
if (matchedZones.length) {
999
const matchedZone = matchedZones[0];
1000
matchedZone.update(thread);
1001
this.openCommentsView(thread);
1002
}
1003
}
1004
const editorId = this.editor?.getId();
1005
for (const thread of added) {
1006
await this.handleCommentAdded(editorId, e.uniqueOwner, thread);
1007
}
1008
1009
for (const thread of pending) {
1010
await this.resumePendingComment(editorURI, thread);
1011
}
1012
this._commentThreadRangeDecorator.update(this.editor, commentInfo);
1013
}));
1014
1015
this.beginComputeAndHandleEditorChange();
1016
}
1017
1018
private async resumePendingComment(editorURI: URI, thread: languages.PendingCommentThread) {
1019
const matchedZones = this._commentWidgets.filter(zoneWidget => zoneWidget.uniqueOwner === thread.uniqueOwner && Range.lift(zoneWidget.commentThread.range)?.equalsRange(thread.range));
1020
if (thread.isReply && matchedZones.length) {
1021
this.commentService.removeContinueOnComment({ uniqueOwner: thread.uniqueOwner, uri: editorURI, range: thread.range, isReply: true });
1022
matchedZones[0].setPendingComment(thread.comment);
1023
} else if (matchedZones.length) {
1024
this.commentService.removeContinueOnComment({ uniqueOwner: thread.uniqueOwner, uri: editorURI, range: thread.range, isReply: false });
1025
const existingPendingComment = matchedZones[0].getPendingComments().newComment;
1026
// We need to try to reconcile the existing pending comment with the incoming pending comment
1027
let pendingComment: languages.PendingComment;
1028
if (!existingPendingComment || thread.comment.body.includes(existingPendingComment.body)) {
1029
pendingComment = thread.comment;
1030
} else if (existingPendingComment.body.includes(thread.comment.body)) {
1031
pendingComment = existingPendingComment;
1032
} else {
1033
pendingComment = { body: `${existingPendingComment}\n${thread.comment.body}`, cursor: thread.comment.cursor };
1034
}
1035
matchedZones[0].setPendingComment(pendingComment);
1036
} else if (!thread.isReply) {
1037
const threadStillAvailable = this.commentService.removeContinueOnComment({ uniqueOwner: thread.uniqueOwner, uri: editorURI, range: thread.range, isReply: false });
1038
if (!threadStillAvailable) {
1039
return;
1040
}
1041
if (!this._inProcessContinueOnComments.has(thread.uniqueOwner)) {
1042
this._inProcessContinueOnComments.set(thread.uniqueOwner, []);
1043
}
1044
this._inProcessContinueOnComments.get(thread.uniqueOwner)?.push(thread);
1045
await this.commentService.createCommentThreadTemplate(thread.uniqueOwner, thread.uri, thread.range ? Range.lift(thread.range) : undefined);
1046
}
1047
}
1048
1049
private beginComputeAndHandleEditorChange(): void {
1050
this.beginCompute().then(() => {
1051
if (!this._hasRespondedToEditorChange) {
1052
if (this._commentInfos.some(commentInfo => commentInfo.commentingRanges.ranges.length > 0 || commentInfo.commentingRanges.fileComments)) {
1053
this._hasRespondedToEditorChange = true;
1054
const verbose = this.configurationService.getValue(AccessibilityVerbositySettingId.Comments);
1055
if (verbose) {
1056
const keybinding = this.keybindingService.lookupKeybinding(AccessibilityCommandId.OpenAccessibilityHelp)?.getAriaLabel();
1057
if (keybinding) {
1058
status(nls.localize('hasCommentRangesKb', "Editor has commenting ranges, run the command Open Accessibility Help ({0}), for more information.", keybinding));
1059
} else {
1060
status(nls.localize('hasCommentRangesNoKb', "Editor has commenting ranges, run the command Open Accessibility Help, which is currently not triggerable via keybinding, for more information."));
1061
}
1062
} else {
1063
status(nls.localize('hasCommentRanges', "Editor has commenting ranges."));
1064
}
1065
}
1066
}
1067
});
1068
}
1069
1070
private async openCommentsView(thread: languages.CommentThread) {
1071
if (thread.comments && (thread.comments.length > 0) && threadHasMeaningfulComments(thread)) {
1072
const openViewState = this.configurationService.getValue<ICommentsConfiguration>(COMMENTS_SECTION).openView;
1073
if (openViewState === 'file') {
1074
return this.viewsService.openView(COMMENTS_VIEW_ID);
1075
} else if (openViewState === 'firstFile' || (openViewState === 'firstFileUnresolved' && thread.state === languages.CommentThreadState.Unresolved)) {
1076
const hasShownView = this.viewsService.getViewWithId<CommentsPanel>(COMMENTS_VIEW_ID)?.hasRendered;
1077
if (!hasShownView) {
1078
return this.viewsService.openView(COMMENTS_VIEW_ID);
1079
}
1080
}
1081
}
1082
return undefined;
1083
}
1084
1085
private async displayCommentThread(uniqueOwner: string, thread: languages.CommentThread, shouldReveal: boolean, pendingComment: languages.PendingComment | undefined, pendingEdits: { [key: number]: languages.PendingComment } | undefined): Promise<void> {
1086
const editor = this.editor?.getModel();
1087
if (!editor) {
1088
return;
1089
}
1090
if (!this.editor || this.isEditorInlineOriginal(this.editor)) {
1091
return;
1092
}
1093
1094
let continueOnCommentReply: languages.PendingCommentThread | undefined;
1095
if (thread.range && !pendingComment) {
1096
continueOnCommentReply = this.commentService.removeContinueOnComment({ uniqueOwner, uri: editor.uri, range: thread.range, isReply: true });
1097
}
1098
const zoneWidget = this.instantiationService.createInstance(ReviewZoneWidget, this.editor, uniqueOwner, thread, pendingComment ?? continueOnCommentReply?.comment, pendingEdits);
1099
await zoneWidget.display(thread.range, shouldReveal);
1100
this._commentWidgets.push(zoneWidget);
1101
this.localToDispose.add(zoneWidget.onDidChangeExpandedState(() => this._updateCommentWidgetVisibleContext()));
1102
this.localToDispose.add(zoneWidget.onDidClose(() => this._updateCommentWidgetVisibleContext()));
1103
this.openCommentsView(thread);
1104
}
1105
1106
private onEditorMouseDown(e: IEditorMouseEvent): void {
1107
this.mouseDownInfo = (e.target.element?.className.indexOf('comment-range-glyph') ?? -1) >= 0 ? parseMouseDownInfoFromEvent(e) : null;
1108
}
1109
1110
private onEditorMouseUp(e: IEditorMouseEvent): void {
1111
const matchedLineNumber = isMouseUpEventDragFromMouseDown(this.mouseDownInfo, e);
1112
this.mouseDownInfo = null;
1113
1114
if (!this.editor || matchedLineNumber === null || !e.target.element) {
1115
return;
1116
}
1117
const mouseUpIsOnDecorator = (e.target.element.className.indexOf('comment-range-glyph') >= 0);
1118
1119
const lineNumber = e.target.position!.lineNumber;
1120
let range: Range | undefined;
1121
let selection: Range | null | undefined;
1122
// Check for drag along gutter decoration
1123
if ((matchedLineNumber !== lineNumber)) {
1124
if (matchedLineNumber > lineNumber) {
1125
selection = new Range(matchedLineNumber, this.editor.getModel()!.getLineLength(matchedLineNumber) + 1, lineNumber, 1);
1126
} else {
1127
selection = new Range(matchedLineNumber, 1, lineNumber, this.editor.getModel()!.getLineLength(lineNumber) + 1);
1128
}
1129
} else if (mouseUpIsOnDecorator) {
1130
selection = this.editor.getSelection();
1131
}
1132
1133
// Check for selection at line number.
1134
if (selection && (selection.startLineNumber <= lineNumber) && (lineNumber <= selection.endLineNumber)) {
1135
range = selection;
1136
this.editor.setSelection(new Range(selection.endLineNumber, 1, selection.endLineNumber, 1));
1137
} else if (mouseUpIsOnDecorator) {
1138
range = new Range(lineNumber, 1, lineNumber, 1);
1139
}
1140
1141
if (range) {
1142
this.addOrToggleCommentAtLine(range, e);
1143
}
1144
}
1145
1146
public getCommentsAtLine(commentRange: Range | undefined): ReviewZoneWidget[] {
1147
return this._commentWidgets.filter(widget => widget.getGlyphPosition() === (commentRange ? commentRange.endLineNumber : 0));
1148
}
1149
1150
public async addOrToggleCommentAtLine(commentRange: Range | undefined, e: IEditorMouseEvent | undefined): Promise<void> {
1151
// If an add is already in progress, queue the next add and process it after the current one finishes to
1152
// prevent empty comment threads from being added to the same line.
1153
if (!this._addInProgress) {
1154
this._addInProgress = true;
1155
// The widget's position is undefined until the widget has been displayed, so rely on the glyph position instead
1156
const existingCommentsAtLine = this.getCommentsAtLine(commentRange);
1157
if (existingCommentsAtLine.length) {
1158
const allExpanded = existingCommentsAtLine.every(widget => widget.expanded);
1159
existingCommentsAtLine.forEach(allExpanded ? widget => widget.collapse(true) : widget => widget.expand(true));
1160
this.processNextThreadToAdd();
1161
return;
1162
} else {
1163
this.addCommentAtLine(commentRange, e);
1164
}
1165
} else {
1166
this._emptyThreadsToAddQueue.push([commentRange, e]);
1167
}
1168
}
1169
1170
private processNextThreadToAdd(): void {
1171
this._addInProgress = false;
1172
const info = this._emptyThreadsToAddQueue.shift();
1173
if (info) {
1174
this.addOrToggleCommentAtLine(info[0], info[1]);
1175
}
1176
}
1177
1178
private clipUserRangeToCommentRange(userRange: Range, commentRange: Range): Range {
1179
if (userRange.startLineNumber < commentRange.startLineNumber) {
1180
userRange = new Range(commentRange.startLineNumber, commentRange.startColumn, userRange.endLineNumber, userRange.endColumn);
1181
}
1182
if (userRange.endLineNumber > commentRange.endLineNumber) {
1183
userRange = new Range(userRange.startLineNumber, userRange.startColumn, commentRange.endLineNumber, commentRange.endColumn);
1184
}
1185
return userRange;
1186
}
1187
1188
public addCommentAtLine(range: Range | undefined, e: IEditorMouseEvent | undefined): Promise<void> {
1189
const newCommentInfos = this._commentingRangeDecorator.getMatchedCommentAction(range);
1190
if (!newCommentInfos.length || !this.editor?.hasModel()) {
1191
this._addInProgress = false;
1192
if (!newCommentInfos.length) {
1193
if (range) {
1194
this.notificationService.error(nls.localize('comments.addCommand.error', "The cursor must be within a commenting range to add a comment."));
1195
} else {
1196
this.notificationService.error(nls.localize('comments.addFileCommentCommand.error', "File comments are not allowed on this file."));
1197
}
1198
}
1199
return Promise.resolve();
1200
}
1201
1202
if (newCommentInfos.length > 1) {
1203
if (e && range) {
1204
this.contextMenuService.showContextMenu({
1205
getAnchor: () => e.event,
1206
getActions: () => this.getContextMenuActions(newCommentInfos, range),
1207
getActionsContext: () => newCommentInfos.length ? newCommentInfos[0] : undefined,
1208
onHide: () => { this._addInProgress = false; }
1209
});
1210
1211
return Promise.resolve();
1212
} else {
1213
const picks = this.getCommentProvidersQuickPicks(newCommentInfos);
1214
return this.quickInputService.pick(picks, { placeHolder: nls.localize('pickCommentService', "Select Comment Provider"), matchOnDescription: true }).then(pick => {
1215
if (!pick) {
1216
return;
1217
}
1218
1219
const commentInfos = newCommentInfos.filter(info => info.action.ownerId === pick.id);
1220
1221
if (commentInfos.length) {
1222
const { ownerId } = commentInfos[0].action;
1223
const clippedRange = range && commentInfos[0].range ? this.clipUserRangeToCommentRange(range, commentInfos[0].range) : range;
1224
this.addCommentAtLine2(clippedRange, ownerId);
1225
}
1226
}).then(() => {
1227
this._addInProgress = false;
1228
});
1229
}
1230
} else {
1231
const { ownerId } = newCommentInfos[0].action;
1232
const clippedRange = range && newCommentInfos[0].range ? this.clipUserRangeToCommentRange(range, newCommentInfos[0].range) : range;
1233
this.addCommentAtLine2(clippedRange, ownerId);
1234
}
1235
1236
return Promise.resolve();
1237
}
1238
1239
private getCommentProvidersQuickPicks(commentInfos: MergedCommentRangeActions[]) {
1240
const picks: QuickPickInput[] = commentInfos.map((commentInfo) => {
1241
const { ownerId, extensionId, label } = commentInfo.action;
1242
1243
return {
1244
label: label ?? extensionId ?? ownerId,
1245
id: ownerId
1246
} satisfies IQuickPickItem;
1247
});
1248
1249
return picks;
1250
}
1251
1252
private getContextMenuActions(commentInfos: MergedCommentRangeActions[], commentRange: Range): IAction[] {
1253
const actions: IAction[] = [];
1254
1255
commentInfos.forEach(commentInfo => {
1256
const { ownerId, extensionId, label } = commentInfo.action;
1257
1258
actions.push(new Action(
1259
'addCommentThread',
1260
`${label || extensionId}`,
1261
undefined,
1262
true,
1263
() => {
1264
const clippedRange = commentInfo.range ? this.clipUserRangeToCommentRange(commentRange, commentInfo.range) : commentRange;
1265
this.addCommentAtLine2(clippedRange, ownerId);
1266
return Promise.resolve();
1267
}
1268
));
1269
});
1270
return actions;
1271
}
1272
1273
public addCommentAtLine2(range: Range | undefined, ownerId: string) {
1274
if (!this.editor) {
1275
return;
1276
}
1277
this.commentService.createCommentThreadTemplate(ownerId, this.editor.getModel()!.uri, range, this.editor.getId());
1278
this.processNextThreadToAdd();
1279
return;
1280
}
1281
1282
private getExistingCommentEditorOptions(editor: ICodeEditor) {
1283
const lineDecorationsWidth: number = editor.getOption(EditorOption.lineDecorationsWidth);
1284
let extraEditorClassName: string[] = [];
1285
const configuredExtraClassName = editor.getRawOptions().extraEditorClassName;
1286
if (configuredExtraClassName) {
1287
extraEditorClassName = configuredExtraClassName.split(' ');
1288
}
1289
return { lineDecorationsWidth, extraEditorClassName };
1290
}
1291
1292
private getWithoutCommentsEditorOptions(editor: ICodeEditor, extraEditorClassName: string[], startingLineDecorationsWidth: number) {
1293
let lineDecorationsWidth = startingLineDecorationsWidth;
1294
const inlineCommentPos = extraEditorClassName.findIndex(name => name === 'inline-comment');
1295
if (inlineCommentPos >= 0) {
1296
extraEditorClassName.splice(inlineCommentPos, 1);
1297
}
1298
1299
const options = editor.getOptions();
1300
if (options.get(EditorOption.folding) && options.get(EditorOption.showFoldingControls) !== 'never') {
1301
lineDecorationsWidth += 11; // 11 comes from https://github.com/microsoft/vscode/blob/94ee5f58619d59170983f453fe78f156c0cc73a3/src/vs/workbench/contrib/comments/browser/media/review.css#L485
1302
}
1303
lineDecorationsWidth -= 24;
1304
return { extraEditorClassName, lineDecorationsWidth };
1305
}
1306
1307
private getWithCommentsLineDecorationWidth(editor: ICodeEditor, startingLineDecorationsWidth: number) {
1308
let lineDecorationsWidth = startingLineDecorationsWidth;
1309
const options = editor.getOptions();
1310
if (options.get(EditorOption.folding) && options.get(EditorOption.showFoldingControls) !== 'never') {
1311
lineDecorationsWidth -= 11;
1312
}
1313
lineDecorationsWidth += 24;
1314
this._commentingRangeAmountReserved = lineDecorationsWidth;
1315
return this._commentingRangeAmountReserved;
1316
}
1317
1318
private getWithCommentsEditorOptions(editor: ICodeEditor, extraEditorClassName: string[], startingLineDecorationsWidth: number) {
1319
extraEditorClassName.push('inline-comment');
1320
return { lineDecorationsWidth: this.getWithCommentsLineDecorationWidth(editor, startingLineDecorationsWidth), extraEditorClassName };
1321
}
1322
1323
private updateEditorLayoutOptions(editor: ICodeEditor, extraEditorClassName: string[], lineDecorationsWidth: number) {
1324
editor.updateOptions({
1325
extraEditorClassName: extraEditorClassName.join(' '),
1326
lineDecorationsWidth: lineDecorationsWidth
1327
});
1328
}
1329
1330
private ensureCommentingRangeReservedAmount(editor: ICodeEditor) {
1331
const existing = this.getExistingCommentEditorOptions(editor);
1332
if (existing.lineDecorationsWidth !== this._commentingRangeAmountReserved) {
1333
editor.updateOptions({
1334
lineDecorationsWidth: this.getWithCommentsLineDecorationWidth(editor, existing.lineDecorationsWidth)
1335
});
1336
}
1337
}
1338
1339
private tryUpdateReservedSpace(uri?: URI) {
1340
if (!this.editor) {
1341
return;
1342
}
1343
1344
const hasCommentsOrRangesInInfo = this._commentInfos.some(info => {
1345
const hasRanges = Boolean(info.commentingRanges && (Array.isArray(info.commentingRanges) ? info.commentingRanges : info.commentingRanges.ranges).length);
1346
return hasRanges || (info.threads.length > 0);
1347
});
1348
uri = uri ?? this.editor.getModel()?.uri;
1349
const resourceHasCommentingRanges = uri ? this.commentService.resourceHasCommentingRanges(uri) : false;
1350
1351
const hasCommentsOrRanges = hasCommentsOrRangesInInfo || resourceHasCommentingRanges;
1352
1353
if (hasCommentsOrRanges && this.commentService.isCommentingEnabled) {
1354
if (!this._commentingRangeSpaceReserved) {
1355
this._commentingRangeSpaceReserved = true;
1356
const { lineDecorationsWidth, extraEditorClassName } = this.getExistingCommentEditorOptions(this.editor);
1357
const newOptions = this.getWithCommentsEditorOptions(this.editor, extraEditorClassName, lineDecorationsWidth);
1358
this.updateEditorLayoutOptions(this.editor, newOptions.extraEditorClassName, newOptions.lineDecorationsWidth);
1359
} else {
1360
this.ensureCommentingRangeReservedAmount(this.editor);
1361
}
1362
} else if ((!hasCommentsOrRanges || !this.commentService.isCommentingEnabled) && this._commentingRangeSpaceReserved) {
1363
this._commentingRangeSpaceReserved = false;
1364
const { lineDecorationsWidth, extraEditorClassName } = this.getExistingCommentEditorOptions(this.editor);
1365
const newOptions = this.getWithoutCommentsEditorOptions(this.editor, extraEditorClassName, lineDecorationsWidth);
1366
this.updateEditorLayoutOptions(this.editor, newOptions.extraEditorClassName, newOptions.lineDecorationsWidth);
1367
}
1368
}
1369
1370
private async setComments(commentInfos: ICommentInfo[]): Promise<void> {
1371
if (!this.editor || !this.commentService.isCommentingEnabled) {
1372
return;
1373
}
1374
1375
this._commentInfos = commentInfos;
1376
this.tryUpdateReservedSpace();
1377
// create viewzones
1378
this.removeCommentWidgetsAndStoreCache();
1379
1380
let hasCommentingRanges = false;
1381
for (const info of this._commentInfos) {
1382
if (!hasCommentingRanges && (info.commentingRanges.ranges.length > 0 || info.commentingRanges.fileComments)) {
1383
hasCommentingRanges = true;
1384
}
1385
1386
const providerCacheStore = this._pendingNewCommentCache[info.uniqueOwner];
1387
const providerEditsCacheStore = this._pendingEditsCache[info.uniqueOwner];
1388
info.threads = info.threads.filter(thread => !thread.isDisposed);
1389
for (const thread of info.threads) {
1390
let pendingComment: languages.PendingComment | undefined = undefined;
1391
if (providerCacheStore) {
1392
pendingComment = providerCacheStore[thread.threadId];
1393
}
1394
1395
let pendingEdits: { [key: number]: languages.PendingComment } | undefined = undefined;
1396
if (providerEditsCacheStore) {
1397
pendingEdits = providerEditsCacheStore[thread.threadId];
1398
}
1399
1400
await this.displayCommentThread(info.uniqueOwner, thread, false, pendingComment, pendingEdits);
1401
}
1402
for (const thread of info.pendingCommentThreads ?? []) {
1403
this.resumePendingComment(this.editor.getModel()!.uri, thread);
1404
}
1405
}
1406
1407
this._commentingRangeDecorator.update(this.editor, this._commentInfos);
1408
this._commentThreadRangeDecorator.update(this.editor, this._commentInfos);
1409
1410
if (hasCommentingRanges) {
1411
this._activeEditorHasCommentingRange.set(true);
1412
} else {
1413
this._activeEditorHasCommentingRange.set(false);
1414
}
1415
}
1416
1417
public collapseAndFocusRange(threadId: string): void {
1418
this._commentWidgets?.find(widget => widget.commentThread.threadId === threadId)?.collapseAndFocusRange();
1419
}
1420
1421
private removeCommentWidgetsAndStoreCache() {
1422
if (this._commentWidgets) {
1423
this._commentWidgets.forEach(zone => {
1424
const pendingComments = zone.getPendingComments();
1425
const pendingNewComment = pendingComments.newComment;
1426
const providerNewCommentCacheStore = this._pendingNewCommentCache[zone.uniqueOwner];
1427
1428
let lastCommentBody;
1429
if (zone.commentThread.comments && zone.commentThread.comments.length) {
1430
const lastComment = zone.commentThread.comments[zone.commentThread.comments.length - 1];
1431
if (typeof lastComment.body === 'string') {
1432
lastCommentBody = lastComment.body;
1433
} else {
1434
lastCommentBody = lastComment.body.value;
1435
}
1436
}
1437
if (pendingNewComment && (pendingNewComment.body !== lastCommentBody)) {
1438
if (!providerNewCommentCacheStore) {
1439
this._pendingNewCommentCache[zone.uniqueOwner] = {};
1440
}
1441
1442
this._pendingNewCommentCache[zone.uniqueOwner][zone.commentThread.threadId] = pendingNewComment;
1443
} else {
1444
if (providerNewCommentCacheStore) {
1445
delete providerNewCommentCacheStore[zone.commentThread.threadId];
1446
}
1447
}
1448
1449
const pendingEdits = pendingComments.edits;
1450
const providerEditsCacheStore = this._pendingEditsCache[zone.uniqueOwner];
1451
if (Object.keys(pendingEdits).length > 0) {
1452
if (!providerEditsCacheStore) {
1453
this._pendingEditsCache[zone.uniqueOwner] = {};
1454
}
1455
this._pendingEditsCache[zone.uniqueOwner][zone.commentThread.threadId] = pendingEdits;
1456
} else if (providerEditsCacheStore) {
1457
delete providerEditsCacheStore[zone.commentThread.threadId];
1458
}
1459
1460
zone.dispose();
1461
});
1462
}
1463
1464
this._commentWidgets = [];
1465
}
1466
}
1467
1468