Skip to content

Commit 07855da

Browse files
authored
fix: Prevent main file changes when running blocks with snapshots enabled (#312)
* fix: Prevent main file changes when running blocks with snapshots enabled - Strip executionCount from blocks in snapshot mode (was only stripping outputs and execution timestamps) - Only update modifiedAt when actual content changes occur in snapshot mode, not when running cells - Add detectContentChanges helper to compare block content, type, and IDs while ignoring outputs and execution metadata
1 parent 5ce5059 commit 07855da

11 files changed

Lines changed: 1137 additions & 224 deletions

AGENTS.md

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
# Code Style & Organization
2+
3+
- Order method, fields and properties, first by accessibility and then by alphabetical order.
4+
- Don't add the Microsoft copyright header to new files.
5+
- Use `Uri.joinPath()` for constructing file paths to ensure platform-correct path separators (e.g., `Uri.joinPath(venvPath, 'share', 'jupyter', 'kernels')` instead of string concatenation with `/`)
6+
- Follow established patterns, especially when importing new packages (e.g. instead of importing uuid directly, use the helper `import { generateUuid } from '../platform/common/uuid';`)
7+
8+
## Code conventions
9+
10+
- Always run `npx prettier` before committing
11+
12+
## Testing
13+
14+
- Unit tests use Mocha/Chai framework with `.unit.test.ts` extension
15+
- Test files should be placed alongside the source files they test
16+
- Run all tests: `npm test` or `npm run test:unittests`
17+
- Run single test file: `npx mocha --config ./build/.mocha.unittests.js.json ./out/path/to/file.unit.test.js`
18+
- Tests run against compiled JavaScript files in `out/` directory
19+
- Use `assert.deepStrictEqual()` for object comparisons instead of checking individual properties
20+
21+
## Project Structure
22+
23+
- VSCode extension for Jupyter notebooks
24+
- Uses dependency injection with inversify
25+
- Follows separation of concerns pattern
26+
- TypeScript codebase that compiles to `out/` directory
27+
28+
## Deepnote Integration
29+
30+
- Located in `src/notebooks/deepnote/`
31+
- Refactored architecture:
32+
- `deepnoteTypes.ts` - Type definitions
33+
- `deepnoteNotebookManager.ts` - State management
34+
- `deepnoteNotebookSelector.ts` - UI selection logic
35+
- `deepnoteDataConverter.ts` - Data transformations
36+
- `deepnoteSerializer.ts` - Main serializer (orchestration)
37+
- `deepnoteActivationService.ts` - VSCode activation
38+
- Whitespace is good for readability, add a blank line after const groups and before return statements
39+
- Separate third-party and local file imports
40+
- How the extension works is described in @specs/architecture.md
41+
- Snapshot mode: avoid persisting execution-time metadata (e.g., `contentHash`) to prevent dirty state; rely on in-memory tracking when needed
42+
- `DeepnoteNotebookSerializer.detectContentChanges` should consider notebook-level fields (e.g., `name`, `executionMode`, `isModule`, `workingDirectory`) and detect removed notebooks, in addition to block-level comparisons
43+
44+
## Best Practices
45+
46+
### Resource Cleanup
47+
48+
- Always dispose `CancellationTokenSource` - never create inline without storing/disposing
49+
- Use try/finally to ensure cleanup:
50+
51+
```typescript
52+
const cts = new CancellationTokenSource();
53+
try {
54+
await fn(cts.token);
55+
} finally {
56+
cts.dispose();
57+
}
58+
```
59+
60+
61+
### DRY Principle
62+
63+
- Extract duplicate logic into helper methods to prevent drift
64+
- When similar logic appears in multiple places (e.g., placeholder controller setup, interpreter validation), consolidate it
65+
66+
### Magic Numbers
67+
68+
- Extract magic numbers (retry counts, delays, timeouts) as named constants near the top of the module
69+
70+
### Error Handling
71+
72+
- Use per-iteration error handling in loops - wrap each iteration in try/catch so one failure doesn't stop the rest
73+
- Handle `withProgress` cancellation gracefully - it throws when user cancels, so wrap in try/catch and return appropriate value
74+
75+
### State Validation
76+
77+
- Verify state after async setup operations - methods can return early without throwing, so check expected state was created
78+
- Validate cached state before early returns - before returning "already configured", verify the state is still valid (e.g., interpreter paths match, controllers aren't stale)
79+
80+
### Cancellation Tokens
81+
82+
- Use real cancellation tokens tied to lifecycle events instead of fake/never-cancelled tokens
83+
- Create `CancellationTokenSource` tied to relevant events (e.g., notebook close, cell cancel)

CLAUDE.md

Lines changed: 0 additions & 79 deletions
This file was deleted.

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
AGENTS.md

src/notebooks/deepnote/deepnoteActivationService.ts

Lines changed: 84 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { injectable, inject, optional } from 'inversify';
2-
import { workspace } from 'vscode';
1+
import { inject, injectable, optional } from 'inversify';
2+
import { commands, l10n, workspace, window, type Disposable, type NotebookDocumentContentOptions } from 'vscode';
33

44
import { IExtensionSyncActivationService } from '../../platform/activation/types';
55
import { IExtensionContext } from '../../platform/common/types';
@@ -17,13 +17,17 @@ import { SnapshotService } from './snapshots/snapshotService';
1717
*/
1818
@injectable()
1919
export class DeepnoteActivationService implements IExtensionSyncActivationService {
20+
private editProtection: DeepnoteInputBlockEditProtection;
21+
2022
private explorerView: DeepnoteExplorerView;
2123

2224
private integrationManager: IIntegrationManager;
2325

2426
private serializer: DeepnoteNotebookSerializer;
2527

26-
private editProtection: DeepnoteInputBlockEditProtection;
28+
private serializerRegistration?: Disposable;
29+
30+
private snapshotsEnabled = false;
2731

2832
constructor(
2933
@inject(IExtensionContext) private extensionContext: IExtensionContext,
@@ -43,11 +47,87 @@ export class DeepnoteActivationService implements IExtensionSyncActivationServic
4347
this.serializer = new DeepnoteNotebookSerializer(this.notebookManager, this.snapshotService);
4448
this.explorerView = new DeepnoteExplorerView(this.extensionContext, this.notebookManager, this.logger);
4549
this.editProtection = new DeepnoteInputBlockEditProtection(this.logger);
50+
this.snapshotsEnabled = this.isSnapshotsEnabled();
4651

47-
this.extensionContext.subscriptions.push(workspace.registerNotebookSerializer('deepnote', this.serializer));
52+
this.registerSerializer();
4853
this.extensionContext.subscriptions.push(this.editProtection);
54+
this.extensionContext.subscriptions.push(
55+
workspace.onDidChangeConfiguration((event) => {
56+
if (event.affectsConfiguration('deepnote.snapshots.enabled')) {
57+
const snapshotsEnabled = this.isSnapshotsEnabled();
58+
59+
if (!this.snapshotsEnabled && snapshotsEnabled) {
60+
this.promptReloadForSnapshots();
61+
}
62+
63+
this.snapshotsEnabled = snapshotsEnabled;
64+
this.registerSerializer();
65+
}
66+
})
67+
);
4968

5069
this.explorerView.activate();
5170
this.integrationManager.activate();
5271
}
72+
73+
private isSnapshotsEnabled(): boolean {
74+
if (this.snapshotService) {
75+
return this.snapshotService.isSnapshotsEnabled();
76+
}
77+
78+
const config = workspace.getConfiguration('deepnote');
79+
80+
return config.get<boolean>('snapshots.enabled', false);
81+
}
82+
83+
private promptReloadForSnapshots(): void {
84+
const hasOpenDeepnoteNotebooks = workspace.notebookDocuments.some(
85+
(notebook) => notebook.notebookType === 'deepnote'
86+
);
87+
88+
if (!hasOpenDeepnoteNotebooks) {
89+
void window.showInformationMessage(l10n.t('Snapshots enabled for this workspace.'));
90+
91+
return;
92+
}
93+
94+
const reloadOption = l10n.t('Reload Window');
95+
const laterOption = l10n.t('Later'); // Dismisses the dialog without action
96+
97+
void window
98+
.showInformationMessage(
99+
l10n.t('Snapshots enabled. Reload the window to apply to open notebooks.'),
100+
reloadOption,
101+
laterOption
102+
)
103+
.then((selection) => {
104+
if (selection === reloadOption) {
105+
void commands.executeCommand('workbench.action.reloadWindow');
106+
}
107+
// "Later" or dialog dismissal: no action needed
108+
});
109+
}
110+
111+
private registerSerializer(): void {
112+
// When snapshots are enabled, treat outputs as transient so VS Code
113+
// doesn't mark the document dirty when outputs change during execution
114+
const contentOptions: NotebookDocumentContentOptions = {};
115+
116+
if (this.isSnapshotsEnabled()) {
117+
contentOptions.transientOutputs = true;
118+
}
119+
120+
if (this.serializerRegistration) {
121+
this.serializerRegistration.dispose();
122+
123+
const idx = this.extensionContext.subscriptions.indexOf(this.serializerRegistration);
124+
125+
if (idx >= 0) {
126+
this.extensionContext.subscriptions.splice(idx, 1);
127+
}
128+
}
129+
130+
this.serializerRegistration = workspace.registerNotebookSerializer('deepnote', this.serializer, contentOptions);
131+
this.extensionContext.subscriptions.push(this.serializerRegistration);
132+
}
53133
}

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

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import { assert } from 'chai';
2+
import { anything, verify, when } from 'ts-mockito';
23

34
import { DeepnoteActivationService } from './deepnoteActivationService';
45
import { DeepnoteNotebookManager } from './deepnoteNotebookManager';
56
import { IExtensionContext } from '../../platform/common/types';
67
import { ILogger } from '../../platform/logging/types';
78
import { IIntegrationManager } from './integrations/types';
9+
import { mockedVSCodeNamespaces, resetVSCodeMocks } from '../../test/vscode-mock';
810

911
function createMockLogger(): ILogger {
1012
return {
@@ -71,6 +73,97 @@ suite('DeepnoteActivationService', () => {
7173
assert.isTrue(true, 'activate() method exists and attempts to initialize components');
7274
}
7375
});
76+
77+
test('should re-register serializer when snapshots are enabled in-session', () => {
78+
resetVSCodeMocks();
79+
80+
const registrations: Array<{ transientOutputs?: boolean }> = [];
81+
let configHandler: ((event: { affectsConfiguration: (section: string) => boolean }) => void) | undefined;
82+
83+
when(
84+
mockedVSCodeNamespaces.workspace.registerNotebookSerializer(anything(), anything(), anything())
85+
).thenCall((_type, _serializer, options) => {
86+
registrations.push(options);
87+
88+
return { dispose: () => undefined } as any;
89+
});
90+
const onDidChangeConfiguration = (
91+
handler: (event: { affectsConfiguration: (section: string) => boolean }) => void
92+
) => {
93+
configHandler = handler;
94+
95+
return { dispose: () => undefined } as any;
96+
};
97+
when(mockedVSCodeNamespaces.workspace.onDidChangeConfiguration).thenReturn(onDidChangeConfiguration as any);
98+
99+
let snapshotsEnabled = false;
100+
const mockSnapshotService = { isSnapshotsEnabled: () => snapshotsEnabled } as any;
101+
activationService = new DeepnoteActivationService(
102+
mockExtensionContext,
103+
manager,
104+
mockIntegrationManager,
105+
mockLogger,
106+
mockSnapshotService
107+
);
108+
109+
try {
110+
activationService.activate();
111+
} catch {
112+
// Activation may fail in the test environment, but registrations should still occur.
113+
}
114+
115+
assert.strictEqual(registrations.length, 1);
116+
assert.isUndefined(registrations[0].transientOutputs);
117+
118+
snapshotsEnabled = true;
119+
configHandler?.({ affectsConfiguration: (section) => section === 'deepnote.snapshots.enabled' });
120+
121+
assert.strictEqual(registrations.length, 2);
122+
assert.strictEqual(registrations[1].transientOutputs, true);
123+
verify(
124+
mockedVSCodeNamespaces.workspace.registerNotebookSerializer(anything(), anything(), anything())
125+
).twice();
126+
});
127+
128+
test('should prompt to reload when snapshots are enabled with open notebooks', () => {
129+
resetVSCodeMocks();
130+
131+
let configHandler: ((event: { affectsConfiguration: (section: string) => boolean }) => void) | undefined;
132+
133+
when(
134+
mockedVSCodeNamespaces.workspace.registerNotebookSerializer(anything(), anything(), anything())
135+
).thenReturn({ dispose: () => undefined } as any);
136+
const onDidChangeConfiguration = (
137+
handler: (event: { affectsConfiguration: (section: string) => boolean }) => void
138+
) => {
139+
configHandler = handler;
140+
141+
return { dispose: () => undefined } as any;
142+
};
143+
when(mockedVSCodeNamespaces.workspace.onDidChangeConfiguration).thenReturn(onDidChangeConfiguration as any);
144+
when(mockedVSCodeNamespaces.workspace.notebookDocuments).thenReturn([{ notebookType: 'deepnote' } as any]);
145+
146+
let snapshotsEnabled = false;
147+
const mockSnapshotService = { isSnapshotsEnabled: () => snapshotsEnabled } as any;
148+
activationService = new DeepnoteActivationService(
149+
mockExtensionContext,
150+
manager,
151+
mockIntegrationManager,
152+
mockLogger,
153+
mockSnapshotService
154+
);
155+
156+
try {
157+
activationService.activate();
158+
} catch {
159+
// Activation may fail in the test environment, but prompts should still be attempted.
160+
}
161+
162+
snapshotsEnabled = true;
163+
configHandler?.({ affectsConfiguration: (section) => section === 'deepnote.snapshots.enabled' });
164+
165+
verify(mockedVSCodeNamespaces.window.showInformationMessage(anything(), anything(), anything())).once();
166+
});
74167
});
75168

76169
suite('component initialization', () => {

0 commit comments

Comments
 (0)