diff --git a/src/vs/platform/contextkey/common/contextkeys.ts b/src/vs/platform/contextkey/common/contextkeys.ts index 94b03a0ce938f7880c1b8e2ede34ac8f0441e43e..6697e0d989be3d172b3cba1b6b72a010d2c1b54f 100644 --- a/src/vs/platform/contextkey/common/contextkeys.ts +++ b/src/vs/platform/contextkey/common/contextkeys.ts @@ -11,3 +11,6 @@ export const InputFocusedContext = new RawContextKey(InputFocusedContex export const IsMacContext = new RawContextKey('isMac', isMacintosh); export const IsLinuxContext = new RawContextKey('isLinux', isLinux); export const IsWindowsContext = new RawContextKey('isWindows', isWindows); + +export const SupportsWorkspacesContext = new RawContextKey('supportsWorkspaces', true); +export const SupportsOpenFileFolderContext = new RawContextKey('supportsOpenFileFolder', isMacintosh); diff --git a/src/vs/platform/label/common/label.ts b/src/vs/platform/label/common/label.ts index 4bd34446d4f93f0be5610455220120e7e2ad9ef7..87746ef28af44b7ceefdf2856b35114ac7195242 100644 --- a/src/vs/platform/label/common/label.ts +++ b/src/vs/platform/label/common/label.ts @@ -19,7 +19,7 @@ export interface ILabelService { * If relative is passed returns a label relative to the workspace root that the uri belongs to. * If noPrefix is passed does not tildify the label and also does not prepand the root name for relative labels in a multi root scenario. */ - getUriLabel(resource: URI, options?: { relative?: boolean, noPrefix?: boolean }): string; + getUriLabel(resource: URI, options?: { relative?: boolean, noPrefix?: boolean, endWithSeparator?: boolean }): string; getWorkspaceLabel(workspace: (IWorkspaceIdentifier | ISingleFolderWorkspaceIdentifier | IWorkspace), options?: { verbose: boolean }): string; getHostLabel(): string; registerFormatter(formatter: ResourceLabelFormatter): IDisposable; diff --git a/src/vs/workbench/contrib/files/electron-browser/fileActions.contribution.ts b/src/vs/workbench/contrib/files/electron-browser/fileActions.contribution.ts index b601ff205567de4d21619d9a8a2f1f63d7a1d22f..1d6e6e4500123f0b3d9dc4026f1e1c6c3320804d 100644 --- a/src/vs/workbench/contrib/files/electron-browser/fileActions.contribution.ts +++ b/src/vs/workbench/contrib/files/electron-browser/fileActions.contribution.ts @@ -23,6 +23,7 @@ import { ResourceContextKey } from 'vs/workbench/common/resources'; import { WorkbenchListDoubleSelection } from 'vs/platform/list/browser/listService'; import { URI } from 'vs/base/common/uri'; import { Schemas } from 'vs/base/common/network'; +import { SupportsWorkspacesContext } from 'vs/platform/contextkey/common/contextkeys'; import { ServicesAccessor } from 'vs/platform/instantiation/common/instantiation'; // Contribute Global Actions @@ -478,7 +479,7 @@ MenuRegistry.appendMenuItem(MenuId.ExplorerContext, { id: ADD_ROOT_FOLDER_COMMAND_ID, title: ADD_ROOT_FOLDER_LABEL }, - when: ContextKeyExpr.and(ExplorerRootContext) + when: ContextKeyExpr.and(ExplorerRootContext, SupportsWorkspacesContext) }); MenuRegistry.appendMenuItem(MenuId.ExplorerContext, { diff --git a/src/vs/workbench/contrib/files/electron-browser/fileActions.ts b/src/vs/workbench/contrib/files/electron-browser/fileActions.ts index bd8c77fd5a04db5bda0ca35e9be8a8ba83b2bf2a..1d61e4c585aac3d4795f2115c9c3b1fe121a20b4 100644 --- a/src/vs/workbench/contrib/files/electron-browser/fileActions.ts +++ b/src/vs/workbench/contrib/files/electron-browser/fileActions.ts @@ -46,6 +46,7 @@ import { AsyncDataTree } from 'vs/base/browser/ui/tree/asyncDataTree'; import { ExplorerItem } from 'vs/workbench/contrib/files/common/explorerModel'; import { onUnexpectedError } from 'vs/base/common/errors'; import { sequence } from 'vs/base/common/async'; +import { REMOTE_HOST_SCHEME } from 'vs/platform/remote/common/remoteHosts'; export const NEW_FILE_COMMAND_ID = 'explorer.newFile'; export const NEW_FILE_LABEL = nls.localize('newFile', "New File"); @@ -874,8 +875,8 @@ export class ShowOpenedFileInNewWindow extends Action { } public run(): Promise { - const fileResource = toResource(this.editorService.activeEditor, { supportSideBySide: true, filter: Schemas.file /* todo@remote */ }); - if (fileResource) { + const fileResource = toResource(this.editorService.activeEditor, { supportSideBySide: true }); + if (fileResource && (fileResource.scheme === Schemas.file || fileResource.scheme === REMOTE_HOST_SCHEME)) { this.windowService.openWindow([{ uri: fileResource, typeHint: 'file' }], { forceNewWindow: true, forceOpenWorkspaceAsFile: true }); } else { this.notificationService.info(nls.localize('openFileToShowInNewWindow', "Open a file first to open in new window")); diff --git a/src/vs/workbench/electron-browser/shell.contribution.ts b/src/vs/workbench/electron-browser/shell.contribution.ts index 511a3802324fd7212bcc3b177036f4df06d55534..d7445124b534aa3b5592bd07bd2d909887b1d7ef 100644 --- a/src/vs/workbench/electron-browser/shell.contribution.ts +++ b/src/vs/workbench/electron-browser/shell.contribution.ts @@ -21,7 +21,7 @@ import { KeybindingsRegistry, KeybindingWeight } from 'vs/platform/keybinding/co import { CommandsRegistry } from 'vs/platform/commands/common/commands'; import { IInstantiationService, ServicesAccessor } from 'vs/platform/instantiation/common/instantiation'; import { ADD_ROOT_FOLDER_COMMAND_ID } from 'vs/workbench/browser/actions/workspaceCommands'; -import { IsMacContext } from 'vs/platform/contextkey/common/contextkeys'; +import { SupportsOpenFileFolderContext, SupportsWorkspacesContext, IsMacContext } from 'vs/platform/contextkey/common/contextkeys'; import { NoEditorsVisibleContext, SingleEditorGroupsContext } from 'vs/workbench/common/editor'; import { IWindowService, IWindowsService } from 'vs/platform/windows/common/windows'; @@ -61,12 +61,9 @@ workbenchActionsRegistry.registerWorkbenchAction(new SyncActionDescriptor(Switch workbenchActionsRegistry.registerWorkbenchAction(new SyncActionDescriptor(QuickSwitchWindow, QuickSwitchWindow.ID, QuickSwitchWindow.LABEL), 'Quick Switch Window...'); workbenchActionsRegistry.registerWorkbenchAction(new SyncActionDescriptor(QuickOpenRecentAction, QuickOpenRecentAction.ID, QuickOpenRecentAction.LABEL), 'File: Quick Open Recent...', fileCategory); -if (isMacintosh) { - workbenchActionsRegistry.registerWorkbenchAction(new SyncActionDescriptor(OpenFileFolderAction, OpenFileFolderAction.ID, OpenFileFolderAction.LABEL, { primary: KeyMod.CtrlCmd | KeyCode.KEY_O }), 'File: Open...', fileCategory); -} else { - workbenchActionsRegistry.registerWorkbenchAction(new SyncActionDescriptor(OpenFileAction, OpenFileAction.ID, OpenFileAction.LABEL, { primary: KeyMod.CtrlCmd | KeyCode.KEY_O }), 'File: Open File...', fileCategory); - workbenchActionsRegistry.registerWorkbenchAction(new SyncActionDescriptor(OpenFolderAction, OpenFolderAction.ID, OpenFolderAction.LABEL, { primary: KeyChord(KeyMod.CtrlCmd | KeyCode.KEY_K, KeyMod.CtrlCmd | KeyCode.KEY_O) }), 'File: Open Folder...', fileCategory); -} +workbenchActionsRegistry.registerWorkbenchAction(new SyncActionDescriptor(OpenFileFolderAction, OpenFileFolderAction.ID, OpenFileFolderAction.LABEL, { primary: KeyMod.CtrlCmd | KeyCode.KEY_O }), 'File: Open...', fileCategory, SupportsOpenFileFolderContext); +workbenchActionsRegistry.registerWorkbenchAction(new SyncActionDescriptor(OpenFileAction, OpenFileAction.ID, OpenFileAction.LABEL, { primary: KeyMod.CtrlCmd | KeyCode.KEY_O }), 'File: Open File...', fileCategory, SupportsOpenFileFolderContext.toNegated()); +workbenchActionsRegistry.registerWorkbenchAction(new SyncActionDescriptor(OpenFolderAction, OpenFolderAction.ID, OpenFolderAction.LABEL, { primary: KeyChord(KeyMod.CtrlCmd | KeyCode.KEY_K, KeyMod.CtrlCmd | KeyCode.KEY_O) }), 'File: Open Folder...', fileCategory, SupportsOpenFileFolderContext.toNegated()); workbenchActionsRegistry.registerWorkbenchAction(new SyncActionDescriptor(CloseWorkspaceAction, CloseWorkspaceAction.ID, CloseWorkspaceAction.LABEL, { primary: KeyChord(KeyMod.CtrlCmd | KeyCode.KEY_K, KeyCode.KEY_F) }), 'File: Close Workspace', fileCategory); @@ -115,10 +112,10 @@ workbenchActionsRegistry.registerWorkbenchAction( workbenchActionsRegistry.registerWorkbenchAction(new SyncActionDescriptor(ToggleFullScreenAction, ToggleFullScreenAction.ID, ToggleFullScreenAction.LABEL, { primary: KeyCode.F11, mac: { primary: KeyMod.CtrlCmd | KeyMod.WinCtrl | KeyCode.KEY_F } }), 'View: Toggle Full Screen', viewCategory); const workspacesCategory = nls.localize('workspaces', "Workspaces"); -workbenchActionsRegistry.registerWorkbenchAction(new SyncActionDescriptor(AddRootFolderAction, AddRootFolderAction.ID, AddRootFolderAction.LABEL), 'Workspaces: Add Folder to Workspace...', workspacesCategory); +workbenchActionsRegistry.registerWorkbenchAction(new SyncActionDescriptor(AddRootFolderAction, AddRootFolderAction.ID, AddRootFolderAction.LABEL), 'Workspaces: Add Folder to Workspace...', workspacesCategory, SupportsWorkspacesContext); workbenchActionsRegistry.registerWorkbenchAction(new SyncActionDescriptor(GlobalRemoveRootFolderAction, GlobalRemoveRootFolderAction.ID, GlobalRemoveRootFolderAction.LABEL), 'Workspaces: Remove Folder from Workspace...', workspacesCategory); -workbenchActionsRegistry.registerWorkbenchAction(new SyncActionDescriptor(OpenWorkspaceAction, OpenWorkspaceAction.ID, OpenWorkspaceAction.LABEL), 'Workspaces: Open Workspace...', workspacesCategory); -workbenchActionsRegistry.registerWorkbenchAction(new SyncActionDescriptor(SaveWorkspaceAsAction, SaveWorkspaceAsAction.ID, SaveWorkspaceAsAction.LABEL), 'Workspaces: Save Workspace As...', workspacesCategory); +workbenchActionsRegistry.registerWorkbenchAction(new SyncActionDescriptor(OpenWorkspaceAction, OpenWorkspaceAction.ID, OpenWorkspaceAction.LABEL), 'Workspaces: Open Workspace...', workspacesCategory, SupportsWorkspacesContext); +workbenchActionsRegistry.registerWorkbenchAction(new SyncActionDescriptor(SaveWorkspaceAsAction, SaveWorkspaceAsAction.ID, SaveWorkspaceAsAction.LABEL), 'Workspaces: Save Workspace As...', workspacesCategory, SupportsWorkspacesContext); workbenchActionsRegistry.registerWorkbenchAction(new SyncActionDescriptor(DuplicateWorkspaceInNewWindowAction, DuplicateWorkspaceInNewWindowAction.ID, DuplicateWorkspaceInNewWindowAction.LABEL), 'Workspaces: Duplicate Workspace in New Window', workspacesCategory); CommandsRegistry.registerCommand(OpenWorkspaceConfigFileAction.ID, serviceAccessor => { @@ -178,7 +175,7 @@ MenuRegistry.appendMenuItem(MenuId.MenubarFileMenu, { title: nls.localize({ key: 'miOpenFile', comment: ['&& denotes a mnemonic'] }, "&&Open File...") }, order: 1, - when: IsMacContext.toNegated() + when: SupportsOpenFileFolderContext.toNegated() }); MenuRegistry.appendMenuItem(MenuId.MenubarFileMenu, { @@ -188,7 +185,7 @@ MenuRegistry.appendMenuItem(MenuId.MenubarFileMenu, { title: nls.localize({ key: 'miOpenFolder', comment: ['&& denotes a mnemonic'] }, "Open &&Folder...") }, order: 2, - when: IsMacContext.toNegated() + when: SupportsOpenFileFolderContext.toNegated() }); MenuRegistry.appendMenuItem(MenuId.MenubarFileMenu, { @@ -198,7 +195,7 @@ MenuRegistry.appendMenuItem(MenuId.MenubarFileMenu, { title: nls.localize({ key: 'miOpen', comment: ['&& denotes a mnemonic'] }, "&&Open...") }, order: 1, - when: IsMacContext + when: SupportsOpenFileFolderContext }); MenuRegistry.appendMenuItem(MenuId.MenubarFileMenu, { @@ -207,7 +204,8 @@ MenuRegistry.appendMenuItem(MenuId.MenubarFileMenu, { id: OpenWorkspaceAction.ID, title: nls.localize({ key: 'miOpenWorkspace', comment: ['&& denotes a mnemonic'] }, "Open Wor&&kspace...") }, - order: 3 + order: 3, + when: SupportsWorkspacesContext }); MenuRegistry.appendMenuItem(MenuId.MenubarFileMenu, { @@ -234,7 +232,8 @@ MenuRegistry.appendMenuItem(MenuId.MenubarFileMenu, { id: ADD_ROOT_FOLDER_COMMAND_ID, title: nls.localize({ key: 'miAddFolderToWorkspace', comment: ['&& denotes a mnemonic'] }, "A&&dd Folder to Workspace...") }, - order: 1 + order: 1, + when: SupportsWorkspacesContext }); MenuRegistry.appendMenuItem(MenuId.MenubarFileMenu, { @@ -243,7 +242,8 @@ MenuRegistry.appendMenuItem(MenuId.MenubarFileMenu, { id: SaveWorkspaceAsAction.ID, title: nls.localize('miSaveWorkspaceAs', "Save Workspace As...") }, - order: 2 + order: 2, + when: SupportsWorkspacesContext }); MenuRegistry.appendMenuItem(MenuId.MenubarFileMenu, { diff --git a/src/vs/workbench/electron-browser/workbench.ts b/src/vs/workbench/electron-browser/workbench.ts index aa5a4e9ad363c69dd3270a6e5394db8d5a73f741..3b279295ead26409be0c07541d2a59a6183d9d4f 100644 --- a/src/vs/workbench/electron-browser/workbench.ts +++ b/src/vs/workbench/electron-browser/workbench.ts @@ -87,7 +87,7 @@ import { IDecorationsService } from 'vs/workbench/services/decorations/browser/d import { ActivityService } from 'vs/workbench/services/activity/browser/activityService'; import { URI } from 'vs/base/common/uri'; import { IListService, ListService } from 'vs/platform/list/browser/listService'; -import { InputFocusedContext, IsMacContext, IsLinuxContext, IsWindowsContext } from 'vs/platform/contextkey/common/contextkeys'; +import { InputFocusedContext, IsMacContext, IsLinuxContext, IsWindowsContext, SupportsOpenFileFolderContext, SupportsWorkspacesContext } from 'vs/platform/contextkey/common/contextkeys'; import { IViewsService } from 'vs/workbench/common/views'; import { ViewsService } from 'vs/workbench/browser/parts/views/views'; import { INotificationService } from 'vs/platform/notification/common/notification'; @@ -109,7 +109,7 @@ import { ContextViewService } from 'vs/platform/contextview/browser/contextViewS import { WorkbenchThemeService } from 'vs/workbench/services/themes/electron-browser/workbenchThemeService'; import { IWorkbenchThemeService } from 'vs/workbench/services/themes/common/workbenchThemeService'; import { IFileDialogService } from 'vs/platform/dialogs/common/dialogs'; -import { FileDialogService } from 'vs/workbench/services/dialogs/electron-browser/dialogService'; +import { RemoteFileDialogService } from 'vs/workbench/services/dialogs/electron-browser/dialogService'; import { LogStorageAction } from 'vs/platform/storage/node/storageService'; import { Sizing, Direction, Grid, View } from 'vs/base/browser/ui/grid/grid'; import { IEditor } from 'vs/editor/common/editorCommon'; @@ -425,7 +425,7 @@ export class Workbench extends Disposable implements IPartService { serviceCollection.set(IHistoryService, new SyncDescriptor(HistoryService)); // File Dialogs - serviceCollection.set(IFileDialogService, new SyncDescriptor(FileDialogService)); + serviceCollection.set(IFileDialogService, new SyncDescriptor(RemoteFileDialogService)); // Backup File Service if (this.workbenchParams.configuration.backupPath) { @@ -627,6 +627,12 @@ export class Workbench extends Disposable implements IPartService { IsMacContext.bindTo(this.contextKeyService); IsLinuxContext.bindTo(this.contextKeyService); IsWindowsContext.bindTo(this.contextKeyService); + const supportsOpenFileFolderContextKey = SupportsOpenFileFolderContext.bindTo(this.contextKeyService); + const supportsWorkspacesContextKey = SupportsWorkspacesContext.bindTo(this.contextKeyService); + if (this.windowService.getConfiguration().remoteAuthority) { + supportsOpenFileFolderContextKey.set(true); + supportsWorkspacesContextKey.set(false); + } const sidebarVisibleContextRaw = new RawContextKey('sidebarVisible', false); this.sideBarVisibleContext = sidebarVisibleContextRaw.bindTo(this.contextKeyService); diff --git a/src/vs/workbench/services/dialogs/electron-browser/dialogService.ts b/src/vs/workbench/services/dialogs/electron-browser/dialogService.ts index f34d75f6120927406fc09605898a6eb5998305b0..4e3c58dac66892df5618d05e40b32971382629e6 100644 --- a/src/vs/workbench/services/dialogs/electron-browser/dialogService.ts +++ b/src/vs/workbench/services/dialogs/electron-browser/dialogService.ts @@ -15,9 +15,12 @@ import { IWorkspaceContextService, WorkbenchState } from 'vs/platform/workspace/ import { IHistoryService } from 'vs/workbench/services/history/common/history'; import { IEnvironmentService } from 'vs/platform/environment/common/environment'; import { URI } from 'vs/base/common/uri'; +import { REMOTE_HOST_SCHEME } from 'vs/platform/remote/common/remoteHosts'; import { Schemas } from 'vs/base/common/network'; import * as resources from 'vs/base/common/resources'; import { isParent } from 'vs/platform/files/common/files'; +import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; +import { RemoteFileDialog } from 'vs/workbench/services/dialogs/electron-browser/remoteFileDialog'; interface IMassagedMessageBoxOptions { @@ -160,7 +163,8 @@ export class FileDialogService implements IFileDialogService { @IWindowService private readonly windowService: IWindowService, @IWorkspaceContextService private readonly contextService: IWorkspaceContextService, @IHistoryService private readonly historyService: IHistoryService, - @IEnvironmentService private readonly environmentService: IEnvironmentService + @IEnvironmentService private readonly environmentService: IEnvironmentService, + @IInstantiationService private readonly instantiationService: IInstantiationService, ) { } defaultFilePath(schemeFilter: string): URI | undefined { @@ -273,6 +277,11 @@ export class FileDialogService implements IFileDialogService { }); } + public showSaveRemoteDialog(options: ISaveDialogOptions): Promise { + const remoteFileDialog = this.instantiationService.createInstance(RemoteFileDialog); + return remoteFileDialog.showSaveDialog(options); + } + showOpenDialog(options: IOpenDialogOptions): Promise { const defaultUri = options.defaultUri; if (defaultUri && defaultUri.scheme !== Schemas.file) { @@ -303,6 +312,32 @@ export class FileDialogService implements IFileDialogService { return this.windowService.showOpenDialog(newOptions).then(result => result ? result.map(URI.file) : undefined); } + + public showOpenRemoteDialog(options: IOpenDialogOptions): Promise { + const remoteFileDialog = this.instantiationService.createInstance(RemoteFileDialog); + return remoteFileDialog.showOpenDialog(options); + } +} + +export class RemoteFileDialogService extends FileDialogService { + + constructor( + @IWindowService windowService: IWindowService, + @IWorkspaceContextService contextService: IWorkspaceContextService, + @IHistoryService historyService: IHistoryService, + @IEnvironmentService environmentService: IEnvironmentService, + @IInstantiationService instantiationService: IInstantiationService, + ) { + super(windowService, contextService, historyService, environmentService, instantiationService); + } + + public showSaveDialog(options: ISaveDialogOptions): Promise { + const defaultUri = options.defaultUri; + if (defaultUri && defaultUri.scheme === REMOTE_HOST_SCHEME) { + return this.showSaveRemoteDialog(options); + } + return super.showSaveDialog(options); + } } function isUntitledWorkspace(path: string, environmentService: IEnvironmentService): boolean { diff --git a/src/vs/workbench/services/dialogs/electron-browser/remoteFileDialog.ts b/src/vs/workbench/services/dialogs/electron-browser/remoteFileDialog.ts new file mode 100644 index 0000000000000000000000000000000000000000..bfd6b0d74e624dcdb2dfa6249e32f8fa98c8ecdf --- /dev/null +++ b/src/vs/workbench/services/dialogs/electron-browser/remoteFileDialog.ts @@ -0,0 +1,319 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as nls from 'vs/nls'; +import * as resources from 'vs/base/common/resources'; +import { RemoteFileService } from 'vs/workbench/services/files/electron-browser/remoteFileService'; +import { IFileService } from 'vs/platform/files/common/files'; +import { IQuickInputService, IQuickPickItem, IQuickPick } from 'vs/platform/quickinput/common/quickInput'; +import { URI } from 'vs/base/common/uri'; +import { isWindows } from 'vs/base/common/platform'; +import { ISaveDialogOptions, IOpenDialogOptions } from 'vs/platform/dialogs/common/dialogs'; +import { REMOTE_HOST_SCHEME } from 'vs/platform/remote/common/remoteHosts'; +import { IWindowService } from 'vs/platform/windows/common/windows'; +import { ILabelService } from 'vs/platform/label/common/label'; +import { IWorkspaceContextService } from 'vs/platform/workspace/common/workspace'; +import { IEditorService } from 'vs/workbench/services/editor/common/editorService'; +import { INotificationService } from 'vs/platform/notification/common/notification'; + +interface FileQuickPickItem extends IQuickPickItem { + uri: URI; + isFolder: boolean; +} + +// Reference: https://en.wikipedia.org/wiki/Filename +const INVALID_FILE_CHARS = isWindows ? /[\\/:\*\?"<>\|]/g : /[\\/]/g; +const WINDOWS_FORBIDDEN_NAMES = /^(con|prn|aux|clock\$|nul|lpt[0-9]|com[0-9])$/i; + +export class RemoteFileDialog { + + private acceptButton = { iconPath: this.getIcons('accept.svg'), tooltip: 'Select' }; + private cancelButton = { iconPath: this.getIcons('cancel.svg'), tooltip: 'Cancel' }; + private currentFolder: URI; + private filePickBox: IQuickPick; + private allowFileSelection: boolean; + private allowFolderSelection: boolean; + private remoteAuthority: string; + + constructor( + @IFileService private readonly remoteFileService: RemoteFileService, + @IQuickInputService private readonly quickInputService: IQuickInputService, + @IWindowService private readonly windowService: IWindowService, + @ILabelService private readonly labelService: ILabelService, + @IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService, + @IEditorService private readonly editorService: IEditorService, + @INotificationService private readonly notificationService: INotificationService, + ) { + this.remoteAuthority = this.windowService.getConfiguration().remoteAuthority; + } + + public async showOpenDialog(options: IOpenDialogOptions = {}): Promise { + if (!this.remoteAuthority) { + this.notificationService.info(nls.localize('remoteFileDialog.notConnectedToRemote', 'Not connected to a remote.')); + return Promise.resolve(undefined); + } + const defaultUri = options.defaultUri ? options.defaultUri : URI.from({ scheme: REMOTE_HOST_SCHEME, authority: this.remoteAuthority, path: '/' }); + const title = nls.localize('remoteFileDialog.openTitle', 'Open File or Folder'); + return this.pickResource({ title, defaultUri, canSelectFiles: true, canSelectFolders: true }).then(async fileFolderUri => { + if (fileFolderUri) { + const stat = await this.remoteFileService.resolveFile(fileFolderUri); + if (stat.isDirectory) { + return this.windowService.openWindow([{ uri: fileFolderUri, typeHint: 'folder' }]); + } else { + return this.editorService.openEditor({ resource: fileFolderUri }).then(() => { + return Promise.resolve(undefined); + }); + } + } + return Promise.resolve(undefined); + }); + } + + public showSaveDialog(options: ISaveDialogOptions): Promise { + if (!this.remoteAuthority) { + this.notificationService.info(nls.localize('remoteFileDialog.notConnectedToRemote', 'Not connected to a remote.')); + return Promise.resolve(undefined); + } + const defaultUri = options.defaultUri ? options.defaultUri : URI.from({ scheme: REMOTE_HOST_SCHEME, authority: this.remoteAuthority, path: '/' }); + return new Promise((resolve) => { + let saveNameBox = this.quickInputService.createInputBox(); + saveNameBox.title = options.title; + saveNameBox.placeholder = nls.localize('remoteFileDialog.saveTitle', 'Enter the new name of the file'); + saveNameBox.value = ''; + saveNameBox.totalSteps = 2; + saveNameBox.step = 1; + saveNameBox.onDidChangeValue(v => { + saveNameBox.validationMessage = this.isValidBaseName(v) ? void 0 : nls.localize('remoteFileDialog.error.invalidfilename', 'Not a valid file name'); + }); + saveNameBox.onDidAccept(_ => { + const name = saveNameBox.value; + if (this.isValidBaseName(name)) { + saveNameBox.hide(); + this.pickResource({ defaultUri: defaultUri, canSelectFolders: true, title: nls.localize('remoteFileDialogerror.titleFolderPage', 'Folder for \'{0}\'', name) }, { step: 2, totalSteps: 2 }).then(folderUri => { + if (folderUri) { + resolve(this.remoteUriFrom(this.remotePathJoin(folderUri, name))); + } else { + resolve(undefined); + } + saveNameBox.dispose(); + }); + } + }); + saveNameBox.show(); + }); + } + + private remoteUriFrom(path: string): URI { + return URI.from({ scheme: REMOTE_HOST_SCHEME, authority: this.remoteAuthority, path }); + } + + private remotePathJoin(firstPart: URI, secondPart: string): string { + return this.labelService.getUriLabel(resources.joinPath(firstPart, secondPart)); + } + + private async pickResource(options: IOpenDialogOptions, multiOpts?: { step: number; totalSteps: number; }): Promise { + this.allowFolderSelection = !!options.canSelectFolders; + this.allowFileSelection = !!options.canSelectFiles; + const defaultUri = options.defaultUri; + let homedir = defaultUri && defaultUri.scheme === REMOTE_HOST_SCHEME ? defaultUri : this.workspaceContextService.getWorkspace().folders[0].uri; + + return new Promise((resolve) => { + this.filePickBox = this.quickInputService.createQuickPick(); + if (multiOpts) { + this.filePickBox.totalSteps = multiOpts.totalSteps; + this.filePickBox.step = multiOpts.step; + } + + let isResolved = false; + let isAcceptHandled = false; + + this.currentFolder = homedir; + this.filePickBox.buttons = [this.acceptButton, this.cancelButton]; + this.filePickBox.onDidTriggerButton(button => { + if (button === this.acceptButton) { + resolve(this.currentFolder); + isResolved = true; + } + this.filePickBox.hide(); + }); + this.filePickBox.title = options.title; + this.filePickBox.placeholder = this.labelService.getUriLabel(this.currentFolder, { endWithSeparator: true }); + this.filePickBox.items = []; + this.filePickBox.onDidAccept(_ => { + if (isAcceptHandled || this.filePickBox.busy) { + return; + } + isAcceptHandled = true; + if (this.filePickBox.activeItems.length === 0) { + if (this.allowFolderSelection) { + resolve(this.currentFolder); + isResolved = true; + this.filePickBox.hide(); + } + } else if (this.filePickBox.activeItems.length === 1) { + const item = this.filePickBox.selectedItems[0]; + if (item) { + if (!item.isFolder) { + resolve(item.uri); + isResolved = true; + this.filePickBox.hide(); + } else { + this.updateItems(item.uri); + } + } + } + }); + this.filePickBox.onDidChangeActive(i => { + isAcceptHandled = false; + }); + + let to: NodeJS.Timer | undefined; + this.filePickBox.onDidChangeValue(value => { + if (to) { + clearTimeout(to); + } + if (this.endsWithSlash(value)) { + to = undefined; + this.onValueChange(); + } else { + to = setTimeout(this.onValueChange, 300); + } + }); + this.filePickBox.onDidHide(() => { + if (!isResolved) { + resolve(undefined); + } + this.filePickBox.dispose(); + }); + + this.filePickBox.show(); + this.updateItems(homedir); + }); + } + + private async onValueChange() { + if (this.filePickBox) { + let fullPath = this.remoteUriFrom(this.filePickBox.value); + let stat = await this.remoteFileService.resolveFile(fullPath); + if (!stat.isDirectory && this.allowFileSelection) { + this.updateItems(resources.dirname(fullPath)); + this.filePickBox.value = resources.basename(fullPath); + } else if (stat.isDirectory) { + this.updateItems(fullPath); + } + } + } + + private updateItems(newFolder: URI) { + this.currentFolder = newFolder; + this.filePickBox.placeholder = this.labelService.getUriLabel(newFolder, { endWithSeparator: true }); + this.filePickBox.value = ''; + this.filePickBox.busy = true; + this.createItems(this.currentFolder).then(items => { + this.filePickBox.items = items; + if (this.allowFolderSelection) { + this.filePickBox.activeItems = []; + } + this.filePickBox.busy = false; + }); + } + + private isValidBaseName(name: string): boolean { + if (!name || name.length === 0 || /^\s+$/.test(name)) { + return false; // require a name that is not just whitespace + } + + INVALID_FILE_CHARS.lastIndex = 0; // the holy grail of software development + if (INVALID_FILE_CHARS.test(name)) { + return false; // check for certain invalid file characters + } + + if (isWindows && WINDOWS_FORBIDDEN_NAMES.test(name)) { + return false; // check for certain invalid file names + } + + if (name === '.' || name === '..') { + return false; // check for reserved values + } + + if (isWindows && name[name.length - 1] === '.') { + return false; // Windows: file cannot end with a "." + } + + if (isWindows && name.length !== name.trim().length) { + return false; // Windows: file cannot end with a whitespace + } + + return true; + } + + private endsWithSlash(s: string) { + return /[\/\\]$/.test(s); + } + + private basenameWithTrailingSlash(fullPath: URI): string { + const child = this.labelService.getUriLabel(fullPath, { endWithSeparator: true }); + const parent = this.labelService.getUriLabel(resources.dirname(fullPath), { endWithSeparator: true }); + return child.substring(parent.length); + } + + private createBackItem(currFolder: URI): FileQuickPickItem | null { + const parentFolder = resources.dirname(currFolder); + if (!resources.isEqual(currFolder, parentFolder)) { + return { label: '..', uri: resources.dirname(currFolder), isFolder: true }; + } + return null; + } + + private async createItems(currentFolder: URI): Promise { + const result: FileQuickPickItem[] = []; + + const backDir = this.createBackItem(currentFolder); + if (backDir) { + result.push(backDir); + } + try { + const fileNames = await this.remoteFileService.readFolder(currentFolder); + const items = await Promise.all(fileNames.map(fileName => this.createItem(fileName, currentFolder))); + for (let item of items) { + if (item) { + result.push(item); + } + } + } catch (e) { + // ignore + console.log(e); + } + return result.sort((i1, i2) => { + if (i1.isFolder !== i2.isFolder) { + return i1.isFolder ? -1 : 1; + } + return i1.label.localeCompare(i2.label); + }); + } + + private async createItem(filename: string, parent: URI): Promise { + let fullPath = resources.joinPath(parent, filename); + try { + const stat = await this.remoteFileService.resolveFile(fullPath); + if (stat.isDirectory) { + filename = this.basenameWithTrailingSlash(fullPath); + return { label: filename, uri: fullPath, isFolder: true }; + } else if (!stat.isDirectory && this.allowFileSelection) { + return { label: filename, uri: fullPath, isFolder: false }; + } + return null; + } catch (e) { + return null; + } + } + + private getIcons(name: string): { light: URI, dark: URI } { + return { + light: URI.parse(require.toUrl(`vs/workbench/services/dialogs/media/light/${name}`)), + dark: URI.parse(require.toUrl(`vs/workbench/services/dialogs/media/dark/${name}`)) + }; + } +} \ No newline at end of file diff --git a/src/vs/workbench/services/dialogs/media/dark/accept.svg b/src/vs/workbench/services/dialogs/media/dark/accept.svg new file mode 100644 index 0000000000000000000000000000000000000000..c225b2f597f343c63e6817ab7a44c987a7106c98 --- /dev/null +++ b/src/vs/workbench/services/dialogs/media/dark/accept.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/vs/workbench/services/dialogs/media/dark/cancel.svg b/src/vs/workbench/services/dialogs/media/dark/cancel.svg new file mode 100644 index 0000000000000000000000000000000000000000..751e89b3b0215f74d84195d1dea54ca0c25f91c3 --- /dev/null +++ b/src/vs/workbench/services/dialogs/media/dark/cancel.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/vs/workbench/services/dialogs/media/light/accept.svg b/src/vs/workbench/services/dialogs/media/light/accept.svg new file mode 100644 index 0000000000000000000000000000000000000000..d45df06edf8100df8309b86611114779c61b09e7 --- /dev/null +++ b/src/vs/workbench/services/dialogs/media/light/accept.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/vs/workbench/services/dialogs/media/light/cancel.svg b/src/vs/workbench/services/dialogs/media/light/cancel.svg new file mode 100644 index 0000000000000000000000000000000000000000..fde34404d4eb8537f8bf76eb9f76127872ef68ba --- /dev/null +++ b/src/vs/workbench/services/dialogs/media/light/cancel.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/vs/workbench/services/label/common/labelService.ts b/src/vs/workbench/services/label/common/labelService.ts index d92d21679ac2899ca2254c1249e016cd9b8b3b16..5cabc34cee5bd60213450fecbadaf88b87e467b9 100644 --- a/src/vs/workbench/services/label/common/labelService.ts +++ b/src/vs/workbench/services/label/common/labelService.ts @@ -14,7 +14,7 @@ import { IWorkspaceContextService, IWorkspace } from 'vs/platform/workspace/comm import { isEqual, basenameOrAuthority, isEqualOrParent, basename, joinPath, dirname } from 'vs/base/common/resources'; import { isLinux, isWindows } from 'vs/base/common/platform'; import { tildify, getPathLabel } from 'vs/base/common/labels'; -import { ltrim } from 'vs/base/common/strings'; +import { ltrim, endsWith } from 'vs/base/common/strings'; import { IWorkspaceIdentifier, ISingleFolderWorkspaceIdentifier, isSingleFolderWorkspaceIdentifier, WORKSPACE_EXTENSION, toWorkspaceIdentifier, isWorkspaceIdentifier } from 'vs/platform/workspaces/common/workspaces'; import { Schemas } from 'vs/base/common/network'; import { IWindowService } from 'vs/platform/windows/common/windows'; @@ -130,12 +130,14 @@ export class LabelService implements ILabelService { return bestResult ? bestResult.formatting : undefined; } - getUriLabel(resource: URI, options: { relative?: boolean, noPrefix?: boolean } = {}): string { + getUriLabel(resource: URI, options: { relative?: boolean, noPrefix?: boolean, endWithSeparator?: boolean } = {}): string { const formatting = this.findFormatting(resource); if (!formatting) { return getPathLabel(resource.path, this.environmentService, options.relative ? this.contextService : undefined); } + let label: string; + if (options.relative) { const baseResource = this.contextService && this.contextService.getWorkspaceFolder(resource); if (baseResource) { @@ -153,11 +155,13 @@ export class LabelService implements ILabelService { relativeLabel = relativeLabel ? (rootName + ' • ' + relativeLabel) : rootName; // always show root basename if there are multiple } - return relativeLabel; + label = relativeLabel; } + } else { + label = this.formatUri(resource, formatting, options.noPrefix); } - return this.formatUri(resource, formatting, options.noPrefix); + return options.endWithSeparator ? this.appendSeparatorIfMissing(label, formatting) : label; } getWorkspaceLabel(workspace: (IWorkspaceIdentifier | ISingleFolderWorkspaceIdentifier | IWorkspace), options?: { verbose: boolean }): string { @@ -247,4 +251,12 @@ export class LabelService implements ILabelService { return label.replace(sepRegexp, formatting.separator); } + + private appendSeparatorIfMissing(label: string, formatting: ResourceLabelFormatting): string { + let appendedLabel = label; + if (!endsWith(label, formatting.separator)) { + appendedLabel += formatting.separator; + } + return appendedLabel; + } } diff --git a/src/vs/workbench/services/textfile/common/textFileService.ts b/src/vs/workbench/services/textfile/common/textFileService.ts index 1b8e23675a7c7fb44c0b22e373b6204c1a5cd72b..2a85b26c188d5b3a62256f1ce8a0d5c4f2f6ac38 100644 --- a/src/vs/workbench/services/textfile/common/textFileService.ts +++ b/src/vs/workbench/services/textfile/common/textFileService.ts @@ -32,6 +32,7 @@ import { createTextBufferFactoryFromSnapshot } from 'vs/editor/common/model/text import { IModelService } from 'vs/editor/common/services/modelService'; import { INotificationService } from 'vs/platform/notification/common/notification'; import { isEqualOrParent, isEqual, joinPath, dirname } from 'vs/base/common/resources'; +import { REMOTE_HOST_SCHEME } from 'vs/platform/remote/common/remoteHosts'; export interface IBackupResult { didBackup: boolean; @@ -434,7 +435,8 @@ export abstract class TextFileService extends Disposable implements ITextFileSer // Untitled with associated file path don't need to prompt if (this.untitledEditorService.hasAssociatedFilePath(untitled)) { - targetUri = untitled.with({ scheme: Schemas.file }); + const authority = this.windowService.getConfiguration().remoteAuthority; + targetUri = authority ? untitled.with({ scheme: REMOTE_HOST_SCHEME, authority }) : untitled.with({ scheme: Schemas.file }); } // Otherwise ask user @@ -616,7 +618,8 @@ export abstract class TextFileService extends Disposable implements ITextFileSer private suggestFileName(untitledResource: URI): URI { const untitledFileName = this.untitledEditorService.suggestFileName(untitledResource); - const schemeFilter = Schemas.file; + const remoteAuthority = this.windowService.getConfiguration().remoteAuthority; + const schemeFilter = remoteAuthority ? REMOTE_HOST_SCHEME : Schemas.file; const lastActiveFile = this.historyService.getLastActiveFile(schemeFilter); if (lastActiveFile) { @@ -629,7 +632,7 @@ export abstract class TextFileService extends Disposable implements ITextFileSer return joinPath(lastActiveFolder, untitledFileName); } - return URI.file(untitledFileName); + return schemeFilter === Schemas.file ? URI.file(untitledFileName) : URI.from({ scheme: schemeFilter, authority: remoteAuthority, path: untitledFileName }); } revert(resource: URI, options?: IRevertOptions): Promise {