diff --git a/src/messageTypes.ts b/src/messageTypes.ts index 273c8f7bb5..4398a4d655 100644 --- a/src/messageTypes.ts +++ b/src/messageTypes.ts @@ -165,6 +165,45 @@ export type LocalizedMessages = { dataframePageOf: string; dataframeCopyTable: string; dataframeExportTable: string; + // Integration panel strings + integrationsTitle: string; + integrationsNoIntegrationsFound: string; + integrationsConnected: string; + integrationsNotConfigured: string; + integrationsConfigure: string; + integrationsReconfigure: string; + integrationsReset: string; + integrationsConfirmResetTitle: string; + integrationsConfirmResetMessage: string; + integrationsConfirmResetDetails: string; + integrationsConfigureTitle: string; + integrationsCancel: string; + integrationsSave: string; + // PostgreSQL form strings + integrationsPostgresNameLabel: string; + integrationsPostgresNamePlaceholder: string; + integrationsPostgresHostLabel: string; + integrationsPostgresHostPlaceholder: string; + integrationsPostgresPortLabel: string; + integrationsPostgresPortPlaceholder: string; + integrationsPostgresDatabaseLabel: string; + integrationsPostgresDatabasePlaceholder: string; + integrationsPostgresUsernameLabel: string; + integrationsPostgresUsernamePlaceholder: string; + integrationsPostgresPasswordLabel: string; + integrationsPostgresPasswordPlaceholder: string; + integrationsPostgresSslLabel: string; + // BigQuery form strings + integrationsBigQueryNameLabel: string; + integrationsBigQueryNamePlaceholder: string; + integrationsBigQueryProjectIdLabel: string; + integrationsBigQueryProjectIdPlaceholder: string; + integrationsBigQueryCredentialsLabel: string; + integrationsBigQueryCredentialsPlaceholder: string; + integrationsBigQueryCredentialsRequired: string; + // Common form strings + integrationsRequiredField: string; + integrationsOptionalField: string; }; // Map all messages to specific payloads export class IInteractiveWindowMapping { diff --git a/src/notebooks/deepnote/integrations/integrationManager.ts b/src/notebooks/deepnote/integrations/integrationManager.ts index 1335ecb1fe..87ab98d905 100644 --- a/src/notebooks/deepnote/integrations/integrationManager.ts +++ b/src/notebooks/deepnote/integrations/integrationManager.ts @@ -1,5 +1,5 @@ import { inject, injectable } from 'inversify'; -import { commands, NotebookDocument, window, workspace } from 'vscode'; +import { commands, l10n, NotebookDocument, window, workspace } from 'vscode'; import { IExtensionContext } from '../../../platform/common/types'; import { Commands } from '../../../platform/common/constants'; @@ -26,8 +26,27 @@ export class IntegrationManager implements IIntegrationManager { public activate(): void { // Register the manage integrations command + // The command can optionally receive an integration ID to select/configure + // Note: When invoked from a notebook cell status bar, VSCode passes context object first, + // then the actual arguments from the command definition this.extensionContext.subscriptions.push( - commands.registerCommand(Commands.ManageIntegrations, () => this.showIntegrationsUI()) + commands.registerCommand(Commands.ManageIntegrations, (...args: unknown[]) => { + logger.debug(`IntegrationManager: Command invoked with args:`, args); + + // Find the integration ID from the arguments + // It could be the first arg (if called directly) or in the args array (if called from UI) + let integrationId: string | undefined; + + for (const arg of args) { + if (typeof arg === 'string') { + integrationId = arg; + break; + } + } + + logger.debug(`IntegrationManager: Extracted integrationId: ${integrationId}`); + return this.showIntegrationsUI(integrationId); + }) ); // Listen for active notebook changes to update context @@ -95,18 +114,19 @@ export class IntegrationManager implements IIntegrationManager { /** * Show the integrations management UI + * @param selectedIntegrationId Optional integration ID to select/configure immediately */ - private async showIntegrationsUI(): Promise { + private async showIntegrationsUI(selectedIntegrationId?: string): Promise { const activeNotebook = window.activeNotebookEditor?.notebook; if (!activeNotebook || activeNotebook.notebookType !== 'deepnote') { - void window.showErrorMessage('No active Deepnote notebook'); + void window.showErrorMessage(l10n.t('No active Deepnote notebook')); return; } const projectId = activeNotebook.metadata?.deepnoteProjectId; if (!projectId) { - void window.showErrorMessage('Cannot determine project ID'); + void window.showErrorMessage(l10n.t('Cannot determine project ID')); return; } @@ -125,13 +145,24 @@ export class IntegrationManager implements IIntegrationManager { logger.debug(`IntegrationManager: Found ${integrations.size} integrations`); + // If a specific integration was requested (e.g., from status bar click), + // ensure it's in the map even if not detected from the project + if (selectedIntegrationId && !integrations.has(selectedIntegrationId)) { + logger.debug(`IntegrationManager: Adding requested integration ${selectedIntegrationId} to the map`); + const config = await this.integrationStorage.get(selectedIntegrationId); + integrations.set(selectedIntegrationId, { + config: config || null, + status: config ? IntegrationStatus.Connected : IntegrationStatus.Disconnected + }); + } + if (integrations.size === 0) { - void window.showInformationMessage(`No integrations found in this project.`); + void window.showInformationMessage(l10n.t('No integrations found in this project.')); return; } - // Show the webview - await this.webviewProvider.show(integrations); + // Show the webview with optional selected integration + await this.webviewProvider.show(integrations, selectedIntegrationId); } /** @@ -143,17 +174,24 @@ export class IntegrationManager implements IIntegrationManager { const blocksWithIntegrations: BlockWithIntegration[] = []; for (const cell of notebook.getCells()) { - const deepnoteMetadata = cell.metadata?.deepnoteMetadata; - logger.trace(`IntegrationManager: Cell ${cell.index} metadata:`, deepnoteMetadata); - - if (deepnoteMetadata?.sql_integration_id) { - blocksWithIntegrations.push({ - id: `cell-${cell.index}`, - sql_integration_id: deepnoteMetadata.sql_integration_id - }); + const metadata = cell.metadata; + logger.trace(`IntegrationManager: Cell ${cell.index} metadata:`, metadata); + + // Check cell metadata for sql_integration_id + if (metadata && typeof metadata === 'object') { + const integrationId = (metadata as Record).sql_integration_id; + if (typeof integrationId === 'string') { + logger.debug(`IntegrationManager: Found integration ${integrationId} in cell ${cell.index}`); + blocksWithIntegrations.push({ + id: `cell-${cell.index}`, + sql_integration_id: integrationId + }); + } } } + logger.debug(`IntegrationManager: Found ${blocksWithIntegrations.length} cells with integrations`); + // Use the shared utility to scan blocks and build the status map return scanBlocksForIntegrations(blocksWithIntegrations, this.integrationStorage, 'IntegrationManager'); } diff --git a/src/notebooks/deepnote/integrations/integrationStorage.ts b/src/notebooks/deepnote/integrations/integrationStorage.ts index 3f97dce782..17a8a2b15c 100644 --- a/src/notebooks/deepnote/integrations/integrationStorage.ts +++ b/src/notebooks/deepnote/integrations/integrationStorage.ts @@ -1,8 +1,11 @@ import { inject, injectable } from 'inversify'; +import { EventEmitter } from 'vscode'; import { IEncryptedStorage } from '../../../platform/common/application/types'; +import { IAsyncDisposableRegistry } from '../../../platform/common/types'; import { logger } from '../../../platform/logging'; import { IntegrationConfig, IntegrationType } from './integrationTypes'; +import { IIntegrationStorage } from './types'; const INTEGRATION_SERVICE_NAME = 'deepnote-integrations'; @@ -12,12 +15,22 @@ const INTEGRATION_SERVICE_NAME = 'deepnote-integrations'; * Storage is scoped to the user's machine and shared across all deepnote projects. */ @injectable() -export class IntegrationStorage { +export class IntegrationStorage implements IIntegrationStorage { private readonly cache: Map = new Map(); private cacheLoaded = false; - constructor(@inject(IEncryptedStorage) private readonly encryptedStorage: IEncryptedStorage) {} + private readonly _onDidChangeIntegrations = new EventEmitter(); + + public readonly onDidChangeIntegrations = this._onDidChangeIntegrations.event; + + constructor( + @inject(IEncryptedStorage) private readonly encryptedStorage: IEncryptedStorage, + @inject(IAsyncDisposableRegistry) asyncRegistry: IAsyncDisposableRegistry + ) { + // Register for disposal when the extension deactivates + asyncRegistry.push(this); + } /** * Get all stored integration configurations @@ -35,6 +48,15 @@ export class IntegrationStorage { return this.cache.get(integrationId); } + /** + * Get integration configuration for a specific project and integration + * Note: Currently integrations are stored globally, not per-project, + * so this method ignores the projectId parameter + */ + async getIntegrationConfig(_projectId: string, integrationId: string): Promise { + return this.get(integrationId); + } + /** * Get all integrations of a specific type */ @@ -58,6 +80,9 @@ export class IntegrationStorage { // Update the index await this.updateIndex(); + + // Fire change event + this._onDidChangeIntegrations.fire(); } /** @@ -74,6 +99,9 @@ export class IntegrationStorage { // Update the index await this.updateIndex(); + + // Fire change event + this._onDidChangeIntegrations.fire(); } /** @@ -101,6 +129,9 @@ export class IntegrationStorage { // Clear cache this.cache.clear(); + + // Notify listeners + this._onDidChangeIntegrations.fire(); } /** @@ -148,4 +179,11 @@ export class IntegrationStorage { const indexJson = JSON.stringify(integrationIds); await this.encryptedStorage.store(INTEGRATION_SERVICE_NAME, 'index', indexJson); } + + /** + * Dispose of resources to prevent memory leaks + */ + public dispose(): void { + this._onDidChangeIntegrations.dispose(); + } } diff --git a/src/notebooks/deepnote/integrations/integrationWebview.ts b/src/notebooks/deepnote/integrations/integrationWebview.ts index 50f888336a..09c2134369 100644 --- a/src/notebooks/deepnote/integrations/integrationWebview.ts +++ b/src/notebooks/deepnote/integrations/integrationWebview.ts @@ -2,7 +2,9 @@ import { inject, injectable } from 'inversify'; import { Disposable, l10n, Uri, ViewColumn, WebviewPanel, window } from 'vscode'; import { IExtensionContext } from '../../../platform/common/types'; +import * as localize from '../../../platform/common/utils/localize'; import { logger } from '../../../platform/logging'; +import { LocalizedMessages, SharedMessages } from '../../../messageTypes'; import { IIntegrationStorage, IIntegrationWebviewProvider } from './types'; import { IntegrationConfig, IntegrationStatus, IntegrationWithStatus } from './integrationTypes'; @@ -24,8 +26,10 @@ export class IntegrationWebviewProvider implements IIntegrationWebviewProvider { /** * Show the integration management webview + * @param integrations Map of integration IDs to their status + * @param selectedIntegrationId Optional integration ID to select/configure immediately */ - public async show(integrations: Map): Promise { + public async show(integrations: Map, selectedIntegrationId?: string): Promise { // Update the stored integrations with the latest data this.integrations = integrations; @@ -35,6 +39,11 @@ export class IntegrationWebviewProvider implements IIntegrationWebviewProvider { if (this.currentPanel) { this.currentPanel.reveal(column); await this.updateWebview(); + + // If a specific integration was requested, show its configuration form + if (selectedIntegrationId) { + await this.showConfigurationForm(selectedIntegrationId); + } return; } @@ -75,7 +84,65 @@ export class IntegrationWebviewProvider implements IIntegrationWebviewProvider { this.disposables ); + await this.sendLocStrings(); await this.updateWebview(); + + // If a specific integration was requested, show its configuration form + if (selectedIntegrationId) { + await this.showConfigurationForm(selectedIntegrationId); + } + } + + /** + * Send localization strings to the webview + */ + private async sendLocStrings(): Promise { + if (!this.currentPanel) { + return; + } + + const locStrings: Partial = { + integrationsTitle: localize.Integrations.title, + integrationsNoIntegrationsFound: localize.Integrations.noIntegrationsFound, + integrationsConnected: localize.Integrations.connected, + integrationsNotConfigured: localize.Integrations.notConfigured, + integrationsConfigure: localize.Integrations.configure, + integrationsReconfigure: localize.Integrations.reconfigure, + integrationsReset: localize.Integrations.reset, + integrationsConfirmResetTitle: localize.Integrations.confirmResetTitle, + integrationsConfirmResetMessage: localize.Integrations.confirmResetMessage, + integrationsConfirmResetDetails: localize.Integrations.confirmResetDetails, + integrationsConfigureTitle: localize.Integrations.configureTitle, + integrationsCancel: localize.Integrations.cancel, + integrationsSave: localize.Integrations.save, + integrationsRequiredField: localize.Integrations.requiredField, + integrationsOptionalField: localize.Integrations.optionalField, + integrationsPostgresNameLabel: localize.Integrations.postgresNameLabel, + integrationsPostgresNamePlaceholder: localize.Integrations.postgresNamePlaceholder, + integrationsPostgresHostLabel: localize.Integrations.postgresHostLabel, + integrationsPostgresHostPlaceholder: localize.Integrations.postgresHostPlaceholder, + integrationsPostgresPortLabel: localize.Integrations.postgresPortLabel, + integrationsPostgresPortPlaceholder: localize.Integrations.postgresPortPlaceholder, + integrationsPostgresDatabaseLabel: localize.Integrations.postgresDatabaseLabel, + integrationsPostgresDatabasePlaceholder: localize.Integrations.postgresDatabasePlaceholder, + integrationsPostgresUsernameLabel: localize.Integrations.postgresUsernameLabel, + integrationsPostgresUsernamePlaceholder: localize.Integrations.postgresUsernamePlaceholder, + integrationsPostgresPasswordLabel: localize.Integrations.postgresPasswordLabel, + integrationsPostgresPasswordPlaceholder: localize.Integrations.postgresPasswordPlaceholder, + integrationsPostgresSslLabel: localize.Integrations.postgresSslLabel, + integrationsBigQueryNameLabel: localize.Integrations.bigQueryNameLabel, + integrationsBigQueryNamePlaceholder: localize.Integrations.bigQueryNamePlaceholder, + integrationsBigQueryProjectIdLabel: localize.Integrations.bigQueryProjectIdLabel, + integrationsBigQueryProjectIdPlaceholder: localize.Integrations.bigQueryProjectIdPlaceholder, + integrationsBigQueryCredentialsLabel: localize.Integrations.bigQueryCredentialsLabel, + integrationsBigQueryCredentialsPlaceholder: localize.Integrations.bigQueryCredentialsPlaceholder, + integrationsBigQueryCredentialsRequired: localize.Integrations.bigQueryCredentialsRequired + }; + + await this.currentPanel.webview.postMessage({ + type: SharedMessages.LocInit, + locStrings: locStrings + }); } /** @@ -83,6 +150,7 @@ export class IntegrationWebviewProvider implements IIntegrationWebviewProvider { */ private async updateWebview(): Promise { if (!this.currentPanel) { + logger.debug('IntegrationWebviewProvider: No current panel, skipping update'); return; } @@ -91,6 +159,7 @@ export class IntegrationWebviewProvider implements IIntegrationWebviewProvider { id, status: integration.status })); + logger.debug(`IntegrationWebviewProvider: Sending ${integrationsData.length} integrations to webview`); await this.currentPanel.webview.postMessage({ integrations: integrationsData, diff --git a/src/notebooks/deepnote/integrations/types.ts b/src/notebooks/deepnote/integrations/types.ts index 2daa217ae1..f414e34583 100644 --- a/src/notebooks/deepnote/integrations/types.ts +++ b/src/notebooks/deepnote/integrations/types.ts @@ -1,9 +1,22 @@ +import { Event } from 'vscode'; +import { IDisposable } from '../../../platform/common/types'; import { IntegrationConfig, IntegrationWithStatus } from './integrationTypes'; export const IIntegrationStorage = Symbol('IIntegrationStorage'); -export interface IIntegrationStorage { +export interface IIntegrationStorage extends IDisposable { + /** + * Event fired when integrations change + */ + readonly onDidChangeIntegrations: Event; + getAll(): Promise; get(integrationId: string): Promise; + + /** + * Get integration configuration for a specific project and integration + */ + getIntegrationConfig(projectId: string, integrationId: string): Promise; + save(config: IntegrationConfig): Promise; delete(integrationId: string): Promise; exists(integrationId: string): Promise; @@ -27,8 +40,10 @@ export const IIntegrationWebviewProvider = Symbol('IIntegrationWebviewProvider') export interface IIntegrationWebviewProvider { /** * Show the integration management webview + * @param integrations Map of integration IDs to their status + * @param selectedIntegrationId Optional integration ID to select/configure immediately */ - show(integrations: Map): Promise; + show(integrations: Map, selectedIntegrationId?: string): Promise; } export const IIntegrationManager = Symbol('IIntegrationManager'); diff --git a/src/notebooks/deepnote/sqlCellStatusBarProvider.ts b/src/notebooks/deepnote/sqlCellStatusBarProvider.ts new file mode 100644 index 0000000000..80740260a9 --- /dev/null +++ b/src/notebooks/deepnote/sqlCellStatusBarProvider.ts @@ -0,0 +1,114 @@ +import { + CancellationToken, + EventEmitter, + NotebookCell, + NotebookCellStatusBarItem, + NotebookCellStatusBarItemProvider, + NotebookDocument, + ProviderResult, + l10n, + notebooks +} from 'vscode'; +import { inject, injectable } from 'inversify'; + +import { IExtensionSyncActivationService } from '../../platform/activation/types'; +import { IDisposableRegistry } from '../../platform/common/types'; +import { Commands } from '../../platform/common/constants'; +import { IIntegrationStorage } from './integrations/types'; +import { DATAFRAME_SQL_INTEGRATION_ID } from './integrations/integrationTypes'; + +/** + * Provides status bar items for SQL cells showing the integration name + */ +@injectable() +export class SqlCellStatusBarProvider implements NotebookCellStatusBarItemProvider, IExtensionSyncActivationService { + private readonly _onDidChangeCellStatusBarItems = new EventEmitter(); + + public readonly onDidChangeCellStatusBarItems = this._onDidChangeCellStatusBarItems.event; + + constructor( + @inject(IDisposableRegistry) private readonly disposables: IDisposableRegistry, + @inject(IIntegrationStorage) private readonly integrationStorage: IIntegrationStorage + ) {} + + public activate(): void { + // Register the status bar provider for Deepnote notebooks + this.disposables.push(notebooks.registerNotebookCellStatusBarItemProvider('deepnote', this)); + + // Listen for integration configuration changes to update status bar + this.disposables.push( + this.integrationStorage.onDidChangeIntegrations(() => { + this._onDidChangeCellStatusBarItems.fire(); + }) + ); + + // Dispose our emitter with the extension + this.disposables.push(this._onDidChangeCellStatusBarItems); + } + + public provideCellStatusBarItems( + cell: NotebookCell, + token: CancellationToken + ): ProviderResult { + if (token?.isCancellationRequested) { + return undefined; + } + + // Only show status bar for SQL cells + if (cell.document.languageId !== 'sql') { + return undefined; + } + + // Get the integration ID from cell metadata + const integrationId = this.getIntegrationId(cell); + if (!integrationId) { + return undefined; + } + + // Don't show status bar for the internal DuckDB integration + if (integrationId === DATAFRAME_SQL_INTEGRATION_ID) { + return undefined; + } + + return this.createStatusBarItem(cell.notebook, integrationId); + } + + private getIntegrationId(cell: NotebookCell): string | undefined { + // Check cell metadata for sql_integration_id + const metadata = cell.metadata; + if (metadata && typeof metadata === 'object') { + const integrationId = (metadata as Record).sql_integration_id; + if (typeof integrationId === 'string') { + return integrationId; + } + } + + return undefined; + } + + private async createStatusBarItem( + notebook: NotebookDocument, + integrationId: string + ): Promise { + const projectId = notebook.metadata?.deepnoteProjectId; + if (!projectId) { + return undefined; + } + + // Get integration configuration to display the name + const config = await this.integrationStorage.getIntegrationConfig(projectId, integrationId); + const displayName = config?.name || l10n.t('Unknown integration (configure)'); + + // Create a status bar item that opens the integration management UI + return { + text: `$(database) ${displayName}`, + alignment: 1, // NotebookCellStatusBarAlignment.Left + tooltip: l10n.t('SQL Integration: {0}\nClick to configure', displayName), + command: { + title: l10n.t('Configure Integration'), + command: Commands.ManageIntegrations, + arguments: [integrationId] + } + }; + } +} diff --git a/src/notebooks/deepnote/sqlCellStatusBarProvider.unit.test.ts b/src/notebooks/deepnote/sqlCellStatusBarProvider.unit.test.ts new file mode 100644 index 0000000000..1949be6712 --- /dev/null +++ b/src/notebooks/deepnote/sqlCellStatusBarProvider.unit.test.ts @@ -0,0 +1,147 @@ +import { assert } from 'chai'; +import { anything, instance, mock, when } from 'ts-mockito'; +import { + CancellationToken, + CancellationTokenSource, + NotebookCell, + NotebookCellKind, + NotebookDocument, + TextDocument, + Uri +} from 'vscode'; + +import { IDisposableRegistry } from '../../platform/common/types'; +import { IIntegrationStorage } from './integrations/types'; +import { SqlCellStatusBarProvider } from './sqlCellStatusBarProvider'; +import { DATAFRAME_SQL_INTEGRATION_ID, IntegrationType } from './integrations/integrationTypes'; + +suite('SqlCellStatusBarProvider', () => { + let provider: SqlCellStatusBarProvider; + let disposables: IDisposableRegistry; + let integrationStorage: IIntegrationStorage; + let cancellationToken: CancellationToken; + + setup(() => { + disposables = []; + integrationStorage = mock(); + provider = new SqlCellStatusBarProvider(disposables, instance(integrationStorage)); + + const tokenSource = new CancellationTokenSource(); + cancellationToken = tokenSource.token; + }); + + test('returns undefined for non-SQL cells', async () => { + const cell = createMockCell('python', {}); + + const result = await provider.provideCellStatusBarItems(cell, cancellationToken); + + assert.isUndefined(result); + }); + + test('returns undefined for SQL cells without integration ID', async () => { + const cell = createMockCell('sql', {}); + + const result = await provider.provideCellStatusBarItems(cell, cancellationToken); + + assert.isUndefined(result); + }); + + test('returns undefined for SQL cells with dataframe integration ID', async () => { + const cell = createMockCell('sql', { + sql_integration_id: DATAFRAME_SQL_INTEGRATION_ID + }); + + const result = await provider.provideCellStatusBarItems(cell, cancellationToken); + + assert.isUndefined(result); + }); + + test('returns status bar item for SQL cell with integration ID', async () => { + const integrationId = 'postgres-123'; + const cell = createMockCell( + 'sql', + { + sql_integration_id: integrationId + }, + { + deepnoteProjectId: 'project-1' + } + ); + + when(integrationStorage.getIntegrationConfig(anything(), anything())).thenResolve({ + id: integrationId, + name: 'My Postgres DB', + type: IntegrationType.Postgres, + host: 'localhost', + port: 5432, + database: 'test', + username: 'user', + password: 'pass' + }); + + const result = await provider.provideCellStatusBarItems(cell, cancellationToken); + + assert.isDefined(result); + assert.strictEqual((result as any).text, '$(database) My Postgres DB'); + assert.strictEqual((result as any).alignment, 1); // NotebookCellStatusBarAlignment.Left + assert.isDefined((result as any).command); + assert.strictEqual((result as any).command.command, 'deepnote.manageIntegrations'); + assert.deepStrictEqual((result as any).command.arguments, [integrationId]); + }); + + test('shows "Unknown integration (configure)" when config not found', async () => { + const integrationId = 'postgres-123'; + const cell = createMockCell( + 'sql', + { + sql_integration_id: integrationId + }, + { + deepnoteProjectId: 'project-1' + } + ); + + when(integrationStorage.getIntegrationConfig(anything(), anything())).thenResolve(undefined); + + const result = await provider.provideCellStatusBarItems(cell, cancellationToken); + + assert.isDefined(result); + assert.strictEqual((result as any).text, '$(database) Unknown integration (configure)'); + }); + + test('returns undefined when notebook has no project ID', async () => { + const integrationId = 'postgres-123'; + const cell = createMockCell('sql', { + sql_integration_id: integrationId + }); + + const result = await provider.provideCellStatusBarItems(cell, cancellationToken); + + assert.isUndefined(result); + }); + + function createMockCell( + languageId: string, + cellMetadata: Record, + notebookMetadata: Record = {} + ): NotebookCell { + const document = { + languageId + } as TextDocument; + + const notebook = { + metadata: notebookMetadata, + uri: Uri.file('/test/notebook.deepnote') + } as NotebookDocument; + + const cell = { + document, + notebook, + kind: NotebookCellKind.Code, + metadata: cellMetadata, + index: 0 + } as NotebookCell; + + return cell; + } +}); diff --git a/src/notebooks/serviceRegistry.node.ts b/src/notebooks/serviceRegistry.node.ts index 9b7ed3a7c3..a42308f4f4 100644 --- a/src/notebooks/serviceRegistry.node.ts +++ b/src/notebooks/serviceRegistry.node.ts @@ -53,6 +53,7 @@ import { IIntegrationStorage, IIntegrationWebviewProvider } from './deepnote/integrations/types'; +import { SqlCellStatusBarProvider } from './deepnote/sqlCellStatusBarProvider'; import { IDeepnoteToolkitInstaller, IDeepnoteServerStarter, @@ -142,6 +143,10 @@ export function registerTypes(serviceManager: IServiceManager, isDevMode: boolea serviceManager.addSingleton(IIntegrationDetector, IntegrationDetector); serviceManager.addSingleton(IIntegrationWebviewProvider, IntegrationWebviewProvider); serviceManager.addSingleton(IIntegrationManager, IntegrationManager); + serviceManager.addSingleton( + IExtensionSyncActivationService, + SqlCellStatusBarProvider + ); // Deepnote kernel services serviceManager.addSingleton(IDeepnoteToolkitInstaller, DeepnoteToolkitInstaller); diff --git a/src/notebooks/serviceRegistry.web.ts b/src/notebooks/serviceRegistry.web.ts index 8c1728f231..1fb50d004a 100644 --- a/src/notebooks/serviceRegistry.web.ts +++ b/src/notebooks/serviceRegistry.web.ts @@ -48,6 +48,7 @@ import { IIntegrationStorage, IIntegrationWebviewProvider } from './deepnote/integrations/types'; +import { SqlCellStatusBarProvider } from './deepnote/sqlCellStatusBarProvider'; export function registerTypes(serviceManager: IServiceManager, isDevMode: boolean) { registerControllerTypes(serviceManager, isDevMode); @@ -106,6 +107,10 @@ export function registerTypes(serviceManager: IServiceManager, isDevMode: boolea serviceManager.addSingleton(IIntegrationDetector, IntegrationDetector); serviceManager.addSingleton(IIntegrationWebviewProvider, IntegrationWebviewProvider); serviceManager.addSingleton(IIntegrationManager, IntegrationManager); + serviceManager.addSingleton( + IExtensionSyncActivationService, + SqlCellStatusBarProvider + ); serviceManager.addSingleton(IExportBase, ExportBase); serviceManager.addSingleton(IFileConverter, FileConverter); diff --git a/src/platform/common/utils/localize.ts b/src/platform/common/utils/localize.ts index efab91f883..ae961c9442 100644 --- a/src/platform/common/utils/localize.ts +++ b/src/platform/common/utils/localize.ts @@ -814,6 +814,51 @@ export namespace WebViews { export const dataframeExportTable = l10n.t('Export table'); } +export namespace Integrations { + export const title = l10n.t('Deepnote Integrations'); + export const noIntegrationsFound = l10n.t('No integrations found in this project.'); + export const connected = l10n.t('Connected'); + export const notConfigured = l10n.t('Not Configured'); + export const configure = l10n.t('Configure'); + export const reconfigure = l10n.t('Reconfigure'); + export const reset = l10n.t('Reset'); + export const confirmResetTitle = l10n.t('Confirm Reset'); + export const confirmResetMessage = l10n.t('Are you sure you want to reset this integration configuration?'); + export const confirmResetDetails = l10n.t('This will remove the stored credentials. You can reconfigure it later.'); + export const configureTitle = l10n.t('Configure Integration: {0}'); + export const cancel = l10n.t('Cancel'); + export const save = l10n.t('Save'); + export const requiredField = l10n.t('*'); + export const optionalField = l10n.t('(optional)'); + + // PostgreSQL form strings + export const postgresNameLabel = l10n.t('Name (optional)'); + export const postgresNamePlaceholder = l10n.t('My PostgreSQL Database'); + export const postgresHostLabel = l10n.t('Host'); + export const postgresHostPlaceholder = l10n.t('localhost'); + export const postgresPortLabel = l10n.t('Port'); + export const postgresPortPlaceholder = l10n.t('5432'); + export const postgresDatabaseLabel = l10n.t('Database'); + export const postgresDatabasePlaceholder = l10n.t('mydb'); + export const postgresUsernameLabel = l10n.t('Username'); + export const postgresUsernamePlaceholder = l10n.t('postgres'); + export const postgresPasswordLabel = l10n.t('Password'); + export const postgresPasswordPlaceholder = l10n.t('••••••••'); + export const postgresSslLabel = l10n.t('Use SSL'); + export const postgresUnnamedIntegration = (id: string) => l10n.t('Unnamed PostgreSQL Integration ({0})', id); + + // BigQuery form strings + export const bigQueryNameLabel = l10n.t('Name (optional)'); + export const bigQueryNamePlaceholder = l10n.t('My BigQuery Project'); + export const bigQueryProjectIdLabel = l10n.t('Project ID'); + export const bigQueryProjectIdPlaceholder = l10n.t('my-project-id'); + export const bigQueryCredentialsLabel = l10n.t('Service Account Credentials (JSON)'); + export const bigQueryCredentialsPlaceholder = l10n.t('{"type": "service_account", ...}'); + export const bigQueryCredentialsRequired = l10n.t('Credentials are required'); + export const bigQueryInvalidJson = (message: string) => l10n.t('Invalid JSON: {0}', message); + export const bigQueryUnnamedIntegration = (id: string) => l10n.t('Unnamed BigQuery Integration ({0})', id); +} + export namespace Deprecated { export const SHOW_DEPRECATED_FEATURE_PROMPT_FORMAT_ON_SAVE = l10n.t({ message: "The setting 'python.formatting.formatOnSave' is deprecated, please use 'editor.formatOnSave'.", diff --git a/src/platform/webviews/webviewHost.ts b/src/platform/webviews/webviewHost.ts index b26727cdfd..643ae042d7 100644 --- a/src/platform/webviews/webviewHost.ts +++ b/src/platform/webviews/webviewHost.ts @@ -221,7 +221,7 @@ export abstract class WebviewHost implements IDisposable { } protected async sendLocStrings() { - const locStrings: LocalizedMessages = { + const locStrings: Partial = { collapseSingle: localize.WebViews.collapseSingle, expandSingle: localize.WebViews.expandSingle, openExportFileYes: localize.DataScience.openExportFileYes, diff --git a/src/webviews/webview-side/integrations/BigQueryForm.tsx b/src/webviews/webview-side/integrations/BigQueryForm.tsx index 627aa04c30..cad543fce3 100644 --- a/src/webviews/webview-side/integrations/BigQueryForm.tsx +++ b/src/webviews/webview-side/integrations/BigQueryForm.tsx @@ -1,6 +1,5 @@ import * as React from 'react'; -import { l10n } from 'vscode'; - +import { format, getLocString } from '../react-common/locReactSide'; import { BigQueryIntegrationConfig } from './types'; export interface IBigQueryFormProps { @@ -23,12 +22,17 @@ export const BigQueryForm: React.FC = ({ integrationId, exis setProjectId(existingConfig.projectId || ''); setCredentials(existingConfig.credentials || ''); setCredentialsError(null); + } else { + setName(''); + setProjectId(''); + setCredentials(''); + setCredentialsError(null); } }, [existingConfig]); const validateCredentials = (value: string): boolean => { if (!value.trim()) { - setCredentialsError(l10n.t('Credentials are required')); + setCredentialsError(getLocString('integrationsBigQueryCredentialsRequired', 'Credentials are required')); return false; } @@ -38,7 +42,8 @@ export const BigQueryForm: React.FC = ({ integrationId, exis return true; } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Invalid JSON format'; - setCredentialsError(l10n.t('Invalid JSON: {0}', errorMessage)); + const invalidJsonMsg = format('Invalid JSON: {0}', errorMessage); + setCredentialsError(invalidJsonMsg); return false; } }; @@ -52,17 +57,21 @@ export const BigQueryForm: React.FC = ({ integrationId, exis const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); + const trimmedCredentials = credentials.trim(); + // Validate credentials before submitting - if (!validateCredentials(credentials)) { + if (!validateCredentials(trimmedCredentials)) { return; } + const unnamedIntegration = format('Unnamed BigQuery Integration ({0})', integrationId); + const config: BigQueryIntegrationConfig = { id: integrationId, - name: name || l10n.t('Unnamed BigQuery Integration ({0})', integrationId), + name: (name || unnamedIntegration).trim(), type: 'bigquery', - projectId, - credentials + projectId: projectId.trim(), + credentials: trimmedCredentials }; onSave(config); @@ -71,27 +80,28 @@ export const BigQueryForm: React.FC = ({ integrationId, exis return (
- + setName(e.target.value)} - placeholder="My BigQuery Project" + placeholder={getLocString('integrationsBigQueryNamePlaceholder', 'My BigQuery Project')} autoComplete="off" />
setProjectId(e.target.value)} - placeholder="my-project-id" + placeholder={getLocString('integrationsBigQueryProjectIdPlaceholder', 'my-project-id')} autoComplete="off" required /> @@ -99,13 +109,17 @@ export const BigQueryForm: React.FC = ({ integrationId, exis