未验证 提交 eefd2c42 编写于 作者: B Benjamin Pasero 提交者: GitHub

Merge pull request #73123 from microsoft/ben/hot-exit

Hot Exit - Allow arbitrary metadata with backups
......@@ -287,6 +287,30 @@ suite('workspace-namespace', () => {
});
});
test('events: onDidSaveTextDocument fires even for non dirty file when saved', () => {
return createRandomFile().then(file => {
let disposables: vscode.Disposable[] = [];
let onDidSaveTextDocument = false;
disposables.push(vscode.workspace.onDidSaveTextDocument(e => {
assert.ok(pathEquals(e.uri.fsPath, file.fsPath));
onDidSaveTextDocument = true;
}));
return vscode.workspace.openTextDocument(file).then(doc => {
return vscode.window.showTextDocument(doc).then(() => {
return vscode.commands.executeCommand('workbench.action.files.save').then(() => {
assert.ok(onDidSaveTextDocument);
disposeAll(disposables);
return deleteFile(file);
});
});
});
});
});
test('openTextDocument, with selection', function () {
return createRandomFile('foo\nbar\nbar').then(file => {
return vscode.workspace.openTextDocument(file).then(doc => {
......
......@@ -5,7 +5,7 @@
import { IDisposable, IReference } from 'vs/base/common/lifecycle';
import { URI } from 'vs/base/common/uri';
import { ITextModel } from 'vs/editor/common/model';
import { ITextModel, ITextSnapshot } from 'vs/editor/common/model';
import { IEditorModel } from 'vs/platform/editor/common/editor';
import { createDecorator } from 'vs/platform/instantiation/common/instantiation';
......@@ -46,6 +46,12 @@ export interface ITextEditorModel extends IEditorModel {
*/
readonly textEditorModel: ITextModel | null;
/**
* Creates a snapshot of the model's contents.
*/
createSnapshot(this: IResolvedTextEditorModel): ITextSnapshot;
createSnapshot(this: ITextEditorModel): ITextSnapshot | null;
isReadonly(): boolean;
}
......
......@@ -19,7 +19,7 @@ import { EditOperation } from 'vs/editor/common/core/editOperation';
import { IPosition, Position as Pos } from 'vs/editor/common/core/position';
import { Range } from 'vs/editor/common/core/range';
import * as editorCommon from 'vs/editor/common/editorCommon';
import { ITextModel } from 'vs/editor/common/model';
import { ITextModel, ITextSnapshot } from 'vs/editor/common/model';
import { TextEdit, WorkspaceEdit, isResourceTextEdit } from 'vs/editor/common/modes';
import { IModelService } from 'vs/editor/common/services/modelService';
import { IResolvedTextEditorModel, ITextModelContentProvider, ITextModelService } from 'vs/editor/common/services/resolverService';
......@@ -67,6 +67,10 @@ export class SimpleModel implements IResolvedTextEditorModel {
return this.model;
}
public createSnapshot(): ITextSnapshot {
return this.model.createSnapshot();
}
public isReadonly(): boolean {
return false;
}
......
......@@ -240,7 +240,7 @@ export class ResourcesDropHandler {
return this.backupFileService.resolveBackupContent(droppedDirtyEditor.backupResource!).then(content => {
// Set the contents of to the resource to the target
return this.backupFileService.backupResource(droppedDirtyEditor.resource, content!.create(this.getDefaultEOL()).createSnapshot(true));
return this.backupFileService.backupResource(droppedDirtyEditor.resource, content.value.create(this.getDefaultEOL()).createSnapshot(true));
}).then(() => false, () => false /* ignore any error */);
}
......
......@@ -4,8 +4,8 @@
*--------------------------------------------------------------------------------------------*/
import { URI } from 'vs/base/common/uri';
import { IBackupFileService } from 'vs/workbench/services/backup/common/backup';
import { ITextBufferFactory, ITextSnapshot } from 'vs/editor/common/model';
import { IBackupFileService, IResolvedBackup } from 'vs/workbench/services/backup/common/backup';
import { ITextSnapshot } from 'vs/editor/common/model';
import { createTextBufferFactoryFromSnapshot } from 'vs/editor/common/model/textModel';
import { keys, ResourceMap } from 'vs/base/common/map';
import { registerSingleton } from 'vs/platform/instantiation/common/extensions';
......@@ -86,20 +86,20 @@ export class SimpleBackupFileService implements IBackupFileService {
return Promise.resolve(undefined);
}
backupResource(resource: URI, content: ITextSnapshot, versionId?: number): Promise<void> {
backupResource<T extends object>(resource: URI, content: ITextSnapshot, versionId?: number, meta?: T): Promise<void> {
const backupResource = this.toBackupResource(resource);
this.backups.set(backupResource.toString(), content);
return Promise.resolve();
}
resolveBackupContent(backupResource: URI): Promise<ITextBufferFactory | undefined> {
resolveBackupContent<T extends object>(backupResource: URI): Promise<IResolvedBackup<T>> {
const snapshot = this.backups.get(backupResource.toString());
if (snapshot) {
return Promise.resolve(createTextBufferFactoryFromSnapshot(snapshot));
return Promise.resolve({ value: createTextBufferFactoryFromSnapshot(snapshot) });
}
return Promise.resolve(undefined);
return Promise.reject('Unexpected backup resource to resolve');
}
getWorkspaceFileBackups(): Promise<URI[]> {
......
......@@ -19,12 +19,18 @@ export class ResourceEditorModel extends BaseTextEditorModel {
@IModelService modelService: IModelService
) {
super(modelService, modeService, resource);
// TODO@Joao: force this class to dispose the underlying model
this.createdEditorModel = true;
}
isReadonly(): boolean {
return true;
}
dispose(): void {
// TODO@Joao: force this class to dispose the underlying model
if (this.textEditorModelHandle) {
this.modelService.destroyModel(this.textEditorModelHandle);
}
super.dispose();
}
}
\ No newline at end of file
......@@ -15,10 +15,9 @@ import { IDisposable } from 'vs/base/common/lifecycle';
* The base text editor model leverages the code editor model. This class is only intended to be subclassed and not instantiated.
*/
export abstract class BaseTextEditorModel extends EditorModel implements ITextEditorModel {
protected textEditorModelHandle: URI | null;
private createdEditorModel: boolean;
protected createdEditorModel: boolean;
private textEditorModelHandle: URI | null;
private modelDisposeListener: IDisposable | null;
constructor(
......@@ -125,13 +124,14 @@ export abstract class BaseTextEditorModel extends EditorModel implements ITextEd
this.modelService.updateModel(this.textEditorModel, newValue);
}
createSnapshot(this: IResolvedTextEditorModel): ITextSnapshot;
createSnapshot(this: ITextEditorModel): ITextSnapshot | null;
createSnapshot(): ITextSnapshot | null {
const model = this.textEditorModel;
if (model) {
return model.createSnapshot(true /* Preserve BOM */);
if (!this.textEditorModel) {
return null;
}
return null;
return this.textEditorModel.createSnapshot(true /* preserve BOM */);
}
isResolved(): this is IResolvedTextEditorModel {
......
......@@ -134,24 +134,32 @@ export class UntitledEditorModel extends BaseTextEditorModel implements IEncodin
this.contentChangeEventScheduler.schedule();
}
backup(): Promise<void> {
if (this.isResolved()) {
return this.backupFileService.backupResource(this.resource, this.createSnapshot(), this.versionId);
}
return Promise.resolve();
}
load(): Promise<UntitledEditorModel & IResolvedTextEditorModel> {
// Check for backups first
return this.backupFileService.loadBackupResource(this.resource).then((backupResource) => {
return this.backupFileService.loadBackupResource(this.resource).then(backupResource => {
if (backupResource) {
return this.backupFileService.resolveBackupContent(backupResource);
}
return undefined;
}).then(backupTextBufferFactory => {
const hasBackup = !!backupTextBufferFactory;
return Promise.resolve(undefined);
}).then(backup => {
const hasBackup = !!backup;
// untitled associated to file path are dirty right away as well as untitled with content
this.setDirty(this._hasAssociatedFilePath || hasBackup);
let untitledContents: ITextBufferFactory;
if (backupTextBufferFactory) {
untitledContents = backupTextBufferFactory;
if (backup) {
untitledContents = backup.value;
} else {
untitledContents = createTextBufferFactory(this.initialValue || '');
}
......
......@@ -66,10 +66,7 @@ export class BackupModelTracker extends Disposable implements IWorkbenchContribu
if (!this.configuredAutoSaveAfterDelay) {
const model = this.textFileService.models.get(event.resource);
if (model) {
const snapshot = model.createSnapshot();
if (snapshot) {
this.backupFileService.backupResource(model.getResource(), snapshot, model.getVersionId());
}
model.backup();
}
}
}
......@@ -77,12 +74,7 @@ export class BackupModelTracker extends Disposable implements IWorkbenchContribu
private onUntitledModelChanged(resource: Uri): void {
if (this.untitledEditorService.isDirty(resource)) {
this.untitledEditorService.loadOrCreate({ resource }).then(model => {
const snapshot = model.createSnapshot();
if (snapshot) {
this.backupFileService.backupResource(resource, snapshot, model.getVersionId());
}
});
this.untitledEditorService.loadOrCreate({ resource }).then(model => model.backup());
} else {
this.discardBackup(resource);
}
......
......@@ -9,6 +9,11 @@ import { ITextBufferFactory, ITextSnapshot } from 'vs/editor/common/model';
export const IBackupFileService = createDecorator<IBackupFileService>('backupFileService');
export interface IResolvedBackup<T extends object> {
value: ITextBufferFactory;
meta?: T;
}
/**
* A service that handles any I/O and state associated with the backup system.
*/
......@@ -42,8 +47,10 @@ export interface IBackupFileService {
* @param resource The resource to back up.
* @param content The content of the resource as snapshot.
* @param versionId The version id of the resource to backup.
* @param meta The (optional) meta data of the resource to backup. This information
* can be restored later when loading the backup again.
*/
backupResource(resource: URI, content: ITextSnapshot, versionId?: number): Promise<void>;
backupResource<T extends object>(resource: URI, content: ITextSnapshot, versionId?: number, meta?: T): Promise<void>;
/**
* Gets a list of file backups for the current workspace.
......@@ -55,10 +62,10 @@ export interface IBackupFileService {
/**
* Resolves the backup for the given resource.
*
* @param value The contents from a backup resource as stream.
* @return The backup file's backed up content as text buffer factory.
* @param resource The resource to get the backup for.
* @return The backup file's backed up content and metadata if available.
*/
resolveBackupContent(backup: URI): Promise<ITextBufferFactory | undefined>;
resolveBackupContent<T extends object>(resource: URI): Promise<IResolvedBackup<T>>;
/**
* Discards the backup associated with a resource if it exists..
......
......@@ -3,94 +3,112 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as path from 'vs/base/common/path';
import * as crypto from 'crypto';
import * as pfs from 'vs/base/node/pfs';
import { URI as Uri } from 'vs/base/common/uri';
import { join } from 'vs/base/common/path';
import { joinPath } from 'vs/base/common/resources';
import { createHash } from 'crypto';
import { URI } from 'vs/base/common/uri';
import { coalesce } from 'vs/base/common/arrays';
import { equals, deepClone } from 'vs/base/common/objects';
import { ResourceQueue } from 'vs/base/common/async';
import { IBackupFileService } from 'vs/workbench/services/backup/common/backup';
import { IBackupFileService, IResolvedBackup } from 'vs/workbench/services/backup/common/backup';
import { IFileService } from 'vs/platform/files/common/files';
import { readToMatchingString } from 'vs/base/node/stream';
import { ITextBufferFactory, ITextSnapshot } from 'vs/editor/common/model';
import { ITextSnapshot } from 'vs/editor/common/model';
import { createTextBufferFactoryFromStream, createTextBufferFactoryFromSnapshot } from 'vs/editor/common/model/textModel';
import { keys } from 'vs/base/common/map';
import { keys, ResourceMap } from 'vs/base/common/map';
import { Schemas } from 'vs/base/common/network';
import { IWorkbenchEnvironmentService } from 'vs/workbench/services/environment/common/environmentService';
import { registerSingleton } from 'vs/platform/instantiation/common/extensions';
import { VSBuffer } from 'vs/base/common/buffer';
import { TextSnapshotReadable } from 'vs/workbench/services/textfile/common/textfiles';
import { ServiceIdentifier } from 'vs/platform/instantiation/common/instantiation';
export interface IBackupFilesModel {
resolve(backupRoot: string): Promise<IBackupFilesModel>;
resolve(backupRoot: URI): Promise<IBackupFilesModel>;
add(resource: Uri, versionId?: number): void;
has(resource: Uri, versionId?: number): boolean;
get(): Uri[];
remove(resource: Uri): void;
add(resource: URI, versionId?: number, meta?: object): void;
has(resource: URI, versionId?: number, meta?: object): boolean;
get(): URI[];
remove(resource: URI): void;
count(): number;
clear(): void;
}
export class BackupFilesModel implements IBackupFilesModel {
private cache: { [resource: string]: number /* version ID */ } = Object.create(null);
resolve(backupRoot: string): Promise<IBackupFilesModel> {
return pfs.readDirsInDir(backupRoot).then(backupSchemas => {
// For all supported schemas
return Promise.all(backupSchemas.map(backupSchema => {
interface IBackupCacheEntry {
versionId?: number;
meta?: object;
}
// Read backup directory for backups
const backupSchemaPath = path.join(backupRoot, backupSchema);
return pfs.readdir(backupSchemaPath).then(backupHashes => {
export class BackupFilesModel implements IBackupFilesModel {
private cache: ResourceMap<IBackupCacheEntry> = new ResourceMap();
constructor(private fileService: IFileService) { }
async resolve(backupRoot: URI): Promise<IBackupFilesModel> {
try {
const backupRootStat = await this.fileService.resolve(backupRoot);
if (backupRootStat.children) {
await Promise.all(backupRootStat.children
.filter(child => child.isDirectory)
.map(async backupSchema => {
// Read backup directory for backups
const backupSchemaStat = await this.fileService.resolve(backupSchema.resource);
// Remember known backups in our caches
if (backupSchemaStat.children) {
backupSchemaStat.children.forEach(backupHash => this.add(backupHash.resource));
}
}));
}
} catch (error) {
// ignore any errors
}
// Remember known backups in our caches
backupHashes.forEach(backupHash => {
const backupResource = Uri.file(path.join(backupSchemaPath, backupHash));
this.add(backupResource);
});
});
}));
}).then(() => this, error => this);
return this;
}
add(resource: Uri, versionId = 0): void {
this.cache[resource.toString()] = versionId;
add(resource: URI, versionId = 0, meta?: object): void {
this.cache.set(resource, { versionId, meta: deepClone(meta) }); // make sure to not store original meta in our cache...
}
count(): number {
return Object.keys(this.cache).length;
return this.cache.size;
}
has(resource: Uri, versionId?: number): boolean {
const cachedVersionId = this.cache[resource.toString()];
if (typeof cachedVersionId !== 'number') {
has(resource: URI, versionId?: number, meta?: object): boolean {
const entry = this.cache.get(resource);
if (!entry) {
return false; // unknown resource
}
if (typeof versionId === 'number') {
return versionId === cachedVersionId; // if we are asked with a specific version ID, make sure to test for it
if (typeof versionId === 'number' && versionId !== entry.versionId) {
return false; // different versionId
}
if (meta && !equals(meta, entry.meta)) {
return false; // different metadata
}
return true;
}
get(): Uri[] {
return Object.keys(this.cache).map(k => Uri.parse(k));
get(): URI[] {
return this.cache.keys();
}
remove(resource: Uri): void {
delete this.cache[resource.toString()];
remove(resource: URI): void {
this.cache.delete(resource);
}
clear(): void {
this.cache = Object.create(null);
this.cache.clear();
}
}
export class BackupFileService implements IBackupFileService {
_serviceBrand: any;
_serviceBrand: ServiceIdentifier<IBackupFileService>;
private impl: IBackupFileService;
......@@ -116,15 +134,15 @@ export class BackupFileService implements IBackupFileService {
return this.impl.hasBackups();
}
loadBackupResource(resource: Uri): Promise<Uri | undefined> {
loadBackupResource(resource: URI): Promise<URI | undefined> {
return this.impl.loadBackupResource(resource);
}
backupResource(resource: Uri, content: ITextSnapshot, versionId?: number): Promise<void> {
return this.impl.backupResource(resource, content, versionId);
backupResource<T extends object>(resource: URI, content: ITextSnapshot, versionId?: number, meta?: T): Promise<void> {
return this.impl.backupResource(resource, content, versionId, meta);
}
discardResourceBackup(resource: Uri): Promise<void> {
discardResourceBackup(resource: URI): Promise<void> {
return this.impl.discardResourceBackup(resource);
}
......@@ -132,26 +150,28 @@ export class BackupFileService implements IBackupFileService {
return this.impl.discardAllWorkspaceBackups();
}
getWorkspaceFileBackups(): Promise<Uri[]> {
getWorkspaceFileBackups(): Promise<URI[]> {
return this.impl.getWorkspaceFileBackups();
}
resolveBackupContent(backup: Uri): Promise<ITextBufferFactory | undefined> {
resolveBackupContent<T extends object>(backup: URI): Promise<IResolvedBackup<T>> {
return this.impl.resolveBackupContent(backup);
}
toBackupResource(resource: Uri): Uri {
toBackupResource(resource: URI): URI {
return this.impl.toBackupResource(resource);
}
}
class BackupFileServiceImpl implements IBackupFileService {
private static readonly META_MARKER = '\n';
private static readonly PREAMBLE_END_MARKER = '\n';
private static readonly PREAMBLE_META_SEPARATOR = ' '; // using a character that is know to be escaped in a URI as separator
private static readonly PREAMBLE_MAX_LENGTH = 10000;
_serviceBrand: any;
private backupWorkspacePath: string;
private backupWorkspacePath: URI;
private isShuttingDown: boolean;
private ready: Promise<IBackupFilesModel>;
......@@ -168,115 +188,165 @@ class BackupFileServiceImpl implements IBackupFileService {
}
initialize(backupWorkspacePath: string): void {
this.backupWorkspacePath = backupWorkspacePath;
this.backupWorkspacePath = URI.file(backupWorkspacePath);
this.ready = this.init();
}
private init(): Promise<IBackupFilesModel> {
const model = new BackupFilesModel();
const model = new BackupFilesModel(this.fileService);
return model.resolve(this.backupWorkspacePath);
}
hasBackups(): Promise<boolean> {
return this.ready.then(model => {
return model.count() > 0;
});
async hasBackups(): Promise<boolean> {
const model = await this.ready;
return model.count() > 0;
}
loadBackupResource(resource: Uri): Promise<Uri | undefined> {
return this.ready.then(model => {
async loadBackupResource(resource: URI): Promise<URI | undefined> {
const model = await this.ready;
// Return directly if we have a known backup with that resource
const backupResource = this.toBackupResource(resource);
if (model.has(backupResource)) {
return backupResource;
}
// Return directly if we have a known backup with that resource
const backupResource = this.toBackupResource(resource);
if (model.has(backupResource)) {
return backupResource;
}
return undefined;
});
return undefined;
}
backupResource(resource: Uri, content: ITextSnapshot, versionId?: number): Promise<void> {
async backupResource<T extends object>(resource: URI, content: ITextSnapshot, versionId?: number, meta?: T): Promise<void> {
if (this.isShuttingDown) {
return Promise.resolve();
return;
}
const model = await this.ready;
const backupResource = this.toBackupResource(resource);
if (model.has(backupResource, versionId, meta)) {
return; // return early if backup version id matches requested one
}
return this.ready.then(model => {
const backupResource = this.toBackupResource(resource);
if (model.has(backupResource, versionId)) {
return undefined; // return early if backup version id matches requested one
return this.ioOperationQueues.queueFor(backupResource).queue(async () => {
let preamble: string | undefined = undefined;
// With Metadata: URI + META-START + Meta + END
if (meta) {
const preambleWithMeta = `${resource.toString()}${BackupFileServiceImpl.PREAMBLE_META_SEPARATOR}${JSON.stringify(meta)}${BackupFileServiceImpl.PREAMBLE_END_MARKER}`;
if (preambleWithMeta.length < BackupFileServiceImpl.PREAMBLE_MAX_LENGTH) {
preamble = preambleWithMeta;
}
}
// Without Metadata: URI + END
if (!preamble) {
preamble = `${resource.toString()}${BackupFileServiceImpl.PREAMBLE_END_MARKER}`;
}
return this.ioOperationQueues.queueFor(backupResource).queue(() => {
const preamble = `${resource.toString()}${BackupFileServiceImpl.META_MARKER}`;
// Update content with value
await this.fileService.writeFile(backupResource, new TextSnapshotReadable(content, preamble));
// Update content with value
return this.fileService.writeFile(backupResource, new TextSnapshotReadable(content, preamble)).then(() => model.add(backupResource, versionId));
});
// Update model
model.add(backupResource, versionId, meta);
});
}
discardResourceBackup(resource: Uri): Promise<void> {
return this.ready.then(model => {
const backupResource = this.toBackupResource(resource);
async discardResourceBackup(resource: URI): Promise<void> {
const model = await this.ready;
const backupResource = this.toBackupResource(resource);
return this.ioOperationQueues.queueFor(backupResource).queue(async () => {
await this.fileService.del(backupResource, { recursive: true });
return this.ioOperationQueues.queueFor(backupResource).queue(() => {
return pfs.rimraf(backupResource.fsPath, pfs.RimRafMode.MOVE).then(() => model.remove(backupResource));
});
model.remove(backupResource);
});
}
discardAllWorkspaceBackups(): Promise<void> {
async discardAllWorkspaceBackups(): Promise<void> {
this.isShuttingDown = true;
return this.ready.then(model => {
return pfs.rimraf(this.backupWorkspacePath, pfs.RimRafMode.MOVE).then(() => model.clear());
});
const model = await this.ready;
await this.fileService.del(this.backupWorkspacePath, { recursive: true });
model.clear();
}
getWorkspaceFileBackups(): Promise<Uri[]> {
return this.ready.then(model => {
const readPromises: Promise<Uri>[] = [];
async getWorkspaceFileBackups(): Promise<URI[]> {
const model = await this.ready;
model.get().forEach(fileBackup => {
readPromises.push(
readToMatchingString(fileBackup.fsPath, BackupFileServiceImpl.META_MARKER, 2000, 10000).then(Uri.parse)
);
});
const backups = await Promise.all(model.get().map(async fileBackup => {
const backupPreamble = await readToMatchingString(fileBackup.fsPath, BackupFileServiceImpl.PREAMBLE_END_MARKER, BackupFileServiceImpl.PREAMBLE_MAX_LENGTH / 5, BackupFileServiceImpl.PREAMBLE_MAX_LENGTH);
if (!backupPreamble) {
return undefined;
}
return Promise.all(readPromises);
});
// Preamble with metadata: URI + META-START + Meta + END
const metaStartIndex = backupPreamble.indexOf(BackupFileServiceImpl.PREAMBLE_META_SEPARATOR);
if (metaStartIndex > 0) {
return URI.parse(backupPreamble.substring(0, metaStartIndex));
}
// Preamble without metadata: URI + END
else {
return URI.parse(backupPreamble);
}
}));
return coalesce(backups);
}
resolveBackupContent(backup: Uri): Promise<ITextBufferFactory> {
return this.fileService.readFileStream(backup).then(content => {
async resolveBackupContent<T extends object>(backup: URI): Promise<IResolvedBackup<T>> {
// Metadata extraction
let metaRaw = '';
let metaEndFound = false;
// Add a filter method to filter out everything until the meta marker
let metaFound = false;
const metaPreambleFilter = (chunk: VSBuffer) => {
const chunkString = chunk.toString();
// Add a filter method to filter out everything until the meta end marker
const metaPreambleFilter = (chunk: VSBuffer) => {
const chunkString = chunk.toString();
if (!metaFound && chunk) {
const metaIndex = chunkString.indexOf(BackupFileServiceImpl.META_MARKER);
if (metaIndex === -1) {
return VSBuffer.fromString(''); // meta not yet found, return empty string
}
if (!metaEndFound) {
const metaEndIndex = chunkString.indexOf(BackupFileServiceImpl.PREAMBLE_END_MARKER);
if (metaEndIndex === -1) {
metaRaw += chunkString;
metaFound = true;
return VSBuffer.fromString(chunkString.substr(metaIndex + 1)); // meta found, return everything after
return VSBuffer.fromString(''); // meta not yet found, return empty string
}
return chunk;
};
metaEndFound = true;
metaRaw += chunkString.substring(0, metaEndIndex); // ensure to get last chunk from metadata
return createTextBufferFactoryFromStream(content.value, metaPreambleFilter);
});
return VSBuffer.fromString(chunkString.substr(metaEndIndex + 1)); // meta found, return everything after
}
return chunk;
};
// Read backup into factory
const content = await this.fileService.readFileStream(backup);
const factory = await createTextBufferFactoryFromStream(content.value, metaPreambleFilter);
// Trigger read for meta data extraction from the filter above
factory.getFirstLineText(1);
let meta: T | undefined;
const metaStartIndex = metaRaw.indexOf(BackupFileServiceImpl.PREAMBLE_META_SEPARATOR);
if (metaStartIndex !== -1) {
try {
meta = JSON.parse(metaRaw.substr(metaStartIndex + 1));
} catch (error) {
// ignore JSON parse errors
}
}
return { value: factory, meta };
}
toBackupResource(resource: Uri): Uri {
return Uri.file(path.join(this.backupWorkspacePath, resource.scheme, hashPath(resource)));
toBackupResource(resource: URI): URI {
return joinPath(this.backupWorkspacePath, resource.scheme, hashPath(resource));
}
}
......@@ -290,7 +360,7 @@ export class InMemoryBackupFileService implements IBackupFileService {
return Promise.resolve(this.backups.size > 0);
}
loadBackupResource(resource: Uri): Promise<Uri | undefined> {
loadBackupResource(resource: URI): Promise<URI | undefined> {
const backupResource = this.toBackupResource(resource);
if (this.backups.has(backupResource.toString())) {
return Promise.resolve(backupResource);
......@@ -299,27 +369,27 @@ export class InMemoryBackupFileService implements IBackupFileService {
return Promise.resolve(undefined);
}
backupResource(resource: Uri, content: ITextSnapshot, versionId?: number): Promise<void> {
backupResource<T extends object>(resource: URI, content: ITextSnapshot, versionId?: number, meta?: T): Promise<void> {
const backupResource = this.toBackupResource(resource);
this.backups.set(backupResource.toString(), content);
return Promise.resolve();
}
resolveBackupContent(backupResource: Uri): Promise<ITextBufferFactory | undefined> {
resolveBackupContent<T extends object>(backupResource: URI): Promise<IResolvedBackup<T>> {
const snapshot = this.backups.get(backupResource.toString());
if (snapshot) {
return Promise.resolve(createTextBufferFactoryFromSnapshot(snapshot));
return Promise.resolve({ value: createTextBufferFactoryFromSnapshot(snapshot) });
}
return Promise.resolve(undefined);
return Promise.reject('Unexpected backup resource to resolve');
}
getWorkspaceFileBackups(): Promise<Uri[]> {
return Promise.resolve(keys(this.backups).map(key => Uri.parse(key)));
getWorkspaceFileBackups(): Promise<URI[]> {
return Promise.resolve(keys(this.backups).map(key => URI.parse(key)));
}
discardResourceBackup(resource: Uri): Promise<void> {
discardResourceBackup(resource: URI): Promise<void> {
this.backups.delete(this.toBackupResource(resource).toString());
return Promise.resolve();
......@@ -331,17 +401,17 @@ export class InMemoryBackupFileService implements IBackupFileService {
return Promise.resolve();
}
toBackupResource(resource: Uri): Uri {
return Uri.file(path.join(resource.scheme, hashPath(resource)));
toBackupResource(resource: URI): URI {
return URI.file(join(resource.scheme, hashPath(resource)));
}
}
/*
* Exported only for testing
*/
export function hashPath(resource: Uri): string {
export function hashPath(resource: URI): string {
const str = resource.scheme === Schemas.file || resource.scheme === Schemas.untitled ? resource.fsPath : resource.toString();
return crypto.createHash('md5').update(str).digest('hex');
return createHash('md5').update(str).digest('hex');
}
registerSingleton(IBackupFileService, BackupFileService);
\ No newline at end of file
......@@ -9,7 +9,7 @@ import { Event, Emitter } from 'vs/base/common/event';
import { guessMimeTypes } from 'vs/base/common/mime';
import { toErrorMessage } from 'vs/base/common/errorMessage';
import { URI } from 'vs/base/common/uri';
import { isUndefinedOrNull, withUndefinedAsNull } from 'vs/base/common/types';
import { isUndefinedOrNull } from 'vs/base/common/types';
import { IWorkspaceContextService } from 'vs/platform/workspace/common/workspace';
import { IEnvironmentService } from 'vs/platform/environment/common/environment';
import { ITextFileService, IAutoSaveConfiguration, ModelState, ITextFileEditorModel, ISaveOptions, ISaveErrorHandler, ISaveParticipant, StateChange, SaveReason, ITextFileStreamContent, ILoadOptions, LoadReason, IResolvedTextFileEditorModel } from 'vs/workbench/services/textfile/common/textfiles';
......@@ -24,7 +24,6 @@ import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry';
import { RunOnceScheduler, timeout } from 'vs/base/common/async';
import { ITextBufferFactory } from 'vs/editor/common/model';
import { hash } from 'vs/base/common/hash';
import { createTextBufferFactory } from 'vs/editor/common/model/textModel';
import { INotificationService } from 'vs/platform/notification/common/notification';
import { isLinux } from 'vs/base/common/platform';
import { IDisposable, toDisposable } from 'vs/base/common/lifecycle';
......@@ -33,6 +32,13 @@ import { isEqual, isEqualOrParent, extname, basename } from 'vs/base/common/reso
import { onUnexpectedError } from 'vs/base/common/errors';
import { Schemas } from 'vs/base/common/network';
export interface IBackupMetaData {
mtime: number;
size: number;
etag: string;
orphaned: boolean;
}
/**
* The text file editor model listens to changes to its underlying code editor model and saves these changes through the file service back to the disk.
*/
......@@ -64,8 +70,6 @@ export class TextFileEditorModel extends BaseTextEditorModel implements ITextFil
private bufferSavedVersionId: number;
private blockModelContentChange: boolean;
private createTextEditorModelPromise: Promise<TextFileEditorModel> | null;
private lastResolvedDiskStat: IFileStatWithMetadata;
private autoSaveAfterMillies?: number;
......@@ -199,7 +203,7 @@ export class TextFileEditorModel extends BaseTextEditorModel implements ITextFil
}
private onFilesAssociationChange(): void {
if (!this.textEditorModel) {
if (!this.isResolved()) {
return;
}
......@@ -209,8 +213,24 @@ export class TextFileEditorModel extends BaseTextEditorModel implements ITextFil
this.modelService.setMode(this.textEditorModel, languageSelection);
}
getVersionId(): number {
return this.versionId;
async backup(target = this.resource): Promise<void> {
if (this.isResolved()) {
// Only fill in model metadata if resource matches
let meta: IBackupMetaData | undefined = undefined;
if (isEqual(target, this.resource) && this.lastResolvedDiskStat) {
meta = {
mtime: this.lastResolvedDiskStat.mtime,
size: this.lastResolvedDiskStat.size,
etag: this.lastResolvedDiskStat.etag,
orphaned: this.inOrphanMode
};
}
return this.backupFileService.backupResource<IBackupMetaData>(target, this.createSnapshot(), this.versionId, meta);
}
return Promise.resolve();
}
async revert(soft?: boolean): Promise<void> {
......@@ -245,7 +265,7 @@ export class TextFileEditorModel extends BaseTextEditorModel implements ITextFil
}
}
load(options?: ILoadOptions): Promise<ITextFileEditorModel> {
async load(options?: ILoadOptions): Promise<ITextFileEditorModel> {
this.logService.trace('load() - enter', this.resource);
// It is very important to not reload the model when the model is dirty.
......@@ -254,44 +274,57 @@ export class TextFileEditorModel extends BaseTextEditorModel implements ITextFil
if (this.dirty || this.saveSequentializer.hasPendingSave()) {
this.logService.trace('load() - exit - without loading because model is dirty or being saved', this.resource);
return Promise.resolve(this);
return this;
}
// Only for new models we support to load from backup
if (!this.textEditorModel && !this.createTextEditorModelPromise) {
return this.loadFromBackup(options);
if (!this.isResolved()) {
const backup = await this.backupFileService.loadBackupResource(this.resource);
if (this.isResolved()) {
return this; // Make sure meanwhile someone else did not suceed in loading
}
if (backup) {
try {
return await this.loadFromBackup(backup, options);
} catch (error) {
// ignore error and continue to load as file below
}
}
}
// Otherwise load from file resource
return this.loadFromFile(options);
}
private async loadFromBackup(options?: ILoadOptions): Promise<TextFileEditorModel> {
const backup = await this.backupFileService.loadBackupResource(this.resource);
private async loadFromBackup(backup: URI, options?: ILoadOptions): Promise<TextFileEditorModel> {
// Make sure meanwhile someone else did not suceed or start loading
if (this.createTextEditorModelPromise || this.textEditorModel) {
return this.createTextEditorModelPromise || this;
}
// Resolve actual backup contents
const resolvedBackup = await this.backupFileService.resolveBackupContent<IBackupMetaData>(backup);
// If we have a backup, continue loading with it
if (!!backup) {
const content: ITextFileStreamContent = {
resource: this.resource,
name: basename(this.resource),
mtime: Date.now(),
size: 0,
etag: ETAG_DISABLED, // always allow to save content restored from a backup (see https://github.com/Microsoft/vscode/issues/72343)
value: createTextBufferFactory(''), // will be filled later from backup
encoding: this.textFileService.encoding.getPreferredWriteEncoding(this.resource, this.preferredEncoding).encoding,
isReadonly: false
};
if (this.isResolved()) {
return this; // Make sure meanwhile someone else did not suceed in loading
}
return this.loadWithContent(content, options, backup);
// Load with backup
this.loadFromContent({
resource: this.resource,
name: basename(this.resource),
mtime: resolvedBackup.meta ? resolvedBackup.meta.mtime : Date.now(),
size: resolvedBackup.meta ? resolvedBackup.meta.size : 0,
etag: resolvedBackup.meta ? resolvedBackup.meta.etag : ETAG_DISABLED, // etag disabled if unknown!
value: resolvedBackup.value,
encoding: this.textFileService.encoding.getPreferredWriteEncoding(this.resource, this.preferredEncoding).encoding,
isReadonly: false
}, options, true /* from backup */);
// Restore orphaned flag based on state
if (resolvedBackup.meta && resolvedBackup.meta.orphaned) {
this.setOrphaned(true);
}
// Otherwise load from file
return this.loadFromFile(options);
return this;
}
private async loadFromFile(options?: ILoadOptions): Promise<TextFileEditorModel> {
......@@ -321,12 +354,11 @@ export class TextFileEditorModel extends BaseTextEditorModel implements ITextFil
// Clear orphaned state when loading was successful
this.setOrphaned(false);
// Guard against the model having changed in the meantime
if (currentVersionId === this.versionId) {
return this.loadWithContent(content, options);
if (currentVersionId !== this.versionId) {
return this; // Make sure meanwhile someone else did not suceed loading
}
return this;
return this.loadFromContent(content, options);
} catch (error) {
const result = error.fileOperationResult;
......@@ -356,33 +388,7 @@ export class TextFileEditorModel extends BaseTextEditorModel implements ITextFil
}
}
private async loadWithContent(content: ITextFileStreamContent, options?: ILoadOptions, backup?: URI): Promise<TextFileEditorModel> {
const model = await this.doLoadWithContent(content, backup);
// Telemetry: We log the fileGet telemetry event after the model has been loaded to ensure a good mimetype
const settingsType = this.getTypeIfSettings();
if (settingsType) {
/* __GDPR__
"settingsRead" : {
"settingsType": { "classification": "SystemMetaData", "purpose": "FeatureInsight" }
}
*/
this.telemetryService.publicLog('settingsRead', { settingsType }); // Do not log read to user settings.json and .vscode folder as a fileGet event as it ruins our JSON usage data
} else {
/* __GDPR__
"fileGet" : {
"${include}": [
"${FileTelemetryData}"
]
}
*/
this.telemetryService.publicLog('fileGet', this.getTelemetryData(options && options.reason ? options.reason : LoadReason.OTHER));
}
return model;
}
private doLoadWithContent(content: ITextFileStreamContent, backup?: URI): Promise<TextFileEditorModel> {
private loadFromContent(content: ITextFileStreamContent, options?: ILoadOptions, fromBackup?: boolean): TextFileEditorModel {
this.logService.trace('load() - resolved content', this.resource);
// Update our resolved disk stat model
......@@ -409,21 +415,61 @@ export class TextFileEditorModel extends BaseTextEditorModel implements ITextFil
}
// Update Existing Model
if (this.textEditorModel) {
if (this.isResolved()) {
this.doUpdateTextModel(content.value);
}
return Promise.resolve(this);
// Create New Model
else {
this.doCreateTextModel(content.resource, content.value, !!fromBackup);
}
// Telemetry: We log the fileGet telemetry event after the model has been loaded to ensure a good mimetype
const settingsType = this.getTypeIfSettings();
if (settingsType) {
/* __GDPR__
"settingsRead" : {
"settingsType": { "classification": "SystemMetaData", "purpose": "FeatureInsight" }
}
*/
this.telemetryService.publicLog('settingsRead', { settingsType }); // Do not log read to user settings.json and .vscode folder as a fileGet event as it ruins our JSON usage data
} else {
/* __GDPR__
"fileGet" : {
"${include}": [
"${FileTelemetryData}"
]
}
*/
this.telemetryService.publicLog('fileGet', this.getTelemetryData(options && options.reason ? options.reason : LoadReason.OTHER));
}
// Join an existing request to create the editor model to avoid race conditions
else if (this.createTextEditorModelPromise) {
this.logService.trace('load() - join existing text editor model promise', this.resource);
return this;
}
return this.createTextEditorModelPromise;
private doCreateTextModel(resource: URI, value: ITextBufferFactory, fromBackup: boolean): void {
this.logService.trace('load() - created text editor model', this.resource);
// Create model
this.createTextEditorModel(value, resource);
// We restored a backup so we have to set the model as being dirty
// We also want to trigger auto save if it is enabled to simulate the exact same behaviour
// you would get if manually making the model dirty (fixes https://github.com/Microsoft/vscode/issues/16977)
if (fromBackup) {
this.makeDirty();
if (this.autoSaveAfterMilliesEnabled) {
this.doAutoSave(this.versionId);
}
}
// Create New Model
return this.doCreateTextModel(content.resource, content.value, backup);
// Ensure we are not tracking a stale state
else {
this.setDirty(false);
}
// Model Listeners
this.installModelListeners();
}
private doUpdateTextModel(value: ITextBufferFactory): void {
......@@ -444,44 +490,6 @@ export class TextFileEditorModel extends BaseTextEditorModel implements ITextFil
this.updateSavedVersionId();
}
private doCreateTextModel(resource: URI, value: ITextBufferFactory, backup: URI | undefined): Promise<TextFileEditorModel> {
this.logService.trace('load() - created text editor model', this.resource);
this.createTextEditorModelPromise = this.doLoadBackup(backup).then(backupContent => {
this.createTextEditorModelPromise = null;
// Create model
const hasBackupContent = !!backupContent;
this.createTextEditorModel(backupContent ? backupContent : value, resource);
// We restored a backup so we have to set the model as being dirty
// We also want to trigger auto save if it is enabled to simulate the exact same behaviour
// you would get if manually making the model dirty (fixes https://github.com/Microsoft/vscode/issues/16977)
if (hasBackupContent) {
this.makeDirty();
if (this.autoSaveAfterMilliesEnabled) {
this.doAutoSave(this.versionId);
}
}
// Ensure we are not tracking a stale state
else {
this.setDirty(false);
}
// Model Listeners
this.installModelListeners();
return this;
}, error => {
this.createTextEditorModelPromise = null;
return Promise.reject<TextFileEditorModel>(error);
});
return this.createTextEditorModelPromise;
}
private installModelListeners(): void {
// See https://github.com/Microsoft/vscode/issues/30189
......@@ -489,23 +497,11 @@ export class TextFileEditorModel extends BaseTextEditorModel implements ITextFil
// where `value` was captured in the content change listener closure scope.
// Content Change
if (this.textEditorModel) {
if (this.isResolved()) {
this._register(this.textEditorModel.onDidChangeContent(() => this.onModelContentChanged()));
}
}
private async doLoadBackup(backup: URI | undefined): Promise<ITextBufferFactory | null> {
if (!backup) {
return null;
}
try {
return withUndefinedAsNull(await this.backupFileService.resolveBackupContent(backup));
} catch (error) {
return null; // ignore errors
}
}
protected getOrCreateMode(modeService: IModeService, preferredModeIds: string | undefined, firstLineText?: string): ILanguageSelection {
return modeService.createByFilepathOrFirstLine(this.resource.fsPath, firstLineText);
}
......@@ -526,7 +522,7 @@ export class TextFileEditorModel extends BaseTextEditorModel implements ITextFil
// In this case we clear the dirty flag and emit a SAVED event to indicate this state.
// Note: we currently only do this check when auto-save is turned off because there you see
// a dirty indicator that you want to get rid of when undoing to the saved version.
if (!this.autoSaveAfterMilliesEnabled && this.textEditorModel && this.textEditorModel.getAlternativeVersionId() === this.bufferSavedVersionId) {
if (!this.autoSaveAfterMilliesEnabled && this.isResolved() && this.textEditorModel.getAlternativeVersionId() === this.bufferSavedVersionId) {
this.logService.trace('onModelContentChanged() - model content changed back to last saved version', this.resource);
// Clear flags
......@@ -657,7 +653,7 @@ export class TextFileEditorModel extends BaseTextEditorModel implements ITextFil
// Push all edit operations to the undo stack so that the user has a chance to
// Ctrl+Z back to the saved version. We only do this when auto-save is turned off
if (!this.autoSaveAfterMilliesEnabled && this.textEditorModel) {
if (!this.autoSaveAfterMilliesEnabled && this.isResolved()) {
this.textEditorModel.pushStackElement();
}
......@@ -687,7 +683,12 @@ export class TextFileEditorModel extends BaseTextEditorModel implements ITextFil
// saving contents to disk that are stale (see https://github.com/Microsoft/vscode/issues/50942).
// To fix this issue, we will not store the contents to disk when we got disposed.
if (this.disposed) {
return undefined;
return;
}
// We require a resolved model from this point on, since we are about to write data to disk.
if (!this.isResolved()) {
return;
}
// Under certain conditions we do a short-cut of flushing contents to disk when we can assume that
......@@ -713,11 +714,7 @@ export class TextFileEditorModel extends BaseTextEditorModel implements ITextFil
// Save to Disk
// mark the save operation as currently pending with the versionId (it might have changed from a save participant triggering)
this.logService.trace(`doSave(${versionId}) - before write()`, this.resource);
const snapshot = this.createSnapshot();
if (!snapshot) {
throw new Error('Invalid snapshot');
}
return this.saveSequentializer.setPending(newVersionId, this.textFileService.write(this.lastResolvedDiskStat.resource, snapshot, {
return this.saveSequentializer.setPending(newVersionId, this.textFileService.write(this.lastResolvedDiskStat.resource, this.createSnapshot(), {
overwriteReadonly: options.overwriteReadonly,
overwriteEncoding: options.overwriteEncoding,
mtime: this.lastResolvedDiskStat.mtime,
......@@ -850,12 +847,11 @@ export class TextFileEditorModel extends BaseTextEditorModel implements ITextFil
}
private doTouch(versionId: number): Promise<void> {
const snapshot = this.createSnapshot();
if (!snapshot) {
throw new Error('invalid snapshot');
if (!this.isResolved()) {
return Promise.resolve();
}
return this.saveSequentializer.setPending(versionId, this.textFileService.write(this.lastResolvedDiskStat.resource, snapshot, {
return this.saveSequentializer.setPending(versionId, this.textFileService.write(this.lastResolvedDiskStat.resource, this.createSnapshot(), {
mtime: this.lastResolvedDiskStat.mtime,
encoding: this.getEncoding(),
etag: this.lastResolvedDiskStat.etag
......@@ -863,6 +859,10 @@ export class TextFileEditorModel extends BaseTextEditorModel implements ITextFil
// Updated resolved stat with updated stat since touching it might have changed mtime
this.updateLastResolvedDiskStat(stat);
// Emit File Saved Event
this._onDidStateChange.fire(StateChange.SAVED);
}, error => onUnexpectedError(error) /* just log any error but do not notify the user since the file was not dirty */));
}
......@@ -896,7 +896,7 @@ export class TextFileEditorModel extends BaseTextEditorModel implements ITextFil
// in order to find out if the model changed back to a saved version (e.g.
// when undoing long enough to reach to a version that is saved and then to
// clear the dirty flag)
if (this.textEditorModel) {
if (this.isResolved()) {
this.bufferSavedVersionId = this.textEditorModel.getAlternativeVersionId();
}
}
......@@ -935,10 +935,6 @@ export class TextFileEditorModel extends BaseTextEditorModel implements ITextFil
return this.lastSaveAttemptTime;
}
getETag(): string | null {
return this.lastResolvedDiskStat ? this.lastResolvedDiskStat.etag || null : null;
}
hasState(state: ModelState): boolean {
switch (state) {
case ModelState.CONFLICT:
......@@ -1020,8 +1016,8 @@ export class TextFileEditorModel extends BaseTextEditorModel implements ITextFil
return true;
}
isResolved(): boolean {
return !isUndefinedOrNull(this.lastResolvedDiskStat);
isResolved(): this is IResolvedTextFileEditorModel {
return !!this.textEditorModel;
}
isReadonly(): boolean {
......@@ -1046,8 +1042,6 @@ export class TextFileEditorModel extends BaseTextEditorModel implements ITextFil
this.inOrphanMode = false;
this.inErrorMode = false;
this.createTextEditorModelPromise = null;
this.cancelPendingAutoSave();
super.dispose();
......
......@@ -238,59 +238,44 @@ export abstract class TextFileService extends Disposable implements ITextFileSer
private async doBackupAll(dirtyFileModels: ITextFileEditorModel[], untitledResources: URI[]): Promise<void> {
// Handle file resources first
await Promise.all(dirtyFileModels.map(async model => {
const snapshot = model.createSnapshot();
if (snapshot) {
await this.backupFileService.backupResource(model.getResource(), snapshot, model.getVersionId());
}
}));
await Promise.all(dirtyFileModels.map(model => model.backup()));
// Handle untitled resources
const untitledModelPromises = untitledResources
await Promise.all(untitledResources
.filter(untitled => this.untitledEditorService.exists(untitled))
.map(untitled => this.untitledEditorService.loadOrCreate({ resource: untitled }));
const untitledModels = await Promise.all(untitledModelPromises);
await Promise.all(untitledModels.map(async model => {
const snapshot = model.createSnapshot();
if (snapshot) {
await this.backupFileService.backupResource(model.getResource(), snapshot, model.getVersionId());
}
}));
.map(async untitled => (await this.untitledEditorService.loadOrCreate({ resource: untitled })).backup()));
}
private confirmBeforeShutdown(): boolean | Promise<boolean> {
return this.confirmSave().then(confirm => {
private async confirmBeforeShutdown(): Promise<boolean> {
const confirm = await this.confirmSave();
// Save
if (confirm === ConfirmResult.SAVE) {
return this.saveAll(true /* includeUntitled */, { skipSaveParticipants: true }).then(result => {
if (result.results.some(r => !r.success)) {
return true; // veto if some saves failed
}
// Save
if (confirm === ConfirmResult.SAVE) {
const result = await this.saveAll(true /* includeUntitled */, { skipSaveParticipants: true });
return this.noVeto({ cleanUpBackups: true });
});
if (result.results.some(r => !r.success)) {
return true; // veto if some saves failed
}
// Don't Save
else if (confirm === ConfirmResult.DONT_SAVE) {
return this.noVeto({ cleanUpBackups: true });
}
// Don't Save
else if (confirm === ConfirmResult.DONT_SAVE) {
// Make sure to revert untitled so that they do not restore
// see https://github.com/Microsoft/vscode/issues/29572
this.untitledEditorService.revertAll();
// Make sure to revert untitled so that they do not restore
// see https://github.com/Microsoft/vscode/issues/29572
this.untitledEditorService.revertAll();
return this.noVeto({ cleanUpBackups: true });
}
return this.noVeto({ cleanUpBackups: true });
}
// Cancel
else if (confirm === ConfirmResult.CANCEL) {
return true; // veto
}
// Cancel
else if (confirm === ConfirmResult.CANCEL) {
return true; // veto
}
return false;
});
return false;
}
private noVeto(options: { cleanUpBackups: boolean }): boolean | Promise<boolean> {
......@@ -503,10 +488,7 @@ export abstract class TextFileService extends Disposable implements ITextFileSer
dirtyTargetModelUris.push(targetModelResource);
// Backup dirty source model to the target resource it will become later
const snapshot = sourceModel.createSnapshot();
if (snapshot) {
await this.backupFileService.backupResource(targetModelResource, snapshot, sourceModel.getVersionId());
}
await sourceModel.backup(targetModelResource);
}));
}
......@@ -872,17 +854,12 @@ export abstract class TextFileService extends Disposable implements ITextFileSer
// take over encoding, mode and model value from source model
targetModel.updatePreferredEncoding(sourceModel.getEncoding());
if (targetModel.textEditorModel) {
const snapshot = sourceModel.createSnapshot();
if (snapshot) {
this.modelService.updateModel(targetModel.textEditorModel, createTextBufferFactoryFromSnapshot(snapshot));
}
if (sourceModel.isResolved() && targetModel.isResolved()) {
this.modelService.updateModel(targetModel.textEditorModel, createTextBufferFactoryFromSnapshot(sourceModel.createSnapshot()));
if (sourceModel.textEditorModel) {
const language = sourceModel.textEditorModel.getLanguageIdentifier();
if (language.id > 1) {
targetModel.textEditorModel.setMode(language); // only use if more specific than plain/text
}
const language = sourceModel.textEditorModel.getLanguageIdentifier();
if (language.id > 1) {
targetModel.textEditorModel.setMode(language); // only use if more specific than plain/text
}
}
......
......@@ -448,14 +448,10 @@ export interface ITextFileEditorModel extends ITextEditorModel, IEncodingSupport
readonly onDidContentChange: Event<StateChange>;
readonly onDidStateChange: Event<StateChange>;
getVersionId(): number;
getResource(): URI;
hasState(state: ModelState): boolean;
getETag(): string | null;
updatePreferredEncoding(encoding: string): void;
save(options?: ISaveOptions): Promise<void>;
......@@ -464,16 +460,17 @@ export interface ITextFileEditorModel extends ITextEditorModel, IEncodingSupport
revert(soft?: boolean): Promise<void>;
createSnapshot(): ITextSnapshot | null;
backup(target?: URI): Promise<void>;
isDirty(): boolean;
isResolved(): boolean;
isResolved(): this is IResolvedTextFileEditorModel;
isDisposed(): boolean;
}
export interface IResolvedTextFileEditorModel extends ITextFileEditorModel {
readonly textEditorModel: ITextModel;
createSnapshot(): ITextSnapshot;
......
......@@ -17,7 +17,7 @@ import { ServiceIdentifier } from 'vs/platform/instantiation/common/instantiatio
export class TextResourcePropertiesService implements ITextResourcePropertiesService {
_serviceBrand: ServiceIdentifier<any>;
_serviceBrand: ServiceIdentifier<ITextResourcePropertiesService>;
private remoteEnvironment: IRemoteAgentEnvironment | null = null;
......
......@@ -44,7 +44,7 @@ suite('Files - TextFileEditorModel', () => {
accessor.fileService.setContent(content);
});
test('Save', async function () {
test('save', async function () {
const model: TextFileEditorModel = instantiationService.createInstance(TextFileEditorModel, toResource.call(this, '/path/index_async.txt'), 'utf8');
await model.load();
......@@ -52,10 +52,38 @@ suite('Files - TextFileEditorModel', () => {
model.textEditorModel!.setValue('bar');
assert.ok(getLastModifiedTime(model) <= Date.now());
let savedEvent = false;
model.onDidStateChange(e => {
if (e === StateChange.SAVED) {
savedEvent = true;
}
});
await model.save();
assert.ok(model.getLastSaveAttemptTime() <= Date.now());
assert.ok(!model.isDirty());
assert.ok(savedEvent);
model.dispose();
assert.ok(!accessor.modelService.getModel(model.getResource()));
});
test('save - touching also emits saved event', async function () {
const model: TextFileEditorModel = instantiationService.createInstance(TextFileEditorModel, toResource.call(this, '/path/index_async.txt'), 'utf8');
await model.load();
let savedEvent = false;
model.onDidStateChange(e => {
if (e === StateChange.SAVED) {
savedEvent = true;
}
});
await model.save({ force: true });
assert.ok(savedEvent);
model.dispose();
assert.ok(!accessor.modelService.getModel(model.getResource()));
......
......@@ -65,7 +65,7 @@ suite('Files - TextFileService', () => {
accessor.untitledEditorService.revertAll();
});
test('confirm onWillShutdown - no veto', function () {
test('confirm onWillShutdown - no veto', async function () {
model = instantiationService.createInstance(TextFileEditorModel, toResource.call(this, '/path/file.txt'), 'utf8');
(<TextFileEditorModelManager>accessor.textFileService.models).add(model.getResource(), model);
......@@ -76,9 +76,7 @@ suite('Files - TextFileService', () => {
if (typeof veto === 'boolean') {
assert.ok(!veto);
} else {
veto.then(veto => {
assert.ok(!veto);
});
assert.ok(!(await veto));
}
});
......
......@@ -15,7 +15,7 @@ import { ConfirmResult, IEditorInputWithOptions, CloseDirection, IEditorIdentifi
import { IEditorOpeningEvent, EditorServiceImpl, IEditorGroupView } from 'vs/workbench/browser/parts/editor/editor';
import { Event, Emitter } from 'vs/base/common/event';
import Severity from 'vs/base/common/severity';
import { IBackupFileService } from 'vs/workbench/services/backup/common/backup';
import { IBackupFileService, IResolvedBackup } from 'vs/workbench/services/backup/common/backup';
import { IConfigurationService } from 'vs/platform/configuration/common/configuration';
import { IWorkbenchLayoutService, Parts, Position as PartPosition } from 'vs/workbench/services/layout/browser/layoutService';
import { TextModelResolverService } from 'vs/workbench/services/textmodelResolver/common/textModelResolverService';
......@@ -1093,7 +1093,7 @@ export class TestBackupFileService implements IBackupFileService {
throw new Error('not implemented');
}
public backupResource(_resource: URI, _content: ITextSnapshot): Promise<void> {
public backupResource<T extends object>(_resource: URI, _content: ITextSnapshot, versionId?: number, meta?: T): Promise<void> {
return Promise.resolve();
}
......@@ -1108,7 +1108,7 @@ export class TestBackupFileService implements IBackupFileService {
return textBuffer.getValueInRange(range, EndOfLinePreference.TextDefined);
}
public resolveBackupContent(_backup: URI): Promise<ITextBufferFactory> {
public resolveBackupContent<T extends object>(_backup: URI): Promise<IResolvedBackup<T>> {
throw new Error('not implemented');
}
......
Markdown is supported
0% .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册