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
@@ -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()