Skip to content

Commit

Permalink
untitled - use ref counted lifecycle (#194371)
Browse files Browse the repository at this point in the history
* wip

* cleanup

* also use ref in main

* fix one test

* adop `canDispose` for untitled as well

* untitled - ignore dirty on change on revert
  • Loading branch information
bpasero committed Nov 6, 2023
1 parent cff1d63 commit 6786b8f
Show file tree
Hide file tree
Showing 9 changed files with 125 additions and 39 deletions.
8 changes: 6 additions & 2 deletions src/vs/workbench/api/browser/mainThreadDocuments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import { IWorkbenchEnvironmentService } from 'vs/workbench/services/environment/
import { toLocalResource, extUri, IExtUri } from 'vs/base/common/resources';
import { IWorkingCopyFileService } from 'vs/workbench/services/workingCopy/common/workingCopyFileService';
import { IUriIdentityService } from 'vs/platform/uriIdentity/common/uriIdentity';
import { Emitter } from 'vs/base/common/event';
import { Emitter, Event } from 'vs/base/common/event';
import { IPathService } from 'vs/workbench/services/path/common/pathService';
import { ResourceMap } from 'vs/base/common/map';
import { IExtHostContext } from 'vs/workbench/services/extensions/common/extHostCustomers';
Expand Down Expand Up @@ -267,15 +267,19 @@ export class MainThreadDocuments extends Disposable implements MainThreadDocumen
}

private async _doCreateUntitled(associatedResource?: URI, languageId?: string, initialValue?: string): Promise<URI> {
const model = await this._textFileService.untitled.resolve({
const model = this._textFileService.untitled.create({
associatedResource,
languageId,
initialValue
});
const resource = model.resource;
const ref = await this._textModelResolverService.createModelReference(resource);
if (!this._modelTrackers.has(resource)) {
ref.dispose();
throw new Error(`expected URI ${resource.toString()} to have come to LIFE`);
}
this._modelReferenceCollection.add(resource, ref, ref.object.textEditorModel.getValueLength());
Event.once(model.onDidRevert)(() => this._modelReferenceCollection.remove(resource));
this._proxy.$acceptDirtyStateChanged(resource, true); // mark as dirty
return resource;
}
Expand Down
4 changes: 2 additions & 2 deletions src/vs/workbench/common/editor/textResourceEditorInput.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ export class TextResourceEditorInput extends AbstractTextResourceEditorInput imp
private description: string | undefined,
private preferredLanguageId: string | undefined,
private preferredContents: string | undefined,
@ITextModelService private readonly textModelResolverService: ITextModelService,
@ITextModelService private readonly textModelService: ITextModelService,
@ITextFileService textFileService: ITextFileService,
@IEditorService editorService: IEditorService,
@IFileService fileService: IFileService,
Expand Down Expand Up @@ -160,7 +160,7 @@ export class TextResourceEditorInput extends AbstractTextResourceEditorInput imp
this.preferredLanguageId = undefined;

if (!this.modelReference) {
this.modelReference = this.textModelResolverService.createModelReference(this.resource);
this.modelReference = this.textModelService.createModelReference(this.resource);
}

const ref = await this.modelReference;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ export class FileEditorInput extends AbstractTextResourceEditorInput implements
preferredContents: string | undefined,
@IInstantiationService private readonly instantiationService: IInstantiationService,
@ITextFileService textFileService: ITextFileService,
@ITextModelService private readonly textModelResolverService: ITextModelService,
@ITextModelService private readonly textModelService: ITextModelService,
@ILabelService labelService: ILabelService,
@IFileService fileService: IFileService,
@IFilesConfigurationService filesConfigurationService: IFilesConfigurationService,
Expand Down Expand Up @@ -348,7 +348,7 @@ export class FileEditorInput extends AbstractTextResourceEditorInput implements
// resolve() ensures we are not creating model references for these kind of resources.
// In addition we have a bit of payload to take into account (encoding, reload) that the text resolver does not handle yet.
if (!this.cachedTextFileModelReference) {
this.cachedTextFileModelReference = await this.textModelResolverService.createModelReference(this.resource) as IReference<ITextFileEditorModel>;
this.cachedTextFileModelReference = await this.textModelService.createModelReference(this.resource) as IReference<ITextFileEditorModel>;
}

const model = this.cachedTextFileModelReference.object;
Expand Down
15 changes: 1 addition & 14 deletions src/vs/workbench/services/textfile/common/textEditorService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,20 +146,7 @@ export class TextEditorService extends Disposable implements ITextEditorService
untitledModel = this.untitledTextEditorService.create({ associatedResource: untitledInput.resource, ...untitledOptions });
}

return this.createOrGetCached(untitledModel.resource, () => {

// Factory function for new untitled editor
const input = this.instantiationService.createInstance(UntitledTextEditorInput, untitledModel);

// We dispose the untitled model once the editor
// is being disposed. Even though we may have not
// created the model initially, the lifecycle for
// untitled is tightly coupled with the editor
// lifecycle for now.
Event.once(input.onWillDispose)(() => untitledModel.dispose());

return input;
});
return this.createOrGetCached(untitledModel.resource, () => this.instantiationService.createInstance(UntitledTextEditorInput, untitledModel));
}

// Text File/Resource Editor Support
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { InstantiationType, registerSingleton } from 'vs/platform/instantiation/
import { IUndoRedoService } from 'vs/platform/undoRedo/common/undoRedo';
import { ModelUndoRedoParticipant } from 'vs/editor/common/services/modelUndoRedoParticipant';
import { IUriIdentityService } from 'vs/platform/uriIdentity/common/uriIdentity';
import { UntitledTextEditorModel } from 'vs/workbench/services/untitled/common/untitledTextEditorModel';

class ResourceModelCollection extends ReferenceCollection<Promise<IResolvedTextEditorModel>> {

Expand Down Expand Up @@ -102,9 +103,9 @@ class ResourceModelCollection extends ReferenceCollection<Promise<IResolvedTextE

protected destroyReferencedObject(key: string, modelPromise: Promise<ITextEditorModel>): void {

// untitled and inMemory are bound to a different lifecycle
// inMemory is bound to a different lifecycle
const resource = URI.parse(key);
if (resource.scheme === Schemas.untitled || resource.scheme === Schemas.inMemory) {
if (resource.scheme === Schemas.inMemory) {
return;
}

Expand All @@ -125,6 +126,10 @@ class ResourceModelCollection extends ReferenceCollection<Promise<IResolvedTextE
// text file models have conditions that prevent them
// from dispose, so we have to wait until we can dispose
await this.textFileService.files.canDispose(model);
} else if (model instanceof UntitledTextEditorModel) {
// untitled file models have conditions that prevent them
// from dispose, so we have to wait until we can dispose
await this.textFileService.untitled.canDispose(model);
}

if (!this.modelsToDispose.has(key)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import { IWorkbenchEnvironmentService } from 'vs/workbench/services/environment/
import { IPathService } from 'vs/workbench/services/path/common/pathService';
import { ITextEditorOptions } from 'vs/platform/editor/common/editor';
import { IFilesConfigurationService } from 'vs/workbench/services/filesConfiguration/common/filesConfigurationService';
import { ITextModelService } from 'vs/editor/common/services/resolverService';
import { dispose, IReference } from 'vs/base/common/lifecycle';

/**
* An editor input to be used for untitled text buffers.
Expand All @@ -34,6 +36,7 @@ export class UntitledTextEditorInput extends AbstractTextResourceEditorInput imp
}

private modelResolve: Promise<void> | undefined = undefined;
private cachedUntitledTextEditorModelReference: IReference<IUntitledTextEditorModel> | undefined = undefined;

constructor(
readonly model: IUntitledTextEditorModel,
Expand All @@ -43,7 +46,8 @@ export class UntitledTextEditorInput extends AbstractTextResourceEditorInput imp
@IFileService fileService: IFileService,
@IWorkbenchEnvironmentService private readonly environmentService: IWorkbenchEnvironmentService,
@IPathService private readonly pathService: IPathService,
@IFilesConfigurationService filesConfigurationService: IFilesConfigurationService
@IFilesConfigurationService filesConfigurationService: IFilesConfigurationService,
@ITextModelService private readonly textModelService: ITextModelService
) {
super(model.resource, undefined, editorService, textFileService, labelService, fileService, filesConfigurationService);

Expand Down Expand Up @@ -120,11 +124,22 @@ export class UntitledTextEditorInput extends AbstractTextResourceEditorInput imp

override async resolve(): Promise<IUntitledTextEditorModel> {
if (!this.modelResolve) {
this.modelResolve = this.model.resolve();
this.modelResolve = (async () => {

// Acquire a model reference
this.cachedUntitledTextEditorModelReference = await this.textModelService.createModelReference(this.resource) as IReference<IUntitledTextEditorModel>;
})();
}

await this.modelResolve;

// It is possible that this input was disposed before the model
// finished resolving. As such, we need to make sure to dispose
// the model reference to not leak it.
if (this.isDisposed()) {
this.disposeModelReference();
}

return this.model;
}

Expand Down Expand Up @@ -176,8 +191,18 @@ export class UntitledTextEditorInput extends AbstractTextResourceEditorInput imp
}

override dispose(): void {

// Model
this.modelResolve = undefined;

// Model reference
this.disposeModelReference();

super.dispose();
}

private disposeModelReference(): void {
dispose(this.cachedUntitledTextEditorModelReference);
this.cachedUntitledTextEditorModelReference = undefined;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { Event, Emitter } from 'vs/base/common/event';
import { IWorkingCopyBackupService } from 'vs/workbench/services/workingCopy/common/workingCopyBackup';
import { ITextResourceConfigurationChangeEvent, ITextResourceConfigurationService } from 'vs/editor/common/services/textResourceConfiguration';
import { ITextModel } from 'vs/editor/common/model';
import { createTextBufferFactoryFromStream } from 'vs/editor/common/model/textModel';
import { createTextBufferFactory, createTextBufferFactoryFromStream } from 'vs/editor/common/model/textModel';
import { ITextEditorModel } from 'vs/editor/common/services/resolverService';
import { IWorkingCopyService } from 'vs/workbench/services/workingCopy/common/workingCopyService';
import { IWorkingCopy, WorkingCopyCapabilities, IWorkingCopyBackup, NO_TYPE_ID, IWorkingCopySaveEvent } from 'vs/workbench/services/workingCopy/common/workingCopy';
Expand Down Expand Up @@ -273,16 +273,19 @@ export class UntitledTextEditorModel extends BaseTextEditorModel implements IUnt

async revert(): Promise<void> {

// Reset contents to be empty
this.ignoreDirtyOnModelContentChange = true;
try {
this.updateTextEditorModel(createTextBufferFactory(''));
} finally {
this.ignoreDirtyOnModelContentChange = false;
}

// No longer dirty
this.setDirty(false);

// Emit as event
this._onDidRevert.fire();

// A reverted untitled model is invalid because it has
// no actual source on disk to revert to. As such we
// dispose the model.
this.dispose();
}

async backup(token: CancellationToken): Promise<IWorkingCopyBackup> {
Expand All @@ -307,6 +310,8 @@ export class UntitledTextEditorModel extends BaseTextEditorModel implements IUnt

//#region Resolve

private ignoreDirtyOnModelContentChange = false;

override async resolve(): Promise<void> {

// Create text editor model if not yet done
Expand Down Expand Up @@ -375,16 +380,18 @@ export class UntitledTextEditorModel extends BaseTextEditorModel implements IUnt
}

private onModelContentChanged(textEditorModel: ITextModel, e: IModelContentChangedEvent): void {
if (!this.ignoreDirtyOnModelContentChange) {

// mark the untitled text editor as non-dirty once its content becomes empty and we do
// not have an associated path set. we never want dirty indicator in that case.
if (!this.hasAssociatedFilePath && textEditorModel.getLineCount() === 1 && textEditorModel.getLineLength(1) === 0) {
this.setDirty(false);
}
// mark the untitled text editor as non-dirty once its content becomes empty and we do
// not have an associated path set. we never want dirty indicator in that case.
if (!this.hasAssociatedFilePath && textEditorModel.getLineCount() === 1 && textEditorModel.getLineLength(1) === 0) {
this.setDirty(false);
}

// turn dirty otherwise
else {
this.setDirty(true);
// turn dirty otherwise
else {
this.setDirty(true);
}
}

// Check for name change if first line changed in the range of 0-FIRST_LINE_NAME_CANDIDATE_MAX_LENGTH columns
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,13 @@ export interface IUntitledTextEditorModelManager {
* Figures out if the given resource has an associated resource or not.
*/
isUntitledWithAssociatedResource(resource: URI): boolean;

/**
* Waits for the model to be ready to be disposed. There may be conditions
* under which the model cannot be disposed, e.g. when it is dirty. Once the
* promise is settled, it is safe to dispose the model.
*/
canDispose(model: IUntitledTextEditorModel): true | Promise<true>;
}

export interface IUntitledTextEditorService extends IUntitledTextEditorModelManager {
Expand Down Expand Up @@ -270,6 +277,29 @@ export class UntitledTextEditorService extends Disposable implements IUntitledTe
isUntitledWithAssociatedResource(resource: URI): boolean {
return resource.scheme === Schemas.untitled && resource.path.length > 1 && !UntitledTextEditorService.UNTITLED_WITHOUT_ASSOCIATED_RESOURCE_REGEX.test(resource.path);
}

canDispose(model: UntitledTextEditorModel): true | Promise<true> {
if (model.isDisposed()) {
return true; // quick return if model already disposed
}

// promise based return in all other cases
return this.doCanDispose(model);
}

private async doCanDispose(model: UntitledTextEditorModel): Promise<true> {

// dirty model: we do not allow to dispose dirty models to prevent
// data loss cases. dirty models can only be disposed when they are
// either saved or reverted
if (model.isDirty()) {
await Event.toPromise(model.onDidChangeDirty);

return this.canDispose(model);
}

return true;
}
}

registerSingleton(IUntitledTextEditorService, UntitledTextEditorService, InstantiationType.Delayed);
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,15 @@ import { PLAINTEXT_LANGUAGE_ID } from 'vs/editor/common/languages/modesRegistry'
import { ISingleEditOperation } from 'vs/editor/common/core/editOperation';
import { Range } from 'vs/editor/common/core/range';
import { UntitledTextEditorInput } from 'vs/workbench/services/untitled/common/untitledTextEditorInput';
import { IUntitledTextEditorModel } from 'vs/workbench/services/untitled/common/untitledTextEditorModel';
import { IUntitledTextEditorModel, UntitledTextEditorModel } from 'vs/workbench/services/untitled/common/untitledTextEditorModel';
import { CancellationToken } from 'vs/base/common/cancellation';
import { EditorInputCapabilities } from 'vs/workbench/common/editor';
import { DisposableStore } from 'vs/base/common/lifecycle';
import { isReadable, isReadableStream } from 'vs/base/common/stream';
import { readableToBuffer, streamToBuffer, VSBufferReadable, VSBufferReadableStream } from 'vs/base/common/buffer';
import { LanguageDetectionLanguageEventSource } from 'vs/workbench/services/languageDetection/common/languageDetectionWorkerService';
import { ensureNoDisposablesAreLeakedInTestSuite } from 'vs/base/test/common/utils';
import { timeout } from 'vs/base/common/async';

suite('Untitled text editors', () => {

Expand Down Expand Up @@ -573,5 +574,32 @@ suite('Untitled text editors', () => {
assert.strictEqual(counter, 1, 'Another change to same encoding does not fire event');
});

test('canDispose with dirty model', async function () {
const service = accessor.untitledTextEditorService;
const input = disposables.add(instantiationService.createInstance(UntitledTextEditorInput, service.create()));

const model = disposables.add(await input.resolve());

model.textEditorModel?.setValue('foo');

const canDisposePromise = service.canDispose(model as UntitledTextEditorModel);
assert.ok(canDisposePromise instanceof Promise);

let canDispose = false;
(async () => {
canDispose = await canDisposePromise;
})();

assert.strictEqual(canDispose, false);
model.revert({ soft: true });

await timeout(0);

assert.strictEqual(canDispose, true);

const canDispose2 = service.canDispose(model as UntitledTextEditorModel);
assert.strictEqual(canDispose2, true);
});

ensureNoDisposablesAreLeakedInTestSuite();
});

0 comments on commit 6786b8f

Please sign in to comment.