diff --git a/src/vs/code/electron-main/main.ts b/src/vs/code/electron-main/main.ts index b08da75351fb0..61abd4bb8f766 100644 --- a/src/vs/code/electron-main/main.ts +++ b/src/vs/code/electron-main/main.ts @@ -32,6 +32,8 @@ import { ServiceCollection } from 'vs/platform/instantiation/common/serviceColle import { SyncDescriptor } from 'vs/platform/instantiation/common/descriptors'; import { ILogService, MainLogService } from 'vs/code/electron-main/log'; import { IStorageService, StorageService } from 'vs/code/electron-main/storage'; +import { IBackupService } from 'vs/platform/backup/common/backup'; +import { BackupService } from 'vs/platform/backup/node/backupService'; import { IEnvironmentService } from 'vs/platform/environment/common/environment'; import { EnvironmentService } from 'vs/platform/environment/node/environmentService'; import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; @@ -255,11 +257,11 @@ function main(accessor: ServicesAccessor, mainIpcServer: Server, userEnv: platfo // Open our first window if (environmentService.args['new-window'] && environmentService.args._.length === 0) { - windowsService.open({ cli: environmentService.args, forceNewWindow: true, forceEmpty: true }); // new window if "-n" was used without paths + windowsService.open({ cli: environmentService.args, forceNewWindow: true, forceEmpty: true, restoreBackups: true }); // new window if "-n" was used without paths } else if (global.macOpenFiles && global.macOpenFiles.length && (!environmentService.args._ || !environmentService.args._.length)) { - windowsService.open({ cli: environmentService.args, pathsToOpen: global.macOpenFiles }); // mac: open-file event received on startup + windowsService.open({ cli: environmentService.args, pathsToOpen: global.macOpenFiles, restoreBackups: true }); // mac: open-file event received on startup } else { - windowsService.open({ cli: environmentService.args, forceNewWindow: environmentService.args['new-window'], diffMode: environmentService.args.diff }); // default: read paths from cli + windowsService.open({ cli: environmentService.args, forceNewWindow: environmentService.args['new-window'], diffMode: environmentService.args.diff, restoreBackups: true }); // default: read paths from cli } } @@ -474,6 +476,7 @@ function start(): void { services.set(IConfigurationService, new SyncDescriptor(ConfigurationService)); services.set(IRequestService, new SyncDescriptor(RequestService)); services.set(IUpdateService, new SyncDescriptor(UpdateManager)); + services.set(IBackupService, new SyncDescriptor(BackupService)); services.set(IURLService, new SyncDescriptor(URLService, args['open-url'])); const instantiationService = new InstantiationService(services); diff --git a/src/vs/code/electron-main/windows.ts b/src/vs/code/electron-main/windows.ts index 3c45f98b14c1f..2171303a5ebeb 100644 --- a/src/vs/code/electron-main/windows.ts +++ b/src/vs/code/electron-main/windows.ts @@ -15,6 +15,7 @@ import * as types from 'vs/base/common/types'; import * as arrays from 'vs/base/common/arrays'; import { assign, mixin } from 'vs/base/common/objects'; import { EventEmitter } from 'events'; +import { IBackupService } from 'vs/platform/backup/common/backup'; import { IEnvironmentService } from 'vs/platform/environment/common/environment'; import { IStorageService } from 'vs/code/electron-main/storage'; import { IPath, VSCodeWindow, ReadyState, IWindowConfiguration, IWindowState as ISingleWindowState, defaultWindowState, IWindowSettings } from 'vs/code/electron-main/window'; @@ -28,6 +29,7 @@ import { IWindowEventService } from 'vs/code/common/windows'; import { createDecorator, IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; import CommonEvent, { Emitter } from 'vs/base/common/event'; import product from 'vs/platform/product'; +import Uri from 'vs/base/common/uri'; import { ParsedArgs } from 'vs/platform/environment/node/argv'; const EventTypes = { @@ -50,6 +52,7 @@ export interface IOpenConfiguration { forceEmpty?: boolean; windowToUse?: VSCodeWindow; diffMode?: boolean; + restoreBackups?: boolean; } interface IWindowState { @@ -167,7 +170,8 @@ export class WindowsManager implements IWindowsService { @IEnvironmentService private environmentService: IEnvironmentService, @ILifecycleService private lifecycleService: ILifecycleService, @IUpdateService private updateService: IUpdateService, - @IConfigurationService private configurationService: IConfigurationService + @IConfigurationService private configurationService: IConfigurationService, + @IBackupService private backupService: IBackupService ) { } onOpen(clb: (path: IPath) => void): () => void { @@ -621,6 +625,20 @@ export class WindowsManager implements IWindowsService { iPathsToOpen = this.cliToPaths(openConfig.cli, ignoreFileNotFound); } + // Add any existing backup workspaces + if (openConfig.restoreBackups) { + this.backupService.getWorkspaceBackupPathsSync().forEach(ws => { + iPathsToOpen.push(this.toIPath(ws)); + }); + // Get rid of duplicates + iPathsToOpen = arrays.distinct(iPathsToOpen, path => { + if (!('workspacePath' in path)) { + return path.workspacePath; + } + return platform.isLinux ? path.workspacePath : path.workspacePath.toLowerCase(); + }); + } + let filesToOpen: IPath[] = []; let filesToDiff: IPath[] = []; let foldersToOpen = iPathsToOpen.filter(iPath => iPath.workspacePath && !iPath.filePath); @@ -749,6 +767,11 @@ export class WindowsManager implements IWindowsService { // Emit events iPathsToOpen.forEach(iPath => this.eventEmitter.emit(EventTypes.OPEN, iPath)); + // Start tracking workspace backups + this.backupService.pushWorkspaceBackupPathsSync(iPathsToOpen.filter(path => 'workspacePath' in path).map(path => { + return Uri.file(path.workspacePath); + })); + return arrays.distinct(usedWindows); } diff --git a/src/vs/platform/backup/common/backup.ts b/src/vs/platform/backup/common/backup.ts new file mode 100644 index 0000000000000..fe55e94179367 --- /dev/null +++ b/src/vs/platform/backup/common/backup.ts @@ -0,0 +1,83 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +'use strict'; + +import Uri from 'vs/base/common/uri'; +import { createDecorator } from 'vs/platform/instantiation/common/instantiation'; +import { TPromise } from 'vs/base/common/winjs.base'; + +export const IBackupService = createDecorator('backupService'); + +export interface IBackupService { + _serviceBrand: any; + + /** + * Gets the set of active workspace backup paths being tracked for restoration. + * + * @return The set of active workspace backup paths being tracked for restoration. + */ + getWorkspaceBackupPaths(): TPromise; + + /** + * Gets the set of active workspace backup paths being tracked for restoration. + * + * @return The set of active workspace backup paths being tracked for restoration. + */ + getWorkspaceBackupPathsSync(): string[]; + + /** + * Pushes workspace backup paths to be tracked for restoration. + * + * @param workspaces The workspaces to add. + */ + pushWorkspaceBackupPathsSync(workspaces: Uri[]): void; + + /** + * Removes a workspace backup path being tracked for restoration, deregistering all associated + * resources for backup. + * + * @param workspace The absolute workspace path being removed. + */ + removeWorkspaceBackupPath(workspace: Uri): TPromise; + + /** + * Gets the set of text files that are backed up for a particular workspace. + * + * @param workspace The workspace to get the backed up files for. + * @return The absolute paths for text files _that have backups_. + */ + getWorkspaceTextFilesWithBackupsSync(workspace: Uri): string[]; + + /** + * Gets the set of untitled file backups for a particular workspace. + * + * @param workspace The workspace to get the backups for for. + * @return The absolute paths for all the untitled file _backups_. + */ + getWorkspaceUntitledFileBackupsSync(workspace: Uri): string[]; + + /** + * Registers a resource for backup, flagging it for restoration. + * + * @param resource The resource that is being backed up. + */ + registerResourceForBackup(resource: Uri): TPromise; + + /** + * Deregisters a resource for backup, unflagging it for restoration. + * + * @param resource The resource that is no longer being backed up. + */ + deregisterResourceForBackup(resource: Uri): TPromise; + + /** + * Gets the backup resource for a particular resource within the current workspace. + * + * @param resource The resource that is backed up. + * @return The backup resource. + */ + getBackupResource(resource: Uri): Uri; +} diff --git a/src/vs/platform/backup/node/backupService.ts b/src/vs/platform/backup/node/backupService.ts new file mode 100644 index 0000000000000..599672754eec1 --- /dev/null +++ b/src/vs/platform/backup/node/backupService.ts @@ -0,0 +1,222 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +'use strict'; + +import * as path from 'path'; +import * as crypto from 'crypto'; +import * as arrays from 'vs/base/common/arrays'; +import fs = require('fs'); +import pfs = require('vs/base/node/pfs'); +import Uri from 'vs/base/common/uri'; +import { IBackupService } from 'vs/platform/backup/common/backup'; +import { IEnvironmentService } from 'vs/platform/environment/common/environment'; +import { TPromise } from 'vs/base/common/winjs.base'; + +interface IBackupFormat { + folderWorkspaces?: { + [workspacePath: string]: string[] + }; +} + +export class BackupService implements IBackupService { + + public _serviceBrand: any; + + private workspaceResource: Uri; + private fileContent: IBackupFormat; + private backupHome: string; + private backupWorkspacesPath: string; + + constructor( + @IEnvironmentService environmentService: IEnvironmentService + ) { + this.backupHome = environmentService.backupHome; + this.backupWorkspacesPath = environmentService.backupWorkspacesPath; + } + + public setCurrentWorkspace(resource: Uri): void { + this.workspaceResource = resource; + } + + /** + * Due to the Environment service not being initialized when it's needed on the main thread + * side, this is here so that tests can override the paths pulled from it. + */ + public setBackupPathsForTest(backupHome: string, backupWorkspacesPath: string) { + this.backupHome = backupHome; + this.backupWorkspacesPath = backupWorkspacesPath; + } + + public getWorkspaceBackupPaths(): TPromise { + return this.load().then(() => { + return Object.keys(this.fileContent.folderWorkspaces); + }); + } + + public getWorkspaceBackupPathsSync(): string[] { + this.loadSync(); + return Object.keys(this.fileContent.folderWorkspaces); + } + + public pushWorkspaceBackupPathsSync(workspaces: Uri[]): void { + // Only allow this on the main thread in the window initialization's critical path due to + // the usage of synchronous IO. + if (this.workspaceResource) { + throw new Error('pushWorkspaceBackupPaths should only be called on the main process'); + } + + this.loadSync(); + workspaces.forEach(workspace => { + // Hot exit is disabled for empty workspaces + if (!workspace) { + return; + } + + if (!this.fileContent.folderWorkspaces[workspace.fsPath]) { + this.fileContent.folderWorkspaces[workspace.fsPath] = []; + } + }); + this.saveSync(); + } + + public removeWorkspaceBackupPath(workspace: Uri): TPromise { + return this.load().then(() => { + if (!this.fileContent.folderWorkspaces) { + return TPromise.as(void 0); + } + delete this.fileContent.folderWorkspaces[workspace.fsPath]; + return this.save(); + }); + } + + public getWorkspaceTextFilesWithBackupsSync(workspace: Uri): string[] { + // Allow sync here as it's only used in workbench initialization's critical path + this.loadSync(); + return this.fileContent.folderWorkspaces[workspace.fsPath] || []; + } + + public getWorkspaceUntitledFileBackupsSync(workspace: Uri): string[] { + // Hot exit is disabled for empty workspaces + if (!this.workspaceResource) { + return []; + } + + const workspaceHash = crypto.createHash('md5').update(workspace.fsPath).digest('hex'); + const untitledDir = path.join(this.backupHome, workspaceHash, 'untitled'); + + // Allow sync here as it's only used in workbench initialization's critical path + try { + return fs.readdirSync(untitledDir).map(file => path.join(untitledDir, file)); + } catch (ex) { + return []; + } + } + + public getBackupResource(resource: Uri): Uri { + // Hot exit is disabled for empty workspaces + if (!this.workspaceResource) { + return null; + } + + const workspaceHash = crypto.createHash('md5').update(this.workspaceResource.fsPath).digest('hex'); + const backupName = crypto.createHash('md5').update(resource.fsPath).digest('hex'); + const backupPath = path.join(this.backupHome, workspaceHash, resource.scheme, backupName); + return Uri.file(backupPath); + } + + public registerResourceForBackup(resource: Uri): TPromise { + // Hot exit is disabled for empty workspaces + if (!this.workspaceResource) { + return TPromise.as(void 0); + } + + return this.load().then(() => { + if (!(this.workspaceResource.fsPath in this.fileContent.folderWorkspaces)) { + this.fileContent.folderWorkspaces[this.workspaceResource.fsPath] = []; + } + if (arrays.contains(this.fileContent.folderWorkspaces[this.workspaceResource.fsPath], resource.fsPath)) { + return TPromise.as(void 0); + } + this.fileContent.folderWorkspaces[this.workspaceResource.fsPath].push(resource.fsPath); + return this.save(); + }); + } + + public deregisterResourceForBackup(resource: Uri): TPromise { + // Hot exit is disabled for empty workspaces + if (!this.workspaceResource) { + return TPromise.as(void 0); + } + + return this.load().then(() => { + const workspace = this.fileContent.folderWorkspaces[this.workspaceResource.fsPath]; + if (workspace) { + this.fileContent.folderWorkspaces[this.workspaceResource.fsPath] = workspace.filter(value => value !== resource.fsPath); + return this.save(); + } + return TPromise.as(void 0); + }); + } + + private load(): TPromise { + return pfs.fileExists(this.backupWorkspacesPath).then(exists => { + if (!exists) { + this.fileContent = { + folderWorkspaces: Object.create(null) + }; + return TPromise.as(void 0); + } + + return pfs.readFile(this.backupWorkspacesPath, 'utf8').then(content => { + try { + return JSON.parse(content.toString()); + } catch (ex) { + return Object.create(null); + } + }).then(content => { + this.fileContent = content; + if (!this.fileContent.folderWorkspaces) { + this.fileContent.folderWorkspaces = Object.create(null); + } + return TPromise.as(void 0); + }); + }); + } + + private loadSync(): void { + if (fs.existsSync(this.backupWorkspacesPath)) { + try { + this.fileContent = JSON.parse(fs.readFileSync(this.backupWorkspacesPath, 'utf8').toString()); // invalid JSON or permission issue can happen here + } catch (error) { + this.fileContent = Object.create(null); + } + } else { + this.fileContent = Object.create(null); + } + + if (!this.fileContent.folderWorkspaces) { + this.fileContent.folderWorkspaces = Object.create(null); + } + } + + private save(): TPromise { + const data = JSON.stringify(this.fileContent); + return pfs.mkdirp(this.backupHome).then(() => { + return pfs.writeFile(this.backupWorkspacesPath, data); + }); + } + + private saveSync(): void { + try { + // The user data directory must exist so only the Backup directory needs to be checked. + if (!fs.existsSync(this.backupHome)) { + fs.mkdirSync(this.backupHome); + } + fs.writeFileSync(this.backupWorkspacesPath, JSON.stringify(this.fileContent)); + } catch (ex) { + } + } +} \ No newline at end of file diff --git a/src/vs/platform/backup/test/backupService.test.ts b/src/vs/platform/backup/test/backupService.test.ts new file mode 100644 index 0000000000000..66321ddda6de4 --- /dev/null +++ b/src/vs/platform/backup/test/backupService.test.ts @@ -0,0 +1,175 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +'use strict'; + +import * as assert from 'assert'; +import * as platform from 'vs/base/common/platform'; +import crypto = require('crypto'); +import os = require('os'); +import path = require('path'); +import extfs = require('vs/base/node/extfs'); +import pfs = require('vs/base/node/pfs'); +import Uri from 'vs/base/common/uri'; +import { nfcall } from 'vs/base/common/async'; +import { TestEnvironmentService } from 'vs/test/utils/servicesTestUtils'; +import { IEnvironmentService } from 'vs/platform/environment/common/environment'; +import { BackupService } from 'vs/platform/backup/node/backupService'; + +suite('BackupService', () => { + const parentDir = path.join(os.tmpdir(), 'vsctests', 'service') + const backupHome = path.join(parentDir, 'Backups'); + const backupWorkspacesHome = path.join(backupHome, 'workspaces.json'); + + const fooFile = Uri.file(platform.isWindows ? 'C:\\foo' : '/foo'); + const barFile = Uri.file(platform.isWindows ? 'C:\\bar' : '/bar'); + const bazFile = Uri.file(platform.isWindows ? 'C:\\baz' : '/baz'); + + let backupService: BackupService; + + setup(done => { + const environmentService = TestEnvironmentService; + + backupService = new BackupService(environmentService); + backupService.setBackupPathsForTest(backupHome, backupWorkspacesHome); + + // Delete any existing backups completely and then re-create it. + extfs.del(backupHome, os.tmpdir(), () => { + pfs.mkdirp(backupHome).then(() => { + pfs.writeFileAndFlush(backupWorkspacesHome, '').then(() => { + done(); + }); + }); + }); + }); + + teardown(done => { + extfs.del(backupHome, os.tmpdir(), done); + }); + + test('pushWorkspaceBackupPathsSync should persist paths to workspaces.json', () => { + backupService.pushWorkspaceBackupPathsSync([fooFile, barFile]); + assert.deepEqual(backupService.getWorkspaceBackupPathsSync(), [fooFile.fsPath, barFile.fsPath]); + }); + + test('pushWorkspaceBackupPathsSync should throw if a workspace is set', () => { + backupService.setCurrentWorkspace(fooFile); + assert.throws(() => backupService.pushWorkspaceBackupPathsSync([fooFile])); + }); + + test('removeWorkspaceBackupPath should remove workspaces from workspaces.json', done => { + backupService.pushWorkspaceBackupPathsSync([fooFile, barFile]); + assert.deepEqual(backupService.getWorkspaceBackupPathsSync(), [fooFile.fsPath, barFile.fsPath]); + backupService.removeWorkspaceBackupPath(fooFile).then(() => { + assert.deepEqual(backupService.getWorkspaceBackupPathsSync(), [barFile.fsPath]); + backupService.removeWorkspaceBackupPath(barFile).then(() => { + assert.deepEqual(backupService.getWorkspaceBackupPathsSync(), []); + done(); + }); + }); + }); + + test('removeWorkspaceBackupPath should fail gracefully when removing a path that doesn\'t exist', done => { + backupService.pushWorkspaceBackupPathsSync([fooFile]); + assert.deepEqual(backupService.getWorkspaceBackupPathsSync(), [fooFile.fsPath]); + backupService.removeWorkspaceBackupPath(barFile).then(() => { + assert.deepEqual(backupService.getWorkspaceBackupPathsSync(), [fooFile.fsPath]); + done(); + }); + }); + + test('registerResourceForBackup should register backups to workspaces.json', done => { + backupService.setCurrentWorkspace(fooFile); + backupService.registerResourceForBackup(barFile).then(() => { + assert.deepEqual(backupService.getWorkspaceTextFilesWithBackupsSync(fooFile), [barFile.fsPath]); + done(); + }); + }); + + test('deregisterResourceForBackup should deregister backups from workspaces.json', done => { + backupService.setCurrentWorkspace(fooFile); + backupService.registerResourceForBackup(barFile).then(() => { + assert.deepEqual(backupService.getWorkspaceTextFilesWithBackupsSync(fooFile), [barFile.fsPath]); + backupService.deregisterResourceForBackup(barFile).then(() => { + assert.deepEqual(backupService.getWorkspaceTextFilesWithBackupsSync(fooFile), []); + done(); + }); + }); + }); + + test('getBackupResource should get the correct backup path for text files', () => { + // Format should be: /// + const workspaceResource = fooFile; + backupService.setCurrentWorkspace(workspaceResource); + const backupResource = barFile; + const workspaceHash = crypto.createHash('md5').update(workspaceResource.fsPath).digest('hex'); + const filePathHash = crypto.createHash('md5').update(backupResource.fsPath).digest('hex'); + const expectedPath = Uri.file(path.join(backupHome, workspaceHash, 'file', filePathHash)).fsPath; + assert.equal(backupService.getBackupResource(backupResource).fsPath, expectedPath); + }); + + test('getBackupResource should get the correct backup path for untitled files', () => { + // Format should be: /// + const workspaceResource = barFile; + backupService.setCurrentWorkspace(workspaceResource); + const backupResource = Uri.from({ scheme: 'untitled' }); + const workspaceHash = crypto.createHash('md5').update(workspaceResource.fsPath).digest('hex'); + const filePathHash = crypto.createHash('md5').update(backupResource.fsPath).digest('hex'); + const expectedPath = Uri.file(path.join(backupHome, workspaceHash, 'untitled', filePathHash)).fsPath; + assert.equal(backupService.getBackupResource(backupResource).fsPath, expectedPath); + }); + + test('getBackupResource should get the correct backup path for text files', () => { + // Format should be: /// + const workspaceResource = fooFile; + backupService.setCurrentWorkspace(workspaceResource); + const backupResource = barFile; + const workspaceHash = crypto.createHash('md5').update(workspaceResource.fsPath).digest('hex'); + const filePathHash = crypto.createHash('md5').update(backupResource.fsPath).digest('hex'); + const expectedPath = Uri.file(path.join(backupHome, workspaceHash, 'file', filePathHash)).fsPath; + assert.equal(backupService.getBackupResource(backupResource).fsPath, expectedPath); + }); + + test('getBackupResource should get the correct backup path for untitled files', () => { + // Format should be: /// + const workspaceResource = fooFile; + backupService.setCurrentWorkspace(workspaceResource); + const backupResource = Uri.from({ scheme: 'untitled' }); + const workspaceHash = crypto.createHash('md5').update(workspaceResource.fsPath).digest('hex'); + const filePathHash = crypto.createHash('md5').update(backupResource.fsPath).digest('hex'); + const expectedPath = Uri.file(path.join(backupHome, workspaceHash, 'untitled', filePathHash)).fsPath; + assert.equal(backupService.getBackupResource(backupResource).fsPath, expectedPath); + }); + + test('getWorkspaceTextFilesWithBackupsSync should return text file resources that have backups', done => { + const workspaceResource = fooFile; + backupService.setCurrentWorkspace(workspaceResource); + backupService.registerResourceForBackup(barFile).then(() => { + assert.deepEqual(backupService.getWorkspaceTextFilesWithBackupsSync(workspaceResource), [barFile.fsPath]); + backupService.registerResourceForBackup(bazFile).then(() => { + assert.deepEqual(backupService.getWorkspaceTextFilesWithBackupsSync(workspaceResource), [barFile.fsPath, bazFile.fsPath]); + done(); + }); + }); + }); + + test('getWorkspaceUntitledFileBackupsSync should return untitled file backup resources', done => { + const workspaceResource = fooFile; + backupService.setCurrentWorkspace(workspaceResource); + const workspaceHash = crypto.createHash('md5').update(workspaceResource.fsPath).digest('hex'); + const untitledBackupDir = path.join(backupHome, workspaceHash, 'untitled'); + const untitledBackup1 = path.join(untitledBackupDir, 'bar'); + const untitledBackup2 = path.join(untitledBackupDir, 'foo'); + pfs.mkdirp(untitledBackupDir).then(() => { + pfs.writeFile(untitledBackup1, 'test').then(() => { + assert.deepEqual(backupService.getWorkspaceUntitledFileBackupsSync(workspaceResource), [untitledBackup1]); + pfs.writeFile(untitledBackup2, 'test').then(() => { + assert.deepEqual(backupService.getWorkspaceUntitledFileBackupsSync(workspaceResource), [untitledBackup1, untitledBackup2]); + done(); + }); + }); + }); + }); +}); \ No newline at end of file diff --git a/src/vs/platform/environment/common/environment.ts b/src/vs/platform/environment/common/environment.ts index 23998095f4f87..7cc3288d9e068 100644 --- a/src/vs/platform/environment/common/environment.ts +++ b/src/vs/platform/environment/common/environment.ts @@ -23,6 +23,9 @@ export interface IEnvironmentService { appSettingsPath: string; appKeybindingsPath: string; + backupHome: string; + backupWorkspacesPath: string; + disableExtensions: boolean; extensionsPath: string; extensionDevelopmentPath: string; diff --git a/src/vs/platform/environment/node/environmentService.ts b/src/vs/platform/environment/node/environmentService.ts index 51ccedd29dac6..cd24af86de344 100644 --- a/src/vs/platform/environment/node/environmentService.ts +++ b/src/vs/platform/environment/node/environmentService.ts @@ -76,6 +76,12 @@ export class EnvironmentService implements IEnvironmentService { @memoize get appKeybindingsPath(): string { return path.join(this.appSettingsHome, 'keybindings.json'); } + @memoize + get backupHome(): string { return path.join(this.userDataPath, 'Backups'); } + + @memoize + get backupWorkspacesPath(): string { return path.join(this.backupHome, 'workspaces.json'); } + @memoize get extensionsPath(): string { return path.normalize(this._args.extensionHomePath || path.join(this.userHome, 'extensions')); } diff --git a/src/vs/platform/files/common/files.ts b/src/vs/platform/files/common/files.ts index dfa97b213611f..e9b05d227d32e 100644 --- a/src/vs/platform/files/common/files.ts +++ b/src/vs/platform/files/common/files.ts @@ -104,6 +104,27 @@ export interface IFileService { */ del(resource: URI, useTrash?: boolean): TPromise; + /** + * Backs up the provided file to a temporary directory to be used by the hot + * exit feature and crash recovery. + */ + backupFile(resource: URI, content: string): TPromise; + + /** + * Discard the backup for the resource specified. + */ + discardBackup(resource: URI): TPromise; + + /** + * Discards all backups associated with this session. + */ + discardBackups(): TPromise; + + /** + * Whether hot exit is enabled. + */ + isHotExitEnabled(): boolean; + /** * Imports the file to the parent identified by the resource. */ @@ -475,6 +496,7 @@ export interface IFilesConfiguration { autoSave: string; autoSaveDelay: number; eol: string; + hotExit: boolean; }; } diff --git a/src/vs/test/utils/servicesTestUtils.ts b/src/vs/test/utils/servicesTestUtils.ts index e1c5b5e187cd9..71e3fcdbf4954 100644 --- a/src/vs/test/utils/servicesTestUtils.ts +++ b/src/vs/test/utils/servicesTestUtils.ts @@ -17,6 +17,7 @@ import { Storage, InMemoryLocalStorage } from 'vs/workbench/common/storage'; import { IEditorGroup, ConfirmResult } from 'vs/workbench/common/editor'; import Event, { Emitter } from 'vs/base/common/event'; import Severity from 'vs/base/common/severity'; +import {IBackupService} from 'vs/platform/backup/common/backup'; import { IConfigurationService, getConfigurationValue, IConfigurationValue } from 'vs/platform/configuration/common/configuration'; import { IStorageService, StorageScope } from 'vs/platform/storage/common/storage'; import { IQuickOpenService } from 'vs/workbench/services/quickopen/common/quickOpenService'; @@ -110,9 +111,10 @@ export class TestTextFileService extends TextFileService { @IEditorGroupService editorGroupService: IEditorGroupService, @IFileService fileService: IFileService, @IUntitledEditorService untitledEditorService: IUntitledEditorService, - @IInstantiationService instantiationService: IInstantiationService + @IInstantiationService instantiationService: IInstantiationService, + @IBackupService backupService: IBackupService ) { - super(lifecycleService, contextService, configurationService, telemetryService, editorGroupService, editorService, fileService, untitledEditorService, instantiationService); + super(lifecycleService, contextService, configurationService, telemetryService, editorGroupService, editorService, fileService, untitledEditorService, instantiationService, backupService); } public setPromptPath(path: string): void { @@ -173,6 +175,7 @@ export function workbenchInstantiationService(): IInstantiationService { instantiationService.stub(IHistoryService, 'getHistory', []); instantiationService.stub(IModelService, createMockModelService(instantiationService)); instantiationService.stub(IFileService, TestFileService); + instantiationService.stub(IBackupService, new TestBackupService()); instantiationService.stub(ITelemetryService, NullTelemetryService); instantiationService.stub(IMessageService, new TestMessageService()); instantiationService.stub(IUntitledEditorService, instantiationService.createInstance(UntitledEditorService)); @@ -569,6 +572,68 @@ export const TestFileService = { name: paths.basename(res.fsPath) }; }); + }, + + backupFile: function (resource: URI, content: string) { + return TPromise.as(void 0); + }, + + discardBackup: function (resource: URI) { + return TPromise.as(void 0); + }, + + discardBackups: function () { + return TPromise.as(void 0); + }, + + isHotExitEnabled: function () { + return false; + } +}; + +export class TestBackupService implements IBackupService { + public _serviceBrand: any; + + // Lists used for verification in tests + public registeredResources: URI[] = []; + public deregisteredResources: URI[] = []; + + public getWorkspaceBackupPaths(): TPromise { + return TPromise.as([]); + } + + public getWorkspaceBackupPathsSync(): string[] { + return []; + } + + public pushWorkspaceBackupPathsSync(workspaces: URI[]): void { + return null; + } + + public removeWorkspaceBackupPath(workspace: URI): TPromise { + return TPromise.as(void 0); + } + + public getWorkspaceTextFilesWithBackupsSync(workspace: URI): string[] { + return []; + } + + public getWorkspaceUntitledFileBackupsSync(workspace: URI): string[] { + return []; + } + + public registerResourceForBackup(resource: URI): TPromise { + this.registeredResources.push(resource); + return TPromise.as(void 0); + } + + public deregisterResourceForBackup(resource: URI): TPromise { + this.deregisteredResources.push(resource); + return TPromise.as(void 0); + } + + public getBackupResource(resource: URI): URI { + return null; } }; diff --git a/src/vs/workbench/common/editor.ts b/src/vs/workbench/common/editor.ts index bea6ac51ac4e5..a5fef85c2f62b 100644 --- a/src/vs/workbench/common/editor.ts +++ b/src/vs/workbench/common/editor.ts @@ -319,6 +319,11 @@ export interface IFileEditorInput extends IEditorInput, IEncodingSupport { */ setResource(resource: URI): void; + /** + * Sets whether to restore the resource from backup. + */ + setRestoreFromBackup(restore: boolean): void; + /** * Sets the preferred encodingt to use for this input. */ diff --git a/src/vs/workbench/common/editor/untitledEditorInput.ts b/src/vs/workbench/common/editor/untitledEditorInput.ts index 7761d5135a509..32dd24729e655 100644 --- a/src/vs/workbench/common/editor/untitledEditorInput.ts +++ b/src/vs/workbench/common/editor/untitledEditorInput.ts @@ -28,6 +28,7 @@ export class UntitledEditorInput extends AbstractUntitledEditorInput { public static SCHEMA: string = 'untitled'; private resource: URI; + private restoreResource: URI; private hasAssociatedFilePath: boolean; private modeId: string; private cachedModel: UntitledEditorModel; @@ -46,7 +47,6 @@ export class UntitledEditorInput extends AbstractUntitledEditorInput { @ITextFileService private textFileService: ITextFileService ) { super(); - this.resource = resource; this.hasAssociatedFilePath = hasAssociatedFilePath; this.modeId = modeId; @@ -66,6 +66,10 @@ export class UntitledEditorInput extends AbstractUntitledEditorInput { return this.resource; } + public setRestoreResource(resource: URI): void { + this.restoreResource = resource; + } + public getName(): string { return this.hasAssociatedFilePath ? paths.basename(this.resource.fsPath) : this.resource.fsPath; } @@ -130,17 +134,25 @@ export class UntitledEditorInput extends AbstractUntitledEditorInput { return TPromise.as(this.cachedModel); } - // Otherwise Create Model and load - const model = this.createModel(); - return model.load().then((resolvedModel: UntitledEditorModel) => { - this.cachedModel = resolvedModel; + // Otherwise Create Model and load, restoring from backup if necessary + let restorePromise: TPromise; + if (this.restoreResource) { + restorePromise = this.textFileService.resolveTextContent(this.restoreResource).then(rawTextContent => rawTextContent.value.lines.join('\n')); + } else { + restorePromise = TPromise.as(''); + } + + return restorePromise.then(content => { + const model = this.createModel(content); + return model.load().then((resolvedModel: UntitledEditorModel) => { + this.cachedModel = resolvedModel; - return this.cachedModel; + return this.cachedModel; + }); }); } - private createModel(): UntitledEditorModel { - const content = ''; + private createModel(content: string): UntitledEditorModel { const model = this.instantiationService.createInstance(UntitledEditorModel, content, this.modeId, this.resource, this.hasAssociatedFilePath); // re-emit some events from the model diff --git a/src/vs/workbench/common/editor/untitledEditorModel.ts b/src/vs/workbench/common/editor/untitledEditorModel.ts index ee8d12038666e..d524c67f4a320 100644 --- a/src/vs/workbench/common/editor/untitledEditorModel.ts +++ b/src/vs/workbench/common/editor/untitledEditorModel.ts @@ -11,7 +11,7 @@ import { StringEditorModel } from 'vs/workbench/common/editor/stringEditorModel' import URI from 'vs/base/common/uri'; import { PLAINTEXT_MODE_ID } from 'vs/editor/common/modes/modesRegistry'; import { EndOfLinePreference } from 'vs/editor/common/editorCommon'; -import { IFilesConfiguration } from 'vs/platform/files/common/files'; +import { IFileService, IFilesConfiguration } from 'vs/platform/files/common/files'; import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; import { IModeService } from 'vs/editor/common/services/modeService'; import { IModelService } from 'vs/editor/common/services/modelService'; @@ -31,6 +31,8 @@ export class UntitledEditorModel extends StringEditorModel implements IEncodingS private hasAssociatedFilePath: boolean; + private backupPromises: TPromise[]; + constructor( value: string, modeId: string, @@ -38,16 +40,19 @@ export class UntitledEditorModel extends StringEditorModel implements IEncodingS hasAssociatedFilePath: boolean, @IModeService modeService: IModeService, @IModelService modelService: IModelService, + @IFileService private fileService: IFileService, @IConfigurationService private configurationService: IConfigurationService ) { super(value, modeId, resource, modeService, modelService); this.hasAssociatedFilePath = hasAssociatedFilePath; - this.dirty = hasAssociatedFilePath; // untitled associated to file path are dirty right away + this.dirty = hasAssociatedFilePath || value !== ''; // untitled associated to file path are dirty right away this._onDidChangeDirty = new Emitter(); this._onDidChangeEncoding = new Emitter(); + this.backupPromises = []; + this.registerListeners(); } @@ -111,6 +116,10 @@ export class UntitledEditorModel extends StringEditorModel implements IEncodingS return this.dirty; } + public getResource(): URI { + return this.resource; + } + public revert(): void { this.dirty = false; @@ -153,6 +162,15 @@ export class UntitledEditorModel extends StringEditorModel implements IEncodingS else if (!this.dirty) { this.dirty = true; this._onDidChangeDirty.fire(); + + } + + if (this.fileService.isHotExitEnabled()) { + if (this.dirty) { + this.doBackup(); + } else { + this.fileService.discardBackup(this.resource); + } } } @@ -171,5 +189,36 @@ export class UntitledEditorModel extends StringEditorModel implements IEncodingS this._onDidChangeDirty.dispose(); this._onDidChangeEncoding.dispose(); + + this.cancelBackupPromises(); + this.fileService.discardBackup(this.resource); + } + + public backup(): TPromise { + return this.doBackup(true); + } + + private doBackup(immediate?: boolean): TPromise { + // Cancel any currently running backups to make this the one that succeeds + this.cancelBackupPromises(); + + if (immediate) { + return this.fileService.backupFile(this.resource, this.getValue()).then(f => void 0); + } + + // Create new backup promise and keep it + const promise = TPromise.timeout(1000).then(() => { + this.fileService.backupFile(this.resource, this.getValue()); // Very important here to not return the promise because if the timeout promise is canceled it will bubble up the error otherwise - do not change + }); + + this.backupPromises.push(promise); + + return promise; + } + + private cancelBackupPromises(): void { + while (this.backupPromises.length) { + this.backupPromises.pop().cancel(); + } } } \ No newline at end of file diff --git a/src/vs/workbench/common/options.ts b/src/vs/workbench/common/options.ts index a78e4ccd992e9..ce341d575d361 100644 --- a/src/vs/workbench/common/options.ts +++ b/src/vs/workbench/common/options.ts @@ -22,4 +22,7 @@ export interface IOptions { * Instructs the workbench to open a diff of the provided files right after startup. */ filesToDiff?: IResourceInput[]; + + filesToRestore?: IResourceInput[]; + untitledFilesToRestore?: IResourceInput[]; } \ No newline at end of file diff --git a/src/vs/workbench/electron-browser/shell.ts b/src/vs/workbench/electron-browser/shell.ts index d845f24475c7a..12bc0e6b256c9 100644 --- a/src/vs/workbench/electron-browser/shell.ts +++ b/src/vs/workbench/electron-browser/shell.ts @@ -20,6 +20,8 @@ import product from 'vs/platform/product'; import pkg from 'vs/platform/package'; import { ContextViewService } from 'vs/platform/contextview/browser/contextViewService'; import timer = require('vs/base/common/timer'); +import { BackupService } from 'vs/platform/backup/node/backupService'; +import { IBackupService } from 'vs/platform/backup/common/backup'; import { Workbench } from 'vs/workbench/electron-browser/workbench'; import { Storage, inMemoryLocalStorageInstance } from 'vs/workbench/common/storage'; import { ITelemetryService, NullTelemetryService, loadExperiments } from 'vs/platform/telemetry/common/telemetry'; @@ -243,6 +245,11 @@ export class WorkbenchShell { }); }, errors.onUnexpectedError); + // Backup + const backupService = instantiationService.createInstance(BackupService); + backupService.setCurrentWorkspace(this.contextService.getWorkspace() ? this.contextService.getWorkspace().resource : null); + serviceCollection.set(IBackupService, backupService); + // Storage const disableWorkspaceStorage = this.environmentService.extensionTestsPath || (!this.workspace && !this.environmentService.extensionDevelopmentPath); // without workspace or in any extension test, we use inMemory storage unless we develop an extension where we want to preserve state this.storageService = instantiationService.createInstance(Storage, window.localStorage, disableWorkspaceStorage ? inMemoryLocalStorageInstance : window.localStorage); diff --git a/src/vs/workbench/electron-browser/workbench.ts b/src/vs/workbench/electron-browser/workbench.ts index 9c314525c0b49..8438c95d91302 100644 --- a/src/vs/workbench/electron-browser/workbench.ts +++ b/src/vs/workbench/electron-browser/workbench.ts @@ -16,6 +16,8 @@ import { Delayer } from 'vs/base/common/async'; import assert = require('vs/base/common/assert'); import timer = require('vs/base/common/timer'); import errors = require('vs/base/common/errors'); +import Uri from 'vs/base/common/uri'; +import { IBackupService } from 'vs/platform/backup/common/backup'; import { toErrorMessage } from 'vs/base/common/errorMessage'; import { Registry } from 'vs/platform/platform'; import { isWindows, isLinux } from 'vs/base/common/platform'; @@ -160,7 +162,8 @@ export class Workbench implements IPartService { @IMessageService private messageService: IMessageService, @IConfigurationService private configurationService: IConfigurationService, @ITelemetryService private telemetryService: ITelemetryService, - @IEnvironmentService private environmentService: IEnvironmentService + @IEnvironmentService private environmentService: IEnvironmentService, + @IBackupService private backupService: IBackupService ) { this.container = container; @@ -170,7 +173,22 @@ export class Workbench implements IPartService { serviceCollection }; - this.hasFilesToCreateOpenOrDiff = (options.filesToCreate && options.filesToCreate.length > 0) || (options.filesToOpen && options.filesToOpen.length > 0) || (options.filesToDiff && options.filesToDiff.length > 0); + // Restore any backups if they exist for this workspace (empty workspaces are not supported yet) + if (workspace) { + options.filesToRestore = this.backupService.getWorkspaceTextFilesWithBackupsSync(workspace.resource).map(filePath => { + return { resource: Uri.file(filePath), options: { pinned: true } }; + }); + options.untitledFilesToRestore = this.backupService.getWorkspaceUntitledFileBackupsSync(workspace.resource).map(untitledFilePath => { + return { resource: Uri.file(untitledFilePath), options: { pinned: true } }; + }); + } + + this.hasFilesToCreateOpenOrDiff = + (options.filesToCreate && options.filesToCreate.length > 0) || + (options.filesToOpen && options.filesToOpen.length > 0) || + (options.filesToDiff && options.filesToDiff.length > 0) || + (options.filesToRestore && options.filesToRestore.length > 0) || + (options.untitledFilesToRestore && options.untitledFilesToRestore.length > 0); this.toDispose = []; this.toShutdown = []; @@ -297,6 +315,8 @@ export class Workbench implements IPartService { const wbopt = this.workbenchParams.options; const filesToCreate = wbopt.filesToCreate || []; const filesToOpen = wbopt.filesToOpen || []; + const filesToRestore = wbopt.filesToRestore || []; + const untitledFilesToRestore = wbopt.untitledFilesToRestore || []; const filesToDiff = wbopt.filesToDiff; // Files to diff is exclusive @@ -315,10 +335,17 @@ export class Workbench implements IPartService { inputs.push(...filesToCreate.map(resourceInput => this.untitledEditorService.createOrGet(resourceInput.resource))); options.push(...filesToCreate.map(r => null)); // fill empty options for files to create because we dont have options there + // Files to restore + inputs.push(...untitledFilesToRestore.map(resourceInput => this.untitledEditorService.createOrGet(null, null, resourceInput.resource))); + options.push(...untitledFilesToRestore.map(r => null)); // fill empty options for files to create because we dont have options there + // Files to open - return TPromise.join(filesToOpen.map(resourceInput => this.editorService.createInput(resourceInput))).then((inputsToOpen) => { + let filesToOpenInputPromise = filesToOpen.map(resourceInput => this.editorService.createInput(resourceInput)); + let filesToRestoreInputPromise = filesToRestore.map(resourceInput => this.editorService.createInput(resourceInput, true)); + + return TPromise.join(filesToOpenInputPromise.concat(filesToRestoreInputPromise)).then((inputsToOpen) => { inputs.push(...inputsToOpen); - options.push(...filesToOpen.map(resourceInput => TextEditorOptions.from(resourceInput))); + options.push(...filesToOpen.concat(filesToRestore).map(resourceInput => TextEditorOptions.from(resourceInput))); return inputs.map((input, index) => { return { input, options: options[index] }; }); }); diff --git a/src/vs/workbench/parts/files/browser/files.contribution.ts b/src/vs/workbench/parts/files/browser/files.contribution.ts index b750e467a745a..ffedf8484988d 100644 --- a/src/vs/workbench/parts/files/browser/files.contribution.ts +++ b/src/vs/workbench/parts/files/browser/files.contribution.ts @@ -213,6 +213,12 @@ configurationRegistry.registerConfiguration({ 'default': (platform.isLinux || platform.isMacintosh) ? { '**/.git/objects/**': true, '**/node_modules/**': true } : { '**/.git/objects/**': true }, 'description': nls.localize('watcherExclude', "Configure glob patterns of file paths to exclude from file watching. Changing this setting requires a restart. When you experience Code consuming lots of cpu time on startup, you can exclude large folders to reduce the initial load.") }, + 'files.hotExit': { + 'type': 'boolean', + // TODO: Switch to true once sufficiently stable + 'default': false, + 'description': nls.localize('hotExit', "Controls whether unsaved files are restored after relaunching. If this is enabled there will be no prompt to save when exiting the editor.") + }, 'editor.formatOnSave': { 'type': 'boolean', 'default': false, diff --git a/src/vs/workbench/parts/files/common/editors/fileEditorInput.ts b/src/vs/workbench/parts/files/common/editors/fileEditorInput.ts index 4a3c9782aec8d..36dc6c1ef6153 100644 --- a/src/vs/workbench/parts/files/common/editors/fileEditorInput.ts +++ b/src/vs/workbench/parts/files/common/editors/fileEditorInput.ts @@ -16,6 +16,7 @@ import { ITextFileService, AutoSaveMode, ModelState, TextFileModelChangeEvent, L import { IWorkspaceContextService } from 'vs/platform/workspace/common/workspace'; import { IEventService } from 'vs/platform/event/common/event'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; +import { IBackupService } from 'vs/platform/backup/common/backup'; import { IDisposable, dispose } from 'vs/base/common/lifecycle'; import { IHistoryService } from 'vs/workbench/services/history/common/history'; @@ -26,6 +27,7 @@ export class FileEditorInput extends CommonFileEditorInput { private resource: URI; private preferredEncoding: string; private forceOpenAsBinary: boolean; + private restoreFromBackup: boolean; private name: string; private description: string; @@ -43,7 +45,8 @@ export class FileEditorInput extends CommonFileEditorInput { @IWorkspaceContextService private contextService: IWorkspaceContextService, @IHistoryService private historyService: IHistoryService, @IEventService private eventService: IEventService, - @ITextFileService private textFileService: ITextFileService + @ITextFileService private textFileService: ITextFileService, + @IBackupService private backupService: IBackupService ) { super(); @@ -102,6 +105,10 @@ export class FileEditorInput extends CommonFileEditorInput { this.verboseDescription = null; } + public setRestoreFromBackup(restore: boolean): void { + this.restoreFromBackup = restore; + } + public getResource(): URI { return this.resource; } @@ -195,7 +202,8 @@ export class FileEditorInput extends CommonFileEditorInput { } public resolve(refresh?: boolean): TPromise { - return this.textFileService.models.loadOrCreate(this.resource, this.preferredEncoding, refresh).then(null, error => { + const backupResource = this.restoreFromBackup ? this.backupService.getBackupResource(this.resource) : null; + return this.textFileService.models.loadOrCreate(this.resource, this.preferredEncoding, refresh, backupResource).then(null, error => { // In case of an error that indicates that the file is binary or too large, just return with the binary editor model if ((error).fileOperationResult === FileOperationResult.FILE_IS_BINARY || (error).fileOperationResult === FileOperationResult.FILE_TOO_LARGE) { diff --git a/src/vs/workbench/parts/files/common/files.ts b/src/vs/workbench/parts/files/common/files.ts index 4d4e2a57d1940..6daf31d6b2aad 100644 --- a/src/vs/workbench/parts/files/common/files.ts +++ b/src/vs/workbench/parts/files/common/files.ts @@ -42,6 +42,8 @@ export abstract class FileEditorInput extends EditorInput implements IFileEditor public abstract setResource(resource: URI): void; + public abstract setRestoreFromBackup(restore: boolean): void; + public abstract getResource(): URI; public abstract setPreferredEncoding(encoding: string): void; diff --git a/src/vs/workbench/services/configuration/test/node/configurationEditingService.test.ts b/src/vs/workbench/services/configuration/test/node/configurationEditingService.test.ts index 219ef322dd3ca..5ef7628a4cbc5 100644 --- a/src/vs/workbench/services/configuration/test/node/configurationEditingService.test.ts +++ b/src/vs/workbench/services/configuration/test/node/configurationEditingService.test.ts @@ -33,6 +33,7 @@ import { IConfigurationService } from 'vs/platform/configuration/common/configur import { IUntitledEditorService } from 'vs/workbench/services/untitled/common/untitledEditorService'; import { ILifecycleService } from 'vs/platform/lifecycle/common/lifecycle'; import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry'; +import { IBackupService } from 'vs/platform/backup/common/backup'; class SettingsTestEnvironmentService extends EnvironmentService { @@ -55,9 +56,10 @@ class TestDirtyTextFileService extends TestTextFileService { @IEditorGroupService editorGroupService: IEditorGroupService, @IFileService fileService: IFileService, @IUntitledEditorService untitledEditorService: IUntitledEditorService, - @IInstantiationService instantiationService: IInstantiationService + @IInstantiationService instantiationService: IInstantiationService, + @IBackupService backupService: IBackupService ) { - super(lifecycleService, contextService, configurationService, telemetryService, editorService, editorGroupService, fileService, untitledEditorService, instantiationService); + super(lifecycleService, contextService, configurationService, telemetryService, editorService, editorGroupService, fileService, untitledEditorService, instantiationService, backupService); } public isDirty(resource?: URI): boolean { @@ -85,7 +87,7 @@ suite('WorkspaceConfigurationEditingService - Node', () => { const configurationService = new WorkspaceConfigurationService(workspaceContextService, new TestEventService(), environmentService); const textFileService = workbenchInstantiationService().createInstance(TestDirtyTextFileService, dirty); const events = new utils.TestEventService(); - const fileService = new FileService(noWorkspace ? null : workspaceDir, { disableWatcher: true }, events); + const fileService = new FileService(noWorkspace ? null : workspaceDir, { disableWatcher: true }, events, environmentService, configurationService, null); return configurationService.initialize().then(() => { return { diff --git a/src/vs/workbench/services/editor/browser/editorService.ts b/src/vs/workbench/services/editor/browser/editorService.ts index 589ec7f00d0f0..1b7831c99084b 100644 --- a/src/vs/workbench/services/editor/browser/editorService.ts +++ b/src/vs/workbench/services/editor/browser/editorService.ts @@ -208,8 +208,8 @@ export class WorkbenchEditorService implements IWorkbenchEditorService { } public createInput(input: EditorInput): TPromise; - public createInput(input: IResourceInput): TPromise; - public createInput(input: any): TPromise { + public createInput(input: IResourceInput, restoreFromBackup?: boolean): TPromise; + public createInput(input: any, restoreFromBackup?: boolean): TPromise { // Workbench Input Support if (input instanceof EditorInput) { @@ -263,7 +263,7 @@ export class WorkbenchEditorService implements IWorkbenchEditorService { // Base Text Editor Support for file resources else if (this.fileInputDescriptor && resourceInput.resource instanceof URI && resourceInput.resource.scheme === network.Schemas.file) { - return this.createFileInput(resourceInput.resource, resourceInput.encoding); + return this.createFileInput(resourceInput.resource, resourceInput.encoding, restoreFromBackup); } // Treat an URI as ResourceEditorInput @@ -277,10 +277,11 @@ export class WorkbenchEditorService implements IWorkbenchEditorService { return TPromise.as(null); } - private createFileInput(resource: URI, encoding?: string): TPromise { + private createFileInput(resource: URI, encoding?: string, restoreFromBackup?: boolean): TPromise { return this.instantiationService.createInstance(this.fileInputDescriptor).then((typedFileInput) => { typedFileInput.setResource(resource); typedFileInput.setPreferredEncoding(encoding); + typedFileInput.setRestoreFromBackup(restoreFromBackup); return typedFileInput; }); diff --git a/src/vs/workbench/services/files/electron-browser/fileService.ts b/src/vs/workbench/services/files/electron-browser/fileService.ts index 36e5686163642..e9d2e319a03cc 100644 --- a/src/vs/workbench/services/files/electron-browser/fileService.ts +++ b/src/vs/workbench/services/files/electron-browser/fileService.ts @@ -26,6 +26,7 @@ import { IEnvironmentService } from 'vs/platform/environment/common/environment' import { IEditorGroupService } from 'vs/workbench/services/group/common/groupService'; import { ILifecycleService } from 'vs/platform/lifecycle/common/lifecycle'; import { IStorageService, StorageScope } from 'vs/platform/storage/common/storage'; +import { IBackupService } from 'vs/platform/backup/common/backup'; import { shell } from 'electron'; @@ -47,11 +48,12 @@ export class FileService implements IFileService { @IEventService private eventService: IEventService, @IWorkspaceContextService private contextService: IWorkspaceContextService, @IWorkbenchEditorService private editorService: IWorkbenchEditorService, - @IEnvironmentService environmentService: IEnvironmentService, + @IEnvironmentService private environmentService: IEnvironmentService, @IEditorGroupService private editorGroupService: IEditorGroupService, @ILifecycleService private lifecycleService: ILifecycleService, @IMessageService private messageService: IMessageService, - @IStorageService private storageService: IStorageService + @IStorageService private storageService: IStorageService, + @IBackupService private backupService: IBackupService ) { this.toUnbind = []; this.activeOutOfWorkspaceWatchers = Object.create(null); @@ -81,7 +83,7 @@ export class FileService implements IFileService { // create service const workspace = this.contextService.getWorkspace(); - this.raw = new NodeFileService(workspace ? workspace.resource.fsPath : void 0, fileServiceConfig, this.eventService); + this.raw = new NodeFileService(workspace ? workspace.resource.fsPath : void 0, fileServiceConfig, this.eventService, this.environmentService, this.configurationService, this.backupService); // Listeners this.registerListeners(); @@ -241,6 +243,22 @@ export class FileService implements IFileService { return this.raw.del(resource); } + public backupFile(resource: uri, content: string): TPromise { + return this.raw.backupFile(resource, content); + } + + public discardBackup(resource: uri): TPromise { + return this.raw.discardBackup(resource); + } + + public discardBackups(): TPromise { + return this.raw.discardBackups(); + } + + public isHotExitEnabled(): boolean { + return this.raw.isHotExitEnabled(); + } + private doMoveItemToTrash(resource: uri): TPromise { const workspace = this.contextService.getWorkspace(); if (!workspace) { diff --git a/src/vs/workbench/services/files/node/fileService.ts b/src/vs/workbench/services/files/node/fileService.ts index 738ce04a72c59..4c0e8a68fa6fe 100644 --- a/src/vs/workbench/services/files/node/fileService.ts +++ b/src/vs/workbench/services/files/node/fileService.ts @@ -28,10 +28,15 @@ import pfs = require('vs/base/node/pfs'); import encoding = require('vs/base/node/encoding'); import mime = require('vs/base/node/mime'); import flow = require('vs/base/node/flow'); +import { IDisposable, dispose } from 'vs/base/common/lifecycle'; import { FileWatcher as UnixWatcherService } from 'vs/workbench/services/files/node/watcher/unix/watcherService'; import { FileWatcher as WindowsWatcherService } from 'vs/workbench/services/files/node/watcher/win32/watcherService'; import { toFileChangesEvent, normalize, IRawFileChange } from 'vs/workbench/services/files/node/watcher/common'; import { IEventService } from 'vs/platform/event/common/event'; +import { IBackupService } from 'vs/platform/backup/common/backup'; +import { IEnvironmentService } from 'vs/platform/environment/common/environment'; +import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; +import { IFilesConfiguration } from 'vs/platform/files/common/files'; export interface IEncodingOverride { resource: uri; @@ -72,6 +77,7 @@ export class FileService implements IFileService { private static FS_EVENT_DELAY = 50; // aggregate and only emit events when changes have stopped for this duration (in ms) private static MAX_DEGREE_OF_PARALLEL_FS_OPS = 10; // degree of parallel fs calls that we accept at the same time + private toUnbind: IDisposable[]; private basePath: string; private tmpPath: string; private options: IFileServiceOptions; @@ -82,7 +88,16 @@ export class FileService implements IFileService { private fileChangesWatchDelayer: ThrottledDelayer; private undeliveredRawFileChangesEvents: IRawFileChange[]; - constructor(basePath: string, options: IFileServiceOptions, private eventEmitter: IEventService) { + private configuredHotExit: boolean; + + constructor( + basePath: string, + options: IFileServiceOptions, + private eventEmitter: IEventService, + private environmentService: IEnvironmentService, + private configurationService: IConfigurationService, + private backupService: IBackupService + ) { this.basePath = basePath ? paths.normalize(basePath) : void 0; if (this.basePath && this.basePath.indexOf('\\\\') === 0 && strings.endsWith(this.basePath, paths.sep)) { @@ -115,6 +130,19 @@ export class FileService implements IFileService { this.activeFileChangesWatchers = Object.create(null); this.fileChangesWatchDelayer = new ThrottledDelayer(FileService.FS_EVENT_DELAY); this.undeliveredRawFileChangesEvents = []; + + // Configuration changes + this.toUnbind = []; + if (this.configurationService) { + this.toUnbind.push(this.configurationService.onDidUpdateConfiguration(e => this.onConfigurationChange(e.config))); + + const configuration = this.configurationService.getConfiguration(); + this.onConfigurationChange(configuration); + } + } + + private onConfigurationChange(configuration: IFilesConfiguration): void { + this.configuredHotExit = configuration && configuration.files && configuration.files.hotExit; } public updateOptions(options: IFileServiceOptions): void { @@ -427,8 +455,76 @@ export class FileService implements IFileService { return nfcall(extfs.del, absolutePath, this.tmpPath); } + public backupFile(resource: uri, content: string): TPromise { + let registerResourcePromise: TPromise; + if (resource.scheme === 'file') { + registerResourcePromise = this.backupService.registerResourceForBackup(resource); + } else { + registerResourcePromise = TPromise.as(void 0); + } + return registerResourcePromise.then(() => { + const backupResource = this.getBackupPath(resource); + + // Hot exit is disabled for empty workspaces + if (!backupResource) { + return TPromise.as(null); + } + + return this.updateContent(backupResource, content); + }); + } + + public discardBackup(resource: uri): TPromise { + return this.backupService.deregisterResourceForBackup(resource).then(() => { + const backupResource = this.getBackupPath(resource); + + // Hot exit is disabled for empty workspaces + if (!backupResource) { + return TPromise.as(null); + } + + return this.del(backupResource); + }); + } + + public discardBackups(): TPromise { + // Hot exit is disabled for empty workspaces + const backupRootPath = this.getBackupRootPath(); + if (!backupRootPath) { + return TPromise.as(void 0); + } + + return this.del(uri.file(backupRootPath)); + } + + public isHotExitEnabled(): boolean { + return this.configuredHotExit; + } + // Helpers + private getBackupPath(resource: uri): uri { + // Hot exit is disabled for empty workspaces + const backupRootPath = this.getBackupRootPath(); + if (!backupRootPath) { + return null; + } + + const backupName = crypto.createHash('md5').update(resource.fsPath).digest('hex'); + const backupPath = paths.join(backupRootPath, resource.scheme, backupName); + return uri.file(backupPath); + } + + private getBackupRootPath(): string { + // Hot exit is disabled for empty workspaces + if (!this.basePath) { + return null; + } + + const workspaceHash = crypto.createHash('md5').update(this.basePath).digest('hex'); + return paths.join(this.environmentService.userDataPath, 'Backups', workspaceHash); + } + private toAbsolutePath(arg1: uri | IFileStat): string { let resource: uri; if (arg1 instanceof uri) { @@ -664,6 +760,8 @@ export class FileService implements IFileService { watcher.close(); } this.activeFileChangesWatchers = Object.create(null); + + this.toUnbind = dispose(this.toUnbind); } } diff --git a/src/vs/workbench/services/files/test/node/fileService.test.ts b/src/vs/workbench/services/files/test/node/fileService.test.ts index 69bc17f5f9056..262577c417c65 100644 --- a/src/vs/workbench/services/files/test/node/fileService.test.ts +++ b/src/vs/workbench/services/files/test/node/fileService.test.ts @@ -9,11 +9,14 @@ import fs = require('fs'); import path = require('path'); import os = require('os'); import assert = require('assert'); +import crypto = require('crypto'); import { TPromise } from 'vs/base/common/winjs.base'; import { FileService, IEncodingOverride } from 'vs/workbench/services/files/node/fileService'; import { EventType, FileChangesEvent, FileOperationResult, IFileOperationResult } from 'vs/platform/files/common/files'; +import { IWorkspaceContextService } from 'vs/platform/workspace/common/workspace'; import { nfcall } from 'vs/base/common/async'; +import { TestBackupService, TestEnvironmentService } from 'vs/test/utils/servicesTestUtils'; import uri from 'vs/base/common/uri'; import uuid = require('vs/base/common/uuid'); import extfs = require('vs/base/node/extfs'); @@ -33,7 +36,7 @@ suite('FileService', () => { extfs.copy(sourceDir, testDir, () => { events = new utils.TestEventService(); - service = new FileService(testDir, { disableWatcher: true }, events); + service = new FileService(testDir, { disableWatcher: true }, events, null, null, null); done(); }); }); @@ -275,6 +278,107 @@ suite('FileService', () => { }); }); + suite('backups', () => { + const environment = TestEnvironmentService; + const fooResource = uri.file('/foo'); + const barResource = uri.file('/bar'); + const untitledResource = uri.from({ scheme: 'untitled' }); + + let _service: FileService; + let backup: TestBackupService; + let workspaceHash; + let workspaceBackupRoot; + let fooBackupPath; + let barBackupPath; + let untitledBackupPath; + + setup((done) => { + extfs.del(TestEnvironmentService.backupHome, os.tmpdir(), done); + backup = new TestBackupService(); + _service = new FileService(testDir, { disableWatcher: true }, events, environment, null, backup); + workspaceHash = crypto.createHash('md5').update(testDir).digest('hex'); + workspaceBackupRoot = path.join(environment.backupHome, workspaceHash); + const fooFileHash = crypto.createHash('md5').update(fooResource.fsPath).digest('hex'); + const barFileHash = crypto.createHash('md5').update(barResource.fsPath).digest('hex'); + const untitledFileHash = crypto.createHash('md5').update(untitledResource.fsPath).digest('hex'); + fooBackupPath = path.join(workspaceBackupRoot, 'file', fooFileHash); + barBackupPath = path.join(workspaceBackupRoot, 'file', barFileHash); + untitledBackupPath = path.join(workspaceBackupRoot, 'untitled', untitledFileHash); + }); + + teardown((done) => { + extfs.del(TestEnvironmentService.backupHome, os.tmpdir(), done); + }); + + test('backupFile - text file', function (done: () => void) { + _service.backupFile(fooResource, 'test').then(() => { + assert.equal(fs.readdirSync(path.join(workspaceBackupRoot, 'file')).length, 1); + assert.equal(fs.existsSync(fooBackupPath), true); + assert.deepEqual(backup.registeredResources, [fooResource]); + assert.equal(fs.readFileSync(fooBackupPath), 'test'); + done(); + }); + }); + + test('backupFile - untitled file', function (done: () => void) { + _service.backupFile(untitledResource, 'test').then(() => { + assert.equal(fs.readdirSync(path.join(workspaceBackupRoot, 'untitled')).length, 1); + assert.equal(fs.existsSync(untitledBackupPath), true); + // Untitled files are not registered to workspaces.json as they do not have paths + assert.equal(fs.readFileSync(untitledBackupPath), 'test'); + done(); + }); + }); + + test('discardBackup - text file', function (done: () => void) { + _service.backupFile(fooResource, 'test').then(() => { + assert.equal(fs.readdirSync(path.join(workspaceBackupRoot, 'file')).length, 1); + _service.discardBackup(fooResource).then(() => { + assert.equal(fs.existsSync(fooBackupPath), false); + assert.equal(fs.readdirSync(path.join(workspaceBackupRoot, 'file')).length, 0); + done(); + }); + }); + }); + + test('discardBackup - untitled file', function (done: () => void) { + _service.backupFile(untitledResource, 'test').then(() => { + assert.equal(fs.readdirSync(path.join(workspaceBackupRoot, 'untitled')).length, 1); + _service.discardBackup(untitledResource).then(() => { + assert.equal(fs.existsSync(untitledBackupPath), false); + assert.equal(fs.readdirSync(path.join(workspaceBackupRoot, 'untitled')).length, 0); + done(); + }); + }); + }); + + test('discardBackups - text file', function (done: () => void) { + _service.backupFile(fooResource, 'test').then(() => { + assert.equal(fs.readdirSync(path.join(workspaceBackupRoot, 'file')).length, 1); + _service.backupFile(barResource, 'test').then(() => { + assert.equal(fs.readdirSync(path.join(workspaceBackupRoot, 'file')).length, 2); + _service.discardBackups().then(() => { + assert.equal(fs.existsSync(fooBackupPath), false); + assert.equal(fs.existsSync(barBackupPath), false); + assert.equal(fs.existsSync(path.join(workspaceBackupRoot, 'file')), false); + done(); + }); + }); + }); + }); + + test('discardBackups - untitled file', function (done: () => void) { + _service.backupFile(untitledResource, 'test').then(() => { + assert.equal(fs.readdirSync(path.join(workspaceBackupRoot, 'untitled')).length, 1); + _service.discardBackups().then(() => { + assert.equal(fs.existsSync(untitledBackupPath), false); + assert.equal(fs.existsSync(path.join(workspaceBackupRoot, 'untitled')), false); + done(); + }); + }); + }); + }); + test('resolveFile', function (done: () => void) { service.resolveFile(uri.file(testDir), { resolveTo: [uri.file(path.join(testDir, 'deep'))] }).done(r => { assert.equal(r.children.length, 6); @@ -494,7 +598,7 @@ suite('FileService', () => { encoding: 'windows1252', encodingOverride: encodingOverride, disableWatcher: true - }, null); + }, null, null, null, null); _service.resolveContent(uri.file(path.join(testDir, 'index.html'))).done(c => { assert.equal(c.encoding, 'windows1252'); @@ -520,7 +624,7 @@ suite('FileService', () => { let _service = new FileService(_testDir, { disableWatcher: true - }, null); + }, null, null, null, null); extfs.copy(_sourceDir, _testDir, () => { fs.readFile(resource.fsPath, (error, data) => { diff --git a/src/vs/workbench/services/textfile/browser/textFileService.ts b/src/vs/workbench/services/textfile/browser/textFileService.ts index f1737f8d6d698..5681d8b955563 100644 --- a/src/vs/workbench/services/textfile/browser/textFileService.ts +++ b/src/vs/workbench/services/textfile/browser/textFileService.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ 'use strict'; +import * as platform from 'vs/base/common/platform'; import { TPromise } from 'vs/base/common/winjs.base'; import URI from 'vs/base/common/uri'; import paths = require('vs/base/common/paths'); @@ -26,6 +27,7 @@ import { UntitledEditorModel } from 'vs/workbench/common/editor/untitledEditorMo import { BinaryEditorModel } from 'vs/workbench/common/editor/binaryEditorModel'; import { TextFileEditorModelManager } from 'vs/workbench/services/textfile/common/textFileEditorModelManager'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; +import { IBackupService } from 'vs/platform/backup/common/backup'; /** * The workbench file service implementation implements the raw file service spec and adds additional methods on top. @@ -42,6 +44,8 @@ export abstract class TextFileService implements ITextFileService { private _onFilesAssociationChange: Emitter; private currentFilesAssociationConfig: { [key: string]: string; }; + private configuredHotExit: boolean; + private _onAutoSaveConfigurationChange: Emitter; private configuredAutoSaveDelay: number; private configuredAutoSaveOnFocusChange: boolean; @@ -56,7 +60,8 @@ export abstract class TextFileService implements ITextFileService { @IWorkbenchEditorService private editorService: IWorkbenchEditorService, @IFileService protected fileService: IFileService, @IUntitledEditorService private untitledEditorService: IUntitledEditorService, - @IInstantiationService private instantiationService: IInstantiationService + @IInstantiationService private instantiationService: IInstantiationService, + @IBackupService private backupService: IBackupService ) { this.toUnbind = []; @@ -112,10 +117,33 @@ export abstract class TextFileService implements ITextFileService { } private beforeShutdown(): boolean | TPromise { + // If hot exit is enabled then save the dirty files in the workspace and then exit + // Hot exit is currently disabled for both empty workspaces (#13733) and on Mac (#13305) + if (this.configuredHotExit && this.contextService.getWorkspace() && !platform.isMacintosh) { + // If there are no dirty files, clean up and exit + if (this.getDirty().length === 0) { + return this.cleanupBackupsBeforeShutdown(); + } + + return this.backupService.getWorkspaceBackupPaths().then(workspaceBackupPaths => { + // Only remove the workspace from the backup service if it's not the last one or it's not dirty + if (workspaceBackupPaths.length > 1) { + return this.confirmBeforeShutdown(); + } + + // Backup and hot exit + return this.backupAll().then(result => { + if (result.results.some(r => !r.success)) { + return true; // veto if some backups failed + } + + return false; // the backup went smoothly, no veto + }); + }); + } // Dirty files need treatment on shutdown if (this.getDirty().length) { - // If auto save is enabled, save all files and then check again for dirty files if (this.getAutoSaveMode() !== AutoSaveMode.OFF) { return this.saveAll(false /* files only */).then(() => { @@ -123,7 +151,9 @@ export abstract class TextFileService implements ITextFileService { return this.confirmBeforeShutdown(); // we still have dirty files around, so confirm normally } - return false; // all good, no veto + return this.fileService.discardBackups().then(() => { + return false; // all good, no veto + }); }); } @@ -131,7 +161,9 @@ export abstract class TextFileService implements ITextFileService { return this.confirmBeforeShutdown(); } - return false; // no veto + return this.fileService.discardBackups().then(() => { + return false; // no veto + }); } private confirmBeforeShutdown(): boolean | TPromise { @@ -144,13 +176,13 @@ export abstract class TextFileService implements ITextFileService { return true; // veto if some saves failed } - return false; // no veto + return this.cleanupBackupsBeforeShutdown(); }); } // Don't Save else if (confirm === ConfirmResult.DONT_SAVE) { - return false; // no veto + return this.cleanupBackupsBeforeShutdown(); } // Cancel @@ -159,6 +191,18 @@ export abstract class TextFileService implements ITextFileService { } } + private cleanupBackupsBeforeShutdown(): boolean | TPromise { + const workspace = this.contextService.getWorkspace(); + if (!workspace) { + return false; // no backups to cleanup, no eto + } + return this.backupService.removeWorkspaceBackupPath(workspace.resource).then(() => { + return this.fileService.discardBackups().then(() => { + return false; // no veto + }); + }); + } + private onWindowFocusLost(): void { if (this.configuredAutoSaveOnWindowChange && this.isDirty()) { this.saveAll(void 0, SaveReason.WINDOW_CHANGE).done(null, errors.onUnexpectedError); @@ -209,6 +253,9 @@ export abstract class TextFileService implements ITextFileService { this.saveAll().done(null, errors.onUnexpectedError); } + // Hot exit is disabled for empty workspaces + this.configuredHotExit = this.contextService.getWorkspace() && configuration && configuration.files && configuration.files.hotExit; + // Check for change in files associations const filesAssociation = configuration && configuration.files && configuration.files.associations; if (!objects.equals(this.currentFilesAssociationConfig, filesAssociation)) { @@ -358,6 +405,66 @@ export abstract class TextFileService implements ITextFileService { }); } + /** + * Performs an immedate backup of all dirty file and untitled models. + */ + private backupAll(): TPromise { + const toBackup = this.getDirty(); + + // split up between files and untitled + const filesToBackup: URI[] = []; + const untitledToBackup: URI[] = []; + toBackup.forEach(s => { + if (s.scheme === 'file') { + filesToBackup.push(s); + } else if (s.scheme === 'untitled') { + untitledToBackup.push(s); + } + }); + + return this.doBackupAll(filesToBackup, untitledToBackup); + } + + private doBackupAll(fileResources: URI[], untitledResources: URI[]): TPromise { + // Handle file resources first + const dirtyFileModels = this.getDirtyFileModels(fileResources); + + const mapResourceToResult: { [resource: string]: IResult } = Object.create(null); + dirtyFileModels.forEach(m => { + mapResourceToResult[m.getResource().toString()] = { + source: m.getResource() + }; + }); + + return TPromise.join(dirtyFileModels.map(model => { + return model.backup().then(() => { + mapResourceToResult[model.getResource().toString()].success = true; + }); + })).then(results => { + // Handle untitled resources + const untitledModelPromises = untitledResources.map(untitledResource => this.untitledEditorService.get(untitledResource)) + .filter(untitled => !!untitled) + .map(untitled => untitled.resolve()); + + return TPromise.join(untitledModelPromises).then(untitledModels => { + const untitledBackupPromises = untitledModels.map(model => { + mapResourceToResult[model.getResource().toString()] = { + source: model.getResource(), + target: model.getResource() + }; + return model.backup().then(() => { + mapResourceToResult[model.getResource().toString()].success = true; + }); + }); + return TPromise.join(untitledBackupPromises).then(() => { + return { + results: Object.keys(mapResourceToResult).map(k => mapResourceToResult[k]) + }; + }); + }); + }); + } + private getFileModels(resources?: URI[]): ITextFileEditorModel[]; private getFileModels(resource?: URI): ITextFileEditorModel[]; private getFileModels(arg1?: any): ITextFileEditorModel[] { @@ -520,6 +627,14 @@ export abstract class TextFileService implements ITextFileService { }); } + public backup(resource: URI): void { + let model = this.getDirtyFileModels(resource); + if (!model || model.length === 0) { + return; + } + this.fileService.backupFile(resource, model[0].getValue()); + } + public getAutoSaveMode(): AutoSaveMode { if (this.configuredAutoSaveOnFocusChange) { return AutoSaveMode.ON_FOCUS_CHANGE; diff --git a/src/vs/workbench/services/textfile/common/textFileEditorModel.ts b/src/vs/workbench/services/textfile/common/textFileEditorModel.ts index 2a30db14498b3..e215ddea72d6f 100644 --- a/src/vs/workbench/services/textfile/common/textFileEditorModel.ts +++ b/src/vs/workbench/services/textfile/common/textFileEditorModel.ts @@ -40,6 +40,7 @@ export class TextFileEditorModel extends BaseTextEditorModel implements ITextFil private static saveParticipant: ISaveParticipant; private resource: URI; + private restoreResource: URI; private contentEncoding: string; // encoding as reported from disk private preferredEncoding: string; // encoding as chosen by the user private dirty: boolean; @@ -51,6 +52,7 @@ export class TextFileEditorModel extends BaseTextEditorModel implements ITextFil private autoSaveAfterMillies: number; private autoSaveAfterMilliesEnabled: boolean; private autoSavePromises: TPromise[]; + private backupPromises: TPromise[]; private mapPendingSaveToVersionId: { [versionId: string]: TPromise }; private disposed: boolean; private inConflictResolutionMode: boolean; @@ -82,6 +84,7 @@ export class TextFileEditorModel extends BaseTextEditorModel implements ITextFil this.preferredEncoding = preferredEncoding; this.dirty = false; this.autoSavePromises = []; + this.backupPromises = []; this.versionId = 0; this.lastSaveAttemptTime = 0; this.mapPendingSaveToVersionId = {}; @@ -180,6 +183,10 @@ export class TextFileEditorModel extends BaseTextEditorModel implements ITextFil }); } + public setRestoreResource(resource: URI): void { + this.restoreResource = resource; + } + public load(force?: boolean /* bypass any caches and really go to disk */): TPromise { diag('load() - enter', this.resource, new Date()); @@ -257,18 +264,35 @@ export class TextFileEditorModel extends BaseTextEditorModel implements ITextFil else { diag('load() - created text editor model', this.resource, new Date()); - this.createTextEditorModelPromise = this.createTextEditorModel(content.value, content.resource).then(() => { - this.createTextEditorModelPromise = null; + if (this.restoreResource) { + this.createTextEditorModelPromise = this.textFileService.resolveTextContent(this.restoreResource, { acceptTextOnly: true, etag: etag, encoding: this.preferredEncoding }).then((restoreContent) => { + return this.createTextEditorModel(restoreContent.value, content.resource).then(() => { + this.createTextEditorModelPromise = null; + + this.setDirty(true); + this.toDispose.push(this.textEditorModel.onDidChangeRawContent((e: IModelContentChangedEvent) => this.onModelContentChanged(e))); + + return this; + }, (error) => { + this.createTextEditorModelPromise = null; - this.setDirty(false); // Ensure we are not tracking a stale state - this.toDispose.push(this.textEditorModel.onDidChangeRawContent((e: IModelContentChangedEvent) => this.onModelContentChanged(e))); + return TPromise.wrapError(error); + }); + }); + } else { + this.createTextEditorModelPromise = this.createTextEditorModel(content.value, content.resource).then(() => { + this.createTextEditorModelPromise = null; + + this.setDirty(false); // Ensure we are not tracking a stale state + this.toDispose.push(this.textEditorModel.onDidChangeRawContent((e: IModelContentChangedEvent) => this.onModelContentChanged(e))); - return this; - }, (error) => { - this.createTextEditorModelPromise = null; + return this; + }, (error) => { + this.createTextEditorModelPromise = null; - return TPromise.wrapError(error); - }); + return TPromise.wrapError(error); + }); + } return this.createTextEditorModelPromise; } @@ -318,6 +342,10 @@ export class TextFileEditorModel extends BaseTextEditorModel implements ITextFil this._onDidStateChange.fire(StateChange.REVERTED); } + if (this.fileService.isHotExitEnabled()) { + this.fileService.discardBackup(this.resource); + } + return; } @@ -334,6 +362,10 @@ export class TextFileEditorModel extends BaseTextEditorModel implements ITextFil diag('makeDirty() - prevented save because we are in conflict resolution mode', this.resource, new Date()); } } + + if (this.fileService.isHotExitEnabled()) { + this.doBackup(); + } } private makeDirty(): void { @@ -374,6 +406,38 @@ export class TextFileEditorModel extends BaseTextEditorModel implements ITextFil } } + public backup(): TPromise { + if (!this.dirty) { + return TPromise.as(null); + } + + return this.doBackup(true); + } + + private doBackup(immediate?: boolean): TPromise { + // Cancel any currently running backups to make this the one that succeeds + this.cancelBackupPromises(); + + if (immediate) { + return this.fileService.backupFile(this.resource, this.getValue()).then(f => void 0); + } + + // Create new backup promise and keep it + const promise = TPromise.timeout(1000).then(() => { + this.fileService.backupFile(this.resource, this.getValue()); // Very important here to not return the promise because if the timeout promise is canceled it will bubble up the error otherwise - do not change + }); + + this.backupPromises.push(promise); + + return promise; + } + + private cancelBackupPromises(): void { + while (this.backupPromises.length) { + this.backupPromises.pop().cancel(); + } + } + /** * Saves the current versionId of this editor model if it is dirty. */ @@ -722,6 +786,9 @@ export class TextFileEditorModel extends BaseTextEditorModel implements ITextFil this.createTextEditorModelPromise = null; this.cancelAutoSavePromises(); + this.cancelBackupPromises(); + + this.fileService.discardBackup(this.resource); super.dispose(); } diff --git a/src/vs/workbench/services/textfile/common/textFileEditorModelManager.ts b/src/vs/workbench/services/textfile/common/textFileEditorModelManager.ts index 605772ca838bd..615570f4a2284 100644 --- a/src/vs/workbench/services/textfile/common/textFileEditorModelManager.ts +++ b/src/vs/workbench/services/textfile/common/textFileEditorModelManager.ts @@ -171,7 +171,7 @@ export class TextFileEditorModelManager implements ITextFileEditorModelManager { return this.mapResourceToModel[resource.toString()]; } - public loadOrCreate(resource: URI, encoding: string, refresh?: boolean): TPromise { + public loadOrCreate(resource: URI, encoding: string, refresh?: boolean, restoreResource?: URI): TPromise { // Return early if model is currently being loaded const pendingLoad = this.mapResourceToPendingModelLoaders[resource.toString()]; @@ -194,6 +194,9 @@ export class TextFileEditorModelManager implements ITextFileEditorModelManager { // Model does not exist else { model = this.instantiationService.createInstance(TextFileEditorModel, resource, encoding); + if (restoreResource) { + model.setRestoreResource(restoreResource); + } modelPromise = model.load(); // Install state change listener diff --git a/src/vs/workbench/services/textfile/common/textfiles.ts b/src/vs/workbench/services/textfile/common/textfiles.ts index 52389b16b68a6..a033f7813e743 100644 --- a/src/vs/workbench/services/textfile/common/textfiles.ts +++ b/src/vs/workbench/services/textfile/common/textfiles.ts @@ -192,7 +192,7 @@ export interface ITextFileEditorModelManager { getAll(resource?: URI): ITextFileEditorModel[]; - loadOrCreate(resource: URI, preferredEncoding: string, refresh?: boolean): TPromise; + loadOrCreate(resource: URI, preferredEncoding: string, refresh?: boolean, restoreResource?: URI): TPromise; } export interface IModelSaveOptions { @@ -217,6 +217,10 @@ export interface ITextFileEditorModel extends ITextEditorModel, IEncodingSupport save(options?: IModelSaveOptions): TPromise; + backup(): TPromise; + + setRestoreResource(resource: URI): void; + revert(): TPromise; setConflictResolutionMode(); @@ -318,6 +322,14 @@ export interface ITextFileService extends IDisposable { */ confirmSave(resources?: URI[]): ConfirmResult; + /** + * Backs up the provided file to a temporary directory to be used by the hot + * exit feature and crash recovery. + * + * @param resource The resource to backup. + */ + backup(resource: URI): void; + /** * Convinient fast access to the current auto save mode. */ diff --git a/src/vs/workbench/services/textfile/electron-browser/textFileService.ts b/src/vs/workbench/services/textfile/electron-browser/textFileService.ts index f71a2929e061d..7f5adb0660069 100644 --- a/src/vs/workbench/services/textfile/electron-browser/textFileService.ts +++ b/src/vs/workbench/services/textfile/electron-browser/textFileService.ts @@ -27,6 +27,7 @@ import { IEditorGroupService } from 'vs/workbench/services/group/common/groupSer import { IModelService } from 'vs/editor/common/services/modelService'; import { ModelBuilder } from 'vs/editor/node/model/modelBuilder'; import product from 'vs/platform/product'; +import { IBackupService } from 'vs/platform/backup/common/backup'; import { IEnvironmentService } from 'vs/platform/environment/common/environment'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; @@ -42,6 +43,7 @@ export class TextFileService extends AbstractTextFileService { @IInstantiationService instantiationService: IInstantiationService, @ITelemetryService telemetryService: ITelemetryService, @IConfigurationService configurationService: IConfigurationService, + @IBackupService backupService: IBackupService, @IModeService private modeService: IModeService, @IWorkbenchEditorService editorService: IWorkbenchEditorService, @IEditorGroupService editorGroupService: IEditorGroupService, @@ -49,7 +51,7 @@ export class TextFileService extends AbstractTextFileService { @IModelService private modelService: IModelService, @IEnvironmentService private environmentService: IEnvironmentService ) { - super(lifecycleService, contextService, configurationService, telemetryService, editorGroupService, editorService, fileService, untitledEditorService, instantiationService); + super(lifecycleService, contextService, configurationService, telemetryService, editorGroupService, editorService, fileService, untitledEditorService, instantiationService, backupService); } public resolveTextContent(resource: URI, options?: IResolveContentOptions): TPromise { diff --git a/src/vs/workbench/services/textfile/test/textFileService.test.ts b/src/vs/workbench/services/textfile/test/textFileService.test.ts index 3b1a12f2b0328..4fcada9b2ec0e 100644 --- a/src/vs/workbench/services/textfile/test/textFileService.test.ts +++ b/src/vs/workbench/services/textfile/test/textFileService.test.ts @@ -60,11 +60,14 @@ suite('Files - TextFileService', () => { accessor.untitledEditorService.revertAll(); }); - test('confirm onWillShutdown - no veto', function () { + test('confirm onWillShutdown - no veto', function (done) { const event = new ShutdownEventImpl(); accessor.lifecycleService.fireWillShutdown(event); - assert.ok(!event.value); + return (>event.value).then(veto => { + assert.ok(!veto); + done(); + }); }); test('confirm onWillShutdown - veto if user cancels', function (done) { @@ -97,9 +100,10 @@ suite('Files - TextFileService', () => { const event = new ShutdownEventImpl(); accessor.lifecycleService.fireWillShutdown(event); - assert.ok(!event.value); - - done(); + return (>event.value).then(veto => { + assert.ok(!veto); + done(); + }); }); }); diff --git a/src/vs/workbench/services/untitled/common/untitledEditorService.ts b/src/vs/workbench/services/untitled/common/untitledEditorService.ts index e064aa9da5f56..f240fcde1ecc6 100644 --- a/src/vs/workbench/services/untitled/common/untitledEditorService.ts +++ b/src/vs/workbench/services/untitled/common/untitledEditorService.ts @@ -58,7 +58,7 @@ export interface IUntitledEditorService { * It is valid to pass in a file resource. In that case the path will be used as identifier. * The use case is to be able to create a new file with a specific path with VSCode. */ - createOrGet(resource?: URI, modeId?: string): UntitledEditorInput; + createOrGet(resource?: URI, modeId?: string, restoreResource?: URI): UntitledEditorInput; /** * A check to find out if a untitled resource has a file path associated or not. @@ -76,7 +76,9 @@ export class UntitledEditorService implements IUntitledEditorService { private _onDidChangeDirty: Emitter; private _onDidChangeEncoding: Emitter; - constructor( @IInstantiationService private instantiationService: IInstantiationService) { + constructor( + @IInstantiationService private instantiationService: IInstantiationService + ) { this._onDidChangeDirty = new Emitter(); this._onDidChangeEncoding = new Emitter(); } @@ -130,7 +132,7 @@ export class UntitledEditorService implements IUntitledEditorService { .map((i) => i.getResource()); } - public createOrGet(resource?: URI, modeId?: string): UntitledEditorInput { + public createOrGet(resource?: URI, modeId?: string, restoreResource?: URI): UntitledEditorInput { let hasAssociatedFilePath = false; if (resource) { hasAssociatedFilePath = (resource.scheme === 'file'); @@ -147,10 +149,10 @@ export class UntitledEditorService implements IUntitledEditorService { } // Create new otherwise - return this.doCreate(resource, hasAssociatedFilePath, modeId); + return this.doCreate(resource, hasAssociatedFilePath, modeId, restoreResource); } - private doCreate(resource?: URI, hasAssociatedFilePath?: boolean, modeId?: string): UntitledEditorInput { + private doCreate(resource?: URI, hasAssociatedFilePath?: boolean, modeId?: string, restoreResource?: URI): UntitledEditorInput { if (!resource) { // Create new taking a resource URI that is not already taken @@ -162,6 +164,9 @@ export class UntitledEditorService implements IUntitledEditorService { } const input = this.instantiationService.createInstance(UntitledEditorInput, resource, hasAssociatedFilePath, modeId); + if (restoreResource) { + input.setRestoreResource(restoreResource); + } const dirtyListener = input.onDidChangeDirty(() => { this._onDidChangeDirty.fire(resource); diff --git a/src/vs/workbench/test/common/editor/editorStacksModel.test.ts b/src/vs/workbench/test/common/editor/editorStacksModel.test.ts index d0cc082ff8d2e..530e6e7f5a639 100644 --- a/src/vs/workbench/test/common/editor/editorStacksModel.test.ts +++ b/src/vs/workbench/test/common/editor/editorStacksModel.test.ts @@ -144,6 +144,9 @@ class TestFileEditorInput extends EditorInput implements IFileEditorInput { public setResource(r: URI): void { } + public setRestoreFromBackup(restore: boolean): void { + } + public setEncoding(encoding: string) { }