Establish buildable modules and shared host-service boundaries
This commit is contained in:
@@ -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>
|
||||
+153
@@ -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();
|
||||
}
|
||||
}
|
||||
+33
@@ -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();
|
||||
}
|
||||
}
|
||||
+70
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user