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

@ -284,6 +284,30 @@ export function getConfig(name: string) {
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) => {
// if (!e.affectsConfiguration('sshfs.configs')) return;
return loadConfigs();

@ -5,7 +5,7 @@ import { SFTPStream } from 'ssh2-streams';
import * as vscode from 'vscode';
import { getConfigs } from './config';
import { FileSystemConfig } from './fileSystemConfig';
import { Logging, censorConfig } from './logging';
import { censorConfig, Logging } from './logging';
import { toPromise } from './toPromise';
// 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)] || '');
}
export async function calculateActualConfig(config: FileSystemConfig): Promise<FileSystemConfig | null> {
if ('_calculated' in config) return config;
export async function calculateActualConfig(config: FileSystemConfig): Promise<FileSystemConfig> {
if (config._calculated) return config;
const logging = Logging.here();
config = { ...config };
(config as any)._calculated = true;
// Add the internal _calculated field to cache the actual config for the next calculateActualConfig call
// (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.host = replaceVariables(config.host);
const port = replaceVariables((config.port || '') + '');

@ -20,15 +20,15 @@ function generateDetail(config: FileSystemConfig): string | undefined {
return `${host}${port}`;
}
async function pickConfig(manager: Manager, activeOrNot?: boolean): Promise<string | undefined> {
let names = manager.getActive();
async function pickConfig(manager: Manager, activeFileSystem?: boolean): Promise<string | undefined> {
let fsConfigs = manager.getActiveFileSystems().map(fs => fs.config);
const others = await loadConfigs();
if (activeOrNot === false) {
names = others.filter(c => !names.find(cc => cc.name === c.name));
} else if (activeOrNot === undefined) {
others.forEach(n => !names.find(c => c.name === n.name) && names.push(n));
if (activeFileSystem === false) {
fsConfigs = others.filter(c => !fsConfigs.find(cc => cc.name === c.name));
} else if (activeFileSystem === undefined) {
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,
description: 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.disconnect', (name?: string) => pickAndClick(manager.commandDisconnect, 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.reload', loadConfigs);

@ -102,6 +102,8 @@ export interface FileSystemConfig extends ConnectConfig {
_location?: ConfigLocation;
/** Internal property keeping track of where this config comes from (including merges) */
_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) {

@ -1,9 +1,10 @@
import { Client, ClientChannel } from 'ssh2';
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 { Logging } from './logging';
import { SSHPseudoTerminal } from './pseudoTerminal';
import { catchingPromise, toPromise } from './toPromise';
import { Navigation } from './webviewMessages';
@ -48,8 +49,19 @@ async function tryGetHome(ssh: Client): Promise<string | null> {
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> {
public onDidChangeTreeData: vscode.Event<string | null>;
protected connections: Connection[] = [];
protected pendingConnections: { [name: string]: Promise<Connection> } = {};
protected fileSystems: SSHFileSystem[] = [];
protected creatingFileSystems: { [name: string]: Promise<SSHFileSystem> } = {};
protected onDidChangeTreeDataEmitter = new vscode.EventEmitter<string | null>();
@ -74,10 +86,11 @@ export class Manager implements vscode.TreeDataProvider<string | FileSystemConfi
this.onDidChangeTreeDataEmitter.fire(null);
// TODO: Offer to reconnect everything
}
/** This purely looks at whether a filesystem with the given name is available/connecting */
public getStatus(name: string): ConfigStatus {
const config = getConfig(name);
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);
if (!config) return isActive ? ConfigStatus.Deleted : ConfigStatus.Error;
if (isConnected) {
@ -87,34 +100,91 @@ export class Manager implements vscode.TreeDataProvider<string | FileSystemConfi
}
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> {
const existing = this.fileSystems.find(fs => fs.authority === name);
if (existing) return existing;
let promise = this.creatingFileSystems[name];
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) => {
const { createSSH, getSFTP, calculateActualConfig } = await import('./connect');
// tslint:disable-next-line:no-shadowed-variable (dynamic import for source splitting)
const { getSFTP } = await import('./connect');
const { SSHFileSystem } = await import('./sshFileSystem');
config = config || (await loadConfigs()).find(c => c.name === name);
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);
// Query/calculate the root directory
let root = config!.root || '/';
if (root.startsWith('~')) {
const home = await tryGetHome(client);
const home = await tryGetHome(con.client);
if (!home) {
await vscode.window.showErrorMessage(`Couldn't detect the home directory for '${name}'`, 'Okay');
return reject();
}
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!);
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 {
const rootUri = vscode.Uri.parse(`ssh://${name}/`);
const stat = await fs.stat(rootUri);
@ -131,13 +201,16 @@ export class Manager implements vscode.TreeDataProvider<string | FileSystemConfi
await vscode.window.showErrorMessage(message, 'Okay');
return reject();
}
con.filesystems.push(fs);
this.fileSystems.push(fs);
delete this.creatingFileSystems[name];
vscode.commands.executeCommand('workbench.files.action.refreshFilesExplorer');
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);
}).catch((e) => {
con.pendingUserCount--; // I highly doubt resolve(fs) will error
this.onDidChangeTreeDataEmitter.fire(null);
if (!e) {
delete this.creatingFileSystems[name];
@ -160,8 +233,19 @@ export class Manager implements vscode.TreeDataProvider<string | FileSystemConfi
});
return this.creatingFileSystems[name] = promise;
}
public getActive() {
return this.fileSystems.map(fs => fs.config);
public async createTerminal(name: string, config?: FileSystemConfig): Promise<void> {
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 {
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;
if (typeof target === 'object') target = target.name;
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 folder = folders && folders.find(f => f.uri.scheme === 'ssh' && f.uri.authority === target);
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}` });
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) {
Logging.info(`Command received to configure ${typeof target === 'string' ? target : target.name}`);
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