diff --git a/.eslintrc.json b/.eslintrc.json index d3c9ddca..16a99626 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -19,6 +19,11 @@ "overrides": [ { "files": ["*.ts"], + // Since parserOptions.project is used, can't include files outside of + // the listed tsconfig files and seems silly to add a tsconfig just for + // the one file. It will still be linted by the base config above. It just + // won't be included in this overrides section. + "excludedFiles": ["vitest.config.ts"], "parserOptions": { "project": [ "./tsconfig.json", @@ -29,7 +34,8 @@ "rules": { "no-return-await": "off", - "@typescript-eslint/return-await": "error" + "@typescript-eslint/return-await": "error", + "@typescript-eslint/explicit-function-return-type": "error" } } ], diff --git a/.vscodeignore b/.vscodeignore index 64b7931e..fd7926a8 100644 --- a/.vscodeignore +++ b/.vscodeignore @@ -7,8 +7,10 @@ src/** .yarnrc vsc-extension-quickstart.md **/tsconfig.json +**/tsconfig.unit.json **/.eslintrc.json **/*.map **/*.ts +**/__mocks__/** e2e/** test-reports/** \ No newline at end of file diff --git a/e2e/testUtils.ts b/e2e/testUtils.ts index cad6435b..edfac7ee 100644 --- a/e2e/testUtils.ts +++ b/e2e/testUtils.ts @@ -11,7 +11,9 @@ export const PYTHON_AND_GROOVY_SERVER_CONFIG = [ /** * Find the connection status bar item if it is visible. */ -export async function findConnectionStatusBarItem() { +export async function findConnectionStatusBarItem(): Promise< + WebdriverIO.Element | undefined +> { const workbench = await browser.getWorkbench(); return workbench.getStatusBar().getItem( @@ -23,7 +25,7 @@ export async function findConnectionStatusBarItem() { /** * Check if the connection status bar item is visible. */ -export async function hasConnectionStatusBarItem() { +export async function hasConnectionStatusBarItem(): Promise { return (await findConnectionStatusBarItem()) != null; } diff --git a/e2e/tsconfig.json b/e2e/tsconfig.json index f44612e6..200d0077 100644 --- a/e2e/tsconfig.json +++ b/e2e/tsconfig.json @@ -14,6 +14,7 @@ "moduleResolution": "node", "esModuleInterop": true }, - // Override ../tsconfig.json `exclude` + // Override ../tsconfig.json `include` and `exclude` + "include": ["."], "exclude": ["node_modules"] } diff --git a/src/__mocks__/vscode.ts b/src/__mocks__/vscode.ts new file mode 100644 index 00000000..f77f22b8 --- /dev/null +++ b/src/__mocks__/vscode.ts @@ -0,0 +1,10 @@ +/** + * Mock `vscode` module. Note that `vi.mock('vscode')` has to be explicitly + * called in any test module needing to use this mock. It will not be loaded + * automatically. + */ +import { vi } from 'vitest'; + +export const workspace = { + getConfiguration: vi.fn().mockReturnValue(new Map()), +}; diff --git a/src/dh/dhc.ts b/src/dh/dhc.ts index 81439eaf..6d2eaf0e 100644 --- a/src/dh/dhc.ts +++ b/src/dh/dhc.ts @@ -26,7 +26,7 @@ export function getEmbedWidgetUrl( serverUrl: string, title: string, psk?: string -) { +): string { serverUrl = serverUrl.replace(/\/$/, ''); return `${serverUrl}/iframe/widget/?name=${title}${psk ? `&psk=${psk}` : ''}`; } diff --git a/src/extension.ts b/src/extension.ts index 1ba7cfc5..4f8aece0 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,354 +1,10 @@ import * as vscode from 'vscode'; -import { - ConnectionOption, - ExtendedMap, - createConnectStatusBarItem, - createConnectTextAndTooltip, - createConnectionOptions, - createConnectionQuickPick, - getTempDir, - Logger, - Toaster, -} from './util'; -import { DhcService, RunCommandCodeLensProvider } from './services'; -import { DhServiceRegistry } from './services'; -import { - DOWNLOAD_LOGS_CMD, - RUN_CODE_COMMAND, - RUN_SELECTION_COMMAND, - SELECT_CONNECTION_COMMAND, -} from './common'; -import { OutputChannelWithHistory } from './util/OutputChannelWithHistory'; +import { ExtensionController } from './services'; -const logger = new Logger('extension'); +export function activate(context: vscode.ExtensionContext): void { + const controller = new ExtensionController(context); -export function activate(context: vscode.ExtensionContext) { - let selectedConnectionUrl: string | null = null; - let selectedDhService: DhcService | null = null; - - // Register code lenses for running Deephaven code - const codelensProvider = new RunCommandCodeLensProvider(); - context.subscriptions.push( - vscode.languages.registerCodeLensProvider('groovy', codelensProvider), - vscode.languages.registerCodeLensProvider('python', codelensProvider) - ); - - const diagnosticsCollection = - vscode.languages.createDiagnosticCollection('python'); - - const outputChannel = vscode.window.createOutputChannel('Deephaven', 'log'); - const debugOutputChannel = new OutputChannelWithHistory( - context, - vscode.window.createOutputChannel('Deephaven Debug', 'log') - ); - const toaster = new Toaster(); - - // Configure log handlers - Logger.addConsoleHandler(); - Logger.addOutputChannelHandler(debugOutputChannel); - - logger.info( - 'Congratulations, your extension "vscode-deephaven" is now active!' - ); - - let connectionOptions = createConnectionOptions(); - - outputChannel.appendLine('Deephaven extension activated'); - - // Update connection options when configuration changes - vscode.workspace.onDidChangeConfiguration( - () => { - outputChannel.appendLine('Configuration changed'); - connectionOptions = createConnectionOptions(); - }, - null, - context.subscriptions - ); - - // Clear diagnostics on save - vscode.workspace.onDidSaveTextDocument(doc => { - diagnosticsCollection.set(doc.uri, []); - }); - - const dhcServiceRegistry = new DhServiceRegistry( - DhcService, - new ExtendedMap(), - diagnosticsCollection, - outputChannel, - toaster - ); - - dhcServiceRegistry.addEventListener('disconnect', serverUrl => { - toaster.info(`Disconnected from Deephaven server: ${serverUrl}`); - clearConnection(); - }); - - /* - * Clear connection data - */ - async function clearConnection() { - selectedConnectionUrl = null; - selectedDhService = null; - const { text, tooltip } = createConnectTextAndTooltip('disconnected'); - connectStatusBarItem.text = text; - connectStatusBarItem.tooltip = tooltip; - await dhcServiceRegistry.clearCache(); - } - - /** - * Get currently active DH service. - * @autoActivate If true, auto-activate a service if none is active. - */ - async function getActiveDhService( - autoActivate: boolean, - languageId?: string - ): Promise { - if (!autoActivate || languageId == null) { - return selectedDhService; - } - - const selectedConsoleType = connectionOptions.find( - c => c.url === selectedConnectionUrl - )?.consoleType; - - // If console type of current selection doesn't match the language id, look - // for the first one that does and select it. - if (selectedConsoleType !== languageId) { - const toConnectUrl = - connectionOptions.find(c => c.consoleType === languageId)?.url ?? null; - - if (toConnectUrl == null) { - toaster.error( - `No Deephaven server configured for console type: '${languageId}'` - ); - } - - await onConnectionSelected(toConnectUrl); - } - - return selectedDhService; - } - - /** Register extension commands */ - const { downloadLogsCmd, runCodeCmd, runSelectionCmd, selectConnectionCmd } = - registerCommands( - () => connectionOptions, - getActiveDhService, - onConnectionSelected, - onDownloadLogs - ); - - const connectStatusBarItem = createConnectStatusBarItem( - shouldShowConnectionStatusBarItem() - ); - - // Toggle visibility of connection status bar item based on whether the - // languageid is supported by DH - vscode.window.onDidChangeActiveTextEditor(() => { - updateConnectionStatusBarItemVisibility(); - }); - vscode.workspace.onDidChangeConfiguration(() => { - updateConnectionStatusBarItemVisibility(); - }); - // Handle scenarios such as languageId change within an already open document - vscode.workspace.onDidOpenTextDocument(() => { - updateConnectionStatusBarItemVisibility(); - }); - - context.subscriptions.push( - debugOutputChannel, - downloadLogsCmd, - dhcServiceRegistry, - outputChannel, - runCodeCmd, - runSelectionCmd, - selectConnectionCmd, - connectStatusBarItem - ); - - // recreate tmp dir that will be used to dowload JS Apis - getTempDir(true /*recreate*/); - - /** - * Handle download logs command - */ - async function onDownloadLogs() { - const uri = await debugOutputChannel.downloadHistoryToFile(); - - if (uri != null) { - toaster.info(`Downloaded logs to ${uri.fsPath}`); - vscode.window.showTextDocument(uri); - } - } - - /** - * Only show connection status bar item if either: - * 1. A connection is already selected or - * 2. The active text editor has a languageid that is supported by the currently - * configured server connections. - */ - function shouldShowConnectionStatusBarItem(): boolean { - if (selectedDhService != null) { - return true; - } - - if ( - vscode.window.activeTextEditor?.document.languageId === 'python' && - connectionOptions.some(c => c.consoleType === 'python') - ) { - return true; - } - - if ( - vscode.window.activeTextEditor?.document.languageId === 'groovy' && - connectionOptions.some(c => c.consoleType === 'groovy') - ) { - return true; - } - - return false; - } - - function updateConnectionStatusBarItemVisibility(): void { - if (shouldShowConnectionStatusBarItem()) { - connectStatusBarItem.show(); - } else { - connectStatusBarItem.hide(); - } - } - - /** - * Handle connection selection - */ - async function onConnectionSelected(connectionUrl: string | null) { - // Show the output panel whenever we select a connection. This is a little - // friendlier to the user instead of it opening when the extension activates - // for cases where the user isn't working with DH server - outputChannel.show(true); - - outputChannel.appendLine( - connectionUrl == null - ? 'Disconnecting' - : `Selecting connection: ${connectionUrl}` - ); - - const option = connectionOptions.find( - option => option.url === connectionUrl - ); - - // Disconnect option was selected, or connectionUrl that no longer exists - if (connectionUrl == null || !option) { - clearConnection(); - updateConnectionStatusBarItemVisibility(); - return; - } - - const { text, tooltip } = createConnectTextAndTooltip('connecting', option); - connectStatusBarItem.text = text; - connectStatusBarItem.tooltip = tooltip; - - selectedConnectionUrl = connectionUrl; - selectedDhService = await dhcServiceRegistry.get(selectedConnectionUrl); - - if (selectedDhService.isInitialized || (await selectedDhService.initDh())) { - const { text, tooltip } = createConnectTextAndTooltip( - 'connected', - option - ); - connectStatusBarItem.text = text; - connectStatusBarItem.tooltip = tooltip; - outputChannel.appendLine(`Initialized: ${selectedConnectionUrl}`); - } else { - clearConnection(); - } - - updateConnectionStatusBarItemVisibility(); - } + context.subscriptions.push(controller); } -export function deactivate() {} - -/** - * Get a `TextEditor` containing the given uri. If there is one already open, - * it will be returned. Otherwise, a new one will be opened. The returned editor - * will become the active editor if it is not already. - * @param uri - */ -async function getEditorForUri(uri: vscode.Uri): Promise { - if ( - uri.toString() === vscode.window.activeTextEditor?.document.uri.toString() - ) { - return vscode.window.activeTextEditor; - } - - const viewColumn = vscode.window.visibleTextEditors.find( - editor => editor.document.uri.toString() === uri.toString() - )?.viewColumn; - - // If another panel such as the output panel is active, set the document - // for the url to active first - // https://stackoverflow.com/a/64808497/20489 - return vscode.window.showTextDocument(uri, { preview: false, viewColumn }); -} - -/** Register commands for the extension. */ -function registerCommands( - getConnectionOptions: () => ConnectionOption[], - getActiveDhService: ( - autoActivate: boolean, - languageId?: string - ) => Promise, - onConnectionSelected: (connectionUrl: string | null) => void, - onDownloadLogs: () => void -) { - const downloadLogsCmd = vscode.commands.registerCommand( - DOWNLOAD_LOGS_CMD, - onDownloadLogs - ); - - /** Run all code in active editor */ - const runCodeCmd = vscode.commands.registerCommand( - RUN_CODE_COMMAND, - async (uri: vscode.Uri, _arg: { groupId: number }) => { - const editor = await getEditorForUri(uri); - const dhService = await getActiveDhService( - true, - editor.document.languageId - ); - dhService?.runEditorCode(editor); - } - ); - - /** Run selected code in active editor */ - const runSelectionCmd = vscode.commands.registerCommand( - RUN_SELECTION_COMMAND, - async (uri: vscode.Uri, _arg: { groupId: number }) => { - const editor = await getEditorForUri(uri); - const dhService = await getActiveDhService( - true, - editor.document.languageId - ); - dhService?.runEditorCode(editor, true); - } - ); - - /** Select connection to run scripts against */ - const selectConnectionCmd = vscode.commands.registerCommand( - SELECT_CONNECTION_COMMAND, - async () => { - const dhService = await getActiveDhService(false); - - const result = await createConnectionQuickPick( - getConnectionOptions(), - dhService?.serverUrl - ); - if (!result) { - return; - } - - onConnectionSelected(result.url); - } - ); - - return { downloadLogsCmd, runCodeCmd, runSelectionCmd, selectConnectionCmd }; -} +export function deactivate(): void {} diff --git a/src/services/Config.spec.ts b/src/services/Config.spec.ts index 32c2eed2..67d3524f 100644 --- a/src/services/Config.spec.ts +++ b/src/services/Config.spec.ts @@ -3,11 +3,8 @@ import { beforeEach, describe, expect, it, Mock, vi } from 'vitest'; import { Config } from './Config'; import { CONFIG_CORE_SERVERS } from '../common'; -vi.mock('vscode', () => ({ - workspace: { - getConfiguration: vi.fn().mockReturnValue(new Map()), - }, -})); +// See __mocks__/vscode.ts for the mock implementation +vi.mock('vscode'); let configMap: Map; diff --git a/src/services/Config.ts b/src/services/Config.ts index 58488eac..6cf00917 100644 --- a/src/services/Config.ts +++ b/src/services/Config.ts @@ -11,7 +11,7 @@ import { InvalidConsoleTypeError, Logger } from '../util'; const logger = new Logger('Config'); -function getConfig() { +function getConfig(): vscode.WorkspaceConfiguration { return vscode.workspace.getConfiguration(CONFIG_KEY); } diff --git a/src/services/DhcService.ts b/src/services/DhcService.ts index 9b866c76..2399a9e9 100644 --- a/src/services/DhcService.ts +++ b/src/services/DhcService.ts @@ -16,7 +16,7 @@ const logger = new Logger('DhcService'); export class DhcService extends DhService { private psk?: string; - protected async initApi() { + protected async initApi(): Promise { return initDhcApi(this.serverUrl); } diff --git a/src/services/ExtensionController.ts b/src/services/ExtensionController.ts new file mode 100644 index 00000000..5f2c2fbd --- /dev/null +++ b/src/services/ExtensionController.ts @@ -0,0 +1,405 @@ +import * as vscode from 'vscode'; +import { + Disposable, + DOWNLOAD_LOGS_CMD, + RUN_CODE_COMMAND, + RUN_SELECTION_COMMAND, + SELECT_CONNECTION_COMMAND, +} from '../common'; +import { + assertDefined, + ConnectionOption, + createConnectionOptions, + createConnectionQuickPick, + createConnectStatusBarItem, + ExtendedMap, + getEditorForUri, + getTempDir, + Logger, + OutputChannelWithHistory, + shouldShowConnectionStatusBarItem, + Toaster, + updateConnectionStatusBarItem, +} from '../util'; +import { RunCommandCodeLensProvider } from './RunCommandCodeLensProvider'; +import { DhServiceRegistry } from './DhServiceRegistry'; +import { DhService } from './DhService'; +import { DhcService } from './DhcService'; +import { Config } from './Config'; + +const logger = new Logger('ExtensionController'); + +export class ExtensionController implements Disposable { + constructor(private context: vscode.ExtensionContext) { + this.initializeDiagnostics(); + this.initializeConfig(); + this.initializeCodeLenses(); + this.initializeOutputChannelsAndLogger(); + this.initializeDHServiceRegistry(); + this.initializeTempDirectory(); + this.initializeConnectionOptions(); + this.initializeConnectionStatusBarItem(); + this.initializeCommands(); + + logger.info( + 'Congratulations, your extension "vscode-deephaven" is now active!' + ); + this.outputChannel?.appendLine('Deephaven extension activated'); + } + + dispose(): Promise { + return this.clearConnection(); + } + + selectedConnectionUrl: string | null = null; + selectedDhService: DhService | null = null; + dhcServiceRegistry: DhServiceRegistry | null = null; + + connectionOptions: ConnectionOption[] = []; + connectStatusBarItem: vscode.StatusBarItem | null = null; + + pythonDiagnostics: vscode.DiagnosticCollection | null = null; + outputChannel: vscode.OutputChannel | null = null; + outputChannelDebug: OutputChannelWithHistory | null = null; + toaster = new Toaster(); + + /** + * Initialize code lenses for running Deephaven code. + */ + initializeCodeLenses = (): void => { + const codelensProvider = new RunCommandCodeLensProvider(); + + this.context.subscriptions.push( + vscode.languages.registerCodeLensProvider('groovy', codelensProvider), + vscode.languages.registerCodeLensProvider('python', codelensProvider) + ); + }; + + /** + * Initialize configuration. + */ + initializeConfig = (): void => { + vscode.workspace.onDidChangeConfiguration( + () => { + this.outputChannel?.appendLine('Configuration changed'); + }, + null, + this.context.subscriptions + ); + }; + + /** + * Initialize connection options. + */ + initializeConnectionOptions = (): void => { + this.connectionOptions = createConnectionOptions(Config.getCoreServers()); + + // Update connection options when configuration changes + vscode.workspace.onDidChangeConfiguration( + () => { + this.connectionOptions = createConnectionOptions( + Config.getCoreServers() + ); + }, + null, + this.context.subscriptions + ); + }; + + /** + * Initialize connection status bar item. + */ + initializeConnectionStatusBarItem = (): void => { + this.connectStatusBarItem = createConnectStatusBarItem(false); + this.context.subscriptions.push(this.connectStatusBarItem); + + this.updateConnectionStatusBarItemVisibility(); + + const args = [ + this.updateConnectionStatusBarItemVisibility, + null, + this.context.subscriptions, + ] as const; + + vscode.window.onDidChangeActiveTextEditor(...args); + vscode.workspace.onDidChangeConfiguration(...args); + // Handle scenarios such as languageId change within an already open document + vscode.workspace.onDidOpenTextDocument(...args); + }; + + /** + * Initialize diagnostics collections. + */ + initializeDiagnostics = (): void => { + this.pythonDiagnostics = + vscode.languages.createDiagnosticCollection('python'); + + // Clear diagnostics on save + vscode.workspace.onDidSaveTextDocument( + doc => { + this.pythonDiagnostics?.set(doc.uri, []); + }, + null, + this.context.subscriptions + ); + }; + + /** + * Initialize output channels and Logger. + */ + initializeOutputChannelsAndLogger = (): void => { + this.outputChannel = vscode.window.createOutputChannel('Deephaven', 'log'); + this.outputChannelDebug = new OutputChannelWithHistory( + this.context, + vscode.window.createOutputChannel('Deephaven Debug', 'log') + ); + + Logger.addConsoleHandler(); + Logger.addOutputChannelHandler(this.outputChannelDebug); + + this.context.subscriptions.push( + this.outputChannel, + this.outputChannelDebug + ); + }; + + /** + * Initialize DH service registry. + */ + initializeDHServiceRegistry = (): void => { + assertDefined(this.pythonDiagnostics, 'pythonDiagnostics'); + assertDefined(this.outputChannel, 'outputChannel'); + + this.dhcServiceRegistry = new DhServiceRegistry( + DhcService, + new ExtendedMap(), + this.pythonDiagnostics, + this.outputChannel, + this.toaster + ); + + this.dhcServiceRegistry.addEventListener('disconnect', serverUrl => { + this.toaster.info(`Disconnected from Deephaven server: ${serverUrl}`); + this.clearConnection(); + }); + + this.context.subscriptions.push(this.dhcServiceRegistry); + }; + + /** + * Initialize temp directory. + */ + initializeTempDirectory = (): void => { + // recreate tmp dir that will be used to dowload JS Apis + getTempDir(true /*recreate*/); + }; + + /** + * Register commands for the extension. + */ + initializeCommands = (): void => { + /** Download logs and open in editor */ + this.registerCommand(DOWNLOAD_LOGS_CMD, this.onDownloadLogs); + + /** Run all code in active editor */ + this.registerCommand(RUN_CODE_COMMAND, this.onRunCode); + + /** Run selected code in active editor */ + this.registerCommand(RUN_SELECTION_COMMAND, this.onRunSelectedCode); + + /** Select connection to run scripts against */ + this.registerCommand(SELECT_CONNECTION_COMMAND, this.onSelectConnection); + }; + + /* + * Clear connection data + */ + clearConnection = async (): Promise => { + this.selectedConnectionUrl = null; + this.selectedDhService = null; + + updateConnectionStatusBarItem(this.connectStatusBarItem, 'disconnected'); + + await this.dhcServiceRegistry?.clearCache(); + }; + + /** + * Get currently active DH service. + * @autoActivate If true, auto-activate a service if none is active. + * @languageId Optional language id of the DH service to find. + */ + getActiveDhService = async ( + autoActivate: boolean, + languageId?: string + ): Promise => { + if (!autoActivate || languageId == null) { + return this.selectedDhService; + } + + const selectedConsoleType = this.connectionOptions.find( + c => c.url === this.selectedConnectionUrl + )?.consoleType; + + // If console type of current selection doesn't match the language id, look + // for the first one that does and select it. + if (selectedConsoleType !== languageId) { + const toConnectUrl = + this.connectionOptions.find(c => c.consoleType === languageId)?.url ?? + null; + + if (toConnectUrl == null) { + this.toaster.error( + `No Deephaven server configured for console type: '${languageId}'` + ); + } + + await this.onConnectionSelected(toConnectUrl); + } + + return this.selectedDhService; + }; + + /** + * Handle connection selection + */ + onConnectionSelected = async ( + connectionUrl: string | null + ): Promise => { + assertDefined(this.dhcServiceRegistry, 'dhcServiceRegistry'); + + // Show the output panel whenever we select a connection. This is a little + // friendlier to the user instead of it opening when the extension activates + // for cases where the user isn't working with DH server + this.outputChannel?.show(true); + + this.outputChannel?.appendLine( + connectionUrl == null + ? 'Disconnecting' + : `Selecting connection: ${connectionUrl}` + ); + + const option = this.connectionOptions.find( + option => option.url === connectionUrl + ); + + // Disconnect option was selected, or connectionUrl that no longer exists + if (connectionUrl == null || !option) { + this.clearConnection(); + this.updateConnectionStatusBarItemVisibility(); + return; + } + + updateConnectionStatusBarItem( + this.connectStatusBarItem, + 'connecting', + option + ); + + this.selectedConnectionUrl = connectionUrl; + this.selectedDhService = await this.dhcServiceRegistry.get( + this.selectedConnectionUrl + ); + + if ( + this.selectedDhService.isInitialized || + (await this.selectedDhService.initDh()) + ) { + updateConnectionStatusBarItem( + this.connectStatusBarItem, + 'connected', + option + ); + + this.outputChannel?.appendLine( + `Initialized: ${this.selectedConnectionUrl}` + ); + } else { + this.clearConnection(); + } + + this.updateConnectionStatusBarItemVisibility(); + }; + + /** + * Handle download logs command + */ + onDownloadLogs = async (): Promise => { + assertDefined(this.outputChannelDebug, 'outputChannelDebug'); + + const uri = await this.outputChannelDebug.downloadHistoryToFile(); + + if (uri != null) { + this.toaster.info(`Downloaded logs to ${uri.fsPath}`); + vscode.window.showTextDocument(uri); + } + }; + + /** + * Run all code in editor for given uri. + * @param uri + */ + onRunCode = async (uri: vscode.Uri): Promise => { + const editor = await getEditorForUri(uri); + const dhService = await this.getActiveDhService( + true, + editor.document.languageId + ); + dhService?.runEditorCode(editor); + }; + + /** + * Run selected code in editor for given uri. + * @param uri + */ + onRunSelectedCode = async (uri: vscode.Uri): Promise => { + const editor = await getEditorForUri(uri); + const dhService = await this.getActiveDhService( + true, + editor.document.languageId + ); + dhService?.runEditorCode(editor, true); + }; + + /** + * Handle connection selection. + */ + onSelectConnection = async (): Promise => { + const dhService = await this.getActiveDhService(false); + + const result = await createConnectionQuickPick( + this.connectionOptions, + dhService?.serverUrl + ); + if (!result) { + return; + } + + this.onConnectionSelected(result.url); + }; + + /** + * Update status bar item visibility. + */ + updateConnectionStatusBarItemVisibility = (): void => { + if ( + shouldShowConnectionStatusBarItem( + this.connectionOptions, + this.selectedDhService != null + ) + ) { + this.connectStatusBarItem?.show(); + } else { + this.connectStatusBarItem?.hide(); + } + }; + + /** + * Register a command and add it's subscription to the context. + */ + registerCommand = ( + ...args: Parameters + ): void => { + const cmd = vscode.commands.registerCommand(...args); + this.context.subscriptions.push(cmd); + }; +} diff --git a/src/services/index.ts b/src/services/index.ts index bd03c0d1..a28afda3 100644 --- a/src/services/index.ts +++ b/src/services/index.ts @@ -3,4 +3,5 @@ export * from './Config'; export * from './DhService'; export * from './DhcService'; export * from './DhServiceRegistry'; +export * from './ExtensionController'; export * from './RunCommandCodeLensProvider'; diff --git a/src/util/Logger.ts b/src/util/Logger.ts index 85c3784d..81fa6f78 100644 --- a/src/util/Logger.ts +++ b/src/util/Logger.ts @@ -25,7 +25,7 @@ export class Logger { /** * Register log handler that logs to console. */ - static addConsoleHandler = () => { + static addConsoleHandler = (): void => { Logger.handlers.add({ /* eslint-disable no-console */ error: console.error.bind(console), @@ -41,7 +41,9 @@ export class Logger { * Register a log handler that logs to a `vscode.OutputChannel`. * @param outputChannel */ - static addOutputChannelHandler = (outputChannel: vscode.OutputChannel) => { + static addOutputChannelHandler = ( + outputChannel: vscode.OutputChannel + ): void => { Logger.handlers.add({ error: (label, ...args) => outputChannel.appendLine(`${label} ERROR: ${args.join(', ')}`), @@ -63,7 +65,7 @@ export class Logger { * @param level The level to handle * @param args The arguments to log */ - private handle = (level: LogLevel, ...args: unknown[]) => { + private handle = (level: LogLevel, ...args: unknown[]): void => { Logger.handlers.forEach(handler => handler[level](`[${this.label}]`, ...args) ); diff --git a/src/util/OutputChannelWithHistory.ts b/src/util/OutputChannelWithHistory.ts index 16676445..b2336f4c 100644 --- a/src/util/OutputChannelWithHistory.ts +++ b/src/util/OutputChannelWithHistory.ts @@ -35,7 +35,7 @@ export class OutputChannelWithHistory implements vscode.OutputChannel { * * @param value A string, falsy values will be printed. */ - appendLine = (value: string) => { + appendLine = (value: string): void => { this.history.push(value); this.outputChannel.appendLine(value); }; @@ -43,7 +43,7 @@ export class OutputChannelWithHistory implements vscode.OutputChannel { /** * Clear the history. */ - clearHistory = () => { + clearHistory = (): void => { this.history = []; }; diff --git a/src/util/Toaster.ts b/src/util/Toaster.ts index 2eb0c50b..5ebecd50 100644 --- a/src/util/Toaster.ts +++ b/src/util/Toaster.ts @@ -7,7 +7,7 @@ import { DOWNLOAD_LOGS_CMD, DOWNLOAD_LOGS_TEXT } from '../common'; export class Toaster { constructor() {} - error = async (message: string) => { + error = async (message: string): Promise => { const response = await vscode.window.showErrorMessage( message, DOWNLOAD_LOGS_TEXT @@ -19,7 +19,7 @@ export class Toaster { } }; - info = async (message: string) => { + info = async (message: string): Promise => { await vscode.window.showInformationMessage(message); }; } diff --git a/src/util/__snapshots__/uiUtils.spec.ts.snap b/src/util/__snapshots__/uiUtils.spec.ts.snap new file mode 100644 index 00000000..b0f95751 --- /dev/null +++ b/src/util/__snapshots__/uiUtils.spec.ts.snap @@ -0,0 +1,57 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`createConnectTextAndTooltip > should return text and tooltip: 'connected' 1`] = ` +{ + "text": "$(vm-connect) DHC: localhost:10000", + "tooltip": "Connected to http://localhost:10000", +} +`; + +exports[`createConnectTextAndTooltip > should return text and tooltip: 'connecting' 1`] = ` +{ + "text": "$(sync~spin) Deephaven: Connecting...", + "tooltip": "Connecting to http://localhost:10000...", +} +`; + +exports[`createConnectTextAndTooltip > should return text and tooltip: 'disconnected' 1`] = ` +{ + "text": "$(debug-disconnect) Deephaven: Disconnected", + "tooltip": "Connect to Deephaven", +} +`; + +exports[`createConnectionOption > should return connection option: 'DHC', { url: 'http://localhost:10000', consoleType: 'python' } 1`] = ` +{ + "consoleType": "python", + "label": "DHC: localhost:10000", + "type": "DHC", + "url": "http://localhost:10000", +} +`; + +exports[`createConnectionOption > should return connection option: 'DHC', { url: 'http://localhost:10001', consoleType: 'groovy' } 1`] = ` +{ + "consoleType": "groovy", + "label": "DHC: localhost:10001", + "type": "DHC", + "url": "http://localhost:10001", +} +`; + +exports[`createConnectionOptions > should return connection options 1`] = ` +[ + { + "consoleType": "python", + "label": "DHC: localhost:10000", + "type": "DHC", + "url": "http://localhost:10000", + }, + { + "consoleType": "groovy", + "label": "DHC: localhost:10001", + "type": "DHC", + "url": "http://localhost:10001", + }, +] +`; diff --git a/src/util/assertUtil.spec.ts b/src/util/assertUtil.spec.ts new file mode 100644 index 00000000..6e0b3c91 --- /dev/null +++ b/src/util/assertUtil.spec.ts @@ -0,0 +1,22 @@ +import { describe, it, expect } from 'vitest'; +import { assertDefined } from './assertUtil'; + +// Example function tests +describe('assertDefined', () => { + it.each([{}, 'test', 999, true, false, new Date()])( + 'should not throw if value is defined: %s', + value => { + assertDefined(value, 'value'); + expect(true).toBe(true); + } + ); + + it.each([null, undefined])( + 'should throw an error for null or undefined values: %s', + value => { + expect(() => assertDefined(value, 'value')).toThrow( + `'value' is required` + ); + } + ); +}); diff --git a/src/util/assertUtil.ts b/src/util/assertUtil.ts new file mode 100644 index 00000000..fe66d870 --- /dev/null +++ b/src/util/assertUtil.ts @@ -0,0 +1,13 @@ +/** + * Assert that a given value is not `null` or `undefined`. + * @param dependency The value to assert. + * @param name The name of the value to include in the error message if the assertion fails. + */ +export function assertDefined( + dependency: T | null | undefined, + name: string +): asserts dependency is T { + if (dependency == null) { + throw new Error(`'${name}' is required`); + } +} diff --git a/src/util/index.ts b/src/util/index.ts index 6c411a5c..b67fe4f2 100644 --- a/src/util/index.ts +++ b/src/util/index.ts @@ -1,9 +1,11 @@ +export * from './assertUtil'; export * from './downloadUtils'; export * from './errorUtils'; export * from './ErrorTypes'; export * from './ExtendedMap'; export * from './isDisposable'; export * from './Logger'; +export * from './OutputChannelWithHistory'; export * from './panelUtils'; export * from './polyfillUtils'; export * from './Toaster'; diff --git a/src/util/panelUtils.ts b/src/util/panelUtils.ts index fd64ec6b..273f903b 100644 --- a/src/util/panelUtils.ts +++ b/src/util/panelUtils.ts @@ -1,4 +1,4 @@ -export function getPanelHtml(iframeUrl: string, title: string) { +export function getPanelHtml(iframeUrl: string, title: string): string { return ` diff --git a/src/util/polyfillUtils.ts b/src/util/polyfillUtils.ts index d1f97d63..d2eab70c 100644 --- a/src/util/polyfillUtils.ts +++ b/src/util/polyfillUtils.ts @@ -6,7 +6,7 @@ export class CustomEvent extends Event { } } -export function polyfillDh() { +export function polyfillDh(): void { class Event { type: string; detail: unknown; diff --git a/src/util/uiUtils.spec.ts b/src/util/uiUtils.spec.ts new file mode 100644 index 00000000..e96d027e --- /dev/null +++ b/src/util/uiUtils.spec.ts @@ -0,0 +1,82 @@ +import * as vscode from 'vscode'; +import { describe, it, expect, vi } from 'vitest'; +import { + createConnectTextAndTooltip, + ConnectionOption, + createConnectionOptions, + createConnectionOption, + updateConnectionStatusBarItem, +} from './uiUtils'; +import { ConnectionConfig } from '../common'; + +// See __mocks__/vscode.ts for the mock implementation +vi.mock('vscode'); + +const pythonServerConfig: ConnectionConfig = { + url: 'http://localhost:10000', + consoleType: 'python', +}; + +const groovyServerConfig: ConnectionConfig = { + url: 'http://localhost:10001', + consoleType: 'groovy', +}; + +describe('createConnectionOption', () => { + it.each([ + ['DHC', pythonServerConfig], + ['DHC', groovyServerConfig], + ] as const)(`should return connection option: '%s', %s`, (type, config) => { + const actual = createConnectionOption(type)(config); + expect(actual).toMatchSnapshot(); + }); +}); + +describe('createConnectionOptions', () => { + const configs: ConnectionConfig[] = [pythonServerConfig, groovyServerConfig]; + + it('should return connection options', () => { + const actual = createConnectionOptions(configs); + expect(actual).toMatchSnapshot(); + }); +}); + +describe('createConnectTextAndTooltip', () => { + const option: ConnectionOption = { + type: 'DHC', + consoleType: 'python', + label: 'DHC: localhost:10000', + url: 'http://localhost:10000', + }; + + const statuses = ['connecting', 'connected', 'disconnected'] as const; + + it.each(statuses)(`should return text and tooltip: '%s'`, status => { + const actual = createConnectTextAndTooltip(status, option); + expect(actual).toMatchSnapshot(); + }); +}); + +describe('updateConnectionStatusBarItem', () => { + const option: ConnectionOption = { + type: 'DHC', + consoleType: 'python', + label: 'DHC: localhost:10000', + url: 'http://localhost:10000', + }; + + const statuses = ['connecting', 'connected', 'disconnected'] as const; + + it.each(statuses)( + `should update connection status bar item: '%s'`, + status => { + const statusBarItem = {} as vscode.StatusBarItem; + const { text, tooltip } = createConnectTextAndTooltip(status, option); + + updateConnectionStatusBarItem(statusBarItem, status, option); + + expect(statusBarItem.text).toBe(text); + expect(statusBarItem.tooltip).toBe(tooltip); + } + ); +}); diff --git a/src/util/uiUtils.ts b/src/util/uiUtils.ts index ca627b6c..55774053 100644 --- a/src/util/uiUtils.ts +++ b/src/util/uiUtils.ts @@ -8,7 +8,6 @@ import { STATUS_BAR_DISCONNECTED_TEXT, STATUS_BAR_DISCONNECT_TEXT, } from '../common'; -import { Config } from '../services'; export interface ConnectionOption { type: ConnectionType; @@ -60,7 +59,9 @@ export async function createConnectionQuickPick( /** * Create a status bar item for connecting to DH server */ -export function createConnectStatusBarItem(show: boolean) { +export function createConnectStatusBarItem( + show: boolean +): vscode.StatusBarItem { const statusBarItem = vscode.window.createStatusBarItem( vscode.StatusBarAlignment.Left, 100 @@ -97,10 +98,11 @@ export function createConnectionOption(type: ConnectionType) { /** * Create connection options from current extension config. + * @param dhcServerUrls The server urls from the extension config */ -export function createConnectionOptions(): ConnectionOption[] { - const dhcServerUrls = Config.getCoreServers(); - +export function createConnectionOptions( + dhcServerUrls: ConnectionConfig[] +): ConnectionOption[] { const connectionOptions: ConnectionOption[] = [ ...dhcServerUrls.map(createConnectionOption('DHC')), ]; @@ -153,7 +155,7 @@ export function formatConnectionLabel( label: string, isSelected: boolean, consoleType?: ConsoleType -) { +): string { const consoleTypeStr = consoleType ? ` (${consoleType})` : ''; return isSelected ? `$(vm-connect) ${label}${consoleTypeStr}` @@ -173,3 +175,80 @@ export function formatTimestamp(date: Date): string | null { return `${hours}:${minutes}:${seconds}.${milliseconds}`; } + +/** + * Get a `TextEditor` containing the given uri. If there is one already open, + * it will be returned. Otherwise, a new one will be opened. The returned editor + * will become the active editor if it is not already. + * @param uri + */ +export async function getEditorForUri( + uri: vscode.Uri +): Promise { + if ( + uri.toString() === vscode.window.activeTextEditor?.document.uri.toString() + ) { + return vscode.window.activeTextEditor; + } + + const viewColumn = vscode.window.visibleTextEditors.find( + editor => editor.document.uri.toString() === uri.toString() + )?.viewColumn; + + // If another panel such as the output panel is active, set the document + // for the url to active first + // https://stackoverflow.com/a/64808497/20489 + return vscode.window.showTextDocument(uri, { preview: false, viewColumn }); +} + +/** + * Determine whether connection status bar item should be visible. Only show if either: + * 1. A connection is already selected or + * 2. The active text editor has a languageid that is supported by the currently + * configured server connections. + */ +export function shouldShowConnectionStatusBarItem( + connectionOptions: ConnectionOption[], + isAlreadyConnected: boolean +): boolean { + if (isAlreadyConnected) { + return true; + } + + if ( + vscode.window.activeTextEditor?.document.languageId === 'python' && + connectionOptions.some(c => c.consoleType === 'python') + ) { + return true; + } + + if ( + vscode.window.activeTextEditor?.document.languageId === 'groovy' && + connectionOptions.some(c => c.consoleType === 'groovy') + ) { + return true; + } + + return false; +} + +/** + * Update text and tooltip of given status bar item based on connection status + * and optional `ConnectionOption`. + * @param statusBarItem The status bar item to update + * @param status The connection status + * @param option The connection option + */ +export function updateConnectionStatusBarItem( + statusBarItem: vscode.StatusBarItem | null | undefined, + status: 'connecting' | 'connected' | 'disconnected', + option?: ConnectionOption +): void { + if (statusBarItem == null) { + return; + } + + const { text, tooltip } = createConnectTextAndTooltip(status, option); + statusBarItem.text = text; + statusBarItem.tooltip = tooltip; +} diff --git a/src/util/urlUtils.ts b/src/util/urlUtils.ts index a4cb9798..b2820a3d 100644 --- a/src/util/urlUtils.ts +++ b/src/util/urlUtils.ts @@ -4,7 +4,7 @@ import * as vscode from 'vscode'; * Ensure url has a trailing slash. * @param url */ -export function ensureHasTrailingSlash(url: string | null) { +export function ensureHasTrailingSlash(url: string | null): string | null { if (url == null) { return url; } @@ -16,7 +16,10 @@ export function ensureHasTrailingSlash(url: string | null) { * Get server url and path from a dhfs URI. * @param uri */ -export function getServerUrlAndPath(uri: vscode.Uri) { +export function getServerUrlAndPath(uri: vscode.Uri): { + root: string; + path: string; +} { // Convert format from: // '/https:some-host.com:8123/.vscode/settings.json' to // 'https://some-host.com:8123/.vscode/settings.json' diff --git a/test-output.json b/test-output.json deleted file mode 100644 index 49ebf1a0..00000000 --- a/test-output.json +++ /dev/null @@ -1 +0,0 @@ -{"numTotalTestSuites":2,"numPassedTestSuites":2,"numFailedTestSuites":0,"numPendingTestSuites":0,"numTotalTests":2,"numPassedTests":1,"numFailedTests":1,"numPendingTests":0,"numTodoTests":0,"snapshot":{"added":0,"failure":false,"filesAdded":0,"filesRemoved":0,"filesRemovedList":[],"filesUnmatched":0,"filesUpdated":0,"matched":0,"total":0,"unchecked":0,"uncheckedKeysByFile":[],"unmatched":0,"updated":0,"didUpdate":false},"startTime":1721407735867,"success":false,"testResults":[{"assertionResults":[{"ancestorTitles":["ExtendedMap Test Suite"],"fullName":"ExtendedMap Test Suite should fail","status":"failed","title":"should fail","duration":3,"failureMessages":["AssertionError: expected 4 to be 5 // Object.is equality\n at /Users/bingles/code/tools/vscode-deephaven/src/util/ExtendedMap.spec.ts:6:15\n at file:///Users/bingles/code/tools/vscode-deephaven/node_modules/@vitest/runner/dist/index.js:146:14\n at file:///Users/bingles/code/tools/vscode-deephaven/node_modules/@vitest/runner/dist/index.js:61:7\n at runTest (file:///Users/bingles/code/tools/vscode-deephaven/node_modules/@vitest/runner/dist/index.js:939:17)\n at processTicksAndRejections (node:internal/process/task_queues:95:5)\n at runSuite (file:///Users/bingles/code/tools/vscode-deephaven/node_modules/@vitest/runner/dist/index.js:1095:15)\n at runSuite (file:///Users/bingles/code/tools/vscode-deephaven/node_modules/@vitest/runner/dist/index.js:1095:15)\n at runFiles (file:///Users/bingles/code/tools/vscode-deephaven/node_modules/@vitest/runner/dist/index.js:1152:5)\n at startTests (file:///Users/bingles/code/tools/vscode-deephaven/node_modules/@vitest/runner/dist/index.js:1161:3)\n at file:///Users/bingles/code/tools/vscode-deephaven/node_modules/vitest/dist/chunks/runtime-runBaseTests.hkIOeriM.js:122:11"],"meta":{}},{"ancestorTitles":["ExtendedMap Test Suite"],"fullName":"ExtendedMap Test Suite getOrThrow","status":"passed","title":"getOrThrow","duration":1,"failureMessages":[],"meta":{}}],"startTime":1721407736055,"endTime":1721407736059,"status":"failed","message":"","name":"/Users/bingles/code/tools/vscode-deephaven/src/util/ExtendedMap.spec.ts"}]} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index 991eb1b4..b1d4f025 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,8 +7,13 @@ "sourceMap": true, "rootDir": "src", "strict": true /* enable all strict type-checking options */, - "types": ["./src/types/global"] + "types": ["./src/types/global"], + "paths": { + // workaround for: https://github.com/rollup/rollup/issues/5199#issuecomment-2095374821 + "rollup/parseAst": ["./node_modules/rollup/dist/parseAst"] + } }, + "include": ["src"], "exclude": [ "node_modules", // Exclude tests as they have their own tsconfigs. diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 00000000..18ded11b --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,8 @@ +/// +import { defineConfig } from 'vite'; + +export default defineConfig({ + test: { + root: 'src', + }, +});