Publish shared FXML diagnostics and integrate IntelliJ Problems

Navigate authoritative FXML locations, withdraw stale results and preserve host Undo/Redo. Include editor ownership in native problem identity so equal diagnostics from separate owners survive independent cleanup. Qualify both hosts automatically.
This commit is contained in:
2026-11-13 16:52:50 +01:00
parent 910a8b0e85
commit 8a4f456e38
17 changed files with 883 additions and 17 deletions
+1
View File
@@ -18,6 +18,7 @@ dependencies {
intellijPlatform {
local(providers.gradleProperty("ideaHome").orElse(providers.environmentVariable("IDEA_HOME")))
testFramework(TestFrameworkType.Platform)
bundledPlugin("com.intellij.problemsView")
}
// Maven platform profiles are not selected by Gradle: include the qualified macOS natives explicitly.
listOf("base", "controls", "fxml", "graphics", "media", "swing", "web").forEach {
@@ -0,0 +1,149 @@
package de.neitzel.fxdesigner.intellij;
import com.intellij.analysis.problemsView.*;
import com.intellij.openapi.application.*;
import com.intellij.openapi.fileEditor.FileDocumentManager;
import com.intellij.openapi.project.Project;
import com.intellij.openapi.vfs.*;
import de.neitzel.fxdesigner.contracts.DocumentSnapshot;
import de.neitzel.fxdesigner.host.HostServices;
import java.nio.file.Path;
import java.util.*;
/** Publishes only current FXML evidence to the IDE's existing Problems collector. */
final class IntellijDiagnostics
implements HostServices.Diagnostics, ProblemsProvider, AutoCloseable {
private final Project project;
private final HostServices.Diagnostics output;
private final Map<String, List<Problem>> owned = new HashMap<>(); // EDT only
private volatile boolean closed;
IntellijDiagnostics(Project project, HostServices.Diagnostics output) {
this.project = project;
this.output = output;
}
@Override
public Project getProject() {
return project;
}
@Override
public void report(HostServices.Diagnostic d) {
output.report(d);
}
private void edt(Runnable action) {
ApplicationManager.getApplication()
.invokeLater(
() -> {
if (!project.isDisposed()) WriteIntentReadAction.run(action);
});
}
@Override
public void replace(
String owner,
DocumentSnapshot source,
String context,
List<HostServices.Diagnostic> diagnostics) {
var captured = List.copyOf(diagnostics);
edt(
() -> {
remove(owner);
if (closed) return;
var file =
LocalFileSystem.getInstance().refreshAndFindFileByNioFile(Path.of(source.document()));
if (file == null) return;
var document = FileDocumentManager.getInstance().getDocument(file);
if (document == null || !document.getText().equals(source.text())) return;
var entries = new ArrayList<Problem>();
for (var d : captured) {
if (d.location().isEmpty() || !d.location().get().document().equals(source.document()))
continue;
int offset = Math.min(d.location().get().offset(), document.getTextLength());
int line = document.getLineNumber(offset),
column = offset - document.getLineStartOffset(line);
var p = new Entry(this, owner, file, line, column, d, context);
entries.add(p);
ProblemsCollector.getInstance(project).problemAppeared(p);
}
owned.put(owner, entries);
});
}
@Override
public void clear(String owner) {
edt(() -> remove(owner));
}
private void remove(String owner) {
var old = owned.remove(owner);
if (old != null) old.forEach(ProblemsCollector.getInstance(project)::problemDisappeared);
}
@Override
public void close() {
closed = true;
edt(() -> new ArrayList<>(owned.keySet()).forEach(this::remove));
}
@Override
public void dispose() {
close();
}
private record Entry(
ProblemsProvider provider,
String owner,
VirtualFile file,
int line,
int column,
HostServices.Diagnostic diagnostic,
String context)
implements FileProblem {
@Override
public ProblemsProvider getProvider() {
return provider;
}
@Override
public VirtualFile getFile() {
return file;
}
@Override
public int getLine() {
return line;
}
@Override
public int getColumn() {
return column;
}
@Override
public String getText() {
return diagnostic.severity() + ": " + diagnostic.message();
}
@Override
public String getGroup() {
return "Neitzel FX Designer";
}
@Override
public String getDescription() {
return diagnostic.operation() + " · " + context;
}
@Override
public javax.swing.Icon getIcon() {
return switch (diagnostic.severity()) {
case ERROR -> com.intellij.icons.AllIcons.General.Error;
case WARNING -> com.intellij.icons.AllIcons.General.Warning;
case INFO -> com.intellij.icons.AllIcons.General.Information;
};
}
}
}
@@ -22,6 +22,7 @@ public final class IntellijHost implements AutoCloseable {
Project project, HostServices.Runtimes runtimes, HostServices.Diagnostics diagnostics) {
var docs = scope.own(new IntellijDocuments(project));
var builds = new ProcessBuilds();
var problems = scope.own(new IntellijDiagnostics(project, diagnostics));
services =
new HostServices(
docs,
@@ -62,6 +63,33 @@ public final class IntellijHost implements AutoCloseable {
}));
}
public CompletionStage<Void> openSource(HostServices.Location location) {
var result = new CompletableFuture<Void>();
ApplicationManager.getApplication()
.invokeLater(
() ->
WriteIntentReadAction.run(
() -> {
try {
if (project.isDisposed())
throw new IllegalStateException("Project disposed");
var file =
LocalFileSystem.getInstance()
.refreshAndFindFileByNioFile(
Path.of(location.document()));
if (file == null)
throw new IllegalArgumentException(
"Navigation file unavailable");
new OpenFileDescriptor(project, file, location.offset())
.navigate(true);
result.complete(null);
} catch (Throwable e) {
result.completeExceptionally(e);
}
}));
return result;
}
public CompletionStage<Void> open(HostServices.Location location) {
var result = new CompletableFuture<Void>();
ApplicationManager.getApplication()
@@ -107,7 +135,7 @@ public final class IntellijHost implements AutoCloseable {
return result;
}
},
diagnostics,
problems,
scope);
Disposer.register(project, this::close);
}
@@ -2,6 +2,7 @@
<id>de.neitzel.fxdesigner</id><name>Neitzel FX Designer</name><vendor>Neitzel</vendor>
<description>Shared Kit designer with isolated target previews and IDE-owned FXML documents.</description>
<depends>com.intellij.modules.platform</depends>
<depends>com.intellij.problemsView</depends>
<extensions defaultExtensionNs="com.intellij">
<fileEditorProvider implementation="de.neitzel.fxdesigner.intellij.DesignerEditorProvider"/>
</extensions>
@@ -0,0 +1,62 @@
package de.neitzel.fxdesigner.intellij;
import com.intellij.analysis.problemsView.*;
import com.intellij.openapi.vfs.LocalFileSystem;
import com.intellij.testFramework.fixtures.BasePlatformTestCase;
import de.neitzel.fxdesigner.contracts.DocumentSnapshot;
import de.neitzel.fxdesigner.host.HostServices.*;
import java.nio.file.*;
import java.util.*;
public final class IntellijDiagnosticsTest extends BasePlatformTestCase {
private void until(java.util.function.BooleanSupplier ready) throws Exception {
long end = System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(10);
while (!ready.getAsBoolean() && System.nanoTime() < end) {
com.intellij.testFramework.PlatformTestUtil.dispatchAllEventsInIdeEventQueue();
Thread.sleep(10);
}
assertTrue(ready.getAsBoolean());
}
public void testReplacementIsolationStaleRejectionAndClose() throws Exception {
Path path = Files.createTempFile("designer-problems-", ".fxml");
Files.writeString(path, "<VBox>\n<Label/>\n</VBox>");
com.intellij.openapi.vfs.newvfs.impl.VfsRootAccess.allowRootAccess(
getTestRootDisposable(), path.toRealPath().toString());
var file = LocalFileSystem.getInstance().refreshAndFindFileByNioFile(path);
var collector = ProblemsCollector.getInstance(getProject());
var publisher = new IntellijDiagnostics(getProject(), d -> {});
try {
var source = new DocumentSnapshot(path.toUri(), 1, Files.readString(path), true, false);
var error =
new Diagnostic(
"Bindings", Severity.ERROR, "test error", Optional.of(new Location(path.toUri(), 7)));
publisher.replace("one", source, "current", List.of(error));
until(() -> collector.getFileProblemCount(file) == 1);
var problem = (FileProblem) collector.getFileProblems(file).iterator().next();
assertEquals(1, problem.getLine());
assertEquals(0, problem.getColumn());
publisher.replace("one", source, "current", List.of(error));
until(() -> collector.getFileProblemCount(file) == 1);
publisher.replace("two", source, "current", List.of(error));
until(() -> collector.getFileProblemCount(file) == 2);
publisher.clear("one");
until(() -> collector.getFileProblemCount(file) == 1);
publisher.replace(
"one",
new DocumentSnapshot(path.toUri(), 0, "old source", true, false),
"stale",
List.of(error));
com.intellij.testFramework.PlatformTestUtil.dispatchAllEventsInIdeEventQueue();
assertEquals(1, collector.getFileProblemCount(file));
publisher.close();
until(() -> collector.getFileProblemCount(file) == 0);
publisher.replace("one", source, "late result", List.of(error));
com.intellij.testFramework.PlatformTestUtil.dispatchAllEventsInIdeEventQueue();
assertEquals(0, collector.getFileProblemCount(file));
} finally {
publisher.close();
Files.deleteIfExists(path);
}
}
}