Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/workbench/contrib/comments/browser/commentsTreeViewer.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 * as nls from '../../../../nls.js';
8
import { renderMarkdown } from '../../../../base/browser/markdownRenderer.js';
9
import { IDisposable, DisposableStore } from '../../../../base/common/lifecycle.js';
10
import { IResourceLabel, ResourceLabels } from '../../../browser/labels.js';
11
import { CommentNode, ResourceWithCommentThreads } from '../common/commentModel.js';
12
import { ITreeContextMenuEvent, ITreeFilter, ITreeNode, TreeFilterResult, TreeVisibility } from '../../../../base/browser/ui/tree/tree.js';
13
import { IListVirtualDelegate, IListRenderer } from '../../../../base/browser/ui/list/list.js';
14
import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';
15
import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js';
16
import { IListService, IWorkbenchAsyncDataTreeOptions, WorkbenchObjectTree } from '../../../../platform/list/browser/listService.js';
17
import { IColorTheme, IThemeService } from '../../../../platform/theme/common/themeService.js';
18
import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';
19
import { TimestampWidget } from './timestamp.js';
20
import { Codicon } from '../../../../base/common/codicons.js';
21
import { ThemeIcon } from '../../../../base/common/themables.js';
22
import { IMarkdownString } from '../../../../base/common/htmlContent.js';
23
import { commentViewThreadStateColorVar, getCommentThreadStateIconColor } from './commentColors.js';
24
import { CommentThreadApplicability, CommentThreadState } from '../../../../editor/common/languages.js';
25
import { Color } from '../../../../base/common/color.js';
26
import { IMatch } from '../../../../base/common/filters.js';
27
import { FilterOptions } from './commentsFilterOptions.js';
28
import { basename } from '../../../../base/common/resources.js';
29
import { IStyleOverride } from '../../../../platform/theme/browser/defaultStyles.js';
30
import { IListStyles } from '../../../../base/browser/ui/list/listWidget.js';
31
import { ILocalizedString } from '../../../../platform/action/common/action.js';
32
import { CommentsModel } from './commentsModel.js';
33
import { getDefaultHoverDelegate } from '../../../../base/browser/ui/hover/hoverDelegateFactory.js';
34
import { ActionBar, IActionViewItemProvider } from '../../../../base/browser/ui/actionbar/actionbar.js';
35
import { createActionViewItem, getContextMenuActions } from '../../../../platform/actions/browser/menuEntryActionViewItem.js';
36
import { IMenuService, MenuId } from '../../../../platform/actions/common/actions.js';
37
import { IAction } from '../../../../base/common/actions.js';
38
import { MarshalledId } from '../../../../base/common/marshallingIds.js';
39
import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js';
40
import { ActionViewItem } from '../../../../base/browser/ui/actionbar/actionViewItems.js';
41
import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js';
42
import { MarshalledCommentThread, MarshalledCommentThreadInternal } from '../../../common/comments.js';
43
import { IHoverService } from '../../../../platform/hover/browser/hover.js';
44
45
export const COMMENTS_VIEW_ID = 'workbench.panel.comments';
46
export const COMMENTS_VIEW_STORAGE_ID = 'Comments';
47
export const COMMENTS_VIEW_TITLE: ILocalizedString = nls.localize2('comments.view.title', "Comments");
48
49
interface IResourceTemplateData {
50
resourceLabel: IResourceLabel;
51
separator: HTMLElement;
52
owner: HTMLElement;
53
}
54
55
interface ICommentThreadTemplateData {
56
threadMetadata: {
57
relevance: HTMLElement;
58
icon: HTMLElement;
59
userNames: HTMLSpanElement;
60
timestamp: TimestampWidget;
61
separator: HTMLElement;
62
commentPreview: HTMLSpanElement;
63
range: HTMLElement;
64
};
65
repliesMetadata: {
66
container: HTMLElement;
67
icon: HTMLElement;
68
count: HTMLSpanElement;
69
lastReplyDetail: HTMLSpanElement;
70
separator: HTMLElement;
71
timestamp: TimestampWidget;
72
};
73
actionBar: ActionBar;
74
disposables: IDisposable[];
75
}
76
77
class CommentsModelVirtualDelegate implements IListVirtualDelegate<ResourceWithCommentThreads | CommentNode> {
78
private static readonly RESOURCE_ID = 'resource-with-comments';
79
private static readonly COMMENT_ID = 'comment-node';
80
81
82
getHeight(element: any): number {
83
if ((element instanceof CommentNode) && element.hasReply()) {
84
return 44;
85
}
86
return 22;
87
}
88
89
public getTemplateId(element: any): string {
90
if (element instanceof ResourceWithCommentThreads) {
91
return CommentsModelVirtualDelegate.RESOURCE_ID;
92
}
93
if (element instanceof CommentNode) {
94
return CommentsModelVirtualDelegate.COMMENT_ID;
95
}
96
97
return '';
98
}
99
}
100
101
export class ResourceWithCommentsRenderer implements IListRenderer<ITreeNode<ResourceWithCommentThreads>, IResourceTemplateData> {
102
templateId: string = 'resource-with-comments';
103
104
constructor(
105
private labels: ResourceLabels
106
) {
107
}
108
109
renderTemplate(container: HTMLElement) {
110
const labelContainer = dom.append(container, dom.$('.resource-container'));
111
const resourceLabel = this.labels.create(labelContainer);
112
const separator = dom.append(labelContainer, dom.$('.separator'));
113
const owner = labelContainer.appendChild(dom.$('.owner'));
114
115
return { resourceLabel, owner, separator };
116
}
117
118
renderElement(node: ITreeNode<ResourceWithCommentThreads>, index: number, templateData: IResourceTemplateData): void {
119
templateData.resourceLabel.setFile(node.element.resource);
120
templateData.separator.innerText = '\u00b7';
121
122
if (node.element.ownerLabel) {
123
templateData.owner.innerText = node.element.ownerLabel;
124
templateData.separator.style.display = 'inline';
125
} else {
126
templateData.owner.innerText = '';
127
templateData.separator.style.display = 'none';
128
}
129
}
130
131
disposeTemplate(templateData: IResourceTemplateData): void {
132
templateData.resourceLabel.dispose();
133
}
134
}
135
136
export class CommentsMenus implements IDisposable {
137
private contextKeyService: IContextKeyService | undefined;
138
139
constructor(
140
@IMenuService private readonly menuService: IMenuService
141
) { }
142
143
getResourceActions(element: CommentNode): { actions: IAction[] } {
144
const actions = this.getActions(MenuId.CommentsViewThreadActions, element);
145
return { actions: actions.primary };
146
}
147
148
getResourceContextActions(element: CommentNode): IAction[] {
149
return this.getActions(MenuId.CommentsViewThreadActions, element).secondary;
150
}
151
152
public setContextKeyService(service: IContextKeyService) {
153
this.contextKeyService = service;
154
}
155
156
private getActions(menuId: MenuId, element: CommentNode): { primary: IAction[]; secondary: IAction[] } {
157
if (!this.contextKeyService) {
158
return { primary: [], secondary: [] };
159
}
160
161
const overlay: [string, any][] = [
162
['commentController', element.owner],
163
['resourceScheme', element.resource.scheme],
164
['commentThread', element.contextValue],
165
['canReply', element.thread.canReply]
166
];
167
const contextKeyService = this.contextKeyService.createOverlay(overlay);
168
169
const menu = this.menuService.getMenuActions(menuId, contextKeyService, { shouldForwardArgs: true });
170
return getContextMenuActions(menu, 'inline');
171
}
172
173
dispose() {
174
this.contextKeyService = undefined;
175
}
176
}
177
178
export class CommentNodeRenderer implements IListRenderer<ITreeNode<CommentNode>, ICommentThreadTemplateData> {
179
templateId: string = 'comment-node';
180
181
constructor(
182
private actionViewItemProvider: IActionViewItemProvider,
183
private menus: CommentsMenus,
184
@IConfigurationService private readonly configurationService: IConfigurationService,
185
@IHoverService private readonly hoverService: IHoverService,
186
@IThemeService private themeService: IThemeService
187
) { }
188
189
renderTemplate(container: HTMLElement) {
190
const threadContainer = dom.append(container, dom.$('.comment-thread-container'));
191
const metadataContainer = dom.append(threadContainer, dom.$('.comment-metadata-container'));
192
const metadata = dom.append(metadataContainer, dom.$('.comment-metadata'));
193
194
const icon = dom.append(metadata, dom.$('.icon'));
195
const userNames = dom.append(metadata, dom.$('.user'));
196
const timestamp = new TimestampWidget(this.configurationService, this.hoverService, dom.append(metadata, dom.$('.timestamp-container')));
197
const relevance = dom.append(metadata, dom.$('.relevance'));
198
const separator = dom.append(metadata, dom.$('.separator'));
199
const commentPreview = dom.append(metadata, dom.$('.text'));
200
const rangeContainer = dom.append(metadata, dom.$('.range'));
201
const range = dom.$('p');
202
rangeContainer.appendChild(range);
203
204
const threadMetadata = {
205
icon,
206
userNames,
207
timestamp,
208
relevance,
209
separator,
210
commentPreview,
211
range
212
};
213
threadMetadata.separator.innerText = '\u00b7';
214
215
const actionsContainer = dom.append(metadataContainer, dom.$('.actions'));
216
const actionBar = new ActionBar(actionsContainer, {
217
actionViewItemProvider: this.actionViewItemProvider
218
});
219
220
const snippetContainer = dom.append(threadContainer, dom.$('.comment-snippet-container'));
221
const repliesMetadata = {
222
container: snippetContainer,
223
icon: dom.append(snippetContainer, dom.$('.icon')),
224
count: dom.append(snippetContainer, dom.$('.count')),
225
lastReplyDetail: dom.append(snippetContainer, dom.$('.reply-detail')),
226
separator: dom.append(snippetContainer, dom.$('.separator')),
227
timestamp: new TimestampWidget(this.configurationService, this.hoverService, dom.append(snippetContainer, dom.$('.timestamp-container'))),
228
};
229
repliesMetadata.separator.innerText = '\u00b7';
230
repliesMetadata.icon.classList.add(...ThemeIcon.asClassNameArray(Codicon.indent));
231
232
const disposables = [threadMetadata.timestamp, repliesMetadata.timestamp];
233
return { threadMetadata, repliesMetadata, actionBar, disposables };
234
}
235
236
private getCountString(commentCount: number): string {
237
if (commentCount > 2) {
238
return nls.localize('commentsCountReplies', "{0} replies", commentCount - 1);
239
} else if (commentCount === 2) {
240
return nls.localize('commentsCountReply', "1 reply");
241
} else {
242
return nls.localize('commentCount', "1 comment");
243
}
244
}
245
246
private getRenderedComment(commentBody: IMarkdownString) {
247
const renderedComment = renderMarkdown(commentBody, {}, document.createElement('span'));
248
const images = renderedComment.element.getElementsByTagName('img');
249
for (let i = 0; i < images.length; i++) {
250
const image = images[i];
251
const textDescription = dom.$('');
252
textDescription.textContent = image.alt ? nls.localize('imageWithLabel', "Image: {0}", image.alt) : nls.localize('image', "Image");
253
image.parentNode!.replaceChild(textDescription, image);
254
}
255
const headings = [...renderedComment.element.getElementsByTagName('h1'), ...renderedComment.element.getElementsByTagName('h2'), ...renderedComment.element.getElementsByTagName('h3'), ...renderedComment.element.getElementsByTagName('h4'), ...renderedComment.element.getElementsByTagName('h5'), ...renderedComment.element.getElementsByTagName('h6')];
256
for (const heading of headings) {
257
const textNode = document.createTextNode(heading.textContent || '');
258
heading.parentNode!.replaceChild(textNode, heading);
259
}
260
while ((renderedComment.element.children.length > 1) && (renderedComment.element.firstElementChild?.tagName === 'HR')) {
261
renderedComment.element.removeChild(renderedComment.element.firstElementChild);
262
}
263
return renderedComment;
264
}
265
266
private getIcon(threadState?: CommentThreadState): ThemeIcon {
267
if (threadState === CommentThreadState.Unresolved) {
268
return Codicon.commentUnresolved;
269
} else {
270
return Codicon.comment;
271
}
272
}
273
274
renderElement(node: ITreeNode<CommentNode>, index: number, templateData: ICommentThreadTemplateData): void {
275
templateData.actionBar.clear();
276
277
const commentCount = node.element.replies.length + 1;
278
if (node.element.threadRelevance === CommentThreadApplicability.Outdated) {
279
templateData.threadMetadata.relevance.style.display = '';
280
templateData.threadMetadata.relevance.innerText = nls.localize('outdated', "Outdated");
281
templateData.threadMetadata.separator.style.display = 'none';
282
} else {
283
templateData.threadMetadata.relevance.innerText = '';
284
templateData.threadMetadata.relevance.style.display = 'none';
285
templateData.threadMetadata.separator.style.display = '';
286
}
287
288
templateData.threadMetadata.icon.classList.remove(...Array.from(templateData.threadMetadata.icon.classList.values())
289
.filter(value => value.startsWith('codicon')));
290
templateData.threadMetadata.icon.classList.add(...ThemeIcon.asClassNameArray(this.getIcon(node.element.threadState)));
291
if (node.element.threadState !== undefined) {
292
const color = this.getCommentThreadWidgetStateColor(node.element.threadState, this.themeService.getColorTheme());
293
templateData.threadMetadata.icon.style.setProperty(commentViewThreadStateColorVar, `${color}`);
294
templateData.threadMetadata.icon.style.color = `var(${commentViewThreadStateColorVar})`;
295
}
296
templateData.threadMetadata.userNames.textContent = node.element.comment.userName;
297
templateData.threadMetadata.timestamp.setTimestamp(node.element.comment.timestamp ? new Date(node.element.comment.timestamp) : undefined);
298
const originalComment = node.element;
299
300
templateData.threadMetadata.commentPreview.innerText = '';
301
templateData.threadMetadata.commentPreview.style.height = '22px';
302
if (typeof originalComment.comment.body === 'string') {
303
templateData.threadMetadata.commentPreview.innerText = originalComment.comment.body;
304
} else {
305
const disposables = new DisposableStore();
306
templateData.disposables.push(disposables);
307
const renderedComment = this.getRenderedComment(originalComment.comment.body);
308
templateData.disposables.push(renderedComment);
309
for (let i = renderedComment.element.children.length - 1; i >= 1; i--) {
310
renderedComment.element.removeChild(renderedComment.element.children[i]);
311
}
312
templateData.threadMetadata.commentPreview.appendChild(renderedComment.element);
313
templateData.disposables.push(this.hoverService.setupManagedHover(getDefaultHoverDelegate('mouse'), templateData.threadMetadata.commentPreview, renderedComment.element.textContent ?? ''));
314
}
315
316
if (node.element.range) {
317
if (node.element.range.startLineNumber === node.element.range.endLineNumber) {
318
templateData.threadMetadata.range.textContent = nls.localize('commentLine', "[Ln {0}]", node.element.range.startLineNumber);
319
} else {
320
templateData.threadMetadata.range.textContent = nls.localize('commentRange', "[Ln {0}-{1}]", node.element.range.startLineNumber, node.element.range.endLineNumber);
321
}
322
}
323
324
const menuActions = this.menus.getResourceActions(node.element);
325
templateData.actionBar.push(menuActions.actions, { icon: true, label: false });
326
templateData.actionBar.context = {
327
commentControlHandle: node.element.controllerHandle,
328
commentThreadHandle: node.element.threadHandle,
329
$mid: MarshalledId.CommentThread
330
} satisfies MarshalledCommentThread;
331
332
if (!node.element.hasReply()) {
333
templateData.repliesMetadata.container.style.display = 'none';
334
return;
335
}
336
337
templateData.repliesMetadata.container.style.display = '';
338
templateData.repliesMetadata.count.textContent = this.getCountString(commentCount);
339
const lastComment = node.element.replies[node.element.replies.length - 1].comment;
340
templateData.repliesMetadata.lastReplyDetail.textContent = nls.localize('lastReplyFrom', "Last reply from {0}", lastComment.userName);
341
templateData.repliesMetadata.timestamp.setTimestamp(lastComment.timestamp ? new Date(lastComment.timestamp) : undefined);
342
}
343
344
private getCommentThreadWidgetStateColor(state: CommentThreadState | undefined, theme: IColorTheme): Color | undefined {
345
return (state !== undefined) ? getCommentThreadStateIconColor(state, theme) : undefined;
346
}
347
348
disposeTemplate(templateData: ICommentThreadTemplateData): void {
349
templateData.disposables.forEach(disposeable => disposeable.dispose());
350
templateData.actionBar.dispose();
351
}
352
}
353
354
export interface ICommentsListOptions extends IWorkbenchAsyncDataTreeOptions<any, any> {
355
overrideStyles?: IStyleOverride<IListStyles>;
356
}
357
358
const enum FilterDataType {
359
Resource,
360
Comment
361
}
362
363
interface ResourceFilterData {
364
type: FilterDataType.Resource;
365
uriMatches: IMatch[];
366
}
367
368
interface CommentFilterData {
369
type: FilterDataType.Comment;
370
textMatches: IMatch[];
371
}
372
373
type FilterData = ResourceFilterData | CommentFilterData;
374
375
export class Filter implements ITreeFilter<ResourceWithCommentThreads | CommentNode, FilterData> {
376
377
constructor(public options: FilterOptions) { }
378
379
filter(element: ResourceWithCommentThreads | CommentNode, parentVisibility: TreeVisibility): TreeFilterResult<FilterData> {
380
if (this.options.filter === '' && this.options.showResolved && this.options.showUnresolved) {
381
return TreeVisibility.Visible;
382
}
383
384
if (element instanceof ResourceWithCommentThreads) {
385
return this.filterResourceMarkers(element);
386
} else {
387
return this.filterCommentNode(element, parentVisibility);
388
}
389
}
390
391
private filterResourceMarkers(resourceMarkers: ResourceWithCommentThreads): TreeFilterResult<FilterData> {
392
// Filter by text. Do not apply negated filters on resources instead use exclude patterns
393
if (this.options.textFilter.text && !this.options.textFilter.negate) {
394
const uriMatches = FilterOptions._filter(this.options.textFilter.text, basename(resourceMarkers.resource));
395
if (uriMatches) {
396
return { visibility: true, data: { type: FilterDataType.Resource, uriMatches: uriMatches || [] } };
397
}
398
}
399
400
return TreeVisibility.Recurse;
401
}
402
403
private filterCommentNode(comment: CommentNode, parentVisibility: TreeVisibility): TreeFilterResult<FilterData> {
404
const matchesResolvedState = (comment.threadState === undefined) || (this.options.showResolved && CommentThreadState.Resolved === comment.threadState) ||
405
(this.options.showUnresolved && CommentThreadState.Unresolved === comment.threadState);
406
407
if (!matchesResolvedState) {
408
return false;
409
}
410
411
if (!this.options.textFilter.text) {
412
return true;
413
}
414
415
const textMatches =
416
// Check body of comment for value
417
FilterOptions._messageFilter(this.options.textFilter.text, typeof comment.comment.body === 'string' ? comment.comment.body : comment.comment.body.value)
418
// Check first user for value
419
|| FilterOptions._messageFilter(this.options.textFilter.text, comment.comment.userName)
420
// Check all replies for value
421
|| (comment.replies.map(reply => {
422
// Check user for value
423
return FilterOptions._messageFilter(this.options.textFilter.text, reply.comment.userName)
424
// Check body of reply for value
425
|| FilterOptions._messageFilter(this.options.textFilter.text, typeof reply.comment.body === 'string' ? reply.comment.body : reply.comment.body.value);
426
}).filter(value => !!value) as IMatch[][]).flat();
427
428
// Matched and not negated
429
if (textMatches.length && !this.options.textFilter.negate) {
430
return { visibility: true, data: { type: FilterDataType.Comment, textMatches } };
431
}
432
433
// Matched and negated - exclude it only if parent visibility is not set
434
if (textMatches.length && this.options.textFilter.negate && parentVisibility === TreeVisibility.Recurse) {
435
return false;
436
}
437
438
// Not matched and negated - include it only if parent visibility is not set
439
if ((textMatches.length === 0) && this.options.textFilter.negate && parentVisibility === TreeVisibility.Recurse) {
440
return true;
441
}
442
443
return parentVisibility;
444
}
445
}
446
447
export class CommentsList extends WorkbenchObjectTree<CommentsModel | ResourceWithCommentThreads | CommentNode, any> {
448
private readonly menus: CommentsMenus;
449
450
constructor(
451
labels: ResourceLabels,
452
container: HTMLElement,
453
options: ICommentsListOptions,
454
@IContextKeyService contextKeyService: IContextKeyService,
455
@IListService listService: IListService,
456
@IInstantiationService instantiationService: IInstantiationService,
457
@IConfigurationService configurationService: IConfigurationService,
458
@IContextMenuService private readonly contextMenuService: IContextMenuService,
459
@IKeybindingService private readonly keybindingService: IKeybindingService
460
) {
461
const delegate = new CommentsModelVirtualDelegate();
462
const actionViewItemProvider = createActionViewItem.bind(undefined, instantiationService);
463
const menus = instantiationService.createInstance(CommentsMenus);
464
menus.setContextKeyService(contextKeyService);
465
const renderers = [
466
instantiationService.createInstance(ResourceWithCommentsRenderer, labels),
467
instantiationService.createInstance(CommentNodeRenderer, actionViewItemProvider, menus)
468
];
469
470
super(
471
'CommentsTree',
472
container,
473
delegate,
474
renderers,
475
{
476
accessibilityProvider: options.accessibilityProvider,
477
identityProvider: {
478
getId: (element: any) => {
479
if (element instanceof CommentsModel) {
480
return 'root';
481
}
482
if (element instanceof ResourceWithCommentThreads) {
483
return `${element.uniqueOwner}-${element.id}`;
484
}
485
if (element instanceof CommentNode) {
486
return `${element.uniqueOwner}-${element.resource.toString()}-${element.threadId}-${element.comment.uniqueIdInThread}` + (element.isRoot ? '-root' : '');
487
}
488
return '';
489
}
490
},
491
expandOnlyOnTwistieClick: true,
492
collapseByDefault: false,
493
overrideStyles: options.overrideStyles,
494
filter: options.filter,
495
sorter: options.sorter,
496
findWidgetEnabled: false,
497
multipleSelectionSupport: false,
498
},
499
instantiationService,
500
contextKeyService,
501
listService,
502
configurationService,
503
);
504
this.menus = menus;
505
this.disposables.add(this.onContextMenu(e => this.commentsOnContextMenu(e)));
506
}
507
508
private commentsOnContextMenu(treeEvent: ITreeContextMenuEvent<CommentsModel | ResourceWithCommentThreads | CommentNode | null>): void {
509
const node: CommentsModel | ResourceWithCommentThreads | CommentNode | null = treeEvent.element;
510
if (!(node instanceof CommentNode)) {
511
return;
512
}
513
const event: UIEvent = treeEvent.browserEvent;
514
515
event.preventDefault();
516
event.stopPropagation();
517
518
this.setFocus([node]);
519
const actions = this.menus.getResourceContextActions(node);
520
if (!actions.length) {
521
return;
522
}
523
this.contextMenuService.showContextMenu({
524
getAnchor: () => treeEvent.anchor,
525
getActions: () => actions,
526
getActionViewItem: (action) => {
527
const keybinding = this.keybindingService.lookupKeybinding(action.id);
528
if (keybinding) {
529
return new ActionViewItem(action, action, { label: true, keybinding: keybinding.getLabel() });
530
}
531
return undefined;
532
},
533
onHide: (wasCancelled?: boolean) => {
534
if (wasCancelled) {
535
this.domFocus();
536
}
537
},
538
getActionsContext: (): MarshalledCommentThreadInternal => ({
539
commentControlHandle: node.controllerHandle,
540
commentThreadHandle: node.threadHandle,
541
$mid: MarshalledId.CommentThread,
542
thread: node.thread
543
})
544
});
545
}
546
547
filterComments(): void {
548
this.refilter();
549
}
550
551
getVisibleItemCount(): number {
552
let filtered = 0;
553
const root = this.getNode();
554
555
for (const resourceNode of root.children) {
556
for (const commentNode of resourceNode.children) {
557
if (commentNode.visible && resourceNode.visible) {
558
filtered++;
559
}
560
}
561
}
562
563
return filtered;
564
}
565
}
566
567