Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/workbench/contrib/comments/browser/commentReply.ts
3296 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import * as dom from '../../../../base/browser/dom.js';
7
import { getDefaultHoverDelegate } from '../../../../base/browser/ui/hover/hoverDelegateFactory.js';
8
import { MOUSE_CURSOR_TEXT_CSS_CLASS_NAME } from '../../../../base/browser/ui/mouseCursor/mouseCursor.js';
9
import { IAction } from '../../../../base/common/actions.js';
10
import { Disposable, IDisposable, dispose } from '../../../../base/common/lifecycle.js';
11
import { MarshalledId } from '../../../../base/common/marshallingIds.js';
12
import { FileAccess, Schemas } from '../../../../base/common/network.js';
13
import { URI } from '../../../../base/common/uri.js';
14
import { generateUuid } from '../../../../base/common/uuid.js';
15
import { ICodeEditor } from '../../../../editor/browser/editorBrowser.js';
16
import { IRange } from '../../../../editor/common/core/range.js';
17
import * as languages from '../../../../editor/common/languages.js';
18
import { ITextModel } from '../../../../editor/common/model.js';
19
import { ITextModelService } from '../../../../editor/common/services/resolverService.js';
20
import * as nls from '../../../../nls.js';
21
import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';
22
import { IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js';
23
import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';
24
import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js';
25
import { CommentFormActions } from './commentFormActions.js';
26
import { CommentMenus } from './commentMenus.js';
27
import { ICommentService } from './commentService.js';
28
import { CommentContextKeys } from '../common/commentContextKeys.js';
29
import { ICommentThreadWidget } from '../common/commentThreadWidget.js';
30
import { ICellRange } from '../../notebook/common/notebookRange.js';
31
import { LayoutableEditor, MIN_EDITOR_HEIGHT, SimpleCommentEditor, calculateEditorHeight } from './simpleCommentEditor.js';
32
import { IHoverService } from '../../../../platform/hover/browser/hover.js';
33
import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js';
34
import { Position } from '../../../../editor/common/core/position.js';
35
36
let INMEM_MODEL_ID = 0;
37
export const COMMENTEDITOR_DECORATION_KEY = 'commenteditordecoration';
38
39
export class CommentReply<T extends IRange | ICellRange> extends Disposable {
40
commentEditor: ICodeEditor;
41
private _container: HTMLElement;
42
private _form: HTMLElement;
43
commentEditorIsEmpty: IContextKey<boolean>;
44
private avatar!: HTMLElement;
45
private _error!: HTMLElement;
46
private _formActions!: HTMLElement;
47
private _editorActions!: HTMLElement;
48
private _commentThreadDisposables: IDisposable[] = [];
49
private _commentFormActions!: CommentFormActions;
50
private _commentEditorActions!: CommentFormActions;
51
private _reviewThreadReplyButton!: HTMLElement;
52
private _editorHeight = MIN_EDITOR_HEIGHT;
53
54
constructor(
55
readonly owner: string,
56
container: HTMLElement,
57
private readonly _parentEditor: LayoutableEditor,
58
private _commentThread: languages.CommentThread<T>,
59
private _scopedInstatiationService: IInstantiationService,
60
private _contextKeyService: IContextKeyService,
61
private _commentMenus: CommentMenus,
62
private _commentOptions: languages.CommentOptions | undefined,
63
private _pendingComment: languages.PendingComment | undefined,
64
private _parentThread: ICommentThreadWidget,
65
focus: boolean,
66
private _actionRunDelegate: (() => void) | null,
67
@ICommentService private commentService: ICommentService,
68
@IConfigurationService configurationService: IConfigurationService,
69
@IKeybindingService private keybindingService: IKeybindingService,
70
@IContextMenuService private contextMenuService: IContextMenuService,
71
@IHoverService private hoverService: IHoverService,
72
@ITextModelService private readonly textModelService: ITextModelService
73
) {
74
super();
75
this._container = dom.append(container, dom.$('.comment-form-container'));
76
this._form = dom.append(this._container, dom.$('.comment-form'));
77
this.commentEditor = this._register(this._scopedInstatiationService.createInstance(SimpleCommentEditor, this._form, SimpleCommentEditor.getEditorOptions(configurationService), _contextKeyService, this._parentThread));
78
this.commentEditorIsEmpty = CommentContextKeys.commentIsEmpty.bindTo(this._contextKeyService);
79
this.commentEditorIsEmpty.set(!this._pendingComment);
80
81
this.initialize(focus);
82
}
83
84
private async initialize(focus: boolean) {
85
this.avatar = dom.append(this._form, dom.$('.avatar-container'));
86
this.updateAuthorInfo();
87
const hasExistingComments = this._commentThread.comments && this._commentThread.comments.length > 0;
88
const modeId = generateUuid() + '-' + (hasExistingComments ? this._commentThread.threadId : ++INMEM_MODEL_ID);
89
const params = JSON.stringify({
90
extensionId: this._commentThread.extensionId,
91
commentThreadId: this._commentThread.threadId
92
});
93
94
let resource = URI.from({
95
scheme: Schemas.commentsInput,
96
path: `/${this._commentThread.extensionId}/commentinput-${modeId}.md?${params}` // TODO. Remove params once extensions adopt authority.
97
});
98
const commentController = this.commentService.getCommentController(this.owner);
99
if (commentController) {
100
resource = resource.with({ authority: commentController.id });
101
}
102
103
const model = await this.textModelService.createModelReference(resource);
104
model.object.textEditorModel.setValue(this._pendingComment?.body || '');
105
106
this._register(model);
107
this.commentEditor.setModel(model.object.textEditorModel);
108
if (this._pendingComment) {
109
this.commentEditor.setPosition(this._pendingComment.cursor);
110
}
111
this.calculateEditorHeight();
112
113
this._register(model.object.textEditorModel.onDidChangeContent(() => {
114
this.setCommentEditorDecorations();
115
this.commentEditorIsEmpty?.set(!this.commentEditor.getValue());
116
if (this.calculateEditorHeight()) {
117
this.commentEditor.layout({ height: this._editorHeight, width: this.commentEditor.getLayoutInfo().width });
118
this.commentEditor.render(true);
119
}
120
}));
121
122
this.createTextModelListener(this.commentEditor, this._form);
123
124
this.setCommentEditorDecorations();
125
126
// Only add the additional step of clicking a reply button to expand the textarea when there are existing comments
127
if (this._pendingComment) {
128
this.expandReplyArea();
129
} else if (hasExistingComments) {
130
this.createReplyButton(this.commentEditor, this._form);
131
} else if (this._commentThread.comments && this._commentThread.comments.length === 0) {
132
this.expandReplyArea(focus);
133
}
134
this._error = dom.append(this._container, dom.$('.validation-error.hidden'));
135
const formActions = dom.append(this._container, dom.$('.form-actions'));
136
this._formActions = dom.append(formActions, dom.$('.other-actions'));
137
this.createCommentWidgetFormActions(this._formActions, model.object.textEditorModel);
138
this._editorActions = dom.append(formActions, dom.$('.editor-actions'));
139
this.createCommentWidgetEditorActions(this._editorActions, model.object.textEditorModel);
140
}
141
142
private calculateEditorHeight(): boolean {
143
const newEditorHeight = calculateEditorHeight(this._parentEditor, this.commentEditor, this._editorHeight);
144
if (newEditorHeight !== this._editorHeight) {
145
this._editorHeight = newEditorHeight;
146
return true;
147
}
148
return false;
149
}
150
151
public updateCommentThread(commentThread: languages.CommentThread<IRange | ICellRange>) {
152
const isReplying = this.commentEditor.hasTextFocus();
153
const oldAndNewBothEmpty = !this._commentThread.comments?.length && !commentThread.comments?.length;
154
155
if (!this._reviewThreadReplyButton) {
156
this.createReplyButton(this.commentEditor, this._form);
157
}
158
159
if (this._commentThread.comments && this._commentThread.comments.length === 0 && !oldAndNewBothEmpty) {
160
this.expandReplyArea();
161
}
162
163
if (isReplying) {
164
this.commentEditor.focus();
165
}
166
}
167
168
public getPendingComment(): languages.PendingComment | undefined {
169
const model = this.commentEditor.getModel();
170
171
if (model && model.getValueLength() > 0) { // checking length is cheap
172
return { body: model.getValue(), cursor: this.commentEditor.getPosition() ?? new Position(1, 1) };
173
}
174
175
return undefined;
176
}
177
178
public setPendingComment(pending: languages.PendingComment) {
179
this._pendingComment = pending;
180
this.expandReplyArea();
181
this.commentEditor.setValue(pending.body);
182
this.commentEditor.setPosition(pending.cursor);
183
}
184
185
public layout(widthInPixel: number) {
186
this.commentEditor.layout({ height: this._editorHeight, width: widthInPixel - 54 /* margin 20px * 10 + scrollbar 14px*/ });
187
}
188
189
public focusIfNeeded() {
190
if (!this._commentThread.comments || !this._commentThread.comments.length) {
191
this.commentEditor.focus();
192
} else if ((this.commentEditor.getModel()?.getValueLength() ?? 0) > 0) {
193
this.expandReplyArea();
194
}
195
}
196
197
public focusCommentEditor() {
198
this.commentEditor.focus();
199
}
200
201
public expandReplyAreaAndFocusCommentEditor() {
202
this.expandReplyArea();
203
this.commentEditor.focus();
204
}
205
206
public isCommentEditorFocused(): boolean {
207
return this.commentEditor.hasWidgetFocus();
208
}
209
210
private updateAuthorInfo() {
211
this.avatar.textContent = '';
212
if (typeof this._commentThread.canReply !== 'boolean' && this._commentThread.canReply.iconPath) {
213
this.avatar.style.display = 'block';
214
const img = dom.append(this.avatar, dom.$('img.avatar')) as HTMLImageElement;
215
img.src = FileAccess.uriToBrowserUri(URI.revive(this._commentThread.canReply.iconPath)).toString(true);
216
} else {
217
this.avatar.style.display = 'none';
218
}
219
}
220
221
public updateCanReply() {
222
this.updateAuthorInfo();
223
if (!this._commentThread.canReply) {
224
this._container.style.display = 'none';
225
} else {
226
this._container.style.display = 'block';
227
}
228
}
229
230
async submitComment(): Promise<void> {
231
await this._commentFormActions?.triggerDefaultAction();
232
this._pendingComment = undefined;
233
}
234
235
setCommentEditorDecorations() {
236
const hasExistingComments = this._commentThread.comments && this._commentThread.comments.length > 0;
237
const placeholder = hasExistingComments
238
? (this._commentOptions?.placeHolder || nls.localize('reply', "Reply..."))
239
: (this._commentOptions?.placeHolder || nls.localize('newComment', "Type a new comment"));
240
241
this.commentEditor.updateOptions({ placeholder });
242
}
243
244
private createTextModelListener(commentEditor: ICodeEditor, commentForm: HTMLElement) {
245
this._commentThreadDisposables.push(commentEditor.onDidFocusEditorWidget(() => {
246
this._commentThread.input = {
247
uri: commentEditor.getModel()!.uri,
248
value: commentEditor.getValue()
249
};
250
this.commentService.setActiveEditingCommentThread(this._commentThread);
251
this.commentService.setActiveCommentAndThread(this.owner, { thread: this._commentThread });
252
}));
253
254
this._commentThreadDisposables.push(commentEditor.getModel()!.onDidChangeContent(() => {
255
const modelContent = commentEditor.getValue();
256
if (this._commentThread.input && this._commentThread.input.uri === commentEditor.getModel()!.uri && this._commentThread.input.value !== modelContent) {
257
const newInput: languages.CommentInput = this._commentThread.input;
258
newInput.value = modelContent;
259
this._commentThread.input = newInput;
260
}
261
this.commentService.setActiveEditingCommentThread(this._commentThread);
262
}));
263
264
this._commentThreadDisposables.push(this._commentThread.onDidChangeInput(input => {
265
const thread = this._commentThread;
266
const model = commentEditor.getModel();
267
if (thread.input && model && (thread.input.uri !== model.uri)) {
268
return;
269
}
270
if (!input) {
271
return;
272
}
273
274
if (commentEditor.getValue() !== input.value) {
275
commentEditor.setValue(input.value);
276
277
if (input.value === '') {
278
this._pendingComment = { body: '', cursor: new Position(1, 1) };
279
commentForm.classList.remove('expand');
280
commentEditor.getDomNode()!.style.outline = '';
281
this._error.textContent = '';
282
this._error.classList.add('hidden');
283
}
284
}
285
}));
286
}
287
288
/**
289
* Command based actions.
290
*/
291
private createCommentWidgetFormActions(container: HTMLElement, model: ITextModel) {
292
const menu = this._commentMenus.getCommentThreadActions(this._contextKeyService);
293
294
this._register(menu);
295
this._register(menu.onDidChange(() => {
296
this._commentFormActions.setActions(menu);
297
}));
298
299
this._commentFormActions = new CommentFormActions(this.keybindingService, this._contextKeyService, this.contextMenuService, container, async (action: IAction) => {
300
await this._actionRunDelegate?.();
301
302
await action.run({
303
thread: this._commentThread,
304
text: this.commentEditor.getValue(),
305
$mid: MarshalledId.CommentThreadReply
306
});
307
308
this.hideReplyArea();
309
});
310
311
this._register(this._commentFormActions);
312
this._commentFormActions.setActions(menu);
313
}
314
315
private createCommentWidgetEditorActions(container: HTMLElement, model: ITextModel) {
316
const editorMenu = this._commentMenus.getCommentEditorActions(this._contextKeyService);
317
this._register(editorMenu);
318
this._register(editorMenu.onDidChange(() => {
319
this._commentEditorActions.setActions(editorMenu, true);
320
}));
321
322
this._commentEditorActions = new CommentFormActions(this.keybindingService, this._contextKeyService, this.contextMenuService, container, async (action: IAction) => {
323
this._actionRunDelegate?.();
324
325
action.run({
326
thread: this._commentThread,
327
text: this.commentEditor.getValue(),
328
$mid: MarshalledId.CommentThreadReply
329
});
330
331
this.focusCommentEditor();
332
});
333
334
this._register(this._commentEditorActions);
335
this._commentEditorActions.setActions(editorMenu, true);
336
}
337
338
private get isReplyExpanded(): boolean {
339
return this._container.classList.contains('expand');
340
}
341
342
private expandReplyArea(focus: boolean = true) {
343
if (!this.isReplyExpanded) {
344
this._container.classList.add('expand');
345
if (focus) {
346
this.commentEditor.focus();
347
}
348
this.commentEditor.layout();
349
}
350
}
351
352
private clearAndExpandReplyArea() {
353
if (!this.isReplyExpanded) {
354
this.commentEditor.setValue('');
355
this.expandReplyArea();
356
}
357
}
358
359
private hideReplyArea() {
360
const domNode = this.commentEditor.getDomNode();
361
if (domNode) {
362
domNode.style.outline = '';
363
}
364
this.commentEditor.setValue('');
365
this._pendingComment = { body: '', cursor: new Position(1, 1) };
366
this._container.classList.remove('expand');
367
this._error.textContent = '';
368
this._error.classList.add('hidden');
369
}
370
371
private createReplyButton(commentEditor: ICodeEditor, commentForm: HTMLElement) {
372
this._reviewThreadReplyButton = <HTMLButtonElement>dom.append(commentForm, dom.$(`button.review-thread-reply-button.${MOUSE_CURSOR_TEXT_CSS_CLASS_NAME}`));
373
this._register(this.hoverService.setupManagedHover(getDefaultHoverDelegate('mouse'), this._reviewThreadReplyButton, this._commentOptions?.prompt || nls.localize('reply', "Reply...")));
374
375
this._reviewThreadReplyButton.textContent = this._commentOptions?.prompt || nls.localize('reply', "Reply...");
376
// bind click/escape actions for reviewThreadReplyButton and textArea
377
this._register(dom.addDisposableListener(this._reviewThreadReplyButton, 'click', _ => this.clearAndExpandReplyArea()));
378
this._register(dom.addDisposableListener(this._reviewThreadReplyButton, 'focus', _ => this.clearAndExpandReplyArea()));
379
380
this._register(commentEditor.onDidBlurEditorWidget(() => {
381
if (commentEditor.getModel()!.getValueLength() === 0 && commentForm.classList.contains('expand')) {
382
commentForm.classList.remove('expand');
383
}
384
}));
385
}
386
387
override dispose(): void {
388
super.dispose();
389
dispose(this._commentThreadDisposables);
390
}
391
}
392
393