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
2 changes: 1 addition & 1 deletion 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
38 changes: 37 additions & 1 deletion src/notebooks/deepnote/deepnoteTreeDataProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ import {
workspace,
RelativePattern,
Uri,
FileSystemWatcher
FileSystemWatcher,
ThemeIcon
} from 'vscode';
import * as yaml from 'js-yaml';

Expand All @@ -26,6 +27,8 @@ 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();
Expand All @@ -38,6 +41,8 @@ export class DeepnoteTreeDataProvider implements TreeDataProvider<DeepnoteTreeIt

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

Expand All @@ -51,6 +56,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 +75,28 @@ export class DeepnoteTreeDataProvider implements TreeDataProvider<DeepnoteTreeIt
return [];
}

private createLoadingTreeItem(): DeepnoteTreeItem {
const loadingItem = new DeepnoteTreeItem(
DeepnoteTreeItemType.Loading,
{ filePath: '', projectId: '' },
null,
TreeItemCollapsibleState.None
);
loadingItem.label = '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._onDidChangeTreeData.fire();
}
}

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

Expand Down
131 changes: 131 additions & 0 deletions src/notebooks/deepnote/deepnoteTreeDataProvider.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,44 @@ suite('DeepnoteTreeDataProvider', () => {
assert.isArray(children);
});

test('should return loading item on first call with correct properties', async () => {
const newProvider = new DeepnoteTreeDataProvider();

// First call should return loading item
const children = await newProvider.getChildren();
assert.isArray(children);
assert.isAtLeast(children.length, 1);

const firstChild = children[0];
assert.strictEqual(firstChild.type, DeepnoteTreeItemType.Loading);
assert.strictEqual(firstChild.contextValue, 'loading');
assert.strictEqual(firstChild.label, 'Scanning for Deepnote projects...');
assert.isDefined(firstChild.iconPath);

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

test('should complete initial scan and show projects after loading', async () => {
const newProvider = new DeepnoteTreeDataProvider();

// First call shows loading
const loadingChildren = await newProvider.getChildren();
assert.isArray(loadingChildren);

// Wait a bit for the initial scan to complete
await new Promise((resolve) => setTimeout(resolve, 10));

// Second call should show actual projects (or empty array if no projects)
const actualChildren = await newProvider.getChildren();
assert.isArray(actualChildren);

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 +168,99 @@ 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();

// First call shows loading
const firstChildren = await newProvider.getChildren();
assert.isArray(firstChildren);

// Wait for initial scan to complete
await new Promise((resolve) => setTimeout(resolve, 10));

// After scan, should not show loading
const afterScanChildren = await newProvider.getChildren();
assert.isArray(afterScanChildren);

// Call refresh to reset state
newProvider.refresh();

// After refresh, should show loading again
const childrenAfterRefresh = await newProvider.getChildren();
assert.isArray(childrenAfterRefresh);
if (childrenAfterRefresh.length > 0) {
const firstItem = childrenAfterRefresh[0];
if (firstItem.type === DeepnoteTreeItemType.Loading) {
assert.strictEqual(firstItem.label, 'Scanning for Deepnote projects...');
}
}

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

suite('loading state', () => {
test('should show loading on first call to empty tree', async () => {
const newProvider = new DeepnoteTreeDataProvider();

// Call getChildren without element (root level)
const children = await newProvider.getChildren(undefined);
assert.isArray(children);
assert.isAtLeast(children.length, 1);

// First child should be loading item
assert.strictEqual(children[0].type, DeepnoteTreeItemType.Loading);

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

test('should transition from loading to projects', async () => {
const newProvider = new DeepnoteTreeDataProvider();

// First call shows loading
const loadingResult = await newProvider.getChildren(undefined);
assert.isArray(loadingResult);
assert.isAtLeast(loadingResult.length, 1);
assert.strictEqual(loadingResult[0].type, DeepnoteTreeItemType.Loading);

// Wait for scan to complete
await new Promise((resolve) => setTimeout(resolve, 50));

// Next call shows actual results
const projectsResult = await newProvider.getChildren(undefined);
assert.isArray(projectsResult);
// In test environment without workspace, this will be empty
// but should not contain loading item anymore

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 should never show loading
const children = await provider.getChildren(mockProjectItem);
assert.isArray(children);
// Should not contain any loading items
const hasLoadingItem = children.some((child) => child.type === DeepnoteTreeItemType.Loading);
assert.isFalse(hasLoadingItem);
});
});

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
40 changes: 40 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,46 @@ 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);
});
});

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