Add reference-safe FXML rename, subtree copy and delete

This commit is contained in:
2026-11-13 16:13:03 +01:00
parent 5462f480a4
commit 910a8b0e85
13 changed files with 1191 additions and 7 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). Broader discovery, generation, structural editing 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. 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.
@@ -46,6 +46,7 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
if (computedEditor != null) computedEditor.selectControl(id);
if (repeatEditor != null) repeatEditor.selectControl(id);
if (tableEditor != null) tableEditor.selectControl(id);
if (referenceEditor != null) referenceEditor.selectIndex(projection.sourceIndex(id));
if (id == null || !projection.components().containsKey(id)) return;
componentInspector.getChildren().add(new Label(projection.components().get(id) + " · " + id));
componentInspector
@@ -119,6 +120,8 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
private BindingBrowser bindingBrowser;
private BindingEditor bindingEditor, computedEditor, repeatEditor;
private TableEditor tableEditor;
private ReferenceEditor referenceEditor;
private String pendingReferenceValidation;
private String validatedSource, validatingSource;
private boolean validationPreview;
private java.util.List<de.neitzel.fxdesigner.protocol.BindingMetadata.Problem> bindingProblems =
@@ -216,6 +219,7 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
tableEditor =
new TableEditor(
this::editTable, this::navigateComponent, values -> validateBindings(values, false));
referenceEditor = new ReferenceEditor(file.toUri().toString(), this::editStructure);
bindingBrowser =
new BindingBrowser(
this::discoverBindings,
@@ -368,10 +372,13 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
repeatTab.setClosable(false);
var tableTab = new Tab("Table / Columns", tableEditor);
tableTab.setClosable(false);
var structureTab = new Tab("Structure", referenceEditor);
structureTab.setClosable(false);
var bindings =
new TitledPane(
"Bindings",
new TabPane(directBindingTab, computedTab, repeatTab, tableTab, discoveryTab));
new TabPane(
directBindingTab, computedTab, repeatTab, tableTab, structureTab, discoveryTab));
bindings.setExpanded(false);
center.setBottom(new VBox(properties, bindings));
output.setEditable(false);
@@ -515,6 +522,7 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
computedEditor.load(value.text(), value.writable());
repeatEditor.load(value.text(), value.writable());
tableEditor.load(value.text(), value.writable());
referenceEditor.load(value.text(), value.writable());
submittedSource = null;
commitSource();
return;
@@ -540,6 +548,7 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
computedEditor.load(value.text(), value.writable());
repeatEditor.load(value.text(), value.writable());
tableEditor.load(value.text(), value.writable());
referenceEditor.load(value.text(), value.writable());
if (!source.getText().equals(value.text())) {
int anchor = source.getAnchor(), caret = source.getCaretPosition();
var edits =
@@ -605,6 +614,10 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
report(error);
} finally {
loading = false;
if (value.text().equals(pendingReferenceValidation)) {
pendingReferenceValidation = null;
Platform.runLater(() -> validateBindings(null, false));
}
}
}
@@ -696,6 +709,22 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
tableEditor.invalidateDiscovery();
}
private void editStructure(ReferenceEditor.Intent intent) {
withDraft(
() -> {
try {
if (!draft.writable()) throw new IllegalStateException("Document is read-only");
if (!draft.text().equals(intent.source()))
throw new IllegalStateException("Document changed; select the source object again");
if (intent.validate())
pendingReferenceValidation = apply(draft.text(), intent.change().edits());
submit(intent.change().edits(), intent.label());
} catch (Exception e) {
referenceEditor.error(e.getMessage());
}
});
}
private void editTable(TableEditor.Intent intent) {
withDraft(
() -> {
@@ -742,6 +771,7 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
try {
var errors = new ArrayList<>(new BindingDocument(draft.text()).errors());
errors.addAll(new TableDocument(draft.text()).errors());
errors.addAll(new ReferenceDocument(draft.text()).referenceErrors());
if (errors.isEmpty()) return true;
status.setText("Preview blocked: " + String.join("; ", errors));
if (projection != null)
@@ -92,6 +92,10 @@ public final class FxmlProjection {
return w.toString();
}
Integer sourceIndex(String id) {
return sourceNodes.get(id);
}
public Map<String, String> components() {
return Map.copyOf(components);
}
@@ -278,6 +282,14 @@ public final class FxmlProjection {
return result;
}
private static List<Element> objectElements(Document document) {
var result = new ArrayList<Element>();
var all = document.getElementsByTagName("*");
for (int i = 0; i < all.getLength(); i++)
if (isObject((Element) all.item(i))) result.add((Element) all.item(i));
return result;
}
private static boolean isObject(Element e) {
String tag = e.getTagName();
return !FX.equals(e.getNamespaceURI())
@@ -292,10 +304,38 @@ public final class FxmlProjection {
String projected) throws Exception {
var changed = parse(projected);
var before = indexed(baseline);
var after = indexed(changed);
var result = new ArrayList<de.neitzel.fxdesigner.contracts.EditBatch.Replacement>();
// Kit's identifier inspector can propose one rename. Normalize only for comparison;
// the authoritative document and its binding references are edited together below.
var oldObjects = objectElements(baseline);
var newObjects = objectElements(changed);
if (oldObjects.size() == newObjects.size()) {
int renamed = -1;
for (int i = 0; i < oldObjects.size(); i++)
if (!oldObjects
.get(i)
.getAttributeNS(FX, "id")
.equals(newObjects.get(i).getAttributeNS(FX, "id"))) {
if (renamed >= 0)
throw new IOException("Rename one identifier at a time through Structure");
renamed = i;
}
if (renamed >= 0) {
var old = oldObjects.get(renamed);
var next = newObjects.get(renamed);
String id = old.getAttributeNS(FX, "id");
if (!sourceNodes.containsKey(id)) throw new IOException("Unknown source identity");
result.addAll(
new ReferenceDocument(source.text)
.rename(sourceNodes.get(id), next.getAttributeNS(FX, "id"))
.edits());
next.setAttributeNS(FX, "fx:id", id);
}
}
var after = indexed(changed);
if (!before.keySet().equals(after.keySet()) || !structure(baseline).equals(structure(changed)))
throw new IOException("Structural editing is outside this minimal projection");
throw new IOException(
"Use Bindings → Structure for reference-safe rename, copy, paste or delete");
for (String id : before.keySet()) {
var attributes = new HashSet<String>();
for (Element element : List.of(before.get(id), after.get(id)))
@@ -0,0 +1,527 @@
package de.neitzel.fxdesigner.kit;
import de.neitzel.fxdesigner.contracts.EditBatch.Replacement;
import java.util.*;
import java.util.regex.*;
import org.w3c.dom.*;
/** Lossless structural transactions in one FXML identifier scope. */
final class ReferenceDocument {
static final String FX = BindingDocument.FX;
record Entry(int index, String id, String type) {
public String toString() {
return (id.isEmpty() ? "(anonymous #" + index + ")" : id) + " : " + type;
}
}
record Clip(String source, int index, String origin) {}
record Change(List<Replacement> edits, List<String> notes) {}
private record Target(Element binding, Element element, String attribute, String value) {}
final BindingDocument xml;
final List<Entry> entries = new ArrayList<>();
private final Map<String, Element> ids = new LinkedHashMap<>();
private final List<Target> targets = new ArrayList<>();
ReferenceDocument(String text) throws Exception {
xml = new BindingDocument(text);
for (int i = 0; i < xml.elements.size(); i++) {
var e = xml.elements.get(i);
String id = e.getAttributeNS(FX, "id");
if (!id.isEmpty() && ids.putIfAbsent(id, e) != null)
throw new IllegalArgumentException("Ambiguous fx:id: " + id);
if (!defined(e) && (object(e) || e == xml.document.getDocumentElement()))
entries.add(new Entry(i, id, e.getTagName()));
if (binding(e)) {
String name = xml.named(e, "Repeat") ? "pane" : "control";
var nested = children(e).stream().filter(c -> c.getTagName().equals(name)).toList();
if (e.hasAttribute(name) && nested.isEmpty())
targets.add(new Target(e, e, name, e.getAttribute(name)));
else if (!e.hasAttribute(name)
&& nested.size() == 1
&& children(nested.getFirst()).isEmpty())
targets.add(
new Target(e, nested.getFirst(), null, nested.getFirst().getTextContent().trim()));
else
throw new IllegalArgumentException(
"Ambiguous or unsupported binding target; edit in Source");
}
}
}
private boolean binding(Element e) {
if (!(xml.named(e, "DirectBinding")
|| xml.named(e, "ComputedBinding")
|| xml.named(e, "Repeat"))) return false;
Node parent = e.getParentNode();
if (parent instanceof Element p && p.getTagName().equals("elements"))
parent = p.getParentNode();
return parent instanceof Element list
&& xml.named(list, "BindingList")
&& list.getParentNode() instanceof Element define
&& FX.equals(define.getNamespaceURI())
&& "define".equals(define.getLocalName())
&& define.getParentNode() == xml.document.getDocumentElement();
}
private static boolean object(Element e) {
String n = e.getTagName();
return !FX.equals(e.getNamespaceURI())
&& Character.isUpperCase(n.charAt(n.lastIndexOf('.') + 1));
}
private static boolean defined(Element e) {
for (Node n = e; n instanceof Element p; n = n.getParentNode())
if (FX.equals(p.getNamespaceURI()) && "define".equals(p.getLocalName())) return true;
return false;
}
private static List<Element> children(Element e) {
var result = new ArrayList<Element>();
for (Node n = e.getFirstChild(); n != null; n = n.getNextSibling())
if (n instanceof Element c) result.add(c);
return result;
}
private static boolean within(Element e, Element ancestor) {
for (Node n = e; n != null; n = n.getParentNode()) if (n == ancestor) return true;
return false;
}
private Element entry(int index) {
if (entries.stream().noneMatch(e -> e.index() == index))
throw new IllegalArgumentException("Select a source object again");
return xml.elements.get(index);
}
private XmlSource.Tag tag(Element e) {
return xml.source.tags.get(xml.elements.indexOf(e));
}
private List<Replacement> attributes(Element e, Map<String, String> values) {
return xml.source.attributes(xml.elements.indexOf(e), values);
}
private Replacement text(Target t, String value) {
var tag = tag(t.element());
String body = xml.text.substring(tag.openEnd(), tag.closeStart());
var matcher = Pattern.compile("<!--.*?-->|[^<]+", Pattern.DOTALL).matcher(body);
int start = -1, end = -1, consumed = 0;
while (matcher.find()) {
if (matcher.start() != consumed)
throw new IllegalArgumentException("Unsupported nested target syntax; edit in Source");
consumed = matcher.end();
if (matcher.group().startsWith("<!--") || matcher.group().isBlank()) continue;
if (start >= 0)
throw new IllegalArgumentException("Split nested target text; edit in Source");
start = matcher.start();
end = matcher.end();
while (start < end && Character.isWhitespace(body.charAt(start))) start++;
while (end > start && Character.isWhitespace(body.charAt(end - 1))) end--;
}
if (consumed != body.length() || start < 0)
throw new IllegalArgumentException("Unsupported nested target syntax; edit in Source");
return new Replacement(
tag.openEnd() + start, tag.openEnd() + end, XmlSource.escape(value, '"'));
}
private List<String> notes() {
return new ArrayList<>(
List.of(
"Java Controller fields and unfamiliar declarations are not refactored; review external"
+ " references."));
}
Change rename(int index, String name) {
Element e = entry(index);
String old = e.getAttributeNS(FX, "id");
if (!name.matches("[\\p{javaJavaIdentifierStart}][\\p{javaJavaIdentifierPart}]*"))
throw new IllegalArgumentException("Enter a valid fx:id");
if (old.equals(name)) return new Change(List.of(), List.of());
if (ids.containsKey(name)) throw new IllegalArgumentException("fx:id already exists: " + name);
var edits = new ArrayList<Replacement>();
String attr =
e.hasAttributeNS(FX, "id") ? e.getAttributeNodeNS(FX, "id").getName() : fxPrefix(e) + ":id";
var attrs = new LinkedHashMap<String, String>();
attrs.put(attr, name);
String prefix = attr.substring(0, attr.indexOf(':'));
if (!FX.equals(e.lookupNamespaceURI(prefix))) attrs.put("xmlns:" + prefix, FX);
edits.addAll(attributes(e, attrs));
if (!old.isEmpty()) {
var unknown = unknownReferences(Set.of(old));
if (!unknown.isEmpty()) throw new IllegalArgumentException(String.join("; ", unknown));
edits.addAll(references(Map.of(old, name), null));
}
return new Change(List.copyOf(edits), notes());
}
Change delete(int index) {
Element selected = entry(index);
if (selected == xml.document.getDocumentElement())
throw new IllegalArgumentException("Cannot delete the View root");
var removedIds = new HashSet<String>();
ids.forEach(
(id, e) -> {
if (within(e, selected)) removedIds.add(id);
});
var remove = new LinkedHashSet<Element>();
remove.add(selected);
for (var t : targets)
if (removedIds.contains(t.value()) && !within(t.binding(), selected)) remove.add(t.binding());
var edits = new ArrayList<Replacement>();
for (var e : remove) {
var t = tag(e);
edits.add(new Replacement(t.start(), t.end(), ""));
}
var notes = notes();
notes.addAll(unknownReferences(removedIds));
notes.add(
"Removed "
+ (remove.size() - 1)
+ " associated binding declaration(s). Other dependencies remain visible for"
+ " validation.");
return new Change(List.copyOf(edits), notes);
}
Clip copy(int index, String origin) {
if (entry(index) == xml.document.getDocumentElement())
throw new IllegalArgumentException("Copy a subtree rather than the View root");
var root = entry(index);
var copiedIds = new HashSet<String>();
ids.forEach(
(id, e) -> {
if (within(e, root)) copiedIds.add(id);
});
var unknown = unknownReferences(copiedIds);
if (!unknown.isEmpty()) throw new IllegalArgumentException(String.join("; ", unknown));
if (xml.elements.stream()
.anyMatch(
e ->
within(e, root)
&& FX.equals(e.getNamespaceURI())
&& Set.of("include", "script").contains(e.getLocalName())))
throw new IllegalArgumentException(
"Subtrees with includes or scripts require Source editing");
return new Clip(xml.text, index, origin);
}
Change paste(Clip clip, int destination, String origin) throws Exception {
if (clip == null) throw new IllegalArgumentException("Copy a subtree first");
Element parent = entry(destination);
requireContainer(parent);
var from = new ReferenceDocument(clip.source());
Element selected = from.entry(clip.index());
if (!clip.origin().equals(origin))
for (var e : from.xml.elements)
if (within(e, selected))
for (int a = 0; a < e.getAttributes().getLength(); a++)
if (e.getAttributes().item(a).getNodeValue().startsWith("@"))
throw new IllegalArgumentException(
"Relative resource references require Source review for cross-View paste");
var mapping = new LinkedHashMap<String, String>();
var reserved = new HashSet<>(ids.keySet());
for (var item : from.ids.entrySet())
if (within(item.getValue(), selected)) {
String base = item.getKey() + "Copy", next = base;
int n = 2;
while (reserved.contains(next)) next = base + (n++);
reserved.add(next);
mapping.put(item.getKey(), next);
}
var edits = new ArrayList<Replacement>();
for (var id : mapping.entrySet()) {
var e = from.ids.get(id.getKey());
edits.addAll(
from.attributes(e, Map.of(e.getAttributeNodeNS(FX, "id").getName(), id.getValue())));
}
// Only references carried by the copied subtree/declarations are rewritten.
var associated =
from.targets.stream()
.filter(t -> mapping.containsKey(t.value()) && !within(t.binding(), selected))
.map(Target::binding)
.distinct()
.toList();
var carried = new ArrayList<Element>(associated);
carried.add(selected);
edits.addAll(from.references(mapping, carried));
for (var e : carried) {
var ns = new LinkedHashMap<String, String>();
for (Node n = e; n instanceof Element a; n = n.getParentNode())
for (int i = 0; i < a.getAttributes().getLength(); i++) {
var attr = a.getAttributes().item(i);
String key = attr.getNodeName();
if (key.equals("xmlns") || key.startsWith("xmlns:"))
ns.putIfAbsent(key, attr.getNodeValue());
}
ns.entrySet()
.removeIf(
v ->
Objects.equals(
v.getValue(),
parent.lookupNamespaceURI(
v.getKey().equals("xmlns") ? null : v.getKey().substring(6)))
|| e.hasAttribute(v.getKey()));
edits.addAll(from.attributes(e, ns));
}
String fragment = from.fragment(selected, edits);
String declarations =
associated.stream()
.map(e -> from.fragment(e, edits))
.collect(java.util.stream.Collectors.joining("\n"));
Element container =
children(parent).stream()
.filter(c -> c.getTagName().equals("children"))
.findFirst()
.orElse(parent);
String result = xml.source.apply(List.of(xml.append(container, fragment, "")));
var out = new ReferenceDocument(result);
if (!declarations.isEmpty()) result = out.appendBindings(declarations);
result = imports(from, result);
var notes = notes();
if (!clip.origin().equals(origin)) {
notes.add(
"Cross-View paste: validate copied bindings against the destination Model and"
+ " Controller.");
if (!model(from.xml.document).equals(model(xml.document)))
notes.add(
"Source and destination Model declarations differ; copied Model paths are unresolved"
+ " until destination validation.");
if (!from.xml
.document
.getDocumentElement()
.getAttributeNS(FX, "controller")
.equals(xml.document.getDocumentElement().getAttributeNS(FX, "controller")))
notes.add(
"Source and destination Controllers differ; copied methods require destination"
+ " validation.");
}
notes.add("Assigned identifiers: " + mapping);
return new Change(difference(xml.text, result), List.copyOf(notes));
}
private String fragment(Element e, List<Replacement> edits) {
var t = tag(e);
var local =
edits.stream()
.filter(r -> r.start() >= t.start() && r.end() <= t.end())
.map(r -> new Replacement(r.start() - t.start(), r.end() - t.start(), r.text()))
.toList();
var sorted = new ArrayList<>(local);
sorted.sort(Comparator.comparingInt(Replacement::start).reversed());
var result = new StringBuilder(xml.text.substring(t.start(), t.end()));
for (var r : sorted) result.replace(r.start(), r.end(), r.text());
return result.toString();
}
private List<Replacement> references(Map<String, String> mapping, List<Element> carried) {
var edits = new ArrayList<Replacement>();
for (var t : targets)
if (mapping.containsKey(t.value())
&& (carried == null || carried.stream().anyMatch(e -> within(t.binding(), e)))) {
String next = mapping.get(t.value());
if (t.attribute() == null) edits.add(text(t, next));
else edits.addAll(attributes(t.element(), Map.of(t.attribute(), next)));
}
for (var e : xml.elements) {
if (carried != null && carried.stream().noneMatch(a -> within(e, a))) continue;
if (FX.equals(e.getNamespaceURI()) && "script".equals(e.getLocalName()))
throw new IllegalArgumentException(
"Script references require Source review before refactoring");
var attrs = new LinkedHashMap<String, String>();
for (int i = 0; i < e.getAttributes().getLength(); i++) {
var a = e.getAttributes().item(i);
String name = a.getNodeName(), value = a.getNodeValue();
if ((FX.equals(e.getNamespaceURI())
&& Set.of("reference", "copy").contains(e.getLocalName())
&& name.equals("source"))) {
if (mapping.containsKey(value)) attrs.put(name, mapping.get(value));
} else if (value.startsWith("$") && !FX.equals(a.getNamespaceURI())) {
String key = variable(value);
if (key != null && mapping.containsKey(key))
attrs.put(
name,
value.startsWith("${")
? "${" + mapping.get(key) + value.substring(2 + key.length())
: "$" + mapping.get(key));
else if (key == null
&& mapping.keySet().stream()
.anyMatch(
k ->
Pattern.compile("(?<![\\w$])" + Pattern.quote(k) + "(?![\\w$])")
.matcher(value)
.find()))
throw new IllegalArgumentException(
"Complex expression references require Source review: " + name);
}
}
if (!attrs.isEmpty()) edits.addAll(attributes(e, attrs));
}
return edits;
}
private List<String> unknownReferences(Set<String> names) {
var notes = new ArrayList<String>();
for (var e : xml.elements)
if (targets.stream().noneMatch(t -> t.binding() == e))
for (String attr : List.of("control", "pane", "target"))
if (names.contains(e.getAttribute(attr)))
notes.add(
"Unsupported reference on " + e.getTagName() + "." + attr + "; review in Source");
return notes;
}
List<String> referenceErrors() {
var result = new ArrayList<String>();
var available = new HashSet<>(ids.keySet());
available.addAll(Set.of("controller", "resources", "location"));
for (var e : xml.elements)
for (int i = 0; i < e.getAttributes().getLength(); i++) {
var a = e.getAttributes().item(i);
if (FX.equals(a.getNamespaceURI()) || a.getNodeName().startsWith("xmlns")) continue;
String id = variable(a.getNodeValue());
if (FX.equals(e.getNamespaceURI())
&& Set.of("reference", "copy").contains(e.getLocalName())
&& a.getNodeName().equals("source")) id = a.getNodeValue();
if (id != null && !available.contains(id)) result.add("Unresolved FXML reference: " + id);
}
return List.copyOf(result);
}
private static String variable(String value) {
if (value.matches("\\$[\\p{javaJavaIdentifierStart}][\\p{javaJavaIdentifierPart}]*"))
return value.substring(1);
if (value.matches(
"\\$\\{[\\p{javaJavaIdentifierStart}][\\p{javaJavaIdentifierPart}]*(\\.[\\p{javaJavaIdentifierStart}][\\p{javaJavaIdentifierPart}]*)*}"))
return value.substring(2, value.indexOf('.') > 0 ? value.indexOf('.') : value.length() - 1);
return null;
}
private static String fxPrefix(Element root) {
String p = root.lookupPrefix(FX);
if (p != null) return p;
p = "fx";
for (int n = 2; root.lookupNamespaceURI(p) != null; n++) p = "fx" + n;
return p;
}
private void requireContainer(Element e) {
String name = e.getTagName();
if (FX.equals(e.getNamespaceURI()) && "root".equals(e.getLocalName()))
name = e.getAttribute("type");
String simple = name.substring(name.lastIndexOf('.') + 1);
if (!Set.of(
"VBox",
"HBox",
"Pane",
"AnchorPane",
"StackPane",
"FlowPane",
"TilePane",
"GridPane")
.contains(simple)
|| name.contains(".") && !name.startsWith("javafx.scene.layout."))
throw new IllegalArgumentException(
"Paste into a standard Pane; other child properties require Source editing");
if (children(e).stream().filter(c -> c.getTagName().equals("children")).count() > 1)
throw new IllegalArgumentException("Ambiguous children containers");
}
private String appendBindings(String body) throws Exception {
Element root = xml.document.getDocumentElement();
Element define =
children(root).stream()
.filter(e -> FX.equals(e.getNamespaceURI()) && "define".equals(e.getLocalName()))
.findFirst()
.orElse(null);
if (define != null) {
var list =
children(define).stream()
.filter(e -> xml.named(e, "BindingList"))
.findFirst()
.orElse(null);
if (list != null) {
var container =
children(list).stream()
.filter(e -> e.getTagName().equals("elements"))
.findFirst()
.orElse(list);
return xml.source.apply(List.of(xml.append(container, body, "")));
}
return xml.source.apply(
List.of(
xml.append(
define,
"<de.neitzel.fx.view.BindingList>" + body + "</de.neitzel.fx.view.BindingList>",
"")));
}
String p = fxPrefix(root);
String wrapped =
"<"
+ p
+ ":define xmlns:"
+ p
+ "=\""
+ FX
+ "\"><de.neitzel.fx.view.BindingList>"
+ body
+ "</de.neitzel.fx.view.BindingList></"
+ p
+ ":define>";
// Definitions precede controls, as in a hand-authored View.
var tag = tag(root);
return xml.source.apply(
List.of(new Replacement(tag.openEnd(), tag.openEnd(), "\n" + wrapped + "\n")));
}
private static List<String> imports(Document d) {
var result = new ArrayList<String>();
for (Node n = d.getFirstChild(); n != null; n = n.getNextSibling())
if (n instanceof ProcessingInstruction p && p.getTarget().equals("import"))
result.add(p.getData().trim());
return result;
}
private String imports(ReferenceDocument from, String result) {
var ours = imports(xml.document);
var missing = imports(from.xml.document).stream().filter(i -> !ours.contains(i)).toList();
for (String add : missing)
for (String present : ours)
if ((add.endsWith(".*") || present.endsWith(".*"))
|| add.substring(add.lastIndexOf('.') + 1)
.equals(present.substring(present.lastIndexOf('.') + 1)))
throw new IllegalArgumentException(
"Conflicting or wildcard imports need explicit Source resolution before paste");
int at = result.startsWith("<?xml") ? result.indexOf("?>") + 2 : 0;
String newline = xml.text.contains("\r\n") ? "\r\n" : "\n";
String additions =
missing.stream()
.map(i -> "<?import " + i + "?>" + newline)
.collect(java.util.stream.Collectors.joining());
return result.substring(0, at)
+ (at > 0 && !additions.isEmpty() ? newline : "")
+ additions
+ result.substring(at);
}
private static String model(Document d) {
for (Node n = d.getFirstChild(); n != null; n = n.getNextSibling())
if (n instanceof ProcessingInstruction p && p.getTarget().equals("neitzel-model"))
return p.getData().trim();
return "";
}
private static List<Replacement> difference(String a, String b) {
int start = 0, end = a.length(), last = b.length();
while (start < end && start < last && a.charAt(start) == b.charAt(start)) start++;
while (end > start && last > start && a.charAt(end - 1) == b.charAt(last - 1)) {
end--;
last--;
}
return start == end && start == last
? List.of()
: List.of(new Replacement(start, end, b.substring(start, last)));
}
}
@@ -0,0 +1,153 @@
package de.neitzel.fxdesigner.kit;
import java.util.function.Consumer;
import javafx.scene.control.*;
import javafx.scene.layout.*;
/** Shared, explicitly binding-aware structural actions; clipboard owns immutable text only. */
final class ReferenceEditor extends VBox {
record Intent(String source, String label, ReferenceDocument.Change change, boolean validate) {}
private static ReferenceDocument.Clip clipboard;
private final ListView<ReferenceDocument.Entry> nodes = new ListView<>();
private final TextField name = new TextField();
private final Label status = new Label();
private final String origin;
private final Consumer<Intent> edit;
private ReferenceDocument document;
private String source = "";
private String actionStatus = "";
private boolean loading;
ReferenceEditor(String origin, Consumer<Intent> edit) {
super(5);
this.origin = origin;
this.edit = edit;
nodes.setId("structure-nodes");
nodes.setPrefHeight(95);
name.setId("structure-name");
status.setId("structure-status");
status.setWrapText(true);
nodes
.getSelectionModel()
.selectedItemProperty()
.addListener(
(o, a, b) -> {
if (!loading && b != null) name.setText(b.id());
});
getChildren()
.addAll(
new Label(
"Select a source object or its canvas item. Paste adds children to the selected"
+ " Pane."),
nodes,
new HBox(
6,
new Label("fx:id"),
name,
button(
"Rename identifier",
() ->
apply(
"Rename identifier",
false,
() -> document.rename(selected(), name.getText()))),
button(
"Copy subtree",
() -> {
try {
clipboard = document.copy(selected(), origin);
actionStatus =
"Copied subtree and its associated bindings. Select a destination Pane"
+ " in this or another View.";
status.setText(actionStatus);
} catch (Exception e) {
error(e.getMessage());
}
}),
button(
"Paste subtree",
() ->
apply(
"Paste subtree with bindings",
true,
() -> document.paste(clipboard, selected(), origin))),
button(
"Delete subtree",
() ->
apply(
"Delete subtree with bindings",
true,
() -> document.delete(selected())))),
status);
}
private Button button(String text, Runnable action) {
var b = new Button(text);
b.setOnAction(e -> action.run());
return b;
}
private int selected() {
var n = nodes.getSelectionModel().getSelectedItem();
if (n == null) throw new IllegalArgumentException("Select a source object");
return n.index();
}
private void apply(
String label,
boolean validate,
java.util.concurrent.Callable<ReferenceDocument.Change> action) {
try {
var change = action.call();
if (!change.edits().isEmpty()) {
actionStatus = String.join("\n", change.notes());
status.setText(actionStatus);
edit.accept(new Intent(source, label, change, validate));
}
} catch (Exception e) {
error(e.getMessage());
}
}
void selectIndex(Integer index) {
if (index != null)
nodes.getItems().stream()
.filter(e -> e.index() == index)
.findFirst()
.ifPresent(e -> nodes.getSelectionModel().select(e));
}
void load(String text, boolean writable) {
setDisable(!writable);
if (source.equals(text)) return;
source = text;
loading = true;
try {
var current = nodes.getSelectionModel().getSelectedItem();
document = new ReferenceDocument(text);
nodes.getItems().setAll(document.entries);
var same =
current == null
? null
: document.entries.stream()
.filter(e -> !current.id().isEmpty() && e.id().equals(current.id()))
.findFirst()
.orElse(null);
nodes.getSelectionModel().select(same);
name.setText(same == null ? "" : same.id());
var errors = document.referenceErrors();
status.setText(errors.isEmpty() ? actionStatus : String.join("\n", errors));
} catch (Exception e) {
document = null;
nodes.getItems().clear();
error("Structural actions unavailable: " + e.getMessage());
} finally {
loading = false;
}
}
void error(String message) {
status.setText(message);
}
}
@@ -121,10 +121,10 @@ final class TableDocument {
return List.of(xml.append(parent, node, ""));
}
List<Replacement> removeTable(int index) {
List<Replacement> removeTable(int index) throws Exception {
if (table(index) == xml.document.getDocumentElement())
throw new IllegalArgumentException("A root Table cannot be removed; edit in Source");
return remove(index);
return new ReferenceDocument(xml.text).delete(index).edits();
}
List<Replacement> writeColumn(
@@ -61,6 +61,21 @@ class FxmlProjectionTest {
assertTrue(anonymous.previewText().contains("fx:id=\"__designer_"));
}
@Test
void kitIdentifierRenameUpdatesKnownBindingsInOneSourceTransaction() throws Exception {
String text =
XML.replace(
"<app.Binding control=\"card\" path=\"home\"/>",
"<de.neitzel.fx.view.BindingList><de.neitzel.fx.view.DirectBinding control=\"card\""
+ " path=\"home\" property=\"model\"/></de.neitzel.fx.view.BindingList>");
var projection = new FxmlProjection(text);
String projected = projection.project();
assertEquals(
text.replace("fx:id=\"card\"", "fx:id=\"renamed\"")
.replace("control=\"card\"", "control=\"renamed\""),
projection.restore(projected.replace("fx:id=\"card\"", "fx:id=\"renamed\"")));
}
@Test
void preservesEveryUnchangedCharacterAndUsesAttributeRanges() throws Exception {
String text = XML.replace("caption=\"before\"", "caption = 'a &amp; b'").replace("\n", "\r\n");
@@ -675,6 +675,186 @@ public final class ProjectionHostChecks {
await(() -> fx(() -> source(pane).getText().equals(after)));
}
private static void structureSelect(DesignerPane pane, String id) throws Exception {
fx(
() -> {
var list = (ListView<Object>) pane.lookup("#structure-nodes");
if (id.isEmpty()) list.getSelectionModel().select(0);
else
list.getSelectionModel()
.select(
list.getItems().stream()
.filter(e -> e.toString().startsWith(id + " :"))
.findFirst()
.orElseThrow());
return null;
});
}
private static void structureEdits(DesignerPane pane, DocumentSession document, String full)
throws Exception {
String base =
full.replace(
"<VBox fx:id=\"visits\"/>",
"<VBox fx:id=\"group\"><VBox fx:id=\"visits\"/><javafx.scene.control.Label"
+ " fx:id=\"summary\" text=\"untouched\"/></VBox>")
.replace(
"</BindingList>",
"<de.neitzel.fx.view.ComputedBinding control=\"summary\" property=\"text\""
+ " method=\"#greeting\"/></BindingList>");
set(document, base);
await(() -> fx(() -> source(pane).getText().equals(base)));
bindingTab(pane, "Structure");
structureSelect(pane, "summary");
fx(
() -> {
((TextField) pane.lookup("#structure-name")).setText("renamedSummary");
return null;
});
click(pane, "Rename identifier");
String renamed =
base.replace("fx:id=\"summary\"", "fx:id=\"renamedSummary\"")
.replace("control=\"summary\"", "control=\"renamedSummary\"");
await(() -> documentText(document).equals(renamed));
repeatHistory(pane, document, base, renamed);
structureSelect(pane, "visits");
fx(
() -> {
((TextField) pane.lookup("#structure-name")).setText("visitList");
return null;
});
click(pane, "Rename identifier");
String repeatRenamed =
renamed
.replace("fx:id=\"visits\"", "fx:id=\"visitList\"")
.replace("pane=\"visits\"", "pane=\"visitList\"");
await(() -> documentText(document).equals(repeatRenamed));
repeatHistory(pane, document, renamed, repeatRenamed);
structureSelect(pane, "group");
click(pane, "Copy subtree");
check(documentText(document).equals(repeatRenamed), "Copy mutated source");
structureSelect(pane, "");
click(pane, "Paste subtree");
await(() -> !documentText(document).equals(repeatRenamed));
String pasted = documentText(document);
check(
pasted.contains("fx:id=\"groupCopy\"")
&& pasted.contains("pane=\"visitListCopy\"")
&& pasted.contains("control=\"renamedSummaryCopy\""),
"Subtree/binding IDs not copied together");
repeatHistory(pane, document, repeatRenamed, pasted);
structureSelect(pane, "group");
click(pane, "Delete subtree");
await(() -> !documentText(document).equals(pasted));
String deleted = documentText(document);
check(
!deleted.contains("pane=\"visitList\"")
&& !deleted.contains("control=\"renamedSummary\"")
&& deleted.contains("pane=\"visitListCopy\""),
"Delete did not isolate descendant target bindings");
repeatHistory(pane, document, pasted, deleted);
document
.save(document.snapshot().toCompletableFuture().get().revision())
.toCompletableFuture()
.get();
check(
java.nio.file.Files.readString(
java.nio.file.Path.of(document.snapshot().toCompletableFuture().get().document()))
.equals(deleted),
"Structural save changed source");
click(pane, "Interactive preview");
await(
() ->
fx(
() ->
pane.lookupAll(".label").stream()
.anyMatch(
n ->
n instanceof Label l
&& "Interactive preview running from captured drafts"
.equals(l.getText()))));
click(pane, "Stop preview");
await(() -> pane.interactivePid() < 0);
structureSelect(pane, "rankInput");
click(pane, "Copy subtree");
var file = java.nio.file.Path.of(document.snapshot().toCompletableFuture().get().document());
var root = file.getParent();
while (root != null
&& !java.nio.file.Files.isRegularFile(root.resolve(".neitzel-fx-designer/host.properties")))
root = root.getParent();
check(root != null, "Fixture host configuration absent");
var launch =
de.neitzel.fxdesigner.workflow.HostLaunch.load(
root.resolve(".neitzel-fx-designer/host.properties"));
var destination = file.resolveSibling("AddressForm.fxml");
try (var otherDocument =
pane.kit().host().documents().open(destination.toUri()).toCompletableFuture().get()) {
String original = documentText(otherDocument);
var other = fx(() -> new DesignerPane(pane.kit().host(), launch, destination));
var stage =
fx(
() -> {
var window = new javafx.stage.Stage();
window.setScene(new javafx.scene.Scene(other, 1200, 850));
window.show();
return window;
});
try {
await(() -> fx(() -> other.kit().editor().getFxomDocument() != null));
bindingTab(other, "Structure");
structureSelect(other, "");
click(other, "Paste subtree");
await(() -> !documentText(otherDocument).equals(original));
String cross = documentText(otherDocument);
check(
cross.contains("rankInputCopy") && cross.contains("path=\"rank\""),
"Cross-View paste lost binding payload");
check(
fx(
() ->
((Label) other.lookup("#structure-status"))
.getText()
.contains("Model declarations differ")),
"Cross-View context mismatch not exposed");
bindingTab(other, "Direct bindings");
await(
() ->
fx(
() ->
((Label) other.lookup("#direct-status"))
.getText()
.contains("Unknown or unresolved Model path: rank")));
repeatHistory(other, otherDocument, original, cross);
click(other, "Render drafts");
await(
() ->
fx(
() ->
other.lookupAll(".label").stream()
.anyMatch(
n ->
n instanceof Label l
&& l.getText() != null
&& l.getText().startsWith("Preview blocked:")
&& l.getText().contains("rank"))));
set(otherDocument, original);
await(() -> fx(() -> source(other).getText().equals(original)));
} finally {
fx(
() -> {
other.close();
stage.close();
return null;
});
}
}
set(document, full);
await(() -> fx(() -> source(pane).getText().equals(full)));
System.out.println(
"PASS reference-safe structure: rename, subtree copy/delete, single-step exact history,"
+ " save/load, cross-View paste and destination validation");
}
private static void tableField(DesignerPane pane, String field, String value) throws Exception {
fx(
() -> {
@@ -1527,6 +1707,7 @@ public final class ProjectionHostChecks {
repeatBindingEdits(pane, document, full);
tableDiscovery(pane, document, full);
tableEdits(pane, document, full);
structureEdits(pane, document, full);
document
.save(document.snapshot().toCompletableFuture().get().revision())
.toCompletableFuture()
@@ -0,0 +1,183 @@
package de.neitzel.fxdesigner.kit;
import static org.junit.jupiter.api.Assertions.*;
import de.neitzel.fxdesigner.contracts.*;
import java.net.URI;
import java.util.*;
import org.junit.jupiter.api.Test;
class ReferenceDocumentTest {
private static final String XML =
"<?neitzel-model app.Person?>\r\n"
+ "<?import javafx.scene.layout.VBox?><?import javafx.scene.control.Label?><?import"
+ " de.neitzel.fx.view.*?><VBox xmlns:f='http://javafx.com/fxml/1'"
+ " f:id='root'><f:define><BindingList><elements><!-- before --><DirectBinding control ="
+ " 'child' property='text' path='child' direction='view' extra='keep'/><ComputedBinding"
+ " control='box' property='visible' method='#visible'/><Repeat pane='box'"
+ " component='app.Row' collection='items'/><DirectBinding property='text' path='name'"
+ " direction='view'><control>child</control></DirectBinding><!-- after"
+ " --></elements></BindingList></f:define><!-- outside --><VBox f:id='box'><Label"
+ " f:id='child' text='literal child'/></VBox><Label f:id='other'"
+ " labelFor='$child'/></VBox>";
private int index(ReferenceDocument d, String id) {
return d.entries.stream().filter(e -> e.id().equals(id)).findFirst().orElseThrow().index();
}
private String apply(String before, ReferenceDocument.Change c) throws Exception {
String result =
new EditBatch(4, "structure", c.edits())
.applyTo(new DocumentSnapshot(URI.create("file:/v.fxml"), 4, before, true, false));
new ReferenceDocument(result);
return result;
}
@Test
void renamesTargetReferencesWithoutTouchingPathsMethodsOrLiteralText() throws Exception {
var d = new ReferenceDocument(XML);
String result = apply(XML, d.rename(index(d, "child"), "renamed"));
assertEquals(
XML.replace("f:id='child'", "f:id='renamed'")
.replace("control = 'child'", "control = 'renamed'")
.replace("<control>child</control>", "<control>renamed</control>")
.replace("labelFor='$child'", "labelFor='$renamed'"),
result);
String box = apply(XML, d.rename(index(d, "box"), "group"));
assertTrue(box.contains("control='group'"));
assertTrue(box.contains("pane='group'"));
assertTrue(box.contains("method='#visible'"));
}
@Test
void nestedTargetRenameRetainsWhitespaceAndComments() throws Exception {
String text =
XML.replace(
"<control>child</control>",
"<control>\r\n<!-- target --> child <!-- retained -->\r\n</control>");
var d = new ReferenceDocument(text);
String result = apply(text, d.rename(index(d, "child"), "newName"));
assertTrue(
result.contains("<control>\r\n<!-- target --> newName <!-- retained -->\r\n</control>"));
}
@Test
void deletesSubtreeAndOnlyItsTargetBindingsLeavingOtherDependenciesVisible() throws Exception {
var d = new ReferenceDocument(XML);
String result = apply(XML, d.delete(index(d, "box")));
assertFalse(result.contains("<DirectBinding"));
assertFalse(result.contains("<ComputedBinding"));
assertFalse(result.contains("<Repeat"));
assertTrue(result.contains("<!-- before --><!-- after -->"));
assertTrue(result.contains("<!-- outside -->"));
assertTrue(result.contains("labelFor='$child'"));
assertEquals(
List.of("Unresolved FXML reference: child"),
new ReferenceDocument(result).referenceErrors());
}
@Test
void copiesWholeSubtreeWithFreshIdsAndExactBindingPayloads() throws Exception {
var d = new ReferenceDocument(XML);
var clip = d.copy(index(d, "box"), "one");
String result = apply(XML, d.paste(clip, index(d, "root"), "one"));
assertTrue(
result.contains(
"<VBox f:id='boxCopy'><Label f:id='childCopy' text='literal child'/></VBox>"));
assertTrue(
result.contains(
"control = 'childCopy' property='text' path='child' direction='view' extra='keep'"));
assertTrue(result.contains("pane='boxCopy'"));
assertTrue(result.contains("<control>childCopy</control>"));
assertTrue(result.contains("control = 'child'"));
assertTrue(result.contains("<!-- outside -->"));
var n = new ReferenceDocument(result);
String twice = apply(result, n.paste(clip, index(n, "root"), "one"));
assertTrue(twice.contains("f:id='childCopy2'"));
}
@Test
void carriesNamespacesImportsAndReportsDestinationContextDifferences() throws Exception {
String source =
XML.replace(
"<?import de.neitzel.fx.view.*?>",
"<?import de.neitzel.fx.view.BindingList?><?import"
+ " de.neitzel.fx.view.DirectBinding?><?import"
+ " de.neitzel.fx.view.ComputedBinding?><?import de.neitzel.fx.view.Repeat?>");
var from = new ReferenceDocument(source);
String target =
"<?neitzel-model app.Other?><?import javafx.scene.layout.VBox?><VBox"
+ " xmlns:fx='http://javafx.com/fxml/1' fx:controller='app.OtherController'/>";
var d = new ReferenceDocument(target);
var change = d.paste(from.copy(index(from, "box"), "source"), 0, "destination");
String result = apply(target, change);
assertTrue(result.contains("<?neitzel-model app.Other?>"));
assertTrue(result.contains("xmlns:f=\"http://javafx.com/fxml/1\""));
assertTrue(result.contains("<?import javafx.scene.control.Label?>"));
assertTrue(change.notes().stream().anyMatch(n -> n.contains("Model declarations differ")));
assertTrue(change.notes().stream().anyMatch(n -> n.contains("Controllers differ")));
assertEquals(
4,
new BindingDocument(result)
.elements.stream()
.filter(e -> e.getTagName().endsWith("Binding") || e.getTagName().equals("Repeat"))
.count());
}
@Test
void updatesSimpleFxmlExpressionsAndReferenceNodesAndRejectsComplexOnes() throws Exception {
String text =
XML.replace("labelFor='$child'", "labelFor='${child.text}'")
.replace(
"</VBox><Label f:id='other'",
"<f:reference source='child'/></VBox><Label f:id='other'");
var d = new ReferenceDocument(text);
String result = apply(text, d.rename(index(d, "child"), "x"));
assertTrue(result.contains("${x.text}"));
assertTrue(result.contains("source='x'"));
var complex =
new ReferenceDocument(
XML.replace("labelFor='$child'", "text='${child.text + other.text}'"));
assertThrows(
IllegalArgumentException.class, () -> complex.rename(index(complex, "child"), "x"));
}
@Test
void rejectsDuplicateIdsCollisionsRootsUnknownBindingsAndUnsafePaste() throws Exception {
var d = new ReferenceDocument(XML);
assertThrows(IllegalArgumentException.class, () -> d.rename(index(d, "child"), "box"));
assertThrows(IllegalArgumentException.class, () -> d.rename(index(d, "child"), "bad id"));
assertThrows(IllegalArgumentException.class, () -> d.delete(0));
assertThrows(IllegalArgumentException.class, () -> d.copy(0, "one"));
assertThrows(
IllegalArgumentException.class,
() -> new ReferenceDocument(XML.replace("f:id='other'", "f:id='child'")));
var unknown =
new ReferenceDocument(
XML.replace("<!-- before -->", "<app.Unknown control='child'/><!-- before -->"));
assertThrows(
IllegalArgumentException.class, () -> unknown.rename(index(unknown, "child"), "x"));
assertThrows(
IllegalArgumentException.class,
() -> d.paste(d.copy(index(d, "box"), "one"), index(d, "other"), "one"));
}
@Test
void copyRemapsInternalReferencesAndDoesNotRetargetExternalOnes() throws Exception {
String text = XML.replace("text='literal child'", "text='literal child' labelFor='$box'");
var d = new ReferenceDocument(text);
String result = apply(text, d.paste(d.copy(index(d, "box"), "one"), 0, "one"));
assertTrue(result.contains("labelFor='$boxCopy'"));
assertTrue(result.contains("labelFor='$child'"));
}
@Test
void assignsIdToAnonymousObjectWithoutWritingProjectionIdentity() throws Exception {
var d =
new ReferenceDocument(
"<javafx.scene.layout.VBox><javafx.scene.control.Label/></javafx.scene.layout.VBox>");
String result = apply(d.xml.text, d.rename(1, "label"));
assertTrue(result.contains("fx:id=\"label\""));
assertFalse(result.contains("__designer_"));
}
}
@@ -75,6 +75,22 @@ class TableDocumentTest {
}
}
@Test
void removingTableAlsoRemovesItsTargetBindings() throws Exception {
String declaration = "<DirectBinding control='t' property='visible' path='enabled'/>";
String text =
START.replace(
"<!-- keep -->",
"<f:define><BindingList><!-- binding -->"
+ declaration
+ "</BindingList></f:define><!-- keep -->");
var d = new TableDocument(text);
String result = apply(text, d.removeTable(d.tables.getFirst().index()));
assertFalse(result.contains(declaration));
assertTrue(result.contains("<!-- binding -->"));
assertTrue(result.contains("<Label text='untouched'/>"));
}
@Test
void preservesNestedDeclarationsAndRejectsAmbiguousContainers() throws Exception {
String text =
+31
View File
@@ -0,0 +1,31 @@
# Reference-safe structural editing
Both hosts expose **Bindings → Structure** in the shared Kit UI. Select a source object in the list or canvas, then rename its identifier, copy its subtree, paste into a selected standard Pane, or delete the subtree. Copy captures immutable source and associated declarations; it does not change the document. Each rename, paste or delete is one host-owned source transaction with exact Undo/Redo. Read-only documents and stale selections cannot be modified.
Rename changes `fx:id` and associated root BindingList `DirectBinding.control`, `ComputedBinding.control` and `Repeat.pane` references. Attribute targets and simple nested target elements are supported. A single identifier rename proposed by Kit's own inspector uses the same reference-aware transaction. Anonymous objects can receive an identifier without persisting a synthetic projection identity. Duplicate identifiers and collisions are rejected.
Copy/paste assigns fresh identifiers to every identified descendant and clones its associated bindings, including Model paths, directions, Converters and Computed methods. Internal references are remapped; references to objects outside the subtree are retained. Delete removes the selected subtree and the bindings targeting any removed descendant. Table removal through the Table inspector also uses this cleanup. Other dependencies remain visible for validation; no separate Component or Row source file is rewritten.
Supported ordinary FXML references include `$id`, simple `${id.property}` expressions and `fx:reference`/`fx:copy` source attributes. Missing simple references are displayed and block a new preview while the document remains saveable. Model paths and literal strings are not identifier references. Complex expressions mentioning an affected identifier, scripts and detected unfamiliar target declarations require Source review rather than speculative rewrites. Java Controller fields and other external Java references are not refactored.
## Source ownership and cross-View paste
Edits retain unrelated source characters, comments, processing instructions, declaration order and unknown attributes. Copied fragments retain their lexical form, with identifier/reference substitutions and any required inherited namespace declarations. Missing imports are added; conflicting simple names and ambiguous wildcard combinations require explicit Source resolution.
The immutable clipboard is shared between open designers in the same host process, including separate standalone windows and IntelliJ editor panes. It is not the operating-system clipboard or a transfer between host processes. Cross-View paste preserves the destination Model and Controller declarations and automatically runs destination binding validation. Context differences are reported, unresolved paths/methods remain editable, and known binding errors block new previews. Java changes still require Build and Refresh. Without a usable snapshot, target validation remains unavailable rather than being reported as successful.
Paste supports the children of standard VBox, HBox, Pane, AnchorPane, StackPane, FlowPane, TilePane and GridPane containers, including an explicit children wrapper and a compatible fx:root. Copying/deleting the document root, copying includes/scripts, cross-View relative resource references and unsupported child properties require Source editing. Native Kit structural clipboard/delete/reparenting proposals remain rejected; use the shared Structure actions. This slice does not infer arbitrary application reference conventions or rewrite Java source.
## Automated qualification
Regression tests exercise lexical preservation, nested binding targets, collision handling, unsupported cases, internal and external references, fresh identifiers, destination context preservation and Kit identifier rename. The shared live host scenario checks rename, subtree copy/paste/delete, exact single-command Undo/Redo, save/load, valid interactive preview and cross-View paste with automatic destination diagnostics and preview blocking. It runs in native standalone and registered IntelliJ for classpath and module-path applications. No manual testing is required.
Run the bootstrap and registered IDE commands in the [README](../README.md). Generated metadata and IDE Problems integration remain subsequent work.
### Qualification — 2026-11-13
The final full bootstrap gate passed: 126 unit/adapter tests (119 Maven and seven IntelliJ), no failures/errors/skips, 36 target processes with 512 fixture checks, 48 worker lifecycle cycles, both native standalone modes, connected adapters, plugin packaging and documentation checks. Registered IntelliJ classpath and module-path qualification passed the shared structural scenario, exclusive JavaFX 26 native-library checks and worker shutdown. No manual testing was used; previously skipped manual checks remain skipped.
The first registered IntelliJ attempt stopped at the older DirectBinding scenario's immediate exact-disk assertion, `Invalid binding could not be saved`, before the structural tests. The saved file subsequently contained the invalid declaration; the assertion did not capture an exact expected/actual diagnostic. The unchanged rerun passed both modes. This resembles the previously recorded intermittent persistence-check failure but does not establish its cause or a fix. No save implementation or exact-source assertion was weakened; persistence-check reliability remains a follow-up.
Local evidence: `/tmp/structure-bootstrap-final.log`, `/tmp/structure-registered-ide.log`, `/tmp/structure-registered-ide-retry.log`, `target/connected-workflow/` and `target/ide-workflow/`. Generated qualification artifacts are not repository inputs.
+4
View File
@@ -133,3 +133,7 @@ The [Table/Column authoring slice](table-column-inspector.md) adds Table and ord
## Table-specific compatibility and Row alignment
The [Table discovery slice](table-compatibility.md) adds collection/Row/Attribute choices, Table-specific bean rules and conservative positional Row-binding diagnostics in both hosts. Binding discovery includes captured open Row drafts and rejects changed revisions. Known type errors block preview; mismatches and uncertain alignment offer explicit Row navigation without rearranging shared layouts. Generated metadata, structural reference repair and Problems integration remain open.
## 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.
+5 -1
View File
@@ -67,6 +67,10 @@ The user subsequently passed the physical corner retest for both Components in b
## Remaining boundaries
This stage preserves arbitrary source, but does not make arbitrary structure visually editable. Insert/delete/reparent, binding-aware renames and subtree copy/paste require later structural transactions. Application wildcard imports, `fx:include`, scripts and unrepresentable roots retain source fallback. Arbitrary target properties use declared FXML text editing until shared discovery supplies typed metadata. Anonymous identities are local to a projection revision, not the persistent occurrence identities needed for repeated/Table Components. Target node lookup still uses the initial CSS-ID route; separate CSS IDs and repeated/internal scopes need stage 3's occurrence identity work.
This stage preserves arbitrary source, but does not make arbitrary structure visually editable. Binding-aware renames and subtree copy/paste/delete are now available through the [Structure editor](reference-safe-structure.md). Arbitrary insertion/reparenting still requires Source editing. Application wildcard imports, `fx:include`, scripts and unrepresentable roots retain source fallback. Arbitrary target properties use declared FXML text editing until shared discovery supplies typed metadata. Anonymous identities are local to a projection revision, not the persistent occurrence identities needed for repeated/Table Components. Target node lookup still uses the initial CSS-ID route; separate CSS IDs and repeated/internal scopes need stage 3's occurrence identity work.
Qualification remains scoped to the recorded macOS arm64 / IntelliJ 263.7307.200 / JavaFX and Kit 26 host baseline and Java 21 / JavaFX 21.0.7 application fixtures. Product packaging, a broader platform matrix, lossless structural editing and wider input qualification are not implied. The stage-2 physical input gate is closed on the tested baseline. The following [target-runtime stage](target-runtime-sessions.md) implements retained sessions, sizing and navigation; its new actions have a separate human review.
## Reference-aware structural actions
The [shared Structure editor](reference-safe-structure.md) now supports explicit rename, subtree copy/paste and delete with binding-reference updates. Single native Kit identifier renames use this transaction too; other native structural proposals still require the explicit Structure actions or Source editing.