Refactor tree views + make Manager use ConnectionManager

feature/ssh-config
Kelvin Schoofs 4 years ago
parent 56b10b9c15
commit 01cad2159a

@ -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": [

@ -0,0 +1,12 @@
<svg width="480" height="480"
xmlns="http://www.w3.org/2000/svg">
<g>
<line stroke="#fff" stroke-width="30" x1="25" y1="41.48454" x2="241" y2="39.48454"/>
<line stroke="#fff" stroke-width="30" x1="25" y1="439.375" x2="455" y2="439.375"/>
<line stroke="#fff" stroke-width="30" x1="40" y1="40" x2="40" y2="440"/>
<line stroke="#fff" stroke-width="30" x1="440" y1="140" x2="440" y2="440"/>
<line stroke="#fff" stroke-width="30" x1="336" y1="140" x2="455" y2="140"/>
<line stroke="#fff" stroke-width="30" x1="230.31103" y1="35.32107" x2="346.02341" y2="144.01003"/>
<text font-weight="bold" fill="#fff" x="75.05163" y="332.98954" font-size="200" font-family="Junction, sans-serif" text-anchor="start" xml:space="preserve">&gt; _</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 811 B

@ -19,10 +19,18 @@ export interface Connection {
export class ConnectionManager {
protected onConnectionAddedEmitter = new vscode.EventEmitter<Connection>();
protected onConnectionRemovedEmitter = new vscode.EventEmitter<Connection>();
protected onConnectionUpdatedEmitter = new vscode.EventEmitter<Connection>();
protected onPendingChangedEmitter = new vscode.EventEmitter<void>();
protected connections: Connection[] = [];
protected pendingConnections: { [name: string]: Promise<Connection> } = {};
protected pendingConnections: { [name: string]: [Promise<Connection>, 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,10 +38,11 @@ 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<Connection> {
const logging = Logging.scope(`createConnection(${name},${config ? 'config' : 'undefined'})`);
const con = this.getActiveConnection(name, config);
if (con) return con;
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);
@ -61,7 +70,20 @@ export class ConnectionManager {
this.connections.push(con);
this.onConnectionAddedEmitter.fire(con);
return con;
})().finally(() => delete this.pendingConnections[name]);
}
public async createConnection(name: string, config?: FileSystemConfig): Promise<Connection> {
const con = this.getActiveConnection(name, config);
if (con) return con;
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);
}
}

@ -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<string | undefined> {
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());
}

@ -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<string | null> {
const exec = await toPromise<ClientChannel>(cb => ssh.exec('echo Home: ~', cb));
@ -62,14 +36,11 @@ interface SSHShellTaskOptions {
workingDirectory?: string;
}
export class Manager implements vscode.TreeDataProvider<string | FileSystemConfig>, vscode.TaskProvider {
public onDidChangeTreeData: vscode.Event<string | null>;
export class Manager implements vscode.TaskProvider {
protected fileSystems: SSHFileSystem[] = [];
protected creatingFileSystems: { [name: string]: Promise<SSHFileSystem> } = {};
protected onDidChangeTreeDataEmitter = new vscode.EventEmitter<string | null>();
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<string | FileSystemConfi
e.removed.forEach(async (folder) => {
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<SSHFileSystem> {
const existing = this.fileSystems.find(fs => fs.authority === name);
@ -111,7 +63,7 @@ export class Manager implements vscode.TreeDataProvider<string | FileSystemConfi
config = config || getConfigs().find(c => 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<string | FileSystemConfi
await vscode.window.showErrorMessage(message, 'Okay');
return reject();
}
con.filesystems.push(fs);
this.connectionManager.update(con, con => 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<string | FileSystemConfi
if (result.startsWith('/')) return result; // Already starts with /
return '/' + result; // Add the / to make sure it isn't seen as a relative path
}
public async createTerminal(name: string, config?: FileSystemConfig, uri?: vscode.Uri): Promise<void> {
public async createTerminal(name: string, config?: FileSystemConfig | Connection, uri?: vscode.Uri): Promise<void> {
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<string | FileSystemConfi
const config = getConfig(name);
console.log('config', name, config);
if (!config) return;
const choice = await vscode.window.showWarningMessage(`SSH FS ${config.label || config.name} disconnected`, 'Reconnect', 'Disconnect');
if (choice === 'Reconnect') {
this.commandReconnect(name);
} else {
this.commandDisconnect(name);
}
}
/* TreeDataProvider */
public getTreeItem(element: string | FileSystemConfig): vscode.TreeItem | Thenable<vscode.TreeItem> {
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<vscode.Task[]> {
@ -254,21 +186,21 @@ export class Manager implements vscode.TreeDataProvider<string | FileSystemConfi
return new vscode.Task(
task.definition,
vscode.TaskScope.Workspace,
`SSH Task for ${host}`,
`SSH Task '${task.name}' for ${host}`,
'ssh',
new vscode.CustomExecution(async () => {
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;
})
)
}

@ -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<TreeData> {
protected onDidChangeTreeDataEmitter = new vscode.EventEmitter<TreeData | void>();
public onDidChangeTreeData: vscode.Event<TreeData | void> = 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<vscode.TreeItem> {
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<TreeData[]> {
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<FileSystemConfig | string> {
protected onDidChangeTreeDataEmitter = new vscode.EventEmitter<FileSystemConfig | string | void>();
public onDidChangeTreeData: vscode.Event<FileSystemConfig | string | void> = 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<vscode.TreeItem> {
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()];
}
}

@ -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<FileSystemConfig | Connection | SSHPseudoTerminal | undefined> {
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<QuickPickItemWithValue>();
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<FileSystemConfig | undefined>;
export const pickConnection = (manager: Manager, name?: string) => pickComplex(manager, { promptConnections: name || true }) as Promise<Connection | undefined>;
export const pickTerminal = (manager: Manager) => pickComplex(manager, { promptTerminals: true }) as Promise<SSHPseudoTerminal | undefined>;
Loading…
Cancel
Save