Skip to content

Commit ff3671a

Browse files
authored
feat: progressive project tree view during import (#982)
* feat: progressive project tree view during import Show Java project names in the tree view progressively as they are imported, instead of waiting for the entire import to complete. Key changes: - Use serverRunning() API (v0.14) instead of serverReady() so the tree view can start rendering before import finishes - Add addProgressiveProjects() to create ProjectNode items directly from ProjectsImported notification URIs without querying the server - Guard getChildren() from entering getRootNodes() during progressive loading to avoid blocking on server queries - Keep TreeView progress spinner visible until first items arrive - After import completes, trigger full refresh to replace placeholder items with complete data from the server This reduces perceived loading time for large projects (e.g., 436 Gradle subprojects) from ~7 minutes to ~1 minute. * Downgrade version from 0.27.1 to 0.27.0 * Update version to 0.27.1 in package-lock.json * fix: address review feedback on progressive loading - Extract startServerReadyWait() and call it unconditionally in initializeJavaLanguageServerApis() so isServerReady is set correctly even if onDidProjectsImport fires before ready() (fixes #6) - Restore ready() to not start serverReady() wait inline, keeping its semantics consistent for other callers like syncHandler, upgradeManager, BuildArtifactTaskProvider (fixes #4) - Add .catch() on serverReady().then() to avoid unhandled promise rejections if the server fails to start (fixes #5) - Restore debounce=true on onDidClasspathUpdate when server is ready to avoid burst refreshes (fixes #7) - Add 30s timeout on _progressiveItemsReady await to prevent getChildren() from hanging indefinitely (fixes #2) - Resolve _progressiveItemsReady in doRefresh() to prevent stale getChildren() calls from hanging * fix: remove console.error to satisfy no-console tslint rule * Fix: fall through to getRootNodes() when no progressive items arrive After the 30s timeout, if no progressive notifications were received (e.g., eclipse.jdt.ls progressive notifications not yet available), fall through to the normal getRootNodes() path instead of returning an empty array. This ensures the worst case matches today's behavior. Addresses review comment from wenytang-ms. * Simplify progressive loading: remove timeout fallback and return root items directly
1 parent c677dbc commit ff3671a

File tree

3 files changed

+161
-4
lines changed

3 files changed

+161
-4
lines changed

src/commands.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ export namespace Commands {
2626

2727
export const VIEW_PACKAGE_INTERNAL_REFRESH = "_java.view.package.internal.refresh";
2828

29+
export const VIEW_PACKAGE_INTERNAL_ADD_PROJECTS = "_java.view.package.internal.addProjects";
30+
2931
export const VIEW_PACKAGE_OUTLINE = "java.view.package.outline";
3032

3133
export const VIEW_PACKAGE_REVEAL_FILE_OS = "java.view.package.revealFileInOS";

src/languageServerApi/languageServerApiManager.ts

Lines changed: 73 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ class LanguageServerApiManager {
1313
private extensionApi: any;
1414

1515
private isServerReady: boolean = false;
16+
private isServerRunning: boolean = false;
17+
private serverReadyWaitStarted: boolean = false;
1618

1719
public async ready(): Promise<boolean> {
1820
if (this.isServerReady) {
@@ -28,11 +30,49 @@ class LanguageServerApiManager {
2830
return false;
2931
}
3032

33+
// Use serverRunning() if available (API >= 0.14) for progressive loading.
34+
// This resolves when the server process is alive and can handle requests,
35+
// even if project imports haven't completed yet. This enables the tree view
36+
// to show projects incrementally as they are imported.
37+
if (!this.isServerRunning && this.extensionApi.serverRunning) {
38+
await this.extensionApi.serverRunning();
39+
this.isServerRunning = true;
40+
return true;
41+
}
42+
if (this.isServerRunning) {
43+
return true;
44+
}
45+
46+
// Fallback for older API versions: wait for full server readiness
3147
await this.extensionApi.serverReady();
3248
this.isServerReady = true;
3349
return true;
3450
}
3551

52+
/**
53+
* Start a background wait for full server readiness (import complete).
54+
* When the server finishes importing, trigger a full refresh to replace
55+
* progressive placeholder items with proper data from the server.
56+
* Guarded so it only starts once regardless of call order.
57+
*/
58+
private startServerReadyWait(): void {
59+
if (this.serverReadyWaitStarted || this.isServerReady) {
60+
return;
61+
}
62+
if (this.extensionApi?.serverReady) {
63+
this.serverReadyWaitStarted = true;
64+
this.extensionApi.serverReady()
65+
.then(() => {
66+
this.isServerReady = true;
67+
commands.executeCommand(Commands.VIEW_PACKAGE_INTERNAL_REFRESH, /* debounce = */false);
68+
})
69+
.catch((_error: unknown) => {
70+
// Server failed to become ready (e.g., startup failure).
71+
// Leave isServerReady as false; progressive items remain as-is.
72+
});
73+
}
74+
}
75+
3676
public async initializeJavaLanguageServerApis(): Promise<void> {
3777
if (this.isApiInitialized()) {
3878
return;
@@ -49,18 +89,39 @@ class LanguageServerApiManager {
4989
}
5090

5191
this.extensionApi = extensionApi;
92+
// Start background wait for full server readiness unconditionally.
93+
// This ensures isServerReady is set and final refresh fires even
94+
// if onDidProjectsImport sets isServerRunning before ready() runs.
95+
this.startServerReadyWait();
96+
5297
if (extensionApi.onDidClasspathUpdate) {
5398
const onDidClasspathUpdate: Event<Uri> = extensionApi.onDidClasspathUpdate;
54-
contextManager.context.subscriptions.push(onDidClasspathUpdate(() => {
55-
commands.executeCommand(Commands.VIEW_PACKAGE_INTERNAL_REFRESH, /* debounce = */true);
99+
contextManager.context.subscriptions.push(onDidClasspathUpdate((uri: Uri) => {
100+
if (this.isServerReady) {
101+
// Server is fully ready — do a normal refresh to get full project data.
102+
commands.executeCommand(Commands.VIEW_PACKAGE_INTERNAL_REFRESH, /* debounce = */true);
103+
} else {
104+
// During import, the server is blocked and can't respond to queries.
105+
// Don't clear progressive items. Try to add the project if not
106+
// already present (typically a no-op since ProjectsImported fires first).
107+
commands.executeCommand(Commands.VIEW_PACKAGE_INTERNAL_ADD_PROJECTS, [uri.toString()]);
108+
}
56109
syncHandler.updateFileWatcher(Settings.autoRefresh());
57110
}));
58111
}
59112

60113
if (extensionApi.onDidProjectsImport) {
61114
const onDidProjectsImport: Event<Uri[]> = extensionApi.onDidProjectsImport;
62-
contextManager.context.subscriptions.push(onDidProjectsImport(() => {
63-
commands.executeCommand(Commands.VIEW_PACKAGE_INTERNAL_REFRESH, /* debounce = */true);
115+
contextManager.context.subscriptions.push(onDidProjectsImport((uris: Uri[]) => {
116+
// Server is sending project data, so it's definitely running.
117+
// Mark as running so ready() returns immediately on subsequent calls.
118+
this.isServerRunning = true;
119+
// During import, the JDTLS server is blocked by Eclipse workspace
120+
// operations and cannot respond to queries. Instead of triggering
121+
// a refresh (which queries the server), directly add projects to
122+
// the tree view from the notification data.
123+
const projectUris = uris.map(u => u.toString());
124+
commands.executeCommand(Commands.VIEW_PACKAGE_INTERNAL_ADD_PROJECTS, projectUris);
64125
syncHandler.updateFileWatcher(Settings.autoRefresh());
65126
}));
66127
}
@@ -91,6 +152,14 @@ class LanguageServerApiManager {
91152
return this.extensionApi !== undefined;
92153
}
93154

155+
/**
156+
* Returns true if the server has fully completed initialization (import finished).
157+
* During progressive loading, this returns false even though ready() has resolved.
158+
*/
159+
public isFullyReady(): boolean {
160+
return this.isServerReady;
161+
}
162+
94163
/**
95164
* Check if the language server is ready in the given timeout.
96165
* @param timeout the timeout in milliseconds to wait

src/views/dependencyDataProvider.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,11 +37,16 @@ export class DependencyDataProvider implements TreeDataProvider<ExplorerNode> {
3737
* `null` means no node is pending.
3838
*/
3939
private pendingRefreshElement: ExplorerNode | undefined | null;
40+
/** Resolved when the first batch of progressive items arrives. */
41+
private _progressiveItemsReady: Promise<void> | undefined;
42+
private _resolveProgressiveItems: (() => void) | undefined;
4043

4144
constructor(public readonly context: ExtensionContext) {
4245
// commands that do not send back telemetry
4346
context.subscriptions.push(commands.registerCommand(Commands.VIEW_PACKAGE_INTERNAL_REFRESH, (debounce?: boolean, element?: ExplorerNode) =>
4447
this.refresh(debounce, element)));
48+
context.subscriptions.push(commands.registerCommand(Commands.VIEW_PACKAGE_INTERNAL_ADD_PROJECTS, (projectUris: string[]) =>
49+
this.addProgressiveProjects(projectUris)));
4550
context.subscriptions.push(commands.registerCommand(Commands.EXPORT_JAR_REPORT, (terminalId: string, message: string) => {
4651
appendOutput(terminalId, message);
4752
}));
@@ -117,10 +122,35 @@ export class DependencyDataProvider implements TreeDataProvider<ExplorerNode> {
117122
}
118123

119124
public async getChildren(element?: ExplorerNode): Promise<ExplorerNode[] | undefined | null> {
125+
// Fast path: if root items are already populated by progressive loading
126+
// (addProgressiveProjects), return them directly without querying the
127+
// server, which may be blocked during long-running imports.
128+
if (!element && this._rootItems && this._rootItems.length > 0) {
129+
explorerNodeCache.saveNodes(this._rootItems);
130+
return this._rootItems;
131+
}
132+
120133
if (!await languageServerApiManager.ready()) {
121134
return [];
122135
}
123136

137+
// During progressive loading (server running but not fully ready after
138+
// a clean workspace), don't enter getRootNodes() — its server queries
139+
// will block for the entire import duration. Instead, keep the TreeView
140+
// progress spinner visible by awaiting until the first progressive
141+
// notification delivers items.
142+
if (!element && !languageServerApiManager.isFullyReady()) {
143+
if (!this._rootItems || this._rootItems.length === 0) {
144+
if (!this._progressiveItemsReady) {
145+
this._progressiveItemsReady = new Promise<void>((resolve) => {
146+
this._resolveProgressiveItems = resolve;
147+
});
148+
}
149+
await this._progressiveItemsReady;
150+
}
151+
return this._rootItems || [];
152+
}
153+
124154
const children = (!this._rootItems || !element) ?
125155
await this.getRootNodes() : await element.getChildren();
126156

@@ -167,12 +197,68 @@ export class DependencyDataProvider implements TreeDataProvider<ExplorerNode> {
167197
private doRefresh(element?: ExplorerNode): void {
168198
if (!element) {
169199
this._rootItems = undefined;
200+
// Resolve any pending progressive await so getChildren() doesn't hang
201+
if (this._resolveProgressiveItems) {
202+
this._resolveProgressiveItems();
203+
this._resolveProgressiveItems = undefined;
204+
this._progressiveItemsReady = undefined;
205+
}
170206
}
171207
explorerNodeCache.removeNodeChildren(element);
172208
this._onDidChangeTreeData.fire(element);
173209
this.pendingRefreshElement = null;
174210
}
175211

212+
/**
213+
* Add projects progressively from ProjectsImported notifications.
214+
* This directly creates ProjectNode items from URIs without querying
215+
* the JDTLS server, which may be blocked during long-running imports.
216+
*/
217+
public addProgressiveProjects(projectUris: string[]): void {
218+
const folders = workspace.workspaceFolders;
219+
if (!folders || !folders.length) {
220+
return;
221+
}
222+
223+
if (!this._rootItems) {
224+
this._rootItems = [];
225+
}
226+
227+
const existingUris = new Set(
228+
this._rootItems
229+
.filter((n): n is ProjectNode => n instanceof ProjectNode)
230+
.map((n) => n.uri)
231+
);
232+
233+
let added = false;
234+
for (const uriStr of projectUris) {
235+
if (existingUris.has(uriStr)) {
236+
continue;
237+
}
238+
// Extract project name from URI (last non-empty path segment)
239+
const name = uriStr.replace(/\/+$/, "").split("/").pop() || "unknown";
240+
const nodeData: INodeData = {
241+
name,
242+
uri: uriStr,
243+
kind: NodeKind.Project,
244+
};
245+
this._rootItems.push(new ProjectNode(nodeData, undefined));
246+
existingUris.add(uriStr);
247+
added = true;
248+
}
249+
250+
if (added) {
251+
// Resolve the pending getChildren() promise so the TreeView
252+
// spinner stops and items appear.
253+
if (this._resolveProgressiveItems) {
254+
this._resolveProgressiveItems();
255+
this._resolveProgressiveItems = undefined;
256+
this._progressiveItemsReady = undefined;
257+
}
258+
this._onDidChangeTreeData.fire(undefined);
259+
}
260+
}
261+
176262
private async getRootNodes(): Promise<ExplorerNode[]> {
177263
try {
178264
await explorerLock.acquireAsync();

0 commit comments

Comments
 (0)