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 -1
View File
@@ -6,7 +6,7 @@ Shared Scene Builder Kit designer for NeitzelLib FX, with minimal standalone and
The first workflow is runnable in both hosts: Build and Refresh publishes immutable snapshots; authenticated target workers discover examples and render parent/Component FXML drafts into the shared Kit canvas. Source and supported canvas edits use host-owned documents and undo/redo. See [the connected workflow](docs/first-connected-workflow.md) for architecture, launch commands, evidence and limits.
The repository builds six shared Maven modules, a Maven capture plugin and a separate IntelliJ adapter plugin. Application code runs only in target JVMs. The shared UI now applies [source-preserving edits](docs/source-preserving-projection.md), supports nested/anonymous Components and fx:root bodies, and provides Source/Design/Split modes. Retained [target sessions, target sizing, interactive preview and Component/Row navigation](docs/target-runtime-sessions.md) now extend that workflow. The shared [binding discovery browser](docs/binding-discovery.md) now lists declared Model paths and direction-aware target compatibility. The [DirectBinding inspector](docs/direct-binding-inspector.md) creates, edits and removes declarations through source-preserving host Undo/Redo. Shared [Converter discovery and binding validation](docs/converter-validation.md) now adds capability-based proposals, document diagnostics and preview gating. [Custom ViewModel discovery and Computed-binding validation](docs/viewmodel-computed-validation.md) now follow the explicitly selected Controller or Component in both hosts. The [Computed-binding inspector](docs/computed-binding-inspector.md) now creates, changes and removes Computed declarations with method suggestions and shared source-preserving Undo/Redo. The [Repeat inspector](docs/repeat-binding-inspector.md) adds source-preserving collection declarations with host Undo/Redo and [collection/Component compatibility discovery](docs/repeat-compatibility.md). The shared [Table/Column inspector](docs/table-column-inspector.md) adds source-preserving Table configuration, ordered Column authoring and explicit Row navigation, now with [Table-specific compatibility and declared Row alignment discovery](docs/table-compatibility.md). The shared [reference-safe Structure editor](docs/reference-safe-structure.md) now renames identifiers, copies subtrees with fresh identifiers and deletes associated bindings through source-preserving host Undo/Redo, including destination validation for cross-View paste. Broader discovery, generation and product packaging remain later work.
The repository builds six shared Maven modules, a Maven capture plugin and a separate IntelliJ adapter plugin. Application code runs only in target JVMs. The shared UI now applies [source-preserving edits](docs/source-preserving-projection.md), supports nested/anonymous Components and fx:root bodies, and provides Source/Design/Split modes. Retained [target sessions, target sizing, interactive preview and Component/Row navigation](docs/target-runtime-sessions.md) now extend that workflow. The shared [binding discovery browser](docs/binding-discovery.md) now lists declared Model paths and direction-aware target compatibility. The [DirectBinding inspector](docs/direct-binding-inspector.md) creates, edits and removes declarations through source-preserving host Undo/Redo. Shared [Converter discovery and binding validation](docs/converter-validation.md) now adds capability-based proposals, document diagnostics and preview gating. [Custom ViewModel discovery and Computed-binding validation](docs/viewmodel-computed-validation.md) now follow the explicitly selected Controller or Component in both hosts. The [Computed-binding inspector](docs/computed-binding-inspector.md) now creates, changes and removes Computed declarations with method suggestions and shared source-preserving Undo/Redo. The [Repeat inspector](docs/repeat-binding-inspector.md) adds source-preserving collection declarations with host Undo/Redo and [collection/Component compatibility discovery](docs/repeat-compatibility.md). The shared [Table/Column inspector](docs/table-column-inspector.md) adds source-preserving Table configuration, ordered Column authoring and explicit Row navigation, now with [Table-specific compatibility and declared Row alignment discovery](docs/table-compatibility.md). The shared [reference-safe Structure editor](docs/reference-safe-structure.md) now renames identifiers, copies subtrees with fresh identifiers and deletes associated bindings through source-preserving host Undo/Redo, including destination validation for cross-View paste. The [shared Problems panel and IntelliJ Problems integration](docs/shared-diagnostics.md) now expose current diagnostics with authoritative FXML navigation and editor-scoped cleanup. Broader discovery, generation and product packaging remain later work.
Configuration 1.0 schemas, context selection, local JDK mappings and draft protocol validation are now executable. See [configuration and protocol](docs/configuration-and-protocol.md) for formats and validation rules.
@@ -74,6 +74,11 @@ public record HostServices(
public interface Navigation {
CompletionStage<Void> open(Location location);
/** Open authoritative source at an offset, rather than selecting the visual designer. */
default CompletionStage<Void> openSource(Location location) {
return open(location);
}
/** Editable attached sources whose owning project is known to the host. */
default CompletionStage<List<Location>> componentSources(String type) {
return java.util.concurrent.CompletableFuture.completedFuture(List.of());
@@ -98,6 +103,16 @@ public record HostServices(
public interface Diagnostics {
void report(Diagnostic diagnostic);
/** Replace one editor's current diagnostics, never append obsolete validation results. */
default void replace(
String owner,
de.neitzel.fxdesigner.contracts.DocumentSnapshot source,
String context,
List<Diagnostic> diagnostics) {}
/** Withdraw this editor's diagnostics without removing another editor's results. */
default void clear(String owner) {}
}
public record BuildRequest(
+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);
}
}
}
@@ -121,6 +121,11 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
private BindingEditor bindingEditor, computedEditor, repeatEditor;
private TableEditor tableEditor;
private ReferenceEditor referenceEditor;
private DiagnosticsPane diagnosticsPane;
private final String diagnosticsOwner = java.util.UUID.randomUUID().toString();
private long diagnosticsRevision = -1;
private SplitPane sourceSplit;
private Integer pendingSourceOffset;
private String pendingReferenceValidation;
private String validatedSource, validatingSource;
private boolean validationPreview;
@@ -220,6 +225,8 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
new TableEditor(
this::editTable, this::navigateComponent, values -> validateBindings(values, false));
referenceEditor = new ReferenceEditor(file.toUri().toString(), this::editStructure);
diagnosticsPane =
new DiagnosticsPane(() -> validateBindings(null, false), this::navigateDiagnostic);
bindingBrowser =
new BindingBrowser(
this::discoverBindings,
@@ -346,6 +353,7 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
}
});
var split = new SplitPane(source, design);
sourceSplit = split;
split.setDividerPositions(.3);
var modes =
new ToolBar(
@@ -372,13 +380,21 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
repeatTab.setClosable(false);
var tableTab = new Tab("Table / Columns", tableEditor);
tableTab.setClosable(false);
var problemsTab = new Tab("Problems", diagnosticsPane);
problemsTab.setClosable(false);
var structureTab = new Tab("Structure", referenceEditor);
structureTab.setClosable(false);
var bindings =
new TitledPane(
"Bindings",
new TabPane(
directBindingTab, computedTab, repeatTab, tableTab, structureTab, discoveryTab));
directBindingTab,
computedTab,
repeatTab,
tableTab,
structureTab,
problemsTab,
discoveryTab));
bindings.setExpanded(false);
center.setBottom(new VBox(properties, bindings));
output.setEditable(false);
@@ -478,6 +494,7 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
.report(
new HostServices.Diagnostic(
"Designer", HostServices.Severity.ERROR, String.valueOf(error), Optional.empty()));
if (draft != null && diagnosticsPane != null) validationUnavailable(error);
status.setText("Designer: " + error.getMessage());
log(error.toString());
}
@@ -518,6 +535,7 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
}
}
draft = value;
publishDiagnostics("Source checked; target validation outdated", java.util.List.of());
bindingEditor.load(value.text(), value.writable());
computedEditor.load(value.text(), value.writable());
repeatEditor.load(value.text(), value.writable());
@@ -544,6 +562,7 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
var hit = kit.editor().getSelection().getHitItem();
String selectedId = hit == null ? null : hit.getFxId();
draft = value;
publishDiagnostics("Source checked; target validation outdated", java.util.List.of());
bindingEditor.load(value.text(), value.writable());
computedEditor.load(value.text(), value.writable());
repeatEditor.load(value.text(), value.writable());
@@ -614,6 +633,11 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
report(error);
} finally {
loading = false;
if (pendingSourceOffset != null) {
int offset = pendingSourceOffset;
pendingSourceOffset = null;
showSource(offset);
}
if (value.text().equals(pendingReferenceValidation)) {
pendingReferenceValidation = null;
Platform.runLater(() -> validateBindings(null, false));
@@ -700,6 +724,9 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
private void invalidateBindings(String message) {
bindingRequest++;
host.diagnostics().clear(diagnosticsOwner);
if (diagnosticsPane != null) diagnosticsPane.show(java.util.List.of(), message);
diagnosticsRevision = -1;
validatedSource = null;
validatingSource = null;
validationPreview = false;
@@ -709,6 +736,66 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
tableEditor.invalidateDiscovery();
}
private void publishDiagnostics(String context, java.util.List<HostServices.Diagnostic> target) {
if (closed || draft == null) return;
var all = new java.util.LinkedHashSet<>(FxmlDiagnostics.local(draft));
all.addAll(target);
var diagnostics = java.util.List.copyOf(all);
diagnosticsRevision = draft.revision();
String label = context + " · draft revision " + draft.revision();
diagnosticsPane.show(diagnostics, label);
host.diagnostics().replace(diagnosticsOwner, draft, label, diagnostics);
}
private void validationUnavailable(Throwable error) {
publishDiagnostics(
"Validation unavailable; results are not a clean bill of health",
java.util.List.of(
new HostServices.Diagnostic(
"Designer",
HostServices.Severity.ERROR,
String.valueOf(error.getMessage()),
Optional.of(new HostServices.Location(draft.document(), 0)))));
}
private void navigateDiagnostic(HostServices.Diagnostic problem) {
long revision = diagnosticsRevision;
withDraft(
() -> {
if (revision != draft.revision()) {
publishDiagnostics(
"Document changed; validate again before navigating", java.util.List.of());
return;
}
problem
.location()
.ifPresent(
location -> {
if (location.document().equals(draft.document())) showSource(location.offset());
else
host.navigation()
.openSource(location)
.exceptionally(
error -> {
fx(() -> report(error));
return null;
});
});
});
}
/** Select authoritative FXML source, preserving the host-owned draft and undo history. */
public void showSource(int offset) {
if (closed) return;
if (draft == null) {
pendingSourceOffset = offset;
return;
}
sourceSplit.getItems().setAll(source);
source.positionCaret(Math.min(Math.max(0, offset), source.getLength()));
source.requestFocus();
}
private void editStructure(ReferenceEditor.Intent intent) {
withDraft(
() -> {
@@ -796,6 +883,7 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
var captured = draft;
validatingSource = captured.text();
validationPreview |= preview;
publishDiagnostics("Validating target types…", java.util.List.of());
status.setText("Validating binding types in target runtime…");
bindingWorkflow
.bindingDetails(
@@ -820,12 +908,18 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
boolean resumePreview = validationPreview;
validationPreview = false;
if (error != null) {
validationUnavailable(error);
status.setText(
"Binding validation unavailable: " + error.getMessage());
return;
}
validatedSource = captured.text();
bindingProblems = metadata.problems();
publishDiagnostics(
last
? "Explicit retained build (outdated)"
: "Current successful build",
FxmlDiagnostics.target(captured, metadata.problems()));
bindingEditor.metadata(
metadata,
values,
@@ -888,6 +982,7 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
boolean resumePreview = validationPreview;
validationPreview = false;
if (error != null) {
validationUnavailable(error);
bindingBrowser.invalidate(
"Discovery unavailable: "
+ error.getMessage()
@@ -896,6 +991,11 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
}
validatedSource = captured.text();
bindingProblems = metadata.problems();
publishDiagnostics(
last
? "Explicit retained build (outdated)"
: "Current successful build",
FxmlDiagnostics.target(captured, metadata.problems()));
bindingBrowser.show(
metadata,
(last
@@ -1278,6 +1378,7 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
public void close() {
if (closed) return;
closed = true;
host.diagnostics().clear(diagnosticsOwner);
try {
changes.close();
saves.close();
@@ -0,0 +1,61 @@
package de.neitzel.fxdesigner.kit;
import de.neitzel.fxdesigner.host.HostServices.Diagnostic;
import java.util.List;
import java.util.function.Consumer;
import javafx.scene.control.*;
import javafx.scene.input.KeyCode;
import javafx.scene.layout.VBox;
/** One shared, navigable diagnostic list for both hosts. */
final class DiagnosticsPane extends VBox {
private final ListView<Diagnostic> problems = new ListView<>();
private final Label state = new Label("Target validation not run");
DiagnosticsPane(Runnable validate, Consumer<Diagnostic> navigate) {
super(5);
problems.setId("diagnostics-list");
problems.setPrefHeight(110);
state.setId("diagnostics-state");
state.setWrapText(true);
problems.setCellFactory(
v ->
new ListCell<>() {
@Override
protected void updateItem(Diagnostic d, boolean empty) {
super.updateItem(d, empty);
setText(
empty || d == null
? null
: d.severity() + " · " + d.operation() + " · " + d.message());
}
});
Runnable open =
() -> {
var d = problems.getSelectionModel().getSelectedItem();
if (d != null) navigate.accept(d);
};
problems.setOnMouseClicked(
e -> {
if (e.getClickCount() == 2) open.run();
});
problems.setOnKeyPressed(
e -> {
if (e.getCode() == KeyCode.ENTER) {
open.run();
e.consume();
}
});
var check = new Button("Validate View");
check.setOnAction(e -> validate.run());
var go = new Button("Go to FXML");
go.setOnAction(e -> open.run());
go.disableProperty().bind(problems.getSelectionModel().selectedItemProperty().isNull());
getChildren().addAll(new javafx.scene.layout.HBox(6, check, go), state, problems);
}
void show(List<Diagnostic> diagnostics, String context) {
problems.getItems().setAll(diagnostics);
state.setText(context + " · " + diagnostics.size() + " diagnostics");
}
}
@@ -0,0 +1,92 @@
package de.neitzel.fxdesigner.kit;
import de.neitzel.fxdesigner.contracts.DocumentSnapshot;
import de.neitzel.fxdesigner.host.HostServices.*;
import de.neitzel.fxdesigner.protocol.BindingMetadata.Problem;
import java.util.*;
import org.w3c.dom.Element;
import org.xml.sax.SAXParseException;
/** Maps shared validation evidence to the authoritative FXML, never to the Kit projection. */
final class FxmlDiagnostics {
static List<Diagnostic> local(DocumentSnapshot source) {
var messages = new LinkedHashSet<String>();
int offset = 0;
try {
var xml = new BindingDocument(source.text());
offset = xml.source.tags.getFirst().start();
messages.addAll(xml.errors());
messages.addAll(new TableDocument(source.text()).errors());
messages.addAll(new ReferenceDocument(source.text()).referenceErrors());
} catch (Exception e) {
if (e instanceof SAXParseException parse)
offset = offset(source.text(), parse.getLineNumber() - 1, parse.getColumnNumber() - 1);
messages.add(String.valueOf(e.getMessage()));
}
int origin = offset;
return messages.stream().map(m -> diagnostic(source, "FXML", "ERROR", m, origin)).toList();
}
static List<Diagnostic> target(DocumentSnapshot source, List<Problem> problems) {
var result = new LinkedHashSet<Diagnostic>();
try {
var xml = new BindingDocument(source.text());
for (var p : problems) {
var matches = new ArrayList<Integer>();
for (int i = 0; i < xml.elements.size(); i++) {
var e = xml.elements.get(i);
if ((xml.named(e, "DirectBinding") || xml.named(e, "ComputedBinding"))
&& property(e, "control").equals(p.control())
&& property(e, "property").equals(p.property())) matches.add(i);
else if (xml.named(e, "Repeat")
&& p.property().equals("children")
&& property(e, "pane").equals(p.control())) matches.add(i);
}
if (matches.isEmpty()) {
for (int i = 0; i < xml.elements.size(); i++) {
var e = xml.elements.get(i);
if ((!p.control().isEmpty()
&& e.getAttributeNS(BindingDocument.FX, "id").equals(p.control()))
|| xml.named(e, "Table") && p.control().equals("Table #" + i)) matches.add(i);
}
}
// Unknown or aggregate origins remain at the document root, not a guessed Java/Row
// location.
if (matches.isEmpty()) matches.add(0);
for (int i : matches)
result.add(
diagnostic(
source, "Bindings", p.severity(), p.message(), xml.source.tags.get(i).start()));
}
} catch (Exception e) {
return local(source);
}
return List.copyOf(result);
}
private static String property(Element e, String name) {
if (e.hasAttribute(name)) return e.getAttribute(name);
for (var n = e.getFirstChild(); n != null; n = n.getNextSibling())
if (n instanceof Element child && child.getTagName().equals(name))
return child.getTextContent().trim();
return "";
}
private static Diagnostic diagnostic(
DocumentSnapshot source, String operation, String severity, String message, int offset) {
return new Diagnostic(
operation,
Severity.valueOf(severity),
message,
Optional.of(new Location(source.document(), offset)));
}
static int offset(String text, int line, int column) {
int at = 0;
for (int n = 0; n < line && at < text.length(); n++) {
int next = text.indexOf('\n', at);
at = next < 0 ? text.length() : next + 1;
}
return Math.min(text.length(), at + Math.max(0, column));
}
}
@@ -0,0 +1,83 @@
package de.neitzel.fxdesigner.kit;
import static org.junit.jupiter.api.Assertions.*;
import de.neitzel.fxdesigner.contracts.DocumentSnapshot;
import de.neitzel.fxdesigner.protocol.BindingMetadata.Problem;
import java.net.URI;
import java.util.List;
import org.junit.jupiter.api.Test;
class FxmlDiagnosticsTest {
private DocumentSnapshot source(String xml) {
return new DocumentSnapshot(URI.create("file:/View.fxml"), 3, xml, true, true);
}
private static final String XML =
"<?import de.neitzel.fx.view.*?><VBox xmlns:fx='http://javafx.com/fxml/1'>\n"
+ "<fx:define><BindingList><!-- untouched --><DirectBinding control='field'"
+ " property='text' path='missing' direction='view'/><Repeat pane='rows'"
+ " collection='items' component='app.Row'/></BindingList></fx:define>\n"
+ "<Label fx:id='field'/><VBox fx:id='rows'/><Table fx:id='table' collection='items'"
+ " row='app.Row'><columns><Column header='N'"
+ " attribute='name'/></columns></Table></VBox>";
@Test
void locatesBindingsRepeatAndTableWithoutProjectionIdentities() {
var d = source(XML);
var result =
FxmlDiagnostics.target(
d,
List.of(
new Problem("field", "text", "ERROR", "bad path"),
new Problem("rows", "children", "WARNING", "unknown"),
new Problem("table", "columns", "ERROR", "bad column")));
assertEquals(
List.of(XML.indexOf("<DirectBinding"), XML.indexOf("<Repeat"), XML.indexOf("<Table")),
result.stream().map(p -> p.location().orElseThrow().offset()).toList());
assertTrue(
result.stream().allMatch(p -> p.location().orElseThrow().document().equals(d.document())));
}
@Test
void ambiguousTargetsIdentifyEveryDeclarationAndDeduplicateEvidence() {
String xml =
XML.replace(
"<!-- untouched -->",
"<ComputedBinding control='field' property='text' method='#value'/>");
var p = new Problem("field", "text", "ERROR", "duplicate");
var result = FxmlDiagnostics.target(source(xml), List.of(p, p));
assertEquals(2, result.size());
assertEquals(
xml.indexOf("<ComputedBinding"), result.getFirst().location().orElseThrow().offset());
}
@Test
void unknownOriginsFallBackToRootAndNestedTargetsAreLocated() {
String xml =
XML.replace(
"control='field' property='text' path='missing' direction='view'/>",
"path='missing'"
+ " direction='view'><control>field</control><property>text</property></DirectBinding>");
var result =
FxmlDiagnostics.target(
source(xml),
List.of(
new Problem("field", "text", "ERROR", "bad"),
new Problem("", "", "WARNING", "context unavailable")));
assertEquals(
xml.indexOf("<DirectBinding"), result.getFirst().location().orElseThrow().offset());
assertEquals(xml.indexOf("<VBox"), result.getLast().location().orElseThrow().offset());
}
@Test
void structuralErrorsAndMalformedXmlRemainNavigable() {
assertFalse(
FxmlDiagnostics.local(source(XML.replace("direction='view'", "direction='wrong'")))
.isEmpty());
var malformed = FxmlDiagnostics.local(source("<VBox>\n<Label>\n</VBox>"));
assertEquals(1, malformed.size());
assertTrue(malformed.getFirst().location().orElseThrow().offset() > 0);
assertTrue(FxmlDiagnostics.local(source(XML)).isEmpty());
}
}
@@ -691,6 +691,75 @@ public final class ProjectionHostChecks {
});
}
@SuppressWarnings("unchecked")
private static java.util.List<de.neitzel.fxdesigner.host.HostServices.Diagnostic> diagnostics(
DesignerPane pane) throws Exception {
return fx(
() ->
java.util.List.copyOf(
((ListView<de.neitzel.fxdesigner.host.HostServices.Diagnostic>)
pane.lookup("#diagnostics-list"))
.getItems()));
}
private static void diagnosticsEdits(DesignerPane pane, DocumentSession document, String full)
throws Exception {
String invalid = full.replace("path=\"rank\"", "path=\"diagnosticMissing\"");
set(document, invalid);
await(() -> fx(() -> source(pane).getText().equals(invalid)));
bindingTab(pane, "Problems");
click(pane, "Validate View");
await(
() -> diagnostics(pane).stream().anyMatch(d -> d.message().contains("diagnosticMissing")));
var problem =
diagnostics(pane).stream()
.filter(d -> d.message().contains("diagnosticMissing"))
.findFirst()
.orElseThrow();
int offset = problem.location().orElseThrow().offset();
check(
invalid.substring(offset).startsWith("<DirectBinding"),
"Diagnostic did not locate authoritative binding declaration");
fx(
() -> {
((ListView<de.neitzel.fxdesigner.host.HostServices.Diagnostic>)
pane.lookup("#diagnostics-list"))
.getSelectionModel()
.select(problem);
return null;
});
click(pane, "Go to FXML");
await(() -> fx(() -> source(pane).getCaretPosition() == offset));
check(documentText(document).equals(invalid), "Diagnostic navigation mutated source");
set(document, full);
await(() -> fx(() -> source(pane).getText().equals(full)));
await(
() -> diagnostics(pane).stream().noneMatch(d -> d.message().contains("diagnosticMissing")));
repeatHistory(pane, document, invalid, full);
bindingTab(pane, "Problems");
click(pane, "Validate View");
await(
() ->
fx(
() ->
((Label) pane.lookup("#diagnostics-state"))
.getText()
.startsWith("Current successful build")));
check(
diagnostics(pane).stream()
.noneMatch(d -> d.severity() == de.neitzel.fxdesigner.host.HostServices.Severity.ERROR),
"Corrected source retained error diagnostics");
set(document, "<VBox>\n<Label>\n</VBox>");
await(() -> diagnostics(pane).stream().anyMatch(d -> d.operation().equals("FXML")));
set(document, full);
await(() -> fx(() -> source(pane).getText().equals(full)));
await(() -> diagnostics(pane).isEmpty());
click(pane, "Split");
System.out.println(
"PASS shared diagnostics: authoritative binding location, source navigation, stale"
+ " withdrawal, correction, exact undo/redo and malformed FXML recovery");
}
private static void structureEdits(DesignerPane pane, DocumentSession document, String full)
throws Exception {
String base =
@@ -1708,6 +1777,7 @@ public final class ProjectionHostChecks {
tableDiscovery(pane, document, full);
tableEdits(pane, document, full);
structureEdits(pane, document, full);
diagnosticsEdits(pane, document, full);
document
.save(document.snapshot().toCompletableFuture().get().revision())
.toCompletableFuture()
@@ -75,6 +75,24 @@ public final class DesignerApplication extends Application {
}
}
private CompletableFuture<Void> navigate(
de.neitzel.fxdesigner.host.HostServices.Location location, boolean source) {
var result = new CompletableFuture<Void>();
javafx.application.Platform.runLater(
() -> {
try {
Path file = Path.of(location.document()).toRealPath();
open(new Stage(), file);
if (source)
((DesignerPane) editors.get(file).getScene().getRoot()).showSource(location.offset());
result.complete(null);
} catch (Exception e) {
result.completeExceptionally(e);
}
});
return result;
}
private void open(Stage stage, Path file) throws Exception {
Path identity = file.toRealPath();
var existing = editors.get(identity);
@@ -88,18 +106,16 @@ public final class DesignerApplication extends Application {
host =
new StandaloneHost(
launch.runtimes(),
location -> {
var future = new CompletableFuture<Void>();
javafx.application.Platform.runLater(
() -> {
try {
open(new Stage(), Path.of(location.document()));
future.complete(null);
} catch (Exception e) {
future.completeExceptionally(e);
}
});
return future;
new de.neitzel.fxdesigner.host.HostServices.Navigation() {
public java.util.concurrent.CompletionStage<Void> open(
de.neitzel.fxdesigner.host.HostServices.Location location) {
return navigate(location, false);
}
public java.util.concurrent.CompletionStage<Void> openSource(
de.neitzel.fxdesigner.host.HostServices.Location location) {
return navigate(location, true);
}
},
d -> System.err.println(d.message()));
var pane = new DesignerPane(host.services(), launch, file);
+4
View File
@@ -137,3 +137,7 @@ The [Table discovery slice](table-compatibility.md) adds collection/Row/Attribut
## Reference-safe rename, copy and delete
The [shared Structure editor](reference-safe-structure.md) adds source-preserving identifier rename, subtree copy/paste with fresh identifiers, associated binding cleanup on delete and destination validation for cross-View paste. Both hosts own each operation as one undo command. Simple FXML references are remapped; unfamiliar expressions and external Java references retain explicit boundaries. Generated metadata and Problems integration remain open.
## Shared diagnostics and IntelliJ Problems
The [shared diagnostics slice](shared-diagnostics.md) adds a View-wide Problems panel, source locations, stale-result withdrawal and native IntelliJ Problems publication/navigation. Validation stays shared and target-isolated; both hosts retain ordinary document Undo/Redo and invalid-draft saving. Generated metadata, richer Java/Row origins, source highlighting and quick fixes remain open.
+33
View File
@@ -0,0 +1,33 @@
# Shared diagnostics and IntelliJ Problems
Both hosts now provide **Bindings → Problems**. The shared list combines immediate FXML/structural checks with target-side Direct, Computed, Converter, Repeat and Table validation. **Validate View** validates the current draft against the selected successful build. Results include severity and a build/draft qualifier. **Go to FXML**, double-click or Enter opens the authoritative source at the affected declaration without editing the document or adding an undo command.
Target diagnostics match control/property pairs to binding declarations, Repeat panes to Repeat declarations and Table identities to Tables. Duplicate matching bindings expose each declaration. Unknown/aggregate origins fall back to the document root; malformed XML uses the parser's line/column. This does not guess a Java origin or a particular Column/Row field when metadata supplies only a Table-level location. Local structural checks currently expose document-level locations except XML syntax errors. No protocol change or application execution in the hosts is needed.
## Freshness and lifetime
Each open designer owns a replaceable diagnostic set. FXML edits, related captured-document changes and Build and Refresh withdraw previous target results. Local checks run on the new source; target results require validation/discovery or preview validation. The state label explicitly says when target validation is outdated, pending or unavailable. An empty list before validation is not a successful type check. Explicit last-successful-build validation retains an outdated-build label.
Existing request tickets, source revisions and captured related-draft checks reject obsolete target replies. Navigation checks the source revision again before using an offset. Corrections and Undo/Redo replace local diagnostics and invalidate target evidence. Save remains allowed for invalid drafts; existing preview gates remain unchanged. An editor closing withdraws only its own published diagnostics. The list includes validation/operation failures with a document fallback location; it is not a persistent build-console log or project-wide scan of unopened Views.
## IntelliJ adapter
The IntelliJ adapter publishes the same locations and messages through the native `ProblemsCollector` as `FileProblem` entries grouped under **Neitzel FX Designer**. Severity is exposed in the text and icon. The collector feeds the Problems tool window's Project Errors view; this slice does not install a PSI annotator or source squiggles in Current File. Native file/line/column navigation opens the FXML text editor. The shared panel still uses the shared Source mode in either host.
The adapter checks the current IDE Document text before publishing queued results, removes superseded entries rather than appending duplicates, isolates editor ownership and withdraws entries on close/project disposal. IDE operations use the EDT with read access; target validation remains outside the IDE process. Host navigation now distinguishes source navigation from Component designer navigation, and standalone source navigation honors offsets in existing or newly opened editors.
The pinned IntelliJ build is **263.7307.200**. Its bundled `com.intellij.problemsView` plugin supplies the public `intellij.platform.problemView.shared` API; the designer declares that dependency rather than packaging IDE classes. The qualification companion declares it too. See JetBrains' [2026 API changes](https://plugins.jetbrains.com/docs/intellij/api-changes-list-2026.html) for the module rename and [Problems tool window](https://www.jetbrains.com/help/idea/problems-tool-window.html) for native navigation behavior. Integration is qualified against this SDK, not all IDE versions.
## Automated acceptance
Unit tests cover FXML origins, nested bindings, duplicate matching declarations, severity preservation, root fallbacks and malformed XML. The IDE adapter regression checks replacement, independent owners, source mismatch rejection and close/late-result cleanup through the real collector. The shared live scenario checks validation, exact source navigation, stale withdrawal, correction with Undo/Redo and invalid-source recovery in both native standalone and registered IntelliJ launch modes. The registered companion additionally checks native Problems publication, source navigation using collector file/line/column, Undo/Redo invalidation and registered-editor close cleanup.
Run the bootstrap and registered IDE checks in the [README](../README.md). No manual tests are required for this slice. Previously skipped manual checks remain skipped. Generated metadata, precise Java/Row source origins, source highlighting, quick fixes and broader project-wide diagnostics remain subsequent work.
### Qualification — 2026-11-13
The full bootstrap passed: 131 unit/adapter tests (123 Maven, eight IntelliJ), 36 target processes with 512 fixture checks, 48 lifecycle cycles, both native standalone modes, connected adapters and packaging/documentation checks. A subsequent regression exposed equal Problems entries from two owners collapsing in the collector. Including editor ownership in entry identity fixed it; the strengthened regression, all eight adapter tests and final plugin packaging passed again. The final plugin passed registered IntelliJ classpath and module-path qualification, including the shared panel, native Problems origins/source navigation, Undo/Redo invalidation, editor-close cleanup, exclusive JavaFX 26 natives and worker shutdown. No manual testing was used.
Two earlier IDE attempts timed out at initial preview, before the diagnostics scenario. An unchanged production build subsequently progressed; the pre-existing startup-render reliability follow-up remains open. Temporary startup instrumentation was removed. The native navigation test was corrected to assert the affected declaration rather than the first binding in the document, and to observe the shared editor's Undo state before issuing Redo. Exact source/caret and cleanup assertions remain required. Earlier persistence-check reliability work also remains open; this slice does not claim to fix it.
Local evidence: `/tmp/diagnostics-bootstrap.log`, `/tmp/diagnostics-identical-owner-red.log`, `/tmp/diagnostics-ide-owner-final.log`, `/tmp/diagnostics-qualified-ide.log`, `target/connected-workflow/` and `target/ide-workflow/`. Earlier startup/test diagnostics remain in `/tmp/diagnostics-registered-ide*.log` and `/tmp/diagnostics-startup-probe.log`. Generated qualification artifacts are not repository inputs.
+150
View File
@@ -82,6 +82,155 @@ public final class WorkflowProbe implements StartupActivity.DumbAware {
});
}
private static void checkProblems(
Project project,
VirtualFile file,
DesignerPane pane,
de.neitzel.fxdesigner.host.DocumentSession document)
throws Exception {
var original = document.snapshot().toCompletableFuture().get();
String invalid = original.text().replace("path=\"home\"", "path=\"problemMissing\"");
if (invalid.equals(original.text())) throw new AssertionError("Missing fixture binding");
document
.apply(
new de.neitzel.fxdesigner.contracts.EditBatch(
original.revision(),
"Problem fixture",
java.util.List.of(
new de.neitzel.fxdesigner.contracts.EditBatch.Replacement(
0, original.text().length(), invalid))))
.toCompletableFuture()
.get();
await(
() ->
fx(
() ->
pane.lookupAll(".text-area").stream()
.anyMatch(
n ->
n instanceof javafx.scene.control.TextArea t
&& t.getText().equals(invalid))));
fx(
() -> {
pane.lookupAll(".titled-pane").stream()
.filter(
n ->
n instanceof javafx.scene.control.TitledPane t
&& "Bindings".equals(t.getText()))
.forEach(n -> ((javafx.scene.control.TitledPane) n).setExpanded(true));
pane.lookupAll(".tab-pane").stream()
.filter(n -> n instanceof javafx.scene.control.TabPane)
.map(n -> (javafx.scene.control.TabPane) n)
.forEach(
t ->
t.getTabs().stream()
.filter(tab -> "Problems".equals(tab.getText()))
.findFirst()
.ifPresent(tab -> t.getSelectionModel().select(tab)));
return null;
});
click(pane, "Validate View");
var collector = com.intellij.analysis.problemsView.ProblemsCollector.getInstance(project);
await(
() ->
edt(
() ->
collector.getFileProblems(file).stream()
.anyMatch(p -> p.getText().contains("problemMissing"))));
var problem =
edt(
() ->
(com.intellij.analysis.problemsView.FileProblem)
collector.getFileProblems(file).stream()
.filter(p -> p.getText().contains("problemMissing"))
.findFirst()
.orElseThrow());
int expectedOffset =
edt(
() -> {
var d = FileDocumentManager.getInstance().getDocument(file);
return d.getLineStartOffset(problem.getLine()) + problem.getColumn();
});
String declaration =
invalid.substring(expectedOffset, invalid.indexOf('>', expectedOffset) + 1);
if (!declaration.startsWith("<DirectBinding")
|| !declaration.contains("path=\"problemMissing\""))
throw new AssertionError(
"Problems origin did not locate an affected binding: " + declaration);
edt(
() -> {
var window =
com.intellij.openapi.wm.ToolWindowManager.getInstance(project)
.getToolWindow("Problems View");
if (window == null) throw new AssertionError("Native Problems tool window missing");
window.show();
new OpenFileDescriptor(project, problem.getFile(), problem.getLine(), problem.getColumn())
.navigate(true);
return null;
});
await(
() ->
edt(
() -> {
var textEditor = FileEditorManager.getInstance(project).getSelectedTextEditor();
return textEditor != null
&& textEditor.getCaretModel().getOffset() == expectedOffset;
}));
if (!document.snapshot().toCompletableFuture().get().text().equals(invalid))
throw new AssertionError("Problems navigation mutated source");
document.undo().toCompletableFuture().get();
await(
() ->
edt(
() ->
collector.getFileProblems(file).stream()
.noneMatch(p -> p.getText().contains("problemMissing"))));
if (!document.snapshot().toCompletableFuture().get().text().equals(original.text()))
throw new AssertionError("Problems navigation changed undo history");
// Observe the shared editor's Undo revision before issuing Redo; its previous invalid
// text must not satisfy the Redo wait while the Undo notification is still queued.
await(
() ->
fx(
() ->
pane.lookupAll(".text-area").stream()
.anyMatch(
n ->
n instanceof javafx.scene.control.TextArea t
&& t.getText().equals(original.text()))));
document.redo().toCompletableFuture().get();
await(
() ->
fx(
() ->
pane.lookupAll(".text-area").stream()
.anyMatch(
n ->
n instanceof javafx.scene.control.TextArea t
&& t.getText().equals(invalid))));
click(pane, "Validate View");
await(
() ->
edt(
() ->
collector.getFileProblems(file).stream()
.anyMatch(p -> p.getText().contains("problemMissing"))));
edt(
() -> {
FileEditorManager.getInstance(project).closeFile(file);
return null;
});
await(
() ->
edt(
() ->
collector.getFileProblems(file).stream()
.noneMatch(p -> "Neitzel FX Designer".equals(p.getGroup()))));
System.out.println(
"PASS native Problems: current validation, FXML origin/navigation, correction via Undo,"
+ " redo validation and editor-close cleanup");
}
public void runActivity(Project project) {
if (!started.compareAndSet(false, true)) return;
ApplicationManager.getApplication()
@@ -238,6 +387,7 @@ public final class WorkflowProbe implements StartupActivity.DumbAware {
try (var document =
pane.kit().host().documents().open(path.toUri()).toCompletableFuture().get()) {
de.neitzel.fxdesigner.kit.ProjectionHostChecks.run(pane, document);
checkProblems(project, file, pane, document);
}
String version = fx(() -> System.getProperty("javafx.runtime.version"));
if (!version.startsWith("26"))
+2 -2
View File
@@ -43,12 +43,12 @@ else:
checks = base / "plugins/qualification/lib/projection-checks.jar"
checks.parent.mkdir(parents=True)
shutil.copyfile(ROOT / "designer-kit-ui/target/designer-kit-ui-0.1.0-SNAPSHOT-tests.jar", checks)
jars = [*(args.idea_home / "lib").glob("*.jar"), *(base / "plugins").rglob("*.jar")]
jars = [*(args.idea_home / "lib").glob("*.jar"), *(base / "plugins").rglob("*.jar"), *(args.idea_home / "plugins/platform-problemView-plugin").rglob("*.jar")]
subprocess.run([str(args.java_home / "bin/javac"), "-cp", os.pathsep.join(map(str,jars)), "-d", str(classes),
str(ROOT / "integration-tests/idea/WorkflowProbe.java")], check=True)
meta = classes / "META-INF"
meta.mkdir()
(meta / "plugin.xml").write_text('<idea-plugin><id>de.neitzel.fxdesigner.qualification</id><name>Designer workflow qualification</name><version>1</version><vendor>Local tests</vendor><idea-version since-build="263" until-build="263.*"/><depends>com.intellij.modules.platform</depends><depends>de.neitzel.fxdesigner</depends><extensions defaultExtensionNs="com.intellij"><postStartupActivity implementation="qualification.WorkflowProbe"/></extensions></idea-plugin>')
(meta / "plugin.xml").write_text('<idea-plugin><id>de.neitzel.fxdesigner.qualification</id><name>Designer workflow qualification</name><version>1</version><vendor>Local tests</vendor><idea-version since-build="263" until-build="263.*"/><depends>com.intellij.modules.platform</depends><depends>de.neitzel.fxdesigner</depends><depends>com.intellij.problemsView</depends><extensions defaultExtensionNs="com.intellij"><postStartupActivity implementation="qualification.WorkflowProbe"/></extensions></idea-plugin>')
jar = base / "plugins/qualification/lib/qualification.jar"
jar.parent.mkdir(parents=True, exist_ok=True)
subprocess.run([str(args.java_home / "bin/jar"), "cf", str(jar), "-C", str(classes), "."], check=True)