Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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
52 changes: 44 additions & 8 deletions src/notebooks/deepnote/deepnoteFileChangeWatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ export class DeepnoteFileChangeWatcher implements IExtensionSyncActivationServic
* Consumes a self-write marker. Returns true if the fs event was self-triggered.
*/
private consumeSelfWrite(uri: Uri): boolean {
const key = uri.toString();
const key = this.normalizeFileUri(uri);

// Check snapshot self-writes first
if (this.snapshotSelfWriteUris.has(key)) {
Expand Down Expand Up @@ -187,6 +187,7 @@ export class DeepnoteFileChangeWatcher implements IExtensionSyncActivationServic
if (liveCells.length !== newCells.length) {
return true;
}

return liveCells.some(
(live, i) =>
live.kind !== newCells[i].kind ||
Expand Down Expand Up @@ -332,6 +333,7 @@ export class DeepnoteFileChangeWatcher implements IExtensionSyncActivationServic
}
}

// Apply the edit to update in-memory cells immediately (responsive UX).
const wsEdit = new WorkspaceEdit();
wsEdit.set(notebook.uri, edits);
const applied = await workspace.applyEdit(wsEdit);
Expand All @@ -341,13 +343,38 @@ export class DeepnoteFileChangeWatcher implements IExtensionSyncActivationServic
return;
}

// Save to sync mtime — mark as self-write first
this.markSelfWrite(notebook.uri);
// Serialize the notebook and write canonical bytes to disk. This ensures
// the file on disk matches what VS Code's serializer would produce.
// Then save via workspace.save() to clear dirty state and update VS Code's
// internal mtime tracker. Since WE just wrote the file, its mtime is from
// our write (not the external change), avoiding the "content is newer" conflict.
const serializeTokenSource = new CancellationTokenSource();
try {
await workspace.save(notebook.uri);
} catch (error) {
this.consumeSelfWrite(notebook.uri);
throw error;
const serializedBytes = await this.serializer.serializeNotebook(newData, serializeTokenSource.token);

// Write to disk first — this updates the file mtime to "now"
this.markSelfWrite(fileUri);
try {
await workspace.fs.writeFile(fileUri, serializedBytes);
} catch (writeError) {
this.consumeSelfWrite(fileUri);
logger.warn(`[FileChangeWatcher] Failed to write synced file: ${fileUri.path}`, writeError);
}

// Now save — VS Code serializes (same bytes), sees the mtime is from our
// recent write (which its internal watcher has picked up), and writes
// successfully without a "content is newer" conflict.
this.markSelfWrite(fileUri);
try {
await workspace.save(notebook.uri);
} catch (saveError) {
this.consumeSelfWrite(fileUri);
logger.warn(`[FileChangeWatcher] Save after sync write failed: ${notebook.uri.path}`, saveError);
}
Comment on lines +356 to +373
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Missing return after writeFile failure may cause unintended behavior.

If writeFile fails (line 358), execution continues to markSelfWrite and workspace.save (lines 367-373). The comment at line 364-366 assumes the file was written ("mtime is from our recent write"), which doesn't hold on failure.

Consider returning early after a writeFile failure, or clarify if the fallback to save is intentional.

Proposed fix
             try {
                 await workspace.fs.writeFile(fileUri, serializedBytes);
             } catch (writeError) {
                 this.consumeSelfWrite(fileUri);
                 logger.warn(`[FileChangeWatcher] Failed to write synced file: ${fileUri.path}`, writeError);
+                return;
             }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
this.markSelfWrite(fileUri);
try {
await workspace.fs.writeFile(fileUri, serializedBytes);
} catch (writeError) {
this.consumeSelfWrite(fileUri);
logger.warn(`[FileChangeWatcher] Failed to write synced file: ${fileUri.path}`, writeError);
}
// Now save — VS Code serializes (same bytes), sees the mtime is from our
// recent write (which its internal watcher has picked up), and writes
// successfully without a "content is newer" conflict.
this.markSelfWrite(fileUri);
try {
await workspace.save(notebook.uri);
} catch (saveError) {
this.consumeSelfWrite(fileUri);
logger.warn(`[FileChangeWatcher] Save after sync write failed: ${notebook.uri.path}`, saveError);
}
this.markSelfWrite(fileUri);
try {
await workspace.fs.writeFile(fileUri, serializedBytes);
} catch (writeError) {
this.consumeSelfWrite(fileUri);
logger.warn(`[FileChangeWatcher] Failed to write synced file: ${fileUri.path}`, writeError);
return;
}
// Now save — VS Code serializes (same bytes), sees the mtime is from our
// recent write (which its internal watcher has picked up), and writes
// successfully without a "content is newer" conflict.
this.markSelfWrite(fileUri);
try {
await workspace.save(notebook.uri);
} catch (saveError) {
this.consumeSelfWrite(fileUri);
logger.warn(`[FileChangeWatcher] Save after sync write failed: ${notebook.uri.path}`, saveError);
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/notebooks/deepnote/deepnoteFileChangeWatcher.ts` around lines 356 - 373,
The writeFile catch path allows execution to continue to the subsequent
markSelfWrite and workspace.save calls which assume the file was written; modify
the write failure handler in deepnoteFileChangeWatcher (around
workspace.fs.writeFile) to return early after calling
this.consumeSelfWrite(fileUri) and logging the warning so we do not proceed to
markSelfWrite or call workspace.save on a failed write; ensure markSelfWrite and
consumeSelfWrite usage remains correct and only the successful-write path
reaches the save logic.

} catch (serializeError) {
logger.warn(`[FileChangeWatcher] Failed to serialize for sync write: ${fileUri.path}`, serializeError);
} finally {
serializeTokenSource.dispose();
}

logger.info(`[FileChangeWatcher] Reloaded notebook from external change: ${notebook.uri.path}`);
Expand Down Expand Up @@ -523,6 +550,15 @@ export class DeepnoteFileChangeWatcher implements IExtensionSyncActivationServic
return (metadata?.__deepnoteBlockId ?? metadata?.id) as string | undefined;
}

/**
* Normalizes a URI to the underlying file path by stripping query and fragment.
* Notebook URIs include query params (e.g., ?notebook=id) but the filesystem
* watcher fires with the raw file URI — keys must match for self-write detection.
*/
private normalizeFileUri(uri: Uri): string {
return uri.with({ query: '', fragment: '' }).toString();
}

private handleFileChange(uri: Uri): void {
// Deterministic self-write check — no timers involved
if (this.consumeSelfWrite(uri)) {
Expand Down Expand Up @@ -581,7 +617,7 @@ export class DeepnoteFileChangeWatcher implements IExtensionSyncActivationServic
* Call before workspace.save() to prevent the resulting fs event from triggering a reload.
*/
private markSelfWrite(uri: Uri): void {
const key = uri.toString();
const key = this.normalizeFileUri(uri);
const count = this.selfWriteCounts.get(key) ?? 0;
this.selfWriteCounts.set(key, count + 1);

Expand Down
19 changes: 10 additions & 9 deletions src/notebooks/deepnote/deepnoteSerializer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,15 @@ export class DeepnoteNotebookSerializer implements NotebookSerializer {
* @returns The notebook ID to deserialize, or undefined if none found
*/
findCurrentNotebookId(projectId: string): string | undefined {
// Prefer the active notebook editor when it matches the project
// Check the manager's stored selection first - this is set explicitly when the user
// picks a notebook from the explorer, and must take priority over the active editor
const storedNotebookId = this.notebookManager.getTheSelectedNotebookForAProject(projectId);

if (storedNotebookId) {
return storedNotebookId;
}

// Fallback: prefer the active notebook editor when it matches the project
const activeEditorNotebook = window.activeNotebookEditor?.notebook;

if (
Expand All @@ -199,14 +207,7 @@ export class DeepnoteNotebookSerializer implements NotebookSerializer {
return activeEditorNotebook.metadata.deepnoteNotebookId;
}

// Check the manager's stored selection - this should be set when opening from explorer
const storedNotebookId = this.notebookManager.getTheSelectedNotebookForAProject(projectId);

if (storedNotebookId) {
return storedNotebookId;
}

// Fallback: Check if there's an active notebook document for this project
// Last fallback: Check if there's an active notebook document for this project
const openNotebook = workspace.notebookDocuments.find(
(doc) => doc.notebookType === 'deepnote' && doc.metadata?.deepnoteProjectId === projectId
);
Expand Down
79 changes: 68 additions & 11 deletions src/notebooks/deepnote/deepnoteSerializer.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -275,11 +275,9 @@ project:
assert.strictEqual(result2, 'notebook-2');
});

test('should prioritize active notebook editor over stored selection', () => {
// Store a selection for the project
test('should prioritize stored selection over active editor', () => {
manager.selectNotebookForProject('project-123', 'stored-notebook');

// Mock the active notebook editor to return a different notebook
const mockActiveNotebook = {
notebookType: 'deepnote',
metadata: {
Expand All @@ -294,14 +292,30 @@ project:

const result = serializer.findCurrentNotebookId('project-123');

// Should return the active editor's notebook, not the stored one
assert.strictEqual(result, 'stored-notebook');
});

test('should return active editor notebook when no stored selection exists', () => {
const mockActiveNotebook = {
notebookType: 'deepnote',
metadata: {
deepnoteProjectId: 'project-123',
deepnoteNotebookId: 'active-editor-notebook'
}
};

when(mockedVSCodeNamespaces.window.activeNotebookEditor).thenReturn({
notebook: mockActiveNotebook
} as any);

const result = serializer.findCurrentNotebookId('project-123');

assert.strictEqual(result, 'active-editor-notebook');
});

test('should ignore active editor when project ID does not match', () => {
manager.selectNotebookForProject('project-123', 'stored-notebook');

// Mock active editor with a different project
const mockActiveNotebook = {
notebookType: 'deepnote',
metadata: {
Expand All @@ -316,14 +330,12 @@ project:

const result = serializer.findCurrentNotebookId('project-123');

// Should fall back to stored selection since active editor is for different project
assert.strictEqual(result, 'stored-notebook');
});

test('should ignore active editor when notebook type is not deepnote', () => {
manager.selectNotebookForProject('project-123', 'stored-notebook');

// Mock active editor with non-deepnote notebook type
const mockActiveNotebook = {
notebookType: 'jupyter-notebook',
metadata: {
Expand All @@ -338,19 +350,16 @@ project:

const result = serializer.findCurrentNotebookId('project-123');

// Should fall back to stored selection since active editor is not a deepnote notebook
assert.strictEqual(result, 'stored-notebook');
});

test('should ignore active editor when notebook ID is missing', () => {
manager.selectNotebookForProject('project-123', 'stored-notebook');

// Mock active editor without notebook ID in metadata
const mockActiveNotebook = {
notebookType: 'deepnote',
metadata: {
deepnoteProjectId: 'project-123'
// Missing deepnoteNotebookId
}
};

Expand All @@ -360,9 +369,57 @@ project:

const result = serializer.findCurrentNotebookId('project-123');

// Should fall back to stored selection since active editor has no notebook ID
assert.strictEqual(result, 'stored-notebook');
});

test('switching notebooks: selecting a different notebook while one is open should return the new selection', () => {
manager.selectNotebookForProject('project-123', 'notebook-A');

const mockActiveNotebook = {
notebookType: 'deepnote',
metadata: {
deepnoteProjectId: 'project-123',
deepnoteNotebookId: 'notebook-A'
}
};

when(mockedVSCodeNamespaces.window.activeNotebookEditor).thenReturn({
notebook: mockActiveNotebook
} as any);

assert.strictEqual(serializer.findCurrentNotebookId('project-123'), 'notebook-A');

manager.selectNotebookForProject('project-123', 'notebook-B');

assert.strictEqual(
serializer.findCurrentNotebookId('project-123'),
'notebook-B',
'Should return the newly selected notebook, not the one currently in the active editor'
);
});

test('switching notebooks: rapidly switching between three notebooks should always return the latest selection', () => {
const mockActiveNotebook = {
notebookType: 'deepnote',
metadata: {
deepnoteProjectId: 'project-123',
deepnoteNotebookId: 'notebook-1'
}
};

when(mockedVSCodeNamespaces.window.activeNotebookEditor).thenReturn({
notebook: mockActiveNotebook
} as any);

manager.selectNotebookForProject('project-123', 'notebook-1');
assert.strictEqual(serializer.findCurrentNotebookId('project-123'), 'notebook-1');

manager.selectNotebookForProject('project-123', 'notebook-2');
assert.strictEqual(serializer.findCurrentNotebookId('project-123'), 'notebook-2');

manager.selectNotebookForProject('project-123', 'notebook-3');
assert.strictEqual(serializer.findCurrentNotebookId('project-123'), 'notebook-3');
});
});

suite('component integration', () => {
Expand Down
Loading