Improve UI for port forwarding (#254)

feature/forwarding
Kelvin Schoofs 2 years ago
parent 5e52da7620
commit edcd323774

@ -326,17 +326,21 @@
"group": "remote_11_ssh_sshfs@2" "group": "remote_11_ssh_sshfs@2"
}, },
{ {
"command": "sshfs.settings", "command": "sshfs.forwardPort",
"group": "remote_11_ssh_sshfs@3" "group": "remote_11_ssh_sshfs@3"
}, },
{
"command": "sshfs.settings",
"group": "remote_11_ssh_sshfs@4"
},
{ {
"command": "sshfs.disconnect", "command": "sshfs.disconnect",
"group": "remote_11_ssh_sshfs@4", "group": "remote_11_ssh_sshfs@5",
"when": "sshfs.openConnections > 0" "when": "sshfs.openConnections > 0"
}, },
{ {
"command": "sshfs.disconnectAll", "command": "sshfs.disconnectAll",
"group": "remote_11_ssh_sshfs@5", "group": "remote_11_ssh_sshfs@6",
"when": "sshfs.openConnections > 0" "when": "sshfs.openConnections > 0"
} }
] ]

@ -40,6 +40,8 @@ function tryParseInt(input?: string): number | undefined {
// https://regexr.com/61quq // https://regexr.com/61quq
const PORT_FORWARD_REGEX = /^(?<type>\w+)\s*?(?:(?:\s+(?:(?<localAddress>[^\s:]+|[\da-zA-Z:]+|\[[\da-zA-Z:]+\]):))?(?<localPort>\d+)|(?<localPath>[/\\][/\\.\w?\-]+))(?:\s+(?:(?<remoteAddress>[^\s:]+|[\da-zA-Z:]+|\[[\da-zA-Z:]+\]):)?(?<remotePort>\d+)|\s+(?<remotePath>[/\\][/\\.\w?\-]+))?$/i; const PORT_FORWARD_REGEX = /^(?<type>\w+)\s*?(?:(?:\s+(?:(?<localAddress>[^\s:]+|[\da-zA-Z:]+|\[[\da-zA-Z:]+\]):))?(?<localPort>\d+)|(?<localPath>[/\\][/\\.\w?\-]+))(?:\s+(?:(?<remoteAddress>[^\s:]+|[\da-zA-Z:]+|\[[\da-zA-Z:]+\]):)?(?<remotePort>\d+)|\s+(?<remotePath>[/\\][/\\.\w?\-]+))?$/i;
const PORT_FORWARD_TYPES = ['remote', 'local', 'dynamic']; const PORT_FORWARD_TYPES = ['remote', 'local', 'dynamic'];
export function parsePortForwarding(input: string, mode: 'throw'): PortForwarding;
export function parsePortForwarding(input: string, mode: 'report' | 'ignore'): PortForwarding | undefined;
export function parsePortForwarding(input: string, mode: 'report' | 'throw' | 'ignore'): PortForwarding | undefined { export function parsePortForwarding(input: string, mode: 'report' | 'throw' | 'ignore'): PortForwarding | undefined {
try { try {
const match = input.match(PORT_FORWARD_REGEX); const match = input.match(PORT_FORWARD_REGEX);
@ -50,10 +52,14 @@ export function parsePortForwarding(input: string, mode: 'report' | 'throw' | 'i
if (type.length === 1) type = PORT_FORWARD_TYPES.find(t => t[0] === type); if (type.length === 1) type = PORT_FORWARD_TYPES.find(t => t[0] === type);
if (!type || !PORT_FORWARD_TYPES.includes(type)) if (!type || !PORT_FORWARD_TYPES.includes(type))
throw new Error(`Could not recognize PortForwarding type '${match.groups!.type}'`); throw new Error(`Could not recognize PortForwarding type '${match.groups!.type}'`);
const { let {
localPath, localAddress = localPath, localPort, localPath, localAddress = localPath, localPort,
remotePath, remoteAddress = remotePath, remotePort, remotePath, remoteAddress = remotePath, remotePort,
} = match.groups as Partial<Record<string, string>>; } = match.groups as Partial<Record<string, string>>;
if (localAddress?.[0] === '[' && localAddress.endsWith(']'))
localAddress = localAddress.substring(1, localAddress.length - 1);
if (remoteAddress?.[0] === '[' && remoteAddress.endsWith(']'))
remoteAddress = remoteAddress.substring(1, remoteAddress.length - 1);
let pf: PortForwarding; let pf: PortForwarding;
if (type === 'remote' && !remoteAddress && !remotePort) { if (type === 'remote' && !remoteAddress && !remotePort) {
pf = { type, remoteAddress: localAddress, remotePort: tryParseInt(localPort) }; pf = { type, remoteAddress: localAddress, remotePort: tryParseInt(localPort) };
@ -76,6 +82,13 @@ export function parsePortForwarding(input: string, mode: 'report' | 'throw' | 'i
} }
} }
export function getPortForwardingIcon(forwarding: PortForwarding): string {
if (forwarding.type === 'dynamic') return 'globe';
if (forwarding.type === 'local') return 'arrow-small-left';
if (!forwarding.localAddress && forwarding.localPort === undefined) return 'globe';
return 'arrow-small-right';
}
const SINGLE_WORD_PATH_REGEX = /^[/\\.\w?]+$/; const SINGLE_WORD_PATH_REGEX = /^[/\\.\w?]+$/;
const formatAddrPortPath = (addr?: string, port?: number): string => { const formatAddrPortPath = (addr?: string, port?: number): string => {
if (port === undefined) { if (port === undefined) {
@ -93,7 +106,7 @@ export function formatPortForwarding(forwarding: PortForwarding): string {
if (forwarding.type === 'local' || forwarding.type === 'remote') { if (forwarding.type === 'local' || forwarding.type === 'remote') {
const local = (forwarding.localPort !== undefined || forwarding.localAddress) const local = (forwarding.localPort !== undefined || forwarding.localAddress)
? formatAddrPortPath(forwarding.localAddress, forwarding.localPort) : 'SOCKSv5'; ? formatAddrPortPath(forwarding.localAddress, forwarding.localPort) : 'SOCKSv5';
return `${local} ${formatAddrPortPath(forwarding.remoteAddress, forwarding.remotePort)}`; return `${local} ${forwarding.type === 'local' ? ' → ' : ' '} ${formatAddrPortPath(forwarding.remoteAddress, forwarding.remotePort)}`;
} else if (forwarding.type === 'dynamic') { } else if (forwarding.type === 'dynamic') {
return `${formatAddrPortPath(forwarding.address, forwarding.port)} → SOCKSv5`; return `${formatAddrPortPath(forwarding.address, forwarding.port)} → SOCKSv5`;
} }
@ -390,11 +403,12 @@ function validatePipe(str: string): string | undefined {
if (str.match(PIPE_REGEX)) return undefined; if (str.match(PIPE_REGEX)) return undefined;
return 'Windows pipe path should start with \\\\?\\pipe\\ or \\\\.\\pipe\\'; return 'Windows pipe path should start with \\\\?\\pipe\\ or \\\\.\\pipe\\';
} }
function validatePort(str: string): string | undefined { function validatePort(str: string, allowRandom: boolean): string | undefined {
try { try {
const port = parseInt(str); const port = parseInt(str);
if (!allowRandom && port === 0) return undefined;
if (port >= 0 && port < 2 ** 16) return undefined; if (port >= 0 && port < 2 ** 16) return undefined;
return 'Port has to be in the range 0-65535'; return `Port has to be in the range ${allowRandom ? 0 : 1}-65535`;
} catch (e) { } catch (e) {
return 'Invalid port'; return 'Invalid port';
} }
@ -411,9 +425,10 @@ async function promptAddressOrPath(location: 'local' | 'remote', allowWildcard:
if (!type) return undefined; if (!type) return undefined;
if (type === A) { if (type === A) {
const placeHolder = allowWildcard ? 'IPv4 / IPv6 / domain / *' : 'IPv4 / IPv6 / domain'; const placeHolder = allowWildcard ? 'IPv4 / IPv6 / domain / *' : 'IPv4 / IPv6 / domain';
const validateInput = allowWildcard ? (input: string) => input === '*' ? undefined : validateHost(input) : validateHost; let validateInput = allowWildcard ? (input: string) => input === '*' ? undefined : validateHost(input) : validateHost;
const addr = await vscode.window.showInputBox({ prompt: 'Address to use', validateInput, placeHolder }); const addr = await vscode.window.showInputBox({ prompt: 'Address to use', validateInput, placeHolder });
const port = await vscode.window.showInputBox({ prompt: 'Port to use', validateInput: validatePort, placeHolder: '0-65535' }); validateInput = (input: string) => validatePort(input, allowWildcard);
const port = await vscode.window.showInputBox({ prompt: 'Port to use', validateInput, placeHolder: `${allowWildcard ? 0 : 1}-65535` });
return port === undefined ? undefined : [parseInt(port), addr]; return port === undefined ? undefined : [parseInt(port), addr];
} else if (location === 'local' && process.platform === 'win32') { } else if (location === 'local' && process.platform === 'win32') {
const pipe = await vscode.window.showInputBox({ prompt: 'Pipe to use', validateInput: validatePipe, placeHolder: '\\\\?\\pipe\\...' }); const pipe = await vscode.window.showInputBox({ prompt: 'Pipe to use', validateInput: validatePipe, placeHolder: '\\\\?\\pipe\\...' });
@ -424,32 +439,62 @@ async function promptAddressOrPath(location: 'local' | 'remote', allowWildcard:
} }
} }
async function promptFullLocalRemoteForwarding(type: 'local' | 'remote'): Promise<PortForwarding | undefined> {
const local = await promptAddressOrPath('local', type === 'local');
const remote = local && await promptAddressOrPath('remote', type === 'remote');
if (!remote) return undefined;
const [localPort, localAddress] = local;
const [remotePort, remoteAddress] = remote;
return { type, localPort, localAddress, remotePort, remoteAddress };
}
async function promptRemoteProxyForwarding(): Promise<PortForwarding | undefined> {
const remote = await promptAddressOrPath('remote', true);
if (!remote) return undefined;
const [remotePort, remoteAddress] = remote;
return { type: 'remote', remotePort, remoteAddress };
}
async function promptDynamicForwarding(): Promise<PortForwarding | undefined> {
const local = await promptAddressOrPath('local', true);
if (!local) return undefined;
const [port, address] = local;
return { type: 'dynamic', port: port!, address };
}
export async function promptPortForwarding(config: FileSystemConfig): Promise<PortForwarding | undefined> { export async function promptPortForwarding(config: FileSystemConfig): Promise<PortForwarding | undefined> {
const picker = vscode.window.createQuickPick<FormattedItem>(); const picker = vscode.window.createQuickPick<FormattedItem>();
picker.title = `Port forwarding to ${config.label || config.name}`; picker.title = `Port forwarding to ${config.label || config.name}`;
picker.ignoreFocusOut = true; picker.ignoreFocusOut = true;
picker.matchOnDetail = true;
picker.matchOnDescription = true;
const ITEMS: FormattedItem[] = [ const ITEMS: FormattedItem[] = [
{ item: 'local', label: '→ Local forward' }, { item: 'local', label: '→ Local forward' },
{ item: 'remote', label: '← Remote forward' }, { item: 'remote', label: '← Remote forward' },
{ item: 'remoteProxy', label: '$(globe) Remote proxy (client SOCKSv5 ← server)' }, { item: 'remoteProxy', label: '$(globe) Remote proxy (client SOCKSv5 ← server)', description: '(omit local address/port)' },
{ item: 'dynamic', label: '$(globe) Dynamic forward (client → server SOCKSv5)' }, { item: 'dynamic', label: '$(globe) Dynamic forward (client → server SOCKSv5)' },
{ item: 'examples', label: '$(list-unordered) Show examples' }, { item: 'examples', label: '$(list-unordered) Show examples' },
]; ];
const formatPF = (forward: PortForwarding, description?: string): FormattedItem => ({ const formatPF = (forward: PortForwarding, description?: string, alwaysShow?: boolean): FormattedItem => ({
item: forward, alwaysShow: true, description, item: forward, alwaysShow, description,
label: formatPortForwarding(forward), label: `$(${getPortForwardingIcon(forward)}) ${formatPortForwarding(forward)}`,
detail: formatPortForwardingConfig(forward), detail: formatPortForwardingConfig(forward),
}); });
let examples = false;
const updateItems = () => { const updateItems = () => {
let items: FormattedItem[] = []; let items: FormattedItem[] = [];
let suggested: FormattedItem[] = []; let suggested: FormattedItem[] = [];
if (picker.value === 'examples') { if (picker.value === 'examples' || picker.value.startsWith('examples ')) {
examples = true;
picker.value = picker.value.slice(9);
}
if (examples) {
suggested = [{ item: 'return', label: '$(quick-input-back) Return', alwaysShow: true }]; suggested = [{ item: 'return', label: '$(quick-input-back) Return', alwaysShow: true }];
items = [ items = [
formatPF({ type: 'local', localPort: 0, remoteAddress: 'localhost', remotePort: 8080 }, 'Port 0 will pick a free'), formatPF({ type: 'local', localPort: 0, remoteAddress: 'localhost', remotePort: 8080 }, 'Port 0 will pick a free port'),
formatPF({ type: 'local', localPort: 8080, remoteAddress: 'localhost', remotePort: 8080 }, 'No address or "*" binds to all interfaces'), formatPF({ type: 'local', localPort: 8080, remoteAddress: 'localhost', remotePort: 8080 }, 'No address or "*" binds to all interfaces'),
formatPF({ type: 'local', localAddress: '\\\\?\\pipe\\windows\\named\\pipe', remoteAddress: '/tmp/unix/socket' }, 'Supports Unix sockets'), formatPF({ type: 'local', localAddress: '\\\\?\\pipe\\windows\\named\\pipe', remoteAddress: '/tmp/unix/socket' }, 'Supports Unix sockets'),
formatPF({ type: 'remote', remotePort: 8080 }, 'No address or "*" binds to all interfaces'), formatPF({ type: 'remote', localPort: 8080, remotePort: 8080 }, 'No address or "*" binds to all interfaces'),
formatPF({ type: 'remote', localAddress: 'example.com', localPort: 80, remoteAddress: '0::1', remotePort: 8080 }, 'Supports hostnames and IPv6'), formatPF({ type: 'remote', localAddress: 'example.com', localPort: 80, remoteAddress: '0::1', remotePort: 8080 }, 'Supports hostnames and IPv6'),
formatPF({ type: 'remote', remoteAddress: 'localhost', remotePort: 1234 }, 'Bind remotely to proxy through client'), formatPF({ type: 'remote', remoteAddress: 'localhost', remotePort: 1234 }, 'Bind remotely to proxy through client'),
formatPF({ type: 'dynamic', address: 'localhost', port: 1234 }, 'Bind locally to proxy through server'), formatPF({ type: 'dynamic', address: 'localhost', port: 1234 }, 'Bind locally to proxy through server'),
@ -468,8 +513,8 @@ export async function promptPortForwarding(config: FileSystemConfig): Promise<Po
items = [...ITEMS]; items = [...ITEMS];
} }
try { try {
const forward = parsePortForwarding(picker.value, 'throw')!; const forward = parsePortForwarding(picker.value, 'throw');
suggested.push(formatPF(forward)); suggested.unshift(formatPF(forward, undefined, true));
detail = `Current syntax: ${detail}`; detail = `Current syntax: ${detail}`;
} catch (e) { } catch (e) {
const label = (e.message as string).replace(/from '.*'$/, ''); const label = (e.message as string).replace(/from '.*'$/, '');
@ -481,38 +526,37 @@ export async function promptPortForwarding(config: FileSystemConfig): Promise<Po
} }
// If you set items first, onDidAccept will be triggered (even though it shouldn't) // If you set items first, onDidAccept will be triggered (even though it shouldn't)
picker.selectedItems = picker.activeItems = suggested; picker.selectedItems = picker.activeItems = suggested;
picker.items = items.length ? [...items, ...suggested] : suggested; picker.items = items.length ? [...suggested, ...items] : suggested;
}; };
updateItems(); updateItems();
picker.onDidChangeValue(updateItems); picker.onDidChangeValue(updateItems);
return new Promise((resolve) => { return new Promise<PortForwarding | undefined>((resolve) => {
picker.onDidAccept(() => { picker.onDidAccept(() => {
if (!picker.selectedItems.length) return; if (!picker.selectedItems.length) return;
const [{ item }] = picker.selectedItems; const [{ item }] = picker.selectedItems;
if (!item) return; if (!item) return;
if (item === 'examples') { if (item === 'examples') {
picker.value = 'examples'; examples = true;
picker.value = '';
} else if (item === 'return') { } else if (item === 'return') {
examples = false;
picker.value = ''; picker.value = '';
} else if (item === 'local') { } else if (item === 'local' || item === 'remote') {
picker.value = 'Local '; return resolve(promptFullLocalRemoteForwarding(item));
} else if (item === 'remote' || item === 'remoteProxy') { } else if (item === 'remoteProxy') {
picker.value = 'Remote '; return resolve(promptRemoteProxyForwarding());
} else if (item === 'dynamic') { } else if (item === 'dynamic') {
picker.value = 'Dynamic '; return resolve(promptDynamicForwarding());
} else if (examples) {
// Looking at examples, don't actually accept but copy the value
examples = false;
picker.value = formatPortForwardingConfig(item);
} else { } else {
if (picker.value === 'examples') { return resolve(item);
// Looking at examples, don't actually accept but copy the value
picker.value = formatPortForwardingConfig(item);
} else {
resolve(item);
picker.hide();
return;
}
} }
updateItems(); updateItems();
}); });
picker.onDidHide(() => resolve(undefined)); picker.onDidHide(() => resolve(undefined));
picker.show(); picker.show();
}); }).finally(() => picker.dispose());
} }

@ -4,7 +4,7 @@ import * as vscode from 'vscode';
import { getConfigs } from './config'; import { getConfigs } from './config';
import type { Connection, ConnectionManager } from './connection'; import type { Connection, ConnectionManager } from './connection';
import type { Manager } from './manager'; import type { Manager } from './manager';
import { ActivePortForwarding, isActivePortForwarding } from './portForwarding'; import { ActivePortForwarding, getPortForwardingIcon, isActivePortForwarding } from './portForwarding';
import type { SSHPseudoTerminal } from './pseudoTerminal'; import type { SSHPseudoTerminal } from './pseudoTerminal';
import type { SSHFileSystem } from './sshFileSystem'; import type { SSHFileSystem } from './sshFileSystem';
import { toPromise } from './utils'; import { toPromise } from './utils';
@ -77,10 +77,10 @@ export function formatItem(item: FileSystemConfig | Connection | SSHFileSystem |
iconPath: asAbsolutePath?.('resources/icon.svg'), iconPath: asAbsolutePath?.('resources/icon.svg'),
} }
} else if (isActivePortForwarding(item)) { // ActivePortForwarding } else if (isActivePortForwarding(item)) { // ActivePortForwarding
let label = iconInLabel ? '$(ports-forward-icon) ' : ''; let label = '';
const [forw] = item; const [forw] = item;
if (forw.type === 'local' || forw.type === 'remote') { if (forw.type === 'local' || forw.type === 'remote') {
if (forw.localPort || forw.localAddress) { if (forw.localPort != undefined || forw.localAddress) {
label += forw.localPort === undefined ? forw.localAddress : `${forw.localAddress || '*'}:${forw.localPort}` || '?'; label += forw.localPort === undefined ? forw.localAddress : `${forw.localAddress || '*'}:${forw.localPort}` || '?';
} else { } else {
label += 'SOCKSv5'; label += 'SOCKSv5';
@ -93,12 +93,14 @@ export function formatItem(item: FileSystemConfig | Connection | SSHFileSystem |
label += ' <unrecognized type>'; label += ' <unrecognized type>';
} }
const connLabel = item[1].actualConfig.label || item[1].actualConfig.name; const connLabel = item[1].actualConfig.label || item[1].actualConfig.name;
const detail = `${capitalize(forw.type)} port forwarding ${forw.type === 'remote' ? 'from' : 'to'} ${connLabel}` const detail = `${capitalize(forw.type)} port forwarding ${forw.type === 'remote' ? 'from' : 'to'} ${connLabel}`;
const icon = getPortForwardingIcon(forw);
if (iconInLabel) label = `$(${icon}) ${label}`;
return { return {
item, label, contextValue: 'forwarding', item, label, contextValue: 'forwarding',
detail, tooltip: detail, detail, tooltip: detail,
collapsibleState: vscode.TreeItemCollapsibleState.Expanded, collapsibleState: vscode.TreeItemCollapsibleState.Expanded,
iconPath: new vscode.ThemeIcon('ports-forward-icon'), iconPath: new vscode.ThemeIcon(icon),
}; };
} }
// FileSystemConfig // FileSystemConfig

Loading…
Cancel
Save