diff --git a/package.json b/package.json index 2a1ad69..d8b5ad2 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "onFileSystem:ssh", "onFileSystemAccess:ssh", "onView:sshfs-configs", + "onView:sshfs-connections", "onCommand:sshfs.new", "onCommand:sshfs.add", "onCommand:sshfs.disconnect", @@ -23,6 +24,7 @@ "onCommand:sshfs.configure", "onCommand:sshfs.reload", "onCommand:sshfs.settings", + "onCommand:sshfs.refresh" ], "main": "./dist/extension.js", "author": { @@ -49,10 +51,29 @@ ], "contributes": { "views": { - "explorer": [ + "sshfs": [ { "id": "sshfs-configs", - "name": "SSH File Systems" + "name": "Configurations", + "contextualTitle": "SSH FS - Configurations", + "icon": "resources/icon.svg", + "visibility": "visible" + }, + { + "id": "sshfs-connections", + "name": "Connections", + "contextualTitle": "SSH FS - Connections", + "icon": "resources/icon.svg", + "visibility": "visible" + } + ] + }, + "viewsContainers": { + "activitybar": [ + { + "id": "sshfs", + "title": "SSH FS", + "icon": "resources/icon.svg" } ] }, @@ -99,6 +120,12 @@ "category": "SSH FS", "icon": "$(settings)" }, + { + "command": "sshfs.refresh", + "title": "Refresh", + "category": "SSH FS", + "icon": "$(refresh)" + }, { "command": "sshfs.focusTerminal", "title": "Focus terminal", @@ -149,18 +176,32 @@ { "command": "sshfs.closeTerminal", "when": "false" + }, + { + "command": "sshfs.refresh", + "when": "false" } ], "view/title": [ + { + "command": "sshfs.refresh", + "when": "view == 'sshfs-configs' || view == 'sshfs-connections'", + "group": "navigation@1" + }, { "command": "sshfs.new", "when": "view == 'sshfs-configs'", - "group": "SSH FS@1" + "group": "navigation@1" + }, + { + "command": "sshfs.add", + "when": "view == 'sshfs-connections'", + "group": "navigation@2" }, { "command": "sshfs.settings", - "when": "view == 'sshfs-configs'", - "group": "SSH FS@6" + "when": "view == 'sshfs-configs' || view == 'sshfs-connections'", + "group": "navigation@3" } ], "view/item/context": [ @@ -186,13 +227,18 @@ }, { "command": "sshfs.terminal", - "when": "view == 'sshfs-configs' && viewItem", - "group": "SSH FS@4" + "when": "view == 'sshfs-connections' && viewItem == connection", + "group": "inline@1" }, { - "command": "sshfs.configure", - "when": "view == 'sshfs-configs' && viewItem", - "group": "SSH FS@5" + "command": "sshfs.disconnect", + "when": "view == 'sshfs-connections' && viewItem == connection", + "group": "inline@2" + }, + { + "command": "sshfs.closeTerminal", + "when": "view == 'sshfs-connections' && viewItem == terminal", + "group": "inline@1" } ], "explorer/context": [ diff --git a/resources/icon.svg b/resources/icon.svg new file mode 100644 index 0000000..d90b49b --- /dev/null +++ b/resources/icon.svg @@ -0,0 +1,12 @@ + + + + + + + + + > _ + + \ No newline at end of file diff --git a/src/connection.ts b/src/connection.ts index 3292996..e3d2299 100644 --- a/src/connection.ts +++ b/src/connection.ts @@ -19,10 +19,18 @@ export interface Connection { export class ConnectionManager { protected onConnectionAddedEmitter = new vscode.EventEmitter(); protected onConnectionRemovedEmitter = new vscode.EventEmitter(); + protected onConnectionUpdatedEmitter = new vscode.EventEmitter(); + protected onPendingChangedEmitter = new vscode.EventEmitter(); protected connections: Connection[] = []; - protected pendingConnections: { [name: string]: Promise } = {}; + protected pendingConnections: { [name: string]: [Promise, FileSystemConfig | undefined] } = {}; + /** Fired when a connection got added (and finished connecting) */ public readonly onConnectionAdded = this.onConnectionAddedEmitter.event; + /** Fired when a connection got removed */ public readonly onConnectionRemoved = this.onConnectionRemovedEmitter.event; + /** Fired when a connection got updated (terminal added/removed, ...) */ + public readonly onConnectionUpdated = this.onConnectionUpdatedEmitter.event; + /** Fired when a pending connection gets added/removed */ + public readonly onPendingChanged = this.onPendingChangedEmitter.event; public getActiveConnection(name: string, config?: FileSystemConfig): Connection | undefined { const con = config && this.connections.find(con => configMatches(con.config, config)); return con || (config ? undefined : this.connections.find(con => con.config.name === name)); @@ -30,38 +38,52 @@ export class ConnectionManager { public getActiveConnections(): Connection[] { return [...this.connections]; } + public getPendingConnections(): [string, FileSystemConfig | undefined][] { + return Object.keys(this.pendingConnections).map(name => [name, this.pendingConnections[name][1]]); + } + protected async _createConnection(name: string, config?: FileSystemConfig): Promise { const logging = Logging.scope(`createConnection(${name},${config ? 'config' : 'undefined'})`); + 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}'`); + let timeoutCounter = 0; + const con: Connection = { + config, client, actualConfig, + terminals: [], + filesystems: [], + pendingUserCount: 0, + idleTimer: setInterval(() => { // Automatically close connection when idle for a while + timeoutCounter = timeoutCounter ? timeoutCounter - 1 : 0; + if (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 + if (con.terminals.length) return; // Still got active terminals on this connection + if (timeoutCounter !== 1) return timeoutCounter = 2; + // timeoutCounter === 1, so it's been inactive for at least 5 seconds, close it! + this.closeConnection(con, 'Idle with no active filesystems/terminals'); + }, 5e3), + }; + this.connections.push(con); + this.onConnectionAddedEmitter.fire(con); + return con; + } + public async createConnection(name: string, config?: FileSystemConfig): Promise { const con = this.getActiveConnection(name, config); if (con) return con; - 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}'`); - let timeoutCounter = 0; - const con: Connection = { - config, client, actualConfig, - terminals: [], - filesystems: [], - pendingUserCount: 0, - idleTimer: setInterval(() => { // Automatically close connection when idle for a while - timeoutCounter = timeoutCounter ? timeoutCounter - 1 : 0; - if (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 - if (con.terminals.length) return; // Still got active terminals on this connection - if (timeoutCounter !== 1) return timeoutCounter = 2; - // timeoutCounter === 1, so it's been inactive for at least 5 seconds, close it! - this.closeConnection(con, 'Idle with no active filesystems/terminals'); - }, 5e3), - }; - this.connections.push(con); - this.onConnectionAddedEmitter.fire(con); - return con; - })().finally(() => delete this.pendingConnections[name]); + let pending = this.pendingConnections[name]; + if (pending) return pending[0]; + pending = [this._createConnection(name, config), config]; + this.pendingConnections[name] = pending; + this.onPendingChangedEmitter.fire(); + pending[0].finally(() => { + delete this.pendingConnections[name]; + this.onPendingChangedEmitter.fire(); + }); + return pending[0]; } public closeConnection(connection: Connection, reason?: string) { const index = this.connections.indexOf(connection); @@ -73,4 +95,13 @@ export class ConnectionManager { this.onConnectionRemovedEmitter.fire(connection); connection.client.destroy(); } + // Without making createConnection return a Proxy, or making Connection a class with + // getters and setters informing the manager that created it, we don't know if it updated. + // So stuff that updates connections should inform us by calling this method. + // (currently the only thing this matters for is the 'sshfs-connections' tree view) + // The updater callback just allows for syntactic sugar e.g. update(con, con => modifyCon(con)) + public update(connection: Connection, updater?: (con: Connection) => void) { + updater?.(connection); + this.onConnectionUpdatedEmitter.fire(connection); + } } diff --git a/src/extension.ts b/src/extension.ts index a9c4b86..7bf62a4 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -7,38 +7,8 @@ import { FileSystemRouter } from './fileSystemRouter'; import { Logging } from './logging'; import { Manager } from './manager'; import type { SSHPseudoTerminal } from './pseudoTerminal'; - -function generateDetail(config: FileSystemConfig): string | undefined { - const { username, host, putty } = config; - const port = config.port && config.port !== 22 ? `:${config.port}` : ''; - if (putty) { - if (typeof putty === 'string') return `PuTTY session "${putty}"`; - return 'PuTTY session (deduced from config)'; - } else if (!host) { - return undefined; - } else if (username) { - return `${username}@${host}${port}`; - } - return `${host}${port}`; -} - -async function pickConfig(manager: Manager, activeFileSystem?: boolean): Promise { - let fsConfigs = manager.getActiveFileSystems().map(fs => fs.config).map(c => c._calculated || c); - const others = await loadConfigs(); - 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 })[] = fsConfigs.map(config => ({ - name: config.name, - description: config.name, - label: config.label || config.name, - detail: generateDetail(config), - })); - const pick = await vscode.window.showQuickPick(options, { placeHolder: 'SSH FS Configuration' }); - return pick && pick.name; -} +import { ConfigTreeProvider, ConnectionTreeProvider } from './treeViewManager'; +import { pickComplex, PickComplexOptions, pickConnection, setAsAbsolutePath } from './ui-utils'; function getVersion(): string | undefined { const ext = vscode.extensions.getExtension('Kelvin.vscode-sshfs'); @@ -59,6 +29,10 @@ interface CommandHandler { export function activate(context: vscode.ExtensionContext) { Logging.info(`Extension activated, version ${getVersion()}`); + // Really too bad we *need* the ExtensionContext for relative resources + // I really don't like having to pass context to *everything*, so let's do it this way + setAsAbsolutePath(context.asAbsolutePath.bind(context)); + const manager = new Manager(context); const subscribe = context.subscriptions.push.bind(context.subscriptions) as typeof context.subscriptions.push; @@ -66,7 +40,9 @@ export function activate(context: vscode.ExtensionContext) { subscribe(vscode.commands.registerCommand(command, callback, thisArg)); subscribe(vscode.workspace.registerFileSystemProvider('ssh', new FileSystemRouter(manager), { isCaseSensitive: true })); - subscribe(vscode.window.createTreeView('sshfs-configs', { treeDataProvider: manager, showCollapseAll: true })); + subscribe(vscode.window.createTreeView('sshfs-configs', { treeDataProvider: new ConfigTreeProvider(), showCollapseAll: true })); + const connectionsTreeProvider = new ConnectionTreeProvider(manager.connectionManager); + subscribe(vscode.window.createTreeView('sshfs-connections', { treeDataProvider: connectionsTreeProvider, showCollapseAll: true })); subscribe(vscode.tasks.registerTaskProvider('ssh-shell', manager)); function registerCommandHandler(name: string, handler: CommandHandler) { @@ -141,4 +117,7 @@ export function activate(context: vscode.ExtensionContext) { // sshfs.settings() registerCommand('sshfs.settings', () => manager.openSettings()); + + // sshfs.refresh() + registerCommand('sshfs.refresh', () => connectionsTreeProvider.refresh()); } diff --git a/src/manager.ts b/src/manager.ts index 8efd8e5..637c7cb 100644 --- a/src/manager.ts +++ b/src/manager.ts @@ -2,39 +2,13 @@ import * as path from 'path'; import type { Client, ClientChannel } from 'ssh2'; import * as vscode from 'vscode'; -import { getConfig, getConfigs, loadConfigsRaw, UPDATE_LISTENERS } from './config'; -import { FileSystemConfig, getGroups } from './fileSystemConfig'; +import { getConfig, getConfigs, loadConfigsRaw } from './config'; +import { Connection, ConnectionManager } from './connection'; +import type { FileSystemConfig } from './fileSystemConfig'; import { Logging } from './logging'; import type { SSHFileSystem } from './sshFileSystem'; import { catchingPromise, toPromise } from './toPromise'; import type { Navigation } from './webviewMessages'; -import { Connection, ConnectionManager } from './connection'; - -export enum ConfigStatus { - Idle = 'Idle', - Active = 'Active', - Deleted = 'Deleted', - Connecting = 'Connecting', - Error = 'Error', -} - -function createTreeItem(manager: Manager, item: string | FileSystemConfig): vscode.TreeItem { - if (typeof item === 'string') { - return { - label: item.replace(/^.+\./, ''), - collapsibleState: vscode.TreeItemCollapsibleState.Collapsed, - }; - } - const folders = vscode.workspace.workspaceFolders || []; - const isConnected = folders.some(f => f.uri.scheme === 'ssh' && f.uri.authority === item.name); - const status = manager.getStatus(item.name); - return { - label: item && item.label || item.name, - contextValue: isConnected ? 'active' : 'inactive', - tooltip: status === 'Deleted' ? 'Active but deleted' : status, - iconPath: manager.context.asAbsolutePath(`resources/config/${status}.png`), - }; -} async function tryGetHome(ssh: Client): Promise { const exec = await toPromise(cb => ssh.exec('echo Home: ~', cb)); @@ -62,14 +36,11 @@ interface SSHShellTaskOptions { workingDirectory?: string; } -export class Manager implements vscode.TreeDataProvider, vscode.TaskProvider { - public onDidChangeTreeData: vscode.Event; +export class Manager implements vscode.TaskProvider { protected fileSystems: SSHFileSystem[] = []; protected creatingFileSystems: { [name: string]: Promise } = {}; - protected onDidChangeTreeDataEmitter = new vscode.EventEmitter(); - protected connectionManager = new ConnectionManager(); + public readonly connectionManager = new ConnectionManager(); constructor(public readonly context: vscode.ExtensionContext) { - this.onDidChangeTreeData = this.onDidChangeTreeDataEmitter.event; // In a multi-workspace environment, when the non-main folder gets removed, // it might be one of ours, which we should then disconnect if it's // the only one left for the given config (name) @@ -79,29 +50,10 @@ export class Manager implements vscode.TreeDataProvider { if (folder.uri.scheme !== 'ssh') return; if (workspaceFolders.find(f => f.uri.authority === folder.uri.authority)) return; - this.commandDisconnect(folder.uri.authority); + const fs = this.fileSystems.find(fs => fs.authority === folder.uri.authority); + if (fs) fs.disconnect(); }); - this.onDidChangeTreeDataEmitter.fire(null); }); - UPDATE_LISTENERS.push(() => this.fireConfigChanged()); - } - public fireConfigChanged(): void { - 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.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) { - if (isActive) return ConfigStatus.Active; - if (this.creatingFileSystems[name]) return ConfigStatus.Connecting; - return ConfigStatus.Error; - } - return ConfigStatus.Idle; } public async createFileSystem(name: string, config?: FileSystemConfig): Promise { const existing = this.fileSystems.find(fs => fs.authority === name); @@ -111,7 +63,7 @@ export class Manager implements vscode.TreeDataProvider c.name === name); if (!config) throw new Error(`Couldn't find a configuration with the name '${name}'`); const con = await this.connectionManager.createConnection(name, config); - con.pendingUserCount++; + this.connectionManager.update(con, con => con.pendingUserCount++); config = con.actualConfig; const { getSFTP } = await import('./connect'); const { SSHFileSystem } = await import('./sshFileSystem'); @@ -146,17 +98,19 @@ export class Manager implements vscode.TreeDataProvider con.filesystems.push(fs)); this.fileSystems.push(fs); delete this.creatingFileSystems[name]; + fs.onClose(() => { + this.fileSystems = this.fileSystems.filter(f => f !== fs); + this.connectionManager.update(con, con => con.filesystems = con.filesystems.filter(f => f !== fs)); + }); vscode.commands.executeCommand('workbench.files.action.refreshFilesExplorer'); - this.onDidChangeTreeDataEmitter.fire(null); - con.client.once('close', hadError => hadError ? this.commandReconnect(name) : (!fs.closing && this.promptReconnect(name))); - con.pendingUserCount--; + // con.client.once('close', hadError => !fs.closing && this.promptReconnect(name)); + this.connectionManager.update(con, con => con.pendingUserCount--); return resolve(fs); }).catch((e) => { - if (con) con.pendingUserCount--; // I highly doubt resolve(fs) will error - this.onDidChangeTreeDataEmitter.fire(null); + if (con) this.connectionManager.update(con, con => con.pendingUserCount--); // I highly doubt resolve(fs) will error if (!e) { delete this.creatingFileSystems[name]; this.commandDisconnect(name); @@ -185,21 +139,21 @@ export class Manager implements vscode.TreeDataProvider { + public async createTerminal(name: string, config?: FileSystemConfig | Connection, uri?: vscode.Uri): Promise { const { createTerminal } = await import('./pseudoTerminal'); // Create connection (early so we have .actualConfig.root) - const con = await this.connectionManager.createConnection(name, config); + const con = (config && 'client' in config) ? config : await this.connectionManager.createConnection(name, config); // Calculate working directory if applicable let workingDirectory: string | undefined = uri && uri.path; if (workingDirectory) workingDirectory = this.getRemotePath(con.actualConfig, workingDirectory); // Create pseudo terminal - con.pendingUserCount++; + this.connectionManager.update(con, con => con.pendingUserCount++); const pty = await createTerminal({ client: con.client, config: con.actualConfig, workingDirectory }); - pty.onDidClose(() => con.terminals = con.terminals.filter(t => t !== pty)); - con.terminals.push(pty); - con.pendingUserCount--; + pty.onDidClose(() => this.connectionManager.update(con, con => con.terminals = con.terminals.filter(t => t !== pty))); + this.connectionManager.update(con, con => (con.terminals.push(pty), con.pendingUserCount--)); // Create and show the graphical representation const terminal = vscode.window.createTerminal({ name, pty }); + pty.terminal = terminal; terminal.show(); } public getActiveFileSystems(): readonly SSHFileSystem[] { @@ -214,30 +168,8 @@ export class Manager implements vscode.TreeDataProvider { - return createTreeItem(this, element); - } - public getChildren(element: string | FileSystemConfig = ''): vscode.ProviderResult<(string | FileSystemConfig)[]> { - if (typeof element === 'object') return []; // FileSystemConfig, has no children - const configs = this.fileSystems.map(fs => fs.config).map(c => c._calculated || c); - configs.push(...getConfigs().filter(c => !configs.find(fs => c.name === fs.name))); - const matching = configs.filter(({ group }) => (group || '') === element); - matching.sort((a, b) => a.name > b.name ? 1 : -1); - let groups = getGroups(configs, true); - if (element) { - groups = groups.filter(g => g.startsWith(element) && g[element.length] === '.' && !g.includes('.', element.length + 1)); - } else { - groups = groups.filter(g => !g.includes('.')); - } - return [...matching, ...groups.sort()]; + const choice = await vscode.window.showWarningMessage(`SSH FS ${config.label || config.name} disconnected`, 'Ignore', 'Disconnect'); + if (choice === 'Disconnect') this.commandDisconnect(name); } /* TaskProvider */ public provideTasks(token?: vscode.CancellationToken | undefined): vscode.ProviderResult { @@ -254,21 +186,21 @@ export class Manager implements vscode.TreeDataProvider { const connection = await this.connectionManager.createConnection(host); - connection.pendingUserCount++; + this.connectionManager.update(connection, con => con.pendingUserCount++); const { createTerminal } = await import('./pseudoTerminal'); - const psy = await createTerminal({ + const pty = await createTerminal({ command, workingDirectory, client: connection.client, config: connection.actualConfig, }); - connection.pendingUserCount--; - connection.terminals.push(psy); - psy.onDidClose(() => connection.terminals = connection.terminals.filter(t => t !== psy)); - return psy; + this.connectionManager.update(connection, con => (con.pendingUserCount--, con.terminals.push(pty))); + pty.onDidClose(() => this.connectionManager.update(connection, + con => con.terminals = con.terminals.filter(t => t !== pty))); + return pty; }) ) } @@ -302,7 +234,7 @@ export class Manager implements vscode.TreeDataProvider start) { left.push(folder); - } + } }; vscode.workspace.updateWorkspaceFolders(start, folders.length - start, ...left); } diff --git a/src/treeViewManager.ts b/src/treeViewManager.ts new file mode 100644 index 0000000..d0fbf52 --- /dev/null +++ b/src/treeViewManager.ts @@ -0,0 +1,84 @@ + +import * as vscode from 'vscode'; +import { getConfigs, UPDATE_LISTENERS } from './config'; +import type { Connection, ConnectionManager } from './connection'; +import { FileSystemConfig, getGroups } from './fileSystemConfig'; +import type { SSHPseudoTerminal } from './pseudoTerminal'; +import type { SSHFileSystem } from './sshFileSystem'; +import { formatItem } from './ui-utils'; + +type PendingConnection = [string, FileSystemConfig | undefined]; +type TreeData = Connection | PendingConnection | SSHFileSystem | SSHPseudoTerminal; +export class ConnectionTreeProvider implements vscode.TreeDataProvider { + protected onDidChangeTreeDataEmitter = new vscode.EventEmitter(); + public onDidChangeTreeData: vscode.Event = this.onDidChangeTreeDataEmitter.event; + constructor(protected readonly manager: ConnectionManager) { + manager.onConnectionAdded(() => this.onDidChangeTreeDataEmitter.fire()); + manager.onConnectionRemoved(() => this.onDidChangeTreeDataEmitter.fire()); + manager.onConnectionUpdated(con => this.onDidChangeTreeDataEmitter.fire(con)); + } + public refresh() { + this.onDidChangeTreeDataEmitter.fire(); + } + public getTreeItem(element: TreeData): vscode.TreeItem | Thenable { + if ('onDidChangeFile' in element || 'handleInput' in element) { // SSHFileSystem | SSHPseudoTerminal + return { ...formatItem(element), collapsibleState: vscode.TreeItemCollapsibleState.None } + } else if (Array.isArray(element)) { // PendingConnection + const [name, config] = element; + if (!config) return { label: name, description: 'Connecting...' }; + return { + ...formatItem(config), + contextValue: 'pendingconnection', + collapsibleState: vscode.TreeItemCollapsibleState.Collapsed, + // Doesn't seem to actually spin, but still gets rendered properly otherwise + iconPath: new vscode.ThemeIcon('loading~spin'), + }; + } + // Connection + return { ...formatItem(element), collapsibleState: vscode.TreeItemCollapsibleState.Collapsed }; + } + public getChildren(element?: TreeData): vscode.ProviderResult { + if (!element) return [ + ...this.manager.getActiveConnections(), + ...this.manager.getPendingConnections(), + ]; + if ('onDidChangeFile' in element) return []; // SSHFileSystem + if ('handleInput' in element) return []; // SSHPseudoTerminal + if (Array.isArray(element)) return []; // PendingConnection + return [...element.terminals, ...element.filesystems]; // Connection + } +} + +export class ConfigTreeProvider implements vscode.TreeDataProvider { + protected onDidChangeTreeDataEmitter = new vscode.EventEmitter(); + public onDidChangeTreeData: vscode.Event = this.onDidChangeTreeDataEmitter.event; + constructor() { + // Would be very difficult (and a bit useless) to pinpoint the exact + // group/config that changes, so let's just update the whole tree + UPDATE_LISTENERS.push(() => this.onDidChangeTreeDataEmitter.fire()); + // ^ Technically a memory leak, but there should only be one ConfigTreeProvider that never gets discarded + } + public getTreeItem(element: FileSystemConfig | string): vscode.TreeItem | Thenable { + if (typeof element === 'string') { + return { + label: element.replace(/^.+\./, ''), contextValue: 'group', + collapsibleState: vscode.TreeItemCollapsibleState.Collapsed, + iconPath: vscode.ThemeIcon.Folder, + }; + } + return { ...formatItem(element), collapsibleState: vscode.TreeItemCollapsibleState.None }; + } + public getChildren(element: FileSystemConfig | string = ''): vscode.ProviderResult<(FileSystemConfig | string)[]> { + if (typeof element !== 'string') return []; // Configs don't have children + const configs = getConfigs(); + const matching = configs.filter(({ group }) => (group || '') === element); + matching.sort((a, b) => a.name > b.name ? 1 : -1); + let groups = getGroups(configs, true); + if (element) { + groups = groups.filter(g => g.startsWith(element) && g[element.length] === '.' && !g.includes('.', element.length + 1)); + } else { + groups = groups.filter(g => !g.includes('.')); + } + return [...matching, ...groups.sort()]; + } +} \ No newline at end of file diff --git a/src/ui-utils.ts b/src/ui-utils.ts new file mode 100644 index 0000000..b5c66c1 --- /dev/null +++ b/src/ui-utils.ts @@ -0,0 +1,126 @@ + +import * as vscode from 'vscode'; +import { getConfigs } from './config'; +import type { Connection } from './connection'; +import type { FileSystemConfig } from './fileSystemConfig'; +import type { Manager } from './manager'; +import type { SSHPseudoTerminal } from './pseudoTerminal'; +import type { SSHFileSystem } from './sshFileSystem'; + +export interface FormattedItem extends vscode.QuickPickItem, vscode.TreeItem { + item: any; + label: string; + description?: string; +} + +export function formatAddress(config: FileSystemConfig): string { + const { username, host, port } = config; + return `${username ? `${username}@` : ''}${host}${port ? `:${port}` : ''}`; +} + +export let asAbsolutePath: vscode.ExtensionContext['asAbsolutePath'] | undefined; +export const setAsAbsolutePath = (value: typeof asAbsolutePath) => asAbsolutePath = value; + +/** Converts the supported types to something basically ready-to-use as vscode.QuickPickItem and vscode.TreeItem */ +export function formatItem(item: FileSystemConfig | Connection | SSHFileSystem | SSHPseudoTerminal, iconInLabel = false): FormattedItem { + if ('handleInput' in item) { // SSHPseudoTerminal + return { + item, contextValue: 'terminal', + label: `${iconInLabel ? '$(terminal) ' : ''}${item.terminal?.name || 'Unnamed terminal'}`, + iconPath: new vscode.ThemeIcon('terminal'), + command: { + title: 'Focus', + command: 'sshfs.focusTerminal', + arguments: [item], + }, + }; + } else if ('client' in item) { // Connection + const { label, name, group } = item.config; + const description = group ? `${group}.${name} ` : (label && name); + const detail = formatAddress(item.actualConfig); + return { + item, description, detail, tooltip: detail, + label: `${iconInLabel ? '$(plug) ' : ''}${label || name} `, + iconPath: new vscode.ThemeIcon('plug'), + contextValue: 'connection', + }; + } else if ('onDidChangeFile' in item) { // SSHFileSystem + return { + item, description: item.root, contextValue: 'filesystem', + label: `${iconInLabel ? '$(root-folder) ' : ''}ssh://${item.authority}/`, + iconPath: asAbsolutePath?.('resources/icon.svg'), + } + } + // FileSystemConfig + const { label, name, group, putty } = item; + const description = group ? `${group}.${name} ` : (label && name); + const detail = putty === true ? 'PuTTY session (decuded from config)' : + (typeof putty === 'string' ? `PuTTY session '${putty}'` : formatAddress(item)); + return { + item: item, description, detail, tooltip: detail, contextValue: 'config', + label: `${iconInLabel ? '$(settings-gear) ' : ''}${item.label || item.name} `, + iconPath: new vscode.ThemeIcon('settings-gear'), + } +} + +type QuickPickItemWithValue = vscode.QuickPickItem & { value?: any }; + +export interface PickComplexOptions { + /** If true and there is only one or none item is available, immediately resolve with it/undefined */ + immediateReturn?: boolean; + /** If true, add all connections. If this is a string, filter by config name first */ + promptConnections?: boolean | string; + /** If true, add all configurations. If this is a string, filter by config name first */ + promptConfigs?: boolean | string; + /** If true, add all terminals. If this is a string, filter by config name first */ + promptTerminals?: boolean | string; + /** If set, filter the connections/configs by (config) name first */ + nameFilter?: string; +} + +export async function pickComplex(manager: Manager, options: PickComplexOptions): + Promise { + return new Promise((resolve) => { + const { promptConnections, promptConfigs, promptTerminals, immediateReturn, nameFilter } = options; + const items: QuickPickItemWithValue[] = []; + const toSelect: string[] = []; + if (promptConnections) { + let cons = manager.connectionManager.getActiveConnections(); + if (typeof promptConnections === 'string') cons = cons.filter(con => con.actualConfig.name === promptConnections); + if (nameFilter) cons = cons.filter(con => con.actualConfig.name === nameFilter); + items.push(...cons.map(con => formatItem(con, true))); + toSelect.push('connection'); + } + if (promptConfigs) { + let configs = getConfigs(); + if (typeof promptConfigs === 'string') configs = configs.filter(config => config.name === promptConfigs); + if (nameFilter) configs = configs.filter(config => config.name === nameFilter); + items.push(...configs.map(config => formatItem(config, true))); + toSelect.push('configuration'); + } + if (promptTerminals) { + let cons = manager.connectionManager.getActiveConnections(); + if (typeof promptConnections === 'string') cons = cons.filter(con => con.actualConfig.name === promptConnections); + if (nameFilter) cons = cons.filter(con => con.actualConfig.name === nameFilter); + const terminals = cons.reduce((all, con) => [...all, ...con.terminals], []); + items.push(...terminals.map(config => formatItem(config, true))); + toSelect.push('terminal'); + } + if (immediateReturn && items.length <= 1) return resolve(items[0]?.value); + const quickPick = vscode.window.createQuickPick(); + quickPick.items = items; + quickPick.title = 'Select ' + toSelect.join(' / '); + quickPick.onDidAccept(() => { + const value = quickPick.activeItems[0]?.value; + if (!value) return; + quickPick.hide(); + resolve(value); + }); + quickPick.onDidHide(() => resolve()); + quickPick.show(); + }); +} + +export const pickConfig = (manager: Manager) => pickComplex(manager, { promptConfigs: true }) as Promise; +export const pickConnection = (manager: Manager, name?: string) => pickComplex(manager, { promptConnections: name || true }) as Promise; +export const pickTerminal = (manager: Manager) => pickComplex(manager, { promptTerminals: true }) as Promise;