diff --git a/.eslintrc.json b/.eslintrc.json index f6bea009..d64ecb6e 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -11,6 +11,7 @@ "@typescript-eslint/semi": "warn", "curly": "warn", "eqeqeq": ["warn", "always", { "null": "ignore" }], + "no-console": "warn", "no-throw-literal": "warn", "semi": "off" }, diff --git a/package.json b/package.json index 8d018326..1da65875 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,10 @@ { "command": "vscode-deephaven.selectConnection", "title": "Deephaven: Select Connection" + }, + { + "command": "vscode-deephaven.downloadLogs", + "title": "Deephaven: Download Logs" } ], "menus": { diff --git a/src/common/constants.ts b/src/common/constants.ts index 407d5a6c..25468874 100644 --- a/src/common/constants.ts +++ b/src/common/constants.ts @@ -2,6 +2,7 @@ export const CONFIG_KEY = 'vscode-deephaven'; export const CONFIG_CORE_SERVERS = 'core-servers'; // export const DHFS_SCHEME = 'dhfs'; +export const DOWNLOAD_LOGS_CMD = `${CONFIG_KEY}.downloadLogs`; export const RUN_CODE_COMMAND = `${CONFIG_KEY}.runCode`; export const RUN_SELECTION_COMMAND = `${CONFIG_KEY}.runSelection`; export const SELECT_CONNECTION_COMMAND = `${CONFIG_KEY}.selectConnection`; @@ -9,3 +10,5 @@ export const SELECT_CONNECTION_COMMAND = `${CONFIG_KEY}.selectConnection`; export const STATUS_BAR_DISCONNECTED_TEXT = 'Deephaven: Disconnected'; export const STATUS_BAR_DISCONNECT_TEXT = 'Deephaven: Disconnect'; export const STATUS_BAR_CONNECTING_TEXT = 'Deephaven: Connecting...'; + +export const DOWNLOAD_LOGS_TEXT = 'Download Logs'; diff --git a/src/extension.ts b/src/extension.ts index 22f5e8a4..9bc38a5c 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -7,25 +7,41 @@ import { createConnectionOptions, createConnectionQuickPick, getTempDir, + Logger, + Toaster, } from './util'; import { DhcService } 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'; -export function activate(context: vscode.ExtensionContext) { - console.log( - 'Congratulations, your extension "vscode-deephaven" is now active!' - ); +const logger = new Logger('extension'); +export function activate(context: vscode.ExtensionContext) { let selectedConnectionUrl: string | null = null; let selectedDhService: DhcService | null = null; let connectionOptions = createConnectionOptions(); 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!' + ); + outputChannel.appendLine('Deephaven extension activated'); // Update connection options when configuration changes @@ -41,13 +57,12 @@ export function activate(context: vscode.ExtensionContext) { const dhcServiceRegistry = new DhServiceRegistry( DhcService, new ExtendedMap<string, vscode.WebviewPanel>(), - outputChannel + outputChannel, + toaster ); dhcServiceRegistry.addEventListener('disconnect', serverUrl => { - vscode.window.showInformationMessage( - `Disconnected from Deephaven server: ${serverUrl}` - ); + toaster.info(`Disconnected from Deephaven server: ${serverUrl}`); clearConnection(); }); @@ -79,15 +94,19 @@ export function activate(context: vscode.ExtensionContext) { } /** Register extension commands */ - const { runCodeCmd, runSelectionCmd, selectConnectionCmd } = registerCommands( - () => connectionOptions, - getActiveDhService, - onConnectionSelected - ); + const { downloadLogsCmd, runCodeCmd, runSelectionCmd, selectConnectionCmd } = + registerCommands( + () => connectionOptions, + getActiveDhService, + onConnectionSelected, + onDownloadLogs + ); const connectStatusBarItem = createConnectStatusBarItem(); context.subscriptions.push( + debugOutputChannel, + downloadLogsCmd, dhcServiceRegistry, outputChannel, runCodeCmd, @@ -99,6 +118,18 @@ export function activate(context: vscode.ExtensionContext) { // 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); + } + } + /** * Handle connection selection */ @@ -163,8 +194,14 @@ async function ensureUriEditorIsActive(uri: vscode.Uri) { function registerCommands( getConnectionOptions: () => ConnectionOption[], getActiveDhService: (autoActivate: boolean) => Promise<DhcService | null>, - onConnectionSelected: (connectionUrl: string | null) => void + 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, @@ -213,5 +250,5 @@ function registerCommands( } ); - return { runCodeCmd, runSelectionCmd, selectConnectionCmd }; + return { downloadLogsCmd, runCodeCmd, runSelectionCmd, selectConnectionCmd }; } diff --git a/src/services/CacheService.ts b/src/services/CacheService.ts index 47de24e6..0f3bf5c0 100644 --- a/src/services/CacheService.ts +++ b/src/services/CacheService.ts @@ -1,7 +1,9 @@ import { Disposable } from '../common'; -import { isDisposable } from '../util'; +import { isDisposable, Logger } from '../util'; import { EventDispatcher } from './EventDispatcher'; +const logger = new Logger('CacheService'); + export class CacheService<T, TEventName extends string> extends EventDispatcher<TEventName> implements Disposable @@ -27,7 +29,7 @@ export class CacheService<T, TEventName extends string> const normalizeKey = this.normalizeKey(key); if (!this.cachedPromises.has(normalizeKey)) { - console.log(`${this.label}: caching key: ${normalizeKey}`); + logger.info(`${this.label}: caching key: ${normalizeKey}`); // Note that we cache the promise itself, not the result of the promise. // This helps ensure the loader is only called the first time `get` is // called. @@ -49,7 +51,7 @@ export class CacheService<T, TEventName extends string> } }); } catch (err) { - console.error('An error occurred while disposing cached values:', err); + logger.error('An error occurred while disposing cached values:', err); } this.cachedPromises.clear(); diff --git a/src/services/DhService.ts b/src/services/DhService.ts index faee8637..05048cf7 100644 --- a/src/services/DhService.ts +++ b/src/services/DhService.ts @@ -2,9 +2,11 @@ import * as vscode from 'vscode'; import type { dh as DhcType } from '../dh/dhc-types'; import { hasErrorCode } from '../util/typeUtils'; import { ConnectionAndSession, Disposable } from '../common'; -import { ExtendedMap, formatTimestamp } from '../util'; +import { ExtendedMap, formatTimestamp, Logger, Toaster } from '../util'; import { EventDispatcher } from './EventDispatcher'; +const logger = new Logger('DhService'); + /* eslint-disable @typescript-eslint/naming-convention */ const icons = { Figure: '📈', @@ -33,19 +35,22 @@ export abstract class DhService<TDH, TClient> constructor( serverUrl: string, panelRegistry: ExtendedMap<string, vscode.WebviewPanel>, - outputChannel: vscode.OutputChannel + outputChannel: vscode.OutputChannel, + toaster: Toaster ) { super(); this.serverUrl = serverUrl; this.panelRegistry = panelRegistry; this.outputChannel = outputChannel; + this.toaster = toaster; } public readonly serverUrl: string; protected readonly subscriptions: (() => void)[] = []; protected outputChannel: vscode.OutputChannel; + protected toaster: Toaster; private panelRegistry: ExtendedMap<string, vscode.WebviewPanel>; private cachedCreateClient: Promise<TClient> | null = null; private cachedCreateSession: Promise<ConnectionAndSession< @@ -118,11 +123,11 @@ export abstract class DhService<TDH, TClient> ); } catch (err) { this.clearCaches(); - console.error(err); + logger.error(err); this.outputChannel.appendLine( `Failed to initialize Deephaven API${err == null ? '.' : `: ${err}`}` ); - vscode.window.showErrorMessage('Failed to initialize Deephaven API'); + this.toaster.error('Failed to initialize Deephaven API'); return false; } @@ -172,15 +177,13 @@ export abstract class DhService<TDH, TClient> if (this.cn == null || this.session == null) { this.clearCaches(); - vscode.window.showErrorMessage( + this.toaster.error( `Failed to create Deephaven session: ${this.serverUrl}` ); return false; } else { - vscode.window.showInformationMessage( - `Created Deephaven session: ${this.serverUrl}` - ); + this.toaster.info(`Created Deephaven session: ${this.serverUrl}`); return true; } @@ -192,7 +195,7 @@ export abstract class DhService<TDH, TClient> ): Promise<void> { if (editor.document.languageId !== 'python') { // This should not actually happen - console.log(`languageId '${editor.document.languageId}' not supported.`); + logger.info(`languageId '${editor.document.languageId}' not supported.`); return; } @@ -220,7 +223,7 @@ export abstract class DhService<TDH, TClient> const text = editor.document.getText(selectionRange); - console.log('Sending text to dh:', text); + logger.info('Sending text to dh:', text); let result: CommandResultBase; let error: string | null = null; @@ -235,7 +238,7 @@ export abstract class DhService<TDH, TClient> // clear the caches on connection disconnect if (hasErrorCode(err, 16)) { this.clearCaches(); - vscode.window.showErrorMessage( + this.toaster.error( 'Session is no longer invalid. Please re-run the command to reconnect.' ); return; @@ -243,12 +246,10 @@ export abstract class DhService<TDH, TClient> } if (error) { - console.error(error); + logger.error(error); this.outputChannel.show(true); this.outputChannel.appendLine(error); - vscode.window.showErrorMessage( - 'An error occurred when running a command' - ); + this.toaster.error('An error occurred when running a command'); return; } diff --git a/src/services/DhServiceRegistry.ts b/src/services/DhServiceRegistry.ts index 7b902a5d..a8326d41 100644 --- a/src/services/DhServiceRegistry.ts +++ b/src/services/DhServiceRegistry.ts @@ -1,7 +1,7 @@ import * as vscode from 'vscode'; import { CacheService } from './CacheService'; import { DhcService, DhcServiceConstructor } from './DhcService'; -import { ensureHasTrailingSlash, ExtendedMap } from '../util'; +import { ensureHasTrailingSlash, ExtendedMap, Toaster } from '../util'; export class DhServiceRegistry<T extends DhcService> extends CacheService< T, @@ -10,7 +10,8 @@ export class DhServiceRegistry<T extends DhcService> extends CacheService< constructor( serviceFactory: DhcServiceConstructor<T>, panelRegistry: ExtendedMap<string, vscode.WebviewPanel>, - outputChannel: vscode.OutputChannel + outputChannel: vscode.OutputChannel, + toaster: Toaster ) { super( serviceFactory.name, @@ -22,7 +23,8 @@ export class DhServiceRegistry<T extends DhcService> extends CacheService< const dhService = new serviceFactory( serverUrl, panelRegistry, - outputChannel + outputChannel, + toaster ); // Propagate service events as registry events. diff --git a/src/services/DhcService.ts b/src/services/DhcService.ts index 4cd686da..7d5f2d1d 100644 --- a/src/services/DhcService.ts +++ b/src/services/DhcService.ts @@ -8,13 +8,16 @@ import { initDhcApi, initDhcSession, } from '../dh/dhc'; -import { ExtendedMap, getPanelHtml } from '../util'; +import { ExtendedMap, getPanelHtml, Logger, Toaster } from '../util'; import { ConnectionAndSession } from '../common'; +const logger = new Logger('DhcService'); + export type DhcServiceConstructor<T extends DhcService> = new ( serverUrl: string, panelRegistry: ExtendedMap<string, vscode.WebviewPanel>, - outputChannel: vscode.OutputChannel + outputChannel: vscode.OutputChannel, + toaster: Toaster ) => T; export class DhcService extends DhService<typeof DhcType, DhcType.CoreClient> { @@ -30,7 +33,7 @@ export class DhcService extends DhService<typeof DhcType, DhcType.CoreClient> { try { return new dh.CoreClient(this.serverUrl); } catch (err) { - console.error(err); + logger.error(err); throw err; } } @@ -68,7 +71,7 @@ export class DhcService extends DhService<typeof DhcType, DhcType.CoreClient> { this.psk = token; } } catch (err) { - console.error(err); + logger.error(err); } return connectionAndSession; diff --git a/src/services/PanelFocusManager.ts b/src/services/PanelFocusManager.ts index 64365b51..ebddc649 100644 --- a/src/services/PanelFocusManager.ts +++ b/src/services/PanelFocusManager.ts @@ -1,4 +1,7 @@ import * as vscode from 'vscode'; +import { Logger } from '../util'; + +const logger = new Logger('PanelFocusManager'); /* * Panels steal focus when they finish loading which causes the run @@ -26,7 +29,7 @@ export class PanelFocusManager { >(); initialize(panel: vscode.WebviewPanel): void { - console.log('Initializing panel:', panel.title, 2); + logger.info('Initializing panel:', panel.title, 2); // Only count the last panel initialized this.panelsPendingInitialFocus = new WeakMap(); @@ -59,7 +62,7 @@ export class PanelFocusManager { const pendingChangeCount = this.panelsPendingInitialFocus.get(panel) ?? 0; - console.log('Panel view state changed:', { + logger.info('Panel view state changed:', { panelTitle: panel.title, activeEditorViewColumn, activeTabGroupViewColumn, diff --git a/src/util/Logger.ts b/src/util/Logger.ts new file mode 100644 index 00000000..85c3784d --- /dev/null +++ b/src/util/Logger.ts @@ -0,0 +1,91 @@ +import * as vscode from 'vscode'; + +export type LogLevel = 'error' | 'warn' | 'info' | 'debug' | 'debug2'; + +export type LogLevelHandler = (label: string, ...args: unknown[]) => void; + +export type LogHandler = Record<LogLevel, LogLevelHandler>; + +/** + * Simple logger delegate that can be used to log messages to a set of handlers. + * Messages will include a label for scoping log messages. + * + * Handlers can be statically registered via: + * + * Logger.handlers.add(handler); + * + * Then modules can create labeled loggers and use them to log messages: + * + * const logger = new Logger('MyModule'); + * logger.info('Hello, world!'); + */ +export class Logger { + static handlers: Set<LogHandler> = new Set(); + + /** + * Register log handler that logs to console. + */ + static addConsoleHandler = () => { + Logger.handlers.add({ + /* eslint-disable no-console */ + error: console.error.bind(console), + warn: console.warn.bind(console), + info: console.info.bind(console), + debug: console.debug.bind(console), + debug2: console.debug.bind(console), + /* eslint-enable no-console */ + }); + }; + + /** + * Register a log handler that logs to a `vscode.OutputChannel`. + * @param outputChannel + */ + static addOutputChannelHandler = (outputChannel: vscode.OutputChannel) => { + Logger.handlers.add({ + error: (label, ...args) => + outputChannel.appendLine(`${label} ERROR: ${args.join(', ')}`), + warn: (label, ...args) => + outputChannel.appendLine(`${label} WARN: ${args.join(', ')}`), + info: (label, ...args) => + outputChannel.appendLine(`${label} INFO: ${args.join(', ')}`), + debug: (label, ...args) => + outputChannel.appendLine(`${label} DEBUG: ${args.join(', ')}`), + debug2: (label, ...args) => + outputChannel.appendLine(`${label} DEBUG2: ${args.join(', ')}`), + }); + }; + + constructor(private readonly label: string) {} + + /** + * Handle log args for a given level + * @param level The level to handle + * @param args The arguments to log + */ + private handle = (level: LogLevel, ...args: unknown[]) => { + Logger.handlers.forEach(handler => + handler[level](`[${this.label}]`, ...args) + ); + }; + + debug = (...args: unknown[]): void => { + this.handle('debug', ...args); + }; + + debug2 = (...args: unknown[]): void => { + this.handle('debug2', ...args); + }; + + info = (...args: unknown[]): void => { + this.handle('info', ...args); + }; + + warn = (...args: unknown[]): void => { + this.handle('warn', ...args); + }; + + error = (...args: unknown[]): void => { + this.handle('error', ...args); + }; +} diff --git a/src/util/OutputChannelWithHistory.ts b/src/util/OutputChannelWithHistory.ts new file mode 100644 index 00000000..16676445 --- /dev/null +++ b/src/util/OutputChannelWithHistory.ts @@ -0,0 +1,129 @@ +import * as vscode from 'vscode'; +import * as fs from 'node:fs/promises'; + +/** + * Output channel wrapper that keeps a history of all appended lines. + */ +export class OutputChannelWithHistory implements vscode.OutputChannel { + constructor( + readonly context: vscode.ExtensionContext, + readonly outputChannel: vscode.OutputChannel + ) { + this.name = outputChannel.name; + + // Have to bind this explicitly since function overloads prevent using + // lexical binding via arrow function. + this.show = this.show.bind(this); + } + + private history: string[] = []; + readonly name: string; + + /** + * Append the given value to the channel. Also appends to the history. + * + * @param value A string, falsy values will not be printed. + */ + append = (value: string): void => { + this.history.push(value); + return this.outputChannel.append(value); + }; + + /** + * Append the given value and a line feed character + * to the channel. Also appends to the history. + * + * @param value A string, falsy values will be printed. + */ + appendLine = (value: string) => { + this.history.push(value); + this.outputChannel.appendLine(value); + }; + + /** + * Clear the history. + */ + clearHistory = () => { + this.history = []; + }; + + downloadHistoryToFile = async (): Promise<vscode.Uri | null> => { + const response = await vscode.window.showSaveDialog({ + defaultUri: vscode.Uri.file( + `deephaven-vscode_${new Date() + .toISOString() + .substring(0, 19) + .replace(/[:]/g, '') + .replace('T', '_')}.log` + ), + filters: { + // eslint-disable-next-line @typescript-eslint/naming-convention + Logs: ['log'], + }, + }); + + if (response?.fsPath == null) { + return null; + } + + await fs.writeFile(response.fsPath, this.history.join('\n')); + + return response; + }; + + /** + * Dispose and free associated resources. + */ + dispose = (): void => { + this.clearHistory(); + this.outputChannel.dispose(); + }; + + /** + * Removes all output from the channel. Also clears the history. + */ + clear = (): void => { + this.clearHistory(); + this.outputChannel.clear(); + }; + + /** + * Hide this channel from the UI. + */ + hide = (): void => { + return this.outputChannel.hide(); + }; + + /** + * Replaces all output from the channel with the given value. Also replaces + * the history. + * + * @param value A string, falsy values will not be printed. + */ + replace = (value: string): void => { + this.history = [value]; + this.outputChannel.replace(value); + }; + + /** + * Reveal this channel in the UI. + * + * @param preserveFocus When `true` the channel will not take focus. + */ + show(preserveFocus?: boolean): void; + /** + * Reveal this channel in the UI. + * + * @deprecated Use the overload with just one parameter (`show(preserveFocus?: boolean): void`). + * + * @param column This argument is **deprecated** and will be ignored. + * @param preserveFocus When `true` the channel will not take focus. + */ + show(column?: vscode.ViewColumn, preserveFocus?: boolean): void; + show(column?: unknown, preserveFocus?: unknown): void { + return this.outputChannel.show( + column as Parameters<vscode.OutputChannel['show']>[0], + preserveFocus as Parameters<vscode.OutputChannel['show']>[1] + ); + } +} diff --git a/src/util/Toaster.ts b/src/util/Toaster.ts new file mode 100644 index 00000000..2eb0c50b --- /dev/null +++ b/src/util/Toaster.ts @@ -0,0 +1,25 @@ +import * as vscode from 'vscode'; +import { DOWNLOAD_LOGS_CMD, DOWNLOAD_LOGS_TEXT } from '../common'; + +/** + * Show toast messages to user. + */ +export class Toaster { + constructor() {} + + error = async (message: string) => { + const response = await vscode.window.showErrorMessage( + message, + DOWNLOAD_LOGS_TEXT + ); + + // If user clicks "Download Logs" button + if (response === DOWNLOAD_LOGS_TEXT) { + await vscode.commands.executeCommand(DOWNLOAD_LOGS_CMD); + } + }; + + info = async (message: string) => { + await vscode.window.showInformationMessage(message); + }; +} diff --git a/src/util/downloadUtils.ts b/src/util/downloadUtils.ts index 5612b60d..ff3e320e 100644 --- a/src/util/downloadUtils.ts +++ b/src/util/downloadUtils.ts @@ -2,6 +2,9 @@ import * as fs from 'node:fs'; import * as http from 'node:http'; import * as https from 'node:https'; import * as path from 'node:path'; +import { Logger } from './Logger'; + +const logger = new Logger('downloadUtils'); export function getTempDir(recreate = false) { const tempDir = path.join(__dirname, 'tmp'); @@ -60,12 +63,12 @@ export async function downloadFromURL( }); }) .on('timeout', () => { - console.error('Failed download of url:', url); + logger.error('Failed download of url:', url); reject(); }) .on('error', e => { if (retries > 0) { - console.error('Retrying url:', url); + logger.error('Retrying url:', url); setTimeout( () => downloadFromURL(url, retries - 1, retryDelay).then( @@ -75,10 +78,10 @@ export async function downloadFromURL( retryDelay ); } else { - console.error( + logger.error( `Hit retry limit. Stopping attempted include from ${url} with error` ); - console.error(e); + logger.error(e); reject(); } }); diff --git a/src/util/index.ts b/src/util/index.ts index eac63594..cee0db9b 100644 --- a/src/util/index.ts +++ b/src/util/index.ts @@ -1,7 +1,9 @@ export * from './downloadUtils'; export * from './ExtendedMap'; export * from './isDisposable'; +export * from './Logger'; export * from './panelUtils'; export * from './polyfillUtils'; +export * from './Toaster'; export * from './uiUtils'; export * from './urlUtils';