Path: blob/main/src/vs/workbench/contrib/bulkEdit/browser/preview/bulkEditPreview.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 { ITextModelContentProvider, ITextModelService } from '../../../../../editor/common/services/resolverService.js';6import { URI } from '../../../../../base/common/uri.js';7import { ILanguageService } from '../../../../../editor/common/languages/language.js';8import { IModelService } from '../../../../../editor/common/services/model.js';9import { createTextBufferFactoryFromSnapshot } from '../../../../../editor/common/model/textModel.js';10import { WorkspaceEditMetadata } from '../../../../../editor/common/languages.js';11import { DisposableStore } from '../../../../../base/common/lifecycle.js';12import { coalesceInPlace } from '../../../../../base/common/arrays.js';13import { Range } from '../../../../../editor/common/core/range.js';14import { EditOperation, ISingleEditOperation } from '../../../../../editor/common/core/editOperation.js';15import { ServicesAccessor, IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js';16import { IFileService } from '../../../../../platform/files/common/files.js';17import { Emitter, Event } from '../../../../../base/common/event.js';18import { ConflictDetector } from '../conflicts.js';19import { ResourceMap } from '../../../../../base/common/map.js';20import { localize } from '../../../../../nls.js';21import { extUri } from '../../../../../base/common/resources.js';22import { ResourceEdit, ResourceFileEdit, ResourceTextEdit } from '../../../../../editor/browser/services/bulkEditService.js';23import { Codicon } from '../../../../../base/common/codicons.js';24import { generateUuid } from '../../../../../base/common/uuid.js';25import { SnippetParser } from '../../../../../editor/contrib/snippet/browser/snippetParser.js';26import { MicrotaskDelay } from '../../../../../base/common/symbols.js';27import { Schemas } from '../../../../../base/common/network.js';2829export class CheckedStates<T extends object> {3031private readonly _states = new WeakMap<T, boolean>();32private _checkedCount: number = 0;3334private readonly _onDidChange = new Emitter<T>();35readonly onDidChange: Event<T> = this._onDidChange.event;3637dispose(): void {38this._onDidChange.dispose();39}4041get checkedCount() {42return this._checkedCount;43}4445isChecked(obj: T): boolean {46return this._states.get(obj) ?? false;47}4849updateChecked(obj: T, value: boolean): void {50const valueNow = this._states.get(obj);51if (valueNow === value) {52return;53}54if (valueNow === undefined) {55if (value) {56this._checkedCount += 1;57}58} else {59if (value) {60this._checkedCount += 1;61} else {62this._checkedCount -= 1;63}64}65this._states.set(obj, value);66this._onDidChange.fire(obj);67}68}6970export class BulkTextEdit {7172constructor(73readonly parent: BulkFileOperation,74readonly textEdit: ResourceTextEdit75) { }76}7778export const enum BulkFileOperationType {79TextEdit = 1,80Create = 2,81Delete = 4,82Rename = 8,83}8485export class BulkFileOperation {8687type = 0;88textEdits: BulkTextEdit[] = [];89originalEdits = new Map<number, ResourceTextEdit | ResourceFileEdit>();90newUri?: URI;9192constructor(93readonly uri: URI,94readonly parent: BulkFileOperations95) { }9697addEdit(index: number, type: BulkFileOperationType, edit: ResourceTextEdit | ResourceFileEdit) {98this.type |= type;99this.originalEdits.set(index, edit);100if (edit instanceof ResourceTextEdit) {101this.textEdits.push(new BulkTextEdit(this, edit));102103} else if (type === BulkFileOperationType.Rename) {104this.newUri = edit.newResource;105}106}107108needsConfirmation(): boolean {109for (const [, edit] of this.originalEdits) {110if (!this.parent.checked.isChecked(edit)) {111return true;112}113}114return false;115}116}117118export class BulkCategory {119120private static readonly _defaultMetadata = Object.freeze({121label: localize('default', "Other"),122icon: Codicon.symbolFile,123needsConfirmation: false124});125126static keyOf(metadata?: WorkspaceEditMetadata) {127return metadata?.label || '<default>';128}129130readonly operationByResource = new Map<string, BulkFileOperation>();131132constructor(readonly metadata: WorkspaceEditMetadata = BulkCategory._defaultMetadata) { }133134get fileOperations(): IterableIterator<BulkFileOperation> {135return this.operationByResource.values();136}137}138139export class BulkFileOperations {140141static async create(accessor: ServicesAccessor, bulkEdit: ResourceEdit[]): Promise<BulkFileOperations> {142const result = accessor.get(IInstantiationService).createInstance(BulkFileOperations, bulkEdit);143return await result._init();144}145146readonly checked = new CheckedStates<ResourceEdit>();147148readonly fileOperations: BulkFileOperation[] = [];149readonly categories: BulkCategory[] = [];150readonly conflicts: ConflictDetector;151152constructor(153private readonly _bulkEdit: ResourceEdit[],154@IFileService private readonly _fileService: IFileService,155@IInstantiationService instaService: IInstantiationService,156) {157this.conflicts = instaService.createInstance(ConflictDetector, _bulkEdit);158}159160dispose(): void {161this.checked.dispose();162this.conflicts.dispose();163}164165async _init() {166const operationByResource = new Map<string, BulkFileOperation>();167const operationByCategory = new Map<string, BulkCategory>();168169const newToOldUri = new ResourceMap<URI>();170171for (let idx = 0; idx < this._bulkEdit.length; idx++) {172const edit = this._bulkEdit[idx];173174let uri: URI;175let type: BulkFileOperationType;176177// store inital checked state178this.checked.updateChecked(edit, !edit.metadata?.needsConfirmation);179180if (edit instanceof ResourceTextEdit) {181type = BulkFileOperationType.TextEdit;182uri = edit.resource;183184} else if (edit instanceof ResourceFileEdit) {185if (edit.newResource && edit.oldResource) {186type = BulkFileOperationType.Rename;187uri = edit.oldResource;188if (edit.options?.overwrite === undefined && edit.options?.ignoreIfExists && await this._fileService.exists(uri)) {189// noop -> "soft" rename to something that already exists190continue;191}192// map newResource onto oldResource so that text-edit appear for193// the same file element194newToOldUri.set(edit.newResource, uri);195196} else if (edit.oldResource) {197type = BulkFileOperationType.Delete;198uri = edit.oldResource;199if (edit.options?.ignoreIfNotExists && !await this._fileService.exists(uri)) {200// noop -> "soft" delete something that doesn't exist201continue;202}203204} else if (edit.newResource) {205type = BulkFileOperationType.Create;206uri = edit.newResource;207if (edit.options?.overwrite === undefined && edit.options?.ignoreIfExists && await this._fileService.exists(uri)) {208// noop -> "soft" create something that already exists209continue;210}211212} else {213// invalid edit -> skip214continue;215}216217} else {218// unsupported edit219continue;220}221222const insert = (uri: URI, map: Map<string, BulkFileOperation>) => {223let key = extUri.getComparisonKey(uri, true);224let operation = map.get(key);225226// rename227if (!operation && newToOldUri.has(uri)) {228uri = newToOldUri.get(uri)!;229key = extUri.getComparisonKey(uri, true);230operation = map.get(key);231}232233if (!operation) {234operation = new BulkFileOperation(uri, this);235map.set(key, operation);236}237operation.addEdit(idx, type, edit);238};239240insert(uri, operationByResource);241242// insert into "this" category243const key = BulkCategory.keyOf(edit.metadata);244let category = operationByCategory.get(key);245if (!category) {246category = new BulkCategory(edit.metadata);247operationByCategory.set(key, category);248}249insert(uri, category.operationByResource);250}251252operationByResource.forEach(value => this.fileOperations.push(value));253operationByCategory.forEach(value => this.categories.push(value));254255// "correct" invalid parent-check child states that is256// unchecked file edits (rename, create, delete) uncheck257// all edits for a file, e.g no text change without rename258for (const file of this.fileOperations) {259if (file.type !== BulkFileOperationType.TextEdit) {260let checked = true;261for (const edit of file.originalEdits.values()) {262if (edit instanceof ResourceFileEdit) {263checked = checked && this.checked.isChecked(edit);264}265}266if (!checked) {267for (const edit of file.originalEdits.values()) {268this.checked.updateChecked(edit, checked);269}270}271}272}273274// sort (once) categories atop which have unconfirmed edits275this.categories.sort((a, b) => {276if (a.metadata.needsConfirmation === b.metadata.needsConfirmation) {277return a.metadata.label.localeCompare(b.metadata.label);278} else if (a.metadata.needsConfirmation) {279return -1;280} else {281return 1;282}283});284285return this;286}287288getWorkspaceEdit(): ResourceEdit[] {289const result: ResourceEdit[] = [];290let allAccepted = true;291292for (let i = 0; i < this._bulkEdit.length; i++) {293const edit = this._bulkEdit[i];294if (this.checked.isChecked(edit)) {295result[i] = edit;296continue;297}298allAccepted = false;299}300301if (allAccepted) {302return this._bulkEdit;303}304305// not all edits have been accepted306coalesceInPlace(result);307return result;308}309310private async getFileEditOperation(edit: ResourceFileEdit): Promise<ISingleEditOperation | undefined> {311const content = await edit.options.contents;312if (!content) { return undefined; }313return EditOperation.replaceMove(Range.lift({ startLineNumber: 0, startColumn: 0, endLineNumber: Number.MAX_VALUE, endColumn: 0 }), content.toString());314}315316async getFileEdits(uri: URI): Promise<ISingleEditOperation[]> {317318for (const file of this.fileOperations) {319if (file.uri.toString() === uri.toString()) {320321const result: Promise<ISingleEditOperation | undefined>[] = [];322let ignoreAll = false;323324for (const edit of file.originalEdits.values()) {325if (edit instanceof ResourceFileEdit) {326result.push(this.getFileEditOperation(edit));327} else if (edit instanceof ResourceTextEdit) {328if (this.checked.isChecked(edit)) {329result.push(Promise.resolve(EditOperation.replaceMove(Range.lift(edit.textEdit.range), !edit.textEdit.insertAsSnippet ? edit.textEdit.text : SnippetParser.asInsertText(edit.textEdit.text))));330}331332} else if (!this.checked.isChecked(edit)) {333// UNCHECKED WorkspaceFileEdit disables all text edits334ignoreAll = true;335}336}337338if (ignoreAll) {339return [];340}341342return (await Promise.all(result)).filter(r => r !== undefined).sort((a, b) => Range.compareRangesUsingStarts(a.range, b.range));343}344}345return [];346}347348getUriOfEdit(edit: ResourceEdit): URI {349for (const file of this.fileOperations) {350for (const value of file.originalEdits.values()) {351if (value === edit) {352return file.uri;353}354}355}356throw new Error('invalid edit');357}358}359360export class BulkEditPreviewProvider implements ITextModelContentProvider {361362private static readonly Schema = 'vscode-bulkeditpreview-editor';363364static emptyPreview = URI.from({ scheme: this.Schema, fragment: 'empty' });365366367static fromPreviewUri(uri: URI): URI {368return URI.parse(uri.query);369}370371private readonly _disposables = new DisposableStore();372private readonly _ready: Promise<any>;373private readonly _modelPreviewEdits = new Map<string, ISingleEditOperation[]>();374private readonly _instanceId = generateUuid();375376constructor(377private readonly _operations: BulkFileOperations,378@ILanguageService private readonly _languageService: ILanguageService,379@IModelService private readonly _modelService: IModelService,380@ITextModelService private readonly _textModelResolverService: ITextModelService381) {382this._disposables.add(this._textModelResolverService.registerTextModelContentProvider(BulkEditPreviewProvider.Schema, this));383this._ready = this._init();384}385386dispose(): void {387this._disposables.dispose();388}389390asPreviewUri(uri: URI): URI {391const path = uri.scheme === Schemas.untitled ? `/${uri.path}` : uri.path;392return URI.from({ scheme: BulkEditPreviewProvider.Schema, authority: this._instanceId, path, query: uri.toString() });393}394395private async _init() {396for (const operation of this._operations.fileOperations) {397await this._applyTextEditsToPreviewModel(operation.uri);398}399this._disposables.add(Event.debounce(this._operations.checked.onDidChange, (_last, e) => e, MicrotaskDelay)(e => {400const uri = this._operations.getUriOfEdit(e);401this._applyTextEditsToPreviewModel(uri);402}));403}404405private async _applyTextEditsToPreviewModel(uri: URI) {406const model = await this._getOrCreatePreviewModel(uri);407408// undo edits that have been done before409const undoEdits = this._modelPreviewEdits.get(model.id);410if (undoEdits) {411model.applyEdits(undoEdits);412}413// apply new edits and keep (future) undo edits414const newEdits = await this._operations.getFileEdits(uri);415const newUndoEdits = model.applyEdits(newEdits, true);416this._modelPreviewEdits.set(model.id, newUndoEdits);417}418419private async _getOrCreatePreviewModel(uri: URI) {420const previewUri = this.asPreviewUri(uri);421let model = this._modelService.getModel(previewUri);422if (!model) {423try {424// try: copy existing425const ref = await this._textModelResolverService.createModelReference(uri);426const sourceModel = ref.object.textEditorModel;427model = this._modelService.createModel(428createTextBufferFactoryFromSnapshot(sourceModel.createSnapshot()),429this._languageService.createById(sourceModel.getLanguageId()),430previewUri431);432ref.dispose();433434} catch {435// create NEW model436model = this._modelService.createModel(437'',438this._languageService.createByFilepathOrFirstLine(previewUri),439previewUri440);441}442// this is a little weird but otherwise editors and other cusomers443// will dispose my models before they should be disposed...444// And all of this is off the eventloop to prevent endless recursion445queueMicrotask(async () => {446this._disposables.add(await this._textModelResolverService.createModelReference(model!.uri));447});448}449return model;450}451452async provideTextContent(previewUri: URI) {453if (previewUri.toString() === BulkEditPreviewProvider.emptyPreview.toString()) {454return this._modelService.createModel('', null, previewUri);455}456await this._ready;457return this._modelService.getModel(previewUri);458}459}460461462