Establish buildable modules and shared host-service boundaries

This commit is contained in:
2026-11-12 07:49:06 +01:00
parent 1326b18746
commit 306266ae40
41 changed files with 1980 additions and 23 deletions
+53
View File
@@ -0,0 +1,53 @@
<?xml version="1.0" ?>
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>de.neitzel.fxdesigner</groupId>
<artifactId>neitzel-fx-designer</artifactId>
<version>0.1.0-SNAPSHOT</version>
</parent>
<artifactId>designer-standalone</artifactId>
<properties>
<module.java.release>25</module.java.release>
</properties>
<dependencies>
<dependency>
<groupId>de.neitzel.fxdesigner</groupId>
<artifactId>designer-kit-ui</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-enforcer-plugin</artifactId>
<executions>
<execution>
<id>module-dependency-boundary</id>
<goals>
<goal>enforce</goal>
</goals>
<configuration>
<rules>
<bannedDependencies>
<searchTransitive>true</searchTransitive>
<excludes>
<exclude>com.intellij:*</exclude>
<exclude>com.jetbrains:*</exclude>
<exclude>com.jetbrains.intellij.platform:*</exclude>
</excludes>
</bannedDependencies>
</rules>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
@@ -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<Path, State> documents = new HashMap<>();
private static final class State {
Path file;
String text, saved;
long revision;
ArrayDeque<String> undo = new ArrayDeque<>(), redo = new ArrayDeque<>();
}
private <T> CompletionStage<T> submit(Callable<T> action) {
var future = new CompletableFuture<T>();
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<DocumentSession> 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 <T> CompletionStage<T> run(Callable<T> action) {
return submit(
() -> {
if (closed) throw new IllegalStateException("Document handle closed");
return action.call();
});
}
public CompletionStage<DocumentSnapshot> snapshot() {
return run(() -> FileDocuments.snapshot(s));
}
public CompletionStage<DocumentSnapshot> 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<DocumentSnapshot> undo() {
return history(s.undo, s.redo);
}
public CompletionStage<DocumentSnapshot> redo() {
return history(s.redo, s.undo);
}
private CompletionStage<DocumentSnapshot> history(
ArrayDeque<String> from, ArrayDeque<String> 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<DocumentSnapshot> 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();
}
}
@@ -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();
}
}
@@ -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());
}
}
}