Path: blob/main/src/vs/workbench/contrib/comments/browser/commentsTreeViewer.ts
5241 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import * as dom from '../../../../base/browser/dom.js';6import * as nls from '../../../../nls.js';7import { renderMarkdown } from '../../../../base/browser/markdownRenderer.js';8import { IDisposable, DisposableStore } from '../../../../base/common/lifecycle.js';9import { IResourceLabel, ResourceLabels } from '../../../browser/labels.js';10import { CommentNode, ResourceWithCommentThreads } from '../common/commentModel.js';11import { ITreeContextMenuEvent, ITreeFilter, ITreeNode, TreeFilterResult, TreeVisibility } from '../../../../base/browser/ui/tree/tree.js';12import { IListVirtualDelegate, IListRenderer } from '../../../../base/browser/ui/list/list.js';13import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';14import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js';15import { IListService, IWorkbenchAsyncDataTreeOptions, WorkbenchObjectTree } from '../../../../platform/list/browser/listService.js';16import { IColorTheme, IThemeService } from '../../../../platform/theme/common/themeService.js';17import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';18import { TimestampWidget } from './timestamp.js';19import { Codicon } from '../../../../base/common/codicons.js';20import { ThemeIcon } from '../../../../base/common/themables.js';21import { IMarkdownString } from '../../../../base/common/htmlContent.js';22import { commentViewThreadStateColorVar, getCommentThreadStateIconColor } from './commentColors.js';23import { CommentThreadApplicability, CommentThreadState, CommentState } from '../../../../editor/common/languages.js';24import { Color } from '../../../../base/common/color.js';25import { IMatch } from '../../../../base/common/filters.js';26import { FilterOptions } from './commentsFilterOptions.js';27import { basename } from '../../../../base/common/resources.js';28import { IStyleOverride } from '../../../../platform/theme/browser/defaultStyles.js';29import { IListStyles } from '../../../../base/browser/ui/list/listWidget.js';30import { ILocalizedString } from '../../../../platform/action/common/action.js';31import { CommentsModel } from './commentsModel.js';32import { getDefaultHoverDelegate } from '../../../../base/browser/ui/hover/hoverDelegateFactory.js';33import { ActionBar, IActionViewItemProvider } from '../../../../base/browser/ui/actionbar/actionbar.js';34import { createActionViewItem, getContextMenuActions } from '../../../../platform/actions/browser/menuEntryActionViewItem.js';35import { IMenuService, MenuId } from '../../../../platform/actions/common/actions.js';36import { IAction } from '../../../../base/common/actions.js';37import { MarshalledId } from '../../../../base/common/marshallingIds.js';38import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js';39import { ActionViewItem } from '../../../../base/browser/ui/actionbar/actionViewItems.js';40import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js';41import { MarshalledCommentThread, MarshalledCommentThreadInternal } from '../../../common/comments.js';42import { IHoverService } from '../../../../platform/hover/browser/hover.js';4344export const COMMENTS_VIEW_ID = 'workbench.panel.comments';45export const COMMENTS_VIEW_STORAGE_ID = 'Comments';46export const COMMENTS_VIEW_TITLE: ILocalizedString = nls.localize2('comments.view.title', "Comments");4748interface IResourceTemplateData {49resourceLabel: IResourceLabel;50separator: HTMLElement;51owner: HTMLElement;52}5354interface ICommentThreadTemplateData {55threadMetadata: {56relevance: HTMLElement;57icon: HTMLElement;58userNames: HTMLSpanElement;59timestamp: TimestampWidget;60separator: HTMLElement;61commentPreview: HTMLSpanElement;62range: HTMLElement;63};64repliesMetadata: {65container: HTMLElement;66icon: HTMLElement;67count: HTMLSpanElement;68lastReplyDetail: HTMLSpanElement;69separator: HTMLElement;70timestamp: TimestampWidget;71};72actionBar: ActionBar;73disposables: IDisposable[];74}7576class CommentsModelVirtualDelegate implements IListVirtualDelegate<ResourceWithCommentThreads | CommentNode> {77private static readonly RESOURCE_ID = 'resource-with-comments';78private static readonly COMMENT_ID = 'comment-node';798081getHeight(element: any): number {82if ((element instanceof CommentNode) && element.hasReply()) {83return 44;84}85return 22;86}8788public getTemplateId(element: any): string {89if (element instanceof ResourceWithCommentThreads) {90return CommentsModelVirtualDelegate.RESOURCE_ID;91}92if (element instanceof CommentNode) {93return CommentsModelVirtualDelegate.COMMENT_ID;94}9596return '';97}98}99100export class ResourceWithCommentsRenderer implements IListRenderer<ITreeNode<ResourceWithCommentThreads>, IResourceTemplateData> {101templateId: string = 'resource-with-comments';102103constructor(104private labels: ResourceLabels105) {106}107108renderTemplate(container: HTMLElement) {109const labelContainer = dom.append(container, dom.$('.resource-container'));110const resourceLabel = this.labels.create(labelContainer);111const separator = dom.append(labelContainer, dom.$('.separator'));112const owner = labelContainer.appendChild(dom.$('.owner'));113114return { resourceLabel, owner, separator };115}116117renderElement(node: ITreeNode<ResourceWithCommentThreads>, index: number, templateData: IResourceTemplateData): void {118templateData.resourceLabel.setFile(node.element.resource);119templateData.separator.innerText = '\u00b7';120121if (node.element.ownerLabel) {122templateData.owner.innerText = node.element.ownerLabel;123templateData.separator.style.display = 'inline';124} else {125templateData.owner.innerText = '';126templateData.separator.style.display = 'none';127}128}129130disposeTemplate(templateData: IResourceTemplateData): void {131templateData.resourceLabel.dispose();132}133}134135export class CommentsMenus implements IDisposable {136private contextKeyService: IContextKeyService | undefined;137138constructor(139@IMenuService private readonly menuService: IMenuService140) { }141142getResourceActions(element: CommentNode): { actions: IAction[] } {143const actions = this.getActions(MenuId.CommentsViewThreadActions, element);144return { actions: actions.primary };145}146147getResourceContextActions(element: CommentNode): IAction[] {148return this.getActions(MenuId.CommentsViewThreadActions, element).secondary;149}150151public setContextKeyService(service: IContextKeyService) {152this.contextKeyService = service;153}154155private getActions(menuId: MenuId, element: CommentNode): { primary: IAction[]; secondary: IAction[] } {156if (!this.contextKeyService) {157return { primary: [], secondary: [] };158}159160const overlay: [string, any][] = [161['commentController', element.owner],162['resourceScheme', element.resource.scheme],163['commentThread', element.contextValue],164['canReply', element.thread.canReply]165];166const contextKeyService = this.contextKeyService.createOverlay(overlay);167168const menu = this.menuService.getMenuActions(menuId, contextKeyService, { shouldForwardArgs: true });169return getContextMenuActions(menu, 'inline');170}171172dispose() {173this.contextKeyService = undefined;174}175}176177export class CommentNodeRenderer implements IListRenderer<ITreeNode<CommentNode>, ICommentThreadTemplateData> {178templateId: string = 'comment-node';179180constructor(181private actionViewItemProvider: IActionViewItemProvider,182private menus: CommentsMenus,183@IConfigurationService private readonly configurationService: IConfigurationService,184@IHoverService private readonly hoverService: IHoverService,185@IThemeService private themeService: IThemeService186) { }187188renderTemplate(container: HTMLElement) {189const threadContainer = dom.append(container, dom.$('.comment-thread-container'));190const metadataContainer = dom.append(threadContainer, dom.$('.comment-metadata-container'));191const metadata = dom.append(metadataContainer, dom.$('.comment-metadata'));192193const icon = dom.append(metadata, dom.$('.icon'));194const userNames = dom.append(metadata, dom.$('.user'));195const timestamp = new TimestampWidget(this.configurationService, this.hoverService, dom.append(metadata, dom.$('.timestamp-container')));196const relevance = dom.append(metadata, dom.$('.relevance'));197const separator = dom.append(metadata, dom.$('.separator'));198const commentPreview = dom.append(metadata, dom.$('.text'));199const rangeContainer = dom.append(metadata, dom.$('.range'));200const range = dom.$('p');201rangeContainer.appendChild(range);202203const threadMetadata = {204icon,205userNames,206timestamp,207relevance,208separator,209commentPreview,210range211};212threadMetadata.separator.innerText = '\u00b7';213214const actionsContainer = dom.append(metadataContainer, dom.$('.actions'));215const actionBar = new ActionBar(actionsContainer, {216actionViewItemProvider: this.actionViewItemProvider217});218219const snippetContainer = dom.append(threadContainer, dom.$('.comment-snippet-container'));220const repliesMetadata = {221container: snippetContainer,222icon: dom.append(snippetContainer, dom.$('.icon')),223count: dom.append(snippetContainer, dom.$('.count')),224lastReplyDetail: dom.append(snippetContainer, dom.$('.reply-detail')),225separator: dom.append(snippetContainer, dom.$('.separator')),226timestamp: new TimestampWidget(this.configurationService, this.hoverService, dom.append(snippetContainer, dom.$('.timestamp-container'))),227};228repliesMetadata.separator.innerText = '\u00b7';229repliesMetadata.icon.classList.add(...ThemeIcon.asClassNameArray(Codicon.indent));230231const disposables = [threadMetadata.timestamp, repliesMetadata.timestamp];232return { threadMetadata, repliesMetadata, actionBar, disposables };233}234235private getCountString(commentCount: number): string {236if (commentCount > 2) {237return nls.localize('commentsCountReplies', "{0} replies", commentCount - 1);238} else if (commentCount === 2) {239return nls.localize('commentsCountReply', "1 reply");240} else {241return nls.localize('commentCount', "1 comment");242}243}244245private getRenderedComment(commentBody: IMarkdownString) {246const renderedComment = renderMarkdown(commentBody, {}, document.createElement('span'));247// eslint-disable-next-line no-restricted-syntax248const images = renderedComment.element.getElementsByTagName('img');249for (let i = 0; i < images.length; i++) {250const image = images[i];251const textDescription = dom.$('');252textDescription.textContent = image.alt ? nls.localize('imageWithLabel', "Image: {0}", image.alt) : nls.localize('image', "Image");253image.replaceWith(textDescription);254}255// eslint-disable-next-line no-restricted-syntax256const 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')];257for (const heading of headings) {258const textNode = document.createTextNode(heading.textContent || '');259heading.replaceWith(textNode);260}261while ((renderedComment.element.children.length > 1) && (renderedComment.element.firstElementChild?.tagName === 'HR')) {262renderedComment.element.removeChild(renderedComment.element.firstElementChild);263}264return renderedComment;265}266267private getIcon(threadState?: CommentThreadState, hasDraft?: boolean): ThemeIcon {268// Priority: draft > unresolved > resolved269if (hasDraft) {270return Codicon.commentDraft;271} else if (threadState === CommentThreadState.Unresolved) {272return Codicon.commentUnresolved;273} else {274return Codicon.comment;275}276}277278renderElement(node: ITreeNode<CommentNode>, index: number, templateData: ICommentThreadTemplateData): void {279templateData.actionBar.clear();280281const commentCount = node.element.replies.length + 1;282if (node.element.threadRelevance === CommentThreadApplicability.Outdated) {283templateData.threadMetadata.relevance.style.display = '';284templateData.threadMetadata.relevance.innerText = nls.localize('outdated', "Outdated");285templateData.threadMetadata.separator.style.display = 'none';286} else {287templateData.threadMetadata.relevance.innerText = '';288templateData.threadMetadata.relevance.style.display = 'none';289templateData.threadMetadata.separator.style.display = '';290}291292templateData.threadMetadata.icon.classList.remove(...Array.from(templateData.threadMetadata.icon.classList.values())293.filter(value => value.startsWith('codicon')));294// Check if any comment in the thread has draft state295const hasDraft = node.element.thread.comments?.some(comment => comment.state === CommentState.Draft);296templateData.threadMetadata.icon.classList.add(...ThemeIcon.asClassNameArray(this.getIcon(node.element.threadState, hasDraft)));297if (node.element.threadState !== undefined) {298const color = this.getCommentThreadWidgetStateColor(node.element.threadState, this.themeService.getColorTheme());299templateData.threadMetadata.icon.style.setProperty(commentViewThreadStateColorVar, `${color}`);300templateData.threadMetadata.icon.style.color = `var(${commentViewThreadStateColorVar})`;301}302templateData.threadMetadata.userNames.textContent = node.element.comment.userName;303templateData.threadMetadata.timestamp.setTimestamp(node.element.comment.timestamp ? new Date(node.element.comment.timestamp) : undefined);304const originalComment = node.element;305306templateData.threadMetadata.commentPreview.innerText = '';307templateData.threadMetadata.commentPreview.style.height = '22px';308if (typeof originalComment.comment.body === 'string') {309templateData.threadMetadata.commentPreview.innerText = originalComment.comment.body;310} else {311const disposables = new DisposableStore();312templateData.disposables.push(disposables);313const renderedComment = this.getRenderedComment(originalComment.comment.body);314templateData.disposables.push(renderedComment);315for (let i = renderedComment.element.children.length - 1; i >= 1; i--) {316renderedComment.element.removeChild(renderedComment.element.children[i]);317}318templateData.threadMetadata.commentPreview.appendChild(renderedComment.element);319templateData.disposables.push(this.hoverService.setupManagedHover(getDefaultHoverDelegate('mouse'), templateData.threadMetadata.commentPreview, renderedComment.element.textContent ?? ''));320}321322if (node.element.range) {323if (node.element.range.startLineNumber === node.element.range.endLineNumber) {324templateData.threadMetadata.range.textContent = nls.localize('commentLine', "[Ln {0}]", node.element.range.startLineNumber);325} else {326templateData.threadMetadata.range.textContent = nls.localize('commentRange', "[Ln {0}-{1}]", node.element.range.startLineNumber, node.element.range.endLineNumber);327}328}329330const menuActions = this.menus.getResourceActions(node.element);331templateData.actionBar.push(menuActions.actions, { icon: true, label: false });332templateData.actionBar.context = {333commentControlHandle: node.element.controllerHandle,334commentThreadHandle: node.element.threadHandle,335$mid: MarshalledId.CommentThread336} satisfies MarshalledCommentThread;337338if (!node.element.hasReply()) {339templateData.repliesMetadata.container.style.display = 'none';340return;341}342343templateData.repliesMetadata.container.style.display = '';344templateData.repliesMetadata.count.textContent = this.getCountString(commentCount);345const lastComment = node.element.replies[node.element.replies.length - 1].comment;346templateData.repliesMetadata.lastReplyDetail.textContent = nls.localize('lastReplyFrom', "Last reply from {0}", lastComment.userName);347templateData.repliesMetadata.timestamp.setTimestamp(lastComment.timestamp ? new Date(lastComment.timestamp) : undefined);348}349350private getCommentThreadWidgetStateColor(state: CommentThreadState | undefined, theme: IColorTheme): Color | undefined {351return (state !== undefined) ? getCommentThreadStateIconColor(state, theme) : undefined;352}353354disposeTemplate(templateData: ICommentThreadTemplateData): void {355templateData.disposables.forEach(disposeable => disposeable.dispose());356templateData.actionBar.dispose();357}358}359360export interface ICommentsListOptions extends IWorkbenchAsyncDataTreeOptions<any, any> {361overrideStyles?: IStyleOverride<IListStyles>;362}363364const enum FilterDataType {365Resource,366Comment367}368369interface ResourceFilterData {370type: FilterDataType.Resource;371uriMatches: IMatch[];372}373374interface CommentFilterData {375type: FilterDataType.Comment;376textMatches: IMatch[];377}378379type FilterData = ResourceFilterData | CommentFilterData;380381export class Filter implements ITreeFilter<ResourceWithCommentThreads | CommentNode, FilterData> {382383constructor(public options: FilterOptions) { }384385filter(element: ResourceWithCommentThreads | CommentNode, parentVisibility: TreeVisibility): TreeFilterResult<FilterData> {386if (this.options.filter === '' && this.options.showResolved && this.options.showUnresolved) {387return TreeVisibility.Visible;388}389390if (element instanceof ResourceWithCommentThreads) {391return this.filterResourceMarkers(element);392} else {393return this.filterCommentNode(element, parentVisibility);394}395}396397private filterResourceMarkers(resourceMarkers: ResourceWithCommentThreads): TreeFilterResult<FilterData> {398// Filter by text. Do not apply negated filters on resources instead use exclude patterns399if (this.options.textFilter.text && !this.options.textFilter.negate) {400const uriMatches = FilterOptions._filter(this.options.textFilter.text, basename(resourceMarkers.resource));401if (uriMatches) {402return { visibility: true, data: { type: FilterDataType.Resource, uriMatches: uriMatches || [] } };403}404}405406return TreeVisibility.Recurse;407}408409private filterCommentNode(comment: CommentNode, parentVisibility: TreeVisibility): TreeFilterResult<FilterData> {410const matchesResolvedState = (comment.threadState === undefined) || (this.options.showResolved && CommentThreadState.Resolved === comment.threadState) ||411(this.options.showUnresolved && CommentThreadState.Unresolved === comment.threadState);412413if (!matchesResolvedState) {414return false;415}416417if (!this.options.textFilter.text) {418return true;419}420421const textMatches =422// Check body of comment for value423FilterOptions._messageFilter(this.options.textFilter.text, typeof comment.comment.body === 'string' ? comment.comment.body : comment.comment.body.value)424// Check first user for value425|| FilterOptions._messageFilter(this.options.textFilter.text, comment.comment.userName)426// Check all replies for value427|| (comment.replies.map(reply => {428// Check user for value429return FilterOptions._messageFilter(this.options.textFilter.text, reply.comment.userName)430// Check body of reply for value431|| FilterOptions._messageFilter(this.options.textFilter.text, typeof reply.comment.body === 'string' ? reply.comment.body : reply.comment.body.value);432}).filter(value => !!value) as IMatch[][]).flat();433434// Matched and not negated435if (textMatches.length && !this.options.textFilter.negate) {436return { visibility: true, data: { type: FilterDataType.Comment, textMatches } };437}438439// Matched and negated - exclude it only if parent visibility is not set440if (textMatches.length && this.options.textFilter.negate && parentVisibility === TreeVisibility.Recurse) {441return false;442}443444// Not matched and negated - include it only if parent visibility is not set445if ((textMatches.length === 0) && this.options.textFilter.negate && parentVisibility === TreeVisibility.Recurse) {446return true;447}448449return parentVisibility;450}451}452453export class CommentsList extends WorkbenchObjectTree<CommentsModel | ResourceWithCommentThreads | CommentNode, any> {454private readonly menus: CommentsMenus;455456constructor(457labels: ResourceLabels,458container: HTMLElement,459options: ICommentsListOptions,460@IContextKeyService contextKeyService: IContextKeyService,461@IListService listService: IListService,462@IInstantiationService instantiationService: IInstantiationService,463@IConfigurationService configurationService: IConfigurationService,464@IContextMenuService private readonly contextMenuService: IContextMenuService,465@IKeybindingService private readonly keybindingService: IKeybindingService466) {467const delegate = new CommentsModelVirtualDelegate();468const actionViewItemProvider = createActionViewItem.bind(undefined, instantiationService);469const menus = instantiationService.createInstance(CommentsMenus);470menus.setContextKeyService(contextKeyService);471const renderers = [472instantiationService.createInstance(ResourceWithCommentsRenderer, labels),473instantiationService.createInstance(CommentNodeRenderer, actionViewItemProvider, menus)474];475476super(477'CommentsTree',478container,479delegate,480renderers,481{482accessibilityProvider: options.accessibilityProvider,483identityProvider: {484getId: (element: any) => {485if (element instanceof CommentsModel) {486return 'root';487}488if (element instanceof ResourceWithCommentThreads) {489return `${element.uniqueOwner}-${element.id}`;490}491if (element instanceof CommentNode) {492return `${element.uniqueOwner}-${element.resource.toString()}-${element.threadId}-${element.comment.uniqueIdInThread}` + (element.isRoot ? '-root' : '');493}494return '';495}496},497expandOnlyOnTwistieClick: true,498collapseByDefault: false,499overrideStyles: options.overrideStyles,500filter: options.filter,501sorter: options.sorter,502findWidgetEnabled: false,503multipleSelectionSupport: false,504},505instantiationService,506contextKeyService,507listService,508configurationService,509);510this.menus = menus;511this.disposables.add(this.onContextMenu(e => this.commentsOnContextMenu(e)));512}513514private commentsOnContextMenu(treeEvent: ITreeContextMenuEvent<CommentsModel | ResourceWithCommentThreads | CommentNode | null>): void {515const node: CommentsModel | ResourceWithCommentThreads | CommentNode | null = treeEvent.element;516if (!(node instanceof CommentNode)) {517return;518}519const event: UIEvent = treeEvent.browserEvent;520521event.preventDefault();522event.stopPropagation();523524this.setFocus([node]);525const actions = this.menus.getResourceContextActions(node);526if (!actions.length) {527return;528}529this.contextMenuService.showContextMenu({530getAnchor: () => treeEvent.anchor,531getActions: () => actions,532getActionViewItem: (action) => {533const keybinding = this.keybindingService.lookupKeybinding(action.id);534if (keybinding) {535return new ActionViewItem(action, action, { label: true, keybinding: keybinding.getLabel() });536}537return undefined;538},539onHide: (wasCancelled?: boolean) => {540if (wasCancelled) {541this.domFocus();542}543},544getActionsContext: (): MarshalledCommentThreadInternal => ({545commentControlHandle: node.controllerHandle,546commentThreadHandle: node.threadHandle,547$mid: MarshalledId.CommentThread,548thread: node.thread549})550});551}552553filterComments(): void {554this.refilter();555}556557getVisibleItemCount(): number {558let filtered = 0;559const root = this.getNode();560561for (const resourceNode of root.children) {562for (const commentNode of resourceNode.children) {563if (commentNode.visible && resourceNode.visible) {564filtered++;565}566}567}568569return filtered;570}571}572573574