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

Leverage native clipboard for file copy/paste (#43029)

* wip

* wire copy action into clipboard

* add tests

* fix compile

* macOS: leverage NSFilenamesPboardType
上级 83c35ca9
......@@ -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);
......
......@@ -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<IClipboardService>('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;
}
......@@ -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(`
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<array>
${resources.map(r => `<string>${r.fsPath}</string>`).join('\n')}
</array>
</plist>
`);
}
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
/*---------------------------------------------------------------------------------------------
* 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
......@@ -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
});
......
......@@ -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<boolean>;
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<any> {
// 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<any> {
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<any> {
// 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);
}));
......
......@@ -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<boolean>;
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);
......
Markdown is supported
0% .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册