mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-19 10:02:04 +08:00
fix(jetbrains): stabilize provider settings actions
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": patch
|
||||
---
|
||||
|
||||
Fix JetBrains provider settings after OAuth/connect actions by waiting through transient backend reloads and allowing longer OAuth exchanges.
|
||||
+21
@@ -42,6 +42,7 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.TimeoutCancellationException
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
@@ -92,6 +93,7 @@ class KiloBackendAppService private constructor(
|
||||
private const val MAX_RETRIES = 3
|
||||
private const val RETRY_DELAY_MS = 1000L
|
||||
private const val APP_LOAD_TIMEOUT_MS = 30_000L
|
||||
private const val READY_TIMEOUT_MS = 5_000L
|
||||
|
||||
/** Test factory — no IntelliJ deps needed. */
|
||||
internal fun create(
|
||||
@@ -214,6 +216,25 @@ class KiloBackendAppService private constructor(
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun awaitReady(timeoutMs: Long = READY_TIMEOUT_MS) {
|
||||
when (_appState.value) {
|
||||
is KiloAppState.Ready -> return
|
||||
is KiloAppState.MigrationRequired -> throw IllegalStateException("Migration required")
|
||||
is KiloAppState.Loading,
|
||||
KiloAppState.Connecting -> {
|
||||
val state = withTimeoutOrNull(timeoutMs) {
|
||||
appState.first { it !is KiloAppState.Loading && it !is KiloAppState.Connecting }
|
||||
}
|
||||
when (state) {
|
||||
is KiloAppState.Ready -> return
|
||||
is KiloAppState.MigrationRequired -> throw IllegalStateException("Migration required")
|
||||
else -> throw IllegalStateException("Kilo backend is not ready")
|
||||
}
|
||||
}
|
||||
else -> throw IllegalStateException("Kilo backend is not ready")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun updateConfig(patch: ConfigPatchDto): KiloAppState {
|
||||
val http = connection.apiClient ?: throw IllegalStateException("Not connected")
|
||||
val current = _appState.value as? KiloAppState.Ready ?: throw IllegalStateException("Kilo backend is not ready")
|
||||
|
||||
+8
-7
@@ -39,12 +39,13 @@ internal class KiloBackendProviderSettingsManager(
|
||||
.callTimeout(15, TimeUnit.SECONDS)
|
||||
.build()
|
||||
private const val CALL_TIMEOUT_SECONDS = 15L
|
||||
private const val OAUTH_CALL_TIMEOUT_SECONDS = 60L
|
||||
}
|
||||
|
||||
suspend fun state(directory: String): ProviderSettingsDto {
|
||||
val start = System.currentTimeMillis()
|
||||
LOG.debug { "provider settings state: start dir=$directory" }
|
||||
app.requireReady()
|
||||
app.awaitReady()
|
||||
val errors = mutableListOf<LoadErrorDto>()
|
||||
val providers = load("providers", errors) {
|
||||
KiloCliDataParser.parseProviderSettingsProviders(get("/provider?directory=${enc(directory)}"))
|
||||
@@ -90,14 +91,14 @@ internal class KiloBackendProviderSettingsManager(
|
||||
|
||||
suspend fun authorize(input: ProviderOAuthAuthorizeDto): ProviderOAuthReadyDto {
|
||||
val body = KiloCliDataParser.buildProviderOAuthJson(input.method, input.inputs)
|
||||
val raw = post("/provider/${enc(input.providerId)}/oauth/authorize?directory=${enc(input.directory)}", body)
|
||||
val raw = post("/provider/${enc(input.providerId)}/oauth/authorize?directory=${enc(input.directory)}", body, OAUTH_CALL_TIMEOUT_SECONDS)
|
||||
val parsed = KiloCliDataParser.parseOAuthReady(raw)
|
||||
return ProviderOAuthReadyDto(parsed.first, parsed.second, parsed.third)
|
||||
}
|
||||
|
||||
suspend fun callback(input: ProviderOAuthCallbackDto): ProviderActionResultDto {
|
||||
val body = KiloCliDataParser.buildProviderOAuthJson(input.method, code = input.code)
|
||||
post("/provider/${enc(input.providerId)}/oauth/callback?directory=${enc(input.directory)}", body)
|
||||
post("/provider/${enc(input.providerId)}/oauth/callback?directory=${enc(input.directory)}", body, OAUTH_CALL_TIMEOUT_SECONDS)
|
||||
dispose()
|
||||
return ProviderActionResultDto(state(input.directory))
|
||||
}
|
||||
@@ -190,7 +191,7 @@ internal class KiloBackendProviderSettingsManager(
|
||||
}
|
||||
|
||||
private suspend fun get(path: String) = request(Request.Builder().url(url(path)).get().build())
|
||||
private suspend fun post(path: String, body: String) = request(Request.Builder().url(url(path)).post(body.toRequestBody(JSON)).build())
|
||||
private suspend fun post(path: String, body: String, timeoutSeconds: Long = CALL_TIMEOUT_SECONDS) = request(Request.Builder().url(url(path)).post(body.toRequestBody(JSON)).build(), timeoutSeconds)
|
||||
private suspend fun put(path: String, body: String) = request(Request.Builder().url(url(path)).put(body.toRequestBody(JSON)).build())
|
||||
private suspend fun patch(body: String) = request(Request.Builder().url(url("/global/config")).patch(body.toRequestBody(JSON)).build())
|
||||
|
||||
@@ -204,12 +205,12 @@ internal class KiloBackendProviderSettingsManager(
|
||||
.onFailure { LOG.debug { "Provider settings dispose skipped: ${it.message}" } }
|
||||
}
|
||||
|
||||
private suspend fun request(request: Request): String {
|
||||
private suspend fun request(request: Request, timeoutSeconds: Long = CALL_TIMEOUT_SECONDS): String {
|
||||
val start = System.currentTimeMillis()
|
||||
LOG.debug { "provider settings http: start ${request.method} ${request.url.encodedPath}" }
|
||||
val http = app.http?.newBuilder()
|
||||
?.callTimeout(CALL_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||
?.readTimeout(CALL_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||
?.callTimeout(timeoutSeconds, TimeUnit.SECONDS)
|
||||
?.readTimeout(timeoutSeconds, TimeUnit.SECONDS)
|
||||
?.build() ?: throw IllegalStateException("Kilo HTTP client is unavailable")
|
||||
return withContext(Dispatchers.IO) {
|
||||
try {
|
||||
|
||||
+70
-1
@@ -6,18 +6,25 @@ import ai.kilocode.backend.testing.FakeCliServer
|
||||
import ai.kilocode.backend.testing.MockCliServer
|
||||
import ai.kilocode.backend.testing.TestLog
|
||||
import ai.kilocode.rpc.dto.ProviderDisconnectDto
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import kotlin.system.measureTimeMillis
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertContains
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class KiloBackendProviderSettingsManagerTest {
|
||||
|
||||
@@ -75,12 +82,74 @@ class KiloBackendProviderSettingsManagerTest {
|
||||
assertEquals(1, mock.requestCount("/global/dispose"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `state waits through dispose triggered reload`() = runBlocking {
|
||||
mock.providers = """{
|
||||
"all":[{"id":"openai","name":"OpenAI","source":"custom","models":{}}],
|
||||
"default":{},
|
||||
"connected":["openai"],
|
||||
"failed":[]
|
||||
}""".trimIndent()
|
||||
val app = app()
|
||||
val manager = KiloBackendProviderSettingsManager(app)
|
||||
assertTrue(mock.awaitSseConnection())
|
||||
val gate = CountDownLatch(1)
|
||||
mock.responseGate = gate
|
||||
|
||||
try {
|
||||
mock.pushEvent("global.disposed", "{}")
|
||||
withTimeout(5_000) {
|
||||
app.appState.first { it is KiloAppState.Loading }
|
||||
}
|
||||
|
||||
val state = async { manager.state("/test") }
|
||||
delay(200)
|
||||
assertFalse(state.isCompleted)
|
||||
|
||||
gate.countDown()
|
||||
val result = withTimeout(10_000) { state.await() }
|
||||
assertEquals(listOf("openai"), result.connected)
|
||||
assertEquals(1, result.providers.size)
|
||||
} finally {
|
||||
mock.responseGate = null
|
||||
gate.countDown()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `awaitReady returns immediately when ready`() = runBlocking {
|
||||
val app = app()
|
||||
|
||||
val elapsed = measureTimeMillis {
|
||||
app.awaitReady()
|
||||
}
|
||||
|
||||
assertTrue(elapsed < 500, "awaitReady should not wait when already ready, elapsed=${elapsed}ms")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `awaitReady fails fast when disconnected`() = runBlocking {
|
||||
val app = KiloBackendAppService.create(scope, FakeCliServer(mock), TestLog())
|
||||
|
||||
val elapsed = measureTimeMillis {
|
||||
assertFailsWith<IllegalStateException> {
|
||||
app.awaitReady()
|
||||
}
|
||||
}
|
||||
|
||||
assertTrue(elapsed < 500, "awaitReady should fail fast when disconnected, elapsed=${elapsed}ms")
|
||||
}
|
||||
|
||||
private suspend fun manager(): KiloBackendProviderSettingsManager {
|
||||
return KiloBackendProviderSettingsManager(app())
|
||||
}
|
||||
|
||||
private suspend fun app(): KiloBackendAppService {
|
||||
val app = KiloBackendAppService.create(scope, FakeCliServer(mock), TestLog())
|
||||
app.connect()
|
||||
withTimeout(10_000) {
|
||||
app.appState.first { it is KiloAppState.Ready }
|
||||
}
|
||||
return KiloBackendProviderSettingsManager(app)
|
||||
return app
|
||||
}
|
||||
}
|
||||
|
||||
+15
-6
@@ -19,6 +19,7 @@ import ai.kilocode.rpc.dto.ProviderSettingsDto
|
||||
import com.intellij.openapi.components.Service
|
||||
import com.intellij.openapi.components.service
|
||||
import fleet.rpc.client.durable
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.withTimeout
|
||||
|
||||
@@ -32,14 +33,15 @@ class KiloProviderService internal constructor(
|
||||
companion object {
|
||||
private val LOG = KiloLog.create(KiloProviderService::class.java)
|
||||
private const val RPC_TIMEOUT_MS = 20_000L
|
||||
private const val OAUTH_RPC_TIMEOUT_MS = 90_000L
|
||||
}
|
||||
|
||||
private suspend fun <T> call(name: String, block: suspend KiloProviderRpcApi.() -> T): T {
|
||||
private suspend fun <T> call(name: String, timeoutMs: Long = RPC_TIMEOUT_MS, block: suspend KiloProviderRpcApi.() -> T): T {
|
||||
val start = System.currentTimeMillis()
|
||||
LOG.info("provider settings rpc $name: start")
|
||||
val api = rpc
|
||||
return try {
|
||||
val result = withTimeout(RPC_TIMEOUT_MS) {
|
||||
val result = withTimeout(timeoutMs) {
|
||||
if (api != null) block(api) else durable { block(KiloProviderRpcApi.getInstance()) }
|
||||
}
|
||||
LOG.info("provider settings rpc $name: completed durationMs=${System.currentTimeMillis() - start}")
|
||||
@@ -58,16 +60,23 @@ class KiloProviderService internal constructor(
|
||||
}
|
||||
|
||||
suspend fun connect(input: ProviderConnectDto): ProviderActionResultDto = action(input.directory) { connect(input) }
|
||||
suspend fun authorize(input: ProviderOAuthAuthorizeDto): ProviderOAuthReadyDto = call("authorize provider=${input.providerId}") { authorize(input) }
|
||||
suspend fun callback(input: ProviderOAuthCallbackDto): ProviderActionResultDto = action(input.directory) { callback(input) }
|
||||
suspend fun authorize(input: ProviderOAuthAuthorizeDto): ProviderOAuthReadyDto = call("authorize provider=${input.providerId}", OAUTH_RPC_TIMEOUT_MS) { authorize(input) }
|
||||
suspend fun callback(input: ProviderOAuthCallbackDto): ProviderActionResultDto = action(input.directory, OAUTH_RPC_TIMEOUT_MS) { callback(input) }
|
||||
suspend fun disconnect(input: ProviderDisconnectDto): ProviderActionResultDto = action(input.directory) { disconnect(input) }
|
||||
suspend fun enable(input: ProviderEnableDto): ProviderActionResultDto = action(input.directory) { enable(input) }
|
||||
suspend fun saveCustom(input: CustomProviderSaveDto): ProviderActionResultDto = action(input.directory) { saveCustom(input) }
|
||||
suspend fun fetchCustomModels(input: CustomModelFetchDto): CustomModelFetchResultDto = call("fetch custom models") { fetchCustomModels(input) }
|
||||
|
||||
private suspend fun action(directory: String, block: suspend KiloProviderRpcApi.() -> ProviderActionResultDto): ProviderActionResultDto {
|
||||
private suspend fun action(directory: String, timeoutMs: Long = RPC_TIMEOUT_MS, block: suspend KiloProviderRpcApi.() -> ProviderActionResultDto): ProviderActionResultDto {
|
||||
LOG.info("provider settings action: start dir=$directory")
|
||||
val result = call("action dir=$directory", block)
|
||||
val result = try {
|
||||
call("action dir=$directory", timeoutMs, block)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
LOG.warn("provider settings action failed for directory=$directory", e)
|
||||
return ProviderActionResultDto(state(directory), error = e.message)
|
||||
}
|
||||
service<KiloWorkspaceService>().reload(directory)
|
||||
service<KiloAppService>().refreshProfileAsync()
|
||||
LOG.info("provider settings action: completed dir=$directory")
|
||||
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
package ai.kilocode.client.settings.base
|
||||
|
||||
import ai.kilocode.client.ui.LayeredOverlayPanel
|
||||
import ai.kilocode.client.ui.UiStyle
|
||||
import java.awt.Rectangle
|
||||
|
||||
internal open class SettingsOverlayPanel : LayeredOverlayPanel() {
|
||||
val progress = SettingsProgressOverlay()
|
||||
|
||||
init {
|
||||
addOverlay(progress) { pane, child ->
|
||||
val size = child.preferredSize
|
||||
Rectangle(
|
||||
((pane.width - size.width) / 2).coerceAtLeast(0),
|
||||
UiStyle.Gap.pad(),
|
||||
size.width,
|
||||
size.height,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun showProgress(text: String) {
|
||||
progress.showProgress(text)
|
||||
syncOverlay()
|
||||
}
|
||||
|
||||
fun showError(text: String) {
|
||||
progress.showError(text)
|
||||
syncOverlay()
|
||||
}
|
||||
|
||||
fun clearProgress() {
|
||||
progress.clearProgress()
|
||||
syncOverlay()
|
||||
}
|
||||
|
||||
private fun syncOverlay() {
|
||||
overlay.revalidate()
|
||||
overlay.repaint()
|
||||
content.repaint()
|
||||
revalidate()
|
||||
repaint()
|
||||
}
|
||||
}
|
||||
+1
-29
@@ -1,6 +1,5 @@
|
||||
package ai.kilocode.client.settings.base
|
||||
|
||||
import ai.kilocode.client.ui.LayeredOverlayPanel
|
||||
import ai.kilocode.client.ui.UiStyle
|
||||
import ai.kilocode.client.ui.layout.Stack
|
||||
import ai.kilocode.client.ui.layout.StackAxis
|
||||
@@ -12,10 +11,9 @@ import javax.swing.JComponent
|
||||
import javax.swing.ScrollPaneConstants
|
||||
import javax.swing.Scrollable
|
||||
|
||||
internal open class SettingsPanel : LayeredOverlayPanel() {
|
||||
internal open class SettingsPanel : SettingsOverlayPanel() {
|
||||
val top = SettingsTop()
|
||||
val settings = Stack.vertical()
|
||||
val progress = SettingsProgressOverlay()
|
||||
|
||||
init {
|
||||
val body = SettingsBody()
|
||||
@@ -26,15 +24,6 @@ internal open class SettingsPanel : LayeredOverlayPanel() {
|
||||
border = null
|
||||
horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER
|
||||
}, BorderLayout.CENTER)
|
||||
addOverlay(progress) { pane, child ->
|
||||
val size = child.preferredSize
|
||||
Rectangle(
|
||||
((pane.width - size.width) / 2).coerceAtLeast(0),
|
||||
UiStyle.Gap.pad(),
|
||||
size.width,
|
||||
size.height,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun setContent(component: JComponent) {
|
||||
@@ -44,23 +33,6 @@ internal open class SettingsPanel : LayeredOverlayPanel() {
|
||||
repaint()
|
||||
}
|
||||
|
||||
fun showProgress(text: String) {
|
||||
progress.showProgress(text)
|
||||
overlay.revalidate()
|
||||
overlay.repaint()
|
||||
}
|
||||
|
||||
fun showError(text: String) {
|
||||
progress.showError(text)
|
||||
overlay.revalidate()
|
||||
overlay.repaint()
|
||||
}
|
||||
|
||||
fun clearProgress() {
|
||||
progress.clearProgress()
|
||||
overlay.revalidate()
|
||||
overlay.repaint()
|
||||
}
|
||||
}
|
||||
|
||||
private class SettingsBody : Stack(StackAxis.VERTICAL), Scrollable {
|
||||
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
package ai.kilocode.client.settings.providers
|
||||
|
||||
import ai.kilocode.rpc.dto.ProviderAuthMethodDto
|
||||
import ai.kilocode.rpc.dto.ProviderSettingsDto
|
||||
import ai.kilocode.rpc.dto.ProviderSettingsProviderDto
|
||||
|
||||
internal val POPULAR_PROVIDER_IDS = listOf("kilo", "anthropic", "deepseek", "openai", "google", "openrouter", "vercel")
|
||||
|
||||
internal fun isPopularProvider(id: String) = id in POPULAR_PROVIDER_IDS
|
||||
|
||||
internal fun popularProviderIndex(id: String): Int {
|
||||
val index = POPULAR_PROVIDER_IDS.indexOf(id)
|
||||
return if (index >= 0) index else Int.MAX_VALUE
|
||||
}
|
||||
|
||||
internal fun providerDescription(provider: ProviderSettingsProviderDto): String {
|
||||
val source = provider.source ?: "catalog"
|
||||
val models = provider.models.size
|
||||
return "$source · $models models"
|
||||
}
|
||||
|
||||
internal fun providerMethods(provider: ProviderSettingsProviderDto, state: ProviderSettingsDto): List<ProviderAuthMethodDto> {
|
||||
val methods = state.auth[provider.id]
|
||||
if (!methods.isNullOrEmpty()) return methods
|
||||
return listOf(ProviderAuthMethodDto("api", "API key"))
|
||||
}
|
||||
|
||||
internal fun configured(provider: ProviderSettingsProviderDto, state: ProviderSettingsDto, ids: Set<String>) =
|
||||
provider.id in ids || provider.key != null || provider.source == "config" || provider.id in state.config
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
package ai.kilocode.client.settings.providers
|
||||
|
||||
import ai.kilocode.client.plugin.KiloBundle
|
||||
import ai.kilocode.client.session.ui.PickerRow
|
||||
import ai.kilocode.client.ui.UiStyle
|
||||
import com.intellij.ui.CollectionListModel
|
||||
import com.intellij.ui.GroupHeaderSeparator
|
||||
import com.intellij.ui.SimpleColoredComponent
|
||||
import com.intellij.ui.SimpleTextAttributes
|
||||
import com.intellij.ui.components.JBLabel
|
||||
import com.intellij.util.ui.JBUI
|
||||
import com.intellij.util.ui.UIUtil
|
||||
import java.awt.BorderLayout
|
||||
import java.awt.FlowLayout
|
||||
import java.awt.Point
|
||||
import java.awt.Rectangle
|
||||
import javax.swing.JList
|
||||
import javax.swing.JPanel
|
||||
import javax.swing.ListCellRenderer
|
||||
import javax.swing.SwingConstants
|
||||
import javax.swing.UIManager
|
||||
|
||||
private const val ACTION_GAP = 8
|
||||
|
||||
internal class ProviderListRenderer(
|
||||
private val model: CollectionListModel<ProviderListRow>,
|
||||
) : JPanel(BorderLayout()), ListCellRenderer<ProviderListRow> {
|
||||
companion object {
|
||||
fun actionAt(list: JList<*>, bounds: Rectangle, point: Point, row: ProviderListRow): ProviderListAction? {
|
||||
val height = buttonHeight(list)
|
||||
val top = bounds.y + (bounds.height - height) / 2
|
||||
if (point.y !in top..(top + height)) return null
|
||||
var edge = bounds.x + bounds.width - UiStyle.Gap.pad()
|
||||
for (action in row.actions.asReversed()) {
|
||||
val width = buttonWidth(list, action)
|
||||
val left = edge - width
|
||||
if (point.x in left..edge) return action.takeIf(row::enabled)
|
||||
edge = left - JBUI.scale(ACTION_GAP)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
internal fun actionBounds(list: JList<*>, bounds: Rectangle, row: ProviderListRow): Map<ProviderListAction, Rectangle> {
|
||||
val height = buttonHeight(list)
|
||||
val top = bounds.y + (bounds.height - height) / 2
|
||||
var edge = bounds.x + bounds.width - UiStyle.Gap.pad()
|
||||
val out = linkedMapOf<ProviderListAction, Rectangle>()
|
||||
for (action in row.actions.asReversed()) {
|
||||
val width = buttonWidth(list, action)
|
||||
val left = edge - width
|
||||
out[action] = Rectangle(left, top, width, height)
|
||||
edge = left - JBUI.scale(ACTION_GAP)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private fun buttonWidth(list: JList<*>, action: ProviderListAction): Int {
|
||||
val text = text(action)
|
||||
val metrics = list.getFontMetrics(list.font)
|
||||
return metrics.stringWidth(text) + UiStyle.Gap.pad() * 2
|
||||
}
|
||||
|
||||
private fun buttonHeight(list: JList<*>): Int {
|
||||
val metrics = list.getFontMetrics(list.font)
|
||||
return metrics.height + UiStyle.Gap.sm() * 2
|
||||
}
|
||||
|
||||
internal fun text(action: ProviderListAction) = when (action) {
|
||||
ProviderListAction.CONNECT -> KiloBundle.message("settings.providers.connect")
|
||||
ProviderListAction.OAUTH -> KiloBundle.message("settings.providers.oauth")
|
||||
ProviderListAction.DISCONNECT -> KiloBundle.message("settings.providers.disconnect")
|
||||
ProviderListAction.ENABLE -> KiloBundle.message("settings.providers.enable")
|
||||
}
|
||||
}
|
||||
|
||||
private val sep = GroupHeaderSeparator(JBUI.CurrentTheme.Popup.separatorLabelInsets())
|
||||
private val top = JPanel(BorderLayout()).apply {
|
||||
border = JBUI.Borders.empty()
|
||||
add(sep, BorderLayout.NORTH)
|
||||
}
|
||||
private val title = SimpleColoredComponent()
|
||||
private val desc = JBLabel()
|
||||
private val text = JPanel(BorderLayout()).apply {
|
||||
add(title, BorderLayout.NORTH)
|
||||
add(desc, BorderLayout.SOUTH)
|
||||
}
|
||||
private val actions = JPanel(FlowLayout(FlowLayout.RIGHT, JBUI.scale(ACTION_GAP), 0))
|
||||
private val row = JPanel(BorderLayout()).apply {
|
||||
add(text, BorderLayout.CENTER)
|
||||
add(actions, BorderLayout.EAST)
|
||||
}
|
||||
private val wrap = PickerRow()
|
||||
|
||||
init {
|
||||
isOpaque = true
|
||||
top.isOpaque = true
|
||||
UiStyle.Components.transparent(row, title, text, desc, actions)
|
||||
row.border = JBUI.Borders.empty(
|
||||
UiStyle.Gap.md(),
|
||||
UiStyle.Gap.lg(),
|
||||
UiStyle.Gap.md(),
|
||||
UiStyle.Gap.pad(),
|
||||
)
|
||||
wrap.setContent(row)
|
||||
add(top, BorderLayout.NORTH)
|
||||
add(wrap, BorderLayout.CENTER)
|
||||
}
|
||||
|
||||
override fun getListCellRendererComponent(
|
||||
list: JList<out ProviderListRow>,
|
||||
value: ProviderListRow,
|
||||
index: Int,
|
||||
selected: Boolean,
|
||||
focused: Boolean,
|
||||
): JPanel {
|
||||
val focus = selected || list.hasFocus() || focused
|
||||
val fg = UIUtil.getListForeground(selected, focus)
|
||||
val weak = if (selected) fg else UiStyle.Colors.weak()
|
||||
val current = model.items.getOrNull(index)
|
||||
val section = if (current === value) providerListSectionTitle(model.items, index) else null
|
||||
|
||||
background = list.background
|
||||
top.background = list.background
|
||||
wrap.update(list, selected, focus)
|
||||
sep.caption = section
|
||||
sep.setHideLine(index == 0)
|
||||
top.isVisible = section != null
|
||||
|
||||
title.clear()
|
||||
title.append(value.provider.name, SimpleTextAttributes(SimpleTextAttributes.STYLE_BOLD, fg))
|
||||
desc.text = providerDescription(value.provider)
|
||||
desc.foreground = weak
|
||||
|
||||
actions.removeAll()
|
||||
for (action in value.actions) {
|
||||
actions.add(ActionLabel(action).apply {
|
||||
isEnabled = value.enabled(action)
|
||||
foreground = if (isEnabled) UIManager.getColor("Button.foreground") ?: UIUtil.getLabelForeground()
|
||||
else UIManager.getColor("Button.disabledText") ?: UIUtil.getContextHelpForeground()
|
||||
background = UIManager.getColor("Button.background")
|
||||
})
|
||||
}
|
||||
top.invalidate()
|
||||
return this
|
||||
}
|
||||
|
||||
internal fun actionTexts() = actions.components.filterIsInstance<JBLabel>().map { it.text }
|
||||
|
||||
private class ActionLabel(action: ProviderListAction) : JBLabel(text(action)) {
|
||||
init {
|
||||
horizontalAlignment = SwingConstants.CENTER
|
||||
border = JBUI.Borders.compound(
|
||||
JBUI.Borders.customLine(UIUtil.getBoundsColor()),
|
||||
JBUI.Borders.empty(UiStyle.Gap.sm(), UiStyle.Gap.pad()),
|
||||
)
|
||||
isOpaque = true
|
||||
}
|
||||
}
|
||||
}
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
package ai.kilocode.client.settings.providers
|
||||
|
||||
import ai.kilocode.client.plugin.KiloBundle
|
||||
import ai.kilocode.client.session.ui.model.ModelSearch
|
||||
import ai.kilocode.rpc.dto.ProviderSettingsDto
|
||||
import ai.kilocode.rpc.dto.ProviderSettingsProviderDto
|
||||
|
||||
internal enum class ProviderListAction {
|
||||
CONNECT,
|
||||
OAUTH,
|
||||
DISCONNECT,
|
||||
ENABLE,
|
||||
}
|
||||
|
||||
internal data class ProviderListRow(
|
||||
val provider: ProviderSettingsProviderDto,
|
||||
val section: String,
|
||||
val actions: List<ProviderListAction>,
|
||||
) {
|
||||
val key: String get() = provider.id
|
||||
|
||||
fun enabled(action: ProviderListAction) = action != ProviderListAction.DISCONNECT || provider.source != "env"
|
||||
}
|
||||
|
||||
internal fun providerListRows(state: ProviderSettingsDto, query: String): List<ProviderListRow> {
|
||||
val q = query.trim()
|
||||
val ids = state.connected.toSet()
|
||||
val disabled = state.disabled.toSet()
|
||||
val filtered = state.providers.filter { ModelSearch.matches(q, it.name) }
|
||||
val popular = filtered
|
||||
.filter { it.id != "kilo" }
|
||||
.filter { it.id !in disabled }
|
||||
.filter { !configured(it, state, ids) }
|
||||
.filter { isPopularProvider(it.id) }
|
||||
.sortedWith(compareBy<ProviderSettingsProviderDto> { popularProviderIndex(it.id) }.thenBy { it.name.lowercase() }.thenBy { it.id })
|
||||
val popularIds = popular.mapTo(mutableSetOf()) { it.id }
|
||||
val all = filtered
|
||||
.filter { it.id !in popularIds }
|
||||
.sortedWith(compareBy<ProviderSettingsProviderDto> { it.name.lowercase() }.thenBy { it.id })
|
||||
val rows = mutableListOf<ProviderListRow>()
|
||||
rows += popular.map { ProviderListRow(it, KiloBundle.message("settings.providers.popular"), providerActions(it, state, disabled)) }
|
||||
rows += all.map { ProviderListRow(it, KiloBundle.message("settings.providers.all"), providerActions(it, state, disabled)) }
|
||||
return rows
|
||||
}
|
||||
|
||||
internal fun providerListIndex(rows: List<ProviderListRow>, key: String?): Int {
|
||||
if (key == null) return if (rows.isEmpty()) -1 else 0
|
||||
return rows.indexOfFirst { it.key == key }
|
||||
}
|
||||
|
||||
internal fun providerListIndex(rows: List<ProviderListRow>, index: Int): Int {
|
||||
if (rows.isEmpty()) return -1
|
||||
return index.coerceIn(0, rows.lastIndex)
|
||||
}
|
||||
|
||||
internal fun providerListSectionTitle(rows: List<ProviderListRow>, index: Int): String? {
|
||||
val row = rows.getOrNull(index) ?: return null
|
||||
val prev = rows.getOrNull(index - 1)
|
||||
return if (prev?.section != row.section) row.section else null
|
||||
}
|
||||
|
||||
internal fun providerActions(
|
||||
provider: ProviderSettingsProviderDto,
|
||||
state: ProviderSettingsDto,
|
||||
disabled: Set<String> = state.disabled.toSet(),
|
||||
): List<ProviderListAction> {
|
||||
if (provider.id in disabled) return listOf(ProviderListAction.ENABLE)
|
||||
if (configured(provider, state, state.connected.toSet())) return listOf(ProviderListAction.DISCONNECT)
|
||||
val methods = providerMethods(provider, state)
|
||||
return buildList {
|
||||
if (methods.any { it.type == "oauth" }) add(ProviderListAction.OAUTH)
|
||||
if (methods.any { it.type == "api" }) add(ProviderListAction.CONNECT)
|
||||
}
|
||||
}
|
||||
+13
-4
@@ -6,6 +6,7 @@ import com.intellij.openapi.application.ModalityState
|
||||
import com.intellij.openapi.options.Configurable
|
||||
import com.intellij.openapi.options.SearchableConfigurable
|
||||
import com.intellij.openapi.project.ProjectManager
|
||||
import com.intellij.util.concurrency.annotations.RequiresEdt
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
@@ -19,7 +20,9 @@ class ProvidersConfigurable : SearchableConfigurable, Configurable.NoScroll {
|
||||
override fun getId(): String = ID
|
||||
override fun getDisplayName(): String = KiloBundle.message("settings.providers.displayName")
|
||||
|
||||
@RequiresEdt
|
||||
override fun createComponent(): JComponent {
|
||||
checkEdt()
|
||||
val cs = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
scope = cs
|
||||
val dir = ProjectManager.getInstance().openProjects.firstOrNull { !it.isDefault }?.basePath.orEmpty()
|
||||
@@ -30,27 +33,33 @@ class ProvidersConfigurable : SearchableConfigurable, Configurable.NoScroll {
|
||||
|
||||
override fun isModified(): Boolean = false
|
||||
override fun apply() = Unit
|
||||
override fun reset() = ui?.reload() ?: Unit
|
||||
@RequiresEdt
|
||||
override fun reset() {
|
||||
checkEdt()
|
||||
ui?.reload()
|
||||
}
|
||||
|
||||
override fun disposeUIResources() {
|
||||
val panel = ui
|
||||
val cs = scope
|
||||
ui = null
|
||||
scope = null
|
||||
cs?.cancel()
|
||||
val app = ApplicationManager.getApplication()
|
||||
if (panel != null && app.isDispatchThread) {
|
||||
panel.dispose()
|
||||
cs?.cancel()
|
||||
return
|
||||
}
|
||||
if (panel != null) {
|
||||
app.invokeLater({
|
||||
panel.dispose()
|
||||
cs?.cancel()
|
||||
}, ModalityState.any())
|
||||
return
|
||||
}
|
||||
cs?.cancel()
|
||||
}
|
||||
|
||||
private fun checkEdt() {
|
||||
check(ApplicationManager.getApplication().isDispatchThread) { "Provider configurable UI must run on EDT" }
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
+231
-111
@@ -2,8 +2,7 @@ package ai.kilocode.client.settings.providers
|
||||
|
||||
import ai.kilocode.client.app.KiloProviderService
|
||||
import ai.kilocode.client.plugin.KiloBundle
|
||||
import ai.kilocode.client.settings.base.BaseContentPanel
|
||||
import ai.kilocode.client.settings.base.SettingsRow
|
||||
import ai.kilocode.client.settings.base.SettingsOverlayPanel
|
||||
import ai.kilocode.client.ui.UiStyle
|
||||
import ai.kilocode.client.ui.layout.Stack
|
||||
import ai.kilocode.log.KiloLog
|
||||
@@ -19,138 +18,216 @@ import ai.kilocode.rpc.dto.ProviderOAuthCallbackDto
|
||||
import ai.kilocode.rpc.dto.ProviderSettingsDto
|
||||
import ai.kilocode.rpc.dto.ProviderSettingsProviderDto
|
||||
import com.intellij.ide.BrowserUtil
|
||||
import com.intellij.openapi.application.ApplicationManager
|
||||
import com.intellij.openapi.application.EDT
|
||||
import com.intellij.openapi.application.ModalityState
|
||||
import com.intellij.openapi.application.asContextElement
|
||||
import com.intellij.openapi.Disposable
|
||||
import com.intellij.openapi.components.service
|
||||
import com.intellij.openapi.ui.DialogWrapper
|
||||
import com.intellij.openapi.ui.Messages
|
||||
import com.intellij.openapi.ui.ValidationInfo
|
||||
import com.intellij.ui.CollectionListModel
|
||||
import com.intellij.ui.DocumentAdapter
|
||||
import com.intellij.ui.SearchTextField
|
||||
import com.intellij.ui.ScrollPaneFactory
|
||||
import com.intellij.ui.ScrollingUtil
|
||||
import com.intellij.ui.components.JBLabel
|
||||
import com.intellij.ui.components.JBList
|
||||
import com.intellij.ui.components.JBPasswordField
|
||||
import com.intellij.ui.components.JBScrollPane
|
||||
import com.intellij.ui.components.JBTextField
|
||||
import com.intellij.util.concurrency.annotations.RequiresEdt
|
||||
import com.intellij.util.ui.JBUI
|
||||
import com.intellij.util.ui.UIUtil
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.TimeoutCancellationException
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.awt.BorderLayout
|
||||
import java.awt.event.KeyEvent
|
||||
import java.awt.event.MouseAdapter
|
||||
import java.awt.event.MouseEvent
|
||||
import javax.swing.JButton
|
||||
import javax.swing.JComboBox
|
||||
import javax.swing.JComponent
|
||||
import javax.swing.DefaultListCellRenderer
|
||||
import javax.swing.JPanel
|
||||
import javax.swing.JList
|
||||
import javax.swing.KeyStroke
|
||||
import javax.swing.ListSelectionModel
|
||||
import javax.swing.ScrollPaneConstants
|
||||
import javax.swing.event.DocumentEvent
|
||||
|
||||
class ProvidersSettingsUi(
|
||||
private val edt = Dispatchers.EDT + ModalityState.any().asContextElement()
|
||||
|
||||
internal class ProvidersSettingsUi(
|
||||
private val cs: CoroutineScope,
|
||||
private val directory: String,
|
||||
) : JPanel(BorderLayout()), Disposable {
|
||||
) : SettingsOverlayPanel(), Disposable {
|
||||
companion object {
|
||||
val LOG = KiloLog.create(ProvidersSettingsUi::class.java)
|
||||
}
|
||||
|
||||
private val content = ProvidersContent(::connect, ::oauth, ::disconnect, ::enable, ::custom, ::reload)
|
||||
private val scroll = JBScrollPane(content)
|
||||
private val view = ProvidersContent(::connect, ::oauth, ::disconnect, ::enable, ::custom, ::reload)
|
||||
private var state = ProviderSettingsDto()
|
||||
private var job: Job? = null
|
||||
private var request = 0
|
||||
private var disposed = false
|
||||
|
||||
init {
|
||||
add(scroll, BorderLayout.CENTER)
|
||||
content.add(view, BorderLayout.CENTER)
|
||||
reload()
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
fun reload() {
|
||||
checkEdt()
|
||||
LOG.info("provider settings ui reload: start dir=$directory")
|
||||
syncLoading()
|
||||
launch("reload") {
|
||||
launch("reload") { id ->
|
||||
val next = service<KiloProviderService>().state(directory)
|
||||
LOG.info("provider settings ui reload: state providers=${next.providers.size} errors=${next.errors.size}")
|
||||
apply(next, null)
|
||||
apply(id, next, null)
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
private fun syncLoading() {
|
||||
content.loading()
|
||||
checkEdt()
|
||||
showProgress(KiloBundle.message("settings.providers.loading"))
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
private fun connect(provider: ProviderSettingsProviderDto) {
|
||||
checkEdt()
|
||||
val methods = state.auth[provider.id].orEmpty().filter { it.type == "api" }
|
||||
val dialog = ApiKeyDialog(provider.name, methods.firstOrNull())
|
||||
if (!dialog.showAndGet()) return
|
||||
content.loading()
|
||||
launch("connect provider=${provider.id}") {
|
||||
val result = service<KiloProviderService>().connect(ProviderConnectDto(directory, provider.id, dialog.key(), dialog.metadata()))
|
||||
apply(result.state, result.error)
|
||||
val key = dialog.key()
|
||||
val metadata = dialog.metadata()
|
||||
syncLoading()
|
||||
launch("connect provider=${provider.id}") { id ->
|
||||
val result = service<KiloProviderService>().connect(ProviderConnectDto(directory, provider.id, key, metadata))
|
||||
apply(id, result.state, result.error)
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
private fun oauth(provider: ProviderSettingsProviderDto) {
|
||||
checkEdt()
|
||||
val methods = state.auth[provider.id].orEmpty().filter { it.type == "oauth" }
|
||||
val method = state.auth[provider.id].orEmpty().indexOf(methods.firstOrNull()).coerceAtLeast(0).toString()
|
||||
content.loading()
|
||||
launch("authorize provider=${provider.id}") {
|
||||
syncLoading()
|
||||
launch("authorize provider=${provider.id}") { id ->
|
||||
val ready = service<KiloProviderService>().authorize(ProviderOAuthAuthorizeDto(directory, provider.id, method))
|
||||
val code = withContext(Dispatchers.Main) {
|
||||
val code = withContext(edt) {
|
||||
if (!active(id)) return@withContext null
|
||||
ready.url?.let(BrowserUtil::browse)
|
||||
if (ready.method == "code") Messages.showInputDialog(this@ProvidersSettingsUi, ready.instructions ?: "Enter OAuth code", provider.name, null) else null
|
||||
}
|
||||
val current = withContext(edt) { active(id) }
|
||||
if (!current) return@launch
|
||||
val result = service<KiloProviderService>().callback(ProviderOAuthCallbackDto(directory, provider.id, method, code))
|
||||
apply(result.state, result.error)
|
||||
apply(id, result.state, result.error)
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
private fun disconnect(provider: ProviderSettingsProviderDto) {
|
||||
content.loading()
|
||||
launch("disconnect provider=${provider.id}") {
|
||||
checkEdt()
|
||||
syncLoading()
|
||||
launch("disconnect provider=${provider.id}") { id ->
|
||||
val result = service<KiloProviderService>().disconnect(ProviderDisconnectDto(directory, provider.id))
|
||||
apply(result.state, result.error)
|
||||
apply(id, result.state, result.error)
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
private fun enable(provider: ProviderSettingsProviderDto) {
|
||||
content.loading()
|
||||
launch("enable provider=${provider.id}") {
|
||||
checkEdt()
|
||||
syncLoading()
|
||||
launch("enable provider=${provider.id}") { id ->
|
||||
val result = service<KiloProviderService>().enable(ProviderEnableDto(directory, provider.id))
|
||||
apply(result.state, result.error)
|
||||
apply(id, result.state, result.error)
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
private fun custom() {
|
||||
checkEdt()
|
||||
val dialog = CustomProviderDialog()
|
||||
if (!dialog.showAndGet()) return
|
||||
content.loading()
|
||||
launch("save custom provider") {
|
||||
val result = service<KiloProviderService>().saveCustom(dialog.input(directory))
|
||||
apply(result.state, result.error)
|
||||
val input = dialog.input(directory)
|
||||
syncLoading()
|
||||
launch("save custom provider") { id ->
|
||||
val result = service<KiloProviderService>().saveCustom(input)
|
||||
apply(id, result.state, result.error)
|
||||
}
|
||||
}
|
||||
|
||||
private fun launch(name: String, block: suspend () -> Unit) {
|
||||
cs.launch {
|
||||
@RequiresEdt
|
||||
private fun launch(name: String, block: suspend (Int) -> Unit) {
|
||||
checkEdt()
|
||||
val id = ++request
|
||||
job?.cancel()
|
||||
job = cs.launch {
|
||||
val start = System.currentTimeMillis()
|
||||
LOG.info("provider settings ui $name: coroutine start dir=$directory")
|
||||
try {
|
||||
block()
|
||||
block(id)
|
||||
LOG.info("provider settings ui $name: coroutine completed durationMs=${System.currentTimeMillis() - start}")
|
||||
} catch (e: TimeoutCancellationException) {
|
||||
LOG.warn("provider settings ui $name: coroutine timed out durationMs=${System.currentTimeMillis() - start}", e)
|
||||
withContext(edt) {
|
||||
if (!active(id)) return@withContext
|
||||
showError("${e::class.simpleName}: ${e.message}")
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
LOG.info("provider settings ui $name: coroutine cancelled durationMs=${System.currentTimeMillis() - start}")
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
LOG.warn("provider settings ui $name: coroutine failed durationMs=${System.currentTimeMillis() - start}", e)
|
||||
withContext(Dispatchers.Main) {
|
||||
content.error("${e::class.simpleName}: ${e.message}")
|
||||
withContext(edt) {
|
||||
if (!active(id)) return@withContext
|
||||
showError("${e::class.simpleName}: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun apply(next: ProviderSettingsDto, error: String?) {
|
||||
withContext(Dispatchers.Main) {
|
||||
private suspend fun apply(id: Int, next: ProviderSettingsDto, error: String?) {
|
||||
withContext(edt) {
|
||||
if (!active(id)) return@withContext
|
||||
LOG.info("provider settings ui apply: start providers=${next.providers.size} errors=${next.errors.size} message=${error != null}")
|
||||
state = next
|
||||
content.update(next, error)
|
||||
view.update(next)
|
||||
val text = error ?: next.errors.joinToString("; ") { it.detail ?: it.resource }.takeIf { it.isNotBlank() }
|
||||
if (text != null) showError(text) else clearProgress()
|
||||
LOG.info("provider settings ui apply: completed providers=${next.providers.size}")
|
||||
}
|
||||
}
|
||||
|
||||
override fun dispose() = Unit
|
||||
@RequiresEdt
|
||||
override fun dispose() {
|
||||
checkEdt()
|
||||
disposed = true
|
||||
request++
|
||||
job?.cancel()
|
||||
job = null
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
private fun active(id: Int): Boolean {
|
||||
checkEdt()
|
||||
return !disposed && id == request
|
||||
}
|
||||
|
||||
private fun checkEdt() {
|
||||
check(ApplicationManager.getApplication().isDispatchThread) { "Provider settings UI updates must run on EDT" }
|
||||
}
|
||||
}
|
||||
|
||||
internal class ProvidersContent(
|
||||
@@ -160,92 +237,132 @@ internal class ProvidersContent(
|
||||
private val enable: (ProviderSettingsProviderDto) -> Unit,
|
||||
private val custom: () -> Unit,
|
||||
private val reload: () -> Unit,
|
||||
) : BaseContentPanel() {
|
||||
) : JPanel(BorderLayout()) {
|
||||
private val top = Stack.horizontal(UiStyle.Gap.sm())
|
||||
private val status = JBLabel("").apply { foreground = UIUtil.getContextHelpForeground() }
|
||||
private val connected: ai.kilocode.client.settings.base.SettingsRows
|
||||
private val available: ai.kilocode.client.settings.base.SettingsRows
|
||||
private val disabled: ai.kilocode.client.settings.base.SettingsRows
|
||||
private val model = CollectionListModel<ProviderListRow>()
|
||||
private val list = JBList(model).apply {
|
||||
selectionMode = ListSelectionModel.SINGLE_SELECTION
|
||||
emptyText.text = KiloBundle.message("settings.providers.noMatches")
|
||||
}
|
||||
private val search = SearchTextField(false).apply {
|
||||
textEditor.emptyText.text = KiloBundle.message("settings.providers.search")
|
||||
}
|
||||
private var state = ProviderSettingsDto()
|
||||
|
||||
init {
|
||||
layout = BorderLayout()
|
||||
border = JBUI.Borders.empty(UiStyle.Gap.pad(), UiStyle.Gap.pad(), UiStyle.Gap.pad(), UiStyle.Gap.pad())
|
||||
top.next(JButton(KiloBundle.message("settings.providers.addCustom")).apply { addActionListener { custom() } })
|
||||
top.next(JButton(KiloBundle.message("settings.providers.refresh")).apply { addActionListener { reload() } })
|
||||
next(top)
|
||||
next(status)
|
||||
connected = section(KiloBundle.message("settings.providers.connected"))
|
||||
available = section(KiloBundle.message("settings.providers.available"))
|
||||
disabled = section(KiloBundle.message("settings.providers.disabled"))
|
||||
add(top, BorderLayout.NORTH)
|
||||
list.cellRenderer = ProviderListRenderer(model)
|
||||
list.registerKeyboardAction(
|
||||
{ primary() },
|
||||
KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, 0),
|
||||
JComponent.WHEN_FOCUSED,
|
||||
)
|
||||
search.textEditor.registerKeyboardAction(
|
||||
{ primary() },
|
||||
KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, 0),
|
||||
JComponent.WHEN_FOCUSED,
|
||||
)
|
||||
search.textEditor.registerKeyboardAction(
|
||||
{ move(-1) },
|
||||
KeyStroke.getKeyStroke(KeyEvent.VK_UP, 0),
|
||||
JComponent.WHEN_FOCUSED,
|
||||
)
|
||||
search.textEditor.registerKeyboardAction(
|
||||
{ move(1) },
|
||||
KeyStroke.getKeyStroke(KeyEvent.VK_DOWN, 0),
|
||||
JComponent.WHEN_FOCUSED,
|
||||
)
|
||||
search.textEditor.document.addDocumentListener(object : DocumentAdapter() {
|
||||
override fun textChanged(e: DocumentEvent) {
|
||||
sync()
|
||||
}
|
||||
})
|
||||
list.addMouseListener(object : MouseAdapter() {
|
||||
override fun mouseReleased(e: MouseEvent) {
|
||||
if (!UIUtil.isActionClick(e, MouseEvent.MOUSE_RELEASED, true)) return
|
||||
val idx = list.locationToIndex(e.point)
|
||||
val bounds = idx.takeIf { it >= 0 }?.let { list.getCellBounds(it, it) } ?: return
|
||||
if (!bounds.contains(e.point)) return
|
||||
val row = model.getElementAt(idx)
|
||||
val action = ProviderListRenderer.actionAt(list, bounds, e.point, row) ?: return
|
||||
activate(row, action)
|
||||
e.consume()
|
||||
}
|
||||
})
|
||||
ScrollingUtil.installActions(list)
|
||||
val body = JPanel(BorderLayout(0, UiStyle.Gap.sm()))
|
||||
body.add(search, BorderLayout.NORTH)
|
||||
body.add(ScrollPaneFactory.createScrollPane(list).apply {
|
||||
horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER
|
||||
}, BorderLayout.CENTER)
|
||||
add(body, BorderLayout.CENTER)
|
||||
}
|
||||
|
||||
fun loading() {
|
||||
status.text = KiloBundle.message("settings.providers.loading")
|
||||
}
|
||||
|
||||
fun error(message: String) {
|
||||
status.text = message
|
||||
}
|
||||
|
||||
fun update(state: ProviderSettingsDto, error: String? = null) {
|
||||
@RequiresEdt
|
||||
fun update(state: ProviderSettingsDto) {
|
||||
checkEdt()
|
||||
ProvidersSettingsUi.LOG.info("provider settings content update: start providers=${state.providers.size} connected=${state.connected.size} disabled=${state.disabled.size}")
|
||||
status.text = error ?: state.errors.joinToString("; ") { it.detail ?: it.resource }
|
||||
val ids = state.connected.toSet()
|
||||
val disabledIds = state.disabled.toSet()
|
||||
val connectedKeys = mutableSetOf<String>()
|
||||
val availableKeys = mutableSetOf<String>()
|
||||
val disabledKeys = mutableSetOf<String>()
|
||||
state.providers.sortedWith(compareBy<ProviderSettingsProviderDto> { it.name.lowercase() }.thenBy { it.id }).forEach { provider ->
|
||||
val target = when {
|
||||
provider.id in disabledIds -> disabled
|
||||
configured(provider, state, ids) -> connected
|
||||
else -> available
|
||||
}
|
||||
val keys = when (target) {
|
||||
connected -> connectedKeys
|
||||
available -> availableKeys
|
||||
else -> disabledKeys
|
||||
}
|
||||
val key = provider.id
|
||||
keys.add(key)
|
||||
val row = SettingsRow(provider.name, description(provider), buttons(provider, state, disabledIds))
|
||||
target.row(key, row)
|
||||
this.state = state
|
||||
sync()
|
||||
ProvidersSettingsUi.LOG.info("provider settings content update: completed rows=${model.size}")
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
private fun sync(prefer: String? = list.selectedValue?.key, at: Int? = null) {
|
||||
checkEdt()
|
||||
val rows = providerListRows(state, search.text)
|
||||
model.replaceAll(rows)
|
||||
val idx = at?.let { providerListIndex(rows, it) }?.takeIf { it >= 0 }
|
||||
?: providerListIndex(rows, prefer).takeIf { it >= 0 }
|
||||
?: rows.indices.firstOrNull()
|
||||
?: -1
|
||||
if (idx >= 0) choose(idx)
|
||||
else list.clearSelection()
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
private fun choose(idx: Int) {
|
||||
checkEdt()
|
||||
list.selectedIndex = idx
|
||||
ScrollingUtil.ensureIndexIsVisible(list, idx, 0)
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
private fun move(step: Int) {
|
||||
checkEdt()
|
||||
val size = model.size
|
||||
if (size <= 0) return
|
||||
val idx = ((list.selectedIndex.takeIf { it >= 0 } ?: 0) + step).coerceIn(0, size - 1)
|
||||
choose(idx)
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
private fun primary() {
|
||||
checkEdt()
|
||||
val row = list.selectedValue ?: return
|
||||
val action = row.actions.firstOrNull() ?: return
|
||||
activate(row, action)
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
private fun activate(row: ProviderListRow, action: ProviderListAction) {
|
||||
checkEdt()
|
||||
if (!row.enabled(action)) return
|
||||
when (action) {
|
||||
ProviderListAction.CONNECT -> connect(row.provider)
|
||||
ProviderListAction.OAUTH -> oauth(row.provider)
|
||||
ProviderListAction.DISCONNECT -> disconnect(row.provider)
|
||||
ProviderListAction.ENABLE -> enable(row.provider)
|
||||
}
|
||||
connected.retain(connectedKeys)
|
||||
available.retain(availableKeys)
|
||||
disabled.retain(disabledKeys)
|
||||
ProvidersSettingsUi.LOG.info("provider settings content update: completed connected=${connectedKeys.size} available=${availableKeys.size} disabled=${disabledKeys.size}")
|
||||
}
|
||||
|
||||
private fun description(provider: ProviderSettingsProviderDto): String {
|
||||
val source = provider.source ?: "catalog"
|
||||
val models = provider.models.size
|
||||
return "$source · $models models"
|
||||
private fun checkEdt() {
|
||||
check(ApplicationManager.getApplication().isDispatchThread) { "Provider settings content updates must run on EDT" }
|
||||
}
|
||||
|
||||
private fun buttons(provider: ProviderSettingsProviderDto, state: ProviderSettingsDto, disabled: Set<String>): JComponent {
|
||||
val row = Stack.horizontal(UiStyle.Gap.sm())
|
||||
if (provider.id in disabled) {
|
||||
row.next(JButton(KiloBundle.message("settings.providers.enable")).apply { addActionListener { enable(provider) } })
|
||||
return row
|
||||
}
|
||||
if (configured(provider, state, state.connected.toSet())) {
|
||||
row.next(JButton(KiloBundle.message("settings.providers.disconnect")).apply { isEnabled = provider.source != "env"; addActionListener { disconnect(provider) } })
|
||||
return row
|
||||
}
|
||||
val methods = methods(provider, state)
|
||||
if (methods.any { it.type == "api" }) row.next(JButton(KiloBundle.message("settings.providers.connect")).apply { addActionListener { connect(provider) } })
|
||||
if (methods.any { it.type == "oauth" }) row.next(JButton(KiloBundle.message("settings.providers.oauth")).apply { addActionListener { oauth(provider) } })
|
||||
return row
|
||||
}
|
||||
|
||||
private fun methods(provider: ProviderSettingsProviderDto, state: ProviderSettingsDto): List<ProviderAuthMethodDto> {
|
||||
val methods = state.auth[provider.id]
|
||||
if (!methods.isNullOrEmpty()) return methods
|
||||
return listOf(ProviderAuthMethodDto("api", "API key"))
|
||||
}
|
||||
|
||||
private fun configured(provider: ProviderSettingsProviderDto, state: ProviderSettingsDto, ids: Set<String>) =
|
||||
provider.id in ids || provider.key != null || provider.source == "config" || provider.id in state.config
|
||||
}
|
||||
|
||||
private class ApiKeyDialog(title: String, method: ProviderAuthMethodDto?) : DialogWrapper(true) {
|
||||
@@ -260,8 +377,10 @@ private class ApiKeyDialog(title: String, method: ProviderAuthMethodDto?) : Dial
|
||||
initValidation()
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
fun key(): String = String(key.password)
|
||||
|
||||
@RequiresEdt
|
||||
fun metadata(): Map<String, String> = fields.mapValues { (_, field) ->
|
||||
when (field) {
|
||||
is JComboBox<*> -> (field.selectedItem as? ProviderAuthOptionDto)?.value ?: field.selectedItem?.toString().orEmpty()
|
||||
@@ -312,6 +431,7 @@ private class CustomProviderDialog : DialogWrapper(true) {
|
||||
initValidation()
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
fun input(directory: String) = CustomProviderSaveDto(
|
||||
directory = directory,
|
||||
id = id.text.trim(),
|
||||
|
||||
@@ -207,6 +207,10 @@ settings.providers.loading=Loading providers...
|
||||
settings.providers.connected=Connected providers
|
||||
settings.providers.available=Available providers
|
||||
settings.providers.disabled=Disabled providers
|
||||
settings.providers.popular=Popular providers
|
||||
settings.providers.all=All providers
|
||||
settings.providers.search=Filter providers
|
||||
settings.providers.noMatches=No matching providers
|
||||
settings.providers.addCustom=Add custom provider
|
||||
settings.providers.refresh=Refresh
|
||||
settings.providers.connect=Connect
|
||||
|
||||
+386
-38
@@ -1,78 +1,397 @@
|
||||
package ai.kilocode.client.settings.providers
|
||||
|
||||
import ai.kilocode.client.app.KiloProviderService
|
||||
import ai.kilocode.client.testing.FakeProviderRpcApi
|
||||
import ai.kilocode.rpc.dto.CustomProviderConfigDto
|
||||
import ai.kilocode.rpc.dto.ModelDto
|
||||
import ai.kilocode.rpc.dto.ProviderAuthMethodDto
|
||||
import ai.kilocode.rpc.dto.ProviderDisconnectDto
|
||||
import ai.kilocode.rpc.dto.ProviderSettingsDto
|
||||
import ai.kilocode.rpc.dto.ProviderSettingsProviderDto
|
||||
import com.intellij.openapi.application.ApplicationManager
|
||||
import com.intellij.testFramework.replaceService
|
||||
import com.intellij.testFramework.fixtures.BasePlatformTestCase
|
||||
import com.intellij.ui.SearchTextField
|
||||
import com.intellij.ui.components.JBList
|
||||
import com.intellij.ui.components.JBLabel
|
||||
import com.intellij.util.ui.UIUtil
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.awt.BorderLayout
|
||||
import java.awt.Container
|
||||
import javax.swing.AbstractButton
|
||||
import java.awt.Point
|
||||
import java.awt.Rectangle
|
||||
import java.awt.image.BufferedImage
|
||||
import javax.swing.JButton
|
||||
import javax.swing.JComponent
|
||||
import javax.swing.UIManager
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
class ProvidersSettingsUiTest : BasePlatformTestCase() {
|
||||
private var scope: CoroutineScope? = null
|
||||
private var ui: ProvidersSettingsUi? = null
|
||||
|
||||
override fun tearDown() {
|
||||
try {
|
||||
val panel = ui
|
||||
if (panel != null) edt { panel.dispose() }
|
||||
ui = null
|
||||
scope?.cancel()
|
||||
scope = null
|
||||
} finally {
|
||||
super.tearDown()
|
||||
}
|
||||
}
|
||||
|
||||
fun `test catalog provider without auth methods is connectable`() {
|
||||
val content = content()
|
||||
|
||||
content.update(
|
||||
ProviderSettingsDto(
|
||||
providers = listOf(provider("models-dev-provider", "Models Dev Provider", source = "custom")),
|
||||
),
|
||||
)
|
||||
edt {
|
||||
content.update(
|
||||
ProviderSettingsDto(
|
||||
providers = listOf(provider("models-dev-provider", "Models Dev Provider", source = "custom")),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
val labels = buttons(content)
|
||||
assertTrue(labels.contains("Connect"))
|
||||
assertFalse(labels.contains("Disconnect"))
|
||||
edt {
|
||||
assertEquals(listOf(ProviderListAction.CONNECT), rows(content).single().actions)
|
||||
assertFalse(rows(content).single().actions.contains(ProviderListAction.DISCONNECT))
|
||||
}
|
||||
}
|
||||
|
||||
fun `test catalog custom provider is connectable not disconnectable`() {
|
||||
fun `test provider with api and oauth methods exposes both actions`() {
|
||||
val content = content()
|
||||
|
||||
content.update(
|
||||
ProviderSettingsDto(
|
||||
providers = listOf(provider("cloudflare-ai-gateway", "Cloudflare AI Gateway", source = "custom")),
|
||||
auth = mapOf(
|
||||
"cloudflare-ai-gateway" to listOf(
|
||||
ProviderAuthMethodDto("api", "API key"),
|
||||
ProviderAuthMethodDto("oauth", "OAuth"),
|
||||
edt {
|
||||
content.update(
|
||||
ProviderSettingsDto(
|
||||
providers = listOf(provider("cloudflare-ai-gateway", "Cloudflare AI Gateway", source = "custom")),
|
||||
auth = mapOf(
|
||||
"cloudflare-ai-gateway" to listOf(
|
||||
ProviderAuthMethodDto("api", "API key"),
|
||||
ProviderAuthMethodDto("oauth", "OAuth"),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val labels = buttons(content)
|
||||
assertTrue(labels.contains("Connect"))
|
||||
assertTrue(labels.contains("OAuth"))
|
||||
assertFalse(labels.contains("Disconnect"))
|
||||
edt { assertEquals(listOf(ProviderListAction.OAUTH, ProviderListAction.CONNECT), rows(content).single().actions) }
|
||||
}
|
||||
|
||||
fun `test configured custom provider is disconnectable`() {
|
||||
fun `test content uses border layout with toolbar north and list center`() {
|
||||
val content = content()
|
||||
edt {
|
||||
val layout = content.layout as BorderLayout
|
||||
val north = layout.getLayoutComponent(BorderLayout.NORTH) as Container
|
||||
val center = layout.getLayoutComponent(BorderLayout.CENTER) as Container
|
||||
|
||||
assertEquals(listOf("Add custom provider", "Refresh"), components(north).filterIsInstance<JButton>().map { it.text })
|
||||
assertEquals(1, components(center).filterIsInstance<SearchTextField>().size)
|
||||
assertEquals(1, components(center).filterIsInstance<JBList<ProviderListRow>>().size)
|
||||
}
|
||||
}
|
||||
|
||||
fun `test configured custom provider exposes only disconnect`() {
|
||||
val content = content()
|
||||
|
||||
content.update(
|
||||
ProviderSettingsDto(
|
||||
providers = listOf(provider("local-openai", "Local OpenAI", source = "custom")),
|
||||
config = mapOf("local-openai" to CustomProviderConfigDto("local-openai", npm = "@ai-sdk/openai-compatible")),
|
||||
auth = mapOf("local-openai" to listOf(ProviderAuthMethodDto("api", "API key"))),
|
||||
),
|
||||
)
|
||||
edt {
|
||||
content.update(
|
||||
ProviderSettingsDto(
|
||||
providers = listOf(provider("local-openai", "Local OpenAI", source = "custom")),
|
||||
config = mapOf("local-openai" to CustomProviderConfigDto("local-openai", npm = "@ai-sdk/openai-compatible")),
|
||||
auth = mapOf("local-openai" to listOf(ProviderAuthMethodDto("api", "API key"))),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
assertEquals(listOf("Disconnect"), buttons(content).filter { it in setOf("Connect", "OAuth", "Disconnect") })
|
||||
edt { assertEquals(listOf(ProviderListAction.DISCONNECT), rows(content).single().actions) }
|
||||
}
|
||||
|
||||
private fun content() = ProvidersContent({}, {}, {}, {}, {}, {})
|
||||
fun `test popular rows use vscode order and exclude kilo`() {
|
||||
val rows = providerListRows(
|
||||
ProviderSettingsDto(
|
||||
providers = listOf(
|
||||
provider("openrouter", "OpenRouter"),
|
||||
provider("kilo", "Kilo"),
|
||||
provider("google", "Google"),
|
||||
provider("anthropic", "Anthropic"),
|
||||
provider("vercel", "Vercel"),
|
||||
provider("openai", "OpenAI"),
|
||||
provider("deepseek", "DeepSeek"),
|
||||
),
|
||||
),
|
||||
"",
|
||||
)
|
||||
|
||||
private fun provider(id: String, name: String, source: String) = ProviderSettingsProviderDto(
|
||||
assertEquals(listOf("anthropic", "deepseek", "openai", "google", "openrouter", "vercel"), rows.take(6).map { it.key })
|
||||
assertEquals("Popular providers", providerListSectionTitle(rows, 0))
|
||||
assertEquals("All providers", providerListSectionTitle(rows, 6))
|
||||
assertEquals("kilo", rows[6].key)
|
||||
}
|
||||
|
||||
fun `test connected popular provider is not duplicated in popular section`() {
|
||||
val rows = providerListRows(
|
||||
ProviderSettingsDto(
|
||||
providers = listOf(provider("anthropic", "Anthropic"), provider("openai", "OpenAI")),
|
||||
connected = listOf("anthropic"),
|
||||
),
|
||||
"",
|
||||
)
|
||||
|
||||
assertEquals(listOf("openai", "anthropic"), rows.map { it.key })
|
||||
assertEquals("Popular providers", providerListSectionTitle(rows, 0))
|
||||
assertEquals("All providers", providerListSectionTitle(rows, 1))
|
||||
assertEquals(listOf(ProviderListAction.DISCONNECT), rows[1].actions)
|
||||
}
|
||||
|
||||
fun `test disabled popular provider appears in all providers with enable`() {
|
||||
val rows = providerListRows(
|
||||
ProviderSettingsDto(
|
||||
providers = listOf(provider("anthropic", "Anthropic"), provider("openai", "OpenAI")),
|
||||
disabled = listOf("anthropic"),
|
||||
),
|
||||
"",
|
||||
)
|
||||
|
||||
assertEquals(listOf("openai", "anthropic"), rows.map { it.key })
|
||||
assertEquals("All providers", providerListSectionTitle(rows, 1))
|
||||
assertEquals(listOf(ProviderListAction.ENABLE), rows[1].actions)
|
||||
}
|
||||
|
||||
fun `test non popular providers appear in all providers alphabetically`() {
|
||||
val rows = providerListRows(
|
||||
ProviderSettingsDto(
|
||||
providers = listOf(
|
||||
provider("zeta", "Zeta"),
|
||||
provider("alpha", "Alpha"),
|
||||
provider("openai", "OpenAI"),
|
||||
),
|
||||
),
|
||||
"",
|
||||
)
|
||||
|
||||
assertEquals(listOf("openai", "alpha", "zeta"), rows.map { it.key })
|
||||
assertEquals("All providers", providerListSectionTitle(rows, 1))
|
||||
}
|
||||
|
||||
fun `test filtering by provider name updates rows and sections`() {
|
||||
val content = content()
|
||||
edt {
|
||||
content.update(
|
||||
ProviderSettingsDto(
|
||||
providers = listOf(
|
||||
provider("openai", "OpenAI"),
|
||||
provider("anthropic", "Anthropic"),
|
||||
provider("alpha", "Alpha Labs"),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
search(content).text = "open"
|
||||
|
||||
val rows = rows(content)
|
||||
assertEquals(listOf("openai"), rows.map { it.key })
|
||||
assertEquals("Popular providers", providerListSectionTitle(rows, 0))
|
||||
}
|
||||
}
|
||||
|
||||
fun `test filtering does not match provider id`() {
|
||||
val rows = providerListRows(
|
||||
ProviderSettingsDto(
|
||||
providers = listOf(provider("openai-compatible", "Local")),
|
||||
),
|
||||
"openai",
|
||||
)
|
||||
|
||||
assertTrue(rows.isEmpty())
|
||||
}
|
||||
|
||||
fun `test renderer hit test maps actions`() {
|
||||
edt {
|
||||
val row = ProviderListRow(provider("cloudflare", "Cloudflare"), "All providers", listOf(ProviderListAction.OAUTH, ProviderListAction.CONNECT))
|
||||
val list = JBList(listOf(row))
|
||||
val bounds = Rectangle(0, 0, 320, 48)
|
||||
val areas = ProviderListRenderer.actionBounds(list, bounds, row)
|
||||
|
||||
assertEquals(ProviderListAction.CONNECT, ProviderListRenderer.actionAt(list, bounds, center(areas.getValue(ProviderListAction.CONNECT)), row))
|
||||
assertEquals(ProviderListAction.OAUTH, ProviderListRenderer.actionAt(list, bounds, center(areas.getValue(ProviderListAction.OAUTH)), row))
|
||||
assertNull(ProviderListRenderer.actionAt(list, bounds, Point(4, 4), row))
|
||||
}
|
||||
}
|
||||
|
||||
fun `test renderer ignores disabled env disconnect action`() {
|
||||
edt {
|
||||
val row = ProviderListRow(provider("env", "Env", source = "env"), "All providers", listOf(ProviderListAction.DISCONNECT))
|
||||
val list = JBList(listOf(row))
|
||||
val bounds = Rectangle(0, 0, 320, 48)
|
||||
val area = ProviderListRenderer.actionBounds(list, bounds, row).getValue(ProviderListAction.DISCONNECT)
|
||||
|
||||
assertNull(ProviderListRenderer.actionAt(list, bounds, center(area), row))
|
||||
}
|
||||
}
|
||||
|
||||
fun `test renderer exposes action labels`() {
|
||||
edt {
|
||||
val row = ProviderListRow(provider("cloudflare", "Cloudflare"), "All providers", listOf(ProviderListAction.OAUTH, ProviderListAction.CONNECT))
|
||||
val list = JBList(listOf(row))
|
||||
val renderer = ProviderListRenderer(com.intellij.ui.CollectionListModel(listOf(row)))
|
||||
|
||||
renderer.getListCellRendererComponent(list, row, 0, false, false)
|
||||
|
||||
assertEquals(listOf("OAuth", "Connect"), renderer.actionTexts())
|
||||
}
|
||||
}
|
||||
|
||||
fun `test renderer uses standard button foreground for actions`() {
|
||||
edt {
|
||||
val row = ProviderListRow(provider("cloudflare", "Cloudflare"), "All providers", listOf(ProviderListAction.OAUTH, ProviderListAction.CONNECT))
|
||||
val list = JBList(listOf(row))
|
||||
val renderer = ProviderListRenderer(com.intellij.ui.CollectionListModel(listOf(row)))
|
||||
|
||||
renderer.getListCellRendererComponent(list, row, 0, false, false)
|
||||
|
||||
val fg = UIManager.getColor("Button.foreground") ?: UIUtil.getLabelForeground()
|
||||
val labels = components(renderer).filterIsInstance<JBLabel>().filter { it.text in listOf("OAuth", "Connect") }
|
||||
assertEquals(listOf(fg, fg), labels.map { it.foreground })
|
||||
}
|
||||
}
|
||||
|
||||
fun `test renderer action labels paint with non button border`() {
|
||||
edt {
|
||||
val row = ProviderListRow(provider("cloudflare", "Cloudflare"), "All providers", listOf(ProviderListAction.CONNECT))
|
||||
val list = JBList(listOf(row))
|
||||
val renderer = ProviderListRenderer(com.intellij.ui.CollectionListModel(listOf(row)))
|
||||
|
||||
renderer.getListCellRendererComponent(list, row, 0, false, false)
|
||||
renderer.setSize(320, 64)
|
||||
renderer.doLayout()
|
||||
|
||||
val image = BufferedImage(320, 64, BufferedImage.TYPE_INT_ARGB)
|
||||
val g = image.createGraphics()
|
||||
try {
|
||||
renderer.paint(g)
|
||||
} finally {
|
||||
g.dispose()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun `test provider reload clears loading overlay after state loads`() {
|
||||
val rpc = installProvider(providerState(provider("openai", "OpenAI")))
|
||||
val panel = edt { createUi() }
|
||||
|
||||
flushUntil { rpc.stateCalls.isNotEmpty() && edt { rows(panel).map { it.key } == listOf("openai") && !text(panel).contains("Loading providers") } }
|
||||
|
||||
edt {
|
||||
assertEquals(listOf("openai"), rows(panel).map { it.key })
|
||||
assertFalse(text(panel).contains("Loading providers"))
|
||||
}
|
||||
}
|
||||
|
||||
fun `test provider action failure returns error state`() = runBlocking {
|
||||
val cs = CoroutineScope(SupervisorJob())
|
||||
scope = cs
|
||||
val rpc = FakeProviderRpcApi()
|
||||
rpc.state = providerState(provider("openai", "OpenAI"))
|
||||
rpc.disconnectError = IllegalStateException("Kilo backend is not ready")
|
||||
val service = KiloProviderService(cs, rpc)
|
||||
|
||||
val result = withContext(kotlinx.coroutines.Dispatchers.Default) {
|
||||
service.disconnect(ProviderDisconnectDto("/test", "openai"))
|
||||
}
|
||||
|
||||
assertEquals("Kilo backend is not ready", result.error)
|
||||
assertEquals(listOf("openai"), result.state.providers.map { it.id })
|
||||
assertEquals(listOf("/test"), rpc.stateCalls)
|
||||
}
|
||||
|
||||
fun `test stale reload result is ignored after newer reload`() {
|
||||
val first = CompletableDeferred<ProviderSettingsDto>()
|
||||
val second = CompletableDeferred<ProviderSettingsDto>()
|
||||
val rpc = installProvider(ProviderSettingsDto())
|
||||
rpc.states.add(first)
|
||||
rpc.states.add(second)
|
||||
val panel = edt { createUi() }
|
||||
|
||||
flushUntil { rpc.stateCalls.size == 1 }
|
||||
edt { panel.reload() }
|
||||
flushUntil { rpc.stateCalls.size == 2 }
|
||||
second.complete(providerState(provider("new", "New")))
|
||||
flushUntil { edt { rows(panel).map { it.key } == listOf("new") } }
|
||||
first.complete(providerState(provider("old", "Old")))
|
||||
flushUntil { first.isCompleted }
|
||||
|
||||
edt { assertEquals(listOf("new"), rows(panel).map { it.key }) }
|
||||
}
|
||||
|
||||
fun `test dispose ignores pending reload completion`() {
|
||||
val state = CompletableDeferred<ProviderSettingsDto>()
|
||||
val rpc = installProvider(ProviderSettingsDto())
|
||||
rpc.states.add(state)
|
||||
val panel = edt { createUi() }
|
||||
|
||||
flushUntil { rpc.stateCalls.size == 1 }
|
||||
edt {
|
||||
panel.dispose()
|
||||
ui = null
|
||||
}
|
||||
state.complete(providerState(provider("openai", "OpenAI")))
|
||||
flushUntil { state.isCompleted }
|
||||
|
||||
edt { assertTrue(rows(panel).isEmpty()) }
|
||||
}
|
||||
|
||||
private fun content() = edt { ProvidersContent({}, {}, {}, {}, {}, {}) }
|
||||
|
||||
private fun createUi(): ProvidersSettingsUi {
|
||||
val cs = CoroutineScope(SupervisorJob())
|
||||
scope = cs
|
||||
val panel = ProvidersSettingsUi(cs, "/test")
|
||||
ui = panel
|
||||
return panel
|
||||
}
|
||||
|
||||
private fun installProvider(state: ProviderSettingsDto): FakeProviderRpcApi {
|
||||
val cs = CoroutineScope(SupervisorJob())
|
||||
scope = cs
|
||||
val rpc = FakeProviderRpcApi()
|
||||
rpc.state = state
|
||||
ApplicationManager.getApplication().replaceService(
|
||||
KiloProviderService::class.java,
|
||||
KiloProviderService(cs, rpc),
|
||||
testRootDisposable,
|
||||
)
|
||||
return rpc
|
||||
}
|
||||
|
||||
private fun providerState(vararg providers: ProviderSettingsProviderDto) = ProviderSettingsDto(providers = providers.toList())
|
||||
|
||||
private fun provider(id: String, name: String, source: String? = null) = ProviderSettingsProviderDto(
|
||||
id = id,
|
||||
name = name,
|
||||
source = source,
|
||||
models = mapOf("model" to ModelDto("model", "Model")),
|
||||
)
|
||||
|
||||
private fun buttons(component: JComponent): List<String> = components(component)
|
||||
.filterIsInstance<AbstractButton>()
|
||||
.map { it.text }
|
||||
private fun rows(component: JComponent): List<ProviderListRow> {
|
||||
val model = list(component).model
|
||||
return (0 until model.size).map { model.getElementAt(it) }
|
||||
}
|
||||
|
||||
private fun components(component: JComponent): List<java.awt.Component> {
|
||||
private fun list(component: JComponent) = components(component).filterIsInstance<JBList<ProviderListRow>>().single()
|
||||
|
||||
private fun search(component: JComponent) = components(component).filterIsInstance<SearchTextField>().single()
|
||||
|
||||
private fun center(rect: Rectangle) = Point(rect.x + rect.width / 2, rect.y + rect.height / 2)
|
||||
|
||||
private fun components(component: java.awt.Component): List<java.awt.Component> {
|
||||
val out = mutableListOf<java.awt.Component>()
|
||||
fun visit(c: java.awt.Component) {
|
||||
out += c
|
||||
@@ -81,4 +400,33 @@ class ProvidersSettingsUiTest : BasePlatformTestCase() {
|
||||
visit(component)
|
||||
return out
|
||||
}
|
||||
|
||||
private fun text(root: Container): String {
|
||||
val out = mutableListOf<String>()
|
||||
for (comp in components(root)) {
|
||||
if (!comp.isVisible) continue
|
||||
when (comp) {
|
||||
is JButton -> comp.text?.let { out.add(it) }
|
||||
is JBLabel -> comp.text?.let { out.add(it) }
|
||||
}
|
||||
}
|
||||
return out.joinToString("\n")
|
||||
}
|
||||
|
||||
private fun <T> edt(block: () -> T): T {
|
||||
var result: T? = null
|
||||
ApplicationManager.getApplication().invokeAndWait { result = block() }
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
return result as T
|
||||
}
|
||||
|
||||
private fun flushUntil(done: () -> Boolean) = runBlocking {
|
||||
repeat(20) {
|
||||
delay(100)
|
||||
edt { UIUtil.dispatchAllInvocationEvents() }
|
||||
if (done()) return@runBlocking
|
||||
}
|
||||
edt { UIUtil.dispatchAllInvocationEvents() }
|
||||
assertTrue(done())
|
||||
}
|
||||
}
|
||||
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
package ai.kilocode.client.testing
|
||||
|
||||
import ai.kilocode.rpc.KiloProviderRpcApi
|
||||
import ai.kilocode.rpc.dto.CustomModelFetchDto
|
||||
import ai.kilocode.rpc.dto.CustomModelFetchResultDto
|
||||
import ai.kilocode.rpc.dto.CustomProviderSaveDto
|
||||
import ai.kilocode.rpc.dto.ProviderActionResultDto
|
||||
import ai.kilocode.rpc.dto.ProviderConnectDto
|
||||
import ai.kilocode.rpc.dto.ProviderDisconnectDto
|
||||
import ai.kilocode.rpc.dto.ProviderEnableDto
|
||||
import ai.kilocode.rpc.dto.ProviderOAuthAuthorizeDto
|
||||
import ai.kilocode.rpc.dto.ProviderOAuthCallbackDto
|
||||
import ai.kilocode.rpc.dto.ProviderOAuthReadyDto
|
||||
import ai.kilocode.rpc.dto.ProviderSettingsDto
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
|
||||
class FakeProviderRpcApi : KiloProviderRpcApi {
|
||||
var state = ProviderSettingsDto()
|
||||
val states = ArrayDeque<CompletableDeferred<ProviderSettingsDto>>()
|
||||
val stateCalls = mutableListOf<String>()
|
||||
val connects = mutableListOf<ProviderConnectDto>()
|
||||
val disconnects = mutableListOf<ProviderDisconnectDto>()
|
||||
val enables = mutableListOf<ProviderEnableDto>()
|
||||
val custom = mutableListOf<CustomProviderSaveDto>()
|
||||
var disconnectError: Exception? = null
|
||||
|
||||
override suspend fun state(directory: String): ProviderSettingsDto {
|
||||
assertNotEdt("provider.state")
|
||||
stateCalls.add(directory)
|
||||
if (states.isNotEmpty()) return states.removeFirst().await()
|
||||
return state
|
||||
}
|
||||
|
||||
override suspend fun connect(input: ProviderConnectDto): ProviderActionResultDto {
|
||||
assertNotEdt("provider.connect")
|
||||
connects.add(input)
|
||||
return ProviderActionResultDto(state)
|
||||
}
|
||||
|
||||
override suspend fun authorize(input: ProviderOAuthAuthorizeDto): ProviderOAuthReadyDto {
|
||||
assertNotEdt("provider.authorize")
|
||||
return ProviderOAuthReadyDto()
|
||||
}
|
||||
|
||||
override suspend fun callback(input: ProviderOAuthCallbackDto): ProviderActionResultDto {
|
||||
assertNotEdt("provider.callback")
|
||||
return ProviderActionResultDto(state)
|
||||
}
|
||||
|
||||
override suspend fun disconnect(input: ProviderDisconnectDto): ProviderActionResultDto {
|
||||
assertNotEdt("provider.disconnect")
|
||||
disconnects.add(input)
|
||||
disconnectError?.let { throw it }
|
||||
return ProviderActionResultDto(state)
|
||||
}
|
||||
|
||||
override suspend fun enable(input: ProviderEnableDto): ProviderActionResultDto {
|
||||
assertNotEdt("provider.enable")
|
||||
enables.add(input)
|
||||
return ProviderActionResultDto(state)
|
||||
}
|
||||
|
||||
override suspend fun saveCustom(input: CustomProviderSaveDto): ProviderActionResultDto {
|
||||
assertNotEdt("provider.saveCustom")
|
||||
custom.add(input)
|
||||
return ProviderActionResultDto(state)
|
||||
}
|
||||
|
||||
override suspend fun fetchCustomModels(input: CustomModelFetchDto): CustomModelFetchResultDto {
|
||||
assertNotEdt("provider.fetchCustomModels")
|
||||
return CustomModelFetchResultDto()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user