diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/CompanionApplication.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/CompanionApplication.java index f5fd6fd36..98ed02da2 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/CompanionApplication.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/CompanionApplication.java @@ -151,7 +151,9 @@ public CompanionApplication(CompanionLaunchConfiguration configuration, String t try { JDTHacks.init(configuration.paths().jdtCache()); runtimeIndexService = new RuntimeIndexService(lifecycleLock, this::installRuntimeSnapshot); - runtimeIndexService.addStatusListener(this::updateRuntimeIndexUi); + RuntimeIndexService index = runtimeIndexService; + index.statusChanged().subscribe(() -> updateRuntimeIndexUi(index.status())); + updateRuntimeIndexUi(index.status()); debuggerController = createDebuggerController(); debuggerController.addListener(new DebuggerSessionController.Listener() { private Throwable lastFailure; diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/catalog/ConfigChanges.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/catalog/ConfigChanges.java index 5d6ce9aaa..a7a21fba1 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/catalog/ConfigChanges.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/catalog/ConfigChanges.java @@ -56,12 +56,9 @@ public ConfigChanges(GameLocation location, ChangeRecord record) { this.location = Objects.requireNonNull(location, "location"); this.workspace = location.workspace(); this.record = Objects.requireNonNull(record, "record"); - location.addListener(change -> { - switch (change) { - case PROCESS -> gameProcess(location.process()); - case DISCONNECTED -> gameDisconnected(); - default -> { } - } + location.processChanged().subscribe(() -> gameProcess(location.process())); + location.connectionChanged().subscribe(() -> { + if (location.connection() == null) gameDisconnected(); }); } 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 d795aac8a..dc1b82b2f 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 @@ -70,7 +70,12 @@ public WorldReading(GameLocation location, GamePacks packs) { () -> 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)); - this.stopFollowingGame = location.addListener(change -> 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(); } diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/change/ChangePipeline.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/change/ChangePipeline.java index 6e192556a..3406a0a25 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/change/ChangePipeline.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/change/ChangePipeline.java @@ -74,8 +74,8 @@ public ChangePipeline(GameLocation location, ChangeRecord record, Executor write this.record = Objects.requireNonNull(record, "record"); this.writes = Objects.requireNonNull(writes, "writes"); this.reloads = new Reloads(location); - location.addListener(change -> { - if (change == GameLocation.Change.DISCONNECTED) gameDisconnected(); + location.connectionChanged().subscribe(() -> { + if (location.connection() == null) gameDisconnected(); }); } diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/change/Reloads.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/change/Reloads.java index 97348f80a..0876a6f06 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/change/Reloads.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/change/Reloads.java @@ -119,10 +119,10 @@ void sendIfIdle() { public Reloads(GameLocation location) { this.location = Objects.requireNonNull(location, "location"); - location.addListener(change -> { - if (change == GameLocation.Change.DISCONNECTED) gameDisconnected(); - else if (change == GameLocation.Change.PLAYING) leftWorld(); + location.connectionChanged().subscribe(() -> { + if (location.connection() == null) gameDisconnected(); }); + location.playingChanged().subscribe(this::leftWorld); } /** A write is queued whose reload the next reloads wait for, so writes in quick succession take one reload. */ diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/game/GameLocation.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/game/GameLocation.java index 8b1b36710..5f7167e45 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/game/GameLocation.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/game/GameLocation.java @@ -1,5 +1,6 @@ package com.github.minecraft_ta.totalDebugCompanion.game; +import com.github.minecraft_ta.totalDebugCompanion.util.Signal; import com.github.minecraft_ta.totalDebugCompanion.catalog.Worlds; import com.github.minecraft_ta.totaldebug.protocol.message.PlayingPayload; import com.github.minecraft_ta.totaldebug.storage.GameLock; @@ -7,10 +8,7 @@ import com.github.tth05.scnet.message.AbstractMessage; import java.nio.file.Path; -import java.util.List; import java.util.Objects; -import java.util.concurrent.CopyOnWriteArrayList; -import java.util.function.Consumer; /** * Where a project's game is, as {@code docs/GAME_LOCATION.md} describes: closed, running without a connection, or @@ -25,9 +23,6 @@ public interface Connection { boolean send(AbstractMessage message); } - /** What changed, told to listeners on the thread that changed it. */ - public enum Change { CONNECTED, DISCONNECTED, PROCESS, PLAYING } - /** The instance's files that tell whether a game runs and whether a world is held. Blocking. */ public interface Files { /** Whether a game holds the instance's game lock; a lock that cannot be checked counts as held. */ @@ -60,7 +55,9 @@ record Link(Connection connection, long process, PlayingPayload playing) { private final Path workspace; private final Files files; - private final List> listeners = new CopyOnWriteArrayList<>(); + private final Signal connectionChanged = new Signal(); + private final Signal processChanged = new Signal(); + private final Signal playingChanged = new Signal(); private volatile Link link; /** * What the game told on the current connection, kept from its first message: Companion may take the connection as @@ -85,8 +82,8 @@ public Path workspace() { } /** - * The game's connection is established, with what the game has told on it so far; listeners hear of that as well, as - * if it was told now. + * The game's connection is established, with what the game has told on it so far; its followers hear of that as well, + * as if it was told now. */ public void connected(Connection connection) { Objects.requireNonNull(connection, "connection"); @@ -95,12 +92,12 @@ public void connected(Connection connection) { established = new Link(connection, this.toldProcess, this.toldPlaying); this.link = established; } - tell(Change.CONNECTED); - if (established.process() != 0) tell(Change.PROCESS); - if (established.playing() != null) tell(Change.PLAYING); + this.connectionChanged.fire(); + if (established.process() != 0) this.processChanged.fire(); + if (established.playing() != null) this.playingChanged.fire(); } - /** The game's process, as it announced it on the current connection; listeners hear of it when it changed. */ + /** The game's process, as it announced it on the current connection; followers hear of it when it changed. */ public void process(long process) { synchronized (this) { this.toldProcess = process; @@ -108,11 +105,11 @@ public void process(long process) { if (current == null || current.process() == process) return; this.link = new Link(current.connection(), process, current.playing()); } - tell(Change.PROCESS); + this.processChanged.fire(); } /** - * What the game plays, as it told on the current connection; listeners hear of it when it changed. The game tells it + * What the game plays, as it told on the current connection; followers hear of it when it changed. The game tells it * again after each handshake, which may repeat what {@link #connected} already announced. */ public void playing(PlayingPayload playing) { @@ -123,7 +120,7 @@ public void playing(PlayingPayload playing) { if (current == null || playing.equals(current.playing())) return; this.link = new Link(current.connection(), current.process(), playing); } - tell(Change.PLAYING); + this.playingChanged.fire(); } /** The game's connection ended; what it told on it no longer holds. */ @@ -134,7 +131,7 @@ public void disconnected() { if (this.link == null) return; this.link = null; } - tell(Change.DISCONNECTED); + this.connectionChanged.fire(); } /** The connection to the game, or null without one. Not blocking. */ @@ -165,13 +162,21 @@ public GameState read() { return new GameState(this.workspace, this.files, new GameState.Game.Unconnected()); } - /** Tells {@code listener} of each change, on the thread that made it; returns what removes it. */ - public Runnable addListener(Consumer listener) { - this.listeners.add(Objects.requireNonNull(listener, "listener")); - return () -> this.listeners.remove(listener); + /** + * Fires when the game connected or disconnected, on the thread that saw it; {@link #connection()} tells which. The + * followers of one change hear of it before anything else changes the location. + */ + public Signal connectionChanged() { + return this.connectionChanged; + } + + /** Fires when the connected game's process became known or changed, on the thread that saw it. */ + public Signal processChanged() { + return this.processChanged; } - private void tell(Change change) { - this.listeners.forEach(listener -> listener.accept(change)); + /** Fires when what the connected game plays changed, also when a connection is established, on the thread that saw it. */ + public Signal playingChanged() { + return this.playingChanged; } } diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/inspection/ItemIconService.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/inspection/ItemIconService.java index bfb8571a0..8c593bd1a 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/inspection/ItemIconService.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/inspection/ItemIconService.java @@ -1,5 +1,6 @@ package com.github.minecraft_ta.totalDebugCompanion.inspection; +import com.github.minecraft_ta.totalDebugCompanion.util.Signal; import com.github.minecraft_ta.totalDebugCompanion.catalog.CatalogIndex; import com.github.minecraft_ta.totalDebugCompanion.itemrender.ItemModelId; import com.github.minecraft_ta.totalDebugCompanion.itemrender.ItemRenderBackend; @@ -21,7 +22,6 @@ import java.util.Objects; import java.util.Optional; import java.util.concurrent.CompletableFuture; -import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.Executor; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -54,7 +54,9 @@ private record Key(String model, Map tints, int size) { .daemon() .name("Companion item icons") .unstarted(task)); - private final List listeners = new CopyOnWriteArrayList<>(); + private final Signal changed = new Signal(); + /** Held while an adoption decides whether it is the newest. */ + private final Object adoption = new Object(); private final Map> cache = new LinkedHashMap<>(64, 0.75f, true) { @Override protected boolean removeEldestEntry(Map.Entry> eldest) { @@ -97,20 +99,20 @@ CompletableFuture restore(Path directory, Executor reader) { /** Starts an adoption, superseding every earlier one that has not finished. */ private long start() { - synchronized (this.listeners) { + synchronized (this.adoption) { return ++this.adoptions; } } /** Replaces the snapshot, unless a newer adoption started since {@code generation}. */ private void adopt(long generation, Snapshot next) { - synchronized (this.listeners) { + synchronized (this.adoption) { if (this.closed || generation != this.adoptions || Objects.equals(next, this.snapshot)) { return; } this.snapshot = next; } - SwingUtilities.invokeLater(() -> this.listeners.forEach(Runnable::run)); + SwingUtilities.invokeLater(this.changed::fire); } static Snapshot newestSnapshot(Path directory) { @@ -165,10 +167,9 @@ Snapshot snapshot() { return this.snapshot; } - /** Registers a listener for new snapshots and returns its removal. */ - public Runnable addListener(Runnable listener) { - this.listeners.add(Objects.requireNonNull(listener, "listener")); - return () -> this.listeners.remove(listener); + /** Fires on the Swing thread after a newer snapshot was adopted, so views draw their icons again. */ + public Signal changed() { + return this.changed; } /** Renders {@code model} (for example {@code minecraft:item/furnace}); empty when it cannot be drawn. */ @@ -312,7 +313,6 @@ public void close() { return; } this.closed = true; - this.listeners.clear(); this.worker.execute(this::closeBackend); this.worker.shutdown(); } diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/pack/GamePacks.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/pack/GamePacks.java index b21e72b99..23d8b9715 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/pack/GamePacks.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/pack/GamePacks.java @@ -54,20 +54,20 @@ ChangeRecord.PackSide.RESOURCES, new Signal(), public GamePacks(GameLocation location) { this.location = Objects.requireNonNull(location, "location"); this.workspace = location.workspace(); - location.addListener(change -> { - if (change == GameLocation.Change.DISCONNECTED) gameDisconnected(); - else if (change == GameLocation.Change.PLAYING) { - // The datapacks the server named belong to the world it played; its next world's server is asked. - synchronized (this) { - if (!Objects.equals(this.datapacksFor, location.playing())) { - this.datapacks = null; - this.worldRefusal = ""; - } + location.connectionChanged().subscribe(() -> { + if (location.connection() == null) gameDisconnected(); + }); + location.playingChanged().subscribe(() -> { + // The datapacks the server named belong to the world it played; its next world's server is asked. + synchronized (this) { + if (!Objects.equals(this.datapacksFor, location.playing())) { + this.datapacks = null; + this.worldRefusal = ""; } - askForDatapacks(); - // Without datapacks named yet, the world's own files stand for them, and they are another world's now. - tell(ChangeRecord.PackSide.DATA); } + askForDatapacks(); + // Without datapacks named yet, the world's own files stand for them, and they are another world's now. + tell(ChangeRecord.PackSide.DATA); }); } diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/runtime/RuntimeIndexService.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/runtime/RuntimeIndexService.java index b6775ff54..cee7cee8a 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/runtime/RuntimeIndexService.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/runtime/RuntimeIndexService.java @@ -1,5 +1,6 @@ package com.github.minecraft_ta.totalDebugCompanion.runtime; +import com.github.minecraft_ta.totalDebugCompanion.util.Signal; import com.github.minecraft_ta.totaldebug.storage.RuntimeInventory; import com.github.minecraft_ta.totaldebug.storage.InstancePaths; import com.github.minecraft_ta.totaldebug.storage.AtomicFiles; @@ -24,7 +25,6 @@ import java.util.Objects; import java.util.Set; import java.util.zip.ZipFile; -import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.CancellationException; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -114,7 +114,7 @@ record PreparedInput(IndexSource indexSource, RuntimeSnapshotBytecodeSource.Sour private final Object lifecycleLock; private final Consumer readyHandler; private final Function indexLoader; - private final CopyOnWriteArrayList> listeners = new CopyOnWriteArrayList<>(); + private final Signal statusChanged = new Signal(); private volatile Status status = new Status(Phase.WAITING, "Waiting for runtime inventory", null); private String activeInventoryId; private Metrics activeMetrics; @@ -166,10 +166,9 @@ public Status status() { return this.status; } - public void addStatusListener(Consumer listener) { - Consumer checked = Objects.requireNonNull(listener, "listener"); - this.listeners.add(checked); - checked.accept(this.status); + /** Fires after {@link #status()} changed, on the thread that changed it, under the lifecycle lock. */ + public Signal statusChanged() { + return this.statusChanged; } public void waiting(String detail) { @@ -185,10 +184,6 @@ public void waiting(String detail) { } } - public void removeStatusListener(Consumer listener) { - this.listeners.remove(listener); - } - public void restore(Path dataDirectory) { restore(dataDirectory, null); } @@ -608,9 +603,10 @@ private void update(Work work, Status status) { } private void update(Status replacement) { - this.status = replacement; - for (Consumer listener : this.listeners) { - listener.accept(replacement); + // Under the lock, so what the followers read of the status is this one. + synchronized (this.lifecycleLock) { + this.status = replacement; + this.statusChanged.fire(); } } diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/catalog/CatalogIcons.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/catalog/CatalogIcons.java index 363c4c1be..d2ddcc70f 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/catalog/CatalogIcons.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/catalog/CatalogIcons.java @@ -20,7 +20,7 @@ public CatalogIcons(ItemIconService service, int size) { this.size = size; this.icons = new IconLoader<>(4_096, 8, item -> service.render(item.model(), item.tints(), size) .thenApply(image -> image.map(ImageIcon::new))); - this.removeListener = service.addListener(this.icons::clear); + this.removeListener = service.changed().subscribe(this.icons::clear); } public int size() { diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/catalog/ConfigPanel.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/catalog/ConfigPanel.java index 9d534aa4a..e1d67e4fc 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/catalog/ConfigPanel.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/catalog/ConfigPanel.java @@ -112,12 +112,10 @@ final class ConfigPanel extends JPanel { failure -> showFailure("Could not list the worlds' copies: " + failure.getMessage())) // A server configuration is shown from the world the game has open first; it follows the game to another // world, unless its text has unsaved changes, which stay with the file they were made in. - .follow(listener -> this.location.addListener(change -> { - if (change == GameLocation.Change.PLAYING) SwingUtilities.invokeLater(() -> { - PackCatalog.ConfigFile file = selectedFile(); - if (file != null && file.type() == PackCatalog.ConfigType.SERVER && !this.textEditor.modified()) listener.run(); - }); - })); + .follow(listener -> this.location.playingChanged().subscribe(() -> SwingUtilities.invokeLater(() -> { + PackCatalog.ConfigFile file = selectedFile(); + if (file != null && file.type() == PackCatalog.ConfigType.SERVER && !this.textEditor.modified()) listener.run(); + }))); configureFiles(); this.content.add(toolbar(), BorderLayout.NORTH); JPanel settings = new JPanel(new BorderLayout()); diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/catalog/DefinitionDetails.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/catalog/DefinitionDetails.java index cc10b052c..98a965c74 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/catalog/DefinitionDetails.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/catalog/DefinitionDetails.java @@ -108,7 +108,7 @@ public DefinitionDetails(SubjectRef.Definition subject, Services services, JComp this.appearance = found.appearance(); this.appearancePreviews = found.previews(); showExtras(); - }, failure -> { }).waitsWhileHidden(page).follow(services.icons()::addListener); + }, failure -> { }).waitsWhileHidden(page).follow(services.icons().changed()::subscribe); this.resourceLoader = new PageLoader<>(this::prepareResources, list -> { this.owned = list; this.matched = matching(this.owned, this.subject.namespace(), resourceName()); diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/catalog/PackConfigurationPanel.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/catalog/PackConfigurationPanel.java index 14e65bc5d..72cc84657 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/catalog/PackConfigurationPanel.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/catalog/PackConfigurationPanel.java @@ -98,9 +98,7 @@ public void mouseClicked(MouseEvent event) { failure -> show(new Loaded(List.of(), Map.of(), Map.of(), List.of()), "Could not read the configuration files: " + failure.getMessage())) .whenShown(this).follow(catalog.changed()::subscribe) // A server configuration is shown from the copy of the world the game has open, which changes with it. - .follow(listener -> this.location.addListener(change -> { - if (change != GameLocation.Change.PROCESS) listener.run(); - })); + .follow(this.location.connectionChanged()::subscribe).follow(this.location.playingChanged()::subscribe); load(); } diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/inspection/SubjectPanel.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/inspection/SubjectPanel.java index 4b4f5f19c..4f16f8fbb 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/inspection/SubjectPanel.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/inspection/SubjectPanel.java @@ -138,7 +138,7 @@ private SubjectPanel(SubjectRef.Definition definition, InspectSubjectPayload sub showHeader(); showSections(); // The page draws its items once it is shown; the editor tabs draw the tab's (refreshTabIcon). - this.removeIconListener = ShownUpdates.follow(this, services.icons()::addListener, this::reloadPageIcons); + this.removeIconListener = ShownUpdates.follow(this, services.icons().changed()::subscribe, this::reloadPageIcons); reloadIcons(); } 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 c2ab5ca3f..afa6b47b9 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,7 +1,6 @@ package com.github.minecraft_ta.totalDebugCompanion.ui.components.treeView; import com.github.minecraft_ta.totalDebugCompanion.storage.ChangeRecord; -import com.github.minecraft_ta.totalDebugCompanion.game.GameLocation; import com.github.minecraft_ta.totalDebugCompanion.project.ProjectScope; import com.github.minecraft_ta.totaldebug.protocol.message.PlayingPayload; @@ -273,8 +272,7 @@ public List loadChildren() { }); Runnable removeRead = scope.world().changed().subscribe(refresh); // What the game plays also decides whether there is a World root at all. - Runnable removePlayed = scope.location().addListener(change -> { - if (change != GameLocation.Change.PLAYING) return; + Runnable removePlayed = scope.location().playingChanged().subscribe(() -> { SwingUtilities.invokeLater(() -> { if (this.disposed || project.get() != scope) return; reloadProfile(); 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 432006535..69f278b7f 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 @@ -120,7 +120,7 @@ public MainWindow(Supplier project, DebuggerSessionController debu setAutoRequestFocus(false); // Tabs draw items; a new icon snapshot draws them again, shown or not. - itemIcons.addListener(this.editorTabs::refreshTabIdentities); + itemIcons.changed().subscribe(this.editorTabs::refreshTabIdentities); this.fileTreeView = new FileTreeView(project, target -> navigation().navigate(target)); this.navigationService = new NavigationService(this, this.editorTabs, this.fileTreeView, project.get(), this::editorContext); this.scriptFileActions = new ScriptFileActions(this, editorTabs, fileTreeView, this::editorContext); diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/views/SearchEverywherePopup.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/views/SearchEverywherePopup.java index bdda0b6c4..9c21f37ac 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/views/SearchEverywherePopup.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/views/SearchEverywherePopup.java @@ -123,7 +123,7 @@ public void actionPerformed(ActionEvent event) { private final ModuleFilterPopup moduleFilterPopup; private final Consumer themeListener = theme -> applyTheme(); - private final Consumer indexStatusListener = this::indexStatusChanged; + private Runnable stopFollowingIndex = () -> { }; private void indexStatusChanged(RuntimeIndexService.Status status) { SwingUtilities.invokeLater(() -> { @@ -205,7 +205,8 @@ private void indexStatusChanged(RuntimeIndexService.Status status) { JComponent.WHEN_IN_FOCUSED_WINDOW); } - indexLoader.addStatusListener(this.indexStatusListener); + this.stopFollowingIndex = indexLoader.statusChanged().subscribe(() -> indexStatusChanged(indexLoader.status())); + indexStatusChanged(indexLoader.status()); ((JPanel) getContentPane()).setBorder(PopupChrome.border()); setUndecorated(true); @@ -255,7 +256,7 @@ void catalogChanged() { public void dispose() { this.catalogIcons.dispose(); this.modLogos.clear(); - indexLoader.removeStatusListener(this.indexStatusListener); + this.stopFollowingIndex.run(); ThemeManager.removeThemeChangeListener(this.themeListener); this.searchGeneration.incrementAndGet(); if (this.pendingSearch != null) { diff --git a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/RuntimeInstallationTest.java b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/RuntimeInstallationTest.java index c94bba8e8..b7b7bf4a9 100644 --- a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/RuntimeInstallationTest.java +++ b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/RuntimeInstallationTest.java @@ -1,5 +1,6 @@ package com.github.minecraft_ta.totalDebugCompanion; +import com.github.minecraft_ta.totalDebugCompanion.runtime.IndexStatuses; import com.github.minecraft_ta.totalDebugCompanion.testui.UiTest; import java.util.concurrent.CompletableFuture; import java.util.concurrent.atomic.AtomicReference; @@ -111,12 +112,12 @@ private static void awaitInitialRestore(CompanionApplication app) throws Excepti Consumer listener = status -> { if (!status.active() && status.phase() != RuntimeIndexService.Phase.WAITING) finished.complete(status); }; - service.addStatusListener(listener); + Runnable stop = IndexStatuses.follow(service, listener); try { var status = finished.get(10, TimeUnit.SECONDS); assertEquals(RuntimeIndexService.Phase.EMPTY, status.phase(), status.detail()); } finally { - service.removeStatusListener(listener); + stop.run(); } } 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 1556518cf..eb27e842e 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 @@ -83,40 +83,40 @@ private record Allowed(int times, String why) { Map.entry("mcp/CodeModeJobService.java", new Allowed(2, "the MCP job service")), 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("catalog/ConfigChanges.java", new Allowed(2, "the project's write queue, which the pipeline takes over in PR 3")), - Map.entry("storage/JsonStateWriter.java", new Allowed(2, "moves onto Workers in PR 3")), - Map.entry("ui/components/catalog/TextureThumbnails.java", new Allowed(2, "moves onto Workers in PR 3")), - Map.entry("ui/components/catalog/ModLogoIcons.java", new Allowed(2, "moves onto Workers in PR 3")), - Map.entry("ui/components/editors/ResourceViewPanel.java", new Allowed(2, "moves onto Workers in PR 3")), - Map.entry("pack/ExternalEdits.java", new Allowed(3, "becomes an adoption in PR 5")), - Map.entry("util/FileUtils.java", new Allowed(1, "its watcher becomes a FileWatch follower in PR 5")), + Map.entry("catalog/ConfigChanges.java", new Allowed(2, "the project's write queue, which the pipeline takes over in PR 8")), + Map.entry("storage/JsonStateWriter.java", new Allowed(2, "moves onto Workers in PR 8")), + Map.entry("ui/components/catalog/TextureThumbnails.java", new Allowed(2, "moves onto Workers in PR 8")), + Map.entry("ui/components/catalog/ModLogoIcons.java", new Allowed(2, "moves onto Workers in PR 8")), + Map.entry("ui/components/editors/ResourceViewPanel.java", new Allowed(2, "moves onto Workers in PR 8")), + Map.entry("pack/ExternalEdits.java", new Allowed(3, "becomes an adoption in PR 6")), + Map.entry("util/FileUtils.java", new Allowed(1, "its watcher becomes a FileWatch follower in PR 6")), Map.entry("util/Workers.java", new Allowed(6, "the file work, the owners' strands and the timer everything shares")), Map.entry("util/FileWatch.java", new Allowed(1, "the one watcher of the folders Companion follows"))); - // All move onto Workers' file work in PR 3. + // All move onto Workers' file work in PR 8. private static final Map SHARED_POOL = Map.ofEntries( - Map.entry("CompanionApplication.java", new Allowed(2, "moves onto Workers in PR 3")), - Map.entry("inspection/ItemIconService.java", new Allowed(1, "moves onto Workers in PR 3")), - Map.entry("ui/components/inspection/DataView.java", new Allowed(1, "moves onto Workers in PR 3")), - Map.entry("inspection/InspectionSession.java", new Allowed(2, "moves onto Workers in PR 3")), - Map.entry("model/CodeView.java", new Allowed(1, "moves onto Workers in PR 3")), - Map.entry("navigation/NavigationService.java", new Allowed(2, "moves onto Workers in PR 3")), - Map.entry("pack/ExternalEdits.java", new Allowed(1, "moves onto Workers in PR 3")), - Map.entry("script/SnippetExpressionSupport.java", new Allowed(2, "moves onto Workers in PR 3")), - Map.entry("ui/components/PageLoader.java", new Allowed(1, "reads on Workers' file work from PR 3")), - Map.entry("ui/components/catalog/ModPanel.java", new Allowed(1, "moves onto Workers in PR 3")), - Map.entry("ui/components/editors/PackResourceEditor.java", new Allowed(2, "its saves move onto Workers in PR 3")), - Map.entry("ui/components/editors/ResourceTextEditor.java", new Allowed(1, "moves onto Workers in PR 3")), - Map.entry("ui/components/editors/ScriptPanel.java", new Allowed(3, "moves onto Workers in PR 3")), - Map.entry("ui/components/global/NotificationWidget.java", new Allowed(1, "moves onto Workers in PR 3")), - Map.entry("ui/components/global/ProjectSelector.java", new Allowed(1, "moves onto Workers in PR 3")), - Map.entry("ui/components/treeView/FileTreeView.java", new Allowed(1, "moves onto Workers in PR 3")), - Map.entry("ui/components/treeView/ScriptFileActions.java", new Allowed(1, "moves onto Workers in PR 3")), - Map.entry("ui/components/treeView/lazyFileTree/LazyFileJTree.java", new Allowed(1, "moves onto Workers in PR 3")), - Map.entry("ui/views/PrismInstancePicker.java", new Allowed(1, "moves onto Workers in PR 3")), - Map.entry("ui/views/debugger/BreakpointsWindow.java", new Allowed(2, "moves onto Workers in PR 3")), - Map.entry("ui/views/debugger/DebuggerInspector.java", new Allowed(1, "moves onto Workers in PR 3")), - Map.entry("util/FileUtils.java", new Allowed(1, "moves onto Workers in PR 3"))); + Map.entry("CompanionApplication.java", new Allowed(2, "moves onto Workers in PR 8")), + Map.entry("inspection/ItemIconService.java", new Allowed(1, "moves onto Workers in PR 8")), + Map.entry("ui/components/inspection/DataView.java", new Allowed(1, "moves onto Workers in PR 8")), + Map.entry("inspection/InspectionSession.java", new Allowed(2, "moves onto Workers in PR 8")), + Map.entry("model/CodeView.java", new Allowed(1, "moves onto Workers in PR 8")), + Map.entry("navigation/NavigationService.java", new Allowed(2, "moves onto Workers in PR 8")), + Map.entry("pack/ExternalEdits.java", new Allowed(1, "moves onto Workers in PR 8")), + Map.entry("script/SnippetExpressionSupport.java", new Allowed(2, "moves onto Workers in PR 8")), + Map.entry("ui/components/PageLoader.java", new Allowed(1, "reads on Workers' file work from PR 7")), + Map.entry("ui/components/catalog/ModPanel.java", new Allowed(1, "moves onto Workers in PR 8")), + Map.entry("ui/components/editors/PackResourceEditor.java", new Allowed(2, "its saves move onto Workers in PR 8")), + Map.entry("ui/components/editors/ResourceTextEditor.java", new Allowed(1, "moves onto Workers in PR 8")), + Map.entry("ui/components/editors/ScriptPanel.java", new Allowed(3, "moves onto Workers in PR 8")), + Map.entry("ui/components/global/NotificationWidget.java", new Allowed(1, "moves onto Workers in PR 8")), + Map.entry("ui/components/global/ProjectSelector.java", new Allowed(1, "moves onto Workers in PR 8")), + Map.entry("ui/components/treeView/FileTreeView.java", new Allowed(1, "moves onto Workers in PR 8")), + Map.entry("ui/components/treeView/ScriptFileActions.java", new Allowed(1, "moves onto Workers in PR 8")), + Map.entry("ui/components/treeView/lazyFileTree/LazyFileJTree.java", new Allowed(1, "moves onto Workers in PR 8")), + Map.entry("ui/views/PrismInstancePicker.java", new Allowed(1, "moves onto Workers in PR 8")), + Map.entry("ui/views/debugger/BreakpointsWindow.java", new Allowed(2, "moves onto Workers in PR 8")), + Map.entry("ui/views/debugger/DebuggerInspector.java", new Allowed(1, "moves onto Workers in PR 8")), + Map.entry("util/FileUtils.java", new Allowed(1, "moves onto Workers in PR 8"))); // Signal itself; events inside a subsystem or a control, which are not state (section 1); owners not on signals yet. private static final Map LISTENER_LISTS = Map.ofEntries( @@ -134,16 +134,13 @@ private record Allowed(int times, String why) { Map.entry("ui/components/inspection/DataView.java", new Allowed(1, "speed search, an event")), Map.entry("ui/components/treeView/lazyFileTree/LazyFileJTree.java", new Allowed(1, "a double click, an event")), Map.entry("ui/theme/ThemeManager.java", new Allowed(1, "the theme, which stays as it is")), - Map.entry("game/GameLocation.java", new Allowed(1, "moves onto signals in PR 3")), - Map.entry("inspection/ItemIconService.java", new Allowed(1, "moves onto signals in PR 3")), - Map.entry("runtime/RuntimeIndexService.java", new Allowed(1, "moves onto signals in PR 3")), - Map.entry("util/FileUtils.java", new Allowed(1, "becomes FileWatch in PR 4")), - Map.entry("pack/ExternalEdits.java", new Allowed(1, "becomes an adoption in PR 5"))); + Map.entry("util/FileUtils.java", new Allowed(1, "becomes FileWatch in PR 6")), + Map.entry("pack/ExternalEdits.java", new Allowed(1, "becomes an adoption in PR 6"))); 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/FileUtils.java", new Allowed(1, "becomes a FileWatch follower in PR 5")), - Map.entry("pack/ExternalEdits.java", new Allowed(1, "becomes an adoption in PR 5"))); + Map.entry("util/FileUtils.java", new Allowed(1, "becomes a FileWatch follower in PR 6")), + Map.entry("pack/ExternalEdits.java", new Allowed(1, "becomes an adoption in PR 6"))); @BeforeAll static void scan() throws IOException { diff --git a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/game/GameStateTest.java b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/game/GameStateTest.java index 1b03f9b16..3dae74611 100644 --- a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/game/GameStateTest.java +++ b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/game/GameStateTest.java @@ -146,12 +146,11 @@ void whatTheGameToldBeforeItsConnectionWasEstablishedCounts() { GameLocation location = new GameLocation(GAME, new Files(true, Set.of(WORLD), null)); location.process(7); location.playing(new PlayingPayload.Singleplayer(WORLD.toString())); - List heard = new ArrayList<>(); - location.addListener(heard::add); + List heard = heard(location); location.connected(SEND); - assertEquals(List.of(GameLocation.Change.CONNECTED, GameLocation.Change.PROCESS, GameLocation.Change.PLAYING), heard, + assertEquals(List.of("connected", "process", "playing"), heard, "what the game told before is heard as told now"); location.process(7); location.playing(new PlayingPayload.Singleplayer(WORLD.toString())); @@ -166,11 +165,19 @@ void whatTheGameToldBeforeItsConnectionWasEstablishedCounts() { assertInstanceOf(Access.Refused.class, location.read().world(WORLD, "change its rules")); } + /** What {@code location}'s signals tell, in order: a connection change as connected or disconnected. */ + private static List heard(GameLocation location) { + List heard = new ArrayList<>(); + location.connectionChanged().subscribe(() -> heard.add(location.connection() != null ? "connected" : "disconnected")); + location.processChanged().subscribe(() -> heard.add("process")); + location.playingChanged().subscribe(() -> heard.add("playing")); + return heard; + } + @Test - void listenersHearEachChangeOnce() { + void followersHearEachChangeOnce() { GameLocation location = new GameLocation(GAME, new Files(false, Set.of(), null)); - List heard = new ArrayList<>(); - location.addListener(heard::add); + List heard = heard(location); location.process(7); location.playing(new PlayingPayload.Menu()); @@ -182,8 +189,7 @@ void listenersHearEachChangeOnce() { location.playing(new PlayingPayload.Menu()); location.disconnected(); location.disconnected(); - assertEquals(List.of(GameLocation.Change.CONNECTED, GameLocation.Change.PROCESS, GameLocation.Change.PLAYING, - GameLocation.Change.DISCONNECTED), heard); + assertEquals(List.of("connected", "process", "playing", "disconnected"), heard); assertEquals(0, location.process()); assertNull(location.connection()); assertFalse(location.read().running()); diff --git a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/inspection/ItemIconServiceTest.java b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/inspection/ItemIconServiceTest.java index c60e86c62..deca0953c 100644 --- a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/inspection/ItemIconServiceTest.java +++ b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/inspection/ItemIconServiceTest.java @@ -8,9 +8,12 @@ import java.util.ArrayList; import java.util.List; import java.util.UUID; +import java.util.concurrent.CopyOnWriteArrayList; import java.util.zip.ZipEntry; import java.util.zip.ZipOutputStream; +import javax.swing.SwingUtilities; + import static org.junit.jupiter.api.Assertions.assertEquals; class ItemIconServiceTest { @@ -45,6 +48,20 @@ void anAnnouncedSnapshotSupersedesARestoreStillReading(@TempDir Path directory) } } + @Test + void viewsAreToldOnceOnTheSwingThreadOnlyWhenTheSnapshotChanged(@TempDir Path directory) throws Exception { + Path archive = archive(directory.resolve("snapshot")); + List told = new CopyOnWriteArrayList<>(); + try (ItemIconService icons = new ItemIconService()) { + icons.changed().subscribe(() -> told.add(SwingUtilities.isEventDispatchThread())); + icons.accept(archive); + icons.accept(archive); + SwingUtilities.invokeAndWait(() -> { }); + + assertEquals(List.of(true), told, "the same snapshot again tells nothing"); + } + } + private static Path archive(Path directory) throws Exception { Files.createDirectories(directory); Path archive = directory.resolve(UUID.randomUUID() + ".zip").toAbsolutePath().normalize(); diff --git a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/runtime/IndexStatuses.java b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/runtime/IndexStatuses.java new file mode 100644 index 000000000..04d262451 --- /dev/null +++ b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/runtime/IndexStatuses.java @@ -0,0 +1,16 @@ +package com.github.minecraft_ta.totalDebugCompanion.runtime; + +import java.util.function.Consumer; + +/** Hands a test each status of an index as it is published, the current one first. */ +public final class IndexStatuses { + private IndexStatuses() { + } + + /** Tells {@code told} the current status and each one after; returns what stops it. */ + public static Runnable follow(RuntimeIndexService service, Consumer told) { + Runnable stop = service.statusChanged().subscribe(() -> told.accept(service.status())); + told.accept(service.status()); + return stop; + } +} diff --git a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/runtime/LocalIndexTest.java b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/runtime/LocalIndexTest.java index 461550b60..38c49cb4b 100644 --- a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/runtime/LocalIndexTest.java +++ b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/runtime/LocalIndexTest.java @@ -44,7 +44,7 @@ class LocalIndexTest { var ready = new CompletableFuture(); var retry = new AtomicBoolean(true); try (var service = new RuntimeIndexService(new Object(), ready::complete)) { - service.addStatusListener(status -> { + IndexStatuses.follow(service, status -> { if (status.phase() != RuntimeIndexService.Phase.FAILED) return; if (!retry.getAndSet(false)) { ready.completeExceptionally(status.failure()); return; } try { @@ -147,7 +147,7 @@ class LocalIndexTest { } catch (Exception exception) { index.close(); throw new AssertionError(exception); } return index; })) { - service.addStatusListener(status -> { + IndexStatuses.follow(service, status -> { if (status.phase() == RuntimeIndexService.Phase.FAILED) failure.complete(status); }); service.restore(paths.home(), game); @@ -275,7 +275,7 @@ void failedRuntimePreparationDoesNotCancelALoadingLocalIndex(boolean beforeWorke catch (InterruptedException failure) { Thread.currentThread().interrupt(); throw new AssertionError(failure); } return ClassIndex.fromFile(file); })) { - service.addStatusListener(status -> { + IndexStatuses.follow(service, status -> { if (status.phase() == RuntimeIndexService.Phase.READY) readyStatus.complete(status); }); synchronized (lifecycle) { @@ -297,7 +297,7 @@ void failedRuntimePreparationDoesNotCancelALoadingLocalIndex(boolean beforeWorke private RuntimeIndexService.ReadySnapshot open() throws Exception { var ready = new CompletableFuture(); try (var service = new RuntimeIndexService(new Object(), ready::complete)) { - service.addStatusListener(status -> { + IndexStatuses.follow(service, status -> { if (status.phase() == RuntimeIndexService.Phase.FAILED) ready.completeExceptionally(status.failure()); }); var paths = InstancePaths.forGame(game); diff --git a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/runtime/RuntimeIndexRecoveryTest.java b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/runtime/RuntimeIndexRecoveryTest.java index 6e1a1894c..ad872fdcf 100644 --- a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/runtime/RuntimeIndexRecoveryTest.java +++ b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/runtime/RuntimeIndexRecoveryTest.java @@ -143,7 +143,7 @@ private static void assertRebuilt(InstancePaths paths, boolean restore) throws E private static void await(RuntimeIndexService service, InstancePaths paths, boolean restore) throws Exception { CountDownLatch settled = new CountDownLatch(1); - service.addStatusListener(status -> { + IndexStatuses.follow(service, status -> { if (status.phase() == RuntimeIndexService.Phase.READY || status.phase() == RuntimeIndexService.Phase.FAILED) { settled.countDown(); } diff --git a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/runtime/RuntimeIndexServiceTest.java b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/runtime/RuntimeIndexServiceTest.java index cf75aec6c..af64d9bc7 100644 --- a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/runtime/RuntimeIndexServiceTest.java +++ b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/runtime/RuntimeIndexServiceTest.java @@ -32,7 +32,7 @@ class RuntimeIndexServiceTest { @Test void forcedRebuildReportsMissingInventoryInsteadOfWaitingForIt() throws Exception { var failed = new CountDownLatch(1); try (var service = new RuntimeIndexService(new Object(), ignored -> { throw new AssertionError("No inventory"); })) { - service.addStatusListener(status -> { if (status.phase() == RuntimeIndexService.Phase.FAILED) failed.countDown(); }); + IndexStatuses.follow(service, status -> { if (status.phase() == RuntimeIndexService.Phase.FAILED) failed.countDown(); }); service.rebuild(temporaryDirectory, null); assertTrue(failed.await(5, TimeUnit.SECONDS)); assertEquals(RuntimeIndexService.Phase.FAILED, service.status().phase()); @@ -61,7 +61,7 @@ class RuntimeIndexServiceTest { else assertNotNull(snapshot.index().findClass(RuntimeInventoryTest.class.getName())); } })) { - service.addStatusListener(status -> { + IndexStatuses.follow(service, status -> { if (status.phase() == RuntimeIndexService.Phase.FAILED) { loaded.countDown(); rebuilt.countDown(); } if (status.phase() == RuntimeIndexService.Phase.READY) { if (status.metrics().rebuilt()) rebuilt.countDown(); else loaded.countDown(); @@ -155,7 +155,7 @@ void reusesConfirmedInventoryButRevalidatesItAfterLivePreparation() throws Excep AtomicBoolean inventoryAccepted = new AtomicBoolean(); CountDownLatch settled = new CountDownLatch(1); - service.addStatusListener(status -> { + IndexStatuses.follow(service, status -> { if (inventoryAccepted.get() && (status.phase() == RuntimeIndexService.Phase.READY || status.phase() == RuntimeIndexService.Phase.FAILED)) { @@ -172,7 +172,7 @@ void reusesConfirmedInventoryButRevalidatesItAfterLivePreparation() throws Excep var metrics = service.status().metrics(); assertNotNull(metrics); var reconfirmed = new CountDownLatch(1); - service.addStatusListener(status -> { + IndexStatuses.follow(service, status -> { if (status.phase() == RuntimeIndexService.Phase.READY && installations.get() == 2) reconfirmed.countDown(); }); service.waiting("Preparing inventory after reconnect"); @@ -203,7 +203,7 @@ void rebuildsOneIndexInPlaceAndRestoresOnlyTheCurrentInventory() throws Exceptio .write(paths.inventory()); try (RuntimeIndexService service = new RuntimeIndexService(new Object(), snapshots::add)) { CountDownLatch settled = new CountDownLatch(1); - service.addStatusListener(status -> { + IndexStatuses.follow(service, status -> { if (status.phase() == RuntimeIndexService.Phase.READY || status.phase() == RuntimeIndexService.Phase.FAILED) { settled.countDown(); } @@ -361,7 +361,7 @@ void throwingReadyHandlerLeavesTheLoaderResponsibleForDisposal() throws Exceptio candidate.set(snapshot); throw new IllegalStateException("Rejected installation"); })) { - service.addStatusListener(status -> { if (status.phase() == RuntimeIndexService.Phase.FAILED) failed.countDown(); }); + IndexStatuses.follow(service, status -> { if (status.phase() == RuntimeIndexService.Phase.FAILED) failed.countDown(); }); service.restore(paths.home()); assertTrue(failed.await(5, TimeUnit.SECONDS)); assertTrue(candidate.get().index().isDestroyed()); @@ -375,7 +375,7 @@ void acceptedSnapshotOutlivesItsLoader() throws Exception { var ready = new CountDownLatch(1); try { try (var service = new RuntimeIndexService(new Object(), candidate::set)) { - service.addStatusListener(status -> { if (status.phase() == RuntimeIndexService.Phase.READY) ready.countDown(); }); + IndexStatuses.follow(service, status -> { if (status.phase() == RuntimeIndexService.Phase.READY) ready.countDown(); }); service.restore(paths.home()); assertTrue(ready.await(5, TimeUnit.SECONDS)); } diff --git a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/ui/views/SearchEverywherePopupTest.java b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/ui/views/SearchEverywherePopupTest.java index 7c2fa5c02..aa9f94845 100644 --- a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/ui/views/SearchEverywherePopupTest.java +++ b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/ui/views/SearchEverywherePopupTest.java @@ -1,5 +1,6 @@ package com.github.minecraft_ta.totalDebugCompanion.ui.views; +import com.github.minecraft_ta.totalDebugCompanion.util.Signal; import com.github.minecraft_ta.totalDebugCompanion.inspection.ItemIconService; import com.github.minecraft_ta.totalDebugCompanion.bytecode.RuntimeSnapshotBytecodeSource; import com.github.minecraft_ta.totalDebugCompanion.runtime.RuntimeBinding; @@ -107,7 +108,7 @@ void tabCyclesSearchCategoriesInBothDirections() throws Exception { @Test void disposedPopupUnsubscribesAndIgnoresQueuedRuntimeStatus() throws Exception { - var listenersField = RuntimeIndexService.class.getDeclaredField("listeners"); + var listenersField = Signal.class.getDeclaredField("listeners"); listenersField.setAccessible(true); var messageField = SearchEverywherePopup.class.getDeclaredField("messageLabel"); messageField.setAccessible(true); @@ -117,7 +118,7 @@ void disposedPopupUnsubscribesAndIgnoresQueuedRuntimeStatus() throws Exception { SwingUtilities.invokeAndWait(() -> { var popup = new SearchEverywherePopup(null, service, () -> null, () -> null, () -> null, new ItemIconService(), target -> {}); try { - assertEquals(1, ((Collection) listenersField.get(service)).size()); + assertEquals(1, ((Collection) listenersField.get(service.statusChanged())).size()); label.set((JLabel) messageField.get(popup)); label.get().setText("unchanged after disposal"); service.waiting("queued before disposal"); @@ -125,7 +126,7 @@ void disposedPopupUnsubscribesAndIgnoresQueuedRuntimeStatus() throws Exception { finally { popup.dispose(); } }); SwingUtilities.invokeAndWait(() -> assertEquals("unchanged after disposal", label.get().getText())); - assertTrue(((Collection) listenersField.get(service)).isEmpty()); + assertTrue(((Collection) listenersField.get(service.statusChanged())).isEmpty()); service.waiting("sent after disposal"); SwingUtilities.invokeAndWait(() -> assertEquals("unchanged after disposal", label.get().getText())); } diff --git a/docs/SYSTEMS.md b/docs/SYSTEMS.md index 04ccb5d2b..2d89b7089 100644 --- a/docs/SYSTEMS.md +++ b/docs/SYSTEMS.md @@ -70,8 +70,8 @@ Owners: | `PackCatalogService` | catalog | `addListener` | | `GamePacks` | resource packs, datapacks | `addListener(side)`, `addResourcePackListener`, `addDatapackListener` | | `ChangeRecord` | changes | `addListener` | -| `GameLocation` | connection (with the process), playing | `addListener(Consumer)` | -| `CurrentWorld` (new) | world | `WorldReadings`, which is deleted: it reads the current world's `level.dat` itself | +| `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` | | `GameLogs` | the listed logs and crash reports | the Logs page's and the tree's own listing | | `ItemIconService` | icons | `addListener` | @@ -195,17 +195,18 @@ The test lists each exception with its reason. Adding one is a decision recorded ## Order of work -PRs on 1.21.1, stacked, each reviewed until clean. A shared mechanism comes with its first users; the PRs that carry risk are kept small, the ones that repeat a proven pattern move many users at once. A moved feature keeps no part of its old way. Until the last user of an old way has moved, the old way stays for those not moved yet, as `PageLoader`'s modes do; the PR that moves the last user deletes it. Decided on 2026-09-30 over a finer split of about 22 PRs, whose extra review rounds bought no safety for the mechanical moves. The files came before the remaining owners the same day: they carry most of the races the reviews found, and they are `Workers`' first users. +PRs on 1.21.1, stacked, each reviewed until clean. A shared mechanism comes with its first users; the PRs that carry risk are kept small, the ones that repeat a proven pattern move many users at once. A moved feature keeps no part of its old way. Until the last user of an old way has moved, the old way stays for those not moved yet, as `PageLoader`'s modes do; the PR that moves the last user deletes it. Decided on 2026-09-30 over a finer split of about 22 PRs, whose extra review rounds bought no safety for the mechanical moves. The files came before the remaining owners the same day: they carry most of the races the reviews found, and they are `Workers`' first users. The owners still telling through their own listeners come before the pages, which can follow only signals. | PR | Content | Deletes | |---|---|---| | 1 (#112) | The slice: `Signal` and the new `PageLoader` (`page`, `follows`, `hold`), with `Tables.keepingSelection`; its first users the Key bindings page, which only shows, and the resource editor, which edits, with the owners they follow on signals (catalog, key assignments, change record, packs, resource edits), key assignments read again after Companion's own write; the architecture test with every exception of today listed | the two pages' own subscriptions, constructor reads, navigation refreshes and selection keeping, `KeyBindingControl.addAssignmentListener`, `PackResourceEditor`'s read and write counters and follow flags | | 2 (#113) | Game messages registered by their owners on the connection, in Companion and the mod | `CompanionSession.Listener`'s methods for the project's messages, their relay in `CompanionApplication`, the mod's setters and repeated guards | -| 3 | `Workers` and `Strand`, `FileWatch` and `FileReading`, with `KeyAssignments` as first user | `KeyAssignments`' watcher, scheduler and counters | -| 4 | The current world as a `FileReading` that follows `playing`, with the World page and the tree | `WorldReadings`, the World page's read whenever shown | -| 5 | The remaining readings (`GameLogs`, the configuration folders, `saves` and `resourcepacks`) with their pages; `FileUtils`' watcher onto `FileWatch`; `ExternalEdits` as an adoption | the watchers and schedulers of `FileUtils` and `ExternalEdits`, the `readsWhenShown` mode | -| 6 | All remaining pages, the Project tree and the tab strip; no file checks on the Swing thread | `ShownUpdates`, the old modes, the pages' own subscriptions, the reads in constructors, the navigation refreshes, the `CompanionUi` relays, `ChangeRecord.observed` from page reads | -| 7 | The remaining owners on signals; the current project as state; connection numbers for waiting requests; the pipeline owning the write queue; the remaining executors onto `Workers` | the listener lists, `ConfigChanges`' executor, the scope checks, the UI classes' executors | +| 3 (#114) | `Workers` and `Strand`, `FileWatch` and `FileReading`, with `KeyAssignments` as first user | `KeyAssignments`' watcher, scheduler and counters | +| 4 (#115) | The current world as a `FileReading` that follows `playing`, with the World page and the tree | `WorldReadings`, the World page's read whenever shown | +| 5 | The owners still telling through their own listeners on signals: `GameLocation`, `ItemIconService`, `RuntimeIndexService` | `GameLocation.Change` and the three listener lists | +| 6 | The remaining readings (`GameLogs`, the configuration folders, `saves` and `resourcepacks`) with their pages; `FileUtils`' watcher onto `FileWatch`; `ExternalEdits` as an adoption | the watchers and schedulers of `FileUtils` and `ExternalEdits`, the `readsWhenShown` mode | +| 7 | All remaining pages, the Project tree and the tab strip; no file checks on the Swing thread | `ShownUpdates`, the old modes, the pages' own subscriptions, the reads in constructors, the navigation refreshes, the `CompanionUi` relays, `ChangeRecord.observed` from page reads | +| 8 | The current project as state; connection numbers for waiting requests; the pipeline owning the write queue; the remaining executors onto `Workers` | `ConfigChanges`' executor, the scope checks, the UI classes' executors | 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.