create separate files for text and file operation
This commit is contained in:
Johannes Rieken 2020-06-22 12:48:35 +02:00
parent f816c23b22
commit e3b9caa935
3 changed files with 316 additions and 293 deletions

View file

@ -3,243 +3,20 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { mergeSort } from 'vs/base/common/arrays';
import { dispose, IDisposable, IReference, toDisposable } from 'vs/base/common/lifecycle';
import { URI } from 'vs/base/common/uri';
import { localize } from 'vs/nls';
import { IDisposable, toDisposable } from 'vs/base/common/lifecycle';
import { ICodeEditor, isCodeEditor } from 'vs/editor/browser/editorBrowser';
import { IBulkEditOptions, IBulkEditResult, IBulkEditService, IBulkEditPreviewHandler } from 'vs/editor/browser/services/bulkEditService';
import { EditOperation } from 'vs/editor/common/core/editOperation';
import { Range } from 'vs/editor/common/core/range';
import { Selection } from 'vs/editor/common/core/selection';
import { EndOfLineSequence, IIdentifiedSingleEditOperation, ITextModel } from 'vs/editor/common/model';
import { WorkspaceFileEdit, WorkspaceTextEdit, WorkspaceEdit } from 'vs/editor/common/modes';
import { IModelService } from 'vs/editor/common/services/modelService';
import { ITextModelService, IResolvedTextEditorModel } from 'vs/editor/common/services/resolverService';
import { localize } from 'vs/nls';
import { IFileService, FileSystemProviderCapabilities } from 'vs/platform/files/common/files';
import { registerSingleton } from 'vs/platform/instantiation/common/extensions';
import { ILogService } from 'vs/platform/log/common/log';
import { IProgress, IProgressStep, Progress } from 'vs/platform/progress/common/progress';
import { IEditorService } from 'vs/workbench/services/editor/common/editorService';
import { ITextFileService } from 'vs/workbench/services/textfile/common/textfiles';
import { IConfigurationService } from 'vs/platform/configuration/common/configuration';
import { EditorOption } from 'vs/editor/common/config/editorOptions';
import { IEditorWorkerService } from 'vs/editor/common/services/editorWorkerService';
import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation';
import { IWorkingCopyFileService } from 'vs/workbench/services/workingCopy/common/workingCopyFileService';
import { IUndoRedoService } from 'vs/platform/undoRedo/common/undoRedo';
import { SingleModelEditStackElement, MultiModelEditStackElement } from 'vs/editor/common/model/editStack';
type ValidationResult = { canApply: true } | { canApply: false, reason: URI };
class ModelEditTask implements IDisposable {
public readonly model: ITextModel;
protected _edits: IIdentifiedSingleEditOperation[];
private _expectedModelVersionId: number | undefined;
protected _newEol: EndOfLineSequence | undefined;
constructor(private readonly _modelReference: IReference<IResolvedTextEditorModel>) {
this.model = this._modelReference.object.textEditorModel;
this._edits = [];
}
dispose() {
this._modelReference.dispose();
}
addEdit(resourceEdit: WorkspaceTextEdit): void {
this._expectedModelVersionId = resourceEdit.modelVersionId;
const { edit } = resourceEdit;
if (typeof edit.eol === 'number') {
// honor eol-change
this._newEol = edit.eol;
}
if (!edit.range && !edit.text) {
// lacks both a range and the text
return;
}
if (Range.isEmpty(edit.range) && !edit.text) {
// no-op edit (replace empty range with empty text)
return;
}
// create edit operation
let range: Range;
if (!edit.range) {
range = this.model.getFullModelRange();
} else {
range = Range.lift(edit.range);
}
this._edits.push(EditOperation.replaceMove(range, edit.text));
}
validate(): ValidationResult {
if (typeof this._expectedModelVersionId === 'undefined' || this.model.getVersionId() === this._expectedModelVersionId) {
return { canApply: true };
}
return { canApply: false, reason: this.model.uri };
}
getBeforeCursorState(): Selection[] | null {
return null;
}
apply(): void {
if (this._edits.length > 0) {
this._edits = mergeSort(this._edits, (a, b) => Range.compareRangesUsingStarts(a.range, b.range));
this.model.pushEditOperations(null, this._edits, () => null);
}
if (this._newEol !== undefined) {
this.model.pushEOL(this._newEol);
}
}
}
class EditorEditTask extends ModelEditTask {
private _editor: ICodeEditor;
constructor(modelReference: IReference<IResolvedTextEditorModel>, editor: ICodeEditor) {
super(modelReference);
this._editor = editor;
}
getBeforeCursorState(): Selection[] | null {
return this._editor.getSelections();
}
apply(): void {
if (this._edits.length > 0) {
this._edits = mergeSort(this._edits, (a, b) => Range.compareRangesUsingStarts(a.range, b.range));
this._editor.executeEdits('', this._edits);
}
if (this._newEol !== undefined) {
if (this._editor.hasModel()) {
this._editor.getModel().pushEOL(this._newEol);
}
}
}
}
class BulkEditModel implements IDisposable {
private _edits = new Map<string, WorkspaceTextEdit[]>();
private _tasks: ModelEditTask[] | undefined;
constructor(
private readonly _label: string | undefined,
private readonly _editor: ICodeEditor | undefined,
private readonly _progress: IProgress<void>,
edits: WorkspaceTextEdit[],
@IEditorWorkerService private readonly _editorWorker: IEditorWorkerService,
@ITextModelService private readonly _textModelResolverService: ITextModelService,
@IUndoRedoService private readonly _undoRedoService: IUndoRedoService
) {
edits.forEach(this._addEdit, this);
}
dispose(): void {
if (this._tasks) {
dispose(this._tasks);
}
}
private _addEdit(edit: WorkspaceTextEdit): void {
let array = this._edits.get(edit.resource.toString());
if (!array) {
array = [];
this._edits.set(edit.resource.toString(), array);
}
array.push(edit);
}
async prepare(): Promise<BulkEditModel> {
if (this._tasks) {
throw new Error('illegal state - already prepared');
}
this._tasks = [];
const promises: Promise<any>[] = [];
for (let [key, value] of this._edits) {
const promise = this._textModelResolverService.createModelReference(URI.parse(key)).then(async ref => {
let task: ModelEditTask;
let makeMinimal = false;
if (this._editor && this._editor.hasModel() && this._editor.getModel().uri.toString() === ref.object.textEditorModel.uri.toString()) {
task = new EditorEditTask(ref, this._editor);
makeMinimal = true;
} else {
task = new ModelEditTask(ref);
}
for (const edit of value) {
if (makeMinimal) {
const newEdits = await this._editorWorker.computeMoreMinimalEdits(edit.resource, [edit.edit]);
if (!newEdits) {
task.addEdit(edit);
} else {
for (let moreMinialEdit of newEdits) {
task.addEdit({ ...edit, edit: moreMinialEdit });
}
}
} else {
task.addEdit(edit);
}
}
this._tasks!.push(task);
this._progress.report(undefined);
});
promises.push(promise);
}
await Promise.all(promises);
return this;
}
validate(): ValidationResult {
for (const task of this._tasks!) {
const result = task.validate();
if (!result.canApply) {
return result;
}
}
return { canApply: true };
}
apply(): void {
const tasks = this._tasks!;
if (tasks.length === 1) {
// This edit touches a single model => keep things simple
for (const task of tasks) {
task.model.pushStackElement();
task.apply();
task.model.pushStackElement();
this._progress.report(undefined);
}
return;
}
const multiModelEditStackElement = new MultiModelEditStackElement(
this._label || localize('workspaceEdit', "Workspace Edit"),
tasks.map(t => new SingleModelEditStackElement(t.model, t.getBeforeCursorState()))
);
this._undoRedoService.pushElement(multiModelEditStackElement);
for (const task of tasks) {
task.apply();
this._progress.report(undefined);
}
multiModelEditStackElement.close();
}
}
import { BulkTextEdits } from 'vs/workbench/services/bulkEdit/browser/bulkTextEdits';
import { BulkFileEdits } from 'vs/workbench/services/bulkEdit/browser/bulkFileEdits';
import { ResourceMap } from 'vs/base/common/map';
type Edit = WorkspaceFileEdit | WorkspaceTextEdit;
@ -257,10 +34,6 @@ class BulkEdit {
edits: Edit[],
@IInstantiationService private readonly _instaService: IInstantiationService,
@ILogService private readonly _logService: ILogService,
@IFileService private readonly _fileService: IFileService,
@ITextFileService private readonly _textFileService: ITextFileService,
@IWorkingCopyFileService private readonly _workingCopyFileService: IWorkingCopyFileService,
@IConfigurationService private readonly _configurationService: IConfigurationService
) {
this._label = label;
this._editor = editor;
@ -282,7 +55,7 @@ class BulkEdit {
async perform(): Promise<void> {
let seen = new Set<string>();
let seen = new ResourceMap<true>();
let total = 0;
const groups: Edit[][] = [];
@ -299,8 +72,8 @@ class BulkEdit {
if (WorkspaceFileEdit.is(edit)) {
total += 1;
} else if (!seen.has(edit.resource.toString())) {
seen.add(edit.resource.toString());
} else if (!seen.has(edit.resource)) {
seen.set(edit.resource, true);
total += 2;
}
}
@ -323,55 +96,14 @@ class BulkEdit {
private async _performFileEdits(edits: WorkspaceFileEdit[], progress: IProgress<void>) {
this._logService.debug('_performFileEdits', JSON.stringify(edits));
for (const edit of edits) {
progress.report(undefined);
let options = edit.options || {};
if (edit.newUri && edit.oldUri) {
// rename
if (options.overwrite === undefined && options.ignoreIfExists && await this._fileService.exists(edit.newUri)) {
continue; // not overwriting, but ignoring, and the target file exists
}
await this._workingCopyFileService.move(edit.oldUri, edit.newUri, options.overwrite);
} else if (!edit.newUri && edit.oldUri) {
// delete file
if (await this._fileService.exists(edit.oldUri)) {
let useTrash = this._configurationService.getValue<boolean>('files.enableTrash');
if (useTrash && !(this._fileService.hasCapability(edit.oldUri, FileSystemProviderCapabilities.Trash))) {
useTrash = false; // not supported by provider
}
await this._workingCopyFileService.delete(edit.oldUri, { useTrash, recursive: options.recursive });
} else if (!options.ignoreIfNotExists) {
throw new Error(`${edit.oldUri} does not exist and can not be deleted`);
}
} else if (edit.newUri && !edit.oldUri) {
// create file
if (options.overwrite === undefined && options.ignoreIfExists && await this._fileService.exists(edit.newUri)) {
continue; // not overwriting, but ignoring, and the target file exists
}
await this._textFileService.create(edit.newUri, undefined, { overwrite: options.overwrite });
}
}
const model = this._instaService.createInstance(BulkFileEdits, progress, edits);
await model.apply();
}
private async _performTextEdits(edits: WorkspaceTextEdit[], progress: IProgress<void>): Promise<void> {
this._logService.debug('_performTextEdits', JSON.stringify(edits));
const model = this._instaService.createInstance(BulkEditModel, this._label, this._editor, progress, edits);
await model.prepare();
// this._throwIfConflicts(conflicts);
const validationResult = model.validate();
if (validationResult.canApply === false) {
model.dispose();
throw new Error(`${validationResult.reason.toString()} has changed in the meantime`);
}
model.apply();
model.dispose();
const model = this._instaService.createInstance(BulkTextEdits, this._label, this._editor, progress, edits);
await model.apply();
}
}
@ -384,7 +116,6 @@ export class BulkEditService implements IBulkEditService {
constructor(
@IInstantiationService private readonly _instaService: IInstantiationService,
@ILogService private readonly _logService: ILogService,
@IModelService private readonly _modelService: IModelService,
@IEditorService private readonly _editorService: IEditorService,
) { }
@ -413,18 +144,6 @@ export class BulkEditService implements IBulkEditService {
const { edits } = edit;
let codeEditor = options?.editor;
// First check if loaded models were not changed in the meantime
for (const edit of edits) {
if (!WorkspaceFileEdit.is(edit) && typeof edit.modelVersionId === 'number') {
let model = this._modelService.getModel(edit.resource);
if (model && model.getVersionId() !== edit.modelVersionId) {
// model changed in the meantime
return Promise.reject(new Error(`${model.uri.toString()} has changed in the meantime`));
}
}
}
// try to find code editor
if (!codeEditor) {
let candidate = this._editorService.activeTextEditorControl;

View file

@ -0,0 +1,58 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { WorkspaceFileEdit } from 'vs/editor/common/modes';
import { IFileService, FileSystemProviderCapabilities } from 'vs/platform/files/common/files';
import { IProgress } from 'vs/platform/progress/common/progress';
import { ITextFileService } from 'vs/workbench/services/textfile/common/textfiles';
import { IConfigurationService } from 'vs/platform/configuration/common/configuration';
import { IWorkingCopyFileService } from 'vs/workbench/services/workingCopy/common/workingCopyFileService';
export class BulkFileEdits {
constructor(
private readonly _progress: IProgress<void>,
private readonly _edits: WorkspaceFileEdit[],
@IFileService private readonly _fileService: IFileService,
@ITextFileService private readonly _textFileService: ITextFileService,
@IWorkingCopyFileService private readonly _workingCopyFileService: IWorkingCopyFileService,
@IConfigurationService private readonly _configurationService: IConfigurationService
) { }
async apply(): Promise<void> {
for (const edit of this._edits) {
this._progress.report(undefined);
const options = edit.options || {};
if (edit.newUri && edit.oldUri) {
// rename
if (options.overwrite === undefined && options.ignoreIfExists && await this._fileService.exists(edit.newUri)) {
continue; // not overwriting, but ignoring, and the target file exists
}
await this._workingCopyFileService.move(edit.oldUri, edit.newUri, options.overwrite);
} else if (!edit.newUri && edit.oldUri) {
// delete file
if (await this._fileService.exists(edit.oldUri)) {
let useTrash = this._configurationService.getValue<boolean>('files.enableTrash');
if (useTrash && !(this._fileService.hasCapability(edit.oldUri, FileSystemProviderCapabilities.Trash))) {
useTrash = false; // not supported by provider
}
await this._workingCopyFileService.delete(edit.oldUri, { useTrash, recursive: options.recursive });
} else if (!options.ignoreIfNotExists) {
throw new Error(`${edit.oldUri} does not exist and can not be deleted`);
}
} else if (edit.newUri && !edit.oldUri) {
// create file
if (options.overwrite === undefined && options.ignoreIfExists && await this._fileService.exists(edit.newUri)) {
continue; // not overwriting, but ignoring, and the target file exists
}
await this._textFileService.create(edit.newUri, undefined, { overwrite: options.overwrite });
}
}
}
}

View file

@ -0,0 +1,246 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { mergeSort } from 'vs/base/common/arrays';
import { dispose, IDisposable, IReference } from 'vs/base/common/lifecycle';
import { URI } from 'vs/base/common/uri';
import { ICodeEditor } from 'vs/editor/browser/editorBrowser';
import { EditOperation } from 'vs/editor/common/core/editOperation';
import { Range } from 'vs/editor/common/core/range';
import { Selection } from 'vs/editor/common/core/selection';
import { EndOfLineSequence, IIdentifiedSingleEditOperation, ITextModel } from 'vs/editor/common/model';
import { WorkspaceTextEdit } from 'vs/editor/common/modes';
import { ITextModelService, IResolvedTextEditorModel } from 'vs/editor/common/services/resolverService';
import { localize } from 'vs/nls';
import { IProgress } from 'vs/platform/progress/common/progress';
import { IEditorWorkerService } from 'vs/editor/common/services/editorWorkerService';
import { IUndoRedoService } from 'vs/platform/undoRedo/common/undoRedo';
import { SingleModelEditStackElement, MultiModelEditStackElement } from 'vs/editor/common/model/editStack';
import { ResourceMap } from 'vs/base/common/map';
import { IModelService } from 'vs/editor/common/services/modelService';
type ValidationResult = { canApply: true } | { canApply: false, reason: URI };
class ModelEditTask implements IDisposable {
readonly model: ITextModel;
private _expectedModelVersionId: number | undefined;
protected _edits: IIdentifiedSingleEditOperation[];
protected _newEol: EndOfLineSequence | undefined;
constructor(private readonly _modelReference: IReference<IResolvedTextEditorModel>) {
this.model = this._modelReference.object.textEditorModel;
this._edits = [];
}
dispose() {
this._modelReference.dispose();
}
addEdit(resourceEdit: WorkspaceTextEdit): void {
this._expectedModelVersionId = resourceEdit.modelVersionId;
const { edit } = resourceEdit;
if (typeof edit.eol === 'number') {
// honor eol-change
this._newEol = edit.eol;
}
if (!edit.range && !edit.text) {
// lacks both a range and the text
return;
}
if (Range.isEmpty(edit.range) && !edit.text) {
// no-op edit (replace empty range with empty text)
return;
}
// create edit operation
let range: Range;
if (!edit.range) {
range = this.model.getFullModelRange();
} else {
range = Range.lift(edit.range);
}
this._edits.push(EditOperation.replaceMove(range, edit.text));
}
validate(): ValidationResult {
if (typeof this._expectedModelVersionId === 'undefined' || this.model.getVersionId() === this._expectedModelVersionId) {
return { canApply: true };
}
return { canApply: false, reason: this.model.uri };
}
getBeforeCursorState(): Selection[] | null {
return null;
}
apply(): void {
if (this._edits.length > 0) {
this._edits = mergeSort(this._edits, (a, b) => Range.compareRangesUsingStarts(a.range, b.range));
this.model.pushEditOperations(null, this._edits, () => null);
}
if (this._newEol !== undefined) {
this.model.pushEOL(this._newEol);
}
}
}
class EditorEditTask extends ModelEditTask {
private _editor: ICodeEditor;
constructor(modelReference: IReference<IResolvedTextEditorModel>, editor: ICodeEditor) {
super(modelReference);
this._editor = editor;
}
getBeforeCursorState(): Selection[] | null {
return this._editor.getSelections();
}
apply(): void {
if (this._edits.length > 0) {
this._edits = mergeSort(this._edits, (a, b) => Range.compareRangesUsingStarts(a.range, b.range));
this._editor.executeEdits('', this._edits);
}
if (this._newEol !== undefined) {
if (this._editor.hasModel()) {
this._editor.getModel().pushEOL(this._newEol);
}
}
}
}
export class BulkTextEdits {
private readonly _edits = new ResourceMap<WorkspaceTextEdit[]>();
constructor(
private readonly _label: string | undefined,
private readonly _editor: ICodeEditor | undefined,
private readonly _progress: IProgress<void>,
edits: WorkspaceTextEdit[],
@IEditorWorkerService private readonly _editorWorker: IEditorWorkerService,
@IModelService private readonly _modelService: IModelService,
@ITextModelService private readonly _textModelResolverService: ITextModelService,
@IUndoRedoService private readonly _undoRedoService: IUndoRedoService
) {
for (const edit of edits) {
let array = this._edits.get(edit.resource);
if (!array) {
array = [];
this._edits.set(edit.resource, array);
}
array.push(edit);
}
}
private _validateBeforePrepare(): void {
// First check if loaded models were not changed in the meantime
for (const array of this._edits.values()) {
for (let edit of array) {
if (typeof edit.modelVersionId === 'number') {
let model = this._modelService.getModel(edit.resource);
if (model && model.getVersionId() !== edit.modelVersionId) {
// model changed in the meantime
throw new Error(`${model.uri.toString()} has changed in the meantime`);
}
}
}
}
}
private async _createEditsTasks(): Promise<ModelEditTask[]> {
const tasks: ModelEditTask[] = [];
const promises: Promise<any>[] = [];
for (let [key, value] of this._edits) {
const promise = this._textModelResolverService.createModelReference(key).then(async ref => {
let task: ModelEditTask;
let makeMinimal = false;
if (this._editor?.getModel()?.uri.toString() === ref.object.textEditorModel.uri.toString()) {
task = new EditorEditTask(ref, this._editor);
makeMinimal = true;
} else {
task = new ModelEditTask(ref);
}
for (const edit of value) {
if (makeMinimal) {
const newEdits = await this._editorWorker.computeMoreMinimalEdits(edit.resource, [edit.edit]);
if (!newEdits) {
task.addEdit(edit);
} else {
for (let moreMinialEdit of newEdits) {
task.addEdit({ ...edit, edit: moreMinialEdit });
}
}
} else {
task.addEdit(edit);
}
}
tasks.push(task);
this._progress.report(undefined);
});
promises.push(promise);
}
await Promise.all(promises);
return tasks;
}
private _validateTasks(tasks: ModelEditTask[]): ValidationResult {
for (const task of tasks) {
const result = task.validate();
if (!result.canApply) {
return result;
}
}
return { canApply: true };
}
async apply(): Promise<void> {
this._validateBeforePrepare();
const tasks = await this._createEditsTasks();
try {
const validation = this._validateTasks(tasks);
if (!validation.canApply) {
throw new Error(`${validation.reason.toString()} has changed in the meantime`);
}
if (tasks.length === 1) {
// This edit touches a single model => keep things simple
for (const task of tasks) {
task.model.pushStackElement();
task.apply();
task.model.pushStackElement();
this._progress.report(undefined);
}
} else {
// prepare multi model undo element
const multiModelEditStackElement = new MultiModelEditStackElement(
this._label || localize('workspaceEdit', "Workspace Edit"),
tasks.map(t => new SingleModelEditStackElement(t.model, t.getBeforeCursorState()))
);
this._undoRedoService.pushElement(multiModelEditStackElement);
for (const task of tasks) {
task.apply();
this._progress.report(undefined);
}
multiModelEditStackElement.close();
}
} finally {
dispose(tasks);
}
}
}