diff --git a/build/lib/i18n.resources.json b/build/lib/i18n.resources.json index 4fabe233b0f..94957dc91b8 100644 --- a/build/lib/i18n.resources.json +++ b/build/lib/i18n.resources.json @@ -210,6 +210,10 @@ "name": "vs/workbench/contrib/positronPlots", "project": "vscode-workbench" }, + { + "name": "vs/workbench/contrib/positronConnections", + "project": "vscode-workbench" + }, { "name": "vs/workbench/contrib/positronOutputWebview", "project": "vscode-workbench" @@ -526,6 +530,10 @@ "name": "vs/workbench/services/positronConsole", "project": "vscode-workbench" }, + { + "name": "vs/workbench/services/positronConnections", + "project": "vscode-workbench" + }, { "name": "vs/workbench/services/positronDataExplorer", "project": "vscode-workbench" diff --git a/extensions/positron-connections/package.json b/extensions/positron-connections/package.json index 9b6d7bb92db..aa48873d3f5 100644 --- a/extensions/positron-connections/package.json +++ b/extensions/positron-connections/package.json @@ -22,7 +22,8 @@ { "id": "positron-connections", "title": "%view.title%", - "icon": "media/database.svg" + "icon": "media/database.svg", + "when": "positron-connections.connectionsEnabled" } ] }, @@ -32,7 +33,8 @@ "id": "connections", "name": "%view.title%", "icon": "media/database.svg", - "contextualTitle": "%view.description%" + "contextualTitle": "%view.description%", + "when": "positron-connections.connectionsEnabled" } ] }, diff --git a/extensions/positron-connections/src/extension.ts b/extensions/positron-connections/src/extension.ts index 62d7f595919..74096e56e91 100644 --- a/extensions/positron-connections/src/extension.ts +++ b/extensions/positron-connections/src/extension.ts @@ -8,12 +8,41 @@ import * as positron from 'positron'; import { ConnectionItem, ConnectionItemsProvider, isActiveConnectionItem, DatabaseConnectionItem, DisconnectedConnectionItem } from './connection'; import { PositronConnectionsComm } from './comms/ConnectionsComms'; + +export function activate(context: vscode.ExtensionContext) { + + const config = vscode.workspace.getConfiguration('positron'); + const enabled = !config.get('connections', false); + vscode.commands.executeCommand('setContext', 'positron-connections.connectionsEnabled', enabled); + + vscode.workspace.onDidChangeConfiguration((e) => { + if (e.affectsConfiguration('positron.connections')) { + const config = vscode.workspace.getConfiguration('positron'); + const enabled = !config.get('connections', false); + if (enabled) { + activateImpl(context); + } else { + deactivate(context); + } + vscode.commands.executeCommand( + 'setContext', + 'positron-connections.connectionsEnabled', + enabled + ); + } + }); + + if (enabled) { + return activateImpl(context); + } +} + /** * Activates the extension. * * @param context An ExtensionContext that contains the extention context. */ -export function activate(context: vscode.ExtensionContext) { +export function activateImpl(context: vscode.ExtensionContext) { const viewId = 'connections'; const connectionProvider = new ConnectionItemsProvider(context); const connectionTreeView = vscode.window.createTreeView(viewId, { treeDataProvider: connectionProvider }); @@ -106,3 +135,9 @@ export function activate(context: vscode.ExtensionContext) { // to acccess the ConnectionItemsProvider instance return connectionProvider; } + +function deactivate(context: vscode.ExtensionContext) { + context.subscriptions.forEach((e) => { + e.dispose(); + }); +} diff --git a/extensions/positron-python/python_files/positron/positron_ipykernel/connections.py b/extensions/positron-python/python_files/positron/positron_ipykernel/connections.py index bb95865ce26..ed34f1ef9a7 100644 --- a/extensions/positron-python/python_files/positron/positron_ipykernel/connections.py +++ b/extensions/positron-python/python_files/positron/positron_ipykernel/connections.py @@ -20,6 +20,8 @@ ListObjectsRequest, ObjectSchema, PreviewObjectRequest, + MetadataSchema, + GetMetadataRequest, ) from .positron_comm import CommMessage, JsonRpcErrorCode, PositronComm from .third_party import pd_, sqlalchemy_ @@ -137,6 +139,25 @@ def preview_object(self, path: List[ObjectSchema]) -> Any: """ raise NotImplementedError() + def get_metadata(self) -> MetadataSchema: + """ + Returns metadata about the connection. + + The metadata object must contain the following properties: + - name: The name of the connection. + - language_id: The language ID for the connection. Essentially just R or python. + - host: The host of the connection. + - type: The type of the connection. + - code: The code used to recreate the connection. + """ + return MetadataSchema( + name=self.display_name, + language_id="python", + host=self.host, + type=self.type, + code=self.code, + ) + class ConnectionsService: """ @@ -439,6 +460,8 @@ def _handle_msg( elif isinstance(request, PreviewObjectRequest): self.handle_preview_object_request(connection, request) result = None + elif isinstance(request, GetMetadataRequest): + result = self.handle_get_metadata_request(connection, request) # type: ignore else: raise NotImplementedError(f"Unhandled request: {request}") @@ -450,7 +473,10 @@ def handle_contains_data_request(self, conn: Connection, request: ContainsDataRe return False object_types: Dict[str, Any] = conn.list_object_types() - contains = object_types[path[-1].kind].get("contains", "not_data") + try: + contains = object_types[path[-1].kind].get("contains", "not_data") + except KeyError: + contains = "not_data" return isinstance(contains, str) and contains == "data" def handle_get_icon_request(self, conn: Connection, request: GetIconRequest) -> str: @@ -461,7 +487,10 @@ def handle_get_icon_request(self, conn: Connection, request: GetIconRequest) -> icon = getattr(conn, "icon", None) else: object_types: Dict[str, Any] = conn.list_object_types() - icon = object_types[path[-1].kind].get("icon", "") + try: + icon = object_types[path[-1].kind].get("icon", None) + except KeyError: + pass if icon is None: return "" @@ -484,6 +513,12 @@ def handle_preview_object_request( title = request.params.path[-1].name self._kernel.data_explorer_service.register_table(res, title) + def handle_get_metadata_request( + self, conn: Connection, request: GetMetadataRequest + ) -> MetadataSchema: + res = conn.get_metadata() + return res + class SQLite3Connection(Connection): """ diff --git a/extensions/positron-python/python_files/positron/positron_ipykernel/connections_comm.py b/extensions/positron-python/python_files/positron/positron_ipykernel/connections_comm.py index b4b87a2e688..a22017e7558 100644 --- a/extensions/positron-python/python_files/positron/positron_ipykernel/connections_comm.py +++ b/extensions/positron-python/python_files/positron/positron_ipykernel/connections_comm.py @@ -46,6 +46,35 @@ class FieldSchema(BaseModel): ) +class MetadataSchema(BaseModel): + """ + MetadataSchema in Schemas + """ + + name: StrictStr = Field( + description="Connection name", + ) + + language_id: StrictStr = Field( + description="Language ID for the connections. Essentially just R or python", + ) + + host: Optional[StrictStr] = Field( + default=None, + description="Connection host", + ) + + type: Optional[StrictStr] = Field( + default=None, + description="Connection type", + ) + + code: Optional[StrictStr] = Field( + default=None, + description="Code used to re-create the connection", + ) + + @enum.unique class ConnectionsBackendRequest(str, enum.Enum): """ @@ -67,6 +96,9 @@ class ConnectionsBackendRequest(str, enum.Enum): # Preview object data PreviewObject = "preview_object" + # Gets metadata from the connections + GetMetadata = "get_metadata" + class ListObjectsParams(BaseModel): """ @@ -215,6 +247,35 @@ class PreviewObjectRequest(BaseModel): ) +class GetMetadataParams(BaseModel): + """ + A connection has tied metadata such as an icon, the host, etc. + """ + + comm_id: StrictStr = Field( + description="The comm_id of the client we want to retrieve metdata for.", + ) + + +class GetMetadataRequest(BaseModel): + """ + A connection has tied metadata such as an icon, the host, etc. + """ + + params: GetMetadataParams = Field( + description="Parameters to the GetMetadata method", + ) + + method: Literal[ConnectionsBackendRequest.GetMetadata] = Field( + description="The JSON-RPC method name (get_metadata)", + ) + + jsonrpc: str = Field( + default="2.0", + description="The JSON-RPC version specifier", + ) + + class ConnectionsBackendMessageContent(BaseModel): comm_id: str data: Union[ @@ -223,6 +284,7 @@ class ConnectionsBackendMessageContent(BaseModel): ContainsDataRequest, GetIconRequest, PreviewObjectRequest, + GetMetadataRequest, ] = Field(..., discriminator="method") @@ -243,6 +305,8 @@ class ConnectionsFrontendEvent(str, enum.Enum): FieldSchema.update_forward_refs() +MetadataSchema.update_forward_refs() + ListObjectsParams.update_forward_refs() ListObjectsRequest.update_forward_refs() @@ -262,3 +326,7 @@ class ConnectionsFrontendEvent(str, enum.Enum): PreviewObjectParams.update_forward_refs() PreviewObjectRequest.update_forward_refs() + +GetMetadataParams.update_forward_refs() + +GetMetadataRequest.update_forward_refs() diff --git a/extensions/positron-r/package.json b/extensions/positron-r/package.json index 494c67f5757..ab661f0b01c 100644 --- a/extensions/positron-r/package.json +++ b/extensions/positron-r/package.json @@ -635,7 +635,7 @@ }, "positron": { "binaryDependencies": { - "ark": "0.1.144" + "ark": "0.1.145" }, "minimumRVersion": "4.2.0", "minimumRenvVersion": "1.0.9" diff --git a/positron/comms/connections-backend-openrpc.json b/positron/comms/connections-backend-openrpc.json index 666d25ab68d..2c16ab0545b 100644 --- a/positron/comms/connections-backend-openrpc.json +++ b/positron/comms/connections-backend-openrpc.json @@ -134,7 +134,28 @@ "type": "null" } } + }, + { + "name": "get_metadata", + "summary": "Gets metadata from the connections", + "description": "A connection has tied metadata such as an icon, the host, etc.", + "params": [ + { + "name": "comm_id", + "description": "The comm_id of the client we want to retrieve metdata for.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "result": { + "schema": { + "$ref": "#/components/schemas/metadata_schema" + } + } } + ], "components": { "contentDescriptors": {}, @@ -172,6 +193,35 @@ "description": "The field data type" } } + }, + "metadata_schema": { + "type": "object", + "required": [ + "name", + "language_id" + ], + "properties": { + "name": { + "type": "string", + "description": "Connection name" + }, + "language_id": { + "type": "string", + "description": "Language ID for the connections. Essentially just R or python" + }, + "host": { + "type": "string", + "description": "Connection host" + }, + "type": { + "type": "string", + "description": "Connection type" + }, + "code": { + "type": "string", + "description": "Code used to re-create the connection" + } + } } } } diff --git a/src/vs/base/browser/ui/codicons/codicon/codicon.ttf b/src/vs/base/browser/ui/codicons/codicon/codicon.ttf index 72ec439db83..44fe9c91107 100644 Binary files a/src/vs/base/browser/ui/codicons/codicon/codicon.ttf and b/src/vs/base/browser/ui/codicons/codicon/codicon.ttf differ diff --git a/src/vs/base/common/codiconsLibrary.ts b/src/vs/base/common/codiconsLibrary.ts index 736cc16e88d..2bf2b4c5748 100644 --- a/src/vs/base/common/codiconsLibrary.ts +++ b/src/vs/base/common/codiconsLibrary.ts @@ -663,4 +663,11 @@ export const codiconsLibrary = { positronSelectRow: register('positron-select-row', 0xf281), positronDataTypeObject: register('positron-data-type-object', 0xf282), positronSizeToFit: register('positron-size-to-fit', 0xf283), + positronDatabaseConnection: register('positron-database-connection', 0xf284), + positronDisconnectConnection: register('positron-disconnect-connection', 0xf285), + positronNewConnection: register('positron-new-connection', 0xf286), + positronSchemaConnection: register('positron-schema-connection', 0xf287), + positronTableConnection: register('positron-table-connection', 0xf288), + positronCatalogConnection: register('positron-catalog-connection', 0xf289), + positronViewConnection: register('positron-view-connection', 0xf28a), } as const; diff --git a/src/vs/workbench/api/browser/positron/mainThreadLanguageRuntime.ts b/src/vs/workbench/api/browser/positron/mainThreadLanguageRuntime.ts index 9ae357ca588..c9cd240df2b 100644 --- a/src/vs/workbench/api/browser/positron/mainThreadLanguageRuntime.ts +++ b/src/vs/workbench/api/browser/positron/mainThreadLanguageRuntime.ts @@ -40,6 +40,7 @@ import { IRuntimeStartupService, RuntimeStartupPhase } from 'vs/workbench/servic import { SerializableObjectWithBuffers } from 'vs/workbench/services/extensions/common/proxyIdentifier'; import { isWebviewReplayMessage } from 'vs/workbench/contrib/positronWebviewPreloads/browser/utils'; import { IPositronWebviewPreloadService } from 'vs/workbench/services/positronWebviewPreloads/common/positronWebviewPreloadService'; +import { IPositronConnectionsService } from 'vs/workbench/services/positronConnections/browser/interfaces/positronConnectionsService'; /** * Represents a language runtime event (for example a message or state change) @@ -1135,6 +1136,7 @@ export class MainThreadLanguageRuntime @IPositronPlotsService private readonly _positronPlotService: IPositronPlotsService, @IPositronIPyWidgetsService private readonly _positronIPyWidgetsService: IPositronIPyWidgetsService, @IPositronWebviewPreloadService private readonly _positronWebviewPreloadService: IPositronWebviewPreloadService, + @IPositronConnectionsService private readonly _positronConnectionsService: IPositronConnectionsService, @INotificationService private readonly _notificationService: INotificationService, @ILogService private readonly _logService: ILogService, @ICommandService private readonly _commandService: ICommandService, @@ -1151,6 +1153,7 @@ export class MainThreadLanguageRuntime this._positronPlotService.initialize(); this._positronIPyWidgetsService.initialize(); this._positronWebviewPreloadService.initialize(); + this._positronConnectionsService.initialize(); this._proxy = extHostContext.getProxy(ExtHostPositronContext.ExtHostLanguageRuntime); this._id = MainThreadLanguageRuntime.MAX_ID++; diff --git a/src/vs/workbench/contrib/positronConnections/browser/components/actionBar.css b/src/vs/workbench/contrib/positronConnections/browser/components/actionBar.css new file mode 100644 index 00000000000..654e24ab088 --- /dev/null +++ b/src/vs/workbench/contrib/positronConnections/browser/components/actionBar.css @@ -0,0 +1,15 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2024 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + +.action-bar-disabled { + cursor: not-allowed; +} + +.action-bar-disabled > .action-bar-search { + color: var(--vscode-positronSideActionBar-disabledForeground); + border: 1px solid #d0d0d0; + cursor: not-allowed; + pointer-events: none; +} diff --git a/src/vs/workbench/contrib/positronConnections/browser/components/actionBar.tsx b/src/vs/workbench/contrib/positronConnections/browser/components/actionBar.tsx new file mode 100644 index 00000000000..8a17f3b9181 --- /dev/null +++ b/src/vs/workbench/contrib/positronConnections/browser/components/actionBar.tsx @@ -0,0 +1,113 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2024 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as React from 'react'; + +import { ActionBarButton } from 'vs/platform/positronActionBar/browser/components/actionBarButton'; +import { ActionBarRegion } from 'vs/platform/positronActionBar/browser/components/actionBarRegion'; +import { ActionBarSeparator } from 'vs/platform/positronActionBar/browser/components/actionBarSeparator'; +import { PositronActionBar } from 'vs/platform/positronActionBar/browser/positronActionBar'; +import { PositronActionBarContextProvider } from 'vs/platform/positronActionBar/browser/positronActionBarContext'; + +import { ICommandService } from 'vs/platform/commands/common/commands'; +import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; +import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; +import { IContextMenuService } from 'vs/platform/contextview/browser/contextView'; +import { IHoverService } from 'vs/platform/hover/browser/hover'; +import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding'; +import { ActionBarSearch } from 'vs/platform/positronActionBar/browser/components/actionBarSearch'; + +import 'vs/css!./actionBar'; +import { IPositronConnectionEntry } from 'vs/workbench/services/positronConnections/browser/positronConnectionsCache'; + +const ACTION_BAR_PADDING_LEFT = 8; +const ACTION_BAR_PADDING_RIGHT = 8; +export const ACTION_BAR_HEIGHT = 32; + +interface ActionBarProps { + readonly commandService: ICommandService; + readonly configurationService: IConfigurationService; + readonly contextKeyService: IContextKeyService; + readonly contextMenuService: IContextMenuService; + readonly hoverService: IHoverService; + readonly keybindingService: IKeybindingService; +} + +interface ConnectionActionBarProps extends ActionBarProps { + selectedEntry: IPositronConnectionEntry | undefined; + clearAllHandler: () => void; +} + +export const ActionBar = (props: React.PropsWithChildren) => { + + // We only enable the disconnect button if: + // 1. there's some connection selected + // 2. it's the root of a connection (level == 0). + // 3. the connection is active. + const disconnectDisabled = props.selectedEntry === undefined || + props.selectedEntry.level !== 0 || + !props.selectedEntry.active; + + // We only enable the connect button if: + // 1. there's some connection selected + // 2. it's the root of a connection (level == 0). + // 3. the connection is not active. + // 4. it implements a 'connect' method. + const connectDisabled = props.selectedEntry === undefined || + props.selectedEntry.level !== 0 || + props.selectedEntry.active || + props.selectedEntry.connect === undefined; + + return ( +
+ + + + 'Connect'} + disabled={connectDisabled} + onPressed={() => props.selectedEntry?.connect?.()} + /> + + props.selectedEntry?.disconnect?.()} + /> + + props.selectedEntry?.refresh?.()} + disabled={props.selectedEntry === undefined || props.selectedEntry.refresh === undefined || !props.selectedEntry.active} + /> + + props.clearAllHandler()} + /> + + +
+ +
+
+
+
+
+ ); +}; diff --git a/src/vs/workbench/contrib/positronConnections/browser/positronConnections.contribution.ts b/src/vs/workbench/contrib/positronConnections/browser/positronConnections.contribution.ts new file mode 100644 index 00000000000..59789459824 --- /dev/null +++ b/src/vs/workbench/contrib/positronConnections/browser/positronConnections.contribution.ts @@ -0,0 +1,62 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2024 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Codicon } from 'vs/base/common/codicons'; +import { registerIcon } from 'vs/platform/theme/common/iconRegistry'; +import * as nls from 'vs/nls'; +import { Registry } from 'vs/platform/registry/common/platform'; +import { Extensions, IViewContainersRegistry, IViewsRegistry, ViewContainerLocation } from 'vs/workbench/common/views'; +import { POSITRON_CONNECTIONS_VIEW_ID } from 'vs/workbench/services/positronConnections/browser/interfaces/positronConnectionsService'; +import { SyncDescriptor } from 'vs/platform/instantiation/common/descriptors'; +import { ViewPaneContainer } from 'vs/workbench/browser/parts/views/viewPaneContainer'; +import { PositronConnectionsView } from 'vs/workbench/contrib/positronConnections/browser/positronConnectionsView'; +import { POSITRON_CONNECTIONS_VIEW_ENABLED } from 'vs/workbench/services/positronConnections/browser/positronConnectionsFeatureFlag'; + +const positronConnectionsViewIcon = registerIcon( + 'positron-connections-view-icon', + Codicon.database, + nls.localize('positronConnectionsViewIcon', 'View icon of the Positron Connections view.') +); + +const VIEW_CONTAINER = Registry.as(Extensions.ViewContainersRegistry).registerViewContainer( + { + id: POSITRON_CONNECTIONS_VIEW_ID, + title: { + value: nls.localize('positron.connections', "Connections"), + original: 'Connections' + }, + icon: positronConnectionsViewIcon, + order: 2, + ctorDescriptor: new SyncDescriptor(ViewPaneContainer, [POSITRON_CONNECTIONS_VIEW_ID, { mergeViewWithContainerWhenSingleView: true }]), + storageId: POSITRON_CONNECTIONS_VIEW_ID, + hideIfEmpty: true, + }, + ViewContainerLocation.AuxiliaryBar, + { + doNotRegisterOpenCommand: false, + isDefault: false + } +); + +Registry.as(Extensions.ViewsRegistry).registerViews([{ + id: POSITRON_CONNECTIONS_VIEW_ID, + name: { + value: nls.localize('positron.help', "Connections core"), + original: 'Connections core' + }, + containerIcon: positronConnectionsViewIcon, + canMoveView: true, + canToggleVisibility: false, + ctorDescriptor: new SyncDescriptor(PositronConnectionsView), + positronAlwaysOpenView: true, + when: POSITRON_CONNECTIONS_VIEW_ENABLED, + // openCommandActionDescriptor: { + // id: 'workbench.action.positron.openHelp', + // keybindings: { + // primary: KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyH, + // }, + // order: 1, + // } +}], VIEW_CONTAINER); diff --git a/src/vs/workbench/contrib/positronConnections/browser/positronConnections.css b/src/vs/workbench/contrib/positronConnections/browser/positronConnections.css new file mode 100644 index 00000000000..f0856672ec9 --- /dev/null +++ b/src/vs/workbench/contrib/positronConnections/browser/positronConnections.css @@ -0,0 +1,102 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2022 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + +.positron-connections { + height: 100%; + display: flex; + flex-direction: column; + color: var(--vscode-positronVariables-foreground); + background: var(--vscode-positronVariables-background); + + --positronActionBar-border: var(--vscode-positronSideActionBar-border); + --positronActionBar-separator: var(--vscode-positronSideActionBar-separator); + --positronActionBar-background: var(--vscode-positronSideActionBar-background); + --positronActionBar-foreground: var(--vscode-positronSideActionBar-foreground); + --positronActionBar-hoverBackground: var(--vscode-positronSideActionBar-hoverBackground); + --positronActionBar-disabledForeground: var(--vscode-positronSideActionBar-disabledForeground); + --positronActionBar-textInputBackground: var(--vscode-positronSideActionBar-textInputBackground); + --positronActionBar-textInputBorder: var(--vscode-positronSideActionBar-textInputBorder); + --positronActionBar-textInputSelectionForeground: var(--vscode-positronSideActionBar-textInputSelectionForeground); + --positronActionBar-textInputSelectionBackground: var(--vscode-positronSideActionBar-textInputSelectionBackground); +} + +.positron-connections .positron-connections-action-bars { + height: min-content; +} + +.connections-items-container { + height: 100%; + position: relative; + left: 0.5px; +} + +.connections-item { + display: flex; + cursor: pointer; + align-items: center; + height: 26px; +} + +.connections-details { + flex-grow: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: monospace; + display: flex; + align-items: center; +} + +.connections-dtype { + color: var(--vscode-positronSideActionBar-disabledForeground); + font-weight: 300; +} + +.connections-dtype::before { + content: ":"; + color: var(--vscode-positronVariables-foreground); + margin-left: 3px; + margin-right: 3px; +} + +.connections-language::before { + content: ":"; + color: var(--vscode-positronVariables-foreground); + margin-left: 0px; + margin-right: 6px; +} + +.connections-icon { + flex-shrink: 0; + flex-grow: 0; + padding-right: 5px; + padding-left: 5px; +} + +.connections-item .expand-collapse-area { + width: 26px; + display: flex; + align-items: center; + justify-content: center; +} + +.connections-item.selected { + color: var(--vscode-positronVariables-activeSelectionForeground); + background: var(--vscode-positronVariables-activeSelectionBackground); + outline: 1px; + outline-offset: -1px; +} + +.connection-disabled { + color: var(--vscode-positronSideActionBar-disabledForeground); +} + +.connections-error { + margin-left: 5px; +} + +.connections-error.codicon { + color: var(--vscode-debugConsole-errorForeground); +} diff --git a/src/vs/workbench/contrib/positronConnections/browser/positronConnections.tsx b/src/vs/workbench/contrib/positronConnections/browser/positronConnections.tsx new file mode 100644 index 00000000000..7d3a5242ee4 --- /dev/null +++ b/src/vs/workbench/contrib/positronConnections/browser/positronConnections.tsx @@ -0,0 +1,268 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2024 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + +import React, { useEffect, useRef, useState, MouseEvent } from 'react'; + +import { ICommandService } from 'vs/platform/commands/common/commands'; +import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; +import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; +import { IContextMenuService } from 'vs/platform/contextview/browser/contextView'; +import { IHoverService } from 'vs/platform/hover/browser/hover'; +import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding'; +import { ActionBar, ACTION_BAR_HEIGHT as kActionBarHeight } from 'vs/workbench/contrib/positronConnections/browser/components/actionBar'; + +import { FixedSizeList as List } from 'react-window'; + +import 'vs/css!./positronConnections'; +import { IPositronConnectionsService } from 'vs/workbench/services/positronConnections/browser/interfaces/positronConnectionsService'; +import { IReactComponentContainer } from 'vs/base/browser/positronReactRenderer'; +import { DisposableStore } from 'vs/base/common/lifecycle'; +import { useStateRef } from 'vs/base/browser/ui/react/useStateRef'; +import * as DOM from 'vs/base/browser/dom'; +import { IPositronConnectionEntry } from 'vs/workbench/services/positronConnections/browser/positronConnectionsCache'; + +export interface PositronConnectionsProps { + readonly commandService: ICommandService; + readonly configurationService: IConfigurationService; + readonly contextKeyService: IContextKeyService; + readonly contextMenuService: IContextMenuService; + readonly hoverService: IHoverService; + readonly keybindingService: IKeybindingService; + readonly connectionsService: IPositronConnectionsService; + readonly reactComponentContainer: IReactComponentContainer; +} + +export const PositronConnections = (props: React.PropsWithChildren) => { + + // This allows us to introspect the size of the component. Which then allows + // us to efficiently only render items that are in view. + const [_, setWidth] = React.useState(props.reactComponentContainer.width); + const [height, setHeight] = React.useState(props.reactComponentContainer.height); + + useEffect(() => { + const disposableStore = new DisposableStore(); + disposableStore.add(props.reactComponentContainer.onSizeChanged(size => { + setWidth(size.width); + setHeight(size.height); + })); + return () => disposableStore.dispose(); + }, [props.reactComponentContainer]); + + // We're required to save the scroll state because browsers will automatically + // scrollTop when an object becomes visible again. + const [, setScrollState, scrollStateRef] = useStateRef(undefined); + const innerRef = useRef(undefined!); + useEffect(() => { + const disposableStore = new DisposableStore(); + disposableStore.add(props.reactComponentContainer.onSaveScrollPosition(() => { + if (innerRef.current) { + setScrollState(DOM.saveParentsScrollTop(innerRef.current)); + } + })); + disposableStore.add(props.reactComponentContainer.onRestoreScrollPosition(() => { + if (scrollStateRef.current) { + if (innerRef.current) { + DOM.restoreParentsScrollTop(innerRef.current, scrollStateRef.current); + } + setScrollState(undefined); + } + })); + return () => disposableStore.dispose(); + }, [props.reactComponentContainer, scrollStateRef, setScrollState]); + + const [items, setItems] = useState(props.connectionsService.getConnectionEntries); + useEffect(() => { + const disposableStore = new DisposableStore(); + disposableStore.add(props.connectionsService.onDidChangeEntries((entries) => { + setItems(entries); + })); + // First entries refresh - on component mount. + props.connectionsService.refreshConnectionEntries(); + return () => disposableStore.dispose(); + }, [props.connectionsService]); + + const [selectedId, setSelectedId] = useState(); + + const ItemEntry = (props: ItemEntryProps) => { + const itemProps = items[props.index]; + + return ( + setSelectedId(itemProps.id)} + style={props.style}> + + ); + }; + + return ( +
+ item.id === selectedId)} + clearAllHandler={() => props.connectionsService.clearAllConnections()} + > + +
+ items[index].id} + innerRef={innerRef} + > + {ItemEntry} + +
+
+ ); +}; + +interface ItemEntryProps { + index: number; + style: any; +} + +interface PositronConnectionsItemProps { + item: IPositronConnectionEntry; + style?: any; + selected: boolean; + + /** + * What happens when a row is selected? + */ + onSelectedHandler: () => void; +} + +const PositronConnectionsItem = (props: React.PropsWithChildren) => { + + // If the connection is not expandable, we add some more padding. + const padding = props.item.level * 10 + (props.item.expanded === undefined ? 26 : 0); + const handleExpand = () => { + if (props.item.onToggleExpandEmitter) { + props.item.onToggleExpandEmitter.fire(); + } + }; + + const icon = (() => { + + if (props.item.icon) { + return props.item.icon; + } + + if (props.item.kind) { + // TODO: we'll probably want backends to implement the casting to a set of known + // types or provide their own icon. + switch (props.item.kind) { + case 'table': + return 'positron-table-connection'; + case 'view': + return 'positron-view-connection'; + case 'database': + return 'positron-database-connection'; + case 'schema': + return 'positron-schema-connection'; + case 'catalog': + return 'positron-catalog-connection'; + case 'field': + switch (props.item.dtype) { + case 'character': + return 'positron-data-type-string'; + case 'integer': + case 'numeric': + return 'positron-data-type-number'; + case 'boolean': + case 'bool': + return 'positron-data-type-boolean'; + default: + return 'positron-data-type-unknown'; + } + } + } + // If kind is not known, then no icon is dplsayed by default. + return ''; + })(); + + const rowMouseDownHandler = (e: MouseEvent) => { + // Consume the event. + e.preventDefault(); + e.stopPropagation(); + + // Handle the event. + switch (e.button) { + // Main button. + case 0: + // TODO: handle ctrl+ click, etc. + props.onSelectedHandler(); + break; + + // Secondary button. + case 2: + // TODO: more options here + props.onSelectedHandler(); + break; + } + }; + + return ( +
+
+ { + props.item.expanded === undefined ? + <> : +
+
+
+
+ } +
+ {props.item.name} + { + props.item.language_id ? + {languageIdToName(props.item.language_id)} : + <> + } + { + props.item.dtype ? + {props.item.dtype} : + <> + } + { + props.item.error ? + : + <> + } +
+
props.item.preview?.()} + > +
+
+ ); +}; + +function languageIdToName(id: string) { + switch (id) { + case 'python': + return 'Python'; + case 'r': + return 'R'; + default: + return id; + } +} diff --git a/src/vs/workbench/contrib/positronConnections/browser/positronConnectionsView.tsx b/src/vs/workbench/contrib/positronConnections/browser/positronConnectionsView.tsx new file mode 100644 index 00000000000..d2a9e2eba57 --- /dev/null +++ b/src/vs/workbench/contrib/positronConnections/browser/positronConnectionsView.tsx @@ -0,0 +1,154 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2024 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + +import { + IReactComponentContainer, + ISize, + PositronReactRenderer, +} from 'vs/base/browser/positronReactRenderer'; +import { Emitter, Event } from 'vs/base/common/event'; +import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; +import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; +import { IContextMenuService } from 'vs/platform/contextview/browser/contextView'; +import { IHoverService } from 'vs/platform/hover/browser/hover'; +import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; +import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding'; +import { IOpenerService } from 'vs/platform/opener/common/opener'; +import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry'; +import { IThemeService } from 'vs/platform/theme/common/themeService'; +import { IViewPaneOptions } from 'vs/workbench/browser/parts/views/viewPane'; +import { PositronViewPane } from 'vs/workbench/browser/positronViewPane/positronViewPane'; +import { IViewDescriptorService } from 'vs/workbench/common/views'; +import * as DOM from 'vs/base/browser/dom'; +import { PositronConnections } from 'vs/workbench/contrib/positronConnections/browser/positronConnections'; +import * as React from 'react'; +import { ICommandService } from 'vs/platform/commands/common/commands'; +import { IPositronConnectionsService } from 'vs/workbench/services/positronConnections/browser/interfaces/positronConnectionsService'; + +export class PositronConnectionsView + extends PositronViewPane + implements IReactComponentContainer { + + private onSizeChangedEmitter = this._register(new Emitter()); + private onVisibilityChangedEmitter = this._register(new Emitter()); + private onSaveScrollPositionEmitter = this._register(new Emitter()); + private onRestoreScrollPositionEmitter = this._register(new Emitter()); + private onFocusedEmitter = this._register(new Emitter()); + + private positronConnectionsContainer!: HTMLElement; + private positronReactRenderer?: PositronReactRenderer; + + onFocused: Event = this.onFocusedEmitter.event; + onSizeChanged: Event = this.onSizeChangedEmitter.event; + onVisibilityChanged: Event = this.onVisibilityChangedEmitter.event; + onSaveScrollPosition: Event = this.onSaveScrollPositionEmitter.event; + onRestoreScrollPosition: Event = + this.onRestoreScrollPositionEmitter.event; + + private _width = 0; + private _height = 0; + + get height() { + return this._height; + } + + get width() { + return this._width; + } + + get containerVisible() { + return false; + } + + takeFocus() { + this.focus(); + } + + constructor( + options: IViewPaneOptions, + @IConfigurationService configurationService: IConfigurationService, + @IContextKeyService contextKeyService: IContextKeyService, + @IContextMenuService contextMenuService: IContextMenuService, + @IHoverService hoverService: IHoverService, + @IInstantiationService instantiationService: IInstantiationService, + @IKeybindingService keybindingService: IKeybindingService, + @IOpenerService openerService: IOpenerService, + @ITelemetryService telemetryService: ITelemetryService, + @IThemeService themeService: IThemeService, + @IViewDescriptorService viewDescriptorService: IViewDescriptorService, + @ICommandService private readonly commandService: ICommandService, + @IPositronConnectionsService private readonly connectionsService: IPositronConnectionsService + ) { + super( + options, + keybindingService, + contextMenuService, + configurationService, + contextKeyService, + viewDescriptorService, + instantiationService, + openerService, + themeService, + telemetryService, + hoverService + ); + + this._register(this.onDidChangeBodyVisibility(visible => { + // The browser will automatically set scrollTop to 0 on child components that have been + // hidden and made visible. (This is called "desperate" elsewhere in Visual Studio Code. + // Search for that word and you'll see other examples of hacks that have been added to + // to fix this problem.) IReactComponentContainers can counteract this behavior by + // firing onSaveScrollPosition and onRestoreScrollPosition events to have their child + // components save and restore their scroll positions. + if (!visible) { + this.onSaveScrollPositionEmitter.fire(); + } else { + this.onRestoreScrollPositionEmitter.fire(); + } + this.onVisibilityChangedEmitter.fire(visible); + })); + } + + protected override renderBody(container: HTMLElement): void { + // Call the base class's method. + super.renderBody(container); + + // Create and append the Positron variables container. + this.positronConnectionsContainer = DOM.$('.positron-connections-container'); + container.appendChild(this.positronConnectionsContainer); + + + // Create the PositronReactRenderer for the PositronVariables component and render it. + this.positronReactRenderer = new PositronReactRenderer(this.positronConnectionsContainer); + this._register(this.positronReactRenderer); + this.positronReactRenderer.render( + + ); + } + + protected override layoutBody(height: number, width: number): void { + // Call the base class's method. + super.layoutBody(height, width); + + // Set the width and height. + this._width = width; + this._height = height; + + // Raise the onSizeChanged event. + this.onSizeChangedEmitter.fire({ + width, + height + }); + } +} diff --git a/src/vs/workbench/services/languageRuntime/common/languageRuntimeConnectionsClient.ts b/src/vs/workbench/services/languageRuntime/common/languageRuntimeConnectionsClient.ts new file mode 100644 index 00000000000..64192ba839c --- /dev/null +++ b/src/vs/workbench/services/languageRuntime/common/languageRuntimeConnectionsClient.ts @@ -0,0 +1,55 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2024 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IRuntimeClientInstance } from 'vs/workbench/services/languageRuntime/common/languageRuntimeClientInstance'; +import { ObjectSchema, PositronConnectionsComm } from 'vs/workbench/services/languageRuntime/common/positronConnectionsComm'; +import { Disposable } from 'vs/base/common/lifecycle'; + +export class ConnectionsClientInstance extends Disposable { + private readonly _positronConnectionsComm: PositronConnectionsComm; + + constructor(client: IRuntimeClientInstance) { + super(); + + this._positronConnectionsComm = new PositronConnectionsComm(client); + this._register(this._positronConnectionsComm); + } + + getClientId() { + return this._positronConnectionsComm.clientId; + } + + async listObjects(path: ObjectSchema[]) { + return await this._positronConnectionsComm.listObjects(path); + } + + async listFields(path: ObjectSchema[]) { + return await this._positronConnectionsComm.listFields(path); + } + + async containsData(path: ObjectSchema[]) { + return await this._positronConnectionsComm.containsData(path); + } + + async getIcon(path: ObjectSchema[]) { + return await this._positronConnectionsComm.getIcon(path); + } + + async previewObject(path: ObjectSchema[]) { + await this._positronConnectionsComm.previewObject(path); + } + + async getMetadata() { + return await this._positronConnectionsComm.getMetadata(this._positronConnectionsComm.clientId); + } + + get onDidClose() { + return this._positronConnectionsComm.onDidClose; + } + + get onDidFocus() { + return this._positronConnectionsComm.onDidFocus; + } +} diff --git a/src/vs/workbench/services/languageRuntime/common/positronConnectionsComm.ts b/src/vs/workbench/services/languageRuntime/common/positronConnectionsComm.ts index 1743987f6bd..330ed5fe6b3 100644 --- a/src/vs/workbench/services/languageRuntime/common/positronConnectionsComm.ts +++ b/src/vs/workbench/services/languageRuntime/common/positronConnectionsComm.ts @@ -43,6 +43,37 @@ export interface FieldSchema { } +/** + * MetadataSchema in Schemas + */ +export interface MetadataSchema { + /** + * Connection name + */ + name: string; + + /** + * Language ID for the connections. Essentially just R or python + */ + language_id: string; + + /** + * Connection host + */ + host?: string; + + /** + * Connection type + */ + type?: string; + + /** + * Code used to re-create the connection + */ + code?: string; + +} + /** * Event: Request to focus the Connections pane */ @@ -65,7 +96,8 @@ export enum ConnectionsBackendRequest { ListFields = 'list_fields', ContainsData = 'contains_data', GetIcon = 'get_icon', - PreviewObject = 'preview_object' + PreviewObject = 'preview_object', + GetMetadata = 'get_metadata' } export class PositronConnectionsComm extends PositronBaseComm { @@ -145,6 +177,20 @@ export class PositronConnectionsComm extends PositronBaseComm { return super.performRpc('preview_object', ['path'], [path]); } + /** + * Gets metadata from the connections + * + * A connection has tied metadata such as an icon, the host, etc. + * + * @param commId The comm_id of the client we want to retrieve metdata + * for. + * + * @returns undefined + */ + getMetadata(commId: string): Promise { + return super.performRpc('get_metadata', ['comm_id'], [commId]); + } + /** * Request to focus the Connections pane diff --git a/src/vs/workbench/services/positronConnections/browser/interfaces/positronConnectionsInstance.ts b/src/vs/workbench/services/positronConnections/browser/interfaces/positronConnectionsInstance.ts new file mode 100644 index 00000000000..b762dbfc0bd --- /dev/null +++ b/src/vs/workbench/services/positronConnections/browser/interfaces/positronConnectionsInstance.ts @@ -0,0 +1,67 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2024 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Emitter } from 'vs/base/common/event'; + +export interface ConnectionMetadata { + name: string; + language_id: string; + host?: string; + type?: string; + code?: string; + icon?: string; +} + +/*** + * A Connection Instance represents the root of a connection to a data + * source. Children of a connection instance are tables, views, and other + * objects that can be queried and are represented by Connection Items. + */ +export interface IPositronConnectionInstance extends IPositronConnectionItem { + language_id: string; + active: boolean; + metadata: ConnectionMetadata; + + connect?(): Promise; + disconnect?(): Promise; + refresh?(): Promise; +} + +/*** + * A connection item represents a child object of a connection instance, such as a schema, + * catalog, table, or view. + */ +export interface IPositronConnectionItem { + id: string; // An id is essential for rendering with React + name: string; // Every item needs a name in order for it to be displayed + kind: string; // The kind of the item, eg. table, view, schema, catalog, etc. + dtype?: string; // The data type of the item, usually only implemented if kind == field + icon?: string; // The icon that should be displayed next to the item + error?: string; // Any initialization error for the item. + + expanded: boolean | undefined; // Wether the item is currently expanded + + /** + * Front-end may fire this event whenever the user clicks the + * toggle expand button. Must be implemented if the item is + * expandable. + */ + onToggleExpandEmitter?: Emitter; + + /** + * Items fire this event whenever their data has changed. + * Eg. The connections is turned off, or some child was expanded. + * It's used to notify the renderer that the item has changed. + */ + onDidChangeDataEmitter: Emitter; + + /** + * If the item can be previewed, it should implement this method. + */ + preview?(): Promise; + + hasChildren?(): Promise; + getChildren?(): Promise; +} diff --git a/src/vs/workbench/services/positronConnections/browser/interfaces/positronConnectionsService.ts b/src/vs/workbench/services/positronConnections/browser/interfaces/positronConnectionsService.ts new file mode 100644 index 00000000000..76f99ce4a88 --- /dev/null +++ b/src/vs/workbench/services/positronConnections/browser/interfaces/positronConnectionsService.ts @@ -0,0 +1,41 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2024 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + +import { createDecorator } from 'vs/platform/instantiation/common/instantiation'; +import { IPositronConnectionInstance, IPositronConnectionItem } from 'vs/workbench/services/positronConnections/browser/interfaces/positronConnectionsInstance'; +import { IPositronConnectionEntry } from 'vs/workbench/services/positronConnections/browser/positronConnectionsCache'; +import { Event } from 'vs/base/common/event'; +import Severity from 'vs/base/common/severity'; +import { INotificationHandle } from 'vs/platform/notification/common/notification'; + +export const IPositronConnectionsService = createDecorator('positronConnectionsService'); +export const POSITRON_CONNECTIONS_VIEW_ID = 'workbench.panel.positronConnections'; + +export interface IPositronConnectionsService { + readonly _serviceBrand: undefined; + initialize(): void; + addConnection(instance: IPositronConnectionInstance): void; + getConnections(): IPositronConnectionItem[]; + closeConnection(id: string): void; + clearAllConnections(): void; + + /** + * Returns a flattended list of entries that the service is currently displaying. + */ + getConnectionEntries(): IPositronConnectionEntry[]; + + /** + * Refresh the connections entries cache and fires the onDidChangeEntries event when it's done. + */ + refreshConnectionEntries(): Promise; + + /** + * An event that users can subscribe to receive updates when the flattened list + * of entries changes. + */ + onDidChangeEntries: Event; + + notify(message: string, severity: Severity): INotificationHandle; +} diff --git a/src/vs/workbench/services/positronConnections/browser/mockConnections.ts b/src/vs/workbench/services/positronConnections/browser/mockConnections.ts new file mode 100644 index 00000000000..ea8cc49b08b --- /dev/null +++ b/src/vs/workbench/services/positronConnections/browser/mockConnections.ts @@ -0,0 +1,213 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2024 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Emitter, Event } from 'vs/base/common/event'; +import { ConnectionMetadata, IPositronConnectionInstance, IPositronConnectionItem } from 'vs/workbench/services/positronConnections/browser/interfaces/positronConnectionsInstance'; +import { IPositronConnectionsService } from 'vs/workbench/services/positronConnections/browser/interfaces/positronConnectionsService'; + +export class MockedConnectionInstance implements IPositronConnectionInstance { + private _expanded: boolean = false; + + onToggleExpandEmitter: Emitter = new Emitter(); + onToggleExpand: Event = this.onToggleExpandEmitter.event; + children: IPositronConnectionItem[] = []; + metadata: ConnectionMetadata; + error?: string = undefined; + + constructor( + private readonly clientId: string, + readonly onDidChangeDataEmitter: Emitter, + readonly connectionsService: IPositronConnectionsService, + error = 'error initializing' + ) { + this.onToggleExpand(() => { + this._expanded = !this._expanded; + this.onDidChangeDataEmitter.fire(); + }); + + this.error = error; + + this.children = [ + new MockedConnectionItem(this.onDidChangeDataEmitter), + new MockedConnectionItem(this.onDidChangeDataEmitter), + new MockedConnectionItem(this.onDidChangeDataEmitter), + new MockedConnectionItem(this.onDidChangeDataEmitter), + new MockedConnectionItem(this.onDidChangeDataEmitter), + new MockedConnectionItem(this.onDidChangeDataEmitter), + new MockedConnectionItem(this.onDidChangeDataEmitter), + new MockedConnectionItem(this.onDidChangeDataEmitter), + new MockedConnectionItem(this.onDidChangeDataEmitter), + new MockedConnectionItem(this.onDidChangeDataEmitter), + new MockedConnectionItem(this.onDidChangeDataEmitter), + new MockedConnectionItem(this.onDidChangeDataEmitter), + new MockedConnectionItem(this.onDidChangeDataEmitter), + new MockedConnectionItem(this.onDidChangeDataEmitter), + new MockedConnectionItem(this.onDidChangeDataEmitter), + new MockedConnectionItem(this.onDidChangeDataEmitter), + new MockedConnectionItem(this.onDidChangeDataEmitter), + new MockedConnectionItem(this.onDidChangeDataEmitter), + new MockedConnectionItem(this.onDidChangeDataEmitter), + new MockedConnectionItem(this.onDidChangeDataEmitter), + new MockedConnectionItem(this.onDidChangeDataEmitter), + new MockedConnectionItem(this.onDidChangeDataEmitter), + new MockedConnectionItem(this.onDidChangeDataEmitter), + new MockedConnectionItem(this.onDidChangeDataEmitter), + new MockedConnectionItem(this.onDidChangeDataEmitter), + new MockedConnectionItem(this.onDidChangeDataEmitter), + new MockedConnectionItem(this.onDidChangeDataEmitter), + new MockedConnectionItem(this.onDidChangeDataEmitter), + new MockedConnectionItem(this.onDidChangeDataEmitter), + ]; + + this.metadata = { + name: 'SQL Lite Connection 1', + language_id: 'mock', + type: this.clientId + }; + } + + getClientId() { + return this.clientId; + } + + async getChildren() { + if (Math.random() > 0.5) { + throw new Error('cannot parse'); + } + return this.children; + } + + async hasChildren() { + return true; + } + + get name() { + return 'SQL Lite Connection 1'; + } + + get kind() { + return 'database'; + } + + get language_id() { + return 'mock'; + } + + get id() { + const host = (this.metadata.host !== undefined) ? this.metadata.host : 'undefined'; + const type = (this.metadata.type !== undefined) ? this.metadata.type : 'undefined'; + const language_id = this.metadata.language_id; + return `host-${host}-type-${type}-language_id-${language_id}`; + } + + async connect() { + // Dummy reconnection. Just creates a new instance with the same id. + this.connectionsService.addConnection(new MockedConnectionInstance( + this.clientId, + this.onDidChangeDataEmitter, + this.connectionsService + )); + } + + get expanded() { + return this._expanded; + } + + _active: boolean = true; + + get active() { + return this._active; + } + + async disconnect() { + this._active = false; + this._expanded = false; + this.error = undefined; + this.onDidChangeDataEmitter.fire(); + } + + async refresh() { + this.children.pop(); + this.onDidChangeDataEmitter.fire(); + } +} + +class MockedConnectionItem implements IPositronConnectionItem { + + expanded_: boolean = false; + active: boolean = true; + id: string = generateUniqueId(); + kind: string = 'table'; + + onToggleExpandEmitter: Emitter = new Emitter(); + onToggleExpand: Event = this.onToggleExpandEmitter.event; + + constructor(readonly onDidChangeDataEmitter: Emitter) { + this.onToggleExpand(() => { + this.expanded_ = !this.expanded_; + this.onDidChangeDataEmitter.fire(); + }); + } + + get name() { + return 'children 1'; + } + + async getChildren() { + return [ + new MockField('mpg', this.onDidChangeDataEmitter), + new MockField('mpa', this.onDidChangeDataEmitter) + ]; + } + + async getIcon() { + return 'database'; + } + + async hasChildren() { + return true; + } + + get expanded() { + return this.expanded_; + } +} + +class MockField implements IPositronConnectionItem { + + active: boolean = true; + id: string = generateUniqueId(); + kind: string = 'field'; + + constructor(readonly _name: string, readonly onDidChangeDataEmitter: Emitter) { + + } + + get name() { + return this._name; + } + + async getIcon() { + return 'database'; + } + + get expanded() { + return undefined; + } + + async hasChildren() { + return false; + } + + async getChildren() { + return []; + } +} + +function generateUniqueId(): string { + return ( + Date.now().toString(36) + Math.random().toString(36).substr(2, 9) + ); +} diff --git a/src/vs/workbench/services/positronConnections/browser/positronConnectionsCache.ts b/src/vs/workbench/services/positronConnections/browser/positronConnectionsCache.ts new file mode 100644 index 00000000000..6d6d147c15c --- /dev/null +++ b/src/vs/workbench/services/positronConnections/browser/positronConnectionsCache.ts @@ -0,0 +1,270 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2024 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Emitter } from 'vs/base/common/event'; +import { Disposable } from 'vs/base/common/lifecycle'; +import Severity from 'vs/base/common/severity'; +import { INotificationHandle } from 'vs/platform/notification/common/notification'; +import { IPositronConnectionInstance, IPositronConnectionItem } from 'vs/workbench/services/positronConnections/browser/interfaces/positronConnectionsInstance'; +import { IPositronConnectionsService } from 'vs/workbench/services/positronConnections/browser/interfaces/positronConnectionsService'; + + +export interface IPositronConnectionEntry { + /*** + * The list of connections entries is flat. Level allows us to find + * how nested an entry is. + */ + level: number; + + /*** + * Used to indentify unique connection entries. + * Connections and children of connections must all have unique ids. + */ + id: string; + + /** + * Wether the connection entry is currently active. + */ + active: boolean; + + /** + * Wether the connection is expanded or not. Undefined + * if the connection is not expandable. + */ + expanded: boolean | undefined; + + /** + * Front-end may fire this event whenever the user clicks the + * toggle expand button. Must be implemented if the item is + * expandable. + */ + onToggleExpandEmitter?: Emitter; + + + // Entry properties that may be displayed in the UI. + name: string; + kind?: string; + dtype?: string; + language_id?: string; + icon?: string; + + /** + * Enables the behavior of the connect button. Only + * enabled when the entry is not active. + */ + connect?(): Promise; + + /** + * Causes the item to disconnect. + */ + disconnect?(): Promise; + + /** + * Refresh the connection data. + */ + refresh?(): Promise; + + /** + * Causes the a viewer to open for that item. + * Currently, used to open tables and views in the data explorer. + */ + preview?(): Promise; + + // If an error happens during soem evaluation for that element + // we try to display some information . + error?: string; +} + +/** + * Wraps ConnectionInstance or ConnectionItems to provide a flat list of entries. + */ +class PositronConnectionEntry extends Disposable implements IPositronConnectionEntry { + + error?: string; + + constructor( + private readonly item: IPositronConnectionItem | IPositronConnectionInstance, + private notify: (message: string, severity: Severity) => INotificationHandle, + readonly level: number, + ) { + super(); + } + + get id() { + const id = this.item.id; + return id; + } + + get active() { + if ('active' in this.item) { + return this.item.active; + } + + // Child objects are always 'active'. + return true; + } + + get expanded() { + return this.item.expanded; + } + + get onToggleExpandEmitter() { + return this.item.onToggleExpandEmitter; + } + + get name() { + return this.item.name; + } + + get kind() { + return this.item.kind; + } + + get dtype() { + return this.item.dtype; + } + + get language_id() { + if ('language_id' in this.item) { + return this.item.language_id; + } + + return undefined; + } + + get icon() { + return this.item.icon; + } + + get disconnect() { + if ('disconnect' in this.item) { + const instance = this.item; + return async () => { + try { + return await instance.disconnect?.(); + } catch (err: any) { + // An error that happens during disconnected should be shown + // as a notification to users. + this.notify( + `Error disconnecting ${this.id}: ${err.message}`, + Severity.Error + ); + } + }; + } + + return undefined; + } + + get connect() { + if ('connect' in this.item) { + const instance = this.item; + return async () => { + try { + return await instance.connect?.(); + } catch (err: any) { + this.notify( + `Error creating connection ${this.id}: ${err.message}`, + Severity.Error + ); + } + }; + } + + return undefined; + } + + get preview() { + if (!this.item.preview) { + return undefined; + } + + return async () => { + try { + await this.item.preview?.(); + } catch (err: any) { + this.notify( + `Error previewing object ${this.id}: ${err.message}`, + Severity.Error + ); + } + }; + } + + get refresh() { + if ('refresh' in this.item) { + const instance = this.item; + return async () => { + try { + instance.refresh?.(); + } catch (err: any) { + this.notify( + `Error refreshing connection ${this.id}: ${err.message}`, + Severity.Error + ); + } + }; + } + + return undefined; + } +} + +export class PositronConnectionsCache { + + private _entries: IPositronConnectionEntry[] = []; + + constructor( + private readonly service: IPositronConnectionsService, + ) { } + + get entries(): IPositronConnectionEntry[] { + return this._entries; + } + + async refreshConnectionEntries() { + const entries = await this.getConnectionsEntries(this.service.getConnections()); + this._entries = entries; + } + + async getConnectionsEntries(items: IPositronConnectionItem[], level = 0) { + + const entries: IPositronConnectionEntry[] = []; + for (const item of items) { + + const entry = new PositronConnectionEntry( + item, + (message, severity) => this.service.notify(message, severity), + level, + ); + entries.push(entry); + + if (item.error) { + entry.error = item.error; + } + + const expanded = item.expanded; + const active = 'active' in item ? item.active : true; + + // To show children, the connection must be expanded, have a getChildren() method + // and be active. + if (expanded && item.getChildren && active) { + let children; + try { + children = await item.getChildren(); + } catch (err: any) { + // If some error happened we want to be able + // display it for users. + entry.error = err.message; + continue; + } + const newItems = await this.getConnectionsEntries(children, level + 1); + entries.push(...newItems); + } + } + + return entries; + } +} diff --git a/src/vs/workbench/services/positronConnections/browser/positronConnectionsFeatureFlag.ts b/src/vs/workbench/services/positronConnections/browser/positronConnectionsFeatureFlag.ts new file mode 100644 index 00000000000..41a305abf26 --- /dev/null +++ b/src/vs/workbench/services/positronConnections/browser/positronConnectionsFeatureFlag.ts @@ -0,0 +1,43 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2024 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + +import { localize } from 'vs/nls'; +import { + ConfigurationScope, + Extensions, + IConfigurationRegistry, +} from 'vs/platform/configuration/common/configurationRegistry'; +import { Registry } from 'vs/platform/registry/common/platform'; +import { positronConfigurationNodeBase } from 'vs/workbench/services/languageRuntime/common/languageRuntime'; +import { RawContextKey } from 'vs/platform/contextkey/common/contextkey'; + +export const POSITRON_CONNECTIONS_VIEW_ENABLED = new RawContextKey( + 'positronConnectionsViewEnabled', + false +); + +// Key for the configuration setting +export const USE_POSITRON_CONNECTIONS_KEY = + 'positron.connections'; + +// Register the configuration setting +const configurationRegistry = Registry.as( + Extensions.Configuration +); + +configurationRegistry.registerConfiguration({ + ...positronConfigurationNodeBase, + scope: ConfigurationScope.MACHINE_OVERRIDABLE, + properties: { + [USE_POSITRON_CONNECTIONS_KEY]: { + type: 'boolean', + default: false, + markdownDescription: localize( + 'positron.enableConnectionsPane', + '**CAUTION**: Enable experimental Positron Connections Pane features which may result in unexpected behaviour. Please restart Positron if you change this option.' + ), + }, + }, +}); diff --git a/src/vs/workbench/services/positronConnections/browser/positronConnectionsInstance.ts b/src/vs/workbench/services/positronConnections/browser/positronConnectionsInstance.ts new file mode 100644 index 00000000000..7cc51248594 --- /dev/null +++ b/src/vs/workbench/services/positronConnections/browser/positronConnectionsInstance.ts @@ -0,0 +1,397 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2024 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable } from 'vs/base/common/lifecycle'; +import { Emitter, Event } from 'vs/base/common/event'; +import { ConnectionsClientInstance } from 'vs/workbench/services/languageRuntime/common/languageRuntimeConnectionsClient'; +import { ConnectionMetadata, IPositronConnectionInstance, IPositronConnectionItem } from 'vs/workbench/services/positronConnections/browser/interfaces/positronConnectionsInstance'; +import { ObjectSchema } from 'vs/workbench/services/languageRuntime/common/positronConnectionsComm'; +import { IRuntimeSessionService } from 'vs/workbench/services/runtimeSession/common/runtimeSessionService'; +import { RuntimeCodeExecutionMode, RuntimeErrorBehavior } from 'vs/workbench/services/languageRuntime/common/languageRuntimeService'; +import { ILogService } from 'vs/platform/log/common/log'; + +interface PathSchema extends ObjectSchema { + dtype?: string; +} + +interface ConnectionsService { + runtimeSessionService: IRuntimeSessionService; + logService: ILogService; + onDidFocusEmitter: Emitter; + onDidChangeDataEmitter: Emitter; +} + +class BaseConnectionsInstance extends Disposable { + constructor( + readonly metadata: ConnectionMetadata + ) { + super(); + } + + get id() { + // We use host, type and language_id to identify a unique connection. + const host = (this.metadata.host !== undefined) ? this.metadata.host : 'undefined'; + const type = (this.metadata.type !== undefined) ? this.metadata.type : 'undefined'; + const language_id = this.metadata.language_id; + return `host-${host}-type-${type}-language_id-${language_id}`; + } + + get name() { + return this.metadata.name; + } + + get language_id() { + return this.metadata.language_id; + } + + get icon() { + return this.metadata.icon; + } +} + +export class PositronConnectionsInstance extends BaseConnectionsInstance implements IPositronConnectionInstance { + + readonly onToggleExpandEmitter: Emitter = new Emitter(); + private readonly onToggleExpand: Event = this.onToggleExpandEmitter.event; + + readonly onDidChangeDataEmitter: Emitter; + + private _expanded: boolean = false; + private _active: boolean = true; + private _children: IPositronConnectionItem[] | undefined; + + static async init(metadata: ConnectionMetadata, client: ConnectionsClientInstance, service: ConnectionsService) { + const object = new PositronConnectionsInstance(metadata, client, service); + if (!object.metadata.icon) { + try { + // Failing to acquire the icon is fine + // We just log the error + object.metadata.icon = await object.getIcon(); + } catch (err: any) { + service.logService.error(`Failed to get icon for ${object.id}: ${err.message}`); + } + } + return object; + } + + private constructor( + metadata: ConnectionMetadata, + private readonly client: ConnectionsClientInstance, + private readonly service: ConnectionsService, + ) { + super(metadata); + + this.onDidChangeDataEmitter = service.onDidChangeDataEmitter; + + this._register(this.onToggleExpand(() => { + this._expanded = !this._expanded; + this.service.onDidChangeDataEmitter.fire(); + })); + + this._register(this.client.onDidClose(() => { + this._active = false; + this._expanded = false; + this.service.onDidChangeDataEmitter.fire(); + })); + + this._register(this.client.onDidFocus(() => { + this.service.onDidFocusEmitter.fire(); + })); + } + + readonly kind: string = 'database'; + + async hasChildren() { + return true; + } + + async getChildren() { + if (this._children === undefined) { + const children = await this.client.listObjects([]); + this._children = await Promise.all(children.map(async (item) => { + return await PositronConnectionItem.init( + [item], + this.client, + this.id, + this.service + ); + })); + } + return this._children; + } + + get expanded() { + return this._expanded; + } + + get active() { + return this._active; + } + + get connect() { + if (!this.metadata.code) { + // No code, no connect method. + return undefined; + } + + return async () => { + const language_id = this.metadata.language_id; + const session = this.service.runtimeSessionService.getConsoleSessionForLanguage(language_id); + + if (!session) { + throw new Error(`No console session for language ${language_id}`); + } + + // We have checked that before, but it might have been removed somehow. + if (!this.metadata.code) { + throw new Error('No code to execute'); + } + + session.execute( + this.metadata.code, + this.metadata.name, + RuntimeCodeExecutionMode.Interactive, + RuntimeErrorBehavior.Continue + ); + }; + } + + get disconnect() { + if (!this._active) { + // Not active, can't be disconected. + return undefined; + } + + return async () => { + // We don't need to send the DidDataChange event because it will be triggered + // when the client is actually closed. + this.client.dispose(); + }; + } + + get refresh() { + if (!this._active) { + // Not active, can't be refreshed. + return undefined; + } + + return async () => { + this._children = undefined; + this.onDidChangeDataEmitter.fire(); + }; + } + + private async getIcon() { + return this.client.getIcon([]); + } +} + +export class DisconnectedPositronConnectionsInstance extends BaseConnectionsInstance implements IPositronConnectionInstance { + constructor( + metadata: ConnectionMetadata, + readonly onDidChangeDataEmitter: Emitter, + readonly runtimeSessionService: IRuntimeSessionService, + ) { + super(metadata); + } + + readonly kind: string = 'database'; + readonly expanded: boolean | undefined = false; + readonly active: boolean = false; + + get connect() { + if (!this.metadata.code) { + // No code, no connect method. + return undefined; + } + + return async () => { + const language_id = this.metadata.language_id; + const session = this.runtimeSessionService.getConsoleSessionForLanguage(language_id); + + if (!session) { + throw new Error(`No console session for language ${language_id}`); + } + + // We have checked that before, but it might have been removed somehow. + if (!this.metadata.code) { + throw new Error('No code to execute'); + } + + session.execute( + this.metadata.code, + this.metadata.name, + RuntimeCodeExecutionMode.Interactive, + RuntimeErrorBehavior.Continue + ); + }; + } +} + +class PositronConnectionItem implements IPositronConnectionItem { + + private readonly _name: string; + private readonly _kind: string; + private readonly _dtype?: string; + readonly active: boolean = true; + + private _expanded: boolean | undefined; + private _has_viewer: boolean | undefined; + private _icon: string | undefined; + private _children: IPositronConnectionItem[] | undefined; + private _has_children: boolean | undefined; + + public error?: string; + + onToggleExpandEmitter: Emitter = new Emitter(); + private readonly onToggleExpand: Event = this.onToggleExpandEmitter.event; + + onDidChangeDataEmitter: Emitter; + + static async init(path: PathSchema[], client: ConnectionsClientInstance, parent_id: string, service: ConnectionsService) { + const object = new PositronConnectionItem(path, client, parent_id, service); + + let expandable; + try { + // Failing to check if the object is expandable should not be fatal. + // We'll mark it as 'errored' and keep is non-expandable. + // The user might want to refresh to retry if this happens. + expandable = await object.hasChildren(); + } catch (err: any) { + object.error = err.message; + } + + if (expandable) { + object._expanded = false; + } else { + object._expanded = undefined; + } + + if (!object._icon) { + // Failing to get the icon is OK. + // We only log it. + try { + object._icon = await object.getIcon(); + } catch (err: any) { + service.logService.error(`Failed to get icon for ${object.id}: ${err.message}`); + } + } + + // Calling object.hasViewer() would be enough to set that flag the internal + // _has_viwer flag, because it's used as a cache. But we wanted to make this + // explicit. + object._has_viewer = await object.hasViewer(); + return object; + } + + private constructor( + private readonly path: PathSchema[], + private readonly client: ConnectionsClientInstance, + private readonly parent_id: string, + private readonly service: ConnectionsService + ) { + if (this.path.length === 0) { + throw new Error('path must be length > 0'); + } + + this.onDidChangeDataEmitter = this.service.onDidChangeDataEmitter; + + const last_elt = this.path.at(-1)!; + this._name = last_elt.name; + this._kind = last_elt.kind; + this._dtype = last_elt.dtype; + + this.onToggleExpand(() => { + if (!(this._expanded === undefined)) { + this._expanded = !this._expanded; + // Changing the expanded flag will change the data that we want to show. + this.onDidChangeDataEmitter.fire(); + } + }); + } + + get id() { + return `${this.parent_id}-name:${this._name}`; + } + + get name() { + return this._name; + } + + get kind() { + return this._kind; + } + + get dtype() { + return this._dtype; + } + + get icon() { + return this._icon; + } + + get expanded() { + return this._expanded; + } + + get preview() { + if (!this._has_viewer) { + return undefined; + } + + return async () => { + await this.client.previewObject(this.path); + }; + } + + async hasChildren() { + if (this._has_children === undefined) { + // Anything other than the 'field' type is said to have children. + this._has_children = this._kind !== 'field'; + } + + return this._has_children; + } + + async getChildren() { + if (!this._children) { + let children: PathSchema[]; + const containsData = await this.client.containsData(this.path); + if (containsData) { + children = (await this.client.listFields(this.path)).map((item) => { + return { ...item, kind: 'field' }; + }); + } else { + children = await this.client.listObjects(this.path); + } + + this._children = await Promise.all(children.map(async (item) => { + return await PositronConnectionItem.init( + [...this.path, item], + this.client, + this.id, + this.service + ); + })); + } + return this._children; + } + + private async getIcon() { + const icon = await this.client.getIcon(this.path); + if (icon === '') { + return undefined; + } else { + return icon; + } + } + + private async hasViewer() { + if (this._has_viewer === undefined) { + this._has_viewer = await this.client.containsData(this.path); + } + return this._has_viewer; + } +} diff --git a/src/vs/workbench/services/positronConnections/browser/positronConnectionsService.ts b/src/vs/workbench/services/positronConnections/browser/positronConnectionsService.ts new file mode 100644 index 00000000000..1753227d0c3 --- /dev/null +++ b/src/vs/workbench/services/positronConnections/browser/positronConnectionsService.ts @@ -0,0 +1,250 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2024 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable } from 'vs/base/common/lifecycle'; +import { InstantiationType, registerSingleton } from 'vs/platform/instantiation/common/extensions'; +import { IPositronConnectionEntry, PositronConnectionsCache } from 'vs/workbench/services/positronConnections/browser/positronConnectionsCache'; +import { ConnectionsClientInstance } from 'vs/workbench/services/languageRuntime/common/languageRuntimeConnectionsClient'; +import { ConnectionMetadata, IPositronConnectionInstance } from 'vs/workbench/services/positronConnections/browser/interfaces/positronConnectionsInstance'; +import { IPositronConnectionsService, POSITRON_CONNECTIONS_VIEW_ID } from 'vs/workbench/services/positronConnections/browser/interfaces/positronConnectionsService'; +import { DisconnectedPositronConnectionsInstance, PositronConnectionsInstance } from 'vs/workbench/services/positronConnections/browser/positronConnectionsInstance'; +import { ILanguageRuntimeSession, IRuntimeSessionService, RuntimeClientType } from 'vs/workbench/services/runtimeSession/common/runtimeSessionService'; +import { Event, Emitter } from 'vs/base/common/event'; +import { IStorageService, StorageScope, StorageTarget } from 'vs/platform/storage/common/storage'; +import { POSITRON_CONNECTIONS_VIEW_ENABLED, USE_POSITRON_CONNECTIONS_KEY } from 'vs/workbench/services/positronConnections/browser/positronConnectionsFeatureFlag'; +import { IContextKey, IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; +import { IConfigurationChangeEvent, IConfigurationService } from 'vs/platform/configuration/common/configuration'; +import { IViewsService } from 'vs/workbench/services/views/common/viewsService'; +import { ILogService } from 'vs/platform/log/common/log'; +import { INotificationService, Severity } from 'vs/platform/notification/common/notification'; + +class PositronConnectionsService extends Disposable implements IPositronConnectionsService { + + private readonly _cache: PositronConnectionsCache; + readonly _serviceBrand: undefined; + + private onDidChangeEntriesEmitter = new Emitter; + onDidChangeEntries: Event = this.onDidChangeEntriesEmitter.event; + + public onDidChangeDataEmitter = new Emitter; + private onDidChangeData = this.onDidChangeDataEmitter.event; + + public onDidFocusEmitter = new Emitter; + private onDidFocus = this.onDidFocusEmitter.event; + + private readonly connections: IPositronConnectionInstance[] = []; + private readonly viewEnabled: IContextKey; + + constructor( + @IRuntimeSessionService public readonly runtimeSessionService: IRuntimeSessionService, + @IStorageService private readonly storageService: IStorageService, + @IContextKeyService private readonly contextKeyService: IContextKeyService, + @IConfigurationService private readonly configurationService: IConfigurationService, + @IViewsService private readonly viewsService: IViewsService, + @ILogService public readonly logService: ILogService, + @INotificationService private readonly notificationService: INotificationService, + ) { + super(); + this.viewEnabled = POSITRON_CONNECTIONS_VIEW_ENABLED.bindTo(this.contextKeyService); + const enabled = this.configurationService.getValue(USE_POSITRON_CONNECTIONS_KEY); + this.viewEnabled.set(enabled); + + // Whenever a session starts, we'll register an observer that will create a ConnectionsInstance + // whenever a new connections client is created by the backend. + this._register(this.runtimeSessionService.onDidStartRuntime((runtime) => { + this.attachRuntime(runtime); + })); + + this._cache = new PositronConnectionsCache(this); + this._register(this.onDidChangeData(() => { + this.refreshConnectionEntries(); + })); + + const storedConnections: ConnectionMetadata[] = JSON.parse( + this.storageService.get('positron-connections', StorageScope.WORKSPACE, '[]') + ); + storedConnections.forEach((metadata) => { + if (metadata === null) { + return; + } + + const instance = new DisconnectedPositronConnectionsInstance( + metadata, + this.onDidChangeDataEmitter, + this.runtimeSessionService + ); + + this.addConnection(instance); + }); + + this._register(this.configurationService.onDidChangeConfiguration((e) => { + this.handleConfigChange(e); + })); + + this._register(this.onDidFocus(() => { + this.viewsService.openView(POSITRON_CONNECTIONS_VIEW_ID, false); + })); + } + + private handleConfigChange(e: IConfigurationChangeEvent) { + if (e.affectsConfiguration(USE_POSITRON_CONNECTIONS_KEY)) { + const enabled = this.configurationService.getValue(USE_POSITRON_CONNECTIONS_KEY); + this.viewEnabled.set(enabled); + } + } + + getConnectionEntries() { + const entries = this._cache.entries; + return entries; + } + + async refreshConnectionEntries() { + try { + await this._cache.refreshConnectionEntries(); + this.onDidChangeEntriesEmitter.fire(this._cache.entries); + } catch (err) { + this.notificationService.error(`Failed to refresh connection entries: ${err.message}`); + } + } + + getConnections() { + return this.connections; + } + + initialize(): void { } + + attachRuntime(session: ILanguageRuntimeSession) { + this._register(session.onDidCreateClientInstance(async ({ message, client }) => { + if (client.getClientType() !== RuntimeClientType.Connection) { + return; + } + + if (this.hasConnection(client.getClientId())) { + // A connection with this id is already registered. + return; + } + + const instance = await PositronConnectionsInstance.init( + message.data as ConnectionMetadata, + new ConnectionsClientInstance(client), + this + ); + + this.addConnection(instance); + })); + + session.listClients().then((clients) => { + clients.forEach(async (client) => { + if (client.getClientType() !== RuntimeClientType.Connection) { + return; + } + + const connectionsClient = new ConnectionsClientInstance(client); + const metadata = await connectionsClient.getMetadata(); + + const instance = await PositronConnectionsInstance.init( + metadata, + connectionsClient, + this + ); + + this.addConnection(instance); + }); + }); + } + + addConnection(instance: IPositronConnectionInstance) { + // If a connection with the same id exists, we will replace it with a new one + // otherwise just push it to the end of the list. + const newId = instance.id; + const existingConnectionIndex = this.connections.findIndex((conn) => { + return conn.id === newId; + }); + + if (existingConnectionIndex >= 0) { + this.connections[existingConnectionIndex] = instance; + } else { + this.connections.push(instance); + } + + // Whenever a new connection is added we also update the storage + this.saveConnectionsState(); + + this.refreshConnectionEntries(); + } + + getConnection(id: string) { + return this.connections.find((conn) => { + return conn.id === id; + }); + } + + closeConnection(id: string) { + const connection = this.getConnection(id); + if (connection && connection.disconnect) { + connection.disconnect(); + } + // We don't remove the connection from the `_connections` list as + // we expect that `connection.disconnect()` will make it inactive. + } + + clearAllConnections() { + const ids = this.connections.map((x) => x.id); + ids.forEach((id) => { + this.removeConnection(id); + }); + this.onDidChangeDataEmitter.fire(); + } + + hasConnection(clientId: string) { + return this.getConnection(clientId) !== undefined; + } + + notify(message: string, severity: Severity) { + return this.notificationService.notify({ + message: message, + severity: severity, + source: 'Connections Pane' + }); + } + + private saveConnectionsState() { + this.storageService.store( + 'positron-connections', + this.connections.map((con) => { + return con.metadata; + }), + StorageScope.WORKSPACE, + StorageTarget.USER + ); + } + + private removeConnection(id: string) { + const index = this.connections.findIndex((con) => { + return con.id === id; + }); + + if (index < 0) { + return; + } + + const [connection] = this.connections.splice(index, 1); + this.saveConnectionsState(); + + if (connection.disconnect) { + // if a disconnect method is implemented, we expect it to run onDidChangeDataEmitter + // otherwise, we run it ourselves. + connection.disconnect(); + } else { + this.onDidChangeDataEmitter.fire(); + } + } +} + +registerSingleton( + IPositronConnectionsService, + PositronConnectionsService, + InstantiationType.Delayed +); diff --git a/src/vs/workbench/workbench.common.main.ts b/src/vs/workbench/workbench.common.main.ts index 2edca41d60e..9e4b8cd9967 100644 --- a/src/vs/workbench/workbench.common.main.ts +++ b/src/vs/workbench/workbench.common.main.ts @@ -434,6 +434,7 @@ import 'vs/workbench/contrib/positronConsole/browser/positronConsole.contributio import 'vs/workbench/contrib/positronConsole/browser/positronConsoleView'; import 'vs/workbench/contrib/positronDataExplorerEditor/browser/positronDataExplorerEditor.contribution'; import 'vs/workbench/contrib/positronDataExplorerEditor/browser/positronDataExplorerEditor'; +import 'vs/workbench/contrib/positronConnections/browser/positronConnections.contribution'; import 'vs/workbench/contrib/positronRuntimeSessions/browser/positronRuntimeSessions.contribution'; import 'vs/workbench/contrib/languageRuntime/browser/languageRuntime.contribution'; import 'vs/workbench/contrib/executionHistory/common/executionHistory'; @@ -448,5 +449,6 @@ import 'vs/workbench/services/positronConsole/browser/positronConsoleService'; import 'vs/workbench/contrib/positronHelp/browser/positronHelpService'; import 'vs/workbench/services/positronVariables/common/positronVariablesService'; import 'vs/workbench/services/positronDataExplorer/browser/positronDataExplorerService'; +import 'vs/workbench/services/positronConnections/browser/positronConnectionsService'; import 'vs/workbench/contrib/positronWebviewPreloads/browser/positronWebviewPreloadsService'; // --- End Positron ---