Path: blob/main/src/vs/workbench/contrib/comments/browser/commentService.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 { CommentThreadChangedEvent, CommentInfo, Comment, CommentReaction, CommentingRanges, CommentThread, CommentOptions, PendingCommentThread, CommentingRangeResourceHint } from '../../../../editor/common/languages.js';6import { createDecorator, IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';7import { Event, Emitter } from '../../../../base/common/event.js';8import { Disposable, DisposableStore, IDisposable } from '../../../../base/common/lifecycle.js';9import { URI, UriComponents } from '../../../../base/common/uri.js';10import { Range, IRange } from '../../../../editor/common/core/range.js';11import { CancellationToken } from '../../../../base/common/cancellation.js';12import { ICommentThreadChangedEvent } from '../common/commentModel.js';13import { CommentMenus } from './commentMenus.js';14import { ICellRange } from '../../notebook/common/notebookRange.js';15import { IWorkbenchLayoutService } from '../../../services/layout/browser/layoutService.js';16import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';17import { COMMENTS_SECTION, ICommentsConfiguration } from '../common/commentsConfiguration.js';18import { IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js';19import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js';20import { CommentContextKeys } from '../common/commentContextKeys.js';21import { ILogService } from '../../../../platform/log/common/log.js';22import { CommentsModel, ICommentsModel } from './commentsModel.js';23import { IModelService } from '../../../../editor/common/services/model.js';24import { Schemas } from '../../../../base/common/network.js';2526export const ICommentService = createDecorator<ICommentService>('commentService');2728interface IResourceCommentThreadEvent {29resource: URI;30commentInfos: ICommentInfo[];31}3233export interface ICommentInfo<T = IRange> extends CommentInfo<T> {34uniqueOwner: string;35label?: string;36}3738export interface INotebookCommentInfo {39extensionId?: string;40threads: CommentThread<ICellRange>[];41uniqueOwner: string;42label?: string;43}4445export interface IWorkspaceCommentThreadsEvent {46ownerId: string;47ownerLabel: string;48commentThreads: CommentThread[];49}5051export interface INotebookCommentThreadChangedEvent extends CommentThreadChangedEvent<ICellRange> {52uniqueOwner: string;53}5455export interface ICommentController {56id: string;57label: string;58features: {59reactionGroup?: CommentReaction[];60reactionHandler?: boolean;61options?: CommentOptions;62};63options?: CommentOptions;64contextValue?: string;65owner: string;66activeComment: { thread: CommentThread; comment?: Comment } | undefined;67createCommentThreadTemplate(resource: UriComponents, range: IRange | undefined, editorId?: string): Promise<void>;68updateCommentThreadTemplate(threadHandle: number, range: IRange): Promise<void>;69deleteCommentThreadMain(commentThreadId: string): void;70toggleReaction(uri: URI, thread: CommentThread, comment: Comment, reaction: CommentReaction, token: CancellationToken): Promise<void>;71getDocumentComments(resource: URI, token: CancellationToken): Promise<ICommentInfo<IRange>>;72getNotebookComments(resource: URI, token: CancellationToken): Promise<INotebookCommentInfo>;73setActiveCommentAndThread(commentInfo: { thread: CommentThread; comment?: Comment } | undefined): Promise<void>;74}7576export interface IContinueOnCommentProvider {77provideContinueOnComments(): PendingCommentThread[];78}7980export interface ICommentService {81readonly _serviceBrand: undefined;82readonly onDidSetResourceCommentInfos: Event<IResourceCommentThreadEvent>;83readonly onDidSetAllCommentThreads: Event<IWorkspaceCommentThreadsEvent>;84readonly onDidUpdateCommentThreads: Event<ICommentThreadChangedEvent>;85readonly onDidUpdateNotebookCommentThreads: Event<INotebookCommentThreadChangedEvent>;86readonly onDidChangeActiveEditingCommentThread: Event<CommentThread | null>;87readonly onDidChangeCurrentCommentThread: Event<CommentThread | undefined>;88readonly onDidUpdateCommentingRanges: Event<{ uniqueOwner: string }>;89readonly onDidChangeActiveCommentingRange: Event<{ range: Range; commentingRangesInfo: CommentingRanges }>;90readonly onDidSetDataProvider: Event<void>;91readonly onDidDeleteDataProvider: Event<string | undefined>;92readonly onDidChangeCommentingEnabled: Event<boolean>;93readonly onResourceHasCommentingRanges: Event<void>;94readonly isCommentingEnabled: boolean;95readonly commentsModel: ICommentsModel;96readonly lastActiveCommentcontroller: ICommentController | undefined;97setDocumentComments(resource: URI, commentInfos: ICommentInfo[]): void;98setWorkspaceComments(uniqueOwner: string, commentsByResource: CommentThread<IRange | ICellRange>[]): void;99removeWorkspaceComments(uniqueOwner: string): void;100registerCommentController(uniqueOwner: string, commentControl: ICommentController): void;101unregisterCommentController(uniqueOwner?: string): void;102getCommentController(uniqueOwner: string): ICommentController | undefined;103createCommentThreadTemplate(uniqueOwner: string, resource: URI, range: Range | undefined, editorId?: string): Promise<void>;104updateCommentThreadTemplate(uniqueOwner: string, threadHandle: number, range: Range): Promise<void>;105getCommentMenus(uniqueOwner: string): CommentMenus;106updateComments(ownerId: string, event: CommentThreadChangedEvent<IRange>): void;107updateNotebookComments(ownerId: string, event: CommentThreadChangedEvent<ICellRange>): void;108disposeCommentThread(ownerId: string, threadId: string): void;109getDocumentComments(resource: URI): Promise<(ICommentInfo | null)[]>;110getNotebookComments(resource: URI): Promise<(INotebookCommentInfo | null)[]>;111updateCommentingRanges(ownerId: string, resourceHints?: CommentingRangeResourceHint): void;112hasReactionHandler(uniqueOwner: string): boolean;113toggleReaction(uniqueOwner: string, resource: URI, thread: CommentThread<IRange | ICellRange>, comment: Comment, reaction: CommentReaction): Promise<void>;114setActiveEditingCommentThread(commentThread: CommentThread<IRange | ICellRange> | null): void;115setCurrentCommentThread(commentThread: CommentThread<IRange | ICellRange> | undefined): void;116setActiveCommentAndThread(uniqueOwner: string, commentInfo: { thread: CommentThread<IRange | ICellRange>; comment?: Comment } | undefined): Promise<void>;117enableCommenting(enable: boolean): void;118registerContinueOnCommentProvider(provider: IContinueOnCommentProvider): IDisposable;119removeContinueOnComment(pendingComment: { range: IRange | undefined; uri: URI; uniqueOwner: string; isReply?: boolean }): PendingCommentThread | undefined;120resourceHasCommentingRanges(resource: URI): boolean;121}122123const CONTINUE_ON_COMMENTS = 'comments.continueOnComments';124125export class CommentService extends Disposable implements ICommentService {126declare readonly _serviceBrand: undefined;127128private readonly _onDidSetDataProvider: Emitter<void> = this._register(new Emitter<void>());129readonly onDidSetDataProvider: Event<void> = this._onDidSetDataProvider.event;130131private readonly _onDidDeleteDataProvider: Emitter<string | undefined> = this._register(new Emitter<string | undefined>());132readonly onDidDeleteDataProvider: Event<string | undefined> = this._onDidDeleteDataProvider.event;133134private readonly _onDidSetResourceCommentInfos: Emitter<IResourceCommentThreadEvent> = this._register(new Emitter<IResourceCommentThreadEvent>());135readonly onDidSetResourceCommentInfos: Event<IResourceCommentThreadEvent> = this._onDidSetResourceCommentInfos.event;136137private readonly _onDidSetAllCommentThreads: Emitter<IWorkspaceCommentThreadsEvent> = this._register(new Emitter<IWorkspaceCommentThreadsEvent>());138readonly onDidSetAllCommentThreads: Event<IWorkspaceCommentThreadsEvent> = this._onDidSetAllCommentThreads.event;139140private readonly _onDidUpdateCommentThreads: Emitter<ICommentThreadChangedEvent> = this._register(new Emitter<ICommentThreadChangedEvent>());141readonly onDidUpdateCommentThreads: Event<ICommentThreadChangedEvent> = this._onDidUpdateCommentThreads.event;142143private readonly _onDidUpdateNotebookCommentThreads: Emitter<INotebookCommentThreadChangedEvent> = this._register(new Emitter<INotebookCommentThreadChangedEvent>());144readonly onDidUpdateNotebookCommentThreads: Event<INotebookCommentThreadChangedEvent> = this._onDidUpdateNotebookCommentThreads.event;145146private readonly _onDidUpdateCommentingRanges: Emitter<{ uniqueOwner: string }> = this._register(new Emitter<{ uniqueOwner: string }>());147readonly onDidUpdateCommentingRanges: Event<{ uniqueOwner: string }> = this._onDidUpdateCommentingRanges.event;148149private readonly _onDidChangeActiveEditingCommentThread = this._register(new Emitter<CommentThread | null>());150readonly onDidChangeActiveEditingCommentThread = this._onDidChangeActiveEditingCommentThread.event;151152private readonly _onDidChangeCurrentCommentThread = this._register(new Emitter<CommentThread | undefined>());153readonly onDidChangeCurrentCommentThread = this._onDidChangeCurrentCommentThread.event;154155private readonly _onDidChangeCommentingEnabled = this._register(new Emitter<boolean>());156readonly onDidChangeCommentingEnabled = this._onDidChangeCommentingEnabled.event;157158private readonly _onResourceHasCommentingRanges = this._register(new Emitter<void>());159readonly onResourceHasCommentingRanges = this._onResourceHasCommentingRanges.event;160161private readonly _onDidChangeActiveCommentingRange: Emitter<{162range: Range; commentingRangesInfo:163CommentingRanges;164}> = this._register(new Emitter<{165range: Range; commentingRangesInfo:166CommentingRanges;167}>());168readonly onDidChangeActiveCommentingRange: Event<{ range: Range; commentingRangesInfo: CommentingRanges }> = this._onDidChangeActiveCommentingRange.event;169170private _commentControls = new Map<string, ICommentController>();171private _commentMenus = new Map<string, CommentMenus>();172private _isCommentingEnabled: boolean = true;173private _workspaceHasCommenting: IContextKey<boolean>;174private _commentingEnabled: IContextKey<boolean>;175176private _continueOnComments = new Map<string, PendingCommentThread[]>(); // uniqueOwner -> PendingCommentThread[]177private _continueOnCommentProviders = new Set<IContinueOnCommentProvider>();178179private readonly _commentsModel: CommentsModel = this._register(new CommentsModel());180public readonly commentsModel: ICommentsModel = this._commentsModel;181182private _commentingRangeResources = new Set<string>(); // URIs183private _commentingRangeResourceHintSchemes = new Set<string>(); // schemes184185constructor(186@IInstantiationService protected readonly instantiationService: IInstantiationService,187@IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService,188@IConfigurationService private readonly configurationService: IConfigurationService,189@IContextKeyService contextKeyService: IContextKeyService,190@IStorageService private readonly storageService: IStorageService,191@ILogService private readonly logService: ILogService,192@IModelService private readonly modelService: IModelService193) {194super();195this._handleConfiguration();196this._handleZenMode();197this._workspaceHasCommenting = CommentContextKeys.WorkspaceHasCommenting.bindTo(contextKeyService);198this._commentingEnabled = CommentContextKeys.commentingEnabled.bindTo(contextKeyService);199const storageListener = this._register(new DisposableStore());200201const storageEvent = Event.debounce(this.storageService.onDidChangeValue(StorageScope.WORKSPACE, CONTINUE_ON_COMMENTS, storageListener), (last, event) => last?.external ? last : event, 500);202storageListener.add(storageEvent(v => {203if (!v.external) {204return;205}206const commentsToRestore: PendingCommentThread[] | undefined = this.storageService.getObject(CONTINUE_ON_COMMENTS, StorageScope.WORKSPACE);207if (!commentsToRestore) {208return;209}210this.logService.debug(`Comments: URIs of continue on comments from storage ${commentsToRestore.map(thread => thread.uri.toString()).join(', ')}.`);211const changedOwners = this._addContinueOnComments(commentsToRestore, this._continueOnComments);212for (const uniqueOwner of changedOwners) {213const control = this._commentControls.get(uniqueOwner);214if (!control) {215continue;216}217const evt: ICommentThreadChangedEvent = {218uniqueOwner: uniqueOwner,219owner: control.owner,220ownerLabel: control.label,221pending: this._continueOnComments.get(uniqueOwner) || [],222added: [],223removed: [],224changed: []225};226this.updateModelThreads(evt);227}228}));229this._register(storageService.onWillSaveState(() => {230const map: Map<string, PendingCommentThread[]> = new Map();231for (const provider of this._continueOnCommentProviders) {232const pendingComments = provider.provideContinueOnComments();233this._addContinueOnComments(pendingComments, map);234}235this._saveContinueOnComments(map);236}));237238this._register(this.modelService.onModelAdded(model => {239// Excluded schemes240if ((model.uri.scheme === Schemas.vscodeSourceControl)) {241return;242}243// Allows comment providers to cause their commenting ranges to be prefetched by opening text documents in the background.244if (!this._commentingRangeResources.has(model.uri.toString())) {245this.getDocumentComments(model.uri);246}247}));248}249250private _updateResourcesWithCommentingRanges(resource: URI, commentInfos: (ICommentInfo | null)[]) {251let addedResources = false;252for (const comments of commentInfos) {253if (comments && (comments.commentingRanges.ranges.length > 0 || comments.threads.length > 0)) {254this._commentingRangeResources.add(resource.toString());255addedResources = true;256}257}258if (addedResources) {259this._onResourceHasCommentingRanges.fire();260}261}262263private _handleConfiguration() {264this._isCommentingEnabled = this._defaultCommentingEnablement;265this._register(this.configurationService.onDidChangeConfiguration(e => {266if (e.affectsConfiguration('comments.visible')) {267this.enableCommenting(this._defaultCommentingEnablement);268}269}));270}271272private _handleZenMode() {273let preZenModeValue: boolean = this._isCommentingEnabled;274this._register(this.layoutService.onDidChangeZenMode(e => {275if (e) {276preZenModeValue = this._isCommentingEnabled;277this.enableCommenting(false);278} else {279this.enableCommenting(preZenModeValue);280}281}));282}283284private get _defaultCommentingEnablement(): boolean {285return !!this.configurationService.getValue<ICommentsConfiguration | undefined>(COMMENTS_SECTION)?.visible;286}287288get isCommentingEnabled(): boolean {289return this._isCommentingEnabled;290}291292enableCommenting(enable: boolean): void {293if (enable !== this._isCommentingEnabled) {294this._isCommentingEnabled = enable;295this._commentingEnabled.set(enable);296this._onDidChangeCommentingEnabled.fire(enable);297}298}299300/**301* The current comment thread is the thread that has focus or is being hovered.302* @param commentThread303*/304setCurrentCommentThread(commentThread: CommentThread | undefined) {305this._onDidChangeCurrentCommentThread.fire(commentThread);306}307308/**309* The active comment thread is the thread that is currently being edited.310* @param commentThread311*/312setActiveEditingCommentThread(commentThread: CommentThread | null) {313this._onDidChangeActiveEditingCommentThread.fire(commentThread);314}315316get lastActiveCommentcontroller() {317return this._lastActiveCommentController;318}319320private _lastActiveCommentController: ICommentController | undefined;321async setActiveCommentAndThread(uniqueOwner: string, commentInfo: { thread: CommentThread<IRange>; comment?: Comment } | undefined) {322const commentController = this._commentControls.get(uniqueOwner);323324if (!commentController) {325return;326}327328if (commentController !== this._lastActiveCommentController) {329await this._lastActiveCommentController?.setActiveCommentAndThread(undefined);330}331this._lastActiveCommentController = commentController;332return commentController.setActiveCommentAndThread(commentInfo);333}334335setDocumentComments(resource: URI, commentInfos: ICommentInfo[]): void {336this._onDidSetResourceCommentInfos.fire({ resource, commentInfos });337}338339private setModelThreads(ownerId: string, owner: string, ownerLabel: string, commentThreads: CommentThread<IRange>[]) {340this._commentsModel.setCommentThreads(ownerId, owner, ownerLabel, commentThreads);341this._onDidSetAllCommentThreads.fire({ ownerId, ownerLabel, commentThreads });342}343344private updateModelThreads(event: ICommentThreadChangedEvent) {345this._commentsModel.updateCommentThreads(event);346this._onDidUpdateCommentThreads.fire(event);347}348349setWorkspaceComments(uniqueOwner: string, commentsByResource: CommentThread[]): void {350351if (commentsByResource.length) {352this._workspaceHasCommenting.set(true);353}354const control = this._commentControls.get(uniqueOwner);355if (control) {356this.setModelThreads(uniqueOwner, control.owner, control.label, commentsByResource);357}358}359360removeWorkspaceComments(uniqueOwner: string): void {361const control = this._commentControls.get(uniqueOwner);362if (control) {363this.setModelThreads(uniqueOwner, control.owner, control.label, []);364}365}366367registerCommentController(uniqueOwner: string, commentControl: ICommentController): void {368this._commentControls.set(uniqueOwner, commentControl);369this._onDidSetDataProvider.fire();370}371372unregisterCommentController(uniqueOwner?: string): void {373if (uniqueOwner) {374this._commentControls.delete(uniqueOwner);375} else {376this._commentControls.clear();377}378this._commentsModel.deleteCommentsByOwner(uniqueOwner);379this._onDidDeleteDataProvider.fire(uniqueOwner);380}381382getCommentController(uniqueOwner: string): ICommentController | undefined {383return this._commentControls.get(uniqueOwner);384}385386async createCommentThreadTemplate(uniqueOwner: string, resource: URI, range: Range | undefined, editorId?: string): Promise<void> {387const commentController = this._commentControls.get(uniqueOwner);388389if (!commentController) {390return;391}392393return commentController.createCommentThreadTemplate(resource, range, editorId);394}395396async updateCommentThreadTemplate(uniqueOwner: string, threadHandle: number, range: Range) {397const commentController = this._commentControls.get(uniqueOwner);398399if (!commentController) {400return;401}402403await commentController.updateCommentThreadTemplate(threadHandle, range);404}405406disposeCommentThread(uniqueOwner: string, threadId: string) {407const controller = this.getCommentController(uniqueOwner);408controller?.deleteCommentThreadMain(threadId);409}410411getCommentMenus(uniqueOwner: string): CommentMenus {412if (this._commentMenus.get(uniqueOwner)) {413return this._commentMenus.get(uniqueOwner)!;414}415416const menu = this.instantiationService.createInstance(CommentMenus);417this._commentMenus.set(uniqueOwner, menu);418return menu;419}420421updateComments(ownerId: string, event: CommentThreadChangedEvent<IRange>): void {422const control = this._commentControls.get(ownerId);423if (control) {424const evt: ICommentThreadChangedEvent = Object.assign({}, event, { uniqueOwner: ownerId, ownerLabel: control.label, owner: control.owner });425this.updateModelThreads(evt);426}427}428429updateNotebookComments(ownerId: string, event: CommentThreadChangedEvent<ICellRange>): void {430const evt: INotebookCommentThreadChangedEvent = Object.assign({}, event, { uniqueOwner: ownerId });431this._onDidUpdateNotebookCommentThreads.fire(evt);432}433434updateCommentingRanges(ownerId: string, resourceHints?: CommentingRangeResourceHint) {435if (resourceHints?.schemes && resourceHints.schemes.length > 0) {436for (const scheme of resourceHints.schemes) {437this._commentingRangeResourceHintSchemes.add(scheme);438}439}440this._workspaceHasCommenting.set(true);441this._onDidUpdateCommentingRanges.fire({ uniqueOwner: ownerId });442}443444async toggleReaction(uniqueOwner: string, resource: URI, thread: CommentThread, comment: Comment, reaction: CommentReaction): Promise<void> {445const commentController = this._commentControls.get(uniqueOwner);446447if (commentController) {448return commentController.toggleReaction(resource, thread, comment, reaction, CancellationToken.None);449} else {450throw new Error('Not supported');451}452}453454hasReactionHandler(uniqueOwner: string): boolean {455const commentProvider = this._commentControls.get(uniqueOwner);456457if (commentProvider) {458return !!commentProvider.features.reactionHandler;459}460461return false;462}463464async getDocumentComments(resource: URI): Promise<(ICommentInfo | null)[]> {465const commentControlResult: Promise<ICommentInfo | null>[] = [];466467for (const control of this._commentControls.values()) {468commentControlResult.push(control.getDocumentComments(resource, CancellationToken.None)469.then(documentComments => {470// Check that there aren't any continue on comments in the provided comments471// This can happen because continue on comments are stored separately from local un-submitted comments.472for (const documentCommentThread of documentComments.threads) {473if (documentCommentThread.comments?.length === 0 && documentCommentThread.range) {474this.removeContinueOnComment({ range: documentCommentThread.range, uri: resource, uniqueOwner: documentComments.uniqueOwner });475}476}477const pendingComments = this._continueOnComments.get(documentComments.uniqueOwner);478documentComments.pendingCommentThreads = pendingComments?.filter(pendingComment => pendingComment.uri.toString() === resource.toString());479return documentComments;480})481.catch(_ => {482return null;483}));484}485486const commentInfos = await Promise.all(commentControlResult);487this._updateResourcesWithCommentingRanges(resource, commentInfos);488return commentInfos;489}490491async getNotebookComments(resource: URI): Promise<(INotebookCommentInfo | null)[]> {492const commentControlResult: Promise<INotebookCommentInfo | null>[] = [];493494this._commentControls.forEach(control => {495commentControlResult.push(control.getNotebookComments(resource, CancellationToken.None)496.catch(_ => {497return null;498}));499});500501return Promise.all(commentControlResult);502}503504registerContinueOnCommentProvider(provider: IContinueOnCommentProvider): IDisposable {505this._continueOnCommentProviders.add(provider);506return {507dispose: () => {508this._continueOnCommentProviders.delete(provider);509}510};511}512513private _saveContinueOnComments(map: Map<string, PendingCommentThread[]>) {514const commentsToSave: PendingCommentThread[] = [];515for (const pendingComments of map.values()) {516commentsToSave.push(...pendingComments);517}518this.logService.debug(`Comments: URIs of continue on comments to add to storage ${commentsToSave.map(thread => thread.uri.toString()).join(', ')}.`);519this.storageService.store(CONTINUE_ON_COMMENTS, commentsToSave, StorageScope.WORKSPACE, StorageTarget.USER);520}521522removeContinueOnComment(pendingComment: { range: IRange; uri: URI; uniqueOwner: string; isReply?: boolean }): PendingCommentThread | undefined {523const pendingComments = this._continueOnComments.get(pendingComment.uniqueOwner);524if (pendingComments) {525const commentIndex = pendingComments.findIndex(comment => comment.uri.toString() === pendingComment.uri.toString() && Range.equalsRange(comment.range, pendingComment.range) && (pendingComment.isReply === undefined || comment.isReply === pendingComment.isReply));526if (commentIndex > -1) {527return pendingComments.splice(commentIndex, 1)[0];528}529}530return undefined;531}532533private _addContinueOnComments(pendingComments: PendingCommentThread[], map: Map<string, PendingCommentThread[]>): Set<string> {534const changedOwners = new Set<string>();535for (const pendingComment of pendingComments) {536if (!map.has(pendingComment.uniqueOwner)) {537map.set(pendingComment.uniqueOwner, [pendingComment]);538changedOwners.add(pendingComment.uniqueOwner);539} else {540const commentsForOwner = map.get(pendingComment.uniqueOwner)!;541if (commentsForOwner.every(comment => (comment.uri.toString() !== pendingComment.uri.toString()) || !Range.equalsRange(comment.range, pendingComment.range))) {542commentsForOwner.push(pendingComment);543changedOwners.add(pendingComment.uniqueOwner);544}545}546}547return changedOwners;548}549550resourceHasCommentingRanges(resource: URI): boolean {551return this._commentingRangeResourceHintSchemes.has(resource.scheme) || this._commentingRangeResources.has(resource.toString());552}553}554555556