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;