Add the ability to open a SSH terminal

pull/208/head
Kelvin Schoofs 4 years ago
parent dea6a03116
commit 2d4a582dee

@ -67,6 +67,11 @@
"title": "Disconnect Workspace folder", "title": "Disconnect Workspace folder",
"category": "SSH FS" "category": "SSH FS"
}, },
{
"command": "sshfs.terminal",
"title": "Open a remote SSH terminal",
"category": "SSH FS"
},
{ {
"command": "sshfs.configure", "command": "sshfs.configure",
"title": "Edit configuration", "title": "Edit configuration",
@ -102,16 +107,20 @@
"group": "SSH FS@4" "group": "SSH FS@4"
}, },
{ {
"command": "sshfs.configure", "command": "sshfs.terminal",
"group": "SSH FS@5" "group": "SSH FS@5"
}, },
{ {
"command": "sshfs.reload", "command": "sshfs.configure",
"group": "SSH FS@6" "group": "SSH FS@6"
}, },
{ {
"command": "sshfs.settings", "command": "sshfs.reload",
"group": "SSH FS@7" "group": "SSH FS@7"
},
{
"command": "sshfs.settings",
"group": "SSH FS@8"
} }
], ],
"view/title": [ "view/title": [
@ -130,16 +139,21 @@
{ {
"command": "sshfs.connect", "command": "sshfs.connect",
"when": "view == 'sshfs-configs' && viewItem == inactive", "when": "view == 'sshfs-configs' && viewItem == inactive",
"group": "SSH FS@2" "group": "SSH FS@1"
}, },
{ {
"command": "sshfs.reconnect", "command": "sshfs.reconnect",
"when": "view == 'sshfs-configs' && viewItem == active", "when": "view == 'sshfs-configs' && viewItem == active",
"group": "SSH FS@3" "group": "SSH FS@2"
}, },
{ {
"command": "sshfs.disconnect", "command": "sshfs.disconnect",
"when": "view == 'sshfs-configs' && viewItem == active", "when": "view == 'sshfs-configs' && viewItem == active",
"group": "SSH FS@3"
},
{
"command": "sshfs.terminal",
"when": "view == 'sshfs-configs' && viewItem",
"group": "SSH FS@4" "group": "SSH FS@4"
}, },
{ {
@ -205,4 +219,4 @@
"ssh2": "^0.8.9", "ssh2": "^0.8.9",
"winreg": "^1.2.4" "winreg": "^1.2.4"
} }
} }

@ -284,6 +284,30 @@ export function getConfig(name: string) {
return getConfigs().find(c => c.name === name); return getConfigs().find(c => c.name === name);
} }
function valueMatches(a: any, b: any): boolean {
if (typeof a !== typeof b) return false;
if (typeof a !== 'object') return a === b;
if (Array.isArray(a)) {
if (!Array.isArray(b)) return false;
if (a.length !== b.length) return false;
return a.every((value, index) => valueMatches(value, b[index]));
}
const keysA = Object.keys(a);
const keysB = Object.keys(b);
if (keysA.length !== keysB.length) return false;
for (const key of keysA) {
if (!valueMatches(a[key], b[key])) return false;
}
return true;
}
export function configMatches(a: FileSystemConfig, b: FileSystemConfig): boolean {
// This is kind of the easiest and most robust way of checking if configs are identical.
// If it wasn't for `loadedConfigs` (and its contents) regularly being fully recreated, we
// could just use === between the two configs. This'll do for now.
return valueMatches(a, b);
}
vscode.workspace.onDidChangeConfiguration(async (e) => { vscode.workspace.onDidChangeConfiguration(async (e) => {
// if (!e.affectsConfiguration('sshfs.configs')) return; // if (!e.affectsConfiguration('sshfs.configs')) return;
return loadConfigs(); return loadConfigs();

@ -5,7 +5,7 @@ import { SFTPStream } from 'ssh2-streams';
import * as vscode from 'vscode'; import * as vscode from 'vscode';
import { getConfigs } from './config'; import { getConfigs } from './config';
import { FileSystemConfig } from './fileSystemConfig'; import { FileSystemConfig } from './fileSystemConfig';
import { Logging, censorConfig } from './logging'; import { censorConfig, Logging } from './logging';
import { toPromise } from './toPromise'; import { toPromise } from './toPromise';
// tslint:disable-next-line:variable-name // tslint:disable-next-line:variable-name
@ -22,11 +22,12 @@ function replaceVariables(string?: string) {
return string.replace(/\$\w+/g, key => process.env[key.substr(1)] || ''); return string.replace(/\$\w+/g, key => process.env[key.substr(1)] || '');
} }
export async function calculateActualConfig(config: FileSystemConfig): Promise<FileSystemConfig | null> { export async function calculateActualConfig(config: FileSystemConfig): Promise<FileSystemConfig> {
if ('_calculated' in config) return config; if (config._calculated) return config;
const logging = Logging.here(); const logging = Logging.here();
config = { ...config }; // Add the internal _calculated field to cache the actual config for the next calculateActualConfig call
(config as any)._calculated = true; // (and it also allows accessing the original config that generated this actual config, if ever necessary)
config = { ...config, _calculated: config };
config.username = replaceVariables(config.username); config.username = replaceVariables(config.username);
config.host = replaceVariables(config.host); config.host = replaceVariables(config.host);
const port = replaceVariables((config.port || '') + ''); const port = replaceVariables((config.port || '') + '');

@ -20,15 +20,15 @@ function generateDetail(config: FileSystemConfig): string | undefined {
return `${host}${port}`; return `${host}${port}`;
} }
async function pickConfig(manager: Manager, activeOrNot?: boolean): Promise<string | undefined> { async function pickConfig(manager: Manager, activeFileSystem?: boolean): Promise<string | undefined> {
let names = manager.getActive(); let fsConfigs = manager.getActiveFileSystems().map(fs => fs.config);
const others = await loadConfigs(); const others = await loadConfigs();
if (activeOrNot === false) { if (activeFileSystem === false) {
names = others.filter(c => !names.find(cc => cc.name === c.name)); fsConfigs = others.filter(c => !fsConfigs.find(cc => cc.name === c.name));
} else if (activeOrNot === undefined) { } else if (activeFileSystem === undefined) {
others.forEach(n => !names.find(c => c.name === n.name) && names.push(n)); others.forEach(n => !fsConfigs.find(c => c.name === n.name) && fsConfigs.push(n));
} }
const options: (vscode.QuickPickItem & { name: string })[] = names.map(config => ({ const options: (vscode.QuickPickItem & { name: string })[] = fsConfigs.map(config => ({
name: config.name, name: config.name,
description: config.name, description: config.name,
label: config.label || config.name, label: config.label || config.name,
@ -66,6 +66,7 @@ export function activate(context: vscode.ExtensionContext) {
registerCommand('sshfs.connect', (name?: string) => pickAndClick(manager.commandConnect, name, false)); registerCommand('sshfs.connect', (name?: string) => pickAndClick(manager.commandConnect, name, false));
registerCommand('sshfs.disconnect', (name?: string) => pickAndClick(manager.commandDisconnect, name, true)); registerCommand('sshfs.disconnect', (name?: string) => pickAndClick(manager.commandDisconnect, name, true));
registerCommand('sshfs.reconnect', (name?: string) => pickAndClick(manager.commandReconnect, name, true)); registerCommand('sshfs.reconnect', (name?: string) => pickAndClick(manager.commandReconnect, name, true));
registerCommand('sshfs.terminal', (name?: string) => pickAndClick(manager.commandTerminal, name));
registerCommand('sshfs.configure', (name?: string) => pickAndClick(manager.commandConfigure, name)); registerCommand('sshfs.configure', (name?: string) => pickAndClick(manager.commandConfigure, name));
registerCommand('sshfs.reload', loadConfigs); registerCommand('sshfs.reload', loadConfigs);

@ -102,6 +102,8 @@ export interface FileSystemConfig extends ConnectConfig {
_location?: ConfigLocation; _location?: ConfigLocation;
/** Internal property keeping track of where this config comes from (including merges) */ /** Internal property keeping track of where this config comes from (including merges) */
_locations: ConfigLocation[]; _locations: ConfigLocation[];
/** Internal property keeping track of whether this config is an actually calculated one, and if so, which config it originates from (normally itself) */
_calculated?: FileSystemConfig;
} }
export function invalidConfigName(name: string) { export function invalidConfigName(name: string) {

@ -1,9 +1,10 @@
import { Client, ClientChannel } from 'ssh2'; import { Client, ClientChannel } from 'ssh2';
import * as vscode from 'vscode'; import * as vscode from 'vscode';
import { getConfig, getConfigs, loadConfigs, loadConfigsRaw, UPDATE_LISTENERS } from './config'; import { configMatches, getConfig, getConfigs, loadConfigs, loadConfigsRaw, UPDATE_LISTENERS } from './config';
import { FileSystemConfig, getGroups } from './fileSystemConfig'; import { FileSystemConfig, getGroups } from './fileSystemConfig';
import { Logging } from './logging'; import { Logging } from './logging';
import { SSHPseudoTerminal } from './pseudoTerminal';
import { catchingPromise, toPromise } from './toPromise'; import { catchingPromise, toPromise } from './toPromise';
import { Navigation } from './webviewMessages'; import { Navigation } from './webviewMessages';
@ -48,8 +49,19 @@ async function tryGetHome(ssh: Client): Promise<string | null> {
return mat[1]; return mat[1];
} }
interface Connection {
config: FileSystemConfig;
actualConfig: FileSystemConfig;
client: Client;
terminals: SSHPseudoTerminal[];
filesystems: SSHFileSystem[];
pendingUserCount: number;
}
export class Manager implements vscode.TreeDataProvider<string | FileSystemConfig> { export class Manager implements vscode.TreeDataProvider<string | FileSystemConfig> {
public onDidChangeTreeData: vscode.Event<string | null>; public onDidChangeTreeData: vscode.Event<string | null>;
protected connections: Connection[] = [];
protected pendingConnections: { [name: string]: Promise<Connection> } = {};
protected fileSystems: SSHFileSystem[] = []; protected fileSystems: SSHFileSystem[] = [];
protected creatingFileSystems: { [name: string]: Promise<SSHFileSystem> } = {}; protected creatingFileSystems: { [name: string]: Promise<SSHFileSystem> } = {};
protected onDidChangeTreeDataEmitter = new vscode.EventEmitter<string | null>(); protected onDidChangeTreeDataEmitter = new vscode.EventEmitter<string | null>();
@ -74,10 +86,11 @@ export class Manager implements vscode.TreeDataProvider<string | FileSystemConfi
this.onDidChangeTreeDataEmitter.fire(null); this.onDidChangeTreeDataEmitter.fire(null);
// TODO: Offer to reconnect everything // TODO: Offer to reconnect everything
} }
/** This purely looks at whether a filesystem with the given name is available/connecting */
public getStatus(name: string): ConfigStatus { public getStatus(name: string): ConfigStatus {
const config = getConfig(name); const config = getConfig(name);
const folders = vscode.workspace.workspaceFolders || []; const folders = vscode.workspace.workspaceFolders || [];
const isActive = this.getActive().find(c => c.name === name); const isActive = this.getActiveFileSystems().find(fs => fs.config.name === name);
const isConnected = folders.some(f => f.uri.scheme === 'ssh' && f.uri.authority === name); const isConnected = folders.some(f => f.uri.scheme === 'ssh' && f.uri.authority === name);
if (!config) return isActive ? ConfigStatus.Deleted : ConfigStatus.Error; if (!config) return isActive ? ConfigStatus.Deleted : ConfigStatus.Error;
if (isConnected) { if (isConnected) {
@ -87,34 +100,91 @@ export class Manager implements vscode.TreeDataProvider<string | FileSystemConfi
} }
return ConfigStatus.Idle; return ConfigStatus.Idle;
} }
public getActiveConnection(name: string, config?: FileSystemConfig): Connection | undefined {
let con = config && this.connections.find(con => configMatches(con.config, config));
// If a config was given and we have a connection with the same-ish config, return it
if (con) return con;
// Otherwise if no config was given, just any config with the same name is fine
return config ? undefined : this.connections.find(con => con.config.name === name);
}
public async createConnection(name: string, config?: FileSystemConfig): Promise<Connection> {
const logging = Logging.here(`createConnection(${name},${config && 'config'})`);
let con = this.getActiveConnection(name, config);
if (con) return con;
let promise = this.pendingConnections[name];
if (promise) return promise;
return this.pendingConnections[name] = (async (): Promise<Connection> => {
logging.info(`Creating a new connection for '${name}'`);
const { createSSH, calculateActualConfig } = await import('./connect');
config = config || (await loadConfigs()).find(c => c.name === name);
if (!config) throw new Error(`No configuration with name '${name}' found`);
const actualConfig = await calculateActualConfig(config);
const client = await createSSH(actualConfig);
if (!client) throw new Error(`Could not create SSH session for '${name}'`);
con = {
config, client, actualConfig,
terminals: [],
filesystems: [],
pendingUserCount: 0,
};
this.connections.push(con);
let timeoutCounter = 0;
// Start a timer that'll automatically close the connection once it hasn't been used in a while (about 5s)
const timer = setInterval(() => {
timeoutCounter = timeoutCounter ? timeoutCounter - 1 : 0;
// If something's initiating on the connection, keep it alive
// (the !con is just for intellisense purposes, should never be undefined)
if (!con || con.pendingUserCount) return;
con.filesystems = con.filesystems.filter(fs => !fs.closed && !fs.closing);
if (con.filesystems.length) return; // Still got active filesystems on this connection
// When the manager creates a terminal, it also links up an event to remove it from .terminals when it closes
if (con.terminals.length) return; // Still got active terminals on this connection
// Next iteration, if the connection is still unused, close it
// First iteration here = 2
// Next iteration = 1
// If nothing of the "active" if-statements returned, it'll be 1 here
// After that = 0
if (timeoutCounter !== 1) {
timeoutCounter = 2;
return;
}
// timeoutCounter == 1, so it's been inactive for at least 5 seconds, close it!
logging.info(`Closing connection to '${name}' due to no active filesystems/terminals`);
clearInterval(timer);
this.connections = this.connections.filter(c => c !== con);
con.client.destroy();
}, 5e3);
return con;
})().finally(() => delete this.pendingConnections[name]);
}
public async createFileSystem(name: string, config?: FileSystemConfig): Promise<SSHFileSystem> { public async createFileSystem(name: string, config?: FileSystemConfig): Promise<SSHFileSystem> {
const existing = this.fileSystems.find(fs => fs.authority === name); const existing = this.fileSystems.find(fs => fs.authority === name);
if (existing) return existing; if (existing) return existing;
let promise = this.creatingFileSystems[name]; let promise = this.creatingFileSystems[name];
if (promise) return promise; if (promise) return promise;
config = config || (await getConfigs()).find(c => c.name === name);
if (!config) throw new Error(`Couldn't find a configuration with the name '${name}'`);
const con = await this.createConnection(name, config);
con.pendingUserCount++;
config = con.actualConfig;
promise = catchingPromise<SSHFileSystem>(async (resolve, reject) => { promise = catchingPromise<SSHFileSystem>(async (resolve, reject) => {
const { createSSH, getSFTP, calculateActualConfig } = await import('./connect'); const { getSFTP } = await import('./connect');
// tslint:disable-next-line:no-shadowed-variable (dynamic import for source splitting)
const { SSHFileSystem } = await import('./sshFileSystem'); const { SSHFileSystem } = await import('./sshFileSystem');
config = config || (await loadConfigs()).find(c => c.name === name); // Query/calculate the root directory
config = config && await calculateActualConfig(config) || undefined;
if (!config) {
throw new Error(`A SSH filesystem with the name '${name}' doesn't exist`);
}
const client = await createSSH(config);
if (!client) return reject(null);
let root = config!.root || '/'; let root = config!.root || '/';
if (root.startsWith('~')) { if (root.startsWith('~')) {
const home = await tryGetHome(client); const home = await tryGetHome(con.client);
if (!home) { if (!home) {
await vscode.window.showErrorMessage(`Couldn't detect the home directory for '${name}'`, 'Okay'); await vscode.window.showErrorMessage(`Couldn't detect the home directory for '${name}'`, 'Okay');
return reject(); return reject();
} }
root = root.replace(/^~/, home.replace(/\/$/, '')); root = root.replace(/^~/, home.replace(/\/$/, ''));
} }
const sftp = await getSFTP(client, config); // Create the actual SFTP session (using the connection's actualConfig, otherwise it'll reprompt for passwords etc)
const sftp = await getSFTP(con.client, con.actualConfig);
const fs = new SSHFileSystem(name, sftp, root, config!); const fs = new SSHFileSystem(name, sftp, root, config!);
Logging.info(`Created SSHFileSystem for ${name}, reading root directory...`); Logging.info(`Created SSHFileSystem for ${name}, reading root directory...`);
// Sanity check that we can actually access the root directory (maybe it requires permissions we don't have)
try { try {
const rootUri = vscode.Uri.parse(`ssh://${name}/`); const rootUri = vscode.Uri.parse(`ssh://${name}/`);
const stat = await fs.stat(rootUri); const stat = await fs.stat(rootUri);
@ -131,13 +201,16 @@ export class Manager implements vscode.TreeDataProvider<string | FileSystemConfi
await vscode.window.showErrorMessage(message, 'Okay'); await vscode.window.showErrorMessage(message, 'Okay');
return reject(); return reject();
} }
con.filesystems.push(fs);
this.fileSystems.push(fs); this.fileSystems.push(fs);
delete this.creatingFileSystems[name]; delete this.creatingFileSystems[name];
vscode.commands.executeCommand('workbench.files.action.refreshFilesExplorer'); vscode.commands.executeCommand('workbench.files.action.refreshFilesExplorer');
this.onDidChangeTreeDataEmitter.fire(null); this.onDidChangeTreeDataEmitter.fire(null);
client.once('close', hadError => hadError ? this.commandReconnect(name) : (!fs.closing && this.promptReconnect(name))); con.client.once('close', hadError => hadError ? this.commandReconnect(name) : (!fs.closing && this.promptReconnect(name)));
con.pendingUserCount--;
return resolve(fs); return resolve(fs);
}).catch((e) => { }).catch((e) => {
con.pendingUserCount--; // I highly doubt resolve(fs) will error
this.onDidChangeTreeDataEmitter.fire(null); this.onDidChangeTreeDataEmitter.fire(null);
if (!e) { if (!e) {
delete this.creatingFileSystems[name]; delete this.creatingFileSystems[name];
@ -160,8 +233,19 @@ export class Manager implements vscode.TreeDataProvider<string | FileSystemConfi
}); });
return this.creatingFileSystems[name] = promise; return this.creatingFileSystems[name] = promise;
} }
public getActive() { public async createTerminal(name: string, config?: FileSystemConfig): Promise<void> {
return this.fileSystems.map(fs => fs.config); const { createTerminal } = await import('./pseudoTerminal');
const con = await this.createConnection(name, config);
con.pendingUserCount++;
const pty = await createTerminal(con.client, con.actualConfig);
pty.onDidClose(() => con.terminals = con.terminals.filter(t => t !== pty));
con.terminals.push(pty);
con.pendingUserCount--;
const terminal = vscode.window.createTerminal({ name, pty });
terminal.show();
}
public getActiveFileSystems(): readonly SSHFileSystem[] {
return this.fileSystems;
} }
public getFs(uri: vscode.Uri): SSHFileSystem | null { public getFs(uri: vscode.Uri): SSHFileSystem | null {
const fs = this.fileSystems.find(f => f.authority === uri.authority); const fs = this.fileSystems.find(f => f.authority === uri.authority);
@ -229,7 +313,8 @@ export class Manager implements vscode.TreeDataProvider<string | FileSystemConfi
const config = typeof target === 'object' ? target : undefined; const config = typeof target === 'object' ? target : undefined;
if (typeof target === 'object') target = target.name; if (typeof target === 'object') target = target.name;
Logging.info(`Command received to connect ${target}`); Logging.info(`Command received to connect ${target}`);
if (this.getActive().find(fs => fs.name === target)) return vscode.commands.executeCommand('workbench.files.action.refreshFilesExplorer'); const existing = this.fileSystems.find(fs => fs.config.name === target);
if (existing) return vscode.commands.executeCommand('workbench.files.action.refreshFilesExplorer');
const folders = vscode.workspace.workspaceFolders!; const folders = vscode.workspace.workspaceFolders!;
const folder = folders && folders.find(f => f.uri.scheme === 'ssh' && f.uri.authority === target); const folder = folders && folders.find(f => f.uri.scheme === 'ssh' && f.uri.authority === target);
if (folder) { if (folder) {
@ -239,6 +324,13 @@ export class Manager implements vscode.TreeDataProvider<string | FileSystemConfi
vscode.workspace.updateWorkspaceFolders(folders ? folders.length : 0, 0, { uri: vscode.Uri.parse(`ssh://${target}/`), name: `SSH FS - ${target}` }); vscode.workspace.updateWorkspaceFolders(folders ? folders.length : 0, 0, { uri: vscode.Uri.parse(`ssh://${target}/`), name: `SSH FS - ${target}` });
this.onDidChangeTreeDataEmitter.fire(null); this.onDidChangeTreeDataEmitter.fire(null);
} }
public async commandTerminal(target: string | FileSystemConfig) {
if (typeof target === 'string') {
await this.createTerminal(target);
} else {
await this.createTerminal(target.label || target.name, target);
}
}
public async commandConfigure(target: string | FileSystemConfig) { public async commandConfigure(target: string | FileSystemConfig) {
Logging.info(`Command received to configure ${typeof target === 'string' ? target : target.name}`); Logging.info(`Command received to configure ${typeof target === 'string' ? target : target.name}`);
if (typeof target === 'object') { if (typeof target === 'object') {

@ -0,0 +1,48 @@
import { Client, ClientChannel, PseudoTtyOptions } from "ssh2";
import { Readable } from "stream";
import * as vscode from "vscode";
import { FileSystemConfig } from "./fileSystemConfig";
import { toPromise } from "./toPromise";
const [HEIGHT, WIDTH] = [480, 640];
const PSEUDO_TY_OPTIONS: PseudoTtyOptions = {
height: HEIGHT, width: WIDTH,
};
export interface SSHPseudoTerminal extends vscode.Pseudoterminal {
onDidClose: vscode.Event<number>; // Redeclaring that it isn't undefined
config: FileSystemConfig;
client: Client;
channel: ClientChannel;
}
export async function createTerminal(client: Client, config: FileSystemConfig): Promise<SSHPseudoTerminal> {
const channel = await toPromise<ClientChannel | undefined>(cb => client.shell(PSEUDO_TY_OPTIONS, cb));
if (!channel) throw new Error('Could not create remote terminal');
const onDidWrite = new vscode.EventEmitter<string>();
onDidWrite.fire(`Connecting to ${config.label || config.name}...\n`);
(channel as Readable).on('data', chunk => onDidWrite.fire(chunk.toString()));
const onDidClose = new vscode.EventEmitter<number>();
channel.on('exit', onDidClose.fire);
// Hopefully the exit event fires first
channel.on('close', () => onDidClose.fire(1));
const pseudo: SSHPseudoTerminal = {
config, client, channel,
onDidWrite: onDidWrite.event,
onDidClose: onDidClose.event,
close() {
channel.close();
},
open(dims) {
if (!dims) return;
channel.setWindow(dims.rows, dims.columns, HEIGHT, WIDTH);
},
setDimensions(dims) {
channel.setWindow(dims.rows, dims.columns, HEIGHT, WIDTH);
},
handleInput(data) {
channel.write(data);
},
};
return pseudo;
}
Loading…
Cancel
Save