Path: blob/main/src/vs/workbench/contrib/comments/browser/commentsView.ts
5240 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 } 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';4647interface CommentsViewState {48filter?: string;49filterHistory?: string[];50showResolved?: boolean;51showUnresolved?: boolean;52sortBy?: CommentsSortOrder;53}5455type CommentsTreeNode = CommentsModel | ResourceWithCommentThreads | CommentNode;5657function createResourceCommentsIterator(model: ICommentsModel): Iterable<ITreeElement<CommentsTreeNode>> {58const result: ITreeElement<CommentsTreeNode>[] = [];5960for (const m of model.resourceCommentThreads) {61const children = [];62for (const r of m.commentThreads) {63if (threadHasMeaningfulComments(r.thread)) {64children.push({ element: r });65}66}67if (children.length > 0) {68result.push({ element: m, children });69}70}71return result;72}7374export class CommentsPanel extends FilterViewPane implements ICommentsView {75private treeLabels!: ResourceLabels;76private tree: CommentsList | undefined;77private treeContainer!: HTMLElement;78private messageBoxContainer!: HTMLElement;79private totalComments: number = 0;80private readonly hasCommentsContextKey: IContextKey<boolean>;81private readonly someCommentsExpandedContextKey: IContextKey<boolean>;82private readonly commentsFocusedContextKey: IContextKey<boolean>;83private readonly filter: Filter;84readonly filters: CommentsFilters;8586private currentHeight = 0;87private currentWidth = 0;88private readonly viewState: CommentsViewState;89private readonly stateMemento: Memento<CommentsViewState>;90private cachedFilterStats: { total: number; filtered: number } | undefined = undefined;9192readonly onDidChangeVisibility = this.onDidChangeBodyVisibility;9394get focusedCommentNode(): CommentNode | undefined {95const focused = this.tree?.getFocus();96if (focused?.length === 1 && focused[0] instanceof CommentNode) {97return focused[0];98}99return undefined;100}101102get focusedCommentInfo(): string | undefined {103if (!this.focusedCommentNode) {104return;105}106return this.getScreenReaderInfoForNode(this.focusedCommentNode);107}108109focusNextNode(): void {110if (!this.tree) {111return;112}113const focused = this.tree.getFocus()?.[0];114if (!focused) {115return;116}117let next = this.tree.navigate(focused).next();118while (next && !(next instanceof CommentNode)) {119next = this.tree.navigate(next).next();120}121if (!next) {122return;123}124this.tree.setFocus([next]);125}126127focusPreviousNode(): void {128if (!this.tree) {129return;130}131const focused = this.tree.getFocus()?.[0];132if (!focused) {133return;134}135let previous = this.tree.navigate(focused).previous();136while (previous && !(previous instanceof CommentNode)) {137previous = this.tree.navigate(previous).previous();138}139if (!previous) {140return;141}142this.tree.setFocus([previous]);143}144145constructor(146options: IViewPaneOptions,147@IInstantiationService instantiationService: IInstantiationService,148@IViewDescriptorService viewDescriptorService: IViewDescriptorService,149@IEditorService private readonly editorService: IEditorService,150@IConfigurationService configurationService: IConfigurationService,151@IContextKeyService contextKeyService: IContextKeyService,152@IContextMenuService contextMenuService: IContextMenuService,153@IKeybindingService keybindingService: IKeybindingService,154@IOpenerService openerService: IOpenerService,155@IThemeService themeService: IThemeService,156@ICommentService private readonly commentService: ICommentService,157@IHoverService hoverService: IHoverService,158@IUriIdentityService private readonly uriIdentityService: IUriIdentityService,159@IStorageService storageService: IStorageService,160@IPathService private readonly pathService: IPathService,161) {162const stateMemento = new Memento<CommentsViewState>(VIEW_STORAGE_ID, storageService);163const viewState = stateMemento.getMemento(StorageScope.WORKSPACE, StorageTarget.MACHINE);164super({165...options,166filterOptions: {167placeholder: nls.localize('comments.filter.placeholder', "Filter (e.g. text, author)"),168ariaLabel: nls.localize('comments.filter.ariaLabel', "Filter comments"),169history: viewState.filterHistory || [],170text: viewState.filter || '',171focusContextKey: CommentsViewFilterFocusContextKey.key172}173}, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, hoverService);174this.hasCommentsContextKey = CONTEXT_KEY_HAS_COMMENTS.bindTo(contextKeyService);175this.someCommentsExpandedContextKey = CONTEXT_KEY_SOME_COMMENTS_EXPANDED.bindTo(contextKeyService);176this.commentsFocusedContextKey = CONTEXT_KEY_COMMENT_FOCUSED.bindTo(contextKeyService);177this.stateMemento = stateMemento;178this.viewState = viewState;179180this.filters = this._register(new CommentsFilters({181showResolved: this.viewState.showResolved !== false,182showUnresolved: this.viewState.showUnresolved !== false,183sortBy: this.viewState.sortBy ?? CommentsSortOrder.ResourceAscending,184}, this.contextKeyService));185this.filter = new Filter(new FilterOptions(this.filterWidget.getFilterText(), this.filters.showResolved, this.filters.showUnresolved));186187this._register(this.filters.onDidChange((event: CommentsFiltersChangeEvent) => {188if (event.showResolved || event.showUnresolved) {189this.updateFilter();190}191if (event.sortBy) {192this.refresh();193}194}));195this._register(this.filterWidget.onDidChangeFilterText(() => this.updateFilter()));196}197198override saveState(): void {199this.viewState.filter = this.filterWidget.getFilterText();200this.viewState.filterHistory = this.filterWidget.getHistory();201this.viewState.showResolved = this.filters.showResolved;202this.viewState.showUnresolved = this.filters.showUnresolved;203this.viewState.sortBy = this.filters.sortBy;204this.stateMemento.saveMemento();205super.saveState();206}207208override render(): void {209super.render();210this._register(registerNavigableContainer({211name: 'commentsView',212focusNotifiers: [this, this.filterWidget],213focusNextWidget: () => {214if (this.filterWidget.hasFocus()) {215this.focus();216}217},218focusPreviousWidget: () => {219if (!this.filterWidget.hasFocus()) {220this.focusFilter();221}222}223}));224}225226public focusFilter(): void {227this.filterWidget.focus();228}229230public clearFilterText(): void {231this.filterWidget.setFilterText('');232}233234public getFilterStats(): { total: number; filtered: number } {235if (!this.cachedFilterStats) {236this.cachedFilterStats = {237total: this.totalComments,238filtered: this.tree?.getVisibleItemCount() ?? 0239};240}241242return this.cachedFilterStats;243}244245private updateFilter() {246this.filter.options = new FilterOptions(this.filterWidget.getFilterText(), this.filters.showResolved, this.filters.showUnresolved);247this.tree?.filterComments();248249this.cachedFilterStats = undefined;250const { total, filtered } = this.getFilterStats();251this.filterWidget.updateBadge(total === filtered || total === 0 ? undefined : nls.localize('showing filtered results', "Showing {0} of {1}", filtered, total));252this.filterWidget.checkMoreFilters(!this.filters.showResolved || !this.filters.showUnresolved);253}254255protected override renderBody(container: HTMLElement): void {256super.renderBody(container);257258container.classList.add('comments-panel');259260const domContainer = dom.append(container, dom.$('.comments-panel-container'));261262this.treeContainer = dom.append(domContainer, dom.$('.tree-container'));263this.treeContainer.classList.add('file-icon-themable-tree', 'show-file-icons');264265this.cachedFilterStats = undefined;266this.createTree();267this.createMessageBox(domContainer);268269this._register(this.commentService.onDidSetAllCommentThreads(this.onAllCommentsChanged, this));270this._register(this.commentService.onDidUpdateCommentThreads(this.onCommentsUpdated, this));271this._register(this.commentService.onDidDeleteDataProvider(this.onDataProviderDeleted, this));272273this._register(this.onDidChangeBodyVisibility(visible => {274if (visible) {275this.refresh();276}277}));278279this.renderComments();280}281282public override focus(): void {283super.focus();284285const element = this.tree?.getHTMLElement();286if (element && dom.isActiveElement(element)) {287return;288}289290if (!this.commentService.commentsModel.hasCommentThreads() && this.messageBoxContainer) {291this.messageBoxContainer.focus();292} else if (this.tree) {293this.tree.domFocus();294}295}296297private renderComments(): void {298this.treeContainer.classList.toggle('hidden', !this.commentService.commentsModel.hasCommentThreads());299this.renderMessage();300this.tree?.setChildren(null, createResourceCommentsIterator(this.commentService.commentsModel));301}302303public collapseAll() {304if (this.tree) {305this.tree.collapseAll();306this.tree.setSelection([]);307this.tree.setFocus([]);308this.tree.domFocus();309this.tree.focusFirst();310}311}312313public expandAll() {314if (this.tree) {315this.tree.expandAll();316this.tree.setSelection([]);317this.tree.setFocus([]);318this.tree.domFocus();319this.tree.focusFirst();320}321}322323public get hasRendered(): boolean {324return !!this.tree;325}326327protected layoutBodyContent(height: number = this.currentHeight, width: number = this.currentWidth): void {328if (this.messageBoxContainer) {329this.messageBoxContainer.style.height = `${height}px`;330}331this.tree?.layout(height, width);332this.currentHeight = height;333this.currentWidth = width;334}335336private createMessageBox(parent: HTMLElement): void {337this.messageBoxContainer = dom.append(parent, dom.$('.message-box-container'));338this.messageBoxContainer.setAttribute('tabIndex', '0');339}340341private renderMessage(): void {342this.messageBoxContainer.textContent = this.commentService.commentsModel.getMessage();343this.messageBoxContainer.classList.toggle('hidden', this.commentService.commentsModel.hasCommentThreads());344}345346private makeCommentLocationLabel(file: URI, range?: IRange) {347const fileLabel = basename(file);348if (!range) {349return nls.localize('fileCommentLabel', "in {0}", fileLabel);350}351if (range.startLineNumber === range.endLineNumber) {352return nls.localize('oneLineCommentLabel', "at line {0} column {1} in {2}", range.startLineNumber, range.startColumn, fileLabel);353} else {354return nls.localize('multiLineCommentLabel', "from line {0} to line {1} in {2}", range.startLineNumber, range.endLineNumber, fileLabel);355}356}357358private makeScreenReaderLabelInfo(element: CommentNode, forAriaLabel?: boolean) {359const userName = element.comment.userName;360const locationLabel = this.makeCommentLocationLabel(element.resource, element.range);361const replyCountLabel = this.getReplyCountAsString(element, forAriaLabel);362const bodyLabel = (typeof element.comment.body === 'string') ? element.comment.body : element.comment.body.value;363364return { userName, locationLabel, replyCountLabel, bodyLabel };365}366367private getScreenReaderInfoForNode(element: CommentNode, forAriaLabel?: boolean): string {368let accessibleViewHint = '';369if (forAriaLabel && this.configurationService.getValue(AccessibilityVerbositySettingId.Comments)) {370const kbLabel = this.keybindingService.lookupKeybinding(AccessibleViewAction.id)?.getAriaLabel();371accessibleViewHint = 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.");372}373const replies = this.getRepliesAsString(element, forAriaLabel);374const editor = this.editorService.findEditors(element.resource);375const codeEditor = this.editorService.activeEditorPane?.getControl();376let relevantLines;377if (element.range && editor?.length && isCodeEditor(codeEditor)) {378relevantLines = codeEditor.getModel()?.getValueInRange(element.range);379if (relevantLines) {380relevantLines = '\nCorresponding code: \n' + relevantLines;381}382}383if (!relevantLines) {384relevantLines = '';385}386387const labelInfo = this.makeScreenReaderLabelInfo(element, forAriaLabel);388389if (element.threadRelevance === CommentThreadApplicability.Outdated) {390return nls.localize('resourceWithCommentLabelOutdated',391"Outdated from {0}: {1}\n{2}\n{3}\n{4}",392labelInfo.userName,393labelInfo.bodyLabel,394labelInfo.locationLabel,395labelInfo.replyCountLabel,396relevantLines397) + replies + accessibleViewHint;398} else {399return nls.localize('resourceWithCommentLabel',400"{0}: {1}\n{2}\n{3}\n{4}",401labelInfo.userName,402labelInfo.bodyLabel,403labelInfo.locationLabel,404labelInfo.replyCountLabel,405relevantLines406) + replies + accessibleViewHint;407}408}409410private getRepliesAsString(node: CommentNode, forAriaLabel?: boolean): string {411if (!node.replies.length || forAriaLabel) {412return '';413}414return '\n' + node.replies.map(reply => nls.localize('resourceWithRepliesLabel',415"{0} {1}",416reply.comment.userName,417(typeof reply.comment.body === 'string') ? reply.comment.body : reply.comment.body.value)418).join('\n');419}420421private getReplyCountAsString(node: CommentNode, forAriaLabel?: boolean): string {422return node.replies.length && !forAriaLabel ? nls.localize('replyCount', " {0} replies,", node.replies.length) : '';423}424425private createTree(): void {426this.treeLabels = this._register(this.instantiationService.createInstance(ResourceLabels, this));427this.tree = this._register(this.instantiationService.createInstance(CommentsList, this.treeLabels, this.treeContainer, {428overrideStyles: this.getLocationBasedColors().listOverrideStyles,429selectionNavigation: true,430filter: this.filter,431sorter: {432compare: (a: CommentsTreeNode, b: CommentsTreeNode) => {433if (a instanceof CommentsModel || b instanceof CommentsModel) {434return 0;435}436if (this.filters.sortBy === CommentsSortOrder.UpdatedAtDescending) {437return a.lastUpdatedAt > b.lastUpdatedAt ? -1 : 1;438} else if (this.filters.sortBy === CommentsSortOrder.ResourceAscending) {439if (a instanceof ResourceWithCommentThreads && b instanceof ResourceWithCommentThreads) {440const workspaceScheme = this.pathService.defaultUriScheme;441if ((a.resource.scheme !== b.resource.scheme) && (a.resource.scheme === workspaceScheme || b.resource.scheme === workspaceScheme)) {442// Workspace scheme should always come first443return b.resource.scheme === workspaceScheme ? 1 : -1;444}445return a.resource.toString() > b.resource.toString() ? 1 : -1;446} else if (a instanceof CommentNode && b instanceof CommentNode && a.thread.range && b.thread.range) {447return a.thread.range?.startLineNumber > b.thread.range?.startLineNumber ? 1 : -1;448}449}450return 0;451},452},453keyboardNavigationLabelProvider: {454getKeyboardNavigationLabel: (item: CommentsTreeNode) => {455return undefined;456}457},458accessibilityProvider: {459getAriaLabel: (element: any): string => {460if (element instanceof CommentsModel) {461return nls.localize('rootCommentsLabel', "Comments for current workspace");462}463if (element instanceof ResourceWithCommentThreads) {464return nls.localize('resourceWithCommentThreadsLabel', "Comments in {0}, full path {1}", basename(element.resource), element.resource.fsPath);465}466if (element instanceof CommentNode) {467return this.getScreenReaderInfoForNode(element, true);468}469return '';470},471getWidgetAriaLabel(): string {472return COMMENTS_VIEW_TITLE.value;473}474}475}));476477this._register(this.tree.onDidOpen(e => {478this.openFile(e.element, e.editorOptions.pinned, e.editorOptions.preserveFocus, e.sideBySide);479}));480481482this._register(this.tree.onDidChangeModel(() => {483this.updateSomeCommentsExpanded();484}));485this._register(this.tree.onDidChangeCollapseState(() => {486this.updateSomeCommentsExpanded();487}));488this._register(this.tree.onDidFocus(() => this.commentsFocusedContextKey.set(true)));489this._register(this.tree.onDidBlur(() => this.commentsFocusedContextKey.set(false)));490}491492private openFile(element: any, pinned?: boolean, preserveFocus?: boolean, sideBySide?: boolean): void {493if (!element) {494return;495}496497if (!(element instanceof ResourceWithCommentThreads || element instanceof CommentNode)) {498return;499}500const threadToReveal = element instanceof ResourceWithCommentThreads ? element.commentThreads[0].thread : element.thread;501const commentToReveal = element instanceof ResourceWithCommentThreads ? element.commentThreads[0].comment : undefined;502return revealCommentThread(this.commentService, this.editorService, this.uriIdentityService, threadToReveal, commentToReveal, false, pinned, preserveFocus, sideBySide);503}504505private async refresh(): Promise<void> {506if (!this.tree) {507return;508}509if (this.isVisible()) {510this.hasCommentsContextKey.set(this.commentService.commentsModel.hasCommentThreads());511this.cachedFilterStats = undefined;512this.renderComments();513514if (this.tree.getSelection().length === 0 && this.commentService.commentsModel.hasCommentThreads()) {515const firstComment = this.commentService.commentsModel.resourceCommentThreads[0].commentThreads[0];516if (firstComment) {517this.tree.setFocus([firstComment]);518this.tree.setSelection([firstComment]);519}520}521}522}523524private onAllCommentsChanged(e: IWorkspaceCommentThreadsEvent): void {525this.cachedFilterStats = undefined;526this.totalComments += e.commentThreads.length;527528let unresolved = 0;529for (const thread of e.commentThreads) {530if (thread.state === CommentThreadState.Unresolved) {531unresolved++;532}533}534this.refresh();535}536537private onCommentsUpdated(e: ICommentThreadChangedEvent): void {538this.cachedFilterStats = undefined;539540this.totalComments += e.added.length;541this.totalComments -= e.removed.length;542543let unresolved = 0;544for (const resource of this.commentService.commentsModel.resourceCommentThreads) {545for (const thread of resource.commentThreads) {546if (thread.threadState === CommentThreadState.Unresolved) {547unresolved++;548}549}550}551this.refresh();552}553554private onDataProviderDeleted(owner: string | undefined): void {555this.cachedFilterStats = undefined;556this.totalComments = 0;557this.refresh();558}559560private updateSomeCommentsExpanded() {561this.someCommentsExpandedContextKey.set(this.isSomeCommentsExpanded());562}563564public areAllCommentsExpanded(): boolean {565if (!this.tree) {566return false;567}568const navigator = this.tree.navigate();569while (navigator.next()) {570if (this.tree.isCollapsed(navigator.current())) {571return false;572}573}574return true;575}576577public isSomeCommentsExpanded(): boolean {578if (!this.tree) {579return false;580}581const navigator = this.tree.navigate();582while (navigator.next()) {583if (!this.tree.isCollapsed(navigator.current())) {584return true;585}586}587return false;588}589}590591592