Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
baddb3e
feat(integrations): add an "Add Existing Integration" picker to reuse…
jamesbhobbs Sep 10, 2026
c2148f9
fix(integrations): keep the full project roster when attaching an exi…
jamesbhobbs Sep 10, 2026
3ea348b
chore(integrations): trim comments to carry why, not what
tkislan Sep 18, 2026
058fe8f
fix(integrations): close data-loss windows in add existing integration
tkislan Sep 21, 2026
b614e0b
fix(integrations): validate notebook, cache and scan state in add exi…
tkislan Sep 22, 2026
d3ea64c
refactor(integrations): throw CancellationError from the reusable-int…
tkislan Sep 22, 2026
fa216b4
refactor(localize): reorder imports and improve comment clarity in In…
tkislan Sep 22, 2026
c27500a
test(e2e): cover the add-existing-integration command end to end
tkislan Sep 22, 2026
ff80aa2
test(e2e): collapse the add-existing-integration assertions into one …
tkislan Sep 22, 2026
f82d197
refactor(integrations): merge an added integration into each file on …
tkislan Sep 22, 2026
aa03370
fix(integrations): move the cache update ahead of the sibling sweep
tkislan Sep 22, 2026
ab1a81e
fix(sql): refuse to switch the integration of a snapshot notebook
tkislan Sep 22, 2026
ffbc1d3
test(integrations): drop duplicated manager tests, pin the error each…
tkislan Sep 23, 2026
a8ee993
fix(integrations): do not sweep siblings when the active write did no…
tkislan Sep 23, 2026
b7ddaeb
Merge branch 'main' into feat/add-existing-integration
tkislan Sep 23, 2026
72fa170
refactor(integrations): narrow the scan's catch variable, not assert it
tkislan Sep 23, 2026
744417e
fix(integrations): report cancellations in add_existing_integration
tkislan Sep 23, 2026
3cd68fe
fix(integrations): re-show the panel before the kernel env refresh
tkislan Sep 23, 2026
8c8231a
fix(integrations): do not delete credentials another project declares
tkislan Sep 23, 2026
264eb43
fix(integrations): hold back the type cards until their labels arrive
tkislan Sep 23, 2026
9387f97
test(e2e): drop add-existing assertions that can never fail
tkislan Sep 23, 2026
d2abd3a
test(integrations): type the cache-update doubles off the interface
tkislan Sep 23, 2026
5b2c087
test(integrations): groom the existing-integration picker tests
tkislan Sep 24, 2026
d419c87
test(integrations): check what the add-existing manager tests claim
tkislan Sep 24, 2026
9d1c92f
test(integrations): drop the bundle-key change detector
tkislan Sep 24, 2026
fff2847
test(sql): pin the snapshot refusal message
tkislan Sep 24, 2026
1a30f71
Merge remote-tracking branch 'origin/main' into feat/add-existing-int…
tkislan Sep 24, 2026
1f7ba40
test(integrations): replace this branch's force-cast fixtures with ty…
tkislan Sep 24, 2026
efea1fe
fix(integrations): move each notebook's cached list with its own file…
tkislan Sep 24, 2026
f85f92d
fix(integrations): edit only the panel's own entry in the project files
tkislan Sep 24, 2026
5aad111
fix(integrations): name the projects a shared reset, sign-out or save…
tkislan Sep 24, 2026
5fdb73a
fix(integrations): never fall back to another notebook when the given…
tkislan Sep 24, 2026
83f1985
fix(integrations): refuse snapshots once, in the notebook resolver
tkislan Sep 24, 2026
3393f04
fix(integrations): refresh an open panel after a link instead of open…
tkislan Sep 24, 2026
18a6f51
fix(integrations): send the panel's loc strings whenever its webview …
tkislan Sep 24, 2026
168959b
test(e2e): treat a torn read as not yet in the declared-integration poll
tkislan Sep 24, 2026
f801259
test(integrations): share one project-file fake across the integratio…
tkislan Sep 24, 2026
9bfc262
Merge branch 'main' into feat/add-existing-integration
tkislan Oct 6, 2026
d14ea4f
Merge branch 'main' into feat/add-existing-integration
tkislan Oct 7, 2026
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
6 changes: 6 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,12 @@
"category": "Deepnote",
"icon": "$(plug)"
},
{
"command": "deepnote.addExistingIntegration",
"title": "%deepnote.commands.addExistingIntegration.title%",
"category": "Deepnote",
"icon": "$(link)"
},
{
"command": "deepnote.authenticateIntegration",
"title": "%deepnote.commands.authenticateIntegration.title%",
Expand Down
1 change: 1 addition & 0 deletions package.nls.json
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,7 @@
"deepnote.commands.enableSnapshots.title": "Enable Snapshots",
"deepnote.commands.disableSnapshots.title": "Disable Snapshots",
"deepnote.commands.manageIntegrations.title": "Manage Integrations",
"deepnote.commands.addExistingIntegration.title": "Add Existing Integration",
"deepnote.commands.authenticateIntegration.title": "Authenticate Integration",
"deepnote.commands.newProject.title": "New Project",
"deepnote.commands.importNotebook.title": "Import Notebook",
Expand Down
40 changes: 40 additions & 0 deletions specs/INTEGRATIONS_CREDENTIALS.md
Original file line number Diff line number Diff line change
Expand Up @@ -433,6 +433,32 @@ Orchestrates the integration management UI and commands.
3. Manager opens webview with integration list
4. Optionally pre-selects a specific integration for configuration

Both commands act only on the notebook a menu or the panel names, when one is given, and refuse it once it is closed
rather than acting on whichever editor has focus. They also refuse a snapshot (`*.snapshot.deepnote`): it matches the
notebook selector, but it records a past run, and the integrations writer never edits one.

**Add Existing Integration** (`deepnote.addExistingIntegration`, also the "Add Existing Integration" button in the
webview, implemented in `existingIntegrationPicker.ts`):

1. Scans every `.deepnote` file in the workspace folders (snapshots excluded) and collects the roster entries of
_other_ projects whose id has a config in SecretStorage; ids already on the active project's roster are skipped,
as are file-only (`.deepnote.env.yaml`) integrations, which already apply workspace-wide.
2. Shows a QuickPick (name, type, "Used in: <projects>"). An id whose roster type disagrees with the stored config's
type is dropped with a warning rather than offered.
3. On selection, appends `{ id, name, type }` to the active project's roster via `addProjectIntegration` (see "Project
files" below). Nothing is copied in SecretStorage: configs (and federated refresh tokens) are keyed by integration
id alone, and the roster entry is what scopes an integration to a project, so the linked project resolves the same
credentials.
4. Refreshes the integrations panel when one is open for this project, without revealing it; run from the command
palette, the command never opens one. Then re-runs the integration env refresh in the project's running kernels,
since no storage change event fires for a roster-only edit. The panel goes first: a busy kernel answers the refresh
only after its running cell.

The panel's Delete only takes a linked integration off the active project's roster, and keeps its credentials, while
another project in the open workspace folders still declares the id. No storage change event fires then either, so the
panel refreshes the project's running kernels itself. Reset, Sign out and Save go ahead on shared credentials, which
are the same for every project declaring the id, and their success message names the other projects they affected.

#### 4. **Integration Webview** (`integrationWebview.ts`)

Provides the webview-based UI for managing integration credentials.
Expand All @@ -444,6 +470,17 @@ Provides the webview-based UI for managing integration credentials.
- Configuration forms for each integration type
- Delete/reset functionality

**Project files:**

Each panel action edits the roster entry it acts on, through `projectIntegrationsWriter.ts`: Save adds the saved
integration or updates its entry in place (`addProjectIntegration`), Delete removes it (`removeProjectIntegration`),
and Reset leaves the roster alone. Every edit merges into what each of the project's `.deepnote` files holds rather
than writing the panel's list, so entries the panel does not list survive it: types it cannot configure, like
`pandas-dataframe`, and integrations linked after it opened. The active file goes first, and the siblings are only
swept once it is on disk; each open notebook's cached project then takes the list its own file now holds. A Save
refreshes the project's running kernels once the roster holds the integration, since the refresh its credential save
triggers may have read the roster before.

**Connection Status:**

`IntegrationItem.tsx` derives the status pill from two inputs: `config` (the SecretStorage credentials the panel
Expand Down Expand Up @@ -480,6 +517,9 @@ Extension → Webview:
Webview → Extension:

```typescript
// Posted on every mount, once the listener is attached; answered with the loc strings, then the list
{ type: 'started' }

// Save configuration
{ type: 'save', integrationId: string, config: IntegrationConfig }

Expand Down
7 changes: 5 additions & 2 deletions specs/INTEGRATION_ENV_LIVE_REFRESH.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,10 @@ Reacts to **file** changes.

### 3. `IntegrationEnvLiveRefresher` (`integrationEnvLiveRefresher.node.ts`)

Performs the refresh, for both triggers.
Performs the refresh, for both triggers above and for edits to a project's `integrations` list, which decides the
stored configs its kernels get. Such an edit writes no storage, so no event fires and the code making it calls the
refresher for the project's open notebooks itself: `IntegrationManager` after Add Existing Integration, and
`IntegrationWebviewProvider` after a Save, or after a Delete that only takes a shared integration off the project.

- Skips notebooks with no kernel, or whose kernel has never started
- Runs a hidden execution in each remaining kernel:
Expand Down Expand Up @@ -127,7 +130,7 @@ web counterpart, since the loopback server and the file reads both require Node

- `IntegrationEnvLiveRefresher.refreshNotebook()` never throws; a per-notebook failure is logged and the other
notebooks still refresh
- Both triggers wrap their async entry point and log rather than surfacing an unhandled rejection
- Every trigger wraps its async entry point and logs rather than surfacing an unhandled rejection
- A refresh that produces `error` outputs is counted as failed, so the success message only appears when at
least one kernel actually applied the new environment
- `UserpodApiEndpoints` keeps a persistent `error` listener (an `error` with no listener would crash the
Expand Down
24 changes: 3 additions & 21 deletions src/messageTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
import { KernelSocketOptions } from './kernels/types';
import { IJupyterVariable, IJupyterVariablesRequest, IJupyterVariablesResponse } from './kernels/variables/types';
import { WidgetScriptSource } from './notebooks/controllers/ipywidgets/types';
import type { IntegrationTypeLabelKey } from './platform/notebooks/deepnote/integrationTypeLabels';

export type NotifyIPyWidgetWidgetVersionNotSupportedAction = {
moduleName: 'qgrid';
Expand Down Expand Up @@ -120,7 +121,7 @@ export enum SharedMessages {
LocInit = 'loc_init'
}

export type LocalizedMessages = {
export type LocalizedMessages = { [K in IntegrationTypeLabelKey]: string } & {
collapseSingle: string;
expandSingle: string;
openExportFileYes: string;
Expand Down Expand Up @@ -186,29 +187,10 @@ export type LocalizedMessages = {
integrationsCancel: string;
integrationsSave: string;
integrationsAddNewIntegration: string;
integrationsAddExistingIntegration: string;
integrationsDatabase: string;
integrationsDataWarehousesLakes: string;
integrationsDatabases: string;
// Integration type labels
integrationsPostgresTypeLabel: string;
integrationsBigQueryTypeLabel: string;
integrationsSnowflakeTypeLabel: string;
integrationsAlloyDBTypeLabel: string;
integrationsAthenaTypeLabel: string;
integrationsClickHouseTypeLabel: string;
integrationsCloudSqlTypeLabel: string;
integrationsDatabricksTypeLabel: string;
integrationsDremioTypeLabel: string;
integrationsMariaDBTypeLabel: string;
integrationsMaterializeTypeLabel: string;
integrationsMindsDBTypeLabel: string;
integrationsMongoDBTypeLabel: string;
integrationsMySQLTypeLabel: string;
integrationsDuckDBTypeLabel: string;
integrationsRedshiftTypeLabel: string;
integrationsSpannerTypeLabel: string;
integrationsSQLServerTypeLabel: string;
integrationsTrinoTypeLabel: string;
// PostgreSQL form strings
integrationsPostgresNameLabel: string;
integrationsPostgresNamePlaceholder: string;
Expand Down
28 changes: 13 additions & 15 deletions src/notebooks/deepnote/deepnoteNotebookManager.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { injectable } from 'inversify';
import type { DeepnoteFile } from '@deepnote/blocks';

import { IDeepnoteNotebookManager, ProjectIntegration } from '../types';
import { IDeepnoteNotebookManager, RawProjectIntegration } from '../types';

/**
* Centralized manager for tracking Deepnote notebook selections and project state.
Expand Down Expand Up @@ -36,24 +36,22 @@ export class DeepnoteNotebookManager implements IDeepnoteNotebookManager {
notebookEntries.set(notebookId, clonedProject);
}

/**
* Updates the integrations list across every cached notebook entry under the project (cache-only).
* @returns `true` if at least one cached entry was updated, `false` otherwise.
*/
updateProjectIntegrations(projectId: string, integrations: ProjectIntegration[]): boolean {
/** Replaces the integrations list of one cached (projectId, notebookId) entry (cache-only). */
updateProjectIntegrationsForNotebook(
projectId: string,
notebookId: string,
integrations: RawProjectIntegration[]
): void {
const notebookEntries = this.originalProjects.get(projectId);
const project = notebookEntries?.get(notebookId);

if (!notebookEntries || notebookEntries.size === 0) {
return false;
if (!notebookEntries || !project) {
return;
}

for (const [notebookId, project] of notebookEntries) {
const updatedProject = structuredClone(project);
updatedProject.project.integrations = structuredClone(integrations);

notebookEntries.set(notebookId, updatedProject);
}
const updatedProject = structuredClone(project);
updatedProject.project.integrations = structuredClone(integrations);

return true;
notebookEntries.set(notebookId, updatedProject);
}
}
126 changes: 32 additions & 94 deletions src/notebooks/deepnote/deepnoteNotebookManager.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,91 +52,33 @@ suite('DeepnoteNotebookManager', () => {
});
});

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

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

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

assert.strictEqual(result, true);

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

test('should replace existing integrations list and return true', () => {
const projectWithIntegrations: DeepnoteFile = {
suite('updateProjectIntegrationsForNotebook', () => {
test('replaces the list of the entry and keeps every other field of it', () => {
manager.storeOriginalProject('project-123', 'notebook-456', {
...mockProject,
project: {
...mockProject.project,
integrations: [{ id: 'old-int', name: 'Old Integration', type: 'pgsql' }]
}
};

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

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

const result = manager.updateProjectIntegrations('project-123', newIntegrations);
project: { ...mockProject.project, integrations: [{ id: 'old-int', name: 'Old', type: 'pgsql' }] }
});

assert.strictEqual(result, true);
const integrations: ProjectIntegration[] = [{ id: 'int-1', name: 'PostgreSQL', type: 'pgsql' }];

const updatedProject = manager.getProjectForNotebook('project-123', 'notebook-456');
assert.deepStrictEqual(updatedProject?.project.integrations, newIntegrations);
});
manager.updateProjectIntegrationsForNotebook('project-123', 'notebook-456', integrations);

test('should handle empty integrations array and return true', () => {
const projectWithIntegrations: DeepnoteFile = {
assert.deepStrictEqual(manager.getProjectForNotebook('project-123', 'notebook-456'), {
...mockProject,
project: {
...mockProject.project,
integrations: [{ id: 'int-1', name: 'Integration 1', type: 'pgsql' }]
}
};

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

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

assert.strictEqual(result, true);

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

test('should return false for unknown project', () => {
const result = manager.updateProjectIntegrations('unknown-project', [
{ id: 'int-1', name: 'Integration', type: 'pgsql' }
]);

assert.strictEqual(result, false);

const project = manager.getProjectForNotebook('unknown-project', 'notebook-456');
assert.strictEqual(project, undefined);
project: { ...mockProject.project, integrations }
});
});

test('should preserve other project properties and return true', () => {
test('caches nothing for a project or notebook that is not cached', () => {
manager.storeOriginalProject('project-123', 'notebook-456', mockProject);

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

const result = manager.updateProjectIntegrations('project-123', integrations);
manager.updateProjectIntegrationsForNotebook('unknown-project', 'notebook-456', integrations);
manager.updateProjectIntegrationsForNotebook('project-123', 'unknown-notebook', integrations);

assert.strictEqual(result, true);

const updatedProject = manager.getProjectForNotebook('project-123', 'notebook-456');
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);
assert.strictEqual(manager.getProjectForNotebook('unknown-project', 'notebook-456'), undefined);
assert.strictEqual(manager.getProjectForNotebook('project-123', 'unknown-notebook'), undefined);
assert.deepStrictEqual(manager.getProjectForNotebook('project-123', 'notebook-456'), mockProject);
});
});

Expand Down Expand Up @@ -180,42 +122,38 @@ suite('DeepnoteNotebookManager', () => {
assert.strictEqual(result, undefined);
});

test('updateProjectIntegrations updates every cached notebook entry under the project', () => {
test('updateProjectIntegrationsForNotebook updates that entry alone, leaving the siblings on their own lists', () => {
const siblingIntegrations: ProjectIntegration[] = [{ id: 'int-b', name: 'MySQL', type: 'mysql' }];
const projectB = siblingProject(nbB, 'Sibling B');
projectB.project.integrations = siblingIntegrations;
manager.storeOriginalProject(projectId, nbA, siblingProject(nbA, 'Sibling A'));
manager.storeOriginalProject(projectId, nbB, siblingProject(nbB, 'Sibling B'));
manager.storeOriginalProject(projectId, nbB, projectB);

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

const updated = manager.updateProjectIntegrations(projectId, integrations);
manager.updateProjectIntegrationsForNotebook(projectId, nbA, integrations);

assert.strictEqual(updated, true);
// BOTH siblings of the project must see the new integrations.
assert.deepStrictEqual(manager.getProjectForNotebook(projectId, nbA)?.project.integrations, integrations);
assert.deepStrictEqual(manager.getProjectForNotebook(projectId, nbB)?.project.integrations, integrations);
assert.deepStrictEqual(
manager.getProjectForNotebook(projectId, nbB)?.project.integrations,
siblingIntegrations
);
});

test('updateProjectIntegrations deep-clones integrations so cached siblings are isolated from the caller and each other', () => {
test('updateProjectIntegrationsForNotebook deep-clones integrations so the cache is isolated from the caller', () => {
manager.storeOriginalProject(projectId, nbA, siblingProject(nbA, 'Sibling A'));
manager.storeOriginalProject(projectId, nbB, siblingProject(nbB, 'Sibling B'));

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

manager.updateProjectIntegrations(projectId, integrations);
manager.updateProjectIntegrationsForNotebook(projectId, nbA, integrations);

// Mutating the caller-owned array and its contents after the call must not leak into the cache.
pg.name = 'MUTATED';
integrations.push({ id: 'int-2', name: 'BigQuery', type: 'big-query' });

const cachedA = manager.getProjectForNotebook(projectId, nbA)?.project.integrations;
const cachedB = manager.getProjectForNotebook(projectId, nbB)?.project.integrations;

assert.deepStrictEqual(cachedA, [{ id: 'int-1', name: 'PostgreSQL', type: 'pgsql' }]);
// Each sibling holds its own array instance rather than a shared reference.
assert.notStrictEqual(cachedA, cachedB);
assert.deepStrictEqual(manager.getProjectForNotebook(projectId, nbA)?.project.integrations, [
{ id: 'int-1', name: 'PostgreSQL', type: 'pgsql' }
]);
});
});
});
Loading
Loading