Add reviewed FXML View generation in both hosts
This commit is contained in:
@@ -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, a separate Model metadata processor and an 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. Shared [source highlighting and diagnostic quick fixes](docs/source-highlighting-fixes.md) now add explicit source-preserving corrections with host Undo/Redo and native IntelliJ Alt+Enter actions. Opt-in [generated Model metadata](docs/generated-model-metadata.md) now feeds the shared discovery and binding authoring workflows from immutable build snapshots. Opt-in [generated Java ViewModels and registry defaults](docs/generated-viewmodels.md) now reuse the runtime storage and identity rules, with shared discovery in both hosts. Reviewed FXML form generation and product packaging remain later work.
|
||||
The repository builds six shared Maven modules, a Maven capture plugin, a separate Model metadata processor and an 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. Shared [source highlighting and diagnostic quick fixes](docs/source-highlighting-fixes.md) now add explicit source-preserving corrections with host Undo/Redo and native IntelliJ Alt+Enter actions. Opt-in [generated Model metadata](docs/generated-model-metadata.md) now feeds the shared discovery and binding authoring workflows from immutable build snapshots. Opt-in [generated Java ViewModels and registry defaults](docs/generated-viewmodels.md) now reuse the runtime storage and identity rules, with shared discovery in both hosts. [Reviewed FXML View generation](docs/reviewed-view-generation.md) now proposes editable forms from shared metadata, validates explicit choices and creates new documents in both hosts. Product packaging remains 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.
|
||||
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
package de.neitzel.fxdesigner.generation;
|
||||
|
||||
import de.neitzel.fxdesigner.protocol.BindingMetadata;
|
||||
import java.util.*;
|
||||
|
||||
/** Pure, deterministic form proposals. Target discovery validates the resulting document. */
|
||||
public final class ViewGeneration {
|
||||
private ViewGeneration() {}
|
||||
|
||||
public enum Control {
|
||||
TextField("text"),
|
||||
Label("text"),
|
||||
CheckBox("selected"),
|
||||
ListView("items"),
|
||||
Component("model"),
|
||||
Repeat(""),
|
||||
Table("");
|
||||
private final String property;
|
||||
|
||||
Control(String property) {
|
||||
this.property = property;
|
||||
}
|
||||
|
||||
public String property() {
|
||||
return property;
|
||||
}
|
||||
}
|
||||
|
||||
public record Column(String attribute, String header, String width) {}
|
||||
|
||||
public record Field(
|
||||
String path,
|
||||
String label,
|
||||
Control control,
|
||||
String direction,
|
||||
String converter,
|
||||
String component,
|
||||
List<Column> columns,
|
||||
boolean included) {
|
||||
public Field {
|
||||
columns = List.copyOf(columns);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return (included ? "✓ " : "– ") + path + " · " + control;
|
||||
}
|
||||
}
|
||||
|
||||
public static List<Field> propose(BindingMetadata metadata) {
|
||||
return metadata.attributes().stream()
|
||||
.filter(
|
||||
a ->
|
||||
!a.path().contains(".")
|
||||
&& Set.of("VALUE", "COMPLEX", "COLLECTION").contains(a.kind()))
|
||||
.map(
|
||||
a ->
|
||||
new Field(
|
||||
a.path(),
|
||||
a.path(),
|
||||
a.kind().equals("COMPLEX")
|
||||
? Control.Component
|
||||
: a.kind().equals("COLLECTION")
|
||||
? (Set.of("java.lang.String", "java.lang.Integer", "java.lang.Boolean")
|
||||
.contains(a.elementType())
|
||||
? Control.ListView
|
||||
: Control.Repeat)
|
||||
: Set.of("boolean", "java.lang.Boolean").contains(a.type())
|
||||
? Control.CheckBox
|
||||
: Control.TextField,
|
||||
"both",
|
||||
"",
|
||||
"",
|
||||
List.of(),
|
||||
true))
|
||||
.toList();
|
||||
}
|
||||
|
||||
public static String seed(String model, String controller) {
|
||||
return generate(model, controller, List.of());
|
||||
}
|
||||
|
||||
public static String id(int index) {
|
||||
return "field" + (index + 1);
|
||||
}
|
||||
|
||||
private static String type(String value) {
|
||||
if (!value.matches("[A-Za-z_][\\w]*(\\.[A-Za-z_][\\w]*)+"))
|
||||
throw new IllegalArgumentException("Choose a fully qualified class: " + value);
|
||||
return value;
|
||||
}
|
||||
|
||||
public static String xml(String value) {
|
||||
for (int c : value.codePoints().toArray()) {
|
||||
if ((c < 32 && c != '\n' && c != '\r' && c != '\t')
|
||||
|| c == 0xfffe
|
||||
|| c == 0xffff
|
||||
|| (c >= 0xd800 && c <= 0xdfff))
|
||||
throw new IllegalArgumentException("Invalid XML character");
|
||||
}
|
||||
return value
|
||||
.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace("\"", """)
|
||||
.replace("\r", " ")
|
||||
.replace("\n", " ")
|
||||
.replace("\t", "	");
|
||||
}
|
||||
|
||||
public static String generate(String model, String controller, List<Field> fields) {
|
||||
type(model);
|
||||
if (!controller.isBlank()) type(controller);
|
||||
var out =
|
||||
new StringBuilder(
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<?neitzel-model " + model + "?>\n");
|
||||
for (String name :
|
||||
List.of(
|
||||
"javafx.scene.layout.GridPane",
|
||||
"javafx.scene.layout.VBox",
|
||||
"javafx.scene.control.Label",
|
||||
"javafx.scene.control.TextField",
|
||||
"javafx.scene.control.CheckBox",
|
||||
"javafx.scene.control.ListView",
|
||||
"de.neitzel.fx.view.BindingList",
|
||||
"de.neitzel.fx.view.DirectBinding",
|
||||
"de.neitzel.fx.view.Repeat",
|
||||
"de.neitzel.fx.view.Table",
|
||||
"de.neitzel.fx.view.Column")) out.append("<?import ").append(name).append("?>\n");
|
||||
out.append(
|
||||
"<GridPane xmlns:fx=\"http://javafx.com/fxml/1\" hgap=\"12\" vgap=\"10\""
|
||||
+ " prefWidth=\"600\"");
|
||||
if (!controller.isBlank()) out.append(" fx:controller=\"").append(controller).append('"');
|
||||
out.append(">\n <fx:define><BindingList>\n");
|
||||
var body = new StringBuilder();
|
||||
var paths = new HashSet<String>();
|
||||
int row = 0;
|
||||
for (int i = 0; i < fields.size(); i++) {
|
||||
var f = fields.get(i);
|
||||
if (!f.included()) continue;
|
||||
if (!f.path().matches("[A-Za-z_][\\w]*(\\.[A-Za-z_][\\w]*)*") || !paths.add(f.path()))
|
||||
throw new IllegalArgumentException("Invalid or duplicate Attribute: " + f.path());
|
||||
if (!Set.of("view", "model", "both").contains(f.direction()))
|
||||
throw new IllegalArgumentException("Invalid binding direction");
|
||||
String id = id(i), tag = f.control().name();
|
||||
if (Set.of(Control.Component, Control.Repeat, Control.Table).contains(f.control()))
|
||||
type(f.component());
|
||||
if (!f.converter().isBlank()) type(f.converter());
|
||||
if (f.control() == Control.Repeat) {
|
||||
out.append(" <Repeat pane=\"")
|
||||
.append(id)
|
||||
.append("\" collection=\"")
|
||||
.append(f.path())
|
||||
.append("\" component=\"")
|
||||
.append(f.component())
|
||||
.append("\"/>\n");
|
||||
tag = "VBox";
|
||||
} else if (f.control() != Control.Table) {
|
||||
out.append(" <DirectBinding control=\"")
|
||||
.append(id)
|
||||
.append("\" property=\"")
|
||||
.append(f.control().property())
|
||||
.append("\" path=\"")
|
||||
.append(f.path())
|
||||
.append("\" direction=\"")
|
||||
.append(f.direction())
|
||||
.append('"');
|
||||
if (!f.converter().isBlank()) out.append(" converter=\"").append(f.converter()).append('"');
|
||||
out.append("/>\n");
|
||||
if (f.control() == Control.Component) tag = f.component();
|
||||
}
|
||||
boolean section =
|
||||
Set.of(Control.Component, Control.Repeat, Control.Table, Control.ListView)
|
||||
.contains(f.control());
|
||||
int labelRow = row++, controlRow = section ? row++ : labelRow;
|
||||
body.append(" <Label text=\"")
|
||||
.append(xml(f.label()))
|
||||
.append("\" GridPane.rowIndex=\"")
|
||||
.append(labelRow)
|
||||
.append("\" GridPane.columnIndex=\"0\" GridPane.columnSpan=\"")
|
||||
.append(section ? 2 : 1)
|
||||
.append("\"/>\n");
|
||||
body.append(" <")
|
||||
.append(tag)
|
||||
.append(" fx:id=\"")
|
||||
.append(id)
|
||||
.append("\" GridPane.rowIndex=\"")
|
||||
.append(controlRow)
|
||||
.append("\" GridPane.columnIndex=\"")
|
||||
.append(section ? 0 : 1)
|
||||
.append("\" GridPane.columnSpan=\"")
|
||||
.append(section ? 2 : 1)
|
||||
.append("\" GridPane.hgrow=\"ALWAYS\"");
|
||||
if (f.control() == Control.Table) {
|
||||
if (f.columns().isEmpty())
|
||||
throw new IllegalArgumentException("Choose Columns for " + f.path());
|
||||
body.append(" collection=\"")
|
||||
.append(f.path())
|
||||
.append("\" row=\"")
|
||||
.append(f.component())
|
||||
.append("\">\n");
|
||||
var cols = new HashSet<String>();
|
||||
for (var c : f.columns()) {
|
||||
if (!c.attribute().matches("[A-Za-z_][\\w]*") || !cols.add(c.attribute()))
|
||||
throw new IllegalArgumentException("Invalid or duplicate Column Attribute");
|
||||
body.append(" <Column attribute=\"")
|
||||
.append(c.attribute())
|
||||
.append("\" header=\"")
|
||||
.append(xml(c.header()))
|
||||
.append('"');
|
||||
if (!c.width().isBlank()) {
|
||||
double w = Double.parseDouble(c.width());
|
||||
if (!Double.isFinite(w) || w <= 0)
|
||||
throw new IllegalArgumentException("Column width must be positive");
|
||||
body.append(" width=\"").append(c.width()).append('"');
|
||||
}
|
||||
body.append("/>\n");
|
||||
}
|
||||
body.append(" </Table>\n");
|
||||
} else body.append("/>\n");
|
||||
}
|
||||
return out.append(" </BindingList></fx:define>\n")
|
||||
.append(body)
|
||||
.append("</GridPane>\n")
|
||||
.toString();
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,12 @@ public record HostServices(
|
||||
public interface Documents {
|
||||
CompletionStage<DocumentSession> open(URI document);
|
||||
|
||||
/** Explicit new-document creation; must never replace an existing path. */
|
||||
default CompletionStage<DocumentSession> create(URI document, String text) {
|
||||
return java.util.concurrent.CompletableFuture.failedFuture(
|
||||
new UnsupportedOperationException("Host does not support new documents"));
|
||||
}
|
||||
|
||||
/** Fired synchronously with host edits; listeners must not block the host thread. */
|
||||
default AutoCloseable onChange(Consumer<URI> listener) {
|
||||
return () -> {};
|
||||
|
||||
@@ -198,6 +198,30 @@ public final class DesignerWorkflow implements AutoCloseable {
|
||||
return build.start(request, approved, line -> log.accept(line.line()));
|
||||
}
|
||||
|
||||
public CompletionStage<String> currentBuildId(URI origin) {
|
||||
return host.documents()
|
||||
.snapshots()
|
||||
.thenCompose(
|
||||
drafts ->
|
||||
CompletableFuture.supplyAsync(
|
||||
() -> {
|
||||
var overlays = new HashSet<URI>();
|
||||
overlays.add(origin);
|
||||
drafts.stream()
|
||||
.map(DocumentSnapshot::document)
|
||||
.filter(
|
||||
uri ->
|
||||
uri.toString().endsWith(".fxml")
|
||||
&& Path.of(uri).startsWith(reactor))
|
||||
.forEach(overlays::add);
|
||||
try (var lease = build.previewCurrent(request, overlays)) {
|
||||
return lease.snapshot().id();
|
||||
} catch (Exception e) {
|
||||
throw new CompletionException(e);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
public CompletionStage<BindingMetadata> bindings(
|
||||
boolean last, DocumentSnapshot document, String path, String direction) {
|
||||
return bindingDetails(last, document, path, direction, "", "");
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
package de.neitzel.fxdesigner.generation;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import de.neitzel.fxdesigner.generation.ViewGeneration.*;
|
||||
import de.neitzel.fxdesigner.protocol.BindingMetadata;
|
||||
import java.util.*;
|
||||
import javax.xml.parsers.DocumentBuilderFactory;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class ViewGenerationTest {
|
||||
private Field field(
|
||||
String path, Control control, String component, List<Column> columns, boolean include) {
|
||||
return new Field(
|
||||
path, "Name & <quoted> \"😀\"", control, "both", "", component, columns, include);
|
||||
}
|
||||
|
||||
@Test
|
||||
void emitsOrderedUniqueBindingsSectionsAndEscapedLabels() throws Exception {
|
||||
var fields =
|
||||
List.of(
|
||||
field("name", Control.TextField, "", List.of(), true),
|
||||
field("home", Control.Component, "sample.AddressForm", List.of(), true),
|
||||
field("visits", Control.Repeat, "sample.AddressForm", List.of(), true));
|
||||
String xml = ViewGeneration.generate("sample.Person", "", fields);
|
||||
var doc =
|
||||
DocumentBuilderFactory.newInstance()
|
||||
.newDocumentBuilder()
|
||||
.parse(
|
||||
new java.io.ByteArrayInputStream(
|
||||
xml.getBytes(java.nio.charset.StandardCharsets.UTF_8)));
|
||||
assertEquals("GridPane", doc.getDocumentElement().getTagName());
|
||||
assertEquals(
|
||||
"Name & <quoted> \"😀\"",
|
||||
((org.w3c.dom.Element) doc.getElementsByTagName("Label").item(0)).getAttribute("text"));
|
||||
assertEquals(2, doc.getElementsByTagName("DirectBinding").getLength());
|
||||
assertEquals(1, doc.getElementsByTagName("Repeat").getLength());
|
||||
assertTrue(xml.indexOf("fx:id=\"field1\"") < xml.indexOf("fx:id=\"field2\""));
|
||||
assertFalse(xml.contains("fx:controller"));
|
||||
assertEquals(xml, ViewGeneration.generate("sample.Person", "", fields));
|
||||
}
|
||||
|
||||
@Test
|
||||
void omissionBypassesUnresolvedFieldsButIncludedFieldsMustBeConfigured() {
|
||||
var missing = field("home", Control.Component, "", List.of(), true);
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> ViewGeneration.generate("sample.Person", "", List.of(missing)));
|
||||
assertDoesNotThrow(
|
||||
() ->
|
||||
ViewGeneration.generate(
|
||||
"sample.Person",
|
||||
"",
|
||||
List.of(field("home", Control.Component, "", List.of(), false))));
|
||||
assertThrows(
|
||||
IllegalArgumentException.class, () -> ViewGeneration.seed("sample.Person?><bad>", ""));
|
||||
assertThrows(IllegalArgumentException.class, () -> ViewGeneration.xml("bad\u0000"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void tableColumnsKeepExplicitOrderAndValidateWidth() {
|
||||
String xml =
|
||||
ViewGeneration.generate(
|
||||
"sample.Person",
|
||||
"sample.Controller",
|
||||
List.of(
|
||||
field(
|
||||
"visits",
|
||||
Control.Table,
|
||||
"sample.Row",
|
||||
List.of(new Column("street", "Street", "120"), new Column("city", "City", "")),
|
||||
true)));
|
||||
assertTrue(xml.contains("fx:controller=\"sample.Controller\""));
|
||||
assertTrue(xml.indexOf("attribute=\"street\"") < xml.indexOf("attribute=\"city\""));
|
||||
assertTrue(xml.contains("width=\"120\""));
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() ->
|
||||
ViewGeneration.generate(
|
||||
"sample.Person",
|
||||
"",
|
||||
List.of(
|
||||
field(
|
||||
"visits",
|
||||
Control.Table,
|
||||
"sample.Row",
|
||||
List.of(new Column("street", "Street", "NaN")),
|
||||
true))));
|
||||
}
|
||||
|
||||
@Test
|
||||
void proposalsUseSharedMetadataAndExcludeNestedPathsAndRuntimeMessages() {
|
||||
var metadata =
|
||||
new BindingMetadata(
|
||||
"sample.Person",
|
||||
"",
|
||||
"both",
|
||||
List.of(
|
||||
new BindingMetadata.Attribute("name", "java.lang.String", "VALUE", ""),
|
||||
new BindingMetadata.Attribute("home", "sample.Address", "COMPLEX", ""),
|
||||
new BindingMetadata.Attribute("home.street", "java.lang.String", "VALUE", ""),
|
||||
new BindingMetadata.Attribute(
|
||||
"visits", "java.util.List", "COLLECTION", "sample.Address"),
|
||||
new BindingMetadata.Attribute("latestMessage", "java.lang.String", "BUILTIN", "")),
|
||||
List.of(),
|
||||
List.of());
|
||||
var fields = ViewGeneration.propose(metadata);
|
||||
assertEquals(
|
||||
List.of(Control.TextField, Control.Component, Control.Repeat),
|
||||
fields.stream().map(Field::control).toList());
|
||||
}
|
||||
}
|
||||
@@ -104,6 +104,15 @@ public final class IntellijDocuments implements HostServices.Documents, AutoClos
|
||||
return future;
|
||||
}
|
||||
|
||||
public CompletionStage<DocumentSession> create(URI uri, String text) {
|
||||
return edt(() -> {
|
||||
Files.writeString(
|
||||
Path.of(uri), text, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE);
|
||||
return uri;
|
||||
})
|
||||
.thenCompose(this::open);
|
||||
}
|
||||
|
||||
public CompletionStage<DocumentSession> open(URI uri) {
|
||||
return edt(
|
||||
() -> {
|
||||
|
||||
+25
@@ -6,6 +6,31 @@ import java.nio.file.*;
|
||||
import java.util.*;
|
||||
|
||||
public final class IntellijDocumentsTest extends BasePlatformTestCase {
|
||||
public void testCreateNeverOverwritesAndSupportsNativeHistory() throws Exception {
|
||||
Path folder = Files.createTempDirectory("designer-create-");
|
||||
com.intellij.openapi.vfs.newvfs.impl.VfsRootAccess.allowRootAccess(
|
||||
getTestRootDisposable(), folder.toRealPath().toString(), folder.toString());
|
||||
var uri = folder.resolve("New.fxml").toUri();
|
||||
try (var docs = new IntellijDocuments(getProject());
|
||||
var d = docs.create(uri, "before").toCompletableFuture().get()) {
|
||||
try {
|
||||
docs.create(uri, "overwrite").toCompletableFuture().get();
|
||||
fail("Existing file overwritten");
|
||||
} catch (java.util.concurrent.ExecutionException expected) {
|
||||
assertInstanceOf(expected.getCause(), FileAlreadyExistsException.class);
|
||||
}
|
||||
d.apply(new EditBatch(0, "edit", List.of(new EditBatch.Replacement(0, 6, "after"))))
|
||||
.toCompletableFuture()
|
||||
.get();
|
||||
assertEquals("before", d.undo().toCompletableFuture().get().text());
|
||||
assertEquals("after", d.redo().toCompletableFuture().get().text());
|
||||
assertEquals("before", Files.readString(Path.of(uri)));
|
||||
} finally {
|
||||
Files.deleteIfExists(Path.of(uri));
|
||||
Files.deleteIfExists(folder);
|
||||
}
|
||||
}
|
||||
|
||||
/** Keeps real VFS writes queued until its per-file flush executes them. */
|
||||
public void testSaveWaitsForQueuedVfsWrites() throws Exception {
|
||||
var fs = com.intellij.openapi.vfs.LocalFileSystem.getInstance();
|
||||
|
||||
@@ -118,6 +118,7 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
|
||||
private DesignerWorkflow workflow;
|
||||
private DesignerWorkflow interactiveWorkflow;
|
||||
private DesignerWorkflow bindingWorkflow;
|
||||
private ViewGenerationPane viewGeneration;
|
||||
private BindingBrowser bindingBrowser;
|
||||
private BindingEditor bindingEditor, computedEditor, repeatEditor;
|
||||
private TableEditor tableEditor;
|
||||
@@ -386,6 +387,9 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
|
||||
problemsTab.setClosable(false);
|
||||
var structureTab = new Tab("Structure", referenceEditor);
|
||||
structureTab.setClosable(false);
|
||||
viewGeneration = new ViewGenerationPane(host, launch, file);
|
||||
var generationTab = new Tab("Generate View", new ScrollPane(viewGeneration));
|
||||
generationTab.setClosable(false);
|
||||
var bindings =
|
||||
new TitledPane(
|
||||
"Bindings",
|
||||
@@ -396,7 +400,8 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
|
||||
tableTab,
|
||||
structureTab,
|
||||
problemsTab,
|
||||
discoveryTab));
|
||||
discoveryTab,
|
||||
generationTab));
|
||||
bindings.setExpanded(false);
|
||||
center.setBottom(new VBox(properties, bindings));
|
||||
output.setEditable(false);
|
||||
@@ -1430,6 +1435,7 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
|
||||
workflow.close();
|
||||
interactiveWorkflow.close();
|
||||
bindingWorkflow.close();
|
||||
viewGeneration.close();
|
||||
bindingRequest++;
|
||||
if (document != null) document.close();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,575 @@
|
||||
package de.neitzel.fxdesigner.kit;
|
||||
|
||||
import de.neitzel.fxdesigner.contracts.DocumentSnapshot;
|
||||
import de.neitzel.fxdesigner.generation.ViewGeneration;
|
||||
import de.neitzel.fxdesigner.generation.ViewGeneration.*;
|
||||
import de.neitzel.fxdesigner.generation.ViewGeneration.Control;
|
||||
import de.neitzel.fxdesigner.host.*;
|
||||
import de.neitzel.fxdesigner.protocol.BindingMetadata;
|
||||
import de.neitzel.fxdesigner.workflow.*;
|
||||
import java.nio.file.Path;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
import javafx.application.Platform;
|
||||
import javafx.scene.control.*;
|
||||
import javafx.scene.layout.*;
|
||||
|
||||
/** One review workflow shared by both hosts; application types stay in target workers. */
|
||||
public final class ViewGenerationPane extends VBox implements AutoCloseable {
|
||||
private final HostServices host;
|
||||
private final Path origin;
|
||||
private final DesignerWorkflow workflow;
|
||||
private final TextField model = text("generate-model", "Fully qualified Model"),
|
||||
controller = text("generate-controller", "Optional existing Controller"),
|
||||
destination = text("generate-file", "New sibling filename.fxml"),
|
||||
label = text("generate-label", "Label");
|
||||
private final ListView<Field> fields = new ListView<>();
|
||||
private final CheckBox include = new CheckBox("Include Attribute");
|
||||
private final ComboBox<Control> control = new ComboBox<>();
|
||||
private final ComboBox<String> direction = new ComboBox<>(),
|
||||
component = new ComboBox<>(),
|
||||
converter = new ComboBox<>();
|
||||
private final TextArea columns = new TextArea(),
|
||||
preview = new TextArea(),
|
||||
choices = new TextArea();
|
||||
private final GridPane layoutPreview = new GridPane();
|
||||
private final Label status =
|
||||
new Label("Load a Model from the current successful build, choose fields, then review.");
|
||||
private final Button create = new Button("Create reviewed View");
|
||||
private final AutoCloseable changes;
|
||||
private boolean loading, closed;
|
||||
private long version;
|
||||
private Review reviewed;
|
||||
|
||||
private record Review(String text, String filename, String build, long revision, long version) {}
|
||||
|
||||
private record CheckedReview(Review review, BindingMetadata metadata) {}
|
||||
|
||||
private static TextField text(String id, String prompt) {
|
||||
var field = new TextField();
|
||||
field.setId(id);
|
||||
field.setPromptText(prompt);
|
||||
return field;
|
||||
}
|
||||
|
||||
private static Button button(String name, Runnable action) {
|
||||
var b = new Button(name);
|
||||
b.setOnAction(e -> action.run());
|
||||
return b;
|
||||
}
|
||||
|
||||
private void fx(Runnable task) {
|
||||
Platform.runLater(
|
||||
() -> {
|
||||
if (!closed) task.run();
|
||||
});
|
||||
}
|
||||
|
||||
private void fail(Throwable error) {
|
||||
while (error.getCause() != null) error = error.getCause();
|
||||
status.setText("Generation: " + error.getMessage());
|
||||
}
|
||||
|
||||
private void invalidate() {
|
||||
if (loading) return;
|
||||
version++;
|
||||
reviewed = null;
|
||||
create.setDisable(true);
|
||||
status.setText("Choices changed; review again before creating a file.");
|
||||
}
|
||||
|
||||
public ViewGenerationPane(HostServices host, HostLaunch launch, Path origin) throws Exception {
|
||||
super(6);
|
||||
this.host = host;
|
||||
this.origin = origin;
|
||||
workflow =
|
||||
new DesignerWorkflow(
|
||||
host,
|
||||
launch.configuration(),
|
||||
launch.context(),
|
||||
launch.maven(),
|
||||
launch.tools(),
|
||||
s -> {});
|
||||
setId("view-generation");
|
||||
fields.setId("generate-fields");
|
||||
fields.setPrefHeight(130);
|
||||
include.setId("generate-include");
|
||||
control.setId("generate-control");
|
||||
direction.setId("generate-direction");
|
||||
component.setId("generate-component");
|
||||
converter.setId("generate-converter");
|
||||
columns.setId("generate-columns");
|
||||
columns.setPromptText("Table Columns: attribute | header | optional width (one per line)");
|
||||
columns.setPrefRowCount(2);
|
||||
control.getItems().setAll(Control.values());
|
||||
direction.getItems().setAll("both", "view", "model");
|
||||
component.setEditable(true);
|
||||
converter.setEditable(true);
|
||||
preview.setId("generate-preview");
|
||||
preview.setEditable(false);
|
||||
preview.setPrefRowCount(5);
|
||||
choices.setId("generate-choices");
|
||||
choices.setEditable(false);
|
||||
choices.setPrefRowCount(3);
|
||||
status.setId("generate-status");
|
||||
status.setWrapText(true);
|
||||
layoutPreview.setId("generate-layout");
|
||||
layoutPreview.setHgap(12);
|
||||
layoutPreview.setVgap(10);
|
||||
create.setId("generate-create");
|
||||
create.setDisable(true);
|
||||
create.setOnAction(e -> create());
|
||||
destination.setText("NewView.fxml");
|
||||
getChildren()
|
||||
.addAll(
|
||||
new Label("Generate a new editable View (existing files are never replaced)"),
|
||||
new HBox(6, model, controller, button("Load Model fields", this::discover)),
|
||||
fields,
|
||||
new HBox(6, include, label, control, direction),
|
||||
new HBox(
|
||||
6,
|
||||
new Label("Component / Row"),
|
||||
component,
|
||||
new Label("Converter"),
|
||||
converter,
|
||||
button("Discover field choices", this::choices),
|
||||
button("Open selected Component / Row", this::openComponent)),
|
||||
columns,
|
||||
new HBox(
|
||||
6,
|
||||
button("Move field up", () -> move(-1)),
|
||||
button("Move field down", () -> move(1))),
|
||||
choices,
|
||||
destination,
|
||||
button("Review generated View", this::review),
|
||||
new Label("Layout preview (structure only; Components run in target previews)"),
|
||||
layoutPreview,
|
||||
preview,
|
||||
status,
|
||||
create);
|
||||
fields.getSelectionModel().selectedItemProperty().addListener((o, a, b) -> show(b));
|
||||
for (var t : List.of(model, controller, destination))
|
||||
t.textProperty().addListener((o, a, b) -> invalidate());
|
||||
label.textProperty().addListener((o, a, b) -> edit());
|
||||
columns.textProperty().addListener((o, a, b) -> edit());
|
||||
include.selectedProperty().addListener((o, a, b) -> edit());
|
||||
control
|
||||
.valueProperty()
|
||||
.addListener(
|
||||
(o, a, b) -> {
|
||||
if (!loading && b == Control.Label) direction.setValue("view");
|
||||
configureEditors();
|
||||
edit();
|
||||
});
|
||||
direction.valueProperty().addListener((o, a, b) -> edit());
|
||||
for (var box : List.of(component, converter)) {
|
||||
box.valueProperty()
|
||||
.addListener(
|
||||
(o, a, b) -> {
|
||||
if (!loading && b != null) box.getEditor().setText(b);
|
||||
edit();
|
||||
});
|
||||
box.getEditor().textProperty().addListener((o, a, b) -> edit());
|
||||
}
|
||||
Path reactor = launch.configuration().getParent().toRealPath();
|
||||
changes =
|
||||
host.documents()
|
||||
.onChange(
|
||||
uri -> {
|
||||
if (Path.of(uri).startsWith(reactor)) fx(this::invalidate);
|
||||
});
|
||||
snapshot()
|
||||
.thenAccept(
|
||||
d ->
|
||||
fx(
|
||||
() -> {
|
||||
if (!model.getText().isBlank()) return;
|
||||
var m =
|
||||
java.util.regex.Pattern.compile("<\\?neitzel-model\\s+([^?\\s]+)\\s*\\?>")
|
||||
.matcher(d.text());
|
||||
if (m.find()) model.setText(m.group(1));
|
||||
}));
|
||||
}
|
||||
|
||||
private CompletionStage<DocumentSnapshot> snapshot() {
|
||||
return host.documents()
|
||||
.open(origin.toUri())
|
||||
.thenCompose(d -> d.snapshot().whenComplete((s, e) -> d.close()));
|
||||
}
|
||||
|
||||
private static DocumentSnapshot proposed(DocumentSnapshot base, String xml) {
|
||||
return new DocumentSnapshot(base.document(), base.revision(), xml, true, true);
|
||||
}
|
||||
|
||||
private void configureEditors() {
|
||||
var c = control.getValue();
|
||||
boolean collection = c == Control.Repeat || c == Control.Table;
|
||||
direction.setDisable(collection);
|
||||
converter.setDisable(collection || c == Control.Component);
|
||||
component.setDisable(!collection && c != Control.Component);
|
||||
columns.setDisable(c != Control.Table);
|
||||
}
|
||||
|
||||
private void openComponent() {
|
||||
workflow
|
||||
.componentSource(component.getEditor().getText().trim())
|
||||
.thenCompose(uri -> host.navigation().open(new HostServices.Location(uri, 0)))
|
||||
.whenComplete(
|
||||
(v, e) -> {
|
||||
if (e != null) fx(() -> fail(e));
|
||||
});
|
||||
}
|
||||
|
||||
private void show(Field f) {
|
||||
if (loading || f == null) return;
|
||||
loading = true;
|
||||
try {
|
||||
include.setSelected(f.included());
|
||||
label.setText(f.label());
|
||||
control.setValue(f.control());
|
||||
direction.setValue(f.direction());
|
||||
component.getEditor().setText(f.component());
|
||||
converter.getEditor().setText(f.converter());
|
||||
columns.setText(
|
||||
String.join(
|
||||
"\n",
|
||||
f.columns().stream()
|
||||
.map(c -> c.attribute() + " | " + c.header() + " | " + c.width())
|
||||
.toList()));
|
||||
} finally {
|
||||
loading = false;
|
||||
configureEditors();
|
||||
}
|
||||
}
|
||||
|
||||
private List<Column> columns() {
|
||||
return columns
|
||||
.getText()
|
||||
.lines()
|
||||
.filter(s -> !s.isBlank())
|
||||
.map(
|
||||
line -> {
|
||||
var p = line.split("\\|", 3);
|
||||
return new Column(
|
||||
p[0].trim(),
|
||||
p.length > 1 ? p[1].trim() : p[0].trim(),
|
||||
p.length > 2 ? p[2].trim() : "");
|
||||
})
|
||||
.toList();
|
||||
}
|
||||
|
||||
private void edit() {
|
||||
if (loading) return;
|
||||
int index = fields.getSelectionModel().getSelectedIndex();
|
||||
if (index < 0 || control.getValue() == null || direction.getValue() == null) return;
|
||||
var old = fields.getItems().get(index);
|
||||
var next =
|
||||
new Field(
|
||||
old.path(),
|
||||
label.getText(),
|
||||
control.getValue(),
|
||||
direction.getValue(),
|
||||
converter.getEditor().getText().trim(),
|
||||
component.getEditor().getText().trim(),
|
||||
columns(),
|
||||
include.isSelected());
|
||||
loading = true;
|
||||
try {
|
||||
fields.getItems().set(index, next);
|
||||
fields.getSelectionModel().select(index);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
invalidate();
|
||||
}
|
||||
|
||||
private void move(int delta) {
|
||||
int from = fields.getSelectionModel().getSelectedIndex(), to = from + delta;
|
||||
if (from < 0 || to < 0 || to >= fields.getItems().size()) return;
|
||||
Collections.swap(fields.getItems(), from, to);
|
||||
fields.getSelectionModel().select(to);
|
||||
invalidate();
|
||||
}
|
||||
|
||||
private void discover() {
|
||||
try {
|
||||
String seed = ViewGeneration.seed(model.getText().trim(), controller.getText().trim());
|
||||
long ticket = ++version;
|
||||
reviewed = null;
|
||||
create.setDisable(true);
|
||||
status.setText("Discovering Model fields…");
|
||||
snapshot()
|
||||
.thenCompose(d -> workflow.bindings(false, proposed(d, seed), "", "both"))
|
||||
.whenComplete(
|
||||
(m, e) ->
|
||||
fx(
|
||||
() -> {
|
||||
if (ticket != version) return;
|
||||
if (e != null) {
|
||||
fail(e);
|
||||
return;
|
||||
}
|
||||
fields.getItems().setAll(ViewGeneration.propose(m));
|
||||
if (!fields.getItems().isEmpty()) fields.getSelectionModel().selectFirst();
|
||||
status.setText(
|
||||
"Discovered "
|
||||
+ fields.getItems().size()
|
||||
+ " fields. Nested Models need a Component; numeric text needs a"
|
||||
+ " Converter.\n"
|
||||
+ String.join("\n", m.diagnostics()));
|
||||
}));
|
||||
} catch (Exception e) {
|
||||
fail(e);
|
||||
}
|
||||
}
|
||||
|
||||
private void choices() {
|
||||
int index = fields.getSelectionModel().getSelectedIndex();
|
||||
if (index < 0) return;
|
||||
var field = fields.getItems().get(index);
|
||||
String xml;
|
||||
try {
|
||||
var only = new ArrayList<Field>();
|
||||
for (int i = 0; i < fields.getItems().size(); i++) {
|
||||
var f = fields.getItems().get(i);
|
||||
only.add(
|
||||
new Field(
|
||||
f.path(),
|
||||
f.label(),
|
||||
f.control(),
|
||||
f.direction(),
|
||||
f.converter(),
|
||||
f.component(),
|
||||
f.columns(),
|
||||
i == index));
|
||||
}
|
||||
xml = ViewGeneration.generate(model.getText().trim(), controller.getText().trim(), only);
|
||||
} catch (Exception ignored) {
|
||||
try {
|
||||
xml = ViewGeneration.seed(model.getText().trim(), controller.getText().trim());
|
||||
} catch (Exception error) {
|
||||
fail(error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
String proposal = xml;
|
||||
long ticket = version;
|
||||
snapshot()
|
||||
.thenCompose(
|
||||
d ->
|
||||
workflow.bindingDetails(
|
||||
false,
|
||||
proposed(d, proposal),
|
||||
field.path(),
|
||||
field.direction(),
|
||||
ViewGeneration.id(index),
|
||||
field.control().property()))
|
||||
.whenComplete(
|
||||
(m, e) ->
|
||||
fx(
|
||||
() -> {
|
||||
if (ticket != version) return;
|
||||
if (e != null) {
|
||||
fail(e);
|
||||
return;
|
||||
}
|
||||
loading = true;
|
||||
try {
|
||||
String selected = component.getEditor().getText(),
|
||||
converted = converter.getEditor().getText();
|
||||
var candidates =
|
||||
field.control() == Control.Table ? m.tableRows() : m.components();
|
||||
component
|
||||
.getItems()
|
||||
.setAll(
|
||||
candidates.stream()
|
||||
.map(BindingMetadata.RepeatOption::name)
|
||||
.toList());
|
||||
converter
|
||||
.getItems()
|
||||
.setAll(
|
||||
m.converters().stream()
|
||||
.map(BindingMetadata.Converter::name)
|
||||
.toList());
|
||||
component.getEditor().setText(selected);
|
||||
converter.getEditor().setText(converted);
|
||||
choices.setText(
|
||||
String.join("\n", candidates.stream().map(Object::toString).toList())
|
||||
+ "\n"
|
||||
+ String.join(
|
||||
"\n", m.converters().stream().map(Object::toString).toList())
|
||||
+ "\nColumns: "
|
||||
+ m.tableColumns());
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
private String filename() {
|
||||
String name = destination.getText().trim();
|
||||
if (!name.matches("[A-Za-z_][A-Za-z0-9_.-]*\\.fxml"))
|
||||
throw new IllegalArgumentException("Choose a new sibling .fxml filename (no directories)");
|
||||
return name;
|
||||
}
|
||||
|
||||
private void review() {
|
||||
reviewed = null;
|
||||
create.setDisable(true);
|
||||
try {
|
||||
String name = filename();
|
||||
var selected = List.copyOf(fields.getItems());
|
||||
if (selected.stream().noneMatch(Field::included))
|
||||
throw new IllegalArgumentException("Include at least one Attribute");
|
||||
String xml =
|
||||
ViewGeneration.generate(model.getText().trim(), controller.getText().trim(), selected);
|
||||
preview.setText(xml);
|
||||
layoutPreview.getChildren().clear();
|
||||
int row = 0;
|
||||
for (var f : selected)
|
||||
if (f.included()) {
|
||||
boolean section =
|
||||
Set.of(Control.Component, Control.Repeat, Control.Table, Control.ListView)
|
||||
.contains(f.control());
|
||||
layoutPreview.add(new Label(f.label()), 0, row, section ? 2 : 1, 1);
|
||||
javafx.scene.Node sample =
|
||||
switch (f.control()) {
|
||||
case TextField -> {
|
||||
var value = new TextField();
|
||||
value.setPromptText(f.path());
|
||||
value.setEditable(false);
|
||||
yield value;
|
||||
}
|
||||
case CheckBox -> {
|
||||
var value = new CheckBox();
|
||||
value.setMouseTransparent(true);
|
||||
yield value;
|
||||
}
|
||||
default ->
|
||||
new Label(
|
||||
"["
|
||||
+ f.control()
|
||||
+ (f.component().isBlank() ? "" : " · " + f.component())
|
||||
+ "]");
|
||||
};
|
||||
layoutPreview.add(sample, section ? 0 : 1, section ? ++row : row, section ? 2 : 1, 1);
|
||||
row++;
|
||||
}
|
||||
long ticket = ++version;
|
||||
status.setText("Validating proposed FXML in target runtime…");
|
||||
snapshot()
|
||||
.thenCompose(
|
||||
d ->
|
||||
workflow
|
||||
.currentBuildId(d.document())
|
||||
.thenCompose(
|
||||
build ->
|
||||
workflow
|
||||
.bindings(false, proposed(d, xml), "", "both")
|
||||
.thenCompose(
|
||||
m ->
|
||||
workflow
|
||||
.currentBuildId(d.document())
|
||||
.thenApply(
|
||||
now -> {
|
||||
if (!build.equals(now))
|
||||
throw new CancellationException(
|
||||
"Build changed during review");
|
||||
return new CheckedReview(
|
||||
new Review(
|
||||
xml, name, build, d.revision(), ticket),
|
||||
m);
|
||||
}))))
|
||||
.whenComplete(
|
||||
(result, e) ->
|
||||
fx(
|
||||
() -> {
|
||||
if (ticket != version) return;
|
||||
if (e != null) {
|
||||
fail(e);
|
||||
return;
|
||||
}
|
||||
var m = result.metadata();
|
||||
boolean invalid =
|
||||
m.problems().stream().anyMatch(p -> p.severity().equals("ERROR"));
|
||||
status.setText(
|
||||
(invalid ? "Review blocked" : "Review ready")
|
||||
+ "\n"
|
||||
+ String.join(
|
||||
"\n",
|
||||
m.problems().stream()
|
||||
.map(p -> p.severity() + ": " + p.message())
|
||||
.toList()));
|
||||
if (!invalid) {
|
||||
reviewed = result.review();
|
||||
create.setDisable(false);
|
||||
}
|
||||
}));
|
||||
} catch (Exception e) {
|
||||
fail(e);
|
||||
}
|
||||
}
|
||||
|
||||
private void create() {
|
||||
var accepted = reviewed;
|
||||
if (accepted == null || accepted.version() != version) return;
|
||||
create.setDisable(true);
|
||||
snapshot()
|
||||
.thenCompose(
|
||||
d -> {
|
||||
if (d.revision() != accepted.revision())
|
||||
return CompletableFuture.<String>failedFuture(
|
||||
new CancellationException("Source changed; review again"));
|
||||
return workflow.currentBuildId(d.document());
|
||||
})
|
||||
.whenComplete(
|
||||
(build, e) ->
|
||||
fx(
|
||||
() -> {
|
||||
if (accepted.version() != version) return;
|
||||
if (e != null) {
|
||||
fail(e);
|
||||
return;
|
||||
}
|
||||
if (!accepted.build().equals(build)) {
|
||||
invalidate();
|
||||
status.setText("Build changed; review again");
|
||||
return;
|
||||
}
|
||||
var uri = origin.resolveSibling(accepted.filename()).toUri();
|
||||
setDisable(true);
|
||||
host.documents()
|
||||
.create(uri, accepted.text())
|
||||
.thenCompose(
|
||||
d -> {
|
||||
d.close();
|
||||
return host.navigation().open(new HostServices.Location(uri, 0));
|
||||
})
|
||||
.whenComplete(
|
||||
(v, error) ->
|
||||
fx(
|
||||
() -> {
|
||||
setDisable(false);
|
||||
reviewed = null;
|
||||
version++;
|
||||
create.setDisable(true);
|
||||
if (error != null) fail(error);
|
||||
else
|
||||
status.setText(
|
||||
"Created and opened "
|
||||
+ accepted.filename()
|
||||
+ ". Further changes use the normal editor; Build"
|
||||
+ " and Refresh before target preview.");
|
||||
}));
|
||||
}));
|
||||
}
|
||||
|
||||
public void close() {
|
||||
closed = true;
|
||||
workflow.close();
|
||||
try {
|
||||
changes.close();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -38,11 +38,7 @@ public final class ProjectionHostChecks {
|
||||
}
|
||||
|
||||
private static TextArea source(DesignerPane pane) {
|
||||
return (TextArea)
|
||||
pane.lookupAll(".text-area").stream()
|
||||
.filter(n -> n instanceof TextArea t && t.isEditable())
|
||||
.findFirst()
|
||||
.orElseThrow();
|
||||
return (TextArea) pane.lookup("#fxml-source");
|
||||
}
|
||||
|
||||
private static FXOMInstance object(DesignerPane pane, String id) {
|
||||
@@ -1668,6 +1664,323 @@ public final class ProjectionHostChecks {
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static void generationSelect(DesignerPane pane, String path) throws Exception {
|
||||
fx(
|
||||
() -> {
|
||||
var list =
|
||||
(ListView<de.neitzel.fxdesigner.generation.ViewGeneration.Field>)
|
||||
pane.lookup("#generate-fields");
|
||||
list.getSelectionModel()
|
||||
.select(
|
||||
list.getItems().stream()
|
||||
.filter(f -> f.path().equals(path))
|
||||
.findFirst()
|
||||
.orElseThrow());
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
private static void generationText(DesignerPane pane, String id, String text) throws Exception {
|
||||
fx(
|
||||
() -> {
|
||||
var node = pane.lookup("#generate-" + id);
|
||||
if (node instanceof ComboBox<?> box) box.getEditor().setText(text);
|
||||
else ((TextInputControl) node).setText(text);
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
private static void generationReady(DesignerPane pane) throws Exception {
|
||||
click(pane, "Review generated View");
|
||||
await(
|
||||
() ->
|
||||
fx(
|
||||
() ->
|
||||
((Label) pane.lookup("#generate-status"))
|
||||
.getText()
|
||||
.startsWith("Review ready")));
|
||||
check(fx(() -> !pane.lookup("#generate-create").isDisabled()), "Reviewed creation unavailable");
|
||||
}
|
||||
|
||||
public static void generation(DesignerPane pane, DocumentSession document) throws Exception {
|
||||
String original = documentText(document);
|
||||
bindingTab(pane, "Generate View");
|
||||
generationText(pane, "model", "fixture.model.Person");
|
||||
generationText(pane, "controller", "");
|
||||
click(pane, "Load Model fields");
|
||||
await(() -> fx(() -> ((ListView<?>) pane.lookup("#generate-fields")).getItems().size() == 4));
|
||||
check(
|
||||
fx(
|
||||
() ->
|
||||
((Label) pane.lookup("#generate-status"))
|
||||
.getText()
|
||||
.contains("Generated Model metadata")),
|
||||
"Generator did not use shared generated metadata");
|
||||
click(pane, "Review generated View");
|
||||
check(
|
||||
fx(() -> pane.lookup("#generate-create").isDisabled()),
|
||||
"Unconfigured nested field allowed creation");
|
||||
generationSelect(pane, "home");
|
||||
click(pane, "Discover field choices");
|
||||
await(
|
||||
() ->
|
||||
fx(
|
||||
() ->
|
||||
((TextArea) pane.lookup("#generate-choices"))
|
||||
.getText()
|
||||
.contains(
|
||||
"fixture.components.AddressForm : fixture.model.Address —"
|
||||
+ " COMPATIBLE")));
|
||||
generationText(pane, "component", "fixture.components.AddressForm");
|
||||
generationSelect(pane, "visits");
|
||||
generationText(pane, "component", "fixture.components.AddressForm");
|
||||
generationSelect(pane, "name");
|
||||
generationText(pane, "label", "Name & <display>");
|
||||
click(pane, "Move field up");
|
||||
generationSelect(pane, "rank");
|
||||
click(pane, "Discover field choices");
|
||||
await(
|
||||
() ->
|
||||
fx(
|
||||
() ->
|
||||
((TextArea) pane.lookup("#generate-choices"))
|
||||
.getText()
|
||||
.contains("fixture.components.RankConverter")));
|
||||
click(pane, "Review generated View");
|
||||
await(
|
||||
() ->
|
||||
fx(
|
||||
() ->
|
||||
((Label) pane.lookup("#generate-status"))
|
||||
.getText()
|
||||
.startsWith("Review blocked")));
|
||||
check(
|
||||
fx(() -> pane.lookup("#generate-create").isDisabled()),
|
||||
"Numeric text without a Converter accepted");
|
||||
generationText(pane, "converter", "fixture.components.LegacyRankConverter");
|
||||
generationReady(pane);
|
||||
check(
|
||||
fx(() -> ((Label) pane.lookup("#generate-status")).getText().contains("WARNING")),
|
||||
"Unverified Converter warning hidden");
|
||||
generationText(pane, "converter", "fixture.components.RankConverter");
|
||||
generationReady(pane);
|
||||
String repeat = fx(() -> ((TextArea) pane.lookup("#generate-preview")).getText());
|
||||
check(
|
||||
repeat.contains("<Repeat") && repeat.contains("Name & <display>"),
|
||||
"Repeat/label generation lost choices");
|
||||
check(documentText(document).equals(original), "Review modified the parent View");
|
||||
generationText(pane, "label", "Rank reviewed");
|
||||
check(
|
||||
fx(() -> pane.lookup("#generate-create").isDisabled()),
|
||||
"Edited proposal retained creation approval");
|
||||
generationText(pane, "converter", "missing.Converter");
|
||||
fx(
|
||||
() -> {
|
||||
((CheckBox) pane.lookup("#generate-include")).setSelected(false);
|
||||
return null;
|
||||
});
|
||||
generationReady(pane);
|
||||
check(
|
||||
!fx(() -> ((TextArea) pane.lookup("#generate-preview")).getText())
|
||||
.contains("path=\"rank\""),
|
||||
"Omitted invalid field emitted");
|
||||
generationText(pane, "converter", "fixture.components.RankConverter");
|
||||
fx(
|
||||
() -> {
|
||||
((CheckBox) pane.lookup("#generate-include")).setSelected(true);
|
||||
return null;
|
||||
});
|
||||
generationSelect(pane, "visits");
|
||||
fx(
|
||||
() -> {
|
||||
@SuppressWarnings("unchecked")
|
||||
var control =
|
||||
(ComboBox<de.neitzel.fxdesigner.generation.ViewGeneration.Control>)
|
||||
pane.lookup("#generate-control");
|
||||
control.setValue(de.neitzel.fxdesigner.generation.ViewGeneration.Control.Table);
|
||||
return null;
|
||||
});
|
||||
generationText(pane, "component", "fixture.components.VisitRow");
|
||||
generationText(pane, "columns", "street | Street & road | 160");
|
||||
click(pane, "Discover field choices");
|
||||
await(
|
||||
() ->
|
||||
fx(
|
||||
() ->
|
||||
((TextArea) pane.lookup("#generate-choices"))
|
||||
.getText()
|
||||
.contains("fixture.components.VisitRow")));
|
||||
generationReady(pane);
|
||||
String table = fx(() -> ((TextArea) pane.lookup("#generate-preview")).getText());
|
||||
check(
|
||||
table.contains("<Table") && table.contains("attribute=\"street\""),
|
||||
"Table generation lost Columns");
|
||||
// Load both reviewed variants through the actual target-runtime sample provider.
|
||||
for (String xml : List.of(repeat, table)) {
|
||||
set(document, xml);
|
||||
await(() -> fx(() -> source(pane).getText().equals(xml)));
|
||||
fx(
|
||||
() -> {
|
||||
for (var n : pane.lookupAll(".combo-box"))
|
||||
if (n instanceof ComboBox<?> box) {
|
||||
for (Object value : box.getItems())
|
||||
if (value
|
||||
instanceof de.neitzel.fxdesigner.workflow.DesignerWorkflow.Example example
|
||||
&& example.id().equals("standard")) {
|
||||
@SuppressWarnings("unchecked")
|
||||
var examples = (ComboBox<Object>) box;
|
||||
examples.setValue(example);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
});
|
||||
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()))));
|
||||
check(pane.interactivePid() > 0, "Generated View did not load in target worker");
|
||||
click(pane, "Stop preview");
|
||||
await(() -> pane.interactivePid() < 0);
|
||||
}
|
||||
set(document, original);
|
||||
await(() -> fx(() -> source(pane).getText().equals(original)));
|
||||
document
|
||||
.save(document.snapshot().toCompletableFuture().get().revision())
|
||||
.toCompletableFuture()
|
||||
.get();
|
||||
bindingTab(pane, "Generate View");
|
||||
generationText(pane, "file", "../escape.fxml");
|
||||
click(pane, "Review generated View");
|
||||
check(fx(() -> pane.lookup("#generate-create").isDisabled()), "Traversal filename accepted");
|
||||
generationText(pane, "file", "ReviewedView.fxml");
|
||||
generationReady(pane);
|
||||
set(document, original + "<!-- review invalidation -->");
|
||||
await(() -> fx(() -> pane.lookup("#generate-create").isDisabled()));
|
||||
set(document, original);
|
||||
await(() -> fx(() -> source(pane).getText().equals(original)));
|
||||
document
|
||||
.save(document.snapshot().toCompletableFuture().get().revision())
|
||||
.toCompletableFuture()
|
||||
.get();
|
||||
generationReady(pane);
|
||||
String reviewed = fx(() -> ((TextArea) pane.lookup("#generate-preview")).getText());
|
||||
click(pane, "Create reviewed View");
|
||||
await(
|
||||
() ->
|
||||
fx(
|
||||
() ->
|
||||
((Label) pane.lookup("#generate-status"))
|
||||
.getText()
|
||||
.startsWith("Created and opened ReviewedView.fxml")));
|
||||
var path =
|
||||
java.nio.file.Path.of(document.snapshot().toCompletableFuture().get().document())
|
||||
.resolveSibling("ReviewedView.fxml");
|
||||
check(
|
||||
java.nio.file.Files.readString(path).equals(reviewed),
|
||||
"Created bytes differ from reviewed FXML");
|
||||
check(documentText(document).equals(original), "Creation changed parent");
|
||||
try (var created =
|
||||
pane.kit().host().documents().open(path.toUri()).toCompletableFuture().get()) {
|
||||
var before = created.snapshot().toCompletableFuture().get();
|
||||
String edited = before.text() + "<!-- normal generated-View edit -->";
|
||||
set(created, edited);
|
||||
check(
|
||||
created.undo().toCompletableFuture().get().text().equals(before.text()),
|
||||
"Generated View Undo lost source");
|
||||
check(
|
||||
created.redo().toCompletableFuture().get().text().equals(edited),
|
||||
"Generated View Redo lost source");
|
||||
created
|
||||
.save(created.snapshot().toCompletableFuture().get().revision())
|
||||
.toCompletableFuture()
|
||||
.get();
|
||||
try {
|
||||
pane.kit().host().documents().create(path.toUri(), "overwrite").toCompletableFuture().get();
|
||||
throw new AssertionError("Generated View overwritten");
|
||||
} catch (ExecutionException expected) {
|
||||
check(
|
||||
expected.getCause() instanceof java.nio.file.FileAlreadyExistsException,
|
||||
"Wrong collision failure");
|
||||
}
|
||||
check(java.nio.file.Files.readString(path).equals(edited), "Collision changed existing file");
|
||||
}
|
||||
// Add an ordinary sample descriptor in this disposable fixture, so the newly
|
||||
// created resource itself (not only an overlay at the parent URI) is loaded.
|
||||
var launch = (de.neitzel.fxdesigner.workflow.HostLaunch) field(pane, "launch");
|
||||
var root = launch.configuration().getParent();
|
||||
var catalog =
|
||||
root.resolve("app/src/test/resources/META-INF/neitzel-fx-designer/examples.properties");
|
||||
if (!java.nio.file.Files.exists(catalog))
|
||||
catalog =
|
||||
root.resolve(
|
||||
"preview/src/main/resources/META-INF/neitzel-fx-designer/examples.properties");
|
||||
String catalogText = java.nio.file.Files.readString(catalog);
|
||||
var properties = new java.util.Properties();
|
||||
properties.load(new java.io.StringReader(catalogText));
|
||||
int count = Integer.parseInt(properties.getProperty("count"));
|
||||
String key = "example." + count + ".";
|
||||
catalogText =
|
||||
catalogText.replaceFirst("(?m)^count=\\d+", "count=" + (count + 1))
|
||||
+ "\n"
|
||||
+ key
|
||||
+ "id=reviewed\n"
|
||||
+ key
|
||||
+ "label=Reviewed View\n"
|
||||
+ key
|
||||
+ "modelType=fixture.model.Person\n"
|
||||
+ key
|
||||
+ "providerClass=fixture.preview.Examples\n"
|
||||
+ key
|
||||
+ "viewResource=fixture/components/ReviewedView.fxml\n"
|
||||
+ key
|
||||
+ "componentPackage=fixture.components\n";
|
||||
java.nio.file.Files.writeString(catalog, catalogText);
|
||||
click(pane, "Build and Refresh");
|
||||
var build = (de.neitzel.fxdesigner.build.BuildRefresh.Run) fx(() -> field(pane, "buildRun"));
|
||||
check(
|
||||
build.completion().toCompletableFuture().get(90, TimeUnit.SECONDS).status()
|
||||
== de.neitzel.fxdesigner.build.BuildRefresh.Status.READY,
|
||||
"Build after new View failed");
|
||||
await(
|
||||
() ->
|
||||
fx(
|
||||
() ->
|
||||
object(pane, "card").getSceneGraphObject() instanceof SnapshotRegion r
|
||||
&& r.image() != null
|
||||
&& r.statusText().equals("Target runtime")));
|
||||
try (var generatedWorkflow =
|
||||
new de.neitzel.fxdesigner.workflow.DesignerWorkflow(
|
||||
pane.kit().host(),
|
||||
launch.configuration(),
|
||||
launch.context(),
|
||||
launch.maven(),
|
||||
launch.tools(),
|
||||
s -> {});
|
||||
var created =
|
||||
pane.kit().host().documents().open(path.toUri()).toCompletableFuture().get()) {
|
||||
generatedWorkflow
|
||||
.interactive(false, "reviewed", List.of(created.snapshot().toCompletableFuture().get()))
|
||||
.toCompletableFuture()
|
||||
.get(30, TimeUnit.SECONDS);
|
||||
check(
|
||||
generatedWorkflow.renderingPid() > 0,
|
||||
"New generated resource did not load through NeitzelLib");
|
||||
}
|
||||
System.out.println(
|
||||
"PASS reviewed View generation: metadata, choices, invalid/omitted fields, Repeat/Table"
|
||||
+ " target loading, immutable review, create/open, collision protection, normal"
|
||||
+ " Undo/Redo and rebuild");
|
||||
}
|
||||
|
||||
public static void run(DesignerPane pane, DocumentSession document) throws Exception {
|
||||
renderRevisionRace(pane, document);
|
||||
String original = document.snapshot().toCompletableFuture().get().text();
|
||||
@@ -2046,6 +2359,7 @@ public final class ProjectionHostChecks {
|
||||
object(pane, "card").getSceneGraphObject() instanceof SnapshotRegion r
|
||||
&& r.image() != null
|
||||
&& r.statusText().equals("Target runtime")));
|
||||
generation(pane, document);
|
||||
System.out.println(
|
||||
"PASS shared projection: exact source, picking/resize/relocation, nested/FQ/anonymous"
|
||||
+ " Components, declared properties, selection, undo/redo, modes, target load,"
|
||||
|
||||
@@ -56,6 +56,16 @@ public final class FileDocuments implements HostServices.Documents, AutoCloseabl
|
||||
return future;
|
||||
}
|
||||
|
||||
public CompletionStage<DocumentSession> create(URI uri, String text) {
|
||||
return submit(
|
||||
() -> {
|
||||
Files.writeString(
|
||||
Path.of(uri), text, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE);
|
||||
return uri;
|
||||
})
|
||||
.thenCompose(this::open);
|
||||
}
|
||||
|
||||
public CompletionStage<DocumentSession> open(URI uri) {
|
||||
return submit(
|
||||
() -> {
|
||||
|
||||
+18
@@ -12,6 +12,24 @@ import org.junit.jupiter.api.io.TempDir;
|
||||
class FileDocumentsTest {
|
||||
@TempDir Path folder;
|
||||
|
||||
@Test
|
||||
void creationNeverOverwritesAndTheNewDocumentHasNormalHistory() throws Exception {
|
||||
var uri = folder.resolve("New.fxml").toUri();
|
||||
try (var docs = new FileDocuments();
|
||||
var d = docs.create(uri, "before").toCompletableFuture().get()) {
|
||||
assertThrows(
|
||||
ExecutionException.class,
|
||||
() -> docs.create(uri, "overwrite").toCompletableFuture().get());
|
||||
var changed =
|
||||
d.apply(new EditBatch(0, "edit", List.of(new EditBatch.Replacement(0, 6, "after"))))
|
||||
.toCompletableFuture()
|
||||
.get();
|
||||
assertEquals("before", d.undo().toCompletableFuture().get().text());
|
||||
assertEquals("after", d.redo().toCompletableFuture().get().text());
|
||||
assertEquals("before", Files.readString(Path.of(uri)));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void sharedDraftHistorySurvivesClosingOneHandleAndUndoNeverReusesRevision() throws Exception {
|
||||
Path file = folder.resolve("View.fxml");
|
||||
|
||||
+41
-1
@@ -57,11 +57,35 @@ public final class KitWorkflowSmoke {
|
||||
started.await();
|
||||
Platform.setImplicitExit(false);
|
||||
var launch = HostLaunch.load(Path.of(args[0]));
|
||||
var application = new DesignerApplication();
|
||||
var launchField = DesignerApplication.class.getDeclaredField("launch");
|
||||
launchField.setAccessible(true);
|
||||
launchField.set(application, launch);
|
||||
var hostField = DesignerApplication.class.getDeclaredField("host");
|
||||
hostField.setAccessible(true);
|
||||
var windowsField = DesignerApplication.class.getDeclaredField("windows");
|
||||
windowsField.setAccessible(true);
|
||||
windowsField.set(application, 1);
|
||||
var navigate =
|
||||
DesignerApplication.class.getDeclaredMethod(
|
||||
"navigate", de.neitzel.fxdesigner.host.HostServices.Location.class, boolean.class);
|
||||
navigate.setAccessible(true);
|
||||
try (var host =
|
||||
new StandaloneHost(
|
||||
launch.runtimes(),
|
||||
l -> CompletableFuture.completedFuture(null),
|
||||
l -> {
|
||||
if (!l.document().toString().endsWith("ReviewedView.fxml"))
|
||||
return CompletableFuture.completedFuture(null);
|
||||
try {
|
||||
@SuppressWarnings("unchecked")
|
||||
var result = (CompletableFuture<Void>) navigate.invoke(application, l, false);
|
||||
return result;
|
||||
} catch (Exception e) {
|
||||
return CompletableFuture.failedFuture(e);
|
||||
}
|
||||
},
|
||||
d -> System.out.println(d.message()))) {
|
||||
hostField.set(application, host);
|
||||
fx(
|
||||
() -> {
|
||||
pane = new DesignerPane(host.services(), launch, Path.of(args[1]));
|
||||
@@ -111,6 +135,17 @@ public final class KitWorkflowSmoke {
|
||||
.toList()));
|
||||
throw new AssertionError("Shared Kit did not render target pixels");
|
||||
}
|
||||
if (Boolean.getBoolean("designer.check.generationOnly")) {
|
||||
try (var document =
|
||||
host.services()
|
||||
.documents()
|
||||
.open(Path.of(args[1]).toUri())
|
||||
.toCompletableFuture()
|
||||
.get()) {
|
||||
ProjectionHostChecks.generation(pane, document);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (Boolean.getBoolean("designer.check.racesOnly")) {
|
||||
try (var document =
|
||||
host.services()
|
||||
@@ -288,6 +323,11 @@ public final class KitWorkflowSmoke {
|
||||
} finally {
|
||||
fx(
|
||||
() -> {
|
||||
for (var window : java.util.List.copyOf(javafx.stage.Window.getWindows()))
|
||||
if (window instanceof Stage child
|
||||
&& child != stage
|
||||
&& child.getScene() != null
|
||||
&& child.getScene().getRoot() instanceof DesignerPane) child.close();
|
||||
if (pane != null) pane.close();
|
||||
if (fallback != null) fallback.close();
|
||||
if (stage != null) stage.close();
|
||||
|
||||
+5
-1
@@ -401,6 +401,10 @@ public final class BindingDiscovery {
|
||||
var tables = new TableDiscovery(loader, repeats, modelType, owner);
|
||||
var problems = new ArrayList<>(validate(doc, imports, ids, converters));
|
||||
problems.addAll(tables.validate(doc));
|
||||
var componentOptions = repeats.candidates(selected);
|
||||
for (var a : attributes)
|
||||
if (a.path().equals(selected) && a.kind().equals("COMPLEX"))
|
||||
componentOptions = repeats.modelCandidates(a.type());
|
||||
return new BindingMetadata(
|
||||
model,
|
||||
selected,
|
||||
@@ -414,7 +418,7 @@ public final class BindingDiscovery {
|
||||
owner.viewModel == null ? "" : owner.viewModel.getName(),
|
||||
owner.methods(chosen == null ? null : chosen.type()),
|
||||
repeats.collections(),
|
||||
repeats.candidates(selected),
|
||||
componentOptions,
|
||||
repeats.panes(),
|
||||
tables.collections(),
|
||||
tables.rows(selected),
|
||||
|
||||
@@ -115,6 +115,11 @@ final class RepeatDiscovery {
|
||||
.toList();
|
||||
}
|
||||
|
||||
List<RepeatOption> modelCandidates(String type) {
|
||||
var source = new RepeatOption("", type, "COMPATIBLE", "Nested Model type");
|
||||
return names.stream().map(n -> component(n, source)).toList();
|
||||
}
|
||||
|
||||
List<RepeatOption> candidates(String path) {
|
||||
return names.stream().map(n -> component(n, path)).toList();
|
||||
}
|
||||
|
||||
@@ -159,3 +159,7 @@ Qualification: automated standalone and registered IntelliJ classpath workflows
|
||||
## IntelliJ save/startup reliability gate
|
||||
|
||||
The [reliability follow-up](ide-host-reliability.md) closes the generated-ViewModel slice's remaining IntelliJ module-path gate. Explicit saves now await per-file VFS persistence, native saves update the accepted baseline, and opening/refreshing a document accounts for queued writes. Host revision synchronization clears obsolete validation and schedules current rendering. Controlled red/green regressions and three consecutive fresh registered module-path runs pass without changing source/history assertions or timeouts. Previously skipped human checks remain skipped.
|
||||
|
||||
## Reviewed FXML View generation
|
||||
|
||||
The [reviewed generation slice](reviewed-view-generation.md) adds a shared metadata-driven form setup, structural/FXML preview, target validation and non-overwriting creation in both hosts. Explicit field, Component, Converter and Table/Column choices produce a new normal editable View. Approval is invalidated by changed drafts or choices and checked against the current build before creation. Broader scaffolding, generation settings and product packaging retain their follow-up scope. Automated qualification is recorded in the slice document.
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
# Reviewed FXML View generation
|
||||
|
||||
Both hosts now offer **Bindings → Generate View**, using the same Model metadata and target-side compatibility validation as the binding inspectors. Generation creates a new editable sibling FXML document. It never replaces an existing file or establishes a regeneration relationship.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Build and Refresh the application. Enter the qualified Model class (initially taken from the current View) and optionally an existing Controller class; select **Load Model fields**.
|
||||
2. Include or omit Attributes, reorder them, and adjust labels, controls and binding directions. **Discover field choices** shows compatible Components/Rows, Converter capabilities and Table Column information. Explicit selections are retained.
|
||||
3. For a nested Model, choose an existing compatible Component. For Model collections choose Repeat or Table, supply the existing Component/Row and, for a Table, ordered `attribute | header | optional width` lines. **Open selected Component / Row** uses normal host navigation. Simple collections can use ListView.
|
||||
4. Choose a new sibling `.fxml` filename and select **Review generated View**. The panel shows the proposed structure and exact FXML. Target-side binding errors block creation; warnings, including unverified Converter capabilities, remain visible and permit creation. Omitted fields do not need valid configuration.
|
||||
5. **Create reviewed View** writes exactly the reviewed text and opens it through normal host navigation: a standalone editor window or an IntelliJ editor tab. Subsequent editing, saving and Undo/Redo follow the ordinary document workflow. Build and Refresh includes the new resource in the target snapshot before rendering or interactive preview.
|
||||
|
||||
Scalar fields form a two-column GridPane. Nested Components and collection controls occupy separate full-width sections. Boolean fields default to CheckBox; other scalar fields default to TextField, with an explicit Converter required where types differ. A blank Controller uses NeitzelLib's existing declarative fallback. Supplying an existing Controller uses the shared validation rules; generation does not write handler methods.
|
||||
|
||||
## Ownership and validation
|
||||
|
||||
`ViewGeneration` in core deterministically emits FXML from immutable reviewed field choices. The shared Kit pane obtains Attributes, Component compatibility, Converter choices and diagnostics from the target worker. Application classes never enter either host classloader. Selecting a complex Attribute now exposes compatible nested Components through the existing discovery contract; no protocol change is required.
|
||||
|
||||
Review captures its choices, exact FXML, origin revision and current successful build identity. Changing choices or a document in the reactor invalidates approval. Before creation, the origin revision and build identity are checked again. Changed or obsolete builds require another review; generation does not silently use a retained build. The host document service creates with `CREATE_NEW`, so a collision cannot overwrite another file. Creation itself is not a text Undo command; later edits have normal host Undo/Redo.
|
||||
|
||||
The layout preview uses standard host controls and placeholders, not instantiated application Components. Target validation checks declarations without invoking application constructors. Full application behavior still belongs to the target preview workflow. The new document can use an existing compatible sample provider after an example descriptor points to its resource; this slice does not generate sample providers or descriptors.
|
||||
|
||||
## Automated qualification
|
||||
|
||||
The shared `ProjectionHostChecks.generation` scenario runs in native standalone and registered IntelliJ editors, against both classpath and named-module applications. It checks generated metadata provenance, nested Component and Converter choices, blocked incomplete configuration, omitted fields, visible permissive warnings, ordering, escaped labels, review invalidation, filename constraints, exact creation, navigation, collision refusal and document Undo/Redo. Repeat and Table forms are loaded by real NeitzelLib previews. After creation and Build and Refresh, an example descriptor in the disposable fixture loads the generated View at its own new resource path in a fresh target session.
|
||||
|
||||
The standalone check uses production editor navigation for the created file. IntelliJ uses the registered editor and native document service. Core unit tests cover deterministic XML, omission, identifier and XML-character validation, Table Columns and proposal defaults; both document adapters test non-overwriting creation and edit history.
|
||||
|
||||
Run the full bootstrap and the registered IDE qualification described in the README. A focused native module-path generation check is also available after the modules and smoke classpath have been built:
|
||||
|
||||
```sh
|
||||
python3 scripts/check-view-generation.py --java-home "$JAVA_HOME" \
|
||||
--target-java-home "$TARGET_JAVA_HOME"
|
||||
```
|
||||
|
||||
Two qualification issues were corrected during implementation: the new IntelliJ creation test needed the canonical macOS temporary-directory path in its allowed test roots; and the new shared test must use the source editor's existing `fxml-source` identifier rather than replacing it and breaking native quick-fix lookup. Neither correction weakens collision, history or quick-fix assertions. The initial failures are retained in `/tmp/view-generation-bootstrap.log` and `target/ide-workflow/runs-3mg_cwau`.
|
||||
|
||||
Qualification on 2026-11-13 completed all bootstrap gates: 158 unit/adapter tests with zero failures/errors/skips, bytecode and plugin dependency boundaries, 38 target processes with 548 checks, both native standalone workflows, 48 worker lifecycle cycles and abrupt owner death. After the source-selector correction, a fresh registered IntelliJ classpath run and a fresh module-path run both passed the complete shared workflow, native diagnostics/fixes, exclusive JavaFX 26 native-library checks and worker shutdown. A final focused standalone generation run also qualifies the corrected selector.
|
||||
|
||||
Evidence: `/tmp/view-generation-bootstrap.log` (Maven/runtime checks and the initial test-fixture failure), `/tmp/view-generation-ide-unit.log` (corrected adapter tests and packaging), `/tmp/view-generation-connected.log` and `target/connected-workflow` (both native standalone workflows, lifecycle and document adapters), `/tmp/view-generation-selector-build.log`, `/tmp/view-generation-selector-plugin.log`, `/tmp/view-generation-ide-final.log` and `target/ide-workflow/runs-mzrxahsf` (corrected build and registered IDE qualification), and `/tmp/view-generation-standalone-final.log` (final focused standalone run). These are disposable local records, not release artifacts. The pinned macOS/IDE/JavaFX combination is qualified; other platforms and versions are not established by these runs.
|
||||
|
||||
No manual testing was requested or substituted; previously skipped human checks stay skipped.
|
||||
|
||||
## Scope boundaries
|
||||
|
||||
This is an initial reviewed form generator, not a round-trip regeneration engine. The destination is a sibling resource, and class names currently use qualified top-level Java names. The control set is TextField, Label, CheckBox, ListView, Component, Repeat and Table. Existing Components and Controllers are selected, not generated. Computed bindings, event handlers, new Component scaffolding, arbitrary output directories, sample-provider generation and dedicated Java ViewModel generation-settings UI remain follow-up work. Existing binding inspectors can refine the resulting View normally.
|
||||
@@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Exercise reviewed View generation against a real module-path application."""
|
||||
import argparse
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
p = argparse.ArgumentParser(description=__doc__)
|
||||
p.add_argument('--java-home', type=Path, required=True)
|
||||
p.add_argument('--target-java-home', type=Path, required=True)
|
||||
args = p.parse_args()
|
||||
classpath = os.pathsep.join([
|
||||
str(ROOT / 'designer-standalone/target/test-classes'),
|
||||
str(ROOT / 'designer-standalone/target/classes'),
|
||||
(ROOT / 'designer-standalone/target/smoke-classpath.txt').read_text().strip()])
|
||||
with tempfile.TemporaryDirectory(prefix='designer-view-generation-') as tmp:
|
||||
fixture = Path(tmp) / 'project'
|
||||
subprocess.run([sys.executable, ROOT / 'scripts/prepare-fixture-host.py',
|
||||
'--destination', fixture, '--mode', 'modulepath',
|
||||
'--target-java-home', args.target_java_home], check=True)
|
||||
subprocess.run([args.java_home / 'bin/java', '--enable-native-access=ALL-UNNAMED',
|
||||
'-Ddesigner.check.generationOnly=true', '-cp', classpath,
|
||||
'de.neitzel.fxdesigner.standalone.KitWorkflowSmoke',
|
||||
fixture / '.neitzel-fx-designer/host.properties',
|
||||
fixture / 'app/src/main/resources/fixture/components/Person.fxml',
|
||||
Path(tmp) / 'unused.png'], check=True, timeout=180, cwd=ROOT)
|
||||
Reference in New Issue
Block a user