From 028bdc5db9042f192174110c3352838203081097 Mon Sep 17 00:00:00 2001 From: Ashot Nazaryan Date: Thu, 12 Mar 2026 17:52:16 -0700 Subject: [PATCH 1/8] feat: add support for resolving and managing OpenCode executable info --- .../opencode/companion/OpenCodePlugin.kt | 59 +++++++++- .../companion/OpenCodeStartupActivity.kt | 12 ++ .../companion/ResolvedInfoChangedListener.kt | 12 ++ .../settings/OpenCodeSettingsConfigurable.kt | 110 ++++++++++++++---- .../companion/toolwindow/InstalledPanel.kt | 30 ++++- .../toolwindow/OpenCodeToolWindowPanel.kt | 57 +++++---- src/main/resources/META-INF/plugin.xml | 1 + 7 files changed, 220 insertions(+), 61 deletions(-) create mode 100644 src/main/kotlin/com/ashotn/opencode/companion/OpenCodeStartupActivity.kt create mode 100644 src/main/kotlin/com/ashotn/opencode/companion/ResolvedInfoChangedListener.kt diff --git a/src/main/kotlin/com/ashotn/opencode/companion/OpenCodePlugin.kt b/src/main/kotlin/com/ashotn/opencode/companion/OpenCodePlugin.kt index 95c3b93..32bbd0b 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,30 @@ class OpenCodePlugin(private val project: Project) : Disposable { val isRunning: Boolean get() = serverManager.isRunning val ownsProcess: Boolean get() = serverManager.ownsProcess + // --- Resolved executable info --- + + @Volatile + var resolvedInfo: OpenCodeInfo? = null + + /** + * Runs [OpenCodeChecker.findExecutable] using the current settings path, + * stores the result in [resolvedInfo], and publishes the change on the + * project message bus via [ResolvedInfoChangedListener.TOPIC]. + * + * Safe to call from any thread; the topic is published on the EDT. + */ + fun resolveExecutable() { + val userPath = OpenCodeSettings.getInstance(project).executablePath.takeIf { it.isNotBlank() } + val info = OpenCodeChecker.findExecutable(userPath) + resolvedInfo = info + ApplicationManager.getApplication().invokeLater { + if (!project.isDisposed) { + project.messageBus.syncPublisher(ResolvedInfoChangedListener.TOPIC) + .onResolvedInfoChanged(info) + } + } + } + @Volatile private var overrideState: ServerState? = null val serverState: ServerState get() = overrideState ?: serverManager.serverState @@ -51,10 +77,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 info = resolvedInfo + if (info == null) { + log.warn("startServer() called but resolvedInfo is null") + return + } + serverManager.startServer(port, info.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 +131,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/ResolvedInfoChangedListener.kt b/src/main/kotlin/com/ashotn/opencode/companion/ResolvedInfoChangedListener.kt new file mode 100644 index 0000000..fe31db8 --- /dev/null +++ b/src/main/kotlin/com/ashotn/opencode/companion/ResolvedInfoChangedListener.kt @@ -0,0 +1,12 @@ +package com.ashotn.opencode.companion + +import com.intellij.util.messages.Topic + +fun interface ResolvedInfoChangedListener { + companion object { + @JvmField + val TOPIC = Topic.create("OpenCode Resolved Info Changed", ResolvedInfoChangedListener::class.java) + } + + fun onResolvedInfoChanged(info: OpenCodeInfo?) +} 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..d1a7c56 100644 --- a/src/main/kotlin/com/ashotn/opencode/companion/settings/OpenCodeSettingsConfigurable.kt +++ b/src/main/kotlin/com/ashotn/opencode/companion/settings/OpenCodeSettingsConfigurable.kt @@ -1,36 +1,32 @@ package com.ashotn.opencode.companion.settings +import com.ashotn.opencode.companion.OpenCodeChecker +import com.ashotn.opencode.companion.OpenCodeInfo import com.ashotn.opencode.companion.OpenCodePlugin +import com.ashotn.opencode.companion.ResolvedInfoChangedListener 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.options.BoundConfigurable +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.ui.dsl.builder.* -import javax.swing.SwingUtilities +import java.util.concurrent.atomic.AtomicReference class OpenCodeSettingsConfigurable(private val project: Project) : BoundConfigurable("OpenCode Companion") { override fun createPanel(): DialogPanel { val settings = OpenCodeSettings.getInstance(project) - val running = OpenCodePlugin.getInstance(project).isRunning 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") { row("OpenCode Path:") { textField() .bindText(settings::executablePath) .comment("Path to the opencode executable. Leave blank to auto-detect.") .align(AlignX.FILL) - .enabled(!running) } } group("Server") { @@ -38,7 +34,6 @@ class OpenCodeSettingsConfigurable(private val project: Project) : intTextField(1024..65535) .bindIntText(settings::serverPort) .comment("Port the OpenCode server listens on (default: 4096)") - .enabled(!running) } } group("Editor") { @@ -82,7 +77,6 @@ class OpenCodeSettingsConfigurable(private val project: Project) : "(opencode-diff-traces/) for debugging diff pipeline events. " + "Takes effect after restarting the IDE." ) - .enabled(!running) } row { checkBox("Include historical diffs in trace") @@ -92,24 +86,96 @@ class OpenCodeSettingsConfigurable(private val project: Project) : "in the trace. Only relevant when diff trace logging is enabled. " + "Takes effect after restarting the IDE." ) - .enabled(!running) } } } } override fun apply() { + val settings = OpenCodeSettings.getInstance(project) + val plugin = OpenCodePlugin.getInstance(project) + + // 1. Snapshot old values before the UI writes them. + val oldPort = settings.serverPort + val oldPath = settings.executablePath + + // Write the UI values to settings so we can read the new values. super.apply() - 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() + val newPort = settings.serverPort + val newPath = settings.executablePath + + // 2. Re-resolve the executable with a modal spinner (~3 s max). + val resolvedRef = AtomicReference() + ProgressManager.getInstance().runProcessWithProgressSynchronously( + { + resolvedRef.set( + OpenCodeChecker.findExecutable(newPath.takeIf { it.isNotBlank() }) + ) + }, + "Resolving OpenCode\u2026", + false, + project + ) + + val info = resolvedRef.get() + + // 3. If resolution failed, show error. Settings are already written but + // the UI will show NotInstalledPanel via the topic. + if (info == null) { + Messages.showErrorDialog( + project, + "Could not find a valid OpenCode executable. Check the path and try again.", + "OpenCode Resolution Failed" + ) + // Publish null so the tool window transitions to NotInstalledPanel. + plugin.resolvedInfo = null + project.messageBus.syncPublisher(ResolvedInfoChangedListener.TOPIC) + .onResolvedInfoChanged(null) + EditorDiffRenderer.getInstance(project).onSettingsChanged() + return } + + // 4. If we own the process and port/path changed, warn the user. + val portOrPathChanged = newPort != oldPort || newPath != oldPath + val serverRunning = plugin.isRunning + val owned = plugin.ownsProcess + + if (serverRunning && owned && portOrPathChanged) { + val confirmed = 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 + + if (!confirmed) { + // Revert settings to old values and re-resolve. + settings.serverPort = oldPort + settings.executablePath = oldPath + reset() + return + } + } + + // 6. Publish the freshly resolved info to the topic. + plugin.resolvedInfo = info + project.messageBus.syncPublisher(ResolvedInfoChangedListener.TOPIC) + .onResolvedInfoChanged(info) + + // 7. If we owned the process and user confirmed stop → stop the server. + if (serverRunning && owned && portOrPathChanged) { + plugin.stopServer() + } + + // 8. If we don't own the process → re-attach to the new port. + if (serverRunning && !owned && newPort != oldPort) { + plugin.reattach(newPort) + } + + EditorDiffRenderer.getInstance(project).onSettingsChanged() } } 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..99ebbb0 100644 --- a/src/main/kotlin/com/ashotn/opencode/companion/toolwindow/InstalledPanel.kt +++ b/src/main/kotlin/com/ashotn/opencode/companion/toolwindow/InstalledPanel.kt @@ -2,6 +2,7 @@ package com.ashotn.opencode.companion.toolwindow import com.ashotn.opencode.companion.OpenCodeInfo import com.ashotn.opencode.companion.OpenCodePlugin +import com.ashotn.opencode.companion.ResolvedInfoChangedListener import com.ashotn.opencode.companion.ServerState import com.ashotn.opencode.companion.ServerStateListener import com.ashotn.opencode.companion.settings.OpenCodeSettings @@ -16,12 +17,11 @@ 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(private val project: Project, parentDisposable: Disposable, private var resolvedExecutableInfo: OpenCodeInfo) : JPanel(BorderLayout()), Disposable, ServerStateListener { companion object { @@ -30,6 +30,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 +71,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 +100,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 +114,25 @@ class InstalledPanel(project: Project, parentDisposable: Disposable, private val plugin.checkPort(settings.serverPort) plugin.startPolling(settings.serverPort) + project.messageBus.connect(this).subscribe( + ResolvedInfoChangedListener.TOPIC, + ResolvedInfoChangedListener { info -> + if (info != null) { + resolvedExecutableInfo = info + updateInfoLabels() + } + } + ) + Disposer.register(parentDisposable, this) } + private fun updateInfoLabels() { + val versionSuffix = if (resolvedExecutableInfo.version.isNotBlank()) "(${resolvedExecutableInfo.version})" else "" + subtitleLabel.text = "OpenCode$versionSuffix is installed and ready to use." + pathLabel.text = resolvedExecutableInfo.path + } + override fun onStateChanged(state: ServerState) { val port = settings.serverPort when (state) { 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..ea87722 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.OpenCodeInfo import com.ashotn.opencode.companion.OpenCodePlugin +import com.ashotn.opencode.companion.ResolvedInfoChangedListener import com.ashotn.opencode.companion.ServerState import com.ashotn.opencode.companion.ServerStateListener import com.ashotn.opencode.companion.core.DiffHunksChangedListener @@ -105,6 +106,18 @@ class OpenCodeToolWindowPanel(private val project: Project) : JPanel(BorderLayou plugin.addListener(serverStateListener) + project.messageBus.connect(this).subscribe( + ResolvedInfoChangedListener.TOPIC, + ResolvedInfoChangedListener { _ -> + 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() + } + ) + buildContent() } @@ -162,15 +175,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 +198,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 executableInfo = plugin.resolvedInfo + 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() } /** diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index 75cf736..2a1d27c 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" /> + From ca665737977ed4c864e1be272ebf9d9e53492b34 Mon Sep 17 00:00:00 2001 From: Ashot Nazaryan Date: Thu, 12 Mar 2026 22:40:35 -0700 Subject: [PATCH 2/8] refactor: rename ResolvedInfo to OpenCodeInfo and update related interfaces and listeners --- .../companion/OpenCodeInfoChangedListener.kt | 15 +++ .../opencode/companion/OpenCodePlugin.kt | 16 +-- .../companion/ResolvedInfoChangedListener.kt | 12 -- .../OpenCodeSettingsChangedListener.kt | 22 ++++ .../settings/OpenCodeSettingsConfigurable.kt | 121 ++++++++++-------- .../companion/toolwindow/InstalledPanel.kt | 37 +++++- .../toolwindow/OpenCodeToolWindowPanel.kt | 17 ++- 7 files changed, 152 insertions(+), 88 deletions(-) create mode 100644 src/main/kotlin/com/ashotn/opencode/companion/OpenCodeInfoChangedListener.kt delete mode 100644 src/main/kotlin/com/ashotn/opencode/companion/ResolvedInfoChangedListener.kt create mode 100644 src/main/kotlin/com/ashotn/opencode/companion/settings/OpenCodeSettingsChangedListener.kt 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 32bbd0b..c4af8ba 100644 --- a/src/main/kotlin/com/ashotn/opencode/companion/OpenCodePlugin.kt +++ b/src/main/kotlin/com/ashotn/opencode/companion/OpenCodePlugin.kt @@ -46,23 +46,23 @@ class OpenCodePlugin(private val project: Project) : Disposable { // --- Resolved executable info --- @Volatile - var resolvedInfo: OpenCodeInfo? = null + var openCodeInfo: OpenCodeInfo? = null /** * Runs [OpenCodeChecker.findExecutable] using the current settings path, - * stores the result in [resolvedInfo], and publishes the change on the - * project message bus via [ResolvedInfoChangedListener.TOPIC]. + * stores the result in [openCodeInfo], and publishes the change on the + * project message bus via [OpenCodeInfoChangedListener.TOPIC]. * * Safe to call from any thread; the topic is published on the EDT. */ fun resolveExecutable() { val userPath = OpenCodeSettings.getInstance(project).executablePath.takeIf { it.isNotBlank() } val info = OpenCodeChecker.findExecutable(userPath) - resolvedInfo = info + openCodeInfo = info ApplicationManager.getApplication().invokeLater { if (!project.isDisposed) { - project.messageBus.syncPublisher(ResolvedInfoChangedListener.TOPIC) - .onResolvedInfoChanged(info) + project.messageBus.syncPublisher(OpenCodeInfoChangedListener.TOPIC) + .onOpenCodeInfoChanged(info) } } } @@ -79,9 +79,9 @@ class OpenCodePlugin(private val project: Project) : Disposable { fun checkPort(port: Int) = serverManager.checkPort(port) fun startServer(port: Int) { - val info = resolvedInfo + val info = openCodeInfo if (info == null) { - log.warn("startServer() called but resolvedInfo is null") + log.warn("startServer() called but openCodeInfo is null") return } serverManager.startServer(port, info.path) diff --git a/src/main/kotlin/com/ashotn/opencode/companion/ResolvedInfoChangedListener.kt b/src/main/kotlin/com/ashotn/opencode/companion/ResolvedInfoChangedListener.kt deleted file mode 100644 index fe31db8..0000000 --- a/src/main/kotlin/com/ashotn/opencode/companion/ResolvedInfoChangedListener.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.ashotn.opencode.companion - -import com.intellij.util.messages.Topic - -fun interface ResolvedInfoChangedListener { - companion object { - @JvmField - val TOPIC = Topic.create("OpenCode Resolved Info Changed", ResolvedInfoChangedListener::class.java) - } - - fun onResolvedInfoChanged(info: OpenCodeInfo?) -} 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 d1a7c56..74a3834 100644 --- a/src/main/kotlin/com/ashotn/opencode/companion/settings/OpenCodeSettingsConfigurable.kt +++ b/src/main/kotlin/com/ashotn/opencode/companion/settings/OpenCodeSettingsConfigurable.kt @@ -3,7 +3,7 @@ package com.ashotn.opencode.companion.settings import com.ashotn.opencode.companion.OpenCodeChecker import com.ashotn.opencode.companion.OpenCodeInfo import com.ashotn.opencode.companion.OpenCodePlugin -import com.ashotn.opencode.companion.ResolvedInfoChangedListener +import com.ashotn.opencode.companion.OpenCodeInfoChangedListener import com.ashotn.opencode.companion.core.EditorDiffRenderer import com.ashotn.opencode.companion.settings.OpenCodeSettings.TerminalEngine import com.ashotn.opencode.companion.util.BuildUtils @@ -94,54 +94,58 @@ class OpenCodeSettingsConfigurable(private val project: Project) : override fun apply() { val settings = OpenCodeSettings.getInstance(project) val plugin = OpenCodePlugin.getInstance(project) + val oldSettings = snapshot(settings) - // 1. Snapshot old values before the UI writes them. - val oldPort = settings.serverPort - val oldPath = settings.executablePath - - // Write the UI values to settings so we can read the new values. super.apply() - val newPort = settings.serverPort - val newPath = settings.executablePath + val newSettings = snapshot(settings) + val newPort = newSettings.serverPort + val newPath = newSettings.executablePath + val portChanged = newPort != oldSettings.serverPort + val pathChanged = newPath != oldSettings.executablePath + val portOrPathChanged = portChanged || pathChanged + val serverRunning = plugin.isRunning + val owned = plugin.ownsProcess + val mustConfirmStop = serverRunning && owned && portOrPathChanged + val mustReattach = serverRunning && !owned && portChanged + + + if (pathChanged) { + val resolvedRef = AtomicReference() + ProgressManager.getInstance().runProcessWithProgressSynchronously( + { + resolvedRef.set( + OpenCodeChecker.findExecutable(newPath.takeIf { it.isNotBlank() }) + ) + }, + "Resolving OpenCode\u2026", + false, + project + ) - // 2. Re-resolve the executable with a modal spinner (~3 s max). - val resolvedRef = AtomicReference() - ProgressManager.getInstance().runProcessWithProgressSynchronously( - { - resolvedRef.set( - OpenCodeChecker.findExecutable(newPath.takeIf { it.isNotBlank() }) + val info = resolvedRef.get() + if (info == null) { + Messages.showErrorDialog( + project, + "Could not find a valid OpenCode executable. Check the path and try again.", + "OpenCode Resolution Failed" ) - }, - "Resolving OpenCode\u2026", - false, - project - ) - - val info = resolvedRef.get() - - // 3. If resolution failed, show error. Settings are already written but - // the UI will show NotInstalledPanel via the topic. - if (info == null) { - Messages.showErrorDialog( - project, - "Could not find a valid OpenCode executable. Check the path and try again.", - "OpenCode Resolution Failed" - ) - // Publish null so the tool window transitions to NotInstalledPanel. - plugin.resolvedInfo = null - project.messageBus.syncPublisher(ResolvedInfoChangedListener.TOPIC) - .onResolvedInfoChanged(null) - EditorDiffRenderer.getInstance(project).onSettingsChanged() - return + plugin.openCodeInfo = null + project.messageBus.syncPublisher(OpenCodeInfoChangedListener.TOPIC) + .onOpenCodeInfoChanged(null) + if (newSettings != oldSettings) { + project.messageBus.syncPublisher(OpenCodeSettingsChangedListener.TOPIC) + .onSettingsChanged(oldSettings, newSettings) + } + EditorDiffRenderer.getInstance(project).onSettingsChanged() + return + } + plugin.openCodeInfo = info + project.messageBus.syncPublisher(OpenCodeInfoChangedListener.TOPIC) + .onOpenCodeInfoChanged(info) } - // 4. If we own the process and port/path changed, warn the user. - val portOrPathChanged = newPort != oldPort || newPath != oldPath - val serverRunning = plugin.isRunning - val owned = plugin.ownsProcess - - if (serverRunning && owned && portOrPathChanged) { + if (mustConfirmStop) { val confirmed = Messages.showYesNoDialog( project, "The OpenCode server is currently running. Applying these changes will stop it. " + @@ -153,29 +157,34 @@ class OpenCodeSettingsConfigurable(private val project: Project) : ) == Messages.YES if (!confirmed) { - // Revert settings to old values and re-resolve. - settings.serverPort = oldPort - settings.executablePath = oldPath + settings.serverPort = oldSettings.serverPort + settings.executablePath = oldSettings.executablePath reset() return } - } - - // 6. Publish the freshly resolved info to the topic. - plugin.resolvedInfo = info - project.messageBus.syncPublisher(ResolvedInfoChangedListener.TOPIC) - .onResolvedInfoChanged(info) - // 7. If we owned the process and user confirmed stop → stop the server. - if (serverRunning && owned && portOrPathChanged) { plugin.stopServer() - } - // 8. If we don't own the process → re-attach to the new port. - if (serverRunning && !owned && newPort != oldPort) { + } else if (mustReattach) { plugin.reattach(newPort) } + val finalSettings = snapshot(settings) + if (finalSettings != oldSettings) { + project.messageBus.syncPublisher(OpenCodeSettingsChangedListener.TOPIC) + .onSettingsChanged(oldSettings, finalSettings) + } + EditorDiffRenderer.getInstance(project).onSettingsChanged() } + + private fun snapshot(settings: OpenCodeSettings): OpenCodeSettingsSnapshot = OpenCodeSettingsSnapshot( + serverPort = settings.serverPort, + executablePath = settings.executablePath, + inlineDiffEnabled = settings.inlineDiffEnabled, + diffTraceEnabled = settings.diffTraceEnabled, + diffTraceHistoryEnabled = settings.diffTraceHistoryEnabled, + inlineTerminalEnabled = settings.inlineTerminalEnabled, + terminalEngine = settings.terminalEngine, + ) } 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 99ebbb0..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,10 +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.ResolvedInfoChangedListener +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 @@ -21,7 +22,11 @@ import javax.swing.JButton import javax.swing.JPanel import javax.swing.SwingConstants -class InstalledPanel(private val project: Project, parentDisposable: Disposable, private var resolvedExecutableInfo: OpenCodeInfo) : +class InstalledPanel( + project: Project, + parentDisposable: Disposable, + private var openCodeInfo: OpenCodeInfo +) : JPanel(BorderLayout()), Disposable, ServerStateListener { companion object { @@ -115,22 +120,34 @@ class InstalledPanel(private val project: Project, parentDisposable: Disposable, plugin.startPolling(settings.serverPort) project.messageBus.connect(this).subscribe( - ResolvedInfoChangedListener.TOPIC, - ResolvedInfoChangedListener { info -> + OpenCodeInfoChangedListener.TOPIC, + OpenCodeInfoChangedListener { info -> if (info != null) { - resolvedExecutableInfo = info + 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 (resolvedExecutableInfo.version.isNotBlank()) "(${resolvedExecutableInfo.version})" else "" + val versionSuffix = + if (openCodeInfo.version.isNotBlank()) "(${openCodeInfo.version})" else "" subtitleLabel.text = "OpenCode$versionSuffix is installed and ready to use." - pathLabel.text = resolvedExecutableInfo.path + pathLabel.text = openCodeInfo.path } override fun onStateChanged(state: ServerState) { @@ -141,6 +158,7 @@ class InstalledPanel(private val project: Project, parentDisposable: Disposable, portStatusLabel.foreground = JBUI.CurrentTheme.Label.disabledForeground() buttonPanel.isVisible = false } + ServerState.STARTING -> { portStatusLabel.text = "Starting on port $port…" portStatusLabel.foreground = JBUI.CurrentTheme.Label.disabledForeground() @@ -148,6 +166,7 @@ class InstalledPanel(private val project: Project, parentDisposable: Disposable, buttonCardLayout.show(buttonPanel, CARD_STOP) buttonPanel.isVisible = true } + ServerState.READY -> { portStatusLabel.text = "OpenCode is running on port $port" portStatusLabel.foreground = JBUI.CurrentTheme.Label.foreground() @@ -158,6 +177,7 @@ class InstalledPanel(private val project: Project, parentDisposable: Disposable, } buttonPanel.isVisible = owned } + ServerState.STOPPING -> { portStatusLabel.text = "Stopping OpenCode on port $port..." portStatusLabel.foreground = JBUI.CurrentTheme.Label.disabledForeground() @@ -165,6 +185,7 @@ class InstalledPanel(private val project: Project, parentDisposable: Disposable, 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() @@ -172,11 +193,13 @@ class InstalledPanel(private val project: Project, parentDisposable: Disposable, 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 ea87722..3439805 100644 --- a/src/main/kotlin/com/ashotn/opencode/companion/toolwindow/OpenCodeToolWindowPanel.kt +++ b/src/main/kotlin/com/ashotn/opencode/companion/toolwindow/OpenCodeToolWindowPanel.kt @@ -1,8 +1,7 @@ package com.ashotn.opencode.companion.toolwindow -import com.ashotn.opencode.companion.OpenCodeInfo import com.ashotn.opencode.companion.OpenCodePlugin -import com.ashotn.opencode.companion.ResolvedInfoChangedListener +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 @@ -12,6 +11,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 @@ -107,9 +107,16 @@ class OpenCodeToolWindowPanel(private val project: Project) : JPanel(BorderLayou plugin.addListener(serverStateListener) project.messageBus.connect(this).subscribe( - ResolvedInfoChangedListener.TOPIC, - ResolvedInfoChangedListener { _ -> + 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") @@ -198,7 +205,7 @@ class OpenCodeToolWindowPanel(private val project: Project) : JPanel(BorderLayou } private fun buildContent() { - val executableInfo = plugin.resolvedInfo + val executableInfo = plugin.openCodeInfo val screen = if (executableInfo != null) InstalledPanel( project, slotDisposable, From 3a8464b8e96dbaa663bc4666234de03a05f554d5 Mon Sep 17 00:00:00 2001 From: Ashot Nazaryan Date: Fri, 13 Mar 2026 01:47:19 -0700 Subject: [PATCH 3/8] feat: improve OpenCode executable resolution across multiple components --- .idea/.name | 1 + .../opencode/companion/OpenCodeChecker.kt | 244 ++++++++++++++---- .../opencode/companion/OpenCodePlugin.kt | 10 + .../companion/actions/OpenTerminalAction.kt | 69 +++-- .../settings/OpenCodeSettingsConfigurable.kt | 67 +++-- .../companion/terminal/ClassicTuiPanel.kt | 9 +- .../companion/terminal/ReworkedTuiPanel.kt | 9 +- .../toolwindow/OpenCodeToolWindowPanel.kt | 32 ++- .../toolwindow/ResolvingExecutablePanel.kt | 31 +++ 9 files changed, 369 insertions(+), 103 deletions(-) create mode 100644 .idea/.name create mode 100644 src/main/kotlin/com/ashotn/opencode/companion/toolwindow/ResolvingExecutablePanel.kt 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..ca68e19 100644 --- a/src/main/kotlin/com/ashotn/opencode/companion/OpenCodeChecker.kt +++ b/src/main/kotlin/com/ashotn/opencode/companion/OpenCodeChecker.kt @@ -4,108 +4,244 @@ 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 commandTimeoutSeconds = 10L + private const val outputJoinTimeoutMillis = 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") + if (localAppData.isNullOrBlank()) { + emptyList() + } else { + listOf( + "$localAppData\\OpenCode\\opencode-cli.exe", + ) + } + } + + 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()) { + val normalizedUserProvidedPath = normalizeUserProvidedPath(userProvidedPath) + if (normalizedUserProvidedPath == null) { 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 file = File(normalizedUserProvidedPath) + if (!isCandidateFile(file)) { + log.warn( + "OpenCode executable at user-provided path is not runnable: $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 executableName = if (SystemInfo.isWindows) "opencode-cli.exe" else "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) - } + if (dir.isBlank()) continue + val candidate = File(dir, executableName) + if (isCandidateFile(candidate)) { + validateCandidate(candidate.absolutePath)?.let { return it } } } - 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", - ) - } else { - listOf( - "/usr/local/bin/opencode", - "/usr/bin/opencode", - "$home/.local/bin/opencode", - "$home/.bun/bin/opencode", - "$home/.npm-global/bin/opencode", - ) - } - - 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(commandTimeoutSeconds, 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(outputJoinTimeoutMillis) + return CommandResult(exitCode = null, output = output.trim(), timedOut = true) } - process.inputStream.bufferedReader().use { reader -> - reader.readText().trim().ifBlank { null } + readerThread.join(outputJoinTimeoutMillis) + 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/OpenCodePlugin.kt b/src/main/kotlin/com/ashotn/opencode/companion/OpenCodePlugin.kt index c4af8ba..507aa6e 100644 --- a/src/main/kotlin/com/ashotn/opencode/companion/OpenCodePlugin.kt +++ b/src/main/kotlin/com/ashotn/opencode/companion/OpenCodePlugin.kt @@ -45,8 +45,18 @@ class OpenCodePlugin(private val project: Project) : Disposable { // --- Resolved executable info --- + @Volatile + private var executableResolutionCompleted: Boolean = false + @Volatile var openCodeInfo: OpenCodeInfo? = null + set(value) { + field = value + executableResolutionCompleted = true + } + + val isExecutableResolutionCompleted: Boolean + get() = executableResolutionCompleted /** * Runs [OpenCodeChecker.findExecutable] using the current settings path, 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..30201c1 100644 --- a/src/main/kotlin/com/ashotn/opencode/companion/actions/OpenTerminalAction.kt +++ b/src/main/kotlin/com/ashotn/opencode/companion/actions/OpenTerminalAction.kt @@ -25,27 +25,52 @@ class OpenTerminalAction(private val project: Project) : AnAction() { 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", + "start", + "cmd", + "/k", + 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 = + "\"$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) { 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 74a3834..5acd219 100644 --- a/src/main/kotlin/com/ashotn/opencode/companion/settings/OpenCodeSettingsConfigurable.kt +++ b/src/main/kotlin/com/ashotn/opencode/companion/settings/OpenCodeSettingsConfigurable.kt @@ -7,11 +7,16 @@ import com.ashotn.opencode.companion.OpenCodeInfoChangedListener import com.ashotn.opencode.companion.core.EditorDiffRenderer import com.ashotn.opencode.companion.settings.OpenCodeSettings.TerminalEngine 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.ui.Messages +import com.intellij.openapi.ui.TextFieldWithBrowseButton +import com.intellij.ui.components.JBTextField import com.intellij.ui.dsl.builder.* import java.util.concurrent.atomic.AtomicReference @@ -22,8 +27,34 @@ class OpenCodeSettingsConfigurable(private val project: Project) : val settings = OpenCodeSettings.getInstance(project) return panel { 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() + cell(executablePathField) .bindText(settings::executablePath) .comment("Path to the opencode executable. Leave blank to auto-detect.") .align(AlignX.FILL) @@ -111,12 +142,11 @@ class OpenCodeSettingsConfigurable(private val project: Project) : if (pathChanged) { + val userProvidedPath = newPath.takeIf { it.isNotBlank() } val resolvedRef = AtomicReference() ProgressManager.getInstance().runProcessWithProgressSynchronously( { - resolvedRef.set( - OpenCodeChecker.findExecutable(newPath.takeIf { it.isNotBlank() }) - ) + resolvedRef.set(OpenCodeChecker.findExecutable(userProvidedPath)) }, "Resolving OpenCode\u2026", false, @@ -124,22 +154,13 @@ class OpenCodeSettingsConfigurable(private val project: Project) : ) val info = resolvedRef.get() - if (info == null) { - Messages.showErrorDialog( - project, - "Could not find a valid OpenCode executable. Check the path and try again.", - "OpenCode Resolution Failed" + if (info == null && userProvidedPath != null) { + restore(settings, oldSettings) + throw ConfigurationException( + "Could not find a valid OpenCode executable. Check the path and try again." ) - plugin.openCodeInfo = null - project.messageBus.syncPublisher(OpenCodeInfoChangedListener.TOPIC) - .onOpenCodeInfoChanged(null) - if (newSettings != oldSettings) { - project.messageBus.syncPublisher(OpenCodeSettingsChangedListener.TOPIC) - .onSettingsChanged(oldSettings, newSettings) - } - EditorDiffRenderer.getInstance(project).onSettingsChanged() - return } + plugin.openCodeInfo = info project.messageBus.syncPublisher(OpenCodeInfoChangedListener.TOPIC) .onOpenCodeInfoChanged(info) @@ -178,6 +199,16 @@ class OpenCodeSettingsConfigurable(private val project: Project) : EditorDiffRenderer.getInstance(project).onSettingsChanged() } + private fun restore(settings: OpenCodeSettings, snapshot: OpenCodeSettingsSnapshot) { + settings.serverPort = snapshot.serverPort + settings.executablePath = snapshot.executablePath + settings.inlineDiffEnabled = snapshot.inlineDiffEnabled + settings.diffTraceEnabled = snapshot.diffTraceEnabled + settings.diffTraceHistoryEnabled = snapshot.diffTraceHistoryEnabled + settings.inlineTerminalEnabled = snapshot.inlineTerminalEnabled + settings.terminalEngine = snapshot.terminalEngine + } + private fun snapshot(settings: OpenCodeSettings): OpenCodeSettingsSnapshot = OpenCodeSettingsSnapshot( serverPort = settings.serverPort, executablePath = settings.executablePath, 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/OpenCodeToolWindowPanel.kt b/src/main/kotlin/com/ashotn/opencode/companion/toolwindow/OpenCodeToolWindowPanel.kt index 3439805..b2a1369 100644 --- a/src/main/kotlin/com/ashotn/opencode/companion/toolwindow/OpenCodeToolWindowPanel.kt +++ b/src/main/kotlin/com/ashotn/opencode/companion/toolwindow/OpenCodeToolWindowPanel.kt @@ -143,16 +143,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 @@ -206,11 +211,12 @@ class OpenCodeToolWindowPanel(private val project: Project) : JPanel(BorderLayou private fun buildContent() { val executableInfo = plugin.openCodeInfo - val screen = if (executableInfo != null) InstalledPanel( - project, - slotDisposable, - executableInfo - ) else NotInstalledPanel() + val screen = when { + executableInfo != null -> InstalledPanel(project, slotDisposable, executableInfo) + plugin.isExecutableResolutionCompleted -> NotInstalledPanel() + else -> ResolvingExecutablePanel() + } + // Replace the content card with the new screen val existingContent = outerCardPanel.components.firstOrNull { it != pendingFilesPanel } if (existingContent != null) outerCardPanel.remove(existingContent) 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) + } +} From 50d5d1853b2aedda64b68a8b9935a292779de319 Mon Sep 17 00:00:00 2001 From: Ashot Nazaryan Date: Fri, 13 Mar 2026 01:59:03 -0700 Subject: [PATCH 4/8] feat: add executable path check to OpenTerminalAction update logic --- .../ashotn/opencode/companion/actions/OpenTerminalAction.kt | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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 30201c1..27e437d 100644 --- a/src/main/kotlin/com/ashotn/opencode/companion/actions/OpenTerminalAction.kt +++ b/src/main/kotlin/com/ashotn/opencode/companion/actions/OpenTerminalAction.kt @@ -18,9 +18,11 @@ 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) { From 0946663953c04f43d9d4c1f239a98e83e362a413 Mon Sep 17 00:00:00 2001 From: Ashot Nazaryan Date: Fri, 13 Mar 2026 02:03:03 -0700 Subject: [PATCH 5/8] chore: Kotlin code cleanup --- .../ashotn/opencode/companion/OpenCodeChecker.kt | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/src/main/kotlin/com/ashotn/opencode/companion/OpenCodeChecker.kt b/src/main/kotlin/com/ashotn/opencode/companion/OpenCodeChecker.kt index ca68e19..fa15d8e 100644 --- a/src/main/kotlin/com/ashotn/opencode/companion/OpenCodeChecker.kt +++ b/src/main/kotlin/com/ashotn/opencode/companion/OpenCodeChecker.kt @@ -12,8 +12,8 @@ object OpenCodeChecker { private val log = logger() private val requiredHelpCommands = listOf("opencode serve", "opencode attach") - private const val commandTimeoutSeconds = 10L - private const val outputJoinTimeoutMillis = 1_000L + private const val COMMAND_TIMEOUT_SECONDS = 10L + private const val OUTPUT_JOIN_TIMEOUT_MILLIS = 1_000L private data class CommandResult( val exitCode: Int?, @@ -76,10 +76,7 @@ object OpenCodeChecker { * candidate that passes all validation gates is returned. */ fun findExecutable(userProvidedPath: String? = null): OpenCodeInfo? { - val normalizedUserProvidedPath = normalizeUserProvidedPath(userProvidedPath) - if (normalizedUserProvidedPath == null) { - return autoResolve() - } + val normalizedUserProvidedPath = normalizeUserProvidedPath(userProvidedPath) ?: return autoResolve() val file = File(normalizedUserProvidedPath) if (!isCandidateFile(file)) { @@ -208,16 +205,16 @@ object OpenCodeChecker { output = process.inputStream.bufferedReader().use { it.readText() } } - val completed = process.waitFor(commandTimeoutSeconds, TimeUnit.SECONDS) + val completed = process.waitFor(COMMAND_TIMEOUT_SECONDS, TimeUnit.SECONDS) if (!completed) { process.destroyForcibly() process.waitFor(1, TimeUnit.SECONDS) runCatching { process.inputStream.close() } - readerThread.join(outputJoinTimeoutMillis) + readerThread.join(OUTPUT_JOIN_TIMEOUT_MILLIS) return CommandResult(exitCode = null, output = output.trim(), timedOut = true) } - readerThread.join(outputJoinTimeoutMillis) + readerThread.join(OUTPUT_JOIN_TIMEOUT_MILLIS) if (readerThread.isAlive) { log.warn("OpenCode command output reader did not finish for: $path $arg") } From ba3acee9b07d4355963315b6179b07cd2e6c9da9 Mon Sep 17 00:00:00 2001 From: Ashot Nazaryan Date: Fri, 13 Mar 2026 09:44:17 -0700 Subject: [PATCH 6/8] test: add tests for OpenCodeSettingsConfigurable and executable resolution logic fix: improve executable launch checks on Windows and non-Windows systems in ServerManager feat: enhance OpenCode executable resolution to support additional paths and file names --- .../opencode/companion/OpenCodeChecker.kt | 43 +++++++--- .../opencode/companion/ServerManager.kt | 9 +- .../settings/OpenCodeSettingsConfigurable.kt | 32 ++++--- .../OpenCodeSettingsConfigurableTest.kt | 85 +++++++++++++++++++ 4 files changed, 144 insertions(+), 25 deletions(-) create mode 100644 src/test/kotlin/com/ashotn/opencode/companion/settings/OpenCodeSettingsConfigurableTest.kt diff --git a/src/main/kotlin/com/ashotn/opencode/companion/OpenCodeChecker.kt b/src/main/kotlin/com/ashotn/opencode/companion/OpenCodeChecker.kt index fa15d8e..aeb013b 100644 --- a/src/main/kotlin/com/ashotn/opencode/companion/OpenCodeChecker.kt +++ b/src/main/kotlin/com/ashotn/opencode/companion/OpenCodeChecker.kt @@ -24,13 +24,17 @@ object OpenCodeChecker { private val osSpecificInstallLocations: List get() = when { SystemInfo.isWindows -> { - val localAppData = System.getenv("LOCALAPPDATA") - if (localAppData.isNullOrBlank()) { - emptyList() - } else { - listOf( - "$localAppData\\OpenCode\\opencode-cli.exe", - ) + 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") + add("$it\\npm\\opencode.ps1") + } } } @@ -95,14 +99,25 @@ object OpenCodeChecker { } private fun autoResolve(): OpenCodeInfo? { - val executableName = if (SystemInfo.isWindows) "opencode-cli.exe" else "opencode" + val executableNames = + if (SystemInfo.isWindows) { + listOf("opencode", "opencode.cmd", "opencode.ps1", "opencode-cli.exe") + } else { + listOf("opencode") + } - val pathEnv = System.getenv("PATH") ?: "" - for (dir in pathEnv.split(File.pathSeparator)) { - if (dir.isBlank()) continue - val candidate = File(dir, executableName) - if (isCandidateFile(candidate)) { - validateCandidate(candidate.absolutePath)?.let { return it } + val pathEnv = System.getenv("PATH") + if (pathEnv.isNullOrBlank()) { + log.debug("PATH environment variable is empty; skipping PATH scan") + } else { + 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 } + } + } } } diff --git a/src/main/kotlin/com/ashotn/opencode/companion/ServerManager.kt b/src/main/kotlin/com/ashotn/opencode/companion/ServerManager.kt index f0fa433..93981c2 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 @@ -257,7 +258,13 @@ class ServerManager( 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", 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 5acd219..36ff25d 100644 --- a/src/main/kotlin/com/ashotn/opencode/companion/settings/OpenCodeSettingsConfigurable.kt +++ b/src/main/kotlin/com/ashotn/opencode/companion/settings/OpenCodeSettingsConfigurable.kt @@ -2,8 +2,8 @@ package com.ashotn.opencode.companion.settings import com.ashotn.opencode.companion.OpenCodeChecker import com.ashotn.opencode.companion.OpenCodeInfo -import com.ashotn.opencode.companion.OpenCodePlugin import com.ashotn.opencode.companion.OpenCodeInfoChangedListener +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.util.BuildUtils @@ -23,6 +23,8 @@ import java.util.concurrent.atomic.AtomicReference class OpenCodeSettingsConfigurable(private val project: Project) : BoundConfigurable("OpenCode Companion") { + internal var executableResolver: (String?) -> OpenCodeInfo? = { path -> OpenCodeChecker.findExecutable(path) } + override fun createPanel(): DialogPanel { val settings = OpenCodeSettings.getInstance(project) return panel { @@ -126,6 +128,7 @@ class OpenCodeSettingsConfigurable(private val project: Project) : val settings = OpenCodeSettings.getInstance(project) val plugin = OpenCodePlugin.getInstance(project) val oldSettings = snapshot(settings) + val oldOpenCodeInfo = plugin.openCodeInfo super.apply() @@ -134,36 +137,37 @@ class OpenCodeSettingsConfigurable(private val project: Project) : val newPath = newSettings.executablePath val portChanged = newPort != oldSettings.serverPort val pathChanged = newPath != oldSettings.executablePath + val shouldResolveInfo = pathChanged || (newPath.isBlank() && oldOpenCodeInfo == null) val portOrPathChanged = portChanged || pathChanged val serverRunning = plugin.isRunning val owned = plugin.ownsProcess val mustConfirmStop = serverRunning && owned && portOrPathChanged val mustReattach = serverRunning && !owned && portChanged + var resolvedInfo: OpenCodeInfo? = oldOpenCodeInfo + var openCodeInfoResolved = false - if (pathChanged) { + if (shouldResolveInfo) { val userProvidedPath = newPath.takeIf { it.isNotBlank() } val resolvedRef = AtomicReference() ProgressManager.getInstance().runProcessWithProgressSynchronously( { - resolvedRef.set(OpenCodeChecker.findExecutable(userProvidedPath)) + resolvedRef.set(executableResolver(userProvidedPath)) }, "Resolving OpenCode\u2026", false, project ) - val info = resolvedRef.get() - if (info == null && userProvidedPath != null) { + resolvedInfo = resolvedRef.get() + if (isExecutableResolutionFailureBlocking(userProvidedPath, resolvedInfo)) { restore(settings, oldSettings) throw ConfigurationException( "Could not find a valid OpenCode executable. Check the path and try again." ) } - plugin.openCodeInfo = info - project.messageBus.syncPublisher(OpenCodeInfoChangedListener.TOPIC) - .onOpenCodeInfoChanged(info) + openCodeInfoResolved = true } if (mustConfirmStop) { @@ -178,8 +182,7 @@ class OpenCodeSettingsConfigurable(private val project: Project) : ) == Messages.YES if (!confirmed) { - settings.serverPort = oldSettings.serverPort - settings.executablePath = oldSettings.executablePath + restore(settings, oldSettings) reset() return } @@ -190,6 +193,12 @@ class OpenCodeSettingsConfigurable(private val project: Project) : plugin.reattach(newPort) } + if (openCodeInfoResolved && resolvedInfo != oldOpenCodeInfo) { + plugin.openCodeInfo = resolvedInfo + project.messageBus.syncPublisher(OpenCodeInfoChangedListener.TOPIC) + .onOpenCodeInfoChanged(resolvedInfo) + } + val finalSettings = snapshot(settings) if (finalSettings != oldSettings) { project.messageBus.syncPublisher(OpenCodeSettingsChangedListener.TOPIC) @@ -219,3 +228,6 @@ class OpenCodeSettingsConfigurable(private val project: Project) : terminalEngine = settings.terminalEngine, ) } + +internal fun isExecutableResolutionFailureBlocking(userProvidedPath: String?, resolvedInfo: OpenCodeInfo?): Boolean = + userProvidedPath != null && resolvedInfo == null 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..3fa784b --- /dev/null +++ b/src/test/kotlin/com/ashotn/opencode/companion/settings/OpenCodeSettingsConfigurableTest.kt @@ -0,0 +1,85 @@ +package com.ashotn.opencode.companion.settings + +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.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).openCodeInfo = 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) + } finally { + runOnEdt { configurable.disposeUIResources() } + } + } + + fun testApplyBlocksSavingWhenExplicitPathFailsResolution() { + val settings = OpenCodeSettings.getInstance(project) + settings.executablePath = "" + + 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("", 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 + } +} From 1efd54775c55cd79981f7cbdf6896afb5a6d0b98 Mon Sep 17 00:00:00 2001 From: Ashot Nazaryan Date: Fri, 13 Mar 2026 10:41:14 -0700 Subject: [PATCH 7/8] refactor: use pending state to decouple UI from persisted settings in OpenCodeSettingsConfigurable --- .../settings/OpenCodeSettingsConfigurable.kt | 168 +++++++++--------- .../OpenCodeSettingsConfigurableTest.kt | 52 +++++- 2 files changed, 138 insertions(+), 82 deletions(-) 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 36ff25d..a7efd2e 100644 --- a/src/main/kotlin/com/ashotn/opencode/companion/settings/OpenCodeSettingsConfigurable.kt +++ b/src/main/kotlin/com/ashotn/opencode/companion/settings/OpenCodeSettingsConfigurable.kt @@ -23,10 +23,13 @@ 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) + loadPendingFromPersisted() + return panel { group("Executable") { val executablePathField = TextFieldWithBrowseButton().apply { @@ -34,7 +37,7 @@ class OpenCodeSettingsConfigurable(private val project: Project) : project, FileChooserDescriptorFactory.createSingleFileNoJarsDescriptor() .withTitle("Select OpenCode Executable") - .withDescription("Choose the opencode executable file.") + .withDescription("Choose the opencode executable file."), ) } run { @@ -57,7 +60,7 @@ class OpenCodeSettingsConfigurable(private val project: Project) : } row("OpenCode Path:") { cell(executablePathField) - .bindText(settings::executablePath) + .bindText(pendingState::executablePath) .comment("Path to the opencode executable. Leave blank to auto-detect.") .align(AlignX.FILL) } @@ -65,17 +68,17 @@ class OpenCodeSettingsConfigurable(private val project: Project) : group("Server") { row("Server Port:") { intTextField(1024..65535) - .bindIntText(settings::serverPort) + .bindIntText(pendingState::serverPort) .comment("Port the OpenCode server listens on (default: 4096)") } } 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.", ) } } @@ -83,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:") { @@ -96,138 +99,141 @@ 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.", ) } 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.", ) } } } } + override fun reset() { + loadPendingFromPersisted() + super.reset() + } + override fun apply() { val settings = OpenCodeSettings.getInstance(project) val plugin = OpenCodePlugin.getInstance(project) - val oldSettings = snapshot(settings) + val oldSettings = snapshot(settings.state) val oldOpenCodeInfo = plugin.openCodeInfo - super.apply() + super.apply() // Pushes UI values into pendingState. - val newSettings = snapshot(settings) + 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 shouldResolveInfo = pathChanged || (newPath.isBlank() && oldOpenCodeInfo == null) - val portOrPathChanged = portChanged || pathChanged - val serverRunning = plugin.isRunning - val owned = plugin.ownsProcess - val mustConfirmStop = serverRunning && owned && portOrPathChanged - val mustReattach = serverRunning && !owned && portChanged + if (!settingsChanged && !shouldResolveInfo) return - var resolvedInfo: OpenCodeInfo? = oldOpenCodeInfo - var openCodeInfoResolved = false + val mustConfirmStop = plugin.isRunning && plugin.ownsProcess && (portChanged || pathChanged) + val mustReattach = plugin.isRunning && !plugin.ownsProcess && portChanged + var resolvedInfo = oldOpenCodeInfo if (shouldResolveInfo) { val userProvidedPath = newPath.takeIf { it.isNotBlank() } - val resolvedRef = AtomicReference() - ProgressManager.getInstance().runProcessWithProgressSynchronously( - { - resolvedRef.set(executableResolver(userProvidedPath)) - }, - "Resolving OpenCode\u2026", - false, - project - ) - - resolvedInfo = resolvedRef.get() - if (isExecutableResolutionFailureBlocking(userProvidedPath, resolvedInfo)) { - restore(settings, oldSettings) + resolvedInfo = resolveExecutableInfo(userProvidedPath) + if (userProvidedPath != null && resolvedInfo == null) { throw ConfigurationException( - "Could not find a valid OpenCode executable. Check the path and try again." + "Could not find a valid OpenCode executable. Check the path and try again.", ) } - - openCodeInfoResolved = true } - if (mustConfirmStop) { - val confirmed = 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 - - if (!confirmed) { - restore(settings, oldSettings) - reset() - return - } + if (mustConfirmStop && !confirmStopServerRestart()) { + reset() + return + } - plugin.stopServer() + persistPendingToSettings(settings) - } else if (mustReattach) { - plugin.reattach(newPort) + when { + mustConfirmStop -> plugin.stopServer() + mustReattach -> plugin.reattach(newPort) } - if (openCodeInfoResolved && resolvedInfo != oldOpenCodeInfo) { + if (shouldResolveInfo && resolvedInfo != oldOpenCodeInfo) { plugin.openCodeInfo = resolvedInfo project.messageBus.syncPublisher(OpenCodeInfoChangedListener.TOPIC) .onOpenCodeInfoChanged(resolvedInfo) } - val finalSettings = snapshot(settings) - if (finalSettings != oldSettings) { + if (settingsChanged) { project.messageBus.syncPublisher(OpenCodeSettingsChangedListener.TOPIC) - .onSettingsChanged(oldSettings, finalSettings) + .onSettingsChanged(oldSettings, newSettings) } EditorDiffRenderer.getInstance(project).onSettingsChanged() } - private fun restore(settings: OpenCodeSettings, snapshot: OpenCodeSettingsSnapshot) { - settings.serverPort = snapshot.serverPort - settings.executablePath = snapshot.executablePath - settings.inlineDiffEnabled = snapshot.inlineDiffEnabled - settings.diffTraceEnabled = snapshot.diffTraceEnabled - settings.diffTraceHistoryEnabled = snapshot.diffTraceHistoryEnabled - settings.inlineTerminalEnabled = snapshot.inlineTerminalEnabled - settings.terminalEngine = snapshot.terminalEngine + private fun resolveExecutableInfo(userProvidedPath: String?): OpenCodeInfo? { + val resolvedRef = AtomicReference() + ProgressManager.getInstance().runProcessWithProgressSynchronously( + { + resolvedRef.set(executableResolver(userProvidedPath)) + }, + "Resolving OpenCode...", + false, + project, + ) + return resolvedRef.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 snapshot(settings: OpenCodeSettings): OpenCodeSettingsSnapshot = OpenCodeSettingsSnapshot( - serverPort = settings.serverPort, - executablePath = settings.executablePath, - inlineDiffEnabled = settings.inlineDiffEnabled, - diffTraceEnabled = settings.diffTraceEnabled, - diffTraceHistoryEnabled = settings.diffTraceHistoryEnabled, - inlineTerminalEnabled = settings.inlineTerminalEnabled, - 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, ) } - -internal fun isExecutableResolutionFailureBlocking(userProvidedPath: String?, resolvedInfo: OpenCodeInfo?): Boolean = - userProvidedPath != null && resolvedInfo == null diff --git a/src/test/kotlin/com/ashotn/opencode/companion/settings/OpenCodeSettingsConfigurableTest.kt b/src/test/kotlin/com/ashotn/opencode/companion/settings/OpenCodeSettingsConfigurableTest.kt index 3fa784b..2f97549 100644 --- a/src/test/kotlin/com/ashotn/opencode/companion/settings/OpenCodeSettingsConfigurableTest.kt +++ b/src/test/kotlin/com/ashotn/opencode/companion/settings/OpenCodeSettingsConfigurableTest.kt @@ -8,6 +8,7 @@ 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 @@ -31,6 +32,7 @@ class OpenCodeSettingsConfigurableTest : BasePlatformTestCase() { runOnEdt { configurable.apply() } assertEquals("", settings.executablePath) + assertNull(OpenCodePlugin.getInstance(project).openCodeInfo) } finally { runOnEdt { configurable.disposeUIResources() } } @@ -38,7 +40,7 @@ class OpenCodeSettingsConfigurableTest : BasePlatformTestCase() { fun testApplyBlocksSavingWhenExplicitPathFailsResolution() { val settings = OpenCodeSettings.getInstance(project) - settings.executablePath = "" + settings.executablePath = "C:/existing/opencode" val configurable = OpenCodeSettingsConfigurable(project).apply { executableResolver = { null } @@ -54,12 +56,60 @@ class OpenCodeSettingsConfigurableTest : BasePlatformTestCase() { assertFailsWith { runOnEdt { configurable.apply() } } + assertEquals("C:/existing/opencode", settings.executablePath) + } finally { + runOnEdt { configurable.disposeUIResources() } + } + } + + fun testApplyAttemptsAutoResolveWhenPathBlankAndInfoMissing() { + val settings = OpenCodeSettings.getInstance(project) + settings.executablePath = "" + OpenCodePlugin.getInstance(project).openCodeInfo = null + + 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) } finally { runOnEdt { configurable.disposeUIResources() } } } + fun testApplySkipsResolveWhenNoSettingsChangeAndInfoAlreadyResolved() { + val settings = OpenCodeSettings.getInstance(project) + settings.executablePath = "C:/existing/opencode" + OpenCodePlugin.getInstance(project).openCodeInfo = 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) } From ae41a966a58c508ec22a562b5b579b3923befd13 Mon Sep 17 00:00:00 2001 From: Ashot Nazaryan Date: Fri, 13 Mar 2026 16:39:03 -0700 Subject: [PATCH 8/8] feat: introduce OpenCodeExecutableResolutionState for improved resolution tracking refactor: replace executableResolutionCompleted with OpenCodeExecutableResolutionState fix: ensure proper Windows terminal command structure for server start and attach perf: optimize PATH-based executable detection with simplified File usage chore: migrate OpenCodeStartupActivity to backgroundPostStartupActivity test: update tests to reflect new executable resolution state management --- .../opencode/companion/OpenCodeChecker.kt | 5 +- .../OpenCodeExecutableResolutionState.kt | 7 +++ .../opencode/companion/OpenCodePlugin.kt | 54 +++++++++++++------ .../opencode/companion/ServerManager.kt | 22 +++++++- .../companion/actions/OpenTerminalAction.kt | 10 ++-- .../settings/OpenCodeSettingsConfigurable.kt | 34 ++++++------ .../toolwindow/OpenCodeToolWindowPanel.kt | 10 ++-- src/main/resources/META-INF/plugin.xml | 2 +- .../OpenCodeSettingsConfigurableTest.kt | 47 ++++++++++++++-- 9 files changed, 136 insertions(+), 55 deletions(-) create mode 100644 src/main/kotlin/com/ashotn/opencode/companion/OpenCodeExecutableResolutionState.kt diff --git a/src/main/kotlin/com/ashotn/opencode/companion/OpenCodeChecker.kt b/src/main/kotlin/com/ashotn/opencode/companion/OpenCodeChecker.kt index aeb013b..c97aad9 100644 --- a/src/main/kotlin/com/ashotn/opencode/companion/OpenCodeChecker.kt +++ b/src/main/kotlin/com/ashotn/opencode/companion/OpenCodeChecker.kt @@ -33,7 +33,6 @@ object OpenCodeChecker { appData?.let { add("$it\\npm\\opencode") add("$it\\npm\\opencode.cmd") - add("$it\\npm\\opencode.ps1") } } } @@ -85,7 +84,7 @@ object OpenCodeChecker { val file = File(normalizedUserProvidedPath) if (!isCandidateFile(file)) { log.warn( - "OpenCode executable at user-provided path is not runnable: $normalizedUserProvidedPath " + + "OpenCode executable at user-provided path is invalid: $normalizedUserProvidedPath " + "(exists=${file.exists()}, isFile=${file.isFile}, canExecute=${file.canExecute()}, os=${SystemInfo.OS_NAME})" ) return null @@ -101,7 +100,7 @@ object OpenCodeChecker { private fun autoResolve(): OpenCodeInfo? { val executableNames = if (SystemInfo.isWindows) { - listOf("opencode", "opencode.cmd", "opencode.ps1", "opencode-cli.exe") + listOf("opencode", "opencode.cmd", "opencode-cli.exe") } else { listOf("opencode") } 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/OpenCodePlugin.kt b/src/main/kotlin/com/ashotn/opencode/companion/OpenCodePlugin.kt index 507aa6e..9042459 100644 --- a/src/main/kotlin/com/ashotn/opencode/companion/OpenCodePlugin.kt +++ b/src/main/kotlin/com/ashotn/opencode/companion/OpenCodePlugin.kt @@ -46,29 +46,49 @@ class OpenCodePlugin(private val project: Project) : Disposable { // --- Resolved executable info --- @Volatile - private var executableResolutionCompleted: Boolean = false + var executableResolutionState: OpenCodeExecutableResolutionState = OpenCodeExecutableResolutionState.Resolving + private set - @Volatile - var openCodeInfo: OpenCodeInfo? = null - set(value) { - field = value - executableResolutionCompleted = true - } - - val isExecutableResolutionCompleted: Boolean - get() = executableResolutionCompleted + val openCodeInfo: OpenCodeInfo? + get() = (executableResolutionState as? OpenCodeExecutableResolutionState.Resolved)?.info /** * Runs [OpenCodeChecker.findExecutable] using the current settings path, - * stores the result in [openCodeInfo], and publishes the change on the + * stores the result in [executableResolutionState], and publishes the change on the * project message bus via [OpenCodeInfoChangedListener.TOPIC]. * - * Safe to call from any thread; the topic is published on the EDT. + * 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) - openCodeInfo = info + 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) @@ -89,12 +109,12 @@ class OpenCodePlugin(private val project: Project) : Disposable { fun checkPort(port: Int) = serverManager.checkPort(port) fun startServer(port: Int) { - val info = openCodeInfo - if (info == null) { - log.warn("startServer() called but openCodeInfo is null") + val resolvedExecutableInfo = openCodeInfo + if (resolvedExecutableInfo == null) { + log.warn("startServer() called but executable resolution is not in the resolved state") return } - serverManager.startServer(port, info.path) + serverManager.startServer(port, resolvedExecutableInfo.path) } fun stopServer() = serverManager.stopServer() diff --git a/src/main/kotlin/com/ashotn/opencode/companion/ServerManager.kt b/src/main/kotlin/com/ashotn/opencode/companion/ServerManager.kt index 93981c2..ed74a86 100644 --- a/src/main/kotlin/com/ashotn/opencode/companion/ServerManager.kt +++ b/src/main/kotlin/com/ashotn/opencode/companion/ServerManager.kt @@ -256,6 +256,19 @@ 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) val isLaunchable = if (SystemInfo.isWindows) { @@ -263,7 +276,6 @@ class ServerManager( } else { executable.isFile && executable.canExecute() } - if (!isLaunchable) { project.showNotification( "Failed to start OpenCode Companion", @@ -284,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 27e437d..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,6 +12,7 @@ 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() { @@ -50,9 +51,6 @@ class OpenTerminalAction(private val project: Project) : AnAction() { SystemInfo.isWindows -> listOf( "cmd", "/c", - "start", - "cmd", - "/k", buildWindowsAttachCommand(executablePath, url), ) @@ -91,7 +89,7 @@ class OpenTerminalAction(private val project: Project) : AnAction() { } private fun buildWindowsAttachCommand(executablePath: String, url: String): String = - "\"$executablePath\" attach \"$url\"" + "start \"\" \"$executablePath\" attach \"$url\"" private fun buildPosixAttachCommand(executablePath: String, url: String): String = "${shellQuote(executablePath)} attach ${shellQuote(url)}" @@ -128,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/OpenCodeSettingsConfigurable.kt b/src/main/kotlin/com/ashotn/opencode/companion/settings/OpenCodeSettingsConfigurable.kt index a7efd2e..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,8 +1,8 @@ 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.OpenCodeInfoChangedListener import com.ashotn.opencode.companion.OpenCodePlugin import com.ashotn.opencode.companion.core.EditorDiffRenderer import com.ashotn.opencode.companion.settings.OpenCodeSettings.TerminalEngine @@ -136,7 +136,7 @@ class OpenCodeSettingsConfigurable(private val project: Project) : val settings = OpenCodeSettings.getInstance(project) val plugin = OpenCodePlugin.getInstance(project) val oldSettings = snapshot(settings.state) - val oldOpenCodeInfo = plugin.openCodeInfo + val oldResolutionState = plugin.executableResolutionState super.apply() // Pushes UI values into pendingState. @@ -146,21 +146,25 @@ class OpenCodeSettingsConfigurable(private val project: Project) : val newPath = newSettings.executablePath val portChanged = newPort != oldSettings.serverPort val pathChanged = newPath != oldSettings.executablePath - val shouldResolveInfo = pathChanged || (newPath.isBlank() && oldOpenCodeInfo == null) - if (!settingsChanged && !shouldResolveInfo) return + 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 resolvedInfo = oldOpenCodeInfo - if (shouldResolveInfo) { + var resolvedState = oldResolutionState + if (shouldUpdateExecutableResolution) { val userProvidedPath = newPath.takeIf { it.isNotBlank() } - resolvedInfo = resolveExecutableInfo(userProvidedPath) - if (userProvidedPath != null && resolvedInfo == null) { + 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()) { @@ -175,10 +179,8 @@ class OpenCodeSettingsConfigurable(private val project: Project) : mustReattach -> plugin.reattach(newPort) } - if (shouldResolveInfo && resolvedInfo != oldOpenCodeInfo) { - plugin.openCodeInfo = resolvedInfo - project.messageBus.syncPublisher(OpenCodeInfoChangedListener.TOPIC) - .onOpenCodeInfoChanged(resolvedInfo) + if (shouldUpdateExecutableResolution && resolvedState != oldResolutionState) { + plugin.setExecutableResolutionState(resolvedState) } if (settingsChanged) { @@ -189,17 +191,17 @@ class OpenCodeSettingsConfigurable(private val project: Project) : EditorDiffRenderer.getInstance(project).onSettingsChanged() } - private fun resolveExecutableInfo(userProvidedPath: String?): OpenCodeInfo? { - val resolvedRef = AtomicReference() + private fun detectExecutableInfo(userProvidedPath: String?): OpenCodeInfo? { + val detectedInfoRef = AtomicReference() ProgressManager.getInstance().runProcessWithProgressSynchronously( { - resolvedRef.set(executableResolver(userProvidedPath)) + detectedInfoRef.set(executableResolver(userProvidedPath)) }, "Resolving OpenCode...", false, project, ) - return resolvedRef.get() + return detectedInfoRef.get() } private fun confirmStopServerRestart(): Boolean = 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 b2a1369..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,5 +1,6 @@ package com.ashotn.opencode.companion.toolwindow +import com.ashotn.opencode.companion.OpenCodeExecutableResolutionState import com.ashotn.opencode.companion.OpenCodePlugin import com.ashotn.opencode.companion.OpenCodeInfoChangedListener import com.ashotn.opencode.companion.ServerState @@ -210,11 +211,10 @@ class OpenCodeToolWindowPanel(private val project: Project) : JPanel(BorderLayou } private fun buildContent() { - val executableInfo = plugin.openCodeInfo - val screen = when { - executableInfo != null -> InstalledPanel(project, slotDisposable, executableInfo) - plugin.isExecutableResolutionCompleted -> NotInstalledPanel() - else -> ResolvingExecutablePanel() + 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 diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index 2a1d27c..36596ae 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -26,7 +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 index 2f97549..59edee6 100644 --- a/src/test/kotlin/com/ashotn/opencode/companion/settings/OpenCodeSettingsConfigurableTest.kt +++ b/src/test/kotlin/com/ashotn/opencode/companion/settings/OpenCodeSettingsConfigurableTest.kt @@ -1,5 +1,6 @@ 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 @@ -17,7 +18,9 @@ class OpenCodeSettingsConfigurableTest : BasePlatformTestCase() { fun testApplyAllowsSavingWhenPathIsBlankAndResolutionFails() { val settings = OpenCodeSettings.getInstance(project) settings.executablePath = "C:/Users/VM/AppData/Roaming/npm/opencode.cmd" - OpenCodePlugin.getInstance(project).openCodeInfo = OpenCodeInfo(settings.executablePath, "1.2.3") + OpenCodePlugin.getInstance(project).setExecutableResolutionState( + OpenCodeExecutableResolutionState.Resolved(OpenCodeInfo(settings.executablePath, "1.2.3")) + ) val configurable = OpenCodeSettingsConfigurable(project).apply { executableResolver = { null } @@ -62,10 +65,10 @@ class OpenCodeSettingsConfigurableTest : BasePlatformTestCase() { } } - fun testApplyAttemptsAutoResolveWhenPathBlankAndInfoMissing() { + fun testApplyAttemptsAutoResolveWhenPathBlankAndResolutionStillPending() { val settings = OpenCodeSettings.getInstance(project) settings.executablePath = "" - OpenCodePlugin.getInstance(project).openCodeInfo = null + OpenCodePlugin.getInstance(project).setExecutableResolutionState(OpenCodeExecutableResolutionState.Resolving) val resolveCalls = AtomicInteger(0) val configurable = OpenCodeSettingsConfigurable(project).apply { @@ -81,15 +84,49 @@ class OpenCodeSettingsConfigurableTest : BasePlatformTestCase() { assertEquals(1, resolveCalls.get()) assertEquals("", settings.executablePath) + assertEquals( + OpenCodeExecutableResolutionState.NotFound, + OpenCodePlugin.getInstance(project).executableResolutionState, + ) } finally { runOnEdt { configurable.disposeUIResources() } } } - fun testApplySkipsResolveWhenNoSettingsChangeAndInfoAlreadyResolved() { + 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).openCodeInfo = OpenCodeInfo(settings.executablePath, "1.2.3") + OpenCodePlugin.getInstance(project).setExecutableResolutionState( + OpenCodeExecutableResolutionState.Resolved(OpenCodeInfo(settings.executablePath, "1.2.3")) + ) val resolveCalls = AtomicInteger(0) val configurable = OpenCodeSettingsConfigurable(project).apply {