From 5af1f510ccfcb706b0e57ddfa8aaa86522e04f3c Mon Sep 17 00:00:00 2001 From: Martin Aeschlimann Date: Wed, 27 Feb 2019 14:41:54 +0100 Subject: [PATCH] save untitled workspace on renderer --- src/vs/code/electron-main/windows.ts | 113 +------------- src/vs/platform/windows/common/windows.ts | 2 +- .../windows/electron-main/windowsService.ts | 10 +- src/vs/platform/windows/node/windowsIpc.ts | 6 +- .../platform/workspaces/common/workspaces.ts | 4 +- .../electron-main/workspacesMainService.ts | 34 +--- .../platform/workspaces/node/workspacesIpc.ts | 8 + .../workspacesMainService.test.ts | 147 ++++++++---------- src/vs/workbench/browser/dnd.ts | 2 +- .../workspace/common/workspaceEditing.ts | 5 + .../workspace/node/workspaceEditingService.ts | 114 +++++++++++++- .../workbench/test/workbenchTestServices.ts | 2 +- 12 files changed, 208 insertions(+), 239 deletions(-) diff --git a/src/vs/code/electron-main/windows.ts b/src/vs/code/electron-main/windows.ts index 59bfccd1142..2e94e6cfaed 100644 --- a/src/vs/code/electron-main/windows.ts +++ b/src/vs/code/electron-main/windows.ts @@ -25,14 +25,14 @@ import product from 'vs/platform/product/node/product'; import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry'; import { IWindowsMainService, IOpenConfiguration, IWindowsCountChangedEvent, ICodeWindow, IWindowState as ISingleWindowState, WindowMode } from 'vs/platform/windows/electron-main/windows'; import { IHistoryMainService } from 'vs/platform/history/common/history'; -import { IProcessEnvironment, isLinux, isMacintosh, isWindows } from 'vs/base/common/platform'; +import { IProcessEnvironment, isMacintosh, isWindows } from 'vs/base/common/platform'; import { IWorkspacesMainService, IWorkspaceIdentifier, WORKSPACE_FILTER, ISingleFolderWorkspaceIdentifier, isSingleFolderWorkspaceIdentifier, hasWorkspaceFileExtension } from 'vs/platform/workspaces/common/workspaces'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; import { mnemonicButtonLabel } from 'vs/base/common/labels'; import { Schemas } from 'vs/base/common/network'; import { normalizeNFC } from 'vs/base/common/normalization'; import { URI } from 'vs/base/common/uri'; -import { Queue, timeout } from 'vs/base/common/async'; +import { Queue } from 'vs/base/common/async'; import { exists } from 'vs/base/node/pfs'; import { getComparisonKey, isEqual, normalizePath, basename as resourcesBasename, originalFSPath, hasTrailingPathSeparator, removeTrailingPathSeparator } from 'vs/base/common/resources'; import { getRemoteAuthority } from 'vs/platform/remote/common/remoteHosts'; @@ -197,7 +197,7 @@ export class WindowsManager implements IWindowsMainService { } this.dialogs = new Dialogs(environmentService, telemetryService, stateService, this); - this.workspacesManager = new WorkspacesManager(workspacesMainService, backupMainService, environmentService, historyMainService, this); + this.workspacesManager = new WorkspacesManager(workspacesMainService, backupMainService, this); } ready(initialUserEnv: IProcessEnvironment): void { @@ -1544,23 +1544,6 @@ export class WindowsManager implements IWindowsMainService { this.workspacesMainService.deleteUntitledWorkspaceSync(workspace); return; } - - if (windowClosing && !isMacintosh && this.getWindowCount() === 1) { - return; // Windows/Linux: quits when last window is closed, so do not ask then - } - - // Handle untitled workspaces with prompt as needed - e.veto(this.workspacesManager.promptToSaveUntitledWorkspace(this.getWindowById(e.window.id), workspace).then((veto): boolean | Promise => { - if (veto) { - return veto; - } - - // Bug in electron: somehow we need this timeout so that the window closes properly. That - // might be related to the fact that the untitled workspace prompt shows up async and this - // code can execute before the dialog is fully closed which then blocks the window from closing. - // Issue: https://github.com/Microsoft/vscode/issues/41989 - return timeout(0).then(() => veto); - })); } focusLastActive(cli: ParsedArgs, context: OpenContext): ICodeWindow { @@ -1994,8 +1977,6 @@ class WorkspacesManager { constructor( private readonly workspacesMainService: IWorkspacesMainService, private readonly backupMainService: IBackupMainService, - private readonly environmentService: IEnvironmentService, - private readonly historyMainService: IHistoryMainService, private readonly windowsMainService: IWindowsMainService, ) { } @@ -2079,92 +2060,4 @@ class WorkspacesManager { telemetryExtraData: options.telemetryExtraData }); } - - promptToSaveUntitledWorkspace(window: ICodeWindow | undefined, workspace: IWorkspaceIdentifier): Promise { - enum ConfirmResult { - SAVE, - DONT_SAVE, - CANCEL - } - - const save = { label: mnemonicButtonLabel(localize({ key: 'save', comment: ['&& denotes a mnemonic'] }, "&&Save")), result: ConfirmResult.SAVE }; - const dontSave = { label: mnemonicButtonLabel(localize({ key: 'doNotSave', comment: ['&& denotes a mnemonic'] }, "Do&&n't Save")), result: ConfirmResult.DONT_SAVE }; - const cancel = { label: localize('cancel', "Cancel"), result: ConfirmResult.CANCEL }; - - const buttons: { label: string; result: ConfirmResult; }[] = []; - if (isWindows) { - buttons.push(save, dontSave, cancel); - } else if (isLinux) { - buttons.push(dontSave, cancel, save); - } else { - buttons.push(save, cancel, dontSave); - } - - const options: Electron.MessageBoxOptions = { - title: this.environmentService.appNameLong, - message: localize('saveWorkspaceMessage', "Do you want to save your workspace configuration as a file?"), - detail: localize('saveWorkspaceDetail', "Save your workspace if you plan to open it again."), - noLink: true, - type: 'warning', - buttons: buttons.map(button => button.label), - cancelId: buttons.indexOf(cancel) - }; - - if (isLinux) { - options.defaultId = 2; - } - - return this.windowsMainService.showMessageBox(options, window).then(res => { - switch (buttons[res.button].result) { - - // Cancel: veto unload - case ConfirmResult.CANCEL: - return true; - - // Don't Save: delete workspace - case ConfirmResult.DONT_SAVE: - this.workspacesMainService.deleteUntitledWorkspaceSync(workspace); - return false; - - // Save: save workspace, but do not veto unload - case ConfirmResult.SAVE: { - return this.windowsMainService.showSaveDialog({ - buttonLabel: mnemonicButtonLabel(localize({ key: 'save', comment: ['&& denotes a mnemonic'] }, "&&Save")), - title: localize('saveWorkspace', "Save Workspace"), - filters: WORKSPACE_FILTER, - defaultPath: this.getUntitledWorkspaceSaveDialogDefaultPath(workspace) - }, window).then(target => { - if (target) { - return this.workspacesMainService.saveWorkspaceAs(workspace, target).then(savedWorkspace => { - this.historyMainService.addRecentlyOpened([savedWorkspace], []); - this.workspacesMainService.deleteUntitledWorkspaceSync(workspace); - return false; - }, () => false); - } - - return true; // keep veto if no target was provided - }); - } - } - }); - } - - private getUntitledWorkspaceSaveDialogDefaultPath(workspace?: IWorkspaceIdentifier | ISingleFolderWorkspaceIdentifier): string | undefined { - if (workspace) { - if (isSingleFolderWorkspaceIdentifier(workspace)) { - return workspace.scheme === Schemas.file ? dirname(workspace.fsPath) : undefined; - } - - const resolvedWorkspace = workspace.configPath.scheme === Schemas.file && this.workspacesMainService.resolveLocalWorkspaceSync(workspace.configPath); - if (resolvedWorkspace && resolvedWorkspace.folders.length > 0) { - for (const folder of resolvedWorkspace.folders) { - if (folder.uri.scheme === Schemas.file) { - return dirname(folder.uri.fsPath); - } - } - } - } - - return undefined; - } } diff --git a/src/vs/platform/windows/common/windows.ts b/src/vs/platform/windows/common/windows.ts index e28ca55477a..a39720ec134 100644 --- a/src/vs/platform/windows/common/windows.ts +++ b/src/vs/platform/windows/common/windows.ts @@ -117,7 +117,7 @@ export interface IWindowsService { enterWorkspace(windowId: number, path: URI): Promise; toggleFullScreen(windowId: number): Promise; setRepresentedFilename(windowId: number, fileName: string): Promise; - addRecentlyOpened(files: URI[]): Promise; + addRecentlyOpened(workspaces: URI[], folders: URI[], files: URI[]): Promise; removeFromRecentlyOpened(paths: Array): Promise; clearRecentlyOpened(): Promise; getRecentlyOpened(windowId: number): Promise; diff --git a/src/vs/platform/windows/electron-main/windowsService.ts b/src/vs/platform/windows/electron-main/windowsService.ts index 016c59a3f20..9223005e842 100644 --- a/src/vs/platform/windows/electron-main/windowsService.ts +++ b/src/vs/platform/windows/electron-main/windowsService.ts @@ -17,7 +17,7 @@ import { IURLService, IURLHandler } from 'vs/platform/url/common/url'; import { ILifecycleService } from 'vs/platform/lifecycle/electron-main/lifecycleMain'; import { IWindowsMainService, ISharedProcess, ICodeWindow } from 'vs/platform/windows/electron-main/windows'; import { IHistoryMainService, IRecentlyOpened } from 'vs/platform/history/common/history'; -import { IWorkspaceIdentifier, ISingleFolderWorkspaceIdentifier } from 'vs/platform/workspaces/common/workspaces'; +import { IWorkspaceIdentifier, ISingleFolderWorkspaceIdentifier, IWorkspacesMainService } from 'vs/platform/workspaces/common/workspaces'; import { ISerializableCommandAction } from 'vs/platform/actions/common/actions'; import { Schemas } from 'vs/base/common/network'; import { mnemonicButtonLabel } from 'vs/base/common/labels'; @@ -50,7 +50,8 @@ export class WindowsService implements IWindowsService, IURLHandler, IDisposable @IURLService urlService: IURLService, @ILifecycleService private readonly lifecycleService: ILifecycleService, @IHistoryMainService private readonly historyService: IHistoryMainService, - @ILogService private readonly logService: ILogService + @ILogService private readonly logService: ILogService, + @IWorkspacesMainService private readonly workspacesMainService: IWorkspacesMainService, ) { urlService.registerHandler(this); @@ -156,10 +157,11 @@ export class WindowsService implements IWindowsService, IURLHandler, IDisposable return this.withWindow(windowId, codeWindow => codeWindow.setRepresentedFilename(fileName)); } - async addRecentlyOpened(files: URI[]): Promise { + async addRecentlyOpened(workspaces: URI[], folders: URI[], files: URI[]): Promise { this.logService.trace('windowsService#addRecentlyOpened'); - this.historyService.addRecentlyOpened(undefined, files); + const workspaceIdentifiers = workspaces.map(w => this.workspacesMainService.getWorkspaceIdentifier(w)); + this.historyService.addRecentlyOpened([...workspaceIdentifiers, ...folders], files); } async removeFromRecentlyOpened(paths: Array): Promise { diff --git a/src/vs/platform/windows/node/windowsIpc.ts b/src/vs/platform/windows/node/windowsIpc.ts index 12b741138c0..cece368e7a0 100644 --- a/src/vs/platform/windows/node/windowsIpc.ts +++ b/src/vs/platform/windows/node/windowsIpc.ts @@ -59,7 +59,7 @@ export class WindowsChannel implements IServerChannel { case 'enterWorkspace': return this.service.enterWorkspace(arg[0], URI.revive(arg[1])); case 'toggleFullScreen': return this.service.toggleFullScreen(arg); case 'setRepresentedFilename': return this.service.setRepresentedFilename(arg[0], arg[1]); - case 'addRecentlyOpened': return this.service.addRecentlyOpened(arg.map(URI.revive)); + case 'addRecentlyOpened': return this.service.addRecentlyOpened(arg[0].map(URI.revive), arg[1].map(URI.revive), arg[2].map(URI.revive)); case 'removeFromRecentlyOpened': { let paths: Array = arg; if (Array.isArray(paths)) { @@ -178,8 +178,8 @@ export class WindowsChannelClient implements IWindowsService { return this.channel.call('setRepresentedFilename', [windowId, fileName]); } - addRecentlyOpened(files: URI[]): Promise { - return this.channel.call('addRecentlyOpened', files); + addRecentlyOpened(workspaces: URI[], folders: URI[], files: URI[]): Promise { + return this.channel.call('addRecentlyOpened', [workspaces, folders, files]); } removeFromRecentlyOpened(paths: Array): Promise { diff --git a/src/vs/platform/workspaces/common/workspaces.ts b/src/vs/platform/workspaces/common/workspaces.ts index dab9dd39404..b72fe85d629 100644 --- a/src/vs/platform/workspaces/common/workspaces.ts +++ b/src/vs/platform/workspaces/common/workspaces.ts @@ -96,8 +96,6 @@ export interface IWorkspacesMainService extends IWorkspacesService { onUntitledWorkspaceDeleted: Event; - saveWorkspaceAs(workspace: IWorkspaceIdentifier, target: string): Promise; - createUntitledWorkspaceSync(folders?: IWorkspaceFolderCreationData[]): IWorkspaceIdentifier; resolveLocalWorkspaceSync(path: URI): IResolvedWorkspace | null; @@ -115,6 +113,8 @@ export interface IWorkspacesService { _serviceBrand: any; createUntitledWorkspace(folders?: IWorkspaceFolderCreationData[]): Promise; + + deleteUntitledWorkspace(workspace: IWorkspaceIdentifier): Promise; } export function isSingleFolderWorkspaceIdentifier(obj: any): obj is ISingleFolderWorkspaceIdentifier { diff --git a/src/vs/platform/workspaces/electron-main/workspacesMainService.ts b/src/vs/platform/workspaces/electron-main/workspacesMainService.ts index d61031e0e9d..d0d80e3b846 100644 --- a/src/vs/platform/workspaces/electron-main/workspacesMainService.ts +++ b/src/vs/platform/workspaces/electron-main/workspacesMainService.ts @@ -3,16 +3,15 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { IWorkspacesMainService, IWorkspaceIdentifier, hasWorkspaceFileExtension, UNTITLED_WORKSPACE_NAME, IResolvedWorkspace, IStoredWorkspaceFolder, isStoredWorkspaceFolder, IWorkspaceFolderCreationData, rewriteWorkspaceFileForNewLocation, IUntitledWorkspaceInfo, getStoredWorkspaceFolder } from 'vs/platform/workspaces/common/workspaces'; +import { IWorkspacesMainService, IWorkspaceIdentifier, hasWorkspaceFileExtension, UNTITLED_WORKSPACE_NAME, IResolvedWorkspace, IStoredWorkspaceFolder, isStoredWorkspaceFolder, IWorkspaceFolderCreationData, IUntitledWorkspaceInfo, getStoredWorkspaceFolder } from 'vs/platform/workspaces/common/workspaces'; import { IEnvironmentService } from 'vs/platform/environment/common/environment'; import { join, dirname } from 'vs/base/common/path'; -import { mkdirp, writeFile, readFile } from 'vs/base/node/pfs'; +import { mkdirp, writeFile } from 'vs/base/node/pfs'; import { readFileSync, existsSync, mkdirSync, writeFileSync } from 'fs'; import { isLinux } from 'vs/base/common/platform'; import { delSync, readdirSync, writeFileAndFlushSync } from 'vs/base/node/extfs'; import { Event, Emitter } from 'vs/base/common/event'; import { ILogService } from 'vs/platform/log/common/log'; -import { isEqual } from 'vs/base/common/extpath'; import { createHash } from 'crypto'; import * as json from 'vs/base/common/json'; import { toWorkspaceFolders } from 'vs/platform/workspace/common/workspace'; @@ -168,30 +167,6 @@ export class WorkspacesMainService extends Disposable implements IWorkspacesMain return this.isInsideWorkspacesHome(workspace.configPath); } - saveWorkspaceAs(workspace: IWorkspaceIdentifier, targetConfigPath: string): Promise { - - if (workspace.configPath.scheme !== Schemas.file) { - throw new Error('Only local workspaces can be saved with this API. Use WorkspaceEditingService.saveWorkspaceAs on the renderer instead.'); - } - - const configPath = originalFSPath(workspace.configPath); - - // Return early if target is same as source - if (isEqual(configPath, targetConfigPath, !isLinux)) { - return Promise.resolve(workspace); - } - - // Read the contents of the workspace file and resolve it - return readFile(configPath).then(raw => { - const targetConfigPathURI = URI.file(targetConfigPath); - const newRawWorkspaceContents = rewriteWorkspaceFileForNewLocation(raw.toString(), workspace.configPath, targetConfigPathURI); - - return writeFile(targetConfigPath, newRawWorkspaceContents).then(() => { - return this.getWorkspaceIdentifier(targetConfigPathURI); - }); - }); - } - deleteUntitledWorkspaceSync(workspace: IWorkspaceIdentifier): void { if (!this.isUntitledWorkspace(workspace)) { return; // only supported for untitled workspaces @@ -204,6 +179,11 @@ export class WorkspacesMainService extends Disposable implements IWorkspacesMain this._onUntitledWorkspaceDeleted.fire(workspace); } + deleteUntitledWorkspace(workspace: IWorkspaceIdentifier): Promise { + this.deleteUntitledWorkspaceSync(workspace); + return Promise.resolve(); + } + private doDeleteUntitledWorkspaceSync(workspace: IWorkspaceIdentifier): void { const configPath = originalFSPath(workspace.configPath); try { diff --git a/src/vs/platform/workspaces/node/workspacesIpc.ts b/src/vs/platform/workspaces/node/workspacesIpc.ts index 11ae4da17ec..6d1af4e5457 100644 --- a/src/vs/platform/workspaces/node/workspacesIpc.ts +++ b/src/vs/platform/workspaces/node/workspacesIpc.ts @@ -32,6 +32,10 @@ export class WorkspacesChannel implements IServerChannel { return this.service.createUntitledWorkspace(folders); } + case 'deleteUntitledWorkspace': { + const w: IWorkspaceIdentifier = arg; + return this.service.deleteUntitledWorkspace({ id: w.id, configPath: URI.revive(w.configPath) }); + } } throw new Error(`Call not found: ${command}`); @@ -47,4 +51,8 @@ export class WorkspacesChannelClient implements IWorkspacesService { createUntitledWorkspace(folders?: IWorkspaceFolderCreationData[]): Promise { return this.channel.call('createUntitledWorkspace', folders).then(reviveWorkspaceIdentifier); } + + deleteUntitledWorkspace(workspaceIdentifier: IWorkspaceIdentifier): Promise { + return this.channel.call('deleteUntitledWorkspace', workspaceIdentifier); + } } diff --git a/src/vs/platform/workspaces/test/electron-main/workspacesMainService.test.ts b/src/vs/platform/workspaces/test/electron-main/workspacesMainService.test.ts index 95a46d91ff0..272b810d1bf 100644 --- a/src/vs/platform/workspaces/test/electron-main/workspacesMainService.test.ts +++ b/src/vs/platform/workspaces/test/electron-main/workspacesMainService.test.ts @@ -7,12 +7,11 @@ import * as assert from 'assert'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'vs/base/common/path'; -import * as extfs from 'vs/base/node/extfs'; import * as pfs from 'vs/base/node/pfs'; import { EnvironmentService } from 'vs/platform/environment/node/environmentService'; import { parseArgs } from 'vs/platform/environment/node/argv'; import { WorkspacesMainService, IStoredWorkspace } from 'vs/platform/workspaces/electron-main/workspacesMainService'; -import { WORKSPACE_EXTENSION, IWorkspaceIdentifier, IRawFileWorkspaceFolder, IWorkspaceFolderCreationData, IRawUriWorkspaceFolder } from 'vs/platform/workspaces/common/workspaces'; +import { WORKSPACE_EXTENSION, IWorkspaceIdentifier, IRawFileWorkspaceFolder, IWorkspaceFolderCreationData, IRawUriWorkspaceFolder, rewriteWorkspaceFileForNewLocation } from 'vs/platform/workspaces/common/workspaces'; import { NullLogService } from 'vs/platform/log/common/log'; import { URI } from 'vs/base/common/uri'; import { getRandomTestPath } from 'vs/base/test/node/testUtils'; @@ -233,95 +232,85 @@ suite('WorkspacesMainService', () => { }); }); - test('saveWorkspace (untitled)', () => { - return createWorkspace([process.cwd(), os.tmpdir(), path.join(os.tmpdir(), 'somefolder')]).then(workspace => { - const workspaceConfigPath = path.join(os.tmpdir(), `myworkspace.${Date.now()}.${WORKSPACE_EXTENSION}`); + test('rewriteWorkspaceFileForNewLocation', () => { + const folder1 = process.cwd(); // absolute path because outside of tmpDir + const tmpDir = os.tmpdir(); + const tmpInsideDir = path.join(os.tmpdir(), 'inside'); - return service.saveWorkspaceAs(workspace, workspaceConfigPath).then(savedWorkspace => { - assert.ok(savedWorkspace.id); - assert.notEqual(savedWorkspace.id, workspace.id); - assertPathEquals(savedWorkspace.configPath.fsPath, workspaceConfigPath); + return createWorkspace([folder1, tmpInsideDir, path.join(tmpInsideDir, 'somefolder')]).then(workspace => { + const origContent = fs.readFileSync(workspace.configPath.fsPath).toString(); - const ws = JSON.parse(fs.readFileSync(savedWorkspace.configPath.fsPath).toString()) as IStoredWorkspace; - assert.equal(ws.folders.length, 3); - assertPathEquals((ws.folders[0]).path, process.cwd()); // absolute - assertPathEquals((ws.folders[1]).path, '.'); // relative - assertPathEquals((ws.folders[2]).path, path.relative(path.dirname(workspaceConfigPath), path.join(os.tmpdir(), 'somefolder'))); // relative + let origConfigPath = workspace.configPath; + let workspaceConfigPath = URI.file(path.join(tmpDir, 'inside', 'myworkspace1.code-workspace')); + let newContent = rewriteWorkspaceFileForNewLocation(origContent, origConfigPath, workspaceConfigPath); - extfs.delSync(workspaceConfigPath); - }); + let ws = JSON.parse(newContent) as IStoredWorkspace; + assert.equal(ws.folders.length, 3); + assertPathEquals((ws.folders[0]).path, folder1); // absolute path because outside of tmpdir + assertPathEquals((ws.folders[1]).path, '.'); + assertPathEquals((ws.folders[2]).path, 'somefolder'); + + origConfigPath = workspaceConfigPath; + workspaceConfigPath = URI.file(path.join(tmpDir, 'myworkspace2.code-workspace')); + newContent = rewriteWorkspaceFileForNewLocation(newContent, origConfigPath, workspaceConfigPath); + + ws = JSON.parse(newContent) as IStoredWorkspace; + assert.equal(ws.folders.length, 3); + assertPathEquals((ws.folders[0]).path, folder1); + assertPathEquals((ws.folders[1]).path, 'inside'); + assertPathEquals((ws.folders[2]).path, isWindows ? 'inside\\somefolder' : 'inside/somefolder'); + + origConfigPath = workspaceConfigPath; + workspaceConfigPath = URI.file(path.join(tmpDir, 'other', 'myworkspace2.code-workspace')); + newContent = rewriteWorkspaceFileForNewLocation(newContent, origConfigPath, workspaceConfigPath); + + ws = JSON.parse(newContent) as IStoredWorkspace; + assert.equal(ws.folders.length, 3); + assertPathEquals((ws.folders[0]).path, folder1); + assertPathEquals((ws.folders[1]).path, tmpInsideDir); + assertPathEquals((ws.folders[2]).path, path.join(tmpInsideDir, 'somefolder')); + + origConfigPath = workspaceConfigPath; + workspaceConfigPath = URI.parse('foo://foo/bar/myworkspace2.code-workspace'); + newContent = rewriteWorkspaceFileForNewLocation(newContent, origConfigPath, workspaceConfigPath); + + ws = JSON.parse(newContent) as IStoredWorkspace; + assert.equal(ws.folders.length, 3); + assert.equal((ws.folders[0]).uri, URI.file(folder1).toString(true)); + assert.equal((ws.folders[1]).uri, URI.file(tmpInsideDir).toString(true)); + assert.equal((ws.folders[2]).uri, URI.file(path.join(tmpInsideDir, 'somefolder')).toString(true)); + + service.deleteUntitledWorkspaceSync(workspace); }); }); - test('saveWorkspace (saved workspace)', () => { + test('rewriteWorkspaceFileForNewLocation (preserves comments)', () => { return createWorkspace([process.cwd(), os.tmpdir(), path.join(os.tmpdir(), 'somefolder')]).then(workspace => { - const workspaceConfigPath = path.join(os.tmpdir(), `myworkspace.${Date.now()}.${WORKSPACE_EXTENSION}`); - const newWorkspaceConfigPath = path.join(os.tmpdir(), `mySavedWorkspace.${Date.now()}.${WORKSPACE_EXTENSION}`); + const workspaceConfigPath = URI.file(path.join(os.tmpdir(), `myworkspace.${Date.now()}.${WORKSPACE_EXTENSION}`)); - return service.saveWorkspaceAs(workspace, workspaceConfigPath).then(savedWorkspace => { - return service.saveWorkspaceAs(savedWorkspace, newWorkspaceConfigPath).then(newSavedWorkspace => { - assert.ok(newSavedWorkspace.id); - assert.notEqual(newSavedWorkspace.id, workspace.id); - assertPathEquals(newSavedWorkspace.configPath.fsPath, newWorkspaceConfigPath); + let origContent = fs.readFileSync(workspace.configPath.fsPath).toString(); + origContent = `// this is a comment\n${origContent}`; - const ws = JSON.parse(fs.readFileSync(newSavedWorkspace.configPath.fsPath).toString()) as IStoredWorkspace; - assert.equal(ws.folders.length, 3); - assertPathEquals((ws.folders[0]).path, process.cwd()); // absolute path because outside of tmpdir - assertPathEquals((ws.folders[1]).path, '.'); // relative path because inside of tmpdir - assertPathEquals((ws.folders[2]).path, 'somefolder'); // relative + let newContent = rewriteWorkspaceFileForNewLocation(origContent, workspace.configPath, workspaceConfigPath); - extfs.delSync(workspaceConfigPath); - extfs.delSync(newWorkspaceConfigPath); - }); - }); + assert.equal(0, newContent.indexOf('// this is a comment')); + + service.deleteUntitledWorkspaceSync(workspace); }); }); - test('saveWorkspace (saved workspace, preserves comments)', () => { + test('rewriteWorkspaceFileForNewLocation (preserves forward slashes)', () => { return createWorkspace([process.cwd(), os.tmpdir(), path.join(os.tmpdir(), 'somefolder')]).then(workspace => { - const workspaceConfigPath = path.join(os.tmpdir(), `myworkspace.${Date.now()}.${WORKSPACE_EXTENSION}`); - const newWorkspaceConfigPath = path.join(os.tmpdir(), `mySavedWorkspace.${Date.now()}.${WORKSPACE_EXTENSION}`); + const workspaceConfigPath = URI.file(path.join(os.tmpdir(), `myworkspace.${Date.now()}.${WORKSPACE_EXTENSION}`)); + let origContent = fs.readFileSync(workspace.configPath.fsPath).toString(); + origContent = origContent.replace(/[\\]/g, '/'); // convert backslash to slash - return service.saveWorkspaceAs(workspace, workspaceConfigPath).then(savedWorkspace => { - const contents = fs.readFileSync(savedWorkspace.configPath.fsPath).toString(); - fs.writeFileSync(savedWorkspace.configPath.fsPath, `// this is a comment\n${contents}`); + const newContent = rewriteWorkspaceFileForNewLocation(origContent, workspace.configPath, workspaceConfigPath); - return service.saveWorkspaceAs(savedWorkspace, newWorkspaceConfigPath).then(newSavedWorkspace => { - assert.ok(newSavedWorkspace.id); - assert.notEqual(newSavedWorkspace.id, workspace.id); - assertPathEquals(newSavedWorkspace.configPath.fsPath, newWorkspaceConfigPath); + const ws = JSON.parse(newContent) as IStoredWorkspace; + assert.ok(ws.folders.every(f => (f).path.indexOf('\\') < 0)); - const savedContents = fs.readFileSync(newSavedWorkspace.configPath.fsPath).toString(); - assert.equal(0, savedContents.indexOf('// this is a comment')); - - extfs.delSync(workspaceConfigPath); - extfs.delSync(newWorkspaceConfigPath); - }); - }); - }); - }); - - test('saveWorkspace (saved workspace, preserves forward slashes)', () => { - return createWorkspace([process.cwd(), os.tmpdir(), path.join(os.tmpdir(), 'somefolder')]).then(workspace => { - const workspaceConfigPath = path.join(os.tmpdir(), `myworkspace.${Date.now()}.${WORKSPACE_EXTENSION}`); - const newWorkspaceConfigPath = path.join(os.tmpdir(), `mySavedWorkspace.${Date.now()}.${WORKSPACE_EXTENSION}`); - - return service.saveWorkspaceAs(workspace, workspaceConfigPath).then(savedWorkspace => { - const contents = fs.readFileSync(savedWorkspace.configPath.fsPath).toString(); - fs.writeFileSync(savedWorkspace.configPath.fsPath, contents.replace(/[\\]/g, '/')); // convert backslash to slash - - return service.saveWorkspaceAs(savedWorkspace, newWorkspaceConfigPath).then(newSavedWorkspace => { - assert.ok(newSavedWorkspace.id); - assert.notEqual(newSavedWorkspace.id, workspace.id); - assertPathEquals(newSavedWorkspace.configPath.fsPath, newWorkspaceConfigPath); - - const ws = JSON.parse(fs.readFileSync(newSavedWorkspace.configPath.fsPath).toString()) as IStoredWorkspace; - assert.ok(ws.folders.every(f => (f).path.indexOf('\\') < 0)); - - extfs.delSync(workspaceConfigPath); - extfs.delSync(newWorkspaceConfigPath); - }); - }); + service.deleteUntitledWorkspaceSync(workspace); }); }); @@ -337,15 +326,7 @@ suite('WorkspacesMainService', () => { test('deleteUntitledWorkspaceSync (saved)', () => { return createWorkspace([process.cwd(), os.tmpdir()]).then(workspace => { - const workspaceConfigPath = path.join(os.tmpdir(), `myworkspace.${Date.now()}.${WORKSPACE_EXTENSION}`); - - return service.saveWorkspaceAs(workspace, workspaceConfigPath).then(savedWorkspace => { - assert.ok(fs.existsSync(savedWorkspace.configPath.fsPath)); - - service.deleteUntitledWorkspaceSync(savedWorkspace); - - assert.ok(fs.existsSync(savedWorkspace.configPath.fsPath)); - }); + service.deleteUntitledWorkspaceSync(workspace); }); }); diff --git a/src/vs/workbench/browser/dnd.ts b/src/vs/workbench/browser/dnd.ts index 5897ab9e710..14c2f2f4fe6 100644 --- a/src/vs/workbench/browser/dnd.ts +++ b/src/vs/workbench/browser/dnd.ts @@ -180,7 +180,7 @@ export class ResourcesDropHandler { // Add external ones to recently open list unless dropped resource is a workspace const filesToAddToHistory = untitledOrFileResources.filter(d => d.isExternal && d.resource.scheme === Schemas.file).map(d => d.resource); if (filesToAddToHistory.length) { - this.windowsService.addRecentlyOpened(filesToAddToHistory); + this.windowsService.addRecentlyOpened([], [], filesToAddToHistory); } const editors: IResourceEditor[] = untitledOrFileResources.map(untitledOrFileResource => ({ diff --git a/src/vs/workbench/services/workspace/common/workspaceEditing.ts b/src/vs/workbench/services/workspace/common/workspaceEditing.ts index e65b8e1c29e..c1656dab17f 100644 --- a/src/vs/workbench/services/workspace/common/workspaceEditing.ts +++ b/src/vs/workbench/services/workspace/common/workspaceEditing.ts @@ -51,4 +51,9 @@ export interface IWorkspaceEditingService { * copies current workspace settings to the target workspace. */ copyWorkspaceSettings(toWorkspace: IWorkspaceIdentifier): Promise; + + /** + * picks a new workspace path + */ + pickNewWorkspacePath(): Promise; } \ No newline at end of file diff --git a/src/vs/workbench/services/workspace/node/workspaceEditingService.ts b/src/vs/workbench/services/workspace/node/workspaceEditingService.ts index c91fa335414..5fcaa129676 100644 --- a/src/vs/workbench/services/workspace/node/workspaceEditingService.ts +++ b/src/vs/workbench/services/workspace/node/workspaceEditingService.ts @@ -9,7 +9,7 @@ import * as nls from 'vs/nls'; import { IWorkspaceContextService, WorkbenchState } from 'vs/platform/workspace/common/workspace'; import { IWindowService, MessageBoxOptions, IWindowsService } from 'vs/platform/windows/common/windows'; import { IJSONEditingService, JSONEditingError, JSONEditingErrorCode } from 'vs/workbench/services/configuration/common/jsonEditing'; -import { IWorkspaceIdentifier, IWorkspaceFolderCreationData, isWorkspaceIdentifier, toWorkspaceIdentifier, IWorkspacesService, rewriteWorkspaceFileForNewLocation } from 'vs/platform/workspaces/common/workspaces'; +import { IWorkspaceIdentifier, IWorkspaceFolderCreationData, IWorkspacesService, rewriteWorkspaceFileForNewLocation, WORKSPACE_FILTER } from 'vs/platform/workspaces/common/workspaces'; import { IWorkspaceConfigurationService } from 'vs/workbench/services/configuration/common/configuration'; import { WorkspaceService } from 'vs/workbench/services/configuration/node/configurationService'; import { IStorageService } from 'vs/platform/storage/common/storage'; @@ -21,11 +21,14 @@ import { IBackupFileService } from 'vs/workbench/services/backup/common/backup'; import { BackupFileService } from 'vs/workbench/services/backup/node/backupFileService'; import { ICommandService } from 'vs/platform/commands/common/commands'; import { distinct } from 'vs/base/common/arrays'; -import { isLinux } from 'vs/base/common/platform'; -import { isEqual, basename } from 'vs/base/common/resources'; +import { isLinux, isWindows, isMacintosh } from 'vs/base/common/platform'; +import { isEqual, basename, isEqualOrParent } from 'vs/base/common/resources'; import { INotificationService, Severity } from 'vs/platform/notification/common/notification'; import { IFileService } from 'vs/platform/files/common/files'; import { IEnvironmentService } from 'vs/platform/environment/common/environment'; +import { ILifecycleService, ShutdownReason } from 'vs/platform/lifecycle/common/lifecycle'; +import { IFileDialogService, IDialogService } from 'vs/platform/dialogs/common/dialogs'; +import { mnemonicButtonLabel } from 'vs/base/common/labels'; export class WorkspaceEditingService implements IWorkspaceEditingService { @@ -44,8 +47,97 @@ export class WorkspaceEditingService implements IWorkspaceEditingService { @IFileService private readonly fileSystemService: IFileService, @IWindowsService private readonly windowsService: IWindowsService, @IWorkspacesService private readonly workspaceService: IWorkspacesService, - @IEnvironmentService private readonly environmentService: IEnvironmentService + @IEnvironmentService private readonly environmentService: IEnvironmentService, + @IFileDialogService private readonly fileDialogService: IFileDialogService, + @IDialogService private readonly dialogService: IDialogService, + @ILifecycleService readonly lifecycleService: ILifecycleService ) { + + lifecycleService.onBeforeShutdown(async e => { + const saveOperation = this.saveUntitedBeforeShutdown(e.reason); + if (saveOperation) { + e.veto(saveOperation); + } + }); + + } + + private saveUntitedBeforeShutdown(reason: ShutdownReason): Promise | undefined { + if (reason === ShutdownReason.RELOAD || reason === ShutdownReason.QUIT) { + return undefined; + } + const workspaceIdentifier = this.getCurrentWorkspaceIdentifier(); + if (!workspaceIdentifier) { + return undefined; // not a workspace + } + if (!isEqualOrParent(workspaceIdentifier.configPath, this.environmentService.untitledWorkspacesHome)) { + return undefined; // not an untitled workspace + } + + return this.windowsService.getWindowCount().then(windowCount => { + if (reason === ShutdownReason.CLOSE && !isMacintosh && windowCount === 1) { + return false; // Windows/Linux: quits when last window is closed, so do not ask then + } + enum ConfirmResult { + SAVE, + DONT_SAVE, + CANCEL + } + + const save = { label: mnemonicButtonLabel(nls.localize('save', "Save")), result: ConfirmResult.SAVE }; + const dontSave = { label: mnemonicButtonLabel(nls.localize('doNotSave', "Don't Save")), result: ConfirmResult.DONT_SAVE }; + const cancel = { label: nls.localize('cancel', "Cancel"), result: ConfirmResult.CANCEL }; + + const buttons: { label: string; result: ConfirmResult; }[] = []; + if (isWindows) { + buttons.push(save, dontSave, cancel); + } else if (isLinux) { + buttons.push(dontSave, cancel, save); + } else { + buttons.push(save, cancel, dontSave); + } + + const message = nls.localize('saveWorkspaceMessage', "Do you want to save your workspace configuration as a file?"); + const detail = nls.localize('saveWorkspaceDetail', "Save your workspace if you plan to open it again."); + const cancelId = buttons.indexOf(cancel); + + return this.dialogService.show(Severity.Warning, message, buttons.map(button => button.label), { detail, cancelId }).then(res => { + switch (buttons[res].result) { + + // Cancel: veto unload + case ConfirmResult.CANCEL: + return true; + + // Don't Save: delete workspace + case ConfirmResult.DONT_SAVE: + this.workspaceService.deleteUntitledWorkspace(workspaceIdentifier); + return false; + + // Save: save workspace, but do not veto unload + case ConfirmResult.SAVE: { + return this.pickNewWorkspacePath().then(newWorkspacePath => { + if (newWorkspacePath) { + return this.saveWorkspaceAs(workspaceIdentifier, newWorkspacePath).then(_ => { + this.windowsService.addRecentlyOpened([newWorkspacePath], [], []); + this.workspaceService.deleteUntitledWorkspace(workspaceIdentifier); + return false; + }, () => false); + } + return true; // keep veto if no target was provided + }); + } + } + }); + }); + } + + pickNewWorkspacePath(): Promise { + return this.fileDialogService.showSaveDialog({ + saveLabel: mnemonicButtonLabel(nls.localize('save', "Save")), + title: nls.localize('saveWorkspace', "Save Workspace"), + filters: WORKSPACE_FILTER, + defaultUri: this.fileDialogService.defaultWorkspacePath() + }); } updateFolders(index: number, deleteCount?: number, foldersToAdd?: IWorkspaceFolderCreationData[], donotNotifyError?: boolean): Promise { @@ -164,11 +256,11 @@ export class WorkspaceEditingService implements IWorkspaceEditingService { if (!this.isValidTargetWorkspacePath(path)) { return Promise.reject(null); } - const currentWorkspaceIdentifier = toWorkspaceIdentifier(this.contextService.getWorkspace()); - if (!isWorkspaceIdentifier(currentWorkspaceIdentifier)) { + const workspaceIdentifier = this.getCurrentWorkspaceIdentifier(); + if (!workspaceIdentifier) { return Promise.reject(null); } - await this.saveWorkspaceAs(currentWorkspaceIdentifier, path); + await this.saveWorkspaceAs(workspaceIdentifier, path); return this.enterWorkspace(path); } @@ -328,4 +420,12 @@ export class WorkspaceEditingService implements IWorkspaceEditingService { return this.jsonEditingService.write(toWorkspace.configPath, { key: 'settings', value: targetWorkspaceConfiguration }, true); } + + private getCurrentWorkspaceIdentifier(): IWorkspaceIdentifier | undefined { + const workspace = this.contextService.getWorkspace(); + if (workspace && workspace.configuration) { + return { id: workspace.id, configPath: workspace.configuration }; + } + return undefined; + } } diff --git a/src/vs/workbench/test/workbenchTestServices.ts b/src/vs/workbench/test/workbenchTestServices.ts index d605a994a11..2b7d37ffe8c 100644 --- a/src/vs/workbench/test/workbenchTestServices.ts +++ b/src/vs/workbench/test/workbenchTestServices.ts @@ -1242,7 +1242,7 @@ export class TestWindowsService implements IWindowsService { return Promise.resolve(); } - addRecentlyOpened(_files: URI[]): Promise { + addRecentlyOpened(_workspaces: URI[], _folders: URI[], _files: URI[]): Promise { return Promise.resolve(); }