Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
16 changes: 16 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,16 @@
"category": "Deepnote",
"icon": "$(reveal)"
},
{
"command": "deepnote.enableSnapshots",
"title": "%deepnote.commands.enableSnapshots.title%",
"category": "Deepnote"
},
{
"command": "deepnote.disableSnapshots",
"title": "%deepnote.commands.disableSnapshots.title%",
"category": "Deepnote"
},
{
"command": "deepnote.environments.create",
"title": "%deepnote.commands.environments.create.title%",
Expand Down Expand Up @@ -1635,6 +1645,12 @@
"description": "Disable SSL certificate verification (for development only)",
"scope": "application"
},
"deepnote.snapshots.enabled": {
"type": "boolean",
"default": false,
"description": "When enabled, outputs are saved to separate snapshot files in a 'snapshots' folder instead of the main .deepnote file.",
"scope": "resource"
},
"deepnote.experiments.enabled": {
"type": "boolean",
"default": true,
Expand Down
2 changes: 2 additions & 0 deletions package.nls.json
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,8 @@
"deepnote.commands.openNotebook.title": "Open Notebook",
"deepnote.commands.openFile.title": "Open File",
"deepnote.commands.revealInExplorer.title": "Reveal in Explorer",
"deepnote.commands.enableSnapshots.title": "Enable Snapshots",
"deepnote.commands.disableSnapshots.title": "Disable Snapshots",
"deepnote.commands.manageIntegrations.title": "Manage Integrations",
"deepnote.commands.newProject.title": "New Project",
"deepnote.commands.importNotebook.title": "Import Notebook",
Expand Down
10 changes: 8 additions & 2 deletions src/notebooks/deepnote/deepnoteActivationService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { DeepnoteExplorerView } from './deepnoteExplorerView';
import { IIntegrationManager } from './integrations/types';
import { DeepnoteInputBlockEditProtection } from './deepnoteInputBlockEditProtection';
import { ISnapshotMetadataService, ISnapshotMetadataServiceFull } from './snapshotMetadataService';
import { ISnapshotFileService } from './snapshotFileServiceTypes';

/**
* Service responsible for activating and configuring Deepnote notebook support in VS Code.
Expand All @@ -30,7 +31,8 @@ export class DeepnoteActivationService implements IExtensionSyncActivationServic
@inject(IDeepnoteNotebookManager) private readonly notebookManager: IDeepnoteNotebookManager,
@inject(IIntegrationManager) integrationManager: IIntegrationManager,
@inject(ILogger) private readonly logger: ILogger,
@inject(ISnapshotMetadataService) @optional() private readonly snapshotService?: ISnapshotMetadataServiceFull
@inject(ISnapshotMetadataService) @optional() private readonly snapshotService?: ISnapshotMetadataServiceFull,
@inject(ISnapshotFileService) @optional() private readonly snapshotFileService?: ISnapshotFileService
) {
this.integrationManager = integrationManager;
}
Expand All @@ -40,7 +42,11 @@ export class DeepnoteActivationService implements IExtensionSyncActivationServic
* Called during extension activation to set up Deepnote integration.
*/
public activate() {
this.serializer = new DeepnoteNotebookSerializer(this.notebookManager, this.snapshotService);
this.serializer = new DeepnoteNotebookSerializer(
this.notebookManager,
this.snapshotService,
this.snapshotFileService
);
this.explorerView = new DeepnoteExplorerView(this.extensionContext, this.notebookManager, this.logger);
this.editProtection = new DeepnoteInputBlockEditProtection(this.logger);

Expand Down
42 changes: 40 additions & 2 deletions src/notebooks/deepnote/deepnoteNotebookCommandListener.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { injectable, inject } from 'inversify';
import {
commands,
ConfigurationTarget,
window,
NotebookCellData,
NotebookCellKind,
Expand All @@ -14,7 +15,7 @@ import z from 'zod';

import { logger } from '../../platform/logging';
import { IExtensionSyncActivationService } from '../../platform/activation/types';
import { IDisposableRegistry } from '../../platform/common/types';
import { IConfigurationService, IDisposableRegistry } from '../../platform/common/types';
import { Commands } from '../../platform/common/constants';
import { notebookUpdaterUtils } from '../../kernels/execution/notebookUpdater';
import { WrappedError } from '../../platform/errors/types';
Expand Down Expand Up @@ -139,7 +140,10 @@ export function getNextDeepnoteVariableName(cells: NotebookCell[], prefix: 'df'
*/
@injectable()
export class DeepnoteNotebookCommandListener implements IExtensionSyncActivationService {
constructor(@inject(IDisposableRegistry) private readonly disposableRegistry: IDisposableRegistry) {}
constructor(
@inject(IConfigurationService) private readonly configurationService: IConfigurationService,
@inject(IDisposableRegistry) private readonly disposableRegistry: IDisposableRegistry
) {}

/**
* Activates the service by registering Deepnote-specific commands.
Expand Down Expand Up @@ -181,6 +185,10 @@ export class DeepnoteNotebookCommandListener implements IExtensionSyncActivation
this.disposableRegistry.push(
commands.registerCommand(Commands.AddButtonBlock, () => this.addInputBlock('button'))
);
this.disposableRegistry.push(commands.registerCommand(Commands.EnableSnapshots, () => this.enableSnapshots()));
this.disposableRegistry.push(
commands.registerCommand(Commands.DisableSnapshots, () => this.disableSnapshots())
);
}

public async addSqlBlock(): Promise<void> {
Expand Down Expand Up @@ -368,4 +376,34 @@ export class DeepnoteNotebookCommandListener implements IExtensionSyncActivation
// Enter edit mode on the new cell
await commands.executeCommand('notebook.cell.edit');
}

private async disableSnapshots(): Promise<void> {
try {
await this.configurationService.updateSetting(
'snapshots.enabled',
false,
undefined,
ConfigurationTarget.Workspace
);
void window.showInformationMessage(l10n.t('Snapshots disabled for this workspace.'));
} catch (error) {
logger.error('Failed to disable snapshots', error);
void window.showErrorMessage(l10n.t('Failed to disable snapshots.'));
}
}

private async enableSnapshots(): Promise<void> {
try {
await this.configurationService.updateSetting(
'snapshots.enabled',
true,
undefined,
ConfigurationTarget.Workspace
);
void window.showInformationMessage(l10n.t('Snapshots enabled for this workspace.'));
} catch (error) {
logger.error('Failed to enable snapshots', error);
void window.showErrorMessage(l10n.t('Failed to enable snapshots.'));
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import {
InputBlockType
} from './deepnoteNotebookCommandListener';
import { formatInputBlockCellContent, getInputBlockLanguage } from './inputBlockContentFormatter';
import { IDisposable } from '../../platform/common/types';
import { IConfigurationService, IDisposable } from '../../platform/common/types';
import * as notebookUpdater from '../../kernels/execution/notebookUpdater';
import { createMockedNotebookDocument } from '../../test/datascience/editor-integration/helpers';
import { WrappedError } from '../../platform/errors/types';
Expand All @@ -29,11 +29,21 @@ suite('DeepnoteNotebookCommandListener', () => {
let commandListener: DeepnoteNotebookCommandListener;
let disposables: IDisposable[];
let sandbox: sinon.SinonSandbox;
let mockConfigService: IConfigurationService;

function createMockConfigService(): IConfigurationService {
return {
getSettings: sinon.stub().returns({}),
updateSetting: sinon.stub().resolves(),
updateSectionSetting: sinon.stub().resolves()
} as unknown as IConfigurationService;
}

setup(() => {
sandbox = sinon.createSandbox();
disposables = [];
commandListener = new DeepnoteNotebookCommandListener(disposables);
mockConfigService = createMockConfigService();
commandListener = new DeepnoteNotebookCommandListener(mockConfigService, disposables);
});

teardown(() => {
Expand Down Expand Up @@ -78,7 +88,7 @@ suite('DeepnoteNotebookCommandListener', () => {

// Create new instance and activate again
const disposables2: IDisposable[] = [];
const commandListener2 = new DeepnoteNotebookCommandListener(disposables2);
const commandListener2 = new DeepnoteNotebookCommandListener(createMockConfigService(), disposables2);
commandListener2.activate();

// Both should register the same number of commands
Expand Down
2 changes: 1 addition & 1 deletion src/notebooks/deepnote/deepnoteNotebookManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ import type { DeepnoteProject } from '../../platform/deepnote/deepnoteTypes';
export class DeepnoteNotebookManager implements IDeepnoteNotebookManager {
private readonly currentNotebookId = new Map<string, string>();
private readonly originalProjects = new Map<string, DeepnoteProject>();
private readonly selectedNotebookByProject = new Map<string, string>();
private readonly projectsWithInitNotebookRun = new Set<string>();
private readonly selectedNotebookByProject = new Map<string, string>();

/**
* Gets the currently selected notebook ID for a project.
Expand Down
76 changes: 72 additions & 4 deletions src/notebooks/deepnote/deepnoteSerializer.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import type { DeepnoteBlock, DeepnoteFile } from '@deepnote/blocks';
import { inject, injectable, optional } from 'inversify';
import * as yaml from 'js-yaml';
import { l10n, workspace, type CancellationToken, type NotebookData, type NotebookSerializer } from 'vscode';
import { l10n, Uri, workspace, type CancellationToken, type NotebookData, type NotebookSerializer } from 'vscode';

import { logger } from '../../platform/logging';
import { IDeepnoteNotebookManager } from '../types';
import { DeepnoteDataConverter } from './deepnoteDataConverter';
import type { DeepnoteNotebook } from '../../platform/deepnote/deepnoteTypes';
import { ISnapshotMetadataService, ISnapshotMetadataServiceFull } from './snapshotMetadataService';
import { ISnapshotFileService } from './snapshotFileServiceTypes';
import { computeHash } from '../../platform/common/crypto';

export type { DeepnoteBlock, DeepnoteFile } from '@deepnote/blocks';
Expand Down Expand Up @@ -56,7 +57,8 @@ export class DeepnoteNotebookSerializer implements NotebookSerializer {

constructor(
@inject(IDeepnoteNotebookManager) private readonly notebookManager: IDeepnoteNotebookManager,
@inject(ISnapshotMetadataService) @optional() private readonly snapshotService?: ISnapshotMetadataServiceFull
@inject(ISnapshotMetadataService) @optional() private readonly snapshotService?: ISnapshotMetadataServiceFull,
@inject(ISnapshotFileService) @optional() private readonly snapshotFileService?: ISnapshotFileService
) {}

/**
Expand Down Expand Up @@ -114,10 +116,23 @@ export class DeepnoteNotebookSerializer implements NotebookSerializer {
throw new Error(l10n.t('No notebook selected or found'));
}

const cells = this.converter.convertBlocksToCells(selectedNotebook.blocks ?? []);
let cells = this.converter.convertBlocksToCells(selectedNotebook.blocks ?? []);

logger.debug(`DeepnoteSerializer: Converted ${cells.length} cells from notebook blocks`);

// Merge outputs from snapshot if snapshots are enabled
if (this.snapshotFileService?.isSnapshotsEnabled()) {
const snapshotOutputs = await this.snapshotFileService.readSnapshot(projectId);

if (snapshotOutputs) {
logger.debug(`DeepnoteSerializer: Merging ${snapshotOutputs.size} outputs from snapshot`);
const blocksWithOutputs = structuredClone(selectedNotebook.blocks ?? []);
this.snapshotFileService.mergeOutputsIntoBlocks(blocksWithOutputs, snapshotOutputs);

cells = this.converter.convertBlocksToCells(blocksWithOutputs);
}
}

this.notebookManager.storeOriginalProject(deepnoteFile.project.id, deepnoteFile, selectedNotebook.id);
logger.debug(`DeepnoteSerializer: Stored project ${projectId} in notebook manager`);

Expand Down Expand Up @@ -199,7 +214,47 @@ export class DeepnoteNotebookSerializer implements NotebookSerializer {
// Add snapshot metadata to blocks (contentHash and execution timing)
await this.addSnapshotMetadataToBlocks(blocks, data);

notebook.blocks = cloneWithoutCircularRefs<DeepnoteBlock[]>(blocks);
// Handle snapshot file logic if enabled
if (this.snapshotFileService?.isSnapshotsEnabled()) {
const projectUri = this.findProjectUriFromId(projectId);

if (projectUri) {
logger.debug('SerializeNotebook: Snapshots enabled, creating snapshot');

// Create snapshot project with full outputs
const snapshotProject = cloneWithoutCircularRefs(originalProject) as DeepnoteFile;
const snapshotNotebook = snapshotProject.project.notebooks.find(
(nb: { id: string }) => nb.id === notebookId
);

if (snapshotNotebook) {
snapshotNotebook.blocks = cloneWithoutCircularRefs<DeepnoteBlock[]>(blocks);
}

// Create snapshot if there are changes (writes timestamped first, then copies to latest)
const snapshotUri = await this.snapshotFileService.createSnapshot(
projectUri,
projectId,
originalProject.project.name,
snapshotProject
);

// Strip outputs from main file blocks
notebook.blocks = this.snapshotFileService.stripOutputsFromBlocks(blocks);

if (snapshotUri) {
logger.debug('SerializeNotebook: Created snapshot and stripped outputs from main file');
} else {
logger.debug('SerializeNotebook: No changes, skipped snapshot creation');
}
} else {
// Fallback if we can't find the project URI
notebook.blocks = cloneWithoutCircularRefs<DeepnoteBlock[]>(blocks);
}
} else {
// Default behavior: outputs in main file
notebook.blocks = cloneWithoutCircularRefs<DeepnoteBlock[]>(blocks);
}

logger.debug('SerializeNotebook: Cloned blocks, updating modifiedAt');

Expand Down Expand Up @@ -337,6 +392,19 @@ export class DeepnoteNotebookSerializer implements NotebookSerializer {
return notebookDoc?.uri.toString();
}

/**
* Finds the project URI from the project ID by looking at open notebook documents.
* @param projectId The project ID to find the URI for
* @returns The project URI (without query params), or undefined if not found
*/
private findProjectUriFromId(projectId: string): Uri | undefined {
const notebookDoc = workspace.notebookDocuments.find(
(doc) => doc.notebookType === 'deepnote' && doc.metadata?.deepnoteProjectId === projectId
);

return notebookDoc?.uri.with({ query: '' });
}

/**
* Finds the notebook ID to deserialize by checking the manager's stored selection.
* The notebook ID should be set via selectNotebookForProject before opening the document.
Expand Down
Loading
Loading