From f935fdc89c0df0db6ec78e80af8574092a34ae14 Mon Sep 17 00:00:00 2001 From: Pelotrio <45769595+Pelotrio@users.noreply.github.com> Date: Fri, 11 Sep 2026 17:29:58 +0200 Subject: [PATCH 1/7] Own Companion services in an application instance Took 2 minutes Took 2 minutes --- companion/build.gradle | 1 + .../totalDebugCompanion/CompanionApp.java | 949 +----------------- .../CompanionApplication.java | 842 ++++++++++++++++ .../totalDebugCompanion/jdt/JDTHacks.java | 23 +- .../mcp/CompanionMcpServer.java | 25 +- .../mcp/DebuggerMcpService.java | 8 +- .../totalDebugCompanion/ui/CompanionUi.java | 23 + .../ui/views/MainWindow.java | 13 +- .../CompanionAppDataDirectoryTest.java | 2 +- .../JdtTestEnvironment.java | 18 + .../ProjectSwitchLifecycleTest.java | 197 ++-- .../RuntimeInstallationTest.java | 102 +- .../ScriptPanelDisposalTest.java | 84 +- .../totalDebugCompanion/UiDevHarness.java | 182 ++-- .../totalDebugCompanion/UiScenarioDriver.java | 169 ++-- .../jdt/JdtStorageTest.java | 4 +- .../mcp/CompanionMcpServerTest.java | 3 +- .../org.junit.jupiter.api.extension.Extension | 1 + 18 files changed, 1292 insertions(+), 1354 deletions(-) create mode 100644 companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/CompanionApplication.java create mode 100644 companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/CompanionUi.java create mode 100644 companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/JdtTestEnvironment.java create mode 100644 companion/src/test/resources/META-INF/services/org.junit.jupiter.api.extension.Extension diff --git a/companion/build.gradle b/companion/build.gradle index 9ba4952f..1e8abc4d 100644 --- a/companion/build.gradle +++ b/companion/build.gradle @@ -105,6 +105,7 @@ tasks.named('assemble') { tasks.named('test') { useJUnitPlatform() + systemProperty 'junit.jupiter.extensions.autodetection.enabled', 'true' systemProperty 'totaldebug.testClasspath', sourceSets.test.runtimeClasspath.asPath } diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/CompanionApp.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/CompanionApp.java index 7d91b013..3d387e75 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/CompanionApp.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/CompanionApp.java @@ -1,71 +1,23 @@ package com.github.minecraft_ta.totalDebugCompanion; -import com.github.minecraft_ta.totaldebug.storage.AppPaths; -import com.github.minecraft_ta.totalDebugCompanion.project.ProjectScope; -import com.github.minecraft_ta.totalDebugCompanion.project.ProjectScope.PendingNavigation; -import com.github.minecraft_ta.totaldebug.storage.InstancePaths; import com.github.minecraft_ta.totaldebug.storage.AtomicFiles; import com.github.minecraft_ta.totaldebug.storage.RuntimePhase; -import com.github.minecraft_ta.totalDebugCompanion.storage.InstanceState; import com.formdev.flatlaf.FlatLaf; import com.formdev.flatlaf.fonts.inter.FlatInterFont; import com.formdev.flatlaf.fonts.jetbrains_mono.FlatJetBrainsMonoFont; -import com.github.minecraft_ta.totalDebugCompanion.jdt.CompanionClassIndex; -import com.github.minecraft_ta.totalDebugCompanion.jdt.JdtConfiguration; -import com.github.minecraft_ta.totalDebugCompanion.jdt.impls.CompilationUnitImpl; import com.github.minecraft_ta.totalDebugCompanion.jdt.semanticHighlighting.CustomJavaTokenMaker; -import com.github.minecraft_ta.totalDebugCompanion.bytecode.RuntimeSnapshotBytecodeSource; -import com.github.minecraft_ta.totalDebugCompanion.decompile.CompanionDecompilationService; -import com.github.minecraft_ta.totalDebugCompanion.debugger.DebugTargetDescriptor; -import com.github.minecraft_ta.totalDebugCompanion.debugger.DebugEngine; -import com.github.minecraft_ta.totalDebugCompanion.debugger.DebuggerSessionController; -import com.github.minecraft_ta.totalDebugCompanion.mcp.CodeModeJobService; -import com.github.minecraft_ta.totalDebugCompanion.script.ScriptCompilationService; -import com.github.minecraft_ta.totalDebugCompanion.script.ScriptExecutionService; -import com.github.minecraft_ta.totaldebug.protocol.scnet.OpenClassMessage; -import com.github.minecraft_ta.totalDebugCompanion.script.ScriptCompilationService.CompilationResult; -import com.github.minecraft_ta.totaldebug.protocol.scnet.StopScriptMessage; -import com.github.minecraft_ta.totalDebugCompanion.mcp.CompanionMcpServer; -import com.github.minecraft_ta.totaldebug.protocol.scnet.DebugTargetMessage; -import com.github.minecraft_ta.totaldebug.protocol.scnet.RuntimeInventoryMessage; -import com.github.minecraft_ta.totaldebug.protocol.scnet.ServerManifestMessage; -import com.github.minecraft_ta.totalDebugCompanion.model.ServiceStatus; -import com.github.minecraft_ta.totalDebugCompanion.navigation.NavigationService; -import com.github.minecraft_ta.totalDebugCompanion.navigation.NavigationTarget; -import com.github.minecraft_ta.totalDebugCompanion.navigation.NavigationTargets; -import com.github.minecraft_ta.totalDebugCompanion.runtime.RuntimeIndexService; -import com.github.minecraft_ta.totalDebugCompanion.runtime.RuntimeBinding; -import com.github.minecraft_ta.totaldebug.storage.RuntimeInventory; -import com.github.minecraft_ta.totalDebugCompanion.runtime.RuntimeSourceCatalog; -import com.github.minecraft_ta.totalDebugCompanion.search.insight.CodeInsightService; -import com.github.minecraft_ta.totalDebugCompanion.search.reference.ReferenceSearchService; import com.github.minecraft_ta.totalDebugCompanion.session.CompanionLaunchConfiguration; -import com.github.minecraft_ta.totalDebugCompanion.session.CompanionProfile; -import com.github.minecraft_ta.totalDebugCompanion.session.ProjectRegistry; -import com.github.minecraft_ta.totalDebugCompanion.session.CompanionSession; import com.github.minecraft_ta.totalDebugCompanion.session.CompanionTimeouts; import com.github.minecraft_ta.totalDebugCompanion.resource.FileTypeResolver; import com.github.minecraft_ta.totalDebugCompanion.syntax.ManifestTokenMaker; import com.github.minecraft_ta.totalDebugCompanion.syntax.TomlTokenMaker; import com.github.minecraft_ta.totalDebugCompanion.ui.theme.ThemeManager; -import com.github.minecraft_ta.totalDebugCompanion.ui.views.MainWindow; -import com.github.minecraft_ta.totalDebugCompanion.util.UIUtils; -import com.github.tth05.scnet.message.AbstractMessage; -import com.github.tth05.jindex.ClassIndex; -import org.eclipse.jdt.core.dom.ASTParser; -import org.eclipse.jdt.core.dom.CompilationUnit; import org.fife.ui.rsyntaxtextarea.AbstractTokenMakerFactory; import org.fife.ui.rsyntaxtextarea.RSyntaxTextArea; import org.fife.ui.rsyntaxtextarea.TokenMakerFactory; import javax.swing.JDialog; import javax.swing.JFrame; -import javax.swing.SwingUtilities; -import javax.swing.JOptionPane; -import javax.swing.ToolTipManager; -import java.awt.Window; import java.io.IOException; -import java.lang.reflect.InvocationTargetException; -import java.net.URI; import java.nio.channels.FileChannel; import java.nio.channels.FileLock; import java.nio.channels.OverlappingFileLockException; @@ -74,37 +26,12 @@ import java.nio.file.StandardOpenOption; import java.security.SecureRandom; import java.util.HexFormat; -import java.util.ArrayList; -import java.util.List; import java.util.Map; import java.util.Objects; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.CountDownLatch; public final class CompanionApp { private static final SecureRandom TOKEN_RANDOM = new SecureRandom(); - private static final CountDownLatch EXIT = new CountDownLatch(1); - - private static ScriptExecutionService scriptExecutions; - private static CompanionSession session; - private static CompanionLaunchConfiguration launchConfiguration; - private static final Object lifecycleLock = new Object(); - private static volatile ProjectScope current; - private static final InstanceState emptyState = InstanceState.inMemory(); - private static final CodeInsightService codeInsightService = new CodeInsightService( - () -> { throw new IllegalStateException("Runtime class index is not ready"); }, RuntimeSourceCatalog.empty()); - private static RuntimeIndexService runtimeIndexService; - private static final ScriptCompilationService scriptCompiler = new ScriptCompilationService(CompanionApp::send, CompanionApp::send); - private static CompanionMcpServer mcpServer; - private static volatile DebuggerSessionController debuggerController; - private static volatile boolean uiStarted; - private static ProjectRegistry projects; - private static volatile boolean switching; - private static final java.util.concurrent.ExecutorService projectWorker = java.util.concurrent.Executors.newSingleThreadExecutor( - runnable -> Thread.ofPlatform().daemon().name("companion-projects").unstarted(runnable)); - - private CompanionApp() { - } + private CompanionApp() { } public static void main(String[] args) { int result = 1; @@ -144,469 +71,45 @@ public static void main(String[] args) { static int run(String[] args, Map environment, CompanionTimeouts timeouts) { var startup = RuntimePhase.start("companion.startup"); + CompanionLaunchConfiguration configuration = null; FileChannel lockChannel = null; FileLock instanceLock = null; boolean ownsInstance = false; try { Objects.requireNonNull(timeouts, "timeouts"); - launchConfiguration = CompanionLaunchConfiguration.parse(args, environment); - Files.createDirectories(launchConfiguration.paths().run()); - AtomicFiles.cleanupAbandonedStaging(launchConfiguration.paths().run()); - lockChannel = FileChannel.open( - launchConfiguration.lockFile(), - StandardOpenOption.CREATE, - StandardOpenOption.WRITE - ); - try { - instanceLock = lockChannel.tryLock(); - } catch (OverlappingFileLockException exception) { - instanceLock = null; - } - if (instanceLock == null) { - return 0; - } + configuration = CompanionLaunchConfiguration.parse(args, environment); + Files.createDirectories(configuration.paths().run()); + AtomicFiles.cleanupAbandonedStaging(configuration.paths().run()); + lockChannel = FileChannel.open(configuration.lockFile(), StandardOpenOption.CREATE, StandardOpenOption.WRITE); + try { instanceLock = lockChannel.tryLock(); } + catch (OverlappingFileLockException ignored) { } + if (instanceLock == null) return 0; ownsInstance = true; - - Files.deleteIfExists(launchConfiguration.descriptorFile()); + Files.deleteIfExists(configuration.descriptorFile()); String token = newInstanceToken(); - writeSecret(launchConfiguration.keyFile(), token); - - GlobalConfig.getInstance().loadFrom(launchConfiguration.appHome()); + writeSecret(configuration.keyFile(), token); + GlobalConfig.getInstance().loadFrom(configuration.appHome()); configureLookAndFeel(); - runtimeIndexService = new RuntimeIndexService(lifecycleLock, CompanionApp::installRuntimeSnapshot); - runtimeIndexService.addStatusListener(CompanionApp::updateRuntimeIndexUi); - debuggerController = createDebuggerController(); - debuggerController.setExceptionBreakpoints( - instanceState().breakOnCaughtExceptions(), - instanceState().breakOnUncaughtExceptions() - ); - restoreProfile(); - - session = new CompanionSession(token, CompanionApp::attachSelectedProfile, new CompanionSession.Listener() { - @Override public void openClass(OpenClassMessage message) { - CompanionApp.openClass(message.binaryName(), message.targetType(), message.targetIdentifier()); - } - @Override public void focusWindow() { CompanionApp.focusWindow(); } - @Override - public void connecting() { - updateGameStatus(new ServiceStatus( - ServiceStatus.State.PENDING, - "Connecting", - "Waiting for Minecraft to finish the authenticated connection." - )); - } - - @Override - public void connected() { - updateGameStatus(new ServiceStatus( - ServiceStatus.State.AVAILABLE, - "Connected", - "Minecraft is connected and authenticated." - )); - } - - @Override - public void disconnected() { - scriptCompiler.runtimeDisconnected(); - updateGameStatus(new ServiceStatus( - ServiceStatus.State.INACTIVE, - "Offline", - "Minecraft is not connected." - )); - debuggerController.clearTarget(); - CompanionMcpServer current = mcpServer; - if (current != null) { - current.runtimeDisconnected(); - } - } - - @Override - public void runtimeInventory(RuntimeInventoryMessage message) { - handleRuntimeInventory(message); - } - - @Override - public void serverManifest(ServerManifestMessage message) { - scriptCompiler.acceptServerManifest(message); - } - - @Override - public void debugTarget(DebugTargetMessage message) { - handleDebugTarget(message); - } - }); - scriptExecutions = new ScriptExecutionService(session, scriptCompiler, CompanionApp::isConnected); - session.setProjectSelectionHandler(hello -> { - try { openProject(CompanionProfile.fromHello(hello)).join(); } - catch (java.util.concurrent.CompletionException failure) { - throw new IOException(failure.getCause().getMessage(), failure.getCause()); - } - }); - startUi(); - updateGameStatus(new ServiceStatus( - ServiceStatus.State.INACTIVE, - "Offline", - "Minecraft is not connected." - )); - session.bindAndPublish(launchConfiguration); - startOptionalMcpServer(); - - startup.close(); - EXIT.await(); + try (var application = new CompanionApplication(configuration, token)) { + application.startUi(); + application.start(); + startup.close(); + application.awaitExit(); + } return 0; - } catch (InterruptedException exception) { + } catch (InterruptedException failure) { Thread.currentThread().interrupt(); return 1; - } catch (Throwable throwable) { - throwable.printStackTrace(System.err); + } catch (Throwable failure) { + failure.printStackTrace(System.err); return 1; } finally { startup.close(); - try (var shutdown = RuntimePhase.start("companion.shutdown")) { - projectWorker.close(); - if (runtimeIndexService != null) { - runtimeIndexService.close(); - } - RuntimePhase.run("close.mcp", CompanionApp::closeMcpServer); - if (session != null) { - RuntimePhase.run("close.session", session::close); - } - if (debuggerController != null) { - RuntimePhase.run("close.debugger", debuggerController::close); - } - RuntimePhase.run("close.runtime", CompanionApp::closeRuntime); - RuntimePhase.run("close.code-insight", codeInsightService::close); - RuntimePhase.run("close.script-compiler", scriptCompiler::close); - RuntimePhase.run("close.ui", CompanionApp::stopUiAfterFailure); - try (var state = RuntimePhase.start("close.state")) { - GlobalConfig.getInstance().saveNow(); - instanceState().close(); - } catch (IOException exception) { - exception.printStackTrace(System.err); - } - if (ownsInstance) { - cleanupPublishedInstance(); - } - if (instanceLock != null) { - try { - instanceLock.release(); - } catch (IOException exception) { - exception.printStackTrace(System.err); - } - } - if (lockChannel != null) { - try { - lockChannel.close(); - } catch (IOException exception) { - exception.printStackTrace(System.err); - } - } - } - } - } - - private static void attachSelectedProfile( - com.github.minecraft_ta.totaldebug.protocol.scnet.ClientHelloMessage hello - ) throws IOException { - synchronized (lifecycleLock) { - CompanionProfile requested; - try { - requested = CompanionProfile.fromHello(hello); - } catch (IllegalArgumentException exception) { - throw new IOException("Invalid Minecraft profile", exception); - } - if (switching || !requested.equals(currentProject())) { - throw new IOException("Select this project explicitly before connecting"); - } - } - } - - private static void handleDebugTarget(DebugTargetMessage message) { - if (switching) return; - if (message.targetKind() != DebugTargetMessage.LOCAL_JVM) { - throw new IllegalArgumentException("Unknown debug target kind: " + message.targetKind()); - } - debuggerController.acceptTarget(new DebugTargetDescriptor( - message.targetId(), - message.displayName(), - message.processId() - )); - } - - private static void restoreProfile() throws IOException { - projects = ProjectRegistry.open(launchConfiguration.paths()); - CompanionProfile selected = projects.selected(); - if (selected != null) { - try { activateProfile(selected); } - catch (IOException failure) { System.err.println("Unable to reopen selected project: " + failure.getMessage()); } - } - } - - public static List projects() { - return projects == null ? List.of() : projects.projects(); - } - - public static CompanionProfile currentProject() { var scope = current; return scope == null ? null : scope.profile(); } - - public static boolean isSwitching() { return switching; } - - public static ProjectScope currentScope() { return current; } - - public static ProjectScope requireProject() { - ProjectScope scope = current; - if (scope == null) throw new IllegalStateException("No Minecraft project is loaded"); - scope.requireActive(); - return scope; - } - - /** Application API; selection controls and MCP project tools are added separately. */ - public static CompletableFuture openProject(CompanionProfile requested) { - Objects.requireNonNull(requested); - return CompletableFuture.runAsync(() -> { - try { switchProject(requested); } - catch (IOException failure) { throw new java.util.concurrent.CompletionException(failure); } - }, projectWorker); - } - - private static void switchProject(CompanionProfile requested) throws IOException { - validateProfile(requested); - if (requested.equals(currentProject())) { projects.select(requested); return; } - // Prepare the actual replacement before disturbing the current project. - ProjectScope replacement = ProjectScope.open(lifecycleLock, requested); - replacement.beginSwitch(); - ProjectScope old; - synchronized (lifecycleLock) { - old = current; - switching = true; - if (old != null) old.beginSwitch(); - } - boolean installed = false; - try { - if (uiStarted) { - boolean[] canSwitch = {false}; - SwingUtilities.invokeAndWait(() -> canSwitch[0] = MainWindow.INSTANCE.prepareProjectSwitch()); - if (!canSwitch[0]) throw new IOException("Project switch cancelled because an editor could not be saved"); - } - if (old != null) old.state().saveNow(); - if (uiStarted) { - boolean[] closed = {false}; - SwingUtilities.invokeAndWait(() -> closed[0] = MainWindow.INSTANCE.closeProjectViews()); - if (!closed[0]) throw new IOException("Project switch cancelled because an editor could not be closed"); - } - synchronized (lifecycleLock) { - if (old != null) old.retire(); - current = null; - if (runtimeIndexService != null) runtimeIndexService.clear(); - } - // Retirement is terminal. Attempt every detach and install the prepared replacement even if - // a broken debugger/connection cannot detach cleanly. - if (mcpServer != null) finishTransitionStep("Disconnect execution jobs", mcpServer::prepareProjectSwitch); - finishTransitionStep("Disconnect script compiler", scriptCompiler::runtimeDisconnected); - if (session != null) finishTransitionStep("Disconnect Minecraft", session::disconnect); - finishTransitionStep("Clear debugger target", () -> getDebuggerController().clearTarget().join()); - finishTransitionStep("Clear debugger breakpoints", () -> getDebuggerController().replaceBreakpointDefinitions(List.of()).join()); - CompanionClassIndex.clear(); - if (old != null) { - try { old.close(); } - catch (IOException | RuntimeException failure) { reportTransitionFailure("Close retired project", failure); } - } - com.github.minecraft_ta.totalDebugCompanion.jdt.diagnostics.ASTCache.clear(); - synchronized (lifecycleLock) { current = replacement; } - installed = true; - finishTransitionStep("Restore debugger preferences", () -> restoreProjectState(replacement)); - if (uiStarted) refreshUiProfile(); - updateGameStatus(new ServiceStatus(ServiceStatus.State.INACTIVE, "Offline", "Selected project is not connected to Minecraft.")); - try { projects.select(requested); } - catch (IOException failure) { - throw new IOException("Project opened, but its selection could not be saved: " + failure.getMessage(), failure); - } - } catch (InterruptedException failure) { - Thread.currentThread().interrupt(); - throw new IOException("Project switch interrupted", failure); - } catch (InvocationTargetException failure) { - throw new IOException("Unable to close project editors", failure.getCause()); - } finally { - try { - if (!installed) { replacement.retire(); replacement.close(); } - } finally { - synchronized (lifecycleLock) { - if (old != null) old.cancelSwitch(); - try { - if (installed && runtimeIndexService != null) runtimeIndexService.restore(requested.dataDirectory()); - } finally { - if (installed) replacement.cancelSwitch(); - switching = false; - } - } - if (uiStarted) SwingUtilities.invokeLater(() -> MainWindow.INSTANCE.setEnabled(true)); - } - } - } - - private static void finishTransitionStep(String description, Runnable action) { - try { action.run(); } - catch (RuntimeException failure) { reportTransitionFailure(description, failure); } - } - - private static void reportTransitionFailure(String description, Exception failure) { - System.getLogger(CompanionApp.class.getName()).log(System.Logger.Level.WARNING, - description + " failed while switching projects", failure); - } - - private static void activateProfile(CompanionProfile requested) throws IOException { - validateProfile(requested); - ProjectScope replacement = ProjectScope.open(lifecycleLock, requested); - synchronized (lifecycleLock) { current = replacement; } - restoreProjectState(replacement); - if (runtimeIndexService != null) runtimeIndexService.restore(requested.dataDirectory()); - } - - private static void restoreProjectState(ProjectScope scope) { - getDebuggerController().setBreakpointsMuted(scope.state().debuggerBreakpointsMuted()).join(); - getDebuggerController().setExceptionBreakpoints(scope.state().breakOnCaughtExceptions(), - scope.state().breakOnUncaughtExceptions()).join(); - } - - private static void validateProfile(CompanionProfile requested) throws IOException { - if (!Files.isDirectory(requested.workspaceDirectory())) { - throw new IOException("Minecraft workspace not found"); - } - Files.createDirectories(requested.dataDirectory()); - setupDataDirectories(requested.dataDirectory(), true); - } - - private static void handleRuntimeInventory(RuntimeInventoryMessage message) { - synchronized (lifecycleLock) { - if (switching) return; - CompanionProfile current = currentProject(); - if (current == null || runtimeIndexService == null) { - return; - } - switch (message.state()) { - case RuntimeInventoryMessage.PREPARING -> { - runtimeIndexService.waiting( - message.detail().isBlank() ? "Minecraft is preparing runtime sources" : message.detail()); - } - case RuntimeInventoryMessage.AVAILABLE -> runtimeIndexService.accept( - current.dataDirectory(), - message.inventoryId(), - Path.of(message.inventoryFile()) - ); - case RuntimeInventoryMessage.FAILED -> runtimeIndexService.failedBeforeBuild(message.detail()); - default -> runtimeIndexService.failedBeforeBuild("Minecraft sent an unknown runtime inventory state"); - } - } - } - - private static void installRuntimeSnapshot(RuntimeIndexService.ReadySnapshot snapshot) { - installRuntimeSnapshot(snapshot, RuntimeSnapshotBytecodeSource.fromRuntime(snapshot.sources(), snapshot.index(), - snapshot.indexFile().getParent().resolve("inventory.json"), snapshot.inventoryId())); - } - - private static void installRuntimeSnapshot(RuntimeIndexService.ReadySnapshot snapshot, - RuntimeSnapshotBytecodeSource bytecodeSource) { - synchronized (lifecycleLock) { - if (switching) throw new IllegalStateException("Project is switching"); - ProjectScope scope = requireProject(); - CompanionProfile current = scope.profile(); - RuntimeBinding replacement; - try { - replacement = new RuntimeBinding(snapshot, current.dataDirectory(), bytecodeSource, scriptCompiler, codeInsightService); - } catch (IOException exception) { - throw new IllegalStateException("Unable to prepare the runtime class index", exception); - } - try { - closeRuntime(); - replacement.attach(); - // Queue before publication: rejected scheduling still leaves ownership with the loader. - // The follow-up acquires this lock after the loader finishes its installation callback. - projectWorker.execute(() -> finishRuntimeInstallation(replacement, scope)); - CompanionClassIndex.set(snapshot.index()); - scope.bindRuntime(replacement); - replacement.acceptOwnership(); - } catch (RuntimeException failure) { - replacement.close(); - throw failure; - } - } - } - - private static void finishRuntimeInstallation(RuntimeBinding installed, ProjectScope selected) { - SwingUtilities.invokeLater(() -> { - if (!selected.isActive() || selected.runtime() != installed || current != selected) return; - if (uiStarted) { - MainWindow.INSTANCE.navigation().runtimeChanged(); - MainWindow.INSTANCE.refreshRuntimeSources(); - } - List queued; - synchronized (lifecycleLock) { - if (!selected.isActive() || selected.runtime() != installed || current != selected) return; - queued = selected.drainNavigations(); - } - for (PendingNavigation pending : queued) { - MainWindow.INSTANCE.navigation().navigate(pending.target(), pending.activation()); - } - }); - try { - CompletableFuture breakpoints; - synchronized (lifecycleLock) { - if (!selected.isActive() || selected.runtime() != installed || current != selected) return; - breakpoints = getDebuggerController().replaceBreakpointDefinitions( - selected.restoreBreakpoints(installed.snapshot().signature())); - } - // A failed debugger/UI refresh must never return ownership of an installed index to its loader. - breakpoints.join(); - prewarmJavaParser(); - } catch (RuntimeException failure) { - System.getLogger(CompanionApp.class.getName()).log(System.Logger.Level.WARNING, - "Runtime installed, but debugger refresh failed", failure); - } - } - - static void configureWithoutSession(CompanionProfile developmentProfile) { - session = new CompanionSession("ui-development"); - scriptExecutions = new ScriptExecutionService(session, scriptCompiler, CompanionApp::isConnected); - runtimeIndexService = new RuntimeIndexService(lifecycleLock, CompanionApp::installRuntimeSnapshot); - debuggerController = createDebuggerController(); - try { - activateProfile(Objects.requireNonNull(developmentProfile, "developmentProfile")); - } catch (IOException exception) { - throw new IllegalStateException("Unable to configure the UI profile", exception); - } - } - - static void configureWithoutSession( - CompanionProfile developmentProfile, - Path indexFile, - List runtimeSources, - String runtimeSignature - ) { - configureWithoutSession(developmentProfile); - try { - ClassIndex index = ClassIndex.fromFile(indexFile.toString()); - List sources = new ArrayList<>(); - for (int sourceId = 0; sourceId < runtimeSources.size(); sourceId++) { - Path source = runtimeSources.get(sourceId); - sources.add(new RuntimeSnapshotBytecodeSource.Source( - sourceId, - source, - source.toUri().toASCIIString(), - new RuntimeInventory.RuntimeModule( - "ui-development", - "UI development classes", - RuntimeInventory.ModuleKind.LIBRARY - ) - )); - } - installRuntimeSnapshot(new RuntimeIndexService.ReadySnapshot( - "ui-development", - runtimeSignature, - indexFile, - sources, - index - ), RuntimeSnapshotBytecodeSource.fromIndexedSources(sources, index)); - } catch (RuntimeException exception) { - throw new IllegalStateException("Unable to configure the UI class index", exception); + if (ownsInstance) cleanupPublishedInstance(configuration); + try { if (instanceLock != null) instanceLock.release(); } + catch (IOException failure) { failure.printStackTrace(System.err); } + try { if (lockChannel != null) lockChannel.close(); } + catch (IOException failure) { failure.printStackTrace(System.err); } } } @@ -639,403 +142,6 @@ private static void configureFonts() { FlatLaf.setPreferredMonospacedFontFamily(FlatJetBrainsMonoFont.FAMILY); } - private static void setupDataDirectories() throws IOException { - setupDataDirectories( - getRootPath(), - hasProfile() - ); - } - - static void setupDataDirectories(Path rootPath, boolean scriptExecutionEnabled) throws IOException { - - if (!scriptExecutionEnabled) { - return; - } - Files.createDirectories(new InstancePaths(rootPath).scripts()); - } - - private static void prewarmJavaParser() { - if (!CompanionClassIndex.isOpen()) { - return; - } - Thread thread = new Thread(() -> { - ASTParser parser = JdtConfiguration.createParser(); - parser.setSource(new CompilationUnitImpl("Test", "class Test{}")); - parser.setResolveBindings(true); - parser.setKind(ASTParser.K_COMPILATION_UNIT); - CompilationUnit ignored = (CompilationUnit) parser.createAST(null); - }, "Companion JDT prewarm"); - thread.setDaemon(true); - thread.start(); - } - - private static void startMcpServer() throws Exception { - CodeModeJobService jobs = new CodeModeJobService( - session, scriptExecutions, CompanionApp::requireProject, - () -> isConnected(), - CompanionApp::runtimeContext - ); - CompanionMcpServer server = new CompanionMcpServer(launchConfiguration.appHome(), jobs); - try { - server.start(); - mcpServer = server; - System.err.println("TotalDebug Companion MCP listening at " + server.endpointUrl()); - } catch (Exception exception) { - server.close(); - throw exception; - } - } - - private static void startOptionalMcpServer() { - updateMcpStatus(new ServiceStatus( - ServiceStatus.State.PENDING, - "Starting", - "Starting the loopback MCP server." - )); - try { - startMcpServer(); - updateMcpStatus(new ServiceStatus( - ServiceStatus.State.AVAILABLE, - "Listening", - "MCP is listening at " + mcpServer.endpointUrl() - )); - } catch (Exception exception) { - updateMcpStatus(new ServiceStatus( - ServiceStatus.State.FAILED, - "Unavailable", - "MCP startup failed: " + exception - )); - System.err.println("TotalDebug Companion MCP is unavailable: " + exception.getMessage()); - exception.printStackTrace(System.err); - } - } - - private static void closeMcpServer() { - CompanionMcpServer server = mcpServer; - mcpServer = null; - if (server != null) { - server.close(); - } - updateMcpStatus(new ServiceStatus( - ServiceStatus.State.INACTIVE, - "Stopped", - "The MCP server is stopped." - )); - } - - private static Map runtimeContext() { - CompanionProfile current = currentProject(); - if (current == null) { - return Map.of(); - } - Map context = new java.util.LinkedHashMap<>(); - context.put("profile_id", current.id()); - context.put("workspace_directory", current.workspaceDirectory().toString()); - RuntimeBinding installed = currentRuntime(); - if (installed != null) { - context.put("runtime_signature", installed.snapshot().signature()); - context.put("index_file", installed.snapshot().indexFile().toString()); - } - return Map.copyOf(context); - } - - public static MainWindow createMainWindow() { - return new MainWindow(CompanionApp::currentScope, getDebuggerController(), codeInsightService, - scriptExecutions, session, runtimeIndexService, CompanionApp::openDebugFrame, CompanionApp::exit); - } - - private static void startUi() throws InvocationTargetException, InterruptedException { - SwingUtilities.invokeAndWait(() -> { - uiStarted = true; - MainWindow.INSTANCE.setSize(1280, 720); - MainWindow.INSTANCE.setRuntimeIndexStatus(getRuntimeIndexStatus()); - MainWindow.INSTANCE.setVisible(true); - UIUtils.centerJFrame(MainWindow.INSTANCE, MainWindow.INSTANCE); - ToolTipManager.sharedInstance().setInitialDelay(200); - }); - } - - private static void refreshUiProfile() { - Runnable refresh = MainWindow.INSTANCE::refreshProfile; - if (SwingUtilities.isEventDispatchThread()) { - refresh.run(); - } else { - try { - SwingUtilities.invokeAndWait(refresh); - } catch (InvocationTargetException exception) { - throw new IllegalStateException("Unable to refresh the Companion UI", exception.getCause()); - } catch (InterruptedException exception) { - Thread.currentThread().interrupt(); - throw new IllegalStateException("Interrupted while refreshing the Companion UI", exception); - } - } - } - - private static void updateGameStatus(ServiceStatus status) { - if (!uiStarted) { - return; - } - SwingUtilities.invokeLater(() -> MainWindow.INSTANCE.setGameStatus(status)); - } - - private static void updateMcpStatus(ServiceStatus status) { - if (!uiStarted) { - return; - } - SwingUtilities.invokeLater(() -> MainWindow.INSTANCE.setMcpStatus(status)); - } - - private static void updateRuntimeIndexUi(RuntimeIndexService.Status status) { - if (!uiStarted) { - return; - } - SwingUtilities.invokeLater(() -> MainWindow.INSTANCE.setRuntimeIndexStatus(status)); - } - - private static void stopUi() throws InvocationTargetException, InterruptedException { - if (!uiStarted) { - return; - } - SwingUtilities.invokeAndWait(() -> { - for (Window window : Window.getWindows()) { - window.dispose(); - } - uiStarted = false; - }); - } - - private static void stopUiAfterFailure() { - try { - stopUi(); - } catch (InvocationTargetException | InterruptedException exception) { - if (exception instanceof InterruptedException) { - Thread.currentThread().interrupt(); - } - exception.printStackTrace(System.err); - } - } - - public static void focusWindow() { - if (!SwingUtilities.isEventDispatchThread()) { - throw new IllegalStateException("Companion window focus must run on the Swing event thread"); - } - UIUtils.focusWindow(MainWindow.INSTANCE); - } - - public static void exit() { - if (uiStarted) { - if (!SwingUtilities.isEventDispatchThread()) { - SwingUtilities.invokeLater(CompanionApp::exit); - return; - } - if (!MainWindow.INSTANCE.getEditorTabs().canCloseAll()) { - return; - } - try (var state = RuntimePhase.start("close.request-save")) { - GlobalConfig.getInstance().saveNow(); - instanceState().saveNow(); - } catch (IOException exception) { - JOptionPane.showMessageDialog(MainWindow.INSTANCE, exception.getMessage(), - "Unable to save state", JOptionPane.ERROR_MESSAGE); - return; - } - } - EXIT.countDown(); - } - - public static boolean isConnected() { - return !switching && session != null && session.isConnected(); - } - - public static boolean hasProfile() { - return current != null; - } - - public static String getActiveRuntimeSignature() { - RuntimeBinding current = currentRuntime(); - return current == null ? null : current.snapshot().signature(); - } - - public static boolean send(AbstractMessage message) { - if (switching && !(message instanceof StopScriptMessage)) return false; - CompanionSession current = session; - return current != null && current.send(message); - } - - public static CompletableFuture compileJava(String source, String entryClass) { - try { return requireProject().admit(() -> scriptCompiler.compile(source, entryClass)); } - catch (IllegalStateException failure) { return CompletableFuture.failedFuture(failure); } - } - - /** A pre-send check; the receiving runtime must still validate the result's inventory identity. */ - public static boolean isCurrentRuntimeInventory(String inventoryId) { - return isConnected() && scriptCompiler.isCurrentInventory(inventoryId); - } - - public static ScriptExecutionService scriptExecutions() { return scriptExecutions; } - public static CompanionSession session() { return session; } - - public static void openClass(String binaryName, int targetType, String targetIdentifier) { - openOrQueue( - NavigationTargets.fromClassOpen(binaryName, targetType, targetIdentifier), - NavigationService.Activation.ACTIVATE_WINDOW - ); - } - - private static void openOrQueue(NavigationTarget target, NavigationService.Activation activation) { - if (switching) return; - if (currentRuntime() == null) { - synchronized (lifecycleLock) { - if (switching) return; - if (currentRuntime() == null) { - ProjectScope scope = current; - if (scope != null && scope.isActive()) scope.queueNavigation(target, activation); - return; - } - } - } - MainWindow.INSTANCE.navigation().navigate(target, activation); - } - - public static void openDebugFrame( - com.github.minecraft_ta.totalDebugCompanion.debugger.DebugEngine.StackFrame frame, - boolean activateEditor - ) { - if (frame.binaryName().isBlank() || frame.line() < 1) { - return; - } - openOrQueue( - new NavigationTarget.RuntimeLine(frame.binaryName(), frame.line()), - activateEditor - ? NavigationService.Activation.ACTIVATE_WINDOW - : NavigationService.Activation.KEEP_CURRENT_WINDOW - ); - } - - private static DebugEngine.Source loadDebugSource(String binaryName) throws IOException { - RuntimeBinding current = currentRuntime(); - CompanionDecompilationService service = current == null ? null : current.decompiler(); - return service == null ? null : service.loadDebugSource(binaryName); - } - - public static AppPaths appPaths() { - return launchConfiguration == null ? AppPaths.defaults(System.getenv()) : launchConfiguration.paths(); - } - - public static InstancePaths instancePaths() { - return new InstancePaths(requireProfile().dataDirectory()); - } - - public static InstanceState instanceState() { - ProjectScope scope = current; - return scope == null ? emptyState : scope.state(); - } - - public static Path getRootPath() { - return requireProfile().dataDirectory(); - } - - public static Path getWorkspaceDirectory() { - return requireProfile().workspaceDirectory(); - } - - public static ReferenceSearchService getReferenceSearchService() { - RuntimeBinding current = currentRuntime(); - ReferenceSearchService service = current == null ? null : current.references(); - if (service == null) { - throw new IllegalStateException("Reference search is unavailable"); - } - return service; - } - - public static CodeInsightService getCodeInsightService() { - CodeInsightService service = codeInsightService; - if (currentRuntime() == null) { - throw new IllegalStateException("Code insight is unavailable"); - } - return service; - } - - public static RuntimeSourceCatalog getRuntimeSourceCatalog() { - RuntimeBinding current = currentRuntime(); - return current == null ? RuntimeSourceCatalog.empty() : current.sources(); - } - - public static CompanionDecompilationService getDecompilationService() { - RuntimeBinding current = currentRuntime(); - CompanionDecompilationService service = current == null ? null : current.decompiler(); - if (service == null) { - throw new IllegalStateException("Decompilation is unavailable"); - } - return service; - } - - public static DebuggerSessionController getDebuggerController() { - DebuggerSessionController controller = debuggerController; - if (controller == null) { - throw new IllegalStateException("Debugger controller is not initialized"); - } - return controller; - } - - public static boolean isDebuggerConnected() { - DebuggerSessionController controller = debuggerController; - if (controller == null) { - return false; - } - DebuggerSessionController.Phase phase = controller.status().phase(); - return phase == DebuggerSessionController.Phase.RUNNING - || phase == DebuggerSessionController.Phase.PAUSED; - } - - private static DebuggerSessionController createDebuggerController() { - DebuggerSessionController controller = new DebuggerSessionController(CompanionApp::loadDebugSource, () -> { RuntimeBinding current = currentRuntime(); return current == null ? null : current.classpath(); }, name -> requireProject().loadBreakpointScript(name)); - controller.setBreakpointsMuted(instanceState().debuggerBreakpointsMuted()).join(); - controller.addListener(new DebuggerSessionController.Listener() { - @Override - public void breakpointsChanged( - URI sourceUri, - List breakpoints - ) { - ProjectScope scope = current; - if (scope != null) scope.persistBreakpoints(controller); - } - - @Override - public void breakpointsMutedChanged(boolean muted) { - ProjectScope scope = current; - if (scope != null && scope.isActive()) scope.state().setDebuggerBreakpointsMuted(muted); - } - }); - return controller; - } - - public static RuntimeIndexService.Status getRuntimeIndexStatus() { - RuntimeIndexService service = runtimeIndexService; - return service == null - ? new RuntimeIndexService.Status(RuntimeIndexService.Phase.WAITING, "Waiting for runtime inventory", null) - : service.status(); - } - - private static CompanionProfile requireProfile() { - CompanionProfile current = currentProject(); - if (current == null) { - throw new IllegalStateException("No Minecraft profile is loaded"); - } - return current; - } - - public static RuntimeBinding currentRuntime() { - ProjectScope scope = current; - return scope == null ? null : scope.runtime(); - } - - private static void closeRuntime() { - ProjectScope scope = current; - CompanionClassIndex.clear(); - if (scope != null) scope.closeRuntime(); - } - private static String newInstanceToken() { byte[] token = new byte[32]; TOKEN_RANDOM.nextBytes(token); @@ -1046,8 +152,7 @@ private static void writeSecret(Path keyFile, String token) throws IOException { AtomicFiles.writeSecret(keyFile, token); } - private static void cleanupPublishedInstance() { - CompanionLaunchConfiguration current = launchConfiguration; + private static void cleanupPublishedInstance(CompanionLaunchConfiguration current) { if (current == null) { return; } 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 new file mode 100644 index 00000000..4fbb08eb --- /dev/null +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/CompanionApplication.java @@ -0,0 +1,842 @@ +package com.github.minecraft_ta.totalDebugCompanion; + +import com.github.minecraft_ta.totaldebug.storage.AppPaths; +import com.github.minecraft_ta.totalDebugCompanion.project.ProjectScope; +import com.github.minecraft_ta.totalDebugCompanion.project.ProjectScope.PendingNavigation; +import com.github.minecraft_ta.totaldebug.storage.InstancePaths; +import com.github.minecraft_ta.totaldebug.storage.RuntimePhase; +import com.github.minecraft_ta.totalDebugCompanion.storage.InstanceState; +import com.github.minecraft_ta.totalDebugCompanion.jdt.CompanionClassIndex; +import com.github.minecraft_ta.totalDebugCompanion.jdt.JDTHacks; +import com.github.minecraft_ta.totalDebugCompanion.ui.CompanionUi; +import com.github.minecraft_ta.totalDebugCompanion.jdt.JdtConfiguration; +import com.github.minecraft_ta.totalDebugCompanion.jdt.impls.CompilationUnitImpl; +import com.github.minecraft_ta.totalDebugCompanion.bytecode.RuntimeSnapshotBytecodeSource; +import com.github.minecraft_ta.totalDebugCompanion.decompile.CompanionDecompilationService; +import com.github.minecraft_ta.totalDebugCompanion.debugger.DebugTargetDescriptor; +import com.github.minecraft_ta.totalDebugCompanion.debugger.DebugEngine; +import com.github.minecraft_ta.totalDebugCompanion.debugger.DebuggerSessionController; +import com.github.minecraft_ta.totalDebugCompanion.mcp.CodeModeJobService; +import com.github.minecraft_ta.totalDebugCompanion.script.ScriptCompilationService; +import com.github.minecraft_ta.totalDebugCompanion.script.ScriptExecutionService; +import com.github.minecraft_ta.totaldebug.protocol.scnet.OpenClassMessage; +import com.github.minecraft_ta.totalDebugCompanion.script.ScriptCompilationService.CompilationResult; +import com.github.minecraft_ta.totaldebug.protocol.scnet.StopScriptMessage; +import com.github.minecraft_ta.totalDebugCompanion.mcp.CompanionMcpServer; +import com.github.minecraft_ta.totaldebug.protocol.scnet.DebugTargetMessage; +import com.github.minecraft_ta.totaldebug.protocol.scnet.RetryRuntimeInventoryMessage; +import com.github.minecraft_ta.totaldebug.protocol.scnet.RuntimeInventoryMessage; +import com.github.minecraft_ta.totaldebug.protocol.scnet.ServerManifestMessage; +import com.github.minecraft_ta.totalDebugCompanion.model.ServiceStatus; +import com.github.minecraft_ta.totalDebugCompanion.navigation.NavigationService; +import com.github.minecraft_ta.totalDebugCompanion.navigation.NavigationTarget; +import com.github.minecraft_ta.totalDebugCompanion.navigation.NavigationTargets; +import com.github.minecraft_ta.totalDebugCompanion.runtime.RuntimeIndexService; +import com.github.minecraft_ta.totalDebugCompanion.runtime.RuntimeBinding; +import com.github.minecraft_ta.totalDebugCompanion.runtime.RuntimeSourceCatalog; +import com.github.minecraft_ta.totalDebugCompanion.search.insight.CodeInsightService; +import com.github.minecraft_ta.totalDebugCompanion.search.reference.ReferenceSearchService; +import com.github.minecraft_ta.totalDebugCompanion.session.CompanionLaunchConfiguration; +import com.github.minecraft_ta.totalDebugCompanion.session.CompanionProfile; +import com.github.minecraft_ta.totalDebugCompanion.session.ProjectRegistry; +import com.github.minecraft_ta.totalDebugCompanion.session.CompanionSession; +import com.github.minecraft_ta.totalDebugCompanion.ui.views.MainWindow; +import com.github.minecraft_ta.totalDebugCompanion.util.UIUtils; +import com.github.tth05.scnet.message.AbstractMessage; +import org.eclipse.jdt.core.dom.ASTParser; +import org.eclipse.jdt.core.dom.CompilationUnit; +import javax.swing.SwingUtilities; +import javax.swing.ToolTipManager; +import java.io.IOException; +import java.lang.reflect.InvocationTargetException; +import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.LinkedHashMap; +import com.github.minecraft_ta.totalDebugCompanion.jdt.diagnostics.ASTCache; +import com.github.minecraft_ta.totaldebug.protocol.scnet.ClientHelloMessage; +import java.util.concurrent.CountDownLatch; +import java.util.function.Consumer; + +public final class CompanionApplication implements AutoCloseable { + private final CountDownLatch exitRequested = new CountDownLatch(1); + + private ScriptExecutionService scriptExecutions; + private CompanionSession session; + private final CompanionLaunchConfiguration launchConfiguration; + private final Object lifecycleLock = new Object(); + private volatile ProjectScope current; + private final InstanceState emptyState = InstanceState.inMemory(); + private final CodeInsightService codeInsightService = new CodeInsightService( + () -> { throw new IllegalStateException("Runtime class index is not ready"); }, RuntimeSourceCatalog.empty()); + private RuntimeIndexService runtimeIndexService; + private final ScriptCompilationService scriptCompiler = new ScriptCompilationService(this::send, this::send); + private CompanionMcpServer mcpServer; + private volatile DebuggerSessionController debuggerController; + private volatile CompanionUi ui; + private volatile boolean closed; + private ProjectRegistry projects; + private volatile boolean switching; + private final ExecutorService projectWorker = Executors.newSingleThreadExecutor( + runnable -> Thread.ofPlatform().daemon().name("companion-projects").unstarted(runnable)); + + public CompanionApplication(CompanionLaunchConfiguration configuration, String token) throws IOException { + this(configuration, token, null); + } + + public CompanionApplication(CompanionLaunchConfiguration configuration, String token, CompanionUi ui) throws IOException { + launchConfiguration = Objects.requireNonNull(configuration); + this.ui = ui; + try { + JDTHacks.init(configuration.paths().jdtCache()); + runtimeIndexService = new RuntimeIndexService(lifecycleLock, this::installRuntimeSnapshot); + runtimeIndexService.addStatusListener(this::updateRuntimeIndexUi); + debuggerController = createDebuggerController(); + restoreProfile(); + session = new CompanionSession(token, this::attachSelectedProfile, new CompanionSession.Listener() { + @Override public void openClass(OpenClassMessage message) { + CompanionApplication.this.openClass(message.binaryName(), message.targetType(), message.targetIdentifier()); + } + @Override public void focusWindow() { CompanionApplication.this.focusWindow(); } + @Override + public void connecting() { + updateGameStatus(new ServiceStatus( + ServiceStatus.State.PENDING, + "Connecting", + "Waiting for Minecraft to finish the authenticated connection." + )); + } + + @Override + public void connected() { + updateGameStatus(new ServiceStatus( + ServiceStatus.State.AVAILABLE, + "Connected", + "Minecraft is connected and authenticated." + )); + } + + @Override + public void disconnected() { + scriptCompiler.runtimeDisconnected(); + updateGameStatus(new ServiceStatus( + ServiceStatus.State.INACTIVE, + "Offline", + "Minecraft is not connected." + )); + debuggerController.clearTarget(); + CompanionMcpServer current = mcpServer; + if (current != null) { + current.runtimeDisconnected(); + } + } + + @Override + public void runtimeInventory(RuntimeInventoryMessage message) { + handleRuntimeInventory(message); + } + + @Override + public void serverManifest(ServerManifestMessage message) { + scriptCompiler.acceptServerManifest(message); + } + + @Override + public void debugTarget(DebugTargetMessage message) { + handleDebugTarget(message); + } + }); + scriptExecutions = new ScriptExecutionService(session, scriptCompiler, this::isConnected); + session.setProjectSelectionHandler(hello -> { + try { openProject(CompanionProfile.fromHello(hello)).join(); } + catch (CompletionException failure) { + throw new IOException(failure.getCause().getMessage(), failure.getCause()); + } + }); + } catch (IOException | RuntimeException failure) { + close(); + throw failure; + } + } + + public void start() throws IOException { + updateGameStatus(new ServiceStatus(ServiceStatus.State.INACTIVE, "Offline", "Minecraft is not connected.")); + session.bindAndPublish(launchConfiguration); + startOptionalMcpServer(); + } + + public void awaitExit() throws InterruptedException { exitRequested.await(); } + + @Override public void close() { + if (closed) return; + closed = true; + try (var shutdown = RuntimePhase.start("companion.shutdown")) { + projectWorker.close(); + synchronized (lifecycleLock) { + if (current != null && current.isActive()) current.beginSwitch(); + switching = true; + } + if (runtimeIndexService != null) runCleanup("Close runtime loader", runtimeIndexService::close); + runCleanup("Close MCP", this::closeMcpServer); + if (session != null) runCleanup("Close session", session::close); + if (debuggerController != null) runCleanup("Close debugger", debuggerController::close); + CompanionUi view = ui; + ui = null; + if (view != null) { + try { onEdtAndWait(view::dispose); } + catch (InvocationTargetException | InterruptedException failure) { + if (failure instanceof InterruptedException) Thread.currentThread().interrupt(); + reportCleanupFailure("Close UI", failure); + } + } + ProjectScope scope; + synchronized (lifecycleLock) { + scope = current; + if (scope != null) scope.retire(); + current = null; + } + if (scope != null) { + if (scope.runtime() != null) CompanionClassIndex.clear(); + try { scope.close(); } + catch (IOException | RuntimeException failure) { reportCleanupFailure("Close project", failure); } + } + runCleanup("Close code insight", codeInsightService::close); + runCleanup("Close script compiler", scriptCompiler::close); + try { GlobalConfig.getInstance().saveNow(); emptyState.close(); } + catch (IOException failure) { reportCleanupFailure("Save application state", failure); } + } + } + + private void attachSelectedProfile( + ClientHelloMessage hello + ) throws IOException { + synchronized (lifecycleLock) { + CompanionProfile requested; + try { + requested = CompanionProfile.fromHello(hello); + } catch (IllegalArgumentException exception) { + throw new IOException("Invalid Minecraft profile", exception); + } + if (switching || !requested.equals(currentProject())) { + throw new IOException("Select this project explicitly before connecting"); + } + } + } + + private void handleDebugTarget(DebugTargetMessage message) { + if (switching) return; + if (message.targetKind() != DebugTargetMessage.LOCAL_JVM) { + throw new IllegalArgumentException("Unknown debug target kind: " + message.targetKind()); + } + debuggerController.acceptTarget(new DebugTargetDescriptor( + message.targetId(), + message.displayName(), + message.processId() + )); + } + + private void restoreProfile() throws IOException { + projects = ProjectRegistry.open(launchConfiguration.paths()); + CompanionProfile selected = projects.selected(); + if (selected != null) { + try { activateProfile(selected); } + catch (IOException failure) { System.err.println("Unable to reopen selected project: " + failure.getMessage()); } + } + } + + public List projects() { + return projects == null ? List.of() : projects.projects(); + } + + public CompanionProfile currentProject() { var scope = current; return scope == null ? null : scope.profile(); } + + public boolean isSwitching() { return switching; } + + public ProjectScope currentScope() { return current; } + + public ProjectScope requireProject() { + if (closed) throw new IllegalStateException("Application is closed"); + ProjectScope scope = current; + if (scope == null) throw new IllegalStateException("No Minecraft project is loaded"); + scope.requireActive(); + return scope; + } + + /** Application API; selection controls and MCP project tools are added separately. */ + public CompletableFuture openProject(CompanionProfile requested) { + Objects.requireNonNull(requested); + return CompletableFuture.runAsync(() -> { + try { switchProject(requested); } + catch (IOException failure) { throw new CompletionException(failure); } + }, projectWorker); + } + + private void switchProject(CompanionProfile requested) throws IOException { + validateProfile(requested); + if (requested.equals(currentProject())) { projects.select(requested); return; } + // Prepare the actual replacement before disturbing the current project. + ProjectScope replacement = ProjectScope.open(lifecycleLock, requested); + replacement.beginSwitch(); + ProjectScope old; + synchronized (lifecycleLock) { + old = current; + switching = true; + if (old != null) old.beginSwitch(); + } + boolean installed = false; + try { + if (ui != null) { + boolean[] canSwitch = {false}; + SwingUtilities.invokeAndWait(() -> canSwitch[0] = ui.prepareProjectSwitch()); + if (!canSwitch[0]) throw new IOException("Project switch cancelled because an editor could not be saved"); + } + if (old != null) old.state().saveNow(); + if (ui != null) { + boolean[] viewsClosed = {false}; + SwingUtilities.invokeAndWait(() -> viewsClosed[0] = ui.closeProjectViews()); + if (!viewsClosed[0]) throw new IOException("Project switch cancelled because an editor could not be closed"); + } + synchronized (lifecycleLock) { + if (old != null) old.retire(); + current = null; + if (runtimeIndexService != null) runtimeIndexService.clear(); + } + // Retirement is terminal. Attempt every detach and install the prepared replacement even if + // a broken debugger/connection cannot detach cleanly. + if (mcpServer != null) runCleanup("Disconnect execution jobs", mcpServer::prepareProjectSwitch); + runCleanup("Disconnect script compiler", scriptCompiler::runtimeDisconnected); + if (session != null) runCleanup("Disconnect Minecraft", session::disconnect); + runCleanup("Clear debugger target", () -> getDebuggerController().clearTarget().join()); + runCleanup("Clear debugger breakpoints", () -> getDebuggerController().replaceBreakpointDefinitions(List.of()).join()); + CompanionClassIndex.clear(); + if (old != null) { + try { old.close(); } + catch (IOException | RuntimeException failure) { reportCleanupFailure("Close retired project", failure); } + } + ASTCache.clear(); + synchronized (lifecycleLock) { current = replacement; } + installed = true; + runCleanup("Restore debugger preferences", () -> restoreProjectState(replacement)); + if (ui != null) refreshUiProfile(); + updateGameStatus(new ServiceStatus(ServiceStatus.State.INACTIVE, "Offline", "Selected project is not connected to Minecraft.")); + try { projects.select(requested); } + catch (IOException failure) { + throw new IOException("Project opened, but its selection could not be saved: " + failure.getMessage(), failure); + } + } catch (InterruptedException failure) { + Thread.currentThread().interrupt(); + throw new IOException("Project switch interrupted", failure); + } catch (InvocationTargetException failure) { + throw new IOException("Unable to close project editors", failure.getCause()); + } finally { + try { + if (!installed) { replacement.retire(); replacement.close(); } + } finally { + synchronized (lifecycleLock) { + if (old != null) old.cancelSwitch(); + try { + if (installed && runtimeIndexService != null) runtimeIndexService.restore(requested.dataDirectory()); + } finally { + if (installed) replacement.cancelSwitch(); + switching = false; + } + } + if (ui != null) SwingUtilities.invokeLater(() -> ui.setSwitching(false)); + } + } + } + + private void runCleanup(String description, Runnable action) { + try { action.run(); } + catch (RuntimeException failure) { reportCleanupFailure(description, failure); } + } + + private void reportCleanupFailure(String description, Exception failure) { + System.getLogger(CompanionApplication.class.getName()).log(System.Logger.Level.WARNING, + description + " failed", failure); + } + + private void activateProfile(CompanionProfile requested) throws IOException { + validateProfile(requested); + ProjectScope replacement = ProjectScope.open(lifecycleLock, requested); + synchronized (lifecycleLock) { current = replacement; } + restoreProjectState(replacement); + if (runtimeIndexService != null) runtimeIndexService.restore(requested.dataDirectory()); + } + + private void restoreProjectState(ProjectScope scope) { + getDebuggerController().setBreakpointsMuted(scope.state().debuggerBreakpointsMuted()).join(); + getDebuggerController().setExceptionBreakpoints(scope.state().breakOnCaughtExceptions(), + scope.state().breakOnUncaughtExceptions()).join(); + } + + private void validateProfile(CompanionProfile requested) throws IOException { + if (!Files.isDirectory(requested.workspaceDirectory())) { + throw new IOException("Minecraft workspace not found"); + } + Files.createDirectories(requested.dataDirectory()); + setupDataDirectories(requested.dataDirectory(), true); + } + + private void handleRuntimeInventory(RuntimeInventoryMessage message) { + synchronized (lifecycleLock) { + if (switching) return; + CompanionProfile current = currentProject(); + if (current == null || runtimeIndexService == null) { + return; + } + switch (message.state()) { + case RuntimeInventoryMessage.PREPARING -> { + runtimeIndexService.waiting( + message.detail().isBlank() ? "Minecraft is preparing runtime sources" : message.detail()); + } + case RuntimeInventoryMessage.AVAILABLE -> runtimeIndexService.accept( + current.dataDirectory(), + message.inventoryId(), + Path.of(message.inventoryFile()) + ); + case RuntimeInventoryMessage.FAILED -> runtimeIndexService.failedBeforeBuild(message.detail()); + default -> runtimeIndexService.failedBeforeBuild("Minecraft sent an unknown runtime inventory state"); + } + } + } + + private void installRuntimeSnapshot(RuntimeIndexService.ReadySnapshot snapshot) { + installRuntimeSnapshot(snapshot, RuntimeSnapshotBytecodeSource.fromRuntime(snapshot.sources(), snapshot.index(), + snapshot.indexFile().getParent().resolve("inventory.json"), snapshot.inventoryId())); + } + + void installRuntimeSnapshot(RuntimeIndexService.ReadySnapshot snapshot, + RuntimeSnapshotBytecodeSource bytecodeSource) { + synchronized (lifecycleLock) { + if (switching) throw new IllegalStateException("Project is switching"); + ProjectScope scope = requireProject(); + CompanionProfile current = scope.profile(); + RuntimeBinding replacement; + try { + replacement = new RuntimeBinding(snapshot, current.dataDirectory(), bytecodeSource, scriptCompiler, codeInsightService); + } catch (IOException exception) { + throw new IllegalStateException("Unable to prepare the runtime class index", exception); + } + try { + closeRuntime(); + replacement.attach(); + // Queue before publication: rejected scheduling still leaves ownership with the loader. + // The follow-up acquires this lock after the loader finishes its installation callback. + projectWorker.execute(() -> finishRuntimeInstallation(replacement, scope)); + CompanionClassIndex.set(snapshot.index()); + scope.bindRuntime(replacement); + replacement.acceptOwnership(); + } catch (RuntimeException failure) { + replacement.close(); + throw failure; + } + } + } + + private void finishRuntimeInstallation(RuntimeBinding installed, ProjectScope selected) { + try { + CompletableFuture breakpoints; + synchronized (lifecycleLock) { + if (!selected.isActive() || selected.runtime() != installed || current != selected) return; + breakpoints = getDebuggerController().replaceBreakpointDefinitions( + selected.restoreBreakpoints(installed.snapshot().signature())); + } + // A failed debugger/UI refresh must never return ownership of an installed index to its loader. + breakpoints.join(); + prewarmJavaParser(); + } catch (RuntimeException failure) { + System.getLogger(CompanionApplication.class.getName()).log(System.Logger.Level.WARNING, + "Runtime installed, but debugger refresh failed", failure); + } finally { + CompanionUi view = ui; + if (view != null) SwingUtilities.invokeLater(() -> { + if (ui != view || !selected.isActive() || selected.runtime() != installed || current != selected) return; + view.runtimeChanged(); + List queued; + synchronized (lifecycleLock) { + if (!selected.isActive() || selected.runtime() != installed || current != selected) return; + queued = selected.drainNavigations(); + } + for (PendingNavigation pending : queued) { + view.navigate(pending.target(), pending.activation()); + } + }); + } + } + + static void setupDataDirectories(Path rootPath, boolean scriptExecutionEnabled) throws IOException { + + if (!scriptExecutionEnabled) { + return; + } + Files.createDirectories(new InstancePaths(rootPath).scripts()); + } + + private void prewarmJavaParser() { + if (!CompanionClassIndex.isOpen()) { + return; + } + Thread thread = new Thread(() -> { + ASTParser parser = JdtConfiguration.createParser(); + parser.setSource(new CompilationUnitImpl("Test", "class Test{}")); + parser.setResolveBindings(true); + parser.setKind(ASTParser.K_COMPILATION_UNIT); + CompilationUnit ignored = (CompilationUnit) parser.createAST(null); + }, "Companion JDT prewarm"); + thread.setDaemon(true); + thread.start(); + } + + private void startMcpServer() throws Exception { + var jobs = new CodeModeJobService(session, scriptExecutions, this::requireProject, + this::isConnected, this::runtimeContext); + startMcpServer(jobs, CompanionMcpServer.MCP_PORT); + } + + CompanionMcpServer startMcpServer(CodeModeJobService jobs, int port) throws Exception { + CompanionMcpServer server = new CompanionMcpServer(this, jobs, port); + try { + server.start(); + mcpServer = server; + System.err.println("TotalDebug Companion MCP listening at " + server.endpointUrl()); + return server; + } catch (Exception failure) { server.close(); throw failure; } + } + + private void startOptionalMcpServer() { + updateMcpStatus(new ServiceStatus( + ServiceStatus.State.PENDING, + "Starting", + "Starting the loopback MCP server." + )); + try { + startMcpServer(); + updateMcpStatus(new ServiceStatus( + ServiceStatus.State.AVAILABLE, + "Listening", + "MCP is listening at " + mcpServer.endpointUrl() + )); + } catch (Exception exception) { + updateMcpStatus(new ServiceStatus( + ServiceStatus.State.FAILED, + "Unavailable", + "MCP startup failed: " + exception + )); + System.err.println("TotalDebug Companion MCP is unavailable: " + exception.getMessage()); + exception.printStackTrace(System.err); + } + } + + private void closeMcpServer() { + CompanionMcpServer server = mcpServer; + mcpServer = null; + if (server != null) { + server.close(); + } + updateMcpStatus(new ServiceStatus( + ServiceStatus.State.INACTIVE, + "Stopped", + "The MCP server is stopped." + )); + } + + private Map runtimeContext() { + CompanionProfile current = currentProject(); + if (current == null) { + return Map.of(); + } + Map context = new LinkedHashMap<>(); + context.put("profile_id", current.id()); + context.put("workspace_directory", current.workspaceDirectory().toString()); + RuntimeBinding installed = currentRuntime(); + if (installed != null) { + context.put("runtime_signature", installed.snapshot().signature()); + context.put("index_file", installed.snapshot().indexFile().toString()); + } + return Map.copyOf(context); + } + + public MainWindow createWindow() { + if (!SwingUtilities.isEventDispatchThread()) throw new IllegalStateException("Create the window on the EDT"); + if (ui != null) throw new IllegalStateException("Application already has a UI"); + MainWindow window = new MainWindow(this::currentScope, getDebuggerController(), codeInsightService, + scriptExecutions, session, runtimeIndexService, this::openDebugFrame, this::exit); + ui = window; + window.setRuntimeIndexStatus(getRuntimeIndexStatus()); + ProjectScope scope = current; + if (scope != null && scope.isActive() && scope.runtime() != null) { + for (PendingNavigation pending : scope.drainNavigations()) window.navigate(pending.target(), pending.activation()); + } + return window; + } + + void startUi() throws InvocationTargetException, InterruptedException { + SwingUtilities.invokeAndWait(() -> { + MainWindow window = createWindow(); + window.setSize(1280, 720); + window.setVisible(true); + UIUtils.centerJFrame(window, window); + ToolTipManager.sharedInstance().setInitialDelay(200); + }); + } + + private void refreshUiProfile() { + CompanionUi view = ui; + if (view == null) return; + try { onEdtAndWait(view::refreshProfile); } + catch (InvocationTargetException failure) { throw new IllegalStateException("Unable to refresh the Companion UI", failure.getCause()); } + catch (InterruptedException failure) { Thread.currentThread().interrupt(); throw new IllegalStateException("Interrupted refreshing the Companion UI", failure); } + } + + private void onUi(Consumer action) { + CompanionUi view = ui; + if (view != null) SwingUtilities.invokeLater(() -> { + if (!closed && ui == view) action.accept(view); + }); + } + private void updateGameStatus(ServiceStatus status) { onUi(view -> view.setGameStatus(status)); } + private void updateMcpStatus(ServiceStatus status) { onUi(view -> view.setMcpStatus(status)); } + private void updateRuntimeIndexUi(RuntimeIndexService.Status status) { onUi(view -> view.setRuntimeIndexStatus(status)); } + public void focusWindow() { onUi(CompanionUi::focus); } + + public void exit() { + CompanionUi view = ui; + if (view != null) { + if (!SwingUtilities.isEventDispatchThread()) { SwingUtilities.invokeLater(this::exit); return; } + if (!view.canExit()) return; + try { GlobalConfig.getInstance().saveNow(); instanceState().saveNow(); } + catch (IOException failure) { view.showError("Unable to save state", failure.getMessage()); return; } + } + exitRequested.countDown(); + } + + private static void onEdtAndWait(Runnable action) throws InvocationTargetException, InterruptedException { + if (SwingUtilities.isEventDispatchThread()) action.run(); + else SwingUtilities.invokeAndWait(action); + } + + public boolean isConnected() { + return !closed && !switching && session != null && session.isConnected(); + } + + public boolean hasProfile() { + return current != null; + } + + public String getActiveRuntimeSignature() { + RuntimeBinding current = currentRuntime(); + return current == null ? null : current.snapshot().signature(); + } + + public boolean send(AbstractMessage message) { + if (switching && !(message instanceof StopScriptMessage)) return false; + CompanionSession current = session; + return current != null && current.send(message); + } + + public CompletableFuture compileJava(String source, String entryClass) { + try { return requireProject().admit(() -> scriptCompiler.compile(source, entryClass)); } + catch (IllegalStateException failure) { return CompletableFuture.failedFuture(failure); } + } + + /** A pre-send check; the receiving runtime must still validate the result's inventory identity. */ + public boolean isCurrentRuntimeInventory(String inventoryId) { + return isConnected() && scriptCompiler.isCurrentInventory(inventoryId); + } + + public ScriptExecutionService scriptExecutions() { return scriptExecutions; } + public CompanionSession session() { return session; } + + public void openClass(String binaryName, int targetType, String targetIdentifier) { + openOrQueue( + NavigationTargets.fromClassOpen(binaryName, targetType, targetIdentifier), + NavigationService.Activation.ACTIVATE_WINDOW + ); + } + + private void openOrQueue(NavigationTarget target, NavigationService.Activation activation) { + CompanionUi view; + ProjectScope scope; + RuntimeBinding installed; + synchronized (lifecycleLock) { + scope = current; + if (closed || switching || scope == null || !scope.isActive()) return; + view = ui; + installed = scope.runtime(); + if (view == null || installed == null) { scope.queueNavigation(target, activation); return; } + } + SwingUtilities.invokeLater(() -> { + if (ui == view && current == scope && scope.isActive() && scope.runtime() == installed) view.navigate(target, activation); + }); + } + + public void openDebugFrame( + DebugEngine.StackFrame frame, + boolean activateEditor + ) { + if (frame.binaryName().isBlank() || frame.line() < 1) { + return; + } + openOrQueue( + new NavigationTarget.RuntimeLine(frame.binaryName(), frame.line()), + activateEditor + ? NavigationService.Activation.ACTIVATE_WINDOW + : NavigationService.Activation.KEEP_CURRENT_WINDOW + ); + } + + private DebugEngine.Source loadDebugSource(String binaryName) throws IOException { + RuntimeBinding current = currentRuntime(); + CompanionDecompilationService service = current == null ? null : current.decompiler(); + return service == null ? null : service.loadDebugSource(binaryName); + } + + public AppPaths appPaths() { + return launchConfiguration.paths(); + } + + public InstancePaths instancePaths() { + return new InstancePaths(requireProfile().dataDirectory()); + } + + public InstanceState instanceState() { + ProjectScope scope = current; + return scope == null ? emptyState : scope.state(); + } + + public Path getRootPath() { + return requireProfile().dataDirectory(); + } + + public Path getWorkspaceDirectory() { + return requireProfile().workspaceDirectory(); + } + + public ReferenceSearchService getReferenceSearchService() { + RuntimeBinding current = currentRuntime(); + ReferenceSearchService service = current == null ? null : current.references(); + if (service == null) { + throw new IllegalStateException("Reference search is unavailable"); + } + return service; + } + + public CodeInsightService getCodeInsightService() { + CodeInsightService service = codeInsightService; + if (currentRuntime() == null) { + throw new IllegalStateException("Code insight is unavailable"); + } + return service; + } + + public RuntimeSourceCatalog getRuntimeSourceCatalog() { + RuntimeBinding current = currentRuntime(); + return current == null ? RuntimeSourceCatalog.empty() : current.sources(); + } + + public CompanionDecompilationService getDecompilationService() { + RuntimeBinding current = currentRuntime(); + CompanionDecompilationService service = current == null ? null : current.decompiler(); + if (service == null) { + throw new IllegalStateException("Decompilation is unavailable"); + } + return service; + } + + public DebuggerSessionController getDebuggerController() { + DebuggerSessionController controller = debuggerController; + if (controller == null) { + throw new IllegalStateException("Debugger controller is not initialized"); + } + return controller; + } + + public boolean isDebuggerConnected() { + DebuggerSessionController controller = debuggerController; + if (controller == null) { + return false; + } + DebuggerSessionController.Phase phase = controller.status().phase(); + return phase == DebuggerSessionController.Phase.RUNNING + || phase == DebuggerSessionController.Phase.PAUSED; + } + + private DebuggerSessionController createDebuggerController() { + DebuggerSessionController controller = new DebuggerSessionController(this::loadDebugSource, () -> { RuntimeBinding current = currentRuntime(); return current == null ? null : current.classpath(); }, name -> requireProject().loadBreakpointScript(name)); + controller.setBreakpointsMuted(instanceState().debuggerBreakpointsMuted()).join(); + controller.addListener(new DebuggerSessionController.Listener() { + @Override + public void breakpointsChanged( + URI sourceUri, + List breakpoints + ) { + ProjectScope scope = current; + if (scope != null) scope.persistBreakpoints(controller); + } + + @Override + public void breakpointsMutedChanged(boolean muted) { + ProjectScope scope = current; + if (scope != null && scope.isActive()) scope.state().setDebuggerBreakpointsMuted(muted); + } + }); + return controller; + } + + public RuntimeIndexService.Status getRuntimeIndexStatus() { + RuntimeIndexService service = runtimeIndexService; + return service == null + ? new RuntimeIndexService.Status(RuntimeIndexService.Phase.WAITING, "Waiting for runtime inventory", null) + : service.status(); + } + + public void addRuntimeIndexStatusListener(Consumer listener) { + RuntimeIndexService service = runtimeIndexService; + if (service != null) { + service.addStatusListener(listener); + } else { + listener.accept(getRuntimeIndexStatus()); + } + } + + public void removeRuntimeIndexStatusListener(Consumer listener) { + RuntimeIndexService service = runtimeIndexService; + if (service != null) service.removeStatusListener(listener); + } + + public void retryRuntimeIndex() { + RuntimeIndexService service = runtimeIndexService; + if (service != null) { + service.waiting("Requesting runtime inventory again"); + } + send(new RetryRuntimeInventoryMessage()); + } + + private CompanionProfile requireProfile() { + CompanionProfile current = currentProject(); + if (current == null) { + throw new IllegalStateException("No Minecraft profile is loaded"); + } + return current; + } + + public RuntimeBinding currentRuntime() { + ProjectScope scope = current; + return scope == null ? null : scope.runtime(); + } + + private void closeRuntime() { + ProjectScope scope = current; + CompanionClassIndex.clear(); + if (scope != null) scope.closeRuntime(); + } + +} diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/jdt/JDTHacks.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/jdt/JDTHacks.java index 09fda058..9f6a9ea2 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/jdt/JDTHacks.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/jdt/JDTHacks.java @@ -1,6 +1,5 @@ package com.github.minecraft_ta.totalDebugCompanion.jdt; -import com.github.minecraft_ta.totalDebugCompanion.CompanionApp; import com.github.minecraft_ta.totalDebugCompanion.jdt.impls.BundleContextImpl; import com.github.minecraft_ta.totalDebugCompanion.jdt.impls.ContentTypeManagerImpl; import com.github.minecraft_ta.totalDebugCompanion.jdt.impls.DummyJarPackageFragmentRoot; @@ -11,7 +10,7 @@ import org.eclipse.core.internal.runtime.MetaDataKeeper; import org.eclipse.core.resources.ResourcesPlugin; import org.eclipse.core.runtime.IPath; -import org.eclipse.core.runtime.Path; +import java.nio.file.Path; import org.eclipse.core.runtime.content.IContentTypeManager; import org.eclipse.jdt.core.IClasspathEntry; import org.eclipse.jdt.core.IType; @@ -26,8 +25,8 @@ public class JDTHacks { - public static final JavaProject DUMMY_JAVA_PROJECT; - private static final PackageFragmentRoot PACKAGE_FRAGMENT_ROOT; + public static JavaProject DUMMY_JAVA_PROJECT; + private static PackageFragmentRoot PACKAGE_FRAGMENT_ROOT; private static final Unsafe UNSAFE; static { try { @@ -35,13 +34,19 @@ public class JDTHacks { theUnsafe.setAccessible(true); UNSAFE = (Unsafe) theUnsafe.get(null); - init(); } catch (Throwable e) { throw new RuntimeException(e); } - DUMMY_JAVA_PROJECT = new JavaProjectImpl(); - PACKAGE_FRAGMENT_ROOT = new DummyJarPackageFragmentRoot(); + } + + public static synchronized void init(Path metadataPath) { + if (DUMMY_JAVA_PROJECT != null) return; + try { + initializeWorkspace(metadataPath); + DUMMY_JAVA_PROJECT = new JavaProjectImpl(); + PACKAGE_FRAGMENT_ROOT = new DummyJarPackageFragmentRoot(); + } catch (Throwable failure) { throw new IllegalStateException("Unable to initialize JDT", failure); } } public static PackageFragment createPackageFragment(String name) { @@ -99,7 +104,7 @@ public static void setField(Class clazz, Object o, String fieldName, Object v } } - private static void init() throws Throwable { + private static void initializeWorkspace(Path metadataPath) throws Throwable { //Set global instance new JavaCore(); @@ -130,7 +135,7 @@ private static void init() throws Throwable { value.open(true); field.set(InternalPlatform.getDefault(), value); - IPath metadataLocation = Path.fromOSString(CompanionApp.appPaths().jdtCache().toString()); + IPath metadataLocation = org.eclipse.core.runtime.Path.fromOSString(metadataPath.toString()); //cachedInstanceLocation field = InternalPlatform.class.getDeclaredField("cachedInstanceLocation"); diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/mcp/CompanionMcpServer.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/mcp/CompanionMcpServer.java index 6b91fe5e..c19392ae 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/mcp/CompanionMcpServer.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/mcp/CompanionMcpServer.java @@ -1,8 +1,7 @@ package com.github.minecraft_ta.totalDebugCompanion.mcp; -import com.github.minecraft_ta.totalDebugCompanion.CompanionApp; +import com.github.minecraft_ta.totalDebugCompanion.CompanionApplication; import com.github.minecraft_ta.totalDebugCompanion.project.ProjectScope; -import com.github.minecraft_ta.totalDebugCompanion.jdt.CompanionClassIndex; import io.modelcontextprotocol.json.McpJsonDefaults; import io.modelcontextprotocol.server.McpServer; import io.modelcontextprotocol.server.McpSyncServer; @@ -28,7 +27,7 @@ /** Loopback MCP host for Companion code mode. */ public final class CompanionMcpServer implements AutoCloseable { private static final String MCP_ENDPOINT = "/mcp"; - static final int MCP_PORT = 32_123; + public static final int MCP_PORT = 32_123; private static final int MAX_REQUEST_BYTES = 1_048_576; private final Path dataDirectory; @@ -45,13 +44,11 @@ public final class CompanionMcpServer implements AutoCloseable { private String endpointUrl; private boolean closed; - public CompanionMcpServer(Path dataDirectory, CodeModeJobService jobs) { - this(dataDirectory, jobs, MCP_PORT); - } - - CompanionMcpServer(Path dataDirectory, CodeModeJobService jobs, int port) { - this(dataDirectory, jobs, port, new DebuggerMcpService(CompanionApp::getDebuggerController, - name -> CompanionApp.getDecompilationService().loadDebugSource(name)), CompanionApp::requireProject); + public CompanionMcpServer(CompanionApplication application, CodeModeJobService jobs, int port) { + this(application.appPaths().home(), jobs, port, + new DebuggerMcpService(application::getDebuggerController, + name -> application.requireProject().requireRuntime().decompiler().loadDebugSource(name)), + application::requireProject); } CompanionMcpServer(Path dataDirectory, CodeModeJobService jobs, int port, DebuggerMcpService debugger, Supplier project) { @@ -60,10 +57,10 @@ public CompanionMcpServer(Path dataDirectory, CodeModeJobService jobs) { this.endpointDescriptor = new com.github.minecraft_ta.totaldebug.storage.AppPaths(this.dataDirectory).mcpEndpoint(); this.jobs = Objects.requireNonNull(jobs, "jobs"); this.debugger = Objects.requireNonNull(debugger, "debugger"); - this.runtimeSource = new CompanionMcpRuntimeSource(CompanionApp::getDecompilationService); + this.runtimeSource = new CompanionMcpRuntimeSource(() -> this.project.get().requireRuntime().decompiler()); this.search = new CompanionMcpSearchService( - CompanionClassIndex::get, - sourceId -> CompanionApp.getRuntimeSourceCatalog().moduleFor(sourceId) + () -> this.project.get().requireRuntime().snapshot().index(), + sourceId -> this.project.get().requireRuntime().sources().moduleFor(sourceId) ); if (port < 0 || port > 65_535) { throw new IllegalArgumentException("port is out of range"); @@ -205,7 +202,7 @@ private Map status() { return Map.of( "companion_available", true, "minecraft_connected", this.jobs.isAvailable(), - "debugger_connected", CompanionApp.isDebuggerConnected() + "debugger_connected", this.debugger.isConnected() ); } diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/mcp/DebuggerMcpService.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/mcp/DebuggerMcpService.java index fa6e3ccb..8eb1802e 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/mcp/DebuggerMcpService.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/mcp/DebuggerMcpService.java @@ -1,6 +1,5 @@ package com.github.minecraft_ta.totalDebugCompanion.mcp; -import com.github.minecraft_ta.totalDebugCompanion.CompanionApp; import com.github.minecraft_ta.totalDebugCompanion.project.ProjectScope; import com.github.minecraft_ta.totalDebugCompanion.debugger.DebugEngine; @@ -28,6 +27,13 @@ final class DebuggerMcpService { this.sources = Objects.requireNonNull(sources, "sources"); } + boolean isConnected() { + DebuggerSessionController session = controller.get(); + if (session == null) return false; + var phase = session.status().phase(); + return phase == DebuggerSessionController.Phase.RUNNING || phase == DebuggerSessionController.Phase.PAUSED; + } + Map call(String tool, Map args, ProjectScope project) throws IOException { DebuggerSessionController session = this.controller.get(); try { diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/CompanionUi.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/CompanionUi.java new file mode 100644 index 00000000..a5f37ece --- /dev/null +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/CompanionUi.java @@ -0,0 +1,23 @@ +package com.github.minecraft_ta.totalDebugCompanion.ui; + +import com.github.minecraft_ta.totalDebugCompanion.model.ServiceStatus; +import com.github.minecraft_ta.totalDebugCompanion.navigation.NavigationService; +import com.github.minecraft_ta.totalDebugCompanion.navigation.NavigationTarget; +import com.github.minecraft_ta.totalDebugCompanion.runtime.RuntimeIndexService; + +/** The application may run without a window; only these lifecycle operations cross into Swing. */ +public interface CompanionUi { + boolean prepareProjectSwitch(); + boolean closeProjectViews(); + boolean canExit(); + void setSwitching(boolean switching); + void refreshProfile(); + void runtimeChanged(); + void setGameStatus(ServiceStatus status); + void setMcpStatus(ServiceStatus status); + void setRuntimeIndexStatus(RuntimeIndexService.Status status); + void navigate(NavigationTarget target, NavigationService.Activation activation); + void focus(); + void showError(String title, String message); + void dispose(); +} 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 dfc6aa66..7f4f6157 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 @@ -9,7 +9,8 @@ import com.github.minecraft_ta.totalDebugCompanion.session.CompanionSession; import java.util.function.Supplier; import java.util.function.Consumer; -import com.github.minecraft_ta.totalDebugCompanion.CompanionApp; +import com.github.minecraft_ta.totalDebugCompanion.ui.CompanionUi; +import com.github.minecraft_ta.totalDebugCompanion.util.UIUtils; import com.github.minecraft_ta.totalDebugCompanion.Icons; import com.github.minecraft_ta.totalDebugCompanion.debugger.DebugEngine; import com.github.minecraft_ta.totalDebugCompanion.debugger.DebuggerSessionController; @@ -42,9 +43,8 @@ import java.awt.event.WindowAdapter; import java.awt.event.WindowEvent; -public class MainWindow extends JFrame implements AWTEventListener { +public class MainWindow extends JFrame implements AWTEventListener, CompanionUi { - public static final MainWindow INSTANCE = CompanionApp.createMainWindow(); private final EditorTabs editorTabs = new EditorTabs(); private final FileTreeView fileTreeView; @@ -187,6 +187,13 @@ public void windowClosing(WindowEvent event) { super.dispose(); } + @Override public boolean canExit() { return editorTabs.canCloseAll(); } + @Override public void setSwitching(boolean switching) { setEnabled(!switching); } + @Override public void runtimeChanged() { navigationService.runtimeChanged(); refreshRuntimeSources(); } + @Override public void navigate(NavigationTarget target, NavigationService.Activation activation) { navigation().navigate(target, activation); } + @Override public void focus() { UIUtils.focusWindow(this); } + @Override public void showError(String title, String message) { JOptionPane.showMessageDialog(this, message, title, JOptionPane.ERROR_MESSAGE); } + public EditorContext editorContext() { return new EditorContext(this, project.get(), insights, debugger, navigation(), scripts, session, this::showDebuggerValue); } diff --git a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/CompanionAppDataDirectoryTest.java b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/CompanionAppDataDirectoryTest.java index c7711d73..6ddc9d3e 100644 --- a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/CompanionAppDataDirectoryTest.java +++ b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/CompanionAppDataDirectoryTest.java @@ -15,7 +15,7 @@ class CompanionAppDataDirectoryTest { @Test void cleanScriptHomeDoesNotPersistGeneratedRuntimeSources() throws Exception { - CompanionApp.setupDataDirectories(this.appHome, true); + CompanionApplication.setupDataDirectories(this.appHome, true); assertTrue(Files.isDirectory(this.appHome.resolve("scripts"))); assertFalse(Files.exists(this.appHome.resolve("decompiled-files"))); diff --git a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/JdtTestEnvironment.java b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/JdtTestEnvironment.java new file mode 100644 index 00000000..52962250 --- /dev/null +++ b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/JdtTestEnvironment.java @@ -0,0 +1,18 @@ +package com.github.minecraft_ta.totalDebugCompanion; + +import com.github.minecraft_ta.totalDebugCompanion.jdt.JDTHacks; +import com.github.minecraft_ta.totaldebug.storage.AppPaths; +import org.junit.jupiter.api.extension.BeforeAllCallback; +import org.junit.jupiter.api.extension.ExtensionContext; +import java.nio.file.Path; + +public final class JdtTestEnvironment implements BeforeAllCallback { + public static final AppPaths PATHS = new AppPaths(Path.of("build", "test-app", Long.toString(ProcessHandle.current().pid())).toAbsolutePath()); + private static boolean initialized; + @Override public void beforeAll(ExtensionContext context) throws Exception { + if (initialized) return; + GlobalConfig.getInstance().loadFrom(PATHS.home()); + JDTHacks.init(PATHS.jdtCache()); + initialized = true; + } +} diff --git a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/ProjectSwitchLifecycleTest.java b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/ProjectSwitchLifecycleTest.java index 6a22908d..f37f4ccd 100644 --- a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/ProjectSwitchLifecycleTest.java +++ b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/ProjectSwitchLifecycleTest.java @@ -1,5 +1,7 @@ package com.github.minecraft_ta.totalDebugCompanion; +import com.github.minecraft_ta.totalDebugCompanion.ui.views.MainWindow; +import javax.swing.SwingUtilities; import com.github.minecraft_ta.totalDebugCompanion.ui.components.global.EditorTabs; import com.github.minecraft_ta.totalDebugCompanion.ui.components.treeView.FileTreeView; import com.github.minecraft_ta.totalDebugCompanion.session.CompanionLaunchConfiguration; @@ -12,14 +14,11 @@ import org.junit.jupiter.api.io.TempDir; import java.nio.file.Files; -import javax.swing.SwingUtilities; import java.nio.file.Path; import java.util.concurrent.TimeUnit; import java.util.concurrent.CompletableFuture; import java.util.Map; import com.github.minecraft_ta.totalDebugCompanion.mcp.ProjectSwitchJobs; -import com.github.minecraft_ta.totalDebugCompanion.script.ScriptExecutionService; -import com.github.minecraft_ta.totalDebugCompanion.script.ScriptCompilationService; import com.github.minecraft_ta.totalDebugCompanion.mcp.CodeModeJobService; import java.util.List; import java.util.concurrent.ExecutionException; @@ -27,46 +26,19 @@ import static org.junit.jupiter.api.Assertions.*; import com.github.minecraft_ta.totalDebugCompanion.navigation.NavigationService; import com.github.minecraft_ta.totalDebugCompanion.navigation.NavigationTarget; -import com.github.minecraft_ta.totalDebugCompanion.ui.views.MainWindow; class ProjectSwitchLifecycleTest { @TempDir Path directory; @Test void switchesTheActualApplicationStateWithoutAWindowOrGame() throws Exception { - // Companion owns process-wide singletons. Exercise its real switch in a fresh JVM. - String classpath = System.getProperty("totaldebug.testClasspath", System.getProperty("java.class.path")); - Path log = directory.resolve("probe.log"); - Process process = new ProcessBuilder(Path.of(System.getProperty("java.home"), "bin", "java").toString(), - "-Djava.awt.headless=false", "-cp", classpath, - getClass().getName(), directory.toString()).redirectErrorStream(true).redirectOutput(log.toFile()).start(); - try { - assertTrue(process.waitFor(180, TimeUnit.SECONDS), () -> "Switch did not finish: " + read(log)); - assertEquals(0, process.exitValue(), () -> read(log)); - } finally { if (process.isAlive()) process.destroyForcibly(); } - } - - public static void main(String[] args) { - try { - Path root = Path.of(args[0]); - AppPaths paths = new AppPaths(root.resolve("app")); - var registry = ProjectRegistry.open(paths); - set("launchConfiguration", new CompanionLaunchConfiguration(paths.home())); - set("projects", registry); - var createDebugger = CompanionApp.class.getDeclaredMethod("createDebuggerController"); - createDebugger.setAccessible(true); - set("debuggerController", createDebugger.invoke(null)); - var session = new com.github.minecraft_ta.totalDebugCompanion.session.CompanionSession("test-token"); + Path root = directory; + AppPaths paths = new AppPaths(root.resolve("app")); + GlobalConfig.getInstance().loadFrom(paths.home()); + try (var app = new CompanionApplication(new CompanionLaunchConfiguration(paths.home()), "test-token")) { + var session = app.session(); session.bindAndPublish(new CompanionLaunchConfiguration(paths.home())); - set("session", session); - set("scriptExecutions", new ScriptExecutionService(session, (ScriptCompilationService) get("scriptCompiler"), CompanionApp::isConnected)); - var jobs = ProjectSwitchJobs.create(); - var constructor = com.github.minecraft_ta.totalDebugCompanion.mcp.CompanionMcpServer.class.getDeclaredConstructor( - Path.class, com.github.minecraft_ta.totalDebugCompanion.mcp.CodeModeJobService.class, int.class); - constructor.setAccessible(true); - var mcp = (com.github.minecraft_ta.totalDebugCompanion.mcp.CompanionMcpServer) constructor.newInstance(paths.home(), jobs, 0); - mcp.start(); - set("mcpServer", mcp); + var mcp = app.startMcpServer(jobs, 0); String endpoint = mcp.endpointUrl(); var transport = io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport .builder(endpoint.substring(0, endpoint.length() - 4)).endpoint("/mcp").build(); @@ -76,80 +48,87 @@ public static void main(String[] args) { var b = profile(root, "B"); Files.writeString(a.dataDirectory().resolve("scripts/shared.tdscript"), "A"); Files.writeString(b.dataDirectory().resolve("scripts/shared.tdscript"), "B"); - CompanionApp.openProject(a).get(10, TimeUnit.SECONDS); - CompanionApp.instanceState().setDebuggerWatches(List.of("watch A")); - var admitted = CompanionApp.requireProject().admit(() -> jobs.submit("return 42;", List.of(), + app.openProject(a).get(10, TimeUnit.SECONDS); + app.instanceState().setDebuggerWatches(List.of("watch A")); + var admitted = app.requireProject().admit(() -> jobs.submit("return 42;", List.of(), CodeModeJobService.ExecutionSide.CLIENT, CodeModeJobService.ExecutionEnvironment.THREAD)); assertEquals(CodeModeJobService.JobState.COMPILING, admitted.state()); - CompanionApp.openProject(b).get(10, TimeUnit.SECONDS); + app.openProject(b).get(10, TimeUnit.SECONDS); assertEquals(CodeModeJobService.JobState.DISCONNECTED, jobs.get(admitted.jobId()).orElseThrow().state()); - assertEquals(b, CompanionApp.currentProject()); - assertTrue(CompanionApp.instanceState().debuggerWatches().isEmpty()); - CompanionApp.instanceState().setDebuggerWatches(List.of("watch B")); - CompanionApp.openProject(a).get(10, TimeUnit.SECONDS); - assertEquals(List.of("watch A"), CompanionApp.instanceState().debuggerWatches()); - assertEquals("A", Files.readString(CompanionApp.instancePaths().scripts().resolve("shared.tdscript"))); + assertEquals(b, app.currentProject()); + assertTrue(app.instanceState().debuggerWatches().isEmpty()); + app.instanceState().setDebuggerWatches(List.of("watch B")); + app.openProject(a).get(10, TimeUnit.SECONDS); + assertEquals(List.of("watch A"), app.instanceState().debuggerWatches()); + assertEquals("A", Files.readString(app.instancePaths().scripts().resolve("shared.tdscript"))); assertEquals("B", Files.readString(b.dataDirectory().resolve("scripts/shared.tdscript"))); var missing = new CompanionProfile("missing", root.resolve("absent/total-debug"), root.resolve("absent")); assertThrows(ExecutionException.class, - () -> CompanionApp.openProject(missing).get(10, TimeUnit.SECONDS)); - assertEquals(a, CompanionApp.currentProject()); + () -> app.openProject(missing).get(10, TimeUnit.SECONDS)); + assertEquals(a, app.currentProject()); assertFalse(Files.exists(missing.dataDirectory())); Files.writeString(b.dataDirectory().resolve("state.json"), "invalid state"); assertThrows(ExecutionException.class, - () -> CompanionApp.openProject(b).get(10, TimeUnit.SECONDS)); - assertEquals(a, CompanionApp.currentProject()); + () -> app.openProject(b).get(10, TimeUnit.SECONDS)); + assertEquals(a, app.currentProject()); assertEquals(a, ProjectRegistry.open(paths).selected()); Files.delete(b.dataDirectory().resolve("state.json")); Files.delete(b.dataDirectory().resolve("scripts/shared.tdscript")); Files.delete(b.dataDirectory().resolve("scripts")); Files.writeString(b.dataDirectory().resolve("scripts"), "not a directory"); assertThrows(ExecutionException.class, - () -> CompanionApp.openProject(b).get(10, TimeUnit.SECONDS)); - assertEquals(a, CompanionApp.currentProject()); + () -> app.openProject(b).get(10, TimeUnit.SECONDS)); + assertEquals(a, app.currentProject()); assertEquals(a, ProjectRegistry.open(paths).selected()); - assertEquals(2, CompanionApp.projects().size()); - assertFalse(CompanionApp.isSwitching()); + assertEquals(2, app.projects().size()); + assertFalse(app.isSwitching()); assertEquals(endpoint, mcp.endpointUrl()); var status = client.callTool(new io.modelcontextprotocol.spec.McpSchema.CallToolRequest("status", Map.of())); assertFalse(Boolean.TRUE.equals(status.isError()), "MCP must stay initialized through switches"); // A failed state flush is reversible, just like the editor save veto. Files.delete(b.dataDirectory().resolve("scripts")); Files.createDirectory(b.dataDirectory().resolve("scripts")); - var scopeBeforeFlush = CompanionApp.requireProject(); - CompanionApp.instanceState().saveNow(); + var scopeBeforeFlush = app.requireProject(); + app.instanceState().saveNow(); Path stateFile = a.dataDirectory().resolve("state.json"); byte[] savedState = Files.readAllBytes(stateFile); Files.delete(stateFile); Files.createDirectory(stateFile); Files.writeString(stateFile.resolve("occupied"), "x"); - CompanionApp.instanceState().setDebuggerWatches(List.of("pending A")); + app.instanceState().setDebuggerWatches(List.of("pending A")); assertThrows(ExecutionException.class, - () -> CompanionApp.openProject(b).get(10, TimeUnit.SECONDS)); - assertSame(scopeBeforeFlush, CompanionApp.requireProject()); + () -> app.openProject(b).get(10, TimeUnit.SECONDS)); + assertSame(scopeBeforeFlush, app.requireProject()); assertTrue(scopeBeforeFlush.admit(() -> true)); - assertFalse(CompanionApp.isSwitching()); + assertFalse(app.isSwitching()); Files.delete(stateFile.resolve("occupied")); Files.delete(stateFile); Files.write(stateFile, savedState); - CompanionApp.instanceState().saveNow(); - Files.delete(b.dataDirectory().resolve("scripts")); - Files.writeString(b.dataDirectory().resolve("scripts"), "restore fixture"); - verifyEditorSwitch(a, b, paths); - set("uiStarted", false); - var retired = CompanionApp.requireProject(); - CompanionApp.getDebuggerController().close(); - CompanionApp.openProject(a).get(10, TimeUnit.SECONDS); - assertEquals(a, CompanionApp.currentProject(), "A broken debugger must not strand project selection"); + app.instanceState().saveNow(); + + var retired = app.requireProject(); + app.getDebuggerController().close(); + app.openProject(b).get(10, TimeUnit.SECONDS); + assertEquals(b, app.currentProject(), "A broken debugger must not strand project selection"); assertEquals(ProjectScope.Phase.RETIRED, retired.phase()); assertThrows(IllegalStateException.class, () -> retired.state().setDebuggerWatches(List.of("closed"))); client.close(); mcp.close(); session.close(); - System.exit(0); - } catch (Throwable failure) { - failure.printStackTrace(); - System.exit(1); + } + } + + @Test void editorVetoAndStaleNavigationKeepTheRightProject() throws Exception { + AppPaths paths = new AppPaths(directory.resolve("app")); + GlobalConfig.getInstance().loadFrom(paths.home()); + CompanionApp.configureLookAndFeel(); + try (var app = new CompanionApplication(new CompanionLaunchConfiguration(paths.home()), "test-token")) { + var a = profile(directory, "A"); + var b = profile(directory, "B"); + app.openProject(a).get(10, TimeUnit.SECONDS); + Files.delete(b.dataDirectory().resolve("scripts")); + Files.writeString(b.dataDirectory().resolve("scripts"), "restore fixture"); + verifyEditorSwitch(app, a, b, paths); } } @@ -160,7 +139,7 @@ private static CompanionProfile profile(Path root, String id) throws Exception { return new CompanionProfile(id, data, game); } - private static void verifyEditorSwitch(CompanionProfile a, CompanionProfile b, AppPaths paths) throws Exception { + private static void verifyEditorSwitch(CompanionApplication app, CompanionProfile a, CompanionProfile b, AppPaths paths) throws Exception { Files.delete(b.dataDirectory().resolve("scripts")); Files.createDirectory(b.dataDirectory().resolve("scripts")); var allowed = new java.util.concurrent.atomic.AtomicBoolean(); @@ -173,40 +152,42 @@ private static void verifyEditorSwitch(CompanionProfile a, CompanionProfile b, A public java.awt.Component getComponent() { return panel; } public boolean canClose() { if (!allowed.get()) return false; - assertEquals(a, CompanionApp.currentProject(), "Save must run before selecting B"); + assertEquals(a, app.currentProject(), "Save must run before selecting B"); try { Files.writeString(a.dataDirectory().resolve("scripts/shared.tdscript"), "saved A"); } catch (java.io.IOException failure) { return false; } return true; } public void dispose() { disposed.set(true); } }; - GlobalConfig.getInstance().loadFrom(((CompanionLaunchConfiguration) get("launchConfiguration")).appHome()); - CompanionApp.configureLookAndFeel(); - javax.swing.SwingUtilities.invokeAndWait(() -> - com.github.minecraft_ta.totalDebugCompanion.ui.views.MainWindow.INSTANCE.getEditorTabs().openEditorTab(editor)); - set("uiStarted", true); - var scopeBeforeVeto = CompanionApp.requireProject(); + var createdWindow = new CompletableFuture(); + SwingUtilities.invokeAndWait(() -> { + MainWindow window = app.createWindow(); + window.getEditorTabs().openEditorTab(editor); + createdWindow.complete(window); + }); + var window = createdWindow.join(); + var scopeBeforeVeto = app.requireProject(); assertThrows(ExecutionException.class, - () -> CompanionApp.openProject(b).get(10, TimeUnit.SECONDS)); - assertEquals(a, CompanionApp.currentProject()); - assertSame(scopeBeforeVeto, CompanionApp.requireProject()); + () -> app.openProject(b).get(10, TimeUnit.SECONDS)); + assertEquals(a, app.currentProject()); + assertSame(scopeBeforeVeto, app.requireProject()); assertEquals("still A", scopeBeforeVeto.admit(() -> "still A")); assertFalse(disposed.get()); allowed.set(true); - CompanionApp.instanceState().saveNow(); + app.instanceState().saveNow(); Path stateFile = a.dataDirectory().resolve("state.json"); byte[] savedState = Files.readAllBytes(stateFile); Files.delete(stateFile); Files.createDirectory(stateFile); Files.writeString(stateFile.resolve("occupied"), "x"); - CompanionApp.instanceState().setDebuggerWatches(List.of("pending view state")); + app.instanceState().setDebuggerWatches(List.of("pending view state")); try { - assertThrows(ExecutionException.class, () -> CompanionApp.openProject(b).get(10, TimeUnit.SECONDS)); - assertSame(scopeBeforeVeto, CompanionApp.requireProject()); + assertThrows(ExecutionException.class, () -> app.openProject(b).get(10, TimeUnit.SECONDS)); + assertSame(scopeBeforeVeto, app.requireProject()); assertFalse(disposed.get(), "A failed state flush must preserve open views"); SwingUtilities.invokeAndWait(() -> { - assertSame(editor, MainWindow.INSTANCE.getEditorTabs().getSelectedEditor()); - assertTrue(MainWindow.INSTANCE.isEnabled()); + assertSame(editor, window.getEditorTabs().getSelectedEditor()); + assertTrue(window.isEnabled()); }); } finally { Files.delete(stateFile.resolve("occupied")); @@ -218,13 +199,12 @@ public boolean canClose() { Files.createDirectory(paths.projects()); Files.writeString(paths.projects().resolve("occupied"), "x"); var failure = assertThrows(ExecutionException.class, - () -> CompanionApp.openProject(b).get(10, TimeUnit.SECONDS)); + () -> app.openProject(b).get(10, TimeUnit.SECONDS)); assertTrue(failure.getCause().getMessage().contains("Project opened, but its selection could not be saved")); - assertEquals(b, CompanionApp.currentProject()); - assertTrue(CompanionApp.instanceState().debuggerWatches().isEmpty()); + assertEquals(b, app.currentProject()); + assertTrue(app.instanceState().debuggerWatches().isEmpty()); assertTrue(disposed.get()); assertEquals("saved A", Files.readString(a.dataDirectory().resolve("scripts/shared.tdscript"))); - var window = com.github.minecraft_ta.totalDebugCompanion.ui.views.MainWindow.INSTANCE; var treeField = window.getClass().getDeclaredField("fileTreeView"); treeField.setAccessible(true); var treeView = (javax.swing.JScrollPane) treeField.get(window); @@ -239,28 +219,28 @@ public boolean canClose() { Files.delete(paths.projects()); Files.write(paths.projects(), savedRegistry); assertEquals(a, ProjectRegistry.open(paths).selected()); - CompanionApp.openProject(b).get(10, TimeUnit.SECONDS); + app.openProject(b).get(10, TimeUnit.SECONDS); assertEquals(b, ProjectRegistry.open(paths).selected()); - verifyNavigationReset(window); + verifyNavigationReset(app, window); javax.swing.SwingUtilities.invokeAndWait(window::dispose); } - private static void verifyNavigationReset(com.github.minecraft_ta.totalDebugCompanion.ui.views.MainWindow window) throws Exception { + private static void verifyNavigationReset(CompanionApplication app, MainWindow window) throws Exception { var pending = new java.util.concurrent.atomic.AtomicReference>(); var created = new CompletableFuture(); javax.swing.SwingUtilities.invokeAndWait(() -> { - var tree = new FileTreeView(CompanionApp::currentScope, ignored -> { }) { + var tree = new FileTreeView(app::currentScope, ignored -> { }) { @Override public CompletableFuture revealLocalDirectory(Path path) { var delayed = pending.getAndSet(null); return delayed == null ? CompletableFuture.completedFuture(true) : delayed; } }; created.complete(new NavigationService(window, - new EditorTabs(), tree, CompanionApp.currentScope(), window::editorContext)); + new EditorTabs(), tree, app.currentScope(), window::editorContext)); }); var navigation = created.join(); - var scopeA = new ProjectScope(new Object(), CompanionApp.currentProject(), InstanceState.inMemory()); - var scopeB = new ProjectScope(new Object(), CompanionApp.currentProject(), InstanceState.inMemory()); + var scopeA = new ProjectScope(new Object(), app.currentProject(), InstanceState.inMemory()); + var scopeB = new ProjectScope(new Object(), app.currentProject(), InstanceState.inMemory()); navigation.projectChanged(scopeA); for (String directory : List.of("A/one", "A/two")) navigation.navigate(new NavigationTarget.LocalDirectory(Path.of(directory))).get(3, TimeUnit.SECONDS); @@ -302,19 +282,4 @@ private static void verifyNavigationReset(com.github.minecraft_ta.totalDebugComp javax.swing.SwingUtilities.invokeAndWait(() -> assertTrue(navigation.backAction().isEnabled())); } - private static Object get(String name) throws Exception { - var field = CompanionApp.class.getDeclaredField(name); - field.setAccessible(true); - return field.get(null); - } - - private static void set(String name, Object value) throws Exception { - var field = CompanionApp.class.getDeclaredField(name); - field.setAccessible(true); - field.set(null, value); - } - - private static String read(Path file) { - try { return Files.readString(file); } catch (Exception failure) { return failure.toString(); } - } } 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 be354c32..67463d61 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,11 +1,10 @@ package com.github.minecraft_ta.totalDebugCompanion; +import java.util.concurrent.CompletableFuture; import com.github.minecraft_ta.totalDebugCompanion.model.ResourceView; import com.github.minecraft_ta.totalDebugCompanion.resource.ArchiveEntrySource; import com.github.minecraft_ta.totalDebugCompanion.ui.views.MainWindow; import javax.swing.SwingUtilities; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.atomic.AtomicReference; import com.github.minecraft_ta.totalDebugCompanion.bytecode.RuntimeSnapshotBytecodeSource; import com.github.minecraft_ta.totalDebugCompanion.runtime.RuntimeIndexService.ReadySnapshot; import com.github.minecraft_ta.totalDebugCompanion.session.CompanionLaunchConfiguration; @@ -15,11 +14,9 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; -import java.lang.reflect.InvocationTargetException; import java.nio.file.Files; import java.nio.file.Path; import java.util.List; -import java.util.concurrent.ExecutorService; import java.util.concurrent.TimeUnit; import static org.junit.jupiter.api.Assertions.*; @@ -27,81 +24,42 @@ class RuntimeInstallationTest { @TempDir Path directory; - @Test - void candidateFailureAndPostPublicationFailureRespectTheOwnershipBoundary() throws Exception { - Path log = directory.resolve("install.log"); - Process process = new ProcessBuilder(Path.of(System.getProperty("java.home"), "bin", "java").toString(), - "-cp", System.getProperty("totaldebug.testClasspath", System.getProperty("java.class.path")), - getClass().getName(), directory.toString()).redirectErrorStream(true).redirectOutput(log.toFile()).start(); - try { - assertTrue(process.waitFor(25, TimeUnit.SECONDS), () -> read(log)); - assertEquals(0, process.exitValue(), () -> read(log)); - } finally { if (process.isAlive()) process.destroyForcibly(); } - } - - public static void main(String[] args) { - try { - Path root = Path.of(args[0]); - var config = CompanionApp.class.getDeclaredField("launchConfiguration"); - config.setAccessible(true); - config.set(null, new CompanionLaunchConfiguration(Files.createDirectories(root.resolve("app")))); - CompanionApp.configureWithoutSession(new CompanionProfile("test", Files.createDirectories(root.resolve("data")), - Files.createDirectories(root.resolve("game")))); - GlobalConfig.getInstance().loadFrom(root.resolve("app")); - CompanionApp.configureLookAndFeel(); - SwingUtilities.invokeAndWait(() -> MainWindow.INSTANCE.getEditorTabs().openEditorTab( - new ResourceView(MainWindow.INSTANCE.editorContext(), new ArchiveEntrySource(root.resolve("old.jar"), "old.txt", -1), null))); - var uiStarted = CompanionApp.class.getDeclaredField("uiStarted"); - uiStarted.setAccessible(true); - uiStarted.set(null, true); - // Force debugger restoration to fail after the index is published. - CompanionApp.getDebuggerController().close(); - var install = CompanionApp.class.getDeclaredMethod("installRuntimeSnapshot", ReadySnapshot.class, - RuntimeSnapshotBytecodeSource.class); - install.setAccessible(true); - var queue = CompanionApp.class.getDeclaredField("projectWorker"); - queue.setAccessible(true); - var close = CompanionApp.class.getDeclaredMethod("closeRuntime"); - close.setAccessible(true); + @Test void installedRuntimeSurvivesDebuggerFailureAndRefreshesUi() throws Exception { + Path root = directory; + Path home = Files.createDirectories(root.resolve("app")); + GlobalConfig.getInstance().loadFrom(home); + CompanionApp.configureLookAndFeel(); + try (var app = new CompanionApplication(new CompanionLaunchConfiguration(home), "test-token")) { + var profile = new CompanionProfile("test", Files.createDirectories(root.resolve("data")), + Files.createDirectories(root.resolve("game"))); + app.openProject(profile).get(10, TimeUnit.SECONDS); + var created = new CompletableFuture(); + SwingUtilities.invokeAndWait(() -> { + MainWindow window = app.createWindow(); + window.getEditorTabs().openEditorTab(new ResourceView(window.editorContext(), + new ArchiveEntrySource(root.resolve("old.jar"), "old.txt", -1), null)); + created.complete(window); + }); + MainWindow window = created.join(); + app.getDebuggerController().close(); try (var accepted = snapshot(root, "accepted"); var rejected = snapshot(root, "")) { var bytes = RuntimeSnapshotBytecodeSource.fromIndexedSources(accepted.sources(), accepted.index()); - var releaseRefresh = new CountDownLatch(1); - var refreshBlocked = new CountDownLatch(1); - ((ExecutorService) queue.get(null)).submit(() -> { - refreshBlocked.countDown(); - try { assertTrue(releaseRefresh.await(10, TimeUnit.SECONDS)); } - catch (InterruptedException failure) { throw new AssertionError(failure); } - }); - assertTrue(refreshBlocked.await(3, TimeUnit.SECONDS)); - var newView = new AtomicReference(); - try { - install.invoke(null, accepted, bytes); - SwingUtilities.invokeAndWait(() -> { - var view = new ResourceView(MainWindow.INSTANCE.editorContext(), new ArchiveEntrySource(root.resolve("old.jar"), "old.txt", -1), CompanionApp.currentRuntime()); - newView.set(view); - MainWindow.INSTANCE.getEditorTabs().openEditorTab(view); - }); - } finally { releaseRefresh.countDown(); } - ((ExecutorService) queue.get(null)).submit(() -> {}).get(10, TimeUnit.SECONDS); + app.installRuntimeSnapshot(accepted, bytes); + app.openProject(profile).get(10, TimeUnit.SECONDS); SwingUtilities.invokeAndWait(() -> {}); - SwingUtilities.invokeAndWait(() -> { - assertEquals(1, MainWindow.INSTANCE.getEditorTabs().getTabCount(), "Late invalidation must close only the old runtime tab"); - assertSame(newView.get(), MainWindow.INSTANCE.getEditorTabs().getSelectedEditor()); - }); - assertFalse(accepted.index().isDestroyed(), "Post-publication failure must not close the installed index"); - var decompiler = CompanionApp.getDecompilationService(); + SwingUtilities.invokeAndWait(() -> assertEquals(0, window.getEditorTabs().getTabCount(), + "Old runtime tabs must close even when breakpoint restoration fails")); + assertFalse(accepted.index().isDestroyed()); + var decompiler = app.getDecompilationService(); var candidateBytes = RuntimeSnapshotBytecodeSource.fromIndexedSources(rejected.sources(), rejected.index()); - var failed = assertThrows(InvocationTargetException.class, () -> install.invoke(null, rejected, candidateBytes)); - assertInstanceOf(IllegalArgumentException.class, failed.getCause()); - assertSame(decompiler, CompanionApp.getDecompilationService()); - assertTrue(bytes.hasClass("java.lang.Object"), "Failed preparation must not close the previous bytecode source"); + assertThrows(IllegalArgumentException.class, () -> app.installRuntimeSnapshot(rejected, candidateBytes)); + assertSame(decompiler, app.getDecompilationService()); + assertTrue(bytes.hasClass("java.lang.Object")); assertFalse(rejected.index().isDestroyed(), "Loader still owns the rejected candidate"); - close.invoke(null); + app.close(); assertTrue(accepted.index().isDestroyed()); } - SwingUtilities.invokeAndWait(MainWindow.INSTANCE::dispose); - System.exit(0); - } catch (Throwable failure) { failure.printStackTrace(); System.exit(1); } + } } private static ReadySnapshot snapshot(Path root, String signature) throws Exception { diff --git a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/ScriptPanelDisposalTest.java b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/ScriptPanelDisposalTest.java index 6dfeeff3..a26e2885 100644 --- a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/ScriptPanelDisposalTest.java +++ b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/ScriptPanelDisposalTest.java @@ -5,7 +5,6 @@ import com.github.minecraft_ta.totalDebugCompanion.session.CompanionLaunchConfiguration; import com.github.minecraft_ta.totalDebugCompanion.session.CompanionProfile; import com.github.minecraft_ta.totalDebugCompanion.ui.components.editors.ScriptPanel; -import com.github.tth05.scnet.Server; import com.github.tth05.scnet.message.AbstractMessage; import com.github.tth05.scnet.message.impl.DefaultMessageBus; import org.junit.jupiter.api.Test; @@ -25,59 +24,36 @@ class ScriptPanelDisposalTest { @TempDir Path directory; - @Test - void panelsOwnTheirPopupsAndUnsubscribeOnDirectDisposal() throws Exception { - String classpath = System.getProperty("totaldebug.testClasspath", System.getProperty("java.class.path")); - Path log = directory.resolve("probe.log"); - Process process = new ProcessBuilder(Path.of(System.getProperty("java.home"), "bin", "java").toString(), - "-Djava.awt.headless=false", "-cp", classpath, getClass().getName(), directory.toString()) - .redirectErrorStream(true).redirectOutput(log.toFile()).start(); - try { - assertTrue(process.waitFor(25, TimeUnit.SECONDS), () -> read(log)); - assertEquals(0, process.exitValue(), () -> read(log)); - } finally { if (process.isAlive()) process.destroyForcibly(); } - } - - public static void main(String[] args) { - try { - Path root = Path.of(args[0]); - Path appHome = Files.createDirectories(root.resolve("app")); - var launch = CompanionApp.class.getDeclaredField("launchConfiguration"); - launch.setAccessible(true); - launch.set(null, new CompanionLaunchConfiguration(appHome)); - GlobalConfig.getInstance().loadFrom(appHome); - CompanionApp.configureWithoutSession(CompanionProfile.forGame(Files.createDirectories(root.resolve("game")))); - CompanionApp.configureLookAndFeel(); - CompanionApp.configureTokenMakers(); - try (var session = CompanionApp.session()) { - var server = session.server(); - var bus = new TrackingBus(); - server.setMessageBus(bus); - SwingUtilities.invokeAndWait(() -> { - var first = (ScriptPanel) new ScriptView(MainWindow.INSTANCE.editorContext(), "First").getComponent(); - var second = (ScriptPanel) new ScriptView(MainWindow.INSTANCE.editorContext(), "Second").getComponent(); - Window firstCompletion = popup(first, "codeCompletionPopup"); - Window firstSignature = popup(first, "signatureHelpPopup"); - Window secondCompletion = popup(second, "codeCompletionPopup"); - assertNotSame(firstCompletion, secondCompletion); - firstCompletion.pack(); - firstSignature.pack(); - secondCompletion.pack(); - assertEquals(2, bus.owners.size()); - try (var replacement = new Server()) { - first.dispose(); - first.dispose(); - assertFalse(firstCompletion.isDisplayable()); - assertFalse(firstSignature.isDisplayable()); - assertTrue(secondCompletion.isDisplayable()); - assertEquals(1, bus.owners.size()); - second.dispose(); - assertTrue(bus.owners.isEmpty()); - } - }); - } - System.exit(0); - } catch (Throwable failure) { failure.printStackTrace(); System.exit(1); } + @Test void panelsOwnTheirPopupsAndUnsubscribeOnDirectDisposal() throws Exception { + Path home = Files.createDirectories(directory.resolve("app")); + GlobalConfig.getInstance().loadFrom(home); + CompanionApp.configureLookAndFeel(); + try (var app = new CompanionApplication(new CompanionLaunchConfiguration(home), "test-token")) { + app.openProject(CompanionProfile.forGame(Files.createDirectories(directory.resolve("game")))).get(10, TimeUnit.SECONDS); + var bus = new TrackingBus(); + app.session().server().setMessageBus(bus); + SwingUtilities.invokeAndWait(() -> { + MainWindow window = app.createWindow(); + var first = (ScriptPanel) new ScriptView(window.editorContext(), "First").getComponent(); + var second = (ScriptPanel) new ScriptView(window.editorContext(), "Second").getComponent(); + Window firstCompletion = popup(first, "codeCompletionPopup"); + Window firstSignature = popup(first, "signatureHelpPopup"); + Window secondCompletion = popup(second, "codeCompletionPopup"); + assertNotSame(firstCompletion, secondCompletion); + firstCompletion.pack(); + firstSignature.pack(); + secondCompletion.pack(); + assertEquals(2, bus.owners.size()); + first.dispose(); + first.dispose(); + assertFalse(firstCompletion.isDisplayable()); + assertFalse(firstSignature.isDisplayable()); + assertTrue(secondCompletion.isDisplayable()); + assertEquals(1, bus.owners.size()); + second.dispose(); + assertTrue(bus.owners.isEmpty()); + }); + } } private static Window popup(ScriptPanel panel, String name) { diff --git a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/UiDevHarness.java b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/UiDevHarness.java index 0d3edd9f..741664a5 100644 --- a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/UiDevHarness.java +++ b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/UiDevHarness.java @@ -9,6 +9,10 @@ import com.github.minecraft_ta.totalDebugCompanion.model.UsagesView; import com.github.minecraft_ta.totalDebugCompanion.resource.ArchiveEntrySource; import com.github.minecraft_ta.totalDebugCompanion.session.CompanionProfile; +import com.github.minecraft_ta.totalDebugCompanion.session.CompanionLaunchConfiguration; +import com.github.minecraft_ta.totalDebugCompanion.bytecode.RuntimeSnapshotBytecodeSource; +import com.github.minecraft_ta.totaldebug.storage.RuntimeInventory; +import java.util.concurrent.atomic.AtomicBoolean; import com.github.minecraft_ta.totalDebugCompanion.runtime.RuntimeIndexService; import com.github.minecraft_ta.totalDebugCompanion.source.SourceLineMap; import com.github.minecraft_ta.totalDebugCompanion.source.SourceVariableNames; @@ -58,9 +62,8 @@ * ./gradlew :companion:uiContactSheet * * - *

Lives in {@code src/test} on purpose: production static-initialisation order is delicate here - * (see {@code SearchEverywherePopupProcessTest} and commit fbaa744), so this must not become - * something the shipped jar can load. It deliberately calls the same + *

Lives in {@code src/test}: it constructs a real application with generated sample data. + * It calls the same * {@link CompanionApp#configureLookAndFeel()} the real startup path uses rather than re-implementing * it, so what you see is what the app does. * @@ -69,6 +72,28 @@ *

Press F9 for FlatLaf's component inspector, F10 for the UI defaults inspector. */ public final class UiDevHarness { + private static CompanionApplication application; + private static MainWindow mainWindow; + private static final AtomicBoolean finishing = new AtomicBoolean(); + + private static void finishHarness(int result) { + if (!finishing.compareAndSet(false, true)) return; + Thread.ofPlatform().name("UI harness shutdown").start(() -> { + try { if (application != null) application.close(); } + finally { System.exit(result); } + }); + } + + private static void installRuntimeFixture(Path indexFile, Path classes) throws Exception { + ClassIndex index = ClassIndex.fromFile(indexFile.toString()); + var source = new RuntimeSnapshotBytecodeSource.Source(0, classes, classes.toUri().toASCIIString(), + new RuntimeInventory.RuntimeModule("ui-development", "UI development classes", RuntimeInventory.ModuleKind.LIBRARY)); + var sources = List.of(source); + var snapshot = new RuntimeIndexService.ReadySnapshot("ui-development", "ui-dev", indexFile, sources, index); + try { application.installRuntimeSnapshot(snapshot, RuntimeSnapshotBytecodeSource.fromIndexedSources(sources, index)); } + catch (RuntimeException failure) { snapshot.close(); throw failure; } + } + /** Exercises every token type the editor palette maps, so theming regressions are visible. */ private static Path writeSampleSource(Path target) throws java.io.IOException { @@ -261,7 +286,7 @@ private static T findComponent(Container root, Class ty private static void scheduleSearchEverywhereInteractionVerification() { javax.swing.Timer openTimer = new javax.swing.Timer(500, event -> { - MainWindow.INSTANCE.openSearchEverywhere(); + mainWindow.openSearchEverywhere(); SearchEverywherePopup popup = Arrays.stream(java.awt.Window.getWindows()) .filter(SearchEverywherePopup.class::isInstance) .map(SearchEverywherePopup.class::cast) @@ -272,7 +297,7 @@ private static void scheduleSearchEverywhereInteractionVerification() { var focusListeners = popup.getWindowFocusListeners(); for (var listener : focusListeners) popup.removeWindowFocusListener(listener); javax.swing.Timer firstQuery = new javax.swing.Timer(250, queryEvent -> { - popup.setLocation(MainWindow.INSTANCE.getX() + 220, MainWindow.INSTANCE.getY() + 70); + popup.setLocation(mainWindow.getX() + 220, mainWindow.getY() + 70); FlatIconTextField search = findComponent(popup, FlatIconTextField.class); if (search == null) { throw new IllegalStateException("Search Everywhere query field was not found"); @@ -321,8 +346,8 @@ private static void scheduleSearchEverywhereInteractionVerification() { if (popup.isVisible()) throw new IllegalStateException("Losing focus must dismiss Search Everywhere"); System.out.println("Search Everywhere interaction verification passed"); popup.dispose(); - MainWindow.INSTANCE.dispose(); - System.exit(0); + mainWindow.dispose(); + finishHarness(0); }); verifyUpdated.start(); }); @@ -336,7 +361,7 @@ private static void scheduleSearchEverywhereInteractionVerification() { } private static void scheduleMethodNavigationVerification() { - javax.swing.Timer openTimer = new javax.swing.Timer(500, event -> CompanionApp.openClass( + javax.swing.Timer openTimer = new javax.swing.Timer(500, event -> application.openClass( "sample.ThemeSampleImpl", org.eclipse.jdt.core.IJavaElement.METHOD, "Lsample/ThemeSampleImpl;.apply(Lsample/ThemeSample;)V" @@ -347,7 +372,7 @@ private static void scheduleMethodNavigationVerification() { long deadline = System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(12); javax.swing.Timer verifyTimer = new javax.swing.Timer(100, event -> { try { - var selected = MainWindow.INSTANCE.getEditorTabs().getSelectedEditor(); + var selected = mainWindow.getEditorTabs().getSelectedEditor(); if (selected instanceof CodeView codeView && codeView.getTitle().equals("ThemeSampleImpl")) { RSyntaxTextArea editor = findComponent( @@ -361,8 +386,8 @@ private static void scheduleMethodNavigationVerification() { if (lineText.contains(" apply(")) { ((javax.swing.Timer) event.getSource()).stop(); System.out.println("METHOD_NAVIGATION_OK line=" + (line + 1) + " caret=" + caret); - MainWindow.INSTANCE.dispose(); - System.exit(0); + mainWindow.dispose(); + finishHarness(0); } } if (System.nanoTime() >= deadline) { @@ -396,8 +421,8 @@ private static void scheduleMethodNavigationVerification() { } } catch (Exception exception) { exception.printStackTrace(System.err); - MainWindow.INSTANCE.dispose(); - System.exit(2); + mainWindow.dispose(); + finishHarness(2); } }); verifyTimer.setInitialDelay(0); @@ -486,7 +511,7 @@ private static void startThemeCycling() { private static void scheduleCodeVisionClickVerification(String source) { javax.swing.Timer clickTimer = new javax.swing.Timer(1400, event -> { try { - RSyntaxTextArea editor = findComponent(MainWindow.INSTANCE, RSyntaxTextArea.class); + RSyntaxTextArea editor = findComponent(mainWindow, RSyntaxTextArea.class); if (editor == null) { throw new IllegalStateException("Code editor was not found"); } @@ -500,19 +525,19 @@ private static void scheduleCodeVisionClickVerification(String source) { long deadline = System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(5); javax.swing.Timer resultTimer = new javax.swing.Timer(250, resultEvent -> { - var selected = MainWindow.INSTANCE.getEditorTabs().getSelectedEditor(); + var selected = mainWindow.getEditorTabs().getSelectedEditor(); if (selected instanceof UsagesView) { ((javax.swing.Timer) resultEvent.getSource()).stop(); System.out.println("CODE_VISION_CLICK_OK"); - MainWindow.INSTANCE.dispose(); - System.exit(0); + mainWindow.dispose(); + finishHarness(0); } if (System.nanoTime() >= deadline) { ((javax.swing.Timer) resultEvent.getSource()).stop(); System.err.println("CODE_VISION_CLICK_MISSED selected=" + (selected == null ? "null" : selected.getClass().getName())); - MainWindow.INSTANCE.dispose(); - System.exit(2); + mainWindow.dispose(); + finishHarness(2); } dispatchMouseMove(editor, target); dispatchLeftClick(editor, target); @@ -521,8 +546,8 @@ private static void scheduleCodeVisionClickVerification(String source) { resultTimer.start(); } catch (Exception exception) { exception.printStackTrace(System.err); - MainWindow.INSTANCE.dispose(); - System.exit(3); + mainWindow.dispose(); + finishHarness(3); } }); clickTimer.setRepeats(false); @@ -532,8 +557,8 @@ private static void scheduleCodeVisionClickVerification(String source) { private static void scheduleGutterClickVerification(String source) { javax.swing.Timer setupTimer = new javax.swing.Timer(1400, event -> { try { - RSyntaxTextArea editor = findComponent(MainWindow.INSTANCE, RSyntaxTextArea.class); - IconRowHeader iconRow = findComponent(MainWindow.INSTANCE, IconRowHeader.class); + RSyntaxTextArea editor = findComponent(mainWindow, RSyntaxTextArea.class); + IconRowHeader iconRow = findComponent(mainWindow, IconRowHeader.class); if (editor == null || iconRow == null) { throw new IllegalStateException("Code editor gutter was not found"); } @@ -550,14 +575,14 @@ private static void scheduleGutterClickVerification(String source) { if (isShowing(ImplementationChooserPopup.class)) { ((javax.swing.Timer) resultEvent.getSource()).stop(); System.out.println("GUTTER_CLICK_OK"); - MainWindow.INSTANCE.dispose(); - System.exit(0); + mainWindow.dispose(); + finishHarness(0); } if (System.nanoTime() >= deadline) { ((javax.swing.Timer) resultEvent.getSource()).stop(); System.err.println("GUTTER_CLICK_MISSED"); - MainWindow.INSTANCE.dispose(); - System.exit(2); + mainWindow.dispose(); + finishHarness(2); } dispatchMouseMove(iconRow, target); dispatchLeftClick(iconRow, target); @@ -566,8 +591,8 @@ private static void scheduleGutterClickVerification(String source) { resultTimer.start(); } catch (Exception exception) { exception.printStackTrace(System.err); - MainWindow.INSTANCE.dispose(); - System.exit(3); + mainWindow.dispose(); + finishHarness(3); } }); setupTimer.setRepeats(false); @@ -577,8 +602,8 @@ private static void scheduleGutterClickVerification(String source) { private static void scheduleSingleGutterNavigationVerification(String source) { javax.swing.Timer setupTimer = new javax.swing.Timer(1400, event -> { try { - RSyntaxTextArea editor = findComponent(MainWindow.INSTANCE, RSyntaxTextArea.class); - IconRowHeader iconRow = findComponent(MainWindow.INSTANCE, IconRowHeader.class); + RSyntaxTextArea editor = findComponent(mainWindow, RSyntaxTextArea.class); + IconRowHeader iconRow = findComponent(mainWindow, IconRowHeader.class); if (editor == null || iconRow == null) { throw new IllegalStateException("Code editor gutter was not found"); } @@ -592,19 +617,19 @@ private static void scheduleSingleGutterNavigationVerification(String source) { target.x = iconRow.getWidth() / 2; long deadline = System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(10); javax.swing.Timer resultTimer = new javax.swing.Timer(250, resultEvent -> { - var selected = MainWindow.INSTANCE.getEditorTabs().getSelectedEditor(); + var selected = mainWindow.getEditorTabs().getSelectedEditor(); if (selected instanceof CodeView codeView && "SingleActionImpl".equals(codeView.getTitle())) { ((javax.swing.Timer) resultEvent.getSource()).stop(); System.out.println("GUTTER_DIRECT_NAVIGATION_OK"); - MainWindow.INSTANCE.dispose(); - System.exit(0); + mainWindow.dispose(); + finishHarness(0); } if (System.nanoTime() >= deadline) { ((javax.swing.Timer) resultEvent.getSource()).stop(); System.err.println("GUTTER_DIRECT_NAVIGATION_MISSED selected=" + (selected == null ? "null" : selected.getTitle())); - MainWindow.INSTANCE.dispose(); - System.exit(2); + mainWindow.dispose(); + finishHarness(2); } dispatchMouseMove(iconRow, target); dispatchLeftClick(iconRow, target); @@ -613,8 +638,8 @@ private static void scheduleSingleGutterNavigationVerification(String source) { resultTimer.start(); } catch (Exception exception) { exception.printStackTrace(System.err); - MainWindow.INSTANCE.dispose(); - System.exit(3); + mainWindow.dispose(); + finishHarness(3); } }); setupTimer.setRepeats(false); @@ -625,8 +650,8 @@ private static void scheduleGutterHover(String source, int declarationOffset, bo long deadline = System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(5); javax.swing.Timer hoverTimer = new javax.swing.Timer(150, event -> { try { - RSyntaxTextArea editor = findComponent(MainWindow.INSTANCE, RSyntaxTextArea.class); - IconRowHeader iconRow = findComponent(MainWindow.INSTANCE, IconRowHeader.class); + RSyntaxTextArea editor = findComponent(mainWindow, RSyntaxTextArea.class); + IconRowHeader iconRow = findComponent(mainWindow, IconRowHeader.class); if (editor == null || iconRow == null) { return; } @@ -665,8 +690,8 @@ private static void scheduleGutterHover(String source, int declarationOffset, bo } } catch (Exception exception) { exception.printStackTrace(System.err); - MainWindow.INSTANCE.dispose(); - System.exit(3); + mainWindow.dispose(); + finishHarness(3); } }); hoverTimer.setInitialDelay(1000); @@ -683,14 +708,14 @@ private static void scheduleWindowVerification(Class if (isShowing(type)) { ((javax.swing.Timer) event.getSource()).stop(); System.out.println(successMessage); - MainWindow.INSTANCE.dispose(); - System.exit(0); + mainWindow.dispose(); + finishHarness(0); } if (System.nanoTime() >= deadline) { ((javax.swing.Timer) event.getSource()).stop(); System.err.println(successMessage.replace("_OK", "_MISSED")); - MainWindow.INSTANCE.dispose(); - System.exit(2); + mainWindow.dispose(); + finishHarness(2); } }); timer.start(); @@ -740,18 +765,18 @@ private static void scheduleHierarchyRowLayoutVerification() { modulePresentation.getPreferredSize(), moduleBaseline ); - MainWindow.INSTANCE.dispose(); - System.exit(4); + mainWindow.dispose(); + finishHarness(4); } System.out.println("HIERARCHY_ROW_LAYOUT_OK"); - MainWindow.INSTANCE.dispose(); - System.exit(0); + mainWindow.dispose(); + finishHarness(0); } if (System.nanoTime() >= deadline) { ((javax.swing.Timer) event.getSource()).stop(); System.err.println("HIERARCHY_ROW_LAYOUT_MISSED"); - MainWindow.INSTANCE.dispose(); - System.exit(2); + mainWindow.dispose(); + finishHarness(2); } }); timer.start(); @@ -879,7 +904,9 @@ public static void main(String[] args) throws Exception { root, workspace ); - CompanionApp.configureWithoutSession(profile, indexFile, List.of(sampleClasses), "ui-dev"); + application = new CompanionApplication(new CompanionLaunchConfiguration(root), "ui-development"); + application.openProject(profile).join(); + installRuntimeFixture(indexFile, sampleClasses); writeSampleSource(sample); GlobalConfig.getInstance().loadFrom(root); @@ -891,7 +918,7 @@ public static void main(String[] args) throws Exception { .map(UiRenderScenario::parse) .orElse(UiRenderScenario.MAIN); if (scenario == UiRenderScenario.BREAKPOINT_EDITOR) { - CompanionApp.getDebuggerController().configureBreakpoint( + application.getDebuggerController().configureBreakpoint( decompiledSample.debugSource(), sampleEntryLine, "count > 2", @@ -899,13 +926,13 @@ public static void main(String[] args) throws Exception { ).join(); } if (scenario == UiRenderScenario.DEBUGGER_LOCATION) { - CompanionApp.getDebuggerController().configureBreakpoint( + application.getDebuggerController().configureBreakpoint( decompiledSample.debugSource(), sampleEntryLine + 2, "", "" ).join(); - CompanionApp.getDebuggerController().configureBreakpoint( + application.getDebuggerController().configureBreakpoint( decompiledSample.debugSource(), sampleEntryLine + 5, "", @@ -935,32 +962,33 @@ public static void main(String[] args) throws Exception { System.out.println("F9 = component inspector, F10 = UI defaults inspector"); SwingUtilities.invokeAndWait(() -> { + mainWindow = application.createWindow(); FlatInspector.install("F9"); FlatUIDefaultsInspector.install("F10"); - MainWindow.INSTANCE.getEditorTabs().openEditorTab(new CodeView(MainWindow.INSTANCE.editorContext(), + mainWindow.getEditorTabs().openEditorTab(new CodeView(mainWindow.editorContext(), decompiledSample, 0, EditorLocation.forRuntimeClass( "com.github.minecraft_ta.totaldebug.ThemeSample", sampleClasses.toUri().toASCIIString() - ), CompanionApp.currentRuntime() + ), mainWindow.editorContext().project().runtime() )); - MainWindow.INSTANCE.getEditorTabs().openEditorTab(new ResourceView(MainWindow.INSTANCE.editorContext(), - new ArchiveEntrySource(sampleArchive, "META-INF/MANIFEST.MF", -1), CompanionApp.currentRuntime() + mainWindow.getEditorTabs().openEditorTab(new ResourceView(mainWindow.editorContext(), + new ArchiveEntrySource(sampleArchive, "META-INF/MANIFEST.MF", -1), mainWindow.editorContext().project().runtime() )); - MainWindow.INSTANCE.getEditorTabs().openEditorTab(new ResourceView(MainWindow.INSTANCE.editorContext(), - new ArchiveEntrySource(sampleArchive, "docs/NOTICE.custom", -1), CompanionApp.currentRuntime() + mainWindow.getEditorTabs().openEditorTab(new ResourceView(mainWindow.editorContext(), + new ArchiveEntrySource(sampleArchive, "docs/NOTICE.custom", -1), mainWindow.editorContext().project().runtime() )); - MainWindow.INSTANCE.getEditorTabs().openEditorTab(new ResourceView(MainWindow.INSTANCE.editorContext(), - new ArchiveEntrySource(sampleArchive, "config/defaults.toml", -1), CompanionApp.currentRuntime() + mainWindow.getEditorTabs().openEditorTab(new ResourceView(mainWindow.editorContext(), + new ArchiveEntrySource(sampleArchive, "config/defaults.toml", -1), mainWindow.editorContext().project().runtime() )); - MainWindow.INSTANCE.getEditorTabs().openEditorTab(new ResourceView(MainWindow.INSTANCE.editorContext(), - new ArchiveEntrySource(sampleArchive, "assets/sample/textures/gui/debug.png", -1), CompanionApp.currentRuntime() + mainWindow.getEditorTabs().openEditorTab(new ResourceView(mainWindow.editorContext(), + new ArchiveEntrySource(sampleArchive, "assets/sample/textures/gui/debug.png", -1), mainWindow.editorContext().project().runtime() )); boolean interactionVerification = Arrays.asList(args).stream() .anyMatch(argument -> argument.startsWith("--verify-")); if (interactionVerification) { - SwingUtilities.invokeLater(() -> MainWindow.INSTANCE.getEditorTabs().setSelectedIndex(0)); + SwingUtilities.invokeLater(() -> mainWindow.getEditorTabs().setSelectedIndex(0)); } if (Arrays.asList(args).contains("--verify-code-vision-click")) { scheduleCodeVisionClickVerification(CodeView.readCode(sample)); @@ -989,8 +1017,8 @@ public static void main(String[] args) throws Exception { if (Arrays.asList(args).contains("--cycle-themes")) { startThemeCycling(); } - MainWindow.INSTANCE.setSize(1280, 720); - if (MainWindow.INSTANCE.isAutoRequestFocus()) { + mainWindow.setSize(1280, 720); + if (mainWindow.isAutoRequestFocus()) { throw new IllegalStateException("Showing the main window must not automatically request focus"); } boolean verifySearchEverywhere = Arrays.asList(args).contains( @@ -1003,22 +1031,22 @@ public static void main(String[] args) throws Exception { scheduleMethodNavigationVerification(); } if (backgroundMode) { - MainWindow.INSTANCE.setAutoRequestFocus(false); - MainWindow.INSTANCE.setFocusableWindowState(false); - MainWindow.INSTANCE.setLocation(-20_000, -20_000); - MainWindow.INSTANCE.setVisible(true); + mainWindow.setAutoRequestFocus(false); + mainWindow.setFocusableWindowState(false); + mainWindow.setLocation(-20_000, -20_000); + mainWindow.setVisible(true); } else { - MainWindow.INSTANCE.setVisible(true); - UIUtils.centerJFrame(MainWindow.INSTANCE, MainWindow.INSTANCE); + mainWindow.setVisible(true); + UIUtils.centerJFrame(mainWindow, mainWindow); } ToolTipManager.sharedInstance().setInitialDelay(200); - MainWindow.INSTANCE.setRuntimeIndexStatus(new RuntimeIndexService.Status( + mainWindow.setRuntimeIndexStatus(new RuntimeIndexService.Status( RuntimeIndexService.Phase.READY, "Runtime index ready", null )); if (!interactionVerification) { - UiScenarioDriver.schedule(scenario, CodeView.readCode(sample), screenshot); + new UiScenarioDriver(mainWindow, UiDevHarness::finishHarness).schedule(scenario, CodeView.readCode(sample), screenshot); } }); } diff --git a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/UiScenarioDriver.java b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/UiScenarioDriver.java index b47c5e06..47cbfcc5 100644 --- a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/UiScenarioDriver.java +++ b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/UiScenarioDriver.java @@ -41,16 +41,21 @@ import java.util.HashSet; import java.util.List; import java.util.Set; +import java.util.function.IntConsumer; /** Applies one named UI state, waits until it is stable, and optionally captures it. */ final class UiScenarioDriver { - private static final int READY_POLLS = 2; - private static final int TREE_READY_POLLS = 4; + private final int READY_POLLS = 2; + private final int TREE_READY_POLLS = 4; - private UiScenarioDriver() { + private final MainWindow mainWindow; + private final IntConsumer exit; + UiScenarioDriver(MainWindow mainWindow, IntConsumer exit) { + this.mainWindow = mainWindow; + this.exit = exit; } - static void schedule(UiRenderScenario scenario, String source, Path screenshot) { + void schedule(UiRenderScenario scenario, String source, Path screenshot) { ScenarioContext context = new ScenarioContext(source); long deadline = System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(12); int[] stableReadyPolls = {0}; @@ -65,8 +70,8 @@ static void schedule(UiRenderScenario scenario, String source, Path screenshot) ((javax.swing.Timer) event.getSource()).stop(); if (screenshot != null) { capture(screenshot); - MainWindow.INSTANCE.dispose(); - System.exit(0); + mainWindow.dispose(); + exit.accept(0); } System.out.println("UI scenario ready: " + scenario.id()); return; @@ -77,26 +82,26 @@ static void schedule(UiRenderScenario scenario, String source, Path screenshot) } } catch (Exception exception) { exception.printStackTrace(System.err); - MainWindow.INSTANCE.dispose(); - System.exit(2); + mainWindow.dispose(); + exit.accept(2); } }); timer.setInitialDelay(0); timer.start(); } - private static void advance(UiRenderScenario scenario, ScenarioContext context) throws Exception { + private void advance(UiRenderScenario scenario, ScenarioContext context) throws Exception { expandTree(); switch (scenario) { case MAIN -> selectCodeEditor(context); case INACTIVE_TABS -> context.once("select-resource", () -> { - int lastTab = MainWindow.INSTANCE.getEditorTabs().getTabCount() - 1; - MainWindow.INSTANCE.getEditorTabs().setSelectedIndex(lastTab); + int lastTab = mainWindow.getEditorTabs().getTabCount() - 1; + mainWindow.getEditorTabs().setSelectedIndex(lastTab); }); case TAB_HOVER -> advanceTabHover(context); case EDITOR_CURRENT_LINE -> { selectCodeEditor(context); - RSyntaxTextArea editor = findComponent(MainWindow.INSTANCE, RSyntaxTextArea.class); + RSyntaxTextArea editor = findComponent(mainWindow, RSyntaxTextArea.class); if (editor != null) { int offset = context.source().indexOf("double ratio"); if (editor.getDocument().getLength() >= offset && editor.getCaretPosition() != offset) { @@ -109,7 +114,7 @@ private static void advance(UiRenderScenario scenario, ScenarioContext context) case DEBUGGER_LOCATION -> { selectCodeEditor(context); context.once("show-debugger-location", () -> { - var selected = MainWindow.INSTANCE.getEditorTabs().getSelectedEditor(); + var selected = mainWindow.getEditorTabs().getSelectedEditor(); if (selected instanceof CodeView codeView) { int line = context.source().substring(0, context.source().indexOf("double ratio")) .split("\\n", -1).length; @@ -131,20 +136,20 @@ private static void advance(UiRenderScenario scenario, ScenarioContext context) } }); } - case DEBUGGER -> context.once("open-debugger", () -> DebuggerWindowPreview.open(MainWindow.INSTANCE)); + case DEBUGGER -> context.once("open-debugger", () -> DebuggerWindowPreview.open(mainWindow)); case BREAKPOINTS -> context.once( "open-breakpoints", - () -> BreakpointsWindowPreview.open(MainWindow.INSTANCE) + () -> BreakpointsWindowPreview.open(mainWindow) ); case EVALUATE_CODE, EVALUATE_EXPRESSION -> context.once("open-evaluate", () -> { var window = new com.github.minecraft_ta.totalDebugCompanion.ui.views.EvaluateExpressionWindow( - MainWindow.INSTANCE, null, MainWindow.INSTANCE.editorContext(), MainWindow.INSTANCE::refreshRuntimeSources); // This fixture renders the editor without an execution backend. + mainWindow, null, mainWindow.editorContext(), mainWindow::refreshRuntimeSources); // This fixture renders the editor without an execution backend. var editor = findComponent(window, com.github.minecraft_ta.totalDebugCompanion.ui.components.JavaExpressionField.class); editor.setText(scenario == UiRenderScenario.EVALUATE_CODE ? "var values = java.util.List.of(1, 2, 3);\nint total = 0;\nfor (int value : values) {\n total += value;\n}\nreturn total;" : "getServer()"); window.showWindow(); - window.setLocation(MainWindow.INSTANCE.getX() + 70, MainWindow.INSTANCE.getY() + 70); + window.setLocation(mainWindow.getX() + 70, mainWindow.getY() + 70); }); case HIERARCHY_ONE -> advanceHierarchyPreview( context, @@ -157,22 +162,22 @@ private static void advance(UiRenderScenario scenario, ScenarioContext context) case IMPLEMENTATION_CHOOSER -> advanceImplementationChooser(context); case SEARCH_EMPTY, SEARCH_RESULTS, MODULE_FILTER -> advanceSearch(scenario, context); case USAGES_RESULTS -> context.once("open-usages", () -> { - UsagesView view = new UsagesView(MainWindow.INSTANCE.editorContext(), new CodeSymbol.ClassSymbol("sample.ThemeSample"), CompanionApp.currentRuntime()); - MainWindow.INSTANCE.getEditorTabs().openEditorTab(view) + UsagesView view = new UsagesView(mainWindow.editorContext(), new CodeSymbol.ClassSymbol("sample.ThemeSample"), mainWindow.editorContext().project().runtime()); + mainWindow.getEditorTabs().openEditorTab(view) .thenRun(() -> SwingUtilities.invokeLater(view::restartSearch)); }); case SETTINGS -> { selectCodeEditor(context); context.once("open-settings", () -> { - SettingsWindow settings = new SettingsWindow(MainWindow.INSTANCE, CompanionApp.instanceState(), CompanionApp.getDebuggerController()); - settings.setLocation(MainWindow.INSTANCE.getX() + 250, MainWindow.INSTANCE.getY() + 80); + SettingsWindow settings = new SettingsWindow(mainWindow, mainWindow.editorContext().project().state(), mainWindow.editorContext().debugger()); + settings.setLocation(mainWindow.getX() + 250, mainWindow.getY() + 80); settings.setVisible(true); }); } case SERVICE_STATUS -> advanceServiceStatus(context); case INDEXING -> { selectCodeEditor(context); - context.once("indexing", () -> MainWindow.INSTANCE.setRuntimeIndexStatus( + context.once("indexing", () -> mainWindow.setRuntimeIndexStatus( new RuntimeIndexService.Status( RuntimeIndexService.Phase.BUILDING, "Building class index", @@ -183,7 +188,7 @@ private static void advance(UiRenderScenario scenario, ScenarioContext context) } } - private static DebugEngine.Variable variable( + private DebugEngine.Variable variable( String name, String value, String type, @@ -192,21 +197,21 @@ private static DebugEngine.Variable variable( return new DebugEngine.Variable(name, name, name, value, type, kind, 0, 0, 0, 0); } - private static boolean ready(UiRenderScenario scenario, ScenarioContext context) { + private boolean ready(UiRenderScenario scenario, ScenarioContext context) { return switch (scenario) { - case MAIN -> MainWindow.INSTANCE.getEditorTabs().getSelectedIndex() == 0; - case INACTIVE_TABS -> MainWindow.INSTANCE.getEditorTabs().getSelectedIndex() - == MainWindow.INSTANCE.getEditorTabs().getTabCount() - 1; + case MAIN -> mainWindow.getEditorTabs().getSelectedIndex() == 0; + case INACTIVE_TABS -> mainWindow.getEditorTabs().getSelectedIndex() + == mainWindow.getEditorTabs().getTabCount() - 1; case TAB_HOVER -> { - Component header = MainWindow.INSTANCE.getEditorTabs().getTabComponentAt(0); + Component header = mainWindow.getEditorTabs().getTabComponentAt(0); CloseButton close = header instanceof Container container ? findComponent(container, CloseButton.class) : null; yield header != null && !header.isOpaque() && close != null && close.isVisible(); } case EDITOR_CURRENT_LINE -> { - RSyntaxTextArea editor = findComponent(MainWindow.INSTANCE, RSyntaxTextArea.class); - IconRowHeader gutter = findComponent(MainWindow.INSTANCE, IconRowHeader.class); + RSyntaxTextArea editor = findComponent(mainWindow, RSyntaxTextArea.class); + IconRowHeader gutter = findComponent(mainWindow, IconRowHeader.class); yield editor != null && gutter != null && editor.getCaretPosition() == context.source().indexOf("double ratio") && gutter.isShowing(); @@ -214,7 +219,7 @@ private static boolean ready(UiRenderScenario scenario, ScenarioContext context) case BREAKPOINT_EDITOR -> visibleMenuPopup() != null && findLabelContaining(visibleMenuPopup(), "Line breakpoint") != null; case METHOD_BREAKPOINT -> { - var selected = MainWindow.INSTANCE.getEditorTabs().getSelectedEditor(); + var selected = mainWindow.getEditorTabs().getSelectedEditor(); if (!(selected instanceof CodeView codeView)) { yield false; } @@ -225,7 +230,7 @@ private static boolean ready(UiRenderScenario scenario, ScenarioContext context) .substring(0, context.source().indexOf("double ratio")) .split("\\n", -1).length; yield codeView.getDebugSource() - .map(source -> CompanionApp.getDebuggerController() + .map(source -> mainWindow.editorContext().debugger() .breakpoint(source.uri(), declarationLine)) .map(breakpoint -> breakpoint.request().isMethodEntry() && breakpoint.request().debuggerLine() == entryLine @@ -233,7 +238,7 @@ private static boolean ready(UiRenderScenario scenario, ScenarioContext context) .equals("(IZ)Ljava/util/List;")) .orElse(false); } - case DEBUGGER_LOCATION -> MainWindow.INSTANCE.getEditorTabs().getSelectedEditor() instanceof CodeView; + case DEBUGGER_LOCATION -> mainWindow.getEditorTabs().getSelectedEditor() instanceof CodeView; case DEBUGGER -> findShowingWindow(DebuggerWindow.class) != null; case BREAKPOINTS -> findShowingWindow(BreakpointsWindow.class) != null; case EVALUATE_CODE, EVALUATE_EXPRESSION -> findShowingWindow(com.github.minecraft_ta.totalDebugCompanion.ui.views.EvaluateExpressionWindow.class) != null; @@ -259,7 +264,7 @@ private static boolean ready(UiRenderScenario scenario, ScenarioContext context) } case MODULE_FILTER -> visibleMenuPopup() != null; case USAGES_RESULTS -> { - var selected = MainWindow.INSTANCE.getEditorTabs().getSelectedEditor(); + var selected = mainWindow.getEditorTabs().getSelectedEditor(); javax.swing.JTree tree = selected instanceof UsagesView ? findComponent((Container) selected.getComponent(), javax.swing.JTree.class) : null; @@ -267,19 +272,19 @@ private static boolean ready(UiRenderScenario scenario, ScenarioContext context) } case SETTINGS -> findShowingWindow(SettingsWindow.class) != null; case SERVICE_STATUS -> visibleMenuPopup() != null - && findButton(MainWindow.INSTANCE, "Game: Connected") != null - && findButton(MainWindow.INSTANCE, "MCP: Listening") != null; - case INDEXING -> findLabelContaining(MainWindow.INSTANCE, "Building class index") != null; + && findButton(mainWindow, "Game: Connected") != null + && findButton(mainWindow, "MCP: Listening") != null; + case INDEXING -> findLabelContaining(mainWindow, "Building class index") != null; }; } - private static void advanceTabHover(ScenarioContext context) { - int lastTab = MainWindow.INSTANCE.getEditorTabs().getTabCount() - 1; + private void advanceTabHover(ScenarioContext context) { + int lastTab = mainWindow.getEditorTabs().getTabCount() - 1; if (lastTab < 1) { return; } - context.once("select-last-tab", () -> MainWindow.INSTANCE.getEditorTabs().setSelectedIndex(lastTab)); - Component header = MainWindow.INSTANCE.getEditorTabs().getTabComponentAt(0); + context.once("select-last-tab", () -> mainWindow.getEditorTabs().setSelectedIndex(lastTab)); + Component header = mainWindow.getEditorTabs().getTabComponentAt(0); if (header != null) { context.once("hover-first-tab", () -> header.dispatchEvent(new MouseEvent( header, @@ -295,10 +300,10 @@ private static void advanceTabHover(ScenarioContext context) { } } - private static void advanceBreakpointEditor(ScenarioContext context) throws Exception { + private void advanceBreakpointEditor(ScenarioContext context) throws Exception { selectCodeEditor(context); - RSyntaxTextArea editor = findComponent(MainWindow.INSTANCE, RSyntaxTextArea.class); - LineNumberList lineNumbers = findComponent(MainWindow.INSTANCE, LineNumberList.class); + RSyntaxTextArea editor = findComponent(mainWindow, RSyntaxTextArea.class); + LineNumberList lineNumbers = findComponent(mainWindow, LineNumberList.class); if (editor == null || lineNumbers == null) { return; } @@ -326,15 +331,15 @@ private static void advanceBreakpointEditor(ScenarioContext context) throws Exce }); } - private static void advanceMethodBreakpoint(ScenarioContext context) throws Exception { + private void advanceMethodBreakpoint(ScenarioContext context) throws Exception { selectCodeEditor(context); - var selected = MainWindow.INSTANCE.getEditorTabs().getSelectedEditor(); + var selected = mainWindow.getEditorTabs().getSelectedEditor(); if (!(selected instanceof CodeView codeView) || ASTCache.getFromCache(codeView.getPath().toString()) == null) { return; } - RSyntaxTextArea editor = findComponent(MainWindow.INSTANCE, RSyntaxTextArea.class); - LineNumberList lineNumbers = findComponent(MainWindow.INSTANCE, LineNumberList.class); + RSyntaxTextArea editor = findComponent(mainWindow, RSyntaxTextArea.class); + LineNumberList lineNumbers = findComponent(mainWindow, LineNumberList.class); if (editor == null || lineNumbers == null) { return; } @@ -356,20 +361,20 @@ private static void advanceMethodBreakpoint(ScenarioContext context) throws Exce ))); } - private static void advanceServiceStatus(ScenarioContext context) { + private void advanceServiceStatus(ScenarioContext context) { context.once("publish-service-status", () -> { - MainWindow.INSTANCE.setGameStatus(new ServiceStatus( + mainWindow.setGameStatus(new ServiceStatus( ServiceStatus.State.AVAILABLE, "Connected", "Minecraft is connected and authenticated." )); - MainWindow.INSTANCE.setMcpStatus(new ServiceStatus( + mainWindow.setMcpStatus(new ServiceStatus( ServiceStatus.State.AVAILABLE, "Listening", "MCP is listening at http://127.0.0.1:32123/mcp" )); }); - JButton mcp = findButton(MainWindow.INSTANCE, "MCP: Listening"); + JButton mcp = findButton(mainWindow, "MCP: Listening"); if (mcp != null) { context.once("open-mcp-status", () -> { mcp.getModel().setRollover(true); @@ -379,13 +384,13 @@ private static void advanceServiceStatus(ScenarioContext context) { } } - private static void advanceImplementationChooser(ScenarioContext context) { + private void advanceImplementationChooser(ScenarioContext context) { selectCodeEditor(context); - RSyntaxTextArea editor = findComponent(MainWindow.INSTANCE, RSyntaxTextArea.class); + RSyntaxTextArea editor = findComponent(mainWindow, RSyntaxTextArea.class); if (editor == null) { return; } - if (!(MainWindow.INSTANCE.getEditorTabs().getSelectedEditor() instanceof CodeView codeView) + if (!(mainWindow.getEditorTabs().getSelectedEditor() instanceof CodeView codeView) || ASTCache.getFromCache(codeView.getPath().toString()) == null) { return; } @@ -401,10 +406,10 @@ private static void advanceImplementationChooser(ScenarioContext context) { }); } - private static void advanceHierarchyPreview(ScenarioContext context, int declarationOffset) throws Exception { + private void advanceHierarchyPreview(ScenarioContext context, int declarationOffset) throws Exception { selectCodeEditor(context); - RSyntaxTextArea editor = findComponent(MainWindow.INSTANCE, RSyntaxTextArea.class); - IconRowHeader iconRow = findComponent(MainWindow.INSTANCE, IconRowHeader.class); + RSyntaxTextArea editor = findComponent(mainWindow, RSyntaxTextArea.class); + IconRowHeader iconRow = findComponent(mainWindow, IconRowHeader.class); if (editor == null || iconRow == null) { return; } @@ -432,16 +437,16 @@ private static void advanceHierarchyPreview(ScenarioContext context, int declara dispatchMouseMove(iconRow, target); } - private static void advanceSearch(UiRenderScenario scenario, ScenarioContext context) { + private void advanceSearch(UiRenderScenario scenario, ScenarioContext context) { selectCodeEditor(context); - context.once("open-search", MainWindow.INSTANCE::openSearchEverywhere); + context.once("open-search", mainWindow::openSearchEverywhere); SearchEverywherePopup popup = findShowingWindow(SearchEverywherePopup.class); if (popup == null) { return; } context.once("position-search", () -> popup.setLocation( - MainWindow.INSTANCE.getX() + 220, - MainWindow.INSTANCE.getY() + 70 + mainWindow.getX() + 220, + mainWindow.getY() + 70 )); if (scenario == UiRenderScenario.SEARCH_RESULTS) { FlatIconTextField field = findComponent(popup, FlatIconTextField.class); @@ -459,12 +464,12 @@ private static void advanceSearch(UiRenderScenario scenario, ScenarioContext con } } - private static void selectCodeEditor(ScenarioContext context) { - context.once("select-code", () -> MainWindow.INSTANCE.getEditorTabs().setSelectedIndex(0)); + private void selectCodeEditor(ScenarioContext context) { + context.once("select-code", () -> mainWindow.getEditorTabs().setSelectedIndex(0)); } - private static void expandTree() { - LazyFileJTree tree = findComponent(MainWindow.INSTANCE, LazyFileJTree.class); + private void expandTree() { + LazyFileJTree tree = findComponent(mainWindow, LazyFileJTree.class); if (tree == null) { return; } @@ -473,7 +478,7 @@ private static void expandTree() { } } - private static javax.swing.JPopupMenu visibleMenuPopup() { + private javax.swing.JPopupMenu visibleMenuPopup() { return Arrays.stream(javax.swing.MenuSelectionManager.defaultManager().getSelectedPath()) .filter(javax.swing.JPopupMenu.class::isInstance) .map(javax.swing.JPopupMenu.class::cast) @@ -482,7 +487,7 @@ private static javax.swing.JPopupMenu visibleMenuPopup() { .orElse(null); } - private static T findShowingWindow(Class type) { + private T findShowingWindow(Class type) { return Arrays.stream(java.awt.Window.getWindows()) .filter(type::isInstance) .map(type::cast) @@ -491,7 +496,7 @@ private static T findShowingWindow(Class type) { .orElse(null); } - private static T findComponent(Container root, Class type) { + private T findComponent(Container root, Class type) { for (Component component : root.getComponents()) { if (type.isInstance(component)) { return type.cast(component); @@ -506,7 +511,7 @@ private static T findComponent(Container root, Class ty return null; } - private static javax.swing.JLabel findLabelContaining(Container root, String expectedText) { + private javax.swing.JLabel findLabelContaining(Container root, String expectedText) { for (Component component : root.getComponents()) { if (component instanceof javax.swing.JLabel label && label.getText() != null @@ -523,7 +528,7 @@ private static javax.swing.JLabel findLabelContaining(Container root, String exp return null; } - private static JButton findButton(Container root, String text) { + private JButton findButton(Container root, String text) { for (Component component : root.getComponents()) { if (component instanceof JButton button && text.equals(button.getText())) { return button; @@ -538,7 +543,7 @@ private static JButton findButton(Container root, String text) { return null; } - private static void dispatchMouseMove(Component component, Point point) { + private void dispatchMouseMove(Component component, Point point) { long now = System.currentTimeMillis(); component.dispatchEvent(new MouseEvent( component, @@ -564,34 +569,34 @@ private static void dispatchMouseMove(Component component, Point point) { )); } - private static void capture(Path target) throws Exception { + private void capture(Path target) throws Exception { Files.createDirectories(target.toAbsolutePath().getParent()); BufferedImage image = new BufferedImage( - MainWindow.INSTANCE.getWidth(), - MainWindow.INSTANCE.getHeight(), + mainWindow.getWidth(), + mainWindow.getHeight(), BufferedImage.TYPE_INT_ARGB ); Graphics2D graphics = image.createGraphics(); - MainWindow.INSTANCE.paintAll(graphics); + mainWindow.paintAll(graphics); for (java.awt.Window window : java.awt.Window.getWindows()) { - if (window == MainWindow.INSTANCE || !window.isShowing()) { + if (window == mainWindow || !window.isShowing()) { continue; } Graphics2D popupGraphics = (Graphics2D) graphics.create(); popupGraphics.translate( - window.getX() - MainWindow.INSTANCE.getX(), - window.getY() - MainWindow.INSTANCE.getY() + window.getX() - mainWindow.getX(), + window.getY() - mainWindow.getY() ); window.paintAll(popupGraphics); popupGraphics.dispose(); } - OffscreenPopupFactory.paintActivePopups(graphics, MainWindow.INSTANCE); + OffscreenPopupFactory.paintActivePopups(graphics, mainWindow); graphics.dispose(); ImageIO.write(image, "png", target.toFile()); System.out.println("UI screenshot: " + target.toAbsolutePath()); } - private static final class ScenarioContext { + private final class ScenarioContext { private final String source; private final Set completedActions = new HashSet<>(); private int previousTreeRows = -1; @@ -612,7 +617,7 @@ private void once(String key, Runnable action) { } private boolean workspaceReady() { - LazyFileJTree tree = findComponent(MainWindow.INSTANCE, LazyFileJTree.class); + LazyFileJTree tree = findComponent(mainWindow, LazyFileJTree.class); if (tree == null || tree.getRowCount() == 0) { return false; } diff --git a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/jdt/JdtStorageTest.java b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/jdt/JdtStorageTest.java index 4d545b3f..c960d1d3 100644 --- a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/jdt/JdtStorageTest.java +++ b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/jdt/JdtStorageTest.java @@ -1,6 +1,6 @@ package com.github.minecraft_ta.totalDebugCompanion.jdt; -import com.github.minecraft_ta.totalDebugCompanion.CompanionApp; +import com.github.minecraft_ta.totalDebugCompanion.JdtTestEnvironment; import org.eclipse.core.internal.runtime.InternalPlatform; import org.eclipse.core.internal.runtime.MetaDataKeeper; import org.eclipse.jdt.core.JavaCore; @@ -12,7 +12,7 @@ class JdtStorageTest { @Test void eclipseMetadataUsesTheApplicationCache() { assertNotNull(JDTHacks.DUMMY_JAVA_PROJECT); - var cache = CompanionApp.appPaths().jdtCache(); + var cache = JdtTestEnvironment.PATHS.jdtCache(); assertEquals(cache, MetaDataKeeper.getMetaArea().getMetadataLocation().toFile().toPath()); assertEquals(cache, InternalPlatform.getDefault().getLocation().toFile().toPath()); assertEquals(cache.resolve(".plugins/dummyBundle"), JavaCore.getPlugin().getStateLocation().toFile().toPath()); diff --git a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/mcp/CompanionMcpServerTest.java b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/mcp/CompanionMcpServerTest.java index b6b34f77..aff8dbb8 100644 --- a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/mcp/CompanionMcpServerTest.java +++ b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/mcp/CompanionMcpServerTest.java @@ -39,7 +39,8 @@ void servesMcpInitializeAndToolDiscoveryOnTheStableLoopbackEndpoint() throws Exc CompanionMcpServer server = new CompanionMcpServer( dataDirectory, jobs, - 0 + 0, new DebuggerMcpService(() -> null, name -> null), + () -> { throw new IllegalStateException("No Minecraft project is loaded"); } ); try (server; HttpClient client = HttpClient.newHttpClient()) { server.start(); diff --git a/companion/src/test/resources/META-INF/services/org.junit.jupiter.api.extension.Extension b/companion/src/test/resources/META-INF/services/org.junit.jupiter.api.extension.Extension new file mode 100644 index 00000000..a5e881d3 --- /dev/null +++ b/companion/src/test/resources/META-INF/services/org.junit.jupiter.api.extension.Extension @@ -0,0 +1 @@ +com.github.minecraft_ta.totalDebugCompanion.JdtTestEnvironment From c0de24f352cf51cfcc5abde752d0eb7f8f39db9c Mon Sep 17 00:00:00 2001 From: Pelotrio <45769595+Pelotrio@users.noreply.github.com> Date: Fri, 11 Sep 2026 17:41:19 +0200 Subject: [PATCH 2/7] Guard application UI publication and remove obsolete locators --- companion/README.md | 4 +- .../CompanionApplication.java | 109 ++---------------- .../ApplicationNavigationTest.java | 53 +++++++++ .../RuntimeInstallationTest.java | 2 +- .../totalDebugCompanion/UiDevHarness.java | 80 +++++++------ .../totalDebugCompanion/UiScenarioDriver.java | 21 ++-- 6 files changed, 122 insertions(+), 147 deletions(-) create mode 100644 companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/ApplicationNavigationTest.java diff --git a/companion/README.md b/companion/README.md index c9ff9bbc..51af9a2f 100644 --- a/companion/README.md +++ b/companion/README.md @@ -49,12 +49,12 @@ The [MCP API](MCP.md) exposes source queries, Java execution and debugger operat ## Ownership -`CompanionApp` owns the session, debugger, compiler, index loader and project worker. One `ProjectScope` owns the selected profile, instance state, navigation history, pending navigation and nullable `RuntimeBinding`. A scope admits work while ACTIVE; SWITCHING rejects new work but can be cancelled after an editor veto or failed state flush; RETIRED is terminal. Check-and-submit uses the same lifecycle lock as runtime installation. Swing hops and debugger waits run outside that lock. +`CompanionApp` is the process bootstrap: launch arguments, process lock, logging, look and feel, and application construction. `CompanionApplication` owns the session, debugger, compiler, index loader and project worker. It can run without a UI; `CompanionUi` is the boundary for window lifecycle and navigation. One `ProjectScope` owns the selected profile, instance state, navigation history, pending navigation and nullable `RuntimeBinding`. A scope admits work while ACTIVE; SWITCHING rejects new work but can be cancelled after an editor veto or failed state flush; RETIRED is terminal. Check-and-submit uses the same lifecycle lock as runtime installation. Swing hops and debugger waits run outside that lock. The scope publishes one `RuntimeBinding` for the installed inventory. The binding groups its identity, source catalog, classpath, decompiler and reference search, and owns the native index after installation succeeds. `CompanionClassIndex` is only JDT's process-wide lookup hook; setting or clearing it never closes an index. The index loader retains ownership while a candidate is prepared. The application detaches the previous runtime, attaches the new compiler/insight bindings, and completes publication under the existing lifecycle lock. Debugger and UI follow-up runs afterward and cannot return an installed index to the loader's failure cleanup. Closing a runtime detaches its consumers before releasing the index, and is idempotent. A rejected candidate closes its own prepared consumers while leaving index disposal to the loader. -The script compiler and code-insight worker remain application-lived. Open local editors retain the code-insight service, so a runtime changes its binding rather than replacing that service instance. MCP tool declarations identify project-bound requests. Mutations use the captured scope's atomic admission gate; late results check that scope, and navigation also checks runtime identity. Instance state is flushed before retirement and detach. Stateless JDT parsing is in `JavaAst`; editor analysis/listeners remain in `ASTCache`. +The script compiler and code-insight worker remain application-lived. Open local editors retain the code-insight service, so a runtime changes its binding rather than replacing that service instance. MCP tool declarations identify project-bound requests. Mutations use the captured scope's atomic admission gate; late results check that scope, and navigation also checks runtime identity. Instance state is flushed before retirement and detach. JDT workspace initialization receives its metadata path explicitly through `JDTHacks.init`. Its process globals initialize once; tests share an explicit cache per JVM. Stateless JDT parsing is in `JavaAst`; editor analysis/listeners remain in `ASTCache` until the cache ownership slice. Java editors receive an `EditorContext` containing their project, runtime-facing services, navigation and window callbacks. Search and settings windows receive their own specific collaborators. `ScriptExecutionService` shares authenticated execution and cancellation between UI and MCP; execution-result subscriptions belong to `CompanionSession` and are removed by their UI/job owners. Window disposal and project switching share the same auxiliary-window cleanup. 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 4fbb08eb..62d9c4ef 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 @@ -20,11 +20,9 @@ import com.github.minecraft_ta.totalDebugCompanion.script.ScriptCompilationService; import com.github.minecraft_ta.totalDebugCompanion.script.ScriptExecutionService; import com.github.minecraft_ta.totaldebug.protocol.scnet.OpenClassMessage; -import com.github.minecraft_ta.totalDebugCompanion.script.ScriptCompilationService.CompilationResult; import com.github.minecraft_ta.totaldebug.protocol.scnet.StopScriptMessage; import com.github.minecraft_ta.totalDebugCompanion.mcp.CompanionMcpServer; import com.github.minecraft_ta.totaldebug.protocol.scnet.DebugTargetMessage; -import com.github.minecraft_ta.totaldebug.protocol.scnet.RetryRuntimeInventoryMessage; import com.github.minecraft_ta.totaldebug.protocol.scnet.RuntimeInventoryMessage; import com.github.minecraft_ta.totaldebug.protocol.scnet.ServerManifestMessage; import com.github.minecraft_ta.totalDebugCompanion.model.ServiceStatus; @@ -35,7 +33,6 @@ import com.github.minecraft_ta.totalDebugCompanion.runtime.RuntimeBinding; import com.github.minecraft_ta.totalDebugCompanion.runtime.RuntimeSourceCatalog; import com.github.minecraft_ta.totalDebugCompanion.search.insight.CodeInsightService; -import com.github.minecraft_ta.totalDebugCompanion.search.reference.ReferenceSearchService; import com.github.minecraft_ta.totalDebugCompanion.session.CompanionLaunchConfiguration; import com.github.minecraft_ta.totalDebugCompanion.session.CompanionProfile; import com.github.minecraft_ta.totalDebugCompanion.session.ProjectRegistry; @@ -44,7 +41,6 @@ import com.github.minecraft_ta.totalDebugCompanion.util.UIUtils; import com.github.tth05.scnet.message.AbstractMessage; import org.eclipse.jdt.core.dom.ASTParser; -import org.eclipse.jdt.core.dom.CompilationUnit; import javax.swing.SwingUtilities; import javax.swing.ToolTipManager; import java.io.IOException; @@ -272,6 +268,7 @@ public ProjectScope requireProject() { /** Application API; selection controls and MCP project tools are added separately. */ public CompletableFuture openProject(CompanionProfile requested) { Objects.requireNonNull(requested); + if (closed) return CompletableFuture.failedFuture(new IllegalStateException("Application is closed")); return CompletableFuture.runAsync(() -> { try { switchProject(requested); } catch (IOException failure) { throw new CompletionException(failure); } @@ -348,7 +345,7 @@ private void switchProject(CompanionProfile requested) throws IOException { switching = false; } } - if (ui != null) SwingUtilities.invokeLater(() -> ui.setSwitching(false)); + onUi(view -> view.setSwitching(isSwitching())); } } } @@ -484,15 +481,12 @@ private void prewarmJavaParser() { if (!CompanionClassIndex.isOpen()) { return; } - Thread thread = new Thread(() -> { - ASTParser parser = JdtConfiguration.createParser(); - parser.setSource(new CompilationUnitImpl("Test", "class Test{}")); - parser.setResolveBindings(true); - parser.setKind(ASTParser.K_COMPILATION_UNIT); - CompilationUnit ignored = (CompilationUnit) parser.createAST(null); - }, "Companion JDT prewarm"); - thread.setDaemon(true); - thread.start(); + // This follow-up already runs on the project worker, which shutdown drains before closing the index. + ASTParser parser = JdtConfiguration.createParser(); + parser.setSource(new CompilationUnitImpl("Test", "class Test{}")); + parser.setResolveBindings(true); + parser.setKind(ASTParser.K_COMPILATION_UNIT); + parser.createAST(null); } private void startMcpServer() throws Exception { @@ -565,11 +559,14 @@ private Map runtimeContext() { } public MainWindow createWindow() { + if (closed) throw new IllegalStateException("Application is closed"); if (!SwingUtilities.isEventDispatchThread()) throw new IllegalStateException("Create the window on the EDT"); if (ui != null) throw new IllegalStateException("Application already has a UI"); MainWindow window = new MainWindow(this::currentScope, getDebuggerController(), codeInsightService, scriptExecutions, session, runtimeIndexService, this::openDebugFrame, this::exit); ui = window; + // A restore may have finished while the constructor still had no published UI. + window.refreshProfile(); window.setRuntimeIndexStatus(getRuntimeIndexStatus()); ProjectScope scope = current; if (scope != null && scope.isActive() && scope.runtime() != null) { @@ -627,32 +624,12 @@ public boolean isConnected() { return !closed && !switching && session != null && session.isConnected(); } - public boolean hasProfile() { - return current != null; - } - - public String getActiveRuntimeSignature() { - RuntimeBinding current = currentRuntime(); - return current == null ? null : current.snapshot().signature(); - } - - public boolean send(AbstractMessage message) { + private boolean send(AbstractMessage message) { if (switching && !(message instanceof StopScriptMessage)) return false; CompanionSession current = session; return current != null && current.send(message); } - public CompletableFuture compileJava(String source, String entryClass) { - try { return requireProject().admit(() -> scriptCompiler.compile(source, entryClass)); } - catch (IllegalStateException failure) { return CompletableFuture.failedFuture(failure); } - } - - /** A pre-send check; the receiving runtime must still validate the result's inventory identity. */ - public boolean isCurrentRuntimeInventory(String inventoryId) { - return isConnected() && scriptCompiler.isCurrentInventory(inventoryId); - } - - public ScriptExecutionService scriptExecutions() { return scriptExecutions; } public CompanionSession session() { return session; } public void openClass(String binaryName, int targetType, String targetIdentifier) { @@ -712,36 +689,6 @@ public InstanceState instanceState() { return scope == null ? emptyState : scope.state(); } - public Path getRootPath() { - return requireProfile().dataDirectory(); - } - - public Path getWorkspaceDirectory() { - return requireProfile().workspaceDirectory(); - } - - public ReferenceSearchService getReferenceSearchService() { - RuntimeBinding current = currentRuntime(); - ReferenceSearchService service = current == null ? null : current.references(); - if (service == null) { - throw new IllegalStateException("Reference search is unavailable"); - } - return service; - } - - public CodeInsightService getCodeInsightService() { - CodeInsightService service = codeInsightService; - if (currentRuntime() == null) { - throw new IllegalStateException("Code insight is unavailable"); - } - return service; - } - - public RuntimeSourceCatalog getRuntimeSourceCatalog() { - RuntimeBinding current = currentRuntime(); - return current == null ? RuntimeSourceCatalog.empty() : current.sources(); - } - public CompanionDecompilationService getDecompilationService() { RuntimeBinding current = currentRuntime(); CompanionDecompilationService service = current == null ? null : current.decompiler(); @@ -759,16 +706,6 @@ public DebuggerSessionController getDebuggerController() { return controller; } - public boolean isDebuggerConnected() { - DebuggerSessionController controller = debuggerController; - if (controller == null) { - return false; - } - DebuggerSessionController.Phase phase = controller.status().phase(); - return phase == DebuggerSessionController.Phase.RUNNING - || phase == DebuggerSessionController.Phase.PAUSED; - } - private DebuggerSessionController createDebuggerController() { DebuggerSessionController controller = new DebuggerSessionController(this::loadDebugSource, () -> { RuntimeBinding current = currentRuntime(); return current == null ? null : current.classpath(); }, name -> requireProject().loadBreakpointScript(name)); controller.setBreakpointsMuted(instanceState().debuggerBreakpointsMuted()).join(); @@ -798,28 +735,6 @@ public RuntimeIndexService.Status getRuntimeIndexStatus() { : service.status(); } - public void addRuntimeIndexStatusListener(Consumer listener) { - RuntimeIndexService service = runtimeIndexService; - if (service != null) { - service.addStatusListener(listener); - } else { - listener.accept(getRuntimeIndexStatus()); - } - } - - public void removeRuntimeIndexStatusListener(Consumer listener) { - RuntimeIndexService service = runtimeIndexService; - if (service != null) service.removeStatusListener(listener); - } - - public void retryRuntimeIndex() { - RuntimeIndexService service = runtimeIndexService; - if (service != null) { - service.waiting("Requesting runtime inventory again"); - } - send(new RetryRuntimeInventoryMessage()); - } - private CompanionProfile requireProfile() { CompanionProfile current = currentProject(); if (current == null) { diff --git a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/ApplicationNavigationTest.java b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/ApplicationNavigationTest.java new file mode 100644 index 00000000..c4adae2f --- /dev/null +++ b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/ApplicationNavigationTest.java @@ -0,0 +1,53 @@ +package com.github.minecraft_ta.totalDebugCompanion; + +import com.github.minecraft_ta.totalDebugCompanion.bytecode.RuntimeSnapshotBytecodeSource; +import com.github.minecraft_ta.totalDebugCompanion.debugger.DebugEngine; +import com.github.minecraft_ta.totalDebugCompanion.model.ServiceStatus; +import com.github.minecraft_ta.totalDebugCompanion.navigation.NavigationService; +import com.github.minecraft_ta.totalDebugCompanion.navigation.NavigationTarget; +import com.github.minecraft_ta.totalDebugCompanion.runtime.RuntimeIndexService; +import com.github.minecraft_ta.totalDebugCompanion.session.CompanionLaunchConfiguration; +import com.github.minecraft_ta.totalDebugCompanion.session.CompanionProfile; +import com.github.minecraft_ta.totalDebugCompanion.ui.CompanionUi; +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.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; +import static org.junit.jupiter.api.Assertions.*; + +class ApplicationNavigationTest { + @TempDir Path directory; + @Test void frameNavigationWaitsForTheRuntimeInItsProject() throws Exception { + var ui = new RecordingUi(); + var home = Files.createDirectories(directory.resolve("app")); + GlobalConfig.getInstance().loadFrom(home); + try (var app = new CompanionApplication(new CompanionLaunchConfiguration(home), "test-token", ui)) { + app.openProject(CompanionProfile.forGame(Files.createDirectories(directory.resolve("game")))).get(3, TimeUnit.SECONDS); + var frame = new DebugEngine.StackFrame(1, "run", "java.lang.Object", null, 7, 0); + app.openDebugFrame(frame, true); + assertFalse(ui.navigation.isDone(), "Frame navigation must wait for an installed runtime"); + var snapshot = RuntimeInstallationTest.snapshot(directory, "navigation"); + try { app.installRuntimeSnapshot(snapshot, RuntimeSnapshotBytecodeSource.fromIndexedSources(snapshot.sources(), snapshot.index())); } + catch (RuntimeException failure) { snapshot.close(); throw failure; } + assertEquals(new NavigationTarget.RuntimeLine("java.lang.Object", 7), ui.navigation.get(10, TimeUnit.SECONDS)); + } + } + + private static final class RecordingUi implements CompanionUi { + final CompletableFuture navigation = new CompletableFuture<>(); + public boolean prepareProjectSwitch() { return true; } + public boolean canExit() { return true; } + public void setSwitching(boolean switching) { } + public void refreshProfile() { } + public void runtimeChanged() { } + public void setGameStatus(ServiceStatus status) { } + public void setMcpStatus(ServiceStatus status) { } + public void setRuntimeIndexStatus(RuntimeIndexService.Status status) { } + public void navigate(NavigationTarget target, NavigationService.Activation activation) { navigation.complete(target); } + public void focus() { } + public void showError(String title, String message) { fail(title + ": " + message); } + public void dispose() { } + } +} 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 67463d61..992e8d27 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 @@ -62,7 +62,7 @@ class RuntimeInstallationTest { } } - private static ReadySnapshot snapshot(Path root, String signature) throws Exception { + static ReadySnapshot snapshot(Path root, String signature) throws Exception { Path classes = Files.createDirectories(root.resolve("classes")); var module = new RuntimeInventory.RuntimeModule("test", "Test", RuntimeInventory.ModuleKind.LIBRARY); var source = new RuntimeSnapshotBytecodeSource.Source(0, classes, classes.toUri().toString(), module); diff --git a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/UiDevHarness.java b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/UiDevHarness.java index 741664a5..444ed03b 100644 --- a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/UiDevHarness.java +++ b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/UiDevHarness.java @@ -1,5 +1,7 @@ package com.github.minecraft_ta.totalDebugCompanion; +import javax.swing.JLabel; +import javax.swing.Timer; import com.formdev.flatlaf.extras.FlatInspector; import com.formdev.flatlaf.extras.FlatUIDefaultsInspector; import com.github.minecraft_ta.totalDebugCompanion.model.CodeView; @@ -285,7 +287,7 @@ private static T findComponent(Container root, Class ty } private static void scheduleSearchEverywhereInteractionVerification() { - javax.swing.Timer openTimer = new javax.swing.Timer(500, event -> { + Timer openTimer = new Timer(500, event -> { mainWindow.openSearchEverywhere(); SearchEverywherePopup popup = Arrays.stream(java.awt.Window.getWindows()) .filter(SearchEverywherePopup.class::isInstance) @@ -296,7 +298,7 @@ private static void scheduleSearchEverywhereInteractionVerification() { // This fixture sends synthetic events; native focus belongs to the user's other windows. var focusListeners = popup.getWindowFocusListeners(); for (var listener : focusListeners) popup.removeWindowFocusListener(listener); - javax.swing.Timer firstQuery = new javax.swing.Timer(250, queryEvent -> { + Timer firstQuery = new Timer(250, queryEvent -> { popup.setLocation(mainWindow.getX() + 220, mainWindow.getY() + 70); FlatIconTextField search = findComponent(popup, FlatIconTextField.class); if (search == null) { @@ -305,7 +307,7 @@ private static void scheduleSearchEverywhereInteractionVerification() { search.setText("Theme"); long resultsDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(3); - javax.swing.Timer verifyResults = new javax.swing.Timer(50, verifyEvent -> { + Timer verifyResults = new Timer(50, verifyEvent -> { @SuppressWarnings("rawtypes") JList results = findComponent(popup, JList.class); if (results == null || results.getModel().getSize() == 0 || !results.isShowing()) { @@ -313,7 +315,7 @@ private static void scheduleSearchEverywhereInteractionVerification() { throw new IllegalStateException("Initial Search Everywhere results did not become visible"); } - ((javax.swing.Timer) verifyEvent.getSource()).stop(); + ((Timer) verifyEvent.getSource()).stop(); int previousResultCount = results.getModel().getSize(); search.setText("ThemeSampleImpl"); if (!results.isShowing() || results.getModel().getSize() != previousResultCount) { @@ -334,12 +336,12 @@ private static void scheduleSearchEverywhereInteractionVerification() { } long updatedDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(3); - javax.swing.Timer verifyUpdated = new javax.swing.Timer(50, updatedEvent -> { + Timer verifyUpdated = new Timer(50, updatedEvent -> { if (results.getModel().getSize() != 1 || !results.isShowing()) { if (System.nanoTime() < updatedDeadline) return; throw new IllegalStateException("Updated Search Everywhere results did not remain visible"); } - ((javax.swing.Timer) updatedEvent.getSource()).stop(); + ((Timer) updatedEvent.getSource()).stop(); for (var listener : focusListeners) popup.addWindowFocusListener(listener); var lostFocus = new WindowEvent(popup, WindowEvent.WINDOW_LOST_FOCUS); for (var listener : focusListeners) listener.windowLostFocus(lostFocus); @@ -361,7 +363,7 @@ private static void scheduleSearchEverywhereInteractionVerification() { } private static void scheduleMethodNavigationVerification() { - javax.swing.Timer openTimer = new javax.swing.Timer(500, event -> application.openClass( + Timer openTimer = new Timer(500, event -> application.openClass( "sample.ThemeSampleImpl", org.eclipse.jdt.core.IJavaElement.METHOD, "Lsample/ThemeSampleImpl;.apply(Lsample/ThemeSample;)V" @@ -370,7 +372,7 @@ private static void scheduleMethodNavigationVerification() { openTimer.start(); long deadline = System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(12); - javax.swing.Timer verifyTimer = new javax.swing.Timer(100, event -> { + Timer verifyTimer = new Timer(100, event -> { try { var selected = mainWindow.getEditorTabs().getSelectedEditor(); if (selected instanceof CodeView codeView @@ -384,7 +386,7 @@ private static void scheduleMethodNavigationVerification() { int lineStart = editor.getLineStartOffset(line); String lineText = editor.getText(lineStart, editor.getLineEndOffset(line) - lineStart); if (lineText.contains(" apply(")) { - ((javax.swing.Timer) event.getSource()).stop(); + ((Timer) event.getSource()).stop(); System.out.println("METHOD_NAVIGATION_OK line=" + (line + 1) + " caret=" + caret); mainWindow.dispose(); finishHarness(0); @@ -497,7 +499,7 @@ private static void dispatchWindowDrag(Component component, int deltaX, int delt * actually refreshes. */ private static void startThemeCycling() { - javax.swing.Timer timer = new javax.swing.Timer(8000, null); + Timer timer = new Timer(8000, null); timer.addActionListener(event -> { var themes = com.github.minecraft_ta.totalDebugCompanion.ui.theme.CompanionTheme.available(); var current = com.github.minecraft_ta.totalDebugCompanion.ui.theme.ThemeManager.current(); @@ -509,7 +511,7 @@ private static void startThemeCycling() { } private static void scheduleCodeVisionClickVerification(String source) { - javax.swing.Timer clickTimer = new javax.swing.Timer(1400, event -> { + Timer clickTimer = new Timer(1400, event -> { try { RSyntaxTextArea editor = findComponent(mainWindow, RSyntaxTextArea.class); if (editor == null) { @@ -524,16 +526,16 @@ private static void scheduleCodeVisionClickVerification(String source) { ); long deadline = System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(5); - javax.swing.Timer resultTimer = new javax.swing.Timer(250, resultEvent -> { + Timer resultTimer = new Timer(250, resultEvent -> { var selected = mainWindow.getEditorTabs().getSelectedEditor(); if (selected instanceof UsagesView) { - ((javax.swing.Timer) resultEvent.getSource()).stop(); + ((Timer) resultEvent.getSource()).stop(); System.out.println("CODE_VISION_CLICK_OK"); mainWindow.dispose(); finishHarness(0); } if (System.nanoTime() >= deadline) { - ((javax.swing.Timer) resultEvent.getSource()).stop(); + ((Timer) resultEvent.getSource()).stop(); System.err.println("CODE_VISION_CLICK_MISSED selected=" + (selected == null ? "null" : selected.getClass().getName())); mainWindow.dispose(); @@ -555,7 +557,7 @@ private static void scheduleCodeVisionClickVerification(String source) { } private static void scheduleGutterClickVerification(String source) { - javax.swing.Timer setupTimer = new javax.swing.Timer(1400, event -> { + Timer setupTimer = new Timer(1400, event -> { try { RSyntaxTextArea editor = findComponent(mainWindow, RSyntaxTextArea.class); IconRowHeader iconRow = findComponent(mainWindow, IconRowHeader.class); @@ -571,15 +573,15 @@ private static void scheduleGutterClickVerification(String source) { ); target.x = iconRow.getWidth() / 2; long deadline = System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(5); - javax.swing.Timer resultTimer = new javax.swing.Timer(250, resultEvent -> { + Timer resultTimer = new Timer(250, resultEvent -> { if (isShowing(ImplementationChooserPopup.class)) { - ((javax.swing.Timer) resultEvent.getSource()).stop(); + ((Timer) resultEvent.getSource()).stop(); System.out.println("GUTTER_CLICK_OK"); mainWindow.dispose(); finishHarness(0); } if (System.nanoTime() >= deadline) { - ((javax.swing.Timer) resultEvent.getSource()).stop(); + ((Timer) resultEvent.getSource()).stop(); System.err.println("GUTTER_CLICK_MISSED"); mainWindow.dispose(); finishHarness(2); @@ -600,7 +602,7 @@ private static void scheduleGutterClickVerification(String source) { } private static void scheduleSingleGutterNavigationVerification(String source) { - javax.swing.Timer setupTimer = new javax.swing.Timer(1400, event -> { + Timer setupTimer = new Timer(1400, event -> { try { RSyntaxTextArea editor = findComponent(mainWindow, RSyntaxTextArea.class); IconRowHeader iconRow = findComponent(mainWindow, IconRowHeader.class); @@ -616,16 +618,16 @@ private static void scheduleSingleGutterNavigationVerification(String source) { ); target.x = iconRow.getWidth() / 2; long deadline = System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(10); - javax.swing.Timer resultTimer = new javax.swing.Timer(250, resultEvent -> { + Timer resultTimer = new Timer(250, resultEvent -> { var selected = mainWindow.getEditorTabs().getSelectedEditor(); if (selected instanceof CodeView codeView && "SingleActionImpl".equals(codeView.getTitle())) { - ((javax.swing.Timer) resultEvent.getSource()).stop(); + ((Timer) resultEvent.getSource()).stop(); System.out.println("GUTTER_DIRECT_NAVIGATION_OK"); mainWindow.dispose(); finishHarness(0); } if (System.nanoTime() >= deadline) { - ((javax.swing.Timer) resultEvent.getSource()).stop(); + ((Timer) resultEvent.getSource()).stop(); System.err.println("GUTTER_DIRECT_NAVIGATION_MISSED selected=" + (selected == null ? "null" : selected.getTitle())); mainWindow.dispose(); @@ -648,7 +650,7 @@ private static void scheduleSingleGutterNavigationVerification(String source) { private static void scheduleGutterHover(String source, int declarationOffset, boolean verify) { long deadline = System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(5); - javax.swing.Timer hoverTimer = new javax.swing.Timer(150, event -> { + Timer hoverTimer = new Timer(150, event -> { try { RSyntaxTextArea editor = findComponent(mainWindow, RSyntaxTextArea.class); IconRowHeader iconRow = findComponent(mainWindow, IconRowHeader.class); @@ -664,7 +666,7 @@ private static void scheduleGutterHover(String source, int declarationOffset, bo } if (verify) { - ((javax.swing.Timer) event.getSource()).stop(); + ((Timer) event.getSource()).stop(); } Rectangle2D declaration = editor.modelToView2D(declarationOffset); var viewport = (javax.swing.JViewport) SwingUtilities.getAncestorOfClass( @@ -704,15 +706,15 @@ private static boolean isShowing(Class type) { private static void scheduleWindowVerification(Class type, String successMessage) { long deadline = System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(5); - javax.swing.Timer timer = new javax.swing.Timer(100, event -> { + Timer timer = new Timer(100, event -> { if (isShowing(type)) { - ((javax.swing.Timer) event.getSource()).stop(); + ((Timer) event.getSource()).stop(); System.out.println(successMessage); mainWindow.dispose(); finishHarness(0); } if (System.nanoTime() >= deadline) { - ((javax.swing.Timer) event.getSource()).stop(); + ((Timer) event.getSource()).stop(); System.err.println(successMessage.replace("_OK", "_MISSED")); mainWindow.dispose(); finishHarness(2); @@ -723,18 +725,18 @@ private static void scheduleWindowVerification(Class private static void scheduleHierarchyRowLayoutVerification() { long deadline = System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(5); - javax.swing.Timer timer = new javax.swing.Timer(100, event -> { + Timer timer = new Timer(100, event -> { HierarchyPreviewPopup popup = Arrays.stream(java.awt.Window.getWindows()) .filter(HierarchyPreviewPopup.class::isInstance) .map(HierarchyPreviewPopup.class::cast) .filter(java.awt.Window::isShowing) .findFirst() .orElse(null); - javax.swing.JLabel declaration = popup == null + JLabel declaration = popup == null ? null : findLabelContaining(popup, "OverrideChild.overrideMe"); if (declaration != null) { - ((javax.swing.Timer) event.getSource()).stop(); + ((Timer) event.getSource()).stop(); PrimarySecondaryLabel declarationPresentation = (PrimarySecondaryLabel) declaration.getParent(); Container row = declarationPresentation.getParent(); PrimarySecondaryLabel modulePresentation = Arrays.stream(row.getComponents()) @@ -743,7 +745,7 @@ private static void scheduleHierarchyRowLayoutVerification() { .filter(presentation -> presentation != declarationPresentation) .findFirst() .orElseThrow(); - javax.swing.JLabel module = firstLabel(modulePresentation); + JLabel module = firstLabel(modulePresentation); Point declarationOrigin = SwingUtilities.convertPoint(declaration, 0, 0, row); Point moduleOrigin = SwingUtilities.convertPoint(module, 0, 0, row); int declarationBaseline = declarationOrigin.y @@ -773,7 +775,7 @@ private static void scheduleHierarchyRowLayoutVerification() { finishHarness(0); } if (System.nanoTime() >= deadline) { - ((javax.swing.Timer) event.getSource()).stop(); + ((Timer) event.getSource()).stop(); System.err.println("HIERARCHY_ROW_LAYOUT_MISSED"); mainWindow.dispose(); finishHarness(2); @@ -782,15 +784,15 @@ private static void scheduleHierarchyRowLayoutVerification() { timer.start(); } - private static javax.swing.JLabel findLabelContaining(Container root, String expectedText) { + private static JLabel findLabelContaining(Container root, String expectedText) { for (Component component : root.getComponents()) { - if (component instanceof javax.swing.JLabel label + if (component instanceof JLabel label && label.getText() != null && label.getText().contains(expectedText)) { return label; } if (component instanceof Container child) { - javax.swing.JLabel match = findLabelContaining(child, expectedText); + JLabel match = findLabelContaining(child, expectedText); if (match != null) { return match; } @@ -799,10 +801,10 @@ private static javax.swing.JLabel findLabelContaining(Container root, String exp return null; } - private static javax.swing.JLabel firstLabel(Container root) { + private static JLabel firstLabel(Container root) { return Arrays.stream(root.getComponents()) - .filter(javax.swing.JLabel.class::isInstance) - .map(javax.swing.JLabel.class::cast) + .filter(JLabel.class::isInstance) + .map(JLabel.class::cast) .findFirst() .orElseThrow(); } @@ -1049,6 +1051,8 @@ public static void main(String[] args) throws Exception { new UiScenarioDriver(mainWindow, UiDevHarness::finishHarness).schedule(scenario, CodeView.readCode(sample), screenshot); } }); + application.awaitExit(); + finishHarness(0); } private static java.util.Optional argument(String[] args, String prefix) { diff --git a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/UiScenarioDriver.java b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/UiScenarioDriver.java index 47cbfcc5..5ab6a30e 100644 --- a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/UiScenarioDriver.java +++ b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/UiScenarioDriver.java @@ -1,5 +1,8 @@ package com.github.minecraft_ta.totalDebugCompanion; +import javax.swing.JLabel; +import javax.swing.JPopupMenu; +import javax.swing.Timer; import com.github.minecraft_ta.totalDebugCompanion.jdt.symbol.CodeSymbol; import com.github.minecraft_ta.totalDebugCompanion.debugger.DebugEngine; import com.github.minecraft_ta.totalDebugCompanion.jdt.diagnostics.ASTCache; @@ -59,7 +62,7 @@ void schedule(UiRenderScenario scenario, String source, Path screenshot) { ScenarioContext context = new ScenarioContext(source); long deadline = System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(12); int[] stableReadyPolls = {0}; - javax.swing.Timer timer = new javax.swing.Timer(50, event -> { + Timer timer = new Timer(50, event -> { try { advance(scenario, context); if (context.workspaceReady() && ready(scenario, context)) { @@ -67,7 +70,7 @@ void schedule(UiRenderScenario scenario, String source, Path screenshot) { if (stableReadyPolls[0] < READY_POLLS) { return; } - ((javax.swing.Timer) event.getSource()).stop(); + ((Timer) event.getSource()).stop(); if (screenshot != null) { capture(screenshot); mainWindow.dispose(); @@ -478,11 +481,11 @@ private void expandTree() { } } - private javax.swing.JPopupMenu visibleMenuPopup() { + private JPopupMenu visibleMenuPopup() { return Arrays.stream(javax.swing.MenuSelectionManager.defaultManager().getSelectedPath()) - .filter(javax.swing.JPopupMenu.class::isInstance) - .map(javax.swing.JPopupMenu.class::cast) - .filter(javax.swing.JPopupMenu::isShowing) + .filter(JPopupMenu.class::isInstance) + .map(JPopupMenu.class::cast) + .filter(JPopupMenu::isShowing) .findFirst() .orElse(null); } @@ -511,15 +514,15 @@ private T findComponent(Container root, Class type) { return null; } - private javax.swing.JLabel findLabelContaining(Container root, String expectedText) { + private JLabel findLabelContaining(Container root, String expectedText) { for (Component component : root.getComponents()) { - if (component instanceof javax.swing.JLabel label + if (component instanceof JLabel label && label.getText() != null && label.getText().contains(expectedText)) { return label; } if (component instanceof Container child) { - javax.swing.JLabel match = findLabelContaining(child, expectedText); + JLabel match = findLabelContaining(child, expectedText); if (match != null) { return match; } From 304f3cb9e27662cca1ad00304d7a34c42f809ece Mon Sep 17 00:00:00 2001 From: Pelotrio <45769595+Pelotrio@users.noreply.github.com> Date: Fri, 11 Sep 2026 18:23:22 +0200 Subject: [PATCH 3/7] Synchronize lifecycle regressions on completed callbacks # Commit time for manual adjustment: # Took 1 second --- .../ApplicationNavigationTest.java | 1 + .../ProjectSwitchLifecycleTest.java | 12 +++++- .../RuntimeInstallationTest.java | 37 +++++++++++++++++-- .../CompanionProjectAttachmentTest.java | 9 ++++- 4 files changed, 53 insertions(+), 6 deletions(-) diff --git a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/ApplicationNavigationTest.java b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/ApplicationNavigationTest.java index c4adae2f..e0870140 100644 --- a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/ApplicationNavigationTest.java +++ b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/ApplicationNavigationTest.java @@ -38,6 +38,7 @@ class ApplicationNavigationTest { private static final class RecordingUi implements CompanionUi { final CompletableFuture navigation = new CompletableFuture<>(); public boolean prepareProjectSwitch() { return true; } + public boolean closeProjectViews() { return true; } public boolean canExit() { return true; } public void setSwitching(boolean switching) { } public void refreshProfile() { } diff --git a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/ProjectSwitchLifecycleTest.java b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/ProjectSwitchLifecycleTest.java index f37f4ccd..5e81368a 100644 --- a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/ProjectSwitchLifecycleTest.java +++ b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/ProjectSwitchLifecycleTest.java @@ -16,6 +16,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.CompletableFuture; import java.util.Map; import com.github.minecraft_ta.totalDebugCompanion.mcp.ProjectSwitchJobs; @@ -174,6 +175,7 @@ public boolean canClose() { assertEquals("still A", scopeBeforeVeto.admit(() -> "still A")); assertFalse(disposed.get()); allowed.set(true); + app.instanceState().setDebuggerWatches(List.of("saved view state")); app.instanceState().saveNow(); Path stateFile = a.dataDirectory().resolve("state.json"); byte[] savedState = Files.readAllBytes(stateFile); @@ -226,12 +228,14 @@ public boolean canClose() { } private static void verifyNavigationReset(CompanionApplication app, MainWindow window) throws Exception { - var pending = new java.util.concurrent.atomic.AtomicReference>(); + var pending = new AtomicReference>(); + var lookupStarted = new AtomicReference>(); var created = new CompletableFuture(); javax.swing.SwingUtilities.invokeAndWait(() -> { var tree = new FileTreeView(app::currentScope, ignored -> { }) { @Override public CompletableFuture revealLocalDirectory(Path path) { var delayed = pending.getAndSet(null); + if (delayed != null) lookupStarted.get().complete(null); return delayed == null ? CompletableFuture.completedFuture(true) : delayed; } }; @@ -246,7 +250,9 @@ private static void verifyNavigationReset(CompanionApplication app, MainWindow w navigation.navigate(new NavigationTarget.LocalDirectory(Path.of(directory))).get(3, TimeUnit.SECONDS); var delayedA = new CompletableFuture(); pending.set(delayedA); + lookupStarted.set(new CompletableFuture<>()); var oldTraversal = navigation.goBack(); + lookupStarted.get().get(3, TimeUnit.SECONDS); scopeA.retire(); javax.swing.SwingUtilities.invokeAndWait(() -> navigation.projectChanged(scopeB)); assertFalse(oldTraversal.isDone(), "Old lookup is still awaiting a callback"); @@ -255,7 +261,9 @@ private static void verifyNavigationReset(CompanionApplication app, MainWindow w javax.swing.SwingUtilities.invokeAndWait(() -> assertTrue(navigation.backAction().isEnabled())); var delayedB = new CompletableFuture(); pending.set(delayedB); + lookupStarted.set(new CompletableFuture<>()); var newTraversal = navigation.goBack(); + lookupStarted.get().get(3, TimeUnit.SECONDS); javax.swing.SwingUtilities.invokeAndWait(() -> { }); assertFalse(newTraversal.isDone()); javax.swing.SwingUtilities.invokeAndWait(() -> { @@ -272,7 +280,9 @@ private static void verifyNavigationReset(CompanionApplication app, MainWindow w javax.swing.SwingUtilities.invokeAndWait(() -> assertTrue(navigation.forwardAction().isEnabled())); var duringVeto = new CompletableFuture(); pending.set(duringVeto); + lookupStarted.set(new CompletableFuture<>()); var vetoTraversal = navigation.goForward(); + lookupStarted.get().get(3, TimeUnit.SECONDS); javax.swing.SwingUtilities.invokeAndWait(() -> { }); scopeB.beginSwitch(); duringVeto.complete(true); 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 992e8d27..2db06e4d 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,6 +1,7 @@ package com.github.minecraft_ta.totalDebugCompanion; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicReference; import com.github.minecraft_ta.totalDebugCompanion.model.ResourceView; import com.github.minecraft_ta.totalDebugCompanion.resource.ArchiveEntrySource; import com.github.minecraft_ta.totalDebugCompanion.ui.views.MainWindow; @@ -62,6 +63,38 @@ class RuntimeInstallationTest { } } + @Test void lateRuntimeRefreshKeepsViewsOpenedAgainstTheInstalledBinding() throws Exception { + Path home = Files.createDirectories(directory.resolve("app")); + try (var app = new CompanionApplication(new CompanionLaunchConfiguration(home), "test-token")) { + app.openProject(new CompanionProfile("test", Files.createDirectories(directory.resolve("data")), + Files.createDirectories(directory.resolve("game")))).get(10, TimeUnit.SECONDS); + try (var accepted = snapshot(directory, "accepted")) { + app.installRuntimeSnapshot(accepted, + RuntimeSnapshotBytecodeSource.fromIndexedSources(accepted.sources(), accepted.index())); + app.openProject(app.currentProject()).get(10, TimeUnit.SECONDS); + var createdWindow = new AtomicReference(); + var currentView = new AtomicReference(); + SwingUtilities.invokeAndWait(() -> { + MainWindow window = app.createWindow(); + var source = new ArchiveEntrySource(directory.resolve("same.jar"), "same.txt", -1); + var stale = new ResourceView(window.editorContext(), source, null); + var current = new ResourceView(window.editorContext(), source, app.requireProject().runtime()); + window.getEditorTabs().openEditorTab(stale); + window.getEditorTabs().openEditorTab(current); + createdWindow.set(window); + currentView.set(current); + window.navigation().runtimeChanged(); + }); + SwingUtilities.invokeAndWait(() -> { + assertEquals(1, createdWindow.get().getEditorTabs().getTabCount()); + assertSame(currentView.get(), createdWindow.get().getEditorTabs().getSelectedEditor(), + "A late refresh must preserve a tab opened against the installed runtime"); + }); + app.close(); + } + } + } + static ReadySnapshot snapshot(Path root, String signature) throws Exception { Path classes = Files.createDirectories(root.resolve("classes")); var module = new RuntimeInventory.RuntimeModule("test", "Test", RuntimeInventory.ModuleKind.LIBRARY); @@ -72,8 +105,4 @@ static ReadySnapshot snapshot(Path root, String signature) throws Exception { } } - private static String read(Path path) { - try { return Files.readString(path); } - catch (Exception failure) { return failure.toString(); } - } } diff --git a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/session/CompanionProjectAttachmentTest.java b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/session/CompanionProjectAttachmentTest.java index 3925248e..56f7bd69 100644 --- a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/session/CompanionProjectAttachmentTest.java +++ b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/session/CompanionProjectAttachmentTest.java @@ -15,6 +15,8 @@ import java.nio.ByteBuffer; import java.nio.file.Path; import java.util.concurrent.atomic.AtomicReference; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; import static org.junit.jupiter.api.Assertions.*; class CompanionProjectAttachmentTest { @@ -22,9 +24,12 @@ class CompanionProjectAttachmentTest { @Test void explicitSwitchReplacesAnAttachedGameButAnOrdinaryHandshakeCannot() throws Exception { var selected = new AtomicReference<>("a"); + var disconnected = new LinkedBlockingQueue(); try (var session = new CompanionSession("secret", hello -> { if (!hello.profileId().equals(selected.get())) throw new IOException("Select the project first"); - }, new CompanionSession.Listener() {})) { + }, new CompanionSession.Listener() { + @Override public void disconnected() { disconnected.add(true); } + })) { session.setProjectSelectionHandler(hello -> { if (!hello.profileId().equals(selected.get())) { session.disconnect(); @@ -39,10 +44,12 @@ class CompanionProjectAttachmentTest { assertTrue(ProjectSelectionRequest.send(descriptor.projectPort(), hello("a"))); assertFalse(ProjectSelectionRequest.send(descriptor.projectPort(), hello("b"))); assertEquals(-1, a.getInputStream().read()); + assertNotNull(disconnected.poll(5, TimeUnit.SECONDS), "A teardown must finish before reconnecting"); assertEquals("b", selected.get()); try (var rejected = connect(descriptor.port())) { assertFalse(handshake(rejected, "a")); assertEquals(-1, rejected.getInputStream().read()); + assertNotNull(disconnected.poll(5, TimeUnit.SECONDS), "Rejected connection teardown must finish"); } try (var b = connect(descriptor.port())) { assertTrue(handshake(b, "b")); From 5bfcec3c6bb8d2d252d1b2e09747fb00466cb2c0 Mon Sep 17 00:00:00 2001 From: Pelotrio <45769595+Pelotrio@users.noreply.github.com> Date: Fri, 11 Sep 2026 18:55:40 +0200 Subject: [PATCH 4/7] Publish initialized windows atomically and release owned theme listeners --- .../totalDebugCompanion/CompanionApp.java | 11 +++- .../CompanionApplication.java | 52 +++++++++---------- .../global/ApplicationStatusBar.java | 9 +++- .../ui/theme/ThemeManager.java | 2 + .../ui/views/EvaluateExpressionWindow.java | 12 +++-- .../ui/views/MainWindow.java | 14 ++--- .../ApplicationNavigationTest.java | 33 +++++++++++- .../CompanionAppDataDirectoryTest.java | 2 +- .../ui/theme/ThemeListenerDisposalTest.java | 34 ++++++++++++ 9 files changed, 127 insertions(+), 42 deletions(-) create mode 100644 companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/ui/theme/ThemeListenerDisposalTest.java diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/CompanionApp.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/CompanionApp.java index 3d387e75..cf7b4e76 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/CompanionApp.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/CompanionApp.java @@ -17,6 +17,9 @@ import org.fife.ui.rsyntaxtextarea.TokenMakerFactory; import javax.swing.JDialog; import javax.swing.JFrame; +import javax.swing.SwingUtilities; +import javax.swing.ToolTipManager; +import com.github.minecraft_ta.totalDebugCompanion.util.UIUtils; import java.io.IOException; import java.nio.channels.FileChannel; import java.nio.channels.FileLock; @@ -91,7 +94,13 @@ static int run(String[] args, Map environment, CompanionTimeouts GlobalConfig.getInstance().loadFrom(configuration.appHome()); configureLookAndFeel(); try (var application = new CompanionApplication(configuration, token)) { - application.startUi(); + SwingUtilities.invokeAndWait(() -> { + var window = application.createWindow(); + window.setSize(1280, 720); + window.setVisible(true); + UIUtils.centerJFrame(window, window); + ToolTipManager.sharedInstance().setInitialDelay(200); + }); application.start(); startup.close(); application.awaitExit(); 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 62d9c4ef..05e97d3e 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 @@ -38,11 +38,9 @@ import com.github.minecraft_ta.totalDebugCompanion.session.ProjectRegistry; import com.github.minecraft_ta.totalDebugCompanion.session.CompanionSession; import com.github.minecraft_ta.totalDebugCompanion.ui.views.MainWindow; -import com.github.minecraft_ta.totalDebugCompanion.util.UIUtils; import com.github.tth05.scnet.message.AbstractMessage; import org.eclipse.jdt.core.dom.ASTParser; import javax.swing.SwingUtilities; -import javax.swing.ToolTipManager; import java.io.IOException; import java.lang.reflect.InvocationTargetException; import java.net.URI; @@ -379,7 +377,7 @@ private void validateProfile(CompanionProfile requested) throws IOException { throw new IOException("Minecraft workspace not found"); } Files.createDirectories(requested.dataDirectory()); - setupDataDirectories(requested.dataDirectory(), true); + setupDataDirectories(requested.dataDirectory()); } private void handleRuntimeInventory(RuntimeInventoryMessage message) { @@ -469,11 +467,8 @@ private void finishRuntimeInstallation(RuntimeBinding installed, ProjectScope se } } - static void setupDataDirectories(Path rootPath, boolean scriptExecutionEnabled) throws IOException { + static void setupDataDirectories(Path rootPath) throws IOException { - if (!scriptExecutionEnabled) { - return; - } Files.createDirectories(new InstancePaths(rootPath).scripts()); } @@ -559,30 +554,33 @@ private Map runtimeContext() { } public MainWindow createWindow() { - if (closed) throw new IllegalStateException("Application is closed"); if (!SwingUtilities.isEventDispatchThread()) throw new IllegalStateException("Create the window on the EDT"); - if (ui != null) throw new IllegalStateException("Application already has a UI"); + synchronized (lifecycleLock) { checkWindowCreation(); } MainWindow window = new MainWindow(this::currentScope, getDebuggerController(), codeInsightService, scriptExecutions, session, runtimeIndexService, this::openDebugFrame, this::exit); - ui = window; - // A restore may have finished while the constructor still had no published UI. - window.refreshProfile(); - window.setRuntimeIndexStatus(getRuntimeIndexStatus()); - ProjectScope scope = current; - if (scope != null && scope.isActive() && scope.runtime() != null) { - for (PendingNavigation pending : scope.drainNavigations()) window.navigate(pending.target(), pending.activation()); + List queued; + try { + synchronized (lifecycleLock) { + checkWindowCreation(); + // These direct EDT updates and the replay snapshot precede publication. + window.refreshProfile(); + window.setRuntimeIndexStatus(getRuntimeIndexStatus()); + ProjectScope scope = current; + queued = scope != null && scope.runtime() != null ? scope.drainNavigations() : List.of(); + ui = window; + } + } catch (RuntimeException | Error failure) { + window.dispose(); + throw failure; } + for (PendingNavigation pending : queued) window.navigate(pending.target(), pending.activation()); return window; } - void startUi() throws InvocationTargetException, InterruptedException { - SwingUtilities.invokeAndWait(() -> { - MainWindow window = createWindow(); - window.setSize(1280, 720); - window.setVisible(true); - UIUtils.centerJFrame(window, window); - ToolTipManager.sharedInstance().setInitialDelay(200); - }); + private void checkWindowCreation() { + if (closed) throw new IllegalStateException("Application is closed"); + if (ui != null) throw new IllegalStateException("Application already has a UI"); + if (switching || current != null && !current.isActive()) throw new IllegalStateException("Project is switching"); } private void refreshUiProfile() { @@ -630,7 +628,7 @@ private boolean send(AbstractMessage message) { return current != null && current.send(message); } - public CompanionSession session() { return session; } + CompanionSession session() { return session; } public void openClass(String binaryName, int targetType, String targetIdentifier) { openOrQueue( @@ -680,7 +678,7 @@ public AppPaths appPaths() { return launchConfiguration.paths(); } - public InstancePaths instancePaths() { + InstancePaths instancePaths() { return new InstancePaths(requireProfile().dataDirectory()); } @@ -689,7 +687,7 @@ public InstanceState instanceState() { return scope == null ? emptyState : scope.state(); } - public CompanionDecompilationService getDecompilationService() { + CompanionDecompilationService getDecompilationService() { RuntimeBinding current = currentRuntime(); CompanionDecompilationService service = current == null ? null : current.decompiler(); if (service == null) { diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/global/ApplicationStatusBar.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/global/ApplicationStatusBar.java index 42e45b5e..f73cf138 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/global/ApplicationStatusBar.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/components/global/ApplicationStatusBar.java @@ -14,6 +14,7 @@ import com.github.minecraft_ta.totalDebugCompanion.ui.components.AnimatedFlatSVGIcon; import com.github.minecraft_ta.totalDebugCompanion.ui.theme.ThemeColors; import com.github.minecraft_ta.totalDebugCompanion.ui.theme.ThemeManager; +import com.github.minecraft_ta.totalDebugCompanion.ui.theme.CompanionTheme; import javax.swing.BorderFactory; import javax.swing.Box; @@ -32,6 +33,7 @@ import java.util.function.Consumer; public final class ApplicationStatusBar extends JPanel { + private final Consumer themeListener = theme -> applyTheme(); private final BreadcrumbBar breadcrumbs; private final JLabel editorStatusLabel = new JLabel(); private final JLabel taskLabel = new JLabel(); @@ -118,10 +120,15 @@ public ApplicationStatusBar(Consumer navigator, Runnable retry add(this.mcpStatus); add(this.taskCards); applyTheme(); - ThemeManager.addThemeChangeListener(theme -> applyTheme()); + ThemeManager.addThemeChangeListener(this.themeListener); setRuntimeStatus(this.runtimeStatus); } + public void dispose() { + ThemeManager.removeThemeChangeListener(this.themeListener); + setEditor(null); + } + public void setEditor(IEditorPanel editor) { this.memberDebounce.stop(); this.removeCaretListener.run(); diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/theme/ThemeManager.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/theme/ThemeManager.java index 2ba14176..a7c6d8b8 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/theme/ThemeManager.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/theme/ThemeManager.java @@ -47,6 +47,8 @@ public static void addThemeChangeListener(Consumer listener) { LISTENERS.add(listener); } + static int listenerCount() { return LISTENERS.size(); } + public static void removeThemeChangeListener(Consumer listener) { LISTENERS.remove(listener); } diff --git a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/views/EvaluateExpressionWindow.java b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/views/EvaluateExpressionWindow.java index 7144977b..fc1f16ab 100644 --- a/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/views/EvaluateExpressionWindow.java +++ b/companion/src/main/java/com/github/minecraft_ta/totalDebugCompanion/ui/views/EvaluateExpressionWindow.java @@ -47,9 +47,15 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.List; +import java.util.function.Consumer; +import com.github.minecraft_ta.totalDebugCompanion.ui.theme.CompanionTheme; /** Global, history-backed Java expression evaluator for a running Minecraft session. */ public final class EvaluateExpressionWindow extends JDialog { + private final Consumer themeListener = theme -> { + setIconImages(Icons.createWindowIconImages(theme)); + applyTheme(); + }; private static final String CLASS_NAME = "CompanionExpression"; private final JavaExpressionField expression = new JavaExpressionField(54); @@ -203,10 +209,7 @@ private void configureWindow() { setMinimumSize(new Dimension(720, 360)); setSize(860, 500); setIconImages(Icons.createWindowIconImages(ThemeManager.current())); - ThemeManager.addThemeChangeListener(theme -> { - setIconImages(Icons.createWindowIconImages(theme)); - applyTheme(); - }); + ThemeManager.addThemeChangeListener(this.themeListener); } private void evaluate() { @@ -503,6 +506,7 @@ else if (!this.evaluationRunning && this.status.getText().startsWith("Frame no l } @Override public void dispose() { + ThemeManager.removeThemeChangeListener(this.themeListener); clearDebuggerResults(); editorContext.debugger().removeListener(this.debuggerListener); super.dispose(); 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 7f4f6157..fef0b219 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 @@ -182,7 +182,7 @@ public void windowClosing(WindowEvent event) { Toolkit.getDefaultToolkit().removeAWTEventListener(this); closeProjectWindows(); editorTabs.closeMatching(editor -> true); - statusBar.setEditor(null); + statusBar.dispose(); } super.dispose(); } @@ -413,21 +413,21 @@ public NavigationService navigation() { return this.navigationService; } - public void refreshProfile() { + @Override public void refreshProfile() { setDebuggerState(debugger.status()); this.navigationService.projectChanged(project.get()); this.fileTreeView.reloadProfile(); refreshActions(); } - public boolean prepareProjectSwitch() { + @Override public boolean prepareProjectSwitch() { if (!SwingUtilities.isEventDispatchThread()) throw new IllegalStateException("Project views must close on the EDT"); if (!this.editorTabs.canCloseAll()) return false; setEnabled(false); return true; } - public boolean closeProjectViews() { + @Override public boolean closeProjectViews() { if (!SwingUtilities.isEventDispatchThread()) throw new IllegalStateException("Project views must close on the EDT"); this.editorTabs.closeMatching(editor -> true); if (this.editorTabs.getTabCount() != 0) return false; @@ -467,7 +467,7 @@ private void refreshActions() { this.debuggerState.setVisible(hasProfile); } - public void setGameStatus(ServiceStatus status) { + @Override public void setGameStatus(ServiceStatus status) { this.statusBar.setGameStatus(status); if (status.state() != ServiceStatus.State.AVAILABLE && this.snippetExecutions != null) { this.snippetExecutions.runtimeDisconnected(); @@ -475,11 +475,11 @@ public void setGameStatus(ServiceStatus status) { refreshActions(); } - public void setMcpStatus(ServiceStatus status) { + @Override public void setMcpStatus(ServiceStatus status) { this.statusBar.setMcpStatus(status); } - public void setRuntimeIndexStatus(RuntimeIndexService.Status status) { + @Override public void setRuntimeIndexStatus(RuntimeIndexService.Status status) { this.statusBar.setRuntimeStatus(status); } } diff --git a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/ApplicationNavigationTest.java b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/ApplicationNavigationTest.java index e0870140..bca23122 100644 --- a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/ApplicationNavigationTest.java +++ b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/ApplicationNavigationTest.java @@ -12,6 +12,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; import java.nio.file.Files; +import javax.swing.SwingUtilities; import java.nio.file.Path; import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; @@ -35,13 +36,43 @@ class ApplicationNavigationTest { } } + @Test void replacementRejectsRequestsUntilUiRestorationFinishes() throws Exception { + var ui = new RecordingUi(); + try (var app = new CompanionApplication(new CompanionLaunchConfiguration(directory), "test-token", ui)) { + var restored = new CompletableFuture(); + ui.onRefresh = () -> { + assertTrue(app.isSwitching()); + assertFalse(app.currentScope().isActive()); + assertThrows(IllegalStateException.class, app::requireProject); + assertThrows(IllegalStateException.class, () -> app.currentScope().admit(() -> true)); + restored.complete(null); + }; + app.openProject(CompanionProfile.forGame(Files.createDirectories(directory.resolve("game")))).get(3, TimeUnit.SECONDS); + assertTrue(restored.isDone()); + assertTrue(app.requireProject().admit(() -> true)); + assertFalse(app.isSwitching()); + } + } + + @Test void rejectedWindowCreationDoesNotPublishAWindow() throws Exception { + try (var app = new CompanionApplication(new CompanionLaunchConfiguration(directory), "test-token")) { + app.openProject(CompanionProfile.forGame(Files.createDirectories(directory.resolve("game")))).get(3, TimeUnit.SECONDS); + var scope = app.requireProject(); + scope.beginSwitch(); + SwingUtilities.invokeAndWait(() -> assertThrows(IllegalStateException.class, app::createWindow)); + scope.cancelSwitch(); + SwingUtilities.invokeAndWait(() -> assertNotNull(app.createWindow())); + } + } + private static final class RecordingUi implements CompanionUi { + Runnable onRefresh = () -> { }; final CompletableFuture navigation = new CompletableFuture<>(); public boolean prepareProjectSwitch() { return true; } public boolean closeProjectViews() { return true; } public boolean canExit() { return true; } public void setSwitching(boolean switching) { } - public void refreshProfile() { } + public void refreshProfile() { onRefresh.run(); } public void runtimeChanged() { } public void setGameStatus(ServiceStatus status) { } public void setMcpStatus(ServiceStatus status) { } diff --git a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/CompanionAppDataDirectoryTest.java b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/CompanionAppDataDirectoryTest.java index 6ddc9d3e..f26ef308 100644 --- a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/CompanionAppDataDirectoryTest.java +++ b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/CompanionAppDataDirectoryTest.java @@ -15,7 +15,7 @@ class CompanionAppDataDirectoryTest { @Test void cleanScriptHomeDoesNotPersistGeneratedRuntimeSources() throws Exception { - CompanionApplication.setupDataDirectories(this.appHome, true); + CompanionApplication.setupDataDirectories(this.appHome); assertTrue(Files.isDirectory(this.appHome.resolve("scripts"))); assertFalse(Files.exists(this.appHome.resolve("decompiled-files"))); diff --git a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/ui/theme/ThemeListenerDisposalTest.java b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/ui/theme/ThemeListenerDisposalTest.java new file mode 100644 index 00000000..d6a9b189 --- /dev/null +++ b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/ui/theme/ThemeListenerDisposalTest.java @@ -0,0 +1,34 @@ +package com.github.minecraft_ta.totalDebugCompanion.ui.theme; + +import com.github.minecraft_ta.totalDebugCompanion.CompanionApplication; +import com.github.minecraft_ta.totalDebugCompanion.session.CompanionLaunchConfiguration; +import com.github.minecraft_ta.totalDebugCompanion.session.CompanionProfile; +import com.github.minecraft_ta.totalDebugCompanion.ui.views.EvaluateExpressionWindow; +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.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import static org.junit.jupiter.api.Assertions.assertEquals; + +class ThemeListenerDisposalTest { + @TempDir Path directory; + + @Test void closingApplicationsReleasesWindowAndEvaluationThemeListeners() throws Exception { + var baseline = new AtomicInteger(); + SwingUtilities.invokeAndWait(() -> baseline.set(ThemeManager.listenerCount())); + for (int i = 0; i < 2; i++) { + try (var app = new CompanionApplication(new CompanionLaunchConfiguration(directory.resolve("app" + i)), "test-token")) { + app.openProject(CompanionProfile.forGame(Files.createDirectories(directory.resolve("game" + i)))).get(3, TimeUnit.SECONDS); + SwingUtilities.invokeAndWait(() -> { + var window = app.createWindow(); + new EvaluateExpressionWindow(window, null, window.editorContext(), () -> { }); + }); + } + SwingUtilities.invokeAndWait(() -> assertEquals(baseline.get(), ThemeManager.listenerCount(), + "Closing an application must release every theme listener owned by its windows")); + } + } +} From 020451c34faebf98f493af5a0dde4b954996aa99 Mon Sep 17 00:00:00 2001 From: Pelotrio <45769595+Pelotrio@users.noreply.github.com> Date: Fri, 11 Sep 2026 19:21:07 +0200 Subject: [PATCH 5/7] Replay current service statuses when attaching the window --- .../CompanionApplication.java | 26 ++++++++---- .../ApplicationNavigationTest.java | 40 +++++++++++++++++++ 2 files changed, 59 insertions(+), 7 deletions(-) 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 05e97d3e..3cf41564 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 @@ -75,6 +75,8 @@ public final class CompanionApplication implements AutoCloseable { private CompanionMcpServer mcpServer; private volatile DebuggerSessionController debuggerController; private volatile CompanionUi ui; + private ServiceStatus gameStatus; + private ServiceStatus mcpStatus; private volatile boolean closed; private ProjectRegistry projects; private volatile boolean switching; @@ -495,6 +497,8 @@ CompanionMcpServer startMcpServer(CodeModeJobService jobs, int port) throws Exce try { server.start(); mcpServer = server; + updateMcpStatus(new ServiceStatus(ServiceStatus.State.AVAILABLE, "Listening", + "MCP is listening at " + server.endpointUrl())); System.err.println("TotalDebug Companion MCP listening at " + server.endpointUrl()); return server; } catch (Exception failure) { server.close(); throw failure; } @@ -508,11 +512,7 @@ private void startOptionalMcpServer() { )); try { startMcpServer(); - updateMcpStatus(new ServiceStatus( - ServiceStatus.State.AVAILABLE, - "Listening", - "MCP is listening at " + mcpServer.endpointUrl() - )); + } catch (Exception exception) { updateMcpStatus(new ServiceStatus( ServiceStatus.State.FAILED, @@ -565,6 +565,8 @@ public MainWindow createWindow() { // These direct EDT updates and the replay snapshot precede publication. window.refreshProfile(); window.setRuntimeIndexStatus(getRuntimeIndexStatus()); + if (gameStatus != null) window.setGameStatus(gameStatus); + if (mcpStatus != null) window.setMcpStatus(mcpStatus); ProjectScope scope = current; queued = scope != null && scope.runtime() != null ? scope.drainNavigations() : List.of(); ui = window; @@ -597,8 +599,18 @@ private void onUi(Consumer action) { if (!closed && ui == view) action.accept(view); }); } - private void updateGameStatus(ServiceStatus status) { onUi(view -> view.setGameStatus(status)); } - private void updateMcpStatus(ServiceStatus status) { onUi(view -> view.setMcpStatus(status)); } + private void updateGameStatus(ServiceStatus status) { + synchronized (lifecycleLock) { + gameStatus = status; + onUi(view -> view.setGameStatus(status)); + } + } + private void updateMcpStatus(ServiceStatus status) { + synchronized (lifecycleLock) { + mcpStatus = status; + onUi(view -> view.setMcpStatus(status)); + } + } private void updateRuntimeIndexUi(RuntimeIndexService.Status status) { onUi(view -> view.setRuntimeIndexStatus(status)); } public void focusWindow() { onUi(CompanionUi::focus); } diff --git a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/ApplicationNavigationTest.java b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/ApplicationNavigationTest.java index bca23122..3e22b983 100644 --- a/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/ApplicationNavigationTest.java +++ b/companion/src/test/java/com/github/minecraft_ta/totalDebugCompanion/ApplicationNavigationTest.java @@ -12,6 +12,14 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; import java.nio.file.Files; +import java.net.Socket; +import java.awt.Container; +import java.awt.Component; +import javax.swing.AbstractButton; +import com.github.tth05.scnet.IConnectionListener; +import com.github.minecraft_ta.totalDebugCompanion.mcp.ProjectSwitchJobs; +import com.github.minecraft_ta.totaldebug.protocol.CompanionProtocol; +import com.github.minecraft_ta.totaldebug.storage.CompanionSessionDescriptor; import javax.swing.SwingUtilities; import java.nio.file.Path; import java.util.concurrent.CompletableFuture; @@ -65,6 +73,38 @@ class ApplicationNavigationTest { } } + @Test void lateWindowReplaysGameAndMcpStatuses() throws Exception { + var configuration = new CompanionLaunchConfiguration(directory); + try (var app = new CompanionApplication(configuration, "test-token")) { + app.openProject(CompanionProfile.forGame(Files.createDirectories(directory.resolve("game")))).get(3, TimeUnit.SECONDS); + app.startMcpServer(ProjectSwitchJobs.create(), 0); + var connecting = new CompletableFuture(); + app.session().server().addConnectionListener(new IConnectionListener() { + @Override public void onConnected() { connecting.complete(null); } + @Override public void onDisconnected() { } + @Override public void onConnectionError(Throwable failure) { connecting.completeExceptionally(failure); } + }); + app.session().bindAndPublish(configuration); + int port = CompanionSessionDescriptor.read(configuration.descriptorFile(), CompanionProtocol.VERSION).port(); + try (var socket = new Socket("127.0.0.1", port)) { + connecting.get(3, TimeUnit.SECONDS); + SwingUtilities.invokeAndWait(() -> { + var window = app.createWindow(); + assertTrue(hasButton(window, "MCP: Listening")); + assertTrue(hasButton(window, "Game: Connecting")); + }); + } + } + } + + private static boolean hasButton(Container parent, String text) { + for (Component child : parent.getComponents()) { + if (child instanceof AbstractButton button && text.equals(button.getText())) return true; + if (child instanceof Container container && hasButton(container, text)) return true; + } + return false; + } + private static final class RecordingUi implements CompanionUi { Runnable onRefresh = () -> { }; final CompletableFuture navigation = new CompletableFuture<>(); From a8d46219b6d895c72589c704dd8c4da2afa0d83a Mon Sep 17 00:00:00 2001 From: Pelotrio <45769595+Pelotrio@users.noreply.github.com> Date: Fri, 11 Sep 2026 19:21:08 +0200 Subject: [PATCH 6/7] Separate header and body deadline cases in the download test --- .../totaldebug/client/companion/CompanionDownloadTest.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/mod/src/test/java/com/github/minecraft_ta/totaldebug/client/companion/CompanionDownloadTest.java b/mod/src/test/java/com/github/minecraft_ta/totaldebug/client/companion/CompanionDownloadTest.java index 56a5bb11..c7aa1da4 100644 --- a/mod/src/test/java/com/github/minecraft_ta/totaldebug/client/companion/CompanionDownloadTest.java +++ b/mod/src/test/java/com/github/minecraft_ta/totaldebug/client/companion/CompanionDownloadTest.java @@ -191,7 +191,8 @@ void deadlinesUnblockTheRealHttpClientBeforeHeadersAndDuringBodyReads() throws E client.transport = transport; client.endpoint = URI.create("http://127.0.0.1:" + server.getAddress().getPort() + "/"); CompanionAppInstaller installer = new CompanionAppInstaller(this.directory, "", release, client, - new CompanionAppInstaller.DownloadLimits(Duration.ofMillis(300), Duration.ofMillis(300), + new CompanionAppInstaller.DownloadLimits( + sendHeaders ? Duration.ofSeconds(2) : Duration.ofMillis(300), Duration.ofMillis(300), Duration.ofSeconds(3), 4)); IOException failure = assertThrows(IOException.class, installer::resolveOrInstall); if (sendHeaders) assertTrue(failure.getMessage().contains("made no progress"), failure.toString()); From be80e5694c99018574b029b1de98550929626563 Mon Sep 17 00:00:00 2001 From: Pelotrio <45769595+Pelotrio@users.noreply.github.com> Date: Fri, 11 Sep 2026 19:36:05 +0200 Subject: [PATCH 7/7] Preserve early runtime UI invalidation in the application instance --- .../CompanionApplication.java | 27 +++++++++---------- 1 file changed, 13 insertions(+), 14 deletions(-) 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 3cf41564..cccc8c8a 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 @@ -439,6 +439,19 @@ void installRuntimeSnapshot(RuntimeIndexService.ReadySnapshot snapshot, } private void finishRuntimeInstallation(RuntimeBinding installed, ProjectScope selected) { + CompanionUi view = ui; + if (view != null) SwingUtilities.invokeLater(() -> { + if (ui != view || !selected.isActive() || selected.runtime() != installed || current != selected) return; + view.runtimeChanged(); + List queued; + synchronized (lifecycleLock) { + if (!selected.isActive() || selected.runtime() != installed || current != selected) return; + queued = selected.drainNavigations(); + } + for (PendingNavigation pending : queued) { + view.navigate(pending.target(), pending.activation()); + } + }); try { CompletableFuture breakpoints; synchronized (lifecycleLock) { @@ -452,20 +465,6 @@ private void finishRuntimeInstallation(RuntimeBinding installed, ProjectScope se } catch (RuntimeException failure) { System.getLogger(CompanionApplication.class.getName()).log(System.Logger.Level.WARNING, "Runtime installed, but debugger refresh failed", failure); - } finally { - CompanionUi view = ui; - if (view != null) SwingUtilities.invokeLater(() -> { - if (ui != view || !selected.isActive() || selected.runtime() != installed || current != selected) return; - view.runtimeChanged(); - List queued; - synchronized (lifecycleLock) { - if (!selected.isActive() || selected.runtime() != installed || current != selected) return; - queued = selected.drainNavigations(); - } - for (PendingNavigation pending : queued) { - view.navigate(pending.target(), pending.activation()); - } - }); } }