Path: blob/main/src/vs/workbench/contrib/comments/browser/commentsTreeViewer.ts
3296 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 } 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'));247const images = renderedComment.element.getElementsByTagName('img');248for (let i = 0; i < images.length; i++) {249const image = images[i];250const textDescription = dom.$('');251textDescription.textContent = image.alt ? nls.localize('imageWithLabel', "Image: {0}", image.alt) : nls.localize('image', "Image");252image.parentNode!.replaceChild(textDescription, image);253}254const 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')];255for (const heading of headings) {256const textNode = document.createTextNode(heading.textContent || '');257heading.parentNode!.replaceChild(textNode, heading);258}259while ((renderedComment.element.children.length > 1) && (renderedComment.element.firstElementChild?.tagName === 'HR')) {260renderedComment.element.removeChild(renderedComment.element.firstElementChild);261}262return renderedComment;263}264265private getIcon(threadState?: CommentThreadState): ThemeIcon {266if (threadState === CommentThreadState.Unresolved) {267return Codicon.commentUnresolved;268} else {269return Codicon.comment;270}271}272273renderElement(node: ITreeNode<CommentNode>, index: number, templateData: ICommentThreadTemplateData): void {274templateData.actionBar.clear();275276const commentCount = node.element.replies.length + 1;277if (node.element.threadRelevance === CommentThreadApplicability.Outdated) {278templateData.threadMetadata.relevance.style.display = '';279templateData.threadMetadata.relevance.innerText = nls.localize('outdated', "Outdated");280templateData.threadMetadata.separator.style.display = 'none';281} else {282templateData.threadMetadata.relevance.innerText = '';283templateData.threadMetadata.relevance.style.display = 'none';284templateData.threadMetadata.separator.style.display = '';285}286287templateData.threadMetadata.icon.classList.remove(...Array.from(templateData.threadMetadata.icon.classList.values())288.filter(value => value.startsWith('codicon')));289templateData.threadMetadata.icon.classList.add(...ThemeIcon.asClassNameArray(this.getIcon(node.element.threadState)));290if (node.element.threadState !== undefined) {291const color = this.getCommentThreadWidgetStateColor(node.element.threadState, this.themeService.getColorTheme());292templateData.threadMetadata.icon.style.setProperty(commentViewThreadStateColorVar, `${color}`);293templateData.threadMetadata.icon.style.color = `var(${commentViewThreadStateColorVar})`;294}295templateData.threadMetadata.userNames.textContent = node.element.comment.userName;296templateData.threadMetadata.timestamp.setTimestamp(node.element.comment.timestamp ? new Date(node.element.comment.timestamp) : undefined);297const originalComment = node.element;298299templateData.threadMetadata.commentPreview.innerText = '';300templateData.threadMetadata.commentPreview.style.height = '22px';301if (typeof originalComment.comment.body === 'string') {302templateData.threadMetadata.commentPreview.innerText = originalComment.comment.body;303} else {304const disposables = new DisposableStore();305templateData.disposables.push(disposables);306const renderedComment = this.getRenderedComment(originalComment.comment.body);307templateData.disposables.push(renderedComment);308for (let i = renderedComment.element.children.length - 1; i >= 1; i--) {309renderedComment.element.removeChild(renderedComment.element.children[i]);310}311templateData.threadMetadata.commentPreview.appendChild(renderedComment.element);312templateData.disposables.push(this.hoverService.setupManagedHover(getDefaultHoverDelegate('mouse'), templateData.threadMetadata.commentPreview, renderedComment.element.textContent ?? ''));313}314315if (node.element.range) {316if (node.element.range.startLineNumber === node.element.range.endLineNumber) {317templateData.threadMetadata.range.textContent = nls.localize('commentLine', "[Ln {0}]", node.element.range.startLineNumber);318} else {319templateData.threadMetadata.range.textContent = nls.localize('commentRange', "[Ln {0}-{1}]", node.element.range.startLineNumber, node.element.range.endLineNumber);320}321}322323const menuActions = this.menus.getResourceActions(node.element);324templateData.actionBar.push(menuActions.actions, { icon: true, label: false });325templateData.actionBar.context = {326commentControlHandle: node.element.controllerHandle,327commentThreadHandle: node.element.threadHandle,328$mid: MarshalledId.CommentThread329} satisfies MarshalledCommentThread;330331if (!node.element.hasReply()) {332templateData.repliesMetadata.container.style.display = 'none';333return;334}335336templateData.repliesMetadata.container.style.display = '';337templateData.repliesMetadata.count.textContent = this.getCountString(commentCount);338const lastComment = node.element.replies[node.element.replies.length - 1].comment;339templateData.repliesMetadata.lastReplyDetail.textContent = nls.localize('lastReplyFrom', "Last reply from {0}", lastComment.userName);340templateData.repliesMetadata.timestamp.setTimestamp(lastComment.timestamp ? new Date(lastComment.timestamp) : undefined);341}342343private getCommentThreadWidgetStateColor(state: CommentThreadState | undefined, theme: IColorTheme): Color | undefined {344return (state !== undefined) ? getCommentThreadStateIconColor(state, theme) : undefined;345}346347disposeTemplate(templateData: ICommentThreadTemplateData): void {348templateData.disposables.forEach(disposeable => disposeable.dispose());349templateData.actionBar.dispose();350}351}352353export interface ICommentsListOptions extends IWorkbenchAsyncDataTreeOptions<any, any> {354overrideStyles?: IStyleOverride<IListStyles>;355}356357const enum FilterDataType {358Resource,359Comment360}361362interface ResourceFilterData {363type: FilterDataType.Resource;364uriMatches: IMatch[];365}366367interface CommentFilterData {368type: FilterDataType.Comment;369textMatches: IMatch[];370}371372type FilterData = ResourceFilterData | CommentFilterData;373374export class Filter implements ITreeFilter<ResourceWithCommentThreads | CommentNode, FilterData> {375376constructor(public options: FilterOptions) { }377378filter(element: ResourceWithCommentThreads | CommentNode, parentVisibility: TreeVisibility): TreeFilterResult<FilterData> {379if (this.options.filter === '' && this.options.showResolved && this.options.showUnresolved) {380return TreeVisibility.Visible;381}382383if (element instanceof ResourceWithCommentThreads) {384return this.filterResourceMarkers(element);385} else {386return this.filterCommentNode(element, parentVisibility);387}388}389390private filterResourceMarkers(resourceMarkers: ResourceWithCommentThreads): TreeFilterResult<FilterData> {391// Filter by text. Do not apply negated filters on resources instead use exclude patterns392if (this.options.textFilter.text && !this.options.textFilter.negate) {393const uriMatches = FilterOptions._filter(this.options.textFilter.text, basename(resourceMarkers.resource));394if (uriMatches) {395return { visibility: true, data: { type: FilterDataType.Resource, uriMatches: uriMatches || [] } };396}397}398399return TreeVisibility.Recurse;400}401402private filterCommentNode(comment: CommentNode, parentVisibility: TreeVisibility): TreeFilterResult<FilterData> {403const matchesResolvedState = (comment.threadState === undefined) || (this.options.showResolved && CommentThreadState.Resolved === comment.threadState) ||404(this.options.showUnresolved && CommentThreadState.Unresolved === comment.threadState);405406if (!matchesResolvedState) {407return false;408}409410if (!this.options.textFilter.text) {411return true;412}413414const textMatches =415// Check body of comment for value416FilterOptions._messageFilter(this.options.textFilter.text, typeof comment.comment.body === 'string' ? comment.comment.body : comment.comment.body.value)417// Check first user for value418|| FilterOptions._messageFilter(this.options.textFilter.text, comment.comment.userName)419// Check all replies for value420|| (comment.replies.map(reply => {421// Check user for value422return FilterOptions._messageFilter(this.options.textFilter.text, reply.comment.userName)423// Check body of reply for value424|| FilterOptions._messageFilter(this.options.textFilter.text, typeof reply.comment.body === 'string' ? reply.comment.body : reply.comment.body.value);425}).filter(value => !!value) as IMatch[][]).flat();426427// Matched and not negated428if (textMatches.length && !this.options.textFilter.negate) {429return { visibility: true, data: { type: FilterDataType.Comment, textMatches } };430}431432// Matched and negated - exclude it only if parent visibility is not set433if (textMatches.length && this.options.textFilter.negate && parentVisibility === TreeVisibility.Recurse) {434return false;435}436437// Not matched and negated - include it only if parent visibility is not set438if ((textMatches.length === 0) && this.options.textFilter.negate && parentVisibility === TreeVisibility.Recurse) {439return true;440}441442return parentVisibility;443}444}445446export class CommentsList extends WorkbenchObjectTree<CommentsModel | ResourceWithCommentThreads | CommentNode, any> {447private readonly menus: CommentsMenus;448449constructor(450labels: ResourceLabels,451container: HTMLElement,452options: ICommentsListOptions,453@IContextKeyService contextKeyService: IContextKeyService,454@IListService listService: IListService,455@IInstantiationService instantiationService: IInstantiationService,456@IConfigurationService configurationService: IConfigurationService,457@IContextMenuService private readonly contextMenuService: IContextMenuService,458@IKeybindingService private readonly keybindingService: IKeybindingService459) {460const delegate = new CommentsModelVirtualDelegate();461const actionViewItemProvider = createActionViewItem.bind(undefined, instantiationService);462const menus = instantiationService.createInstance(CommentsMenus);463menus.setContextKeyService(contextKeyService);464const renderers = [465instantiationService.createInstance(ResourceWithCommentsRenderer, labels),466instantiationService.createInstance(CommentNodeRenderer, actionViewItemProvider, menus)467];468469super(470'CommentsTree',471container,472delegate,473renderers,474{475accessibilityProvider: options.accessibilityProvider,476identityProvider: {477getId: (element: any) => {478if (element instanceof CommentsModel) {479return 'root';480}481if (element instanceof ResourceWithCommentThreads) {482return `${element.uniqueOwner}-${element.id}`;483}484if (element instanceof CommentNode) {485return `${element.uniqueOwner}-${element.resource.toString()}-${element.threadId}-${element.comment.uniqueIdInThread}` + (element.isRoot ? '-root' : '');486}487return '';488}489},490expandOnlyOnTwistieClick: true,491collapseByDefault: false,492overrideStyles: options.overrideStyles,493filter: options.filter,494sorter: options.sorter,495findWidgetEnabled: false,496multipleSelectionSupport: false,497},498instantiationService,499contextKeyService,500listService,501configurationService,502);503this.menus = menus;504this.disposables.add(this.onContextMenu(e => this.commentsOnContextMenu(e)));505}506507private commentsOnContextMenu(treeEvent: ITreeContextMenuEvent<CommentsModel | ResourceWithCommentThreads | CommentNode | null>): void {508const node: CommentsModel | ResourceWithCommentThreads | CommentNode | null = treeEvent.element;509if (!(node instanceof CommentNode)) {510return;511}512const event: UIEvent = treeEvent.browserEvent;513514event.preventDefault();515event.stopPropagation();516517this.setFocus([node]);518const actions = this.menus.getResourceContextActions(node);519if (!actions.length) {520return;521}522this.contextMenuService.showContextMenu({523getAnchor: () => treeEvent.anchor,524getActions: () => actions,525getActionViewItem: (action) => {526const keybinding = this.keybindingService.lookupKeybinding(action.id);527if (keybinding) {528return new ActionViewItem(action, action, { label: true, keybinding: keybinding.getLabel() });529}530return undefined;531},532onHide: (wasCancelled?: boolean) => {533if (wasCancelled) {534this.domFocus();535}536},537getActionsContext: (): MarshalledCommentThreadInternal => ({538commentControlHandle: node.controllerHandle,539commentThreadHandle: node.threadHandle,540$mid: MarshalledId.CommentThread,541thread: node.thread542})543});544}545546filterComments(): void {547this.refilter();548}549550getVisibleItemCount(): number {551let filtered = 0;552const root = this.getNode();553554for (const resourceNode of root.children) {555for (const commentNode of resourceNode.children) {556if (commentNode.visible && resourceNode.visible) {557filtered++;558}559}560}561562return filtered;563}564}565566567