diff --git a/assets/dh-icons.woff b/assets/dh-icons.woff new file mode 100644 index 00000000..6abba479 Binary files /dev/null and b/assets/dh-icons.woff differ diff --git a/package.json b/package.json index 1e974b5a..01eb31bb 100644 --- a/package.json +++ b/package.json @@ -141,6 +141,10 @@ "command": "vscode-deephaven.openVariablePanels", "title": "Deephaven: Open Variable Panels" }, + { + "command": "vscode-deephaven.refreshVariablePanels", + "title": "Deephaven: Refresh Variable Panels" + }, { "command": "vscode-deephaven.refreshServerTree", "title": "Deephaven: Refresh Server Tree", @@ -611,6 +615,10 @@ { "command": "vscode-deephaven.refreshServerConnectionTree", "when": "false" + }, + { + "command": "vscode-deephaven.refreshVariablePanels", + "when": "false" } ], "editor/context": [ @@ -707,6 +715,11 @@ "id": "vscode-deephaven.serverConnectionTree", "name": "Connections", "type": "tree" + }, + { + "id": "vscode-deephaven.serverConnectionPanelTree", + "name": "Panels", + "type": "tree" } ] } diff --git a/src/common/commands.ts b/src/common/commands.ts index e3cbe3e7..cdf75395 100644 --- a/src/common/commands.ts +++ b/src/common/commands.ts @@ -9,6 +9,7 @@ export const OPEN_IN_BROWSER_CMD = `${EXTENSION_ID}.openInBrowser`; export const OPEN_VARIABLE_PANELS_CMD = `${EXTENSION_ID}.openVariablePanels`; export const REFRESH_SERVER_TREE_CMD = `${EXTENSION_ID}.refreshServerTree`; export const REFRESH_SERVER_CONNECTION_TREE_CMD = `${EXTENSION_ID}.refreshServerConnectionTree`; +export const REFRESH_VARIABLE_PANELS_CMD = `${EXTENSION_ID}.refreshVariablePanels`; export const RUN_CODE_COMMAND = `${EXTENSION_ID}.runCode`; export const RUN_SELECTION_COMMAND = `${EXTENSION_ID}.runSelection`; export const SELECT_CONNECTION_COMMAND = `${EXTENSION_ID}.selectConnection`; diff --git a/src/common/constants.ts b/src/common/constants.ts index 47c9e705..d84b7517 100644 --- a/src/common/constants.ts +++ b/src/common/constants.ts @@ -48,6 +48,7 @@ export const TMP_DIR_ROOT = path.join(__dirname, '..', 'tmp'); export const VIEW_ID = { serverTree: `${EXTENSION_ID}.serverTree`, serverConnectionTree: `${EXTENSION_ID}.serverConnectionTree`, + serverConnectionPanelTree: `${EXTENSION_ID}.serverConnectionPanelTree`, } as const; export const ICON_ID = { @@ -61,6 +62,10 @@ export const ICON_ID = { serverConnected: 'circle-large-filled', serverRunning: 'circle-large-outline', serverStopped: 'circle-slash', + varFigure: 'graph', + varElement: 'preview', + varPandas: 'dh-pandas', + varTable: 'dh-table', } as const; /* eslint-disable @typescript-eslint/naming-convention */ @@ -68,7 +73,14 @@ export const VARIABLE_UNICODE_ICONS = { 'deephaven.plot.express.DeephavenFigure': '📈', 'deephaven.ui.Element': '✨', Figure: '📈', - Table: '⬜', + HierarchicalTable: '▤', + OtherWidget: '⬜', + 'pandas.DataFrame': '🐼', + PartitionedTable: '▤', + Table: '▤', + TableMap: '▤', + Treemap: '▤', + TreeTable: '▤', } as const satisfies Record; /* eslint-enable @typescript-eslint/naming-convention */ diff --git a/src/controllers/ExtensionController.ts b/src/controllers/ExtensionController.ts index 509d9024..5cc49acb 100644 --- a/src/controllers/ExtensionController.ts +++ b/src/controllers/ExtensionController.ts @@ -7,7 +7,6 @@ import { DISCONNECT_FROM_SERVER_CMD, DOWNLOAD_LOGS_CMD, OPEN_IN_BROWSER_CMD, - OPEN_VARIABLE_PANELS_CMD, REFRESH_SERVER_CONNECTION_TREE_CMD, REFRESH_SERVER_TREE_CMD, RUN_CODE_COMMAND, @@ -30,6 +29,7 @@ import { RunCommandCodeLensProvider, ServerTreeProvider, ServerConnectionTreeProvider, + ServerConnectionPanelTreeProvider, runSelectedLinesHoverProvider, } from '../providers'; import { DhcServiceFactory, PanelService, ServerManager } from '../services'; @@ -41,10 +41,11 @@ import type { IPanelService, IServerManager, IToastService, + ServerConnectionPanelNode, + ServerConnectionPanelTreeView, ServerConnectionTreeView, ServerState, ServerTreeView, - VariableDefintion, } from '../types'; import { ServerConnectionTreeDragAndDropController } from './ServerConnectionTreeDragAndDropController'; import { ConnectionController } from './ConnectionController'; @@ -76,7 +77,6 @@ export class ExtensionController implements Disposable { 'Congratulations, your extension "vscode-deephaven" is now active!' ); - this._outputChannel?.show(); this._outputChannel?.appendLine('Deephaven extension activated'); } @@ -89,11 +89,19 @@ export class ExtensionController implements Disposable { private _pipServerController: PipServerController | null = null; private _dhcServiceFactory: IDhServiceFactory | null = null; private _serverManager: IServerManager | null = null; + + // Tree providers private _serverTreeProvider: ServerTreeProvider | null = null; private _serverConnectionTreeProvider: ServerConnectionTreeProvider | null = null; + private _serverConnectionPanelTreeProvider: ServerConnectionPanelTreeProvider | null = + null; + + // Tree views private _serverTreeView: ServerTreeView | null = null; private _serverConnectionTreeView: ServerConnectionTreeView | null = null; + private _serverConnectionPanelTreeView: ServerConnectionPanelTreeView | null = + null; private _pythonDiagnostics: vscode.DiagnosticCollection | null = null; private _outputChannel: vscode.OutputChannel | null = null; @@ -239,6 +247,7 @@ export class ExtensionController implements Disposable { this._context.subscriptions.push(this._panelService); this._dhcServiceFactory = new DhcServiceFactory( + this._panelService, this._pythonDiagnostics, this._outputChannel, this._toaster @@ -314,9 +323,6 @@ export class ExtensionController implements Disposable { /** Open server in browser */ this.registerCommand(OPEN_IN_BROWSER_CMD, this.onOpenInBrowser); - /** Open variable panel */ - this.registerCommand(OPEN_VARIABLE_PANELS_CMD, this.onOpenVariablePanels); - /** Run all code in active editor */ this.registerCommand(RUN_CODE_COMMAND, this.onRunCode); @@ -349,6 +355,7 @@ export class ExtensionController implements Disposable { * Register web views for the extension. */ initializeWebViews = (): void => { + assertDefined(this._panelService, 'panelService'); assertDefined(this._serverManager, 'serverManager'); // Server tree @@ -374,10 +381,25 @@ export class ExtensionController implements Disposable { } ); + // Connection Panel tree + this._serverConnectionPanelTreeProvider = + new ServerConnectionPanelTreeProvider( + this._serverManager, + this._panelService + ); + this._serverConnectionPanelTreeView = + vscode.window.createTreeView( + VIEW_ID.serverConnectionPanelTree, + { + showCollapseAll: true, + treeDataProvider: this._serverConnectionPanelTreeProvider, + } + ); + this._context.subscriptions.push( - this._serverManager, this._serverTreeView, - this._serverConnectionTreeView + this._serverConnectionTreeView, + this._serverConnectionPanelTreeView ); }; @@ -490,15 +512,6 @@ export class ExtensionController implements Disposable { ); }; - /** - * Open panels for given url and variables. - * @param url Connection url to open panels for. - * @param variables Variables to open panels for. - */ - onOpenVariablePanels = (url: URL, variables: VariableDefintion[]): void => { - this._panelController?.openPanels(url, variables); - }; - onRefreshServerStatus = async (): Promise => { await this._pipServerController?.syncManagedServers(); await this._serverManager?.updateStatus(); diff --git a/src/controllers/PanelController.ts b/src/controllers/PanelController.ts index 2aad4469..a03f2b9a 100644 --- a/src/controllers/PanelController.ts +++ b/src/controllers/PanelController.ts @@ -5,25 +5,59 @@ import type { IServerManager, VariableDefintion, } from '../types'; +import { assertDefined, getDHThemeKey, getPanelHtml, Logger } from '../util'; import { getEmbedWidgetUrl } from '../dh/dhc'; -import { assertDefined, getDHThemeKey, getPanelHtml } from '../util'; import { DhcService } from '../services'; +import { + OPEN_VARIABLE_PANELS_CMD, + REFRESH_VARIABLE_PANELS_CMD, +} from '../common'; +import { waitFor } from '../util/promiseUtils'; + +const logger = new Logger('PanelController'); export class PanelController implements Disposable { constructor(serverManager: IServerManager, panelService: IPanelService) { this._panelService = panelService; this._serverManager = serverManager; + this._subscriptions = []; + + this._subscriptions.push( + vscode.commands.registerCommand( + OPEN_VARIABLE_PANELS_CMD, + this._onOpenPanels + ), + vscode.commands.registerCommand( + REFRESH_VARIABLE_PANELS_CMD, + this._onRefreshPanelsContent + ), + vscode.window.onDidChangeActiveColorTheme( + this._onDidChangeActiveColorTheme + ) + ); } private readonly _panelService: IPanelService; private readonly _serverManager: IServerManager; + private readonly _subscriptions: vscode.Disposable[]; - dispose = async (): Promise => {}; + dispose = async (): Promise => { + for (const subscription of this._subscriptions) { + subscription.dispose(); + } + this._subscriptions.length = 0; + }; - openPanels = async ( + private _onOpenPanels = async ( serverUrl: URL, variables: VariableDefintion[] ): Promise => { + logger.debug('openPanels', serverUrl, variables); + + // Waiting for next tick seems to decrease the occurrences of a subtle bug + // where the `editor/title/run` menu gets stuck on a previous selection. + await waitFor(0); + let lastPanel: vscode.WebviewPanel | null = null; for (const { id, title } of variables) { @@ -73,5 +107,43 @@ export class PanelController implements Disposable { } lastPanel?.reveal(); + this._onRefreshPanelsContent(serverUrl, variables); + }; + + /** + * Reload the html content for all panels associated with the given server url + * + variables. + * @param serverUrl The server url. + * @param variables Variables identifying the panels to refresh. + */ + private _onRefreshPanelsContent = ( + serverUrl: URL, + variables: VariableDefintion[] + ): void => { + const connection = this._serverManager.getConnection(serverUrl); + assertDefined(connection, 'connection'); + + for (const { id, title } of variables) { + const panel = this._panelService.getPanelOrThrow(serverUrl, id); + + const iframeUrl = getEmbedWidgetUrl( + serverUrl, + title, + getDHThemeKey(), + connection instanceof DhcService ? connection.getPsk() : undefined + ); + + panel.webview.html = getPanelHtml(iframeUrl, title); + } + }; + + /** + * Whenever active theme changes, refresh any open panels. + */ + private _onDidChangeActiveColorTheme = (): void => { + for (const url of this._panelService.getPanelUrls()) { + const variables = this._panelService.getPanelVariables(url); + this._onRefreshPanelsContent(url, [...variables]); + } }; } diff --git a/src/providers/ServerConnectionPanelTreeProvider.ts b/src/providers/ServerConnectionPanelTreeProvider.ts new file mode 100644 index 00000000..4fc2fed1 --- /dev/null +++ b/src/providers/ServerConnectionPanelTreeProvider.ts @@ -0,0 +1,50 @@ +import * as vscode from 'vscode'; +import type { + IDhService, + IPanelService, + IServerManager, + ServerConnectionPanelNode, +} from '../types'; +import { TreeDataProviderBase } from './TreeDataProviderBase'; +import { + getPanelConnectionTreeItem, + getPanelVariableTreeItem, + sortByStringProp, +} from '../util'; + +export class ServerConnectionPanelTreeProvider extends TreeDataProviderBase { + constructor(serverManager: IServerManager, panelService: IPanelService) { + super(serverManager); + this._panelService = panelService; + + this._panelService.onDidUpdate(() => { + this._onDidChangeTreeData.fire(); + }); + } + + private readonly _panelService: IPanelService; + + getTreeItem = async ( + connectionOrVariable: ServerConnectionPanelNode + ): Promise => { + if (Array.isArray(connectionOrVariable)) { + return getPanelVariableTreeItem(connectionOrVariable); + } + + return getPanelConnectionTreeItem(connectionOrVariable); + }; + + getChildren = ( + connectionOrRoot?: IDhService + ): vscode.ProviderResult => { + if (connectionOrRoot == null) { + return this.serverManager + .getConnections() + .sort(sortByStringProp('serverUrl')); + } + + return [...this._panelService.getVariables(connectionOrRoot.serverUrl)] + .sort(sortByStringProp('title')) + .map(variable => [connectionOrRoot.serverUrl, variable]); + }; +} diff --git a/src/providers/ServerConnectionTreeProvider.ts b/src/providers/ServerConnectionTreeProvider.ts index cf7a43cc..e4385394 100644 --- a/src/providers/ServerConnectionTreeProvider.ts +++ b/src/providers/ServerConnectionTreeProvider.ts @@ -2,14 +2,15 @@ import * as vscode from 'vscode'; import { TreeDataProviderBase } from './TreeDataProviderBase'; import { CONNECTION_TREE_ITEM_CONTEXT, ICON_ID } from '../common'; import type { IDhService, ServerConnectionNode } from '../types'; +import { sortByStringProp } from '../util'; /** * Provider for the server connection tree view. */ export class ServerConnectionTreeProvider extends TreeDataProviderBase { - async getTreeItem( + getTreeItem = async ( connectionOrUri: ServerConnectionNode - ): Promise { + ): Promise => { // Uri node associated with a parent connection node if (connectionOrUri instanceof vscode.Uri) { return { @@ -41,17 +42,15 @@ export class ServerConnectionTreeProvider extends TreeDataProviderBase => { + ): vscode.ProviderResult => { if (elementOrRoot == null) { return this.serverManager .getConnections() - .sort((a, b) => - a.serverUrl.toString().localeCompare(b.serverUrl.toString()) - ); + .sort(sortByStringProp('serverUrl')); } return this.serverManager.getConnectionUris(elementOrRoot); diff --git a/src/providers/ServerTreeProvider.ts b/src/providers/ServerTreeProvider.ts index b0544cec..24dbe0d7 100644 --- a/src/providers/ServerTreeProvider.ts +++ b/src/providers/ServerTreeProvider.ts @@ -16,9 +16,7 @@ function isServerGroupState(node: ServerNode): node is ServerGroupState { * Provider for the server tree view. */ export class ServerTreeProvider extends TreeDataProviderBase { - getTreeItem( - element: ServerNode - ): vscode.TreeItem | Thenable { + getTreeItem = (element: ServerNode): vscode.TreeItem => { if (isServerGroupState(element)) { return getServerGroupTreeItem(element, this.serverManager.canStartServer); } @@ -33,7 +31,7 @@ export class ServerTreeProvider extends TreeDataProviderBase { isManaged, isRunning, }); - } + }; getChildren(elementOrRoot?: ServerNode): vscode.ProviderResult { const { managed, running, stopped } = groupServers( diff --git a/src/providers/TreeDataProviderBase.ts b/src/providers/TreeDataProviderBase.ts index b6d620ca..f790421b 100644 --- a/src/providers/TreeDataProviderBase.ts +++ b/src/providers/TreeDataProviderBase.ts @@ -7,13 +7,17 @@ import type { IServerManager } from '../types'; export abstract class TreeDataProviderBase implements vscode.TreeDataProvider { - constructor(readonly serverManager: IServerManager) { - serverManager.onDidUpdate(() => { + constructor(serverManager: IServerManager) { + this.serverManager = serverManager; + + this.serverManager.onDidUpdate(() => { this._onDidChangeTreeData.fire(); }); } - private readonly _onDidChangeTreeData = new vscode.EventEmitter< + protected readonly serverManager: IServerManager; + + protected readonly _onDidChangeTreeData = new vscode.EventEmitter< T | undefined | void >(); diff --git a/src/providers/index.ts b/src/providers/index.ts index a44ad351..48c9fe8a 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -2,4 +2,5 @@ export * from './RunCommandCodeLensProvider'; export * from './RunSelectedLinesHoverProvider'; export * from './ServerConnectionTreeProvider'; export * from './ServerTreeProvider'; +export * from './ServerConnectionPanelTreeProvider'; export * from './TreeDataProviderBase'; diff --git a/src/services/DhService.ts b/src/services/DhService.ts index 3cd97aa7..7ee39ebc 100644 --- a/src/services/DhService.ts +++ b/src/services/DhService.ts @@ -5,8 +5,11 @@ import type { ConnectionAndSession, ConsoleType, IDhService, + IPanelService, IToastService, + VariableChanges, VariableDefintion, + VariableID, } from '../types'; import { formatTimestamp, @@ -16,7 +19,11 @@ import { NoConsoleTypesError, parseServerError, } from '../util'; -import { OPEN_VARIABLE_PANELS_CMD, VARIABLE_UNICODE_ICONS } from '../common'; +import { + OPEN_VARIABLE_PANELS_CMD, + REFRESH_VARIABLE_PANELS_CMD, + VARIABLE_UNICODE_ICONS, +} from '../common'; const logger = new Logger('DhService'); @@ -25,11 +32,13 @@ export abstract class DhService { constructor( serverUrl: URL, + panelService: IPanelService, diagnosticsCollection: vscode.DiagnosticCollection, outputChannel: vscode.OutputChannel, toaster: IToastService ) { this.serverUrl = serverUrl; + this.panelService = panelService; this.diagnosticsCollection = diagnosticsCollection; this.outputChannel = outputChannel; this.toaster = toaster; @@ -43,6 +52,7 @@ export abstract class DhService protected readonly outputChannel: vscode.OutputChannel; protected readonly toaster: IToastService; + private readonly panelService: IPanelService; private readonly diagnosticsCollection: vscode.DiagnosticCollection; private cachedCreateClient: Promise | null = null; private cachedCreateSession: Promise< @@ -148,6 +158,27 @@ export abstract class DhService try { const { cn, session } = await this.cachedCreateSession; + cn.subscribeToFieldUpdates(changes => { + this.panelService.updateVariables( + this.serverUrl, + changes as VariableChanges + ); + + const panelVariablesToUpdate = changes.updated.filter( + (variable): variable is VariableDefintion => + this.panelService.hasPanel( + this.serverUrl, + variable.id as VariableID + ) + ); + + vscode.commands.executeCommand( + REFRESH_VARIABLE_PANELS_CMD, + this.serverUrl, + panelVariablesToUpdate + ); + }); + // TODO: Use constant 'disconnect' event name this.subscriptions.push( cn.addEventListener('disconnect', () => { diff --git a/src/services/DhcServiceFactory.ts b/src/services/DhcServiceFactory.ts index b3f30ade..de121508 100644 --- a/src/services/DhcServiceFactory.ts +++ b/src/services/DhcServiceFactory.ts @@ -1,12 +1,13 @@ import * as vscode from 'vscode'; import { DhcService } from './DhcService'; -import type { IDhServiceFactory, IToastService } from '../types'; +import type { IDhServiceFactory, IPanelService, IToastService } from '../types'; /** * Factory for creating DhcService instances. */ export class DhcServiceFactory implements IDhServiceFactory { constructor( + private panelService: IPanelService, private diagnosticsCollection: vscode.DiagnosticCollection, private outputChannel: vscode.OutputChannel, private toaster: IToastService @@ -15,6 +16,7 @@ export class DhcServiceFactory implements IDhServiceFactory { create = (serverUrl: URL, psk?: string): DhcService => { const dhService = new DhcService( serverUrl, + this.panelService, this.diagnosticsCollection, this.outputChannel, this.toaster diff --git a/src/services/PanelService.ts b/src/services/PanelService.ts index 62dffd93..097de023 100644 --- a/src/services/PanelService.ts +++ b/src/services/PanelService.ts @@ -3,16 +3,24 @@ import { URLMap } from './URLMap'; import type { Disposable, IPanelService, + VariableChanges, + VariableDefintion, VariableID, + VariableMap, VariablePanelMap, } from '../types'; export class PanelService implements IPanelService, Disposable { constructor() { this._cnPanelMap = new URLMap(); + this._cnVariableMap = new URLMap(); } + private readonly _onDidUpdate = new vscode.EventEmitter(); + readonly onDidUpdate = this._onDidUpdate.event; + private readonly _cnPanelMap: URLMap; + private readonly _cnVariableMap: URLMap; dispose = async (): Promise => { this._cnPanelMap.clear(); @@ -41,9 +49,28 @@ export class PanelService implements IPanelService, Disposable { this._cnPanelMap.get(url)?.delete(variableId); }; + /** + * Get all connection URLs that have panels. + * @returns Array of URLs + */ + getPanelUrls = (): URL[] => { + return [...this._cnPanelMap.keys()].filter( + url => this._cnPanelMap.get(url)?.size ?? 0 > 0 + ); + }; + + /** + * Get all variables for the given connection url that have panels. + * @param url The connection url. + * @returns Array of variables + */ + getPanelVariables = (url: URL): VariableDefintion[] => { + return [...this.getVariables(url)].filter(v => this.hasPanel(url, v.id)); + }; + /** * Check if a panel is associated with a given connection url + variable id. - * @param url + * @param url The connection url. * @param variableId */ hasPanel = (url: URL, variableId: VariableID): boolean => { @@ -69,4 +96,44 @@ export class PanelService implements IPanelService, Disposable { this._cnPanelMap.get(url)!.set(variableId, panel); }; + + /** + * Get variables for the given connection url. + * @param url The connection url. + * @returns Iterable of variables + */ + getVariables = (url: URL): Iterable => { + return this._cnVariableMap.get(url)?.values() ?? []; + }; + + /** + * Update the variables for the given connection url. + * @param url The connection URL + * @param variableChanges Changes made on that connection + */ + updateVariables = ( + url: URL, + { created, removed, updated }: VariableChanges + ): void => { + if (!this._cnVariableMap.has(url)) { + this._cnVariableMap.set(url, new Map()); + } + + const variableMap = this._cnVariableMap.get(url)!; + + for (const variable of removed) { + variableMap.delete(variable.id); + this.deletePanel(url, variable.id); + } + + for (const variable of created) { + variableMap.set(variable.id, variable); + } + + for (const variable of updated) { + variableMap.set(variable.id, variable); + } + + this._onDidUpdate.fire(); + }; } diff --git a/src/types/commonTypes.d.ts b/src/types/commonTypes.d.ts index aa8f1842..2c380c80 100644 --- a/src/types/commonTypes.d.ts +++ b/src/types/commonTypes.d.ts @@ -79,10 +79,24 @@ export type VariableDefintion = DhcType.ide.VariableDefinition & { type: VariableType; }; +export type VariableMap = Map; export type VariablePanelMap = Map; +export interface VariableChanges { + readonly created: VariableDefintion[]; + readonly removed: VariableDefintion[]; + readonly updated: VariableDefintion[]; +} + export type VariableType = - | 'Figure' | 'deephaven.plot.express.DeephavenFigure' + | 'deephaven.ui.Element' + | 'Figure' + | 'HierarchicalTable' + | 'OtherWidget' + | 'pandas.DataFrame' + | 'PartitionedTable' | 'Table' - | 'deephaven.ui.Element'; + | 'TableMap' + | 'Treemap' + | 'TreeTable'; diff --git a/src/types/serviceTypes.d.ts b/src/types/serviceTypes.d.ts index aeb273a0..18431007 100644 --- a/src/types/serviceTypes.d.ts +++ b/src/types/serviceTypes.d.ts @@ -8,6 +8,7 @@ import type { ServerConnection, ServerState, UnsubscribeEventListener, + VariableChanges, VariableDefintion, VariableID, } from '../types/commonTypes'; @@ -66,6 +67,10 @@ export type IDhServiceFactory = IFactory< >; export interface IPanelService extends Disposable { + readonly onDidUpdate: vscode.Event; + + getPanelUrls: () => URL[]; + getPanelVariables: (url: URL) => VariableDefintion[]; getPanelOrThrow: (url: URL, variableId: VariableID) => vscode.WebviewPanel; deletePanel: (url: URL, variableId: VariableID) => void; hasPanel: (url: URL, variableId: VariableID) => boolean; @@ -74,6 +79,8 @@ export interface IPanelService extends Disposable { variableId: VariableID, panel: vscode.WebviewPanel ) => void; + getVariables: (url: URL) => Iterable; + updateVariables: (url: URL, changes: VariableChanges) => void; } /** diff --git a/src/types/treeViewTypes.d.ts b/src/types/treeViewTypes.d.ts index 4ee3e324..bd2d0ffc 100644 --- a/src/types/treeViewTypes.d.ts +++ b/src/types/treeViewTypes.d.ts @@ -1,6 +1,6 @@ import * as vscode from 'vscode'; import type { IDhService } from './serviceTypes'; -import type { ServerState } from './commonTypes'; +import type { ServerState, VariableDefintion } from './commonTypes'; export type ServerGroupState = 'Managed' | 'Running' | 'Stopped'; export type ServerNode = ServerGroupState | ServerState; @@ -9,3 +9,7 @@ export interface ServerTreeView extends vscode.TreeView {} export type ServerConnectionNode = IDhService | vscode.Uri; export interface ServerConnectionTreeView extends vscode.TreeView {} + +export type ServerConnectionPanelNode = IDhService | [URL, VariableDefintion]; +export interface ServerConnectionPanelTreeView + extends vscode.TreeView {} diff --git a/src/util/__snapshots__/treeViewUtils.spec.ts.snap b/src/util/__snapshots__/treeViewUtils.spec.ts.snap index 1fbd7ca6..d08eb7d6 100644 --- a/src/util/__snapshots__/treeViewUtils.spec.ts.snap +++ b/src/util/__snapshots__/treeViewUtils.spec.ts.snap @@ -1,5 +1,306 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html +exports[`getPanelConnectionTreeItem > should return panel connection tree item: isConnected:false, isInitialized:false 1`] = ` +{ + "collapsibleState": 2, + "description": undefined, + "iconPath": { + "color": undefined, + "id": "sync~spin", + }, + "label": "localhost:10000", +} +`; + +exports[`getPanelConnectionTreeItem > should return panel connection tree item: isConnected:false, isInitialized:true 1`] = ` +{ + "collapsibleState": 2, + "description": "python", + "iconPath": { + "color": undefined, + "id": "sync~spin", + }, + "label": "localhost:10000", +} +`; + +exports[`getPanelConnectionTreeItem > should return panel connection tree item: isConnected:true, isInitialized:false 1`] = ` +{ + "collapsibleState": 2, + "description": undefined, + "iconPath": { + "color": undefined, + "id": "vm-connect", + }, + "label": "localhost:10000", +} +`; + +exports[`getPanelConnectionTreeItem > should return panel connection tree item: isConnected:true, isInitialized:true 1`] = ` +{ + "collapsibleState": 2, + "description": "python", + "iconPath": { + "color": undefined, + "id": "vm-connect", + }, + "label": "localhost:10000", +} +`; + +exports[`getPanelVariableTreeItem > should return panel variable tree item: type:Figure 1`] = ` +{ + "command": { + "arguments": [ + "http://localhost:10000/", + [ + { + "title": "some title", + "type": "Figure", + }, + ], + ], + "command": "vscode-deephaven.openVariablePanels", + "title": "Open Panel", + }, + "description": "some title", + "iconPath": { + "color": undefined, + "id": "graph", + }, +} +`; + +exports[`getPanelVariableTreeItem > should return panel variable tree item: type:HierarchicalTable 1`] = ` +{ + "command": { + "arguments": [ + "http://localhost:10000/", + [ + { + "title": "some title", + "type": "HierarchicalTable", + }, + ], + ], + "command": "vscode-deephaven.openVariablePanels", + "title": "Open Panel", + }, + "description": "some title", + "iconPath": { + "color": undefined, + "id": "dh-table", + }, +} +`; + +exports[`getPanelVariableTreeItem > should return panel variable tree item: type:OtherWidget 1`] = ` +{ + "command": { + "arguments": [ + "http://localhost:10000/", + [ + { + "title": "some title", + "type": "OtherWidget", + }, + ], + ], + "command": "vscode-deephaven.openVariablePanels", + "title": "Open Panel", + }, + "description": "some title", + "iconPath": { + "color": undefined, + "id": "preview", + }, +} +`; + +exports[`getPanelVariableTreeItem > should return panel variable tree item: type:PartitionedTable 1`] = ` +{ + "command": { + "arguments": [ + "http://localhost:10000/", + [ + { + "title": "some title", + "type": "PartitionedTable", + }, + ], + ], + "command": "vscode-deephaven.openVariablePanels", + "title": "Open Panel", + }, + "description": "some title", + "iconPath": { + "color": undefined, + "id": "dh-table", + }, +} +`; + +exports[`getPanelVariableTreeItem > should return panel variable tree item: type:Table 1`] = ` +{ + "command": { + "arguments": [ + "http://localhost:10000/", + [ + { + "title": "some title", + "type": "Table", + }, + ], + ], + "command": "vscode-deephaven.openVariablePanels", + "title": "Open Panel", + }, + "description": "some title", + "iconPath": { + "color": undefined, + "id": "dh-table", + }, +} +`; + +exports[`getPanelVariableTreeItem > should return panel variable tree item: type:TableMap 1`] = ` +{ + "command": { + "arguments": [ + "http://localhost:10000/", + [ + { + "title": "some title", + "type": "TableMap", + }, + ], + ], + "command": "vscode-deephaven.openVariablePanels", + "title": "Open Panel", + }, + "description": "some title", + "iconPath": { + "color": undefined, + "id": "dh-table", + }, +} +`; + +exports[`getPanelVariableTreeItem > should return panel variable tree item: type:TreeTable 1`] = ` +{ + "command": { + "arguments": [ + "http://localhost:10000/", + [ + { + "title": "some title", + "type": "TreeTable", + }, + ], + ], + "command": "vscode-deephaven.openVariablePanels", + "title": "Open Panel", + }, + "description": "some title", + "iconPath": { + "color": undefined, + "id": "dh-table", + }, +} +`; + +exports[`getPanelVariableTreeItem > should return panel variable tree item: type:Treemap 1`] = ` +{ + "command": { + "arguments": [ + "http://localhost:10000/", + [ + { + "title": "some title", + "type": "Treemap", + }, + ], + ], + "command": "vscode-deephaven.openVariablePanels", + "title": "Open Panel", + }, + "description": "some title", + "iconPath": { + "color": undefined, + "id": "preview", + }, +} +`; + +exports[`getPanelVariableTreeItem > should return panel variable tree item: type:deephaven.plot.express.DeephavenFigure 1`] = ` +{ + "command": { + "arguments": [ + "http://localhost:10000/", + [ + { + "title": "some title", + "type": "deephaven.plot.express.DeephavenFigure", + }, + ], + ], + "command": "vscode-deephaven.openVariablePanels", + "title": "Open Panel", + }, + "description": "some title", + "iconPath": { + "color": undefined, + "id": "graph", + }, +} +`; + +exports[`getPanelVariableTreeItem > should return panel variable tree item: type:deephaven.ui.Element 1`] = ` +{ + "command": { + "arguments": [ + "http://localhost:10000/", + [ + { + "title": "some title", + "type": "deephaven.ui.Element", + }, + ], + ], + "command": "vscode-deephaven.openVariablePanels", + "title": "Open Panel", + }, + "description": "some title", + "iconPath": { + "color": undefined, + "id": "preview", + }, +} +`; + +exports[`getPanelVariableTreeItem > should return panel variable tree item: type:pandas.DataFrame 1`] = ` +{ + "command": { + "arguments": [ + "http://localhost:10000/", + [ + { + "title": "some title", + "type": "pandas.DataFrame", + }, + ], + ], + "command": "vscode-deephaven.openVariablePanels", + "title": "Open Panel", + }, + "description": "some title", + "iconPath": { + "color": undefined, + "id": "dh-pandas", + }, +} +`; + exports[`getServerContextValue > should return contextValue based on server state: isConnected=false, isManaged=false, isRunning=false 1`] = `"isServerStopped"`; exports[`getServerContextValue > should return contextValue based on server state: isConnected=false, isManaged=false, isRunning=true 1`] = `"isServerRunningDisconnected"`; @@ -234,6 +535,88 @@ exports[`getServerTreeItem > should return DHC server tree item: isConnected=tru } `; +exports[`getVariableIconPath > should return icon path for variableType 1`] = ` +[ + [ + "deephaven.plot.express.DeephavenFigure", + { + "color": undefined, + "id": "graph", + }, + ], + [ + "deephaven.ui.Element", + { + "color": undefined, + "id": "preview", + }, + ], + [ + "Figure", + { + "color": undefined, + "id": "graph", + }, + ], + [ + "HierarchicalTable", + { + "color": undefined, + "id": "dh-table", + }, + ], + [ + "OtherWidget", + { + "color": undefined, + "id": "preview", + }, + ], + [ + "pandas.DataFrame", + { + "color": undefined, + "id": "dh-pandas", + }, + ], + [ + "PartitionedTable", + { + "color": undefined, + "id": "dh-table", + }, + ], + [ + "Table", + { + "color": undefined, + "id": "dh-table", + }, + ], + [ + "TableMap", + { + "color": undefined, + "id": "dh-table", + }, + ], + [ + "Treemap", + { + "color": undefined, + "id": "preview", + }, + ], + [ + "TreeTable", + { + "color": undefined, + "id": "dh-table", + }, + ], +] +`; + exports[`groupServers > should group servers by state 1`] = ` { "managed": [ diff --git a/src/util/assertUtil.ts b/src/util/assertUtil.ts index 7710d15b..5649adb9 100644 --- a/src/util/assertUtil.ts +++ b/src/util/assertUtil.ts @@ -1,5 +1,3 @@ -import type { VariableID } from '../types'; - /** * Assert that a given value is not `null` or `undefined`. * @param dependency The value to assert. @@ -24,16 +22,3 @@ export function assertNever(shouldBeNever: never, name?: string): never { const label = name == null ? 'value' : `'${name}'`; throw new Error(`Unexpected ${label}: ${shouldBeNever}`); } - -/** - * Assert that given variable is a `VariableID`. - * @param maybeVariableId - */ -export function assertIsVariableID( - maybeVariableId: string | null | undefined, - name: string -): asserts maybeVariableId is VariableID { - if (typeof maybeVariableId !== 'string') { - throw new Error(`'${name}' is not a valid VariableID`); - } -} diff --git a/src/util/dataUtils.ts b/src/util/dataUtils.ts new file mode 100644 index 00000000..1acb9f3f --- /dev/null +++ b/src/util/dataUtils.ts @@ -0,0 +1,15 @@ +/** + * Create a sort comparator function that compares a stringified property on + * 2 objects. + * @param propName Prop to compare + */ +export function sortByStringProp( + propName: TPropName +) { + return ( + a: TValue, + b: TValue + ): number => { + return String(a[propName]).localeCompare(String(b[propName])); + }; +} diff --git a/src/util/index.ts b/src/util/index.ts index fde7eca2..5bcdc833 100644 --- a/src/util/index.ts +++ b/src/util/index.ts @@ -1,4 +1,5 @@ export * from './assertUtil'; +export * from './dataUtils'; export * from './downloadUtils'; export * from './errorUtils'; export * from './ErrorTypes'; @@ -9,6 +10,7 @@ export * from './panelUtils'; export * from './polyfillUtils'; export * from './selectionUtils'; export * from './serverUtils'; +export * from './testUtils'; export * from './treeViewUtils'; export * from './Toaster'; export * from './uiUtils'; diff --git a/src/util/testUtils.ts b/src/util/testUtils.ts new file mode 100644 index 00000000..0ddb41e6 --- /dev/null +++ b/src/util/testUtils.ts @@ -0,0 +1,35 @@ +export const bitValues = [0, 1] as const; +export const boolValues = [true, false] as const; + +/** + * Generate a 2 dimensional array of all possible combinations of the given value + * lists. + * + * e.g. + * matrix([1, 2], ['a', 'b']) => [[1, 'a'], [1, 'b'], [2, 'a'], [2, 'b']] + * + * matrix([1, 2], ['a', 'b', 'c']) => [ + * [1, 'a'], [1, 'b'], [1, 'c'], + * [2, 'a'], [2, 'b'], [2, 'c'], + * ] + * + * @param args Value lists + * @returns 2D array of all possible combinations + */ +export function matrix< + TArgs extends (unknown[] | readonly unknown[])[], + TReturn extends { [P in keyof TArgs]: TArgs[P][number] }, +>(...args: TArgs): TReturn[] { + const [first, ...rest] = args; + + if (rest.length === 0) { + return first.map(value => [value] as TReturn); + } + + // recursively call matrix + const restMatrix = matrix(...rest); + + return first.flatMap(value => + restMatrix.map(values => [value, ...values] as TReturn) + ); +} diff --git a/src/util/treeViewUtils.spec.ts b/src/util/treeViewUtils.spec.ts index 58b5122e..23718f23 100644 --- a/src/util/treeViewUtils.spec.ts +++ b/src/util/treeViewUtils.spec.ts @@ -1,29 +1,84 @@ import { describe, it, expect, vi } from 'vitest'; +import { bitValues, boolValues, matrix } from './testUtils'; import { + getPanelConnectionTreeItem, + getPanelVariableTreeItem, getServerContextValue, getServerDescription, getServerGroupContextValue, getServerGroupTreeItem, getServerIconID, getServerTreeItem, + getVariableIconPath, groupServers, } from './treeViewUtils'; -import type { ServerState } from '../types'; +import type { + ConsoleType, + IDhService, + ServerState, + VariableDefintion, + VariableType, +} from '../types'; // See __mocks__/vscode.ts for the mock implementation vi.mock('vscode'); +const variableTypes: readonly VariableType[] = [ + 'deephaven.plot.express.DeephavenFigure', + 'deephaven.ui.Element', + 'Figure', + 'HierarchicalTable', + 'OtherWidget', + 'pandas.DataFrame', + 'PartitionedTable', + 'Table', + 'TableMap', + 'Treemap', + 'TreeTable', +] as const; + +describe('getPanelConnectionTreeItem', () => { + const getConsoleTypes: IDhService['getConsoleTypes'] = vi + .fn() + .mockResolvedValue(new Set(['python'])); + + const serverUrl = new URL('http://localhost:10000'); + + it.each(matrix(boolValues, boolValues))( + 'should return panel connection tree item: isConnected:%s, isInitialized:%s', + async (isConnected, isInitialized) => { + const connection = { + isConnected, + isInitialized, + serverUrl, + getConsoleTypes, + } as IDhService; + + const actual = await getPanelConnectionTreeItem(connection); + expect(actual).toMatchSnapshot(); + } + ); +}); + +describe('getPanelVariableTreeItem', () => { + const url = new URL('http://localhost:10000'); + + it.each(variableTypes)( + 'should return panel variable tree item: type:%s', + type => { + const variable = { + title: 'some title', + type, + } as VariableDefintion; + + const actual = getPanelVariableTreeItem([url, variable]); + expect(actual).toMatchSnapshot(); + } + ); +}); + describe('getServerContextValue', () => { - it.each([ - [true, true, true], - [true, true, false], - [true, false, true], - [true, false, false], - [false, true, true], - [false, true, false], - [false, false, true], - [false, false, false], - ])( + it.each(matrix(boolValues, boolValues, boolValues))( 'should return contextValue based on server state: isConnected=%s, isManaged=%s, isRunning=%s', (isConnected, isManaged, isRunning) => { const actual = getServerContextValue({ @@ -37,16 +92,9 @@ describe('getServerContextValue', () => { }); describe('getServerDescription', () => { - it.each([ - [0, true, 'some label'], - [1, true, 'some label'], - [0, false, 'some label'], - [1, false, 'some label'], - [0, true, undefined], - [1, true, undefined], - [0, false, undefined], - [1, false, undefined], - ])( + const labelValeus = ['some label', undefined] as const; + + it.each(matrix(bitValues, boolValues, labelValeus))( 'should return server description based on parameters: connectionCount=%s, isManaged=%s, label=%s', (connectionCount, isManaged, label) => { const actual = getServerDescription(connectionCount, isManaged, label); @@ -56,12 +104,9 @@ describe('getServerDescription', () => { }); describe('getServerGroupContextValue', () => { - it.each([ - ['Managed', true], - ['Running', true], - ['Managed', false], - ['Running', false], - ] as const)( + const groupValues = ['Managed', 'Running'] as const; + + it.each(matrix(groupValues, boolValues))( 'should return context value when servers can be managed: group=%s, canStartServer=%s', (group, canStartServer) => { const actual = getServerGroupContextValue(group, canStartServer); @@ -71,12 +116,9 @@ describe('getServerGroupContextValue', () => { }); describe('getServerGroupTreeItem', () => { - it.each([ - ['Managed', true], - ['Running', true], - ['Managed', false], - ['Running', false], - ] as const)( + const groupValues = ['Managed', 'Running'] as const; + + it.each(matrix(groupValues, boolValues))( 'should return server group tree item: group=%s, canStartServer=%s', (group, canStartServer) => { const actual = getServerGroupTreeItem(group, canStartServer); @@ -86,16 +128,7 @@ describe('getServerGroupTreeItem', () => { }); describe('getServerIconID', () => { - it.each([ - [true, true, true], - [true, true, false], - [true, false, true], - [true, false, false], - [false, true, true], - [false, true, false], - [false, false, true], - [false, false, false], - ])( + it.each(matrix(boolValues, boolValues, boolValues))( 'should return icon id based on server state: isConnected=%s, isManaged=%s, isRunning=%s', (isConnected, isManaged, isRunning) => { const actual = getServerIconID({ isConnected, isManaged, isRunning }); @@ -110,16 +143,7 @@ describe('getServerTreeItem', () => { url: new URL('http://localhost:10000'), }; - it.each([ - [true, true, true], - [true, true, false], - [true, false, true], - [true, false, false], - [false, true, true], - [false, true, false], - [false, false, true], - [false, false, false], - ])( + it.each(matrix(boolValues, boolValues, boolValues))( 'should return DHC server tree item: isConnected=%s, isManaged=%s, isRunning=%s', (isConnected, isManaged, isRunning) => { const actual = getServerTreeItem({ @@ -134,18 +158,19 @@ describe('getServerTreeItem', () => { ); }); +describe('getVariableIconPath', () => { + it('should return icon path for variableType', () => { + expect( + variableTypes.map(type => [type, getVariableIconPath(type)]) + ).toMatchSnapshot(); + }); +}); + describe('groupServers', () => { it('should group servers by state', () => { - const props = [ - [true, true], - [true, true], - [true, false], - [true, false], - [false, true], - [false, true], - [false, false], - [false, false], - ]; + // Note that each combination is duplicated so that multiple servers get + // created for each group. + const props = matrix(boolValues, [true, true, false, false]); const servers = props.map( ([isManaged, isRunning], i) => diff --git a/src/util/treeViewUtils.ts b/src/util/treeViewUtils.ts index 2d6ba90a..b8fb8c0e 100644 --- a/src/util/treeViewUtils.ts +++ b/src/util/treeViewUtils.ts @@ -1,11 +1,92 @@ import * as vscode from 'vscode'; -import type { ServerGroupState, ServerState } from '../types'; +import type { + IDhService, + ServerGroupState, + ServerState, + VariableDefintion, + VariableType, +} from '../types'; import { ICON_ID, + OPEN_VARIABLE_PANELS_CMD, SERVER_TREE_ITEM_CONTEXT, type ServerTreeItemContextValue, } from '../common'; +/** + * Get a tree item vscode.ThemeIcon for a variable type. + * @param variableType Variable type + * @returns Theme icon for the variable type + */ +export function getVariableIconPath( + variableType: VariableType +): vscode.ThemeIcon { + // Based on @deephaven/console `ObjectIcon` + switch (variableType) { + case 'Table': + case 'TableMap': + case 'TreeTable': + case 'HierarchicalTable': + case 'PartitionedTable': + return new vscode.ThemeIcon(ICON_ID.varTable); + + case 'deephaven.plot.express.DeephavenFigure': + case 'Figure': + return new vscode.ThemeIcon(ICON_ID.varFigure); + + case 'pandas.DataFrame': + return new vscode.ThemeIcon(ICON_ID.varPandas); + + case 'deephaven.ui.Element': + case 'OtherWidget': + case 'Treemap': + default: + return new vscode.ThemeIcon(ICON_ID.varElement); + } +} + +/** + * Get `TreeItem` for a panel connection. + * @param connection + */ +export async function getPanelConnectionTreeItem( + connection: IDhService +): Promise { + const [consoleType] = connection.isInitialized + ? await connection.getConsoleTypes() + : []; + + return { + label: new URL(connection.serverUrl.toString()).host, + description: consoleType, + collapsibleState: vscode.TreeItemCollapsibleState.Expanded, + iconPath: new vscode.ThemeIcon( + connection.isConnected ? ICON_ID.connected : ICON_ID.connecting + ), + }; +} + +/** + * Get `TreeItem` for a panel variable. + * @param variable + */ +export function getPanelVariableTreeItem([url, variable]: [ + URL, + VariableDefintion, +]): vscode.TreeItem { + const iconPath = getVariableIconPath(variable.type); + + return { + description: variable.title, + iconPath, + command: { + title: 'Open Panel', + command: OPEN_VARIABLE_PANELS_CMD, + arguments: [url, [variable]], + }, + }; +} + /** * Get `contextValue` for server tree items. * @param isConnected Whether the server is connected diff --git a/src/util/uiUtils.ts b/src/util/uiUtils.ts index 436bfcc5..f5026e7c 100644 --- a/src/util/uiUtils.ts +++ b/src/util/uiUtils.ts @@ -15,6 +15,7 @@ import type { ConnectionPickOption, ServerConnection, } from '../types'; +import { sortByStringProp } from './dataUtils'; export interface ConnectionOption { type: ConnectionType; @@ -79,8 +80,8 @@ export function createConnectionQuickPickOptions< } // Sort options by label - connectionOptions.sort(sortByLabel); - serverOptions.sort(sortByLabel); + connectionOptions.sort(sortByStringProp('label')); + serverOptions.sort(sortByStringProp('label')); return [ createSeparatorPickItem('Active Connections'), @@ -251,13 +252,6 @@ export async function getEditorForUri( return vscode.window.showTextDocument(uri, { preview: false, viewColumn }); } -/** - * Sort function for sorting by label. - */ -export function sortByLabel(a: T, b: T): number { - return a.label.localeCompare(b.label); -} - /** * Update given status bar item based on connection status * and optional `ConnectionOption`.