diff --git a/package.json b/package.json index 1229c05..891a0a7 100644 --- a/package.json +++ b/package.json @@ -326,17 +326,21 @@ "group": "remote_11_ssh_sshfs@2" }, { - "command": "sshfs.settings", + "command": "sshfs.forwardPort", "group": "remote_11_ssh_sshfs@3" }, + { + "command": "sshfs.settings", + "group": "remote_11_ssh_sshfs@4" + }, { "command": "sshfs.disconnect", - "group": "remote_11_ssh_sshfs@4", + "group": "remote_11_ssh_sshfs@5", "when": "sshfs.openConnections > 0" }, { "command": "sshfs.disconnectAll", - "group": "remote_11_ssh_sshfs@5", + "group": "remote_11_ssh_sshfs@6", "when": "sshfs.openConnections > 0" } ] diff --git a/src/portForwarding.ts b/src/portForwarding.ts index 9cc515c..3e0f526 100644 --- a/src/portForwarding.ts +++ b/src/portForwarding.ts @@ -40,6 +40,8 @@ function tryParseInt(input?: string): number | undefined { // https://regexr.com/61quq const PORT_FORWARD_REGEX = /^(?\w+)\s*?(?:(?:\s+(?:(?[^\s:]+|[\da-zA-Z:]+|\[[\da-zA-Z:]+\]):))?(?\d+)|(?[/\\][/\\.\w?\-]+))(?:\s+(?:(?[^\s:]+|[\da-zA-Z:]+|\[[\da-zA-Z:]+\]):)?(?\d+)|\s+(?[/\\][/\\.\w?\-]+))?$/i; 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 { try { 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 || !PORT_FORWARD_TYPES.includes(type)) throw new Error(`Could not recognize PortForwarding type '${match.groups!.type}'`); - const { + let { localPath, localAddress = localPath, localPort, remotePath, remoteAddress = remotePath, remotePort, } = match.groups as Partial>; + 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; if (type === 'remote' && !remoteAddress && !remotePort) { 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 formatAddrPortPath = (addr?: string, port?: number): string => { if (port === undefined) { @@ -93,7 +106,7 @@ export function formatPortForwarding(forwarding: PortForwarding): string { if (forwarding.type === 'local' || forwarding.type === 'remote') { const local = (forwarding.localPort !== undefined || forwarding.localAddress) ? 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') { return `${formatAddrPortPath(forwarding.address, forwarding.port)} → SOCKSv5`; } @@ -390,11 +403,12 @@ function validatePipe(str: string): string | undefined { if (str.match(PIPE_REGEX)) return undefined; 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 { const port = parseInt(str); + if (!allowRandom && port === 0) 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) { return 'Invalid port'; } @@ -411,9 +425,10 @@ async function promptAddressOrPath(location: 'local' | 'remote', allowWildcard: if (!type) return undefined; if (type === A) { 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 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]; } else if (location === 'local' && process.platform === 'win32') { 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 { + 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 { + const remote = await promptAddressOrPath('remote', true); + if (!remote) return undefined; + const [remotePort, remoteAddress] = remote; + return { type: 'remote', remotePort, remoteAddress }; +} + +async function promptDynamicForwarding(): Promise { + 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 { const picker = vscode.window.createQuickPick(); picker.title = `Port forwarding to ${config.label || config.name}`; picker.ignoreFocusOut = true; + picker.matchOnDetail = true; + picker.matchOnDescription = true; const ITEMS: FormattedItem[] = [ { item: 'local', label: '→ Local 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: 'examples', label: '$(list-unordered) Show examples' }, ]; - const formatPF = (forward: PortForwarding, description?: string): FormattedItem => ({ - item: forward, alwaysShow: true, description, - label: formatPortForwarding(forward), + const formatPF = (forward: PortForwarding, description?: string, alwaysShow?: boolean): FormattedItem => ({ + item: forward, alwaysShow, description, + label: `$(${getPortForwardingIcon(forward)}) ${formatPortForwarding(forward)}`, detail: formatPortForwardingConfig(forward), }); + let examples = false; const updateItems = () => { let items: 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 }]; 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', 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', remoteAddress: 'localhost', remotePort: 1234 }, 'Bind remotely to proxy through client'), formatPF({ type: 'dynamic', address: 'localhost', port: 1234 }, 'Bind locally to proxy through server'), @@ -468,8 +513,8 @@ export async function promptPortForwarding(config: FileSystemConfig): Promise { + return new Promise((resolve) => { picker.onDidAccept(() => { if (!picker.selectedItems.length) return; const [{ item }] = picker.selectedItems; if (!item) return; if (item === 'examples') { - picker.value = 'examples'; + examples = true; + picker.value = ''; } else if (item === 'return') { + examples = false; picker.value = ''; - } else if (item === 'local') { - picker.value = 'Local '; - } else if (item === 'remote' || item === 'remoteProxy') { - picker.value = 'Remote '; + } else if (item === 'local' || item === 'remote') { + return resolve(promptFullLocalRemoteForwarding(item)); + } else if (item === 'remoteProxy') { + return resolve(promptRemoteProxyForwarding()); } 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 { - if (picker.value === 'examples') { - // Looking at examples, don't actually accept but copy the value - picker.value = formatPortForwardingConfig(item); - } else { - resolve(item); - picker.hide(); - return; - } + return resolve(item); } updateItems(); }); picker.onDidHide(() => resolve(undefined)); picker.show(); - }); + }).finally(() => picker.dispose()); } diff --git a/src/ui-utils.ts b/src/ui-utils.ts index b9d2a00..22415f6 100644 --- a/src/ui-utils.ts +++ b/src/ui-utils.ts @@ -4,7 +4,7 @@ import * as vscode from 'vscode'; import { getConfigs } from './config'; import type { Connection, ConnectionManager } from './connection'; import type { Manager } from './manager'; -import { ActivePortForwarding, isActivePortForwarding } from './portForwarding'; +import { ActivePortForwarding, getPortForwardingIcon, isActivePortForwarding } from './portForwarding'; import type { SSHPseudoTerminal } from './pseudoTerminal'; import type { SSHFileSystem } from './sshFileSystem'; import { toPromise } from './utils'; @@ -77,10 +77,10 @@ export function formatItem(item: FileSystemConfig | Connection | SSHFileSystem | iconPath: asAbsolutePath?.('resources/icon.svg'), } } else if (isActivePortForwarding(item)) { // ActivePortForwarding - let label = iconInLabel ? '$(ports-forward-icon) ' : ''; + let label = ''; const [forw] = item; 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}` || '?'; } else { label += 'SOCKSv5'; @@ -93,12 +93,14 @@ export function formatItem(item: FileSystemConfig | Connection | SSHFileSystem | label += ' '; } 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 { item, label, contextValue: 'forwarding', detail, tooltip: detail, collapsibleState: vscode.TreeItemCollapsibleState.Expanded, - iconPath: new vscode.ThemeIcon('ports-forward-icon'), + iconPath: new vscode.ThemeIcon(icon), }; } // FileSystemConfig