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
5243 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 { Emitter } from '../../../../base/common/event.js';
9
import { Disposable, dispose, IDisposable, toDisposable } from '../../../../base/common/lifecycle.js';
10
import { URI } from '../../../../base/common/uri.js';
11
import * as languages from '../../../../editor/common/languages.js';
12
import { IMarkdownRendererExtraOptions } from '../../../../platform/markdown/browser/markdownRenderer.js';
13
import { IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js';
14
import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';
15
import { CommentMenus } from './commentMenus.js';
16
import { CommentReply } from './commentReply.js';
17
import { ICommentService } from './commentService.js';
18
import { CommentThreadBody } from './commentThreadBody.js';
19
import { CommentThreadHeader } from './commentThreadHeader.js';
20
import { CommentThreadAdditionalActions } from './commentThreadAdditionalActions.js';
21
import { CommentContextKeys } from '../common/commentContextKeys.js';
22
import { ICommentThreadWidget } from '../common/commentThreadWidget.js';
23
import { IRange, Range } from '../../../../editor/common/core/range.js';
24
import { ICellRange } from '../../notebook/common/notebookRange.js';
25
import { FontInfo } from '../../../../editor/common/config/fontInfo.js';
26
import { registerNavigableContainer } from '../../../browser/actions/widgetNavigationCommands.js';
27
import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';
28
import { COMMENTS_SECTION, ICommentsConfiguration } from '../common/commentsConfiguration.js';
29
import { localize } from '../../../../nls.js';
30
import { AccessibilityVerbositySettingId } from '../../accessibility/browser/accessibilityConfiguration.js';
31
import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js';
32
import { AccessibilityCommandId } from '../../accessibility/common/accessibilityCommands.js';
33
import { LayoutableEditor } from './simpleCommentEditor.js';
34
import { isCodeEditor } from '../../../../editor/browser/editorBrowser.js';
35
36
export const COMMENTEDITOR_DECORATION_KEY = 'commenteditordecoration';
37
38
export class CommentThreadWidget<T extends IRange | ICellRange = IRange> extends Disposable implements ICommentThreadWidget {
39
private _header!: CommentThreadHeader<T>;
40
private _body: CommentThreadBody<T>;
41
private _commentReply?: CommentReply<T>;
42
private _additionalActions?: CommentThreadAdditionalActions<T>;
43
private _commentMenus: CommentMenus;
44
private _commentThreadDisposables: IDisposable[] = [];
45
private _threadIsEmpty: IContextKey<boolean>;
46
private _commentThreadContextValue: IContextKey<string | undefined>;
47
private _focusedContextKey: IContextKey<boolean>;
48
private _onDidResize = this._register(new Emitter<dom.Dimension>());
49
onDidResize = this._onDidResize.event;
50
51
private _commentThreadState: languages.CommentThreadState | undefined;
52
53
get commentThread() {
54
return this._commentThread;
55
}
56
constructor(
57
readonly container: HTMLElement,
58
readonly _parentEditor: LayoutableEditor,
59
private _owner: string,
60
private _parentResourceUri: URI,
61
private _contextKeyService: IContextKeyService,
62
private _scopedInstantiationService: IInstantiationService,
63
private _commentThread: languages.CommentThread<T>,
64
private _pendingComment: languages.PendingComment | undefined,
65
private _pendingEdits: { [key: number]: languages.PendingComment } | undefined,
66
private _markdownOptions: IMarkdownRendererExtraOptions,
67
private _commentOptions: languages.CommentOptions | undefined,
68
private _containerDelegate: {
69
actionRunner: (() => void) | null;
70
collapse: () => Promise<boolean>;
71
},
72
@ICommentService private readonly commentService: ICommentService,
73
@IConfigurationService private readonly configurationService: IConfigurationService,
74
@IKeybindingService private readonly _keybindingService: IKeybindingService
75
) {
76
super();
77
78
this._threadIsEmpty = CommentContextKeys.commentThreadIsEmpty.bindTo(this._contextKeyService);
79
this._threadIsEmpty.set(!_commentThread.comments || !_commentThread.comments.length);
80
this._focusedContextKey = CommentContextKeys.commentFocused.bindTo(this._contextKeyService);
81
82
this._commentMenus = this.commentService.getCommentMenus(this._owner);
83
84
this._register(this._header = this._scopedInstantiationService.createInstance(
85
CommentThreadHeader,
86
container,
87
{
88
collapse: this._containerDelegate.collapse.bind(this)
89
},
90
this._commentMenus,
91
this._commentThread
92
));
93
94
this._header.updateCommentThread(this._commentThread);
95
96
const bodyElement = dom.$('.body');
97
container.appendChild(bodyElement);
98
this._register(toDisposable(() => bodyElement.remove()));
99
100
const tracker = this._register(dom.trackFocus(bodyElement));
101
this._register(registerNavigableContainer({
102
name: 'commentThreadWidget',
103
focusNotifiers: [tracker],
104
focusNextWidget: () => {
105
if (!this._commentReply?.isCommentEditorFocused()) {
106
this._commentReply?.expandReplyAreaAndFocusCommentEditor();
107
}
108
},
109
focusPreviousWidget: () => {
110
if (this._commentReply?.isCommentEditorFocused() && this._commentThread.comments?.length) {
111
this._body.focus();
112
}
113
}
114
}));
115
this._register(tracker.onDidFocus(() => this._focusedContextKey.set(true)));
116
this._register(tracker.onDidBlur(() => this._focusedContextKey.reset()));
117
this._register(this.configurationService.onDidChangeConfiguration(e => {
118
if (e.affectsConfiguration(AccessibilityVerbositySettingId.Comments)) {
119
this._setAriaLabel();
120
}
121
}));
122
this._body = this._scopedInstantiationService.createInstance(
123
CommentThreadBody,
124
this._parentEditor,
125
this._owner,
126
this._parentResourceUri,
127
bodyElement,
128
this._markdownOptions,
129
this._commentThread,
130
this._pendingEdits,
131
this._scopedInstantiationService,
132
this
133
) as unknown as CommentThreadBody<T>;
134
this._register(this._body);
135
this._setAriaLabel();
136
137
this._commentThreadContextValue = CommentContextKeys.commentThreadContext.bindTo(this._contextKeyService);
138
this._commentThreadContextValue.set(_commentThread.contextValue);
139
140
const commentControllerKey = CommentContextKeys.commentControllerContext.bindTo(this._contextKeyService);
141
const controller = this.commentService.getCommentController(this._owner);
142
143
if (controller?.contextValue) {
144
commentControllerKey.set(controller.contextValue);
145
}
146
147
this.currentThreadListeners();
148
}
149
150
get hasUnsubmittedComments(): boolean {
151
return !!this._commentReply?.commentEditor.getValue() || this._body.hasCommentsInEditMode();
152
}
153
154
private _setAriaLabel(): void {
155
let ariaLabel = localize('commentLabel', "Comment");
156
let keybinding: string | undefined;
157
const verbose = this.configurationService.getValue(AccessibilityVerbositySettingId.Comments);
158
if (verbose) {
159
keybinding = this._keybindingService.lookupKeybinding(AccessibilityCommandId.OpenAccessibilityHelp, this._contextKeyService)?.getLabel() ?? undefined;
160
}
161
if (keybinding) {
162
ariaLabel = localize('commentLabelWithKeybinding', "{0}, use ({1}) for accessibility help", ariaLabel, keybinding);
163
} else if (verbose) {
164
ariaLabel = localize('commentLabelWithKeybindingNoKeybinding', "{0}, run the command Open Accessibility Help which is currently not triggerable via keybinding.", ariaLabel);
165
}
166
this._body.container.ariaLabel = ariaLabel;
167
}
168
169
private updateCurrentThread(hasMouse: boolean, hasFocus: boolean) {
170
if (hasMouse || hasFocus) {
171
this.commentService.setCurrentCommentThread(this.commentThread);
172
} else {
173
this.commentService.setCurrentCommentThread(undefined);
174
}
175
}
176
177
private currentThreadListeners() {
178
let hasMouse = false;
179
let hasFocus = false;
180
this._register(dom.addDisposableListener(this.container, dom.EventType.MOUSE_ENTER, (e) => {
181
if (e.relatedTarget === this.container) {
182
hasMouse = true;
183
this.updateCurrentThread(hasMouse, hasFocus);
184
}
185
}, true));
186
this._register(dom.addDisposableListener(this.container, dom.EventType.MOUSE_LEAVE, (e) => {
187
if (e.relatedTarget === this.container) {
188
hasMouse = false;
189
this.updateCurrentThread(hasMouse, hasFocus);
190
}
191
}, true));
192
this._register(dom.addDisposableListener(this.container, dom.EventType.FOCUS_IN, () => {
193
hasFocus = true;
194
this.updateCurrentThread(hasMouse, hasFocus);
195
}, true));
196
this._register(dom.addDisposableListener(this.container, dom.EventType.FOCUS_OUT, () => {
197
hasFocus = false;
198
this.updateCurrentThread(hasMouse, hasFocus);
199
}, true));
200
}
201
202
async updateCommentThread(commentThread: languages.CommentThread<T>) {
203
const shouldCollapse = (this._commentThread.collapsibleState === languages.CommentThreadCollapsibleState.Expanded) && (this._commentThreadState === languages.CommentThreadState.Unresolved)
204
&& (commentThread.state === languages.CommentThreadState.Resolved);
205
this._commentThreadState = commentThread.state;
206
this._commentThread = commentThread;
207
dispose(this._commentThreadDisposables);
208
this._commentThreadDisposables = [];
209
this._bindCommentThreadListeners();
210
211
await this._body.updateCommentThread(commentThread, this._commentReply?.isCommentEditorFocused() ?? false);
212
this._threadIsEmpty.set(!this._body.length);
213
this._header.updateCommentThread(commentThread);
214
this._commentReply?.updateCommentThread(commentThread);
215
216
if (this._commentThread.contextValue) {
217
this._commentThreadContextValue.set(this._commentThread.contextValue);
218
} else {
219
this._commentThreadContextValue.reset();
220
}
221
222
if (shouldCollapse && this.configurationService.getValue<ICommentsConfiguration>(COMMENTS_SECTION).collapseOnResolve) {
223
this.collapse();
224
}
225
}
226
227
async display(lineHeight: number, focus: boolean) {
228
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
229
this._header.updateHeight(headHeight);
230
231
await this._body.display();
232
233
// create comment thread only when it supports reply
234
if (this._commentThread.canReply) {
235
this._createCommentForm(focus);
236
}
237
this._createAdditionalActions();
238
239
this._register(this._body.onDidResize(dimension => {
240
this._refresh(dimension);
241
}));
242
243
// If there are no existing comments, place focus on the text area. This must be done after show, which also moves focus.
244
// if this._commentThread.comments is undefined, it doesn't finish initialization yet, so we don't focus the editor immediately.
245
if (this._commentThread.canReply && this._commentReply) {
246
this._commentReply.focusIfNeeded();
247
}
248
249
this._bindCommentThreadListeners();
250
}
251
252
private _refresh(dimension: dom.Dimension) {
253
this._body.layout();
254
this._onDidResize.fire(dimension);
255
}
256
257
override dispose() {
258
super.dispose();
259
dispose(this._commentThreadDisposables);
260
this.updateCurrentThread(false, false);
261
}
262
263
private _bindCommentThreadListeners() {
264
this._commentThreadDisposables.push(this._commentThread.onDidChangeCanReply(() => {
265
if (this._commentReply) {
266
this._commentReply.updateCanReply();
267
} else {
268
if (this._commentThread.canReply) {
269
this._createCommentForm(false);
270
}
271
}
272
}));
273
274
this._commentThreadDisposables.push(this._commentThread.onDidChangeComments(async _ => {
275
await this.updateCommentThread(this._commentThread);
276
}));
277
278
this._commentThreadDisposables.push(this._commentThread.onDidChangeLabel(_ => {
279
this._header.createThreadLabel();
280
}));
281
}
282
283
private _createCommentForm(focus: boolean) {
284
this._commentReply = this._scopedInstantiationService.createInstance(
285
CommentReply,
286
this._owner,
287
this._body.container,
288
this._parentEditor,
289
this._commentThread,
290
this._scopedInstantiationService,
291
this._contextKeyService,
292
this._commentMenus,
293
this._commentOptions,
294
this._pendingComment,
295
this,
296
focus,
297
this._containerDelegate.actionRunner
298
);
299
300
this._register(this._commentReply);
301
}
302
303
private _createAdditionalActions() {
304
this._additionalActions = this._scopedInstantiationService.createInstance(
305
CommentThreadAdditionalActions,
306
this._body.container,
307
this._commentThread,
308
this._contextKeyService,
309
this._commentMenus,
310
this._containerDelegate.actionRunner,
311
);
312
313
this._register(this._additionalActions);
314
}
315
316
getCommentCoords(commentUniqueId: number) {
317
return this._body.getCommentCoords(commentUniqueId);
318
}
319
320
getPendingEdits(): { [key: number]: languages.PendingComment } {
321
return this._body.getPendingEdits();
322
}
323
324
getPendingComment(): languages.PendingComment | undefined {
325
if (this._commentReply) {
326
return this._commentReply.getPendingComment();
327
}
328
329
return undefined;
330
}
331
332
setPendingComment(pending: languages.PendingComment) {
333
this._pendingComment = pending;
334
this._commentReply?.setPendingComment(pending);
335
}
336
337
getDimensions() {
338
return this._body.getDimensions();
339
}
340
341
layout(widthInPixel?: number) {
342
this._body.layout(widthInPixel);
343
344
if (widthInPixel !== undefined) {
345
this._commentReply?.layout(widthInPixel);
346
}
347
}
348
349
ensureFocusIntoNewEditingComment() {
350
this._body.ensureFocusIntoNewEditingComment();
351
}
352
353
focusCommentEditor() {
354
this._commentReply?.expandReplyAreaAndFocusCommentEditor();
355
}
356
357
focus(commentUniqueId: number | undefined) {
358
this._body.focus(commentUniqueId);
359
}
360
361
async submitComment() {
362
const activeComment = this._body.activeComment;
363
if (activeComment) {
364
return activeComment.submitComment();
365
} else if ((this._commentReply?.getPendingComment()?.body.length ?? 0) > 0) {
366
return this._commentReply?.submitComment();
367
}
368
}
369
370
async collapse() {
371
if ((await this._containerDelegate.collapse()) && Range.isIRange(this.commentThread.range) && isCodeEditor(this._parentEditor)) {
372
this._parentEditor.setSelection(this.commentThread.range);
373
}
374
375
}
376
377
applyTheme(fontInfo: FontInfo) {
378
const fontFamilyVar = '--comment-thread-editor-font-family';
379
const fontWeightVar = '--comment-thread-editor-font-weight';
380
this.container?.style.setProperty(fontFamilyVar, fontInfo.fontFamily);
381
this.container?.style.setProperty(fontWeightVar, fontInfo.fontWeight);
382
383
this._commentReply?.setCommentEditorDecorations();
384
}
385
}
386
387