Skip to content
Merged
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
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -1439,7 +1439,7 @@
"view/item/context": [
{
"command": "deepnote.revealInExplorer",
"when": "view == deepnoteExplorer",
"when": "view == deepnoteExplorer && viewItem != loading",
"group": "inline@2"
}
]
Expand Down Expand Up @@ -2101,7 +2101,8 @@
"viewsWelcome": [
{
"view": "deepnoteExplorer",
"contents": "Welcome to Deepnote for VS Code!\nExplore your data with SQL and Python. Build interactive notebooks, collaborate with your team, and share your insights.\n\n\n\n[$(new-file) New Project](command:deepnote.newProject)\n[$(folder-opened) Import Notebook](command:deepnote.importNotebook)"
"contents": "Welcome to Deepnote for VS Code!\nExplore your data with SQL and Python. Build interactive notebooks, collaborate with your team, and share your insights.\n\n\n\n[$(new-file) New Project](command:deepnote.newProject)\n[$(folder-opened) Import Notebook](command:deepnote.importNotebook)",
"when": "deepnote.explorerInitialScanComplete"
}
],
"debuggers": [
Expand Down
47 changes: 46 additions & 1 deletion src/notebooks/deepnote/deepnoteTreeDataProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@ import {
workspace,
RelativePattern,
Uri,
FileSystemWatcher
FileSystemWatcher,
ThemeIcon,
commands,
l10n
} from 'vscode';
import * as yaml from 'js-yaml';

Expand All @@ -26,9 +29,12 @@ export class DeepnoteTreeDataProvider implements TreeDataProvider<DeepnoteTreeIt

private fileWatcher: FileSystemWatcher | undefined;
private cachedProjects: Map<string, DeepnoteProject> = new Map();
private isInitialScanComplete: boolean = false;
private initialScanPromise: Promise<void> | undefined;

constructor() {
this.setupFileWatcher();
this.updateContextKey();
}

public dispose(): void {
Expand All @@ -38,6 +44,9 @@ export class DeepnoteTreeDataProvider implements TreeDataProvider<DeepnoteTreeIt

public refresh(): void {
this.cachedProjects.clear();
this.isInitialScanComplete = false;
this.initialScanPromise = undefined;
this.updateContextKey();
this._onDidChangeTreeData.fire();
}

Expand All @@ -51,6 +60,15 @@ export class DeepnoteTreeDataProvider implements TreeDataProvider<DeepnoteTreeIt
}

if (!element) {
if (!this.isInitialScanComplete) {
if (!this.initialScanPromise) {
this.initialScanPromise = this.performInitialScan();
}

// Show loading item
return [this.createLoadingTreeItem()];
}

return this.getDeepnoteProjectFiles();
}

Expand All @@ -61,6 +79,29 @@ export class DeepnoteTreeDataProvider implements TreeDataProvider<DeepnoteTreeIt
return [];
}

private createLoadingTreeItem(): DeepnoteTreeItem {
const loadingItem = new DeepnoteTreeItem(
DeepnoteTreeItemType.Loading,
{ filePath: '', projectId: '' },
null,
TreeItemCollapsibleState.None
);
loadingItem.label = l10n.t('Scanning for Deepnote projects...');
loadingItem.iconPath = new ThemeIcon('loading~spin');
return loadingItem;
}

private async performInitialScan(): Promise<void> {
try {
await this.getDeepnoteProjectFiles();
} finally {
this.isInitialScanComplete = true;
this.initialScanPromise = undefined;
this.updateContextKey();
this._onDidChangeTreeData.fire();
}
}

private async getDeepnoteProjectFiles(): Promise<DeepnoteTreeItem[]> {
const deepnoteFiles: DeepnoteTreeItem[] = [];

Expand Down Expand Up @@ -197,4 +238,8 @@ export class DeepnoteTreeDataProvider implements TreeDataProvider<DeepnoteTreeIt

return undefined;
}

private updateContextKey(): void {
void commands.executeCommand('setContext', 'deepnote.explorerInitialScanComplete', this.isInitialScanComplete);
}
}
132 changes: 132 additions & 0 deletions src/notebooks/deepnote/deepnoteTreeDataProvider.unit.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { assert } from 'chai';
import { l10n } from 'vscode';

import { DeepnoteTreeDataProvider } from './deepnoteTreeDataProvider';
import { DeepnoteTreeItem, DeepnoteTreeItemType } from './deepnoteTreeItem';
Expand Down Expand Up @@ -85,6 +86,31 @@ suite('DeepnoteTreeDataProvider', () => {
assert.isArray(children);
});

test('should not throw on first getChildren call with new provider instance', async () => {
const newProvider = new DeepnoteTreeDataProvider();

// First call - just verify it returns an array and doesn't throw
const children = await newProvider.getChildren();
assert.isArray(children);

if (newProvider && typeof newProvider.dispose === 'function') {
newProvider.dispose();
}
});

test('should return empty array when no workspace is available', async () => {
const newProvider = new DeepnoteTreeDataProvider();

// In test environment without workspace, returns empty array
const children = await newProvider.getChildren();
assert.isArray(children);
assert.strictEqual(children.length, 0, 'Should return empty array when no workspace folders exist');

if (newProvider && typeof newProvider.dispose === 'function') {
newProvider.dispose();
}
});

test('should return array when called with project item parent', async () => {
// Create a mock project item
const mockProjectItem = new DeepnoteTreeItem(
Expand Down Expand Up @@ -130,6 +156,112 @@ suite('DeepnoteTreeDataProvider', () => {
// Call refresh to verify it doesn't throw
assert.doesNotThrow(() => provider.refresh());
});

test('should reset initial scan state on refresh', async () => {
const newProvider = new DeepnoteTreeDataProvider();
const firstChildren = await newProvider.getChildren();
assert.isArray(firstChildren);

await new Promise((resolve) => setTimeout(resolve, 10));

// After scan
const afterScanChildren = await newProvider.getChildren();
assert.isArray(afterScanChildren);

// Call refresh to reset state - this exercises the refresh logic
newProvider.refresh();

// After refresh - should return to initial state (loading or empty)
const childrenAfterRefresh = await newProvider.getChildren();
assert.isArray(childrenAfterRefresh);

// Verify that refresh reset to initial scan state
// The post-refresh state should match the initial state
assert.strictEqual(
childrenAfterRefresh.length,
firstChildren.length,
'After refresh, should return to initial state with same number of children'
);

// If initial state had a loading item, post-refresh should too
if (firstChildren.length > 0 && firstChildren[0].contextValue === 'loading') {
assert.strictEqual(
childrenAfterRefresh[0].contextValue,
'loading',
'After refresh, should show loading item again'
);
assert.strictEqual(
childrenAfterRefresh[0].label,
firstChildren[0].label,
'Loading item label should match initial state'
);
}

if (newProvider && typeof newProvider.dispose === 'function') {
newProvider.dispose();
}
});
});

suite('loading state', () => {
test('should call getChildren and execute loading logic', async () => {
const newProvider = new DeepnoteTreeDataProvider();

// Call getChildren without element (root level) - exercises loading code path
const children = await newProvider.getChildren(undefined);
assert.isArray(children);
// In test environment may be empty or have loading item depending on timing

if (newProvider && typeof newProvider.dispose === 'function') {
newProvider.dispose();
}
});

test('should handle multiple getChildren calls', async () => {
const newProvider = new DeepnoteTreeDataProvider();

// First call
const firstResult = await newProvider.getChildren(undefined);
assert.isArray(firstResult);

// Wait a bit
await new Promise((resolve) => setTimeout(resolve, 50));

// Second call
const secondResult = await newProvider.getChildren(undefined);
assert.isArray(secondResult);

if (newProvider && typeof newProvider.dispose === 'function') {
newProvider.dispose();
}
});

test('should not show loading for child elements', async () => {
// Create a mock project item
const mockProjectItem = new DeepnoteTreeItem(
DeepnoteTreeItemType.ProjectFile,
{
filePath: '/workspace/project.deepnote',
projectId: 'project-123'
},
mockProject,
1
);

// Getting children of a project exercises the non-loading code path
const children = await provider.getChildren(mockProjectItem);
assert.isArray(children);

// Verify no loading items are present
const hasLoadingType = children.some((child) => child.type === DeepnoteTreeItemType.Loading);
assert.isFalse(hasLoadingType, 'Children should not contain any loading type items');

// Also verify no loading labels
const hasLoadingLabel = children.some(
(child) => child.label === l10n.t('Scanning for Deepnote projects...') || child.label === 'Loading'
);
assert.isFalse(hasLoadingLabel, 'Children should not contain any loading labels');
});
});

suite('data management', () => {
Expand Down
17 changes: 11 additions & 6 deletions src/notebooks/deepnote/deepnoteTreeItem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ import type { DeepnoteProject, DeepnoteNotebook } from '../../platform/deepnote/
*/
export enum DeepnoteTreeItemType {
ProjectFile = 'projectFile',
Notebook = 'notebook'
Notebook = 'notebook',
Loading = 'loading'
}

/**
Expand All @@ -25,16 +26,20 @@ export class DeepnoteTreeItem extends TreeItem {
constructor(
public readonly type: DeepnoteTreeItemType,
public readonly context: DeepnoteTreeItemContext,
public readonly data: DeepnoteProject | DeepnoteNotebook,
public readonly data: DeepnoteProject | DeepnoteNotebook | null,
collapsibleState: TreeItemCollapsibleState
) {
super('', collapsibleState);

this.contextValue = this.type;
this.tooltip = this.getTooltip();
this.iconPath = this.getIcon();
this.label = this.getLabel();
this.description = this.getDescription();

// Skip initialization for loading items as they don't have real data
if (this.type !== DeepnoteTreeItemType.Loading) {
this.tooltip = this.getTooltip();
this.iconPath = this.getIcon();
this.label = this.getLabel();
this.description = this.getDescription();
}

if (this.type === DeepnoteTreeItemType.Notebook) {
this.resourceUri = this.getNotebookUri();
Expand Down
47 changes: 47 additions & 0 deletions src/notebooks/deepnote/deepnoteTreeItem.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -579,6 +579,53 @@ suite('DeepnoteTreeItem', () => {
});
});

suite('Loading type', () => {
test('should create loading item with null data', () => {
const context: DeepnoteTreeItemContext = {
filePath: '',
projectId: ''
};

const item = new DeepnoteTreeItem(
DeepnoteTreeItemType.Loading,
context,
null,
TreeItemCollapsibleState.None
);

assert.strictEqual(item.type, DeepnoteTreeItemType.Loading);
assert.strictEqual(item.contextValue, 'loading');
assert.strictEqual(item.collapsibleState, TreeItemCollapsibleState.None);
assert.isNull(item.data);
});

test('should skip initialization for loading items', () => {
const context: DeepnoteTreeItemContext = {
filePath: '',
projectId: ''
};

const item = new DeepnoteTreeItem(
DeepnoteTreeItemType.Loading,
context,
null,
TreeItemCollapsibleState.None
);

// Loading items can have label and iconPath set manually after creation
// but should not throw during construction
assert.isDefined(item);
assert.strictEqual(item.type, DeepnoteTreeItemType.Loading);

// Verify initialization was skipped - these properties should not be set
assert.isUndefined(item.tooltip);
assert.isUndefined(item.iconPath);
assert.isUndefined(item.description);
// label is set to empty string by TreeItem base class
assert.strictEqual(item.label, '');
});
});

suite('integration scenarios', () => {
test('should create valid tree structure hierarchy', () => {
// Create parent project file
Expand Down