From 82309adf49b97370cd4c14d5903f1042871c82ca Mon Sep 17 00:00:00 2001 From: Benjamin Pasero Date: Tue, 6 Feb 2018 12:11:28 +0100 Subject: [PATCH] Leverage native clipboard for file copy/paste (#43029) * wip * wire copy action into clipboard * add tests * fix compile * macOS: leverage NSFilenamesPboardType --- .../snippet/test/snippetVariables.test.ts | 6 + .../clipboard/common/clipboardService.ts | 16 ++ .../electron-browser/clipboardService.ts | 82 ++++++++++- .../electron-browser/clipboardService.test.ts | 34 +++++ .../fileActions.contribution.ts | 2 +- .../files/electron-browser/fileActions.ts | 138 +++++++++--------- .../electron-browser/views/explorerViewer.ts | 12 +- 7 files changed, 215 insertions(+), 75 deletions(-) create mode 100644 src/vs/platform/clipboard/test/electron-browser/clipboardService.test.ts diff --git a/src/vs/editor/contrib/snippet/test/snippetVariables.test.ts b/src/vs/editor/contrib/snippet/test/snippetVariables.test.ts index 7eb0f74f147..743b5322b1c 100644 --- a/src/vs/editor/contrib/snippet/test/snippetVariables.test.ts +++ b/src/vs/editor/contrib/snippet/test/snippetVariables.test.ts @@ -217,6 +217,9 @@ suite('Snippet Variables Resolver', function () { writeText = this._throw; readFindText = this._throw; writeFindText = this._throw; + writeFiles = this._throw; + readFiles = this._throw; + hasFiles = this._throw; }; let resolver = new ClipboardBasedVariableResolver(clipboardService, 1, 0); @@ -247,6 +250,9 @@ suite('Snippet Variables Resolver', function () { writeText = this._throw; readFindText = this._throw; writeFindText = this._throw; + writeFiles = this._throw; + readFiles = this._throw; + hasFiles = this._throw; }; resolver = new ClipboardBasedVariableResolver(clipboardService, 1, 2); diff --git a/src/vs/platform/clipboard/common/clipboardService.ts b/src/vs/platform/clipboard/common/clipboardService.ts index ea350cacae3..cbcb41ffad0 100644 --- a/src/vs/platform/clipboard/common/clipboardService.ts +++ b/src/vs/platform/clipboard/common/clipboardService.ts @@ -6,6 +6,7 @@ 'use strict'; import { createDecorator } from 'vs/platform/instantiation/common/instantiation'; +import URI from 'vs/base/common/uri'; export const IClipboardService = createDecorator('clipboardService'); @@ -32,4 +33,19 @@ export interface IClipboardService { * Writes text to the system find pasteboard. */ writeFindText(text: string): void; + + /** + * Writes files to the system clipboard. + */ + writeFiles(files: URI[]): void; + + /** + * Reads files from the system clipboard. + */ + readFiles(): URI[]; + + /** + * Find out if files are copied to the clipboard. + */ + hasFiles(): boolean; } diff --git a/src/vs/platform/clipboard/electron-browser/clipboardService.ts b/src/vs/platform/clipboard/electron-browser/clipboardService.ts index d779be48e6c..89cb2c51ba0 100644 --- a/src/vs/platform/clipboard/electron-browser/clipboardService.ts +++ b/src/vs/platform/clipboard/electron-browser/clipboardService.ts @@ -7,10 +7,17 @@ import { IClipboardService } from 'vs/platform/clipboard/common/clipboardService'; import { clipboard } from 'electron'; -import * as platform from 'vs/base/common/platform'; +import URI from 'vs/base/common/uri'; +import { isMacintosh } from 'vs/base/common/platform'; +import { parse } from 'fast-plist'; export class ClipboardService implements IClipboardService { + // Clipboard format for files + // Windows/Linux: custom + // macOS: native, see https://developer.apple.com/documentation/appkit/nsfilenamespboardtype + private static FILE_FORMAT = isMacintosh ? 'NSFilenamesPboardType' : 'code/file-list'; + _serviceBrand: any; public writeText(text: string): void { @@ -22,15 +29,82 @@ export class ClipboardService implements IClipboardService { } public readFindText(): string { - if (platform.isMacintosh) { + if (isMacintosh) { return clipboard.readFindText(); } + return ''; } public writeFindText(text: string): void { - if (platform.isMacintosh) { + if (isMacintosh) { clipboard.writeFindText(text); } } -} + + public writeFiles(resources: URI[]): void { + const files = resources.filter(f => f.scheme === 'file'); + + if (files.length) { + clipboard.writeBuffer(ClipboardService.FILE_FORMAT, this.filesToBuffer(files)); + } + } + + public readFiles(): URI[] { + return this.bufferToFiles(clipboard.readBuffer(ClipboardService.FILE_FORMAT)); + } + + public hasFiles(): boolean { + return clipboard.has(ClipboardService.FILE_FORMAT); + } + + private filesToBuffer(resources: URI[]): Buffer { + if (isMacintosh) { + return this.macOSFilesToBuffer(resources); + } + + return new Buffer(resources.map(r => r.fsPath).join('\n')); + } + + private bufferToFiles(buffer: Buffer): URI[] { + if (!buffer) { + return []; + } + + const bufferValue = buffer.toString(); + if (!bufferValue) { + return []; + } + + try { + if (isMacintosh) { + return this.macOSBufferToFiles(bufferValue); + } + + return bufferValue.split('\n').map(f => URI.file(f)); + } catch (error) { + return []; // do not trust clipboard data + } + } + + private macOSFilesToBuffer(resources: URI[]): Buffer { + return new Buffer(` + + + + + ${resources.map(r => `${r.fsPath}`).join('\n')} + + + `); + } + + private macOSBufferToFiles(buffer: string): URI[] { + const result = parse(buffer) as string[]; + if (Array.isArray(result)) { + return result.map(f => URI.file(f)); + } + + return []; + } +} \ No newline at end of file diff --git a/src/vs/platform/clipboard/test/electron-browser/clipboardService.test.ts b/src/vs/platform/clipboard/test/electron-browser/clipboardService.test.ts new file mode 100644 index 00000000000..78b9a8cd2c5 --- /dev/null +++ b/src/vs/platform/clipboard/test/electron-browser/clipboardService.test.ts @@ -0,0 +1,34 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { ClipboardService } from 'vs/platform/clipboard/electron-browser/clipboardService'; +import URI from 'vs/base/common/uri'; + +suite('ClipboardService', () => { + test('writeFiles, hasFiles, readFiles', function () { + const clipboardService = new ClipboardService(); + + clipboardService.writeText('test'); + assert.ok(!clipboardService.hasFiles()); + + const files: URI[] = []; + files.push(URI.file('/test/file.txt')); + files.push(URI.file('/test/otherfile.txt')); + + clipboardService.writeFiles(files); + + assert.ok(clipboardService.hasFiles()); + + const clipboardFiles = clipboardService.readFiles(); + assert.equal(clipboardFiles.length, 2); + assert.equal(clipboardFiles[0].fsPath, files[0].fsPath); + assert.equal(clipboardFiles[1].fsPath, files[1].fsPath); + + clipboardService.writeText('test'); + assert.ok(!clipboardService.hasFiles()); + }); +}); \ No newline at end of file diff --git a/src/vs/workbench/parts/files/electron-browser/fileActions.contribution.ts b/src/vs/workbench/parts/files/electron-browser/fileActions.contribution.ts index 743441bf0a1..fe2d75be9bd 100644 --- a/src/vs/workbench/parts/files/electron-browser/fileActions.contribution.ts +++ b/src/vs/workbench/parts/files/electron-browser/fileActions.contribution.ts @@ -95,7 +95,7 @@ const PASTE_FILE_ID = 'filesExplorer.paste'; KeybindingsRegistry.registerCommandAndKeybindingRule({ id: PASTE_FILE_ID, weight: KeybindingsRegistry.WEIGHT.workbenchContrib(explorerCommandsWeightBonus), - when: ContextKeyExpr.and(FilesExplorerFocusCondition, FileCopiedContext), + when: ContextKeyExpr.and(FilesExplorerFocusCondition), primary: KeyMod.CtrlCmd | KeyCode.KEY_V, handler: pasteFileHandler }); diff --git a/src/vs/workbench/parts/files/electron-browser/fileActions.ts b/src/vs/workbench/parts/files/electron-browser/fileActions.ts index 952968f7641..355efa87c57 100644 --- a/src/vs/workbench/parts/files/electron-browser/fileActions.ts +++ b/src/vs/workbench/parts/files/electron-browser/fileActions.ts @@ -50,8 +50,8 @@ import { IModeService } from 'vs/editor/common/services/modeService'; import { IModelService } from 'vs/editor/common/services/modelService'; import { ICommandService, CommandsRegistry } from 'vs/platform/commands/common/commands'; import { IListService, ListWidget } from 'vs/platform/list/browser/listService'; -import { RawContextKey, IContextKey, IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; -import { distinctParents } from 'vs/base/common/resources'; +import { RawContextKey, IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; +import { distinctParents, basenameOrAuthority } from 'vs/base/common/resources'; export interface IEditableData { action: IAction; @@ -873,9 +873,6 @@ export class ImportFileAction extends BaseFileAction { } // Copy File/Folder -let filesToCopy: FileStat[]; -let fileCopiedContextKey: IContextKey; - class CopyFileAction extends BaseFileAction { private tree: ITree; @@ -885,22 +882,19 @@ class CopyFileAction extends BaseFileAction { @IFileService fileService: IFileService, @IMessageService messageService: IMessageService, @ITextFileService textFileService: ITextFileService, - @IContextKeyService contextKeyService: IContextKeyService + @IContextKeyService contextKeyService: IContextKeyService, + @IClipboardService private clipboardService: IClipboardService ) { super('filesExplorer.copy', COPY_FILE_LABEL, fileService, messageService, textFileService); this.tree = tree; - if (!fileCopiedContextKey) { - fileCopiedContextKey = FileCopiedContext.bindTo(contextKeyService); - } this._updateEnablement(); } public run(): TPromise { - // Remember as file/folder to copy - filesToCopy = this.elements; - fileCopiedContextKey.set(!!filesToCopy.length); + // Write to clipboard as file/folder to copy + this.clipboardService.writeFiles(this.elements.map(e => e.resource)); // Remove highlight if (this.tree) { @@ -926,7 +920,7 @@ class PasteFileAction extends BaseFileAction { @IFileService fileService: IFileService, @IMessageService messageService: IMessageService, @ITextFileService textFileService: ITextFileService, - @IInstantiationService private instantiationService: IInstantiationService + @IWorkbenchEditorService private editorService: IWorkbenchEditorService ) { super(PasteFileAction.ID, PASTE_FILE_LABEL, fileService, messageService, textFileService); @@ -939,33 +933,42 @@ class PasteFileAction extends BaseFileAction { this._updateEnablement(); } - public run(fileToCopy: FileStat): TPromise { - - const exists = fileToCopy.root.find(fileToCopy.resource); - if (!exists) { - fileToCopy = null; - fileCopiedContextKey.set(false); - throw new Error(nls.localize('fileDeleted', "File was deleted or moved meanwhile")); - } + public run(fileToPaste: URI): TPromise { // Check if target is ancestor of pasted folder - if (this.element.resource.toString() !== fileToCopy.resource.toString() && resources.isEqualOrParent(this.element.resource, fileToCopy.resource, !isLinux /* ignorecase */)) { - throw new Error(nls.localize('fileIsAncestor', "File to copy is an ancestor of the desitnation folder")); + if (this.element.resource.toString() !== fileToPaste.toString() && resources.isEqualOrParent(this.element.resource, fileToPaste, !isLinux /* ignorecase */)) { + throw new Error(nls.localize('fileIsAncestor', "File to paste is an ancestor of the destination folder")); } - // Find target - let target: FileStat; - if (this.element.resource.toString() === fileToCopy.resource.toString()) { - target = this.element.parent; - } else { - target = this.element.isDirectory ? this.element : this.element.parent; - } + return this.fileService.resolveFile(fileToPaste).then(fileToPasteStat => { - // Reuse duplicate action - const pasteAction = this.instantiationService.createInstance(DuplicateFileAction, this.tree, fileToCopy, target); + // Remove highlight + if (this.tree) { + this.tree.clearHighlight(); + } + + // Find target + let target: FileStat; + if (this.element.resource.toString() === fileToPaste.toString()) { + target = this.element.parent; + } else { + target = this.element.isDirectory ? this.element : this.element.parent; + } + + const targetFile = findValidPasteFileTarget(target, { resource: fileToPaste, isDirectory: fileToPasteStat.isDirectory }); + + // Copy File + return this.fileService.copyFile(fileToPaste, targetFile).then(stat => { + if (!stat.isDirectory) { + return this.editorService.openEditor({ resource: stat.resource, options: { pinned: true } }); + } - return pasteAction.run().then(() => { - this.tree.DOMFocus(); + return void 0; + }, error => this.onError(error)).then(() => { + this.tree.DOMFocus(); + }); + }, error => { + this.onError(new Error(nls.localize('fileDeleted', "File to paste was deleted or moved meanwhile"))); }); } } @@ -973,11 +976,11 @@ class PasteFileAction extends BaseFileAction { // Duplicate File/Folder export class DuplicateFileAction extends BaseFileAction { private tree: ITree; - private target: IFileStat; + private target: FileStat; constructor( tree: ITree, - element: FileStat, + fileToDuplicate: FileStat, target: FileStat, @IFileService fileService: IFileService, @IWorkbenchEditorService private editorService: IWorkbenchEditorService, @@ -987,8 +990,8 @@ export class DuplicateFileAction extends BaseFileAction { super('workbench.files.action.duplicateFile', nls.localize('duplicateFile', "Duplicate"), fileService, messageService, textFileService); this.tree = tree; - this.element = element; - this.target = (target && target.isDirectory) ? target : element.parent; + this.element = fileToDuplicate; + this.target = (target && target.isDirectory) ? target : fileToDuplicate.parent; this._updateEnablement(); } @@ -1000,7 +1003,7 @@ export class DuplicateFileAction extends BaseFileAction { } // Copy File - const result = this.fileService.copyFile(this.element.resource, this.findTarget()).then(stat => { + const result = this.fileService.copyFile(this.element.resource, findValidPasteFileTarget(this.target, { resource: this.element.resource, isDirectory: this.element.isDirectory })).then(stat => { if (!stat.isDirectory) { return this.editorService.openEditor({ resource: stat.resource, options: { pinned: true } }); } @@ -1010,44 +1013,44 @@ export class DuplicateFileAction extends BaseFileAction { return result; } +} - private findTarget(): URI { - let name = this.element.name; - - let candidate = this.target.resource.with({ path: paths.join(this.target.resource.path, name) }); - while (true) { - if (!this.element.root.find(candidate)) { - break; - } +function findValidPasteFileTarget(targetFolder: FileStat, fileToPaste: { resource: URI, isDirectory?: boolean }): URI { + let name = basenameOrAuthority(fileToPaste.resource); - name = this.toCopyName(name, this.element.isDirectory); - candidate = this.target.resource.with({ path: paths.join(this.target.resource.path, name) }); + let candidate = targetFolder.resource.with({ path: paths.join(targetFolder.resource.path, name) }); + while (true) { + if (!targetFolder.root.find(candidate)) { + break; } - return candidate; + name = incrementFileName(name, fileToPaste.isDirectory); + candidate = targetFolder.resource.with({ path: paths.join(targetFolder.resource.path, name) }); } - private toCopyName(name: string, isFolder: boolean): string { + return candidate; +} - // file.1.txt=>file.2.txt - if (!isFolder && name.match(/(.*\.)(\d+)(\..*)$/)) { - return name.replace(/(.*\.)(\d+)(\..*)$/, (match, g1?, g2?, g3?) => { return g1 + (parseInt(g2) + 1) + g3; }); - } +function incrementFileName(name: string, isFolder: boolean): string { - // file.txt=>file.1.txt - const lastIndexOfDot = name.lastIndexOf('.'); - if (!isFolder && lastIndexOfDot >= 0) { - return strings.format('{0}.1{1}', name.substr(0, lastIndexOfDot), name.substr(lastIndexOfDot)); - } + // file.1.txt=>file.2.txt + if (!isFolder && name.match(/(.*\.)(\d+)(\..*)$/)) { + return name.replace(/(.*\.)(\d+)(\..*)$/, (match, g1?, g2?, g3?) => { return g1 + (parseInt(g2) + 1) + g3; }); + } - // folder.1=>folder.2 - if (isFolder && name.match(/(\d+)$/)) { - return name.replace(/(\d+)$/, (match: string, ...groups: any[]) => { return String(parseInt(groups[0]) + 1); }); - } + // file.txt=>file.1.txt + const lastIndexOfDot = name.lastIndexOf('.'); + if (!isFolder && lastIndexOfDot >= 0) { + return strings.format('{0}.1{1}', name.substr(0, lastIndexOfDot), name.substr(lastIndexOfDot)); + } - // file/folder=>file.1/folder.1 - return strings.format('{0}.1', name); + // folder.1=>folder.2 + if (isFolder && name.match(/(\d+)$/)) { + return name.replace(/(\d+)$/, (match: string, ...groups: any[]) => { return String(parseInt(groups[0]) + 1); }); } + + // file/folder=>file.1/folder.1 + return strings.format('{0}.1', name); } // Global Compare with @@ -1587,9 +1590,10 @@ export const copyFileHandler = (accessor: ServicesAccessor) => { export const pasteFileHandler = (accessor: ServicesAccessor) => { const instantationService = accessor.get(IInstantiationService); const listService = accessor.get(IListService); + const clipboardService = accessor.get(IClipboardService); const explorerContext = getContext(listService.lastFocusedList, accessor.get(IViewletService)); - return TPromise.join(distinctParents(filesToCopy, s => s.resource).map(toCopy => { + return TPromise.join(distinctParents(clipboardService.readFiles(), r => r).map(toCopy => { const pasteFileAction = instantationService.createInstance(PasteFileAction, listService.lastFocusedList, explorerContext.stat); return pasteFileAction.run(toCopy); })); diff --git a/src/vs/workbench/parts/files/electron-browser/views/explorerViewer.ts b/src/vs/workbench/parts/files/electron-browser/views/explorerViewer.ts index 965d9ca8476..94f649e84ed 100644 --- a/src/vs/workbench/parts/files/electron-browser/views/explorerViewer.ts +++ b/src/vs/workbench/parts/files/electron-browser/views/explorerViewer.ts @@ -26,7 +26,7 @@ import { IFilesConfiguration, SortOrder } from 'vs/workbench/parts/files/common/ import { ITextFileService } from 'vs/workbench/services/textfile/common/textfiles'; import { FileOperationError, FileOperationResult, IFileService, FileKind } from 'vs/platform/files/common/files'; import { ResourceMap } from 'vs/base/common/map'; -import { DuplicateFileAction, ImportFileAction, IEditableData, IFileViewletState } from 'vs/workbench/parts/files/electron-browser/fileActions'; +import { DuplicateFileAction, ImportFileAction, IEditableData, IFileViewletState, FileCopiedContext } from 'vs/workbench/parts/files/electron-browser/fileActions'; import { IDataSource, ITree, IAccessibilityProvider, IRenderer, ContextMenuEvent, ISorter, IFilter, IDragAndDropData, IDragOverReaction, DRAG_OVER_ACCEPT_BUBBLE_DOWN, DRAG_OVER_ACCEPT_BUBBLE_DOWN_COPY, DRAG_OVER_ACCEPT_BUBBLE_UP, DRAG_OVER_ACCEPT_BUBBLE_UP_COPY, DRAG_OVER_REJECT } from 'vs/base/parts/tree/browser/tree'; import { DesktopDragAndDropData, ExternalElementsDragAndDropData, SimpleFileResourceDragAndDrop } from 'vs/base/parts/tree/browser/treeDnd'; import { ClickBehavior } from 'vs/base/parts/tree/browser/treeDefaults'; @@ -36,7 +36,7 @@ import { IWorkbenchEditorService } from 'vs/workbench/services/editor/common/edi import { IPartService } from 'vs/workbench/services/part/common/partService'; import { IWorkspaceContextService, WorkbenchState } from 'vs/platform/workspace/common/workspace'; import { IConfigurationService, ConfigurationTarget } from 'vs/platform/configuration/common/configuration'; -import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; +import { IContextKeyService, IContextKey } from 'vs/platform/contextkey/common/contextkey'; import { IContextViewService, IContextMenuService } from 'vs/platform/contextview/browser/contextView'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; import { IMessageService, IConfirmation, Severity, IConfirmationResult, getConfirmMessage } from 'vs/platform/message/common/message'; @@ -57,6 +57,7 @@ import { relative } from 'path'; import { DataTransfers } from 'vs/base/browser/dnd'; import { distinctParents } from 'vs/base/common/resources'; import { WorkbenchTree, WorkbenchTreeController } from 'vs/platform/list/browser/listService'; +import { IClipboardService } from 'vs/platform/clipboard/common/clipboardService'; export class FileDataSource implements IDataSource { constructor( @@ -326,7 +327,7 @@ export class FileAccessibilityProvider implements IAccessibilityProvider { // Explorer Controller export class FileController extends WorkbenchTreeController implements IDisposable { - + private fileCopiedContextKey: IContextKey; private contributedContextMenu: IMenu; private toDispose: IDisposable[]; private previousSelectionRangeStop: FileStat; @@ -337,10 +338,12 @@ export class FileController extends WorkbenchTreeController implements IDisposab @ITelemetryService private telemetryService: ITelemetryService, @IMenuService private menuService: IMenuService, @IContextKeyService contextKeyService: IContextKeyService, + @IClipboardService private clipboardService: IClipboardService, @IConfigurationService configurationService: IConfigurationService ) { super({ clickBehavior: ClickBehavior.ON_MOUSE_UP /* do not change to not break DND */ }, configurationService); + this.fileCopiedContextKey = FileCopiedContext.bindTo(contextKeyService); this.toDispose = []; } @@ -446,6 +449,9 @@ export class FileController extends WorkbenchTreeController implements IDisposab tree.setFocus(stat); + // update dynamic contexts + this.fileCopiedContextKey.set(this.clipboardService.hasFiles()); + if (!this.contributedContextMenu) { this.contributedContextMenu = this.menuService.createMenu(MenuId.ExplorerContext, tree.contextKeyService); this.toDispose.push(this.contributedContextMenu); -- GitLab