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
+ * 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 extends TreeItem> 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