Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/workbench/contrib/comments/browser/commentThreadWidget.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 './media/review.css';
7
import * as dom from '../../../../base/browser/dom.js';
8
import * as domStylesheets from '../../../../base/browser/domStylesheets.js';
9
import { Emitter } from '../../../../base/common/event.js';
10
import { Disposable, dispose, IDisposable, toDisposable } from '../../../../base/common/lifecycle.js';
11
import { URI } from '../../../../base/common/uri.js';
12
import * as languages from '../../../../editor/common/languages.js';
13
import { IMarkdownRendererOptions } from '../../../../editor/browser/widget/markdownRenderer/browser/markdownRenderer.js';
14
import { IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js';
15
import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';
16
import { CommentMenus } from './commentMenus.js';
17
import { CommentReply } from './commentReply.js';
18
import { ICommentService } from './commentService.js';
19
import { CommentThreadBody } from './commentThreadBody.js';
20
import { CommentThreadHeader } from './commentThreadHeader.js';
21
import { CommentThreadAdditionalActions } from './commentThreadAdditionalActions.js';
22
import { CommentContextKeys } from '../common/commentContextKeys.js';
23
import { ICommentThreadWidget } from '../common/commentThreadWidget.js';
24
import { IColorTheme } from '../../../../platform/theme/common/themeService.js';
25
import { contrastBorder, focusBorder, inputValidationErrorBackground, inputValidationErrorBorder, inputValidationErrorForeground, textBlockQuoteBackground, textBlockQuoteBorder, textLinkActiveForeground, textLinkForeground } from '../../../../platform/theme/common/colorRegistry.js';
26
import { PANEL_BORDER } from '../../../common/theme.js';
27
import { IRange, Range } from '../../../../editor/common/core/range.js';
28
import { commentThreadStateBackgroundColorVar, commentThreadStateColorVar } from './commentColors.js';
29
import { ICellRange } from '../../notebook/common/notebookRange.js';
30
import { FontInfo } from '../../../../editor/common/config/fontInfo.js';
31
import { registerNavigableContainer } from '../../../browser/actions/widgetNavigationCommands.js';
32
import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';
33
import { COMMENTS_SECTION, ICommentsConfiguration } from '../common/commentsConfiguration.js';
34
import { localize } from '../../../../nls.js';
35
import { AccessibilityVerbositySettingId } from '../../accessibility/browser/accessibilityConfiguration.js';
36
import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js';
37
import { AccessibilityCommandId } from '../../accessibility/common/accessibilityCommands.js';
38
import { LayoutableEditor } from './simpleCommentEditor.js';
39
import { isCodeEditor } from '../../../../editor/browser/editorBrowser.js';
40
41
export const COMMENTEDITOR_DECORATION_KEY = 'commenteditordecoration';
42
43
export class CommentThreadWidget<T extends IRange | ICellRange = IRange> extends Disposable implements ICommentThreadWidget {
44
private _header!: CommentThreadHeader<T>;
45
private _body: CommentThreadBody<T>;
46
private _commentReply?: CommentReply<T>;
47
private _additionalActions?: CommentThreadAdditionalActions<T>;
48
private _commentMenus: CommentMenus;
49
private _commentThreadDisposables: IDisposable[] = [];
50
private _threadIsEmpty: IContextKey<boolean>;
51
private _styleElement: HTMLStyleElement;
52
private _commentThreadContextValue: IContextKey<string | undefined>;
53
private _focusedContextKey: IContextKey<boolean>;
54
private _onDidResize = new Emitter<dom.Dimension>();
55
onDidResize = this._onDidResize.event;
56
57
private _commentThreadState: languages.CommentThreadState | undefined;
58
59
get commentThread() {
60
return this._commentThread;
61
}
62
constructor(
63
readonly container: HTMLElement,
64
readonly _parentEditor: LayoutableEditor,
65
private _owner: string,
66
private _parentResourceUri: URI,
67
private _contextKeyService: IContextKeyService,
68
private _scopedInstantiationService: IInstantiationService,
69
private _commentThread: languages.CommentThread<T>,
70
private _pendingComment: languages.PendingComment | undefined,
71
private _pendingEdits: { [key: number]: languages.PendingComment } | undefined,
72
private _markdownOptions: IMarkdownRendererOptions,
73
private _commentOptions: languages.CommentOptions | undefined,
74
private _containerDelegate: {
75
actionRunner: (() => void) | null;
76
collapse: () => Promise<boolean>;
77
},
78
@ICommentService private readonly commentService: ICommentService,
79
@IConfigurationService private readonly configurationService: IConfigurationService,
80
@IKeybindingService private readonly _keybindingService: IKeybindingService
81
) {
82
super();
83
84
this._threadIsEmpty = CommentContextKeys.commentThreadIsEmpty.bindTo(this._contextKeyService);
85
this._threadIsEmpty.set(!_commentThread.comments || !_commentThread.comments.length);
86
this._focusedContextKey = CommentContextKeys.commentFocused.bindTo(this._contextKeyService);
87
88
this._commentMenus = this.commentService.getCommentMenus(this._owner);
89
90
this._register(this._header = this._scopedInstantiationService.createInstance(
91
CommentThreadHeader,
92
container,
93
{
94
collapse: this._containerDelegate.collapse.bind(this)
95
},
96
this._commentMenus,
97
this._commentThread
98
));
99
100
this._header.updateCommentThread(this._commentThread);
101
102
const bodyElement = <HTMLDivElement>dom.$('.body');
103
container.appendChild(bodyElement);
104
this._register(toDisposable(() => bodyElement.remove()));
105
106
const tracker = this._register(dom.trackFocus(bodyElement));
107
this._register(registerNavigableContainer({
108
name: 'commentThreadWidget',
109
focusNotifiers: [tracker],
110
focusNextWidget: () => {
111
if (!this._commentReply?.isCommentEditorFocused()) {
112
this._commentReply?.expandReplyAreaAndFocusCommentEditor();
113
}
114
},
115
focusPreviousWidget: () => {
116
if (this._commentReply?.isCommentEditorFocused() && this._commentThread.comments?.length) {
117
this._body.focus();
118
}
119
}
120
}));
121
this._register(tracker.onDidFocus(() => this._focusedContextKey.set(true)));
122
this._register(tracker.onDidBlur(() => this._focusedContextKey.reset()));
123
this._register(this.configurationService.onDidChangeConfiguration(e => {
124
if (e.affectsConfiguration(AccessibilityVerbositySettingId.Comments)) {
125
this._setAriaLabel();
126
}
127
}));
128
this._body = this._scopedInstantiationService.createInstance(
129
CommentThreadBody,
130
this._parentEditor,
131
this._owner,
132
this._parentResourceUri,
133
bodyElement,
134
this._markdownOptions,
135
this._commentThread,
136
this._pendingEdits,
137
this._scopedInstantiationService,
138
this
139
) as unknown as CommentThreadBody<T>;
140
this._register(this._body);
141
this._setAriaLabel();
142
this._styleElement = domStylesheets.createStyleSheet(this.container);
143
144
145
this._commentThreadContextValue = CommentContextKeys.commentThreadContext.bindTo(this._contextKeyService);
146
this._commentThreadContextValue.set(_commentThread.contextValue);
147
148
const commentControllerKey = CommentContextKeys.commentControllerContext.bindTo(this._contextKeyService);
149
const controller = this.commentService.getCommentController(this._owner);
150
151
if (controller?.contextValue) {
152
commentControllerKey.set(controller.contextValue);
153
}
154
155
this.currentThreadListeners();
156
}
157
158
get hasUnsubmittedComments(): boolean {
159
return !!this._commentReply?.commentEditor.getValue() || this._body.hasCommentsInEditMode();
160
}
161
162
private _setAriaLabel(): void {
163
let ariaLabel = localize('commentLabel', "Comment");
164
let keybinding: string | undefined;
165
const verbose = this.configurationService.getValue(AccessibilityVerbositySettingId.Comments);
166
if (verbose) {
167
keybinding = this._keybindingService.lookupKeybinding(AccessibilityCommandId.OpenAccessibilityHelp, this._contextKeyService)?.getLabel() ?? undefined;
168
}
169
if (keybinding) {
170
ariaLabel = localize('commentLabelWithKeybinding', "{0}, use ({1}) for accessibility help", ariaLabel, keybinding);
171
} else if (verbose) {
172
ariaLabel = localize('commentLabelWithKeybindingNoKeybinding', "{0}, run the command Open Accessibility Help which is currently not triggerable via keybinding.", ariaLabel);
173
}
174
this._body.container.ariaLabel = ariaLabel;
175
}
176
177
private updateCurrentThread(hasMouse: boolean, hasFocus: boolean) {
178
if (hasMouse || hasFocus) {
179
this.commentService.setCurrentCommentThread(this.commentThread);
180
} else {
181
this.commentService.setCurrentCommentThread(undefined);
182
}
183
}
184
185
private currentThreadListeners() {
186
let hasMouse = false;
187
let hasFocus = false;
188
this._register(dom.addDisposableListener(this.container, dom.EventType.MOUSE_ENTER, (e) => {
189
if ((<any>e).toElement === this.container) {
190
hasMouse = true;
191
this.updateCurrentThread(hasMouse, hasFocus);
192
}
193
}, true));
194
this._register(dom.addDisposableListener(this.container, dom.EventType.MOUSE_LEAVE, (e) => {
195
if ((<any>e).fromElement === this.container) {
196
hasMouse = false;
197
this.updateCurrentThread(hasMouse, hasFocus);
198
}
199
}, true));
200
this._register(dom.addDisposableListener(this.container, dom.EventType.FOCUS_IN, () => {
201
hasFocus = true;
202
this.updateCurrentThread(hasMouse, hasFocus);
203
}, true));
204
this._register(dom.addDisposableListener(this.container, dom.EventType.FOCUS_OUT, () => {
205
hasFocus = false;
206
this.updateCurrentThread(hasMouse, hasFocus);
207
}, true));
208
}
209
210
async updateCommentThread(commentThread: languages.CommentThread<T>) {
211
const shouldCollapse = (this._commentThread.collapsibleState === languages.CommentThreadCollapsibleState.Expanded) && (this._commentThreadState === languages.CommentThreadState.Unresolved)
212
&& (commentThread.state === languages.CommentThreadState.Resolved);
213
this._commentThreadState = commentThread.state;
214
this._commentThread = commentThread;
215
dispose(this._commentThreadDisposables);
216
this._commentThreadDisposables = [];
217
this._bindCommentThreadListeners();
218
219
await this._body.updateCommentThread(commentThread, this._commentReply?.isCommentEditorFocused() ?? false);
220
this._threadIsEmpty.set(!this._body.length);
221
this._header.updateCommentThread(commentThread);
222
this._commentReply?.updateCommentThread(commentThread);
223
224
if (this._commentThread.contextValue) {
225
this._commentThreadContextValue.set(this._commentThread.contextValue);
226
} else {
227
this._commentThreadContextValue.reset();
228
}
229
230
if (shouldCollapse && this.configurationService.getValue<ICommentsConfiguration>(COMMENTS_SECTION).collapseOnResolve) {
231
this.collapse();
232
}
233
}
234
235
async display(lineHeight: number, focus: boolean) {
236
const headHeight = Math.max(23, Math.ceil(lineHeight * 1.2)); // 23 is the value of `Math.ceil(lineHeight * 1.2)` with the default editor font size
237
this._header.updateHeight(headHeight);
238
239
await this._body.display();
240
241
// create comment thread only when it supports reply
242
if (this._commentThread.canReply) {
243
this._createCommentForm(focus);
244
}
245
this._createAdditionalActions();
246
247
this._register(this._body.onDidResize(dimension => {
248
this._refresh(dimension);
249
}));
250
251
// If there are no existing comments, place focus on the text area. This must be done after show, which also moves focus.
252
// if this._commentThread.comments is undefined, it doesn't finish initialization yet, so we don't focus the editor immediately.
253
if (this._commentThread.canReply && this._commentReply) {
254
this._commentReply.focusIfNeeded();
255
}
256
257
this._bindCommentThreadListeners();
258
}
259
260
private _refresh(dimension: dom.Dimension) {
261
this._body.layout();
262
this._onDidResize.fire(dimension);
263
}
264
265
override dispose() {
266
super.dispose();
267
dispose(this._commentThreadDisposables);
268
this.updateCurrentThread(false, false);
269
}
270
271
private _bindCommentThreadListeners() {
272
this._commentThreadDisposables.push(this._commentThread.onDidChangeCanReply(() => {
273
if (this._commentReply) {
274
this._commentReply.updateCanReply();
275
} else {
276
if (this._commentThread.canReply) {
277
this._createCommentForm(false);
278
}
279
}
280
}));
281
282
this._commentThreadDisposables.push(this._commentThread.onDidChangeComments(async _ => {
283
await this.updateCommentThread(this._commentThread);
284
}));
285
286
this._commentThreadDisposables.push(this._commentThread.onDidChangeLabel(_ => {
287
this._header.createThreadLabel();
288
}));
289
}
290
291
private _createCommentForm(focus: boolean) {
292
this._commentReply = this._scopedInstantiationService.createInstance(
293
CommentReply,
294
this._owner,
295
this._body.container,
296
this._parentEditor,
297
this._commentThread,
298
this._scopedInstantiationService,
299
this._contextKeyService,
300
this._commentMenus,
301
this._commentOptions,
302
this._pendingComment,
303
this,
304
focus,
305
this._containerDelegate.actionRunner
306
);
307
308
this._register(this._commentReply);
309
}
310
311
private _createAdditionalActions() {
312
this._additionalActions = this._scopedInstantiationService.createInstance(
313
CommentThreadAdditionalActions,
314
this._body.container,
315
this._commentThread,
316
this._contextKeyService,
317
this._commentMenus,
318
this._containerDelegate.actionRunner,
319
);
320
321
this._register(this._additionalActions);
322
}
323
324
getCommentCoords(commentUniqueId: number) {
325
return this._body.getCommentCoords(commentUniqueId);
326
}
327
328
getPendingEdits(): { [key: number]: languages.PendingComment } {
329
return this._body.getPendingEdits();
330
}
331
332
getPendingComment(): languages.PendingComment | undefined {
333
if (this._commentReply) {
334
return this._commentReply.getPendingComment();
335
}
336
337
return undefined;
338
}
339
340
setPendingComment(pending: languages.PendingComment) {
341
this._pendingComment = pending;
342
this._commentReply?.setPendingComment(pending);
343
}
344
345
getDimensions() {
346
return this._body.getDimensions();
347
}
348
349
layout(widthInPixel?: number) {
350
this._body.layout(widthInPixel);
351
352
if (widthInPixel !== undefined) {
353
this._commentReply?.layout(widthInPixel);
354
}
355
}
356
357
ensureFocusIntoNewEditingComment() {
358
this._body.ensureFocusIntoNewEditingComment();
359
}
360
361
focusCommentEditor() {
362
this._commentReply?.expandReplyAreaAndFocusCommentEditor();
363
}
364
365
focus(commentUniqueId: number | undefined) {
366
this._body.focus(commentUniqueId);
367
}
368
369
async submitComment() {
370
const activeComment = this._body.activeComment;
371
if (activeComment) {
372
return activeComment.submitComment();
373
} else if ((this._commentReply?.getPendingComment()?.body.length ?? 0) > 0) {
374
return this._commentReply?.submitComment();
375
}
376
}
377
378
async collapse() {
379
if ((await this._containerDelegate.collapse()) && Range.isIRange(this.commentThread.range) && isCodeEditor(this._parentEditor)) {
380
this._parentEditor.setSelection(this.commentThread.range);
381
}
382
383
}
384
385
applyTheme(theme: IColorTheme, fontInfo: FontInfo) {
386
const content: string[] = [];
387
388
content.push(`.monaco-editor .review-widget > .body { border-top: 1px solid var(${commentThreadStateColorVar}) }`);
389
content.push(`.monaco-editor .review-widget > .head { background-color: var(${commentThreadStateBackgroundColorVar}) }`);
390
391
const linkColor = theme.getColor(textLinkForeground);
392
if (linkColor) {
393
content.push(`.review-widget .body .comment-body a { color: ${linkColor} }`);
394
}
395
396
const linkActiveColor = theme.getColor(textLinkActiveForeground);
397
if (linkActiveColor) {
398
content.push(`.review-widget .body .comment-body a:hover, a:active { color: ${linkActiveColor} }`);
399
}
400
401
const focusColor = theme.getColor(focusBorder);
402
if (focusColor) {
403
content.push(`.review-widget .body .comment-body a:focus { outline: 1px solid ${focusColor}; }`);
404
content.push(`.review-widget .body .monaco-editor.focused { outline: 1px solid ${focusColor}; }`);
405
}
406
407
const blockQuoteBackground = theme.getColor(textBlockQuoteBackground);
408
if (blockQuoteBackground) {
409
content.push(`.review-widget .body .review-comment blockquote { background: ${blockQuoteBackground}; }`);
410
}
411
412
const blockQuoteBOrder = theme.getColor(textBlockQuoteBorder);
413
if (blockQuoteBOrder) {
414
content.push(`.review-widget .body .review-comment blockquote { border-color: ${blockQuoteBOrder}; }`);
415
}
416
417
const border = theme.getColor(PANEL_BORDER);
418
if (border) {
419
content.push(`.review-widget .body .review-comment .review-comment-contents .comment-reactions .action-item a.action-label { border-color: ${border}; }`);
420
}
421
422
const hcBorder = theme.getColor(contrastBorder);
423
if (hcBorder) {
424
content.push(`.review-widget .body .comment-form .review-thread-reply-button { outline-color: ${hcBorder}; }`);
425
content.push(`.review-widget .body .monaco-editor { outline: 1px solid ${hcBorder}; }`);
426
}
427
428
const errorBorder = theme.getColor(inputValidationErrorBorder);
429
if (errorBorder) {
430
content.push(`.review-widget .validation-error { border: 1px solid ${errorBorder}; }`);
431
}
432
433
const errorBackground = theme.getColor(inputValidationErrorBackground);
434
if (errorBackground) {
435
content.push(`.review-widget .validation-error { background: ${errorBackground}; }`);
436
}
437
438
const errorForeground = theme.getColor(inputValidationErrorForeground);
439
if (errorForeground) {
440
content.push(`.review-widget .body .comment-form .validation-error { color: ${errorForeground}; }`);
441
}
442
443
const fontFamilyVar = '--comment-thread-editor-font-family';
444
const fontSizeVar = '--comment-thread-editor-font-size';
445
const fontWeightVar = '--comment-thread-editor-font-weight';
446
this.container?.style.setProperty(fontFamilyVar, fontInfo.fontFamily);
447
this.container?.style.setProperty(fontSizeVar, `${fontInfo.fontSize}px`);
448
this.container?.style.setProperty(fontWeightVar, fontInfo.fontWeight);
449
450
content.push(`.review-widget .body code {
451
font-family: var(${fontFamilyVar});
452
font-weight: var(${fontWeightVar});
453
}`);
454
455
this._styleElement.textContent = content.join('\n');
456
this._commentReply?.setCommentEditorDecorations();
457
}
458
}
459
460