Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for untitled file working copies #124120

Merged
merged 18 commits into from
May 19, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 19 additions & 4 deletions src/vs/platform/dialogs/test/common/testDialogService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,23 @@ export class TestDialogService implements IDialogService {

declare readonly _serviceBrand: undefined;

confirm(_confirmation: IConfirmation): Promise<IConfirmationResult> { return Promise.resolve({ confirmed: false }); }
show(_severity: Severity, _message: string, _buttons: string[], _options?: IDialogOptions): Promise<IShowResult> { return Promise.resolve({ choice: 0 }); }
input(): Promise<IInputResult> { { return Promise.resolve({ choice: 0, values: [] }); } }
about(): Promise<void> { return Promise.resolve(); }
private confirmResult: IConfirmationResult | undefined = undefined;
setConfirmResult(result: IConfirmationResult) {
this.confirmResult = result;
}

async confirm(confirmation: IConfirmation): Promise<IConfirmationResult> {
if (this.confirmResult) {
const confirmResult = this.confirmResult;
this.confirmResult = undefined;

return confirmResult;
}

return { confirmed: false };
}

async show(severity: Severity, message: string, buttons: string[], options?: IDialogOptions): Promise<IShowResult> { return { choice: 0 }; }
async input(): Promise<IInputResult> { { return { choice: 0, values: [] }; } }
async about(): Promise<void> { }
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import { DisposableStore, IDisposable } from 'vs/base/common/lifecycle';
import { canceled } from 'vs/base/common/errors';
import { NotebookEditorInput } from 'vs/workbench/contrib/notebook/common/notebookEditorInput';
import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation';
import { IFileWorkingCopyManager, IFileWorkingCopySaveAsOptions } from 'vs/workbench/services/workingCopy/common/fileWorkingCopyManager';
import { IFileWorkingCopyManager } from 'vs/workbench/services/workingCopy/common/fileWorkingCopyManager';
import { filter } from 'vs/base/common/objects';

//#region --- complex content provider
Expand Down Expand Up @@ -491,8 +491,8 @@ export class SimpleNotebookEditorModel extends EditorModel implements INotebookE
return this;
}

async saveAs(target: URI, options?: IFileWorkingCopySaveAsOptions): Promise<IEditorInput | undefined> {
const newWorkingCopy = await this._workingCopyManager.saveAs(this.resource, target, options);
async saveAs(target: URI): Promise<IEditorInput | undefined> {
const newWorkingCopy = await this._workingCopyManager.saveAs(this.resource, target);
if (!newWorkingCopy) {
return undefined;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -457,7 +457,7 @@ export abstract class AbstractTextFileService extends Disposable implements ITex
// Otherwise try to suggest a path that can be saved
let suggestedFilename: string | undefined = undefined;
if (resource.scheme === Schemas.untitled) {
const model = this.untitledTextEditorService.get(resource);
const model = this.untitled.get(resource);
if (model) {

// Untitled with associated file path
Expand Down
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 { ITextResourceConfigurationService } from 'vs/editor/common/services/textResourceConfigurationService';
import { ITextModel } from 'vs/editor/common/model';
import { createTextBufferFactory, createTextBufferFactoryFromStream } from 'vs/editor/common/model/textModel';
import { 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 } from 'vs/workbench/services/workingCopy/common/workingCopy';
Expand Down Expand Up @@ -63,12 +63,6 @@ export interface IUntitledTextEditorModel extends ITextEditorModel, IModeSupport
* Resolves the untitled model.
*/
resolve(): Promise<void>;

/**
* Updates the value of the untitled model optionally allowing to ignore dirty.
* The model must be resolved for this method to work.
*/
setValue(value: string, ignoreDirty?: boolean): void;
}

export class UntitledTextEditorModel extends BaseTextEditorModel implements IUntitledTextEditorModel {
Expand Down Expand Up @@ -99,6 +93,10 @@ export class UntitledTextEditorModel extends BaseTextEditorModel implements IUnt

readonly capabilities = WorkingCopyCapabilities.Untitled;

//#region Name

private configuredLabelFormat: 'content' | 'name' = 'content';

private cachedModelFirstLineWords: string | undefined = undefined;
get name(): string {
// Take name from first line if present and only if
Expand All @@ -112,13 +110,8 @@ export class UntitledTextEditorModel extends BaseTextEditorModel implements IUnt
return this.labelService.getUriBasenameLabel(this.resource);
}

private dirty = this.hasAssociatedFilePath || !!this.initialValue;
private ignoreDirtyOnModelContentChange = false;

private versionId = 0;
//#endregion

private configuredEncoding: string | undefined;
private configuredLabelFormat: 'content' | 'name' = 'content';

constructor(
public readonly resource: URI,
Expand Down Expand Up @@ -153,7 +146,7 @@ export class UntitledTextEditorModel extends BaseTextEditorModel implements IUnt
private registerListeners(): void {

// Config Changes
this._register(this.textResourceConfigurationService.onDidChangeConfiguration(e => this.onConfigurationChange(true)));
this._register(this.textResourceConfigurationService.onDidChangeConfiguration(() => this.onConfigurationChange(true)));
}

private onConfigurationChange(fromEvent: boolean): void {
Expand All @@ -179,9 +172,8 @@ export class UntitledTextEditorModel extends BaseTextEditorModel implements IUnt
}
}

getVersionId(): number {
return this.versionId;
}

//#region Mode

private _hasModeSetExplicitly: boolean = false;
get hasModeSetExplicitly(): boolean { return this._hasModeSetExplicitly; }
Expand Down Expand Up @@ -216,6 +208,13 @@ export class UntitledTextEditorModel extends BaseTextEditorModel implements IUnt
return this.preferredMode;
}

//#endregion


//#region Encoding

private configuredEncoding: string | undefined;

getEncoding(): string | undefined {
return this.preferredEncoding || this.configuredEncoding;
}
Expand All @@ -230,25 +229,13 @@ export class UntitledTextEditorModel extends BaseTextEditorModel implements IUnt
}
}

setValue(value: string, ignoreDirty?: boolean): void {
if (ignoreDirty) {
this.ignoreDirtyOnModelContentChange = true;
}

try {
this.updateTextEditorModel(createTextBufferFactory(value));
} finally {
this.ignoreDirtyOnModelContentChange = false;
}
}

override isReadonly(): boolean {
return false;
}
//#endregion


//#region Dirty

private dirty = this.hasAssociatedFilePath || !!this.initialValue;

isDirty(): boolean {
return this.dirty;
}
Expand Down Expand Up @@ -360,19 +347,16 @@ export class UntitledTextEditorModel extends BaseTextEditorModel implements IUnt
}

private onModelContentChanged(textEditorModel: ITextModel, e: IModelContentChangedEvent): void {
this.versionId++;

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.getLineContent(1) === '') {
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.getLineContent(1) === '') {
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 Expand Up @@ -421,4 +405,9 @@ export class UntitledTextEditorModel extends BaseTextEditorModel implements IUnt
}

//#endregion


override isReadonly(): boolean {
return false;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -102,22 +102,6 @@ suite('Untitled text editors', () => {
});
}

test('setValue()', async () => {
const service = accessor.untitledTextEditorService;
const untitled = instantiationService.createInstance(UntitledTextEditorInput, service.create());

const model = await untitled.resolve();

model.setValue('not dirty', true);
assert.ok(!model.isDirty());

model.setValue('dirty');
assert.ok(model.isDirty());

untitled.dispose();
model.dispose();
});

test('associated resource is dirty', async () => {
const service = accessor.untitledTextEditorService;
const file = URI.file(join('C:\\', '/foo/file.txt'));
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import { IDisposable } from 'vs/base/common/lifecycle';
import { Event } from 'vs/base/common/event';
import { CancellationToken } from 'vs/base/common/cancellation';
import { VSBufferReadableStream } from 'vs/base/common/buffer';
import { URI } from 'vs/base/common/uri';
import { IWorkingCopy } from 'vs/workbench/services/workingCopy/common/workingCopy';

export interface IBaseFileWorkingCopyModelFactory<T extends IBaseFileWorkingCopyModel> {

/**
* Create a model from the given content under the provided resource.
*
* @param resource the `URI` of the model
* @param contents the content of the model to create it
* @param token support for cancellation
*/
createModel(resource: URI, contents: VSBufferReadableStream, token: CancellationToken): Promise<T>;
}

/**
* A generic file working copy model to be reused by untitled
* and existing file working copies.
*/
export interface IBaseFileWorkingCopyModel extends IDisposable {

/**
* This event signals ANY changes to the contents, for example:
* - through the user typing into the editor
* - from API usage (e.g. bulk edits)
* - when `IBaseFileWorkingCopyModel#update` is invoked with contents
* that are different from the current contents
*
* The file working copy will listen to these changes and may mark
* the working copy as dirty whenever this event fires.
*
* Note: ONLY report changes to the model but not the underlying
* file. The file working copy is tracking changes to the file
* automatically.
*/
readonly onDidChangeContent: Event<unknown>;

/**
* An event emitted right before disposing the model.
*/
readonly onWillDispose: Event<void>;

/**
* Snapshots the model's current content for writing. This must include
* any changes that were made to the model that are in memory.
*
* @param token support for cancellation
*/
snapshot(token: CancellationToken): Promise<VSBufferReadableStream>;

/**
* Updates the model with the provided contents. The implementation should
* behave in a similar fashion as `IBaseFileWorkingCopyModelFactory#createModel`
* except that here the model already exists and just needs to update to
* the provided contents.
*
* Note: it is expected that the model fires a `onDidChangeContent` event
* as part of the update.
*
* @param the contents to use for the model
* @param token support for cancellation
*/
update(contents: VSBufferReadableStream, token: CancellationToken): Promise<void>;
}

export interface IBaseFileWorkingCopy<T extends IBaseFileWorkingCopyModel> extends IWorkingCopy, IDisposable {

/**
* An event for when the file working copy has been reverted.
*/
readonly onDidRevert: Event<void>;

/**
* An event for when the file working copy has been disposed.
*/
readonly onWillDispose: Event<void>;

/**
* Provides access to the underlying model of this file
* based working copy. As long as the file working copy
* has not been resolved, the model is `undefined`.
*/
readonly model: T | undefined;

/**
* Resolves the file working copy and thus makes the `model`
* available.
*/
resolve(): Promise<void>;

/**
* Whether we have a resolved model or not.
*/
isResolved(): this is IBaseResolvedFileWorkingCopy<T>;
}

export interface IBaseResolvedFileWorkingCopy<T extends IBaseFileWorkingCopyModel> extends IBaseFileWorkingCopy<T> {

/**
* A resolved file working copy has a resolved model.
*/
readonly model: T;
}
Loading