diff --git a/package.json b/package.json index 98ad604b9..e1cf46e97 100644 --- a/package.json +++ b/package.json @@ -1439,7 +1439,7 @@ "view/item/context": [ { "command": "deepnote.revealInExplorer", - "when": "view == deepnoteExplorer", + "when": "view == deepnoteExplorer && viewItem != loading", "group": "inline@2" } ] @@ -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": [ diff --git a/src/notebooks/deepnote/deepnoteTreeDataProvider.ts b/src/notebooks/deepnote/deepnoteTreeDataProvider.ts index d44a00e95..8bfb5cbbe 100644 --- a/src/notebooks/deepnote/deepnoteTreeDataProvider.ts +++ b/src/notebooks/deepnote/deepnoteTreeDataProvider.ts @@ -7,7 +7,10 @@ import { workspace, RelativePattern, Uri, - FileSystemWatcher + FileSystemWatcher, + ThemeIcon, + commands, + l10n } from 'vscode'; import * as yaml from 'js-yaml'; @@ -26,9 +29,12 @@ export class DeepnoteTreeDataProvider implements TreeDataProvider = new Map(); + private isInitialScanComplete: boolean = false; + private initialScanPromise: Promise | undefined; constructor() { this.setupFileWatcher(); + this.updateContextKey(); } public dispose(): void { @@ -38,6 +44,9 @@ export class DeepnoteTreeDataProvider implements TreeDataProvider { + try { + await this.getDeepnoteProjectFiles(); + } finally { + this.isInitialScanComplete = true; + this.initialScanPromise = undefined; + this.updateContextKey(); + this._onDidChangeTreeData.fire(); + } + } + private async getDeepnoteProjectFiles(): Promise { const deepnoteFiles: DeepnoteTreeItem[] = []; @@ -197,4 +238,8 @@ export class DeepnoteTreeDataProvider implements TreeDataProvider { 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( @@ -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', () => { diff --git a/src/notebooks/deepnote/deepnoteTreeItem.ts b/src/notebooks/deepnote/deepnoteTreeItem.ts index 8bda4a1b4..a0e353e3c 100644 --- a/src/notebooks/deepnote/deepnoteTreeItem.ts +++ b/src/notebooks/deepnote/deepnoteTreeItem.ts @@ -6,7 +6,8 @@ import type { DeepnoteProject, DeepnoteNotebook } from '../../platform/deepnote/ */ export enum DeepnoteTreeItemType { ProjectFile = 'projectFile', - Notebook = 'notebook' + Notebook = 'notebook', + Loading = 'loading' } /** @@ -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(); diff --git a/src/notebooks/deepnote/deepnoteTreeItem.unit.test.ts b/src/notebooks/deepnote/deepnoteTreeItem.unit.test.ts index dd4e8883e..0b3e6bd10 100644 --- a/src/notebooks/deepnote/deepnoteTreeItem.unit.test.ts +++ b/src/notebooks/deepnote/deepnoteTreeItem.unit.test.ts @@ -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