Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support file operations on non-textual files #63

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ Nonexistent folders are created automatically.
{
"command": "fileutils.copyFileName",
"category": "File",
"title": "Copy file name"
"title": "Copy Name Of Active File"
}
]
```
Expand Down
52 changes: 52 additions & 0 deletions src/ClipboardUtil.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { copy, paste } from 'copy-paste-win32fix';
import { promisify } from 'util';

const clipboardCopy = promisify(copy);
const clipboardPaste = promisify(paste);

const GENERIC_ERROR_MESSAGE = 'Could not perform copy file name to clipboard';

// Possible errors and their suggested solutions
const POSSIBLE_ERROR_MAP: { [errorMessage: string]: string } = {
'spawn xclip ENOENT': 'Please install xclip package (`apt-get install xclip`)'
};

export class ClipboardUtil {
public static async getClipboardContent(): Promise<string> {
try {
return clipboardPaste();
} catch (error) {
this.handleError(error.message);
}
}

public static async setClipboardContent(content: string): Promise<void> {
try {
return clipboardCopy(content);
} catch (error) {
this.handleError(error.message);
}
}

public static handleClipboardError(error: Error): void {
// As explained in BaseFileController.getSourcePath(),
// Whenever the window.activeTextEditor doesn't exist, we attempt to retrieve the source path
// using clipboard manipulations.
// This can lead to errors in unsupported platforms, which are suppressed during tests.
if (POSSIBLE_ERROR_MAP[error.message]) {
return;
}

// If error is not a known clipboard error - re-throw it.
throw (error);
}

private static handleError(errorMessage: string): void {
// Can happen on unsupported platforms (e.g Linux machine without the xclip package installed).
// Attempting to provide a solution according to the error received
const errorSolution = POSSIBLE_ERROR_MAP[errorMessage];
const errorMessageSuffix = errorSolution || errorMessage;

throw new Error(`${GENERIC_ERROR_MESSAGE}: ${errorMessageSuffix}`);
}
}
2 changes: 1 addition & 1 deletion src/command/CopyFileNameCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export class CopyFileNameCommand extends BaseCommand {
}

public async execute(uri?: Uri) {
const sourcePath: string = this.controller.sourcePath;
const sourcePath = await this.controller.getSourcePath();
if (!sourcePath) {
return;
}
Expand Down
51 changes: 44 additions & 7 deletions src/controller/BaseFileController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,10 @@ import { IDialogOptions, IExecuteOptions, IFileController } from './FileControll

import * as fs from 'fs';
import { commands, TextDocument, TextEditor, ViewColumn, window, workspace } from 'vscode';
import { ClipboardUtil } from '../ClipboardUtil';
import { FileItem } from '../Item';

export abstract class BaseFileController implements IFileController {
public get sourcePath(): string {
const activeEditor: TextEditor = window.activeTextEditor;
const document: TextDocument = activeEditor && activeEditor.document;

return document && document.fileName;
}

public abstract async showDialog(options?: IDialogOptions): Promise<FileItem>;

public abstract async execute(options: IExecuteOptions): Promise<FileItem>;
Expand Down Expand Up @@ -39,6 +33,26 @@ export abstract class BaseFileController implements IFileController {
return commands.executeCommand('workbench.action.closeActiveEditor');
}

public async getSourcePath(): Promise<string> {
// Attempting to get the fileName from the activeTextEditor.
// Works for text files only.
const activeEditor: TextEditor = window.activeTextEditor;
if (activeEditor && activeEditor.document && activeEditor.document.fileName) {
return Promise.resolve(activeEditor.document.fileName);
}

// No activeTextEditor means that we don't have an active file or
// the active file is a non-text file (e.g. binary files such as images).
// Since there is no actual API to differentiate between the scenarios, we try to retrieve
// the path for a non-textual file before throwing an error.
const sourcePath = this.getSourcePathForNonTextFile();
if (!sourcePath) {
throw new Error();
}

return sourcePath;
}

protected async ensureWritableFile(fileItem: FileItem): Promise<FileItem> {
if (!fileItem.exists) {
return fileItem;
Expand All @@ -52,4 +66,27 @@ export abstract class BaseFileController implements IFileController {
}
throw new Error();
}

private async getSourcePathForNonTextFile(): Promise<string> {
// Since there is no API to get details of non-textual files, the following workaround is performed:
// 1. Saving the original clipboard data to a local variable.
const originalClipboardData = await ClipboardUtil.getClipboardContent();

// 2. Populating the clipboard with an empty string
await ClipboardUtil.setClipboardContent('');

// 3. Calling the copyPathOfActiveFile that populates the clipboard with the source path of the active file.
// If there is no active file - the clipboard will not be populated and it will stay with the empty string.
await commands.executeCommand('workbench.action.files.copyPathOfActiveFile');

// 4. Get the clipboard data after the API call
const postAPICallClipboardData = await ClipboardUtil.getClipboardContent();

// 5. Return the saved original clipboard data to the clipboard so this method
// will not interfere with the clipboard's content.
await ClipboardUtil.setClipboardContent(originalClipboardData);

// 6. Return the clipboard data from the API call (which could be an empty string if it failed).
return postAPICallClipboardData;
}
}
30 changes: 5 additions & 25 deletions src/controller/CopyFileNameController.ts
Original file line number Diff line number Diff line change
@@ -1,37 +1,17 @@
import { copy } from 'copy-paste-win32fix';
import { promisify } from 'util';
import { ClipboardUtil } from '../ClipboardUtil';
import { FileItem } from '../Item';
import { BaseFileController } from './BaseFileController';
import { IExecuteOptions } from './FileController';

const copyAsync = promisify(copy);

const GENERIC_ERROR_MESSAGE = 'Could not perform copy file name to clipboard';

export class CopyFileNameController extends BaseFileController {
// Possible errors and their suggested solutions
private readonly possibleErrorsMap: { [errorMessage: string]: string} = {
'spawn xclip ENOENT': 'Please install xclip package (`apt-get install xclip`)'
};

// Not relevant to CopyFileNameController as it need no dialog
public async showDialog(): Promise<FileItem> {
return new FileItem(this.sourcePath);
const sourcePath = await this.getSourcePath();
return new FileItem(sourcePath);
}

public async execute(options: IExecuteOptions): Promise<FileItem> {
return copyAsync(options.fileItem.name)
.catch((error: Error) => {
this.handleError(error.message);
});
}

private handleError(errorMessage: string): void {
// Can happen on unsupported platforms (e.g Linux machine without the xclip package installed).
// Attempting to provide a solution according to the error received
const errorSolution = this.possibleErrorsMap[errorMessage];
const errorMessageSuffix = errorSolution || errorMessage;

throw new Error(`${GENERIC_ERROR_MESSAGE}: ${errorMessageSuffix}`);
await ClipboardUtil.setClipboardContent(options.fileItem.name);
return options.fileItem;
}
}
3 changes: 1 addition & 2 deletions src/controller/FileController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,9 @@ export interface IExecuteOptions {
}

export interface IFileController {
readonly sourcePath: string;

showDialog(options?: IDialogOptions): Promise<FileItem>;
execute(options: IExecuteOptions): Promise<FileItem>;
openFileInEditor(fileItem: FileItem): Promise<TextEditor>;
closeCurrentFileEditor(): Promise<any>;
getSourcePath(): Promise<string>;
}
2 changes: 1 addition & 1 deletion src/controller/MoveFileController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export class MoveFileController extends BaseFileController {

public async showDialog(options: IMoveFileDialogOptions): Promise<FileItem> {
const { prompt, showFullPath = false, uri = null } = options;
const sourcePath = uri && uri.fsPath || this.sourcePath;
const sourcePath = uri && uri.fsPath || await this.getSourcePath();

if (!sourcePath) {
throw new Error();
Expand Down
3 changes: 2 additions & 1 deletion src/controller/NewFileController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ export class NewFileController extends BaseFileController {
}

if (!relativeToRoot) {
sourcePath = path.dirname(this.sourcePath);
sourcePath = await this.getSourcePath();
sourcePath = path.dirname(sourcePath);
}

if (getConfiguration('typeahead.enabled') === true) {
Expand Down
2 changes: 1 addition & 1 deletion src/controller/RemoveFileController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { IExecuteOptions } from './FileController';
export class RemoveFileController extends BaseFileController {

public async showDialog(): Promise<FileItem> {
const sourcePath = this.sourcePath;
const sourcePath = await this.getSourcePath();

if (!sourcePath) {
throw new Error();
Expand Down
73 changes: 59 additions & 14 deletions test/command/CopyFileName.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { expect, use as chaiUse } from 'chai';
import { paste as clipboardPaste } from 'copy-paste-win32fix';
import * as path from 'path';
import * as sinon from 'sinon';
import * as sinonChai from 'sinon-chai';
import { commands, Uri, window, workspace } from 'vscode';
import { ClipboardUtil } from '../../src/ClipboardUtil';
import { CopyFileNameCommand, ICommand } from '../../src/command';

chaiUse(sinonChai);
Expand All @@ -12,37 +14,80 @@ const rootDir = path.resolve(__dirname, '..', '..', '..');
const fixtureFile1Name = 'file-1.rb';
const fixtureFile1 = path.resolve(rootDir, 'test', 'fixtures', fixtureFile1Name);

const clipboardInitialTestData = 'SOME_TEXT';

describe('CopyFileNameCommand', () => {
const sut: ICommand = new CopyFileNameCommand();

describe('as command', () => {

describe('with open text document', () => {
// Saving original clipboard content to be able to return it to the clipboard after the test
let originalClipboardContent = '';

before(() => {
const uri = Uri.file(fixtureFile1);
return workspace.openTextDocument(uri)
.then((textDocument) => window.showTextDocument(textDocument));

return ClipboardUtil.getClipboardContent()
.then((clipboardContent) => {
originalClipboardContent = clipboardContent;
return workspace.openTextDocument(uri);
})
.then((textDocument) => window.showTextDocument(textDocument))
.catch(ClipboardUtil.handleClipboardError);
});

after(() => {
return commands.executeCommand('workbench.action.closeAllEditors');
// After test has finished - return original clipboard content.
return ClipboardUtil.setClipboardContent(originalClipboardContent)
.then(() => commands.executeCommand('workbench.action.closeAllEditors'))
.catch(ClipboardUtil.handleClipboardError);
});

it('check file name was copied to clipboard', () => {
return sut.execute()
.then(() => {
clipboardPaste((err, pasteContent) => {
if (err) {
// paste is not supported on this platform, probably due to a
// missing xclip package.
return;
}

return ClipboardUtil.getClipboardContent()
.then((pasteContent) => {
expect(pasteContent).to.equal(fixtureFile1Name);
});
})
.catch(() => {
// Suppressing errors that can be caused by unsupported platforms.
});
}).catch(ClipboardUtil.handleClipboardError);
});
});

describe('with no open text document', () => {
// Saving original clipboard content to be able to return it to the clipboard after the test
let originalClipboardContent = '';

before(() => {
const closeAllEditors = () => {
return commands.executeCommand('workbench.action.closeAllEditors');
};

return ClipboardUtil.getClipboardContent()
.then((clipboardContent) => {
originalClipboardContent = clipboardContent;
return ClipboardUtil.setClipboardContent(clipboardInitialTestData);
}).catch(ClipboardUtil.handleClipboardError);
});

after(() => {
// After test has finished - return original clipboard content.
return ClipboardUtil.setClipboardContent(originalClipboardContent)
.catch(ClipboardUtil.handleClipboardError);
});

it('ignores the command call and verifies that clipboard text did not change', () => {

return sut.execute()
.then(() => {
// Retrieving clipboard data and verifying that it is indeed the data that was in the
// clipboard prior to the test.
return ClipboardUtil.getClipboardContent()
.then((clipboardData) => {
expect(clipboardData).to.equal(clipboardInitialTestData);
});
}).catch(ClipboardUtil.handleClipboardError);
});
});
});
Expand Down
3 changes: 2 additions & 1 deletion test/command/DuplicateFileCommand.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import * as path from 'path';
import * as sinon from 'sinon';
import * as sinonChai from 'sinon-chai';
import { commands, TextEditor, Uri, window, workspace } from 'vscode';
import { ClipboardUtil } from '../../src/ClipboardUtil';
import { ICommand } from '../../src/command/Command';
import { DuplicateFileCommand } from '../../src/command/DuplicateFileCommand';

Expand Down Expand Up @@ -226,7 +227,7 @@ describe('DuplicateFileCommand', () => {
return sut.execute().catch(() => {
// tslint:disable-next-line:no-unused-expression
expect(window.showInputBox).to.have.not.been.called;
});
}).catch(ClipboardUtil.handleClipboardError);
});

});
Expand Down
3 changes: 2 additions & 1 deletion test/command/MoveFileCommand.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import * as path from 'path';
import * as sinon from 'sinon';
import * as sinonChai from 'sinon-chai';
import { commands, TextEditor, Uri, window, workspace } from 'vscode';
import { ClipboardUtil } from '../../src/ClipboardUtil';
import { ICommand, MoveFileCommand } from '../../src/command';

chaiUse(sinonChai);
Expand Down Expand Up @@ -225,7 +226,7 @@ describe('MoveFileCommand', () => {
return sut.execute().catch(() => {
// tslint:disable-next-line:no-unused-expression
expect(window.showInputBox).to.have.not.been.called;
});
}).catch(ClipboardUtil.handleClipboardError);
});

});
Expand Down
3 changes: 2 additions & 1 deletion test/command/RemoveFile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import * as path from 'path';
import * as sinon from 'sinon';
import * as sinonChai from 'sinon-chai';
import { commands, TextEditor, Uri, window, workspace } from 'vscode';
import { ClipboardUtil } from '../../src/ClipboardUtil';
import { ICommand, RemoveFileCommand } from '../../src/command';

chaiUse(sinonChai);
Expand Down Expand Up @@ -195,7 +196,7 @@ describe('RemoveFileCommand', () => {
return sut.execute().catch(() => {
// tslint:disable-next-line:no-unused-expression
expect(window.showInformationMessage).to.have.not.been.called;
});
}).catch(ClipboardUtil.handleClipboardError);
});
});
});
Expand Down
Loading