Initial (big) commit

pull/13/head
Kelvin Schoofs 7 years ago
commit b3e74eefb8

2
.gitignore vendored

@ -0,0 +1,2 @@
out
node_modules

@ -0,0 +1,21 @@
// A launch configuration that compiles the extension and then opens it inside a new window
{
"version": "0.1.0",
"configurations": [
{
"name": "Launch Extension",
"type": "extensionHost",
"request": "launch",
"runtimeExecutable": "${execPath}",
"args": [
"--extensionDevelopmentPath=${workspaceRoot}"
],
"stopOnEntry": false,
"sourceMaps": true,
"outFiles": [
"${workspaceRoot}/out/src/**/*.js"
],
// "preLaunchTask": "npm:watch"
}
]
}

16
.vscode/tasks.json vendored

@ -0,0 +1,16 @@
{
// See https://go.microsoft.com/fwlink/?LinkId=733558
// for the documentation about the tasks.json format
"version": "2.0.0",
"tasks": [
{
"identifier": "npm:watch",
"type": "npm",
"script": "watch",
"problemMatcher": [
"$tsc-watch"
],
"isBackground": true
}
]
}

@ -0,0 +1,8 @@
.vscode/**
.vscode-test/**
out/test/**
test/**
src/**
**/*.map
.gitignore
tsconfig.json

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><style type="text/css">.icon-canvas-transparent{opacity:0;fill:#F6F6F6;} .icon-vs-out{opacity:0;fill:#F6F6F6;} .icon-vs-fg{opacity:0;fill:#F0EFF1;} .icon-folder{fill:#C5C5C5;}</style><path class="icon-canvas-transparent" d="M16 16h-16v-16h16v16z" id="canvas"/><path class="icon-vs-out" d="M16 2.5v10c0 .827-.673 1.5-1.5 1.5h-11.996c-.827 0-1.5-.673-1.5-1.5v-8c0-.827.673-1.5 1.5-1.5h2.886l1-2h8.11c.827 0 1.5.673 1.5 1.5z" id="outline"/><path class="icon-folder" d="M14.5 2h-7.492l-1 2h-3.504c-.277 0-.5.224-.5.5v8c0 .276.223.5.5.5h11.996c.275 0 .5-.224.5-.5v-10c0-.276-.225-.5-.5-.5zm-.496 2h-6.496l.5-1h5.996v1z" id="iconBg"/><path class="icon-vs-fg" d="M14 3v1h-6.5l.5-1h6z" id="iconFg"/></svg>

After

Width:  |  Height:  |  Size: 760 B

2458
package-lock.json generated

File diff suppressed because it is too large Load Diff

@ -0,0 +1,226 @@
{
"name": "vscode-sshfs",
"displayName": "SSH FS",
"description": "File system provider using SSH, based on the MemFS sample extension",
"publisher": "Kelvin",
"version": "0.0.1",
"engines": {
"vscode": "^1.23.0"
},
"categories": [
"Other"
],
"activationEvents": [
"onFileSystem:ssh",
"onView:sshfs-configs",
"onCommand:ssfs.new",
"onCommand:ssfs.connect",
"onCommand:ssfs.reconnect",
"onCommand:ssfs.disconnect"
],
"main": "./out/src/extension",
"contributes": {
"views": {
"explorer": [
{
"id": "sshfs-configs",
"name": "SSH File Systems"
}
]
},
"commands": [
{
"command": "sshfs.new",
"title": "Create a new SSH FS configuration",
"category": "SSH FS"
},
{
"command": "sshfs.connect",
"title": "Connect a SSH FS as Workspace folder",
"category": "SSH FS"
},
{
"command": "sshfs.reconnect",
"title": "Reconnect a SSH FS Workspace folder",
"category": "SSH FS"
},
{
"command": "sshfs.disconnect",
"title": "Disconnect a SSH FS Workspace folder",
"category": "SSH FS"
},
{
"command": "sshfs-configs.connect",
"title": "Connect",
"category": "SSH FS"
},
{
"command": "sshfs-configs.reconnect",
"title": "Reconnect",
"category": "SSH FS"
},
{
"command": "sshfs-configs.disconnect",
"title": "Disconnect",
"category": "SSH FS"
},
{
"command": "sshfs-configs.configure",
"title": "Configure",
"category": "SSH FS"
},
{
"command": "sshfs-configs.delete",
"title": "Delete",
"category": "SSH FS"
}
],
"menus": {
"commandPalette": [
{
"command": "sshfs.new",
"group": "SSH FS@1"
},
{
"command": "sshfs.connect",
"group": "SSH FS@2"
},
{
"command": "sshfs.reconnect",
"group": "SSH FS@3"
},
{
"command": "sshfs.disconnect",
"group": "SSH FS@4"
},
{
"command": "sshfs-configs.connect",
"when": "view == never"
},
{
"command": "sshfs-configs.reconnect",
"when": "view == never"
},
{
"command": "sshfs-configs.disconnect",
"when": "view == never"
},
{
"command": "sshfs-configs.configure",
"when": "view == never"
},
{
"command": "sshfs-configs.delete",
"when": "view == never"
}
],
"view/item/context": [
{
"command": "sshfs-configs.connect",
"when": "view == 'sshfs-configs' && viewItem == inactive"
},
{
"command": "sshfs-configs.reconnect",
"when": "view == 'sshfs-configs' && viewItem == active"
},
{
"command": "sshfs-configs.disconnect",
"when": "view == 'sshfs-configs' && viewItem == active"
},
{
"command": "sshfs-configs.configure",
"when": "view == 'sshfs-configs' && viewItem"
},
{
"command": "sshfs-configs.delete",
"when": "view == 'sshfs-configs' && viewItem"
},
{
"command": "sshfs.new",
"when": "view == 'sshfs-configs' && !viewItem"
}
]
},
"configuration": {
"title": "SSH FS Configuration",
"properties": {
"sshfs.configs": {
"type": "array",
"default": [
{
"root": "/",
"host": "localhost",
"port": 22,
"username": "root",
"password": "CorrectHorseBatteryStaple"
}
],
"description": "A list of SSH FS configurations",
"items": {
"type": "object",
"required": [
"name",
"host",
"username"
],
"properties": {
"name": {
"type": "string",
"description": "The unique name for this configuration"
},
"root": {
"type": "string",
"description": "The remote folder to act as root folder",
"default": "/"
},
"host": {
"type": "string",
"description": "Hostname or IP address of the server"
},
"port": {
"type": "number",
"description": "Port number of the server",
"default": 22
},
"username": {
"type": "string",
"description": "Username for authentication"
},
"password": {
"type": "string",
"description": "Password for password-based user authentication"
},
"agent": {
"type": "string",
"description": "Path to ssh-agent's UNIX socket for ssh-agent-based user authentication (or 'pageant' when using Pagent on Windows)"
},
"privateKey": {
"type": "string",
"description": "String that contains a private key for either key-based or hostbased user authentication (OpenSSH format)"
},
"passphrase": {
"type": "string",
"description": "For an encrypted private key, this is the passphrase used to decrypt it"
}
}
}
}
}
}
},
"scripts": {
"vscode:prepublish": "npm run compile",
"compile": "tsc -p ./",
"watch": "tsc -watch -p ./",
"postinstall": "node ./node_modules/vscode/bin/install"
},
"devDependencies": {
"@types/node": "^7.0.43",
"@types/ssh2": "^0.5.35",
"typescript": "^2.5.2",
"vscode": "^1.1.17"
},
"dependencies": {
"ssh2": "^0.6.0"
}
}

@ -0,0 +1,37 @@
import * as fs from 'fs';
import * as vscode from 'vscode';
import { MemFs } from './fileSystemProvider';
import { Manager } from './manager';
const workspace = vscode.workspace;
export function activate(context: vscode.ExtensionContext) {
const manager = new Manager(context);
context.subscriptions.push(vscode.workspace.registerFileSystemProvider('ssh', manager, { isCaseSensitive: true }));
context.subscriptions.push(vscode.commands.registerCommand('sshfs.new', async () => {
const name = await vscode.window.showInputBox({ placeHolder: 'Name for the new SSH file system', validateInput: manager.invalidConfigName.bind(manager) });
vscode.window.showTextDocument(vscode.Uri.parse(`ssh://<config>/${name}.json`));
}));
async function pickAndClick(func: (name: string) => void, activeOrNot: boolean) {
const active = manager.getActive();
const names = activeOrNot ? active : manager.loadConfigs().map(c => c.name).filter(n => active.indexOf(n) === -1);
const pick = await vscode.window.showQuickPick(names, { placeHolder: 'SSH FS Configuration' });
if (pick) func.call(manager, pick);
}
context.subscriptions.push(vscode.commands.registerCommand('sshfs.connect', () => pickAndClick(manager.commandConfigConnect, false)));
context.subscriptions.push(vscode.commands.registerCommand('sshfs.disconnect', () => pickAndClick(manager.commandConfigDisconnect, true)));
context.subscriptions.push(vscode.commands.registerCommand('sshfs.reconnect', () => pickAndClick(manager.commandConfigReconnect, true)));
context.subscriptions.push(vscode.commands.registerCommand('sshfs-configs.disconnect', manager.commandConfigDisconnect.bind(manager)));
context.subscriptions.push(vscode.commands.registerCommand('sshfs-configs.reconnect', manager.commandConfigReconnect.bind(manager)));
context.subscriptions.push(vscode.commands.registerCommand('sshfs-configs.connect', manager.commandConfigConnect.bind(manager)));
context.subscriptions.push(vscode.commands.registerCommand('sshfs-configs.configure', manager.commandConfigure.bind(manager)));
context.subscriptions.push(vscode.commands.registerCommand('sshfs-configs.delete', manager.commandConfigDelete.bind(manager)));
vscode.window.createTreeView('sshfs-configs', { treeDataProvider: manager });
}

@ -0,0 +1,218 @@
/*---------------------------------------------------------------------------------------------
* 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 fs from 'fs';
import * as path from 'path';
import * as vscode from 'vscode';
export class File implements vscode.FileStat {
public type: vscode.FileType;
public ctime: number;
public mtime: number;
public size: number;
public name: string;
public data: Uint8Array;
constructor(name: string) {
this.type = vscode.FileType.File;
this.ctime = Date.now();
this.mtime = Date.now();
this.size = 0;
this.name = name;
}
}
export class Directory implements vscode.FileStat {
public type: vscode.FileType;
public ctime: number;
public mtime: number;
public size: number;
public name: string;
public entries: Map<string, File | Directory>;
constructor(name: string) {
this.type = vscode.FileType.Directory;
this.ctime = Date.now();
this.mtime = Date.now();
this.size = 0;
this.name = name;
this.entries = new Map();
}
}
export type Entry = File | Directory;
export class MemFs implements vscode.FileSystemProvider {
public readonly onDidChangeFile: vscode.Event<vscode.FileChangeEvent[]>;
public root = new Directory('');
private emitter = new vscode.EventEmitter<vscode.FileChangeEvent[]>();
private bufferedEvents: vscode.FileChangeEvent[] = [];
private fireSoonHandle: NodeJS.Timer;
constructor() {
this.onDidChangeFile = this.emitter.event;
}
public stat(uri: vscode.Uri): vscode.FileStat {
return this.lookup(uri, false);
}
public readDirectory(uri: vscode.Uri): [string, vscode.FileType][] {
const entry = this.lookupAsDirectory(uri, false);
const result: [string, vscode.FileType][] = [];
for (const [name, child] of entry.entries) {
result.push([name, child.type]);
}
return result;
}
// --- manage file contents
public readFile(uri: vscode.Uri): Uint8Array {
return this.lookupAsFile(uri, false).data;
}
public writeFile(uri: vscode.Uri, content: Uint8Array, options: { create: boolean, overwrite: boolean }): void {
const basename = path.posix.basename(uri.path);
const parent = this.lookupParentDirectory(uri);
let entry = parent.entries.get(basename);
if (entry instanceof Directory) {
throw vscode.FileSystemError.FileIsADirectory(uri);
}
if (!entry && !options.create) {
throw vscode.FileSystemError.FileNotFound(uri);
}
if (entry && options.create && !options.overwrite) {
throw vscode.FileSystemError.FileExists(uri);
}
if (!entry) {
entry = new File(basename);
parent.entries.set(basename, entry);
this.fireSoon({ uri, type: vscode.FileChangeType.Created });
}
entry.mtime = Date.now();
entry.size = content.byteLength;
entry.data = content;
this.fireSoon({ uri, type: vscode.FileChangeType.Changed });
}
// --- manage files/folders
public rename(oldUri: vscode.Uri, newUri: vscode.Uri, options: { overwrite: boolean }): void {
if (!options.overwrite && this.lookup(newUri, true)) {
throw vscode.FileSystemError.FileExists(newUri);
}
const entry = this.lookup(oldUri, false);
const oldParent = this.lookupParentDirectory(oldUri);
const newParent = this.lookupParentDirectory(newUri);
const newName = path.posix.basename(newUri.path);
oldParent.entries.delete(entry.name);
entry.name = newName;
newParent.entries.set(newName, entry);
this.fireSoon(
{ type: vscode.FileChangeType.Deleted, uri: oldUri },
{ type: vscode.FileChangeType.Created, uri: newUri },
);
}
public delete(uri: vscode.Uri): void {
const dirname = uri.with({ path: path.posix.dirname(uri.path) });
const basename = path.posix.basename(uri.path);
const parent = this.lookupAsDirectory(dirname, false);
if (!parent.entries.has(basename)) {
throw vscode.FileSystemError.FileNotFound(uri);
}
parent.entries.delete(basename);
parent.mtime = Date.now();
parent.size -= 1;
this.fireSoon({ type: vscode.FileChangeType.Changed, uri: dirname }, { uri, type: vscode.FileChangeType.Deleted });
}
public createDirectory(uri: vscode.Uri): void {
const basename = path.posix.basename(uri.path);
const dirname = uri.with({ path: path.posix.dirname(uri.path) });
const parent = this.lookupAsDirectory(dirname, false);
const entry = new Directory(basename);
parent.entries.set(entry.name, entry);
parent.mtime = Date.now();
parent.size += 1;
this.fireSoon({ type: vscode.FileChangeType.Changed, uri: dirname }, { uri, type: vscode.FileChangeType.Created });
}
public watch(resource: vscode.Uri, opts): vscode.Disposable {
// ignore, fires for all changes...
return new vscode.Disposable(() => { });
}
private lookup(uri: vscode.Uri, silent: false): Entry;
private lookup(uri: vscode.Uri, silent: boolean): Entry | undefined;
private lookup(uri: vscode.Uri, silent: boolean): Entry | undefined {
const parts = uri.path.split('/');
let entry: Entry = this.root;
for (const part of parts) {
if (!part) {
continue;
}
let child: Entry | undefined;
if (entry instanceof Directory) {
child = entry.entries.get(part);
}
if (!child) {
if (!silent) {
throw vscode.FileSystemError.FileNotFound(uri);
} else {
return undefined;
}
}
entry = child;
}
return entry;
}
private lookupAsDirectory(uri: vscode.Uri, silent: boolean): Directory {
const entry = this.lookup(uri, silent);
if (entry instanceof Directory) {
return entry;
}
throw vscode.FileSystemError.FileNotADirectory(uri);
}
private lookupAsFile(uri: vscode.Uri, silent: boolean): File {
const entry = this.lookup(uri, silent);
if (entry instanceof File) {
return entry;
}
throw vscode.FileSystemError.FileIsADirectory(uri);
}
private lookupParentDirectory(uri: vscode.Uri): Directory {
const dirname = uri.with({ path: path.posix.dirname(uri.path) });
return this.lookupAsDirectory(dirname, false);
}
private fireSoon(...events: vscode.FileChangeEvent[]): void {
this.bufferedEvents.push(...events);
clearTimeout(this.fireSoonHandle);
this.fireSoonHandle = setTimeout(() => {
this.emitter.fire(this.bufferedEvents);
this.bufferedEvents.length = 0;
}, 5);
}
}

@ -0,0 +1,303 @@
import { Client, ConnectConfig } from 'ssh2';
import * as vscode from 'vscode';
import SSHFileSystem, { EMPTY_FILE_SYSTEM } from './sshFileSystem';
async function assertFs(man: Manager, uri: vscode.Uri) {
const fs = await man.getFs(uri);
if (fs) return fs;
return man.createFileSystem(uri.authority);
// throw new Error(`A SSH filesystem with the name '${uri.authority}' doesn't exists`);
}
export interface FileSystemConfig extends ConnectConfig {
name: string;
root?: string;
}
function createTreeItem(manager: Manager, name: string): vscode.TreeItem {
const config = manager.getConfig(name);
const folders = vscode.workspace.workspaceFolders || [];
const active = folders.some(f => f.uri.scheme === 'ssh' && f.uri.authority === name);
return {
label: name,
contextValue: active ? 'active' : 'inactive',
tooltip: config ? (active ? 'Active' : 'Inactive') : 'Active but deleted',
};
}
const defaultConfig: FileSystemConfig = ({
name: undefined!, root: '/', host: 'localhost', port: 22,
username: 'root', password: 'CorrectHorseBatteryStaple',
});
function createConfigFs(manager: Manager): SSHFileSystem {
return {
...EMPTY_FILE_SYSTEM,
authority: '<config>',
stat: (uri: vscode.Uri) => ({ type: vscode.FileType.File, ctime: 0, mtime: 0, size: 0 } as vscode.FileStat),
readFile: (uri: vscode.Uri) => {
const name = uri.path.substring(1, uri.path.length - 5);
let config = manager.getConfig(name) || defaultConfig;
config = { ...config, name: undefined! };
return new Uint8Array(new Buffer(JSON.stringify(config, undefined, 4)));
},
writeFile: (uri: vscode.Uri, content: Uint8Array) => {
const name = uri.path.substring(1, uri.path.length - 5);
try {
const config = JSON.parse(new Buffer(content).toString());
config.name = name;
const loc = manager.updateConfig(name, config);
let dialog: Thenable<string | undefined>;
if (loc === vscode.ConfigurationTarget.Global) {
dialog = vscode.window.showInformationMessage(`Config for '${name}' saved globally`, 'Connect', 'Okay');
} else if (loc === vscode.ConfigurationTarget.Workspace) {
dialog = vscode.window.showInformationMessage(`Config for '${name}' saved for this workspace`, 'Connect', 'Okay');
} else if (loc === vscode.ConfigurationTarget.WorkspaceFolder) {
dialog = vscode.window.showInformationMessage(`Config for '${name}' saved for the current workspace folder`, 'Connect', 'Okay');
} else {
throw new Error(`This isn't supposed to happen! Config location was '${loc}' somehow`);
}
dialog.then(o => o === 'Connect' && manager.commandConfigReconnect(name));
} catch (e) {
vscode.window.showErrorMessage(`Couldn't parse this config as JSON`);
}
},
} as any;
}
export class Manager implements vscode.FileSystemProvider, vscode.TreeDataProvider<string> {
public onDidChangeTreeData: vscode.Event<string>;
public onDidChangeFile: vscode.Event<vscode.FileChangeEvent[]>;
protected fileSystems: SSHFileSystem[] = [];
protected creatingFileSystems: { [name: string]: Promise<SSHFileSystem> } = {};
protected configFileSystem = createConfigFs(this);
protected onDidChangeFileEmitter = new vscode.EventEmitter<vscode.FileChangeEvent[]>();
protected onDidChangeTreeDataEmitter = new vscode.EventEmitter<string>();
protected skippedConfigNames: string[] = [];
// private memento: vscode.Memento = this.context.globalState;
constructor(protected readonly context: vscode.ExtensionContext) {
this.onDidChangeFile = this.onDidChangeFileEmitter.event;
this.onDidChangeTreeData = this.onDidChangeTreeDataEmitter.event;
const folderAdded = async (folder) => {
if (folder.uri.scheme !== 'ssh') return;
this.createFileSystem(folder.uri.authority);
};
const folders = vscode.workspace.workspaceFolders || [];
folders.forEach(folderAdded);
vscode.workspace.onDidChangeWorkspaceFolders((e) => {
e.added.forEach(folderAdded);
e.removed.forEach(async (folder) => {
if (folder.uri.scheme !== 'ssh') return;
this.commandConfigDisconnect(folder.uri.authority);
});
});
vscode.workspace.onDidChangeConfiguration((e) => {
if (!e.affectsConfiguration('sshfs.configs')) return;
this.onDidChangeTreeDataEmitter.fire();
// TODO: Offer to reconnect everything
});
this.loadConfigs();
}
public invalidConfigName(name: string) {
if (name.match(/^[\w_\\\/\.@\-+]+$/)) return null;
return `A SSH FS name can only exists of alphanumeric characters, slashes and any of these: _.+-@`;
}
public getConfig(name: string) {
if (name === '<config>') return null;
return this.loadConfigs().find(c => c.name === name);
// return this.memento.get<FileSystemConfig>(`fs.config.${name}`);
}
public async registerFileSystem(name: string, config?: FileSystemConfig) {
if (name === '<config>') return;
// this.memento.update(`fs.config.${name}`, config);
this.updateConfig(name, config);
// const configs: string[] = this.memento.get('fs.configs',[]);
// if (configs.indexOf(name) === -1) configs.push(name);
// this.memento.update('fs.configs', configs);
this.onDidChangeTreeDataEmitter.fire();
}
public async createFileSystem(name: string, config?: FileSystemConfig): Promise<SSHFileSystem> {
if (name === '<config>') return this.configFileSystem;
const existing = this.fileSystems.find(fs => fs.authority === name);
if (existing) return existing;
let promise = this.creatingFileSystems[name];
if (promise) return promise;
// config = config || this.memento.get(`fs.config.${name}`);
config = config || (await this.loadConfigs()).find(c => c.name === name);
promise = new Promise<SSHFileSystem>((resolve, reject) => {
if (!config) {
throw new Error(`A SSH filesystem with the name '${name}' doesn't exist`);
}
this.registerFileSystem(name, config);
const client = new Client();
client.on('ready', () => {
client.sftp((err, sftp) => {
if (err) {
client.end();
return reject(err);
}
sftp.on('end', () => client.end());
const fs = new SSHFileSystem(name, sftp, config!.root || '/');
this.fileSystems.push(fs);
delete this.creatingFileSystems[name];
vscode.commands.executeCommand('workbench.files.action.refreshFilesExplorer');
return resolve(fs);
});
});
client.on('timeout', () => reject(new Error(`Socket timed out while connecting SSH FS '${name}'`)));
client.on('close', hadError => hadError && this.commandConfigReconnect(name));
client.on('error', (error) => {
if (error.description) {
error.message = `${error.description}\n${error.message}`;
}
reject(error);
});
client.connect(Object.assign({ tryKeyboard: false }, config));
}).catch((e) => {
delete this.creatingFileSystems[name];
vscode.window.showErrorMessage(`Error while connecting to SSH FS ${name}:\n${e.message}`, 'Retry', 'Configure', 'Ignore')
.then(chosen => chosen === 'Retry' ? this.createFileSystem(name).catch(console.error) : chosen === 'Configure' && this.commandConfigure(name));
throw e;
});
return this.creatingFileSystems[name] = promise;
}
public getActive() {
return this.fileSystems.map(fs => fs.authority);
}
public async getFs(uri: vscode.Uri) {
const fs = this.fileSystems.find(f => f.authority === uri.authority);
if (fs) return fs;
return null;
}
/* FileSystemProvider */
public watch(uri: vscode.Uri, options: { recursive: boolean; excludes: string[]; }): vscode.Disposable {
/*let disp = () => {};
assertFs(this, uri).then((fs) => {
disp = fs.watch(uri, options).dispose.bind(fs);
}).catch(console.error);
return new vscode.Disposable(() => disp());*/
return new vscode.Disposable(() => {});
}
public async stat(uri: vscode.Uri): Promise<vscode.FileStat> {
return (await assertFs(this, uri)).stat(uri);
}
public async readDirectory(uri: vscode.Uri): Promise<[string, vscode.FileType][]> {
return (await assertFs(this, uri)).readDirectory(uri);
}
public async createDirectory(uri: vscode.Uri): Promise<void> {
return (await assertFs(this, uri)).createDirectory(uri);
}
public async readFile(uri: vscode.Uri): Promise<Uint8Array> {
return (await assertFs(this, uri)).readFile(uri);
}
public async writeFile(uri: vscode.Uri, content: Uint8Array, options: { create: boolean; overwrite: boolean; }): Promise<void> {
return (await assertFs(this, uri)).writeFile(uri, content, options);
}
public async delete(uri: vscode.Uri, options: { recursive: boolean; }): Promise<void> {
return (await assertFs(this, uri)).delete(uri, options);
}
public async rename(oldUri: vscode.Uri, newUri: vscode.Uri, options: { overwrite: boolean; }): Promise<void> {
const fs = await assertFs(this, oldUri);
if (fs !== (await assertFs(this, newUri))) throw new Error(`Can't copy between different SSH filesystems`);
return fs.rename(oldUri, newUri, options);
}
/* TreeDataProvider */
public getTreeItem(element: string): vscode.TreeItem | Thenable<vscode.TreeItem> {
return createTreeItem(this, element);
}
public getChildren(element?: string | undefined): vscode.ProviderResult<string[]> {
const configs = this.loadConfigs().map(c => c.name);
this.fileSystems.forEach(fs => configs.indexOf(fs.authority) === -1 && configs.push(fs.authority));
return configs;
}
/* Commands (stuff for e.g. context menu for ssh-configs tree) */
public commandConfigDisconnect(name: string) {
const folders = vscode.workspace.workspaceFolders!;
const index = folders.findIndex(f => f.uri.scheme === 'ssh' && f.uri.authority === name);
if (index !== -1) vscode.workspace.updateWorkspaceFolders(index, 1);
this.onDidChangeTreeDataEmitter.fire();
}
public commandConfigReconnect(name: string) {
const fs = this.fileSystems.find(f => f.authority === name);
if (fs) {
fs.disconnect();
this.fileSystems.splice(this.fileSystems.indexOf(fs), 1);
}
this.commandConfigConnect(name);
}
public commandConfigConnect(name: string) {
if (this.getActive().indexOf(name) !== -1) return vscode.commands.executeCommand('workbench.files.action.refreshFilesExplorer');
const folders = vscode.workspace.workspaceFolders!;
const folder = folders && folders.find(f => f.uri.scheme === 'ssh' && f.uri.authority === name);
if (folder) {
this.onDidChangeTreeDataEmitter.fire();
return this.createFileSystem(name);
}
vscode.workspace.updateWorkspaceFolders(folders ? folders.length : 0, 0, { uri: vscode.Uri.parse(`ssh://${name}/`), name: `SSH FS - ${name}` });
this.onDidChangeTreeDataEmitter.fire();
}
public async commandConfigure(name: string) {
vscode.window.showTextDocument(vscode.Uri.parse(`ssh://<config>/${name}.json`), { preview: false });
}
public commandConfigDelete(name: string) {
this.commandConfigDisconnect(name);
this.updateConfig(name);
}
/* Configuration discovery */
public loadConfigs() {
const config = vscode.workspace.getConfiguration('sshfs');
if (!config) return [];
const inspect = config.inspect<FileSystemConfig[]>('configs')!;
const configs: FileSystemConfig[] = [
...(inspect.globalValue || []),
...(inspect.workspaceValue || []),
...(inspect.workspaceFolderValue || []),
];
for (const index in configs) {
if (this.invalidConfigName(configs[index].name)) {
const conf = configs[index];
if (this.skippedConfigNames.indexOf(conf.name) !== -1) continue;
vscode.window.showErrorMessage(`Invalid SSH FS config name: ${conf.name}`, { modal: true }, 'Rename', 'Delete', 'Skip').then(async (answer) => {
if (answer === 'Rename') {
const name = await vscode.window.showInputBox({ prompt: `New name for: ${conf.name}`, validateInput: this.invalidConfigName, placeHolder: 'New name' });
if (name) return conf.name = name;
vscode.window.showWarningMessage(`Skipped SSH FS config '${conf.name}'`);
} else if (answer === 'Delete') {
return this.updateConfig(conf.name);
}
this.skippedConfigNames.push(conf.name);
});
}
}
return configs.filter(c => !this.invalidConfigName(c.name));
}
public getConfigLocation(name: string) {
const conf = vscode.workspace.getConfiguration('sshfs');
const inspect = conf.inspect<FileSystemConfig[]>('configs')!;
const contains = (v?: FileSystemConfig[]) => v && v.find(c => c.name === name);
if (contains(inspect.workspaceFolderValue)) {
return vscode.ConfigurationTarget.WorkspaceFolder;
} else if (contains(inspect.workspaceValue)) {
return vscode.ConfigurationTarget.Workspace;
} else { // if (contains(inspect.globalValue)) {
return vscode.ConfigurationTarget.Global;
}
}
public updateConfig(name: string, config?: FileSystemConfig) {
const conf = vscode.workspace.getConfiguration('sshfs');
const inspect = conf.inspect<FileSystemConfig[]>('configs')!;
// const contains = (v?: FileSystemConfig[]) => v && v.find(c => c.name === name);
const patch = (v: FileSystemConfig[]) => {
const con = v.findIndex(c => c.name === name);
if (!config) return v.filter(c => c.name !== name);
v[con === -1 ? v.length : con] = config;
return v;
};
const loc = this.getConfigLocation(name);
const array = [[], inspect.globalValue, inspect.workspaceValue, inspect.workspaceFolderValue][loc];
conf.update('configs', patch(array || []), loc || vscode.ConfigurationTarget.Global);
return loc;
}
}
export default Manager;

@ -0,0 +1,109 @@
import * as path from 'path';
import * as ssh2 from 'ssh2';
import * as ssh2s from 'ssh2-streams';
import * as vscode from 'vscode';
type toPromiseCallback<T> = (err: Error | null, res?: T) => void;
async function toPromise<T>(func: (cb: toPromiseCallback<T>) => void): Promise<T> {
return new Promise<T>((resolve, reject) => {
func((err, res) => err ? reject(err) : resolve(res));
});
}
export class SSHFileSystem implements vscode.FileSystemProvider {
public copy = undefined;
public onDidChangeFile: vscode.Event<vscode.FileChangeEvent[]>;
protected onDidChangeFileEmitter = new vscode.EventEmitter<vscode.FileChangeEvent[]>();
constructor(public readonly authority: string, protected sftp: ssh2.SFTPWrapper, public readonly root: string) {
this.onDidChangeFile = this.onDidChangeFileEmitter.event;
}
public disconnect() {
this.sftp.end();
}
public relative(relPath: string) {
if (relPath.startsWith('/')) relPath = relPath.substr(1);
return path.posix.resolve(this.root, relPath);
}
public watch(uri: vscode.Uri, options: { recursive: boolean; excludes: string[]; }): vscode.Disposable {
// throw new Error('Method not implemented.');
return new vscode.Disposable(() => { });
}
public async stat(uri: vscode.Uri): Promise<vscode.FileStat> {
const stat = await toPromise<ssh2s.Stats>(cb => this.sftp.stat(this.relative(uri.path), cb));
const { mtime, size } = stat;
let type = vscode.FileType.Unknown;
// tslint:disable no-bitwise */
if (stat.isFile()) type = type | vscode.FileType.File;
if (stat.isDirectory()) type = type | vscode.FileType.Directory;
if (stat.isSymbolicLink()) type = type | vscode.FileType.SymbolicLink;
// tslint:enable no-bitwise */
return {
type, mtime, size,
ctime: 0,
};
}
public async readDirectory(uri: vscode.Uri): Promise<[string, vscode.FileType][]> {
const entries = await toPromise<ssh2s.FileEntry[]>(cb => this.sftp.readdir(this.relative(uri.path), cb));
return Promise.all(entries.map(async (file) => {
const furi = uri.with({ path: `${uri.path}${uri.path.endsWith('/') ? '' : '/'}${file.filename}` });
const type = (await this.stat(furi)).type;
return [file.filename, type] as [string, vscode.FileType];
}));
}
public createDirectory(uri: vscode.Uri): void | Promise<void> {
return toPromise(cb => this.sftp.mkdir(this.relative(uri.path), cb));
}
public readFile(uri: vscode.Uri): Uint8Array | Promise<Uint8Array> {
return new Promise((resolve, reject) => {
const array = new Buffer(0);
const stream = this.sftp.createReadStream(this.relative(uri.path), { autoClose: true });
const bufs = [];
stream.on('data', bufs.push.bind(bufs));
stream.on('error', reject);
stream.on('close', () => {
resolve(new Uint8Array(Buffer.concat(bufs)));
});
});
}
public writeFile(uri: vscode.Uri, content: Uint8Array, options: { create: boolean; overwrite: boolean; }): void | Promise<void> {
return new Promise((resolve, reject) => {
const array = new Buffer(0);
const stream = this.sftp.createWriteStream(this.relative(uri.path));
stream.on('error', reject);
stream.end(content, resolve);
});
}
public async delete(uri: vscode.Uri, options: { recursive: boolean; }): Promise<any> {
const stats = await this.stat(uri);
// tslint:disable no-bitwise */
if (stats.type & (vscode.FileType.SymbolicLink | vscode.FileType.File)) {
return toPromise(cb => this.sftp.unlink(this.relative(uri.path), cb));
} else if ((stats.type & vscode.FileType.Directory) && options.recursive) {
return toPromise(cb => this.sftp.rmdir(this.relative(uri.path), cb));
}
return toPromise(cb => this.sftp.unlink(this.relative(uri.path), cb));
// tslint:enable no-bitwise */
}
public rename(oldUri: vscode.Uri, newUri: vscode.Uri, options: { overwrite: boolean; }): void | Promise<void> {
return toPromise(cb => this.sftp.rename(this.relative(oldUri.path), this.relative(newUri.path), cb));
}
}
export default SSHFileSystem;
export const EMPTY_FILE_SYSTEM = {
onDidChangeFile: new vscode.EventEmitter<vscode.FileChangeEvent[]>().event,
watch: (uri: vscode.Uri, options: { recursive: boolean; excludes: string[]; }) => new vscode.Disposable(() => {}),
stat: (uri: vscode.Uri) => ({ type: vscode.FileType.Unknown }) as vscode.FileStat,
readDirectory: (uri: vscode.Uri) => [],
createDirectory: (uri: vscode.Uri) => {},
readFile: (uri: vscode.Uri) => new Uint8Array(0),
writeFile: (uri: vscode.Uri, content: Uint8Array, options: { create: boolean; overwrite: boolean; }) => {},
delete: (uri: vscode.Uri, options: { recursive: boolean; }) => {},
rename: (oldUri: vscode.Uri, newUri: vscode.Uri, options: { overwrite: boolean; }) => {},
} as vscode.FileSystemProvider;

@ -0,0 +1,17 @@
{
"compilerOptions": {
"strictNullChecks": true,
"module": "commonjs",
"target": "es6",
"outDir": "out",
"lib": [
"es6"
],
"sourceMap": true,
"rootDir": "."
},
"exclude": [
"node_modules",
".vscode-test"
]
}

@ -0,0 +1,26 @@
{
"defaultSeverity": "error",
"extends": [
"tslint:recommended",
"tslint-config-airbnb"
],
"jsRules": {},
"rules": {
"no-empty": false,
"no-console": false,
"curly": [
true,
"ignore-same-line"
],
"max-line-length": false,
"array-type": false,
"no-else-after-return": false,
"import-name": false,
"object-literal-sort-keys": false,
"no-parameter-reassignment": false,
"no-var-requires": false,
"interface-name": false,
"max-classes-per-file": false
},
"rulesDirectory": []
}
Loading…
Cancel
Save