diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/catalog/KeyAssignments.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/catalog/KeyAssignments.java deleted file mode 100644 index 483231087..000000000 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/catalog/KeyAssignments.java +++ /dev/null @@ -1,44 +0,0 @@ -package com.github.minecraft_ta.totalDebugCompanion.catalog; - -import com.github.minecraft_ta.totalDebugCompanion.util.FileReading; -import com.github.minecraft_ta.totalDebugCompanion.util.Signal; - -import java.io.IOException; -import java.nio.file.Path; -import java.time.Duration; -import java.util.Map; - -/** - * The keys {@code options.txt} assigns, whoever writes the file: the game when a key is rebound in its controls screen, - * Companion or an editor. Followers hear of it once the assignments differ from those read before; another option - * written, such as the volume, tells nobody, and a deleted file assigns nothing. The file is followed as a - * {@link FileReading}, which reads it once it settled, tries again after a failed read and watches the game's folder - * also before it exists. Companion's own writes are read at once ({@link #readNow()}). - */ -public final class KeyAssignments implements AutoCloseable { - private final FileReading> reading; - - public KeyAssignments(Path options) { - this.reading = new FileReading<>(options, KeyBindings::readOptions, Duration.ofMillis(300)); - } - - /** Fires after the assignments changed. */ - public Signal changed() { - return this.reading.changed(); - } - - /** The keys the file assigns, as read last; read now where none were read yet. Blocking then. */ - public Map assignments() throws IOException { - return this.reading.value(); - } - - /** Reads the file now, as after Companion wrote it. */ - public void readNow() { - this.reading.readNow(); - } - - @Override - public void close() { - this.reading.close(); - } -} diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/catalog/KeyBindingControl.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/catalog/KeyBindingControl.java index eb94e8170..6159b9b17 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/catalog/KeyBindingControl.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/catalog/KeyBindingControl.java @@ -1,5 +1,6 @@ package com.github.minecraft_ta.totalDebugCompanion.catalog; +import com.github.minecraft_ta.totalDebugCompanion.util.FileReading; import com.github.minecraft_ta.totalDebugCompanion.util.Signal; import com.github.minecraft_ta.totalDebugCompanion.change.ChangeCategory; import com.github.minecraft_ta.totalDebugCompanion.change.ChangePipeline; @@ -29,33 +30,34 @@ * in {@code options.txt}, which the game reads when it starts. Writing that file while the game runs would be undone the * next time the game saves its options, so a game running without a connection is asked to connect first. */ -public final class KeyBindingControl implements ChangeCategory { +public final class KeyBindingControl implements ChangeCategory, AutoCloseable { /** A change to make: the binding {@code name}, the key it had when the change was made, and its new key. */ public record Change(String name, KeyBindings.Assignment shown, KeyBindings.Assignment assignment) { } private final ChangePipeline pipeline; private final Path options; - private final KeyAssignments assignments; + /** The keys {@code options.txt} assigns, whoever writes the file: the game, Companion or an editor. */ + private final FileReading> assignments; - /** - * Changes the keys of the pipeline's game, in its {@code options.txt}, whose keys {@code assignments} follows, whoever - * writes the file. - */ - public KeyBindingControl(ChangePipeline pipeline, KeyAssignments assignments) { + /** Changes the keys of the pipeline's game, in its {@code options.txt}. */ + public KeyBindingControl(ChangePipeline pipeline) { this.pipeline = Objects.requireNonNull(pipeline, "pipeline"); this.options = pipeline.location().workspace().resolve("options.txt"); - this.assignments = Objects.requireNonNull(assignments, "assignments"); + this.assignments = new FileReading<>(() -> KeyBindings.readOptions(this.options)); } - /** Fires when the keys {@code options.txt} assigns changed, such as a key rebound in the game's controls screen. */ + /** + * Fires when the keys {@code options.txt} assigns changed, as read after Companion changed them or when the user came + * back to Companion, such as from the game's controls screen; another option written, such as the volume, tells nobody. + */ public Signal assignmentsChanged() { return this.assignments.changed(); } - /** The keys {@code options.txt} assigns now ({@link KeyAssignments#assignments()}). Blocking. */ + /** The keys {@code options.txt} assigns, as read last; read now where none were read yet. Blocking then. */ public Map assignments() throws IOException { - return this.assignments.assignments(); + return this.assignments.value(); } /** The change record the bindings' changes are entered in. */ @@ -75,8 +77,8 @@ public Path options() { public CompletableFuture set(List changes) { List> edits = changes.stream().map(change -> new ChangePipeline.Edit<>( new ChangeRecord.KeyBinding(change.name()), change.shown().encode(), change.assignment().encode())).toList(); - // The game saved options.txt before it answered, or Companion wrote it: the owner of the keys reads what changed. - return this.pipeline.change(this, edits).whenComplete((done, failure) -> this.assignments.readNow()) + // The game saved options.txt before it answered, or Companion wrote it: the keys are read again. + return this.pipeline.change(this, edits).whenComplete((done, failure) -> this.assignments.refresh()) .handle((done, failure) -> failure == null ? "" : message(failure)); } @@ -144,4 +146,9 @@ public void writeFile(List> writes, Consu Files.write(this.options, lines, StandardCharsets.UTF_8); writes.forEach(write -> landed.accept(write.target())); } + + @Override + public void close() { + this.assignments.close(); + } } diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/catalog/WorldReading.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/catalog/WorldReading.java index 1dd97b781..472e31970 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/catalog/WorldReading.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/catalog/WorldReading.java @@ -1,183 +1,95 @@ package com.github.minecraft_ta.totalDebugCompanion.catalog; -import java.util.Optional; -import java.util.concurrent.CompletableFuture; -import java.nio.file.attribute.FileTime; -import com.github.minecraft_ta.totalDebugCompanion.util.FileWatch; -import com.github.minecraft_ta.totalDebugCompanion.storage.ChangeRecord; -import com.github.minecraft_ta.totalDebugCompanion.pack.GamePacks; -import com.github.minecraft_ta.totalDebugCompanion.game.GameState; import com.github.minecraft_ta.totalDebugCompanion.game.GameLocation; +import com.github.minecraft_ta.totalDebugCompanion.game.GameState; +import com.github.minecraft_ta.totalDebugCompanion.pack.GamePacks; +import com.github.minecraft_ta.totalDebugCompanion.storage.ChangeRecord; import com.github.minecraft_ta.totalDebugCompanion.util.FileReading; import com.github.minecraft_ta.totalDebugCompanion.util.Signal; -import com.github.minecraft_ta.totalDebugCompanion.util.Strand; -import com.github.minecraft_ta.totalDebugCompanion.util.Workers; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; -import java.time.Duration; +import java.nio.file.attribute.FileTime; +import java.util.List; import java.util.Objects; -import java.util.Set; +import java.util.Optional; /** * The instance's current world: the one the game has open, otherwise the one played last, read from its folder - * (docs/SYSTEMS.md, section 2). The World page and the Project tree show what it read; neither reads the world itself. - * Which world is current is decided on its strand, again when the game connects, plays another world or leaves one, and - * when a world of {@code saves} changes, as when a game Companion is not connected to opens or saves one. The current - * world is read again then, and after Companion changed its datapacks. Its own folder is watched only while the connected - * game plays it, which the game tells only once it let go of the world: on Windows a watched folder cannot be deleted or - * renamed, as by the game's Delete World. + * (docs/SYSTEMS.md, section 2). The World page, the Project tree and the World tab's title show what it read; none reads + * the world itself. It is read again when the game connects, plays another world or leaves one, after Companion changed + * the datapacks, and when the user comes back to Companion, as from a game that saved the world meanwhile. It is first + * read as it is made, so the World tab names the world also while its page was never shown. */ public final class WorldReading implements AutoCloseable { - /** The entries of a world's folder that make up what is read of it. */ - private static final long SETTLE_MILLIS = 500; - private static final Set ENTRIES = Set.of("level.dat", "level.dat_old", "session.lock", "datapacks", "icon.png"); - - /** The current world: its folder and what its level.dat holds, or why there is nothing to show. */ - public record World(Path directory, CurrentWorld.Saved saved, String problem) { - } - - /** What is read of a world's folder: its level.dat, and when its icon was written, so a new icon is a change too. */ - private record Read(CurrentWorld.Saved saved, FileTime icon) { - } - - /** The world followed and its reading, published together. */ - private record Followed(Path directory, FileReading reading) { + /** + * The current world: its folder, what its level.dat holds and when its icon was written, so a new icon is a change too; + * without what it holds where it could not be read, and all null where no world was played yet. + */ + public record World(Path directory, CurrentWorld.Saved saved, FileTime icon) { } - private final GameLocation location; - private final Signal changed = new Signal(); - private final Runnable stopFollowingSaves; - private final Runnable stopFollowingDatapacks; - private final Strand strand = Workers.strand(); - private final Runnable stopFollowingGame; - private volatile Followed followed = new Followed(null, null); - // Changed on the strand only. - private Runnable stopReading = () -> { }; - /** The played world whose datapacks folder is watched, or null: a change inside it is not told by the world's own watch. */ - private Path datapacksOf; - private Runnable stopDatapacks = () -> { }; - private boolean closed; + private final FileReading reading; + private final List stopFollowing; - /** - * Follows the current world of the game {@code location} tells of, whose datapacks {@code packs} names. Reads which - * world that is now. Blocking. - */ + /** Follows the current world of the game {@code location} tells of, whose datapacks {@code packs} names. */ public WorldReading(GameLocation location, GamePacks packs) { - this.location = Objects.requireNonNull(location, "location"); - // A game opening a world creates its session lock before it takes it: which world is open is looked at after that. - this.stopFollowingSaves = FileWatch.shared().watch(location.workspace().resolve("saves"), world -> true, - () -> Workers.later(SETTLE_MILLIS, this.strand, this::follow)); - // Companion changed the datapacks, also in the level.dat of a world the game does not hold. - this.stopFollowingDatapacks = packs.changed(ChangeRecord.PackSide.DATA).subscribe(() -> this.strand.execute(this::follow)); - Runnable stopConnection = location.connectionChanged().subscribe(() -> this.strand.execute(this::follow)); - Runnable stopPlaying = location.playingChanged().subscribe(() -> this.strand.execute(this::follow)); - this.stopFollowingGame = () -> { - stopConnection.run(); - stopPlaying.run(); - }; - // Followed after the triggers are, so none is missed in between; on the strand, as every later look. - CompletableFuture.runAsync(this::follow, this.strand).join(); + Objects.requireNonNull(location, "location"); + this.reading = new FileReading<>(() -> read(location.read())); + this.stopFollowing = List.of(location.connectionChanged().subscribe(this.reading::refresh), + location.playingChanged().subscribe(this.reading::refresh), + // Also in the level.dat of a world the game does not hold. + packs.changed(ChangeRecord.PackSide.DATA).subscribe(this.reading::refresh)); + this.reading.refresh(); } /** Fires after the current world changed or what it holds did. */ public Signal changed() { - return this.changed; + return this.reading.changed(); } /** The name of the current world as read last, without reading; empty where none was read yet. */ public Optional publishedName() { - Followed now = this.followed; - if (now.reading() == null) return Optional.empty(); - return now.reading().published().map(read -> read.saved().name()); + return this.reading.published().map(World::saved).map(CurrentWorld.Saved::name); } - /** The current world as read last; read now where it was not read yet. Blocking then. */ - public World value() { - Followed now = this.followed; - if (now.directory() == null) return new World(null, null, "No world has been played in this instance yet."); - try { - return new World(now.directory(), now.reading().value().saved(), ""); - } catch (IOException unreadable) { - return new World(now.directory(), null, - "The world " + now.directory().getFileName() + " could not be read: " + unreadable.getMessage()); - } + /** + * The current world as read last; read now where it was not read yet, which fails where it cannot be read. A read + * that fails later, as while the game writes level.dat, keeps the world read before. Blocking then. + */ + public World value() throws IOException { + return this.reading.value(); } - /** Finds which world is current now and follows it; the one followed so far is read again, as it may have opened. */ - private void follow() { - if (this.closed) return; - GameState game = this.location.read(); - Path current = CurrentWorld.directory(game).orElse(null); - Followed before = this.followed; - boolean played = current != null && game.plays(current); - if (Objects.equals(current, before.directory())) { - if (before.reading() != null) { - before.reading().watch(played); - watchDatapacks(played ? current : null, before.reading()); - before.reading().readNow(); - } - return; - } - this.stopReading.run(); - if (current == null) { - watchDatapacks(null, null); - this.stopReading = () -> { }; - this.followed = new Followed(null, null); - } else { - FileReading reading = new FileReading<>(current, entry -> ENTRIES.contains(entry.toString()), - world -> new Read(CurrentWorld.read(this.location.read(), world), iconWritten(world)), Duration.ofMillis(SETTLE_MILLIS)); - reading.watch(played); - watchDatapacks(played ? current : null, reading); - Runnable stopTelling = reading.changed().subscribe(this.changed::fire); - this.stopReading = () -> { - stopTelling.run(); - reading.close(); - }; - this.followed = new Followed(current, reading); - // Told once the new world's first read landed, so its followers find it published, as the tab its name. - Workers.files().execute(() -> { - try { - reading.value(); - } catch (IOException unreadable) { - // The World page shows why. - } - this.changed.fire(); - }); - return; + /** + * Reads the current world. A world read before that fails to read now, as while the game writes its level.dat, fails, + * so the world read before stays; another world that fails to read is published without what it holds, so nothing + * shows or changes the world before as if it were current. + */ + private World read(GameState game) throws IOException { + Path directory = CurrentWorld.directory(game).orElse(null); + if (directory == null) return new World(null, null, null); + try { + return new World(directory, CurrentWorld.read(game, directory), iconWritten(directory)); + } catch (IOException unreadable) { + if (this.reading.published().map(World::directory).filter(directory::equals).isPresent()) throw unreadable; + return new World(directory, null, null); } - if (before.directory() != null) this.changed.fire(); - } - - /** Watches the datapacks folder of {@code world}, which the game plays, or none; a change there reads it again. */ - private void watchDatapacks(Path world, FileReading reading) { - if (Objects.equals(world, this.datapacksOf)) return; - this.stopDatapacks.run(); - this.stopDatapacks = () -> { }; - this.datapacksOf = world; - if (world != null) this.stopDatapacks = FileWatch.shared().watch(world.resolve("datapacks"), entry -> true, reading::readNow); } /** When the world's icon was written, or null without one. */ private static FileTime iconWritten(Path world) { try { - Path icon = world.resolve("icon.png"); - return Files.isRegularFile(icon) ? Files.getLastModifiedTime(icon) : null; - } catch (IOException unreadable) { + return Files.getLastModifiedTime(world.resolve("icon.png")); + } catch (IOException none) { return null; } } @Override public void close() { - this.stopFollowingGame.run(); - this.stopFollowingSaves.run(); - this.stopFollowingDatapacks.run(); - this.strand.execute(() -> { - this.closed = true; - this.stopDatapacks.run(); - this.stopReading.run(); - }); + this.stopFollowing.forEach(Runnable::run); + this.reading.close(); } } diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/decompile/CompanionDecompilationService.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/decompile/CompanionDecompilationService.java index 21e8db8ba..d6e04b238 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/decompile/CompanionDecompilationService.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/decompile/CompanionDecompilationService.java @@ -1,5 +1,6 @@ package com.github.minecraft_ta.totalDebugCompanion.decompile; +import com.github.minecraft_ta.totalDebugCompanion.util.Signal; import com.github.minecraft_ta.totalDebugCompanion.bytecode.RuntimeSnapshotBytecodeSource; import com.github.minecraft_ta.totalDebugCompanion.decompiler.DecompilationResult; import com.github.minecraft_ta.totalDebugCompanion.decompiler.DecompilerDiagnostic; @@ -30,6 +31,7 @@ public final class CompanionDecompilationService implements AutoCloseable { private final ExecutorService cacheReaders; private final Map> inFlightRequests = new HashMap<>(); private final Object publicationLock = new Object(); + private final Signal cached = new Signal(); private volatile boolean closed; public CompanionDecompilationService( @@ -109,6 +111,11 @@ public Path cacheDirectory() { return this.sourceStore.directory(); } + /** Fires after a class was decompiled and its source cached ({@link #cachedClasses()}). */ + public Signal cached() { + return this.cached; + } + public List cachedClasses() throws IOException { // A retired tree may finish refreshing after its runtime has been replaced. return this.closed ? List.of() : this.sourceStore.cachedClasses(); @@ -145,11 +152,14 @@ private DecompiledSource decompileNow(String binaryName) throws IOException { this.bytecodeSource.requireCurrent(); SourceDocument document = new SourceDocument(binaryName, result.source(), result.lineMap(), result.variableNames(), result.symbols()); document.prepare(); + DecompiledSource source; synchronized (this.publicationLock) { ensureOpen(); Path path = this.sourceStore.write(document); - return new DecompiledSource(path, document, this.bytecodeSource.findClassOrigin(binaryName)); + source = new DecompiledSource(path, document, this.bytecodeSource.findClassOrigin(binaryName)); } + this.cached.fire(); + return source; } private DecompiledSource readStoredSource(String binaryName) throws IOException { diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/pack/ExternalEdits.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/pack/ExternalEdits.java index a208589d1..06ddb0fc5 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/pack/ExternalEdits.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/pack/ExternalEdits.java @@ -88,12 +88,14 @@ Path follow(String path, Path pack) throws IOException { if (!Files.isRegularFile(file)) throw new IOException(file + " does not exist"); synchronized (this) { if (this.closed) throw new IOException("The project is closing"); - if (!this.followed.containsKey(file)) { - Followed followed = new Followed(path, pack, Files.readAllBytes(file)); - Path name = file.getFileName(); - followed.unwatch = FileWatch.shared().watch(file.getParent(), name::equals, () -> settle(followed)); - this.followed.put(file, followed); - } + Followed known = this.followed.get(file); + Followed followed = known != null ? known : new Followed(path, pack, Files.readAllBytes(file)); + // Watched anew at every opening, so a file whose folder was removed and made again is followed again; followed + // only once watched, so a watch that fails leaves nothing behind. + Runnable before = followed.unwatch; + followed.unwatch = FileWatch.shared().watch(file, () -> settle(followed)); + before.run(); + this.followed.putIfAbsent(file, followed); } return file; } diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/project/ProjectScope.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/project/ProjectScope.java index dcadfc065..9c9d7bca5 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/project/ProjectScope.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/project/ProjectScope.java @@ -14,7 +14,6 @@ import com.github.minecraft_ta.totalDebugCompanion.catalog.ConfigSettings; import com.github.minecraft_ta.totalDebugCompanion.catalog.KeyBindingLabels; import com.github.minecraft_ta.totalDebugCompanion.catalog.ConfigChanges; -import com.github.minecraft_ta.totalDebugCompanion.catalog.KeyAssignments; import com.github.minecraft_ta.totalDebugCompanion.catalog.KeyBindingControl; import com.github.minecraft_ta.totalDebugCompanion.change.ChangeLabels; import com.github.minecraft_ta.totalDebugCompanion.change.ChangePipeline; @@ -85,7 +84,6 @@ public List changeLabels() { private final ChangePipeline pipeline; /** The one path of the changes Companion makes to values the game keeps. */ public ChangePipeline pipeline() { return pipeline; } - private final KeyAssignments keyAssignments; private final KeyBindingControl keyBindings; /** Puts the instance's key bindings on keys, in the running game or in options.txt. */ public KeyBindingControl keyBindings() { return keyBindings; } @@ -118,8 +116,7 @@ public ProjectScope(Object lock, CompanionProfile profile, InstanceState state, this.configChanges = new ConfigChanges(this.location, changes); this.pipeline = new ChangePipeline(this.location, changes, this.writes); this.configSettings = new ConfigSettings(this.configChanges, this.pipeline); - this.keyAssignments = new KeyAssignments(profile.workspaceDirectory().resolve("options.txt")); - this.keyBindings = new KeyBindingControl(this.pipeline, this.keyAssignments); + this.keyBindings = new KeyBindingControl(this.pipeline); this.packs = new GamePacks(this.location); this.world = new WorldReading(this.location, this.packs); this.resources = new ResourceEdits(this.pipeline, this.packs, new ResourceOriginals(paths().originals()), @@ -230,7 +227,7 @@ public List drainNavigations() { pending.clear(); } // Writes still queued finish first, so each is recorded before the change record closes. - try { keyAssignments.close(); world.close(); resources.close(); writes.close(); closeRuntime(); } finally { try { state.close(); } finally { changes.close(); } } + try { keyBindings.close(); world.close(); resources.close(); writes.close(); closeRuntime(); } finally { try { state.close(); } finally { changes.close(); } } } public String loadBreakpointScript(String name) { diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/PageLoader.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/PageLoader.java index 39bdc1b43..e3604b1d4 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/PageLoader.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/PageLoader.java @@ -1,6 +1,8 @@ package com.github.minecraft_ta.totalDebugCompanion.ui.components; import com.github.minecraft_ta.totalDebugCompanion.util.Signal; +import com.github.minecraft_ta.totalDebugCompanion.util.UIUtils; +import com.github.minecraft_ta.totalDebugCompanion.util.WindowFocus; import com.github.minecraft_ta.totalDebugCompanion.util.Workers; import javax.swing.JComponent; @@ -30,7 +32,8 @@ * page shown again with nothing changed reads nothing, unless its last read failed. While the page holds its reads * ({@link #hold}), as during a save, signals wait in the same way. The page never reads in its constructor or because a * navigation showed it. A page whose files others write and only it reads, such as the logs, reads whenever it is shown - * ({@link #readsWhenShown}); work that only redraws from memory waits the same way ({@link #updates}).

+ * and when the user comes back to Companion while it is shown ({@link #readsWhenShown}); work that only redraws from + * memory waits the same way ({@link #updates}).

*/ public final class PageLoader { /** What to read: prepared on the Swing thread, where the page's state is captured, then run off it. */ @@ -129,30 +132,41 @@ public boolean fired(Signal signal) { } /** - * Reads every time {@code component} is shown, such as a tab listing files that change without telling. It may be a - * part of the page, which then reads when that part is chosen. + * Reads every time {@code component} is shown, and when the user comes back to Companion from another program while it + * is shown, such as a tab listing files that change without telling. It may be a part of the page, which then reads + * when that part is chosen. */ public PageLoader readsWhenShown(JComponent component) { + this.unsubscribe.add(WindowFocus.returned().subscribe(() -> UIUtils.onEdt(() -> { + if (!this.disposed) readShown(component, true); + }))); return watch(component, true); } /** Reads when {@code component} is shown: {@code always}, or only what the page missed. */ private PageLoader watch(JComponent component, boolean always) { HierarchyListener listener = event -> { - if ((event.getChangeFlags() & HierarchyEvent.SHOWING_CHANGED) == 0 || !component.isShowing()) return; - if (!always && this.missed.isEmpty() || this.showReadQueued) return; - this.showReadQueued = true; - SwingUtilities.invokeLater(() -> { - this.showReadQueued = false; - if (always) load(); - else resume(); - }); + if ((event.getChangeFlags() & HierarchyEvent.SHOWING_CHANGED) != 0) readShown(component, always); }; component.addHierarchyListener(listener); this.unwatch.add(() -> component.removeHierarchyListener(listener)); return this; } + /** + * Reads at the end of the Swing step while {@code component} is shown: {@code always}, or only what the page missed. + * Whatever asks in the same step, as the window shown again and taking the focus, reads once. Swing thread. + */ + private void readShown(JComponent component, boolean always) { + if (!component.isShowing() || !always && this.missed.isEmpty() || this.showReadQueued) return; + this.showReadQueued = true; + SwingUtilities.invokeLater(() -> { + this.showReadQueued = false; + if (always) load(); + else resume(); + }); + } + /** * Runs {@code update}, which only redraws from memory, such as icons again after new ones came, when {@code signal} * fires: at once while the page is shown, otherwise once it is shown again, once however often it fired. Swing thread. diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/catalog/WorldPanel.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/catalog/WorldPanel.java index 1b35e74bc..5d0e8c009 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/catalog/WorldPanel.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/catalog/WorldPanel.java @@ -146,8 +146,8 @@ public WorldPanel(PackCatalogService catalog, ItemIconService icons, WorldReadin this.cards.add(page, PAGE_CARD); add(this.cards, BorderLayout.CENTER); - // The world's owner reads it when the game saves it or plays another; the datapacks the connected game names - // come with its packs. + // The world's owner reads it when the game plays another and when the user comes back to Companion; the + // datapacks the connected game names come with its packs. this.loader = new PageLoader<>(() -> { PackStackPayload stack = edits.packs().datapacks(); String refusal = edits.packs().worldRefusal(); @@ -168,7 +168,8 @@ private static Loaded read(GameState game, WorldReading world, PackStackPayload Optional server = game.server(); if (server.isPresent()) return readServer(game, server.get(), stack, refusal); WorldReading.World current = world.value(); - if (current.saved() == null) return Loaded.problem(current.problem()); + if (current.directory() == null) return Loaded.problem("No world has been played in this instance yet."); + if (current.saved() == null) return Loaded.problem("The world " + current.directory().getFileName() + " could not be read."); CurrentWorld.Saved saved = current.saved(); return new Loaded(saved, null, PackResources.worldDatapacks(stack, saved), icon(current.directory().resolve("icon.png")), ""); } diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/treeView/DecompiledSourcesTreeItem.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/treeView/DecompiledSourcesTreeItem.java index e7ed5bf5a..93977d5ff 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/treeView/DecompiledSourcesTreeItem.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/treeView/DecompiledSourcesTreeItem.java @@ -1,6 +1,5 @@ package com.github.minecraft_ta.totalDebugCompanion.ui.components.treeView; -import com.github.minecraft_ta.totalDebugCompanion.util.FileWatch; import com.github.minecraft_ta.totalDebugCompanion.Icons; import com.github.minecraft_ta.totalDebugCompanion.decompile.CompanionDecompilationService; import com.github.minecraft_ta.totalDebugCompanion.ui.components.treeView.lazyFileTree.*; @@ -13,14 +12,13 @@ /** Shows cached source units, not their storage metadata or generation directories. */ final class DecompiledSourcesTreeItem extends DirectoryTreeItem { private final CompanionDecompilationService service; - private final Runnable stopWatching; + private final Runnable stopFollowing; DecompiledSourcesTreeItem(LazyFileJTree tree, CompanionDecompilationService service) { super("decompiled-files"); this.service = service; setIcon(Icons.FOLDER); - this.stopWatching = FileWatch.shared().watchEntries(service.cacheDirectory(), - () -> tree.loadItemsForTopLevelItem(this)); + this.stopFollowing = service.cached().subscribe(() -> tree.loadItemsForTopLevelItem(this)); } @Override @@ -34,7 +32,7 @@ public List loadChildren() { @Override public void dispose() { - this.stopWatching.run(); + this.stopFollowing.run(); } static final class SourceItem extends TreeItem { diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/treeView/FileTreeView.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/treeView/FileTreeView.java index e9a12d67a..f710e4a7c 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/treeView/FileTreeView.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/treeView/FileTreeView.java @@ -1,5 +1,6 @@ package com.github.minecraft_ta.totalDebugCompanion.ui.components.treeView; +import com.github.minecraft_ta.totalDebugCompanion.util.WindowFocus; import com.github.minecraft_ta.totalDebugCompanion.util.Workers; import com.github.minecraft_ta.totalDebugCompanion.storage.ChangeRecord; import com.github.minecraft_ta.totalDebugCompanion.project.ProjectScope; @@ -50,13 +51,28 @@ public CompletableFuture refreshDirectory(Path parent) { } private boolean hasScriptsRoot(ProjectScope scope) { - if (scope == null) return false; + return scriptsRoot(scope) != null; + } + + /** The Scripts root of {@code scope}'s tree, or null. Swing thread only. */ + private FileSystemDirectoryItem scriptsRoot(ProjectScope scope) { + if (scope == null) return null; var root = (LazyTreeNode) tree.getModel().getRoot(); for (int i = 0; i < root.getChildCount(); i++) { var item = ((LazyTreeNode) root.getChildAt(i)).getUserObject(); - if (item instanceof FileSystemDirectoryItem folder && folder.getPath().equals(scope.paths().scripts())) return true; + if (item instanceof FileSystemDirectoryItem folder && folder.getPath().equals(scope.paths().scripts())) return folder; } - return false; + return null; + } + + /** + * Lists the loaded folders of Scripts again, which an editor may have changed while the user was away, or adds the + * Scripts root where the folder was made meanwhile; completes once listed. Swing thread only. + */ + private CompletableFuture refreshScripts() { + if (disposed) return CompletableFuture.completedFuture(null); + var scripts = scriptsRoot(project.get()); + return scripts != null ? tree.refreshRoot(scripts, true) : ensureScriptsRoot(); } /** Creation can introduce Scripts after profile loading; prepare only that new root off the EDT. */ @@ -69,7 +85,7 @@ private CompletableFuture ensureScriptsRoot() { var factory = tree.getItemFactory(); var loading = CompletableFuture.supplyAsync(() -> { if (!Files.isDirectory(scope.paths().scripts())) return null; - var scripts = factory.createFileSystemDirectoryItem(scope.paths().scripts(), true); + var scripts = factory.createFileSystemDirectoryItem(scope.paths().scripts()); scripts.setIcon(FileTreeIcons.forRootDirectory("scripts")); return scripts; }, Workers.files()).thenAcceptAsync(scripts -> { @@ -91,17 +107,19 @@ private CompletableFuture ensureScriptsRoot() { }, SwingUtilities::invokeLater); return loading; } - public void dispose() { disposed = true; removeWorldListener.run(); tree.setRootNodes(); } + public void dispose() { disposed = true; stopFollowingFocus.run(); removeWorldListener.run(); tree.setRootNodes(); } private final Supplier project; private ProjectScope displayedProject; private Runnable removeWorldListener = () -> { }; + private final Runnable stopFollowingFocus; public FileTreeView(Supplier project, Consumer navigator) { super(); this.project = project; Objects.requireNonNull(navigator, "navigator"); + this.stopFollowingFocus = WindowFocus.returned().subscribe(() -> SwingUtilities.invokeLater(this::refreshScripts)); this.tree = new LazyFileJTree(); ContextMenus.installTree(this.tree, path -> createContextMenu(path != null && path.getLastPathComponent() instanceof LazyTreeNode node @@ -232,7 +250,7 @@ public void reloadProfile() { RuntimeSourceCatalog catalog = scope.sources(); List rootItems = new ArrayList<>(); if (Files.isDirectory(scope.paths().scripts())) { - var scripts = this.tree.getItemFactory().createFileSystemDirectoryItem(scope.paths().scripts(), true); + var scripts = this.tree.getItemFactory().createFileSystemDirectoryItem(scope.paths().scripts()); scripts.setIcon(FileTreeIcons.forRootDirectory("scripts")); rootItems.add(scripts); } @@ -452,7 +470,12 @@ public CompletableFuture revealLocalPath(Path directory) { if (!target.equals(root)) { for (Path segment : root.relativize(target)) relative.add(segment.toString()); } - return this.tree.revealItemPath(root.getFileName().toString(), relative); + String name = root.getFileName().toString(); + // A script Companion made beside the tree's own actions, as a tool from the Tools menu, is listed first. + return this.tree.revealItemPath(name, relative).thenComposeAsync(revealed -> revealed + ? CompletableFuture.completedFuture(true) + : refreshScripts().handle((ignored, failure) -> null).thenCompose(ignored -> this.tree.revealItemPath(name, relative)), + SwingUtilities::invokeLater); } return CompletableFuture.completedFuture(false); } diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/treeView/ScriptFileActions.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/treeView/ScriptFileActions.java index bb085e301..5da8eff3e 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/treeView/ScriptFileActions.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/treeView/ScriptFileActions.java @@ -1,7 +1,6 @@ package com.github.minecraft_ta.totalDebugCompanion.ui.components.treeView; import com.github.minecraft_ta.totalDebugCompanion.util.Workers; -import com.github.minecraft_ta.totalDebugCompanion.util.FileWatch; import com.github.minecraft_ta.totalDebugCompanion.notification.NotificationCenter.Source; import com.github.minecraft_ta.totalDebugCompanion.notification.NotificationCenter.Severity; import com.formdev.flatlaf.util.SystemFileChooser; @@ -376,7 +375,7 @@ private CompletableFuture execute(List roots, boolean deleting, Work ctx.project().requireActive(); for (Path root : roots) ctx.project().scriptFiles().mutable(root); for (var entry : drafts.entrySet()) { entry.getKey().persist(entry.getValue()); saved.add(entry.getKey()); } - FileWatch.shared().pausing(roots, () -> work.run(ctx.project().scriptFiles(), changes)); + work.run(ctx.project().scriptFiles(), changes); } catch (IOException failure) { throw new CompletionException(failure); } finally { if (deleting) { diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/treeView/WorldTreeItems.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/treeView/WorldTreeItems.java index d1c8cc5fb..6b55cb594 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/treeView/WorldTreeItems.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/treeView/WorldTreeItems.java @@ -17,6 +17,7 @@ import com.github.minecraft_ta.totaldebug.protocol.message.PackStackPayload; import com.github.minecraft_ta.totaldebug.protocol.message.PlayingPayload; +import java.io.IOException; import java.text.NumberFormat; import java.util.ArrayList; import java.util.List; @@ -78,7 +79,12 @@ public List loadChildren() { return count == 0 ? List.of() : List.of(new Tab(WorldTab.DATAPACKS, count)); } // As its owner read it last; the page says why a world could not be read. - CurrentWorld.Saved saved = this.world.value().saved(); + CurrentWorld.Saved saved; + try { + saved = this.world.value().saved(); + } catch (IOException unreadable) { + return List.of(); + } if (saved == null) return List.of(); List children = new ArrayList<>(); if (!saved.gameRules().isEmpty()) children.add(new Tab(WorldTab.GAME_RULES, saved.gameRules().size())); diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/treeView/lazyFileTree/DirectoryChain.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/treeView/lazyFileTree/DirectoryChain.java index ee7073a2e..834eccead 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/treeView/lazyFileTree/DirectoryChain.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/treeView/lazyFileTree/DirectoryChain.java @@ -26,9 +26,6 @@ public static List segments(TreeItem item) { public static TreeItem first(TreeItem item) { return segments(item).getFirst(); } public static TreeItem last(TreeItem item) { return segments(item).getLast(); } - static boolean changedDuringDiscovery(TreeItem item) { - return segments(item).stream().anyMatch(part -> part instanceof FileSystemDirectoryItem folder && folder.changedDuringDiscovery()); - } public String separator() { return separator; } public boolean supportsSegmentSelection() { return segments.getFirst() instanceof FileSystemDirectoryItem; } @Override public Icon getIcon() { return segments.getFirst().getIcon(); } diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/treeView/lazyFileTree/FileSystemDirectoryItem.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/treeView/lazyFileTree/FileSystemDirectoryItem.java index 64de6a214..c50e0f9a2 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/treeView/lazyFileTree/FileSystemDirectoryItem.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/treeView/lazyFileTree/FileSystemDirectoryItem.java @@ -1,10 +1,8 @@ package com.github.minecraft_ta.totalDebugCompanion.ui.components.treeView.lazyFileTree; -import com.github.minecraft_ta.totalDebugCompanion.util.FileWatch; import com.github.minecraft_ta.totalDebugCompanion.ui.Tooltip; import com.github.minecraft_ta.totalDebugCompanion.Icons; -import javax.swing.*; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; @@ -16,29 +14,17 @@ public class FileSystemDirectoryItem extends DirectoryTreeItem { private final LazyFileJTree tree; private final Path path; - private final Runnable stopWatching; - private final boolean watch; private boolean initiallyEmpty; - private volatile boolean changed; public Path getPath() { return path; } - boolean changedDuringDiscovery() { return changed; } - FileSystemDirectoryItem(LazyFileJTree lazyFileJTree, Path path, boolean watch) { + FileSystemDirectoryItem(LazyFileJTree lazyFileJTree, Path path) { super(path.getFileName().toString()); this.tree = lazyFileJTree; - this.watch = watch; if (!Files.isDirectory(path)) throw new IllegalArgumentException("Not a directory"); this.path = path; setIcon(Icons.FOLDER); - - this.stopWatching = watch - ? FileWatch.shared().watchEntries(path, () -> { - changed = true; - SwingUtilities.invokeLater(() -> lazyFileJTree.refreshDirectory(path)); - }) - : () -> {}; } @Override @@ -60,7 +46,7 @@ public List loadChildren() { private TreeItem createChildIfPresent(Path child) { try { if (Files.isDirectory(child)) { - var item = tree.getItemFactory().createFileSystemDirectoryItem(child, watch); + var item = tree.getItemFactory().createFileSystemDirectoryItem(child); // This runs in the parent's background scan. Peek once, without loading descendants. try (var entries = Files.newDirectoryStream(child)) { item.initiallyEmpty = !entries.iterator().hasNext(); @@ -79,8 +65,7 @@ private TreeItem createChildIfPresent(Path child) { @Override protected boolean isInitiallyEmpty() { - // A watcher notification before attachment invalidates the initial snapshot too. - return initiallyEmpty && !changed; + return initiallyEmpty; } @Override public String compactSeparator() { return "/"; } @@ -97,11 +82,6 @@ protected boolean isInitiallyEmpty() { return null; } - @Override - public void dispose() { - this.stopWatching.run(); - } - @Override public String getTooltip() { return Tooltip.of(Tooltip.shortPath(this.path)).html(); diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/treeView/lazyFileTree/FileTreeItemFactory.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/treeView/lazyFileTree/FileTreeItemFactory.java index d2a921ad5..28bd9a8fd 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/treeView/lazyFileTree/FileTreeItemFactory.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/treeView/lazyFileTree/FileTreeItemFactory.java @@ -10,8 +10,8 @@ public TreeItem createHiddenRoot() { return new TreeItem("", true); } - public FileSystemDirectoryItem createFileSystemDirectoryItem(Path path, boolean watch) { - return new FileSystemDirectoryItem(tree, path, watch); + public FileSystemDirectoryItem createFileSystemDirectoryItem(Path path) { + return new FileSystemDirectoryItem(tree, path); } public FileSystemFileItem createFileSystemFileItem(Path path) { diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/treeView/lazyFileTree/LazyFileJTree.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/treeView/lazyFileTree/LazyFileJTree.java index 428652031..20e86a673 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/treeView/lazyFileTree/LazyFileJTree.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/treeView/lazyFileTree/LazyFileJTree.java @@ -343,12 +343,7 @@ private CompletableFuture loadItemsForNode(LazyTreeNode node) { items.forEach(TreeItem::dispose); return loadItemsForNode(node); } - if (items.stream().anyMatch(DirectoryChain::changedDuringDiscovery)) { - items.forEach(TreeItem::dispose); - node.markChildrenStale(); - return loadItemsForNode(node); - } - // Filesystem notifications concern this directory; its loaded subfolders can stay cached, as they do for a + // A refresh of a folder concerns that folder; its loaded subfolders can stay cached, as they do for a // refresh of the rows only. boolean rows = this.rowsOnly.remove(node); return updateChildren(node, items, node.refreshDescendants() @@ -783,12 +778,12 @@ public boolean hasRootNode(String name) { * Puts {@code item} in place of the top-level root of its name, and loads the rows under it again when they were * loaded: with {@code below}, also every row loaded below them; otherwise only the rows directly under it, for a root * whose rows count what a source holds, such as the changes in effect. The other roots are left alone. Swing thread - * only. + * only. Completes once the rows under it are loaded again. */ - public void refreshRoot(DirectoryTreeItem item, boolean below) { + public CompletableFuture refreshRoot(DirectoryTreeItem item, boolean below) { if (!SwingUtilities.isEventDispatchThread()) throw new IllegalStateException("Tree roots must be refreshed on the Swing event thread"); LazyTreeNode root = findTopLevelNode(item.getName()); - if (root == null) return; + if (root == null) return CompletableFuture.completedFuture(null); TreeItem previous = root.getUserObject(); if (previous != item) { previous.dispose(); @@ -796,12 +791,12 @@ public void refreshRoot(DirectoryTreeItem item, boolean below) { getModel().nodeChanged(root); } boolean loading = this.activeLoads.containsKey(root); - if (!root.areChildrenLoaded() && !loading) return; + if (!root.areChildrenLoaded() && !loading) return CompletableFuture.completedFuture(null); // A load already running may be one that refreshes what is below, which this must not narrow. if (below) this.rowsOnly.remove(root); else if (!loading) this.rowsOnly.add(root); root.markChildrenStale(below); - loadItemsForNode(root); + return loadItemsForNode(root); } public CompletableFuture restoreItemPath(String rootName, List segments, boolean select) { diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/views/MainWindow.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/views/MainWindow.java index 4b3686c10..31ea247da 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/views/MainWindow.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/views/MainWindow.java @@ -25,6 +25,7 @@ import java.util.concurrent.CompletableFuture; import com.github.minecraft_ta.totalDebugCompanion.ui.CompanionUi; import com.github.minecraft_ta.totalDebugCompanion.util.UIUtils; +import com.github.minecraft_ta.totalDebugCompanion.util.WindowFocus; import com.github.minecraft_ta.totalDebugCompanion.Icons; import com.github.minecraft_ta.totalDebugCompanion.debugger.DebugEngine; import com.github.minecraft_ta.totalDebugCompanion.debugger.DebuggerSessionController; @@ -242,7 +243,7 @@ public void windowClosing(WindowEvent event) { Toolkit.getDefaultToolkit().addAWTEventListener( this, - AWTEvent.KEY_EVENT_MASK | AWTEvent.MOUSE_EVENT_MASK + AWTEvent.KEY_EVENT_MASK | AWTEvent.MOUSE_EVENT_MASK | AWTEvent.WINDOW_FOCUS_EVENT_MASK ); } @@ -439,6 +440,10 @@ public void showDebuggerValue(DebugEngine.StackFrame frame, DebugEngine.Variable @Override public void eventDispatched(AWTEvent event) { + if (event instanceof WindowEvent windowEvent) { + WindowFocus.changed(windowEvent); + return; + } if (event instanceof MouseEvent mouseEvent) { handleHistoryMouseButton(mouseEvent); return; diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/util/FileReading.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/util/FileReading.java index 4b71892e1..8cb5a51ce 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/util/FileReading.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/util/FileReading.java @@ -1,84 +1,59 @@ package com.github.minecraft_ta.totalDebugCompanion.util; -import java.util.Optional; import java.io.IOException; import java.io.InterruptedIOException; -import java.nio.file.Path; -import java.time.Duration; -import java.util.List; import java.util.Objects; +import java.util.Optional; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; -import java.util.concurrent.ScheduledFuture; -import java.util.concurrent.atomic.AtomicLong; -import java.util.function.Predicate; /** - * The owner of a value read from a file others write, such as the keys {@code options.txt} assigns (docs/SYSTEMS.md, - * section 2). Every read of the file and every change of the value runs on this reading's strand, in order; the value is - * published as it was read, and {@link #changed()} fires only when a read found another value than the one before. - * - *

A write is read once it has settled, as a file can be written in parts. A read that fails keeps the value read - * before, and is tried again after 1, 5 and 30 seconds and at the next write; the next read that succeeds is told, since a - * page may show the failure. The file's folder is followed through {@link FileWatch}, also before it exists.

- * - *

A reading may also follow a whole folder, such as a world's, where some of its entries make up the value.

+ * An owner's value read from files others write, such as the keys {@code options.txt} assigns (docs/SYSTEMS.md, section + * 2). Nothing watches the files. The whole contract: + *
    + *
  • Reads run one at a time, in order, on the reading's strand.
  • + *
  • {@link #value()} returns the value published last, or reads it now where none was.
  • + *
  • {@link #refresh()} reads once after it was asked, as after Companion wrote the file or the game told of a change; + * requests made while one waits are that one, and a read under way when one is made publishes nothing, since the + * one it waits for is newer. The first value it reads is told, for followers that show it without + * asking, such as a tab's title.
  • + *
  • The user coming back to Companion from another program ({@link WindowFocus#returned()}) refreshes a value read + * before; a value nobody asked for stays unread.
  • + *
  • A read that fails keeps the value read before, and the next read that succeeds is told.
  • + *
  • {@link #changed()} fires only when a read publishes another value than the one before.
  • + *
  • Once closed, no read starts and none publishes.
  • + *
*/ public final class FileReading implements AutoCloseable { - /** Reads the value a file or folder holds; a missing one is a value too, such as nothing assigned. Blocking. */ + /** Reads the value; a missing file is a value too, such as nothing assigned. Blocking. */ @FunctionalInterface public interface Reader { - T read(Path file) throws IOException; + T read() throws IOException; } - private static final List RETRY_MILLIS = List.of(1_000L, 5_000L, 30_000L); - /** A value as read, so a value that is null can be told from none read yet. */ private record Read(T value) { } private final Reader reader; - /** For a reading of a folder, the names of its entries that make up the value; null for a reading of a file. */ - private final Predicate entries; - private final long settleMillis; private final Signal changed = new Signal(); private final Strand strand = Workers.strand(); + private final Runnable stopFollowingFocus; /** The value read last, or null before the first read: published, so it is read without the strand. */ private volatile Read last; - // Changed on the strand only. - private Path file; - private Runnable unwatch = () -> { }; - /** Whether the file is watched; one that is not is read only when asked, as a world the game does not hold. */ - private boolean watching = true; - private ScheduledFuture pending; - /** - * Counts the reads asked for, counted where they are asked, also on the watcher's thread: a read waiting for a write - * to settle, or one under way when another write is seen, gives way to the later one and publishes nothing. - */ - private final AtomicLong generation = new AtomicLong(); - private boolean unreadable; - /** Set as soon as the reading closes, so a read under way then publishes nothing. */ + /** Whether the last read failed, so the next one that succeeds is told: a page may show the failure. Strand only. */ + private boolean failed; private volatile boolean closed; + /** Whether a refresh waits on the strand, which later requests join, and whether one of them reads a first value. */ + private boolean queued; + private boolean first; - /** Reads {@code file} with {@code reader}, and again after each write that stopped for {@code settle}. */ - public FileReading(Path file, Reader reader, Duration settle) { - this(file, null, reader, settle); - } - - /** - * Reads {@code folder} with {@code reader}, and again after each change of an entry that {@code entries} accepts that - * stopped for {@code settle}. - */ - public FileReading(Path folder, Predicate entries, Reader reader, Duration settle) { + public FileReading(Reader reader) { this.reader = Objects.requireNonNull(reader, "reader"); - this.entries = entries; - this.settleMillis = settle.toMillis(); - Path followed = Objects.requireNonNull(folder, "folder").toAbsolutePath().normalize(); - long generation = this.generation.incrementAndGet(); - this.strand.execute(() -> follow(followed, generation)); + this.stopFollowingFocus = WindowFocus.returned().subscribe(() -> request(false)); } - /** Fires on this reading's strand after a read found another value than the one before. */ + /** Fires on this reading's strand after a read published another value than the one before. */ public Signal changed() { return this.changed; } @@ -100,7 +75,7 @@ public T value() throws IOException { this.strand.execute(() -> { try { Read now = this.last; - read.complete(now != null ? now.value() : readHere(0)); + read.complete(now != null ? now.value() : read(false)); } catch (IOException | RuntimeException failure) { read.completeExceptionally(failure); } @@ -116,104 +91,64 @@ public T value() throws IOException { } } - /** Reads the file now rather than once a write settled, as after Companion wrote it. */ - public void readNow() { - long generation = this.generation.incrementAndGet(); - this.strand.execute(() -> read(0, generation)); + /** Reads once after this request, and tells the first value it reads. */ + public void refresh() { + request(true); } - /** Follows {@code file} from now on, as the current world after the game opened another; its value is read now. */ - public void moveTo(Path file) { - Path followed = Objects.requireNonNull(file, "file").toAbsolutePath().normalize(); - // A read of the file before gives way at once, so nothing it read is published after the move was asked for. - long generation = this.generation.incrementAndGet(); - this.strand.execute(() -> follow(followed, generation)); - } - - /** Stops following the file; a read under way publishes nothing. */ - @Override - public void close() { - this.closed = true; - this.strand.execute(() -> { - this.unwatch.run(); - if (this.pending != null) this.pending.cancel(false); - }); - } - - /** - * Watches the file, or stops watching it while it is read only when asked ({@link #readNow()}), as a world the game - * does not hold: on Windows, a watched folder cannot be deleted or renamed, as by the game's Delete World. - */ - public void watch(boolean watch) { - this.strand.execute(() -> { - if (this.closed || this.watching == watch) return; - this.watching = watch; - if (watch) register(); - else unregister(); - }); - } - - private void follow(Path followed, long generation) { - if (this.closed) return; - unregister(); - this.file = followed; - if (this.watching) register(); - read(0, generation); - } - - private void register() { - Path name = this.file.getFileName(); - this.unwatch = this.entries != null - ? FileWatch.shared().watch(this.file, this.entries, this::written) - : FileWatch.shared().watch(this.file.getParent(), name::equals, this::written); - } - - private void unregister() { - this.unwatch.run(); - this.unwatch = () -> { }; - } - - /** A write of the file was seen: it is read once no other came for the settle time. Any thread. */ - private void written() { - // Counted here, so a read under way gives way at once. - long generation = this.generation.incrementAndGet(); + /** Asks for one read on the strand, which reads a value nobody asked for only where {@code firstToo}. */ + private void request(boolean firstToo) { + synchronized (this) { + this.first |= firstToo; + if (this.queued) return; + this.queued = true; + } this.strand.execute(() -> { - if (this.closed) return; - if (this.pending != null) this.pending.cancel(false); - this.pending = Workers.later(this.settleMillis, this.strand, () -> read(0, generation)); - }); - } - - private void read(int attempt, long generation) { - if (this.closed || generation != this.generation.get()) return; - try { - readHere(generation); - } catch (IOException | RuntimeException unreadable) { - if (attempt < RETRY_MILLIS.size()) { - this.pending = Workers.later(RETRY_MILLIS.get(attempt), this.strand, () -> read(attempt + 1, generation)); + boolean readFirst; + synchronized (this) { + this.queued = false; + readFirst = this.first; + this.first = false; } - } + if (this.last == null && !this.failed && !readFirst) return; + try { + read(true); + } catch (IOException | RuntimeException unreadable) { + // Told by the next read that succeeds. + } + }); } /** - * Reads the file, publishes what it holds and tells the followers where that changed. A read for {@code generation}, - * other than 0 for one a page waits for, publishes nothing where another write was seen meanwhile. On the strand only. + * Reads the value, publishes it and tells the followers where it changed, or with {@code tellFirst} where it is the + * first. On the strand only. */ - private T readHere(long generation) throws IOException { + private T read(boolean tellFirst) throws IOException { + if (this.closed) throw new IOException("The project closed"); T now; try { - now = this.reader.read(this.file); + now = this.reader.read(); } catch (IOException | RuntimeException failure) { - // A read another write overtook fails for nothing that is current: the next read is not told for it. - if (generation == 0 || generation == this.generation.get()) this.unreadable = true; + this.failed = true; throw failure instanceof IOException unreadable ? unreadable : new IOException(failure); } - if (generation != 0 && generation != this.generation.get() || this.closed) return now; + if (this.closed) return now; + synchronized (this) { + // A request made while this read ran reads again and publishes: the newest wins, as in the page loader. + if (this.queued) return now; + } Read before = this.last; - boolean tell = this.unreadable || before != null && !Objects.equals(before.value(), now); + boolean tell = this.failed || (before == null ? tellFirst : !Objects.equals(before.value(), now)); this.last = new Read<>(now); - this.unreadable = false; + this.failed = false; if (tell) this.changed.fire(); return now; } + + /** No read starts or publishes from now on; the value stays published. */ + @Override + public void close() { + this.closed = true; + this.stopFollowingFocus.run(); + } } diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/util/FileWatch.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/util/FileWatch.java index cb67589ea..31e608ff6 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/util/FileWatch.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/util/FileWatch.java @@ -3,7 +3,6 @@ import java.io.IOException; import java.nio.file.ClosedWatchServiceException; import java.nio.file.FileSystems; -import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardWatchEventKinds; import java.nio.file.WatchEvent; @@ -11,75 +10,29 @@ import java.nio.file.WatchService; import java.util.ArrayList; import java.util.HashMap; -import java.util.Iterator; import java.util.List; import java.util.Map; -import java.util.Objects; -import java.util.concurrent.ScheduledFuture; -import java.util.function.Predicate; /** - * The one watcher of the folders Companion follows (docs/SYSTEMS.md, section 2). A folder that does not exist yet, as a - * game's before it first ran, is watched through its nearest existing ancestor and watched itself once it appears; one - * removed while watched is watched that way again. Where a folder cannot be watched at all, it is tried again after 1 - * and 5 seconds and every 30 seconds after, and its follower is told each time, so it reads what it follows then. - * Followers are told on the watcher's thread and hand the work on at once. - * - *

On Windows a folder cannot be renamed or moved while it or a folder inside it is watched: Companion's own moves of - * such folders run {@link #pausing}, without watches there.

+ * Tells when a file Companion handed to another program is written, such as a texture opened in an image editor, whose + * saves are taken at once (docs/SYSTEMS.md, section 2). Nothing else is watched: what others write is read when it is + * shown and when the user comes back to Companion. Followers are told on the watcher's thread and hand the work on at + * once. */ public final class FileWatch { - private static final List RETRY_MILLIS = List.of(1_000L, 5_000L, 30_000L); private static final System.Logger LOGGER = System.getLogger(FileWatch.class.getName()); private static final FileWatch SHARED = new FileWatch(); - /** Runs with no watch on or inside some folders. */ - @FunctionalInterface - public interface Operation { - void run() throws IOException; + /** A follower: the name of its file in the watched folder, and what to tell. */ + private record Watched(Path name, Runnable written) { } - /** A folder followed, with the names of its entries that matter and what to tell. */ - private static final class Watched { - final Path folder; - final Predicate names; - /** Told only of entries created or removed, not of those written, as a listing of the folder. */ - final boolean entriesOnly; - final Runnable changed; - /** The folder watched for it: its own, or its nearest existing ancestor; null while it cannot be watched. */ - Path at; - /** The watch of {@link #at}, which a folder reached through another path, as a link, shares. */ - WatchKey key; - /** The real folder whose pause holds this registration, even after that folder was moved. */ - Path pausedAt; - ScheduledFuture retry; - boolean closed; - - Watched(Path folder, Predicate names, boolean entriesOnly, Runnable changed) { - this.folder = folder; - this.names = names; - this.entriesOnly = entriesOnly; - this.changed = changed; - } - } - - /** Null where the system has no file watching at all; then every folder is followed by the retries alone. */ - private final WatchService service; - /** The watches by the real path of their folder, so one reached through a link is watched once. */ - private final Map keys = new HashMap<>(); - private final List watched = new ArrayList<>(); - /** The real paths of the folders no watch may be on or inside, with how many operations pause each. */ - private final Map paused = new HashMap<>(); + /** Made with the first watch. */ + private WatchService service; + /** The followers of each watched folder, by its watch, which every file of the folder shares. */ + private final Map> watched = new HashMap<>(); private FileWatch() { - WatchService created = null; - try { - created = FileSystems.getDefault().newWatchService(); - } catch (IOException | RuntimeException unavailable) { - LOGGER.log(System.Logger.Level.WARNING, "Files are read every 30 seconds, not watched: " + unavailable.getMessage()); - } - this.service = created; - if (created != null) Thread.ofPlatform().daemon().name("Companion file watch").start(this::run); } /** The application's watcher. */ @@ -88,228 +41,47 @@ public static FileWatch shared() { } /** - * Tells {@code changed} after an entry of {@code folder} whose name {@code names} accepts was created, changed or - * removed, and whenever events may have been lost; returns what stops it. + * Tells {@code written} after {@code file} was written, created or removed, and whenever its folder's events were + * lost or the folder was removed; returns what stops it. Fails where the file's folder cannot be watched. */ - public Runnable watch(Path folder, Predicate names, Runnable changed) { - return watch(new Watched(followedFolder(folder), Objects.requireNonNull(names, "names"), false, - Objects.requireNonNull(changed, "changed"))); - } - - /** - * Tells {@code changed} after an entry of {@code folder} was created or removed, not when one was written, and - * whenever events may have been lost; returns what stops it. For a listing of the folder. - */ - public Runnable watchEntries(Path folder, Runnable changed) { - return watch(new Watched(followedFolder(folder), name -> true, true, Objects.requireNonNull(changed, "changed"))); - } - - /** Keeps a linked folder's real target when it disappears, so watching its parent can follow its return. */ - private static Path followedFolder(Path folder) { - Path absolute = folder.toAbsolutePath().normalize(); - Path existing = absolute; - while (existing != null && !Files.isDirectory(existing)) existing = existing.getParent(); - if (existing != null) { - try { - return existing.toRealPath().resolve(existing.relativize(absolute)); - } catch (IOException | RuntimeException unavailable) { - // The ordinary registration retries paths it cannot resolve yet. - } + public synchronized Runnable watch(Path file, Runnable written) throws IOException { + if (this.service == null) { + this.service = FileSystems.getDefault().newWatchService(); + Thread.ofPlatform().daemon().name("Companion file watch").start(this::run); } - return absolute; - } - - /** - * Runs {@code operation} with no watch on or inside {@code roots}, so it can rename or move them on Windows, then - * watches them again and tells their followers, since the operation changed what they follow. Blocking. - */ - public void pausing(List roots, Operation operation) throws IOException { - List real = new ArrayList<>(); - for (Path root : roots) real.add(root.toRealPath()); - synchronized (this) { - real.forEach(root -> this.paused.merge(root, 1, Integer::sum)); - for (Iterator> keys = this.keys.entrySet().iterator(); keys.hasNext(); ) { - Map.Entry entry = keys.next(); - if (!paused(entry.getKey())) continue; - keys.remove(); - entry.getValue().cancel(); - for (Watched followed : this.watched) { - if (followed.key != entry.getValue()) continue; - followed.key = null; - followed.at = null; - followed.pausedAt = entry.getKey(); - } - } - } - try { - operation.run(); - } finally { - List tell = new ArrayList<>(); - synchronized (this) { - real.forEach(root -> this.paused.computeIfPresent(root, (ignored, count) -> count == 1 ? null : count - 1)); - for (Watched followed : this.watched) { - if (followed.key != null) continue; - if (followed.pausedAt == null || paused(followed.pausedAt)) continue; - if (followed.retry != null) followed.retry.cancel(false); - place(followed, 0); - if (followed.pausedAt == null) tell.add(followed.changed); - } - } - // Also where the operation failed: it may have changed something before. - tell.forEach(FileWatch::tell); - } - } - - private Runnable watch(Watched followed) { - synchronized (this) { - this.watched.add(followed); - place(followed, 0); - } - return () -> { - synchronized (this) { - followed.closed = true; - this.watched.remove(followed); - if (followed.retry != null) followed.retry.cancel(false); - release(followed.key); - } - }; - } - - /** Watches {@code followed}'s folder or its nearest existing ancestor, or tries again later. Under the lock. */ - private void place(Watched followed, int attempt) { - followed.pausedAt = null; - WatchKey previous = followed.key; - List passed = new ArrayList<>(); - Path at; - WatchKey key; - while (true) { - at = followed.folder; - while (at != null && !Files.isDirectory(at)) at = at.getParent(); - key = at == null ? null : register(at, followed); - if (key == null) { - at = null; - break; - } - // A folder created between looking and watching, as a game makes its folders at once, was not seen: once - // watched, the folder beneath is looked for again. - if (at.equals(followed.folder) || !Files.isDirectory(followed.folder.getRoot().resolve( - followed.folder.subpath(0, at.getNameCount() + 1)))) break; - passed.add(key); - } - followed.at = at; - followed.key = key; - if (previous != null && previous != key) release(previous); - // Ancestors watched on the way down that nothing else follows are let go. - passed.forEach(this::release); - if (key == null) retryLater(followed, attempt); - } - - /** The watch of {@code folder}, shared with every path that leads to it; null where it cannot be watched. */ - private WatchKey register(Path folder, Watched followed) { - if (this.service == null) return null; - try { - Path real = folder.toRealPath(); - // Watched again once the operation that pauses it ended. - if (paused(real)) { - followed.pausedAt = real; - return null; - } - WatchKey key = this.keys.get(real); - if (key != null && key.isValid()) return key; - key = real.register(this.service, StandardWatchEventKinds.ENTRY_CREATE, - StandardWatchEventKinds.ENTRY_MODIFY, StandardWatchEventKinds.ENTRY_DELETE); - this.keys.put(real, key); - return key; - } catch (IOException | RuntimeException unwatchable) { - return null; - } - } - - /** Whether {@code real} is a paused folder or inside one. Under the lock. */ - private boolean paused(Path real) { - for (Path root : this.paused.keySet()) { - if (real.startsWith(root)) return true; - } - return false; - } - - /** Stops the watch {@code key} once nothing followed uses it. Under the lock. */ - private void release(WatchKey key) { - if (key == null) return; - for (Watched followed : this.watched) { - if (followed.key == key) return; - } - this.keys.values().remove(key); + // A folder watched already keeps its watch. + WatchKey key = file.getParent().register(this.service, StandardWatchEventKinds.ENTRY_CREATE, + StandardWatchEventKinds.ENTRY_MODIFY, StandardWatchEventKinds.ENTRY_DELETE); + Watched follower = new Watched(file.getFileName(), written); + this.watched.computeIfAbsent(key, ignored -> new ArrayList<>()).add(follower); + return () -> stop(key, follower); + } + + private synchronized void stop(WatchKey key, Watched follower) { + List followers = this.watched.get(key); + if (followers == null || !followers.remove(follower) || !followers.isEmpty()) return; + this.watched.remove(key); key.cancel(); } - /** Tries to watch {@code followed} again after a while, and tells it then, so it reads what it follows. Under the lock. */ - private void retryLater(Watched followed, int attempt) { - long delay = RETRY_MILLIS.get(Math.min(attempt, RETRY_MILLIS.size() - 1)); - followed.retry = Workers.later(delay, Workers.files(), () -> { - synchronized (this) { - if (followed.closed) return; - if (followed.pausedAt != null && paused(followed.pausedAt)) { - retryLater(followed, attempt + 1); - return; - } - place(followed, attempt + 1); - if (followed.pausedAt != null) return; - } - tell(followed.changed); - }); - } - - /** Tells a follower; one follower's failure does not stop the watch of every other. */ - private static void tell(Runnable changed) { - try { - changed.run(); - } catch (RuntimeException failure) { - LOGGER.log(System.Logger.Level.WARNING, "A follower of a watched folder failed", failure); - } - } - - /** Whether an entry named among {@code names} matters to {@code followed}; a follower's failing names tell nothing. */ - private static boolean concerns(Watched followed, List names) { - try { - return names.stream().anyMatch(followed.names); - } catch (RuntimeException failure) { - // One follower's failure does not stop the watch of every other. - LOGGER.log(System.Logger.Level.WARNING, "A follower of a watched folder failed", failure); - return false; - } - } - private void run() { try { while (true) { WatchKey key = this.service.take(); List names = new ArrayList<>(); - List entries = new ArrayList<>(); boolean lost = false; for (WatchEvent event : key.pollEvents()) { if (event.kind() == StandardWatchEventKinds.OVERFLOW) lost = true; - else if (event.context() instanceof Path name) { - names.add(name); - if (event.kind() != StandardWatchEventKinds.ENTRY_MODIFY) entries.add(name); - } + else if (event.context() instanceof Path name) names.add(name); } List tell = new ArrayList<>(); synchronized (this) { + // A removed folder is watched no more: its followers are told once and find their file gone. boolean gone = !key.reset(); - if (gone) this.keys.values().remove(key); - for (Watched followed : List.copyOf(this.watched)) { - if (followed.key != key) continue; - if (gone || !followed.at.equals(followed.folder)) { - // Removed, or an ancestor whose entry toward the folder may have appeared: placed again. - Path toward = gone ? null : followed.folder.getName(followed.at.getNameCount()); - if (gone || lost || names.contains(toward)) { - if (gone) followed.key = null; - place(followed, 0); - tell.add(followed.changed); - } - } else if (lost || concerns(followed, followed.entriesOnly ? entries : names)) { - tell.add(followed.changed); + List followers = gone ? this.watched.remove(key) : this.watched.get(key); + if (followers != null) { + for (Watched follower : followers) { + if (gone || lost || names.contains(follower.name())) tell.add(follower.written()); } } } @@ -319,4 +91,13 @@ else if (event.context() instanceof Path name) { // The application ends. } } + + /** Tells a follower; one follower's failure does not stop the watch of every other. */ + private static void tell(Runnable written) { + try { + written.run(); + } catch (RuntimeException failure) { + LOGGER.log(System.Logger.Level.WARNING, "A follower of a watched file failed", failure); + } + } } diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/util/WindowFocus.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/util/WindowFocus.java new file mode 100644 index 000000000..e08c2db29 --- /dev/null +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/util/WindowFocus.java @@ -0,0 +1,35 @@ +package com.github.minecraft_ta.totalDebugCompanion.util; + +import java.awt.event.WindowEvent; + +/** + * The user coming back to Companion from another program (docs/SYSTEMS.md, section 2). That program, such as the game or + * an editor, may have written the files Companion shows; nothing watches them, so what reads them reads them again then: + * the file readings, and the pages that read whenever they are shown. Companion's window tells it of focus changes; a + * return is a window of Companion taking the focus after Companion lost it, so its first window opening is none. + */ +public final class WindowFocus { + private static final Signal RETURNED = new Signal(); + /** Whether the focus went to another program. Swing thread only. */ + private static boolean away; + + private WindowFocus() { + } + + /** Fires on the Swing thread when a window of Companion takes the focus back from another program. */ + public static Signal returned() { + return RETURNED; + } + + /** Tells of a window of Companion gaining or losing the focus. Swing thread only. */ + public static void changed(WindowEvent event) { + // The other window is null where the focus came from, or went to, another program. + if (event.getOppositeWindow() != null) return; + if (event.getID() == WindowEvent.WINDOW_LOST_FOCUS) { + away = true; + } else if (event.getID() == WindowEvent.WINDOW_GAINED_FOCUS && away) { + away = false; + RETURNED.fire(); + } + } +} diff --git a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/FileTreeRefreshTest.java b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/FileTreeRefreshTest.java index f429b9d7d..bc077019e 100644 --- a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/FileTreeRefreshTest.java +++ b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/FileTreeRefreshTest.java @@ -4,12 +4,15 @@ import com.github.minecraft_ta.totalDebugCompanion.session.CompanionProfile; import com.github.minecraft_ta.totalDebugCompanion.ui.components.treeView.FileTreeView; import com.github.minecraft_ta.totalDebugCompanion.ui.components.treeView.lazyFileTree.LazyFileJTree; +import com.github.minecraft_ta.totalDebugCompanion.ui.components.treeView.lazyFileTree.LazyTreeNode; +import com.github.minecraft_ta.totalDebugCompanion.util.WindowFocus; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; import javax.swing.SwingUtilities; import java.nio.file.Files; import java.nio.file.Path; +import java.util.ArrayList; import java.util.List; import java.util.concurrent.TimeUnit; @@ -53,4 +56,119 @@ void creatingAScriptAndRefreshingTheProfilePreservesTheOpenTree() throws Excepti scope.close(); } } + + @Test + void comingBackToCompanionListsTheLoadedScriptFoldersAgain() throws Exception { + var scope = ProjectScope.open(new Object(), CompanionProfile.forGame(directory)); + FileTreeView[] view = new FileTreeView[1]; + try { + Path scripts = Files.createDirectories(scope.paths().scripts()); + Files.createDirectories(scripts.resolve("nested")); + Files.writeString(scripts.resolve("nested/Selected.tdscript"), "return 1;"); + Files.writeString(scripts.resolve("Saved.tdscript"), "return 1;"); + SwingUtilities.invokeAndWait(() -> { + view[0] = new FileTreeView(() -> scope, ignored -> {}); + view[0].reloadProfile(); + }); + var tree = (LazyFileJTree) view[0].getViewport().getView(); + assertTrue(tree.revealItemPath("scripts", List.of("nested", "Selected.tdscript")).get(3, TimeUnit.SECONDS)); + var selection = tree.getSelectionPath(); + + // An editor adds a script in the open folder and deletes one beside it while the user is away. + Files.writeString(scripts.resolve("nested/Added.tdscript"), ""); + Files.delete(scripts.resolve("Saved.tdscript")); + SwingUtilities.invokeAndWait(() -> WindowFocus.returned().fire()); + + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); + while (!(names(tree, "nested").contains("Added.tdscript") && !names(tree).contains("Saved.tdscript")) + && System.nanoTime() < deadline) Thread.sleep(20); + assertEquals(List.of("Added.tdscript", "Selected.tdscript"), names(tree, "nested")); + assertEquals(List.of("nested"), names(tree)); + SwingUtilities.invokeAndWait(() -> { + assertTrue(tree.isExpanded(selection.getParentPath()), "the open folder stays open"); + assertEquals(selection, tree.getSelectionPath()); + }); + } finally { + SwingUtilities.invokeAndWait(() -> { if (view[0] != null) view[0].dispose(); }); + scope.retire(); + scope.close(); + } + } + + @Test + void comingBackToCompanionAddsAScriptsFolderMadeMeanwhile() throws Exception { + var scope = ProjectScope.open(new Object(), CompanionProfile.forGame(directory)); + FileTreeView[] view = new FileTreeView[1]; + try { + SwingUtilities.invokeAndWait(() -> { + view[0] = new FileTreeView(() -> scope, ignored -> {}); + view[0].reloadProfile(); + }); + var tree = (LazyFileJTree) view[0].getViewport().getView(); + boolean[] before = new boolean[1]; + SwingUtilities.invokeAndWait(() -> before[0] = tree.hasRootNode("scripts")); + assertFalse(before[0], "no Scripts folder, no root"); + + // A tool makes the Scripts folder while the user is away. + Path scripts = Files.createDirectories(scope.paths().scripts()); + Files.writeString(scripts.resolve("Made.tdscript"), ""); + SwingUtilities.invokeAndWait(() -> WindowFocus.returned().fire()); + assertTrue(awaitRevealed(tree), "coming back adds the root"); + } finally { + SwingUtilities.invokeAndWait(() -> { if (view[0] != null) view[0].dispose(); }); + scope.retire(); + scope.close(); + } + } + + @Test + void aToolMadeFromTheToolsMenuIsShownInTheLoadedTree() throws Exception { + var scope = ProjectScope.open(new Object(), CompanionProfile.forGame(directory)); + FileTreeView[] view = new FileTreeView[1]; + try { + Path scripts = Files.createDirectories(scope.paths().scripts()); + Files.writeString(scripts.resolve("Existing.tdscript"), ""); + SwingUtilities.invokeAndWait(() -> { + view[0] = new FileTreeView(() -> scope, ignored -> {}); + view[0].reloadProfile(); + }); + var tree = (LazyFileJTree) view[0].getViewport().getView(); + assertTrue(tree.revealItemPath("scripts", List.of("Existing.tdscript")).get(5, TimeUnit.SECONDS)); + + // The Tools menu makes the tools folder and a script in it, then navigates to the script. + Path tool = scope.scriptFiles().create(scope.scriptFiles().create(scripts, "tools", true, ""), "Gear", false, ""); + assertTrue(view[0].revealLocalPath(tool).get(5, TimeUnit.SECONDS), "the new script is listed and shown"); + } finally { + SwingUtilities.invokeAndWait(() -> { if (view[0] != null) view[0].dispose(); }); + scope.retire(); + scope.close(); + } + } + + /** Tries to reveal the script for a while, as the root is prepared off the Swing thread. */ + private static boolean awaitRevealed(LazyFileJTree tree) throws Exception { + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); + while (System.nanoTime() < deadline) { + if (tree.revealItemPath("scripts", List.of("Made.tdscript")).get(5, TimeUnit.SECONDS)) return true; + Thread.sleep(50); + } + return false; + } + + /** The names of the rows under Scripts, or under its loaded folder {@code folder}. */ + private static List names(LazyFileJTree tree, String... folder) throws Exception { + List names = new ArrayList<>(); + SwingUtilities.invokeAndWait(() -> { + var node = (LazyTreeNode) ((LazyTreeNode) tree.getModel().getRoot()).getChildAt(0); + for (String name : folder) { + for (int i = 0; i < node.getChildCount(); i++) { + if (node.getChildAt(i) instanceof LazyTreeNode child && child.getUserObject().getName().equals(name)) node = child; + } + } + for (int i = 0; i < node.getChildCount(); i++) { + if (node.getChildAt(i) instanceof LazyTreeNode child) names.add(child.getUserObject().getName()); + } + }); + return names; + } } diff --git a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/SystemsRulesTest.java b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/SystemsRulesTest.java index 9e3f97455..3fa44b3bf 100644 --- a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/SystemsRulesTest.java +++ b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/SystemsRulesTest.java @@ -84,7 +84,7 @@ private record Allowed(int times, String why) { Map.entry("session/ProjectSelectionServer.java", new Allowed(2, "accepts connections")), Map.entry("inspection/ItemIconService.java", new Allowed(2, "the item icon renderer is confined to one thread")), Map.entry("util/Workers.java", new Allowed(8, "the shared file work, owners and timer, and each project's write worker")), - Map.entry("util/FileWatch.java", new Allowed(1, "the one watcher of the folders Companion follows"))); + Map.entry("util/FileWatch.java", new Allowed(1, "the watch of files handed to another program"))); // Every async task names its worker. private static final Map SHARED_POOL = Map.of(); @@ -108,7 +108,7 @@ private record Allowed(int times, String why) { Map.entry("pack/ExternalEdits.java", new Allowed(1, "an external save's result, an event"))); private static final Map WATCHERS = Map.ofEntries( - Map.entry("util/FileWatch.java", new Allowed(1, "the one watcher of the folders Companion follows"))); + Map.entry("util/FileWatch.java", new Allowed(1, "the watch of files handed to another program"))); @BeforeAll static void scan() throws IOException { diff --git a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/catalog/ConfigChangesTest.java b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/catalog/ConfigChangesTest.java index 063a460a2..c9050b9b8 100644 --- a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/catalog/ConfigChangesTest.java +++ b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/catalog/ConfigChangesTest.java @@ -1,7 +1,5 @@ package com.github.minecraft_ta.totalDebugCompanion.catalog; -import java.util.ArrayList; -import org.junit.jupiter.api.AfterEach; import com.github.minecraft_ta.totalDebugCompanion.change.ChangePipeline; import com.github.minecraft_ta.totalDebugCompanion.change.Effect; import com.github.minecraft_ta.totalDebugCompanion.game.GameLocation; @@ -19,7 +17,6 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardOpenOption; -import java.util.List; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutionException; @@ -34,19 +31,6 @@ class ConfigChangesTest { @TempDir Path directory; - private final List assignments = new ArrayList<>(); - - @AfterEach - void closeAssignments() { - this.assignments.forEach(KeyAssignments::close); - } - - /** The key assignments of the test's {@code options.txt}, watched until the test ends. */ - private KeyAssignments assignments() { - KeyAssignments assignments = new KeyAssignments(this.directory.resolve("options.txt")); - this.assignments.add(assignments); - return assignments; - } private GameLocation location; @BeforeEach diff --git a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/catalog/KeyAssignmentsTest.java b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/catalog/KeyAssignmentsTest.java deleted file mode 100644 index 32b1c4d4c..000000000 --- a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/catalog/KeyAssignmentsTest.java +++ /dev/null @@ -1,105 +0,0 @@ -package com.github.minecraft_ta.totalDebugCompanion.catalog; - -import java.nio.file.AccessDeniedException; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; - -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.StandardCopyOption; -import java.util.Map; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.function.IntSupplier; - -import static org.junit.jupiter.api.Assertions.assertEquals; - -class KeyAssignmentsTest { - @TempDir Path directory; - - @Test - void aKeyReboundIsToldAndAnotherOptionIsNot() throws Exception { - Path options = this.directory.resolve("options.txt"); - Files.writeString(options, "soundCategory_master:1.0\nkey_key.jump:key.keyboard.space\n"); - AtomicInteger told = new AtomicInteger(); - try (KeyAssignments assignments = new KeyAssignments(options)) { - assignments.changed().subscribe(told::incrementAndGet); - // The first read only learns what the file assigns. - Thread.sleep(500); - - replace(options, "soundCategory_master:0.4\nkey_key.jump:key.keyboard.space\n"); - Thread.sleep(1_000); - assertEquals(0, told.get(), "the volume changed; no key did"); - - replace(options, "soundCategory_master:0.4\nkey_key.jump:key.keyboard.g\n"); - await(told::get, 1); - Thread.sleep(500); - assertEquals(1, told.get(), "a file written in parts is read once it settled"); - - Files.delete(options); - await(told::get, 2); - } - } - - @Test - void aGameFolderThatAppearsLaterIsWatchedOnceItDoes() throws Exception { - Path options = this.directory.resolve("game/options.txt"); - AtomicInteger told = new AtomicInteger(); - try (KeyAssignments assignments = new KeyAssignments(options)) { - assignments.changed().subscribe(told::incrementAndGet); - Thread.sleep(300); - - // The game runs for the first time: its folder and options.txt appear. - Files.createDirectories(options.getParent()); - Files.writeString(options, "key_key.jump:key.keyboard.g\n"); - await(told::get, 1); - assertEquals(Map.of("key.jump", KeyBindings.Assignment.decode("key.keyboard.g")), assignments.assignments(), - "once the folder is there, it is followed and its keys are told"); - - replace(options, "key_key.jump:key.keyboard.h\n"); - await(told::get, 2); - assertEquals(Map.of("key.jump", KeyBindings.Assignment.decode("key.keyboard.h")), assignments.assignments(), - "watched now, a key rebound in the game is told without Companion asking"); - } - } - - @Test - void aPageThatReadBeforeTheOwnerStillHearsOfTheNextChange() throws Exception { - Path options = this.directory.resolve("options.txt"); - Files.writeString(options, "key_key.jump:key.keyboard.space" + System.lineSeparator()); - AtomicInteger told = new AtomicInteger(); - try (KeyAssignments assignments = new KeyAssignments(options)) { - assignments.changed().subscribe(told::incrementAndGet); - // A page opened at once reads the keys, perhaps before the owner's own first read, and the game then saves. - Map shown = assignments.assignments(); - replace(options, "key_key.jump:key.keyboard.g" + System.lineSeparator()); - await(told::get, 1); - assertEquals(Map.of("key.jump", KeyBindings.Assignment.decode("key.keyboard.space")), shown); - } - } - - /** - * Writes {@code text} beside {@code file} and moves it over the file, as an editor may save it. Windows refuses the move - * while the file is being read, as by the watch itself, so it is tried again for a moment. - */ - private static void replace(Path file, String text) throws Exception { - Path staged = file.resolveSibling(file.getFileName() + ".tmp"); - Files.writeString(staged, text); - long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(2); - while (true) { - try { - Files.move(staged, file, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); - return; - } catch (AccessDeniedException reading) { - if (System.nanoTime() > deadline) throw reading; - Thread.sleep(20); - } - } - } - - private static void await(IntSupplier count, int expected) throws InterruptedException { - long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10); - while (count.getAsInt() < expected && System.nanoTime() < deadline) Thread.sleep(20); - assertEquals(expected, count.getAsInt()); - } -} diff --git a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/catalog/KeyBindingControlTest.java b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/catalog/KeyBindingControlTest.java index 047d8bdc1..e70ac66fd 100644 --- a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/catalog/KeyBindingControlTest.java +++ b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/catalog/KeyBindingControlTest.java @@ -1,10 +1,10 @@ package com.github.minecraft_ta.totalDebugCompanion.catalog; -import org.junit.jupiter.api.AfterEach; import com.github.minecraft_ta.totalDebugCompanion.change.ChangePipeline; import com.github.minecraft_ta.totalDebugCompanion.game.GameLocation; import com.github.minecraft_ta.totalDebugCompanion.game.GameLocations; import com.github.minecraft_ta.totalDebugCompanion.storage.ChangeRecord; +import com.github.minecraft_ta.totalDebugCompanion.util.WindowFocus; import com.github.minecraft_ta.totaldebug.protocol.message.ChangePayload; import com.github.minecraft_ta.totaldebug.protocol.message.ChangeResultPayload; import com.github.minecraft_ta.totaldebug.protocol.scnet.ChangeMessage; @@ -16,6 +16,7 @@ import java.util.ArrayList; import java.util.List; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -27,20 +28,6 @@ class KeyBindingControlTest { @TempDir Path directory; - private final List assignments = new ArrayList<>(); - - @AfterEach - void closeAssignments() { - this.assignments.forEach(KeyAssignments::close); - } - - /** The key assignments of the test's {@code options.txt}, watched until the test ends. */ - private KeyAssignments assignments() { - KeyAssignments assignments = new KeyAssignments(this.directory.resolve("options.txt")); - this.assignments.add(assignments); - return assignments; - } - @Test void aClosedGameGetsItsKeysInOptions() throws Exception { Path options = this.directory.resolve("options.txt"); @@ -118,7 +105,7 @@ void bindingsChangedTogetherAllStayInOptions() throws Exception { void aRunningGameMakesTheChangeAndAnswers() throws Exception { GameLocation location = GameLocations.of(this.directory, true); ChangePipeline pipeline = new ChangePipeline(location, ChangeRecord.inMemory(), Runnable::run); - KeyBindingControl control = new KeyBindingControl(pipeline, assignments()); + KeyBindingControl control = new KeyBindingControl(pipeline); List sent = new ArrayList<>(); location.connected(message -> { if (message instanceof ChangeMessage change) sent.add(change.payload()); @@ -153,7 +140,7 @@ void aRunningGameMakesTheChangeAndAnswers() throws Exception { void anAnswerAfterTheCallerStoppedWaitingIsStillRecorded() throws Exception { GameLocation location = GameLocations.of(this.directory, true); ChangePipeline pipeline = new ChangePipeline(location, ChangeRecord.inMemory(), Runnable::run); - KeyBindingControl control = new KeyBindingControl(pipeline, assignments()); + KeyBindingControl control = new KeyBindingControl(pipeline); List sent = new ArrayList<>(); location.connected(message -> { if (message instanceof ChangeMessage change) sent.add(change.payload()); @@ -174,8 +161,32 @@ void keysAreWrittenTheWayOptionsWritesThem() { assertEquals(new KeyBindings.Assignment("key.keyboard.e", "SHIFT"), KeyBindings.Assignment.decode("key.keyboard.e:SHIFT")); } + @Test + void aKeyReboundInTheGameIsToldWhenTheUserComesBackAndAnotherOptionIsNot() throws Exception { + Path options = this.directory.resolve("options.txt"); + Files.writeString(options, "soundCategory_master:1.0\nkey_key.jump:key.keyboard.space\n"); + AtomicInteger told = new AtomicInteger(); + try (KeyBindingControl control = control(GameLocations.of(this.directory, false))) { + control.assignmentsChanged().subscribe(told::incrementAndGet); + assertEquals(SPACE, control.assignments().get("key.jump")); + + Files.writeString(options, "soundCategory_master:0.4\nkey_key.jump:key.keyboard.space\n"); + WindowFocus.returned().fire(); + Thread.sleep(300); + assertEquals(0, told.get(), "the volume changed; no key did"); + + // The game saved a key rebound in its controls screen, and the user comes back to Companion. + Files.writeString(options, "soundCategory_master:0.4\nkey_key.jump:key.keyboard.g\n"); + WindowFocus.returned().fire(); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); + while (told.get() == 0 && System.nanoTime() < deadline) Thread.sleep(10); + assertEquals(1, told.get()); + assertEquals(G, control.assignments().get("key.jump")); + } + } + private KeyBindingControl control(GameLocation location) { - return new KeyBindingControl(new ChangePipeline(location, ChangeRecord.inMemory(), Runnable::run), assignments()); + return new KeyBindingControl(new ChangePipeline(location, ChangeRecord.inMemory(), Runnable::run)); } private static String set(KeyBindingControl control, KeyBindingControl.Change... changes) throws Exception { diff --git a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/catalog/WorldReadingTest.java b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/catalog/WorldReadingTest.java index 550582f2f..f1cd38530 100644 --- a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/catalog/WorldReadingTest.java +++ b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/catalog/WorldReadingTest.java @@ -1,25 +1,26 @@ package com.github.minecraft_ta.totalDebugCompanion.catalog; import com.github.minecraft_ta.totalDebugCompanion.game.GameLocation; +import com.github.minecraft_ta.totalDebugCompanion.pack.GamePacks; +import com.github.minecraft_ta.totalDebugCompanion.util.WindowFocus; import com.github.minecraft_ta.totaldebug.protocol.message.PlayingPayload; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; -import com.github.minecraft_ta.totalDebugCompanion.pack.GamePacks; -import java.util.concurrent.CopyOnWriteArrayList; -import java.util.List; -import java.util.stream.Stream; -import java.util.Comparator; -import java.nio.file.attribute.FileTime; import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.attribute.FileTime; +import java.util.Comparator; +import java.util.List; import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; -import java.util.function.BooleanSupplier; +import java.util.stream.Stream; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; /** The current world, read by its one owner, which the World page and the Project tree show. */ @@ -27,7 +28,7 @@ class WorldReadingTest { @TempDir Path directory; @Test - void theWorldTheGamePlaysIsReadAgainWhenTheGameSavesIt() throws Exception { + void aWorldTheGameSavedIsReadAgainWhenTheUserComesBack() throws Exception { Path world = LevelDatFixture.write(this.directory.resolve("saves/World"), LevelDatFixture.world("World")).getParent(); GameLocation location = new GameLocation(this.directory); AtomicInteger told = new AtomicInteger(); @@ -36,14 +37,20 @@ void theWorldTheGamePlaysIsReadAgainWhenTheGameSavesIt() throws Exception { location.playing(new PlayingPayload.Singleplayer(world.toString())); await(() -> reading.value().saved().open()); reading.changed().subscribe(told::incrementAndGet); - Thread.sleep(700); - // The game saves the world with a rule changed. + // The game saves the world with a rule changed while the user plays. Map saved = LevelDatFixture.world("World"); ((Map) saved.get("GameRules")).put("keepInventory", "false"); LevelDatFixture.write(world, saved); - await(() -> told.get() >= 1); + Thread.sleep(300); + assertEquals(0, told.get(), "nothing watches the world"); + + WindowFocus.returned().fire(); + await(() -> told.get() == 1); assertEquals("false", reading.value().saved().gameRules().get("keepInventory")); + WindowFocus.returned().fire(); + Thread.sleep(300); + assertEquals(1, told.get(), "a world read again unchanged tells nothing"); } } @@ -57,7 +64,6 @@ void theWorldTheGamePlaysIsFollowedAndTheOneBeforeTellsNothing() throws Exceptio try (WorldReading reading = new WorldReading(location, new GamePacks(location))) { reading.changed().subscribe(told::incrementAndGet); reading.value(); - // The game connects and opens the second world. location.connected(message -> true); location.playing(new PlayingPayload.Singleplayer(second.toString())); @@ -66,9 +72,9 @@ void theWorldTheGamePlaysIsFollowedAndTheOneBeforeTellsNothing() throws Exceptio assertTrue(reading.value().saved().open(), "the world the game has open is read as open"); int before = told.get(); - Map other = LevelDatFixture.world("First renamed"); - LevelDatFixture.write(first, other); - Thread.sleep(1_000); + LevelDatFixture.write(first, LevelDatFixture.world("First renamed")); + WindowFocus.returned().fire(); + Thread.sleep(300); assertEquals(before, told.get(), "the world followed before tells nothing any more"); } } @@ -92,41 +98,31 @@ void whenTheGamePlaysAnotherWorldItsFollowersFindItsNamePublished() throws Excep } @Test - void withoutAWorldThePageIsToldWhy() { + void withoutAWorldThereIsNone() throws Exception { try (WorldReading reading = reading()) { - assertEquals("No world has been played in this instance yet.", reading.value().problem()); + assertNull(reading.value().directory()); } } @Test - void aWorldAGameCompanionIsNotConnectedToHoldsCanBeMovedOnceItLeftIt() throws Exception { + void aReadThatFailsKeepsTheWorldReadBeforeAndTellsNothing() throws Exception { Path world = LevelDatFixture.write(this.directory.resolve("saves/World"), LevelDatFixture.world("World")).getParent(); - // On Windows a watch of a folder inside, as of the datapacks, holds the world's folder. - LevelDatFixture.datapack(world, "Pack"); - WorldReading reading = null; - try { - try (LevelDatFixture.Held game = LevelDatFixture.hold(world)) { - reading = reading(); - assertTrue(reading.value().saved().open()); - Thread.sleep(300); - } - // The game left the world; nothing of Companion holds its folder, which can be moved, as a backup does. - Files.move(world, this.directory.resolve("saves/Moved")); - assertFalse(Files.exists(world), "nothing of Companion holds the folder"); - } finally { - if (reading != null) reading.close(); - } - } - - @Test - void aWorldTheGameDoesNotPlayCanBeMovedWhileItIsShown() throws Exception { - Path world = LevelDatFixture.write(this.directory.resolve("saves/World"), LevelDatFixture.world("World")).getParent(); - LevelDatFixture.datapack(world, "Pack"); + AtomicInteger told = new AtomicInteger(); try (WorldReading reading = reading()) { - assertEquals("World", reading.value().saved().name()); + WorldReading.World before = reading.value(); + reading.changed().subscribe(told::incrementAndGet); + + // Caught while the game writes level.dat: the World page keeps the world and the datapacks staged for it. + Files.write(world.resolve("level.dat"), new byte[]{1, 2, 3}); + WindowFocus.returned().fire(); Thread.sleep(300); - Files.move(world, this.directory.resolve("saves/Moved")); - assertFalse(Files.exists(world), "nothing of Companion holds the folder"); + assertEquals(0, told.get()); + assertEquals(before, reading.value()); + + LevelDatFixture.write(world, LevelDatFixture.world("World")); + WindowFocus.returned().fire(); + await(() -> told.get() == 1); + assertEquals("World", reading.value().saved().name()); } } @@ -139,28 +135,26 @@ void aWorldTheConnectedGameLeftCanBeMoved() throws Exception { location.connected(message -> true); location.playing(new PlayingPayload.Singleplayer(world.toString())); await(() -> reading.value().saved().open()); - Thread.sleep(300); - // The game tells it is in the menu once it let go of the world. + // The game tells it is in the menu once it let go of the world; its Delete World or a backup moves it. location.playing(new PlayingPayload.Menu()); await(() -> !reading.value().saved().open()); - Thread.sleep(300); Files.move(world, this.directory.resolve("saves/Moved")); assertFalse(Files.exists(world), "nothing of Companion holds the folder"); } } @Test - void aNewWorldAGameCompanionIsNotConnectedToOpensIsFollowed() throws Exception { + void aNewWorldAGameCompanionIsNotConnectedToOpensIsReadWhenTheUserComesBack() throws Exception { Path first = LevelDatFixture.write(this.directory.resolve("saves/First"), LevelDatFixture.world("First")).getParent(); try (WorldReading reading = reading()) { assertEquals(first, reading.value().directory()); - Thread.sleep(300); // A game Companion is not connected to creates a world and opens it. Path second = Files.createDirectories(this.directory.resolve("saves/Second")); try (LevelDatFixture.Held game = LevelDatFixture.hold(second)) { LevelDatFixture.write(second, LevelDatFixture.world("Second")); + WindowFocus.returned().fire(); await(() -> second.equals(reading.value().directory())); assertTrue(reading.value().saved().open(), "the world the game holds is read as open"); } @@ -168,40 +162,65 @@ void aNewWorldAGameCompanionIsNotConnectedToOpensIsFollowed() throws Exception { } @Test - void theWorldPlayedBeforeIsFollowedWhenTheCurrentOneIsDeleted() throws Exception { + void theWorldPlayedBeforeIsCurrentOnceTheCurrentOneIsDeleted() throws Exception { Path first = LevelDatFixture.write(this.directory.resolve("saves/First"), LevelDatFixture.world("First")).getParent(); Path second = LevelDatFixture.write(this.directory.resolve("saves/Second"), LevelDatFixture.world("Second")).getParent(); Files.setLastModifiedTime(first.resolve("level.dat"), FileTime.fromMillis(System.currentTimeMillis() - 60_000)); try (WorldReading reading = reading()) { assertEquals(second, reading.value().directory()); - Thread.sleep(300); // The game's Delete World removes the world played last. try (Stream files = Files.walk(second)) { for (Path file : files.sorted(Comparator.reverseOrder()).toList()) Files.delete(file); } + WindowFocus.returned().fire(); await(() -> first.equals(reading.value().directory())); } } @Test - void aNewIconOrDatapackOfTheWorldTheGamePlaysIsAChange() throws Exception { + void aDatapackAddedToTheWorldIsAChange() throws Exception { Path world = LevelDatFixture.write(this.directory.resolve("saves/World"), LevelDatFixture.world("World")).getParent(); - GameLocation location = new GameLocation(this.directory); AtomicInteger told = new AtomicInteger(); - try (WorldReading reading = new WorldReading(location, new GamePacks(location))) { - location.connected(message -> true); - location.playing(new PlayingPayload.Singleplayer(world.toString())); - await(() -> reading.value().saved() != null && reading.value().saved().open()); + try (WorldReading reading = reading()) { + reading.value(); reading.changed().subscribe(told::incrementAndGet); - Thread.sleep(700); - int before = told.get(); - Files.write(world.resolve("icon.png"), new byte[]{1, 2, 3}); - await(() -> told.get() > before); - int afterIcon = told.get(); LevelDatFixture.datapack(world, "Added"); - await(() -> told.get() > afterIcon); + WindowFocus.returned().fire(); + await(() -> told.get() == 1); + } + } + + @Test + void aNewIconOfTheWorldIsAChange() throws Exception { + Path world = LevelDatFixture.write(this.directory.resolve("saves/World"), LevelDatFixture.world("World")).getParent(); + AtomicInteger told = new AtomicInteger(); + try (WorldReading reading = reading()) { + reading.value(); + reading.changed().subscribe(told::incrementAndGet); + + Files.write(world.resolve("icon.png"), new byte[]{1, 2, 3}); + WindowFocus.returned().fire(); + await(() -> told.get() == 1); + } + } + + @Test + void anotherWorldThatCannotBeReadIsCurrentWithoutWhatItHolds() throws Exception { + Path first = LevelDatFixture.write(this.directory.resolve("saves/First"), LevelDatFixture.world("First")).getParent(); + Path second = LevelDatFixture.write(this.directory.resolve("saves/Second"), LevelDatFixture.world("Second")).getParent(); + Files.write(second.resolve("level.dat"), new byte[]{1, 2, 3}); + GameLocation location = new GameLocation(this.directory); + try (WorldReading reading = new WorldReading(location, new GamePacks(location))) { + location.connected(message -> true); + location.playing(new PlayingPayload.Singleplayer(first.toString())); + await(() -> first.equals(reading.value().directory()) && reading.value().saved() != null); + + // The game opens the second world while its level.dat is being written. + location.playing(new PlayingPayload.Singleplayer(second.toString())); + await(() -> second.equals(reading.value().directory())); + assertNull(reading.value().saved(), "the world before is not shown or changed as if it were current"); } } @@ -211,9 +230,15 @@ private WorldReading reading() { return new WorldReading(location, new GamePacks(location)); } - private static void await(BooleanSupplier condition) throws InterruptedException { + /** A condition on what the reading holds, which reads it. */ + @FunctionalInterface + private interface Condition { + boolean holds() throws Exception; + } + + private static void await(Condition condition) throws Exception { long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10); - while (!condition.getAsBoolean() && System.nanoTime() < deadline) Thread.sleep(10); - assertTrue(condition.getAsBoolean(), "timed out"); + while (!condition.holds() && System.nanoTime() < deadline) Thread.sleep(10); + assertTrue(condition.holds(), "timed out"); } } diff --git a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/change/WriteQueueTest.java b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/change/WriteQueueTest.java index 0c086d67f..56388d8d3 100644 --- a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/change/WriteQueueTest.java +++ b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/change/WriteQueueTest.java @@ -2,18 +2,15 @@ import com.github.minecraft_ta.totalDebugCompanion.catalog.KeyBindingControl; import com.github.minecraft_ta.totalDebugCompanion.catalog.KeyBindings; -import com.github.minecraft_ta.totalDebugCompanion.catalog.KeyAssignments; import com.github.minecraft_ta.totalDebugCompanion.game.GameLocation; import com.github.minecraft_ta.totalDebugCompanion.storage.ChangeRecord; import com.github.minecraft_ta.totalDebugCompanion.util.Workers; -import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; import java.nio.file.Files; import java.nio.file.Path; -import java.util.ArrayList; import java.util.List; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CountDownLatch; @@ -28,18 +25,11 @@ class WriteQueueTest { @TempDir Path directory; private GameLocation location; - private final List assignments = new ArrayList<>(); - @BeforeEach void location() { this.location = new GameLocation(this.directory); } - @AfterEach - void closeAssignments() { - this.assignments.forEach(KeyAssignments::close); - } - @Test void writesAndClosingDoNotWaitForUnrelatedFileReads() throws Exception { CountDownLatch entered = new CountDownLatch(4); @@ -135,7 +125,7 @@ void anOfflineKeyChangeTakenBeforeClosingIsWrittenAndRecorded() throws Exception ChangeRecord record = ChangeRecord.inMemory(); WriteQueue writes = new WriteQueue(); ChangePipeline changes = new ChangePipeline(this.location, record, writes); - KeyBindingControl keys = new KeyBindingControl(changes, assignments()); + KeyBindingControl keys = new KeyBindingControl(changes); CountDownLatch release = new CountDownLatch(1); changes.write(() -> { try { @@ -158,9 +148,4 @@ void anOfflineKeyChangeTakenBeforeClosingIsWrittenAndRecorded() throws Exception "the key change is recorded before the record could close"); } - private KeyAssignments assignments() { - KeyAssignments assignments = new KeyAssignments(this.directory.resolve("options.txt")); - this.assignments.add(assignments); - return assignments; - } } diff --git a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/navigation/PageReadsTest.java b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/navigation/PageReadsTest.java index c24209aec..35e8d2887 100644 --- a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/navigation/PageReadsTest.java +++ b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/navigation/PageReadsTest.java @@ -17,6 +17,7 @@ import com.github.minecraft_ta.totalDebugCompanion.testui.UiTestScope; import com.github.minecraft_ta.totalDebugCompanion.ui.components.catalog.KeyBindingsPanel; import com.github.minecraft_ta.totalDebugCompanion.ui.views.MainWindow; +import com.github.minecraft_ta.totalDebugCompanion.util.WindowFocus; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -79,7 +80,7 @@ void theKeyBindingsPageReadsOnceWhenOpenedAndAgainOnlyAfterAChangeItMissed() thr } @Test - void theWorldPageReadsOnceWhenOpenedAndAgainOnlyAfterTheGameSavedTheWorld() throws Exception { + void theWorldPageReadsOnceWhenOpenedAndAgainOnlyAfterTheUserCameBackFromAGameThatSavedIt() throws Exception { Path home = Files.createDirectory(this.directory.resolve("home")); GlobalConfig.getInstance().loadFrom(home); Path game = Files.createDirectory(this.directory.resolve("game")); @@ -107,10 +108,11 @@ void theWorldPageReadsOnceWhenOpenedAndAgainOnlyAfterTheGameSavedTheWorld() thro assertEquals(1, (int) UiTestScope.onEdt(panel::reads), "navigating to the page it shows reads nothing"); open(window, new NavigationTarget.Changes()); - // The game saves the world it plays. + // The game saves the world it plays, and the user comes back to Companion. Map saved = LevelDatFixture.world("World"); ((Map) saved.get("GameRules")).put("keepInventory", "false"); LevelDatFixture.write(world, saved); + UiTestScope.onEdt(() -> WindowFocus.returned().fire()); Thread.sleep(1_500); assertEquals(1, (int) UiTestScope.onEdt(panel::reads), "a hidden page does not read, though the game saved the world"); open(window, new NavigationTarget.World(WorldTab.OVERVIEW)); diff --git a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/pack/ExternalEditsTest.java b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/pack/ExternalEditsTest.java index ad4e1da5e..cbae23d36 100644 --- a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/pack/ExternalEditsTest.java +++ b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/pack/ExternalEditsTest.java @@ -120,6 +120,35 @@ void aFollowedTextureIsTakenOnceItIsWhole() throws Exception { } } + @Test + void aTextureWhoseFolderWasMadeAgainIsFollowedOnceOpenedAgain() throws Exception { + ChangeRecord record = ChangeRecord.inMemory(); + ResourceEdits edits = edits(record); + Path pack = edits.save(TEXTURE, png(0xFF112233)).get(5, TimeUnit.SECONDS).pack(); + BlockingQueue taken = new LinkedBlockingQueue<>(); + try { + edits.external().follow(TEXTURE, pack); + edits.external().addListener(TEXTURE, pack, (saved, failure) -> taken.add(failure != null ? failure : saved)); + // A tool removes the texture's folder and makes it again, which ends the folder's watch. + Path folder = pack.resolve(TEXTURE).getParent(); + Files.delete(pack.resolve(TEXTURE)); + Files.delete(folder); + Files.createDirectory(folder); + Files.write(pack.resolve(TEXTURE), png(0xFF112233)); + Thread.sleep(500); + taken.clear(); + + edits.external().follow(TEXTURE, pack); + byte[] drawn = png(0xFF778899); + Files.write(pack.resolve(TEXTURE), drawn); + Object result = taken.poll(10, TimeUnit.SECONDS); + assertEquals(ResourceEdits.Saved.class, result == null ? null : result.getClass(), String.valueOf(result)); + assertEquals(ResourceOriginals.hash(drawn), record.change(new ChangeRecord.Resource(TEXTURE, pack)).current()); + } finally { + edits.close(); + } + } + @Test void aClosedProjectTakesNoSaveAndHoldsNoFolderOfThePack() throws Exception { ChangeRecord record = ChangeRecord.inMemory(); diff --git a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/PageLoaderTest.java b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/PageLoaderTest.java index d955ebf0a..c62cd4307 100644 --- a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/PageLoaderTest.java +++ b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/PageLoaderTest.java @@ -2,6 +2,7 @@ import java.util.concurrent.atomic.AtomicBoolean; import com.github.minecraft_ta.totalDebugCompanion.util.Signal; +import com.github.minecraft_ta.totalDebugCompanion.util.WindowFocus; import org.junit.jupiter.api.Test; import javax.swing.SwingUtilities; @@ -135,6 +136,39 @@ void aPageReadWheneverShownWaitsWhileHidden() throws Exception { assertEquals(2, this.prepared.get(), "it reads every time it is shown, for what changed without telling"); } + @Test + void aPageReadWheneverShownReadsWhenTheUserComesBackWhileItIsShown() throws Exception { + ShowablePage page = new ShowablePage(); + PageLoader loader = onEdt(() -> loader().page(page).readsWhenShown(page)); + try { + fire(WindowFocus.returned()); + assertEquals(0, this.prepared.get(), "a hidden page does not read"); + show(page, loader, true); + assertEquals(1, this.prepared.get()); + + // An editor wrote the page's file while the user was in it. + fire(WindowFocus.returned()); + settle(loader); + assertEquals(2, this.prepared.get(), "coming back reads the shown page again"); + + // Shown again and taking the focus in one step, as a window restored from the taskbar. + SwingUtilities.invokeAndWait(() -> page.setShown(false)); + SwingUtilities.invokeAndWait(() -> { + page.setShown(true); + WindowFocus.returned().fire(); + }); + SwingUtilities.invokeAndWait(() -> { }); + settle(loader); + assertEquals(3, this.prepared.get(), "showing and coming back at once read once"); + + onEdt(loader::dispose); + fire(WindowFocus.returned()); + assertEquals(3, this.prepared.get(), "a disposed page reads no more"); + } finally { + onEdt(loader::dispose); + } + } + @Test void aPartOfThePageReadsWhenItIsChosenAndChangesFollowWhileThePageIsShown() throws Exception { ShowablePage page = new ShowablePage(); diff --git a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/catalog/CatalogPanelsTest.java b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/catalog/CatalogPanelsTest.java index a8eb04a63..35ac4d751 100644 --- a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/catalog/CatalogPanelsTest.java +++ b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/catalog/CatalogPanelsTest.java @@ -1,10 +1,9 @@ package com.github.minecraft_ta.totalDebugCompanion.ui.components.catalog; import com.github.minecraft_ta.totalDebugCompanion.util.Signal; +import com.github.minecraft_ta.totalDebugCompanion.util.WindowFocus; import com.github.minecraft_ta.totalDebugCompanion.testui.UiTestScope; import com.github.minecraft_ta.totalDebugCompanion.testui.UiTest; -import org.junit.jupiter.api.AfterEach; -import com.github.minecraft_ta.totalDebugCompanion.catalog.KeyAssignments; import com.github.minecraft_ta.totalDebugCompanion.catalog.CatalogFixtures; import com.github.minecraft_ta.totalDebugCompanion.catalog.ConfigSettingsFixture; import com.github.minecraft_ta.totalDebugCompanion.catalog.KeyBindingControl; @@ -54,20 +53,6 @@ class CatalogPanelsTest { @TempDir Path directory; - private final List assignments = new ArrayList<>(); - - @AfterEach - void closeAssignments() { - this.assignments.forEach(KeyAssignments::close); - } - - /** The key assignments of the test's {@code options.txt}, watched until the test ends. */ - private KeyAssignments assignments() { - KeyAssignments assignments = new KeyAssignments(this.directory.resolve("options.txt")); - this.assignments.add(assignments); - return assignments; - } - @Test void wideModLogosStayReadableAndFitInsideTheirHeader() throws Exception { BufferedImage banner = new BufferedImage(600, 240, BufferedImage.TYPE_INT_ARGB); @@ -118,7 +103,7 @@ void aModPageListsWhatTheModRegisteredAndOpensDefinitions() throws Exception { try (ItemIconService icons = new ItemIconService()) { onEdt(() -> { ModPanel panel = new ModPanel("testmod", catalog, RuntimeSourceCatalog::empty, icons, this.directory, ConfigSettingsFixture.of(GameLocations.of(this.directory, false), ChangeRecord.inMemory()), - new KeyBindingControl(new ChangePipeline(GameLocations.of(this.directory, false), ChangeRecord.inMemory(), Runnable::run), assignments()), opened::add, new Signal()); + new KeyBindingControl(new ChangePipeline(GameLocations.of(this.directory, false), ChangeRecord.inMemory(), Runnable::run)), opened::add, new Signal()); try { assertEquals("Test Mod", panel.title()); assertTrue(labels(panel).contains("1.2.3"), labels(panel)::toString); @@ -154,7 +139,7 @@ void aModOverviewLinksInstalledDependencies() throws Exception { try (ItemIconService icons = new ItemIconService()) { onEdt(() -> { ModPanel panel = new ModPanel("testmod", catalog, RuntimeSourceCatalog::empty, icons, this.directory, ConfigSettingsFixture.of(GameLocations.of(this.directory, false), ChangeRecord.inMemory()), - new KeyBindingControl(new ChangePipeline(GameLocations.of(this.directory, false), ChangeRecord.inMemory(), Runnable::run), assignments()), target -> { }, new Signal()); + new KeyBindingControl(new ChangePipeline(GameLocations.of(this.directory, false), ChangeRecord.inMemory(), Runnable::run)), target -> { }, new Signal()); try { List sections = panel.sections(catalog.index().orElseThrow().mod("testmod").orElseThrow()); assertEquals(List.of("Mod", "Dependencies"), sections.stream().map(FactSection::title).toList()); @@ -175,7 +160,7 @@ void anUnknownModSaysSo() throws Exception { try (ItemIconService icons = new ItemIconService()) { onEdt(() -> { ModPanel panel = new ModPanel("absent", catalog, RuntimeSourceCatalog::empty, icons, this.directory, ConfigSettingsFixture.of(GameLocations.of(this.directory, false), ChangeRecord.inMemory()), - new KeyBindingControl(new ChangePipeline(GameLocations.of(this.directory, false), ChangeRecord.inMemory(), Runnable::run), assignments()), target -> { }, new Signal()); + new KeyBindingControl(new ChangePipeline(GameLocations.of(this.directory, false), ChangeRecord.inMemory(), Runnable::run)), target -> { }, new Signal()); try { assertTrue(labels(panel).contains("absent is not an installed mod"), labels(panel)::toString); assertEquals(1, panel.tabs().getTabCount(), "Only the Overview has something to show"); @@ -260,7 +245,7 @@ void keyBindingsKeepTheirSelectionWhenTheirKeysAreReadAgain() throws Exception { PackCatalogService catalog = readyCatalog(); Files.writeString(this.directory.resolve("options.txt"), "key_key.drop:key.keyboard.q\n"); KeyBindingControl control = new KeyBindingControl(new ChangePipeline(GameLocations.of(this.directory, false), - ChangeRecord.inMemory(), Runnable::run), assignments()); + ChangeRecord.inMemory(), Runnable::run)); KeyBindingsPanel[] panel = new KeyBindingsPanel[1]; onEdt(() -> panel[0] = new KeyBindingsPanel(catalog, control, "", target -> { })); try { @@ -277,8 +262,9 @@ void keyBindingsKeepTheirSelectionWhenTheirKeysAreReadAgain() throws Exception { table.setRowSelectionInterval(drop, drop); }); - // As when the game saved a key rebound in its controls screen: the watch of options.txt tells the page. + // As when the game saved a key rebound in its controls screen and the user came back to Companion. Files.writeString(this.directory.resolve("options.txt"), "key_key.drop:key.keyboard.g\n"); + onEdt(() -> WindowFocus.returned().fire()); UiTestScope.await(() -> panel[0].reads() == 2 && panel[0].loading().isDone()); SwingUtilities.invokeAndWait(() -> { }); onEdt(() -> { diff --git a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/catalog/ChangesPanelTest.java b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/catalog/ChangesPanelTest.java index 96f050ba7..876b585d6 100644 --- a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/catalog/ChangesPanelTest.java +++ b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/catalog/ChangesPanelTest.java @@ -2,8 +2,6 @@ import com.github.minecraft_ta.totalDebugCompanion.testui.UiTestScope; import com.github.minecraft_ta.totalDebugCompanion.testui.UiTest; -import org.junit.jupiter.api.AfterEach; -import com.github.minecraft_ta.totalDebugCompanion.catalog.KeyAssignments; import com.github.minecraft_ta.totalDebugCompanion.catalog.ConfigLabels; import com.github.minecraft_ta.totalDebugCompanion.catalog.ConfigSettings; import com.github.minecraft_ta.totalDebugCompanion.catalog.ConfigSettingsFixture; @@ -48,20 +46,6 @@ class ChangesPanelTest { @TempDir Path directory; - private final List assignments = new ArrayList<>(); - - @AfterEach - void closeAssignments() { - this.assignments.forEach(KeyAssignments::close); - } - - /** The key assignments of the test's {@code options.txt}, watched until the test ends. */ - private KeyAssignments assignments() { - KeyAssignments assignments = new KeyAssignments(this.directory.resolve("options.txt")); - this.assignments.add(assignments); - return assignments; - } - @Test void listsRecordedChangesUntilTheFileHoldsTheOriginalAgain() throws Exception { Path jar = CatalogFixtures.modJar(this.directory); @@ -82,7 +66,7 @@ void listsRecordedChangesUntilTheFileHoldsTheOriginalAgain() throws Exception { Runnable::run, InstanceState.inMemory()); ConfigSettings settings = ConfigSettingsFixture.of(GameLocations.of(this.directory, false), record); List labels = List.of(new ConfigLabels(settings), - new KeyBindingLabels(new KeyBindingControl(new ChangePipeline(GameLocations.of(this.directory, false), record, Runnable::run), assignments())), + new KeyBindingLabels(new KeyBindingControl(new ChangePipeline(GameLocations.of(this.directory, false), record, Runnable::run))), new ResourceLabels(edits), new PackLabels(new PackSelections(edits))); SwingUtilities.invokeAndWait(() -> panel[0] = new ChangesPanel(catalog, record, labels, target -> { })); SwingUtilities.invokeAndWait(() -> UiTestScope.showPages(panel[0])); diff --git a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/treeView/FileTreeViewMenuTest.java b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/treeView/FileTreeViewMenuTest.java index 54471db5b..7b911e3e2 100644 --- a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/treeView/FileTreeViewMenuTest.java +++ b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/treeView/FileTreeViewMenuTest.java @@ -36,7 +36,7 @@ void classFileFolderAndArchiveMenusCopyTheirActualIdentity() throws Exception { var fileMenu = view.createContextMenu(tree.getItemFactory().createFileSystemFileItem(file)); assertEquals(file.toString(), item(fileMenu, "Copy Path").getActionCommand()); assertEquals(1, fileMenu.getComponentCount(), "Unowned local files remain read-only"); - var folderMenu = view.createContextMenu(tree.getItemFactory().createFileSystemDirectoryItem(directory, false)); + var folderMenu = view.createContextMenu(tree.getItemFactory().createFileSystemDirectoryItem(directory)); assertEquals(1, folderMenu.getComponentCount()); assertEquals(directory.toString(), item(folderMenu, "Copy Path").getActionCommand()); var root = new ZipFileRootItem(archive); diff --git a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/treeView/ScriptFileActionsTest.java b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/treeView/ScriptFileActionsTest.java index 02da64c55..daa4985c7 100644 --- a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/treeView/ScriptFileActionsTest.java +++ b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/treeView/ScriptFileActionsTest.java @@ -368,7 +368,7 @@ void closeChecksRecheckOperationsStartedDuringSaveEvents(boolean switching) thro Path folder = window.editorContext().project().scriptFiles().create( window.editorContext().project().scriptFiles().root(), "Folder.tdscript", true, ""); FileTreeView files = find(window, FileTreeView.class); - var item = files.tree().getItemFactory().createFileSystemDirectoryItem(folder, false); + var item = files.tree().getItemFactory().createFileSystemDirectoryItem(folder); edt(() -> { files.tree().setRootNodes(item); files.tree().setSelectionRow(0); return null; }); // A loaded row still describes its folder when the backing filesystem is unavailable. Files.delete(folder); @@ -547,9 +547,9 @@ private static List labels(JPopupMenu menu) { var menu = treeView.createContextMenu(((LazyTreeNode) tree.getSelectionPath().getLastPathComponent()).getUserObject()); assertEquals(List.of("Rename", "Move to...", "Duplicate script", "Copy Path", "Delete file"), labels(menu)); assertEquals(KeyStroke.getKeyStroke("F2"), ((JMenuItem) menu.getComponent(0)).getAccelerator()); - var folderMenu = treeView.createContextMenu(tree.getItemFactory().createFileSystemDirectoryItem(root.resolve("One"), false)); + var folderMenu = treeView.createContextMenu(tree.getItemFactory().createFileSystemDirectoryItem(root.resolve("One"))); assertEquals(List.of("New Script", "New Folder", "Rename", "Move to...", "Copy Path", "Delete folder"), labels(folderMenu)); - var rootMenu = treeView.createContextMenu(tree.getItemFactory().createFileSystemDirectoryItem(root, false)); + var rootMenu = treeView.createContextMenu(tree.getItemFactory().createFileSystemDirectoryItem(root)); assertEquals(List.of("New Script", "New Folder", "Copy Path"), labels(rootMenu)); return null; }); diff --git a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/treeView/lazyFileTree/DirectoryChainTest.java b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/treeView/lazyFileTree/DirectoryChainTest.java index c679c116a..044871cfd 100644 --- a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/treeView/lazyFileTree/DirectoryChainTest.java +++ b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/treeView/lazyFileTree/DirectoryChainTest.java @@ -1,6 +1,5 @@ package com.github.minecraft_ta.totalDebugCompanion.ui.components.treeView.lazyFileTree; -import com.github.minecraft_ta.totalDebugCompanion.util.FileWatch; import com.github.minecraft_ta.totalDebugCompanion.testui.UiTest; import com.github.minecraft_ta.totalDebugCompanion.ui.theme.CompanionTheme; import com.github.minecraft_ta.totalDebugCompanion.ui.theme.ThemeManager; @@ -19,13 +18,11 @@ import java.awt.event.ActionEvent; import java.awt.event.MouseEvent; import java.awt.image.BufferedImage; -import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; import java.util.List; import java.util.concurrent.Callable; -import java.util.concurrent.CompletableFuture; import java.util.concurrent.CountDownLatch; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.TimeUnit; @@ -41,14 +38,14 @@ class DirectoryChainTest { @AfterEach void close() throws Exception { edt(() -> { trees.forEach(LazyFileJTree::setRootNodes); return null; }); } - private LazyFileJTree tree(boolean watch) throws Exception { + private LazyFileJTree tree() throws Exception { Path root = Files.createDirectories(directory.resolve("scripts")); return edt(() -> { ThemeManager.installTheme(CompanionTheme.ISLANDS_DARK); var tree = new LazyFileJTree(); tree.setRowHeight(24); tree.setSize(900, 600); - tree.setRootNodes(tree.getItemFactory().createFileSystemDirectoryItem(root, watch)); + tree.setRootNodes(tree.getItemFactory().createFileSystemDirectoryItem(root)); trees.add(tree); return tree; }); @@ -61,7 +58,7 @@ private Path file() throws Exception { @Test void chainRetainsRealSegmentsAndRevealCanStopInTheMiddle() throws Exception { Path file = file(); - var tree = tree(false); + var tree = tree(); reveal(tree, "modules", "client", "items", "Test.tdscript"); var row = edt(() -> (LazyTreeNode) tree.getSelectionPath().getParentPath().getLastPathComponent()); assertEquals("modules/client/items", row.getUserObject().getPresentation().primary()); @@ -84,7 +81,7 @@ private Path file() throws Exception { @ParameterizedTest @ValueSource(ints = {0, 1, 2}) void splittingAndJoiningAtEveryPositionPreservesTheSelectedFile(int position) throws Exception { Path file = file(); - var tree = tree(false); + var tree = tree(); reveal(tree, "modules", "client", "items", "Test.tdscript"); var original = edt(() -> firstFolder(tree)); Path changed = directory.resolve("scripts").resolve(List.of("modules", "modules/client", "modules/client/items").get(position)); @@ -101,7 +98,7 @@ void splittingAndJoiningAtEveryPositionPreservesTheSelectedFile(int position) th @Test void selectedIntermediateDirectorySurvivesRegrouping() throws Exception { file(); - var tree = tree(false); + var tree = tree(); reveal(tree, "modules", "client"); Path target = directory.resolve("scripts/modules/client"); Files.writeString(directory.resolve("scripts/modules/marker"), ""); @@ -115,7 +112,7 @@ void splittingAndJoiningAtEveryPositionPreservesTheSelectedFile(int position) th @Test void aCollapsedChainStillExpandsAfterItsEndpointChanges() throws Exception { file(); Files.writeString(directory.resolve("scripts/Other.tdscript"), ""); - var tree = tree(false); + var tree = tree(); reveal(tree, "Other.tdscript"); assertFalse(edt(() -> firstFolder(tree).areChildrenLoaded())); Path middle = directory.resolve("scripts/modules/client"); @@ -127,7 +124,7 @@ void splittingAndJoiningAtEveryPositionPreservesTheSelectedFile(int position) th @Test void popupTriggerOnReleaseUsesTheClickedSegmentOfAnUnselectedRow() throws Exception { file(); - var tree = tree(false); + var tree = tree(); reveal(tree, "modules", "client", "items", "Test.tdscript"); var menuTarget = new AtomicReference(); edt(() -> { @@ -147,7 +144,7 @@ void splittingAndJoiningAtEveryPositionPreservesTheSelectedFile(int position) th @Test void ctrlClickWithMinorMouseMovementKeepsThePressedSegment() throws Exception { file(); - var tree = tree(false); + var tree = tree(); reveal(tree, "modules", "client", "items", "Test.tdscript"); edt(() -> { tree.setDragEnabled(true); @@ -165,7 +162,7 @@ void splittingAndJoiningAtEveryPositionPreservesTheSelectedFile(int position) th @Test void regroupingDiscardsAnObsoleteHoveredSegment() throws Exception { file(); - var tree = tree(false); + var tree = tree(); reveal(tree, "modules", "client", "items"); edt(() -> { Point point = segmentPoint(tree, tree.getSelectionPath(), 2); @@ -183,51 +180,6 @@ void splittingAndJoiningAtEveryPositionPreservesTheSelectedFile(int position) th }); } - @Test void aChangeBeforeDiscoveryPublishesIsReprobedEvenIfItsWatcherFoundNoRow() throws Exception { - Path file = file(); - Path middle = file.getParent().getParent(); - var probed = new CountDownLatch(1); - var release = new CountDownLatch(1); - var callback = new CountDownLatch(1); - var holdOnce = new AtomicBoolean(true); - LazyFileJTree tree = edt(() -> { - var result = new LazyFileJTree() { - @Override public CompletableFuture refreshDirectory(Path path) { - var refreshed = super.refreshDirectory(path); - if (path.equals(middle)) callback.countDown(); - return refreshed; - } - }; - result.setItemFactory(new FileTreeItemFactory() { - @Override public FileSystemDirectoryItem createFileSystemDirectoryItem(Path path, boolean watch) { - return new FileSystemDirectoryItem(result, path, watch) { - @Override public DirectoryTreeItem singleDirectoryChild() throws IOException { - var child = super.singleDirectoryChild(); - if (path.equals(middle) && holdOnce.getAndSet(false)) { - probed.countDown(); - try { if (!release.await(5, TimeUnit.SECONDS)) throw new IOException("Probe timed out"); } - catch (InterruptedException failure) { throw new IOException(failure); } - } - return child; - } - }; - } - }); - result.setRootNodes(result.getItemFactory().createFileSystemDirectoryItem(directory.resolve("scripts"), true)); - trees.add(result); - return result; - }); - var revealing = tree.revealItemPath("scripts", List.of("modules", "client", "items", "Test.tdscript")); - try { - assertTrue(probed.await(5, TimeUnit.SECONDS)); - Files.writeString(middle.resolve("marker"), ""); - assertTrue(callback.await(5, TimeUnit.SECONDS)); - release.countDown(); - assertTrue(revealing.get(5, TimeUnit.SECONDS)); - reveal(tree, "modules", "client", "marker"); - } finally { release.countDown(); } - } - @Test void aDescendantReadFailureDoesNotEraseHealthySiblings() throws Exception { var broken = new AtomicBoolean(); DirectoryTreeItem bad = new DirectoryTreeItem("bad") { @@ -259,8 +211,8 @@ void splittingAndJoiningAtEveryPositionPreservesTheSelectedFile(int position) th var tree = edt(() -> { var result = new LazyFileJTree(); result.setItemFactory(new FileTreeItemFactory() { - @Override public FileSystemDirectoryItem createFileSystemDirectoryItem(Path path, boolean watch) { - return new FileSystemDirectoryItem(result, path, false) { + @Override public FileSystemDirectoryItem createFileSystemDirectoryItem(Path path) { + return new FileSystemDirectoryItem(result, path) { @Override public List loadChildren() { if (failReads.get() && path.equals(file.getParent())) throw new IllegalStateException("Unreadable items"); return super.loadChildren(); @@ -268,7 +220,7 @@ void splittingAndJoiningAtEveryPositionPreservesTheSelectedFile(int position) th }; } }); - result.setRootNodes(result.getItemFactory().createFileSystemDirectoryItem(directory.resolve("scripts"), false)); + result.setRootNodes(result.getItemFactory().createFileSystemDirectoryItem(directory.resolve("scripts"))); trees.add(result); return result; }); @@ -290,8 +242,8 @@ void splittingAndJoiningAtEveryPositionPreservesTheSelectedFile(int position) th var tree = edt(() -> { var result = new LazyFileJTree(); result.setItemFactory(new FileTreeItemFactory() { - @Override public FileSystemDirectoryItem createFileSystemDirectoryItem(Path path, boolean watch) { - return new FileSystemDirectoryItem(result, path, false) { + @Override public FileSystemDirectoryItem createFileSystemDirectoryItem(Path path) { + return new FileSystemDirectoryItem(result, path) { @Override public List loadChildren() { if (path.equals(middle) && hold.getAndSet(false)) { loading.countDown(); @@ -303,7 +255,7 @@ void splittingAndJoiningAtEveryPositionPreservesTheSelectedFile(int position) th }; } }); - result.setRootNodes(result.getItemFactory().createFileSystemDirectoryItem(directory.resolve("scripts"), false)); + result.setRootNodes(result.getItemFactory().createFileSystemDirectoryItem(directory.resolve("scripts"))); trees.add(result); return result; }); @@ -322,7 +274,7 @@ void splittingAndJoiningAtEveryPositionPreservesTheSelectedFile(int position) th @Test void revealThroughANamedContainerConsumesItsCompactedSegments() throws Exception { Path file = file(); - var tree = tree(false); + var tree = tree(); assertTrue(tree.revealItemPath("scripts", "modules", List.of("client", "items", "Test.tdscript")).get(5, TimeUnit.SECONDS)); assertEquals(file, edt(() -> selected(tree))); } @@ -339,7 +291,7 @@ private static Point segmentPoint(LazyFileJTree tree, TreePath path, int segment @Test void emptyTerminalAndHiddenFileDoNotDisappear() throws Exception { Path leaf = Files.createDirectories(directory.resolve("scripts/folder.with.dots/space folder/empty")); - var tree = tree(false); + var tree = tree(); reveal(tree, "folder.with.dots", "space folder", "empty"); assertTrue(edt(() -> ((LazyTreeNode) tree.getSelectionPath().getLastPathComponent()).isLeaf())); Files.writeString(leaf.getParent().resolve(".hidden"), ""); @@ -351,7 +303,7 @@ private static Point segmentPoint(LazyFileJTree tree, TreePath path, int segment @Test void segmentPaintingAndMouseTargetsAgreeInBothThemes() throws Exception { file(); - var tree = tree(false); + var tree = tree(); reveal(tree, "modules", "client", "items"); edt(() -> { for (var theme : CompanionTheme.available()) { @@ -379,21 +331,17 @@ private static Point segmentPoint(LazyFileJTree tree, TreePath path, int segment }); } - @Test void watchersCoverMiddleSegmentsAndAreReleasedAfterRefresh() throws Exception { + @Test void aRefreshRegroupsAChainAroundAChangedMiddleFolder() throws Exception { file(); - int baseline = subscriptions(); - var tree = tree(true); + var tree = tree(); reveal(tree, "modules", "client", "items", "Test.tdscript"); - await(() -> subscriptions() == baseline + 4); Path middle = directory.resolve("scripts/modules/client"); Files.writeString(middle.resolve("marker"), ""); + tree.refreshDirectory(middle).get(5, TimeUnit.SECONDS); await(() -> firstFolder(tree).getUserObject().getPresentation().primary().equals("modules/client")); Files.delete(middle.resolve("marker")); + tree.refreshDirectory(middle).get(5, TimeUnit.SECONDS); await(() -> firstFolder(tree).getUserObject().getPresentation().primary().equals("modules/client/items")); - for (int i = 0; i < 8; i++) tree.refreshDirectory(middle).get(5, TimeUnit.SECONDS); - await(() -> subscriptions() == baseline + 4); - edt(() -> { tree.setRootNodes(); return null; }); - await(() -> subscriptions() == baseline); } @Test void repeatedDirectoryIdentityStopsDiscoveryAndDisposesEveryDescriptorOnce() { @@ -412,16 +360,6 @@ class Cycle extends DirectoryTreeItem { assertEquals(created.get(), disposed.get()); } - private static int subscriptions() { - FileWatch watch = FileWatch.shared(); - synchronized (watch) { - try { - var field = FileWatch.class.getDeclaredField("watched"); field.setAccessible(true); - return ((List) field.get(watch)).size(); - } catch (ReflectiveOperationException failure) { throw new AssertionError(failure); } - } - } - private static LazyTreeNode firstFolder(LazyFileJTree tree) { return (LazyTreeNode) ((LazyTreeNode) tree.getModel().getRoot()).getChildAt(0).getChildAt(0); } diff --git a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/treeView/lazyFileTree/FileTreeRootPreparationTest.java b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/treeView/lazyFileTree/FileTreeRootPreparationTest.java index 24f77fdbe..cf17586e1 100644 --- a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/treeView/lazyFileTree/FileTreeRootPreparationTest.java +++ b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/treeView/lazyFileTree/FileTreeRootPreparationTest.java @@ -117,10 +117,10 @@ private static final class HeldFactory extends FileTreeItemFactory { final CompletableFuture disposed = new CompletableFuture<>(); final AtomicInteger calls = new AtomicInteger(); - @Override public FileSystemDirectoryItem createFileSystemDirectoryItem(Path path, boolean watch) { - assertFalse(SwingUtilities.isEventDispatchThread(), "Filesystem validation and watcher registration must run off the EDT"); + @Override public FileSystemDirectoryItem createFileSystemDirectoryItem(Path path) { + assertFalse(SwingUtilities.isEventDispatchThread(), "Filesystem validation must run off the EDT"); calls.incrementAndGet(); - var item = new FileSystemDirectoryItem(tree, path, watch) { + var item = new FileSystemDirectoryItem(tree, path) { @Override public void dispose() { super.dispose(); disposed.complete(null); } }; prepared.complete(null); diff --git a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/treeView/lazyFileTree/LazyFileJTreeTest.java b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/treeView/lazyFileTree/LazyFileJTreeTest.java index 0cb662de7..158b9a63e 100644 --- a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/treeView/lazyFileTree/LazyFileJTreeTest.java +++ b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/treeView/lazyFileTree/LazyFileJTreeTest.java @@ -38,28 +38,11 @@ class LazyFileJTreeTest { - @Test void aChangeBeforeAttachmentInvalidatesAnEmptyFolderSnapshot(@TempDir Path directory) throws Exception { - Path folder = Files.createDirectory(directory.resolve("Empty")); - var tree = new LazyFileJTree(); - var root = tree.getItemFactory().createFileSystemDirectoryItem(directory, true); - var child = (FileSystemDirectoryItem) root.loadChildren().getFirst(); - try { - assertTrue(child.isInitiallyEmpty()); - Files.writeString(folder.resolve("First.tdscript"), ""); - awaitOnEdt(() -> !child.isInitiallyEmpty()); - SwingUtilities.invokeAndWait(() -> { - var node = new LazyTreeNode(child); - assertFalse(node.areChildrenLoaded(), "The detached snapshot must not hide a newly created file"); - assertFalse(node.isLeaf()); - }); - } finally { child.dispose(); root.dispose(); } - } - @Test void newEmptyFolderIsALeafFromItsFirstModelEvent(@TempDir Path directory) throws Exception { Path scripts = Files.createDirectory(directory.resolve("scripts")); Files.writeString(scripts.resolve("Existing.tdscript"), ""); var tree = new LazyFileJTree(); - SwingUtilities.invokeAndWait(() -> tree.setRootNodes(tree.getItemFactory().createFileSystemDirectoryItem(scripts, true))); + SwingUtilities.invokeAndWait(() -> tree.setRootNodes(tree.getItemFactory().createFileSystemDirectoryItem(scripts))); try { assertTrue(tree.revealItemPath("scripts", List.of("Existing.tdscript")).get(3, TimeUnit.SECONDS)); var observations = new ArrayList(); @@ -83,6 +66,7 @@ private void observe() { assertTrue(observations.stream().allMatch(Boolean::booleanValue), "Empty folder briefly advertised children: " + observations); }); Files.writeString(folder.resolve("First.tdscript"), ""); + tree.refreshDirectory(folder).get(3, TimeUnit.SECONDS); awaitOnEdt(() -> { var root = (LazyTreeNode) ((LazyTreeNode) tree.getModel().getRoot()).getChildAt(0); var child = (LazyTreeNode) root.getChildAt(0); @@ -193,13 +177,13 @@ void explicitRootRefreshUpdatesAlreadyLoadedFilesystemSubfolders(@TempDir Path d Files.createDirectories(directory.resolve("nested")); Files.writeString(directory.resolve("nested/Old.tdscript"), ""); LazyFileJTree tree = new LazyFileJTree(); - var root = tree.getItemFactory().createFileSystemDirectoryItem(directory, false); + var root = tree.getItemFactory().createFileSystemDirectoryItem(directory); SwingUtilities.invokeAndWait(() -> tree.setRootNodes(root)); assertTrue(tree.revealItemPath(root.getName(), List.of("nested", "Old.tdscript")).get(3, TimeUnit.SECONDS)); var folder = tree.getSelectionPath().getParentPath(); Files.writeString(directory.resolve("nested/New.tdscript"), ""); SwingUtilities.invokeAndWait(() -> tree.refreshRootNodes( - tree.getItemFactory().createFileSystemDirectoryItem(directory, false))); + tree.getItemFactory().createFileSystemDirectoryItem(directory))); awaitOnEdt(() -> ((LazyTreeNode) folder.getLastPathComponent()).getChildCount() == 2); SwingUtilities.invokeAndWait(() -> assertTrue(tree.isExpanded(folder))); } @@ -280,7 +264,7 @@ void replacingRuntimeRootsRefreshesLoadedContentWithoutCollapsingIt() throws Exc } @Test - void atomicSaveWatcherKeepsTheSelectedFileAndExpandedFolder(@TempDir Path directory) throws Exception { + void refreshingAfterAnAtomicSaveKeepsTheSelectedFileAndExpandedFolder(@TempDir Path directory) throws Exception { Files.createDirectories(directory.resolve("nested")); Files.writeString(directory.resolve("nested/Selected.tdscript"), "return 1;"); Path saved = directory.resolve("Saved.tdscript"); @@ -289,8 +273,8 @@ void atomicSaveWatcherKeepsTheSelectedFileAndExpandedFolder(@TempDir Path direct var scans = new AtomicInteger(); var nestedScans = new AtomicInteger(); tree.setItemFactory(new FileTreeItemFactory() { - @Override public FileSystemDirectoryItem createFileSystemDirectoryItem(Path path, boolean watch) { - return new FileSystemDirectoryItem(tree, path, watch) { + @Override public FileSystemDirectoryItem createFileSystemDirectoryItem(Path path) { + return new FileSystemDirectoryItem(tree, path) { @Override public List loadChildren() { nestedScans.incrementAndGet(); return super.loadChildren(); @@ -298,7 +282,7 @@ void atomicSaveWatcherKeepsTheSelectedFileAndExpandedFolder(@TempDir Path direct }; } }); - var root = new FileSystemDirectoryItem(tree, directory, true) { + var root = new FileSystemDirectoryItem(tree, directory) { @Override public List loadChildren() { scans.incrementAndGet(); return super.loadChildren(); @@ -309,6 +293,7 @@ void atomicSaveWatcherKeepsTheSelectedFileAndExpandedFolder(@TempDir Path direct assertTrue(tree.revealItemPath(root.getName(), List.of("nested", "Selected.tdscript")).get(3, TimeUnit.SECONDS)); var selected = tree.getSelectionPath(); AtomicFiles.writeString(saved, "return 2;"); + tree.refreshDirectory(directory).get(3, TimeUnit.SECONDS); awaitOnEdt(() -> scans.get() > 1); assertTrue(tree.revealItemPath(root.getName(), List.of("nested", "Selected.tdscript")).get(3, TimeUnit.SECONDS)); SwingUtilities.invokeAndWait(() -> { @@ -427,7 +412,7 @@ public FileSystemFileItem createFileSystemFileItem(Path path) { } }); Files.createFile(directory.resolve(".decompiled-staged.tmp")); - FileSystemDirectoryItem root = tree.getItemFactory().createFileSystemDirectoryItem(directory, false); + FileSystemDirectoryItem root = tree.getItemFactory().createFileSystemDirectoryItem(directory); assertTrue(assertDoesNotThrow(root::loadChildren).isEmpty()); } @@ -473,37 +458,39 @@ void revealsARequestedPackageInItsOwningContainer() throws Exception { assertTrue(tree.isExpanded(tree.getSelectionPath())); } - @Test void externalChangesRefreshCollapsedButLoadedNestedFolder(@TempDir Path directory) throws Exception { + @Test void aRefreshUpdatesACollapsedButLoadedNestedFolder(@TempDir Path directory) throws Exception { Path scripts = Files.createDirectories(directory.resolve("scripts")); Path nested = Files.createDirectories(scripts.resolve("nested")); Files.writeString(nested.resolve("First.tdscript"), ""); var tree = new LazyFileJTree(); - SwingUtilities.invokeAndWait(() -> tree.setRootNodes(tree.getItemFactory().createFileSystemDirectoryItem(scripts, true))); + SwingUtilities.invokeAndWait(() -> tree.setRootNodes(tree.getItemFactory().createFileSystemDirectoryItem(scripts))); try { assertTrue(tree.revealItemPath("scripts", List.of("nested", "First.tdscript")).get(3, TimeUnit.SECONDS)); TreePath folder = tree.getSelectionPath().getParentPath(); SwingUtilities.invokeAndWait(() -> tree.collapsePath(folder)); Files.writeString(nested.resolve("Second.tdscript"), ""); + tree.refreshDirectory(nested).get(3, TimeUnit.SECONDS); awaitOnEdt(() -> ((LazyTreeNode) folder.getLastPathComponent()).getChildCount() == 2); SwingUtilities.invokeAndWait(() -> assertFalse(tree.isExpanded(folder))); Files.delete(nested.resolve("First.tdscript")); + tree.refreshDirectory(nested).get(3, TimeUnit.SECONDS); awaitOnEdt(() -> ((LazyTreeNode) folder.getLastPathComponent()).getChildCount() == 1); } finally { SwingUtilities.invokeAndWait(tree::setRootNodes); } } - @Test void failedEnumerationDisposesAlreadyConstructedWatchedChildren(@TempDir Path directory) throws Exception { + @Test void failedEnumerationDisposesAlreadyConstructedChildren(@TempDir Path directory) throws Exception { Files.createDirectories(directory.resolve("a")); Files.createDirectories(directory.resolve("b")); var tree = new LazyFileJTree(); var count = new AtomicInteger(); var disposed = new AtomicInteger(); tree.setItemFactory(new FileTreeItemFactory() { - @Override public FileSystemDirectoryItem createFileSystemDirectoryItem(Path path, boolean watch) { - if (count.incrementAndGet() == 2) throw new IllegalStateException("Simulated registration failure"); - return new FileSystemDirectoryItem(tree, path, watch) { + @Override public FileSystemDirectoryItem createFileSystemDirectoryItem(Path path) { + if (count.incrementAndGet() == 2) throw new IllegalStateException("Simulated creation failure"); + return new FileSystemDirectoryItem(tree, path) { @Override public void dispose() { super.dispose(); disposed.incrementAndGet(); } }; } }); - var root = new FileSystemDirectoryItem(tree, directory, true); + var root = new FileSystemDirectoryItem(tree, directory); try { assertThrows(IllegalStateException.class, root::loadChildren); assertEquals(1, disposed.get()); diff --git a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/util/FileReadingTest.java b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/util/FileReadingTest.java index a432453a6..5c3de5c88 100644 --- a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/util/FileReadingTest.java +++ b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/util/FileReadingTest.java @@ -3,403 +3,287 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; -import java.util.concurrent.CompletableFuture; -import java.util.ArrayList; -import java.util.concurrent.atomic.AtomicReference; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.CopyOnWriteArrayList; -import java.util.List; -import java.io.UncheckedIOException; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; -import java.time.Duration; +import java.util.Optional; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; import java.util.function.BooleanSupplier; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; -/** A file others write, read by its one owner on one path (docs/SYSTEMS.md, sections 1 and 2). */ +/** A value read from a file others write, read again when the user comes back to Companion or its owner asks. */ class FileReadingTest { - private static final Duration SETTLE = Duration.ofMillis(200); - @TempDir Path directory; @Test - void aWriteInPartsIsReadOnceItSettledAndAnEqualOneTellsNothing() throws Exception { - Path file = write(this.directory.resolve("options.txt"), "a"); + void aValueIsReadWhenFirstAskedForAndToldOnlyWhenItChanged() throws Exception { + Path file = Files.writeString(this.directory.resolve("options.txt"), "one"); + AtomicInteger reads = new AtomicInteger(); AtomicInteger told = new AtomicInteger(); - try (FileReading reading = new FileReading<>(file, FileReadingTest::text, SETTLE)) { + try (FileReading reading = new FileReading<>(() -> { + reads.incrementAndGet(); + return Files.readString(file); + })) { reading.changed().subscribe(told::incrementAndGet); - assertEquals("a", reading.value()); + settle(reading); + assertEquals(0, reads.get(), "nobody asked for the value yet"); + assertEquals(Optional.empty(), reading.published()); - write(file, "b"); - write(file, "bc"); - write(file, "bcd"); - await(() -> told.get() == 1); - Thread.sleep(400); - assertEquals(1, told.get(), "a write in parts is read once, when it settled"); - assertEquals("bcd", reading.value()); + assertEquals("one", reading.value()); + assertEquals("one", reading.value()); + assertEquals(1, reads.get(), "a value read is kept"); + assertEquals(0, told.get(), "who asked for the first value has it"); - write(file, "bcd"); - Thread.sleep(600); - assertEquals(1, told.get(), "a write that leaves the value as it was tells nobody"); - } - } + reading.refresh(); + settle(reading); + assertEquals(0, told.get(), "read again unchanged, nothing is told"); - @Test - void aValueAskedBeforeTheOwnersFirstReadIsWhatTheNextChangeIsToldAgainst() throws Exception { - Path file = write(this.directory.resolve("options.txt"), "a"); - AtomicInteger told = new AtomicInteger(); - try (FileReading reading = new FileReading<>(file, FileReadingTest::text, SETTLE)) { - reading.changed().subscribe(told::incrementAndGet); - // A page opened at once asks, and the game saves right after. - assertEquals("a", reading.value()); - write(file, "b"); + Files.writeString(file, "two"); + reading.refresh(); await(() -> told.get() == 1); - assertEquals("b", reading.value()); + assertEquals("two", reading.value()); + assertEquals(Optional.of("two"), reading.published()); } } @Test - void aFolderThatAppearsLaterIsFollowedOnceItDoes() throws Exception { - Path file = this.directory.resolve("game/sub/options.txt"); + void aRefreshBeforeAnyoneAskedPublishesTheFirstValueAndTellsIt() throws Exception { + Path file = Files.writeString(this.directory.resolve("level.dat"), "World"); AtomicInteger told = new AtomicInteger(); - try (FileReading reading = new FileReading<>(file, FileReadingTest::text, SETTLE)) { + try (FileReading reading = new FileReading<>(() -> Files.readString(file))) { + // As a tab's title, which shows the value without asking for it. reading.changed().subscribe(told::incrementAndGet); - assertEquals("", reading.value(), "a file that is not there yet holds nothing"); - - // The game runs for the first time. - write(file, "a"); + reading.refresh(); await(() -> told.get() == 1); - write(file, "b"); - await(() -> told.get() == 2); - assertEquals("b", reading.value()); - } - } - - @Test - void foldersMadeAtOnceAreFollowedDownToTheFile() throws Exception { - for (int round = 0; round < 5; round++) { - Path file = this.directory.resolve("round" + round + "/game/a/b/options.txt"); - AtomicInteger told = new AtomicInteger(); - try (FileReading reading = new FileReading<>(file, FileReadingTest::text, SETTLE)) { - reading.changed().subscribe(told::incrementAndGet); - assertEquals("", reading.value()); - // As a game makes its folders in one go on its first start, while the watch moves down to them. - write(file, "a"); - await(() -> told.get() == 1); - write(file, "b"); - await(() -> told.get() == 2); - } + assertEquals(Optional.of("World"), reading.published()); } } @Test - void aFailedReadIsTriedAgainAndToldWhenItSucceeds() throws Exception { - Path file = write(this.directory.resolve("options.txt"), "a"); - AtomicInteger failures = new AtomicInteger(0); + void comingBackToCompanionReadsAgainUntilTheReadingCloses() throws Exception { + Path file = Files.writeString(this.directory.resolve("options.txt"), "one"); AtomicInteger told = new AtomicInteger(); - try (FileReading reading = new FileReading<>(file, path -> { - if (failures.getAndDecrement() > 0) throw new IOException("written in parts"); - return text(path); - }, SETTLE)) { - reading.changed().subscribe(told::incrementAndGet); - assertEquals("a", reading.value()); - - failures.set(1); - write(file, "b"); - await(() -> told.get() == 1); - assertEquals("b", reading.value(), "the read tried again a second later finds the file whole"); - } - } - - @Test - void readsNeverRunAtOnce() throws Exception { - Path file = write(this.directory.resolve("options.txt"), "a"); - AtomicInteger running = new AtomicInteger(); - AtomicInteger overlapping = new AtomicInteger(); AtomicInteger reads = new AtomicInteger(); - try (FileReading reading = new FileReading<>(file, path -> { - if (running.incrementAndGet() > 1) overlapping.incrementAndGet(); - try { - Thread.sleep(20); - } catch (InterruptedException interrupted) { - Thread.currentThread().interrupt(); - } + FileReading reading = new FileReading<>(() -> { reads.incrementAndGet(); - running.decrementAndGet(); - return text(path); - }, SETTLE)) { - reading.value(); - for (int ask = 0; ask < 20; ask++) { - reading.readNow(); - write(file, "v" + ask); - } - // Reads a later request overtook are left out; the last write is read in the end. - await(() -> "v19".equals(value(reading))); - Thread.sleep(500); - assertEquals(0, overlapping.get(), "the owner reads on one path, whoever asks"); - } - } + return Files.readString(file); + }); + reading.changed().subscribe(told::incrementAndGet); + WindowFocus.returned().fire(); + settle(reading); + assertEquals(0, reads.get(), "coming back reads no value nobody asked for yet"); + reading.value(); - @Test - void aReadingMovedToAnotherFileReadsItAndTells() throws Exception { - Path first = write(this.directory.resolve("first/level.dat"), "first"); - Path second = write(this.directory.resolve("second/level.dat"), "second"); - AtomicInteger told = new AtomicInteger(); - try (FileReading reading = new FileReading<>(first, FileReadingTest::text, SETTLE)) { - reading.changed().subscribe(told::incrementAndGet); - assertEquals("first", reading.value()); + // The game saved the file while the user was in it. + Files.writeString(file, "two"); + WindowFocus.returned().fire(); + await(() -> told.get() == 1); - // The game opened another world. - reading.moveTo(second); - await(() -> told.get() == 1); - assertEquals("second", reading.value()); - write(first, "first again"); - Thread.sleep(600); - assertEquals(1, told.get(), "the file followed before tells nothing any more"); - } + reading.close(); + Files.writeString(file, "three"); + WindowFocus.returned().fire(); + // As a key change that completes after its project closed. + reading.refresh(); + settle(reading); + assertEquals(1, told.get(), "a closed reading reads no more"); + assertEquals("two", reading.value()); } @Test - void aClosedReadingTellsNothing() throws Exception { - Path file = write(this.directory.resolve("options.txt"), "a"); + void aReadUnderWayWhenTheReadingClosesPublishesNothing() throws Exception { + Path file = Files.writeString(this.directory.resolve("options.txt"), "one"); + CountDownLatch paused = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); AtomicInteger told = new AtomicInteger(); - FileReading reading = new FileReading<>(file, FileReadingTest::text, SETTLE); + AtomicInteger reads = new AtomicInteger(); + FileReading reading = new FileReading<>(() -> { + if (reads.incrementAndGet() == 2) { + paused.countDown(); + try { + release.await(5, TimeUnit.SECONDS); + } catch (InterruptedException interrupted) { + throw new IOException(interrupted); + } + } + return Files.readString(file); + }); reading.changed().subscribe(told::incrementAndGet); - assertEquals("a", reading.value()); + reading.value(); + + Files.writeString(file, "two"); + reading.refresh(); + assertTrue(paused.await(5, TimeUnit.SECONDS)); + // The project closes while the read is under way. reading.close(); - write(file, "b"); - Thread.sleep(600); + release.countDown(); + settle(reading); assertEquals(0, told.get()); + assertEquals(Optional.of("one"), reading.published()); } @Test - void aReadOvertakenByAnotherWritePublishesNothing() throws Exception { - Path file = write(this.directory.resolve("options.txt"), "a"); - AtomicReference gate = new AtomicReference<>(); - CountDownLatch entered = new CountDownLatch(1); - List told = new CopyOnWriteArrayList<>(); - try (FileReading reading = new FileReading<>(file, path -> { - String read = text(path); - CountDownLatch waiting = gate.get(); - if (waiting != null) { - entered.countDown(); + void refreshesAskedForWhileOneWaitsReadOnceAndAClosedReadingStartsNone() throws Exception { + Path file = Files.writeString(this.directory.resolve("options.txt"), "one"); + CountDownLatch paused = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + AtomicInteger reads = new AtomicInteger(); + FileReading reading = new FileReading<>(() -> { + if (reads.incrementAndGet() == 2) { + paused.countDown(); try { - waiting.await(5, TimeUnit.SECONDS); + release.await(5, TimeUnit.SECONDS); } catch (InterruptedException interrupted) { - Thread.currentThread().interrupt(); + throw new IOException(interrupted); } } - return read; - }, SETTLE)) { - assertEquals("a", reading.value()); - reading.changed().subscribe(() -> told.add(value(reading))); + return Files.readString(file); + }); + reading.value(); + reading.refresh(); + assertTrue(paused.await(5, TimeUnit.SECONDS)); + // While that read runs, the game plays another world: its packs and what it plays both ask. + reading.refresh(); + reading.refresh(); + WindowFocus.returned().fire(); + release.countDown(); + settle(reading); + assertEquals(3, reads.get(), "the requests made while one waited are one read"); - // The game writes in parts: the first part is read, and the next part comes while that read runs. - CountDownLatch release = new CountDownLatch(1); - gate.set(release); - write(file, "partial"); - assertTrue(entered.await(5, TimeUnit.SECONDS)); - gate.set(null); - write(file, "partial and whole"); - Thread.sleep(100); - release.countDown(); - await(() -> !told.isEmpty()); - Thread.sleep(500); - assertEquals(List.of("partial and whole"), told, "the read the next write overtook is not told"); - } + reading.close(); + reading.refresh(); + settle(reading); + assertEquals(3, reads.get(), "a closed reading starts no read"); } @Test - void aReadingClosedWhileItReadsTellsNothing() throws Exception { - Path file = write(this.directory.resolve("options.txt"), "a"); - CountDownLatch entered = new CountDownLatch(1); + void aReadOvertakenByARequestMadeWhileItRanPublishesNothing() throws Exception { + Path file = Files.writeString(this.directory.resolve("level.dat"), "First"); + CountDownLatch paused = new CountDownLatch(1); CountDownLatch release = new CountDownLatch(1); - AtomicBoolean waitNext = new AtomicBoolean(); - AtomicInteger told = new AtomicInteger(); - FileReading reading = new FileReading<>(file, path -> { - String read = text(path); - if (waitNext.getAndSet(false)) { - entered.countDown(); + AtomicInteger reads = new AtomicInteger(); + List published = new CopyOnWriteArrayList<>(); + try (FileReading reading = new FileReading<>(() -> { + String read = Files.readString(file); + if (reads.incrementAndGet() == 2) { + paused.countDown(); try { release.await(5, TimeUnit.SECONDS); } catch (InterruptedException interrupted) { - Thread.currentThread().interrupt(); + throw new IOException(interrupted); } } return read; - }, SETTLE); - assertEquals("a", reading.value()); - reading.changed().subscribe(told::incrementAndGet); - waitNext.set(true); - write(file, "b"); - assertTrue(entered.await(5, TimeUnit.SECONDS)); - // The project closes while its reading reads. - reading.close(); - release.countDown(); - Thread.sleep(500); - assertEquals(0, told.get(), "a reading closed while it read tells nothing"); - } - - @Test - void aFolderReachedThroughALinkIsFollowedByEveryReadingOfIt() throws Exception { - Path folder = Files.createDirectories(this.directory.resolve("game")); - Path link; - try { - link = Files.createSymbolicLink(this.directory.resolve("linked"), folder); - } catch (UnsupportedOperationException | IOException | SecurityException notAllowed) { - // Windows without the privilege to create links: nothing to check here. - return; - } - Path file = write(folder.resolve("options.txt"), "a"); - AtomicInteger direct = new AtomicInteger(); - AtomicInteger linked = new AtomicInteger(); - try (FileReading first = new FileReading<>(file, FileReadingTest::text, SETTLE); - FileReading second = new FileReading<>(link.resolve("options.txt"), FileReadingTest::text, SETTLE)) { - first.changed().subscribe(direct::incrementAndGet); - second.changed().subscribe(linked::incrementAndGet); - first.value(); - second.value(); - write(file, "b"); - await(() -> direct.get() == 1 && linked.get() == 1); - second.close(); - write(file, "c"); - await(() -> direct.get() == 2); + })) { + reading.changed().subscribe(() -> published.add(reading.published().orElse(""))); + reading.value(); + Files.writeString(file, "Second"); + reading.refresh(); + assertTrue(paused.await(5, TimeUnit.SECONDS)); + // The game plays a third world while the second is read. + Files.writeString(file, "Third"); + reading.refresh(); + release.countDown(); + await(() -> published.contains("Third")); + assertEquals(List.of("Third"), published, "the world read before the request is never shown"); } } @Test - void readingsAskedForTheirFirstValueFromEveryFileWorkerAtOnceAllRead() throws Exception { - List> values = new ArrayList<>(); - List> readings = new ArrayList<>(); - try { - for (int each = 0; each < 8; each++) { - Path file = write(this.directory.resolve("file" + each + ".txt"), "v" + each); - FileReading reading = new FileReading<>(file, FileReadingTest::text, SETTLE); - readings.add(reading); - // As pages whose reads run on the file work each ask for a value not read yet. - values.add(CompletableFuture.supplyAsync(() -> value(reading), Workers.files())); - } - for (int each = 0; each < 8; each++) assertEquals("v" + each, values.get(each).get(10, TimeUnit.SECONDS)); - } finally { - readings.forEach(FileReading::close); - } + void aValueAskedForOfAClosedReadingIsNotRead() throws Exception { + AtomicInteger reads = new AtomicInteger(); + FileReading reading = new FileReading<>(() -> "read " + reads.incrementAndGet()); + // A page's first read races the project closing. + reading.close(); + assertThrows(IOException.class, reading::value); + assertEquals(0, reads.get()); } @Test - void aFollowerThatFailsDoesNotStopTheWatchOfOthers() throws Exception { - Path folder = Files.createDirectories(this.directory.resolve("game")); + void aFailedReadKeepsTheValueAndTheNextOneThatSucceedsIsTold() throws Exception { + Path file = Files.writeString(this.directory.resolve("options.txt"), "one"); AtomicInteger told = new AtomicInteger(); - Runnable failing = FileWatch.shared().watch(folder, name -> true, () -> { - throw new IllegalStateException("a follower's bug"); - }); - Runnable failingNames = FileWatch.shared().watch(folder, name -> { - throw new IllegalStateException("a follower's bug"); - }, () -> { }); - Runnable counting = FileWatch.shared().watch(folder, name -> true, told::incrementAndGet); - try { - write(folder.resolve("first.txt"), "a"); - await(() -> told.get() >= 1); - int before = told.get(); - write(folder.resolve("second.txt"), "b"); - await(() -> told.get() > before); - } finally { - failing.run(); - failingNames.run(); - counting.run(); + boolean[] failing = {false}; + try (FileReading reading = new FileReading<>(() -> { + if (failing[0]) throw new IOException("being written"); + return Files.readString(file); + })) { + reading.changed().subscribe(told::incrementAndGet); + reading.value(); + + failing[0] = true; + reading.refresh(); + settle(reading); + assertEquals("one", reading.value(), "the value read before stays"); + + // The file holds what it held: a page that showed the failure still hears of the read. + failing[0] = false; + reading.refresh(); + await(() -> told.get() == 1); } } @Test - void aFailedReadAnotherWriteOvertookDoesNotMakeTheNextOneTell() throws Exception { - Path file = write(this.directory.resolve("options.txt"), "a"); - AtomicReference gate = new AtomicReference<>(); - CountDownLatch entered = new CountDownLatch(1); + void aFirstReadThatFailsIsTriedAgainWhenAskedOrRefreshed() throws Exception { + Path file = this.directory.resolve("options.txt"); AtomicInteger told = new AtomicInteger(); - try (FileReading reading = new FileReading<>(file, path -> { - CountDownLatch waiting = gate.getAndSet(null); - if (waiting != null) { - entered.countDown(); - try { - waiting.await(5, TimeUnit.SECONDS); - } catch (InterruptedException interrupted) { - Thread.currentThread().interrupt(); - } - throw new IOException("read while it was written"); - } - return text(path); - }, SETTLE)) { - assertEquals("a", reading.value()); + try (FileReading reading = new FileReading<>(() -> Files.readString(file, StandardCharsets.UTF_8))) { reading.changed().subscribe(told::incrementAndGet); - CountDownLatch release = new CountDownLatch(1); - gate.set(release); - write(file, "a"); - assertTrue(entered.await(5, TimeUnit.SECONDS)); - // Written again, with the same text, while the read fails. - write(file, "a"); - Thread.sleep(100); - release.countDown(); - Thread.sleep(1_000); - assertEquals(0, told.get(), "the value is as it was, and the failure was of a read overtaken"); + assertThrows(IOException.class, reading::value); + + Files.writeString(file, "one"); + reading.refresh(); + await(() -> told.get() == 1); + assertEquals("one", reading.value()); } } @Test - void aReadOfTheFileBeforeAMoveIsNotPublishedAfterIt() throws Exception { - Path first = write(this.directory.resolve("first/level.dat"), "first"); - Path second = write(this.directory.resolve("second/level.dat"), "second"); - AtomicReference gate = new AtomicReference<>(); - CountDownLatch entered = new CountDownLatch(1); - List told = new CopyOnWriteArrayList<>(); - try (FileReading reading = new FileReading<>(first, path -> { - String read = text(path); - CountDownLatch waiting = gate.getAndSet(null); - if (waiting != null) { - entered.countDown(); - try { - waiting.await(5, TimeUnit.SECONDS); - } catch (InterruptedException interrupted) { - Thread.currentThread().interrupt(); + void readsRunOneAtATimeInOrder() throws Exception { + Path file = Files.writeString(this.directory.resolve("options.txt"), "one"); + CountDownLatch paused = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + AtomicInteger running = new AtomicInteger(); + AtomicInteger overlapped = new AtomicInteger(); + AtomicInteger reads = new AtomicInteger(); + try (FileReading reading = new FileReading<>(() -> { + if (running.incrementAndGet() > 1) overlapped.incrementAndGet(); + try { + if (reads.incrementAndGet() == 2) { + paused.countDown(); + release.await(); } + return Files.readString(file); + } catch (InterruptedException interrupted) { + throw new IOException(interrupted); + } finally { + running.decrementAndGet(); } - return read; - }, SETTLE)) { - assertEquals("first", reading.value()); - reading.changed().subscribe(() -> told.add(value(reading))); - CountDownLatch release = new CountDownLatch(1); - gate.set(release); - write(first, "first saved"); - assertTrue(entered.await(5, TimeUnit.SECONDS)); - // The game opens the other world while the first is read. - reading.moveTo(second); + })) { + reading.value(); + // A read paused mid-flight while the file changes and another read is asked for. + reading.refresh(); + assertTrue(paused.await(5, TimeUnit.SECONDS)); + Files.writeString(file, "two"); + reading.refresh(); + Thread.sleep(100); + assertEquals(2, reads.get(), "the second read waits for the first"); release.countDown(); - await(() -> !told.isEmpty()); - Thread.sleep(500); - assertEquals(List.of("second"), told, "what was read of the world before the move is not told"); - } - } - - private static String value(FileReading reading) { - try { - return reading.value(); - } catch (IOException unreadable) { - throw new UncheckedIOException(unreadable); + await(() -> reading.published().orElse("").equals("two")); + assertEquals(0, overlapped.get()); } } - private static String text(Path file) throws IOException { - return Files.isRegularFile(file) ? Files.readString(file, StandardCharsets.UTF_8) : ""; - } - - private static Path write(Path file, String text) throws IOException { - Files.createDirectories(file.getParent()); - return Files.writeString(file, text, StandardCharsets.UTF_8); + /** Gives the reads asked for so far time to run, for a test that nothing more happens. */ + private static void settle(FileReading reading) throws InterruptedException { + Thread.sleep(200); } private static void await(BooleanSupplier condition) throws InterruptedException { diff --git a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/util/FileWatchTest.java b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/util/FileWatchTest.java index d96679bf4..882c7e005 100644 --- a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/util/FileWatchTest.java +++ b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/util/FileWatchTest.java @@ -1,201 +1,83 @@ package com.github.minecraft_ta.totalDebugCompanion.util; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.condition.EnabledOnOs; -import org.junit.jupiter.api.condition.OS; import org.junit.jupiter.api.io.TempDir; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; -import java.util.List; -import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; -import java.util.concurrent.atomic.AtomicReference; import java.util.function.BooleanSupplier; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; -/** The one watcher: listings told of entries only, and Companion's own moves of watched folders. */ +/** The watch of files Companion handed to another program. */ class FileWatchTest { @TempDir Path directory; @Test - void aWatchedFolderAndOneInsideItCanBeMovedWhileTheWatchIsPaused() throws Exception { - Path parent = Files.createDirectories(this.directory.resolve("parent/child")).getParent(); - Runnable onParent = FileWatch.shared().watchEntries(parent, () -> { }); - Runnable onChild = FileWatch.shared().watchEntries(parent.resolve("child"), () -> { }); - try { - FileWatch.shared().pausing(List.of(parent), () -> Files.move(parent, this.directory.resolve("moved"))); - assertTrue(Files.isDirectory(this.directory.resolve("moved/child"))); - } finally { - onParent.run(); - onChild.run(); - } - } - - @Test - void aFollowerAddedWhileAPauseLastsWatchesNothingThereUntilItEnds() throws Exception { - Path parent = Files.createDirectories(this.directory.resolve("parent/child")).getParent(); - AtomicReference added = new AtomicReference<>(() -> { }); - CountDownLatch told = new CountDownLatch(1); - Runnable original = FileWatch.shared().watchEntries(parent, told::countDown); - try { - FileWatch.shared().pausing(List.of(parent), () -> { - FileWatch.shared().pausing(List.of(parent), () -> added.set(FileWatch.shared().watchEntries(parent.resolve("child"), told::countDown))); - try { - assertFalse(told.await(1_250, TimeUnit.MILLISECONDS), "neither the inner pause nor a retry tells before the outer operation ends"); - } catch (InterruptedException interrupted) { - throw new IOException(interrupted); - } - Files.move(parent, this.directory.resolve("moved")); - }); - assertTrue(told.await(5, TimeUnit.SECONDS), "the completed outer operation tells its followers"); - assertTrue(Files.isDirectory(this.directory.resolve("moved/child"))); - } finally { - added.get().run(); - original.run(); - } - } - - @Test - void aFailedOperationWatchesAgainAndTellsWhatItChanged() throws Exception { - Path folder = Files.createDirectory(this.directory.resolve("folder")); - AtomicReference changed = new AtomicReference<>(new CountDownLatch(1)); - Runnable stop = FileWatch.shared().watchEntries(folder, () -> changed.get().countDown()); - try { - assertThrows(IOException.class, () -> FileWatch.shared().pausing(List.of(folder), () -> { - Files.writeString(folder.resolve("during"), ""); - throw new IOException("the operation failed"); - })); - assertTrue(changed.get().await(5, TimeUnit.SECONDS), "what the operation changed is told"); - changed.set(new CountDownLatch(1)); - Files.writeString(folder.resolve("after"), ""); - assertTrue(changed.get().await(5, TimeUnit.SECONDS), "the folder is watched again"); - } finally { - stop.run(); - } - } - - @Test - void otherFoldersAreToldWhileAPauseLasts() throws Exception { - Path other = Files.createDirectory(this.directory.resolve("other")); - CountDownLatch changed = new CountDownLatch(1); - Runnable stop = FileWatch.shared().watchEntries(other, changed::countDown); + void aWriteOfTheFileIsToldAndOneOfItsNeighbourIsNot() throws Exception { + Path file = Files.writeString(this.directory.resolve("stone.png"), "one"); + AtomicInteger told = new AtomicInteger(); + Runnable stop = FileWatch.shared().watch(file, told::incrementAndGet); try { - FileWatch.shared().pausing(List.of(Files.createDirectory(this.directory.resolve("affected"))), () -> { - Files.writeString(other.resolve("new"), ""); - try { - assertTrue(changed.await(5, TimeUnit.SECONDS)); - } catch (InterruptedException interrupted) { - throw new IOException(interrupted); - } - }); - } finally { - stop.run(); - } - } + Files.writeString(this.directory.resolve("dirt.png"), "other"); + Thread.sleep(300); + assertEquals(0, told.get(), "another file of the folder tells nothing"); - @Test - @EnabledOnOs(OS.WINDOWS) - void aLinkedFoldersTargetIsFollowedAfterItIsRecreated() throws Exception { - Path real = Files.createDirectories(this.directory.resolve("targets/real")); - Path alias = Files.createDirectory(this.directory.resolve("links")).resolve("alias"); - Process link = new ProcessBuilder("cmd", "/c", "mklink", "/J", alias.toString(), real.toString()).redirectErrorStream(true).start(); - String output = new String(link.getInputStream().readAllBytes()); - assertEquals(0, link.waitFor(), output); - AtomicReference changed = new AtomicReference<>(new CountDownLatch(1)); - Runnable stop = FileWatch.shared().watchEntries(alias, () -> changed.get().countDown()); - try { - FileWatch.shared().pausing(List.of(real), () -> Files.delete(real)); - assertTrue(changed.get().await(5, TimeUnit.SECONDS)); - changed.set(new CountDownLatch(1)); - Files.createDirectory(real); - assertTrue(changed.get().await(5, TimeUnit.SECONDS), "the target's parent observes its return even though the link's parent does not change"); + Files.writeString(file, "two"); + await(() -> told.get() > 0); } finally { stop.run(); - Files.delete(alias); } + int before = told.get(); + Files.writeString(file, "three"); + Thread.sleep(300); + assertEquals(before, told.get(), "a stopped watch tells nothing"); } @Test - @EnabledOnOs(OS.WINDOWS) - void aFolderReachedThroughALinkIsPausedAndWatchedAgain() throws Exception { - Path real = Files.createDirectory(this.directory.resolve("real")); - Path alias = this.directory.resolve("alias"); - Process link = new ProcessBuilder("cmd", "/c", "mklink", "/J", alias.toString(), real.toString()).redirectErrorStream(true).start(); - String output = new String(link.getInputStream().readAllBytes()); - assertEquals(0, link.waitFor(), output); - AtomicReference changed = new AtomicReference<>(new CountDownLatch(1)); - Runnable first = FileWatch.shared().watchEntries(real, () -> { }); - Runnable second = FileWatch.shared().watchEntries(alias, () -> changed.get().countDown()); + void twoFilesOfOneFolderAreToldApartAndOneStoppedLeavesTheOther() throws Exception { + Path first = Files.writeString(this.directory.resolve("first.png"), "one"); + Path second = Files.writeString(this.directory.resolve("second.png"), "one"); + AtomicInteger firstTold = new AtomicInteger(); + AtomicInteger secondTold = new AtomicInteger(); + Runnable stopFirst = FileWatch.shared().watch(first, firstTold::incrementAndGet); + Runnable stopSecond = FileWatch.shared().watch(second, secondTold::incrementAndGet); try { - first.run(); - Files.writeString(real.resolve("before"), ""); - assertTrue(changed.get().await(5, TimeUnit.SECONDS), "the link's follower keeps the shared watch"); - FileWatch.shared().pausing(List.of(alias), () -> Files.writeString(real.resolve("during"), "")); - changed.set(new CountDownLatch(1)); - Files.writeString(real.resolve("after"), ""); - assertTrue(changed.get().await(5, TimeUnit.SECONDS), "watched again after the pause"); + stopFirst.run(); + Files.writeString(second, "two"); + await(() -> secondTold.get() > 0); + assertEquals(0, firstTold.get()); } finally { - first.run(); - second.run(); - Files.delete(alias); + stopSecond.run(); } } @Test - void aListingIsToldOfEntriesCreatedAndRemovedButNotOfWrites() throws Exception { - Path folder = Files.createDirectory(this.directory.resolve("scripts")); - Path file = Files.writeString(folder.resolve("existing.tdscript"), "return 1;"); + void aRemovedFileIsTold() throws Exception { + Path file = Files.writeString(this.directory.resolve("stone.png"), "one"); AtomicInteger told = new AtomicInteger(); - Runnable stop = FileWatch.shared().watchEntries(folder, told::incrementAndGet); + Runnable stop = FileWatch.shared().watch(file, told::incrementAndGet); try { - Thread.sleep(300); - Files.writeString(file, "return 2;"); - Thread.sleep(700); - assertEquals(0, told.get(), "a write of an entry is not a change of the listing"); - Path created = Files.writeString(folder.resolve("new.tdscript"), "return 3;"); - await(() -> told.get() >= 1); - int afterCreate = told.get(); - Files.delete(created); - await(() -> told.get() > afterCreate); + // As between an editor's delete and rename. + Files.delete(file); + await(() -> told.get() > 0); } finally { stop.run(); } } @Test - void aListingOfAFolderDeletedAndMadeAgainIsToldAndWatchedAgain() throws Exception { - Path folder = Files.createDirectory(this.directory.resolve("scripts")); - AtomicReference changed = new AtomicReference<>(new CountDownLatch(1)); - Runnable stop = FileWatch.shared().watchEntries(folder, () -> changed.get().countDown()); - try { - for (int attempt = 0; attempt < 2; attempt++) { - changed.set(new CountDownLatch(1)); - Files.delete(folder); - assertTrue(changed.get().await(5, TimeUnit.SECONDS), "the folder's removal is told"); - changed.set(new CountDownLatch(1)); - Files.createDirectory(folder); - assertTrue(changed.get().await(5, TimeUnit.SECONDS), "the folder made again is told"); - changed.set(new CountDownLatch(1)); - Path created = Files.writeString(folder.resolve("new.tdscript"), "return 1;"); - assertTrue(changed.get().await(5, TimeUnit.SECONDS), "the folder made again is watched"); - changed.set(new CountDownLatch(1)); - Files.delete(created); - assertTrue(changed.get().await(5, TimeUnit.SECONDS)); - } - } finally { - stop.run(); - } + void aFileWhoseFolderIsMissingCannotBeWatched() { + assertThrows(IOException.class, () -> FileWatch.shared().watch(this.directory.resolve("missing/stone.png"), () -> { })); } private static void await(BooleanSupplier condition) throws InterruptedException { - long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10); while (!condition.getAsBoolean() && System.nanoTime() < deadline) Thread.sleep(10); assertTrue(condition.getAsBoolean(), "timed out"); } diff --git a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/util/WindowFocusTest.java b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/util/WindowFocusTest.java new file mode 100644 index 000000000..500a0c4bc --- /dev/null +++ b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/util/WindowFocusTest.java @@ -0,0 +1,53 @@ +package com.github.minecraft_ta.totalDebugCompanion.util; + +import org.junit.jupiter.api.Test; + +import javax.swing.JWindow; +import javax.swing.SwingUtilities; +import java.awt.Window; +import java.awt.event.WindowEvent; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** What counts as the user coming back to Companion. */ +class WindowFocusTest { + @Test + void onlyTakingTheFocusBackFromAnotherProgramIsAReturn() throws Exception { + AtomicInteger returned = new AtomicInteger(); + Runnable stop = WindowFocus.returned().subscribe(returned::incrementAndGet); + SwingUtilities.invokeAndWait(() -> { + JWindow main = new JWindow(); + JWindow dialog = new JWindow(); + try { + // Companion's first window takes the focus as it opens. + gained(main, null); + assertEquals(0, returned.get(), "opening is no return"); + + // The focus moves between Companion's own windows. + lost(main, dialog); + gained(dialog, main); + assertEquals(0, returned.get(), "a dialog of Companion is no other program"); + + // The user goes to the game and comes back. + lost(dialog, null); + gained(main, null); + assertEquals(1, returned.get()); + gained(main, null); + assertEquals(1, returned.get(), "once per return"); + } finally { + main.dispose(); + dialog.dispose(); + } + }); + stop.run(); + } + + private static void gained(Window window, Window from) { + WindowFocus.changed(new WindowEvent(window, WindowEvent.WINDOW_GAINED_FOCUS, from)); + } + + private static void lost(Window window, Window to) { + WindowFocus.changed(new WindowEvent(window, WindowEvent.WINDOW_LOST_FOCUS, to)); + } +} diff --git a/docs/SYSTEMS.md b/docs/SYSTEMS.md index 7c807da98..6369fe6b0 100644 --- a/docs/SYSTEMS.md +++ b/docs/SYSTEMS.md @@ -26,9 +26,9 @@ Each way was reasonable where it was added. Together they are the edge cases: a State moves one way. A page learns of a change only through a signal, and reads only through its loader. ```text -game message ─────────────────────┐ -file on disk ─► watch ─► reading ─┼─► owner compares ─► signal (if different) ─► page loader ─► read ─► show -pipeline write ───────────────────┘ +game message ──────────────────────────────────┐ +file on disk ─► reading (shown, user back) ─────┼─► owner compares ─► signal (if different) ─► page loader ─► read ─► show +pipeline write ─────────────────────────────────┘ page action ─► change pipeline ─► project's write queue ─► owner ─► signal ``` @@ -60,7 +60,7 @@ public final class Signal { - **Connection-bound work compares connections.** `GameLocation`'s connection value includes the connection's number and the game's process. A request waiting for an answer keeps the number of the connection it was sent on, and is failed when the connection signal shows another. This replaces reacting to `DISCONNECTED` as an event, which a quick reconnect can hide. - **The current project is state too.** The application owns it and signals a switch. UI that outlives a project (the tab strip, the Project tree, the status bar, search) follows "the current project's catalog" through its loader, which subscribes again on a switch. This deletes the `project.get() != scope` and `currentScope() == scope` checks. -**Newest wins, where it is the same read.** A page loader runs one read at a time: a request during a read makes one more read after it, and the older result is dropped. A file reading needs no such bookkeeping: its reads run on its strand, one after the other, and a read waiting for a write to settle gives way to a later write. It does not replace an owner's protection between requests of different kinds, where a later request must win over an earlier one that finishes last: `ItemIconService` adopting an archive the game announced over restoring the newest from disk, `PackCatalogService` taking a prepared catalog over a restore. Those counters stay in their owners; a signal counts changes, a counter of requests tells which answer is the newest, and they are different jobs. +**Newest wins, where it is the same read.** A page loader runs one read at a time: a request during a read makes one more read after it, and the older result is dropped. A file reading does the same: its reads run on its strand, one after the other, and a read under way when another is asked for publishes nothing. It does not replace an owner's protection between requests of different kinds, where a later request must win over an earlier one that finishes last: `ItemIconService` adopting an archive the game announced over restoring the newest from disk, `PackCatalogService` taking a prepared catalog over a restore. Those counters stay in their owners; a signal counts changes, a counter of requests tells which answer is the newest, and they are different jobs. Owners: @@ -72,7 +72,7 @@ Owners: | `ChangeRecord` | changes | `addListener` | | `GameLocation` | connection, process, playing | `addListener(Consumer)` | | `WorldReading` (new) | world | `WorldReadings`, which is deleted: it reads the current world's folder itself | -| `KeyAssignments` | assignments | its `addListener` and `KeyBindingControl.addAssignmentListener` | +| `KeyBindingControl` | assignments | `KeyAssignments`, folded into it, and `KeyBindingControl.addAssignmentListener` | | `GameLogs` | the listed logs and crash reports | the Logs page's and the tree's own listing | | `ItemIconService` | icons | `addListener` | | `ResourceEdits` | edits, working pack | `addEditListener`, `addWorkingPackListener` | @@ -82,33 +82,40 @@ Owners: ## 2. Files on disk -Files Companion shows that others write are followed by an owner through a `FileReading`: a watch, a reader, the last value, a comparison and a signal. It is what `KeyAssignments` is today, made once for all. +Nothing watches the files others write, apart from one case. Who wrote a file decides how Companion learns of it: + +| Who wrote it | How Companion learns of it | +|---|---| +| The connected game | Its messages: packs, `PLAYING`, the answers to changes and reloads (section 6) | +| Companion | The owner reads again after the write and fires if the value differs (section 5) | +| Another program, or a game without a connection | What shows the file reads it when it is shown, and again when the user comes back to Companion from another program (`WindowFocus.returned()`: a window of Companion taking the focus after Companion lost it to another program, which the main window tells of; Companion's first window opening is no return) | +| A program Companion opened a file in | `FileWatch`, which takes its saves at once (below) | + +A value that others than its page need is held by its owner in a `FileReading`: a reader, the last value, a comparison and a signal. ```java -FileReading> reading = new FileReading<>( - options, // the file, which may not exist yet - KeyBindings::readOptions, // reads the whole file into a domain value; a missing file is a value too - Duration.ofMillis(300)); // settle: read once writes stopped for this long -reading.changed(); // fires only when a read found another value -reading.value(); // the published snapshot; read now, on the strand, where none was read yet -reading.readNow(); // after Companion's own write -reading.moveTo(otherFile); // a folder that moves, as the current world +FileReading> assignments = new FileReading<>( + () -> KeyBindings.readOptions(options)); // reads the whole value; a missing file is a value too +assignments.changed(); // fires only when a read found another value +assignments.value(); // the published snapshot; read now, on the strand, where none was read yet +assignments.refresh(); // after Companion's own write, or when the game told of a change ``` -- **Every read and every change of the value runs on the reading's strand**, whoever asks: the watch, a retry, Companion's own write, a page. So a page that asks before the first read gets the value later changes are told against. -- **One `FileWatch` for the application** does the watching, with one thread. A registration lives until its reading closes or stops watching (`watch(false)`); a reading that does not watch keeps its value and reads only when its owner asks (`readNow`). -- **A folder that does not exist yet** is watched through its nearest existing ancestor, and watched itself once it appears, as a game's folder before it first ran; one removed while watched is watched that way again. -- **Where a folder cannot be watched at all**, as without file watching or on some network drives, it is tried again after 1 and 5 seconds and every 30 seconds after, and the reading reads each time. -- **Events are filtered by name**, so `latest.log` growing beside `options.txt` does not read `options.txt`. A listing of a folder, as the Project tree's, is told only of entries created or removed (`watchEntries`). -- **A read that fails is retried** after 1, 5 and 30 seconds, as a file written in place can be read half written; the next read that succeeds is told, since a page may show the failure. Overflowing events read once. -- **Folders that move are the owner's.** The current world follows `playing` and moves its reading to the world the game plays, or with the game closed, the last played. -- **Windows renames.** On Windows a folder cannot be renamed or deleted while a folder inside it is watched. Registrations sit on the smallest folders that answer the question. The current world's folder and its `datapacks` are watched only while the connected game plays it, which the game tells once it let go of the world, so its Delete World works; a world the game does not play is looked at again when the game connects or plays another world, when a world of `saves` is created or removed, and after Companion changed its datapacks. A game Companion is not connected to has its worlds read then, not watched. Companion's own moves of watched folders, as of script folders, run in `FileWatch.pausing`, which lets go of every watch on or inside them for the move and watches them again after. -- **A reading's value is a domain value with a meaningful equality**, such as parsed assignments or a list of names; the reader supplies it. -- **What each reading holds decides what fires**, such as parsed assignments rather than the file's bytes. -- **Readings replace "read whenever shown" where others than the page need the value:** `options.txt` and the current world, which the Project tree, the tab strip and the change pipeline use too. The Logs page, the configuration pages and the resource packs listing are the only readers of their files, and the game writes its logs the whole time it runs: they read whenever they are shown (`readsWhenShown`), which a reading would only repeat with a watch. Decided on 2026-09-30, when the watchers moved onto `FileWatch`. +Its whole contract, which `FileReading`'s documentation states the same way: + +- **Reads run one at a time, in order, on the reading's strand**, whoever asks. `value()` returns the value published last, or reads it now where none was, so a page that asks first gets the value later changes are told against. +- **`refresh()` reads once after it was asked**; requests made while one waits are that one, so the game playing another world, which both `playing` and the datapacks tell of, reads the world once. A read under way when a request is made publishes nothing, since the read asked for is newer: the world the game left is never shown as current after it left. The first value a refresh reads is told, for followers that show it without asking, as the World tab's title. +- **The user coming back refreshes a value read before**; a value nobody asked for stays unread. +- **A read that fails keeps the value read before**, and the next read that succeeds is told, since a page may show the failure. Nothing retries on a timer: the next return or request reads again. +- **Once closed, no read starts and none publishes.** +- **A reading's value is a domain value with a meaningful equality**, such as parsed assignments, and what it holds decides what fires: the volume changing in `options.txt` fires nothing. +- **What moves is the reader's.** The current world's reader asks the game location which world is current at each read, and its owner asks it to read again when the game connects, plays another world or leaves one, and after Companion changed the datapacks. Keeping the value read before is for the same world read again: another world that fails to read is published without what it holds, so nothing shows or changes the world before as if it were current. +- **Two readings:** the keys `options.txt` assigns (`KeyBindingControl`) and the current world (`WorldReading`), which the Project tree, the World tab's title and the change pipeline use beside their pages. A page that is the only reader of its files, as the logs, the configuration files, the resource packs and the Changes page, reads whenever it is shown and when the user comes back while it is shown (`readsWhenShown`). The Project tree lists its loaded Scripts folders again then; the decompiled sources follow the decompiler's `cached` signal. - **Caches are allowed where keyed by what they cache**, a file's size and time or its hash, as `TextureThumbnails`, `CatalogIcons` and the parsed logs are. A cache is never the truth another part reads. -**Adopting a file is a write, not a reading.** A texture saved in an external editor is taken into the pack: `ExternalEdits` registers with `FileWatch`, keeps its baseline and checks that the image is complete, and its adoption runs on the project's write queue through the pipeline. Before it records a change or asks for a reload, the adoption compares the saved content with what the pack already holds, by hash, and does nothing when they are the same: a notification of content Companion itself wrote, or a second notification of one save, must not record or reload again. Deduplication happens before side effects, in the owner, never after them in a page. The watchers of `FileUtils`, `ExternalEdits` and `KeyAssignments` become registrations. +**Adopting a file is a write, not a reading.** A texture opened in another program from Companion is taken into the pack as that program saves it, while the user stays in the editor and looks at the game. This is the one watch: `FileWatch` tells `ExternalEdits` that a followed file was written, on its one thread; `ExternalEdits` keeps the file's baseline, waits until writes stopped, checks that the image is complete, and adopts it on the project's write queue through the pipeline. Before it records a change or asks for a reload, the adoption compares the saved content with what the pack already holds, by hash, and does nothing when they are the same: a notification of content Companion itself wrote, or a second notification of one save, must not record or reload again. Deduplication happens before side effects, in the owner, never after them in a page. A file whose folder cannot be watched is not opened, and the user is told why. Every opening watches the file anew, so one whose folder was removed and made again is followed again. + +**Why nothing else is watched.** Decided on 2026-10-01, after #114 to #117 moved every watch onto one watcher. Watching folders that others own needed watches of ancestors for folders not there yet, real paths for links, retries, pauses so that Windows can rename or delete a watched folder (the game's Delete World), and settle times for files written in parts; most review findings of those PRs were about these. Reading when shown and when the user comes back answers the same question with none of them. A change that must show while Companion keeps the focus, as a key rebound in the game while Companion is visible beside it, comes as a game message, not as a watch. ## 3. Pages @@ -150,7 +157,7 @@ The Swing thread runs Swing, and nothing that waits or grows with the data: no f - Platform threads, not virtual ones: on Java 21 a virtual thread is pinned inside `synchronized` and by `ZipFile`, which most reads use. - Swing timers stay for delays and animations on the Swing thread. -- **Allowed own threads**, each owned by a service that closes it: the debugger (`DebuggerSessionQueue`, `DebuggerEvaluationRunner`), the editor's Java analysis, script compilation, decompilation, search (also `SearchEverywherePopup`'s) and the runtime index, the MCP job service, `CompanionApplication`'s project switching and MCP lifecycle, `ProjectSelectionServer`, and the item icon renderer, which its graphics library confines to one thread. The list lives in the architecture test; adding to it is a decision recorded here. +- **Allowed own threads**, each owned by a service that closes it: the debugger (`DebuggerSessionQueue`, `DebuggerEvaluationRunner`), the editor's Java analysis, script compilation, decompilation, search (also `SearchEverywherePopup`'s) and the runtime index, the MCP job service, `CompanionApplication`'s project switching and MCP lifecycle, `ProjectSelectionServer`, the item icon renderer, which its graphics library confines to one thread, and `FileWatch`'s one thread. The list lives in the architecture test; adding to it is a decision recorded here. ## 5. Writes @@ -184,7 +191,7 @@ These go into [AGENTS.md](../AGENTS.md) and are checked by an architecture test 1. State Companion shows has one owner, which compares and fires a `Signal` only on a change. No other listener lists for state; outcomes go through the action's future. 2. A page reads and shows through `PageLoader` and follows signals only through it: no subscriptions, file reads or threads of its own, no reads in constructors or on navigation, and a read never changes an owner. 3. Threads, executors and schedulers come from `Workers`, except the listed ones; no one-argument `supplyAsync` or `runAsync`. -4. Only `FileWatch` watches files. A file others write is followed through a `FileReading`; caches are keyed by what they cache and are never the truth. +4. Only `FileWatch` watches files, and only files Companion opened in another program. A file others write is read when it is shown and when the user comes back to Companion, through a `FileReading` where others than its page need the value; caches are keyed by what they cache and are never the truth. 5. The game's and the packs' files are written only inside a pipeline write task, on the project's write queue. 6. A game message is registered by the owners that handle it, on the connection. 7. The Swing thread does nothing that waits or grows with the data. @@ -208,6 +215,7 @@ PRs on 1.21.1, stacked, each reviewed until clean. A shared mechanism comes with | 7 | All remaining pages on `page` and `follows`, the logs, configuration and resource packs pages reading whenever shown; the World tab's title from its owner; reads on the file workers | `ShownUpdates`, `whenShown`, `waitsWhileHidden` and `follow`, the pages' own subscriptions, the reads in constructors, the navigation refreshes | | 8a | The pipeline owning the write queue; the remaining executors and one-argument async calls onto `Workers` | `ConfigChanges`' executor, the UI classes' and `JsonStateWriter`'s executors, every use of the shared pool | | 8b | The current project as state (`CurrentProject`), whose signals the main window follows | the `CompanionUi` relays and their scope checks | +| 9 | Files read when shown and when the user comes back to Companion; `FileWatch` only for files opened in another program; the decompiled sources on a signal | `KeyAssignments`; the watches of `options.txt`, `saves`, the current world, its datapacks, the Project tree's folders and the decompiled sources; `FileReading`'s watch, settle, retries and counter; `FileWatch`'s ancestors, links, retries and pauses | After the messages, A4 continues with categories registering their pages and Modpack rows. Splitting `CompanionApplication` (the game connection and the MCP server into their own classes) and the mod's `CompanionAppClient` (launching Companion) is easier then and is decided at that point. @@ -215,8 +223,8 @@ UI work that follows no system, such as every page's loading, empty and failed s ## Tests -- `Signal`: listeners in order, removal of one subscription. `FileReading`: a write in parts read once, a value asked before the first read, a folder that appears later, a failed read retried, reads never at once, a reading moved to another file, a closed reading. -- `FileReading` and `FileWatch`, through a clock the test advances instead of real waits: settle and maximum wait, a folder that appears later, a linked folder, overflow, a failed read retried, a pause for a rename, a reading whose value did not change. +- `Signal`: listeners in order, removal of one subscription. `FileReading`: read when first asked, not before; told only on another value; read again when the user comes back, not once closed; a failed read keeping the value and the next success told; reads never at once, with one paused mid-flight. +- `FileWatch`: a write of the followed file told and one of its neighbour not, two files of one folder, a stopped watch, a folder that cannot be watched. - `PageLoader`: hidden and shown, a signal during a read, several while hidden, a change that does not concern the page, a held page, a read under way when the page holds, a part of a page. The old modes' tests go with the modes. - Messages: two handlers in order, a handler of a detached scope not called, an answer to another connection not taken, a request without a handler refused, an unauthenticated message refused in the mod. - The architecture test: no listener list, thread, executor, watcher or one-argument async call outside the owners and exceptions it lists.