From 2d4a582dee812231acd8e7a6ca960a20ce2fc2f1 Mon Sep 17 00:00:00 2001 From: Kelvin Schoofs Date: Wed, 8 Jul 2020 01:54:55 +0200 Subject: [PATCH] Add the ability to open a SSH terminal --- package.json | 26 +++++++-- src/config.ts | 24 ++++++++ src/connect.ts | 11 ++-- src/extension.ts | 15 ++--- src/fileSystemConfig.ts | 2 + src/manager.ts | 126 ++++++++++++++++++++++++++++++++++------ src/pseudoTerminal.ts | 48 +++++++++++++++ 7 files changed, 217 insertions(+), 35 deletions(-) create mode 100644 src/pseudoTerminal.ts diff --git a/package.json b/package.json index 3067c7d..2423f92 100644 --- a/package.json +++ b/package.json @@ -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" } -} \ No newline at end of file +} diff --git a/src/config.ts b/src/config.ts index 4065da2..32fb3cf 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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(); diff --git a/src/connect.ts b/src/connect.ts index 2e5a88e..3a9b730 100644 --- a/src/connect.ts +++ b/src/connect.ts @@ -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 { - if ('_calculated' in config) return config; +export async function calculateActualConfig(config: FileSystemConfig): Promise { + 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 || '') + ''); diff --git a/src/extension.ts b/src/extension.ts index 8e5b5c4..a149fea 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -20,15 +20,15 @@ function generateDetail(config: FileSystemConfig): string | undefined { return `${host}${port}`; } -async function pickConfig(manager: Manager, activeOrNot?: boolean): Promise { - let names = manager.getActive(); +async function pickConfig(manager: Manager, activeFileSystem?: boolean): Promise { + 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); diff --git a/src/fileSystemConfig.ts b/src/fileSystemConfig.ts index 06918ce..a06fd09 100644 --- a/src/fileSystemConfig.ts +++ b/src/fileSystemConfig.ts @@ -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) { diff --git a/src/manager.ts b/src/manager.ts index b800068..438e2ba 100644 --- a/src/manager.ts +++ b/src/manager.ts @@ -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 { return mat[1]; } +interface Connection { + config: FileSystemConfig; + actualConfig: FileSystemConfig; + client: Client; + terminals: SSHPseudoTerminal[]; + filesystems: SSHFileSystem[]; + pendingUserCount: number; +} + export class Manager implements vscode.TreeDataProvider { public onDidChangeTreeData: vscode.Event; + protected connections: Connection[] = []; + protected pendingConnections: { [name: string]: Promise } = {}; protected fileSystems: SSHFileSystem[] = []; protected creatingFileSystems: { [name: string]: Promise } = {}; protected onDidChangeTreeDataEmitter = new vscode.EventEmitter(); @@ -74,10 +86,11 @@ export class Manager implements vscode.TreeDataProvider 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 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 { + 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 => { + 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 { 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(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 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 fs.config); + public async createTerminal(name: string, config?: FileSystemConfig): Promise { + 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 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; // Redeclaring that it isn't undefined + config: FileSystemConfig; + client: Client; + channel: ClientChannel; +} + +export async function createTerminal(client: Client, config: FileSystemConfig): Promise { + const channel = await toPromise(cb => client.shell(PSEUDO_TY_OPTIONS, cb)); + if (!channel) throw new Error('Could not create remote terminal'); + const onDidWrite = new vscode.EventEmitter(); + onDidWrite.fire(`Connecting to ${config.label || config.name}...\n`); + (channel as Readable).on('data', chunk => onDidWrite.fire(chunk.toString())); + const onDidClose = new vscode.EventEmitter(); + 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; +}