From 3be780242aff119c3018acce34336cf5b367683e Mon Sep 17 00:00:00 2001 From: Alex Ross Date: Fri, 22 Nov 2019 10:42:51 +0100 Subject: [PATCH 1/7] Initial port UI --- .../remote/browser/media/tunnelView.css | 12 + .../contrib/remote/browser/remote.ts | 19 +- .../contrib/remote/browser/tunnelView.ts | 492 ++++++++++++++++++ 3 files changed, 522 insertions(+), 1 deletion(-) create mode 100644 src/vs/workbench/contrib/remote/browser/media/tunnelView.css create mode 100644 src/vs/workbench/contrib/remote/browser/tunnelView.ts 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..01ad4a2c24f23 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, TunnelModel } 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,9 @@ export class RemoteViewlet extends FilterViewContainerViewlet { @IThemeService themeService: IThemeService, @IContextMenuService contextMenuService: IContextMenuService, @IExtensionService extensionService: IExtensionService, - @IRemoteExplorerService remoteExplorerService: IRemoteExplorerService + @IRemoteExplorerService remoteExplorerService: IRemoteExplorerService, + @IWorkbenchEnvironmentService private readonly environmentService: IWorkbenchEnvironmentService, + @IOpenerService private readonly openerService: IOpenerService ) { super(VIEWLET_ID, remoteExplorerService.onDidChangeTargetType, configurationService, layoutService, telemetryService, storageService, instantiationService, themeService, contextMenuService, extensionService, contextService); } @@ -308,6 +314,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.tunnelPanelDescriptor = new TunnelPanelDescriptor(new TunnelViewModel(new TunnelModel(), this.openerService), 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..958ea8fc0eb82 --- /dev/null +++ b/src/vs/workbench/contrib/remote/browser/tunnelView.ts @@ -0,0 +1,492 @@ +/*--------------------------------------------------------------------------------------------- + * 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 } from 'vs/platform/list/browser/listService'; +import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding'; +import { IContextMenuService } from 'vs/platform/contextview/browser/contextView'; +import { IContextKeyService } 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 } 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 } from 'vs/base/browser/ui/tree/tree'; +import { IWorkbenchEnvironmentService } from 'vs/workbench/services/environment/common/environmentService'; +import { Disposable, IDisposable } from 'vs/base/common/lifecycle'; +import { ViewletPane, IViewletPaneOptions } from 'vs/workbench/browser/parts/views/paneViewlet'; +import { ActionBar } from 'vs/base/browser/ui/actionbar/actionbar'; +import { IconLabel } from 'vs/base/browser/ui/iconLabel/iconLabel'; +import { Action, ActionRunner } from 'vs/base/common/actions'; +import { URI } from 'vs/base/common/uri'; + +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; + + constructor(private model: TunnelModel, + @IOpenerService private readonly openerService: IOpenerService) { + super(); + this._register(this.model.onForwardPort(() => this._onForwardedPortsChanged.fire())); + this._register(this.model.onClosePort(() => 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, this.getActions(TunnelType.Forwarded, tunnel), 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, this.getActions(TunnelType.Published, tunnel), 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, this.getActions(TunnelType.Candidate, iterator.value), undefined, iterator.value.description)); + } + iterator = values.next(); + } + return candidates; + } + + forward(tunnelItem: ITunnelItem) { + // TODO: Show some UI to get the name and local + this.model.forward(tunnelItem.remote); + } + stopForwarding(tunnelItem: ITunnelItem) { + this.model.close(tunnelItem.remote); + } + copy(tunnelItem: ITunnelItem) { + // TODO: implement + } + + dispose() { + super.dispose(); + } + + private getActions(type: TunnelType, tunnel: Tunnel): Action[] { + const actions: Action[] = []; + switch (type) { + case TunnelType.Forwarded: { + actions.push(this.createOpenAction()); + break; + } + case TunnelType.Published: { + actions.push(this.createForwardAction()); + actions.push(this.createOpenAction()); + break; + } + case TunnelType.Candidate: { + actions.push(this.createForwardAction()); + break; + } + } + if (tunnel.closeable) { + actions.push(this.createCloseAction()); + } + return actions; + } + + private createOpenAction(): Action { + const action = new Action('remote.tunnelView.open'); + action.enabled = true; + action.tooltip = nls.localize('remote.tunnelView.open', 'Open in Browser'); + action.class = 'icon codicon codicon-globe'; + action.run = (context: ITunnelItem) => { + const tunnel = this.model.forwarded.has(context.remote) ? this.model.forwarded.get(context.remote) : this.model.published.get(context.remote); + if (tunnel && tunnel.uri) { + return this.openerService.open(tunnel.uri); + } + return Promise.resolve(); + }; + return action; + } + + private createForwardAction(): Action { + const action = new Action('remote.tunnelView.forward'); + action.enabled = true; + action.tooltip = nls.localize('remote.tunnelView.forward', 'Forward to localhost'); + action.class = 'icon codicon codicon-plus'; + action.run = async (context: ITunnelItem) => { + this.model.forward(context.remote, context.local, context.name); + }; + return action; + } + + private createCloseAction(): Action { + const action = new Action('remote.tunnelView.forward'); + action.enabled = true; + action.tooltip = nls.localize('remote.tunnelView.forward', 'Forward to localhost'); + action.class = 'icon codicon codicon-x'; + action.run = async (context: ITunnelItem) => { + this.model.close(context.remote); + }; + return action; + } +} + +interface Tunnel { + remote: string; + local?: string; + name?: string; + description?: string; + closeable?: boolean; + uri?: URI; +} + +export class TunnelModel { + forwarded: Map; + published: Map; + candidates: Map; + private _onForwardPort: Emitter = new Emitter(); + public onForwardPort: Event = this._onForwardPort.event; + private _onClosePort: Emitter = new Emitter(); + public onClosePort: Event = this._onClosePort.event; + constructor() { + this.forwarded = new Map(); + this.forwarded.set('3000', + { + description: 'one description', + local: '3000', + remote: '3000', + closeable: true + }); + this.forwarded.set('4000', + { + local: '4001', + remote: '4000', + name: 'Process Port', + closeable: true + }); + + this.published = new Map(); + this.published.set('3500', + { + description: 'one description', + local: '3500', + remote: '3500', + name: 'My App', + }); + this.published.set('4500', + { + description: 'two description', + local: '4501', + remote: '4500' + }); + this.candidates = new Map(); + this.candidates.set('5000', + { + description: 'node.js /anArg', + remote: '5000', + }); + this.candidates.set('5500', + { + remote: '5500', + }); + } + + forward(remote: string, local?: string, name?: string) { + if (!this.forwarded.has(remote)) { + const newForward: Tunnel = { + remote: remote, + local: local ?? remote, + name: name, + closeable: true + }; + this.forwarded.set(remote, newForward); + this._onForwardPort.fire(newForward); + } + } + + close(remote: string) { + if (this.forwarded.has(remote)) { + this.forwarded.delete(remote); + this._onClosePort.fire(remote); + } + } +} + +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; + + constructor() { + super(); + this.actionRunner = new 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 + }); + + 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'] }); + if (node.actions) { + templateData.actionBar.context = node; + templateData.actionBar.push(node.actions, { icon: true, label: false }); + 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: string; + local?: string; + name?: string; + actions?: Action[]; + readonly description?: string; + readonly label: string; +} + +class TunnelItem implements ITunnelItem { + constructor( + public tunnelType: TunnelType, + public remote: string, + public actions: Action[], + public name?: string, + private _description?: string, + public local?: string, + ) { } + get label(): string { + if (this.name) { + return nls.localize('remote.tunnelsView.forwardedPortLabel0', "{0} to localhost", 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 {0}", 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 class TunnelPanel extends ViewletPane { + static readonly ID = '~remote.tunnelPanel'; + static readonly TITLE = nls.localize('remote.tunnel', "Tunnels"); + private tree!: WorkbenchAsyncDataTree; + + 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 + ) { + super(options, keybindingService, contextMenuService, configurationService, contextKeyService); + } + + 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); + + this.tree = this.instantiationService.createInstance(WorkbenchAsyncDataTree, + 'RemoteTunnels', + treeContainer, + new TunnelTreeVirtualDelegate(), + [new TunnelTreeRenderer()], + new TunnelDataSource(), + { + keyboardSupport: true, + collapseByDefault: (e: ITunnelItem | ITunnelGroup): boolean => { + return false; + } + } + ); + + this.tree.setInput(this.viewModel); + this._register(this.viewModel.onForwardedPortsChanged(() => { + this.tree.updateChildren(undefined, true); + })); + + // TODO: add navigator + // const helpItemNavigator = this._register(new TreeResourceNavigator2(this.tree, { openOnFocus: false, openOnSelection: false })); + + // this._register(Event.debounce(helpItemNavigator.onDidOpenResource, (last, event) => event, 75, true)(e => { + // e.element.handleClick(); + // })); + } + + protected layoutBody(height: number, width: number): void { + this.tree.layout(height, width); + } +} + +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; + } +} From 63d0ae439bfe30a9f7980b9fc2a5d534b91a3183 Mon Sep 17 00:00:00 2001 From: Alex Ross Date: Fri, 22 Nov 2019 17:43:05 +0100 Subject: [PATCH 2/7] Actions working, still more to do --- src/vs/platform/actions/common/actions.ts | 2 + .../contrib/remote/browser/remote.ts | 7 +- .../contrib/remote/browser/tunnelView.ts | 443 +++++++++++------- .../remote/common/remoteExplorerService.ts | 98 ++++ 4 files changed, 372 insertions(+), 178 deletions(-) diff --git a/src/vs/platform/actions/common/actions.ts b/src/vs/platform/actions/common/actions.ts index 99a84647d28ef..d1d7a2e377459 100644 --- a/src/vs/platform/actions/common/actions.ts +++ b/src/vs/platform/actions/common/actions.ts @@ -97,6 +97,8 @@ export const enum MenuId { StatusBarWindowIndicatorMenu, TouchBarContext, TitleBarContext, + TunnelContext, + TunnelInline, ViewItemContext, ViewTitle, CommentThreadTitle, diff --git a/src/vs/workbench/contrib/remote/browser/remote.ts b/src/vs/workbench/contrib/remote/browser/remote.ts index 01ad4a2c24f23..d4a0912e6b96b 100644 --- a/src/vs/workbench/contrib/remote/browser/remote.ts +++ b/src/vs/workbench/contrib/remote/browser/remote.ts @@ -44,7 +44,7 @@ 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, TunnelModel } from 'vs/workbench/contrib/remote/browser/tunnelView'; +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'; @@ -278,9 +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, - @IOpenerService private readonly openerService: IOpenerService ) { super(VIEWLET_ID, remoteExplorerService.onDidChangeTargetType, configurationService, layoutService, telemetryService, storageService, instantiationService, themeService, contextMenuService, extensionService, contextService); } @@ -319,7 +318,7 @@ export class RemoteViewlet extends FilterViewContainerViewlet { // 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.tunnelPanelDescriptor = new TunnelPanelDescriptor(new TunnelViewModel(new TunnelModel(), this.openerService), this.environmentService); + this.tunnelPanelDescriptor = new TunnelPanelDescriptor(new TunnelViewModel(this.remoteExplorerService), this.environmentService); const viewsRegistry = Registry.as(Extensions.ViewsRegistry); viewsRegistry.registerViews([this.tunnelPanelDescriptor!], VIEW_CONTAINER); } diff --git a/src/vs/workbench/contrib/remote/browser/tunnelView.ts b/src/vs/workbench/contrib/remote/browser/tunnelView.ts index 958ea8fc0eb82..9f8ddab43bf80 100644 --- a/src/vs/workbench/contrib/remote/browser/tunnelView.ts +++ b/src/vs/workbench/contrib/remote/browser/tunnelView.ts @@ -6,26 +6,28 @@ 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 { IViewDescriptor } from 'vs/workbench/common/views'; import { WorkbenchAsyncDataTree } from 'vs/platform/list/browser/listService'; import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding'; import { IContextMenuService } from 'vs/platform/contextview/browser/contextView'; -import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; +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 } from 'vs/platform/commands/common/commands'; +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 } from 'vs/base/browser/ui/tree/tree'; +import { ITreeRenderer, ITreeNode, IAsyncDataSource, ITreeContextMenuEvent } from 'vs/base/browser/ui/tree/tree'; import { IWorkbenchEnvironmentService } from 'vs/workbench/services/environment/common/environmentService'; import { Disposable, IDisposable } from 'vs/base/common/lifecycle'; import { ViewletPane, IViewletPaneOptions } from 'vs/workbench/browser/parts/views/paneViewlet'; -import { ActionBar } from 'vs/base/browser/ui/actionbar/actionbar'; +import { ActionBar, ActionViewItem } from 'vs/base/browser/ui/actionbar/actionbar'; import { IconLabel } from 'vs/base/browser/ui/iconLabel/iconLabel'; -import { Action, ActionRunner } from 'vs/base/common/actions'; -import { URI } from 'vs/base/common/uri'; +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'; class TunnelTreeVirtualDelegate implements IListVirtualDelegate { getHeight(element: ITunnelItem): number { @@ -48,12 +50,15 @@ export interface ITunnelViewModel { export class TunnelViewModel extends Disposable implements ITunnelViewModel { private _onForwardedPortsChanged: Emitter = new Emitter(); public onForwardedPortsChanged: Event = this._onForwardedPortsChanged.event; + private model: TunnelModel; - constructor(private model: TunnelModel, - @IOpenerService private readonly openerService: IOpenerService) { + 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[] { @@ -89,13 +94,13 @@ export class TunnelViewModel extends Disposable implements ITunnelViewModel { get forwarded(): TunnelItem[] { return Array.from(this.model.forwarded.values()).map(tunnel => { - return new TunnelItem(TunnelType.Forwarded, tunnel.remote, this.getActions(TunnelType.Forwarded, tunnel), tunnel.name, tunnel.description, tunnel.local); + 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, this.getActions(TunnelType.Published, tunnel), tunnel.name, tunnel.description, tunnel.local); + return new TunnelItem(TunnelType.Published, tunnel.remote, false, tunnel.name, tunnel.description, tunnel.local); }); } @@ -105,20 +110,13 @@ export class TunnelViewModel extends Disposable implements ITunnelViewModel { 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, this.getActions(TunnelType.Candidate, iterator.value), undefined, iterator.value.description)); + candidates.push(new TunnelItem(TunnelType.Candidate, iterator.value.remote, false, undefined, iterator.value.description)); } iterator = values.next(); } return candidates; } - forward(tunnelItem: ITunnelItem) { - // TODO: Show some UI to get the name and local - this.model.forward(tunnelItem.remote); - } - stopForwarding(tunnelItem: ITunnelItem) { - this.model.close(tunnelItem.remote); - } copy(tunnelItem: ITunnelItem) { // TODO: implement } @@ -126,147 +124,6 @@ export class TunnelViewModel extends Disposable implements ITunnelViewModel { dispose() { super.dispose(); } - - private getActions(type: TunnelType, tunnel: Tunnel): Action[] { - const actions: Action[] = []; - switch (type) { - case TunnelType.Forwarded: { - actions.push(this.createOpenAction()); - break; - } - case TunnelType.Published: { - actions.push(this.createForwardAction()); - actions.push(this.createOpenAction()); - break; - } - case TunnelType.Candidate: { - actions.push(this.createForwardAction()); - break; - } - } - if (tunnel.closeable) { - actions.push(this.createCloseAction()); - } - return actions; - } - - private createOpenAction(): Action { - const action = new Action('remote.tunnelView.open'); - action.enabled = true; - action.tooltip = nls.localize('remote.tunnelView.open', 'Open in Browser'); - action.class = 'icon codicon codicon-globe'; - action.run = (context: ITunnelItem) => { - const tunnel = this.model.forwarded.has(context.remote) ? this.model.forwarded.get(context.remote) : this.model.published.get(context.remote); - if (tunnel && tunnel.uri) { - return this.openerService.open(tunnel.uri); - } - return Promise.resolve(); - }; - return action; - } - - private createForwardAction(): Action { - const action = new Action('remote.tunnelView.forward'); - action.enabled = true; - action.tooltip = nls.localize('remote.tunnelView.forward', 'Forward to localhost'); - action.class = 'icon codicon codicon-plus'; - action.run = async (context: ITunnelItem) => { - this.model.forward(context.remote, context.local, context.name); - }; - return action; - } - - private createCloseAction(): Action { - const action = new Action('remote.tunnelView.forward'); - action.enabled = true; - action.tooltip = nls.localize('remote.tunnelView.forward', 'Forward to localhost'); - action.class = 'icon codicon codicon-x'; - action.run = async (context: ITunnelItem) => { - this.model.close(context.remote); - }; - return action; - } -} - -interface Tunnel { - remote: string; - local?: string; - name?: string; - description?: string; - closeable?: boolean; - uri?: URI; -} - -export class TunnelModel { - forwarded: Map; - published: Map; - candidates: Map; - private _onForwardPort: Emitter = new Emitter(); - public onForwardPort: Event = this._onForwardPort.event; - private _onClosePort: Emitter = new Emitter(); - public onClosePort: Event = this._onClosePort.event; - constructor() { - this.forwarded = new Map(); - this.forwarded.set('3000', - { - description: 'one description', - local: '3000', - remote: '3000', - closeable: true - }); - this.forwarded.set('4000', - { - local: '4001', - remote: '4000', - name: 'Process Port', - closeable: true - }); - - this.published = new Map(); - this.published.set('3500', - { - description: 'one description', - local: '3500', - remote: '3500', - name: 'My App', - }); - this.published.set('4500', - { - description: 'two description', - local: '4501', - remote: '4500' - }); - this.candidates = new Map(); - this.candidates.set('5000', - { - description: 'node.js /anArg', - remote: '5000', - }); - this.candidates.set('5500', - { - remote: '5500', - }); - } - - forward(remote: string, local?: string, name?: string) { - if (!this.forwarded.has(remote)) { - const newForward: Tunnel = { - remote: remote, - local: local ?? remote, - name: name, - closeable: true - }; - this.forwarded.set(remote, newForward); - this._onForwardPort.fire(newForward); - } - } - - close(remote: string) { - if (this.forwarded.has(remote)) { - this.forwarded.delete(remote); - this._onClosePort.fire(remote); - } - } } interface ITunnelTemplateData { @@ -280,11 +137,19 @@ class TunnelTreeRenderer extends Disposable implements ITreeRenderer { + if (action instanceof MenuItemAction) { + return this.instantiationService.createInstance(ContextAwareMenuEntryActionViewItem, action); + } + + return undefined; + } }); return { iconLabel, actionBar, container, elementDisposable: Disposable.None }; @@ -314,10 +186,20 @@ class TunnelTreeRenderer extends Disposable implements ITreeRenderer('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; constructor( protected viewModel: ITunnelViewModel, @@ -430,9 +317,12 @@ export class TunnelPanel extends ViewletPane { @IInstantiationService protected readonly instantiationService: IInstantiationService, @IOpenerService protected openerService: IOpenerService, @IQuickInputService protected quickInputService: IQuickInputService, - @ICommandService protected commandService: ICommandService + @ICommandService protected commandService: ICommandService, + @IMenuService private readonly menuService: IMenuService, ) { super(options, keybindingService, contextMenuService, configurationService, contextKeyService); + this.tunnelTypeContext = TunnelTypeContextKey.bindTo(contextKeyService); + this.tunnelCloseableContext = TunnelCloseableContextKey.bindTo(contextKeyService); } protected renderBody(container: HTMLElement): void { @@ -442,12 +332,12 @@ export class TunnelPanel extends ViewletPane { 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(), - [new TunnelTreeRenderer()], + [renderer], new TunnelDataSource(), { keyboardSupport: true, @@ -456,6 +346,10 @@ export class TunnelPanel extends ViewletPane { } } ); + 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(() => { @@ -470,6 +364,49 @@ export class TunnelPanel extends ViewletPane { // })); } + private get contributedContextMenu(): IMenu { + const contributedContextMenu = this.menuService.createMenu(MenuId.TunnelContext, this.tree.contextKeyService); + this._register(contributedContextMenu); + return contributedContextMenu; + } + + 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); } @@ -490,3 +427,161 @@ export class TunnelPanelDescriptor implements IViewDescriptor { 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) => { + if (arg instanceof TunnelItem) { + const remoteExplorerService = accessor.get(IRemoteExplorerService); + const quickInputService = accessor.get(IQuickInputService); + const local: string = await quickInputService.input({ placeHolder: nls.localize('remote.tunnelView.pickLocal', 'Local port to forward to, or leave blank for {0}', arg.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; + } + remoteExplorerService.tunnelModel.forward(arg.remote, (local !== '') ? local : arg.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); + 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); + if (tunnel && tunnel.uri) { + return openerService.open(tunnel.uri); + } + return Promise.resolve(); + } + }; + } +} + +CommandsRegistry.registerCommand(NameTunnelAction.ID, NameTunnelAction.handler()); +CommandsRegistry.registerCommand(ForwardPortAction.ID, ForwardPortAction.handler()); +CommandsRegistry.registerCommand(ClosePortAction.ID, ClosePortAction.handler()); +CommandsRegistry.registerCommand(OpenPortInBrowserAction.ID, OpenPortInBrowserAction.handler()); + +MenuRegistry.appendMenuItem(MenuId.TunnelContext, ({ + group: '0_manage', + order: 0, + 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: 1, + command: { + id: NameTunnelAction.ID, + title: NameTunnelAction.LABEL, + }, + when: TunnelTypeContextKey.isEqualTo(TunnelType.Forwarded) +})); +MenuRegistry.appendMenuItem(MenuId.TunnelContext, ({ + group: '0_manage', + order: 0, + 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: 2, + 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..679e742b42f31 100644 --- a/src/vs/workbench/services/remote/common/remoteExplorerService.ts +++ b/src/vs/workbench/services/remote/common/remoteExplorerService.ts @@ -10,15 +10,108 @@ 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/workbench/workbench.web.api'; export const IRemoteExplorerService = createDecorator('remoteExplorerService'); export const REMOTE_EXPLORER_TYPE_KEY: string = 'remote.explorerType'; + +export interface Tunnel { + remote: string; + local?: string; + name?: string; + description?: string; + closeable?: boolean; + uri?: URI; +} + +export class TunnelModel { + forwarded: Map; + published: Map; + 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() { + this.forwarded = new Map(); + this.forwarded.set('3000', + { + description: 'one description', + local: '3000', + remote: '3000', + closeable: true + }); + this.forwarded.set('4000', + { + local: '4001', + remote: '4000', + name: 'Process Port', + closeable: true + }); + + this.published = new Map(); + this.published.set('3500', + { + description: 'one description', + local: '3500', + remote: '3500', + name: 'My App', + }); + this.published.set('4500', + { + description: 'two description', + local: '4501', + remote: '4500' + }); + this.candidates = new Map(); + this.candidates.set('5000', + { + description: 'node.js /anArg', + remote: '5000', + }); + this.candidates.set('5500', + { + remote: '5500', + }); + } + + forward(remote: string, local?: string, name?: string) { + if (!this.forwarded.has(remote)) { + const newForward: Tunnel = { + remote: remote, + local: local ?? remote, + name: name, + closeable: true + }; + this.forwarded.set(remote, newForward); + this._onForwardPort.fire(newForward); + } + } + + name(remote: string, name: string) { + if (this.forwarded.has(remote)) { + this.forwarded.get(remote)!.name = name; + this._onPortName.fire(remote); + } + } + + close(remote: string) { + if (this.forwarded.has(remote)) { + this.forwarded.delete(remote); + this._onClosePort.fire(remote); + } + } +} + export interface IRemoteExplorerService { _serviceBrand: undefined; onDidChangeTargetType: Event; targetType: string; readonly helpInformation: HelpInformation[]; + readonly tunnelModel: TunnelModel; } export interface HelpInformation { @@ -62,6 +155,7 @@ class RemoteExplorerService implements IRemoteExplorerService { private _onDidChangeTargetType: Emitter = new Emitter(); public onDidChangeTargetType: Event = this._onDidChangeTargetType.event; private _helpInformation: HelpInformation[] = []; + private _tunnelModel: TunnelModel = new TunnelModel(); constructor(@IStorageService private readonly storageService: IStorageService) { remoteHelpExtPoint.setHandler((extensions) => { @@ -108,6 +202,10 @@ class RemoteExplorerService implements IRemoteExplorerService { get helpInformation(): HelpInformation[] { return this._helpInformation; } + + get tunnelModel(): TunnelModel { + return this._tunnelModel; + } } registerSingleton(IRemoteExplorerService, RemoteExplorerService, true); From 7fc4170898511b76498ab039405f17b99be31d1d Mon Sep 17 00:00:00 2001 From: Alex Ross Date: Mon, 25 Nov 2019 11:50:15 +0100 Subject: [PATCH 3/7] Add a port working --- .../contrib/remote/browser/tunnelView.ts | 55 ++++++++++++------- 1 file changed, 35 insertions(+), 20 deletions(-) diff --git a/src/vs/workbench/contrib/remote/browser/tunnelView.ts b/src/vs/workbench/contrib/remote/browser/tunnelView.ts index 9f8ddab43bf80..69e9a4ae88d65 100644 --- a/src/vs/workbench/contrib/remote/browser/tunnelView.ts +++ b/src/vs/workbench/contrib/remote/browser/tunnelView.ts @@ -7,7 +7,7 @@ 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 } from 'vs/platform/list/browser/listService'; +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'; @@ -273,8 +273,8 @@ class TunnelItem implements ITunnelItem { public local?: string, ) { } get label(): string { - if (this.name) { - return nls.localize('remote.tunnelsView.forwardedPortLabel0', "{0} to localhost", this.name); + 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) { @@ -343,7 +343,12 @@ export class TunnelPanel extends ViewletPane { keyboardSupport: true, collapseByDefault: (e: ITunnelItem | ITunnelGroup): boolean => { return false; - } + }, + keyboardNavigationLabelProvider: { + getKeyboardNavigationLabel: (item: ITunnelItem | ITunnelGroup) => { + return item.label; + } + }, } ); const actionRunner: ActionRunner = new ActionRunner(); @@ -356,12 +361,13 @@ export class TunnelPanel extends ViewletPane { this.tree.updateChildren(undefined, true); })); - // TODO: add navigator - // const helpItemNavigator = this._register(new TreeResourceNavigator2(this.tree, { openOnFocus: false, openOnSelection: false })); + const helpItemNavigator = this._register(new TreeResourceNavigator2(this.tree, { openOnFocus: false, openOnSelection: false })); - // this._register(Event.debounce(helpItemNavigator.onDidOpenResource, (last, event) => event, 75, true)(e => { - // e.element.handleClick(); - // })); + 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 { @@ -454,19 +460,28 @@ namespace ForwardPortAction { export function handler(): ICommandHandler { return async (accessor, arg) => { + const quickInputService = accessor.get(IQuickInputService); + const remoteExplorerService = accessor.get(IRemoteExplorerService); + let remote: string | undefined = undefined; if (arg instanceof TunnelItem) { - const remoteExplorerService = accessor.get(IRemoteExplorerService); - const quickInputService = accessor.get(IQuickInputService); - const local: string = await quickInputService.input({ placeHolder: nls.localize('remote.tunnelView.pickLocal', 'Local port to forward to, or leave blank for {0}', arg.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; - } - remoteExplorerService.tunnelModel.forward(arg.remote, (local !== '') ? local : arg.remote, (name !== '') ? name : undefined); + remote = arg.remote; + } else { + remote = await quickInputService.input({ placeHolder: nls.localize('remote.tunnelView.pickRemote', 'Remote port to forward') }); + } + + 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; } + remoteExplorerService.tunnelModel.forward(remote, (local !== '') ? local : remote, (name !== '') ? name : undefined); }; } } From cb8578faba7edb025233fa54889648a6ddb1d4b2 Mon Sep 17 00:00:00 2001 From: Alex Ross Date: Mon, 25 Nov 2019 12:20:02 +0100 Subject: [PATCH 4/7] Copy address --- .../contrib/remote/browser/tunnelView.ts | 47 +++++++++++++++---- .../remote/common/remoteExplorerService.ts | 37 ++++++++++++--- 2 files changed, 67 insertions(+), 17 deletions(-) diff --git a/src/vs/workbench/contrib/remote/browser/tunnelView.ts b/src/vs/workbench/contrib/remote/browser/tunnelView.ts index 69e9a4ae88d65..660e87cbed595 100644 --- a/src/vs/workbench/contrib/remote/browser/tunnelView.ts +++ b/src/vs/workbench/contrib/remote/browser/tunnelView.ts @@ -28,6 +28,8 @@ 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'; class TunnelTreeVirtualDelegate implements IListVirtualDelegate { getHeight(element: ITunnelItem): number { @@ -117,10 +119,6 @@ export class TunnelViewModel extends Disposable implements ITunnelViewModel { return candidates; } - copy(tunnelItem: ITunnelItem) { - // TODO: implement - } - dispose() { super.dispose(); } @@ -481,7 +479,7 @@ namespace ForwardPortAction { if (name === undefined) { return; } - remoteExplorerService.tunnelModel.forward(remote, (local !== '') ? local : remote, (name !== '') ? name : undefined); + remoteExplorerService.tunnelModel.forward(remote, undefined, (local !== '') ? local : remote, (name !== '') ? name : undefined); }; } } @@ -510,8 +508,9 @@ namespace OpenPortInBrowserAction { 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); - if (tunnel && tunnel.uri) { - return openerService.open(tunnel.uri); + let address: URI | undefined; + if (tunnel && tunnel.host && (address = model.address(tunnel.remote))) { + return openerService.open(address); } return Promise.resolve(); } @@ -519,14 +518,42 @@ namespace OpenPortInBrowserAction { } } +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.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, @@ -535,7 +562,7 @@ MenuRegistry.appendMenuItem(MenuId.TunnelContext, ({ })); MenuRegistry.appendMenuItem(MenuId.TunnelContext, ({ group: '0_manage', - order: 1, + order: 2, command: { id: NameTunnelAction.ID, title: NameTunnelAction.LABEL, @@ -544,7 +571,7 @@ MenuRegistry.appendMenuItem(MenuId.TunnelContext, ({ })); MenuRegistry.appendMenuItem(MenuId.TunnelContext, ({ group: '0_manage', - order: 0, + order: 1, command: { id: ForwardPortAction.ID, title: ForwardPortAction.LABEL, @@ -553,7 +580,7 @@ MenuRegistry.appendMenuItem(MenuId.TunnelContext, ({ })); MenuRegistry.appendMenuItem(MenuId.TunnelContext, ({ group: '0_manage', - order: 2, + order: 3, command: { id: ClosePortAction.ID, title: ClosePortAction.LABEL, diff --git a/src/vs/workbench/services/remote/common/remoteExplorerService.ts b/src/vs/workbench/services/remote/common/remoteExplorerService.ts index 679e742b42f31..0c2df4962b391 100644 --- a/src/vs/workbench/services/remote/common/remoteExplorerService.ts +++ b/src/vs/workbench/services/remote/common/remoteExplorerService.ts @@ -10,7 +10,7 @@ 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/workbench/workbench.web.api'; +import { URI } from 'vs/base/common/uri'; export const IRemoteExplorerService = createDecorator('remoteExplorerService'); export const REMOTE_EXPLORER_TYPE_KEY: string = 'remote.explorerType'; @@ -18,11 +18,11 @@ export const REMOTE_EXPLORER_TYPE_KEY: string = 'remote.explorerType'; export interface Tunnel { remote: string; + host: URI; local?: string; name?: string; description?: string; closeable?: boolean; - uri?: URI; } export class TunnelModel { @@ -42,14 +42,16 @@ export class TunnelModel { description: 'one description', local: '3000', remote: '3000', - closeable: true + closeable: true, + host: URI.parse('http://fakeHost') }); this.forwarded.set('4000', { local: '4001', remote: '4000', name: 'Process Port', - closeable: true + closeable: true, + host: URI.parse('http://fakeHost') }); this.published = new Map(); @@ -59,32 +61,40 @@ export class TunnelModel { local: '3500', remote: '3500', name: 'My App', + host: URI.parse('http://fakeHost') }); this.published.set('4500', { description: 'two description', local: '4501', - remote: '4500' + remote: '4500', + host: URI.parse('http://fakeHost') }); this.candidates = new Map(); this.candidates.set('5000', { description: 'node.js /anArg', remote: '5000', + host: URI.parse('http://fakeHost') }); this.candidates.set('5500', { remote: '5500', + host: URI.parse('http://fakeHost') }); } - forward(remote: string, local?: string, name?: string) { + forward(remote: string, host?: URI, local?: string, name?: string) { + if (!host) { + host = URI.parse('http://fakeHost'); + } if (!this.forwarded.has(remote)) { const newForward: Tunnel = { remote: remote, local: local ?? remote, name: name, - closeable: true + closeable: true, + host }; this.forwarded.set(remote, newForward); this._onForwardPort.fire(newForward); @@ -104,6 +114,19 @@ export class TunnelModel { this._onClosePort.fire(remote); } } + + address(remote: string): URI | undefined { + let tunnel: Tunnel | undefined = undefined; + if (this.forwarded.has(remote)) { + tunnel = this.forwarded.get(remote)!; + } else if (this.published.has(remote)) { + tunnel = this.published.get(remote)!; + } + if (tunnel) { + return URI.parse('http://localhost:' + tunnel.local); + } + return undefined; + } } export interface IRemoteExplorerService { From ae86b07b6c630ba400df3b125b2064e4af32da15 Mon Sep 17 00:00:00 2001 From: Alex Ross Date: Tue, 26 Nov 2019 09:52:50 +0100 Subject: [PATCH 5/7] Title action, setting, add TunnelService --- src/vs/platform/actions/common/actions.ts | 1 + .../contrib/remote/browser/remote.ts | 2 +- .../contrib/remote/browser/tunnelView.ts | 48 ++++++++++++++++++- .../remote/common/remoteExplorerService.ts | 12 +++-- 4 files changed, 57 insertions(+), 6 deletions(-) diff --git a/src/vs/platform/actions/common/actions.ts b/src/vs/platform/actions/common/actions.ts index d1d7a2e377459..c746abc83dcd7 100644 --- a/src/vs/platform/actions/common/actions.ts +++ b/src/vs/platform/actions/common/actions.ts @@ -99,6 +99,7 @@ export const enum MenuId { TitleBarContext, TunnelContext, TunnelInline, + TunnelTitle, ViewItemContext, ViewTitle, CommentThreadTitle, diff --git a/src/vs/workbench/contrib/remote/browser/remote.ts b/src/vs/workbench/contrib/remote/browser/remote.ts index d4a0912e6b96b..2d7afa657bff5 100644 --- a/src/vs/workbench/contrib/remote/browser/remote.ts +++ b/src/vs/workbench/contrib/remote/browser/remote.ts @@ -317,7 +317,7 @@ export class RemoteViewlet extends FilterViewContainerViewlet { 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) { + 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); diff --git a/src/vs/workbench/contrib/remote/browser/tunnelView.ts b/src/vs/workbench/contrib/remote/browser/tunnelView.ts index 660e87cbed595..2dcdba5535722 100644 --- a/src/vs/workbench/contrib/remote/browser/tunnelView.ts +++ b/src/vs/workbench/contrib/remote/browser/tunnelView.ts @@ -20,9 +20,9 @@ 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 } from 'vs/base/common/lifecycle'; +import { Disposable, IDisposable, toDisposable, MutableDisposable } from 'vs/base/common/lifecycle'; import { ViewletPane, IViewletPaneOptions } from 'vs/workbench/browser/parts/views/paneViewlet'; -import { ActionBar, ActionViewItem } from 'vs/base/browser/ui/actionbar/actionbar'; +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'; @@ -30,6 +30,7 @@ import { createAndFillInContextMenuActions, createAndFillInActionBarActions, Con 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 { @@ -305,6 +306,9 @@ export class TunnelPanel extends ViewletPane { private tunnelTypeContext: IContextKey; private tunnelCloseableContext: IContextKey; + private titleActions: IAction[] = []; + private readonly titleActionsDisposable = this._register(new MutableDisposable()); + constructor( protected viewModel: ITunnelViewModel, options: IViewletPaneOptions, @@ -317,10 +321,29 @@ export class TunnelPanel extends ViewletPane { @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 { @@ -374,6 +397,10 @@ export class TunnelPanel extends ViewletPane { return contributedContextMenu; } + getActions(): IAction[] { + return this.titleActions; + } + private onContextMenu(treeEvent: ITreeContextMenuEvent, actionRunner: ActionRunner): void { if (!(treeEvent.element instanceof TunnelItem)) { return; @@ -414,6 +441,10 @@ export class TunnelPanel extends ViewletPane { 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 { @@ -542,6 +573,19 @@ 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, diff --git a/src/vs/workbench/services/remote/common/remoteExplorerService.ts b/src/vs/workbench/services/remote/common/remoteExplorerService.ts index 0c2df4962b391..29012d13d7e66 100644 --- a/src/vs/workbench/services/remote/common/remoteExplorerService.ts +++ b/src/vs/workbench/services/remote/common/remoteExplorerService.ts @@ -11,6 +11,7 @@ import { IStorageService, StorageScope } from 'vs/platform/storage/common/storag 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'; export const IRemoteExplorerService = createDecorator('remoteExplorerService'); export const REMOTE_EXPLORER_TYPE_KEY: string = 'remote.explorerType'; @@ -35,7 +36,9 @@ export class TunnelModel { public onClosePort: Event = this._onClosePort.event; private _onPortName: Emitter = new Emitter(); public onPortName: Event = this._onPortName.event; - constructor() { + constructor( + @ITunnelService private readonly tunnelService: ITunnelService + ) { this.forwarded = new Map(); this.forwarded.set('3000', { @@ -178,9 +181,12 @@ class RemoteExplorerService implements IRemoteExplorerService { private _onDidChangeTargetType: Emitter = new Emitter(); public onDidChangeTargetType: Event = this._onDidChangeTargetType.event; private _helpInformation: HelpInformation[] = []; - private _tunnelModel: TunnelModel = new TunnelModel(); + 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) { From 9593c06bb208b6f61b1e1ed2b3d842680b08b8bf Mon Sep 17 00:00:00 2001 From: Alex Ross Date: Tue, 26 Nov 2019 16:07:05 +0100 Subject: [PATCH 6/7] Finally hook up to tunnel service. --- src/vs/platform/remote/common/tunnel.ts | 8 +- .../platform/remote/common/tunnelService.ts | 8 +- .../contrib/remote/browser/tunnelView.ts | 21 +-- .../remote/common/remoteExplorerService.ts | 134 ++++++++---------- .../services/remote/node/tunnelService.ts | 56 ++++++-- 5 files changed, 127 insertions(+), 100 deletions(-) diff --git a/src/vs/platform/remote/common/tunnel.ts b/src/vs/platform/remote/common/tunnel.ts index 37847839298e4..e85d8189c31ba 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; + onTunnelOpened: Event; + 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/tunnelView.ts b/src/vs/workbench/contrib/remote/browser/tunnelView.ts index 2dcdba5535722..6d445537bb41e 100644 --- a/src/vs/workbench/contrib/remote/browser/tunnelView.ts +++ b/src/vs/workbench/contrib/remote/browser/tunnelView.ts @@ -254,8 +254,8 @@ interface ITunnelGroup { interface ITunnelItem { tunnelType: TunnelType; - remote: string; - local?: string; + remote: number; + local?: number; name?: string; closeable?: boolean; readonly description?: string; @@ -265,11 +265,11 @@ interface ITunnelItem { class TunnelItem implements ITunnelItem { constructor( public tunnelType: TunnelType, - public remote: string, + public remote: number, public closeable?: boolean, public name?: string, private _description?: string, - public local?: string, + public local?: number, ) { } get label(): string { if (this.name && this.local) { @@ -491,11 +491,14 @@ namespace ForwardPortAction { return async (accessor, arg) => { const quickInputService = accessor.get(IQuickInputService); const remoteExplorerService = accessor.get(IRemoteExplorerService); - let remote: string | undefined = undefined; + let remote: number | undefined = undefined; if (arg instanceof TunnelItem) { remote = arg.remote; } else { - remote = await quickInputService.input({ placeHolder: nls.localize('remote.tunnelView.pickRemote', 'Remote port to forward') }); + const input = parseInt(await quickInputService.input({ placeHolder: nls.localize('remote.tunnelView.pickRemote', 'Remote port to forward') })); + if (typeof input === 'number') { + remote = input; + } } if (!remote) { @@ -510,7 +513,7 @@ namespace ForwardPortAction { if (name === undefined) { return; } - remoteExplorerService.tunnelModel.forward(remote, undefined, (local !== '') ? local : remote, (name !== '') ? name : undefined); + await remoteExplorerService.tunnelModel.forward(remote, (local !== '') ? parseInt(local) : remote, (name !== '') ? name : undefined); }; } } @@ -523,7 +526,7 @@ namespace ClosePortAction { return async (accessor, arg) => { if (arg instanceof TunnelItem) { const remoteExplorerService = accessor.get(IRemoteExplorerService); - remoteExplorerService.tunnelModel.close(arg.remote); + await remoteExplorerService.tunnelModel.close(arg.remote); } }; } @@ -540,7 +543,7 @@ namespace OpenPortInBrowserAction { 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.host && (address = model.address(tunnel.remote))) { + if (tunnel && tunnel.localUri && (address = model.address(tunnel.remote))) { return openerService.open(address); } return Promise.resolve(); diff --git a/src/vs/workbench/services/remote/common/remoteExplorerService.ts b/src/vs/workbench/services/remote/common/remoteExplorerService.ts index 29012d13d7e66..8f6c9ab641014 100644 --- a/src/vs/workbench/services/remote/common/remoteExplorerService.ts +++ b/src/vs/workbench/services/remote/common/remoteExplorerService.ts @@ -12,113 +12,99 @@ 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: string; - host: URI; - local?: string; + remote: number; + localUri: URI; + local?: number; name?: string; description?: string; closeable?: boolean; } -export class TunnelModel { - forwarded: Map; - published: Map; - candidates: Map; +export class TunnelModel extends Disposable { + forwarded: Map; + published: Map; + 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; + 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.forwarded.set('3000', - { - description: 'one description', - local: '3000', - remote: '3000', - closeable: true, - host: URI.parse('http://fakeHost') - }); - this.forwarded.set('4000', - { - local: '4001', - remote: '4000', - name: 'Process Port', - closeable: true, - host: URI.parse('http://fakeHost') + 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.published.set('3500', - { - description: 'one description', - local: '3500', - remote: '3500', - name: 'My App', - host: URI.parse('http://fakeHost') - }); - this.published.set('4500', - { - description: 'two description', - local: '4501', - remote: '4500', - host: URI.parse('http://fakeHost') - }); this.candidates = new Map(); - this.candidates.set('5000', - { - description: 'node.js /anArg', - remote: '5000', - host: URI.parse('http://fakeHost') - }); - this.candidates.set('5500', - { - remote: '5500', - host: URI.parse('http://fakeHost') - }); + 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); + } + })); } - forward(remote: string, host?: URI, local?: string, name?: string) { - if (!host) { - host = URI.parse('http://fakeHost'); - } + async forward(remote: number, local?: number, name?: string): Promise { if (!this.forwarded.has(remote)) { - const newForward: Tunnel = { - remote: remote, - local: local ?? remote, - name: name, - closeable: true, - host - }; - this.forwarded.set(remote, newForward); - this._onForwardPort.fire(newForward); + 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: string, name: string) { + name(remote: number, name: string) { if (this.forwarded.has(remote)) { this.forwarded.get(remote)!.name = name; this._onPortName.fire(remote); } } - close(remote: string) { - if (this.forwarded.has(remote)) { - this.forwarded.delete(remote); - this._onClosePort.fire(remote); - } + async close(remote: number): Promise { + return this.tunnelService.closeTunnel(remote); } - address(remote: string): URI | undefined { + address(remote: number): URI | undefined { let tunnel: Tunnel | undefined = undefined; if (this.forwarded.has(remote)) { tunnel = this.forwarded.get(remote)!; @@ -126,7 +112,7 @@ export class TunnelModel { tunnel = this.published.get(remote)!; } if (tunnel) { - return URI.parse('http://localhost:' + tunnel.local); + return tunnel.localUri; } return undefined; } diff --git a/src/vs/workbench/services/remote/node/tunnelService.ts b/src/vs/workbench/services/remote/node/tunnelService.ts index 62b872ac8c99c..b81e585e8f3b1 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)!; + value.refcount = 0; + (await value.value).dispose(); + } } - 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; } } From 8d5e4fe2f7bdad7dd50f32749bf1b3bb73219159 Mon Sep 17 00:00:00 2001 From: Alex Ross Date: Thu, 28 Nov 2019 10:44:03 +0100 Subject: [PATCH 7/7] PR feedback --- src/vs/platform/remote/common/tunnel.ts | 4 ++-- .../remote/common/remoteExplorerService.ts | 17 ++++------------- .../services/remote/node/tunnelService.ts | 2 +- 3 files changed, 7 insertions(+), 16 deletions(-) diff --git a/src/vs/platform/remote/common/tunnel.ts b/src/vs/platform/remote/common/tunnel.ts index e85d8189c31ba..ebbef722b59a5 100644 --- a/src/vs/platform/remote/common/tunnel.ts +++ b/src/vs/platform/remote/common/tunnel.ts @@ -20,8 +20,8 @@ export interface ITunnelService { _serviceBrand: undefined; readonly tunnels: Promise; - onTunnelOpened: Event; - onTunnelClosed: Event; + readonly onTunnelOpened: Event; + readonly onTunnelClosed: Event; openTunnel(remotePort: number, localPort?: number): Promise | undefined; closeTunnel(remotePort: number): Promise; diff --git a/src/vs/workbench/services/remote/common/remoteExplorerService.ts b/src/vs/workbench/services/remote/common/remoteExplorerService.ts index 8f6c9ab641014..6eb52e24c426f 100644 --- a/src/vs/workbench/services/remote/common/remoteExplorerService.ts +++ b/src/vs/workbench/services/remote/common/remoteExplorerService.ts @@ -27,9 +27,9 @@ export interface Tunnel { } export class TunnelModel extends Disposable { - forwarded: Map; - published: Map; - candidates: Map; + 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(); @@ -105,16 +105,7 @@ export class TunnelModel extends Disposable { } address(remote: number): URI | undefined { - let tunnel: Tunnel | undefined = undefined; - if (this.forwarded.has(remote)) { - tunnel = this.forwarded.get(remote)!; - } else if (this.published.has(remote)) { - tunnel = this.published.get(remote)!; - } - if (tunnel) { - return tunnel.localUri; - } - return undefined; + return (this.forwarded.get(remote) || this.published.get(remote))?.localUri; } } diff --git a/src/vs/workbench/services/remote/node/tunnelService.ts b/src/vs/workbench/services/remote/node/tunnelService.ts index b81e585e8f3b1..30c886758f7ca 100644 --- a/src/vs/workbench/services/remote/node/tunnelService.ts +++ b/src/vs/workbench/services/remote/node/tunnelService.ts @@ -164,8 +164,8 @@ export class TunnelService implements ITunnelService { async closeTunnel(remotePort: number): Promise { if (this._tunnels.has(remotePort)) { const value = this._tunnels.get(remotePort)!; - value.refcount = 0; (await value.value).dispose(); + value.refcount = 0; } }