Path: blob/main/src/vs/workbench/contrib/comments/browser/commentsView.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 './media/panel.css';6import * as nls from '../../../../nls.js';7import * as dom from '../../../../base/browser/dom.js';8import { basename } from '../../../../base/common/resources.js';9import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';10import { IThemeService } from '../../../../platform/theme/common/themeService.js';11import { CommentNode, ICommentThreadChangedEvent, ResourceWithCommentThreads } from '../common/commentModel.js';12import { ICommentService, IWorkspaceCommentThreadsEvent } from './commentService.js';13import { IEditorService } from '../../../services/editor/common/editorService.js';14import { ResourceLabels } from '../../../browser/labels.js';15import { CommentsList, COMMENTS_VIEW_TITLE, Filter } from './commentsTreeViewer.js';16import { IViewPaneOptions, FilterViewPane } from '../../../browser/parts/views/viewPane.js';17import { IViewDescriptorService } from '../../../common/views.js';18import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';19import { IContextKey, IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js';20import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js';21import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js';22import { IOpenerService } from '../../../../platform/opener/common/opener.js';23import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js';24import { CommentsViewFilterFocusContextKey, ICommentsView } from './comments.js';25import { CommentsFilters, CommentsFiltersChangeEvent, CommentsSortOrder } from './commentsViewActions.js';26import { Memento, MementoObject } from '../../../common/memento.js';27import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js';28import { FilterOptions } from './commentsFilterOptions.js';29import { CommentThreadApplicability, CommentThreadState } from '../../../../editor/common/languages.js';30import { revealCommentThread } from './commentsController.js';31import { registerNavigableContainer } from '../../../browser/actions/widgetNavigationCommands.js';32import { CommentsModel, threadHasMeaningfulComments, type ICommentsModel } from './commentsModel.js';33import { IHoverService } from '../../../../platform/hover/browser/hover.js';34import { AccessibilityVerbositySettingId } from '../../accessibility/browser/accessibilityConfiguration.js';35import { AccessibleViewAction } from '../../accessibility/browser/accessibleViewActions.js';36import type { ITreeElement } from '../../../../base/browser/ui/tree/tree.js';37import { IPathService } from '../../../services/path/common/pathService.js';38import { isCodeEditor } from '../../../../editor/browser/editorBrowser.js';39import { URI } from '../../../../base/common/uri.js';40import { IRange } from '../../../../editor/common/core/range.js';4142export const CONTEXT_KEY_HAS_COMMENTS = new RawContextKey<boolean>('commentsView.hasComments', false);43export const CONTEXT_KEY_SOME_COMMENTS_EXPANDED = new RawContextKey<boolean>('commentsView.someCommentsExpanded', false);44export const CONTEXT_KEY_COMMENT_FOCUSED = new RawContextKey<boolean>('commentsView.commentFocused', false);45const VIEW_STORAGE_ID = 'commentsViewState';4647type CommentsTreeNode = CommentsModel | ResourceWithCommentThreads | CommentNode;4849function createResourceCommentsIterator(model: ICommentsModel): Iterable<ITreeElement<CommentsTreeNode>> {50const result: ITreeElement<CommentsTreeNode>[] = [];5152for (const m of model.resourceCommentThreads) {53const children = [];54for (const r of m.commentThreads) {55if (threadHasMeaningfulComments(r.thread)) {56children.push({ element: r });57}58}59if (children.length > 0) {60result.push({ element: m, children });61}62}63return result;64}6566export class CommentsPanel extends FilterViewPane implements ICommentsView {67private treeLabels!: ResourceLabels;68private tree: CommentsList | undefined;69private treeContainer!: HTMLElement;70private messageBoxContainer!: HTMLElement;71private totalComments: number = 0;72private readonly hasCommentsContextKey: IContextKey<boolean>;73private readonly someCommentsExpandedContextKey: IContextKey<boolean>;74private readonly commentsFocusedContextKey: IContextKey<boolean>;75private readonly filter: Filter;76readonly filters: CommentsFilters;7778private currentHeight = 0;79private currentWidth = 0;80private readonly viewState: MementoObject;81private readonly stateMemento: Memento;82private cachedFilterStats: { total: number; filtered: number } | undefined = undefined;8384readonly onDidChangeVisibility = this.onDidChangeBodyVisibility;8586get focusedCommentNode(): CommentNode | undefined {87const focused = this.tree?.getFocus();88if (focused?.length === 1 && focused[0] instanceof CommentNode) {89return focused[0];90}91return undefined;92}9394get focusedCommentInfo(): string | undefined {95if (!this.focusedCommentNode) {96return;97}98return this.getScreenReaderInfoForNode(this.focusedCommentNode);99}100101focusNextNode(): void {102if (!this.tree) {103return;104}105const focused = this.tree.getFocus()?.[0];106if (!focused) {107return;108}109let next = this.tree.navigate(focused).next();110while (next && !(next instanceof CommentNode)) {111next = this.tree.navigate(next).next();112}113if (!next) {114return;115}116this.tree.setFocus([next]);117}118119focusPreviousNode(): void {120if (!this.tree) {121return;122}123const focused = this.tree.getFocus()?.[0];124if (!focused) {125return;126}127let previous = this.tree.navigate(focused).previous();128while (previous && !(previous instanceof CommentNode)) {129previous = this.tree.navigate(previous).previous();130}131if (!previous) {132return;133}134this.tree.setFocus([previous]);135}136137constructor(138options: IViewPaneOptions,139@IInstantiationService instantiationService: IInstantiationService,140@IViewDescriptorService viewDescriptorService: IViewDescriptorService,141@IEditorService private readonly editorService: IEditorService,142@IConfigurationService configurationService: IConfigurationService,143@IContextKeyService contextKeyService: IContextKeyService,144@IContextMenuService contextMenuService: IContextMenuService,145@IKeybindingService keybindingService: IKeybindingService,146@IOpenerService openerService: IOpenerService,147@IThemeService themeService: IThemeService,148@ICommentService private readonly commentService: ICommentService,149@IHoverService hoverService: IHoverService,150@IUriIdentityService private readonly uriIdentityService: IUriIdentityService,151@IStorageService storageService: IStorageService,152@IPathService private readonly pathService: IPathService,153) {154const stateMemento = new Memento(VIEW_STORAGE_ID, storageService);155const viewState = stateMemento.getMemento(StorageScope.WORKSPACE, StorageTarget.MACHINE);156super({157...options,158filterOptions: {159placeholder: nls.localize('comments.filter.placeholder', "Filter (e.g. text, author)"),160ariaLabel: nls.localize('comments.filter.ariaLabel', "Filter comments"),161history: viewState['filterHistory'] || [],162text: viewState['filter'] || '',163focusContextKey: CommentsViewFilterFocusContextKey.key164}165}, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, hoverService);166this.hasCommentsContextKey = CONTEXT_KEY_HAS_COMMENTS.bindTo(contextKeyService);167this.someCommentsExpandedContextKey = CONTEXT_KEY_SOME_COMMENTS_EXPANDED.bindTo(contextKeyService);168this.commentsFocusedContextKey = CONTEXT_KEY_COMMENT_FOCUSED.bindTo(contextKeyService);169this.stateMemento = stateMemento;170this.viewState = viewState;171172this.filters = this._register(new CommentsFilters({173showResolved: this.viewState['showResolved'] !== false,174showUnresolved: this.viewState['showUnresolved'] !== false,175sortBy: this.viewState['sortBy'] ?? CommentsSortOrder.ResourceAscending,176}, this.contextKeyService));177this.filter = new Filter(new FilterOptions(this.filterWidget.getFilterText(), this.filters.showResolved, this.filters.showUnresolved));178179this._register(this.filters.onDidChange((event: CommentsFiltersChangeEvent) => {180if (event.showResolved || event.showUnresolved) {181this.updateFilter();182}183if (event.sortBy) {184this.refresh();185}186}));187this._register(this.filterWidget.onDidChangeFilterText(() => this.updateFilter()));188}189190override saveState(): void {191this.viewState['filter'] = this.filterWidget.getFilterText();192this.viewState['filterHistory'] = this.filterWidget.getHistory();193this.viewState['showResolved'] = this.filters.showResolved;194this.viewState['showUnresolved'] = this.filters.showUnresolved;195this.viewState['sortBy'] = this.filters.sortBy;196this.stateMemento.saveMemento();197super.saveState();198}199200override render(): void {201super.render();202this._register(registerNavigableContainer({203name: 'commentsView',204focusNotifiers: [this, this.filterWidget],205focusNextWidget: () => {206if (this.filterWidget.hasFocus()) {207this.focus();208}209},210focusPreviousWidget: () => {211if (!this.filterWidget.hasFocus()) {212this.focusFilter();213}214}215}));216}217218public focusFilter(): void {219this.filterWidget.focus();220}221222public clearFilterText(): void {223this.filterWidget.setFilterText('');224}225226public getFilterStats(): { total: number; filtered: number } {227if (!this.cachedFilterStats) {228this.cachedFilterStats = {229total: this.totalComments,230filtered: this.tree?.getVisibleItemCount() ?? 0231};232}233234return this.cachedFilterStats;235}236237private updateFilter() {238this.filter.options = new FilterOptions(this.filterWidget.getFilterText(), this.filters.showResolved, this.filters.showUnresolved);239this.tree?.filterComments();240241this.cachedFilterStats = undefined;242const { total, filtered } = this.getFilterStats();243this.filterWidget.updateBadge(total === filtered || total === 0 ? undefined : nls.localize('showing filtered results', "Showing {0} of {1}", filtered, total));244this.filterWidget.checkMoreFilters(!this.filters.showResolved || !this.filters.showUnresolved);245}246247protected override renderBody(container: HTMLElement): void {248super.renderBody(container);249250container.classList.add('comments-panel');251252const domContainer = dom.append(container, dom.$('.comments-panel-container'));253254this.treeContainer = dom.append(domContainer, dom.$('.tree-container'));255this.treeContainer.classList.add('file-icon-themable-tree', 'show-file-icons');256257this.cachedFilterStats = undefined;258this.createTree();259this.createMessageBox(domContainer);260261this._register(this.commentService.onDidSetAllCommentThreads(this.onAllCommentsChanged, this));262this._register(this.commentService.onDidUpdateCommentThreads(this.onCommentsUpdated, this));263this._register(this.commentService.onDidDeleteDataProvider(this.onDataProviderDeleted, this));264265this._register(this.onDidChangeBodyVisibility(visible => {266if (visible) {267this.refresh();268}269}));270271this.renderComments();272}273274public override focus(): void {275super.focus();276277const element = this.tree?.getHTMLElement();278if (element && dom.isActiveElement(element)) {279return;280}281282if (!this.commentService.commentsModel.hasCommentThreads() && this.messageBoxContainer) {283this.messageBoxContainer.focus();284} else if (this.tree) {285this.tree.domFocus();286}287}288289private renderComments(): void {290this.treeContainer.classList.toggle('hidden', !this.commentService.commentsModel.hasCommentThreads());291this.renderMessage();292this.tree?.setChildren(null, createResourceCommentsIterator(this.commentService.commentsModel));293}294295public collapseAll() {296if (this.tree) {297this.tree.collapseAll();298this.tree.setSelection([]);299this.tree.setFocus([]);300this.tree.domFocus();301this.tree.focusFirst();302}303}304305public expandAll() {306if (this.tree) {307this.tree.expandAll();308this.tree.setSelection([]);309this.tree.setFocus([]);310this.tree.domFocus();311this.tree.focusFirst();312}313}314315public get hasRendered(): boolean {316return !!this.tree;317}318319protected layoutBodyContent(height: number = this.currentHeight, width: number = this.currentWidth): void {320if (this.messageBoxContainer) {321this.messageBoxContainer.style.height = `${height}px`;322}323this.tree?.layout(height, width);324this.currentHeight = height;325this.currentWidth = width;326}327328private createMessageBox(parent: HTMLElement): void {329this.messageBoxContainer = dom.append(parent, dom.$('.message-box-container'));330this.messageBoxContainer.setAttribute('tabIndex', '0');331}332333private renderMessage(): void {334this.messageBoxContainer.textContent = this.commentService.commentsModel.getMessage();335this.messageBoxContainer.classList.toggle('hidden', this.commentService.commentsModel.hasCommentThreads());336}337338private makeCommentLocationLabel(file: URI, range?: IRange) {339const fileLabel = basename(file);340if (!range) {341return nls.localize('fileCommentLabel', "in {0}", fileLabel);342}343if (range.startLineNumber === range.endLineNumber) {344return nls.localize('oneLineCommentLabel', "at line {0} column {1} in {2}", range.startLineNumber, range.startColumn, fileLabel);345} else {346return nls.localize('multiLineCommentLabel', "from line {0} to line {1} in {2}", range.startLineNumber, range.endLineNumber, fileLabel);347}348}349350private makeScreenReaderLabelInfo(element: CommentNode, forAriaLabel?: boolean) {351const userName = element.comment.userName;352const locationLabel = this.makeCommentLocationLabel(element.resource, element.range);353const replyCountLabel = this.getReplyCountAsString(element, forAriaLabel);354const bodyLabel = (typeof element.comment.body === 'string') ? element.comment.body : element.comment.body.value;355356return { userName, locationLabel, replyCountLabel, bodyLabel };357}358359private getScreenReaderInfoForNode(element: CommentNode, forAriaLabel?: boolean): string {360let accessibleViewHint = '';361if (forAriaLabel && this.configurationService.getValue(AccessibilityVerbositySettingId.Comments)) {362const kbLabel = this.keybindingService.lookupKeybinding(AccessibleViewAction.id)?.getAriaLabel();363accessibleViewHint = kbLabel ? nls.localize('accessibleViewHint', "\nInspect this in the accessible view ({0}).", kbLabel) : nls.localize('acessibleViewHintNoKbOpen', "\nInspect this in the accessible view via the command Open Accessible View which is currently not triggerable via keybinding.");364}365const replies = this.getRepliesAsString(element, forAriaLabel);366const editor = this.editorService.findEditors(element.resource);367const codeEditor = this.editorService.activeEditorPane?.getControl();368let relevantLines;369if (element.range && editor?.length && isCodeEditor(codeEditor)) {370relevantLines = codeEditor.getModel()?.getValueInRange(element.range);371if (relevantLines) {372relevantLines = '\nCorresponding code: \n' + relevantLines;373}374}375if (!relevantLines) {376relevantLines = '';377}378379const labelInfo = this.makeScreenReaderLabelInfo(element, forAriaLabel);380381if (element.threadRelevance === CommentThreadApplicability.Outdated) {382return nls.localize('resourceWithCommentLabelOutdated',383"Outdated from {0}: {1}\n{2}\n{3}\n{4}",384labelInfo.userName,385labelInfo.bodyLabel,386labelInfo.locationLabel,387labelInfo.replyCountLabel,388relevantLines389) + replies + accessibleViewHint;390} else {391return nls.localize('resourceWithCommentLabel',392"{0}: {1}\n{2}\n{3}\n{4}",393labelInfo.userName,394labelInfo.bodyLabel,395labelInfo.locationLabel,396labelInfo.replyCountLabel,397relevantLines398) + replies + accessibleViewHint;399}400}401402private getRepliesAsString(node: CommentNode, forAriaLabel?: boolean): string {403if (!node.replies.length || forAriaLabel) {404return '';405}406return '\n' + node.replies.map(reply => nls.localize('resourceWithRepliesLabel',407"{0} {1}",408reply.comment.userName,409(typeof reply.comment.body === 'string') ? reply.comment.body : reply.comment.body.value)410).join('\n');411}412413private getReplyCountAsString(node: CommentNode, forAriaLabel?: boolean): string {414return node.replies.length && !forAriaLabel ? nls.localize('replyCount', " {0} replies,", node.replies.length) : '';415}416417private createTree(): void {418this.treeLabels = this._register(this.instantiationService.createInstance(ResourceLabels, this));419this.tree = this._register(this.instantiationService.createInstance(CommentsList, this.treeLabels, this.treeContainer, {420overrideStyles: this.getLocationBasedColors().listOverrideStyles,421selectionNavigation: true,422filter: this.filter,423sorter: {424compare: (a: CommentsTreeNode, b: CommentsTreeNode) => {425if (a instanceof CommentsModel || b instanceof CommentsModel) {426return 0;427}428if (this.filters.sortBy === CommentsSortOrder.UpdatedAtDescending) {429return a.lastUpdatedAt > b.lastUpdatedAt ? -1 : 1;430} else if (this.filters.sortBy === CommentsSortOrder.ResourceAscending) {431if (a instanceof ResourceWithCommentThreads && b instanceof ResourceWithCommentThreads) {432const workspaceScheme = this.pathService.defaultUriScheme;433if ((a.resource.scheme !== b.resource.scheme) && (a.resource.scheme === workspaceScheme || b.resource.scheme === workspaceScheme)) {434// Workspace scheme should always come first435return b.resource.scheme === workspaceScheme ? 1 : -1;436}437return a.resource.toString() > b.resource.toString() ? 1 : -1;438} else if (a instanceof CommentNode && b instanceof CommentNode && a.thread.range && b.thread.range) {439return a.thread.range?.startLineNumber > b.thread.range?.startLineNumber ? 1 : -1;440}441}442return 0;443},444},445keyboardNavigationLabelProvider: {446getKeyboardNavigationLabel: (item: CommentsTreeNode) => {447return undefined;448}449},450accessibilityProvider: {451getAriaLabel: (element: any): string => {452if (element instanceof CommentsModel) {453return nls.localize('rootCommentsLabel', "Comments for current workspace");454}455if (element instanceof ResourceWithCommentThreads) {456return nls.localize('resourceWithCommentThreadsLabel', "Comments in {0}, full path {1}", basename(element.resource), element.resource.fsPath);457}458if (element instanceof CommentNode) {459return this.getScreenReaderInfoForNode(element, true);460}461return '';462},463getWidgetAriaLabel(): string {464return COMMENTS_VIEW_TITLE.value;465}466}467}));468469this._register(this.tree.onDidOpen(e => {470this.openFile(e.element, e.editorOptions.pinned, e.editorOptions.preserveFocus, e.sideBySide);471}));472473474this._register(this.tree.onDidChangeModel(() => {475this.updateSomeCommentsExpanded();476}));477this._register(this.tree.onDidChangeCollapseState(() => {478this.updateSomeCommentsExpanded();479}));480this._register(this.tree.onDidFocus(() => this.commentsFocusedContextKey.set(true)));481this._register(this.tree.onDidBlur(() => this.commentsFocusedContextKey.set(false)));482}483484private openFile(element: any, pinned?: boolean, preserveFocus?: boolean, sideBySide?: boolean): void {485if (!element) {486return;487}488489if (!(element instanceof ResourceWithCommentThreads || element instanceof CommentNode)) {490return;491}492const threadToReveal = element instanceof ResourceWithCommentThreads ? element.commentThreads[0].thread : element.thread;493const commentToReveal = element instanceof ResourceWithCommentThreads ? element.commentThreads[0].comment : undefined;494return revealCommentThread(this.commentService, this.editorService, this.uriIdentityService, threadToReveal, commentToReveal, false, pinned, preserveFocus, sideBySide);495}496497private async refresh(): Promise<void> {498if (!this.tree) {499return;500}501if (this.isVisible()) {502this.hasCommentsContextKey.set(this.commentService.commentsModel.hasCommentThreads());503this.cachedFilterStats = undefined;504this.renderComments();505506if (this.tree.getSelection().length === 0 && this.commentService.commentsModel.hasCommentThreads()) {507const firstComment = this.commentService.commentsModel.resourceCommentThreads[0].commentThreads[0];508if (firstComment) {509this.tree.setFocus([firstComment]);510this.tree.setSelection([firstComment]);511}512}513}514}515516private onAllCommentsChanged(e: IWorkspaceCommentThreadsEvent): void {517this.cachedFilterStats = undefined;518this.totalComments += e.commentThreads.length;519520let unresolved = 0;521for (const thread of e.commentThreads) {522if (thread.state === CommentThreadState.Unresolved) {523unresolved++;524}525}526this.refresh();527}528529private onCommentsUpdated(e: ICommentThreadChangedEvent): void {530this.cachedFilterStats = undefined;531532this.totalComments += e.added.length;533this.totalComments -= e.removed.length;534535let unresolved = 0;536for (const resource of this.commentService.commentsModel.resourceCommentThreads) {537for (const thread of resource.commentThreads) {538if (thread.threadState === CommentThreadState.Unresolved) {539unresolved++;540}541}542}543this.refresh();544}545546private onDataProviderDeleted(owner: string | undefined): void {547this.cachedFilterStats = undefined;548this.totalComments = 0;549this.refresh();550}551552private updateSomeCommentsExpanded() {553this.someCommentsExpandedContextKey.set(this.isSomeCommentsExpanded());554}555556public areAllCommentsExpanded(): boolean {557if (!this.tree) {558return false;559}560const navigator = this.tree.navigate();561while (navigator.next()) {562if (this.tree.isCollapsed(navigator.current())) {563return false;564}565}566return true;567}568569public isSomeCommentsExpanded(): boolean {570if (!this.tree) {571return false;572}573const navigator = this.tree.navigate();574while (navigator.next()) {575if (!this.tree.isCollapsed(navigator.current())) {576return true;577}578}579return false;580}581}582583584