Skip to content

Commit 7be414f

Browse files
authored
fix: refresh now surfaces externally-generated files in new pakages (#1026)
* Fix #914: refresh now surfaces externally-generated files in new packages The source package-root load only did a shallow (DEPTH_ONE) refresh, so files written to disk by external generators into brand-new packages never appeared in the Java Projects view, even after Refresh. Manual Refresh now deep-refreshes (DEPTH_INFINITE) and closes the package root so the Java Model re-enumerates its package-fragment list. Auto-refresh passes the changed resource URI to the server via PackageParams.syncPaths, which scopes the refresh to the nearest existing ancestor subtree instead of the whole source root, falling back to a full deep refresh when a path cannot be resolved. - PackageParams: add syncPaths field + accessors - PackageCommand: targeted refresh branch + findNearestExistingResource helper - packageRootNode: stash pending sync paths, snapshot/clear in loadData - syncHandler: onDidCreate stashes the created URI (cycle-safe NodeKind check) - add E2E plans for manual deep refresh and targeted auto-refresh * test: make #914 E2E assertions deterministic to fix CI false negatives CI runs the autotest harness with the LLM screenshot judge enabled, which the local --no-llm runs skipped. Four state-check steps sat on a wait action while asserting a state that was already true (the package/class had appeared in an earlier refresh step). The LLM judge diffed the before/after screenshots, saw no visual delta, and failed the steps -- even though the deterministic verifyTreeItem DOM assertion passed. This produced a spurious 18/20 in CI vs 20/20 locally. Remove the natural-language �erify: field from those pure state-check steps and rely solely on the deterministic �erifyTreeItem assertion, which is the actual behaviour under test. Steps where a visible change genuinely occurs within their own window keep their �erify:. * test: drop LLM verify on file-write/refresh steps and trim comments (#914) The Windows-UI run still failed (19/20): the LLM screenshot judge could not confirm the insertLineInFile step because the written file is not visible in the collapsed Explorer (autoReveal off), a flaky judgment that passed on Linux but failed on Windows. Remove �erify: from all steps with no reliable visual signal (file writes, manual refresh, view focus); keep it only on expandTreeItem steps, which produce a clear visual change. Deterministic verifyTreeItem remains the real assertion. Also trim the verbose header/inline comments. * test: add deterministic negative baseline to #914 plans Each plan only asserted the new package was PRESENT after refresh, never that it was ABSENT before — the missing "before" half of the before/after comparison. Add a deterministic verifyTreeItem (visible: false) baseline proving the package does not exist prior to the file write, so the later appearance is a genuine refresh-driven transition rather than a pre-existing or leftover node. Uses the DOM check (waitForTreeItemGone), so no LLM-judge flakiness. Verified locally: manual 21/21, auto-refresh 16/16. * fix: address PR review - restore syncPaths on failure, dedup refresh targets - packageRootNode: restore the pending syncPaths snapshot if getPackageData rejects so a transient server error does not drop a targeted refresh - PackageCommand: dedup resolved refresh targets via LinkedHashSet so multiple files in the same new package only refresh the subtree once - e2e plans: align Maven section id to lowercase 'maven' for consistency
1 parent da10f1c commit 7be414f

6 files changed

Lines changed: 390 additions & 11 deletions

File tree

jdtls.ext/com.microsoft.jdtls.ext.core/src/com/microsoft/jdtls/ext/core/PackageCommand.java

Lines changed: 101 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
import java.util.Collections;
1717
import java.util.EnumMap;
1818
import java.util.HashMap;
19+
import java.util.LinkedHashSet;
1920
import java.util.LinkedList;
2021
import java.util.List;
2122
import java.util.Map;
@@ -371,7 +372,7 @@ private static List<PackageNode> getPackageRootChildren(PackageParams query, IPr
371372
throw new CoreException(
372373
new Status(IStatus.ERROR, JdtlsExtActivator.PLUGIN_ID, String.format("No package root found for %s", query.getPath())));
373374
}
374-
List<Object> result = getPackageFragmentRootContent(packageRoot, query.isHierarchicalView(), pm);
375+
List<Object> result = getPackageFragmentRootContent(packageRoot, query.isHierarchicalView(), query.getSyncPaths(), pm);
375376
ResourceSet resourceSet = new ResourceSet(result, query.isHierarchicalView());
376377
ResourceVisitor visitor = new JavaResourceVisitor(packageRoot.getJavaProject());
377378
resourceSet.accept(visitor);
@@ -508,8 +509,62 @@ private static List<PackageNode> getFolderChildren(PackageParams query, IProgres
508509
* @param pm the progress monitor
509510
*/
510511
public static List<Object> getPackageFragmentRootContent(IPackageFragmentRoot root, boolean isHierarchicalView, IProgressMonitor pm) throws CoreException {
512+
return getPackageFragmentRootContent(root, isHierarchicalView, null, pm);
513+
}
514+
515+
public static List<Object> getPackageFragmentRootContent(IPackageFragmentRoot root, boolean isHierarchicalView, List<String> syncPaths, IProgressMonitor pm) throws CoreException {
511516
ArrayList<Object> result = new ArrayList<>();
512-
refreshLocal(root.getResource(), pm);
517+
IResource rootResource = root.getResource();
518+
if (rootResource instanceof IContainer && rootResource.exists()
519+
&& root.getKind() == IPackageFragmentRoot.K_SOURCE) {
520+
// Packages created out-of-band (e.g. by code generators or
521+
// refactor-moves that write straight to disk) are otherwise never
522+
// surfaced. A shallow DEPTH_ONE refresh only syncs the source root's
523+
// immediate children and never discovers brand-new nested package
524+
// folders. Even a DEPTH_INFINITE resource refresh is not enough on
525+
// its own: the Java Model keeps a cached list of package fragments
526+
// for the root, so closing it forces getChildren() below to rebuild
527+
// that list from the freshly refreshed resource tree.
528+
//
529+
// On auto-refresh the client passes the changed resource URIs in
530+
// syncPaths so we only deep-refresh those subtrees instead of the
531+
// whole source tree. If any path cannot be resolved to an existing
532+
// resource inside this root we conservatively fall back to a full
533+
// DEPTH_INFINITE refresh so no package is ever missed.
534+
// See https://github.com/microsoft/vscode-java-dependency/issues/914
535+
boolean refreshedTargets = false;
536+
if (syncPaths != null && !syncPaths.isEmpty()) {
537+
Set<IResource> targets = new LinkedHashSet<>();
538+
boolean allResolved = true;
539+
for (String syncPath : syncPaths) {
540+
IResource target = findNearestExistingResource(syncPath, (IContainer) rootResource);
541+
if (target == null) {
542+
allResolved = false;
543+
break;
544+
}
545+
// Multiple changed paths can resolve to the same existing
546+
// ancestor (e.g. several new files in one new package); the
547+
// set keeps each distinct subtree so it is refreshed once.
548+
targets.add(target);
549+
}
550+
if (allResolved && !targets.isEmpty()) {
551+
for (IResource target : targets) {
552+
refreshLocal(target, IResource.DEPTH_INFINITE, pm);
553+
}
554+
refreshedTargets = true;
555+
}
556+
}
557+
if (!refreshedTargets) {
558+
refreshLocal(rootResource, IResource.DEPTH_INFINITE, pm);
559+
}
560+
try {
561+
root.close();
562+
} catch (JavaModelException e) {
563+
JdtlsExtActivator.log(e);
564+
}
565+
} else {
566+
refreshLocal(rootResource, IResource.DEPTH_ONE, pm);
567+
}
513568
if (isHierarchicalView) {
514569
Map<String, IJavaElement> map = new HashMap<>();
515570
for (IJavaElement child : root.getChildren()) {
@@ -599,13 +654,56 @@ public static IJavaProject getJavaProject(String projectUri) {
599654
}
600655

601656
private static void refreshLocal(IResource resource, IProgressMonitor monitor) {
657+
refreshLocal(resource, IResource.DEPTH_ONE, monitor);
658+
}
659+
660+
private static void refreshLocal(IResource resource, int depth, IProgressMonitor monitor) {
602661
if (resource == null || !resource.exists()) {
603662
return;
604663
}
605664
try {
606-
resource.refreshLocal(IResource.DEPTH_ONE, monitor);
665+
resource.refreshLocal(depth, monitor);
607666
} catch (CoreException e) {
608667
JdtlsExtActivator.log(e);
609668
}
610669
}
670+
671+
/**
672+
* Resolve a changed resource URI to the nearest ancestor that already exists
673+
* in the workspace resource tree and lives inside the given source root.
674+
* Used to scope an auto-refresh to only the affected subtree. Returns null
675+
* when the URI cannot be mapped to a resource within the root, in which case
676+
* the caller falls back to a full refresh so no package is missed.
677+
*/
678+
private static IResource findNearestExistingResource(String uriStr, IContainer root) {
679+
if (StringUtils.isBlank(uriStr) || root == null) {
680+
return null;
681+
}
682+
try {
683+
URI uri = JDTUtils.toURI(uriStr);
684+
if (uri == null) {
685+
return null;
686+
}
687+
IWorkspaceRoot wsRoot = ResourcesPlugin.getWorkspace().getRoot();
688+
IResource resource = null;
689+
IFile[] files = wsRoot.findFilesForLocationURI(uri);
690+
if (files.length > 0) {
691+
resource = files[0];
692+
} else {
693+
IContainer[] containers = wsRoot.findContainersForLocationURI(uri);
694+
if (containers.length > 0) {
695+
resource = containers[0];
696+
}
697+
}
698+
while (resource != null && !resource.exists()) {
699+
resource = resource.getParent();
700+
}
701+
if (resource != null && root.getFullPath().isPrefixOf(resource.getFullPath())) {
702+
return resource;
703+
}
704+
} catch (Exception e) {
705+
JdtlsExtActivator.logException("Failed to resolve sync path " + uriStr, e);
706+
}
707+
return null;
708+
}
611709
}

jdtls.ext/com.microsoft.jdtls.ext.core/src/com/microsoft/jdtls/ext/core/PackageParams.java

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111

1212
package com.microsoft.jdtls.ext.core;
1313

14+
import java.util.List;
15+
1416
import com.microsoft.jdtls.ext.core.model.NodeKind;
1517

1618
/**
@@ -31,6 +33,14 @@ public class PackageParams {
3133

3234
private boolean isHierarchicalView;
3335

36+
/**
37+
* Optional list of resource URIs (sent by the client on auto-refresh) that
38+
* have just changed on disk. When present, the server only refreshes the
39+
* affected subtrees instead of deeply refreshing the whole source root.
40+
* See https://github.com/microsoft/vscode-java-dependency/issues/914
41+
*/
42+
private List<String> syncPaths;
43+
3444
public PackageParams() {
3545
}
3646

@@ -97,4 +107,12 @@ public void setRootPath(String rootPath) {
97107
this.rootPath = rootPath;
98108
}
99109

110+
public List<String> getSyncPaths() {
111+
return syncPaths;
112+
}
113+
114+
public void setSyncPaths(List<String> syncPaths) {
115+
this.syncPaths = syncPaths;
116+
}
117+
100118
}

src/syncHandler.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,18 @@ class SyncHandler implements Disposable {
9090
}));
9191

9292
this.disposables.push(watcher.onDidCreate((uri: Uri) => {
93-
this.refresh(this.getParentNodeInExplorer(uri));
93+
const node: ExplorerNode | undefined = this.getParentNodeInExplorer(uri);
94+
// When the created resource lands in a package that is not currently
95+
// rendered, getParentNodeInExplorer resolves to the source root. Tell
96+
// that root which path changed so the server can refresh only that
97+
// subtree instead of deeply refreshing the whole source tree. Gate on
98+
// the node kind (not instanceof) to avoid importing PackageRootNode
99+
// here, which would create a module cycle and break activation.
100+
// See https://github.com/microsoft/vscode-java-dependency/issues/914
101+
if (node instanceof DataNode && node.nodeData?.kind === NodeKind.PackageRoot) {
102+
(node as unknown as { pendingSyncPaths: Set<string> }).pendingSyncPaths.add(uri.toString());
103+
}
104+
this.refresh(node);
94105
}));
95106

96107
this.disposables.push(watcher.onDidDelete((uri: Uri) => {

src/views/packageRootNode.ts

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,14 @@ import { NodeFactory } from "./nodeFactory";
1616

1717
export class PackageRootNode extends DataNode {
1818

19+
/**
20+
* Resource URIs reported by the file watcher as newly created under this
21+
* source root since the last load. Consumed on the next loadData() so the
22+
* server can scope its filesystem refresh to only the changed subtrees.
23+
* See https://github.com/microsoft/vscode-java-dependency/issues/914
24+
*/
25+
public pendingSyncPaths: Set<string> = new Set<string>();
26+
1927
constructor(nodeData: INodeData, parent: DataNode, protected _project: ProjectNode) {
2028
super(nodeData, parent);
2129
}
@@ -25,13 +33,28 @@ export class PackageRootNode extends DataNode {
2533
}
2634

2735
protected async loadData(): Promise<INodeData[]> {
28-
return Jdtls.getPackageData({
29-
kind: NodeKind.PackageRoot,
30-
projectUri: this._project.nodeData.uri,
31-
rootPath: this.nodeData.path,
32-
handlerIdentifier: this.nodeData.handlerIdentifier,
33-
isHierarchicalView: Settings.isHierarchicalView(),
34-
});
36+
let syncPaths: string[] | undefined;
37+
if (this.pendingSyncPaths.size) {
38+
// Snapshot and clear synchronously before the async server call so
39+
// watcher events arriving during the await are not lost.
40+
syncPaths = Array.from(this.pendingSyncPaths);
41+
this.pendingSyncPaths.clear();
42+
}
43+
try {
44+
return await Jdtls.getPackageData({
45+
kind: NodeKind.PackageRoot,
46+
projectUri: this._project.nodeData.uri,
47+
rootPath: this.nodeData.path,
48+
handlerIdentifier: this.nodeData.handlerIdentifier,
49+
isHierarchicalView: Settings.isHierarchicalView(),
50+
syncPaths,
51+
});
52+
} catch (error) {
53+
// Restore the snapshot so a transient server error does not drop the
54+
// pending paths; the next refresh will retry the targeted sync.
55+
syncPaths?.forEach((path) => this.pendingSyncPaths.add(path));
56+
throw error;
57+
}
3558
}
3659

3760
protected createChildNodeList(): ExplorerNode[] {
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
# Validates the targeted (scoped) auto-refresh optimization for issue #914.
2+
# Companion to java-dep-refresh-generated-files.yaml (which tests manual Refresh).
3+
#
4+
# On auto-refresh the extension passes the changed URI to the server
5+
# (PackageParams.syncPaths); the server refreshes only the affected subtree (the
6+
# nearest existing ancestor) instead of the whole source root, then closes the
7+
# package root to rebuild its package-fragment list. This plan does NOT call
8+
# java.view.package.refresh — it relies solely on the FileSystemWatcher, so it
9+
# exercises the syncPaths path. Auto-refresh is on by default.
10+
#
11+
# Tree layout and verify policy are the same as the manual-Refresh plan: compact
12+
# virtualized tree, deterministic verifyTreeItem assertions, no `verify:` on the
13+
# file-write step (no reliable visual signal for the LLM judge).
14+
#
15+
# Usage:
16+
# npx autotest run test/e2e-plans/java-dep-autorefresh-targeted.yaml \
17+
# --override extensionPath=<path-to-this-repo>
18+
19+
name: "Java Dependency — Auto-refresh surfaces a new package (targeted, #914)"
20+
description: |
21+
Validates the targeted (scoped) auto-refresh optimization for issue #914: a
22+
.java file written by an external generator into a brand-new sub-package must
23+
appear in the Java Projects view via the file-watcher auto-refresh alone (no
24+
manual Refresh), which routes the changed URI through PackageParams.syncPaths.
25+
26+
setup:
27+
extension: "redhat.java"
28+
vscodeVersion: "stable"
29+
workspace: "../maven"
30+
timeout: 180
31+
settings:
32+
java.configuration.checkProjectSettingsExclusions: false
33+
workbench.startupEditor: "none"
34+
explorer.autoReveal: false
35+
36+
steps:
37+
- id: "ls-ready"
38+
action: "waitForLanguageServer"
39+
timeout: 180
40+
41+
# Free vertical space so the Java Projects tree is not virtualized.
42+
- id: "close-aux-bar"
43+
action: "executeVSCodeCommand workbench.action.closeAuxiliaryBar"
44+
45+
- id: "collapse-outline"
46+
action: "collapseSidebarSection OUTLINE"
47+
48+
- id: "collapse-timeline"
49+
action: "collapseSidebarSection TIMELINE"
50+
51+
- id: "collapse-explorer-folders"
52+
action: "collapseSidebarSection maven"
53+
54+
- id: "focus-java-projects"
55+
action: "executeVSCodeCommand javaProjectExplorer.focus"
56+
57+
- id: "wait-tree-load"
58+
action: "wait 3 seconds"
59+
60+
# The source root must be expanded so a PackageRootNode exists to target.
61+
- id: "expand-project"
62+
action: "expandTreeItem my-app"
63+
verify: "my-app project expanded"
64+
65+
- id: "expand-source-root"
66+
action: "expandTreeItem src/main/java"
67+
verify: "source root src/main/java expanded"
68+
69+
- id: "baseline-existing-pkg"
70+
action: "wait 1 seconds"
71+
verifyTreeItem:
72+
name: "com.mycompany.app"
73+
visible: true
74+
75+
# Negative baseline: the brand-new package must be ABSENT before the file is
76+
# written, so check-new-pkg-autorefresh later observes a genuine appearance
77+
# driven by the watcher, not a pre-existing node.
78+
- id: "baseline-new-pkg-absent"
79+
action: "wait 1 seconds"
80+
verifyTreeItem:
81+
name: "com.mycompany.app.autogen"
82+
visible: false
83+
timeout: 5
84+
85+
# Write a file straight to disk into a brand-new sub-package.
86+
- id: "gen-file-new-pkg"
87+
action: "insertLineInFile src/main/java/com/mycompany/app/autogen/Gen914AutoNewPkg.java 1 package com.mycompany.app.autogen;\n\npublic class Gen914AutoNewPkg {\n}\n"
88+
89+
- id: "dismiss-overlay"
90+
action: "pressKey Escape"
91+
92+
# Let the file watcher + debounced auto-refresh fire. No manual Refresh.
93+
- id: "wait-for-auto-refresh"
94+
action: "wait 6 seconds"
95+
96+
- id: "reexpand-source-root"
97+
action: "expandTreeItem src/main/java"
98+
99+
# The brand-new package appears via auto-refresh alone.
100+
- id: "check-new-pkg-autorefresh"
101+
action: "wait 1 seconds"
102+
verifyTreeItem:
103+
name: "com.mycompany.app.autogen"
104+
visible: true
105+
timeout: 20

0 commit comments

Comments
 (0)