Skip to content

Commit 44bd482

Browse files
committed
feat(deepnote): Enhance notebook resolution management and error handling
- Introduced `queueNotebookResolution` and `consumePendingNotebookResolution` methods in `DeepnoteNotebookManager` to manage transient notebook resolutions, improving the handling of notebook selections during file operations. - Updated `DeepnoteNotebookSerializer` to prioritize queued notebook resolutions, ensuring more reliable notebook ID retrieval during deserialization. - Refactored `DeepnoteExplorerView` to utilize a new `registerNotebookOpenIntent` method, streamlining the process of selecting and opening notebooks. - Improved error handling in `DeepnoteServerStarter` to log warnings when disposing listeners fails, enhancing diagnostics during server operations. - Adjusted unit tests to cover new functionality and ensure consistent behavior across notebook management processes.
1 parent 9d5c6c0 commit 44bd482

9 files changed

Lines changed: 324 additions & 221 deletions

src/kernels/deepnote/deepnoteServerStarter.node.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -405,7 +405,11 @@ export class DeepnoteServerStarter implements IDeepnoteServerStarter, IExtension
405405
const existing = this.disposablesByFile.get(fileKey);
406406
if (existing) {
407407
for (const d of existing) {
408-
d.dispose();
408+
try {
409+
d.dispose();
410+
} catch (ex) {
411+
logger.warn(`Error disposing listener for ${fileKey}`, ex);
412+
}
409413
}
410414
}
411415
const disposables: IDisposable[] = [];

src/notebooks/deepnote/deepnoteExplorerView.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -260,7 +260,7 @@ export class DeepnoteExplorerView {
260260
await this.treeDataProvider.refreshNotebook(treeItem.context.projectId);
261261

262262
// Optionally open the duplicated notebook
263-
this.manager.selectNotebookForProject(treeItem.context.projectId, newNotebook.id);
263+
this.registerNotebookOpenIntent(treeItem.context.projectId, newNotebook.id);
264264
const notebookUri = fileUri.with({ query: `notebook=${newNotebook.id}` });
265265
const document = await workspace.openNotebookDocument(notebookUri);
266266
await window.showNotebookDocument(document, {
@@ -508,7 +508,7 @@ export class DeepnoteExplorerView {
508508
await this.treeDataProvider.refreshNotebook(projectData.project.id);
509509

510510
// Open the new notebook
511-
this.manager.selectNotebookForProject(projectData.project.id, notebookId);
511+
this.registerNotebookOpenIntent(projectData.project.id, notebookId);
512512
const notebookUri = fileUri.with({ query: `notebook=${notebookId}` });
513513
const document = await workspace.openNotebookDocument(notebookUri);
514514
await window.showNotebookDocument(document, {
@@ -517,6 +517,11 @@ export class DeepnoteExplorerView {
517517
});
518518
}
519519

520+
private registerNotebookOpenIntent(projectId: string, notebookId: string): void {
521+
this.manager.queueNotebookResolution(projectId, notebookId);
522+
this.manager.selectNotebookForProject(projectId, notebookId);
523+
}
524+
520525
private refreshExplorer(): void {
521526
this.treeDataProvider.refresh();
522527
}
@@ -537,7 +542,7 @@ export class DeepnoteExplorerView {
537542

538543
console.log(`Selecting notebook in manager.`);
539544

540-
this.manager.selectNotebookForProject(context.projectId, context.notebookId);
545+
this.registerNotebookOpenIntent(context.projectId, context.notebookId);
541546

542547
console.log(`Opening notebook document.`, fileUri);
543548

@@ -701,7 +706,7 @@ export class DeepnoteExplorerView {
701706

702707
this.treeDataProvider.refresh();
703708

704-
this.manager.selectNotebookForProject(projectId, notebookId);
709+
this.registerNotebookOpenIntent(projectId, notebookId);
705710

706711
const notebookUri = fileUri.with({ query: `notebook=${notebookId}` });
707712
const document = await workspace.openNotebookDocument(notebookUri);

src/notebooks/deepnote/deepnoteFileChangeWatcher.ts

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -242,17 +242,6 @@ export class DeepnoteFileChangeWatcher implements IExtensionSyncActivationServic
242242
doc.notebookType === 'deepnote' && doc.uri.with({ query: '', fragment: '' }).toString() === uriString
243243
);
244244

245-
// Clear the global notebook selection so that any VS Code-triggered
246-
// re-deserialization (e.g. from workspace.save) falls back to the
247-
// active editor rather than a stale global selection.
248-
for (const notebook of affectedNotebooks) {
249-
const projectId = notebook.metadata?.deepnoteProjectId as string | undefined;
250-
if (projectId) {
251-
this.notebookManager.clearNotebookSelection(projectId);
252-
break;
253-
}
254-
}
255-
256245
for (const notebook of affectedNotebooks) {
257246
const nbKey = notebook.uri.toString();
258247
// main-file-sync always replaces any pending operation

src/notebooks/deepnote/deepnoteFileChangeWatcher.unit.test.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -135,9 +135,10 @@ suite('DeepnoteFileChangeWatcher', () => {
135135
mockDisposables = [];
136136

137137
mockedNotebookManager = mock<IDeepnoteNotebookManager>();
138+
when(mockedNotebookManager.consumePendingNotebookResolution(anything())).thenReturn(undefined);
138139
when(mockedNotebookManager.getOriginalProject(anything())).thenReturn(validProject);
139140
when(mockedNotebookManager.getTheSelectedNotebookForAProject(anything())).thenReturn('notebook-1');
140-
when(mockedNotebookManager.clearNotebookSelection(anything())).thenReturn();
141+
when(mockedNotebookManager.queueNotebookResolution(anything(), anything())).thenReturn();
141142
mockNotebookManager = instance(mockedNotebookManager);
142143

143144
// Set up FileSystemWatcher mock
@@ -452,7 +453,7 @@ project:
452453

453454
onDidChangeFile.fire(uri);
454455

455-
await waitFor(() => saveCount > 0);
456+
await waitFor(() => applyEditCount > 0);
456457

457458
// Only ONE applyEdit call (atomic: replaceCells + metadata in single WorkspaceEdit)
458459
assert.strictEqual(applyEditCount, 1, 'applyEdit should be called exactly once (atomic edit)');
@@ -1271,9 +1272,10 @@ project:
12711272

12721273
setup(() => {
12731274
reset(mockedNotebookManager);
1275+
when(mockedNotebookManager.consumePendingNotebookResolution(anything())).thenReturn(undefined);
12741276
when(mockedNotebookManager.getOriginalProject(anything())).thenReturn(multiNotebookProject);
12751277
when(mockedNotebookManager.getTheSelectedNotebookForAProject(anything())).thenReturn('notebook-1');
1276-
when(mockedNotebookManager.clearNotebookSelection(anything())).thenReturn();
1278+
when(mockedNotebookManager.queueNotebookResolution(anything(), anything())).thenReturn();
12771279
resetCalls(mockedNotebookManager);
12781280
workspaceSetCaptures = [];
12791281
sinon.stub(watcher, 'applyNotebookEdits' as any).callsFake(async (...args: unknown[]) => {
@@ -1349,7 +1351,7 @@ project:
13491351
assert.notInclude(byUri.get(uriNb2.toString()) ?? '', 'nb1-new');
13501352
});
13511353

1352-
test('should clear notebook selection before processing file change', async () => {
1354+
test('should not clear notebook selection before processing file change', async () => {
13531355
const basePath = Uri.file('/workspace/multi.deepnote');
13541356
const uriNb1 = basePath.with({ query: 'a=1' });
13551357
const uriNb2 = basePath.with({ query: 'b=2' });
@@ -1393,7 +1395,7 @@ project:
13931395

13941396
await waitFor(() => applyEditCount >= 2);
13951397

1396-
verify(mockedNotebookManager.clearNotebookSelection('project-1')).once();
1398+
verify(mockedNotebookManager.clearNotebookSelection(anything())).never();
13971399
});
13981400

13991401
test('should not corrupt other notebooks when one notebook triggers a file change', async () => {

src/notebooks/deepnote/deepnoteNotebookManager.ts

Lines changed: 60 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,13 @@ import { injectable } from 'inversify';
33
import { IDeepnoteNotebookManager, ProjectIntegration } from '../types';
44
import type { DeepnoteProject } from '../../platform/deepnote/deepnoteTypes';
55

6+
const pendingNotebookResolutionTtlMs = 2_000;
7+
8+
interface PendingNotebookResolution {
9+
notebookId: string;
10+
queuedAt: number;
11+
}
12+
613
/**
714
* Centralized manager for tracking Deepnote notebook selections and project state.
815
* Manages per-project state including current selections and project data caching.
@@ -11,17 +18,36 @@ import type { DeepnoteProject } from '../../platform/deepnote/deepnoteTypes';
1118
export class DeepnoteNotebookManager implements IDeepnoteNotebookManager {
1219
private readonly currentNotebookId = new Map<string, string>();
1320
private readonly originalProjects = new Map<string, DeepnoteProject>();
21+
private readonly pendingNotebookResolutions = new Map<string, PendingNotebookResolution[]>();
1422
private readonly projectsWithInitNotebookRun = new Set<string>();
1523
private readonly selectedNotebookByProject = new Map<string, string>();
1624

1725
/**
18-
* Clears the notebook selection for a project so that subsequent
19-
* deserializations fall back to the active editor or open documents.
26+
* Clears the remembered notebook selection and any pending resolution hints for a project.
2027
*/
2128
clearNotebookSelection(projectId: string): void {
29+
this.pendingNotebookResolutions.delete(projectId);
2230
this.selectedNotebookByProject.delete(projectId);
2331
}
2432

33+
/**
34+
* Consumes the next short-lived notebook resolution hint for a project.
35+
* These hints are queued immediately before operations that trigger a
36+
* deserialize without explicit URI context.
37+
*/
38+
consumePendingNotebookResolution(projectId: string): string | undefined {
39+
const pendingResolutions = this.getValidPendingNotebookResolutions(projectId);
40+
const nextResolution = pendingResolutions.shift();
41+
42+
if (pendingResolutions.length > 0) {
43+
this.pendingNotebookResolutions.set(projectId, pendingResolutions);
44+
} else {
45+
this.pendingNotebookResolutions.delete(projectId);
46+
}
47+
48+
return nextResolution?.notebookId;
49+
}
50+
2551
/**
2652
* Gets the currently selected notebook ID for a project.
2753
* @param projectId Project identifier
@@ -50,9 +76,24 @@ export class DeepnoteNotebookManager implements IDeepnoteNotebookManager {
5076
}
5177

5278
/**
53-
* Associates a notebook ID with a project to remember user's notebook selection.
54-
* When a Deepnote project contains multiple notebooks, this mapping persists the user's
55-
* choice so we can automatically open the same notebook on subsequent file opens.
79+
* Queues a short-lived notebook resolution hint for the next deserialize.
80+
*
81+
* @param projectId - The project ID that identifies the Deepnote project
82+
* @param notebookId - The notebook ID the next deserialize should resolve to
83+
*/
84+
queueNotebookResolution(projectId: string, notebookId: string): void {
85+
const pendingResolutions = this.getValidPendingNotebookResolutions(projectId);
86+
87+
pendingResolutions.push({
88+
notebookId,
89+
queuedAt: Date.now()
90+
});
91+
92+
this.pendingNotebookResolutions.set(projectId, pendingResolutions);
93+
}
94+
95+
/**
96+
* Associates a notebook ID with a project to remember the user's last explicit selection.
5697
*
5798
* @param projectId - The project ID that identifies the Deepnote project
5899
* @param notebookId - The ID of the selected notebook within the project
@@ -134,4 +175,18 @@ export class DeepnoteNotebookManager implements IDeepnoteNotebookManager {
134175
markInitNotebookAsRun(projectId: string): void {
135176
this.projectsWithInitNotebookRun.add(projectId);
136177
}
178+
179+
private getValidPendingNotebookResolutions(projectId: string): PendingNotebookResolution[] {
180+
const cutoffTime = Date.now() - pendingNotebookResolutionTtlMs;
181+
const pendingResolutions = (this.pendingNotebookResolutions.get(projectId) ?? []).filter(
182+
(resolution) => resolution.queuedAt >= cutoffTime
183+
);
184+
185+
if (pendingResolutions.length === 0) {
186+
this.pendingNotebookResolutions.delete(projectId);
187+
return [];
188+
}
189+
190+
return pendingResolutions;
191+
}
137192
}

src/notebooks/deepnote/deepnoteNotebookManager.unit.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,39 @@ suite('DeepnoteNotebookManager', () => {
9393
});
9494
});
9595

96+
suite('consumePendingNotebookResolution', () => {
97+
test('should return undefined when no pending resolution exists', () => {
98+
const result = manager.consumePendingNotebookResolution('unknown-project');
99+
100+
assert.strictEqual(result, undefined);
101+
});
102+
103+
test('should consume queued notebook resolutions in order', () => {
104+
manager.queueNotebookResolution('project-123', 'notebook-1');
105+
manager.queueNotebookResolution('project-123', 'notebook-2');
106+
107+
assert.strictEqual(manager.consumePendingNotebookResolution('project-123'), 'notebook-1');
108+
assert.strictEqual(manager.consumePendingNotebookResolution('project-123'), 'notebook-2');
109+
assert.strictEqual(manager.consumePendingNotebookResolution('project-123'), undefined);
110+
});
111+
112+
test('should keep pending resolutions isolated per project', () => {
113+
manager.queueNotebookResolution('project-1', 'notebook-1');
114+
manager.queueNotebookResolution('project-2', 'notebook-2');
115+
116+
assert.strictEqual(manager.consumePendingNotebookResolution('project-1'), 'notebook-1');
117+
assert.strictEqual(manager.consumePendingNotebookResolution('project-2'), 'notebook-2');
118+
});
119+
});
120+
121+
suite('queueNotebookResolution', () => {
122+
test('should queue a notebook resolution for later consumption', () => {
123+
manager.queueNotebookResolution('project-123', 'notebook-456');
124+
125+
assert.strictEqual(manager.consumePendingNotebookResolution('project-123'), 'notebook-456');
126+
});
127+
});
128+
96129
suite('selectNotebookForProject', () => {
97130
test('should store notebook selection for project', () => {
98131
manager.selectNotebookForProject('project-123', 'notebook-456');
@@ -148,6 +181,13 @@ suite('DeepnoteNotebookManager', () => {
148181
manager.clearNotebookSelection('unknown-project');
149182
});
150183
});
184+
185+
test('should clear pending notebook resolutions for a project', () => {
186+
manager.queueNotebookResolution('project-123', 'notebook-456');
187+
manager.clearNotebookSelection('project-123');
188+
189+
assert.strictEqual(manager.consumePendingNotebookResolution('project-123'), undefined);
190+
});
151191
});
152192

153193
suite('storeOriginalProject', () => {

src/notebooks/deepnote/deepnoteSerializer.ts

Lines changed: 37 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { DeepnoteBlock, DeepnoteFile, DeepnoteSnapshot } from '@deepnote/blocks';
22
import { deserializeDeepnoteFile, isExecutableBlock, serializeDeepnoteSnapshot } from '@deepnote/blocks';
33
import { inject, injectable, optional } from 'inversify';
4-
import { l10n, window, workspace, type CancellationToken, type NotebookData, type NotebookSerializer } from 'vscode';
4+
import { l10n, workspace, type CancellationToken, type NotebookData, type NotebookSerializer } from 'vscode';
55

66
import { logger } from '../../platform/logging';
77
import { IDeepnoteNotebookManager } from '../types';
@@ -62,7 +62,8 @@ export class DeepnoteNotebookSerializer implements NotebookSerializer {
6262
/**
6363
* Deserializes a Deepnote YAML file into VS Code notebook format.
6464
* Parses YAML and converts the selected notebook's blocks to cells.
65-
* The notebook to deserialize must be pre-selected and stored in the manager.
65+
* Notebook resolution prefers an explicit notebook ID, then transient
66+
* resolver state, and finally a deterministic default notebook.
6667
* @param content Raw file content as bytes
6768
* @param token Cancellation token (unused)
6869
* @returns Promise resolving to notebook data
@@ -186,37 +187,29 @@ export class DeepnoteNotebookSerializer implements NotebookSerializer {
186187
}
187188

188189
/**
189-
* Finds the notebook ID to deserialize by checking the manager's stored selection.
190-
* The notebook ID should be set via selectNotebookForProject before opening the document.
190+
* Finds the notebook ID to deserialize without relying on active-editor state.
191+
* Prefers a pending resolution hint, then current/open-document state.
191192
* @param projectId The project ID to find a notebook for
192193
* @returns The notebook ID to deserialize, or undefined if none found
193194
*/
194195
findCurrentNotebookId(projectId: string): string | undefined {
195-
// Check the manager's stored selection first - this is set explicitly when the user
196-
// picks a notebook from the explorer, and must take priority over the active editor
197-
const storedNotebookId = this.notebookManager.getTheSelectedNotebookForAProject(projectId);
196+
const pendingNotebookId = this.notebookManager.consumePendingNotebookResolution(projectId);
197+
const openNotebookIds = this.findOpenNotebookIds(projectId);
198+
const currentNotebookId = this.notebookManager.getCurrentNotebookId(projectId);
198199

199-
if (storedNotebookId) {
200-
return storedNotebookId;
200+
if (pendingNotebookId) {
201+
return pendingNotebookId;
201202
}
202203

203-
// Fallback: prefer the active notebook editor when it matches the project
204-
const activeEditorNotebook = window.activeNotebookEditor?.notebook;
205-
206-
if (
207-
activeEditorNotebook?.notebookType === 'deepnote' &&
208-
activeEditorNotebook.metadata?.deepnoteProjectId === projectId &&
209-
activeEditorNotebook.metadata?.deepnoteNotebookId
210-
) {
211-
return activeEditorNotebook.metadata.deepnoteNotebookId;
204+
if (currentNotebookId && (openNotebookIds.length === 0 || openNotebookIds.includes(currentNotebookId))) {
205+
return currentNotebookId;
212206
}
213207

214-
// Last fallback: Check if there's an active notebook document for this project
215-
const openNotebook = workspace.notebookDocuments.find(
216-
(doc) => doc.notebookType === 'deepnote' && doc.metadata?.deepnoteProjectId === projectId
217-
);
208+
if (openNotebookIds.length === 1) {
209+
return openNotebookIds[0];
210+
}
218211

219-
return openNotebook?.metadata?.deepnoteNotebookId;
212+
return undefined;
220213
}
221214

222215
/**
@@ -264,7 +257,7 @@ export class DeepnoteNotebookSerializer implements NotebookSerializer {
264257
logger.debug('SerializeNotebook: Got and cloned original project');
265258

266259
const notebookId =
267-
data.metadata?.deepnoteNotebookId || this.notebookManager.getTheSelectedNotebookForAProject(projectId);
260+
data.metadata?.deepnoteNotebookId || this.notebookManager.getCurrentNotebookId(projectId);
268261

269262
if (!notebookId) {
270263
throw new Error('Cannot determine which notebook to save');
@@ -353,6 +346,11 @@ export class DeepnoteNotebookSerializer implements NotebookSerializer {
353346

354347
// Store the updated project back so subsequent saves start from correct state
355348
this.notebookManager.storeOriginalProject(projectId, originalProject, notebookId);
349+
const openNotebookIdsAtSerialize = this.findOpenNotebookIds(projectId);
350+
351+
if (openNotebookIdsAtSerialize.length === 0) {
352+
this.notebookManager.queueNotebookResolution(projectId, notebookId);
353+
}
356354

357355
logger.debug('SerializeNotebook: Serializing to YAML');
358356

@@ -555,6 +553,21 @@ export class DeepnoteNotebookSerializer implements NotebookSerializer {
555553
return false;
556554
}
557555

556+
private findOpenNotebookIds(projectId: string): string[] {
557+
return [
558+
...new Set(
559+
workspace.notebookDocuments
560+
.filter(
561+
(doc) =>
562+
doc.notebookType === 'deepnote' &&
563+
doc.metadata?.deepnoteProjectId === projectId &&
564+
typeof doc.metadata?.deepnoteNotebookId === 'string'
565+
)
566+
.map((doc) => doc.metadata.deepnoteNotebookId as string)
567+
)
568+
];
569+
}
570+
558571
/**
559572
* Finds the default notebook to open when no selection is made.
560573
* @param file

0 commit comments

Comments
 (0)