diff --git a/.idea/.name b/.idea/.name new file mode 100644 index 0000000..02ebab3 --- /dev/null +++ b/.idea/.name @@ -0,0 +1 @@ +OpenCode-Companion \ No newline at end of file diff --git a/src/main/kotlin/com/ashotn/opencode/companion/OpenCodeChecker.kt b/src/main/kotlin/com/ashotn/opencode/companion/OpenCodeChecker.kt index 8ea24df..c97aad9 100644 --- a/src/main/kotlin/com/ashotn/opencode/companion/OpenCodeChecker.kt +++ b/src/main/kotlin/com/ashotn/opencode/companion/OpenCodeChecker.kt @@ -4,108 +4,255 @@ import com.intellij.openapi.diagnostic.logger import com.intellij.openapi.util.SystemInfo import java.io.File import java.util.concurrent.TimeUnit +import kotlin.concurrent.thread data class OpenCodeInfo(val path: String, val version: String) object OpenCodeChecker { private val log = logger() + private val requiredHelpCommands = listOf("opencode serve", "opencode attach") + private const val COMMAND_TIMEOUT_SECONDS = 10L + private const val OUTPUT_JOIN_TIMEOUT_MILLIS = 1_000L + + private data class CommandResult( + val exitCode: Int?, + val output: String, + val timedOut: Boolean, + ) + + private val osSpecificInstallLocations: List + get() = when { + SystemInfo.isWindows -> { + val localAppData = System.getenv("LOCALAPPDATA")?.takeIf { it.isNotBlank() } + val appData = System.getenv("APPDATA")?.takeIf { it.isNotBlank() } + buildList { + localAppData?.let { + add("$it\\OpenCode\\opencode-cli.exe") + } + appData?.let { + add("$it\\npm\\opencode") + add("$it\\npm\\opencode.cmd") + } + } + } + + SystemInfo.isMac -> { + val home = System.getProperty("user.home")?.takeIf { it.isNotBlank() } + buildList { + add("/usr/local/bin/opencode") // Homebrew (Intel Mac) + add("/opt/homebrew/bin/opencode") // Homebrew (Apple Silicon) + home?.let { + add("$it/.local/bin/opencode") + add("$it/.bun/bin/opencode") + add("$it/.npm-global/bin/opencode") + } + } + } + + SystemInfo.isLinux -> { + val home = System.getProperty("user.home")?.takeIf { it.isNotBlank() } + buildList { + add("/usr/bin/opencode") + home?.let { + add("$it/.opencode/bin/opencode") + add("$it/.local/bin/opencode") + add("$it/.bun/bin/opencode") + add("$it/.npm-global/bin/opencode") + } + } + } + + else -> emptyList() + } /** * Returns an [OpenCodeInfo] containing the resolved path and version of the `opencode` * executable, or null if no valid executable is found. * - * If [userProvidedPath] is non-blank, it is validated (file exists, is executable, and - * responds to `--version`). If it does not pass validation, a warning is logged and null - * is returned immediately (auto-resolve is NOT attempted). + * If [userProvidedPath] is non-blank, it is validated (file exists, is runnable on this OS, and + * responds to `--version` and `--help`). If it does not pass validation, a warning is + * logged and null is returned immediately (auto-resolve is NOT attempted). * * If [userProvidedPath] is blank or null, the auto-resolve strategy is used: * PATH entries are searched first, followed by common install locations. The first - * candidate that passes all validation gates (including `--version`) is returned. + * candidate that passes all validation gates is returned. */ fun findExecutable(userProvidedPath: String? = null): OpenCodeInfo? { - if (userProvidedPath.isNullOrBlank()) { - return autoResolve() - } - val file = File(userProvidedPath) - return if (file.isFile && file.canExecute()) { - val version = getVersion(file.absolutePath) - if (version != null) { - OpenCodeInfo(file.absolutePath, version) - } else { - log.warn("OpenCode executable at user-provided path did not respond to --version: $userProvidedPath") - null - } - } else { - log.warn("OpenCode executable not found at user-provided path: $userProvidedPath") + val normalizedUserProvidedPath = normalizeUserProvidedPath(userProvidedPath) ?: return autoResolve() + + val file = File(normalizedUserProvidedPath) + if (!isCandidateFile(file)) { + log.warn( + "OpenCode executable at user-provided path is invalid: $normalizedUserProvidedPath " + + "(exists=${file.exists()}, isFile=${file.isFile}, canExecute=${file.canExecute()}, os=${SystemInfo.OS_NAME})" + ) + return null + } + + val absolutePath = file.absolutePath + return validateCandidate(absolutePath) ?: run { + log.warn("OpenCode executable at user-provided path failed validation: $absolutePath") null } } private fun autoResolve(): OpenCodeInfo? { - val executableNames = if (SystemInfo.isWindows) listOf("opencode.exe", "opencode.cmd") else listOf("opencode") - - val pathEnv = System.getenv("PATH") ?: "" - for (dir in pathEnv.split(File.pathSeparator)) { - for (executableName in executableNames) { - val candidate = File(dir, executableName) - if (candidate.isFile && candidate.canExecute()) { - val version = getVersion(candidate.absolutePath) - if (version != null) return OpenCodeInfo(candidate.absolutePath, version) - } + val executableNames = + if (SystemInfo.isWindows) { + listOf("opencode", "opencode.cmd", "opencode-cli.exe") + } else { + listOf("opencode") } - } - val home = System.getProperty("user.home") - val extraLocations = if (SystemInfo.isWindows) { - listOf( - "${System.getenv("APPDATA") ?: ""}\\npm\\opencode.cmd", - "${System.getenv("LOCALAPPDATA") ?: ""}\\Programs\\opencode\\opencode.exe", - ) + val pathEnv = System.getenv("PATH") + if (pathEnv.isNullOrBlank()) { + log.debug("PATH environment variable is empty; skipping PATH scan") } else { - listOf( - "/usr/local/bin/opencode", - "/usr/bin/opencode", - "$home/.local/bin/opencode", - "$home/.bun/bin/opencode", - "$home/.npm-global/bin/opencode", - ) + for (dir in pathEnv.split(File.pathSeparator)) { + if (dir.isBlank()) continue + for (executableName in executableNames) { + val candidate = File(dir, executableName) + if (isCandidateFile(candidate)) { + validateCandidate(candidate.absolutePath)?.let { return it } + } + } + } } - for (path in extraLocations) { + for (path in osSpecificInstallLocations) { val candidate = File(path) - if (candidate.isFile && candidate.canExecute()) { - val version = getVersion(candidate.absolutePath) - if (version != null) return OpenCodeInfo(candidate.absolutePath, version) + if (isCandidateFile(candidate)) { + validateCandidate(candidate.absolutePath)?.let { return it } } } return null } + private fun normalizeUserProvidedPath(path: String?): String? { + return path + ?.trim() + ?.removeSurrounding("\"") + ?.removeSurrounding("'") + ?.takeIf { it.isNotBlank() } + } + + private fun isCandidateFile(candidate: File): Boolean { + if (!candidate.exists() || !candidate.isFile) { + return false + } + return if (SystemInfo.isWindows) { + true + } else { + candidate.canExecute() + } + } + + private fun validateCandidate(path: String): OpenCodeInfo? { + val version = getVersion(path) ?: return null + val helpOutput = getHelpOutput(path) ?: return null + if (!hasRequiredHelpCommands(helpOutput, path)) return null + return OpenCodeInfo(path, version) + } + private fun getVersion(path: String): String? { + val result = runCommand(path, "--version") ?: return null + + if (result.timedOut) { + log.warn("OpenCode --version timed out for: $path") + return null + } + + val exitCode = result.exitCode + if (exitCode != 0) { + log.warn("OpenCode --version exited with code $exitCode for: $path") + return null + } + + val output = result.output + if (output.isBlank()) { + log.warn("OpenCode --version returned empty output for: $path") + return null + } + + val startsWithSemVer = Regex("^\\d+\\.\\d+\\.\\d+.*").matches(output) + if (!startsWithSemVer) { + log.warn("OpenCode --version output did not start with semantic version for: $path. Output: '$output'") + return null + } + + return output + } + + private fun getHelpOutput(path: String): String? { + val result = runCommand(path, "--help") ?: return null + + if (result.timedOut) { + log.warn("OpenCode --help timed out for: $path") + return null + } + + val exitCode = result.exitCode + if (exitCode != 0) { + log.warn("OpenCode --help exited with code $exitCode for: $path") + return null + } + + val output = result.output + if (output.isBlank()) { + log.warn("OpenCode --help returned empty output for: $path") + return null + } + + return output + } + + private fun runCommand(path: String, arg: String): CommandResult? { return try { - val process = ProcessBuilder(path, "--version") + val process = ProcessBuilder(path, arg) .redirectErrorStream(true) .start() - val completed = process.waitFor(3, TimeUnit.SECONDS) + var output = "" + val readerThread = thread(start = true, isDaemon = true, name = "opencode-checker-$arg") { + output = process.inputStream.bufferedReader().use { it.readText() } + } + + val completed = process.waitFor(COMMAND_TIMEOUT_SECONDS, TimeUnit.SECONDS) if (!completed) { process.destroyForcibly() - log.warn("OpenCode --version timed out for: $path") - return null - } - if (process.exitValue() != 0) { - log.warn("OpenCode --version exited with code ${process.exitValue()} for: $path") - return null + process.waitFor(1, TimeUnit.SECONDS) + runCatching { process.inputStream.close() } + readerThread.join(OUTPUT_JOIN_TIMEOUT_MILLIS) + return CommandResult(exitCode = null, output = output.trim(), timedOut = true) } - process.inputStream.bufferedReader().use { reader -> - reader.readText().trim().ifBlank { null } + readerThread.join(OUTPUT_JOIN_TIMEOUT_MILLIS) + if (readerThread.isAlive) { + log.warn("OpenCode command output reader did not finish for: $path $arg") } + + CommandResult(exitCode = process.exitValue(), output = output.trim(), timedOut = false) } catch (e: Exception) { - log.warn("Failed to run --version for: $path", e) + log.warn("Failed to run command '$arg' for: $path", e) null } } + + private fun hasRequiredHelpCommands(helpOutput: String, path: String): Boolean { + val normalizedOutput = helpOutput.lowercase() + val missingCommands = requiredHelpCommands.filterNot { normalizedOutput.contains(it) } + if (missingCommands.isNotEmpty()) { + log.warn( + "OpenCode --help output missing required commands for: $path. Missing: ${ + missingCommands.joinToString( + ", " + ) + }" + ) + return false + } + return true + } } diff --git a/src/main/kotlin/com/ashotn/opencode/companion/OpenCodeExecutableResolutionState.kt b/src/main/kotlin/com/ashotn/opencode/companion/OpenCodeExecutableResolutionState.kt new file mode 100644 index 0000000..ff259f5 --- /dev/null +++ b/src/main/kotlin/com/ashotn/opencode/companion/OpenCodeExecutableResolutionState.kt @@ -0,0 +1,7 @@ +package com.ashotn.opencode.companion + +sealed interface OpenCodeExecutableResolutionState { + data object Resolving : OpenCodeExecutableResolutionState + data class Resolved(val info: OpenCodeInfo) : OpenCodeExecutableResolutionState + data object NotFound : OpenCodeExecutableResolutionState +} diff --git a/src/main/kotlin/com/ashotn/opencode/companion/OpenCodeInfoChangedListener.kt b/src/main/kotlin/com/ashotn/opencode/companion/OpenCodeInfoChangedListener.kt new file mode 100644 index 0000000..c91f2b3 --- /dev/null +++ b/src/main/kotlin/com/ashotn/opencode/companion/OpenCodeInfoChangedListener.kt @@ -0,0 +1,15 @@ +package com.ashotn.opencode.companion + +import com.intellij.util.messages.Topic + +/** + * Published when the current [OpenCodeInfo] changes. + */ +fun interface OpenCodeInfoChangedListener { + companion object { + @JvmField + val TOPIC = Topic.create("OpenCode Info Changed", OpenCodeInfoChangedListener::class.java) + } + + fun onOpenCodeInfoChanged(info: OpenCodeInfo?) +} diff --git a/src/main/kotlin/com/ashotn/opencode/companion/OpenCodePlugin.kt b/src/main/kotlin/com/ashotn/opencode/companion/OpenCodePlugin.kt index 95c3b93..9042459 100644 --- a/src/main/kotlin/com/ashotn/opencode/companion/OpenCodePlugin.kt +++ b/src/main/kotlin/com/ashotn/opencode/companion/OpenCodePlugin.kt @@ -1,10 +1,12 @@ package com.ashotn.opencode.companion import com.ashotn.opencode.companion.core.OpenCodeCoreService +import com.ashotn.opencode.companion.settings.OpenCodeSettings import com.ashotn.opencode.companion.tui.OpenCodeTuiClient import com.intellij.openapi.Disposable import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.components.Service +import com.intellij.openapi.diagnostic.logger import com.intellij.openapi.project.Project import java.util.concurrent.CopyOnWriteArrayList @@ -23,7 +25,7 @@ class OpenCodePlugin(private val project: Project) : Disposable { private val serverManager = ServerManager(project) { state -> listeners.forEach { it.onStateChanged(state) } if (state == ServerState.READY) { - val port = com.ashotn.opencode.companion.settings.OpenCodeSettings.getInstance(project).serverPort + val port = OpenCodeSettings.getInstance(project).serverPort ApplicationManager.getApplication().executeOnPooledThread { if (project.isDisposed) return@executeOnPooledThread OpenCodeCoreService.getInstance(project).startListening(port) @@ -41,6 +43,60 @@ class OpenCodePlugin(private val project: Project) : Disposable { val isRunning: Boolean get() = serverManager.isRunning val ownsProcess: Boolean get() = serverManager.ownsProcess + // --- Resolved executable info --- + + @Volatile + var executableResolutionState: OpenCodeExecutableResolutionState = OpenCodeExecutableResolutionState.Resolving + private set + + val openCodeInfo: OpenCodeInfo? + get() = (executableResolutionState as? OpenCodeExecutableResolutionState.Resolved)?.info + + /** + * Runs [OpenCodeChecker.findExecutable] using the current settings path, + * stores the result in [executableResolutionState], and publishes the change on the + * project message bus via [OpenCodeInfoChangedListener.TOPIC]. + * + * Safe to call from any thread; expensive resolution work is moved off the EDT, + * while topic publication still happens on the EDT. + */ + fun resolveExecutable() { + val application = ApplicationManager.getApplication() + if (application.isDispatchThread) { + application.executeOnPooledThread { + if (project.isDisposed) return@executeOnPooledThread + publishExecutableResolution(resolveExecutableState()) + } + return + } + + if (project.isDisposed) return + publishExecutableResolution(resolveExecutableState()) + } + + fun setExecutableResolutionState(state: OpenCodeExecutableResolutionState) { + publishExecutableResolution(state) + } + + private fun resolveExecutableState(): OpenCodeExecutableResolutionState { + val userPath = OpenCodeSettings.getInstance(project).executablePath.takeIf { it.isNotBlank() } + val info = OpenCodeChecker.findExecutable(userPath) + return info?.let(OpenCodeExecutableResolutionState::Resolved) ?: OpenCodeExecutableResolutionState.NotFound + } + + private fun publishExecutableResolution(state: OpenCodeExecutableResolutionState) { + if (state == executableResolutionState) return + + executableResolutionState = state + val info = (state as? OpenCodeExecutableResolutionState.Resolved)?.info + ApplicationManager.getApplication().invokeLater { + if (!project.isDisposed) { + project.messageBus.syncPublisher(OpenCodeInfoChangedListener.TOPIC) + .onOpenCodeInfoChanged(info) + } + } + } + @Volatile private var overrideState: ServerState? = null val serverState: ServerState get() = overrideState ?: serverManager.serverState @@ -51,10 +107,35 @@ class OpenCodePlugin(private val project: Project) : Disposable { fun startPolling(port: Int, intervalSeconds: Long = 10L) = serverManager.startPolling(port, intervalSeconds) fun checkPort(port: Int) = serverManager.checkPort(port) - fun startServer(port: Int, executablePath: String) = serverManager.startServer(port, executablePath) + + fun startServer(port: Int) { + val resolvedExecutableInfo = openCodeInfo + if (resolvedExecutableInfo == null) { + log.warn("startServer() called but executable resolution is not in the resolved state") + return + } + serverManager.startServer(port, resolvedExecutableInfo.path) + } + fun stopServer() = serverManager.stopServer() + + /** + * Tears down the current core/TUI connections and re-attaches to the given [port]. + * Used when the user changes the port setting while attached to an external process + * (i.e. we don't own the server process). + */ + fun reattach(port: Int) { + ApplicationManager.getApplication().executeOnPooledThread { + if (project.isDisposed) return@executeOnPooledThread + OpenCodeCoreService.getInstance(project).stopListening() + OpenCodeTuiClient.getInstance(project).setPort(0) + startPolling(port) + checkPort(port) + } + } + fun resetConnection() { - val port = com.ashotn.opencode.companion.settings.OpenCodeSettings.getInstance(project).serverPort + val port = OpenCodeSettings.getInstance(project).serverPort overrideState = ServerState.RESETTING broadcastState(ServerState.RESETTING) ApplicationManager.getApplication().executeOnPooledThread { @@ -80,6 +161,8 @@ class OpenCodePlugin(private val project: Project) : Disposable { } companion object { + private val log = logger() + fun getInstance(project: Project): OpenCodePlugin = project.getService(OpenCodePlugin::class.java) } diff --git a/src/main/kotlin/com/ashotn/opencode/companion/OpenCodeStartupActivity.kt b/src/main/kotlin/com/ashotn/opencode/companion/OpenCodeStartupActivity.kt new file mode 100644 index 0000000..3a06594 --- /dev/null +++ b/src/main/kotlin/com/ashotn/opencode/companion/OpenCodeStartupActivity.kt @@ -0,0 +1,12 @@ +package com.ashotn.opencode.companion + +import com.intellij.openapi.project.Project +import com.intellij.openapi.startup.ProjectActivity + +class OpenCodeStartupActivity : ProjectActivity { + + override suspend fun execute(project: Project) { + val plugin = OpenCodePlugin.getInstance(project) + plugin.resolveExecutable() + } +} diff --git a/src/main/kotlin/com/ashotn/opencode/companion/ServerManager.kt b/src/main/kotlin/com/ashotn/opencode/companion/ServerManager.kt index f0fa433..ed74a86 100644 --- a/src/main/kotlin/com/ashotn/opencode/companion/ServerManager.kt +++ b/src/main/kotlin/com/ashotn/opencode/companion/ServerManager.kt @@ -6,6 +6,7 @@ import com.ashotn.opencode.companion.util.showNotification import com.intellij.notification.NotificationType import com.intellij.openapi.diagnostic.logger import com.intellij.openapi.project.Project +import com.intellij.openapi.util.SystemInfo import java.io.File import java.net.InetAddress import java.net.InetSocketAddress @@ -255,9 +256,27 @@ class ServerManager( } } + private fun buildWindowsCommand(executablePath: String, vararg args: String): String = + buildString { + append("\"") + append(executablePath) + append("\"") + args.forEach { + append(' ') + append("\"") + append(it) + append("\"") + } + } + fun startServer(port: Int, executablePath: String) { val executable = File(executablePath) - if (!executable.isFile || !executable.canExecute()) { + val isLaunchable = if (SystemInfo.isWindows) { + executable.exists() && executable.isFile + } else { + executable.isFile && executable.canExecute() + } + if (!isLaunchable) { project.showNotification( "Failed to start OpenCode Companion", "OpenCode Companion executable is not valid or executable: $executablePath", @@ -277,7 +296,13 @@ class ServerManager( } try { - val process = ProcessBuilder(executablePath, "serve", "--port", port.toString()) + val command = + if (SystemInfo.isWindows) { + listOf("cmd", "/c", buildWindowsCommand(executablePath, "serve", "--port", port.toString())) + } else { + listOf(executablePath, "serve", "--port", port.toString()) + } + val process = ProcessBuilder(command) .inheritIO() .apply { val basePath = project.basePath diff --git a/src/main/kotlin/com/ashotn/opencode/companion/actions/OpenTerminalAction.kt b/src/main/kotlin/com/ashotn/opencode/companion/actions/OpenTerminalAction.kt index 7728801..5cbf4a1 100644 --- a/src/main/kotlin/com/ashotn/opencode/companion/actions/OpenTerminalAction.kt +++ b/src/main/kotlin/com/ashotn/opencode/companion/actions/OpenTerminalAction.kt @@ -12,40 +12,65 @@ import com.intellij.openapi.actionSystem.AnAction import com.intellij.openapi.actionSystem.AnActionEvent import com.intellij.openapi.project.Project import com.intellij.openapi.util.SystemInfo +import java.io.File class OpenTerminalAction(private val project: Project) : AnAction() { override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT override fun update(e: AnActionEvent) { - val running = OpenCodePlugin.getInstance(project).isRunning + val plugin = OpenCodePlugin.getInstance(project) + val running = plugin.isRunning + val hasExecutable = !plugin.openCodeInfo?.path.isNullOrBlank() e.presentation.icon = AllIcons.Debugger.Console - e.applyStrings(ActionStrings.OPEN_TERMINAL, running) + e.applyStrings(ActionStrings.OPEN_TERMINAL, running && hasExecutable) } override fun actionPerformed(e: AnActionEvent) { val settings = OpenCodeSettings.getInstance(project) - launchExternalTerminal("opencode attach ${serverUrl(settings.serverPort)}") + val executablePath = OpenCodePlugin.getInstance(project).openCodeInfo?.path + if (executablePath.isNullOrBlank()) { + project.showNotification( + "Cannot open terminal", + "OpenCode executable is not resolved. Configure a valid executable path in settings.", + NotificationType.WARNING, + ) + return + } + + val url = serverUrl(settings.serverPort) + launchExternalTerminal(executablePath, url) } /** - * Launches an external OS terminal window running [command]. + * Launches an external OS terminal window running ` attach `. */ - private fun launchExternalTerminal(command: String) { + private fun launchExternalTerminal(executablePath: String, url: String) { + val attachArgs = listOf(executablePath, "attach", url) val processCommand: List = when { - SystemInfo.isWindows -> listOf("cmd", "/c", "start", "cmd", "/k", command) - SystemInfo.isMac -> listOf( - "osascript", "-e", - """tell application "Terminal" to do script "$command"""" + SystemInfo.isWindows -> listOf( + "cmd", + "/c", + buildWindowsAttachCommand(executablePath, url), ) - else -> findLinuxTerminalCommand(command) + + SystemInfo.isMac -> { + val command = buildPosixAttachCommand(executablePath, url) + listOf( + "osascript", + "-e", + "tell application \"Terminal\" to do script \"${escapeAppleScriptString(command)}\"", + ) + } + + else -> findLinuxTerminalCommand(attachArgs) } if (processCommand.isEmpty()) { project.showNotification( "Cannot open terminal", - "No supported terminal emulator found. Install xterm, gnome-terminal, konsole, or xfce4-terminal.", - NotificationType.WARNING, + "No supported terminal emulator found.", + NotificationType.ERROR, ) return } @@ -63,21 +88,33 @@ class OpenTerminalAction(private val project: Project) : AnAction() { } } + private fun buildWindowsAttachCommand(executablePath: String, url: String): String = + "start \"\" \"$executablePath\" attach \"$url\"" + + private fun buildPosixAttachCommand(executablePath: String, url: String): String = + "${shellQuote(executablePath)} attach ${shellQuote(url)}" + + private fun shellQuote(value: String): String = + "'${value.replace("'", "'\"'\"'")}'" + + private fun escapeAppleScriptString(value: String): String = + value.replace("\\", "\\\\").replace("\"", "\\\"") + /** * Tries common Linux terminal emulators in order of preference. * Returns the full command list for the first one found on PATH, * or an empty list if none are available. */ - private fun findLinuxTerminalCommand(command: String): List { + private fun findLinuxTerminalCommand(attachArgs: List): List { val candidates = listOf( // Preferred: distro-agnostic default symlink - listOf("x-terminal-emulator", "-e", command), + listOf("x-terminal-emulator", "-e") + attachArgs, // Common desktop environments - listOf("gnome-terminal", "--", "bash", "-c", "$command; exec bash"), - listOf("konsole", "-e", "bash", "-c", "$command; exec bash"), - listOf("xfce4-terminal", "-e", "bash -c \"$command; exec bash\""), + listOf("gnome-terminal", "--") + attachArgs, + listOf("konsole", "-e") + attachArgs, + listOf("xfce4-terminal", "-x") + attachArgs, // Universal fallback - listOf("xterm", "-e", "bash -c \"$command; exec bash\""), + listOf("xterm", "-e") + attachArgs, ) for (candidate in candidates) { @@ -89,8 +126,8 @@ class OpenTerminalAction(private val project: Project) : AnAction() { private fun isOnPath(executable: String): Boolean { val pathEnv = System.getenv("PATH") ?: return false - return pathEnv.split(java.io.File.pathSeparator).any { dir -> - java.io.File(dir, executable).let { it.isFile && it.canExecute() } + return pathEnv.split(File.pathSeparator).any { dir -> + File(dir, executable).let { it.isFile && it.canExecute() } } } } diff --git a/src/main/kotlin/com/ashotn/opencode/companion/settings/OpenCodeSettingsChangedListener.kt b/src/main/kotlin/com/ashotn/opencode/companion/settings/OpenCodeSettingsChangedListener.kt new file mode 100644 index 0000000..ef78380 --- /dev/null +++ b/src/main/kotlin/com/ashotn/opencode/companion/settings/OpenCodeSettingsChangedListener.kt @@ -0,0 +1,22 @@ +package com.ashotn.opencode.companion.settings + +import com.intellij.util.messages.Topic + +data class OpenCodeSettingsSnapshot( + val serverPort: Int, + val executablePath: String, + val inlineDiffEnabled: Boolean, + val diffTraceEnabled: Boolean, + val diffTraceHistoryEnabled: Boolean, + val inlineTerminalEnabled: Boolean, + val terminalEngine: OpenCodeSettings.TerminalEngine, +) + +fun interface OpenCodeSettingsChangedListener { + companion object { + @JvmField + val TOPIC = Topic.create("OpenCode Settings Changed", OpenCodeSettingsChangedListener::class.java) + } + + fun onSettingsChanged(oldSettings: OpenCodeSettingsSnapshot, newSettings: OpenCodeSettingsSnapshot) +} diff --git a/src/main/kotlin/com/ashotn/opencode/companion/settings/OpenCodeSettingsConfigurable.kt b/src/main/kotlin/com/ashotn/opencode/companion/settings/OpenCodeSettingsConfigurable.kt index 3506ef9..53729d6 100644 --- a/src/main/kotlin/com/ashotn/opencode/companion/settings/OpenCodeSettingsConfigurable.kt +++ b/src/main/kotlin/com/ashotn/opencode/companion/settings/OpenCodeSettingsConfigurable.kt @@ -1,53 +1,84 @@ package com.ashotn.opencode.companion.settings +import com.ashotn.opencode.companion.OpenCodeChecker +import com.ashotn.opencode.companion.OpenCodeExecutableResolutionState +import com.ashotn.opencode.companion.OpenCodeInfo import com.ashotn.opencode.companion.OpenCodePlugin import com.ashotn.opencode.companion.core.EditorDiffRenderer import com.ashotn.opencode.companion.settings.OpenCodeSettings.TerminalEngine -import com.ashotn.opencode.companion.toolwindow.OpenCodeToolWindowPanel import com.ashotn.opencode.companion.util.BuildUtils +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.fileChooser.FileChooserDescriptorFactory import com.intellij.openapi.options.BoundConfigurable +import com.intellij.openapi.options.ConfigurationException +import com.intellij.openapi.progress.ProgressManager import com.intellij.openapi.project.Project import com.intellij.openapi.ui.DialogPanel -import com.intellij.openapi.wm.ToolWindowManager +import com.intellij.openapi.ui.Messages +import com.intellij.openapi.ui.TextFieldWithBrowseButton +import com.intellij.ui.components.JBTextField import com.intellij.ui.dsl.builder.* -import javax.swing.SwingUtilities +import java.util.concurrent.atomic.AtomicReference class OpenCodeSettingsConfigurable(private val project: Project) : BoundConfigurable("OpenCode Companion") { + private val pendingState = OpenCodeSettings.State() + + internal var executableResolver: (String?) -> OpenCodeInfo? = { path -> OpenCodeChecker.findExecutable(path) } + override fun createPanel(): DialogPanel { - val settings = OpenCodeSettings.getInstance(project) - val running = OpenCodePlugin.getInstance(project).isRunning + loadPendingFromPersisted() + return panel { - if (running) { - row { - comment("Some settings are read-only while the OpenCode server is running. Stop the server to make changes.") - } - } group("Executable") { + val executablePathField = TextFieldWithBrowseButton().apply { + addBrowseFolderListener( + project, + FileChooserDescriptorFactory.createSingleFileNoJarsDescriptor() + .withTitle("Select OpenCode Executable") + .withDescription("Choose the opencode executable file."), + ) + } + run { + val executableTextField = executablePathField.textField as? JBTextField ?: return@run + val resolvedPath = OpenCodePlugin.getInstance(project).openCodeInfo?.path + if (!resolvedPath.isNullOrBlank()) { + executableTextField.emptyText.text = resolvedPath + return@run + } + + ApplicationManager.getApplication().executeOnPooledThread { + val autoResolvedPath = OpenCodeChecker.findExecutable()?.path ?: return@executeOnPooledThread + ApplicationManager.getApplication().invokeLater { + if (project.isDisposed) return@invokeLater + if (executablePathField.text.isBlank()) { + executableTextField.emptyText.text = autoResolvedPath + } + } + } + } row("OpenCode Path:") { - textField() - .bindText(settings::executablePath) + cell(executablePathField) + .bindText(pendingState::executablePath) .comment("Path to the opencode executable. Leave blank to auto-detect.") .align(AlignX.FILL) - .enabled(!running) } } group("Server") { row("Server Port:") { intTextField(1024..65535) - .bindIntText(settings::serverPort) + .bindIntText(pendingState::serverPort) .comment("Port the OpenCode server listens on (default: 4096)") - .enabled(!running) } } group("Editor") { row { checkBox("Show inline diff highlights") - .bindSelected(settings::inlineDiffEnabled) + .bindSelected(pendingState::inlineDiffEnabled) .comment( "Renders green/red inline diff highlights in the editor " + - "for AI-modified files. Changes take effect immediately." + "for AI-modified files. Changes take effect immediately.", ) } } @@ -55,7 +86,7 @@ class OpenCodeSettingsConfigurable(private val project: Project) : val reworkedSupported = BuildUtils.isEmbeddedTerminalSupported row { checkBox("Show inline terminal") - .bindSelected(settings::inlineTerminalEnabled) + .bindSelected(pendingState::inlineTerminalEnabled) .comment("Embeds the OpenCode TUI directly inside the tool window panel when the server is running.") } buttonsGroup("Terminal engine:") { @@ -68,48 +99,143 @@ class OpenCodeSettingsConfigurable(private val project: Project) : .enabled(reworkedSupported) .comment( if (reworkedSupported) "New terminal engine (IntelliJ 2025.3+)." - else "Requires IntelliJ 2025.3 or later." + else "Requires IntelliJ 2025.3 or later.", ) } - }.bind(settings::terminalEngine) + }.bind(pendingState::terminalEngine) } group("Diagnostics") { row { checkBox("Enable diff trace logging") - .bindSelected(settings::diffTraceEnabled) + .bindSelected(pendingState::diffTraceEnabled) .comment( "Writes a JSONL trace file to the system temp directory " + "(opencode-diff-traces/) for debugging diff pipeline events. " + - "Takes effect after restarting the IDE." + "Takes effect after restarting the IDE.", ) - .enabled(!running) } row { checkBox("Include historical diffs in trace") - .bindSelected(settings::diffTraceHistoryEnabled) + .bindSelected(pendingState::diffTraceHistoryEnabled) .comment( "Also records events from historical (loaded-on-demand) session diffs " + "in the trace. Only relevant when diff trace logging is enabled. " + - "Takes effect after restarting the IDE." + "Takes effect after restarting the IDE.", ) - .enabled(!running) } } } } + override fun reset() { + loadPendingFromPersisted() + super.reset() + } + override fun apply() { - super.apply() + val settings = OpenCodeSettings.getInstance(project) + val plugin = OpenCodePlugin.getInstance(project) + val oldSettings = snapshot(settings.state) + val oldResolutionState = plugin.executableResolutionState + + super.apply() // Pushes UI values into pendingState. + + val newSettings = snapshot(pendingState) + val settingsChanged = newSettings != oldSettings + val newPort = newSettings.serverPort + val newPath = newSettings.executablePath + val portChanged = newPort != oldSettings.serverPort + val pathChanged = newPath != oldSettings.executablePath + val shouldUpdateExecutableResolution = + pathChanged || (newPath.isBlank() && oldResolutionState == OpenCodeExecutableResolutionState.Resolving) + if (!settingsChanged && !shouldUpdateExecutableResolution) return + + val mustConfirmStop = plugin.isRunning && plugin.ownsProcess && (portChanged || pathChanged) + val mustReattach = plugin.isRunning && !plugin.ownsProcess && portChanged + + var resolvedState = oldResolutionState + if (shouldUpdateExecutableResolution) { + val userProvidedPath = newPath.takeIf { it.isNotBlank() } + val detectedExecutableInfo = detectExecutableInfo(userProvidedPath) + if (userProvidedPath != null && detectedExecutableInfo == null) { + throw ConfigurationException( + "Could not find a valid OpenCode executable. Check the path and try again.", + ) + } + resolvedState = + detectedExecutableInfo?.let(OpenCodeExecutableResolutionState::Resolved) + ?: OpenCodeExecutableResolutionState.NotFound + } + + if (mustConfirmStop && !confirmStopServerRestart()) { + reset() + return + } + + persistPendingToSettings(settings) + + when { + mustConfirmStop -> plugin.stopServer() + mustReattach -> plugin.reattach(newPort) + } + + if (shouldUpdateExecutableResolution && resolvedState != oldResolutionState) { + plugin.setExecutableResolutionState(resolvedState) + } + + if (settingsChanged) { + project.messageBus.syncPublisher(OpenCodeSettingsChangedListener.TOPIC) + .onSettingsChanged(oldSettings, newSettings) + } + EditorDiffRenderer.getInstance(project).onSettingsChanged() - refreshToolWindowPanel() } - private fun refreshToolWindowPanel() { - SwingUtilities.invokeLater { - val toolWindow = - ToolWindowManager.getInstance(project).getToolWindow("OpenCode Companion") ?: return@invokeLater - val content = toolWindow.contentManager.getContent(0) ?: return@invokeLater - (content.component as? OpenCodeToolWindowPanel)?.refresh() - } + private fun detectExecutableInfo(userProvidedPath: String?): OpenCodeInfo? { + val detectedInfoRef = AtomicReference() + ProgressManager.getInstance().runProcessWithProgressSynchronously( + { + detectedInfoRef.set(executableResolver(userProvidedPath)) + }, + "Resolving OpenCode...", + false, + project, + ) + return detectedInfoRef.get() } + + private fun confirmStopServerRestart(): Boolean = + Messages.showYesNoDialog( + project, + "The OpenCode server is currently running. Applying these changes will stop it. " + + "You will need to start it again manually.", + "Stop OpenCode Server?", + "Stop Server", + "Cancel", + Messages.getWarningIcon(), + ) == Messages.YES + + private fun loadPendingFromPersisted(settings: OpenCodeSettings = OpenCodeSettings.getInstance(project)) { + pendingState.serverPort = settings.serverPort + pendingState.executablePath = settings.executablePath + pendingState.inlineDiffEnabled = settings.inlineDiffEnabled + pendingState.diffTraceEnabled = settings.diffTraceEnabled + pendingState.diffTraceHistoryEnabled = settings.diffTraceHistoryEnabled + pendingState.inlineTerminalEnabled = settings.inlineTerminalEnabled + pendingState.terminalEngine = settings.terminalEngine + } + + private fun persistPendingToSettings(settings: OpenCodeSettings) { + settings.loadState(pendingState.copy()) + } + + private fun snapshot(state: OpenCodeSettings.State): OpenCodeSettingsSnapshot = OpenCodeSettingsSnapshot( + serverPort = state.serverPort, + executablePath = state.executablePath, + inlineDiffEnabled = state.inlineDiffEnabled, + diffTraceEnabled = state.diffTraceEnabled, + diffTraceHistoryEnabled = state.diffTraceHistoryEnabled, + inlineTerminalEnabled = state.inlineTerminalEnabled, + terminalEngine = state.terminalEngine, + ) } diff --git a/src/main/kotlin/com/ashotn/opencode/companion/terminal/ClassicTuiPanel.kt b/src/main/kotlin/com/ashotn/opencode/companion/terminal/ClassicTuiPanel.kt index ffe5563..9bc3b7c 100644 --- a/src/main/kotlin/com/ashotn/opencode/companion/terminal/ClassicTuiPanel.kt +++ b/src/main/kotlin/com/ashotn/opencode/companion/terminal/ClassicTuiPanel.kt @@ -1,5 +1,6 @@ package com.ashotn.opencode.companion.terminal +import com.ashotn.opencode.companion.OpenCodePlugin import com.ashotn.opencode.companion.settings.OpenCodeSettings import com.ashotn.opencode.companion.util.serverUrl import com.intellij.openapi.Disposable @@ -56,9 +57,15 @@ class ClassicTuiPanel( } try { + val executablePath = OpenCodePlugin.getInstance(project).openCodeInfo?.path + if (executablePath.isNullOrBlank()) { + logger.warn("Skipping classic terminal start because OpenCode executable is unresolved") + return + } + val workingDir = project.basePath ?: System.getProperty("user.home") val command = listOf( - "opencode", + executablePath, "attach", serverUrl(OpenCodeSettings.getInstance(project).serverPort), ) diff --git a/src/main/kotlin/com/ashotn/opencode/companion/terminal/ReworkedTuiPanel.kt b/src/main/kotlin/com/ashotn/opencode/companion/terminal/ReworkedTuiPanel.kt index c844f16..b7f113b 100644 --- a/src/main/kotlin/com/ashotn/opencode/companion/terminal/ReworkedTuiPanel.kt +++ b/src/main/kotlin/com/ashotn/opencode/companion/terminal/ReworkedTuiPanel.kt @@ -2,6 +2,7 @@ package com.ashotn.opencode.companion.terminal +import com.ashotn.opencode.companion.OpenCodePlugin import com.ashotn.opencode.companion.settings.OpenCodeSettings import com.ashotn.opencode.companion.util.BuildUtils import com.ashotn.opencode.companion.util.serverUrl @@ -66,9 +67,15 @@ class ReworkedTuiPanel( if (!BuildUtils.isEmbeddedTerminalSupported) return try { + val executablePath = OpenCodePlugin.getInstance(project).openCodeInfo?.path + if (executablePath.isNullOrBlank()) { + logger.warn("Skipping reworked terminal start because OpenCode executable is unresolved") + return + } + val workingDir = project.basePath ?: System.getProperty("user.home") val command = listOf( - "opencode", + executablePath, "attach", serverUrl(OpenCodeSettings.getInstance(project).serverPort), ) diff --git a/src/main/kotlin/com/ashotn/opencode/companion/toolwindow/InstalledPanel.kt b/src/main/kotlin/com/ashotn/opencode/companion/toolwindow/InstalledPanel.kt index 4e8e2cc..4221fe8 100644 --- a/src/main/kotlin/com/ashotn/opencode/companion/toolwindow/InstalledPanel.kt +++ b/src/main/kotlin/com/ashotn/opencode/companion/toolwindow/InstalledPanel.kt @@ -2,9 +2,11 @@ package com.ashotn.opencode.companion.toolwindow import com.ashotn.opencode.companion.OpenCodeInfo import com.ashotn.opencode.companion.OpenCodePlugin +import com.ashotn.opencode.companion.OpenCodeInfoChangedListener import com.ashotn.opencode.companion.ServerState import com.ashotn.opencode.companion.ServerStateListener import com.ashotn.opencode.companion.settings.OpenCodeSettings +import com.ashotn.opencode.companion.settings.OpenCodeSettingsChangedListener import com.intellij.icons.AllIcons import com.intellij.openapi.Disposable import com.intellij.openapi.project.Project @@ -16,12 +18,15 @@ import java.awt.BorderLayout import java.awt.CardLayout import java.awt.GridBagConstraints import java.awt.GridBagLayout -import java.awt.Insets import javax.swing.JButton import javax.swing.JPanel import javax.swing.SwingConstants -class InstalledPanel(project: Project, parentDisposable: Disposable, private val resolvedExecutableInfo: OpenCodeInfo) : +class InstalledPanel( + project: Project, + parentDisposable: Disposable, + private var openCodeInfo: OpenCodeInfo +) : JPanel(BorderLayout()), Disposable, ServerStateListener { companion object { @@ -30,6 +35,8 @@ class InstalledPanel(project: Project, parentDisposable: Disposable, private val } private val portStatusLabel = JBLabel("Checking…", SwingConstants.CENTER) + private val subtitleLabel = JBLabel("", SwingConstants.CENTER) + private val pathLabel = JBLabel("", SwingConstants.CENTER) private val startButton = JButton("Start OpenCode", AllIcons.Actions.Execute) private val stopButton = JButton("Stop OpenCode", AllIcons.Actions.Suspend) private val buttonCardLayout = CardLayout() @@ -69,15 +76,15 @@ class InstalledPanel(project: Project, parentDisposable: Disposable, private val insets = JBUI.insets(JBUI.scale(4), pad, JBUI.scale(4), pad) } - val versionSuffix = if (resolvedExecutableInfo.version.isNotBlank()) "(${resolvedExecutableInfo.version})" else "" - val subtitleLabel = JBLabel("OpenCode$versionSuffix is installed and ready to use.", SwingConstants.CENTER).apply { + subtitleLabel.apply { font = base.deriveFont(base.size * 1.1f) foreground = JBUI.CurrentTheme.Label.disabledForeground() } - val pathLabel = JBLabel(resolvedExecutableInfo.path, SwingConstants.CENTER).apply { + pathLabel.apply { font = UIUtil.getLabelFont(UIUtil.FontSize.SMALL) foreground = JBUI.CurrentTheme.Label.disabledForeground() } + updateInfoLabels() gbc.gridy = 0; add(subtitleLabel, gbc) gbc.gridy = 1; gbc.insets = JBUI.insets(JBUI.scale(2), pad, JBUI.scale(16), pad) @@ -98,7 +105,7 @@ class InstalledPanel(project: Project, parentDisposable: Disposable, private val stopButton.isEnabled = true portStatusLabel.text = "Starting…" portStatusLabel.foreground = JBUI.CurrentTheme.Label.disabledForeground() - plugin.startServer(settings.serverPort, resolvedExecutableInfo.path) + plugin.startServer(settings.serverPort) } stopButton.addActionListener { stopButton.isEnabled = false @@ -112,9 +119,37 @@ class InstalledPanel(project: Project, parentDisposable: Disposable, private val plugin.checkPort(settings.serverPort) plugin.startPolling(settings.serverPort) + project.messageBus.connect(this).subscribe( + OpenCodeInfoChangedListener.TOPIC, + OpenCodeInfoChangedListener { info -> + if (info != null) { + openCodeInfo = info + updateInfoLabels() + } + } + ) + + project.messageBus.connect(this).subscribe( + OpenCodeSettingsChangedListener.TOPIC, + OpenCodeSettingsChangedListener { oldSettings, newSettings -> + if (oldSettings.serverPort != newSettings.serverPort) { + plugin.startPolling(newSettings.serverPort) + plugin.checkPort(newSettings.serverPort) + } + onStateChanged(plugin.serverState) + } + ) + Disposer.register(parentDisposable, this) } + private fun updateInfoLabels() { + val versionSuffix = + if (openCodeInfo.version.isNotBlank()) "(${openCodeInfo.version})" else "" + subtitleLabel.text = "OpenCode$versionSuffix is installed and ready to use." + pathLabel.text = openCodeInfo.path + } + override fun onStateChanged(state: ServerState) { val port = settings.serverPort when (state) { @@ -123,6 +158,7 @@ class InstalledPanel(project: Project, parentDisposable: Disposable, private val portStatusLabel.foreground = JBUI.CurrentTheme.Label.disabledForeground() buttonPanel.isVisible = false } + ServerState.STARTING -> { portStatusLabel.text = "Starting on port $port…" portStatusLabel.foreground = JBUI.CurrentTheme.Label.disabledForeground() @@ -130,6 +166,7 @@ class InstalledPanel(project: Project, parentDisposable: Disposable, private val buttonCardLayout.show(buttonPanel, CARD_STOP) buttonPanel.isVisible = true } + ServerState.READY -> { portStatusLabel.text = "OpenCode is running on port $port" portStatusLabel.foreground = JBUI.CurrentTheme.Label.foreground() @@ -140,6 +177,7 @@ class InstalledPanel(project: Project, parentDisposable: Disposable, private val } buttonPanel.isVisible = owned } + ServerState.STOPPING -> { portStatusLabel.text = "Stopping OpenCode on port $port..." portStatusLabel.foreground = JBUI.CurrentTheme.Label.disabledForeground() @@ -147,6 +185,7 @@ class InstalledPanel(project: Project, parentDisposable: Disposable, private val buttonCardLayout.show(buttonPanel, CARD_STOP) buttonPanel.isVisible = true } + ServerState.STOPPED -> { portStatusLabel.text = "OpenCode is not running on port $port" portStatusLabel.foreground = JBUI.CurrentTheme.Label.disabledForeground() @@ -154,11 +193,13 @@ class InstalledPanel(project: Project, parentDisposable: Disposable, private val buttonCardLayout.show(buttonPanel, CARD_START) buttonPanel.isVisible = true } + ServerState.PORT_CONFLICT -> { portStatusLabel.text = "Port $port is in use by another process" portStatusLabel.foreground = JBUI.CurrentTheme.Label.disabledForeground() buttonPanel.isVisible = false } + ServerState.RESETTING -> { portStatusLabel.text = "Resetting OpenCode Companion…" portStatusLabel.foreground = JBUI.CurrentTheme.Label.disabledForeground() diff --git a/src/main/kotlin/com/ashotn/opencode/companion/toolwindow/OpenCodeToolWindowPanel.kt b/src/main/kotlin/com/ashotn/opencode/companion/toolwindow/OpenCodeToolWindowPanel.kt index 4f7c132..840011f 100644 --- a/src/main/kotlin/com/ashotn/opencode/companion/toolwindow/OpenCodeToolWindowPanel.kt +++ b/src/main/kotlin/com/ashotn/opencode/companion/toolwindow/OpenCodeToolWindowPanel.kt @@ -1,7 +1,8 @@ package com.ashotn.opencode.companion.toolwindow -import com.ashotn.opencode.companion.OpenCodeChecker +import com.ashotn.opencode.companion.OpenCodeExecutableResolutionState import com.ashotn.opencode.companion.OpenCodePlugin +import com.ashotn.opencode.companion.OpenCodeInfoChangedListener import com.ashotn.opencode.companion.ServerState import com.ashotn.opencode.companion.ServerStateListener import com.ashotn.opencode.companion.core.DiffHunksChangedListener @@ -11,6 +12,7 @@ import com.ashotn.opencode.companion.ipc.PermissionChangedListener import com.ashotn.opencode.companion.permission.OpenCodePermissionService import com.ashotn.opencode.companion.settings.OpenCodeSettings import com.ashotn.opencode.companion.settings.OpenCodeSettings.TerminalEngine +import com.ashotn.opencode.companion.settings.OpenCodeSettingsChangedListener import com.ashotn.opencode.companion.terminal.ClassicTuiPanel import com.ashotn.opencode.companion.terminal.ReworkedTuiPanel import com.ashotn.opencode.companion.terminal.TuiPanel @@ -105,6 +107,25 @@ class OpenCodeToolWindowPanel(private val project: Project) : JPanel(BorderLayou plugin.addListener(serverStateListener) + project.messageBus.connect(this).subscribe( + OpenCodeSettingsChangedListener.TOPIC, + OpenCodeSettingsChangedListener { _, _ -> + swapTuiPanelIfEngineChanged() + requestSyncCard() + } + ) + + project.messageBus.connect(this).subscribe( + OpenCodeInfoChangedListener.TOPIC, + OpenCodeInfoChangedListener { _ -> + // Dispose and recreate the slot so the old InstalledPanel is eagerly cleaned up. + Disposer.dispose(slotDisposable) + slotDisposable = Disposer.newDisposable("OpenCodeToolWindowPanel.slot") + Disposer.register(this, slotDisposable) + buildContent() + } + ) + buildContent() } @@ -123,16 +144,21 @@ class OpenCodeToolWindowPanel(private val project: Project) : JPanel(BorderLayou val inlineTerminal = serverReady && OpenCodeSettings.getInstance(project).inlineTerminalEnabled if (inlineTerminal) { tuiPanel.startIfNeeded() - // Restore the split whenever the divider is hidden (e.g. after a reset). - // Run after layout so the panel has a real height. - if (splitPane.dividerSize == 0) { - ApplicationManager.getApplication().invokeLater { - val total = splitPane.height - if (total > 0) { - splitPane.dividerSize = JBUI.scale(2) - splitPane.dividerLocation = (total * 0.25).toInt() + if (tuiPanel.isStarted) { + // Restore the split whenever the divider is hidden (e.g. after a reset). + // Run after layout so the panel has a real height. + if (splitPane.dividerSize == 0) { + ApplicationManager.getApplication().invokeLater { + val total = splitPane.height + if (total > 0) { + splitPane.dividerSize = JBUI.scale(2) + splitPane.dividerLocation = (total * 0.25).toInt() + } } } + } else { + splitPane.dividerSize = 0 + splitPane.dividerLocation = Int.MAX_VALUE } } else { // Inline terminal disabled or server not running – stop any running widget @@ -162,15 +188,6 @@ class OpenCodeToolWindowPanel(private val project: Project) : JPanel(BorderLayou tuiPanel.focusTerminal() } - fun refresh() { - swapTuiPanelIfEngineChanged() - // Dispose and recreate the slot so the old InstalledPanel is eagerly cleaned up. - Disposer.dispose(slotDisposable) - slotDisposable = Disposer.newDisposable("OpenCodeToolWindowPanel.slot") - Disposer.register(this, slotDisposable) - buildContent() - } - /** * If the user changed the terminal engine in settings, tears down the current * [tuiPanel], swaps in a fresh instance backed by the new engine, and re-wires @@ -194,26 +211,19 @@ class OpenCodeToolWindowPanel(private val project: Project) : JPanel(BorderLayou } private fun buildContent() { - val userProvidedPath = OpenCodeSettings.getInstance(project).executablePath - // findExecutable() performs filesystem I/O (PATH scan, File.isFile, File.canExecute, - // process spawn for --version). Run it on a pooled thread to avoid blocking the EDT. - ApplicationManager.getApplication().executeOnPooledThread { - val executableInfo = OpenCodeChecker.findExecutable(userProvidedPath.takeIf { it.isNotBlank() }) - ApplicationManager.getApplication().invokeLater { - val screen = if (executableInfo != null) InstalledPanel( - project, - slotDisposable, - executableInfo - ) else NotInstalledPanel() - // Replace the content card with the new screen - val existingContent = outerCardPanel.components.firstOrNull { it != pendingFilesPanel } - if (existingContent != null) outerCardPanel.remove(existingContent) - outerCardPanel.add(screen, CARD_CONTENT) - syncCard() - revalidate() - repaint() - } + val screen = when (val state = plugin.executableResolutionState) { + OpenCodeExecutableResolutionState.Resolving -> ResolvingExecutablePanel() + OpenCodeExecutableResolutionState.NotFound -> NotInstalledPanel() + is OpenCodeExecutableResolutionState.Resolved -> InstalledPanel(project, slotDisposable, state.info) } + + // Replace the content card with the new screen + val existingContent = outerCardPanel.components.firstOrNull { it != pendingFilesPanel } + if (existingContent != null) outerCardPanel.remove(existingContent) + outerCardPanel.add(screen, CARD_CONTENT) + syncCard() + revalidate() + repaint() } /** diff --git a/src/main/kotlin/com/ashotn/opencode/companion/toolwindow/ResolvingExecutablePanel.kt b/src/main/kotlin/com/ashotn/opencode/companion/toolwindow/ResolvingExecutablePanel.kt new file mode 100644 index 0000000..9534d92 --- /dev/null +++ b/src/main/kotlin/com/ashotn/opencode/companion/toolwindow/ResolvingExecutablePanel.kt @@ -0,0 +1,31 @@ +package com.ashotn.opencode.companion.toolwindow + +import com.intellij.ui.components.JBLabel +import com.intellij.util.ui.JBUI +import com.intellij.util.ui.UIUtil +import java.awt.GridBagConstraints +import java.awt.GridBagLayout +import javax.swing.JPanel +import javax.swing.SwingConstants + +class ResolvingExecutablePanel : JPanel(GridBagLayout()) { + + init { + val gbc = GridBagConstraints().apply { + gridx = 0 + gridy = 0 + fill = GridBagConstraints.HORIZONTAL + anchor = GridBagConstraints.CENTER + insets = JBUI.insets(JBUI.scale(4), JBUI.scale(16), JBUI.scale(4), JBUI.scale(16)) + } + + val base = UIUtil.getLabelFont() + val label = JBLabel("Checking OpenCode installation…", SwingConstants.CENTER).apply { + font = base.deriveFont(base.size * 1.1f) + foreground = JBUI.CurrentTheme.Label.disabledForeground() + alignmentX = CENTER_ALIGNMENT + } + + add(label, gbc) + } +} diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index 75cf736..36596ae 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -26,6 +26,7 @@ key="configurable.opencode.displayName" /> + diff --git a/src/test/kotlin/com/ashotn/opencode/companion/settings/OpenCodeSettingsConfigurableTest.kt b/src/test/kotlin/com/ashotn/opencode/companion/settings/OpenCodeSettingsConfigurableTest.kt new file mode 100644 index 0000000..59edee6 --- /dev/null +++ b/src/test/kotlin/com/ashotn/opencode/companion/settings/OpenCodeSettingsConfigurableTest.kt @@ -0,0 +1,172 @@ +package com.ashotn.opencode.companion.settings + +import com.ashotn.opencode.companion.OpenCodeExecutableResolutionState +import com.ashotn.opencode.companion.OpenCodeInfo +import com.ashotn.opencode.companion.OpenCodePlugin +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.options.ConfigurationException +import com.intellij.openapi.ui.TextFieldWithBrowseButton +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import java.awt.Component +import java.awt.Container +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicReference +import kotlin.test.assertFailsWith + +class OpenCodeSettingsConfigurableTest : BasePlatformTestCase() { + + fun testApplyAllowsSavingWhenPathIsBlankAndResolutionFails() { + val settings = OpenCodeSettings.getInstance(project) + settings.executablePath = "C:/Users/VM/AppData/Roaming/npm/opencode.cmd" + OpenCodePlugin.getInstance(project).setExecutableResolutionState( + OpenCodeExecutableResolutionState.Resolved(OpenCodeInfo(settings.executablePath, "1.2.3")) + ) + + val configurable = OpenCodeSettingsConfigurable(project).apply { + executableResolver = { null } + } + + try { + val component = getOnEdt { configurable.createComponent() } + val executableField = findFirst(component, TextFieldWithBrowseButton::class.java) + ?: error("Executable path field not found") + + runOnEdt { executableField.text = "" } + runOnEdt { configurable.apply() } + + assertEquals("", settings.executablePath) + assertNull(OpenCodePlugin.getInstance(project).openCodeInfo) + } finally { + runOnEdt { configurable.disposeUIResources() } + } + } + + fun testApplyBlocksSavingWhenExplicitPathFailsResolution() { + val settings = OpenCodeSettings.getInstance(project) + settings.executablePath = "C:/existing/opencode" + + val configurable = OpenCodeSettingsConfigurable(project).apply { + executableResolver = { null } + } + + try { + val component = getOnEdt { configurable.createComponent() } + val executableField = findFirst(component, TextFieldWithBrowseButton::class.java) + ?: error("Executable path field not found") + + runOnEdt { executableField.text = "C:/invalid/opencode" } + + assertFailsWith { + runOnEdt { configurable.apply() } + } + assertEquals("C:/existing/opencode", settings.executablePath) + } finally { + runOnEdt { configurable.disposeUIResources() } + } + } + + fun testApplyAttemptsAutoResolveWhenPathBlankAndResolutionStillPending() { + val settings = OpenCodeSettings.getInstance(project) + settings.executablePath = "" + OpenCodePlugin.getInstance(project).setExecutableResolutionState(OpenCodeExecutableResolutionState.Resolving) + + val resolveCalls = AtomicInteger(0) + val configurable = OpenCodeSettingsConfigurable(project).apply { + executableResolver = { + resolveCalls.incrementAndGet() + null + } + } + + try { + getOnEdt { configurable.createComponent() } + runOnEdt { configurable.apply() } + + assertEquals(1, resolveCalls.get()) + assertEquals("", settings.executablePath) + assertEquals( + OpenCodeExecutableResolutionState.NotFound, + OpenCodePlugin.getInstance(project).executableResolutionState, + ) + } finally { + runOnEdt { configurable.disposeUIResources() } + } + } + + fun testApplySkipsResolveWhenPathBlankAndNotFoundAlreadyResolved() { + val settings = OpenCodeSettings.getInstance(project) + settings.executablePath = "" + OpenCodePlugin.getInstance(project).setExecutableResolutionState(OpenCodeExecutableResolutionState.NotFound) + + val resolveCalls = AtomicInteger(0) + val configurable = OpenCodeSettingsConfigurable(project).apply { + executableResolver = { + resolveCalls.incrementAndGet() + null + } + } + + try { + getOnEdt { configurable.createComponent() } + runOnEdt { configurable.apply() } + + assertEquals(0, resolveCalls.get()) + assertEquals("", settings.executablePath) + assertEquals( + OpenCodeExecutableResolutionState.NotFound, + OpenCodePlugin.getInstance(project).executableResolutionState, + ) + } finally { + runOnEdt { configurable.disposeUIResources() } + } + } + + fun testApplySkipsResolveWhenNoSettingsChangeAndResolutionAlreadyResolved() { + val settings = OpenCodeSettings.getInstance(project) + settings.executablePath = "C:/existing/opencode" + OpenCodePlugin.getInstance(project).setExecutableResolutionState( + OpenCodeExecutableResolutionState.Resolved(OpenCodeInfo(settings.executablePath, "1.2.3")) + ) + + val resolveCalls = AtomicInteger(0) + val configurable = OpenCodeSettingsConfigurable(project).apply { + executableResolver = { + resolveCalls.incrementAndGet() + OpenCodeInfo("C:/resolved/opencode", "1.2.3") + } + } + + try { + getOnEdt { configurable.createComponent() } + runOnEdt { configurable.apply() } + + assertEquals(0, resolveCalls.get()) + assertEquals("C:/existing/opencode", settings.executablePath) + } finally { + runOnEdt { configurable.disposeUIResources() } + } + } + + private fun runOnEdt(action: () -> Unit) { + ApplicationManager.getApplication().invokeAndWait(action) + } + + private fun getOnEdt(action: () -> T): T { + val resultRef = AtomicReference() + ApplicationManager.getApplication().invokeAndWait { + resultRef.set(action()) + } + return resultRef.get() + } + + private fun findFirst(root: Component, clazz: Class): T? { + if (clazz.isInstance(root)) return clazz.cast(root) + if (root is Container) { + for (child in root.components) { + val found = findFirst(child, clazz) + if (found != null) return found + } + } + return null + } +}