Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions .idea/runConfigurations/Run_Plugin.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,15 @@ class OpenCodeTestEventCollector(
@Suppress("PLATFORM_CLASS_MAPPED_TO_KOTLIN")
private val lock = Object()
private val events = mutableListOf<OpenCodeEvent>()
private val sseClient = SseClient(port) { event ->
synchronized(lock) {
events.add(event)
lock.notifyAll()
}
}
private val sseClient = SseClient(
port = port,
onEvent = { event ->
synchronized(lock) {
events.add(event)
lock.notifyAll()
}
},
)

init {
sseClient.start()
Expand Down
61 changes: 40 additions & 21 deletions src/main/kotlin/com/ashotn/opencode/relay/OpenCodePlugin.kt
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,17 @@ import com.intellij.openapi.components.Service
import com.intellij.openapi.diagnostic.logger
import com.intellij.openapi.project.Project
import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.Executors

@Service(Service.Level.PROJECT)
class OpenCodePlugin(private val project: Project) : Disposable {

// --- Listeners ---

private val listeners = CopyOnWriteArrayList<ServerStateListener>()
private val connectionSyncExecutor = Executors.newSingleThreadExecutor { runnable ->
Thread(runnable, "opencode-connection-sync").apply { isDaemon = true }
}

fun addListener(listener: ServerStateListener) = listeners.add(listener)
fun removeListener(listener: ServerStateListener) = listeners.remove(listener)
Expand All @@ -24,20 +28,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 = OpenCodeSettings.getInstance(project).serverPort
ApplicationManager.getApplication().executeOnPooledThread {
if (project.isDisposed) return@executeOnPooledThread
OpenCodeCoreService.getInstance(project).startListening(port)
OpenCodeTuiClient.getInstance(project).setPort(port)
}
} else if (state == ServerState.STOPPED) {
ApplicationManager.getApplication().executeOnPooledThread {
if (project.isDisposed) return@executeOnPooledThread
OpenCodeCoreService.getInstance(project).stopListening()
OpenCodeTuiClient.getInstance(project).setPort(0)
}
}
scheduleConnectionSync(state)
}

val isRunning: Boolean get() = serverManager.isRunning
Expand Down Expand Up @@ -105,6 +96,27 @@ class OpenCodePlugin(private val project: Project) : Disposable {
listeners.forEach { it.onStateChanged(state) }
}

private fun scheduleConnectionSync(state: ServerState) {
connectionSyncExecutor.execute {
if (project.isDisposed) return@execute

when (state) {
ServerState.READY -> {
val port = OpenCodeSettings.getInstance(project).serverPort
OpenCodeCoreService.getInstance(project).startListening(port)
OpenCodeTuiClient.getInstance(project).setPort(port)
}

ServerState.STOPPED, ServerState.PORT_CONFLICT, ServerState.AUTH_REQUIRED -> {
OpenCodeCoreService.getInstance(project).stopListening()
OpenCodeTuiClient.getInstance(project).setPort(0)
}

else -> Unit
}
}
}

fun startPolling(port: Int, intervalSeconds: Long = 10L) = serverManager.startPolling(port, intervalSeconds)
fun checkPort(port: Int) = serverManager.checkPort(port)

Expand Down Expand Up @@ -138,26 +150,33 @@ class OpenCodePlugin(private val project: Project) : Disposable {
val port = OpenCodeSettings.getInstance(project).serverPort
overrideState = ServerState.RESETTING
broadcastState(ServerState.RESETTING)
ApplicationManager.getApplication().executeOnPooledThread {
if (project.isDisposed) return@executeOnPooledThread
connectionSyncExecutor.execute {
if (project.isDisposed) return@execute
val diffService = OpenCodeCoreService.getInstance(project)
val tuiClient = OpenCodeTuiClient.getInstance(project)
diffService.stopListening()
overrideState = null
tuiClient.setPort(0)
diffService.startListening(port)
// Broadcast the real underlying state now that the override is cleared,
// so listeners (e.g. the TUI panel) can react and restore themselves.
val realState = serverManager.serverState
tuiClient.setPort(port)
overrideState = null
ApplicationManager.getApplication().invokeLater {
broadcastState(realState)
if (!project.isDisposed) {
broadcastState(serverManager.serverState)
}
}
}
}

fun reportAuthenticationRequired() {
serverManager.reportAuthenticationRequired()
}

// --- Disposable ---

override fun dispose() {
listeners.clear()
serverManager.dispose()
connectionSyncExecutor.shutdownNow()
}

companion object {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,23 +5,35 @@ import java.io.File

internal object OpenCodeProcessEnvironment {

fun configure(processBuilder: ProcessBuilder, executablePath: String) {
fun configure(
processBuilder: ProcessBuilder,
executablePath: String,
environmentVariables: Map<String, String> = emptyMap(),
) {
applyEnvironmentVariables(processBuilder.environment(), environmentVariables)
nvmBinDirectory(executablePath)?.let { binDir ->
prependPath(processBuilder.environment(), binDir)
}
}

fun terminalCommand(command: List<String>): List<String> {
fun terminalCommand(
command: List<String>,
environmentVariables: Map<String, String>,
): List<String> {
if (command.isEmpty()) return command

val executablePath = command.first()
val nvmBinDirectory = nvmBinDirectory(executablePath) ?: return command
if (SystemInfo.isWindows) return command
val environment = linkedMapOf<String, String>()
applyEnvironmentVariables(environment, environmentVariables)
nvmBinDirectory(command.first())?.let { binDir ->
prependPath(environment, binDir)
}
if (environment.isEmpty()) return command

return listOf(
"/usr/bin/env",
"PATH=${pathWithPrependedDirectory(System.getenv("PATH"), nvmBinDirectory)}",
) + command
return if (SystemInfo.isWindows) {
listOf("cmd", "/c", buildWindowsTerminalCommand(command, environment))
} else {
listOf("/usr/bin/env") + environment.map { (name, value) -> "$name=$value" } + command
}
}

private fun nvmBinDirectory(executablePath: String): String? {
Expand All @@ -40,11 +52,41 @@ internal object OpenCodeProcessEnvironment {
}
}

private fun applyEnvironmentVariables(
environment: MutableMap<String, String>,
environmentVariables: Map<String, String>,
) {
environmentVariables.forEach { (name, value) ->
val normalizedName = name.trim()
if (normalizedName.isBlank()) return@forEach

val key = environment.keys.firstOrNull { it.equals(normalizedName, ignoreCase = true) } ?: normalizedName
environment[key] = value
}
}

private fun prependPath(environment: MutableMap<String, String>, directory: String) {
val pathKey = environment.keys.firstOrNull { it.equals("PATH", ignoreCase = true) } ?: "PATH"
environment[pathKey] = pathWithPrependedDirectory(environment[pathKey], directory)
}

private fun buildWindowsTerminalCommand(
command: List<String>,
environment: Map<String, String>,
): String = buildString {
environment.forEach { (name, value) ->
append("set \"")
append(name)
append('=')
append(value.replace("\"", "\"\""))
append("\" && ")
}
append(command.joinToString(" ") { windowsQuote(it) })
}

private fun windowsQuote(value: String): String =
"\"${value.replace("\"", "\\\"")}\""

private fun pathWithPrependedDirectory(currentPath: String?, directory: String): String {
val pathEntries = currentPath.orEmpty().split(File.pathSeparator).filter { it.isNotBlank() }
if (pathEntries.any { it == directory }) return currentPath.orEmpty()
Expand Down
Loading
Loading