-
Notifications
You must be signed in to change notification settings - Fork 8
fix: Fix deepnote notebook deserializer and file change watcher #363
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 33 commits
6802960
2de7885
bc2de06
3a418c1
9b5c107
77c0347
6dc77a0
8898be9
c74ffa7
8d061ac
5388ead
6d05f22
c5342a3
3e7dfc9
c160ae3
0b2504a
9d5c6c0
44bd482
36cc1e6
71579cf
23fdcca
824f471
c6d21e6
5d9d6e2
4962616
552366b
bd4d5fd
cb4644f
ec96f53
d44c1aa
7815f42
855f38c
f72b0fa
8e6f679
0396a2c
06d3197
f4b79d3
c83af55
40f6c67
46cbcda
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,3 +1,5 @@ | ||||||||||||||||||||
| import type { DeepnoteBlock } from '@deepnote/blocks'; | ||||||||||||||||||||
| import { inject, injectable, optional } from 'inversify'; | ||||||||||||||||||||
| import { | ||||||||||||||||||||
| CancellationTokenSource, | ||||||||||||||||||||
| NotebookCell, | ||||||||||||||||||||
|
|
@@ -10,13 +12,11 @@ import { | |||||||||||||||||||
| WorkspaceEdit, | ||||||||||||||||||||
| workspace | ||||||||||||||||||||
| } from 'vscode'; | ||||||||||||||||||||
| import { inject, injectable, optional } from 'inversify'; | ||||||||||||||||||||
| import type { DeepnoteBlock } from '@deepnote/blocks'; | ||||||||||||||||||||
|
|
||||||||||||||||||||
| import { IControllerRegistration } from '../controllers/types'; | ||||||||||||||||||||
| import { IExtensionSyncActivationService } from '../../platform/activation/types'; | ||||||||||||||||||||
| import { IDisposableRegistry } from '../../platform/common/types'; | ||||||||||||||||||||
| import { logger } from '../../platform/logging'; | ||||||||||||||||||||
| import { IControllerRegistration } from '../controllers/types'; | ||||||||||||||||||||
| import { IDeepnoteNotebookManager } from '../types'; | ||||||||||||||||||||
| import { DeepnoteDataConverter } from './deepnoteDataConverter'; | ||||||||||||||||||||
| import { DeepnoteNotebookSerializer } from './deepnoteSerializer'; | ||||||||||||||||||||
|
|
@@ -99,6 +99,15 @@ export class DeepnoteFileChangeWatcher implements IExtensionSyncActivationServic | |||||||||||||||||||
| this.disposables.push(watcher.onDidCreate((uri) => this.handleFileChange(uri))); | ||||||||||||||||||||
| this.disposables.push({ dispose: () => this.clearAllTimers() }); | ||||||||||||||||||||
|
|
||||||||||||||||||||
| this.disposables.push( | ||||||||||||||||||||
| workspace.onDidSaveNotebookDocument((notebook) => { | ||||||||||||||||||||
| if (notebook.notebookType === 'deepnote') { | ||||||||||||||||||||
| const fileUri = notebook.uri.with({ query: '', fragment: '' }); | ||||||||||||||||||||
| this.markSelfWrite(fileUri); | ||||||||||||||||||||
| } | ||||||||||||||||||||
| }) | ||||||||||||||||||||
| ); | ||||||||||||||||||||
|
|
||||||||||||||||||||
| if (this.snapshotService) { | ||||||||||||||||||||
| this.disposables.push( | ||||||||||||||||||||
| this.snapshotService.onFileWritten((uri) => { | ||||||||||||||||||||
|
|
@@ -122,6 +131,13 @@ export class DeepnoteFileChangeWatcher implements IExtensionSyncActivationServic | |||||||||||||||||||
| } | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| public async applyNotebookEdits(uri: Uri, edits: NotebookEdit[]): Promise<boolean> { | ||||||||||||||||||||
| const wsEdit = new WorkspaceEdit(); | ||||||||||||||||||||
| wsEdit.set(uri, edits); | ||||||||||||||||||||
|
|
||||||||||||||||||||
| return workspace.applyEdit(wsEdit); | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| private clearAllTimers(): void { | ||||||||||||||||||||
| for (const timer of this.debounceTimers.values()) { | ||||||||||||||||||||
| clearTimeout(timer); | ||||||||||||||||||||
|
|
@@ -145,7 +161,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)) { | ||||||||||||||||||||
|
|
@@ -187,6 +203,7 @@ export class DeepnoteFileChangeWatcher implements IExtensionSyncActivationServic | |||||||||||||||||||
| if (liveCells.length !== newCells.length) { | ||||||||||||||||||||
| return true; | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| return liveCells.some( | ||||||||||||||||||||
| (live, i) => | ||||||||||||||||||||
| live.kind !== newCells[i].kind || | ||||||||||||||||||||
|
|
@@ -275,10 +292,15 @@ export class DeepnoteFileChangeWatcher implements IExtensionSyncActivationServic | |||||||||||||||||||
| return; | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| // Pass the notebook ID explicitly to avoid mutating the global selection state. | ||||||||||||||||||||
| // Multiple notebooks from the same project may be open simultaneously, and | ||||||||||||||||||||
| // mutating selectedNotebookByProject would cause race conditions. | ||||||||||||||||||||
| const targetNotebookId = notebook.metadata?.deepnoteNotebookId as string | undefined; | ||||||||||||||||||||
|
|
||||||||||||||||||||
| const tokenSource = new CancellationTokenSource(); | ||||||||||||||||||||
| let newData; | ||||||||||||||||||||
| try { | ||||||||||||||||||||
| newData = await this.serializer.deserializeNotebook(content, tokenSource.token); | ||||||||||||||||||||
| newData = await this.serializer.deserializeNotebook(content, tokenSource.token, targetNotebookId); | ||||||||||||||||||||
| } catch (error) { | ||||||||||||||||||||
| logger.warn(`[FileChangeWatcher] Failed to parse changed file: ${fileUri.path}`, error); | ||||||||||||||||||||
| return; | ||||||||||||||||||||
|
|
@@ -332,22 +354,53 @@ export class DeepnoteFileChangeWatcher implements IExtensionSyncActivationServic | |||||||||||||||||||
| } | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| const wsEdit = new WorkspaceEdit(); | ||||||||||||||||||||
| wsEdit.set(notebook.uri, edits); | ||||||||||||||||||||
| const applied = await workspace.applyEdit(wsEdit); | ||||||||||||||||||||
| // Apply the edit to update in-memory cells immediately (responsive UX). | ||||||||||||||||||||
| const applied = await this.applyNotebookEdits(notebook.uri, edits); | ||||||||||||||||||||
|
|
||||||||||||||||||||
| if (!applied) { | ||||||||||||||||||||
| logger.warn(`[FileChangeWatcher] Failed to apply edit: ${notebook.uri.path}`); | ||||||||||||||||||||
| 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); | ||||||||||||||||||||
|
|
||||||||||||||||||||
| // Bail out — without a successful write, workspace.save() would hit | ||||||||||||||||||||
| // the stale mtime and show a "content is newer" conflict dialog. | ||||||||||||||||||||
| // The notebook stays dirty but cells are already up-to-date from applyEdit. | ||||||||||||||||||||
| return; | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| // Save to clear dirty state. VS Code serializes (same bytes) and sees the | ||||||||||||||||||||
| // mtime from our recent write, so no "content is newer" conflict. | ||||||||||||||||||||
| // NOTE: onDidSaveNotebookDocument handles the self-write mark for this save. | ||||||||||||||||||||
| try { | ||||||||||||||||||||
| const saved = await workspace.save(notebook.uri); | ||||||||||||||||||||
| if (!saved) { | ||||||||||||||||||||
| logger.warn(`[FileChangeWatcher] Save after sync write returned undefined: ${notebook.uri.path}`); | ||||||||||||||||||||
| return; | ||||||||||||||||||||
| } | ||||||||||||||||||||
| } catch (saveError) { | ||||||||||||||||||||
| logger.warn(`[FileChangeWatcher] Save after sync write failed: ${notebook.uri.path}`, saveError); | ||||||||||||||||||||
|
tkislan marked this conversation as resolved.
|
||||||||||||||||||||
| } | ||||||||||||||||||||
|
coderabbitai[bot] marked this conversation as resolved.
|
||||||||||||||||||||
| } 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}`); | ||||||||||||||||||||
|
|
@@ -452,9 +505,13 @@ export class DeepnoteFileChangeWatcher implements IExtensionSyncActivationServic | |||||||||||||||||||
| } | ||||||||||||||||||||
| } | ||||||||||||||||||||
| if (metadataEdits.length > 0) { | ||||||||||||||||||||
| const wsEdit = new WorkspaceEdit(); | ||||||||||||||||||||
| wsEdit.set(notebook.uri, metadataEdits); | ||||||||||||||||||||
| await workspace.applyEdit(wsEdit); | ||||||||||||||||||||
| const metadataApplied = await this.applyNotebookEdits(notebook.uri, metadataEdits); | ||||||||||||||||||||
| if (!metadataApplied) { | ||||||||||||||||||||
| logger.warn( | ||||||||||||||||||||
| `[FileChangeWatcher] Failed to restore block IDs via execution path: ${notebook.uri.path}` | ||||||||||||||||||||
| ); | ||||||||||||||||||||
| return; | ||||||||||||||||||||
| } | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| logger.info(`[FileChangeWatcher] Updated notebook outputs via execution API: ${notebook.uri.path}`); | ||||||||||||||||||||
|
|
@@ -482,9 +539,7 @@ export class DeepnoteFileChangeWatcher implements IExtensionSyncActivationServic | |||||||||||||||||||
| ); | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| const wsEdit = new WorkspaceEdit(); | ||||||||||||||||||||
| wsEdit.set(notebook.uri, replaceEdits); | ||||||||||||||||||||
| const applied = await workspace.applyEdit(wsEdit); | ||||||||||||||||||||
| const applied = await this.applyNotebookEdits(notebook.uri, replaceEdits); | ||||||||||||||||||||
|
|
||||||||||||||||||||
| if (!applied) { | ||||||||||||||||||||
| logger.warn(`[FileChangeWatcher] Failed to apply snapshot outputs: ${notebook.uri.path}`); | ||||||||||||||||||||
|
|
@@ -503,26 +558,32 @@ export class DeepnoteFileChangeWatcher implements IExtensionSyncActivationServic | |||||||||||||||||||
| }) | ||||||||||||||||||||
| ); | ||||||||||||||||||||
| } | ||||||||||||||||||||
| const metaEdit = new WorkspaceEdit(); | ||||||||||||||||||||
| metaEdit.set(notebook.uri, metadataEdits); | ||||||||||||||||||||
| await workspace.applyEdit(metaEdit); | ||||||||||||||||||||
|
|
||||||||||||||||||||
| // Save to sync mtime — mark as self-write first | ||||||||||||||||||||
| this.markSelfWrite(notebook.uri); | ||||||||||||||||||||
| try { | ||||||||||||||||||||
| await workspace.save(notebook.uri); | ||||||||||||||||||||
| } catch (error) { | ||||||||||||||||||||
| this.consumeSelfWrite(notebook.uri); | ||||||||||||||||||||
| throw error; | ||||||||||||||||||||
| const metadataApplied = await this.applyNotebookEdits(notebook.uri, metadataEdits); | ||||||||||||||||||||
| if (!metadataApplied) { | ||||||||||||||||||||
| logger.warn(`[FileChangeWatcher] Failed to restore block IDs after replaceCells: ${notebook.uri.path}`); | ||||||||||||||||||||
| return; | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| // Save to sync mtime. | ||||||||||||||||||||
| // NOTE: onDidSaveNotebookDocument handles the self-write mark for this save. | ||||||||||||||||||||
| await workspace.save(notebook.uri); | ||||||||||||||||||||
|
Comment on lines
+564
to
+566
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Check Lines 391-396 check for Suggested fix // Save to sync mtime.
// NOTE: onDidSaveNotebookDocument handles the self-write mark for this save.
-await workspace.save(notebook.uri);
+const saved = await workspace.save(notebook.uri);
+if (!saved) {
+ logger.warn(`[FileChangeWatcher] Save after snapshot update returned undefined: ${notebook.uri.path}`);
+}📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||
|
|
||||||||||||||||||||
| logger.info(`[FileChangeWatcher] Updated notebook outputs from external snapshot: ${notebook.uri.path}`); | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| private getBlockIdFromMetadata(metadata: Record<string, unknown> | undefined): string | undefined { | ||||||||||||||||||||
| 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(); | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
tkislan marked this conversation as resolved.
|
||||||||||||||||||||
|
|
||||||||||||||||||||
| private handleFileChange(uri: Uri): void { | ||||||||||||||||||||
| // Deterministic self-write check — no timers involved | ||||||||||||||||||||
| if (this.consumeSelfWrite(uri)) { | ||||||||||||||||||||
|
|
@@ -577,11 +638,12 @@ export class DeepnoteFileChangeWatcher implements IExtensionSyncActivationServic | |||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| /** | ||||||||||||||||||||
| * Marks a URI as about to be written by us (workspace.save). | ||||||||||||||||||||
| * Call before workspace.save() to prevent the resulting fs event from triggering a reload. | ||||||||||||||||||||
| * Marks a URI as about to be written by us. | ||||||||||||||||||||
| * For workspace.fs.writeFile(): call this before the write. | ||||||||||||||||||||
| * For workspace.save(): do NOT call this — onDidSaveNotebookDocument handles it. | ||||||||||||||||||||
| */ | ||||||||||||||||||||
| 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); | ||||||||||||||||||||
|
|
||||||||||||||||||||
|
|
||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
Repository: deepnote/vscode-deepnote
Length of output: 87
🏁 Script executed:
Repository: deepnote/vscode-deepnote
Length of output: 1794
🏁 Script executed:
Repository: deepnote/vscode-deepnote
Length of output: 6998
🏁 Script executed:
Repository: deepnote/vscode-deepnote
Length of output: 123
🏁 Script executed:
Repository: deepnote/vscode-deepnote
Length of output: 1409
🏁 Script executed:
Repository: deepnote/vscode-deepnote
Length of output: 2412
🏁 Script executed:
Repository: deepnote/vscode-deepnote
Length of output: 3805
🏁 Script executed:
Repository: deepnote/vscode-deepnote
Length of output: 50
🏁 Script executed:
Repository: deepnote/vscode-deepnote
Length of output: 2138
🏁 Script executed:
Repository: deepnote/vscode-deepnote
Length of output: 1183
🏁 Script executed:
Repository: deepnote/vscode-deepnote
Length of output: 335
🏁 Script executed:
Repository: deepnote/vscode-deepnote
Length of output: 2223
🏁 Script executed:
Repository: deepnote/vscode-deepnote
Length of output: 601
Add
commandPalettescoping to gate this command to Deepnote notebooks.The command is globally discoverable in the palette but the handler silently returns when no active Deepnote notebook exists, creating a dead command outside that context. Add
"when": "notebookType == 'deepnote'"to the command definition, following the pattern used elsewhere in the manifest.🤖 Prompt for AI Agents