diff --git a/package.json b/package.json index 99c360c5f..a749425ee 100644 --- a/package.json +++ b/package.json @@ -289,19 +289,34 @@ "icon": "$(close)" }, { - "command": "vscode-deephaven.addRemoteFileSource", - "title": "Add to Deephaven remote file sources", + "command": "vscode-deephaven.addGroovyRemoteFileSource", + "title": "Add to Deephaven Groovy remote file sources", "icon": "$(add)" }, { - "command": "vscode-deephaven.removeRemoteFileSource", - "title": "Remove from Deephaven remote file sources", + "command": "vscode-deephaven.removeGroovyRemoteFileSource", + "title": "Remove from Deephaven Groovy remote file sources", + "icon": "$(remove)" + }, + { + "command": "vscode-deephaven.addPythonRemoteFileSource", + "title": "Add to Deephaven Python remote file sources", + "icon": "$(add)" + }, + { + "command": "vscode-deephaven.removePythonRemoteFileSource", + "title": "Remove from Deephaven Python remote file sources", "icon": "$(remove)" }, { "command": "vscode-deephaven.deleteVariable", "title": "Delete Deephaven Variable", "icon": "$(trash)" + }, + { + "command": "vscode-deephaven.revealInExplorer", + "title": "Reveal in Explorer", + "icon": "$(eye)" } ], "icons": { @@ -807,16 +822,28 @@ "when": "false" }, { - "command": "vscode-deephaven.addRemoteFileSource", + "command": "vscode-deephaven.addGroovyRemoteFileSource", "when": "false" }, { - "command": "vscode-deephaven.removeRemoteFileSource", + "command": "vscode-deephaven.removeGroovyRemoteFileSource", + "when": "false" + }, + { + "command": "vscode-deephaven.addPythonRemoteFileSource", + "when": "false" + }, + { + "command": "vscode-deephaven.removePythonRemoteFileSource", "when": "false" }, { "command": "vscode-deephaven.deleteVariable", "when": "false" + }, + { + "command": "vscode-deephaven.revealInExplorer", + "when": "false" } ], "editor/context": [ @@ -845,12 +872,22 @@ ], "explorer/context": [ { - "command": "vscode-deephaven.addRemoteFileSource", + "command": "vscode-deephaven.addGroovyRemoteFileSource", "group": "deephaven", "when": "explorerResourceIsFolder" }, { - "command": "vscode-deephaven.removeRemoteFileSource", + "command": "vscode-deephaven.removeGroovyRemoteFileSource", + "group": "deephaven", + "when": "explorerResourceIsFolder" + }, + { + "command": "vscode-deephaven.addPythonRemoteFileSource", + "group": "deephaven", + "when": "explorerResourceIsFolder" + }, + { + "command": "vscode-deephaven.removePythonRemoteFileSource", "group": "deephaven", "when": "explorerResourceIsFolder" } @@ -941,14 +978,28 @@ "group": "inline@2" }, { - "command": "vscode-deephaven.addRemoteFileSource", - "when": "view == vscode-deephaven.view.remoteImportSourceTree && viewItem == canAddRemoteFileSource", + "command": "vscode-deephaven.addGroovyRemoteFileSource", + "when": "view == vscode-deephaven.view.remoteImportSourceTree && viewItem == canAddRemoteFileSource:groovy", "group": "inline" }, { - "command": "vscode-deephaven.removeRemoteFileSource", - "when": "view == vscode-deephaven.view.remoteImportSourceTree && viewItem == canRemoveRemoteFileSource", + "command": "vscode-deephaven.removeGroovyRemoteFileSource", + "when": "view == vscode-deephaven.view.remoteImportSourceTree && viewItem == canRemoveRemoteFileSource:groovy", "group": "inline" + }, + { + "command": "vscode-deephaven.addPythonRemoteFileSource", + "when": "view == vscode-deephaven.view.remoteImportSourceTree && viewItem == canAddRemoteFileSource:python", + "group": "inline" + }, + { + "command": "vscode-deephaven.removePythonRemoteFileSource", + "when": "view == vscode-deephaven.view.remoteImportSourceTree && viewItem == canRemoveRemoteFileSource:python", + "group": "inline" + }, + { + "command": "vscode-deephaven.revealInExplorer", + "when": "view == vscode-deephaven.view.remoteImportSourceTree && viewItem != root && viewItem != languageRoot" } ] }, diff --git a/releases/vscode-deephaven-1.0.10-remote.0.vsix b/releases/vscode-deephaven-1.0.10-remote.0.vsix new file mode 100644 index 000000000..3f02c1e44 Binary files /dev/null and b/releases/vscode-deephaven-1.0.10-remote.0.vsix differ diff --git a/src/common/commands.ts b/src/common/commands.ts index bf699d93b..8c50d9ee5 100644 --- a/src/common/commands.ts +++ b/src/common/commands.ts @@ -68,5 +68,16 @@ export const SEARCH_PANELS_CMD = cmd('searchPanels'); export const SELECT_CONNECTION_COMMAND = cmd('selectConnection'); export const START_SERVER_CMD = cmd('startServer'); export const STOP_SERVER_CMD = cmd('stopServer'); -export const ADD_REMOTE_FILE_SOURCE_CMD = cmd('addRemoteFileSource'); -export const REMOVE_REMOTE_FILE_SOURCE_CMD = cmd('removeRemoteFileSource'); +export const ADD_GROOVY_REMOTE_FILE_SOURCE_CMD = cmd( + 'addGroovyRemoteFileSource' +); +export const REMOVE_GROOVY_REMOTE_FILE_SOURCE_CMD = cmd( + 'removeGroovyRemoteFileSource' +); +export const ADD_PYTHON_REMOTE_FILE_SOURCE_CMD = cmd( + 'addPythonRemoteFileSource' +); +export const REMOVE_PYTHON_REMOTE_FILE_SOURCE_CMD = cmd( + 'removePythonRemoteFileSource' +); +export const REVEAL_IN_EXPLORER_CMD = cmd('revealInExplorer'); diff --git a/src/common/constants.ts b/src/common/constants.ts index bed5721d9..049d7108f 100644 --- a/src/common/constants.ts +++ b/src/common/constants.ts @@ -88,6 +88,8 @@ export const ICON_ID = { connected: 'vm-connect', connecting: 'sync~spin', disconnected: 'plug', + groovy: 'coffee', + python: 'dh-python', runAll: 'run-all', runSelection: 'run', runningCode: 'sync~spin', diff --git a/src/controllers/ExtensionController.ts b/src/controllers/ExtensionController.ts index 35a9f7c80..e52edf9a7 100644 --- a/src/controllers/ExtensionController.ts +++ b/src/controllers/ExtensionController.ts @@ -11,7 +11,8 @@ import { } from '@deephaven-enterprise/auth-nodejs'; import { NodeHttp2gRPCTransport } from '@deephaven/jsapi-nodejs'; import { - ADD_REMOTE_FILE_SOURCE_CMD, + ADD_GROOVY_REMOTE_FILE_SOURCE_CMD, + ADD_PYTHON_REMOTE_FILE_SOURCE_CMD, CLEAR_SECRET_STORAGE_CMD, CREATE_NEW_TEXT_DOC_CMD, DELETE_VARIABLE_CMD, @@ -22,7 +23,9 @@ import { REFRESH_REMOTE_IMPORT_SOURCE_TREE_CMD, REFRESH_SERVER_CONNECTION_TREE_CMD, REFRESH_SERVER_TREE_CMD, - REMOVE_REMOTE_FILE_SOURCE_CMD, + REMOVE_GROOVY_REMOTE_FILE_SOURCE_CMD, + REMOVE_PYTHON_REMOTE_FILE_SOURCE_CMD, + REVEAL_IN_EXPLORER_CMD, RUN_CODE_COMMAND, RUN_MARKDOWN_CODEBLOCK_CMD, RUN_SELECTION_COMMAND, @@ -39,6 +42,8 @@ import { import { deserializeRange, getEditorForUri, + getGroovyTopLevelPackageName, + getPythonTopLevelModuleFullname, getTempDir, isInstanceOf, isSerializedRange, @@ -80,6 +85,9 @@ import { PYTHON_FILE_PATTERN, SecretService, ServerManager, + PYTHON_IGNORE_TOP_LEVEL_FOLDER_NAMES, + GROOVY_FILE_PATTERN, + GROOVY_IGNORE_TOP_LEVEL_FOLDER_NAMES, } from '../services'; import type { IDisposable, @@ -115,6 +123,8 @@ import type { VariableDefintion, RemoteImportSourceTreeElement, RemoteImportSourceTreeFolderElement, + GroovyPackageName, + PythonModuleFullname, } from '../types'; import { ServerConnectionTreeDragAndDropController } from './ServerConnectionTreeDragAndDropController'; import { ConnectionController } from './ConnectionController'; @@ -187,7 +197,9 @@ export class ExtensionController implements IDisposable { private _dhcServiceFactory: IDhcServiceFactory | null = null; private _dheJsApiCache: IAsyncCacheService | null = null; private _dheServiceFactory: IDheServiceFactory | null = null; - private _pythonWorkspace: FilteredWorkspace | null = null; + private _groovyWorkspace: FilteredWorkspace | null = null; + private _pythonWorkspace: FilteredWorkspace | null = + null; private _remoteFileSourceService: RemoteFileSourceService | null = null; private _secretService: ISecretService | null = null; private _serverManager: IServerManager | null = null; @@ -369,13 +381,27 @@ export class ExtensionController implements IDisposable { */ initializeRemoteFileSourcing = (): void => { assertDefined(this._toaster, 'toaster'); + + this._groovyWorkspace = new FilteredWorkspace( + GROOVY_FILE_PATTERN, + 'groovy', + getGroovyTopLevelPackageName, + GROOVY_IGNORE_TOP_LEVEL_FOLDER_NAMES, + this._toaster + ); + this._context.subscriptions.push(this._groovyWorkspace); + this._pythonWorkspace = new FilteredWorkspace( PYTHON_FILE_PATTERN, + 'python', + getPythonTopLevelModuleFullname, + PYTHON_IGNORE_TOP_LEVEL_FOLDER_NAMES, this._toaster ); this._context.subscriptions.push(this._pythonWorkspace); this._remoteFileSourceService = new RemoteFileSourceService( + this._groovyWorkspace, this._pythonWorkspace ); this._context.subscriptions.push(this._remoteFileSourceService); @@ -704,6 +730,9 @@ export class ExtensionController implements IDisposable { initializeCommands = (): void => { assertDefined(this._connectionController, 'connectionController'); + /** Reveal in Explorer */ + this.registerCommand(REVEAL_IN_EXPLORER_CMD, this.onRevealInExplorer); + /** Clear secret storage */ this.registerCommand(CLEAR_SECRET_STORAGE_CMD, this.onClearSecretStorage); @@ -752,12 +781,20 @@ export class ExtensionController implements IDisposable { this.onRefreshRemoteImportSourceTree ); this.registerCommand( - ADD_REMOTE_FILE_SOURCE_CMD, - this.onAddRemoteFileSource + ADD_GROOVY_REMOTE_FILE_SOURCE_CMD, + this.onAddRemoteFileSource.bind(this, 'groovy') ); this.registerCommand( - REMOVE_REMOTE_FILE_SOURCE_CMD, - this.onRemoveRemoteFileSource + REMOVE_GROOVY_REMOTE_FILE_SOURCE_CMD, + this.onRemoveRemoteFileSource.bind(this, 'groovy') + ); + this.registerCommand( + ADD_PYTHON_REMOTE_FILE_SOURCE_CMD, + this.onAddRemoteFileSource.bind(this, 'python') + ); + this.registerCommand( + REMOVE_PYTHON_REMOTE_FILE_SOURCE_CMD, + this.onRemoveRemoteFileSource.bind(this, 'python') ); /** Search connections */ @@ -788,6 +825,7 @@ export class ExtensionController implements IDisposable { */ initializeWebViews = (): void => { assertDefined(this._dheClientCache, 'dheClientCache'); + assertDefined(this._groovyWorkspace, 'groovyWorkspace'); assertDefined(this._pythonWorkspace, 'pythonWorkspace'); assertDefined(this._panelService, 'panelService'); assertDefined(this._serverManager, 'serverManager'); @@ -849,6 +887,7 @@ export class ExtensionController implements IDisposable { // Remote import source tree this._remoteImportSourceTreeProvider = new RemoteImportSourceTreeProvider( + this._groovyWorkspace, this._pythonWorkspace ); this._remoteImportSourceTreeView = @@ -940,6 +979,7 @@ export class ExtensionController implements IDisposable { }; onAddRemoteFileSource = async ( + languageId: 'groovy' | 'python', folderElementOrUri: | RemoteImportSourceTreeFolderElement | vscode.Uri @@ -948,23 +988,33 @@ export class ExtensionController implements IDisposable { // Sometimes view/item/context commands pass undefined instead of a value. // Just ignore. microsoft/vscode#283655 if (folderElementOrUri == null) { - logger.debug('onAddRemoteFileSource', 'folderElementOrUri is undefined'); + logger.debug( + 'onAddRemoteFileSource', + languageId, + 'folderElementOrUri is undefined' + ); return; } - assertDefined(this._pythonWorkspace, 'pythonWorkspace'); + const workspace = + languageId === 'groovy' ? this._groovyWorkspace : this._pythonWorkspace; + + assertDefined(workspace, `${languageId}Workspace`); - await this._pythonWorkspace.refresh(); + // supress notifications since `markFolder` will raise notifications. + // this avoids sending data to the server that will change anyway + await workspace.refresh(true); const uri = folderElementOrUri instanceof vscode.Uri ? folderElementOrUri : folderElementOrUri.uri; - this._pythonWorkspace.markFolder(uri); + workspace.markFolder(uri); }; onRemoveRemoteFileSource = async ( + languageId: 'groovy' | 'python', folderElementOrUri: | RemoteImportSourceTreeFolderElement | vscode.Uri @@ -975,20 +1025,27 @@ export class ExtensionController implements IDisposable { if (folderElementOrUri == null) { logger.debug( 'onRemoveRemoteFileSource', + languageId, 'folderElementOrUri is undefined' ); return; } - assertDefined(this._pythonWorkspace, 'pythonWorkspace'); + const workspace = + languageId === 'groovy' ? this._groovyWorkspace : this._pythonWorkspace; + + assertDefined(workspace, `${languageId}Workspace`); - await this._pythonWorkspace.refresh(); + // supress notifications since `unmarkFolder` will raise notifications. + // this avoids sending data to the server that will change anyway + await workspace.refresh(true); const uri = folderElementOrUri instanceof vscode.Uri ? folderElementOrUri : folderElementOrUri.uri; - this._pythonWorkspace.unmarkFolder(uri); + + workspace.unmarkFolder(uri); }; /** @@ -1103,6 +1160,22 @@ export class ExtensionController implements IDisposable { await this._serverManager?.updateStatus(); }; + onRevealInExplorer = async ( + uriOrHasUri: vscode.Uri | { uri: vscode.Uri } | undefined + ): Promise => { + // Sometimes view/item/context commands pass undefined instead of a value. + // Just ignore. microsoft/vscode#283655 + if (uriOrHasUri == null) { + logger.debug('onRevealInExplorer', 'uri is undefined'); + return; + } + + const uri = + uriOrHasUri instanceof vscode.Uri ? uriOrHasUri : uriOrHasUri.uri; + + await vscode.commands.executeCommand('revealInExplorer', uri); + }; + /** * Run code block in markdown. * @param uri The uri of the editor diff --git a/src/dh/dhc.ts b/src/dh/dhc.ts index dab21bf88..fb26879e0 100644 --- a/src/dh/dhc.ts +++ b/src/dh/dhc.ts @@ -28,7 +28,8 @@ export const AUTH_HANDLER_TYPE_DHE = export type ConnectionAndSession = { cn: TConnection; cnId: UniqueID; - remoteFileSourcePlugin: DhType.Widget | null; + groovyRemoteFileSourcePlugin: DhType.remotefilesource.RemoteFileSourceService | null; + pythonRemoteFileSourcePlugin: DhType.Widget | null; session: TSession; }; @@ -152,10 +153,21 @@ export async function initDhcSession( const session = await cn.startSession(type); - const remoteFileSourcePlugin = + const pythonRemoteFileSourcePlugin = type === 'python' ? await getRemoteFileSourcePlugin(cnId, session) : null; - return { cn, cnId, remoteFileSourcePlugin, session }; + const groovyRemoteFileSourcePlugin: DhType.remotefilesource.RemoteFileSourceService | null = + type === 'groovy' && 'getRemoteFileSourceService' in client + ? await client.getRemoteFileSourceService() + : null; + + return { + cn, + cnId, + groovyRemoteFileSourcePlugin, + pythonRemoteFileSourcePlugin, + session, + }; } /** diff --git a/src/providers/RemoteImportSourceTreeProvider.ts b/src/providers/RemoteImportSourceTreeProvider.ts index 63ae37b0d..ac45b0dde 100644 --- a/src/providers/RemoteImportSourceTreeProvider.ts +++ b/src/providers/RemoteImportSourceTreeProvider.ts @@ -1,24 +1,40 @@ import * as vscode from 'vscode'; import { TreeDataProviderBase } from './TreeDataProviderBase'; import type { FilteredWorkspace } from '../services'; -import type { RemoteImportSourceTreeElement } from '../types'; +import type { + GroovyPackageName, + PythonModuleFullname, + RemoteImportSourceTreeElement, +} from '../types'; import { getFileTreeItem, getFolderTreeItem, + getLanguageRootTreeItem, + getRootTreeItem, getTopLevelMarkedFolderTreeItem, + getWorkspaceFolderRootTreeItem, sortByStringProp, } from '../util'; const ACTIVE_SOURCES_LABEL = 'Active Sources'; const WORKSPACE_LABEL = 'Workspace'; +const GROOVY_LABEL = 'Groovy'; +const PYTHON_LABEL = 'Python'; /** * Tree data provider that shows the Python modules in the workspace. */ export class RemoteImportSourceTreeProvider extends TreeDataProviderBase { - constructor(private readonly _pythonWorkspace: FilteredWorkspace) { + constructor( + private readonly _groovyWorkspace: FilteredWorkspace, + private readonly _pythonWorkspace: FilteredWorkspace + ) { super(); + this.disposables.add( + this._groovyWorkspace.onDidUpdate(() => this.refresh()) + ); + this.disposables.add( this._pythonWorkspace.onDidUpdate(() => this.refresh()) ); @@ -41,15 +57,34 @@ export class RemoteImportSourceTreeProvider extends TreeDataProviderBase // Sort folders before files, then by name nodeB.type.localeCompare(nodeA.type) || @@ -75,12 +110,14 @@ export class RemoteImportSourceTreeProvider extends TreeDataProviderBase void) | null = null; + private groovyRemoteFileSourcePluginSubscription: (() => void) | null = null; + private pythonRemoteFileSourcePluginSubscription: (() => void) | null = null; private session: DhcType.IdeSession | null = null; + private groovyRemoteFileSourcePluginService: DhcType.remotefilesource.RemoteFileSourceService | null = + null; + private pythonRemoteFileSourcePlugin: DhcType.Widget | null = null; + get isInitialized(): boolean { return this.initSessionPromise != null; } @@ -281,15 +286,22 @@ export class DhcService extends DisposableBase implements IDhcService { } try { - const { cn, cnId, remoteFileSourcePlugin, session } = - await this.initSessionPromise; + const { + cn, + cnId, + groovyRemoteFileSourcePlugin, + pythonRemoteFileSourcePlugin, + session, + } = await this.initSessionPromise; this.cn = cn; this.cnId = cnId; this.session = session; - if (remoteFileSourcePlugin != null) { - await this._initRemoteFileSourcePlugin(session, remoteFileSourcePlugin); - } + await this._initRemoteFileSourcePlugins( + session, + groovyRemoteFileSourcePlugin, + pythonRemoteFileSourcePlugin + ); } catch (err) { logger.error(err); const toastMessage = this.getToastErrorMessage( @@ -329,25 +341,49 @@ export class DhcService extends DisposableBase implements IDhcService { }; /** - * Initialize the remote file source plugin. - * @param session the ide session to associate with the plugin - * @param remoteFileSourcePlugin the remote file source plugin widget + * Initialize the remote file source plugins. + * @param session the ide session to associate with the plugins + * @param groovyRemoteFileSourcePluginService the Groovy remote file source plugin service + * @param pythonRemoteFileSourcePlugin the Python remote file source plugin widget */ - private _initRemoteFileSourcePlugin = async ( + private _initRemoteFileSourcePlugins = async ( session: DhcType.IdeSession, - remoteFileSourcePlugin: DhcType.Widget + groovyRemoteFileSourcePluginService: DhcType.remotefilesource.RemoteFileSourceService | null, + pythonRemoteFileSourcePlugin: DhcType.Widget | null ): Promise => { - this.disposables.add(() => { - remoteFileSourcePlugin.close(); - }); + this.groovyRemoteFileSourcePluginService = + groovyRemoteFileSourcePluginService; + this.pythonRemoteFileSourcePlugin = pythonRemoteFileSourcePlugin; + + // Groovy plugin + if (groovyRemoteFileSourcePluginService != null) { + this.disposables.add(() => { + groovyRemoteFileSourcePluginService.close(); + }); + + this.groovyRemoteFileSourcePluginSubscription = + await this.remoteFileSourceService.registerGroovyPlugin( + session, + groovyRemoteFileSourcePluginService + ); - this.remoteFileSourcePluginSubscription = - await this.remoteFileSourceService.registerPlugin( - session, - remoteFileSourcePlugin - ); + this.disposables.add(this.groovyRemoteFileSourcePluginSubscription); + } + + // Python plugin + if (pythonRemoteFileSourcePlugin != null) { + this.disposables.add(() => { + pythonRemoteFileSourcePlugin.close(); + }); + + this.pythonRemoteFileSourcePluginSubscription = + await this.remoteFileSourceService.registerPythonPlugin( + session, + pythonRemoteFileSourcePlugin + ); - this.disposables.add(this.remoteFileSourcePluginSubscription); + this.disposables.add(this.pythonRemoteFileSourcePluginSubscription); + } }; async getClient(): Promise { @@ -441,13 +477,19 @@ export class DhcService extends DisposableBase implements IDhcService { this.isRunningCode = true; - if (this.remoteFileSourcePluginSubscription != null) { - await this.remoteFileSourceService.setServerExecutionContext( + if (this.pythonRemoteFileSourcePlugin != null) { + await this.remoteFileSourceService.setPythonServerExecutionContext( this.cnId, this.session ); } + if (this.groovyRemoteFileSourcePluginService != null) { + await this.remoteFileSourceService.setGroovyServerExecutionContext( + this.groovyRemoteFileSourcePluginService + ); + } + result = await this.session.runCode(text); this.isRunningCode = false; diff --git a/src/services/FilteredWorkspace.spec.ts b/src/services/FilteredWorkspace.spec.ts index fc6bff769..c8f119e0f 100644 --- a/src/services/FilteredWorkspace.spec.ts +++ b/src/services/FilteredWorkspace.spec.ts @@ -1,9 +1,20 @@ /* eslint-disable @typescript-eslint/naming-convention */ import * as vscode from 'vscode'; import { beforeEach, describe, it, expect, vi } from 'vitest'; -import { FilteredWorkspace, PYTHON_FILE_PATTERN } from './FilteredWorkspace'; -import { getWorkspaceFileUriMap, Toaster, URIMap, URISet } from '../util'; +import { + FilteredWorkspace, + PYTHON_FILE_PATTERN, + PYTHON_IGNORE_TOP_LEVEL_FOLDER_NAMES, +} from './FilteredWorkspace'; +import { + getPythonTopLevelModuleFullname, + getWorkspaceFileUriMap, + Toaster, + URIMap, + URISet, +} from '../util'; import type { + PythonModuleFullname, RemoteImportSourceTreeElement, RemoteImportSourceTreeFileElement, RemoteImportSourceTreeFolderElement, @@ -73,9 +84,18 @@ function element( }; } + if (type === 'languageRoot') { + return { + name, + type: 'languageRoot', + languageId: 'python', + }; + } + if (type === 'topLevelMarkedFolder') { return { name, + languageId: 'python', type: 'topLevelMarkedFolder', uri, isMarked: true, @@ -85,6 +105,7 @@ function element( if (type === 'workspaceRootFolder') { return { name: overrides.name ?? name, + languageId: 'python', type: 'workspaceRootFolder', uri, }; @@ -93,6 +114,7 @@ function element( return { uri, name, + languageId: 'python', type, isMarked: false, ...overrides, @@ -263,7 +285,7 @@ async function initWorkspace(wsMap: URIMap): Promise<{ expectedNodes: RemoteImportSourceTreeElement[], message?: string ): void; - workspace: FilteredWorkspace; + workspace: FilteredWorkspace; }> { vi.mocked(getWorkspaceFileUriMap).mockResolvedValue(wsMap); @@ -271,6 +293,9 @@ async function initWorkspace(wsMap: URIMap): Promise<{ // ensures if any errors are thrown they are caught by the test framework. const workspace = await new FilteredWorkspace( PYTHON_FILE_PATTERN, + 'python', + getPythonTopLevelModuleFullname, + PYTHON_IGNORE_TOP_LEVEL_FOLDER_NAMES, mockToaster ); diff --git a/src/services/FilteredWorkspace.ts b/src/services/FilteredWorkspace.ts index 6bab3b053..c81480f47 100644 --- a/src/services/FilteredWorkspace.ts +++ b/src/services/FilteredWorkspace.ts @@ -1,15 +1,17 @@ import * as vscode from 'vscode'; +import path from 'node:path'; import type { FilePattern, FolderName, - ModuleFullname, + GroovyPackageName, + PythonModuleFullname, + RelativeWsUriString, RemoteImportSourceTreeFileElement, RemoteImportSourceTreeFolderElement, RemoteImportSourceTreeTopLevelMarkedFolderElement, RemoteImportSourceTreeWkspRootFolderElement, } from '../types'; import { - getTopLevelModuleFullname, getWorkspaceFileUriMap, Logger, URIMap, @@ -20,10 +22,13 @@ import { DisposableBase } from './DisposableBase'; const logger = new Logger('FilteredWorkspace'); +export const GROOVY_FILE_PATTERN = '**/*.groovy' as const; export const PYTHON_FILE_PATTERN = '**/*.py' as const; +export const GROOVY_IGNORE_TOP_LEVEL_FOLDER_NAMES = new Set(); + // TODO: This should be configurable DH-20662 -const PYTHON_IGNORE_TOP_LEVEL_FOLDER_NAMES: Set = +export const PYTHON_IGNORE_TOP_LEVEL_FOLDER_NAMES: Set = new Set([ '.venv', 'venv', @@ -50,12 +55,19 @@ type FilteredWorkspaceNode = * Represents a filtered view of a VS Code workspace. Also supports "marking" * folders that can be used for an additional filter layer. */ -export class FilteredWorkspace +export class FilteredWorkspace< + TModuleName extends PythonModuleFullname | GroovyPackageName, + > extends DisposableBase implements vscode.FileDecorationProvider { constructor( readonly filePattern: FilePattern, + readonly languageId: 'groovy' | 'python', + private readonly _getTopLevelModuleName: ( + folderUri: vscode.Uri + ) => TModuleName, + private readonly _ignoreTopLevelFolderNames: Set, private readonly _toaster: Toaster ) { super(); @@ -80,17 +92,11 @@ export class FilteredWorkspace private _onDidUpdate = new vscode.EventEmitter(); readonly onDidUpdate = this._onDidUpdate.event; - private readonly _ignoreTopLevelFolderNames = - PYTHON_IGNORE_TOP_LEVEL_FOLDER_NAMES; - private readonly _childNodeMap = new URIMap>(); private readonly _parentUriMap = new URIMap(); private readonly _nodeMap = new URIMap(); private readonly _rootNodeMap = new URIMap(); - private readonly _topLevelMarkedUriMap = new Map< - ModuleFullname, - vscode.Uri - >(); + private readonly _topLevelMarkedUriMap = new Map(); private _wsFileUriMap = new URIMap(); /** @@ -98,7 +104,7 @@ export class FilteredWorkspace * @param folderUri The folder URI to delete. */ deleteExactTopLevelMarkedUri(folderUri: vscode.Uri): void { - const moduleName = getTopLevelModuleFullname(folderUri); + const moduleName = this._getTopLevelModuleName(folderUri); if ( this._topLevelMarkedUriMap.get(moduleName)?.fsPath === folderUri.fsPath @@ -111,9 +117,11 @@ export class FilteredWorkspace * Mark a folder and all its children. Will also update parent folders if the * changes cause a status change. * @param folderUri The folder URI to mark. + * @param supressNotify If true, will not notify listeners of changes. */ - markFolder(folderUri: vscode.Uri): void { - this.unmarkConflictingTopLevelFolder(folderUri); + markFolder(folderUri: vscode.Uri, supressNotify = false): void { + // supress notify to avoid multiple notifications during marking + this.unmarkConflictingTopLevelFolder(folderUri, true); for (const node of this.iterateNodeTree(folderUri)) { if (node.type === 'workspaceRootFolder') { @@ -124,7 +132,7 @@ export class FilteredWorkspace // If this node is the parent folder being marked, add it to the map if (node.uri.fsPath === folderUri.fsPath) { - const moduleName = getTopLevelModuleFullname(node.uri); + const moduleName = this._getTopLevelModuleName(node.uri); this._topLevelMarkedUriMap.set(moduleName, folderUri); } else { // Since we've marked the parent folder as top-level, remove top-level @@ -133,8 +141,10 @@ export class FilteredWorkspace } } - this._onDidChangeFileDecorations.fire(undefined); - this._onDidUpdate.fire(); + if (!supressNotify) { + this._onDidChangeFileDecorations.fire(undefined); + this._onDidUpdate.fire(); + } } /** @@ -142,10 +152,14 @@ export class FilteredWorkspace * checks for any existing mappings for the same module name as the given * folder URI, and unmarks them. * @param folderUri + * @param supressNotify If true, will not notify listeners of changes. * @returns */ - unmarkConflictingTopLevelFolder(folderUri: vscode.Uri): void { - const moduleName = getTopLevelModuleFullname(folderUri); + unmarkConflictingTopLevelFolder( + folderUri: vscode.Uri, + supressNotify = false + ): void { + const moduleName = this._getTopLevelModuleName(folderUri); const existingTopLevelUri = this._topLevelMarkedUriMap.get(moduleName); const noConflict = existingTopLevelUri == null || @@ -162,7 +176,7 @@ export class FilteredWorkspace return; } - this.unmarkFolder(existingTopLevelUri); + this.unmarkFolder(existingTopLevelUri, supressNotify); const relativePath = vscode.workspace.asRelativePath(folderUri, true); @@ -174,8 +188,9 @@ export class FilteredWorkspace /** * Unmark a folder, its children, and its ancestors. * @param folderUri The folder URI to unmark. + * @param supressNotify If true, will not notify listeners of changes. */ - unmarkFolder(folderUri: vscode.Uri): void { + unmarkFolder(folderUri: vscode.Uri, supressNotify = false): void { for (const node of this.iterateNodeTree(folderUri)) { if (node.type !== 'workspaceRootFolder') { node.isMarked = false; @@ -191,18 +206,21 @@ export class FilteredWorkspace // remaining marked children to re-add as top-level folders for (const childNode of this.getChildNodes(node.uri)) { if (childNode.isMarked) { - this.unmarkConflictingTopLevelFolder(childNode.uri); + // supress notify to avoid multiple notifications during unmarking + this.unmarkConflictingTopLevelFolder(childNode.uri, true); this._topLevelMarkedUriMap.set( - getTopLevelModuleFullname(childNode.uri), + this._getTopLevelModuleName(childNode.uri), childNode.uri ); } } } - this._onDidChangeFileDecorations.fire(undefined); - this._onDidUpdate.fire(); + if (!supressNotify) { + this._onDidChangeFileDecorations.fire(undefined); + this._onDidUpdate.fire(); + } } /** @@ -230,6 +248,29 @@ export class FilteredWorkspace return [...childMap.values()]; } + /** + * Get relative file paths under marked folders. + * @returns The set of relative file paths. + */ + getMarkedRelativeFilePaths(): Set { + const relativeFilePaths = new Set(); + + const topLeveMarkedlUris = [...this._topLevelMarkedUriMap.entries()]; + for (const [, folderUri] of topLeveMarkedlUris) { + for (const node of this.iterateNodeTree(folderUri)) { + if (node.type === 'file') { + const relativePath = path.join( + path.basename(folderUri.fsPath), + path.relative(folderUri.fsPath, node.uri.fsPath) + ); + relativeFilePaths.add(relativePath as RelativeWsUriString); + } + } + } + + return relativeFilePaths; + } + /** * Get top-level marked folder elements in the filtered workspace. * @returns The set of top-level marked folder elements. @@ -240,6 +281,7 @@ export class FilteredWorkspace return topLeveMarkedlUris.map(([name, uri]) => ({ name, type: 'topLevelMarkedFolder', + languageId: this.languageId, isMarked: true, uri, })); @@ -381,8 +423,9 @@ export class FilteredWorkspace /** * Refresh caches based on current workspace state. + * @param supressNotify If true, will not notify listeners of changes. */ - async refresh(): Promise { + async refresh(supressNotify = false): Promise { // Store a map of just the filtered files in the workspace this._wsFileUriMap = await getWorkspaceFileUriMap( this.filePattern, @@ -408,6 +451,7 @@ export class FilteredWorkspace this._updateNodeMaps(null, { name: ws.name, type: 'workspaceRootFolder', + languageId: this.languageId, uri: wsUri, }); @@ -425,6 +469,7 @@ export class FilteredWorkspace this._updateNodeMaps(parentUri, { uri, type: uri.fsPath === fileUri.fsPath ? 'file' : 'folder', + languageId: this.languageId, isMarked: false, name: token, }); @@ -437,14 +482,17 @@ export class FilteredWorkspace // Re-apply top level marked folders if they still exist after refresh for (const uri of this._topLevelMarkedUriMap.values()) { if (this._nodeMap.has(uri)) { - this.markFolder(uri); + // surpress notify to avoid multiple notifications during refresh + this.markFolder(uri, true); } else { this.deleteExactTopLevelMarkedUri(uri); } } - this._onDidChangeFileDecorations.fire(undefined); - this._onDidUpdate.fire(); + if (!supressNotify) { + this._onDidChangeFileDecorations.fire(undefined); + this._onDidUpdate.fire(); + } } /** diff --git a/src/services/RemoteFileSourceService.ts b/src/services/RemoteFileSourceService.ts index 12d6823ee..ed40defc9 100644 --- a/src/services/RemoteFileSourceService.ts +++ b/src/services/RemoteFileSourceService.ts @@ -3,40 +3,87 @@ import type { dh as DhcType } from '@deephaven/jsapi-types'; import { DisposableBase } from './DisposableBase'; import { getSetExecutionContextScript, - getTopLevelModuleFullname, + getPythonTopLevelModuleFullname, Logger, - registerRemoteFileSourcePluginMessageListener, + registerGroovyRemoteFileSourcePluginMessageListener, + registerPythonRemoteFileSourcePluginMessageListener, + getGroovyTopLevelPackageName, } from '../util'; -import type { ModuleFullname, PythonModuleSpecData, UniqueID } from '../types'; +import type { + GroovyPackageName, + GroovyResourceData, + GroovyResourceName, + PythonModuleFullname, + PythonModuleSpecData, + UniqueID, +} from '../types'; import type { FilteredWorkspace } from './FilteredWorkspace'; const logger = new Logger('RemoteFileSourceService'); export class RemoteFileSourceService extends DisposableBase { - constructor(private readonly _pythonWorkspace: FilteredWorkspace) { + constructor( + private readonly _groovyWorkspace: FilteredWorkspace, + private readonly _pythonWorkspace: FilteredWorkspace + ) { super(); + this.disposables.add( + this._groovyWorkspace.onDidUpdate(() => { + this._onDidUpdateGroovyModuleMeta.fire(); + }) + ); + this.disposables.add( this._pythonWorkspace.onDidUpdate(() => { - this._onDidUpdateModuleMeta.fire(); + this._onDidUpdatePythonModuleMeta.fire(); }) ); } - private _onDidUpdateModuleMeta = new vscode.EventEmitter(); - readonly onDidUpdateModuleMeta = this._onDidUpdateModuleMeta.event; + private _onDidUpdateGroovyModuleMeta = new vscode.EventEmitter(); + readonly onDidUpdateGroovyModuleMeta = + this._onDidUpdateGroovyModuleMeta.event; + + private _onDidUpdatePythonModuleMeta = new vscode.EventEmitter(); + readonly onDidUpdatePythonModuleMeta = + this._onDidUpdatePythonModuleMeta.event; /** - * Get the top level Python module names available to the remote file source. + * Get Groovy source for a given resource name. + * @param resourceName The Groovy resource name. + * @returns The Groovy source content. */ - getTopLevelPythonModuleNames(): Set { - const set = new Set(); + getGroovyResourceData( + resourceName: GroovyResourceName + ): GroovyResourceData | null { + const [firstModuleToken, ...restModuleTokens] = resourceName.split('/'); - this._pythonWorkspace.getTopLevelMarkedFolders().forEach(({ uri }) => { - set.add(getTopLevelModuleFullname(uri)); - }); + // Get the top-level folder URI that could contain this module + const topLevelFolderUri = this._groovyWorkspace + .getTopLevelMarkedFolders() + .find( + ({ uri }) => getGroovyTopLevelPackageName(uri) === firstModuleToken + )?.uri; - return set; + if (topLevelFolderUri == null) { + return null; + } + + // Get the full URI for the resource under the top-level folder + const originUri = vscode.Uri.joinPath( + topLevelFolderUri, + `${restModuleTokens.join('/')}` + ); + + if (!this._groovyWorkspace.hasFile(originUri)) { + return null; + } + + return { + name: resourceName, + origin: originUri.fsPath, + }; } /** @@ -45,7 +92,7 @@ export class RemoteFileSourceService extends DisposableBase { * @returns The Python module spec data, or undefined if not found. */ getPythonModuleSpecData( - moduleFullname: ModuleFullname + moduleFullname: PythonModuleFullname ): PythonModuleSpecData | null { const [firstModuleToken, ...restModuleTokens] = moduleFullname.split('.'); @@ -53,7 +100,7 @@ export class RemoteFileSourceService extends DisposableBase { const topLevelFolderUri = this._pythonWorkspace .getTopLevelMarkedFolders() .find( - ({ uri }) => getTopLevelModuleFullname(uri) === firstModuleToken + ({ uri }) => getPythonTopLevelModuleFullname(uri) === firstModuleToken )?.uri; if (topLevelFolderUri == null) { @@ -111,23 +158,68 @@ export class RemoteFileSourceService extends DisposableBase { return null; } + /** + * Get the top level Python module names available to the remote file source. + */ + getPythonTopLevelModuleNames(): Set { + const set = new Set(); + + this._pythonWorkspace.getTopLevelMarkedFolders().forEach(({ uri }) => { + set.add(getPythonTopLevelModuleFullname(uri)); + }); + + return set; + } + + async registerGroovyPlugin( + _session: DhcType.IdeSession, + pluginService: DhcType.remotefilesource.RemoteFileSourceService + ): Promise<() => void> { + const getGroovyResourceData = this.getGroovyResourceData.bind(this); + + const messageSubscription = + registerGroovyRemoteFileSourcePluginMessageListener( + pluginService, + getGroovyResourceData + ); + + const setServerExecutionContext = this.setGroovyServerExecutionContext.bind( + this, + pluginService + ); + + // Set initial top-level module names and subscribe to update on meta changes + await setServerExecutionContext(); + const metaSubscription = this.onDidUpdateGroovyModuleMeta( + setServerExecutionContext + ); + + this.disposables.add(messageSubscription); + + return () => { + messageSubscription(); + metaSubscription.dispose(); + }; + } + /** * Register a session + plugin with the remote file source service. * @param session the ide session * @param plugin the remote file source plugin widget * @returns an unsubscribe function to unregister subscriptions */ - async registerPlugin( + async registerPythonPlugin( session: DhcType.IdeSession, plugin: DhcType.Widget ): Promise<() => void> { const getPythonModuleSpecData = this.getPythonModuleSpecData.bind(this); - const messageSubscription = registerRemoteFileSourcePluginMessageListener( - plugin, - getPythonModuleSpecData - ); + const messageSubscription = + registerPythonRemoteFileSourcePluginMessageListener( + plugin, + getPythonModuleSpecData + ); - const setServerExecutionContext = this.setServerExecutionContext.bind( + const setServerExecutionContext = this.setPythonServerExecutionContext.bind( this, null, session @@ -135,7 +227,7 @@ export class RemoteFileSourceService extends DisposableBase { // Set initial top-level module names and subscribe to update on meta changes await setServerExecutionContext(); - const metaSubscription = this.onDidUpdateModuleMeta( + const metaSubscription = this.onDidUpdatePythonModuleMeta( setServerExecutionContext ); @@ -149,17 +241,29 @@ export class RemoteFileSourceService extends DisposableBase { } /** - * Set the server execution context for the plugin using the given session. + * Set the Groovy server execution context for the plugin. + * @param pluginService The remote file source plugin service. + */ + async setGroovyServerExecutionContext( + pluginService: DhcType.remotefilesource.RemoteFileSourceService + ): Promise { + await pluginService.setExecutionContext([ + ...this._groovyWorkspace.getMarkedRelativeFilePaths(), + ]); + } + + /** + * Set the Python server execution context for the plugin using the given session. * @param connectionId The unique ID of the connection. * @param session The IdeSession to use to run the code. */ - async setServerExecutionContext( + async setPythonServerExecutionContext( connectionId: UniqueID | null, session: DhcType.IdeSession ): Promise { const setExecutionContextScript = getSetExecutionContextScript( connectionId, - this.getTopLevelPythonModuleNames() + this.getPythonTopLevelModuleNames() ); await session.runCode(setExecutionContextScript); diff --git a/src/types/remoteFileSourceTypes.ts b/src/types/remoteFileSourceTypes.ts index df20a3bc2..3a9d0a782 100644 --- a/src/types/remoteFileSourceTypes.ts +++ b/src/types/remoteFileSourceTypes.ts @@ -4,7 +4,9 @@ export type Include = { value: T; include?: boolean }; export type FilePattern = `**/*.${string}`; export type FolderName = Brand<'FolderName', string>; -export type ModuleFullname = Brand<'ModuleFullname', string>; +export type GroovyPackageName = Brand<'GroovyPackageName', string>; +export type GroovyResourceName = Brand<'GroovyResourceName', string>; +export type PythonModuleFullname = Brand<'ModuleFullname', string>; export type RelativeWsUriString = Brand<'RelativeWsUriString', string>; interface JsonRpcRequestBase { @@ -15,7 +17,7 @@ interface JsonRpcRequestBase { export interface JsonRpcFetchModuleRequest extends JsonRpcRequestBase { method: 'fetch_module'; // eslint-disable-next-line @typescript-eslint/naming-convention - params: { module_name: ModuleFullname }; + params: { module_name: PythonModuleFullname }; } export interface JsonRpcSetConnectionIdRequest extends JsonRpcRequestBase { @@ -45,14 +47,14 @@ export interface JsonRpcError { export type JsonRpcResponse = JsonRpcSuccess | JsonRpcError; export interface PythonRegularPackageSpecData { - name: ModuleFullname; + name: PythonModuleFullname; isPackage: true; origin: string; subModuleSearchLocations: string[]; } export interface PythonNamespacePackageSpecData { - name: ModuleFullname; + name: PythonModuleFullname; isPackage: true; subModuleSearchLocations: string[]; @@ -61,7 +63,7 @@ export interface PythonNamespacePackageSpecData { } export interface PythonRegularModuleSpecData { - name: ModuleFullname; + name: PythonModuleFullname; isPackage: false; origin: string; @@ -69,13 +71,18 @@ export interface PythonRegularModuleSpecData { subModuleSearchLocations?: never; } +export interface GroovyResourceData { + name: GroovyResourceName; + origin: string; +} + export type PythonModuleSpecData = | PythonRegularPackageSpecData | PythonNamespacePackageSpecData | PythonRegularModuleSpecData; export interface PythonModuleSpecDataResult { - name: ModuleFullname; + name: PythonModuleFullname; origin?: string; /* eslint-disable @typescript-eslint/naming-convention */ is_package: boolean; diff --git a/src/types/remoteImportSourceTreeTypes.ts b/src/types/remoteImportSourceTreeTypes.ts index 4f21c7539..7f767c155 100644 --- a/src/types/remoteImportSourceTreeTypes.ts +++ b/src/types/remoteImportSourceTreeTypes.ts @@ -5,15 +5,23 @@ export interface RemoteImportSourceTreeRootElement { type: 'root'; } +export interface RemoteImportSourceTreeLanguageRootElement { + name: string; + type: 'languageRoot'; + languageId: string; +} + export interface RemoteImportSourceTreeWkspRootFolderElement { name: string; type: 'workspaceRootFolder'; + languageId: string; uri: vscode.Uri; } export interface RemoteImportSourceTreeTopLevelMarkedFolderElement { name: string; type: 'topLevelMarkedFolder'; + languageId: string; isMarked: true; uri: vscode.Uri; } @@ -21,6 +29,7 @@ export interface RemoteImportSourceTreeTopLevelMarkedFolderElement { export interface RemoteImportSourceTreeFileElement { name: string; type: 'file'; + languageId: string; isMarked: boolean; uri: vscode.Uri; } @@ -28,14 +37,16 @@ export interface RemoteImportSourceTreeFileElement { export interface RemoteImportSourceTreeFolderElement { name: string; type: 'folder'; + languageId: string; isMarked: boolean; uri: vscode.Uri; } export type RemoteImportSourceTreeElement = | RemoteImportSourceTreeRootElement - | RemoteImportSourceTreeTopLevelMarkedFolderElement + | RemoteImportSourceTreeLanguageRootElement | RemoteImportSourceTreeWkspRootFolderElement + | RemoteImportSourceTreeTopLevelMarkedFolderElement | RemoteImportSourceTreeFileElement | RemoteImportSourceTreeFolderElement; diff --git a/src/util/__snapshots__/remoteFileSourceUtils.spec.ts.snap b/src/util/__snapshots__/remoteFileSourceUtils.spec.ts.snap index 5b9ab54c8..fd39a9f31 100644 --- a/src/util/__snapshots__/remoteFileSourceUtils.spec.ts.snap +++ b/src/util/__snapshots__/remoteFileSourceUtils.spec.ts.snap @@ -26,7 +26,7 @@ exports[`getFileTreeItem > should return a TreeItem for a file element 1`] = ` exports[`getFolderTreeItem > should return a TreeItem for a folder element. isMarked:false 1`] = ` { "collapsibleState": 1, - "contextValue": "canAddRemoteFileSource", + "contextValue": "canAddRemoteFileSource:python", "iconPath": ThemeIcon { "color": undefined, "id": "folder", @@ -43,7 +43,7 @@ exports[`getFolderTreeItem > should return a TreeItem for a folder element. isMa exports[`getFolderTreeItem > should return a TreeItem for a folder element. isMarked:true 1`] = ` { "collapsibleState": 1, - "contextValue": "canRemoveRemoteFileSource", + "contextValue": "canRemoveRemoteFileSource:python", "iconPath": ThemeIcon { "color": undefined, "id": "folder", @@ -60,7 +60,7 @@ exports[`getFolderTreeItem > should return a TreeItem for a folder element. isMa exports[`getTopLevelMarkedFolderTreeItem > should return a TreeItem for a top-level marked folder element 1`] = ` { "collapsibleState": 0, - "contextValue": "canRemoveRemoteFileSource", + "contextValue": "canRemoveRemoteFileSource:python", "description": undefined, "iconPath": ThemeIcon { "color": undefined, diff --git a/src/util/remoteFileSourceMsgUtils.spec.ts b/src/util/remoteFileSourceMsgUtils.spec.ts index 471a131fb..fe623d873 100644 --- a/src/util/remoteFileSourceMsgUtils.spec.ts +++ b/src/util/remoteFileSourceMsgUtils.spec.ts @@ -4,10 +4,10 @@ import { moduleSpecErrorResponse, setConnectionIdRequest, } from './remoteFileSourceMsgUtils'; -import type { ModuleFullname, UniqueID } from '../types'; +import type { PythonModuleFullname, UniqueID } from '../types'; const mockMsgId = 'mock.msgId'; -const mockModuleName = 'mock.module' as ModuleFullname; +const mockModuleName = 'mock.module' as PythonModuleFullname; describe('moduleSpecResponse', () => { it('should return correct JSON-RPC success response', () => { diff --git a/src/util/remoteFileSourceMsgUtils.ts b/src/util/remoteFileSourceMsgUtils.ts index 431122951..855fa52b7 100644 --- a/src/util/remoteFileSourceMsgUtils.ts +++ b/src/util/remoteFileSourceMsgUtils.ts @@ -2,7 +2,7 @@ import type { JsonRpcError, JsonRpcSetConnectionIdRequest, JsonRpcSuccess, - ModuleFullname, + PythonModuleFullname, PythonModuleSpecData, PythonModuleSpecDataResult, UniqueID, @@ -43,7 +43,7 @@ export function moduleSpecResponse( */ export function moduleSpecErrorResponse( id: string, - moduleName: ModuleFullname + moduleName: PythonModuleFullname ): JsonRpcError { return { jsonrpc: '2.0', diff --git a/src/util/remoteFileSourceUtils.spec.ts b/src/util/remoteFileSourceUtils.spec.ts index d77a5e3cc..ed7dfc549 100644 --- a/src/util/remoteFileSourceUtils.spec.ts +++ b/src/util/remoteFileSourceUtils.spec.ts @@ -6,16 +6,16 @@ import { getFolderTreeItem, getSetExecutionContextScript, getTopLevelMarkedFolderTreeItem, - getTopLevelModuleFullname, + getPythonTopLevelModuleFullname, hasPythonPluginVariable, - registerRemoteFileSourcePluginMessageListener, + registerPythonRemoteFileSourcePluginMessageListener, sendWidgetMessageAsync, } from './remoteFileSourceUtils'; import type { JsonRpcFetchModuleRequest, JsonRpcRequest, JsonRpcResponse, - ModuleFullname, + PythonModuleFullname, PythonModuleSpecData, RemoteImportSourceTreeFileElement, RemoteImportSourceTreeFolderElement, @@ -69,6 +69,7 @@ describe('getFolderTreeItem', () => { name: 'mockFolder', isMarked, uri: vscode.Uri.parse('file:///mock/folder/path/'), + languageId: 'python', } as RemoteImportSourceTreeFolderElement; expect(getFolderTreeItem(element)).toMatchSnapshot(); @@ -97,6 +98,7 @@ describe('getTopLevelMarkedFolderTreeItem', () => { it('should return a TreeItem for a top-level marked folder element', () => { const element = { uri: vscode.Uri.parse('file:///mock/top/level/marked/folder/'), + languageId: 'python', } as RemoteImportSourceTreeTopLevelMarkedFolderElement; expect(getTopLevelMarkedFolderTreeItem(element)).toMatchSnapshot(); @@ -110,7 +112,7 @@ describe('getTopLevelModuleFullname', () => { ])( 'should return the top-level module fullname for a given folder URI: %s', (uriPath, expectedModuleName) => { - const result = getTopLevelModuleFullname(vscode.Uri.parse(uriPath)); + const result = getPythonTopLevelModuleFullname(vscode.Uri.parse(uriPath)); expect(result).toBe(expectedModuleName); } ); @@ -155,9 +157,11 @@ describe('hasPythonPluginVariable', () => { describe('registerRemoteFileSourcePluginMessageListener', () => { const getPythonModuleSpecData = - vi.fn<(moduleFullname: ModuleFullname) => PythonModuleSpecData | null>(); + vi.fn< + (moduleFullname: PythonModuleFullname) => PythonModuleSpecData | null + >(); - const mockModuleName = 'a.b.c' as ModuleFullname; + const mockModuleName = 'a.b.c' as PythonModuleFullname; const mockFile = { exists: { @@ -178,7 +182,7 @@ describe('registerRemoteFileSourcePluginMessageListener', () => { id: '1', method: 'fetch_module', // eslint-disable-next-line @typescript-eslint/naming-convention - params: { module_name: moduleName as ModuleFullname }, + params: { module_name: moduleName as PythonModuleFullname }, }), fetchModuleRes: ({ source, @@ -212,7 +216,7 @@ describe('registerRemoteFileSourcePluginMessageListener', () => { mockIncomingMsg(mockMsg.fetchModuleReq(mockModuleName)); getPythonModuleSpecData.mockReturnValueOnce(spec); - registerRemoteFileSourcePluginMessageListener( + registerPythonRemoteFileSourcePluginMessageListener( mockPlugin, getPythonModuleSpecData ); diff --git a/src/util/remoteFileSourceUtils.ts b/src/util/remoteFileSourceUtils.ts index 6c78b0060..ec8a349c0 100644 --- a/src/util/remoteFileSourceUtils.ts +++ b/src/util/remoteFileSourceUtils.ts @@ -4,14 +4,20 @@ import { URIMap } from './maps'; import type { FilePattern, FolderName, + GroovyPackageName, + GroovyResourceData, + GroovyResourceName, Include, JsonRpcRequest, JsonRpcResponse, - ModuleFullname, + PythonModuleFullname, PythonModuleSpecData, RemoteImportSourceTreeFileElement, RemoteImportSourceTreeFolderElement, + RemoteImportSourceTreeLanguageRootElement, + RemoteImportSourceTreeRootElement, RemoteImportSourceTreeTopLevelMarkedFolderElement, + RemoteImportSourceTreeWkspRootFolderElement, UniqueID, } from '../types'; import { withResolvers } from './promiseUtils'; @@ -19,6 +25,7 @@ import { URISet } from './sets'; import { DH_PYTHON_REMOTE_SOURCE_PLUGIN_CLASS, DH_PYTHON_REMOTE_SOURCE_PLUGIN_VARIABLE, + ICON_ID, } from '../common'; import * as Msg from './remoteFileSourceMsgUtils'; import { Logger } from './Logger'; @@ -26,12 +33,14 @@ import { Logger } from './Logger'; const logger = new Logger('remoteFileSourceUtils'); export type PythonModuleWorkspaceMap = URIMap< - Map> + Map> >; export interface PythonModuleMeta { moduleMap: PythonModuleWorkspaceMap; - topLevelModuleNames: URIMap>>; + topLevelModuleNames: URIMap< + Map> + >; } /** @@ -61,6 +70,8 @@ export const DH_PYTHON_REMOTE_SOURCE_PLUGIN_INIT_SCRIPT = [ ' print("Python remote file source plugin not installed")', ].join('\n'); +export const DH_REQUEST_SOURCE_EVENT = 'requestsource' as const; + // Alias for `dh.Widget.EVENT_MESSAGE` to avoid having to pass in a `dh` instance // to util functions that only need the event name. export const DH_WIDGET_EVENT_MESSAGE = 'message' as const; @@ -150,6 +161,7 @@ export function getFileTreeItem({ export function getFolderTreeItem({ name, isMarked, + languageId, uri, }: RemoteImportSourceTreeFolderElement): vscode.TreeItem { return { @@ -157,12 +169,50 @@ export function getFolderTreeItem({ collapsibleState: vscode.TreeItemCollapsibleState.Collapsed, resourceUri: uri, contextValue: isMarked - ? 'canRemoveRemoteFileSource' - : 'canAddRemoteFileSource', + ? `canRemoveRemoteFileSource:${languageId}` + : `canAddRemoteFileSource:${languageId}`, iconPath: new vscode.ThemeIcon('folder'), }; } +/** + * Get `TreeItem` for a language root element in the remote import source tree. + * @param element The language root element. + * @returns TreeItem for the language root + */ +export function getLanguageRootTreeItem({ + name, + languageId, +}: RemoteImportSourceTreeLanguageRootElement): vscode.TreeItem { + return { + label: name, + contextValue: 'languageRoot', + collapsibleState: vscode.TreeItemCollapsibleState.Collapsed, + iconPath: + languageId === 'python' + ? new vscode.ThemeIcon(ICON_ID.python) + : languageId === 'groovy' + ? new vscode.ThemeIcon(ICON_ID.groovy) + : undefined, + }; +} + +/** + * Get `TreeItem` for a root element in the remote import source tree. + * @param element The root element. + * @returns TreeItem for the root + */ +export function getRootTreeItem({ + name, + type, +}: RemoteImportSourceTreeRootElement): vscode.TreeItem { + return { + label: name, + contextValue: type, + collapsibleState: vscode.TreeItemCollapsibleState.Expanded, + }; +} + /** * Get `TreeItem` for a top-level marked folder element in the remote import * source tree. @@ -171,27 +221,64 @@ export function getFolderTreeItem({ */ export function getTopLevelMarkedFolderTreeItem({ uri, + languageId, }: RemoteImportSourceTreeTopLevelMarkedFolderElement): vscode.TreeItem { return { label: uri.path.split('/').at(-1), description: vscode.workspace.asRelativePath(uri, true), - contextValue: 'canRemoveRemoteFileSource', + contextValue: `canRemoveRemoteFileSource:${languageId}`, resourceUri: uri, - iconPath: new vscode.ThemeIcon('dh-python'), + iconPath: + languageId === 'groovy' + ? new vscode.ThemeIcon(ICON_ID.groovy) + : new vscode.ThemeIcon(ICON_ID.python), collapsibleState: vscode.TreeItemCollapsibleState.None, }; } +/** + * Get `TreeItem` for a workspace folder root element in the remote import + * source tree. + * @param element The workspace folder root element. + * @returns TreeItem for the workspace folder root + */ +export function getWorkspaceFolderRootTreeItem({ + name, +}: RemoteImportSourceTreeWkspRootFolderElement): vscode.TreeItem { + return { + label: name, + collapsibleState: vscode.TreeItemCollapsibleState.Collapsed, + }; +} + +/** + * Get the top-level Groovy package name for a given folder URI. It will be the + * last segment of the folder path. + * @param folderUri The folder URI. + * @returns The top-level package name. + */ +export function getGroovyTopLevelPackageName( + folderUri: vscode.Uri +): GroovyPackageName { + return folderUri.path + .replace(/\/$/, '') + .split('/') + .at(-1) as GroovyPackageName; +} + /** * Get the top-level Python module name for a given folder URI. It will be the * last segment of the folder path. * @param folderUri The folder URI. * @returns The top-level module name. */ -export function getTopLevelModuleFullname( +export function getPythonTopLevelModuleFullname( folderUri: vscode.Uri -): ModuleFullname { - return folderUri.path.replace(/\/$/, '').split('/').at(-1) as ModuleFullname; +): PythonModuleFullname { + return folderUri.path + .replace(/\/$/, '') + .split('/') + .at(-1) as PythonModuleFullname; } /** @@ -259,6 +346,47 @@ export function hasPythonPluginVariable( ); } +/** + * Register a message listener on the Groovy remote file source plugin to + * handle requests. + * @param pluginService The remote file source plugin service. + * @returns a function to unregister the listener + */ +export function registerGroovyRemoteFileSourcePluginMessageListener( + pluginService: DhcType.remotefilesource.RemoteFileSourceService, + getGroovyResourceData: ( + resourceName: GroovyResourceName + ) => GroovyResourceData | null +): () => void { + return pluginService.addEventListener( + DH_REQUEST_SOURCE_EVENT, + async ({ detail }) => { + logger.info( + 'Received resource request from server:', + detail.resourceName + ); + + const resourceData = getGroovyResourceData( + detail.resourceName as GroovyResourceName + ); + + let source: string | undefined; + if (resourceData?.origin != null) { + const textDoc = await vscode.workspace.openTextDocument( + resourceData.origin + ); + source = textDoc.getText(); + } + + if (source == null) { + logger.error('Resource source not found:', detail.resourceName); + } + + detail.respond(source); + } + ); +} + /** * Register a message listener on the remote file source plugin to handle requests. * @param plugin the remote file source plugin widget @@ -266,10 +394,10 @@ export function hasPythonPluginVariable( * for a given module fullname * @returns a function to unregister the listener */ -export function registerRemoteFileSourcePluginMessageListener( +export function registerPythonRemoteFileSourcePluginMessageListener( plugin: DhcType.Widget, getPythonModuleSpecData: ( - moduleFullname: ModuleFullname + moduleFullname: PythonModuleFullname ) => PythonModuleSpecData | null ): () => void { return plugin.addEventListener(