diff --git a/src/vs/platform/actions/common/actions.ts b/src/vs/platform/actions/common/actions.ts index 99a84647d28ef..c746abc83dcd7 100644 --- a/src/vs/platform/actions/common/actions.ts +++ b/src/vs/platform/actions/common/actions.ts @@ -97,6 +97,9 @@ export const enum MenuId { StatusBarWindowIndicatorMenu, TouchBarContext, TitleBarContext, + TunnelContext, + TunnelInline, + TunnelTitle, ViewItemContext, ViewTitle, CommentThreadTitle, diff --git a/src/vs/platform/remote/common/tunnel.ts b/src/vs/platform/remote/common/tunnel.ts index 37847839298e4..ebbef722b59a5 100644 --- a/src/vs/platform/remote/common/tunnel.ts +++ b/src/vs/platform/remote/common/tunnel.ts @@ -5,13 +5,14 @@ import { createDecorator } from 'vs/platform/instantiation/common/instantiation'; import { URI } from 'vs/base/common/uri'; +import { Event } from 'vs/base/common/event'; export const ITunnelService = createDecorator('tunnelService'); export interface RemoteTunnel { readonly tunnelRemotePort: number; readonly tunnelLocalPort: number; - + readonly localAddress?: URI; dispose(): void; } @@ -19,8 +20,11 @@ export interface ITunnelService { _serviceBrand: undefined; readonly tunnels: Promise; + readonly onTunnelOpened: Event; + readonly onTunnelClosed: Event; - openTunnel(remotePort: number): Promise | undefined; + openTunnel(remotePort: number, localPort?: number): Promise | undefined; + closeTunnel(remotePort: number): Promise; } export function extractLocalHostUriMetaDataForPortMapping(uri: URI): { address: string, port: number } | undefined { diff --git a/src/vs/platform/remote/common/tunnelService.ts b/src/vs/platform/remote/common/tunnelService.ts index e0cbf4522c87f..44017cb2a0fbb 100644 --- a/src/vs/platform/remote/common/tunnelService.ts +++ b/src/vs/platform/remote/common/tunnelService.ts @@ -4,13 +4,19 @@ *--------------------------------------------------------------------------------------------*/ import { ITunnelService, RemoteTunnel } from 'vs/platform/remote/common/tunnel'; +import { Event, Emitter } from 'vs/base/common/event'; export class NoOpTunnelService implements ITunnelService { _serviceBrand: undefined; public readonly tunnels: Promise = Promise.resolve([]); - + private _onTunnelOpened: Emitter = new Emitter(); + public onTunnelOpened: Event = this._onTunnelOpened.event; + private _onTunnelClosed: Emitter = new Emitter(); + public onTunnelClosed: Event = this._onTunnelClosed.event; openTunnel(_remotePort: number): Promise | undefined { return undefined; } + async closeTunnel(_remotePort: number): Promise { + } } diff --git a/src/vs/workbench/contrib/remote/browser/media/tunnelView.css b/src/vs/workbench/contrib/remote/browser/media/tunnelView.css new file mode 100644 index 0000000000000..0b34a1d125944 --- /dev/null +++ b/src/vs/workbench/contrib/remote/browser/media/tunnelView.css @@ -0,0 +1,12 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.customview-tree .tunnel-view-label { + flex: 1; +} + +.customview-tree .tunnel-view-label .action-label.codicon { + margin-top: 4px; +} diff --git a/src/vs/workbench/contrib/remote/browser/remote.ts b/src/vs/workbench/contrib/remote/browser/remote.ts index e74c2c97ac2f0..2d7afa657bff5 100644 --- a/src/vs/workbench/contrib/remote/browser/remote.ts +++ b/src/vs/workbench/contrib/remote/browser/remote.ts @@ -44,6 +44,9 @@ import { isStringArray } from 'vs/base/common/types'; import { IRemoteExplorerService, HelpInformation } from 'vs/workbench/services/remote/common/remoteExplorerService'; import { IWorkbenchEnvironmentService } from 'vs/workbench/services/environment/common/environmentService'; import { startsWith } from 'vs/base/common/strings'; +import { TunnelPanelDescriptor, TunnelViewModel } from 'vs/workbench/contrib/remote/browser/tunnelView'; +import { IAddedViewDescriptorRef } from 'vs/workbench/browser/parts/views/views'; +import { ViewletPane } from 'vs/workbench/browser/parts/views/paneViewlet'; class HelpModel { items: IHelpItem[] | undefined; @@ -263,6 +266,7 @@ class HelpAction extends Action { export class RemoteViewlet extends FilterViewContainerViewlet { private actions: IAction[] | undefined; + private tunnelPanelDescriptor: TunnelPanelDescriptor | undefined; constructor( @IWorkbenchLayoutService layoutService: IWorkbenchLayoutService, @@ -274,7 +278,8 @@ export class RemoteViewlet extends FilterViewContainerViewlet { @IThemeService themeService: IThemeService, @IContextMenuService contextMenuService: IContextMenuService, @IExtensionService extensionService: IExtensionService, - @IRemoteExplorerService remoteExplorerService: IRemoteExplorerService + @IRemoteExplorerService private readonly remoteExplorerService: IRemoteExplorerService, + @IWorkbenchEnvironmentService private readonly environmentService: IWorkbenchEnvironmentService, ) { super(VIEWLET_ID, remoteExplorerService.onDidChangeTargetType, configurationService, layoutService, telemetryService, storageService, instantiationService, themeService, contextMenuService, extensionService, contextService); } @@ -308,6 +313,17 @@ export class RemoteViewlet extends FilterViewContainerViewlet { const title = nls.localize('remote.explorer', "Remote Explorer"); return title; } + + onDidAddViews(added: IAddedViewDescriptorRef[]): ViewletPane[] { + // Call to super MUST be first, since registering the additional view will cause this to be called again. + const panels: ViewletPane[] = super.onDidAddViews(added); + if (this.environmentService.configuration.remoteAuthority && !this.tunnelPanelDescriptor && this.configurationService.getValue('remote.forwardedPortsView.visible')) { + this.tunnelPanelDescriptor = new TunnelPanelDescriptor(new TunnelViewModel(this.remoteExplorerService), this.environmentService); + const viewsRegistry = Registry.as(Extensions.ViewsRegistry); + viewsRegistry.registerViews([this.tunnelPanelDescriptor!], VIEW_CONTAINER); + } + return panels; + } } Registry.as(ViewletExtensions.Viewlets).registerViewlet(ViewletDescriptor.create( diff --git a/src/vs/workbench/contrib/remote/browser/tunnelView.ts b/src/vs/workbench/contrib/remote/browser/tunnelView.ts new file mode 100644 index 0000000000000..6d445537bb41e --- /dev/null +++ b/src/vs/workbench/contrib/remote/browser/tunnelView.ts @@ -0,0 +1,676 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import 'vs/css!./media/tunnelView'; +import * as nls from 'vs/nls'; +import * as dom from 'vs/base/browser/dom'; +import { IViewDescriptor } from 'vs/workbench/common/views'; +import { WorkbenchAsyncDataTree, TreeResourceNavigator2 } from 'vs/platform/list/browser/listService'; +import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding'; +import { IContextMenuService } from 'vs/platform/contextview/browser/contextView'; +import { IContextKeyService, IContextKey, RawContextKey, ContextKeyExpr } from 'vs/platform/contextkey/common/contextkey'; +import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; +import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; +import { IOpenerService } from 'vs/platform/opener/common/opener'; +import { IQuickInputService } from 'vs/platform/quickinput/common/quickInput'; +import { ICommandService, ICommandHandler, CommandsRegistry } from 'vs/platform/commands/common/commands'; +import { Event, Emitter } from 'vs/base/common/event'; +import { IListVirtualDelegate } from 'vs/base/browser/ui/list/list'; +import { ITreeRenderer, ITreeNode, IAsyncDataSource, ITreeContextMenuEvent } from 'vs/base/browser/ui/tree/tree'; +import { IWorkbenchEnvironmentService } from 'vs/workbench/services/environment/common/environmentService'; +import { Disposable, IDisposable, toDisposable, MutableDisposable } from 'vs/base/common/lifecycle'; +import { ViewletPane, IViewletPaneOptions } from 'vs/workbench/browser/parts/views/paneViewlet'; +import { ActionBar, ActionViewItem, IActionViewItem } from 'vs/base/browser/ui/actionbar/actionbar'; +import { IconLabel } from 'vs/base/browser/ui/iconLabel/iconLabel'; +import { ActionRunner, IAction } from 'vs/base/common/actions'; +import { IMenuService, MenuId, IMenu, MenuRegistry, MenuItemAction } from 'vs/platform/actions/common/actions'; +import { createAndFillInContextMenuActions, createAndFillInActionBarActions, ContextAwareMenuEntryActionViewItem } from 'vs/platform/actions/browser/menuEntryActionViewItem'; +import { IRemoteExplorerService, TunnelModel } from 'vs/workbench/services/remote/common/remoteExplorerService'; +import { IClipboardService } from 'vs/platform/clipboard/common/clipboardService'; +import { URI } from 'vs/workbench/workbench.web.api'; +import { INotificationService } from 'vs/platform/notification/common/notification'; + +class TunnelTreeVirtualDelegate implements IListVirtualDelegate { + getHeight(element: ITunnelItem): number { + return 22; + } + + getTemplateId(element: ITunnelItem): string { + return 'tunnelItemTemplate'; + } +} + +export interface ITunnelViewModel { + onForwardedPortsChanged: Event; + readonly forwarded: TunnelItem[]; + readonly published: TunnelItem[]; + readonly candidates: TunnelItem[]; + readonly groups: ITunnelGroup[]; +} + +export class TunnelViewModel extends Disposable implements ITunnelViewModel { + private _onForwardedPortsChanged: Emitter = new Emitter(); + public onForwardedPortsChanged: Event = this._onForwardedPortsChanged.event; + private model: TunnelModel; + + constructor( + @IRemoteExplorerService remoteExplorerService: IRemoteExplorerService) { + super(); + this.model = remoteExplorerService.tunnelModel; + this._register(this.model.onForwardPort(() => this._onForwardedPortsChanged.fire())); + this._register(this.model.onClosePort(() => this._onForwardedPortsChanged.fire())); + this._register(this.model.onPortName(() => this._onForwardedPortsChanged.fire())); + } + + get groups(): ITunnelGroup[] { + const groups: ITunnelGroup[] = []; + if (this.model.forwarded.size > 0) { + groups.push({ + label: nls.localize('remote.tunnelsView.forwarded', "Forwarded"), + tunnelType: TunnelType.Forwarded, + items: this.forwarded + }); + } + if (this.model.published.size > 0) { + groups.push({ + label: nls.localize('remote.tunnelsView.published', "Published"), + tunnelType: TunnelType.Published, + items: this.published + }); + } + const candidates = this.candidates; + if (this.candidates.length > 0) { + groups.push({ + label: nls.localize('remote.tunnelsView.candidates', "Candidates"), + tunnelType: TunnelType.Candidate, + items: candidates + }); + } + groups.push({ + label: nls.localize('remote.tunnelsView.add', "Add a Port..."), + tunnelType: TunnelType.Add, + }); + return groups; + } + + get forwarded(): TunnelItem[] { + return Array.from(this.model.forwarded.values()).map(tunnel => { + return new TunnelItem(TunnelType.Forwarded, tunnel.remote, tunnel.closeable, tunnel.name, tunnel.description, tunnel.local); + }); + } + + get published(): TunnelItem[] { + return Array.from(this.model.published.values()).map(tunnel => { + return new TunnelItem(TunnelType.Published, tunnel.remote, false, tunnel.name, tunnel.description, tunnel.local); + }); + } + + get candidates(): TunnelItem[] { + const candidates: TunnelItem[] = []; + const values = this.model.candidates.values(); + let iterator = values.next(); + while (!iterator.done) { + if (!this.model.forwarded.has(iterator.value.remote) && !this.model.published.has(iterator.value.remote)) { + candidates.push(new TunnelItem(TunnelType.Candidate, iterator.value.remote, false, undefined, iterator.value.description)); + } + iterator = values.next(); + } + return candidates; + } + + dispose() { + super.dispose(); + } +} + +interface ITunnelTemplateData { + elementDisposable: IDisposable; + container: HTMLElement; + iconLabel: IconLabel; + actionBar: ActionBar; +} + +class TunnelTreeRenderer extends Disposable implements ITreeRenderer { + static readonly ITEM_HEIGHT = 22; + static readonly TREE_TEMPLATE_ID = 'tunnelItemTemplate'; + + private _actionRunner: ActionRunner | undefined; + + constructor( + private readonly viewId: string, + @IMenuService private readonly menuService: IMenuService, + @IContextKeyService private readonly contextKeyService: IContextKeyService, + @IInstantiationService private readonly instantiationService: IInstantiationService + ) { + super(); + } + + set actionRunner(actionRunner: ActionRunner) { + this._actionRunner = actionRunner; + } + + get templateId(): string { + return TunnelTreeRenderer.TREE_TEMPLATE_ID; + } + + renderTemplate(container: HTMLElement): ITunnelTemplateData { + dom.addClass(container, 'custom-view-tree-node-item'); + const iconLabel = new IconLabel(container, { supportHighlights: true }); + // dom.addClass(iconLabel.element, 'tunnel-view-label'); + const actionsContainer = dom.append(iconLabel.element, dom.$('.actions')); + const actionBar = new ActionBar(actionsContainer, { + // actionViewItemProvider: undefined // this.actionViewItemProvider + actionViewItemProvider: (action: IAction) => { + if (action instanceof MenuItemAction) { + return this.instantiationService.createInstance(ContextAwareMenuEntryActionViewItem, action); + } + + return undefined; + } + }); + + return { iconLabel, actionBar, container, elementDisposable: Disposable.None }; + } + + private isTunnelItem(item: ITunnelGroup | ITunnelItem): item is ITunnelItem { + return !!((item).remote); + } + + renderElement(element: ITreeNode, index: number, templateData: ITunnelTemplateData): void { + templateData.elementDisposable.dispose(); + const node = element.element; + // reset + templateData.actionBar.clear(); + if (this.isTunnelItem(node)) { + templateData.iconLabel.setLabel(node.label, node.description, { title: node.label + ' - ' + node.description, extraClasses: ['tunnel-view-label'] }); + templateData.actionBar.context = node; + const contextKeyService = this.contextKeyService.createScoped(); + contextKeyService.createKey('view', this.viewId); + contextKeyService.createKey('tunnelType', node.tunnelType); + contextKeyService.createKey('tunnelCloseable', node.closeable); + const menu = this.menuService.createMenu(MenuId.TunnelInline, contextKeyService); + this._register(menu); + const actions: IAction[] = []; + this._register(createAndFillInActionBarActions(menu, { shouldForwardArgs: true }, actions)); + if (actions) { + templateData.actionBar.push(actions, { icon: true, label: false }); + if (this._actionRunner) { + templateData.actionBar.actionRunner = this._actionRunner; + } + } + } else { + templateData.iconLabel.setLabel(node.label); + } + } + + disposeElement(resource: ITreeNode, index: number, templateData: ITunnelTemplateData): void { + templateData.elementDisposable.dispose(); + } + + disposeTemplate(templateData: ITunnelTemplateData): void { + templateData.actionBar.dispose(); + templateData.elementDisposable.dispose(); + } +} + +class TunnelDataSource implements IAsyncDataSource { + hasChildren(element: ITunnelViewModel | ITunnelItem | ITunnelGroup) { + if (element instanceof TunnelViewModel) { + return true; + } else if (element instanceof TunnelItem) { + return false; + } else if ((element).items) { + return true; + } + return false; + } + + getChildren(element: ITunnelViewModel | ITunnelItem | ITunnelGroup) { + if (element instanceof TunnelViewModel) { + return element.groups; + } else if (element instanceof TunnelItem) { + return []; + } else if ((element).items) { + return (element).items!; + } + return []; + } +} + +enum TunnelType { + Candidate = 'Candidate', + Published = 'Published', + Forwarded = 'Forwarded', + Add = 'Add' +} + +interface ITunnelGroup { + tunnelType: TunnelType; + label: string; + items?: ITunnelItem[]; +} + +interface ITunnelItem { + tunnelType: TunnelType; + remote: number; + local?: number; + name?: string; + closeable?: boolean; + readonly description?: string; + readonly label: string; +} + +class TunnelItem implements ITunnelItem { + constructor( + public tunnelType: TunnelType, + public remote: number, + public closeable?: boolean, + public name?: string, + private _description?: string, + public local?: number, + ) { } + get label(): string { + if (this.name && this.local) { + return nls.localize('remote.tunnelsView.forwardedPortLabel0', "{0}", this.name); + } else if (this.local === this.remote) { + return nls.localize('remote.tunnelsView.forwardedPortLabel1', "{0} to localhost", this.remote); + } else if (this.local) { + return nls.localize('remote.tunnelsView.forwardedPortLabel2', "{0} to localhost:{1}", this.remote, this.local); + } else { + return nls.localize('remote.tunnelsView.forwardedPortLabel3', "{0} not forwarded", this.remote); + } + } + + get description(): string | undefined { + if (this.name) { + return nls.localize('remote.tunnelsView.forwardedPortDescription0', "remote {0} available at {1}", this.remote, this.local); + } else if (this.local === this.remote) { + return nls.localize('remote.tunnelsView.forwardedPortDescription1', "available at {0}", this.local); + } else if (this.local) { + return this._description; + } else { + return this._description; + } + } +} + +export const TunnelTypeContextKey = new RawContextKey('tunnelType', TunnelType.Add); +export const TunnelCloseableContextKey = new RawContextKey('tunnelCloseable', false); + +export class TunnelPanel extends ViewletPane { + static readonly ID = '~remote.tunnelPanel'; + static readonly TITLE = nls.localize('remote.tunnel', "Tunnels"); + private tree!: WorkbenchAsyncDataTree; + private tunnelTypeContext: IContextKey; + private tunnelCloseableContext: IContextKey; + + private titleActions: IAction[] = []; + private readonly titleActionsDisposable = this._register(new MutableDisposable()); + + constructor( + protected viewModel: ITunnelViewModel, + options: IViewletPaneOptions, + @IKeybindingService protected keybindingService: IKeybindingService, + @IContextMenuService protected contextMenuService: IContextMenuService, + @IContextKeyService protected contextKeyService: IContextKeyService, + @IConfigurationService protected configurationService: IConfigurationService, + @IInstantiationService protected readonly instantiationService: IInstantiationService, + @IOpenerService protected openerService: IOpenerService, + @IQuickInputService protected quickInputService: IQuickInputService, + @ICommandService protected commandService: ICommandService, + @IMenuService private readonly menuService: IMenuService, + @INotificationService private readonly notificationService: INotificationService + ) { + super(options, keybindingService, contextMenuService, configurationService, contextKeyService); + this.tunnelTypeContext = TunnelTypeContextKey.bindTo(contextKeyService); + this.tunnelCloseableContext = TunnelCloseableContextKey.bindTo(contextKeyService); + + const scopedContextKeyService = this._register(this.contextKeyService.createScoped()); + scopedContextKeyService.createKey('view', TunnelPanel.ID); + + const titleMenu = this._register(this.menuService.createMenu(MenuId.TunnelTitle, scopedContextKeyService)); + const updateActions = () => { + this.titleActions = []; + this.titleActionsDisposable.value = createAndFillInActionBarActions(titleMenu, undefined, this.titleActions); + this.updateActions(); + }; + + this._register(titleMenu.onDidChange(updateActions)); + updateActions(); + + this._register(toDisposable(() => { + this.titleActions = []; + })); + + } + + protected renderBody(container: HTMLElement): void { + dom.addClass(container, '.tree-explorer-viewlet-tree-view'); + const treeContainer = document.createElement('div'); + dom.addClass(treeContainer, 'customview-tree'); + dom.addClass(treeContainer, 'file-icon-themable-tree'); + dom.addClass(treeContainer, 'show-file-icons'); + container.appendChild(treeContainer); + const renderer = new TunnelTreeRenderer(TunnelPanel.ID, this.menuService, this.contextKeyService, this.instantiationService); + this.tree = this.instantiationService.createInstance(WorkbenchAsyncDataTree, + 'RemoteTunnels', + treeContainer, + new TunnelTreeVirtualDelegate(), + [renderer], + new TunnelDataSource(), + { + keyboardSupport: true, + collapseByDefault: (e: ITunnelItem | ITunnelGroup): boolean => { + return false; + }, + keyboardNavigationLabelProvider: { + getKeyboardNavigationLabel: (item: ITunnelItem | ITunnelGroup) => { + return item.label; + } + }, + } + ); + const actionRunner: ActionRunner = new ActionRunner(); + renderer.actionRunner = actionRunner; + + this._register(this.tree.onContextMenu(e => this.onContextMenu(e, actionRunner))); + + this.tree.setInput(this.viewModel); + this._register(this.viewModel.onForwardedPortsChanged(() => { + this.tree.updateChildren(undefined, true); + })); + + const helpItemNavigator = this._register(new TreeResourceNavigator2(this.tree, { openOnFocus: false, openOnSelection: false })); + + this._register(Event.debounce(helpItemNavigator.onDidOpenResource, (last, event) => event, 75, true)(e => { + if (e.element.tunnelType === TunnelType.Add) { + this.commandService.executeCommand(ForwardPortAction.ID); + } + })); + } + + private get contributedContextMenu(): IMenu { + const contributedContextMenu = this.menuService.createMenu(MenuId.TunnelContext, this.tree.contextKeyService); + this._register(contributedContextMenu); + return contributedContextMenu; + } + + getActions(): IAction[] { + return this.titleActions; + } + + private onContextMenu(treeEvent: ITreeContextMenuEvent, actionRunner: ActionRunner): void { + if (!(treeEvent.element instanceof TunnelItem)) { + return; + } + const node: ITunnelItem | null = treeEvent.element; + const event: UIEvent = treeEvent.browserEvent; + + event.preventDefault(); + event.stopPropagation(); + + this.tree!.setFocus([node]); + this.tunnelTypeContext.set(node.tunnelType); + this.tunnelCloseableContext.set(!!node.closeable); + + const actions: IAction[] = []; + this._register(createAndFillInContextMenuActions(this.contributedContextMenu, { shouldForwardArgs: true }, actions, this.contextMenuService)); + + this.contextMenuService.showContextMenu({ + getAnchor: () => treeEvent.anchor, + getActions: () => actions, + getActionViewItem: (action) => { + const keybinding = this.keybindingService.lookupKeybinding(action.id); + if (keybinding) { + return new ActionViewItem(action, action, { label: true, keybinding: keybinding.getLabel() }); + } + return undefined; + }, + onHide: (wasCancelled?: boolean) => { + if (wasCancelled) { + this.tree!.domFocus(); + } + }, + getActionsContext: () => node, + actionRunner + }); + } + + protected layoutBody(height: number, width: number): void { + this.tree.layout(height, width); + } + + getActionViewItem(action: IAction): IActionViewItem | undefined { + return action instanceof MenuItemAction ? new ContextAwareMenuEntryActionViewItem(action, this.keybindingService, this.notificationService, this.contextMenuService) : undefined; + } +} + +export class TunnelPanelDescriptor implements IViewDescriptor { + readonly id = TunnelPanel.ID; + readonly name = TunnelPanel.TITLE; + readonly ctorDescriptor: { ctor: any, arguments?: any[] }; + readonly canToggleVisibility = true; + readonly hideByDefault = false; + readonly workspace = true; + readonly group = 'details@0'; + readonly remoteAuthority?: string | string[]; + + constructor(viewModel: ITunnelViewModel, environmentService: IWorkbenchEnvironmentService) { + this.ctorDescriptor = { ctor: TunnelPanel, arguments: [viewModel] }; + this.remoteAuthority = environmentService.configuration.remoteAuthority ? environmentService.configuration.remoteAuthority.split('+')[0] : undefined; + } +} + +namespace NameTunnelAction { + export const ID = 'remote.tunnel.name'; + export const LABEL = nls.localize('remote.tunnel.name', "Name Tunnel"); + + export function handler(): ICommandHandler { + return async (accessor, arg) => { + if (arg instanceof TunnelItem) { + const remoteExplorerService = accessor.get(IRemoteExplorerService); + const quickInputService = accessor.get(IQuickInputService); + const name = await quickInputService.input({ placeHolder: nls.localize('remote.tunnelView.pickName', 'Port name, or leave blank for no name') }); + if (name === undefined) { + return; + } + remoteExplorerService.tunnelModel.name(arg.remote, name); + } + return; + }; + } +} + +namespace ForwardPortAction { + export const ID = 'remote.tunnel.forward'; + export const LABEL = nls.localize('remote.tunnel.forward', "Forward Port"); + + export function handler(): ICommandHandler { + return async (accessor, arg) => { + const quickInputService = accessor.get(IQuickInputService); + const remoteExplorerService = accessor.get(IRemoteExplorerService); + let remote: number | undefined = undefined; + if (arg instanceof TunnelItem) { + remote = arg.remote; + } else { + const input = parseInt(await quickInputService.input({ placeHolder: nls.localize('remote.tunnelView.pickRemote', 'Remote port to forward') })); + if (typeof input === 'number') { + remote = input; + } + } + + if (!remote) { + return; + } + + const local: string = await quickInputService.input({ placeHolder: nls.localize('remote.tunnelView.pickLocal', 'Local port to forward to, or leave blank for {0}', remote) }); + if (local === undefined) { + return; + } + const name = await quickInputService.input({ placeHolder: nls.localize('remote.tunnelView.pickName', 'Port name, or leave blank for no name') }); + if (name === undefined) { + return; + } + await remoteExplorerService.tunnelModel.forward(remote, (local !== '') ? parseInt(local) : remote, (name !== '') ? name : undefined); + }; + } +} + +namespace ClosePortAction { + export const ID = 'remote.tunnel.close'; + export const LABEL = nls.localize('remote.tunnel.close', "Stop Forwarding Port"); + + export function handler(): ICommandHandler { + return async (accessor, arg) => { + if (arg instanceof TunnelItem) { + const remoteExplorerService = accessor.get(IRemoteExplorerService); + await remoteExplorerService.tunnelModel.close(arg.remote); + } + }; + } +} + +namespace OpenPortInBrowserAction { + export const ID = 'remote.tunnel.open'; + export const LABEL = nls.localize('remote.tunnel.open', "Open in Browser"); + + export function handler(): ICommandHandler { + return async (accessor, arg) => { + if (arg instanceof TunnelItem) { + const model = accessor.get(IRemoteExplorerService).tunnelModel; + const openerService = accessor.get(IOpenerService); + const tunnel = model.forwarded.has(arg.remote) ? model.forwarded.get(arg.remote) : model.published.get(arg.remote); + let address: URI | undefined; + if (tunnel && tunnel.localUri && (address = model.address(tunnel.remote))) { + return openerService.open(address); + } + return Promise.resolve(); + } + }; + } +} + +namespace CopyAddressAction { + export const ID = 'remote.tunnel.copyAddress'; + export const LABEL = nls.localize('remote.tunnel.copyAddress', "Copy Address"); + + export function handler(): ICommandHandler { + return async (accessor, arg) => { + if (arg instanceof TunnelItem) { + const model = accessor.get(IRemoteExplorerService).tunnelModel; + const clipboard = accessor.get(IClipboardService); + const address = model.address(arg.remote); + if (address) { + await clipboard.writeText(address.toString()); + } + } + }; + } +} + +CommandsRegistry.registerCommand(NameTunnelAction.ID, NameTunnelAction.handler()); +CommandsRegistry.registerCommand(ForwardPortAction.ID, ForwardPortAction.handler()); +CommandsRegistry.registerCommand(ClosePortAction.ID, ClosePortAction.handler()); +CommandsRegistry.registerCommand(OpenPortInBrowserAction.ID, OpenPortInBrowserAction.handler()); +CommandsRegistry.registerCommand(CopyAddressAction.ID, CopyAddressAction.handler()); + +MenuRegistry.appendMenuItem(MenuId.TunnelTitle, ({ + group: 'navigation', + order: 0, + command: { + id: ForwardPortAction.ID, + title: ForwardPortAction.LABEL, + iconClassName: 'codicon-plus', + iconLocation: { + dark: undefined, + light: undefined + } + } +})); +MenuRegistry.appendMenuItem(MenuId.TunnelContext, ({ + group: '0_manage', + order: 0, + command: { + id: CopyAddressAction.ID, + title: CopyAddressAction.LABEL, + }, + when: ContextKeyExpr.or(TunnelTypeContextKey.isEqualTo(TunnelType.Forwarded), TunnelTypeContextKey.isEqualTo(TunnelType.Published)) +})); +MenuRegistry.appendMenuItem(MenuId.TunnelContext, ({ + group: '0_manage', + order: 1, + command: { + id: OpenPortInBrowserAction.ID, + title: OpenPortInBrowserAction.LABEL, + }, + when: ContextKeyExpr.or(TunnelTypeContextKey.isEqualTo(TunnelType.Forwarded), TunnelTypeContextKey.isEqualTo(TunnelType.Published)) +})); +MenuRegistry.appendMenuItem(MenuId.TunnelContext, ({ + group: '0_manage', + order: 2, + command: { + id: NameTunnelAction.ID, + title: NameTunnelAction.LABEL, + }, + when: TunnelTypeContextKey.isEqualTo(TunnelType.Forwarded) +})); +MenuRegistry.appendMenuItem(MenuId.TunnelContext, ({ + group: '0_manage', + order: 1, + command: { + id: ForwardPortAction.ID, + title: ForwardPortAction.LABEL, + }, + when: ContextKeyExpr.or(TunnelTypeContextKey.isEqualTo(TunnelType.Candidate), TunnelTypeContextKey.isEqualTo(TunnelType.Published)) +})); +MenuRegistry.appendMenuItem(MenuId.TunnelContext, ({ + group: '0_manage', + order: 3, + command: { + id: ClosePortAction.ID, + title: ClosePortAction.LABEL, + }, + when: TunnelCloseableContextKey +})); + +MenuRegistry.appendMenuItem(MenuId.TunnelInline, ({ + order: 0, + command: { + id: OpenPortInBrowserAction.ID, + title: OpenPortInBrowserAction.LABEL, + iconClassName: 'codicon-globe', + iconLocation: { + dark: undefined, + light: undefined + } + }, + when: ContextKeyExpr.or(TunnelTypeContextKey.isEqualTo(TunnelType.Forwarded), TunnelTypeContextKey.isEqualTo(TunnelType.Published)) +})); +MenuRegistry.appendMenuItem(MenuId.TunnelInline, ({ + order: 0, + command: { + id: ForwardPortAction.ID, + title: ForwardPortAction.LABEL, + iconClassName: 'codicon-plus', + iconLocation: { + dark: undefined, + light: undefined + } + }, + when: ContextKeyExpr.or(TunnelTypeContextKey.isEqualTo(TunnelType.Candidate), TunnelTypeContextKey.isEqualTo(TunnelType.Published)) +})); +MenuRegistry.appendMenuItem(MenuId.TunnelInline, ({ + order: 2, + command: { + id: ClosePortAction.ID, + title: ClosePortAction.LABEL, + iconClassName: 'codicon-x', + iconLocation: { + dark: undefined, + light: undefined + } + }, + when: TunnelCloseableContextKey +})); diff --git a/src/vs/workbench/services/remote/common/remoteExplorerService.ts b/src/vs/workbench/services/remote/common/remoteExplorerService.ts index 63fecf0f88b61..6eb52e24c426f 100644 --- a/src/vs/workbench/services/remote/common/remoteExplorerService.ts +++ b/src/vs/workbench/services/remote/common/remoteExplorerService.ts @@ -10,15 +10,111 @@ import { registerSingleton } from 'vs/platform/instantiation/common/extensions'; import { IStorageService, StorageScope } from 'vs/platform/storage/common/storage'; import { IExtensionDescription } from 'vs/platform/extensions/common/extensions'; import { ExtensionsRegistry, IExtensionPointUser } from 'vs/workbench/services/extensions/common/extensionsRegistry'; +import { URI } from 'vs/base/common/uri'; +import { ITunnelService } from 'vs/platform/remote/common/tunnel'; +import { Disposable } from 'vs/base/common/lifecycle'; export const IRemoteExplorerService = createDecorator('remoteExplorerService'); export const REMOTE_EXPLORER_TYPE_KEY: string = 'remote.explorerType'; +export interface Tunnel { + remote: number; + localUri: URI; + local?: number; + name?: string; + description?: string; + closeable?: boolean; +} + +export class TunnelModel extends Disposable { + readonly forwarded: Map; + readonly published: Map; + readonly candidates: Map; + private _onForwardPort: Emitter = new Emitter(); + public onForwardPort: Event = this._onForwardPort.event; + private _onClosePort: Emitter = new Emitter(); + public onClosePort: Event = this._onClosePort.event; + private _onPortName: Emitter = new Emitter(); + public onPortName: Event = this._onPortName.event; + constructor( + @ITunnelService private readonly tunnelService: ITunnelService + ) { + super(); + this.forwarded = new Map(); + this.tunnelService.tunnels.then(tunnels => { + tunnels.forEach(tunnel => { + if (tunnel.localAddress) { + this.forwarded.set(tunnel.tunnelRemotePort, { + remote: tunnel.tunnelRemotePort, + localUri: tunnel.localAddress, + local: tunnel.tunnelLocalPort + }); + } + }); + }); + + this.published = new Map(); + this.candidates = new Map(); + this._register(this.tunnelService.onTunnelOpened(tunnel => { + if (this.candidates.has(tunnel.tunnelRemotePort)) { + this.candidates.delete(tunnel.tunnelRemotePort); + } + if (!this.forwarded.has(tunnel.tunnelRemotePort) && tunnel.localAddress) { + this.forwarded.set(tunnel.tunnelRemotePort, { + remote: tunnel.tunnelRemotePort, + localUri: tunnel.localAddress, + local: tunnel.tunnelLocalPort + }); + } + this._onForwardPort.fire(this.forwarded.get(tunnel.tunnelRemotePort)!); + })); + this._register(this.tunnelService.onTunnelClosed(remotePort => { + if (this.forwarded.has(remotePort)) { + this.forwarded.delete(remotePort); + this._onClosePort.fire(remotePort); + } + })); + } + + async forward(remote: number, local?: number, name?: string): Promise { + if (!this.forwarded.has(remote)) { + const tunnel = await this.tunnelService.openTunnel(remote, local); + if (tunnel && tunnel.localAddress) { + const newForward: Tunnel = { + remote: tunnel.tunnelRemotePort, + local: tunnel.tunnelLocalPort, + name: name, + closeable: true, + localUri: tunnel.localAddress + }; + this.forwarded.set(remote, newForward); + this._onForwardPort.fire(newForward); + } + } + } + + name(remote: number, name: string) { + if (this.forwarded.has(remote)) { + this.forwarded.get(remote)!.name = name; + this._onPortName.fire(remote); + } + } + + async close(remote: number): Promise { + return this.tunnelService.closeTunnel(remote); + } + + address(remote: number): URI | undefined { + return (this.forwarded.get(remote) || this.published.get(remote))?.localUri; + } +} + export interface IRemoteExplorerService { _serviceBrand: undefined; onDidChangeTargetType: Event; targetType: string; readonly helpInformation: HelpInformation[]; + readonly tunnelModel: TunnelModel; } export interface HelpInformation { @@ -62,8 +158,12 @@ class RemoteExplorerService implements IRemoteExplorerService { private _onDidChangeTargetType: Emitter = new Emitter(); public onDidChangeTargetType: Event = this._onDidChangeTargetType.event; private _helpInformation: HelpInformation[] = []; + private _tunnelModel: TunnelModel; - constructor(@IStorageService private readonly storageService: IStorageService) { + constructor( + @IStorageService private readonly storageService: IStorageService, + @ITunnelService tunnelService: ITunnelService) { + this._tunnelModel = new TunnelModel(tunnelService); remoteHelpExtPoint.setHandler((extensions) => { let helpInformation: HelpInformation[] = []; for (let extension of extensions) { @@ -108,6 +208,10 @@ class RemoteExplorerService implements IRemoteExplorerService { get helpInformation(): HelpInformation[] { return this._helpInformation; } + + get tunnelModel(): TunnelModel { + return this._tunnelModel; + } } registerSingleton(IRemoteExplorerService, RemoteExplorerService, true); diff --git a/src/vs/workbench/services/remote/node/tunnelService.ts b/src/vs/workbench/services/remote/node/tunnelService.ts index 62b872ac8c99c..30c886758f7ca 100644 --- a/src/vs/workbench/services/remote/node/tunnelService.ts +++ b/src/vs/workbench/services/remote/node/tunnelService.ts @@ -17,9 +17,11 @@ import { ISignService } from 'vs/platform/sign/common/sign'; import { registerSingleton } from 'vs/platform/instantiation/common/extensions'; import { ILogService } from 'vs/platform/log/common/log'; import { findFreePort } from 'vs/base/node/ports'; +import { URI } from 'vs/base/common/uri'; +import { Event, Emitter } from 'vs/base/common/event'; -export async function createRemoteTunnel(options: IConnectionOptions, tunnelRemotePort: number): Promise { - const tunnel = new NodeRemoteTunnel(options, tunnelRemotePort); +export async function createRemoteTunnel(options: IConnectionOptions, tunnelRemotePort: number, tunnelLocalPort?: number): Promise { + const tunnel = new NodeRemoteTunnel(options, tunnelRemotePort, tunnelLocalPort); return tunnel.waitForReady(); } @@ -27,6 +29,7 @@ class NodeRemoteTunnel extends Disposable implements RemoteTunnel { public readonly tunnelRemotePort: number; public tunnelLocalPort!: number; + public localAddress?: URI; private readonly _options: IConnectionOptions; private readonly _server: net.Server; @@ -35,7 +38,7 @@ class NodeRemoteTunnel extends Disposable implements RemoteTunnel { private readonly _listeningListener: () => void; private readonly _connectionListener: (socket: net.Socket) => void; - constructor(options: IConnectionOptions, tunnelRemotePort: number) { + constructor(options: IConnectionOptions, tunnelRemotePort: number, private readonly suggestedLocalPort?: number) { super(); this._options = options; this._server = net.createServer(); @@ -61,12 +64,14 @@ class NodeRemoteTunnel extends Disposable implements RemoteTunnel { public async waitForReady(): Promise { // try to get the same port number as the remote port number... - const localPort = await findFreePort(this.tunnelRemotePort, 1, 1000); + const localPort = await findFreePort(this.suggestedLocalPort ?? this.tunnelRemotePort, 1, 1000); // if that fails, the method above returns 0, which works out fine below... - this.tunnelLocalPort = (this._server.listen(localPort).address()).port; + const address = (this._server.listen(localPort).address()); + this.tunnelLocalPort = address.port; await this._barrier.wait(); + this.localAddress = URI.from({ scheme: 'http', authority: 'localhost:' + address.port }); return this; } @@ -96,6 +101,10 @@ class NodeRemoteTunnel extends Disposable implements RemoteTunnel { export class TunnelService implements ITunnelService { _serviceBrand: undefined; + private _onTunnelOpened: Emitter = new Emitter(); + public onTunnelOpened: Event = this._onTunnelOpened.event; + private _onTunnelClosed: Emitter = new Emitter(); + public onTunnelClosed: Event = this._onTunnelClosed.event; private readonly _tunnels = new Map }>(); public constructor( @@ -116,33 +125,51 @@ export class TunnelService implements ITunnelService { this._tunnels.clear(); } - openTunnel(remotePort: number): Promise | undefined { + openTunnel(remotePort: number, localPort: number): Promise | undefined { const remoteAuthority = this.environmentService.configuration.remoteAuthority; if (!remoteAuthority) { return undefined; } - const resolvedTunnel = this.retainOrCreateTunnel(remoteAuthority, remotePort); + const resolvedTunnel = this.retainOrCreateTunnel(remoteAuthority, remotePort, localPort); if (!resolvedTunnel) { return resolvedTunnel; } - return resolvedTunnel.then(tunnel => ({ + return resolvedTunnel.then(tunnel => { + const newTunnel = this.makeTunnel(tunnel); + this._onTunnelOpened.fire(newTunnel); + return newTunnel; + }); + } + + private makeTunnel(tunnel: RemoteTunnel): RemoteTunnel { + return { tunnelRemotePort: tunnel.tunnelRemotePort, tunnelLocalPort: tunnel.tunnelLocalPort, + localAddress: tunnel.localAddress, dispose: () => { - const existing = this._tunnels.get(remotePort); + const existing = this._tunnels.get(tunnel.tunnelRemotePort); if (existing) { if (--existing.refcount <= 0) { existing.value.then(tunnel => tunnel.dispose()); - this._tunnels.delete(remotePort); + this._tunnels.delete(tunnel.tunnelRemotePort); + this._onTunnelClosed.fire(tunnel.tunnelRemotePort); } } } - })); + }; + } + + async closeTunnel(remotePort: number): Promise { + if (this._tunnels.has(remotePort)) { + const value = this._tunnels.get(remotePort)!; + (await value.value).dispose(); + value.refcount = 0; + } } - private retainOrCreateTunnel(remoteAuthority: string, remotePort: number): Promise | undefined { + private retainOrCreateTunnel(remoteAuthority: string, remotePort: number, localPort?: number): Promise | undefined { const existing = this._tunnels.get(remotePort); if (existing) { ++existing.refcount; @@ -162,8 +189,9 @@ export class TunnelService implements ITunnelService { logService: this.logService }; - const tunnel = createRemoteTunnel(options, remotePort); - this._tunnels.set(remotePort, { refcount: 1, value: tunnel }); + const tunnel = createRemoteTunnel(options, remotePort, localPort); + // Using makeTunnel here for the value does result in dispose getting called twice, but it also ensures that _onTunnelClosed will be fired when closeTunnel is called. + this._tunnels.set(remotePort, { refcount: 1, value: tunnel.then(value => this.makeTunnel(value)) }); return tunnel; } }