Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/messageTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,9 @@ export type LocalizedMessages = {
integrationsConfigureTitle: string;
integrationsCancel: string;
integrationsSave: string;
// Integration type labels
integrationsPostgresTypeLabel: string;
integrationsBigQueryTypeLabel: string;
// PostgreSQL form strings
integrationsPostgresNameLabel: string;
integrationsPostgresNamePlaceholder: string;
Expand Down
26 changes: 26 additions & 0 deletions src/notebooks/deepnote/deepnoteNotebookManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,32 @@ export class DeepnoteNotebookManager implements IDeepnoteNotebookManager {
this.currentNotebookId.set(projectId, notebookId);
}

/**
* Updates the integrations list in the project data.
* This modifies the stored project to reflect changes in configured integrations.
* @param projectId Project identifier
* @param integrations Array of integration metadata to store in the project
*/
updateProjectIntegrations(
projectId: string,
integrations: Array<{ id: string; name: string; type: string }>
): void {
const project = this.originalProjects.get(projectId);

if (!project) {
return;
}

const updatedProject = JSON.parse(JSON.stringify(project)) as DeepnoteProject;
updatedProject.project.integrations = integrations;

const currentNotebookId = this.currentNotebookId.get(projectId);

if (currentNotebookId) {
this.storeOriginalProject(projectId, updatedProject, currentNotebookId);
}
}

/**
* Checks if the init notebook has already been run for a project.
* @param projectId Project identifier
Expand Down
77 changes: 77 additions & 0 deletions src/notebooks/deepnote/deepnoteNotebookManager.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,83 @@ suite('DeepnoteNotebookManager', () => {
});
});

suite('updateProjectIntegrations', () => {
test('should update integrations list for existing project', () => {
manager.storeOriginalProject('project-123', mockProject, 'notebook-456');

const integrations = [
{ id: 'int-1', name: 'PostgreSQL', type: 'pgsql' },
{ id: 'int-2', name: 'BigQuery', type: 'big-query' }
];

manager.updateProjectIntegrations('project-123', integrations);

const updatedProject = manager.getOriginalProject('project-123');
assert.deepStrictEqual(updatedProject?.project.integrations, integrations);
});

test('should replace existing integrations list', () => {
const projectWithIntegrations: DeepnoteProject = {
...mockProject,
project: {
...mockProject.project,
integrations: [{ id: 'old-int', name: 'Old Integration', type: 'pgsql' }]
}
};

manager.storeOriginalProject('project-123', projectWithIntegrations, 'notebook-456');

const newIntegrations = [
{ id: 'new-int-1', name: 'New Integration 1', type: 'pgsql' },
{ id: 'new-int-2', name: 'New Integration 2', type: 'big-query' }
];

manager.updateProjectIntegrations('project-123', newIntegrations);

const updatedProject = manager.getOriginalProject('project-123');
assert.deepStrictEqual(updatedProject?.project.integrations, newIntegrations);
});

test('should handle empty integrations array', () => {
const projectWithIntegrations: DeepnoteProject = {
...mockProject,
project: {
...mockProject.project,
integrations: [{ id: 'int-1', name: 'Integration 1', type: 'pgsql' }]
}
};

manager.storeOriginalProject('project-123', projectWithIntegrations, 'notebook-456');

manager.updateProjectIntegrations('project-123', []);

const updatedProject = manager.getOriginalProject('project-123');
assert.deepStrictEqual(updatedProject?.project.integrations, []);
});

test('should do nothing for unknown project', () => {
// Should not throw an error
manager.updateProjectIntegrations('unknown-project', [{ id: 'int-1', name: 'Integration', type: 'pgsql' }]);

const project = manager.getOriginalProject('unknown-project');
assert.strictEqual(project, undefined);
});

test('should preserve other project properties', () => {
manager.storeOriginalProject('project-123', mockProject, 'notebook-456');

const integrations = [{ id: 'int-1', name: 'PostgreSQL', type: 'pgsql' }];

manager.updateProjectIntegrations('project-123', integrations);

const updatedProject = manager.getOriginalProject('project-123');
assert.strictEqual(updatedProject?.project.id, mockProject.project.id);
assert.strictEqual(updatedProject?.project.name, mockProject.project.name);
assert.strictEqual(updatedProject?.version, mockProject.version);
assert.deepStrictEqual(updatedProject?.metadata, mockProject.metadata);
});
});

suite('integration scenarios', () => {
test('should handle complete workflow for multiple projects', () => {
manager.storeOriginalProject('project-1', mockProject, 'notebook-1');
Expand Down
70 changes: 43 additions & 27 deletions src/notebooks/deepnote/integrations/integrationDetector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,13 @@ import { inject, injectable } from 'inversify';

import { logger } from '../../../platform/logging';
import { IDeepnoteNotebookManager } from '../../types';
import { IntegrationStatus, IntegrationWithStatus } from '../../../platform/notebooks/deepnote/integrationTypes';
import {
DATAFRAME_SQL_INTEGRATION_ID,
IntegrationStatus,
IntegrationWithStatus,
mapDeepnoteIntegrationType
} from '../../../platform/notebooks/deepnote/integrationTypes';
import { IIntegrationDetector, IIntegrationStorage } from './types';
import { BlockWithIntegration, scanBlocksForIntegrations } from './integrationUtils';

/**
* Service for detecting integrations used in Deepnote notebooks
Expand All @@ -17,7 +21,8 @@ export class IntegrationDetector implements IIntegrationDetector {
) {}

/**
* Detect all integrations used in the given project
* Detect all integrations used in the given project.
* Uses the project's integrations field as the source of truth.
*/
async detectIntegrations(projectId: string): Promise<Map<string, IntegrationWithStatus>> {
// Get the project
Expand All @@ -29,33 +34,44 @@ export class IntegrationDetector implements IIntegrationDetector {
return new Map();
}

logger.debug(
`IntegrationDetector: Scanning project ${projectId} with ${project.project.notebooks.length} notebooks`
);

// Collect all blocks with SQL integration metadata from all notebooks
const blocksWithIntegrations: BlockWithIntegration[] = [];
for (const notebook of project.project.notebooks) {
logger.trace(`IntegrationDetector: Scanning notebook ${notebook.id} with ${notebook.blocks.length} blocks`);

for (const block of notebook.blocks) {
// Check if this is a code block with SQL integration metadata
if (block.type === 'code' && block.metadata?.sql_integration_id) {
blocksWithIntegrations.push({
id: block.id,
sql_integration_id: block.metadata.sql_integration_id
});
} else if (block.type === 'code') {
logger.trace(
`IntegrationDetector: Block ${block.id} has no sql_integration_id. Metadata:`,
block.metadata
);
}
logger.debug(`IntegrationDetector: Scanning project ${projectId} for integrations`);

const integrations = new Map<string, IntegrationWithStatus>();

// Use the project's integrations field as the source of truth
const projectIntegrations = project.project.integrations || [];
logger.debug(`IntegrationDetector: Found ${projectIntegrations.length} integrations in project.integrations`);

for (const projectIntegration of projectIntegrations) {
const integrationId = projectIntegration.id;

// Skip the internal DuckDB integration
if (integrationId === DATAFRAME_SQL_INTEGRATION_ID) {
continue;
}

logger.debug(`IntegrationDetector: Found integration: ${integrationId} (${projectIntegration.type})`);

// Check if the integration is configured
const config = await this.integrationStorage.getIntegrationConfig(integrationId);

// Map the Deepnote integration type to our IntegrationType
const integrationType = mapDeepnoteIntegrationType(projectIntegration.type);

const status: IntegrationWithStatus = {
config: config || null,
status: config ? IntegrationStatus.Connected : IntegrationStatus.Disconnected,
// Include integration metadata from project for prefilling when config is null
integrationName: projectIntegration.name,
integrationType: integrationType
};

integrations.set(integrationId, status);
}

// Use the shared utility to scan blocks and build the status map
return scanBlocksForIntegrations(blocksWithIntegrations, this.integrationStorage, 'IntegrationDetector');
logger.debug(`IntegrationDetector: Found ${integrations.size} integrations`);

return integrations;
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ export class IntegrationManager implements IIntegrationManager {
}

// Show the webview with optional selected integration
await this.webviewProvider.show(integrations, selectedIntegrationId);
await this.webviewProvider.show(projectId, integrations, selectedIntegrationId);
}

/**
Expand Down
73 changes: 69 additions & 4 deletions src/notebooks/deepnote/integrations/integrationWebview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@ 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 { IDeepnoteNotebookManager } from '../../types';
import { IIntegrationStorage, IIntegrationWebviewProvider } from './types';
import {
IntegrationConfig,
IntegrationStatus,
IntegrationWithStatus
IntegrationWithStatus,
mapToDeepnoteIntegrationType
} from '../../../platform/notebooks/deepnote/integrationTypes';

/**
Expand All @@ -23,18 +25,27 @@ export class IntegrationWebviewProvider implements IIntegrationWebviewProvider {

private integrations: Map<string, IntegrationWithStatus> = new Map();

private projectId: string | undefined;

constructor(
@inject(IExtensionContext) private readonly extensionContext: IExtensionContext,
@inject(IIntegrationStorage) private readonly integrationStorage: IIntegrationStorage
@inject(IIntegrationStorage) private readonly integrationStorage: IIntegrationStorage,
@inject(IDeepnoteNotebookManager) private readonly notebookManager: IDeepnoteNotebookManager
) {}

/**
* Show the integration management webview
* @param projectId The Deepnote project ID
* @param integrations Map of integration IDs to their status
* @param selectedIntegrationId Optional integration ID to select/configure immediately
*/
public async show(integrations: Map<string, IntegrationWithStatus>, selectedIntegrationId?: string): Promise<void> {
// Update the stored integrations with the latest data
public async show(
projectId: string,
integrations: Map<string, IntegrationWithStatus>,
selectedIntegrationId?: string
): Promise<void> {
// Update the stored integrations and project ID with the latest data
this.projectId = projectId;
this.integrations = integrations;

const column = window.activeTextEditor ? window.activeTextEditor.viewColumn : ViewColumn.One;
Expand Down Expand Up @@ -117,6 +128,8 @@ export class IntegrationWebviewProvider implements IIntegrationWebviewProvider {
integrationsConfirmResetMessage: localize.Integrations.confirmResetMessage,
integrationsConfirmResetDetails: localize.Integrations.confirmResetDetails,
integrationsConfigureTitle: localize.Integrations.configureTitle,
integrationsPostgresTypeLabel: localize.Integrations.postgresTypeLabel,
integrationsBigQueryTypeLabel: localize.Integrations.bigQueryTypeLabel,
integrationsCancel: localize.Integrations.cancel,
integrationsSave: localize.Integrations.save,
integrationsRequiredField: localize.Integrations.requiredField,
Expand Down Expand Up @@ -161,6 +174,8 @@ export class IntegrationWebviewProvider implements IIntegrationWebviewProvider {
const integrationsData = Array.from(this.integrations.entries()).map(([id, integration]) => ({
config: integration.config,
id,
integrationName: integration.integrationName,
integrationType: integration.integrationType,
status: integration.status
}));
logger.debug(`IntegrationWebviewProvider: Sending ${integrationsData.length} integrations to webview`);
Expand Down Expand Up @@ -210,6 +225,8 @@ export class IntegrationWebviewProvider implements IIntegrationWebviewProvider {
await this.currentPanel?.webview.postMessage({
config: integration.config,
integrationId,
integrationName: integration.integrationName,
integrationType: integration.integrationType,
type: 'showForm'
});
}
Expand All @@ -229,6 +246,9 @@ export class IntegrationWebviewProvider implements IIntegrationWebviewProvider {
this.integrations.set(integrationId, integration);
}

// Update the project's integrations list
await this.updateProjectIntegrationsList();

await this.updateWebview();
await this.currentPanel?.webview.postMessage({
message: l10n.t('Configuration saved successfully'),
Expand Down Expand Up @@ -261,6 +281,9 @@ export class IntegrationWebviewProvider implements IIntegrationWebviewProvider {
this.integrations.set(integrationId, integration);
}

// Update the project's integrations list
await this.updateProjectIntegrationsList();

await this.updateWebview();
await this.currentPanel?.webview.postMessage({
message: l10n.t('Configuration deleted successfully'),
Expand All @@ -278,6 +301,48 @@ export class IntegrationWebviewProvider implements IIntegrationWebviewProvider {
}
}

/**
* Update the project's integrations list based on current integrations
*/
private async updateProjectIntegrationsList(): Promise<void> {
if (!this.projectId) {
logger.warn('IntegrationWebviewProvider: No project ID available, skipping project update');
return;
}

// Build the integrations list from current integrations
const projectIntegrations = Array.from(this.integrations.entries())
.map(([id, integration]) => {
// Get the integration type from config or integration metadata
const type = integration.config?.type || integration.integrationType;
if (!type) {
logger.warn(`IntegrationWebviewProvider: No type found for integration ${id}, skipping`);
return null;
}

// Map to Deepnote integration type
const deepnoteType = mapToDeepnoteIntegrationType(type);
if (!deepnoteType) {
logger.warn(`IntegrationWebviewProvider: Cannot map type ${type} for integration ${id}, skipping`);
return null;
}

return {
id,
name: integration.config?.name || integration.integrationName || id,
type: deepnoteType
};
})
.filter((integration): integration is { id: string; name: string; type: string } => integration !== null);

logger.debug(
`IntegrationWebviewProvider: Updating project ${this.projectId} with ${projectIntegrations.length} integrations`
);

// Update the project in the notebook manager
this.notebookManager.updateProjectIntegrations(this.projectId, projectIntegrations);
}

/**
* Get the HTML content for the webview (React-based)
*/
Expand Down
7 changes: 6 additions & 1 deletion src/notebooks/deepnote/integrations/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,15 @@ export const IIntegrationWebviewProvider = Symbol('IIntegrationWebviewProvider')
export interface IIntegrationWebviewProvider {
/**
* Show the integration management webview
* @param projectId The Deepnote project ID
* @param integrations Map of integration IDs to their status
* @param selectedIntegrationId Optional integration ID to select/configure immediately
*/
show(integrations: Map<string, IntegrationWithStatus>, selectedIntegrationId?: string): Promise<void>;
show(
projectId: string,
integrations: Map<string, IntegrationWithStatus>,
selectedIntegrationId?: string
): Promise<void>;
}

export const IIntegrationManager = Symbol('IIntegrationManager');
Expand Down
Loading
Loading