commit
b3e74eefb8
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
@ -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
|
After Width: | Height: | Size: 760 B |
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…
Reference in new issue