Skip to content

Commit ae41a96

Browse files
committed
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
1 parent 1efd547 commit ae41a96

9 files changed

Lines changed: 136 additions & 55 deletions

File tree

src/main/kotlin/com/ashotn/opencode/companion/OpenCodeChecker.kt

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,6 @@ object OpenCodeChecker {
3333
appData?.let {
3434
add("$it\\npm\\opencode")
3535
add("$it\\npm\\opencode.cmd")
36-
add("$it\\npm\\opencode.ps1")
3736
}
3837
}
3938
}
@@ -85,7 +84,7 @@ object OpenCodeChecker {
8584
val file = File(normalizedUserProvidedPath)
8685
if (!isCandidateFile(file)) {
8786
log.warn(
88-
"OpenCode executable at user-provided path is not runnable: $normalizedUserProvidedPath " +
87+
"OpenCode executable at user-provided path is invalid: $normalizedUserProvidedPath " +
8988
"(exists=${file.exists()}, isFile=${file.isFile}, canExecute=${file.canExecute()}, os=${SystemInfo.OS_NAME})"
9089
)
9190
return null
@@ -101,7 +100,7 @@ object OpenCodeChecker {
101100
private fun autoResolve(): OpenCodeInfo? {
102101
val executableNames =
103102
if (SystemInfo.isWindows) {
104-
listOf("opencode", "opencode.cmd", "opencode.ps1", "opencode-cli.exe")
103+
listOf("opencode", "opencode.cmd", "opencode-cli.exe")
105104
} else {
106105
listOf("opencode")
107106
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package com.ashotn.opencode.companion
2+
3+
sealed interface OpenCodeExecutableResolutionState {
4+
data object Resolving : OpenCodeExecutableResolutionState
5+
data class Resolved(val info: OpenCodeInfo) : OpenCodeExecutableResolutionState
6+
data object NotFound : OpenCodeExecutableResolutionState
7+
}

src/main/kotlin/com/ashotn/opencode/companion/OpenCodePlugin.kt

Lines changed: 37 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -46,29 +46,49 @@ class OpenCodePlugin(private val project: Project) : Disposable {
4646
// --- Resolved executable info ---
4747

4848
@Volatile
49-
private var executableResolutionCompleted: Boolean = false
49+
var executableResolutionState: OpenCodeExecutableResolutionState = OpenCodeExecutableResolutionState.Resolving
50+
private set
5051

51-
@Volatile
52-
var openCodeInfo: OpenCodeInfo? = null
53-
set(value) {
54-
field = value
55-
executableResolutionCompleted = true
56-
}
57-
58-
val isExecutableResolutionCompleted: Boolean
59-
get() = executableResolutionCompleted
52+
val openCodeInfo: OpenCodeInfo?
53+
get() = (executableResolutionState as? OpenCodeExecutableResolutionState.Resolved)?.info
6054

6155
/**
6256
* Runs [OpenCodeChecker.findExecutable] using the current settings path,
63-
* stores the result in [openCodeInfo], and publishes the change on the
57+
* stores the result in [executableResolutionState], and publishes the change on the
6458
* project message bus via [OpenCodeInfoChangedListener.TOPIC].
6559
*
66-
* Safe to call from any thread; the topic is published on the EDT.
60+
* Safe to call from any thread; expensive resolution work is moved off the EDT,
61+
* while topic publication still happens on the EDT.
6762
*/
6863
fun resolveExecutable() {
64+
val application = ApplicationManager.getApplication()
65+
if (application.isDispatchThread) {
66+
application.executeOnPooledThread {
67+
if (project.isDisposed) return@executeOnPooledThread
68+
publishExecutableResolution(resolveExecutableState())
69+
}
70+
return
71+
}
72+
73+
if (project.isDisposed) return
74+
publishExecutableResolution(resolveExecutableState())
75+
}
76+
77+
fun setExecutableResolutionState(state: OpenCodeExecutableResolutionState) {
78+
publishExecutableResolution(state)
79+
}
80+
81+
private fun resolveExecutableState(): OpenCodeExecutableResolutionState {
6982
val userPath = OpenCodeSettings.getInstance(project).executablePath.takeIf { it.isNotBlank() }
7083
val info = OpenCodeChecker.findExecutable(userPath)
71-
openCodeInfo = info
84+
return info?.let(OpenCodeExecutableResolutionState::Resolved) ?: OpenCodeExecutableResolutionState.NotFound
85+
}
86+
87+
private fun publishExecutableResolution(state: OpenCodeExecutableResolutionState) {
88+
if (state == executableResolutionState) return
89+
90+
executableResolutionState = state
91+
val info = (state as? OpenCodeExecutableResolutionState.Resolved)?.info
7292
ApplicationManager.getApplication().invokeLater {
7393
if (!project.isDisposed) {
7494
project.messageBus.syncPublisher(OpenCodeInfoChangedListener.TOPIC)
@@ -89,12 +109,12 @@ class OpenCodePlugin(private val project: Project) : Disposable {
89109
fun checkPort(port: Int) = serverManager.checkPort(port)
90110

91111
fun startServer(port: Int) {
92-
val info = openCodeInfo
93-
if (info == null) {
94-
log.warn("startServer() called but openCodeInfo is null")
112+
val resolvedExecutableInfo = openCodeInfo
113+
if (resolvedExecutableInfo == null) {
114+
log.warn("startServer() called but executable resolution is not in the resolved state")
95115
return
96116
}
97-
serverManager.startServer(port, info.path)
117+
serverManager.startServer(port, resolvedExecutableInfo.path)
98118
}
99119

100120
fun stopServer() = serverManager.stopServer()

src/main/kotlin/com/ashotn/opencode/companion/ServerManager.kt

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -256,14 +256,26 @@ class ServerManager(
256256
}
257257
}
258258

259+
private fun buildWindowsCommand(executablePath: String, vararg args: String): String =
260+
buildString {
261+
append("\"")
262+
append(executablePath)
263+
append("\"")
264+
args.forEach {
265+
append(' ')
266+
append("\"")
267+
append(it)
268+
append("\"")
269+
}
270+
}
271+
259272
fun startServer(port: Int, executablePath: String) {
260273
val executable = File(executablePath)
261274
val isLaunchable = if (SystemInfo.isWindows) {
262275
executable.exists() && executable.isFile
263276
} else {
264277
executable.isFile && executable.canExecute()
265278
}
266-
267279
if (!isLaunchable) {
268280
project.showNotification(
269281
"Failed to start OpenCode Companion",
@@ -284,7 +296,13 @@ class ServerManager(
284296
}
285297

286298
try {
287-
val process = ProcessBuilder(executablePath, "serve", "--port", port.toString())
299+
val command =
300+
if (SystemInfo.isWindows) {
301+
listOf("cmd", "/c", buildWindowsCommand(executablePath, "serve", "--port", port.toString()))
302+
} else {
303+
listOf(executablePath, "serve", "--port", port.toString())
304+
}
305+
val process = ProcessBuilder(command)
288306
.inheritIO()
289307
.apply {
290308
val basePath = project.basePath

src/main/kotlin/com/ashotn/opencode/companion/actions/OpenTerminalAction.kt

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import com.intellij.openapi.actionSystem.AnAction
1212
import com.intellij.openapi.actionSystem.AnActionEvent
1313
import com.intellij.openapi.project.Project
1414
import com.intellij.openapi.util.SystemInfo
15+
import java.io.File
1516

1617
class OpenTerminalAction(private val project: Project) : AnAction() {
1718

@@ -50,9 +51,6 @@ class OpenTerminalAction(private val project: Project) : AnAction() {
5051
SystemInfo.isWindows -> listOf(
5152
"cmd",
5253
"/c",
53-
"start",
54-
"cmd",
55-
"/k",
5654
buildWindowsAttachCommand(executablePath, url),
5755
)
5856

@@ -91,7 +89,7 @@ class OpenTerminalAction(private val project: Project) : AnAction() {
9189
}
9290

9391
private fun buildWindowsAttachCommand(executablePath: String, url: String): String =
94-
"\"$executablePath\" attach \"$url\""
92+
"start \"\" \"$executablePath\" attach \"$url\""
9593

9694
private fun buildPosixAttachCommand(executablePath: String, url: String): String =
9795
"${shellQuote(executablePath)} attach ${shellQuote(url)}"
@@ -128,8 +126,8 @@ class OpenTerminalAction(private val project: Project) : AnAction() {
128126

129127
private fun isOnPath(executable: String): Boolean {
130128
val pathEnv = System.getenv("PATH") ?: return false
131-
return pathEnv.split(java.io.File.pathSeparator).any { dir ->
132-
java.io.File(dir, executable).let { it.isFile && it.canExecute() }
129+
return pathEnv.split(File.pathSeparator).any { dir ->
130+
File(dir, executable).let { it.isFile && it.canExecute() }
133131
}
134132
}
135133
}

src/main/kotlin/com/ashotn/opencode/companion/settings/OpenCodeSettingsConfigurable.kt

Lines changed: 18 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
package com.ashotn.opencode.companion.settings
22

33
import com.ashotn.opencode.companion.OpenCodeChecker
4+
import com.ashotn.opencode.companion.OpenCodeExecutableResolutionState
45
import com.ashotn.opencode.companion.OpenCodeInfo
5-
import com.ashotn.opencode.companion.OpenCodeInfoChangedListener
66
import com.ashotn.opencode.companion.OpenCodePlugin
77
import com.ashotn.opencode.companion.core.EditorDiffRenderer
88
import com.ashotn.opencode.companion.settings.OpenCodeSettings.TerminalEngine
@@ -136,7 +136,7 @@ class OpenCodeSettingsConfigurable(private val project: Project) :
136136
val settings = OpenCodeSettings.getInstance(project)
137137
val plugin = OpenCodePlugin.getInstance(project)
138138
val oldSettings = snapshot(settings.state)
139-
val oldOpenCodeInfo = plugin.openCodeInfo
139+
val oldResolutionState = plugin.executableResolutionState
140140

141141
super.apply() // Pushes UI values into pendingState.
142142

@@ -146,21 +146,25 @@ class OpenCodeSettingsConfigurable(private val project: Project) :
146146
val newPath = newSettings.executablePath
147147
val portChanged = newPort != oldSettings.serverPort
148148
val pathChanged = newPath != oldSettings.executablePath
149-
val shouldResolveInfo = pathChanged || (newPath.isBlank() && oldOpenCodeInfo == null)
150-
if (!settingsChanged && !shouldResolveInfo) return
149+
val shouldUpdateExecutableResolution =
150+
pathChanged || (newPath.isBlank() && oldResolutionState == OpenCodeExecutableResolutionState.Resolving)
151+
if (!settingsChanged && !shouldUpdateExecutableResolution) return
151152

152153
val mustConfirmStop = plugin.isRunning && plugin.ownsProcess && (portChanged || pathChanged)
153154
val mustReattach = plugin.isRunning && !plugin.ownsProcess && portChanged
154155

155-
var resolvedInfo = oldOpenCodeInfo
156-
if (shouldResolveInfo) {
156+
var resolvedState = oldResolutionState
157+
if (shouldUpdateExecutableResolution) {
157158
val userProvidedPath = newPath.takeIf { it.isNotBlank() }
158-
resolvedInfo = resolveExecutableInfo(userProvidedPath)
159-
if (userProvidedPath != null && resolvedInfo == null) {
159+
val detectedExecutableInfo = detectExecutableInfo(userProvidedPath)
160+
if (userProvidedPath != null && detectedExecutableInfo == null) {
160161
throw ConfigurationException(
161162
"Could not find a valid OpenCode executable. Check the path and try again.",
162163
)
163164
}
165+
resolvedState =
166+
detectedExecutableInfo?.let(OpenCodeExecutableResolutionState::Resolved)
167+
?: OpenCodeExecutableResolutionState.NotFound
164168
}
165169

166170
if (mustConfirmStop && !confirmStopServerRestart()) {
@@ -175,10 +179,8 @@ class OpenCodeSettingsConfigurable(private val project: Project) :
175179
mustReattach -> plugin.reattach(newPort)
176180
}
177181

178-
if (shouldResolveInfo && resolvedInfo != oldOpenCodeInfo) {
179-
plugin.openCodeInfo = resolvedInfo
180-
project.messageBus.syncPublisher(OpenCodeInfoChangedListener.TOPIC)
181-
.onOpenCodeInfoChanged(resolvedInfo)
182+
if (shouldUpdateExecutableResolution && resolvedState != oldResolutionState) {
183+
plugin.setExecutableResolutionState(resolvedState)
182184
}
183185

184186
if (settingsChanged) {
@@ -189,17 +191,17 @@ class OpenCodeSettingsConfigurable(private val project: Project) :
189191
EditorDiffRenderer.getInstance(project).onSettingsChanged()
190192
}
191193

192-
private fun resolveExecutableInfo(userProvidedPath: String?): OpenCodeInfo? {
193-
val resolvedRef = AtomicReference<OpenCodeInfo?>()
194+
private fun detectExecutableInfo(userProvidedPath: String?): OpenCodeInfo? {
195+
val detectedInfoRef = AtomicReference<OpenCodeInfo?>()
194196
ProgressManager.getInstance().runProcessWithProgressSynchronously(
195197
{
196-
resolvedRef.set(executableResolver(userProvidedPath))
198+
detectedInfoRef.set(executableResolver(userProvidedPath))
197199
},
198200
"Resolving OpenCode...",
199201
false,
200202
project,
201203
)
202-
return resolvedRef.get()
204+
return detectedInfoRef.get()
203205
}
204206

205207
private fun confirmStopServerRestart(): Boolean =

src/main/kotlin/com/ashotn/opencode/companion/toolwindow/OpenCodeToolWindowPanel.kt

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.ashotn.opencode.companion.toolwindow
22

3+
import com.ashotn.opencode.companion.OpenCodeExecutableResolutionState
34
import com.ashotn.opencode.companion.OpenCodePlugin
45
import com.ashotn.opencode.companion.OpenCodeInfoChangedListener
56
import com.ashotn.opencode.companion.ServerState
@@ -210,11 +211,10 @@ class OpenCodeToolWindowPanel(private val project: Project) : JPanel(BorderLayou
210211
}
211212

212213
private fun buildContent() {
213-
val executableInfo = plugin.openCodeInfo
214-
val screen = when {
215-
executableInfo != null -> InstalledPanel(project, slotDisposable, executableInfo)
216-
plugin.isExecutableResolutionCompleted -> NotInstalledPanel()
217-
else -> ResolvingExecutablePanel()
214+
val screen = when (val state = plugin.executableResolutionState) {
215+
OpenCodeExecutableResolutionState.Resolving -> ResolvingExecutablePanel()
216+
OpenCodeExecutableResolutionState.NotFound -> NotInstalledPanel()
217+
is OpenCodeExecutableResolutionState.Resolved -> InstalledPanel(project, slotDisposable, state.info)
218218
}
219219

220220
// Replace the content card with the new screen

src/main/resources/META-INF/plugin.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
key="configurable.opencode.displayName"
2727
/>
2828
<notificationGroup id="OpenCode Companion" displayType="BALLOON" key="notification.group.opencode"/>
29-
<postStartupActivity implementation="com.ashotn.opencode.companion.OpenCodeStartupActivity"/>
29+
<backgroundPostStartupActivity implementation="com.ashotn.opencode.companion.OpenCodeStartupActivity"/>
3030
</extensions>
3131

3232
<actions>

src/test/kotlin/com/ashotn/opencode/companion/settings/OpenCodeSettingsConfigurableTest.kt

Lines changed: 42 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.ashotn.opencode.companion.settings
22

3+
import com.ashotn.opencode.companion.OpenCodeExecutableResolutionState
34
import com.ashotn.opencode.companion.OpenCodeInfo
45
import com.ashotn.opencode.companion.OpenCodePlugin
56
import com.intellij.openapi.application.ApplicationManager
@@ -17,7 +18,9 @@ class OpenCodeSettingsConfigurableTest : BasePlatformTestCase() {
1718
fun testApplyAllowsSavingWhenPathIsBlankAndResolutionFails() {
1819
val settings = OpenCodeSettings.getInstance(project)
1920
settings.executablePath = "C:/Users/VM/AppData/Roaming/npm/opencode.cmd"
20-
OpenCodePlugin.getInstance(project).openCodeInfo = OpenCodeInfo(settings.executablePath, "1.2.3")
21+
OpenCodePlugin.getInstance(project).setExecutableResolutionState(
22+
OpenCodeExecutableResolutionState.Resolved(OpenCodeInfo(settings.executablePath, "1.2.3"))
23+
)
2124

2225
val configurable = OpenCodeSettingsConfigurable(project).apply {
2326
executableResolver = { null }
@@ -62,10 +65,10 @@ class OpenCodeSettingsConfigurableTest : BasePlatformTestCase() {
6265
}
6366
}
6467

65-
fun testApplyAttemptsAutoResolveWhenPathBlankAndInfoMissing() {
68+
fun testApplyAttemptsAutoResolveWhenPathBlankAndResolutionStillPending() {
6669
val settings = OpenCodeSettings.getInstance(project)
6770
settings.executablePath = ""
68-
OpenCodePlugin.getInstance(project).openCodeInfo = null
71+
OpenCodePlugin.getInstance(project).setExecutableResolutionState(OpenCodeExecutableResolutionState.Resolving)
6972

7073
val resolveCalls = AtomicInteger(0)
7174
val configurable = OpenCodeSettingsConfigurable(project).apply {
@@ -81,15 +84,49 @@ class OpenCodeSettingsConfigurableTest : BasePlatformTestCase() {
8184

8285
assertEquals(1, resolveCalls.get())
8386
assertEquals("", settings.executablePath)
87+
assertEquals(
88+
OpenCodeExecutableResolutionState.NotFound,
89+
OpenCodePlugin.getInstance(project).executableResolutionState,
90+
)
8491
} finally {
8592
runOnEdt { configurable.disposeUIResources() }
8693
}
8794
}
8895

89-
fun testApplySkipsResolveWhenNoSettingsChangeAndInfoAlreadyResolved() {
96+
fun testApplySkipsResolveWhenPathBlankAndNotFoundAlreadyResolved() {
97+
val settings = OpenCodeSettings.getInstance(project)
98+
settings.executablePath = ""
99+
OpenCodePlugin.getInstance(project).setExecutableResolutionState(OpenCodeExecutableResolutionState.NotFound)
100+
101+
val resolveCalls = AtomicInteger(0)
102+
val configurable = OpenCodeSettingsConfigurable(project).apply {
103+
executableResolver = {
104+
resolveCalls.incrementAndGet()
105+
null
106+
}
107+
}
108+
109+
try {
110+
getOnEdt { configurable.createComponent() }
111+
runOnEdt { configurable.apply() }
112+
113+
assertEquals(0, resolveCalls.get())
114+
assertEquals("", settings.executablePath)
115+
assertEquals(
116+
OpenCodeExecutableResolutionState.NotFound,
117+
OpenCodePlugin.getInstance(project).executableResolutionState,
118+
)
119+
} finally {
120+
runOnEdt { configurable.disposeUIResources() }
121+
}
122+
}
123+
124+
fun testApplySkipsResolveWhenNoSettingsChangeAndResolutionAlreadyResolved() {
90125
val settings = OpenCodeSettings.getInstance(project)
91126
settings.executablePath = "C:/existing/opencode"
92-
OpenCodePlugin.getInstance(project).openCodeInfo = OpenCodeInfo(settings.executablePath, "1.2.3")
127+
OpenCodePlugin.getInstance(project).setExecutableResolutionState(
128+
OpenCodeExecutableResolutionState.Resolved(OpenCodeInfo(settings.executablePath, "1.2.3"))
129+
)
93130

94131
val resolveCalls = AtomicInteger(0)
95132
val configurable = OpenCodeSettingsConfigurable(project).apply {

0 commit comments

Comments
 (0)