diff --git a/.gitattributes b/.gitattributes
index ba049f8..456fdb4 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -4,3 +4,5 @@ mvnw text eol=lf
mvnw.cmd text eol=crlf
*.md text eol=lf
*.py text eol=lf
+gradlew text eol=lf
+gradlew.bat text eol=crlf
diff --git a/.gitignore b/.gitignore
index 37e1326..ae40462 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,6 @@
# Build output and local designer state
-**/target/
+/target/
+/*/target/
**/build/
.gradle/
.neitzel-fx-designer/
@@ -12,3 +13,5 @@
!.env.example
__pycache__/
*.py[cod]
+
+.intellijPlatform/
diff --git a/README.md b/README.md
index ce7fe7e..06df75d 100644
--- a/README.md
+++ b/README.md
@@ -4,35 +4,29 @@ Shared Scene Builder Kit designer for NeitzelLib FX, with planned standalone and
## Current state
-This initial repository contains the accepted contracts, bootstrap plan, project instructions and a pinned Maven build foundation (`de.neitzel.fxdesigner:neitzel-fx-designer:0.1.0-SNAPSHOT`). It currently has **no application sources, implemented modules, runnable designer or packaged IntelliJ plugin**. The root POM validates the build environment; it does not yet build a UI. The module layout in the contract is planned work.
+The repository builds six shared Maven modules and a separate IntelliJ adapter plugin. Document transactions, native undo, build execution/cancellation, explicit runtime mappings and lifecycle services have initial implementations and tests. The shared UI exposes a Kit construction seam. There is **no runnable designer application or registered IntelliJ designer editor yet**. See [module and host boundaries](docs/module-host-boundaries.md) for the implemented scope and limitations.
The Component builder fix is integrated in NeitzelLib main. A [verified local development snapshot](docs/runtime/neitzellib-baseline.md) is recorded with source/artifact identity; a corrected released version is still unestablished. NeitzelLib sources and prototype implementations are not copied here.
## Validate the foundation
-Requirements: JDK 21 or newer, Python 3.10 or newer, and network access on the first Maven/dependency download. Select the intended JDK through `JAVA_HOME`.
+Requirements: JDK 25 for builds, JDK 21 for target linkage validation, Python 3.10+, the recorded local NeitzelLib artifacts, and IntelliJ IDEA SDK build **263.7307.200**. Downloads require network access. Set `JAVA_HOME` to JDK 25 and supply explicit local paths:
```sh
-python3 scripts/check-bootstrap.py
+python3 scripts/check-bootstrap.py --idea-home "$IDEA_HOME" --target-java-home "$TARGET_JAVA_HOME"
```
-This runs Maven's clean/verify lifecycle through the pinned wrapper and validates local documentation links and illustrative JSON. Maven is pinned to **3.9.11** using Apache Maven Wrapper **3.3.4**. No system Maven installation is needed for this command. On Windows the check invokes `mvnw.cmd`; validation has initially been performed on macOS only.
+On macOS `IDEA_HOME` points to the IDE application's `Contents` directory. The current SDK check uses its macOS product-info layout; other platforms are not qualified. The command checks runtime artifact hashes, clean-builds/installs the Maven modules, tests the adapters, checks bytecode baselines, runs target linkage under the supplied target JDK, builds the plugin ZIP, and validates documentation. It does not install or publish NeitzelLib.
-For just the Maven foundation:
-
-```sh
-./mvnw --batch-mode --no-transfer-progress clean verify
-```
-
-The JDK requirement above is for the bootstrap, not a declaration that all future modules or JavaFX 26 hosts run on Java 21. Target/host and bytecode baselines are established independently in the implementation plan.
+Maven is pinned to **3.9.11** (Wrapper **3.3.4**); the separate IntelliJ build uses **Gradle 9.1.0** and IntelliJ Platform Gradle Plugin **2.20.1**. Wrapper distributions have pinned checksums. Shared-module-only validation is `./mvnw -B -ntp clean verify`; it does not qualify IntelliJ packaging. Plugin output is under `designer-intellij/build/distributions/`.
## Implementation references
- [Initial project contract](docs/initial-project-contract.md): module responsibilities, configuration, snapshots, runtimes, preview sessions and communication.
-- [Repository bootstrap plan](docs/repository-bootstrap-plan.md): ordered work packages and acceptance gates. Next: establish the buildable module and host-service boundaries; the corrected local NeitzelLib baseline is prepared.
+- [Repository bootstrap plan](docs/repository-bootstrap-plan.md): ordered work packages and acceptance gates. Next: make configuration and compatibility executable.
- [Decision provenance](docs/provenance.md): workspace planning and captured prototype evidence.
- [Third-party notices](THIRD-PARTY-NOTICES.md): Maven wrapper provenance.
The canonical checkout is `/Volumes/PlanckSSD/KI/java/NeitzelFXDesigner`. Its Git remote is `ssh://git@gitea.neitzel.de:2222/konrad/NeitzelFXDesigner.git` and its default branch is `main`.
-Validate the installed corrected development runtime separately with `python3 scripts/check-neitzellib-artifact.py`. This requires the recorded local artifacts; the repository foundation check does not install or publish NeitzelLib.
+Validate the installed corrected development runtime separately with `python3 scripts/check-neitzellib-artifact.py`. This requires the recorded local artifacts; the full check also verifies these hashes.
diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md
index 68eed62..5eaacbf 100644
--- a/THIRD-PARTY-NOTICES.md
+++ b/THIRD-PARTY-NOTICES.md
@@ -2,4 +2,6 @@
`mvnw`, `mvnw.cmd` and `.mvn/wrapper/maven-wrapper.properties` are generated using Apache Maven Wrapper Plugin 3.3.4 (only-script distribution). The wrapper scripts retain their Apache Software Foundation copyright/license headers. See the [Apache Maven Wrapper documentation](https://maven.apache.org/tools/wrapper/) and [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0).
-No Scene Builder Kit, IntelliJ, JavaFX, NeitzelLib or prototype source/binary is included in this bootstrap. Track provenance and required notices when dependencies or patches are introduced; this notice does not assign a license to original NeitzelFXDesigner code.
+The Gradle wrapper in `designer-intellij/` is generated by Gradle 9.1.0; its scripts and JAR retain upstream notices (Apache License 2.0). The wrapper distribution checksum is pinned. Build dependencies are resolved from upstream artifact repositories, not copied source trees.
+
+The shared UI depends on Scene Builder Kit 26.0.0 and OpenJFX 26. The IntelliJ adapter builds against the locally supplied IntelliJ SDK with IntelliJ Platform Gradle Plugin 2.20.1. Target tools depend on the separately recorded NeitzelLib development artifacts. Preserve the upstream license/notice resources in packaged dependencies. Product distribution and any patched Kit source import require their separate notice/provenance review; this foundation does not assign a license to original NeitzelFXDesigner code.
diff --git a/designer-contracts/pom.xml b/designer-contracts/pom.xml
new file mode 100644
index 0000000..70919da
--- /dev/null
+++ b/designer-contracts/pom.xml
@@ -0,0 +1,51 @@
+
+
+ 4.0.0
+
+ de.neitzel.fxdesigner
+ neitzel-fx-designer
+ 0.1.0-SNAPSHOT
+
+ designer-contracts
+
+ 21
+
+
+
+ org.junit.jupiter
+ junit-jupiter
+ test
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-enforcer-plugin
+
+
+ module-dependency-boundary
+
+ enforce
+
+
+
+
+ true
+
+ com.intellij:*
+ com.jetbrains:*
+ com.jetbrains.intellij.platform:*
+ com.gluonhq.scenebuilder:*
+ org.openjfx:*
+ de.neitzel.lib:*
+
+
+
+
+
+
+
+
+
+
diff --git a/designer-contracts/src/main/java/de/neitzel/fxdesigner/contracts/DocumentSnapshot.java b/designer-contracts/src/main/java/de/neitzel/fxdesigner/contracts/DocumentSnapshot.java
new file mode 100644
index 0000000..d4b11e9
--- /dev/null
+++ b/designer-contracts/src/main/java/de/neitzel/fxdesigner/contracts/DocumentSnapshot.java
@@ -0,0 +1,14 @@
+package de.neitzel.fxdesigner.contracts;
+
+import java.net.URI;
+import java.util.Objects;
+
+/** Immutable host-owned draft; revision ordering is independent of native undo stamps. */
+public record DocumentSnapshot(
+ URI document, long revision, String text, boolean writable, boolean dirty) {
+ public DocumentSnapshot {
+ Objects.requireNonNull(document);
+ Objects.requireNonNull(text);
+ if (revision < 0) throw new IllegalArgumentException("Negative revision");
+ }
+}
diff --git a/designer-contracts/src/main/java/de/neitzel/fxdesigner/contracts/EditBatch.java b/designer-contracts/src/main/java/de/neitzel/fxdesigner/contracts/EditBatch.java
new file mode 100644
index 0000000..a483620
--- /dev/null
+++ b/designer-contracts/src/main/java/de/neitzel/fxdesigner/contracts/EditBatch.java
@@ -0,0 +1,40 @@
+package de.neitzel.fxdesigner.contracts;
+
+import java.util.*;
+
+/** One named undo transaction. All UTF-16 ranges address the same expected revision. */
+public record EditBatch(long expectedRevision, String name, List replacements) {
+ public record Replacement(int start, int end, String text) {
+ public Replacement {
+ if (start < 0 || end < start) throw new IllegalArgumentException("Invalid range");
+ Objects.requireNonNull(text);
+ }
+ }
+
+ public EditBatch {
+ if (expectedRevision < 0) throw new IllegalArgumentException("Negative revision");
+ if (name == null || name.isBlank()) throw new IllegalArgumentException("Missing command name");
+ replacements = List.copyOf(replacements);
+ }
+
+ /** Validates every range before computing a result; never partially edits a host draft. */
+ public String applyTo(DocumentSnapshot draft) {
+ if (!draft.writable()) throw new IllegalStateException("Document is read-only");
+ if (draft.revision() != expectedRevision)
+ throw new ConcurrentModificationException("Stale document revision");
+ var ordered = new ArrayList<>(replacements);
+ ordered.sort(Comparator.comparingInt(Replacement::start));
+ int previousEnd = -1;
+ for (var r : ordered) {
+ if (r.end() > draft.text().length() || r.start() < previousEnd)
+ throw new IllegalArgumentException("Overlapping/out-of-bounds edits");
+ previousEnd = r.end();
+ }
+ var result = new StringBuilder(draft.text());
+ for (int i = ordered.size() - 1; i >= 0; i--) {
+ var r = ordered.get(i);
+ result.replace(r.start(), r.end(), r.text());
+ }
+ return result.toString();
+ }
+}
diff --git a/designer-contracts/src/test/java/de/neitzel/fxdesigner/contracts/EditBatchTest.java b/designer-contracts/src/test/java/de/neitzel/fxdesigner/contracts/EditBatchTest.java
new file mode 100644
index 0000000..d630906
--- /dev/null
+++ b/designer-contracts/src/test/java/de/neitzel/fxdesigner/contracts/EditBatchTest.java
@@ -0,0 +1,37 @@
+package de.neitzel.fxdesigner.contracts;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.net.URI;
+import java.util.*;
+import org.junit.jupiter.api.Test;
+
+class EditBatchTest {
+ private DocumentSnapshot draft(long r, boolean writable) {
+ return new DocumentSnapshot(URI.create("file:/sample.fxml"), r, "abcdef", writable, false);
+ }
+
+ @Test
+ void multipleRangesUseOriginalOffsets() {
+ var b =
+ new EditBatch(
+ 4,
+ "Rename",
+ List.of(new EditBatch.Replacement(1, 2, "LONG"), new EditBatch.Replacement(4, 6, "!")));
+ assertEquals("aLONGcd!", b.applyTo(draft(4, true)));
+ }
+
+ @Test
+ void rejectsStaleReadOnlyAndOverlappingTransactions() {
+ var b = new EditBatch(3, "Change", List.of(new EditBatch.Replacement(0, 2, "x")));
+ assertThrows(ConcurrentModificationException.class, () -> b.applyTo(draft(4, true)));
+ assertThrows(IllegalStateException.class, () -> b.applyTo(draft(3, false)));
+ var overlap =
+ new EditBatch(
+ 3,
+ "Bad",
+ List.of(new EditBatch.Replacement(1, 4, ""), new EditBatch.Replacement(3, 5, "")));
+ assertThrows(IllegalArgumentException.class, () -> overlap.applyTo(draft(3, true)));
+ assertEquals("abcdef", draft(3, true).text());
+ }
+}
diff --git a/designer-core/pom.xml b/designer-core/pom.xml
new file mode 100644
index 0000000..7e817f5
--- /dev/null
+++ b/designer-core/pom.xml
@@ -0,0 +1,56 @@
+
+
+ 4.0.0
+
+ de.neitzel.fxdesigner
+ neitzel-fx-designer
+ 0.1.0-SNAPSHOT
+
+ designer-core
+
+ 21
+
+
+
+ de.neitzel.fxdesigner
+ designer-contracts
+ ${project.version}
+
+
+ org.junit.jupiter
+ junit-jupiter
+ test
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-enforcer-plugin
+
+
+ module-dependency-boundary
+
+ enforce
+
+
+
+
+ true
+
+ com.intellij:*
+ com.jetbrains:*
+ com.jetbrains.intellij.platform:*
+ com.gluonhq.scenebuilder:*
+ org.openjfx:*
+ de.neitzel.lib:*
+
+
+
+
+
+
+
+
+
+
diff --git a/designer-core/src/main/java/de/neitzel/fxdesigner/host/ConfiguredRuntimes.java b/designer-core/src/main/java/de/neitzel/fxdesigner/host/ConfiguredRuntimes.java
new file mode 100644
index 0000000..16d4edf
--- /dev/null
+++ b/designer-core/src/main/java/de/neitzel/fxdesigner/host/ConfiguredRuntimes.java
@@ -0,0 +1,27 @@
+package de.neitzel.fxdesigner.host;
+
+import java.util.*;
+import java.util.concurrent.*;
+
+/**
+ * Explicit local mappings; no fallback to the host JVM. Discovery/requirements arrive with
+ * configuration.
+ */
+public final class ConfiguredRuntimes implements HostServices.Runtimes {
+ public record Key(String logicalName, HostServices.RuntimeRole role) {}
+
+ private final Map installations;
+
+ public ConfiguredRuntimes(Map installations) {
+ this.installations = Map.copyOf(installations);
+ }
+
+ public CompletionStage resolve(
+ String name, HostServices.RuntimeRole role) {
+ var found = installations.get(new Key(name, role));
+ return found == null
+ ? CompletableFuture.failedFuture(
+ new IllegalStateException("Configure runtime " + name + " for " + role))
+ : CompletableFuture.completedFuture(found);
+ }
+}
diff --git a/designer-core/src/main/java/de/neitzel/fxdesigner/host/DocumentSession.java b/designer-core/src/main/java/de/neitzel/fxdesigner/host/DocumentSession.java
new file mode 100644
index 0000000..cce1122
--- /dev/null
+++ b/designer-core/src/main/java/de/neitzel/fxdesigner/host/DocumentSession.java
@@ -0,0 +1,23 @@
+package de.neitzel.fxdesigner.host;
+
+import de.neitzel.fxdesigner.contracts.*;
+import java.util.concurrent.CompletionStage;
+
+/**
+ * Host draft/undo authority. Futures complete after host-thread work; never block its UI thread.
+ */
+public interface DocumentSession extends AutoCloseable {
+ CompletionStage snapshot();
+
+ CompletionStage apply(EditBatch edits);
+
+ CompletionStage undo();
+
+ CompletionStage redo();
+
+ CompletionStage save(long expectedRevision);
+
+ /** Releases this handle; does not save, discard or close unrelated host documents. */
+ @Override
+ void close();
+}
diff --git a/designer-core/src/main/java/de/neitzel/fxdesigner/host/HostServices.java b/designer-core/src/main/java/de/neitzel/fxdesigner/host/HostServices.java
new file mode 100644
index 0000000..a8ea1c8
--- /dev/null
+++ b/designer-core/src/main/java/de/neitzel/fxdesigner/host/HostServices.java
@@ -0,0 +1,109 @@
+package de.neitzel.fxdesigner.host;
+
+import java.net.URI;
+import java.nio.file.Path;
+import java.util.*;
+import java.util.concurrent.CompletionStage;
+import java.util.function.Consumer;
+
+/** Host integrations supplied to each designer session, with no host SDK types. */
+public record HostServices(
+ Documents documents,
+ Builds builds,
+ Runtimes runtimes,
+ Navigation navigation,
+ Diagnostics diagnostics,
+ SessionScope lifecycle) {
+ public HostServices {
+ Objects.requireNonNull(documents);
+ Objects.requireNonNull(builds);
+ Objects.requireNonNull(runtimes);
+ Objects.requireNonNull(navigation);
+ Objects.requireNonNull(diagnostics);
+ Objects.requireNonNull(lifecycle);
+ }
+
+ public interface Documents {
+ CompletionStage open(URI document);
+ }
+
+ public enum RuntimeRole {
+ BUILD,
+ TARGET
+ }
+
+ public record RuntimeInstallation(Path javaHome, int javaFeature) {
+ public RuntimeInstallation {
+ Objects.requireNonNull(javaHome);
+ if (javaFeature < 1) throw new IllegalArgumentException("Invalid Java feature");
+ }
+ }
+
+ public interface Runtimes {
+ CompletionStage resolve(String logicalName, RuntimeRole role);
+ }
+
+ public record Location(URI document, int offset) {
+ public Location {
+ Objects.requireNonNull(document);
+ if (offset < 0) throw new IllegalArgumentException("Negative offset");
+ }
+ }
+
+ public interface Navigation {
+ CompletionStage open(Location location);
+ }
+
+ public enum Severity {
+ INFO,
+ WARNING,
+ ERROR
+ }
+
+ public record Diagnostic(
+ String operation, Severity severity, String message, Optional location) {
+ public Diagnostic {
+ Objects.requireNonNull(operation);
+ Objects.requireNonNull(severity);
+ Objects.requireNonNull(message);
+ Objects.requireNonNull(location);
+ }
+ }
+
+ public interface Diagnostics {
+ void report(Diagnostic diagnostic);
+ }
+
+ public record BuildRequest(
+ Path directory, List command, Map environment) {
+ public BuildRequest {
+ Objects.requireNonNull(directory);
+ command = List.copyOf(command);
+ environment = Map.copyOf(environment);
+ if (command.isEmpty()) throw new IllegalArgumentException("Empty build command");
+ }
+ }
+
+ public record BuildOutput(boolean stderr, String line) {}
+
+ public record BuildResult(int exitCode, boolean cancelled) {}
+
+ public interface BuildRun extends AutoCloseable {
+ CompletionStage completion();
+
+ void cancel();
+
+ @Override
+ default void close() {
+ cancel();
+ }
+ }
+
+ /**
+ * Executes an already-resolved build plan; Maven resolution/snapshot capture belong to later
+ * steps.
+ */
+ public interface Builds {
+ BuildRun start(BuildRequest request, Consumer output) throws java.io.IOException;
+ }
+}
diff --git a/designer-core/src/main/java/de/neitzel/fxdesigner/host/ProcessBuilds.java b/designer-core/src/main/java/de/neitzel/fxdesigner/host/ProcessBuilds.java
new file mode 100644
index 0000000..a0a910f
--- /dev/null
+++ b/designer-core/src/main/java/de/neitzel/fxdesigner/host/ProcessBuilds.java
@@ -0,0 +1,81 @@
+package de.neitzel.fxdesigner.host;
+
+import java.io.*;
+import java.nio.charset.StandardCharsets;
+import java.util.concurrent.*;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.function.Consumer;
+
+/** Shared process executor used by both adapters; host output is supplied explicitly. */
+public final class ProcessBuilds implements HostServices.Builds {
+ @Override
+ public HostServices.BuildRun start(
+ HostServices.BuildRequest request, Consumer output)
+ throws IOException {
+ var builder = new ProcessBuilder(request.command()).directory(request.directory().toFile());
+ builder.environment().putAll(request.environment());
+ var process = builder.start();
+ var cancelled = new AtomicBoolean();
+ var completion = new CompletableFuture();
+ var out = pump(process.getInputStream(), false, output);
+ var err = pump(process.getErrorStream(), true, output);
+ Thread.ofVirtual()
+ .start(
+ () -> {
+ try {
+ int exit = process.waitFor();
+ out.join();
+ err.join();
+ completion.complete(new HostServices.BuildResult(exit, cancelled.get()));
+ } catch (Exception e) {
+ completion.completeExceptionally(e);
+ }
+ });
+ return new HostServices.BuildRun() {
+ public CompletionStage completion() {
+ return completion;
+ }
+
+ public void cancel() {
+ if (!process.isAlive()) return;
+ cancelled.set(true);
+ var descendants = process.descendants().toList();
+ descendants.forEach(ProcessHandle::destroy);
+ process.destroy();
+ Thread.ofVirtual()
+ .start(
+ () -> {
+ try {
+ if (!process.waitFor(2, TimeUnit.SECONDS)) process.destroyForcibly();
+ descendants.stream()
+ .filter(ProcessHandle::isAlive)
+ .forEach(ProcessHandle::destroyForcibly);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ process.destroyForcibly();
+ }
+ });
+ }
+ };
+ }
+
+ private static Thread pump(
+ InputStream stream, boolean stderr, Consumer output) {
+ return Thread.ofVirtual()
+ .start(
+ () -> {
+ try (var reader =
+ new BufferedReader(new InputStreamReader(stream, StandardCharsets.UTF_8))) {
+ for (String line; (line = reader.readLine()) != null; ) {
+ try {
+ output.accept(new HostServices.BuildOutput(stderr, line));
+ } catch (RuntimeException ignored) {
+ /* A UI callback cannot stop draining the process pipe. */
+ }
+ }
+ } catch (IOException ignored) {
+ /* Process cancellation may close the pipe. */
+ }
+ });
+ }
+}
diff --git a/designer-core/src/main/java/de/neitzel/fxdesigner/host/SessionScope.java b/designer-core/src/main/java/de/neitzel/fxdesigner/host/SessionScope.java
new file mode 100644
index 0000000..5a34798
--- /dev/null
+++ b/designer-core/src/main/java/de/neitzel/fxdesigner/host/SessionScope.java
@@ -0,0 +1,38 @@
+package de.neitzel.fxdesigner.host;
+
+import java.util.ArrayDeque;
+
+/** Session-local resource ownership, closed once in reverse acquisition order. */
+public final class SessionScope implements AutoCloseable {
+ private final ArrayDeque resources = new ArrayDeque<>();
+ private boolean closed;
+
+ public synchronized T own(T resource) {
+ if (closed) {
+ try {
+ resource.close();
+ } catch (Exception e) {
+ throw new IllegalStateException("Closed scope cleanup failed", e);
+ }
+ throw new IllegalStateException("Session closed");
+ }
+ resources.push(resource);
+ return resource;
+ }
+
+ @Override
+ public synchronized void close() {
+ if (closed) return;
+ closed = true;
+ RuntimeException failure = null;
+ while (!resources.isEmpty()) {
+ try {
+ resources.pop().close();
+ } catch (Exception e) {
+ if (failure == null) failure = new IllegalStateException("Session cleanup failed");
+ failure.addSuppressed(e);
+ }
+ }
+ if (failure != null) throw failure;
+ }
+}
diff --git a/designer-core/src/test/java/de/neitzel/fxdesigner/host/HostServicesTest.java b/designer-core/src/test/java/de/neitzel/fxdesigner/host/HostServicesTest.java
new file mode 100644
index 0000000..23c78d9
--- /dev/null
+++ b/designer-core/src/test/java/de/neitzel/fxdesigner/host/HostServicesTest.java
@@ -0,0 +1,94 @@
+package de.neitzel.fxdesigner.host;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.nio.file.*;
+import java.util.*;
+import java.util.concurrent.*;
+import org.junit.jupiter.api.Test;
+
+class HostServicesTest {
+ @Test
+ void scopesAreIndependentAndCloseInReverseOrder() {
+ var seen = new ArrayList();
+ var first = new SessionScope();
+ var other = new SessionScope();
+ first.own(() -> seen.add(1));
+ first.own(() -> seen.add(2));
+ other.own(() -> seen.add(3));
+ first.close();
+ first.close();
+ assertEquals(List.of(2, 1), seen);
+ other.close();
+ assertEquals(List.of(2, 1, 3), seen);
+ }
+
+ @Test
+ void cleanupContinuesAfterFailureAndLateResourcesClose() {
+ var scope = new SessionScope();
+ var seen = new ArrayList();
+ scope.own(() -> seen.add(1));
+ scope.own(
+ () -> {
+ throw new Exception("broken");
+ });
+ assertThrows(IllegalStateException.class, scope::close);
+ assertEquals(List.of(1), seen);
+ assertThrows(IllegalStateException.class, () -> scope.own(() -> seen.add(2)));
+ assertEquals(List.of(1, 2), seen);
+ }
+
+ @Test
+ void runtimeRolesNeverFallBack() {
+ var runtime = new HostServices.RuntimeInstallation(Path.of("/explicit"), 21);
+ var r =
+ new ConfiguredRuntimes(
+ Map.of(new ConfiguredRuntimes.Key("java", HostServices.RuntimeRole.TARGET), runtime));
+ assertEquals(
+ runtime, r.resolve("java", HostServices.RuntimeRole.TARGET).toCompletableFuture().join());
+ assertThrows(
+ CompletionException.class,
+ () -> r.resolve("java", HostServices.RuntimeRole.BUILD).toCompletableFuture().join());
+ }
+
+ @Test
+ void buildCapturesBothStreamsAndCancellation() throws Exception {
+ String java = Path.of(System.getProperty("java.home"), "bin", "java").toString();
+ var lines = new CopyOnWriteArrayList();
+ var builds = new ProcessBuilds();
+ var run =
+ builds.start(
+ new HostServices.BuildRequest(
+ Path.of("."),
+ List.of(java, "-cp", System.getProperty("java.class.path"), Child.class.getName()),
+ Map.of()),
+ lines::add);
+ var done = run.completion().toCompletableFuture().get(10, TimeUnit.SECONDS);
+ assertEquals(0, done.exitCode());
+ assertFalse(done.cancelled());
+ assertTrue(lines.stream().anyMatch(x -> x.stderr() && x.line().equals("error output")));
+ assertTrue(lines.stream().anyMatch(x -> !x.stderr() && x.line().equals("normal output")));
+ var pending =
+ builds.start(
+ new HostServices.BuildRequest(
+ Path.of("."),
+ List.of(
+ java,
+ "-cp",
+ System.getProperty("java.class.path"),
+ Child.class.getName(),
+ "wait"),
+ Map.of()),
+ x -> {});
+ pending.cancel();
+ assertTrue(pending.completion().toCompletableFuture().get(10, TimeUnit.SECONDS).cancelled());
+ }
+
+ public static class Child {
+ public static void main(String[] args) throws Exception {
+ if (args.length > 0) Thread.sleep(30000);
+ System.out.println("normal output");
+ System.err.println("error output");
+ }
+ }
+}
diff --git a/designer-intellij/build.gradle.kts b/designer-intellij/build.gradle.kts
new file mode 100644
index 0000000..270253b
--- /dev/null
+++ b/designer-intellij/build.gradle.kts
@@ -0,0 +1,35 @@
+import org.jetbrains.intellij.platform.gradle.TestFrameworkType
+plugins {
+ java
+ id("org.jetbrains.intellij.platform") version "2.20.1"
+}
+group = "de.neitzel.fxdesigner"
+version = "0.1.0-SNAPSHOT"
+repositories {
+ mavenLocal { content { includeGroup("de.neitzel.fxdesigner") } }
+ mavenCentral()
+ intellijPlatform { defaultRepositories() }
+}
+java { sourceCompatibility = JavaVersion.VERSION_25; targetCompatibility = JavaVersion.VERSION_25 }
+tasks.withType().configureEach { options.release.set(25) }
+val fxPlatform = if (System.getProperty("os.arch") == "aarch64") "mac-aarch64" else "mac"
+dependencies {
+ implementation("de.neitzel.fxdesigner:designer-kit-ui:0.1.0-SNAPSHOT")
+ intellijPlatform {
+ local(providers.gradleProperty("ideaHome").orElse(providers.environmentVariable("IDEA_HOME")))
+ testFramework(TestFrameworkType.Platform)
+ }
+ // Maven platform profiles are not selected by Gradle: include the qualified macOS natives explicitly.
+ listOf("base", "controls", "fxml", "graphics", "media", "swing", "web").forEach {
+ runtimeOnly("org.openjfx:javafx-$it:26:$fxPlatform")
+ }
+ testImplementation("junit:junit:4.13.2")
+}
+intellijPlatform {
+ pluginConfiguration {
+ name = "Neitzel FX Designer"
+ ideaVersion { sinceBuild = "263.7307.200"; untilBuild = "263.*" }
+ }
+ instrumentCode = false
+}
+tasks.test { useJUnit(); jvmArgs("-Djava.awt.headless=true") }
diff --git a/designer-intellij/gradle.properties b/designer-intellij/gradle.properties
new file mode 100644
index 0000000..f12ad90
--- /dev/null
+++ b/designer-intellij/gradle.properties
@@ -0,0 +1,2 @@
+org.gradle.jvmargs=-Xmx2g
+org.gradle.configuration-cache=false
diff --git a/designer-intellij/gradle/wrapper/gradle-wrapper.jar b/designer-intellij/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..8bdaf60
Binary files /dev/null and b/designer-intellij/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/designer-intellij/gradle/wrapper/gradle-wrapper.properties b/designer-intellij/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..e593595
--- /dev/null
+++ b/designer-intellij/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,9 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-bin.zip
+networkTimeout=10000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
+
+distributionSha256Sum=a17ddd85a26b6a7f5ddb71ff8b05fc5104c0202c6e64782429790c933686c806
diff --git a/designer-intellij/gradlew b/designer-intellij/gradlew
new file mode 100755
index 0000000..adff685
--- /dev/null
+++ b/designer-intellij/gradlew
@@ -0,0 +1,248 @@
+#!/bin/sh
+
+#
+# Copyright © 2015 the original authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+#
+
+##############################################################################
+#
+# Gradle start up script for POSIX generated by Gradle.
+#
+# Important for running:
+#
+# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+# noncompliant, but you have some other compliant shell such as ksh or
+# bash, then to run this script, type that shell name before the whole
+# command line, like:
+#
+# ksh Gradle
+#
+# Busybox and similar reduced shells will NOT work, because this script
+# requires all of these POSIX shell features:
+# * functions;
+# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+# * compound commands having a testable exit status, especially «case»;
+# * various built-in commands including «command», «set», and «ulimit».
+#
+# Important for patching:
+#
+# (2) This script targets any POSIX shell, so it avoids extensions provided
+# by Bash, Ksh, etc; in particular arrays are avoided.
+#
+# The "traditional" practice of packing multiple parameters into a
+# space-separated string is a well documented source of bugs and security
+# problems, so this is (mostly) avoided, by progressively accumulating
+# options in "$@", and eventually passing that to Java.
+#
+# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+# see the in-line comments for details.
+#
+# There are tweaks for specific operating systems such as AIX, CygWin,
+# Darwin, MinGW, and NonStop.
+#
+# (3) This script is generated from the Groovy template
+# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# within the Gradle project.
+#
+# You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
+ [ -h "$app_path" ]
+do
+ ls=$( ls -ld "$app_path" )
+ link=${ls#*' -> '}
+ case $link in #(
+ /*) app_path=$link ;; #(
+ *) app_path=$APP_HOME$link ;;
+ esac
+done
+
+# This is normally unused
+# shellcheck disable=SC2034
+APP_BASE_NAME=${0##*/}
+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
+APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+ echo "$*"
+} >&2
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in #(
+ CYGWIN* ) cygwin=true ;; #(
+ Darwin* ) darwin=true ;; #(
+ MSYS* | MINGW* ) msys=true ;; #(
+ NONSTOP* ) nonstop=true ;;
+esac
+
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD=$JAVA_HOME/jre/sh/java
+ else
+ JAVACMD=$JAVA_HOME/bin/java
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD=java
+ if ! command -v java >/dev/null 2>&1
+ then
+ die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+ case $MAX_FD in #(
+ max*)
+ # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ MAX_FD=$( ulimit -H -n ) ||
+ warn "Could not query maximum file descriptor limit"
+ esac
+ case $MAX_FD in #(
+ '' | soft) :;; #(
+ *)
+ # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ ulimit -n "$MAX_FD" ||
+ warn "Could not set maximum file descriptor limit to $MAX_FD"
+ esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+# * args from the command line
+# * the main class name
+# * -classpath
+# * -D...appname settings
+# * --module-path (only if needed)
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+ APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+
+ JAVACMD=$( cygpath --unix "$JAVACMD" )
+
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ for arg do
+ if
+ case $arg in #(
+ -*) false ;; # don't mess with options #(
+ /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
+ [ -e "$t" ] ;; #(
+ *) false ;;
+ esac
+ then
+ arg=$( cygpath --path --ignore --mixed "$arg" )
+ fi
+ # Roll the args list around exactly as many times as the number of
+ # args, so each arg winds up back in the position where it started, but
+ # possibly modified.
+ #
+ # NB: a `for` loop captures its iteration list before it begins, so
+ # changing the positional parameters here affects neither the number of
+ # iterations, nor the values presented in `arg`.
+ shift # remove old arg
+ set -- "$@" "$arg" # push replacement arg
+ done
+fi
+
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Collect all arguments for the java command:
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
+# and any embedded shellness will be escaped.
+# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
+# treated as '${Hostname}' itself on the command line.
+
+set -- \
+ "-Dorg.gradle.appname=$APP_BASE_NAME" \
+ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
+ "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+ die "xargs is not available"
+fi
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+# set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+ printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+ xargs -n1 |
+ sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+ tr '\n' ' '
+ )" '"$@"'
+
+exec "$JAVACMD" "$@"
diff --git a/designer-intellij/gradlew.bat b/designer-intellij/gradlew.bat
new file mode 100644
index 0000000..c4bdd3a
--- /dev/null
+++ b/designer-intellij/gradlew.bat
@@ -0,0 +1,93 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+@rem SPDX-License-Identifier: Apache-2.0
+@rem
+
+@if "%DEBUG%"=="" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%"=="" set DIRNAME=.
+@rem This is normally unused
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if %ERRORLEVEL% equ 0 goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if %ERRORLEVEL% equ 0 goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+set EXIT_CODE=%ERRORLEVEL%
+if %EXIT_CODE% equ 0 set EXIT_CODE=1
+if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
+exit /b %EXIT_CODE%
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/designer-intellij/settings.gradle.kts b/designer-intellij/settings.gradle.kts
new file mode 100644
index 0000000..d7edddc
--- /dev/null
+++ b/designer-intellij/settings.gradle.kts
@@ -0,0 +1,2 @@
+pluginManagement { repositories { gradlePluginPortal(); mavenCentral() } }
+rootProject.name = "designer-intellij"
diff --git a/designer-intellij/src/main/java/de/neitzel/fxdesigner/intellij/IntellijDocuments.java b/designer-intellij/src/main/java/de/neitzel/fxdesigner/intellij/IntellijDocuments.java
new file mode 100644
index 0000000..3745694
--- /dev/null
+++ b/designer-intellij/src/main/java/de/neitzel/fxdesigner/intellij/IntellijDocuments.java
@@ -0,0 +1,213 @@
+package de.neitzel.fxdesigner.intellij;
+
+import com.intellij.openapi.application.*;
+import com.intellij.openapi.command.WriteCommandAction;
+import com.intellij.openapi.command.undo.UndoManager;
+import com.intellij.openapi.editor.*;
+import com.intellij.openapi.editor.event.*;
+import com.intellij.openapi.fileEditor.*;
+import com.intellij.openapi.fileEditor.impl.text.TextEditorProvider;
+import com.intellij.openapi.project.Project;
+import com.intellij.openapi.util.Disposer;
+import com.intellij.openapi.vfs.*;
+import de.neitzel.fxdesigner.contracts.*;
+import de.neitzel.fxdesigner.host.*;
+import java.net.URI;
+import java.nio.file.*;
+import java.util.*;
+import java.util.concurrent.*;
+import java.util.concurrent.atomic.AtomicLong;
+
+/**
+ * Adapts actual IntelliJ Documents and native commands/history; never creates a competing undo
+ * store.
+ */
+public final class IntellijDocuments implements HostServices.Documents, AutoCloseable {
+ private final Project project;
+ private final com.intellij.openapi.Disposable lifetime;
+ private final Map states = new HashMap<>();
+ private final Set handles = new HashSet<>();
+ private boolean closed;
+
+ private static final class State {
+ VirtualFile file;
+ Document document;
+ String disk;
+ AtomicLong revision = new AtomicLong();
+ }
+
+ public IntellijDocuments(Project project) {
+ this.project = Objects.requireNonNull(project);
+ lifetime = this::disposeResources;
+ Disposer.register(project, lifetime);
+ }
+
+ private CompletionStage edt(Callable action) {
+ var future = new CompletableFuture();
+ Runnable task =
+ () ->
+ WriteIntentReadAction.run(
+ () -> {
+ try {
+ if (closed || project.isDisposed() || Disposer.isDisposed(lifetime))
+ throw new IllegalStateException("Designer host disposed");
+ future.complete(action.call());
+ } catch (Throwable e) {
+ future.completeExceptionally(e);
+ }
+ });
+ if (ApplicationManager.getApplication().isDispatchThread()) task.run();
+ else ApplicationManager.getApplication().invokeLater(task, ModalityState.any());
+ return future;
+ }
+
+ public CompletionStage open(URI uri) {
+ return edt(
+ () -> {
+ Path path = Path.of(uri).toRealPath();
+ State state = states.get(path);
+ if (state == null) {
+ state = new State();
+ state.file = LocalFileSystem.getInstance().refreshAndFindFileByNioFile(path);
+ if (state.file == null) throw new IllegalArgumentException("Missing file: " + path);
+ state.document = FileDocumentManager.getInstance().getDocument(state.file);
+ if (state.document == null) throw new IllegalArgumentException("Not a text document");
+ state.disk = Files.readString(path);
+ var observed = state;
+ state.document.addDocumentListener(
+ new DocumentListener() {
+ @Override
+ public void documentChanged(DocumentEvent e) {
+ observed.revision.incrementAndGet();
+ }
+ },
+ lifetime);
+ states.put(path, state);
+ }
+ var handle = new Handle(state);
+ handles.add(handle);
+ return handle;
+ });
+ }
+
+ private DocumentSnapshot snapshot(State s) {
+ return new DocumentSnapshot(
+ Path.of(s.file.getPath()).toUri(),
+ s.revision.get(),
+ s.document.getText(),
+ s.document.isWritable() && s.file.isWritable(),
+ FileDocumentManager.getInstance().isDocumentUnsaved(s.document));
+ }
+
+ private final class Handle implements DocumentSession {
+ private final State s;
+ private boolean released;
+ private Editor editor;
+
+ Handle(State s) {
+ this.s = s;
+ }
+
+ private CompletionStage run(Callable action) {
+ return edt(
+ () -> {
+ if (released) throw new IllegalStateException("Document handle closed");
+ return action.call();
+ });
+ }
+
+ public CompletionStage snapshot() {
+ return run(() -> IntellijDocuments.this.snapshot(s));
+ }
+
+ public CompletionStage apply(EditBatch edits) {
+ return run(
+ () -> {
+ edits.applyTo(IntellijDocuments.this.snapshot(s));
+ var ranges = new ArrayList<>(edits.replacements());
+ ranges.sort(Comparator.comparingInt(EditBatch.Replacement::start));
+ Collections.reverse(ranges);
+ WriteCommandAction.runWriteCommandAction(
+ project,
+ edits.name(),
+ java.util.UUID.randomUUID().toString(),
+ () -> {
+ for (var range : ranges)
+ s.document.replaceString(range.start(), range.end(), range.text());
+ });
+ return IntellijDocuments.this.snapshot(s);
+ });
+ }
+
+ public CompletionStage undo() {
+ return history(false);
+ }
+
+ public CompletionStage redo() {
+ return history(true);
+ }
+
+ private CompletionStage history(boolean redo) {
+ return run(
+ () -> {
+ if (!s.document.isWritable() || !s.file.isWritable())
+ throw new IllegalStateException("Read-only document");
+ if (editor == null)
+ editor = EditorFactory.getInstance().createEditor(s.document, project);
+ var textEditor = TextEditorProvider.getInstance().getTextEditor(editor);
+ var undo = UndoManager.getInstance(project);
+ if (redo) {
+ if (undo.isRedoAvailable(textEditor)) undo.redo(textEditor);
+ } else if (undo.isUndoAvailable(textEditor)) undo.undo(textEditor);
+ return IntellijDocuments.this.snapshot(s);
+ });
+ }
+
+ public CompletionStage save(long revision) {
+ return run(
+ () -> {
+ if (s.revision.get() != revision)
+ throw new ConcurrentModificationException("Stale save");
+ if (!s.document.isWritable() || !s.file.isWritable())
+ throw new IllegalStateException("Read-only document");
+ Path file = Path.of(s.file.getPath());
+ if (!Files.readString(file).equals(s.disk))
+ throw new ConcurrentModificationException("External change; draft retained");
+ FileDocumentManager.getInstance().saveDocument(s.document);
+ if (FileDocumentManager.getInstance().isDocumentUnsaved(s.document))
+ throw new IllegalStateException("IDE did not save document");
+ s.disk = Files.readString(file);
+ return IntellijDocuments.this.snapshot(s);
+ });
+ }
+
+ private void release() {
+ if (released) return;
+ released = true;
+ if (editor != null) EditorFactory.getInstance().releaseEditor(editor);
+ handles.remove(this);
+ }
+
+ public void close() {
+ edt(
+ () -> {
+ release();
+ return null;
+ });
+ }
+ }
+
+ private void disposeResources() {
+ for (var h : List.copyOf(handles)) h.release();
+ closed = true;
+ states.clear();
+ }
+
+ public void close() {
+ edt(
+ () -> {
+ Disposer.dispose(lifetime);
+ return null;
+ });
+ }
+}
diff --git a/designer-intellij/src/main/java/de/neitzel/fxdesigner/intellij/IntellijHost.java b/designer-intellij/src/main/java/de/neitzel/fxdesigner/intellij/IntellijHost.java
new file mode 100644
index 0000000..da6b5de
--- /dev/null
+++ b/designer-intellij/src/main/java/de/neitzel/fxdesigner/intellij/IntellijHost.java
@@ -0,0 +1,68 @@
+package de.neitzel.fxdesigner.intellij;
+
+import com.intellij.openapi.application.ApplicationManager;
+import com.intellij.openapi.application.WriteIntentReadAction;
+import com.intellij.openapi.fileEditor.OpenFileDescriptor;
+import com.intellij.openapi.project.Project;
+import com.intellij.openapi.util.Disposer;
+import com.intellij.openapi.vfs.LocalFileSystem;
+import de.neitzel.fxdesigner.host.*;
+import java.nio.file.Path;
+import java.util.concurrent.*;
+
+/**
+ * IDE composition; shared build execution exposes output/cancellation to IDE presentation
+ * callbacks.
+ */
+public final class IntellijHost implements AutoCloseable {
+ private final SessionScope scope = new SessionScope();
+ private final HostServices services;
+
+ public IntellijHost(
+ Project project, HostServices.Runtimes runtimes, HostServices.Diagnostics diagnostics) {
+ var docs = scope.own(new IntellijDocuments(project));
+ var builds = new ProcessBuilds();
+ services =
+ new HostServices(
+ docs,
+ (request, output) -> scope.own(builds.start(request, output)),
+ runtimes,
+ location -> {
+ var result = new CompletableFuture();
+ ApplicationManager.getApplication()
+ .invokeLater(
+ () ->
+ WriteIntentReadAction.run(
+ () -> {
+ try {
+ if (project.isDisposed())
+ throw new IllegalStateException("Project disposed");
+ var file =
+ LocalFileSystem.getInstance()
+ .refreshAndFindFileByNioFile(
+ Path.of(location.document()));
+ if (file == null)
+ throw new IllegalArgumentException(
+ "Navigation file unavailable");
+ new OpenFileDescriptor(project, file, location.offset())
+ .navigate(true);
+ result.complete(null);
+ } catch (Throwable e) {
+ result.completeExceptionally(e);
+ }
+ }));
+ return result;
+ },
+ diagnostics,
+ scope);
+ Disposer.register(project, this::close);
+ }
+
+ public HostServices services() {
+ return services;
+ }
+
+ public void close() {
+ scope.close();
+ }
+}
diff --git a/designer-intellij/src/main/resources/META-INF/plugin.xml b/designer-intellij/src/main/resources/META-INF/plugin.xml
new file mode 100644
index 0000000..5561ddb
--- /dev/null
+++ b/designer-intellij/src/main/resources/META-INF/plugin.xml
@@ -0,0 +1,5 @@
+
+ de.neitzel.fxdesignerNeitzel FX DesignerNeitzel
+ Module and host-service foundation. The FXML designer UI is not registered yet.
+ com.intellij.modules.platform
+
diff --git a/designer-intellij/src/test/java/de/neitzel/fxdesigner/intellij/IntellijDocumentsTest.java b/designer-intellij/src/test/java/de/neitzel/fxdesigner/intellij/IntellijDocumentsTest.java
new file mode 100644
index 0000000..caccb52
--- /dev/null
+++ b/designer-intellij/src/test/java/de/neitzel/fxdesigner/intellij/IntellijDocumentsTest.java
@@ -0,0 +1,72 @@
+package de.neitzel.fxdesigner.intellij;
+
+import com.intellij.testFramework.fixtures.BasePlatformTestCase;
+import de.neitzel.fxdesigner.contracts.*;
+import java.nio.file.*;
+import java.util.*;
+
+public final class IntellijDocumentsTest extends BasePlatformTestCase {
+ public void testNativeDocumentTransactionUndoAndIndependentHandles() throws Exception {
+ Path file = Files.createTempFile("designer-document-", ".fxml");
+ Files.writeString(file, "abcdef");
+ com.intellij.openapi.vfs.newvfs.impl.VfsRootAccess.allowRootAccess(getTestRootDisposable(), file.toRealPath().toString());
+ try (var docs = new IntellijDocuments(getProject())) {
+ var a = docs.open(file.toUri()).toCompletableFuture().get();
+ var b = docs.open(file.toUri()).toCompletableFuture().get();
+ var initial = a.snapshot().toCompletableFuture().get();
+ var changed =
+ a.apply(
+ new EditBatch(
+ initial.revision(),
+ "Component properties",
+ List.of(
+ new EditBatch.Replacement(0, 1, "A"),
+ new EditBatch.Replacement(5, 6, "F"))))
+ .toCompletableFuture()
+ .get();
+ assertEquals("AbcdeF", b.snapshot().toCompletableFuture().get().text());
+ a.close();
+ var undone = b.undo().toCompletableFuture().get();
+ assertEquals("abcdef", undone.text());
+ assertTrue(undone.revision() > changed.revision());
+ try {
+ b.apply(new EditBatch(initial.revision(), "stale", List.of())).toCompletableFuture().get();
+ fail("Stale edit accepted");
+ } catch (java.util.concurrent.ExecutionException expected) {
+ assertInstanceOf(expected.getCause(), ConcurrentModificationException.class);
+ }
+ var redone = b.redo().toCompletableFuture().get();
+ assertEquals("AbcdeF", redone.text());
+ b.save(redone.revision()).toCompletableFuture().get();
+ assertEquals("AbcdeF", Files.readString(file));
+ b.close();
+ } finally {
+ Files.deleteIfExists(file);
+ }
+ }
+
+ public void testExternalChangesDoNotLoseDraft() throws Exception {
+ Path file = Files.createTempFile("designer-conflict-", ".fxml");
+ Files.writeString(file, "old");
+ com.intellij.openapi.vfs.newvfs.impl.VfsRootAccess.allowRootAccess(getTestRootDisposable(), file.toRealPath().toString());
+ try (var docs = new IntellijDocuments(getProject())) {
+ var d = docs.open(file.toUri()).toCompletableFuture().get();
+ var changed =
+ d.apply(new EditBatch(0, "edit", List.of(new EditBatch.Replacement(0, 3, "draft"))))
+ .toCompletableFuture()
+ .get();
+ Files.writeString(file, "external");
+ try {
+ d.save(changed.revision()).toCompletableFuture().get();
+ fail("Conflict accepted");
+ } catch (java.util.concurrent.ExecutionException expected) {
+ assertInstanceOf(expected.getCause(), ConcurrentModificationException.class);
+ }
+ assertEquals("draft", d.snapshot().toCompletableFuture().get().text());
+ assertEquals("external", Files.readString(file));
+ d.close();
+ } finally {
+ Files.deleteIfExists(file);
+ }
+ }
+}
diff --git a/designer-kit-ui/pom.xml b/designer-kit-ui/pom.xml
new file mode 100644
index 0000000..f74f8e8
--- /dev/null
+++ b/designer-kit-ui/pom.xml
@@ -0,0 +1,58 @@
+
+
+ 4.0.0
+
+ de.neitzel.fxdesigner
+ neitzel-fx-designer
+ 0.1.0-SNAPSHOT
+
+ designer-kit-ui
+
+ 25
+
+
+
+ de.neitzel.fxdesigner
+ designer-core
+ ${project.version}
+
+
+ com.gluonhq.scenebuilder
+ kit
+ 26.0.0
+
+
+ org.junit.jupiter
+ junit-jupiter
+ test
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-enforcer-plugin
+
+
+ module-dependency-boundary
+
+ enforce
+
+
+
+
+ true
+
+ com.intellij:*
+ com.jetbrains:*
+ com.jetbrains.intellij.platform:*
+
+
+
+
+
+
+
+
+
+
diff --git a/designer-kit-ui/src/main/java/de/neitzel/fxdesigner/kit/KitView.java b/designer-kit-ui/src/main/java/de/neitzel/fxdesigner/kit/KitView.java
new file mode 100644
index 0000000..94b9f59
--- /dev/null
+++ b/designer-kit-ui/src/main/java/de/neitzel/fxdesigner/kit/KitView.java
@@ -0,0 +1,30 @@
+package de.neitzel.fxdesigner.kit;
+
+import com.oracle.javafx.scenebuilder.kit.editor.EditorController;
+import de.neitzel.fxdesigner.host.HostServices;
+import java.util.Objects;
+import javafx.application.Platform;
+
+/**
+ * Shared Kit instance. Host setup/toolkit checks must complete before construction on the FX
+ * thread.
+ */
+public final class KitView {
+ private final HostServices host;
+ private final EditorController editor;
+
+ public KitView(HostServices host) {
+ if (!Platform.isFxApplicationThread())
+ throw new IllegalStateException("Kit requires the FX thread");
+ this.host = Objects.requireNonNull(host);
+ this.editor = new EditorController();
+ }
+
+ public EditorController editor() {
+ return editor;
+ }
+
+ public HostServices host() {
+ return host;
+ }
+}
diff --git a/designer-preview-api/pom.xml b/designer-preview-api/pom.xml
new file mode 100644
index 0000000..1f23697
--- /dev/null
+++ b/designer-preview-api/pom.xml
@@ -0,0 +1,51 @@
+
+
+ 4.0.0
+
+ de.neitzel.fxdesigner
+ neitzel-fx-designer
+ 0.1.0-SNAPSHOT
+
+ designer-preview-api
+
+ 21
+
+
+
+ org.junit.jupiter
+ junit-jupiter
+ test
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-enforcer-plugin
+
+
+ module-dependency-boundary
+
+ enforce
+
+
+
+
+ true
+
+ com.intellij:*
+ com.jetbrains:*
+ com.jetbrains.intellij.platform:*
+ com.gluonhq.scenebuilder:*
+ org.openjfx:*
+ de.neitzel.lib:*
+
+
+
+
+
+
+
+
+
+
diff --git a/designer-preview-api/src/main/java/de/neitzel/fxdesigner/preview/ExampleDescriptor.java b/designer-preview-api/src/main/java/de/neitzel/fxdesigner/preview/ExampleDescriptor.java
new file mode 100644
index 0000000..3c8be9d
--- /dev/null
+++ b/designer-preview-api/src/main/java/de/neitzel/fxdesigner/preview/ExampleDescriptor.java
@@ -0,0 +1,12 @@
+package de.neitzel.fxdesigner.preview;
+
+import java.util.Objects;
+
+/** Non-executing example metadata. Factory/session signatures await real target fixtures. */
+public record ExampleDescriptor(String id, String label, String modelType) {
+ public ExampleDescriptor {
+ if (id == null || id.isBlank()) throw new IllegalArgumentException("Missing example ID");
+ Objects.requireNonNull(label);
+ Objects.requireNonNull(modelType);
+ }
+}
diff --git a/designer-standalone/pom.xml b/designer-standalone/pom.xml
new file mode 100644
index 0000000..e5d66b0
--- /dev/null
+++ b/designer-standalone/pom.xml
@@ -0,0 +1,53 @@
+
+
+ 4.0.0
+
+ de.neitzel.fxdesigner
+ neitzel-fx-designer
+ 0.1.0-SNAPSHOT
+
+ designer-standalone
+
+ 25
+
+
+
+ de.neitzel.fxdesigner
+ designer-kit-ui
+ ${project.version}
+
+
+ org.junit.jupiter
+ junit-jupiter
+ test
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-enforcer-plugin
+
+
+ module-dependency-boundary
+
+ enforce
+
+
+
+
+ true
+
+ com.intellij:*
+ com.jetbrains:*
+ com.jetbrains.intellij.platform:*
+
+
+
+
+
+
+
+
+
+
diff --git a/designer-standalone/src/main/java/de/neitzel/fxdesigner/standalone/FileDocuments.java b/designer-standalone/src/main/java/de/neitzel/fxdesigner/standalone/FileDocuments.java
new file mode 100644
index 0000000..6b64065
--- /dev/null
+++ b/designer-standalone/src/main/java/de/neitzel/fxdesigner/standalone/FileDocuments.java
@@ -0,0 +1,153 @@
+package de.neitzel.fxdesigner.standalone;
+
+import de.neitzel.fxdesigner.contracts.*;
+import de.neitzel.fxdesigner.host.*;
+import java.net.URI;
+import java.nio.file.*;
+import java.util.*;
+import java.util.concurrent.*;
+
+/**
+ * Standalone draft store with shared per-file history; all IO/history operations are serialized off
+ * UI threads.
+ */
+public final class FileDocuments implements HostServices.Documents, AutoCloseable {
+ private final ExecutorService executor =
+ Executors.newSingleThreadExecutor(Thread.ofVirtual().factory());
+ private final Map documents = new HashMap<>();
+
+ private static final class State {
+ Path file;
+ String text, saved;
+ long revision;
+ ArrayDeque undo = new ArrayDeque<>(), redo = new ArrayDeque<>();
+ }
+
+ private CompletionStage submit(Callable action) {
+ var future = new CompletableFuture();
+ try {
+ executor.submit(
+ () -> {
+ try {
+ future.complete(action.call());
+ } catch (Throwable e) {
+ future.completeExceptionally(e);
+ }
+ });
+ } catch (RejectedExecutionException e) {
+ future.completeExceptionally(new IllegalStateException("Host closed", e));
+ }
+ return future;
+ }
+
+ public CompletionStage open(URI uri) {
+ return submit(
+ () -> {
+ Path file = Path.of(uri).toRealPath();
+ var state = documents.get(file);
+ if (state == null) {
+ state = new State();
+ state.file = file;
+ state.text = state.saved = Files.readString(file);
+ documents.put(file, state);
+ }
+ return new Handle(state);
+ });
+ }
+
+ private static DocumentSnapshot snapshot(State s) {
+ return new DocumentSnapshot(
+ s.file.toUri(), s.revision, s.text, Files.isWritable(s.file), !s.text.equals(s.saved));
+ }
+
+ private final class Handle implements DocumentSession {
+ private final State s;
+ private volatile boolean closed;
+
+ Handle(State s) {
+ this.s = s;
+ }
+
+ private CompletionStage run(Callable action) {
+ return submit(
+ () -> {
+ if (closed) throw new IllegalStateException("Document handle closed");
+ return action.call();
+ });
+ }
+
+ public CompletionStage snapshot() {
+ return run(() -> FileDocuments.snapshot(s));
+ }
+
+ public CompletionStage apply(EditBatch edits) {
+ return run(
+ () -> {
+ String updated = edits.applyTo(FileDocuments.snapshot(s));
+ if (!updated.equals(s.text)) {
+ s.undo.push(s.text);
+ s.redo.clear();
+ s.text = updated;
+ s.revision++;
+ }
+ return FileDocuments.snapshot(s);
+ });
+ }
+
+ public CompletionStage undo() {
+ return history(s.undo, s.redo);
+ }
+
+ public CompletionStage redo() {
+ return history(s.redo, s.undo);
+ }
+
+ private CompletionStage history(
+ ArrayDeque from, ArrayDeque to) {
+ return run(
+ () -> {
+ if (!Files.isWritable(s.file)) throw new IllegalStateException("Read-only document");
+ if (!from.isEmpty()) {
+ to.push(s.text);
+ s.text = from.pop();
+ s.revision++;
+ }
+ return FileDocuments.snapshot(s);
+ });
+ }
+
+ public CompletionStage save(long revision) {
+ return run(
+ () -> {
+ if (s.revision != revision) throw new ConcurrentModificationException("Stale save");
+ if (!Files.isWritable(s.file)) throw new IllegalStateException("Read-only document");
+ if (!Files.readString(s.file).equals(s.saved))
+ throw new ConcurrentModificationException("External change; draft retained");
+ Path temp = Files.createTempFile(s.file.getParent(), "designer-save-", ".tmp");
+ try {
+ Files.writeString(temp, s.text);
+ if (Files.getFileAttributeView(s.file, java.nio.file.attribute.PosixFileAttributeView.class) != null) {
+ Files.setPosixFilePermissions(temp, Files.getPosixFilePermissions(s.file));
+ }
+ Files.move(
+ temp,
+ s.file,
+ StandardCopyOption.ATOMIC_MOVE,
+ StandardCopyOption.REPLACE_EXISTING);
+ } finally {
+ Files.deleteIfExists(temp);
+ }
+ s.saved = s.text;
+ return FileDocuments.snapshot(s);
+ });
+ }
+
+ public void close() {
+ closed = true;
+ }
+ }
+
+ public void close() {
+ executor.shutdown();
+ }
+}
diff --git a/designer-standalone/src/main/java/de/neitzel/fxdesigner/standalone/StandaloneHost.java b/designer-standalone/src/main/java/de/neitzel/fxdesigner/standalone/StandaloneHost.java
new file mode 100644
index 0000000..8656f5b
--- /dev/null
+++ b/designer-standalone/src/main/java/de/neitzel/fxdesigner/standalone/StandaloneHost.java
@@ -0,0 +1,33 @@
+package de.neitzel.fxdesigner.standalone;
+
+import de.neitzel.fxdesigner.host.*;
+
+/** Host composition with explicit native navigation, diagnostic and runtime integrations. */
+public final class StandaloneHost implements AutoCloseable {
+ private final SessionScope scope = new SessionScope();
+ private final FileDocuments documents = scope.own(new FileDocuments());
+ private final HostServices services;
+
+ public StandaloneHost(
+ HostServices.Runtimes runtimes,
+ HostServices.Navigation navigation,
+ HostServices.Diagnostics diagnostics) {
+ var builds = new ProcessBuilds();
+ services =
+ new HostServices(
+ documents,
+ (request, output) -> scope.own(builds.start(request, output)),
+ runtimes,
+ navigation,
+ diagnostics,
+ scope);
+ }
+
+ public HostServices services() {
+ return services;
+ }
+
+ public void close() {
+ scope.close();
+ }
+}
diff --git a/designer-standalone/src/test/java/de/neitzel/fxdesigner/standalone/FileDocumentsTest.java b/designer-standalone/src/test/java/de/neitzel/fxdesigner/standalone/FileDocumentsTest.java
new file mode 100644
index 0000000..a6f088c
--- /dev/null
+++ b/designer-standalone/src/test/java/de/neitzel/fxdesigner/standalone/FileDocumentsTest.java
@@ -0,0 +1,70 @@
+package de.neitzel.fxdesigner.standalone;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import de.neitzel.fxdesigner.contracts.*;
+import java.nio.file.*;
+import java.util.*;
+import java.util.concurrent.*;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+class FileDocumentsTest {
+ @TempDir Path folder;
+
+ @Test
+ void sharedDraftHistorySurvivesClosingOneHandleAndUndoNeverReusesRevision() throws Exception {
+ Path file = folder.resolve("View.fxml");
+ Files.writeString(file, "abcdef");
+ var permissions = Files.getPosixFilePermissions(file);
+ try (var docs = new FileDocuments()) {
+ var a = docs.open(file.toUri()).toCompletableFuture().get();
+ var b = docs.open(file.toUri()).toCompletableFuture().get();
+ var start = a.snapshot().toCompletableFuture().get();
+ var changed =
+ a.apply(
+ new EditBatch(
+ start.revision(),
+ "two edits",
+ List.of(
+ new EditBatch.Replacement(0, 1, "A"),
+ new EditBatch.Replacement(5, 6, "F"))))
+ .toCompletableFuture()
+ .get();
+ assertEquals("AbcdeF", b.snapshot().toCompletableFuture().get().text());
+ a.close();
+ var undone = b.undo().toCompletableFuture().get();
+ assertEquals("abcdef", undone.text());
+ assertTrue(undone.revision() > changed.revision());
+ assertThrows(
+ ExecutionException.class,
+ () ->
+ b.apply(new EditBatch(start.revision(), "stale", List.of()))
+ .toCompletableFuture()
+ .get());
+ var redone = b.redo().toCompletableFuture().get();
+ b.save(redone.revision()).toCompletableFuture().get();
+ assertEquals("AbcdeF", Files.readString(file));
+ assertEquals(permissions, Files.getPosixFilePermissions(file));
+ assertFalse(b.snapshot().toCompletableFuture().get().dirty());
+ }
+ }
+
+ @Test
+ void externalChangeBlocksSaveWithoutLosingDraft() throws Exception {
+ Path file = folder.resolve("View.fxml");
+ Files.writeString(file, "old");
+ try (var docs = new FileDocuments()) {
+ var d = docs.open(file.toUri()).toCompletableFuture().get();
+ var changed =
+ d.apply(new EditBatch(0, "edit", List.of(new EditBatch.Replacement(0, 3, "draft"))))
+ .toCompletableFuture()
+ .get();
+ Files.writeString(file, "external");
+ assertThrows(
+ ExecutionException.class, () -> d.save(changed.revision()).toCompletableFuture().get());
+ assertEquals("external", Files.readString(file));
+ assertEquals("draft", d.snapshot().toCompletableFuture().get().text());
+ }
+ }
+}
diff --git a/designer-target-tools/pom.xml b/designer-target-tools/pom.xml
new file mode 100644
index 0000000..b0485f7
--- /dev/null
+++ b/designer-target-tools/pom.xml
@@ -0,0 +1,64 @@
+
+
+ 4.0.0
+
+ de.neitzel.fxdesigner
+ neitzel-fx-designer
+ 0.1.0-SNAPSHOT
+
+ designer-target-tools
+
+ 21
+
+
+
+ de.neitzel.fxdesigner
+ designer-contracts
+ ${project.version}
+
+
+ de.neitzel.fxdesigner
+ designer-preview-api
+ ${project.version}
+
+
+ de.neitzel.lib
+ fx
+ 1.0-SNAPSHOT
+
+
+ org.junit.jupiter
+ junit-jupiter
+ test
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-enforcer-plugin
+
+
+ module-dependency-boundary
+
+ enforce
+
+
+
+
+ true
+
+ com.intellij:*
+ com.jetbrains:*
+ com.jetbrains.intellij.platform:*
+ com.gluonhq.scenebuilder:*
+
+
+
+
+
+
+
+
+
+
diff --git a/designer-target-tools/src/main/java/de/neitzel/fxdesigner/target/TargetToolInfo.java b/designer-target-tools/src/main/java/de/neitzel/fxdesigner/target/TargetToolInfo.java
new file mode 100644
index 0000000..0df0335
--- /dev/null
+++ b/designer-target-tools/src/main/java/de/neitzel/fxdesigner/target/TargetToolInfo.java
@@ -0,0 +1,17 @@
+package de.neitzel.fxdesigner.target;
+
+import de.neitzel.fx.view.ViewLoader;
+import de.neitzel.fxdesigner.contracts.DocumentSnapshot;
+import de.neitzel.fxdesigner.preview.ExampleDescriptor;
+
+/** Target-side linkage smoke entry point; it is not yet a discovery/rendering worker. */
+public final class TargetToolInfo {
+ private TargetToolInfo() {}
+
+ public static void main(String[] args) {
+ System.out.println("target-java=" + Runtime.version().feature());
+ System.out.println("loader=" + ViewLoader.class.getName());
+ System.out.println("preview-api=" + ExampleDescriptor.class.getName());
+ System.out.println("contracts=" + DocumentSnapshot.class.getName());
+ }
+}
diff --git a/docs/initial-project-contract.md b/docs/initial-project-contract.md
index df97145..5d629e0 100644
--- a/docs/initial-project-contract.md
+++ b/docs/initial-project-contract.md
@@ -16,7 +16,7 @@ Decision provenance: [accepted workspace decisions](provenance.md). Execution or
## Modules and dependency direction
-Names are proposed, while the responsibility boundaries are accepted.
+The artifact names below are established by work package 3. See [implemented boundaries and baselines](module-host-boundaries.md); the broader responsibilities remain the target architecture.
| Module/directory | Responsibility | Boundary |
| --- | --- | --- |
diff --git a/docs/module-host-boundaries.md b/docs/module-host-boundaries.md
new file mode 100644
index 0000000..c5b11f1
--- /dev/null
+++ b/docs/module-host-boundaries.md
@@ -0,0 +1,50 @@
+# Buildable module and host-service boundaries
+
+Work package 3 implements a buildable service foundation. It does not register an IntelliJ editor, launch a standalone designer, initialize JavaFX inside IntelliJ, or implement target discovery/rendering. The earlier prototypes remain separate evidence.
+
+## Artifacts
+
+| Artifact | Java release | Dependencies/responsibility |
+| --- | --- | --- |
+| designer-contracts | 21 | Immutable draft and edit DTOs; no framework dependencies |
+| designer-preview-api | 21 | Provisional example descriptor; no framework dependencies |
+| designer-core | 21 | Contracts; host interfaces, runtime mappings, process builds, lifecycle scope |
+| designer-kit-ui | 25 | Core and Scene Builder Kit 26.0.0 (JavaFX 26); FX-thread construction seam |
+| designer-standalone | 25 | Shared Kit UI; file-backed drafts and undo adapter |
+| designer-target-tools | 21 | Contracts, preview API, recorded NeitzelLib snapshot; linkage smoke entry point |
+| designer-intellij | 25 | Shared Kit UI; actual IDE Documents, commands, navigation, project disposal |
+
+```mermaid
+flowchart LR
+ IDEA[IntelliJ adapter] --> UI[Shared Kit UI]
+ Standalone[Standalone adapter] --> UI
+ UI --> Core[Core / HostServices]
+ Core --> Contracts[Contracts]
+ Target[Target tools: separate process] --> Contracts
+ Target --> Preview[Preview API]
+ Target --> Library[NeitzelLib]
+```
+
+The Maven reactor enforces transitive dependency exclusions: contracts/core/preview cannot acquire JavaFX, Kit, IntelliJ or NeitzelLib; target tools cannot acquire Kit or IntelliJ; other shared modules cannot acquire IntelliJ. The orchestration script verifies compiled class versions. There are no JPMS descriptors yet. Module-path qualification and public preview lifecycle signatures remain work package 6.
+
+## Service contract
+
+`HostServices` supplies document access, builds, explicit runtime resolution, navigation, diagnostics and an owned `SessionScope`. Core imports neither host implementation. Each host owns its services; closing one handle does not close sibling handles or implicitly save a document.
+
+Document operations return completion stages. Callers must not block UI threads waiting for them. Standalone IO/history runs on a serialized executor; IntelliJ operations marshal to its EDT with write-intent read access and use native write commands. Revisions are monotonically increasing identities, not undo indices. Every edit batch addresses one revision with UTF-16 offsets; all ranges are validated before any mutation. One batch is one undo command. Closing the IDE adapter releases its listeners/editors, while the native Document remains IDE-owned.
+
+Save checks the expected revision and compares disk content with the captured baseline. Conflicts preserve both draft and external content. This is initial conflict detection, **not** atomic coordination with arbitrary external writers: another writer can race the check. IntelliJ autosave/external reload reconciliation and complete encoding/line-ending fidelity need the later document integration gate. Standalone currently reads/writes UTF-8 and retains history for the lifetime of its host.
+
+Build execution accepts an already resolved command/environment/working directory, drains both output streams and supports cancellation of the process and observed descendants, with forced termination after a bounded grace period. The lifecycle scope closes owned resources once in reverse order and continues cleanup after individual failures. Runtime lookup requires an explicit name/role mapping and has no fallback JVM. Navigation and diagnostics remain host callbacks. Build planning, immutable snapshots, worker authentication and owner-death IPC are not implemented by this process runner.
+
+## Validation and packaging
+
+Run the single command documented in [README](../README.md). Shared modules use Maven 3.9.11; IntelliJ packaging uses Gradle 9.1.0 and IntelliJ Platform Gradle Plugin 2.20.1 against local SDK build 263.7307.200. The plugin range is restricted to the 263 branch. This is build/test evidence for that exact SDK, not binary compatibility evidence for every future build in the range.
+
+Tests exercise atomic multi-range edits, stale and invalid requests, independent handles, standalone/native IDE undo and redo, disk conflicts, separate runtime roles, process output/cancellation and scope cleanup. The Java 21 target smoke checks linkage to the recorded library, contracts and preview descriptor; it does not initialize a Model or JavaFX rendering. Plugin ZIP creation and project configuration verification do not replace Plugin Verifier, manual IDE interaction, accessibility, or JavaFX exclusivity qualification.
+
+The next work package makes configuration/context resolution and compatibility executable. The first full two-host workflow remains work package 7.
+
+Validation on 2026-11-12: eight Maven tests and two native IntelliJ tests passed, Java 21 linkage passed, and the clean plugin build passed. A deliberately injected JavaFX dependency in contracts was rejected by Maven Enforcer and then removed. The plugin archive is checked for host JavaFX implementation JARs and exclusion of target artifacts. Gradle needs explicit macOS classifier dependencies because it does not select Maven's OpenJFX platform profiles. Other OS packaging is not qualified yet.
+
+The plugin configuration check warns against an `until-build` restriction; this foundation intentionally retains `263.*` rather than implying untested forward compatibility. No Plugin Verifier compatibility run is claimed.
diff --git a/docs/repository-bootstrap-plan.md b/docs/repository-bootstrap-plan.md
index 8e30122..4d2b7e5 100644
--- a/docs/repository-bootstrap-plan.md
+++ b/docs/repository-bootstrap-plan.md
@@ -1,6 +1,6 @@
# NeitzelFXDesigner repository bootstrap plan
-Status: repository foundation implemented by the initial commit; work package 2 has a verified development artifact; work packages 3–7 remain planned. Consolidated 2026-11-12. Scope is the ordered implementation start for the [accepted initial contract](initial-project-contract.md); the initial commit creates the foundation only and does not publish a library release.
+Status: repository foundation implemented by the initial commit; work package 2 has a verified development artifact; work package 3 implements the module/service foundation; work packages 4–7 remain planned. Consolidated 2026-11-12. Scope is the ordered implementation start for the [accepted initial contract](initial-project-contract.md); the initial commit creates the foundation only and does not publish a library release.
## Starting-point evidence before bootstrap
@@ -16,7 +16,7 @@ Status: repository foundation implemented by the initial commit; work package 2
## Ordered work packages
-Work package 1 supplies this repository, documentation, pinned wrapper and `python3 scripts/check-bootstrap.py`. Its remote-main and workspace-registration checks are verified during publication. Work package 2 is now integrated with a verified local development artifact; the remaining implementation packages are not implemented.
+Work package 1 supplies this repository, documentation, pinned wrapper and `python3 scripts/check-bootstrap.py`. Its remote-main and workspace-registration checks are verified during publication. Work package 2 is now integrated with a verified local development artifact; work package 3 is documented in [module and host boundaries](module-host-boundaries.md).
### 1. Bootstrap the independent repository and workspace registration
@@ -48,6 +48,8 @@ Establish the first release containing the fix through actual artifact/source ev
### 3. Establish buildable module and host-service boundaries
+Implemented: six shared modules, separate pinned IntelliJ adapter build, minimal host services and regression checks. See [scope and validation](module-host-boundaries.md). This does not complete the first end-to-end designer workflow.
+
Create the contract's shared artifacts and two host adapters. Define interfaces for document drafts/edit transactions/undo, build execution/output/cancellation, navigation, diagnostics, runtime resolution and lifecycle. Keep UI and IDE dependencies out of contracts/preview artifacts. Verify dependency rules with build checks.
Build shared libraries/fixtures using the Maven structure; verify and pin the actual IntelliJ SDK packaging toolchain, using a separate adapter build if required. Provide one documented orchestration entry point. Retain independent standalone/plugin packaging gates. Select per-artifact Java bytecode baselines from real target requirements rather than propagating the host JDK into every artifact.
diff --git a/pom.xml b/pom.xml
index a819627..d684bfd 100644
--- a/pom.xml
+++ b/pom.xml
@@ -7,6 +7,11 @@
neitzel-fx-designer
0.1.0-SNAPSHOT
pom
+
+ designer-contractsdesigner-preview-api
+ designer-coredesigner-kit-ui
+ designer-standalonedesigner-target-tools
+
Neitzel FX Designer
Repository foundation for the shared Kit designer and its standalone and IntelliJ hosts.
https://gitea.neitzel.de/konrad/NeitzelFXDesigner
@@ -20,8 +25,17 @@
https://gitea.neitzel.de/konrad/NeitzelFXDesigner
HEAD
-
+
+ org.junit.jupiterjunit-jupiter5.12.2test
+
+
+ org.apache.maven.pluginsmaven-compiler-plugin3.14.1${module.java.release}
+ org.apache.maven.pluginsmaven-surefire-plugin3.5.4false
+ org.apache.maven.pluginsmaven-resources-plugin3.3.1
+ org.apache.maven.pluginsmaven-jar-plugin3.4.2
+ org.apache.maven.pluginsmaven-install-plugin3.1.2
+
org.apache.maven.plugins
@@ -34,7 +48,7 @@
[3.9.11,3.9.12)
- [21,)
+ [25,)
diff --git a/scripts/check-bootstrap.py b/scripts/check-bootstrap.py
index a4e84f9..7b9edfc 100644
--- a/scripts/check-bootstrap.py
+++ b/scripts/check-bootstrap.py
@@ -1,6 +1,8 @@
#!/usr/bin/env python3
-"""Validate the current POM-only foundation and portable local documentation."""
+"""Build shared modules and the pinned IDE adapter; verify target bytecode and docs."""
from pathlib import Path
+import argparse
+import zipfile
import json
import os
import re
@@ -8,8 +10,38 @@ import subprocess
import sys
ROOT = Path(__file__).resolve().parent.parent
+parser = argparse.ArgumentParser(description=__doc__)
+parser.add_argument("--idea-home", default=os.environ.get("IDEA_HOME"), required=not os.environ.get("IDEA_HOME"))
+parser.add_argument("--target-java-home", default=os.environ.get("TARGET_JAVA_HOME"), required=not os.environ.get("TARGET_JAVA_HOME"))
+args = parser.parse_args()
+idea = Path(args.idea_home).resolve()
+info = json.loads((idea / "Resources/product-info.json").read_text())
+if info["buildNumber"] != "263.7307.200":
+ sys.exit("Expected IntelliJ SDK build 263.7307.200")
+def run(command):
+ subprocess.run([str(x) for x in command], cwd=ROOT, check=True)
wrapper = ROOT / ("mvnw.cmd" if os.name == "nt" else "mvnw")
-subprocess.run([str(wrapper), "--batch-mode", "--no-transfer-progress", "clean", "verify"], cwd=ROOT, check=True)
+run([sys.executable, ROOT / "scripts/check-neitzellib-artifact.py"])
+run([wrapper, "-B", "-ntp", "clean", "install"])
+run([wrapper, "-B", "-ntp", "-pl", "designer-target-tools", "org.apache.maven.plugins:maven-dependency-plugin:3.6.1:build-classpath", "-DincludeScope=runtime", "-Dmdep.outputFile=target/runtime-classpath.txt"])
+for module in ("contracts", "preview-api", "core", "kit-ui", "standalone", "target-tools"):
+ name = "designer-" + module
+ expected = 69 if module in ("kit-ui", "standalone") else 65
+ with zipfile.ZipFile(ROOT / name / "target" / (name + "-0.1.0-SNAPSHOT.jar")) as jar:
+ classes = [n for n in jar.namelist() if n.endswith(".class")]
+ assert classes, name
+ assert all(int.from_bytes(jar.read(n)[6:8], "big") == expected for n in classes), name
+classpath = str(ROOT / "designer-target-tools/target/classes") + os.pathsep + (ROOT / "designer-target-tools/target/runtime-classpath.txt").read_text().strip()
+target_java = Path(args.target_java_home) / "bin/java"
+version = subprocess.run([str(target_java), "-version"], capture_output=True, text=True, check=True)
+if not re.search(r'version "21[.\"]', version.stderr):
+ sys.exit("Target linkage gate requires Java 21")
+run([target_java, "-cp", classpath, "de.neitzel.fxdesigner.target.TargetToolInfo"])
+run([ROOT / "designer-intellij" / ("gradlew.bat" if os.name == "nt" else "gradlew"), "-p", "designer-intellij", "--no-daemon", "-PideaHome=" + str(idea), "clean", "test", "buildPlugin", "verifyPluginProjectConfiguration"])
+with zipfile.ZipFile(next((ROOT / "designer-intellij/build/distributions").glob("*.zip"))) as plugin:
+ names = plugin.namelist()
+ assert any("javafx-graphics-26-mac" in n for n in names), "Missing host JavaFX implementation"
+ assert not any("designer-target-tools" in n or "designer-preview-api" in n or "/fx-1.0" in n for n in names), "Target dependency in IDE host"
for document in [ROOT / "README.md", ROOT / "AGENTS.md", ROOT / "THIRD-PARTY-NOTICES.md", *sorted((ROOT / "docs").rglob("*.md"))]:
content = document.read_text(encoding="utf-8")
for example in re.findall(r"```json\n(.*?)\n```", content, re.S):
@@ -20,4 +52,4 @@ for document in [ROOT / "README.md", ROOT / "AGENTS.md", ROOT / "THIRD-PARTY-NOT
local = document.parent / target.split("#", 1)[0]
if not local.exists():
sys.exit(f"Broken local link in {document.relative_to(ROOT)}: {target}")
-print("PASS Maven foundation, local document links and JSON examples (no application tests yet)")
+print("PASS module builds, adapter tests, bytecode baselines, target linkage, plugin packaging and documentation")