Skip to content

Commit 5d7cb11

Browse files
authored
feat: loading state on startup (#142)
1 parent f564456 commit 5d7cb11

File tree

5 files changed

+239
-9
lines changed

5 files changed

+239
-9
lines changed

package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1439,7 +1439,7 @@
14391439
"view/item/context": [
14401440
{
14411441
"command": "deepnote.revealInExplorer",
1442-
"when": "view == deepnoteExplorer",
1442+
"when": "view == deepnoteExplorer && viewItem != loading",
14431443
"group": "inline@2"
14441444
}
14451445
]
@@ -2101,7 +2101,8 @@
21012101
"viewsWelcome": [
21022102
{
21032103
"view": "deepnoteExplorer",
2104-
"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)"
2104+
"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)",
2105+
"when": "deepnote.explorerInitialScanComplete"
21052106
}
21062107
],
21072108
"debuggers": [

src/notebooks/deepnote/deepnoteTreeDataProvider.ts

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,10 @@ import {
77
workspace,
88
RelativePattern,
99
Uri,
10-
FileSystemWatcher
10+
FileSystemWatcher,
11+
ThemeIcon,
12+
commands,
13+
l10n
1114
} from 'vscode';
1215
import * as yaml from 'js-yaml';
1316

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

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

3035
constructor() {
3136
this.setupFileWatcher();
37+
this.updateContextKey();
3238
}
3339

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

3945
public refresh(): void {
4046
this.cachedProjects.clear();
47+
this.isInitialScanComplete = false;
48+
this.initialScanPromise = undefined;
49+
this.updateContextKey();
4150
this._onDidChangeTreeData.fire();
4251
}
4352

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

5362
if (!element) {
63+
if (!this.isInitialScanComplete) {
64+
if (!this.initialScanPromise) {
65+
this.initialScanPromise = this.performInitialScan();
66+
}
67+
68+
// Show loading item
69+
return [this.createLoadingTreeItem()];
70+
}
71+
5472
return this.getDeepnoteProjectFiles();
5573
}
5674

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

82+
private createLoadingTreeItem(): DeepnoteTreeItem {
83+
const loadingItem = new DeepnoteTreeItem(
84+
DeepnoteTreeItemType.Loading,
85+
{ filePath: '', projectId: '' },
86+
null,
87+
TreeItemCollapsibleState.None
88+
);
89+
loadingItem.label = l10n.t('Scanning for Deepnote projects...');
90+
loadingItem.iconPath = new ThemeIcon('loading~spin');
91+
return loadingItem;
92+
}
93+
94+
private async performInitialScan(): Promise<void> {
95+
try {
96+
await this.getDeepnoteProjectFiles();
97+
} finally {
98+
this.isInitialScanComplete = true;
99+
this.initialScanPromise = undefined;
100+
this.updateContextKey();
101+
this._onDidChangeTreeData.fire();
102+
}
103+
}
104+
64105
private async getDeepnoteProjectFiles(): Promise<DeepnoteTreeItem[]> {
65106
const deepnoteFiles: DeepnoteTreeItem[] = [];
66107

@@ -197,4 +238,8 @@ export class DeepnoteTreeDataProvider implements TreeDataProvider<DeepnoteTreeIt
197238

198239
return undefined;
199240
}
241+
242+
private updateContextKey(): void {
243+
void commands.executeCommand('setContext', 'deepnote.explorerInitialScanComplete', this.isInitialScanComplete);
244+
}
200245
}

src/notebooks/deepnote/deepnoteTreeDataProvider.unit.test.ts

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { assert } from 'chai';
2+
import { l10n } from 'vscode';
23

34
import { DeepnoteTreeDataProvider } from './deepnoteTreeDataProvider';
45
import { DeepnoteTreeItem, DeepnoteTreeItemType } from './deepnoteTreeItem';
@@ -85,6 +86,31 @@ suite('DeepnoteTreeDataProvider', () => {
8586
assert.isArray(children);
8687
});
8788

89+
test('should not throw on first getChildren call with new provider instance', async () => {
90+
const newProvider = new DeepnoteTreeDataProvider();
91+
92+
// First call - just verify it returns an array and doesn't throw
93+
const children = await newProvider.getChildren();
94+
assert.isArray(children);
95+
96+
if (newProvider && typeof newProvider.dispose === 'function') {
97+
newProvider.dispose();
98+
}
99+
});
100+
101+
test('should return empty array when no workspace is available', async () => {
102+
const newProvider = new DeepnoteTreeDataProvider();
103+
104+
// In test environment without workspace, returns empty array
105+
const children = await newProvider.getChildren();
106+
assert.isArray(children);
107+
assert.strictEqual(children.length, 0, 'Should return empty array when no workspace folders exist');
108+
109+
if (newProvider && typeof newProvider.dispose === 'function') {
110+
newProvider.dispose();
111+
}
112+
});
113+
88114
test('should return array when called with project item parent', async () => {
89115
// Create a mock project item
90116
const mockProjectItem = new DeepnoteTreeItem(
@@ -130,6 +156,112 @@ suite('DeepnoteTreeDataProvider', () => {
130156
// Call refresh to verify it doesn't throw
131157
assert.doesNotThrow(() => provider.refresh());
132158
});
159+
160+
test('should reset initial scan state on refresh', async () => {
161+
const newProvider = new DeepnoteTreeDataProvider();
162+
const firstChildren = await newProvider.getChildren();
163+
assert.isArray(firstChildren);
164+
165+
await new Promise((resolve) => setTimeout(resolve, 10));
166+
167+
// After scan
168+
const afterScanChildren = await newProvider.getChildren();
169+
assert.isArray(afterScanChildren);
170+
171+
// Call refresh to reset state - this exercises the refresh logic
172+
newProvider.refresh();
173+
174+
// After refresh - should return to initial state (loading or empty)
175+
const childrenAfterRefresh = await newProvider.getChildren();
176+
assert.isArray(childrenAfterRefresh);
177+
178+
// Verify that refresh reset to initial scan state
179+
// The post-refresh state should match the initial state
180+
assert.strictEqual(
181+
childrenAfterRefresh.length,
182+
firstChildren.length,
183+
'After refresh, should return to initial state with same number of children'
184+
);
185+
186+
// If initial state had a loading item, post-refresh should too
187+
if (firstChildren.length > 0 && firstChildren[0].contextValue === 'loading') {
188+
assert.strictEqual(
189+
childrenAfterRefresh[0].contextValue,
190+
'loading',
191+
'After refresh, should show loading item again'
192+
);
193+
assert.strictEqual(
194+
childrenAfterRefresh[0].label,
195+
firstChildren[0].label,
196+
'Loading item label should match initial state'
197+
);
198+
}
199+
200+
if (newProvider && typeof newProvider.dispose === 'function') {
201+
newProvider.dispose();
202+
}
203+
});
204+
});
205+
206+
suite('loading state', () => {
207+
test('should call getChildren and execute loading logic', async () => {
208+
const newProvider = new DeepnoteTreeDataProvider();
209+
210+
// Call getChildren without element (root level) - exercises loading code path
211+
const children = await newProvider.getChildren(undefined);
212+
assert.isArray(children);
213+
// In test environment may be empty or have loading item depending on timing
214+
215+
if (newProvider && typeof newProvider.dispose === 'function') {
216+
newProvider.dispose();
217+
}
218+
});
219+
220+
test('should handle multiple getChildren calls', async () => {
221+
const newProvider = new DeepnoteTreeDataProvider();
222+
223+
// First call
224+
const firstResult = await newProvider.getChildren(undefined);
225+
assert.isArray(firstResult);
226+
227+
// Wait a bit
228+
await new Promise((resolve) => setTimeout(resolve, 50));
229+
230+
// Second call
231+
const secondResult = await newProvider.getChildren(undefined);
232+
assert.isArray(secondResult);
233+
234+
if (newProvider && typeof newProvider.dispose === 'function') {
235+
newProvider.dispose();
236+
}
237+
});
238+
239+
test('should not show loading for child elements', async () => {
240+
// Create a mock project item
241+
const mockProjectItem = new DeepnoteTreeItem(
242+
DeepnoteTreeItemType.ProjectFile,
243+
{
244+
filePath: '/workspace/project.deepnote',
245+
projectId: 'project-123'
246+
},
247+
mockProject,
248+
1
249+
);
250+
251+
// Getting children of a project exercises the non-loading code path
252+
const children = await provider.getChildren(mockProjectItem);
253+
assert.isArray(children);
254+
255+
// Verify no loading items are present
256+
const hasLoadingType = children.some((child) => child.type === DeepnoteTreeItemType.Loading);
257+
assert.isFalse(hasLoadingType, 'Children should not contain any loading type items');
258+
259+
// Also verify no loading labels
260+
const hasLoadingLabel = children.some(
261+
(child) => child.label === l10n.t('Scanning for Deepnote projects...') || child.label === 'Loading'
262+
);
263+
assert.isFalse(hasLoadingLabel, 'Children should not contain any loading labels');
264+
});
133265
});
134266

135267
suite('data management', () => {

src/notebooks/deepnote/deepnoteTreeItem.ts

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ import type { DeepnoteProject, DeepnoteNotebook } from '../../platform/deepnote/
66
*/
77
export enum DeepnoteTreeItemType {
88
ProjectFile = 'projectFile',
9-
Notebook = 'notebook'
9+
Notebook = 'notebook',
10+
Loading = 'loading'
1011
}
1112

1213
/**
@@ -25,16 +26,20 @@ export class DeepnoteTreeItem extends TreeItem {
2526
constructor(
2627
public readonly type: DeepnoteTreeItemType,
2728
public readonly context: DeepnoteTreeItemContext,
28-
public readonly data: DeepnoteProject | DeepnoteNotebook,
29+
public readonly data: DeepnoteProject | DeepnoteNotebook | null,
2930
collapsibleState: TreeItemCollapsibleState
3031
) {
3132
super('', collapsibleState);
3233

3334
this.contextValue = this.type;
34-
this.tooltip = this.getTooltip();
35-
this.iconPath = this.getIcon();
36-
this.label = this.getLabel();
37-
this.description = this.getDescription();
35+
36+
// Skip initialization for loading items as they don't have real data
37+
if (this.type !== DeepnoteTreeItemType.Loading) {
38+
this.tooltip = this.getTooltip();
39+
this.iconPath = this.getIcon();
40+
this.label = this.getLabel();
41+
this.description = this.getDescription();
42+
}
3843

3944
if (this.type === DeepnoteTreeItemType.Notebook) {
4045
this.resourceUri = this.getNotebookUri();

src/notebooks/deepnote/deepnoteTreeItem.unit.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -579,6 +579,53 @@ suite('DeepnoteTreeItem', () => {
579579
});
580580
});
581581

582+
suite('Loading type', () => {
583+
test('should create loading item with null data', () => {
584+
const context: DeepnoteTreeItemContext = {
585+
filePath: '',
586+
projectId: ''
587+
};
588+
589+
const item = new DeepnoteTreeItem(
590+
DeepnoteTreeItemType.Loading,
591+
context,
592+
null,
593+
TreeItemCollapsibleState.None
594+
);
595+
596+
assert.strictEqual(item.type, DeepnoteTreeItemType.Loading);
597+
assert.strictEqual(item.contextValue, 'loading');
598+
assert.strictEqual(item.collapsibleState, TreeItemCollapsibleState.None);
599+
assert.isNull(item.data);
600+
});
601+
602+
test('should skip initialization for loading items', () => {
603+
const context: DeepnoteTreeItemContext = {
604+
filePath: '',
605+
projectId: ''
606+
};
607+
608+
const item = new DeepnoteTreeItem(
609+
DeepnoteTreeItemType.Loading,
610+
context,
611+
null,
612+
TreeItemCollapsibleState.None
613+
);
614+
615+
// Loading items can have label and iconPath set manually after creation
616+
// but should not throw during construction
617+
assert.isDefined(item);
618+
assert.strictEqual(item.type, DeepnoteTreeItemType.Loading);
619+
620+
// Verify initialization was skipped - these properties should not be set
621+
assert.isUndefined(item.tooltip);
622+
assert.isUndefined(item.iconPath);
623+
assert.isUndefined(item.description);
624+
// label is set to empty string by TreeItem base class
625+
assert.strictEqual(item.label, '');
626+
});
627+
});
628+
582629
suite('integration scenarios', () => {
583630
test('should create valid tree structure hierarchy', () => {
584631
// Create parent project file

0 commit comments

Comments
 (0)