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
13 changes: 7 additions & 6 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import com.jetbrains.plugin.structure.toolbox.ToolboxMeta
import com.jetbrains.plugin.structure.toolbox.ToolboxPluginDescriptor
import org.jetbrains.intellij.pluginRepository.PluginRepositoryFactory
import org.jetbrains.intellij.pluginRepository.model.ProductFamily
import org.jetbrains.kotlin.com.intellij.openapi.util.SystemInfoRt
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import java.nio.file.Path
import kotlin.io.path.createDirectories
Expand Down Expand Up @@ -204,12 +203,14 @@ tasks.register("cleanAll", Delete::class.java) {

private fun getPluginInstallDir(): Path {
val userHome = System.getProperty("user.home").let { Path.of(it) }
val osName = System.getProperty("os.name").lowercase()
val pluginsDir = when {
SystemInfoRt.isWindows -> System.getenv("LOCALAPPDATA")?.let { Path.of(it) } ?: (userHome / "AppData" / "Local")
// currently this is the location that TBA uses on Linux
SystemInfoRt.isLinux -> System.getenv("XDG_DATA_HOME")?.let { Path.of(it) } ?: (userHome / ".local" / "share")
SystemInfoRt.isMac -> userHome / "Library" / "Caches"
else -> error("Unknown os")
osName.contains("win") -> System.getenv("LOCALAPPDATA")?.let { Path.of(it) } ?: (userHome / "AppData" / "Local")
osName.contains("nix") || osName.contains("nux") || osName.contains("aix") -> System.getenv("XDG_DATA_HOME")
?.let { Path.of(it) } ?: (userHome / ".local" / "share")

osName.contains("mac") -> userHome / "Library" / "Caches"
else -> error("Unknown os: $osName")
} / "JetBrains" / "Toolbox" / "plugins"

return pluginsDir / extension.id
Expand Down
8 changes: 4 additions & 4 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
[versions]
toolbox-plugin-api = "1.3.47293"
kotlin = "2.1.20"
toolbox-plugin-api = "1.10.76281"
kotlin = "2.3.10"
coroutines = "1.10.2"
serialization = "1.8.1"
serialization = "1.9.0"
okhttp = "4.12.0"
dependency-license-report = "3.1.1"
marketplace-client = "2.0.50"
gradle-wrapper = "0.15.0"
exec = "1.12"
moshi = "1.15.2"
ksp = "2.1.20-2.0.1"
ksp = "2.3.6"
retrofit = "3.0.0"
changelog = "2.5.0"
gettext = "0.7.0"
Expand Down
115 changes: 115 additions & 0 deletions src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,14 @@ import com.jetbrains.toolbox.api.remoteDev.BeforeConnectionHook
import com.jetbrains.toolbox.api.remoteDev.EnvironmentVisibilityState
import com.jetbrains.toolbox.api.remoteDev.RemoteProviderEnvironment
import com.jetbrains.toolbox.api.remoteDev.environments.EnvironmentContentsView
import com.jetbrains.toolbox.api.remoteDev.states.CustomRemoteEnvironmentStateV2
import com.jetbrains.toolbox.api.remoteDev.states.EnvironmentDescription
import com.jetbrains.toolbox.api.remoteDev.states.EnvironmentStateIcons
import com.jetbrains.toolbox.api.remoteDev.states.RemoteEnvironmentState
import com.jetbrains.toolbox.api.remoteDev.states.StandardRemoteEnvironmentState
import com.jetbrains.toolbox.api.remoteDev.tools.ToolDetails
import com.jetbrains.toolbox.api.remoteDev.tools.ToolLaunchResult
import com.jetbrains.toolbox.api.remoteDev.tools.ToolLifetimeListener
import com.jetbrains.toolbox.api.ui.actions.ActionDescription
import com.jetbrains.toolbox.api.ui.components.TextType
import com.squareup.moshi.Moshi
Expand All @@ -34,14 +40,24 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import org.zeroturnaround.exec.ProcessExecutor
import java.io.ByteArrayOutputStream
import java.io.File
import java.nio.file.Path
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds

private val POLL_INTERVAL = 5.seconds

private val INSTALL_PLUGINS_SCRIPT: String by lazy {
CoderRemoteEnvironment::class.java.getResourceAsStream("/install-plugins.sh")
?.bufferedReader()
?.readText()
?: error("Could not load install-plugins.sh resource")
}

/**
* Represents an agent and workspace combination.
*
Expand Down Expand Up @@ -71,6 +87,13 @@ class CoderRemoteEnvironment(
private val proxyCommandHandle = SshCommandProcessHandle(context)
private var pollJob: Job? = null

/**
* When true, the environment displays "Installing plugins..." with a spinner
* and the poller's [update] calls are suppressed so they don't overwrite the status.
*/
@Volatile
private var installingPlugins = false

init {
if (context.settingsStore.shouldAutoConnect(id)) {
context.logger.info("Last session to $id was still active, trying to establish SSH connection")
Expand Down Expand Up @@ -269,6 +292,13 @@ class CoderRemoteEnvironment(
this.workspace = workspace
this.agent = agent

// While plugins are being installed, keep the workspace/agent data fresh
// but don't touch the UI state — the "Installing plugins..." spinner must stay visible.
if (installingPlugins) {
context.logger.debug("Poller update suppressed for $id while plugins are being installed")
return
}

// workspace&agent status can be different from "environment status"
// which is forced to queued state when a workspace is scheduled to start
updateStatus(WorkspaceAndAgentStatus.from(workspace, agent))
Expand All @@ -288,6 +318,30 @@ class CoderRemoteEnvironment(
context.logger.info("Overall status for workspace $id is $environmentStatus. Workspace status: ${workspace.latestBuild.status}, agent status: ${agent.status}, agent lifecycle state: ${agent.lifecycleState}, login before ready: ${agent.loginBeforeReady}")
}

private fun showInstallingPluginsState() {
installingPlugins = true
state.update {
CustomRemoteEnvironmentStateV2(
label = context.i18n.pnotr("Installing plugins..."),
color = context.envStateColorPalette.getColor(StandardRemoteEnvironmentState.Activating),
isReachable = true,
iconId = EnvironmentStateIcons.Connecting.id,
isPriorityShow = true
)
}
context.logger.info("Showing 'Installing plugins...' status for $id")
}

private fun clearInstallingPluginsState() {
if (!installingPlugins) return
installingPlugins = false
// Re-apply the real workspace status so the poller can resume normally.
updateStatus(WorkspaceAndAgentStatus.from(workspace, agent))
context.connectionMonitoringService.checkConnectionStatus(workspace, agent)
refreshAvailableActions()
context.logger.info("Cleared 'Installing plugins...' status for $id")
}

/**
* The contents are provided by the SSH view provided by Toolbox, all we
* have to do is provide it a host name.
Expand Down Expand Up @@ -377,4 +431,65 @@ class CoderRemoteEnvironment(
this.client = client
this.cli = cli
}

override fun getToolLifetimeListener(): ToolLifetimeListener = object : ToolLifetimeListener {
override suspend fun onToolLaunchAttempt(toolDetails: ToolDetails) {
context.logger.info("Tool Lifetime Launch attempt: $toolDetails from workspace $id")

val installationPath = toolDetails.installationPath
if (installationPath.isNullOrBlank()) {
context.logger.warn("No installation path available for $toolDetails, skipping plugin installation")
return
}

val pluginIds = context.settingsStore.pluginIds
if (pluginIds.isEmpty()) {
context.logger.info("No plugin IDs configured, skipping plugin installation for $id")
return
}

showInstallingPluginsState()
try {
withContext(Dispatchers.IO) {
val hostname = cli.getHostname(client.url, workspace, agent)
val command = listOf("ssh", "-T", hostname, "bash", "-s", "--", installationPath) + pluginIds

context.logger.info("Installing plugins $pluginIds on $id via SSH ($hostname)")

val output = ByteArrayOutputStream()
val result = ProcessExecutor()
.command(command)
.redirectInput(INSTALL_PLUGINS_SCRIPT.byteInputStream())
.redirectOutput(output)
.redirectError(output)
.exitValueAny()
.execute()

val outputText = output.toString(Charsets.UTF_8)
if (result.exitValue == 0) {
context.logger.info("Plugin installation succeeded for $id: $outputText")
} else {
context.logger.error("Plugin installation failed for $id (exit code ${result.exitValue}): $outputText")
}
}
} catch (e: Exception) {
context.logger.error(e, "Failed to run plugin installation script on $id")
} finally {
clearInstallingPluginsState()
}
}

override suspend fun onToolLaunchResult(
toolDetails: ToolDetails,
result: ToolLaunchResult
) {
context.logger.info("Tool Lifetime Launch result: $toolDetails, $result from workspace $id")
clearInstallingPluginsState()
}

override suspend fun onToolShutdown(toolDetails: ToolDetails) {
context.logger.info("Tool Lifetime Shutdown: $toolDetails from workspace $id")
clearInstallingPluginsState()
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,12 @@ interface ReadOnlyCoderSettings {
*/
val preferOAuth2IfAvailable: Boolean

/**
* A list of JetBrains plugin IDs to install on the remote IDE before it launches.
* Stored as a comma-separated string in the settings store.
*/
val pluginIds: List<String>

/**
* Where the specified deployment should put its data.
*/
Expand Down
10 changes: 10 additions & 0 deletions src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,12 @@ class CoderSettingsStore(
.toString()
override val preferOAuth2IfAvailable: Boolean
get() = store[PREFER_OAUTH2_IF_AVAILABLE]?.toBooleanStrictOrNull() ?: false
override val pluginIds: List<String>
get() = store[PLUGIN_IDS]
?.split(",")
?.map { it.trim() }
?.filter { it.isNotBlank() }
?: emptyList()
override val workspaceViewUrl: String?
get() = store[WORKSPACE_VIEW_URL]
override val workspaceCreateUrl: String?
Expand Down Expand Up @@ -264,6 +270,10 @@ class CoderSettingsStore(
store[PREFER_OAUTH2_IF_AVAILABLE] = preferAuthViaOAuth2.toString()
}

fun updatePluginIds(ids: List<String>) {
store[PLUGIN_IDS] = ids.joinToString(",")
}

private fun getDefaultGlobalDataDir(): Path {
return when (getOS()) {
OS.WINDOWS -> Paths.get(getWinAppData(), "coder-toolbox")
Expand Down
2 changes: 2 additions & 0 deletions src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt
Original file line number Diff line number Diff line change
Expand Up @@ -58,3 +58,5 @@ internal const val WORKSPACE_CREATE_URL = "workspaceCreateUrl"
internal const val SSH_AUTO_CONNECT_PREFIX = "ssh_auto_connect_"

internal const val PREFER_OAUTH2_IF_AVAILABLE = "preferOAuth2IfAvailable"

internal const val PLUGIN_IDS = "pluginIds"
23 changes: 23 additions & 0 deletions src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,12 @@ class CoderSettingsPage(
TextType.General
)

private val pluginIdsField = TextField(
context.i18n.ptrl("Plugin IDs (comma-separated)"),
settings.pluginIds.joinToString(", "),
TextType.General
)

private lateinit var visibilityUpdateJob: Job
override val fields: StateFlow<List<UiField>> = MutableStateFlow(
listOf(
Expand Down Expand Up @@ -176,6 +182,13 @@ class CoderSettingsPage(
networkInfoDirField,
sshExtraArgs,
)
),
SectionField(
"IDE Plugins",
false,
listOf(
pluginIdsField,
)
)
)
)
Expand Down Expand Up @@ -220,6 +233,12 @@ class CoderSettingsPage(
updateSshLogDir(sshLogDirField.contentState.value)
updateNetworkInfoDir(networkInfoDirField.contentState.value)
updateSshConfigOptions(sshExtraArgs.contentState.value)
updatePluginIds(
pluginIdsField.contentState.value
.split(",")
.map { it.trim() }
.filter { it.isNotBlank() }
)
}
}
)
Expand Down Expand Up @@ -298,6 +317,10 @@ class CoderSettingsPage(
settings.networkInfoDir
}

pluginIdsField.contentState.update {
settings.pluginIds.joinToString(", ")
}

visibilityUpdateJob = context.cs.launch(CoroutineName("Signature Verification Fallback Setting")) {
disableSignatureVerificationField.checkedState.collect { state ->
signatureFallbackStrategyField.visibility.update {
Expand Down
Loading