Skip to content
Draft
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ Start with the owning directory and its colocated tests before searching broadly
| Environment snapshots & `requirements.txt` generation | `src/notebooks/deepnote/snapshots/` |
| Database integrations (credentials, env refresh, webview) | `src/notebooks/deepnote/integrations/`, `src/webviews/webview-side/integrations/` |
| Kernel selection, SQL LSP, server startup | `src/kernels/deepnote/` |
| Deepnote Cloud API token, sync and publishing | `src/notebooks/deepnote/cloud/` |
| Shared Deepnote types & platform config | `src/platform/deepnote/`, `src/platform/notebooks/deepnote/` |
| Core serializer architecture & data flow | `specs/architecture.md` |
| Kernel management internals | `specs/DEBUGGING_KERNEL_MANAGEMENT.md`, `specs/DEEPNOTE_KERNEL_IMPLEMENTATION.md` |
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ Open the Command Palette (`Cmd+Shift+P` or `Ctrl+Shift+P`) and type `Deepnote` t
| `Deepnote: Manage Integrations` | Configure database connections and credentials |
| `Deepnote: New Project` | Create a new Deepnote project |
| `Deepnote: Import Notebook` | Import an existing notebook into your project |
| `Deepnote: Set API Token` | Save a Deepnote API token to `.env` for syncing and publishing |
| `Deepnote: Clear API Token` | Remove the saved Deepnote API token |
| `Notebook: Select Notebook Kernel` | Select or switch kernels within your notebook |
| `Notebook: Change Cell Language` | Change the language of the cell currently in focus |
| `Deepnote: Enable Snapshots` | Enable snapshot mode for the current workspace |
Expand Down
12 changes: 12 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,18 @@
"category": "Deepnote",
"icon": "$(globe)"
},
{
"command": "deepnote.setApiToken",
"title": "%deepnote.commands.setApiToken.title%",
"category": "Deepnote",
"enablement": "isWorkspaceTrusted && !deepnote.webExtension"
},
{
"command": "deepnote.clearApiToken",
"title": "%deepnote.commands.clearApiToken.title%",
"category": "Deepnote",
"enablement": "isWorkspaceTrusted && !deepnote.webExtension"
},
{
"command": "deepnote.newProject",
"title": "%deepnote.commands.newProject.title%",
Expand Down
2 changes: 2 additions & 0 deletions package.nls.json
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,8 @@
"deepnote.commands.manageIntegrations.title": "Manage Integrations",
"deepnote.commands.addExistingIntegration.title": "Add Existing Integration",
"deepnote.commands.authenticateIntegration.title": "Authenticate Integration",
"deepnote.commands.setApiToken.title": "Set API Token",
"deepnote.commands.clearApiToken.title": "Clear API Token",
"deepnote.commands.newProject.title": "New Project",
"deepnote.commands.importNotebook.title": "Import Notebook",
"deepnote.commands.importJupyterNotebook.title": "Import Jupyter Notebook",
Expand Down
2 changes: 2 additions & 0 deletions src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,4 +210,6 @@ export interface ICommandNameArgumentTypeMapping {
[DSCommands.DuplicateNotebook]: [];
[DSCommands.AddNotebookToProject]: [];
[DSCommands.OpenInDeepnote]: [];
[DSCommands.SetApiToken]: [Uri | undefined];
[DSCommands.ClearApiToken]: [];
}
39 changes: 39 additions & 0 deletions src/notebooks/deepnote/cloud/cloudErrors.node.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { ApiError } from '@deepnote/cloud-sync';
import { commands, Uri, window } from 'vscode';

import { Commands } from '../../../platform/common/constants';
import { DeepnoteCloud } from '../../../platform/common/utils/localize';
import { logger } from '../../../platform/logging';

const UNAUTHORIZED_STATUS = 401;

/**
* Whether Deepnote answered 401, meaning the token is missing, invalid or revoked. A 403 is not matched: the token was
* accepted, but the request is not allowed.
*/
export function isRejectedTokenError(error: unknown): boolean {
return error instanceof ApiError && error.statusCode === UNAUTHORIZED_STATUS;
}

/**
* Logs a failed Deepnote Cloud call and tells the user. A rejected token offers "Set API Token" for `resource` and
* resolves once that prompt is dismissed; any other failure shows `<message>: <detail>` and resolves immediately.
*/
export async function showCloudError(message: string, error: unknown, resource?: Uri): Promise<void> {
logger.error(message, error);

if (isRejectedTokenError(error)) {
const choice = await window.showErrorMessage(
DeepnoteCloud.apiTokenRejectedForRequest,
DeepnoteCloud.setApiToken
);
if (choice === DeepnoteCloud.setApiToken) {
await commands.executeCommand(Commands.SetApiToken, resource);
}

return;
}

const detail = error instanceof Error ? error.message : String(error);
void window.showErrorMessage(DeepnoteCloud.errorWithDetail(message, detail));
}
97 changes: 97 additions & 0 deletions src/notebooks/deepnote/cloud/cloudErrors.node.unit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { ApiError } from '@deepnote/cloud-sync';
import { assert } from 'chai';
import { anything, verify, when } from 'ts-mockito';
import { Uri } from 'vscode';

import { DeepnoteCloud } from '../../../platform/common/utils/localize';
import { mockedVSCodeNamespaces, resetVSCodeMocks } from '../../../test/vscode-mock';
import { isRejectedTokenError, showCloudError } from './cloudErrors.node';

suite('cloudErrors', () => {
setup(() => {
resetVSCodeMocks();
when(mockedVSCodeNamespaces.commands.executeCommand(anything(), anything())).thenResolve(undefined);
});

suite('isRejectedTokenError', () => {
test('is true for an ApiError with status 401', () => {
assert.isTrue(isRejectedTokenError(new ApiError(401, 'Unauthorized')));
});

test('is false for an ApiError with another status, including 403', () => {
assert.isFalse(isRejectedTokenError(new ApiError(403, 'Forbidden')));
assert.isFalse(isRejectedTokenError(new ApiError(500, 'Boom')));
});

test('is false for errors that are not an ApiError', () => {
assert.isFalse(isRejectedTokenError(new Error('offline')));
assert.isFalse(isRejectedTokenError({ statusCode: 401 }));
assert.isFalse(isRejectedTokenError('401'));
assert.isFalse(isRejectedTokenError(undefined));
});
});

suite('showCloudError', () => {
test('offers Set API Token for a rejected token, and runs the command for the resource when chosen', async () => {
const resource = Uri.file('/r');
when(
mockedVSCodeNamespaces.window.showErrorMessage(
DeepnoteCloud.apiTokenRejectedForRequest,
DeepnoteCloud.setApiToken
)
).thenResolve(DeepnoteCloud.setApiToken as any);

await showCloudError(
'Sync failed',
new ApiError(401, 'Authentication failed. Please check your API token.'),
resource
);

verify(
mockedVSCodeNamespaces.window.showErrorMessage(
DeepnoteCloud.apiTokenRejectedForRequest,
DeepnoteCloud.setApiToken
)
).once();
verify(mockedVSCodeNamespaces.commands.executeCommand('deepnote.setApiToken', resource)).once();
verify(mockedVSCodeNamespaces.window.showErrorMessage(anything())).never();
});

test('does not run the command when the rejected-token prompt is dismissed', async () => {
await showCloudError('Sync failed', new ApiError(401, 'Unauthorized'), Uri.file('/r'));

verify(mockedVSCodeNamespaces.commands.executeCommand(anything(), anything())).never();
verify(mockedVSCodeNamespaces.window.showErrorMessage(anything())).never();
});

test('shows the server message without a button for a 403', async () => {
await showCloudError('Publish failed', new ApiError(403, 'Insufficient permissions to publish this app.'));

verify(
mockedVSCodeNamespaces.window.showErrorMessage(
'Publish failed: Insufficient permissions to publish this app.'
)
).once();
verify(mockedVSCodeNamespaces.window.showErrorMessage(anything(), anything())).never();
});

test('shows the message of any other ApiError', async () => {
await showCloudError('Sync failed', new ApiError(500, 'Boom'));

verify(mockedVSCodeNamespaces.window.showErrorMessage('Sync failed: Boom')).once();
});

test('shows the message of a plain Error without a button', async () => {
await showCloudError('Sync failed', new Error('offline'));

verify(mockedVSCodeNamespaces.window.showErrorMessage('Sync failed: offline')).once();
verify(mockedVSCodeNamespaces.window.showErrorMessage(anything(), anything())).never();
});

test('stringifies a thrown value that is not an Error', async () => {
await showCloudError('Sync failed', 'timed out');

verify(mockedVSCodeNamespaces.window.showErrorMessage('Sync failed: timed out')).once();
});
});
});
Loading
Loading