refactor(kilo-jetbrains): unify settings base lifecycle

This commit is contained in:
kirillk
2026-06-02 17:26:49 -04:00
parent b4ac8fac44
commit ebeb65ad77
5 changed files with 141 additions and 138 deletions
@@ -1,25 +1,40 @@
package ai.kilocode.client.settings.base
import ai.kilocode.client.KiloNotifications
import ai.kilocode.client.app.KiloAppService
import ai.kilocode.client.app.KiloWorkspaceService
import ai.kilocode.client.settings.profile.UserProfileConfigurable
import ai.kilocode.rpc.dto.KiloAppStateDto
import ai.kilocode.rpc.dto.KiloAppStatusDto
import ai.kilocode.rpc.dto.ModelStateDto
import com.intellij.ide.DataManager
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.components.service
import com.intellij.openapi.options.Configurable
import com.intellij.openapi.options.ConfigurableWithId
import com.intellij.openapi.options.ShowSettingsUtil
import com.intellij.openapi.options.ex.Settings
import com.intellij.openapi.project.ProjectManager
import com.intellij.util.concurrency.annotations.RequiresBackgroundThread
import com.intellij.util.concurrency.annotations.RequiresEdt
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.function.Predicate
import javax.swing.JComponent
internal abstract class BaseSettingsUi<C : BaseContentPanel, D, P, R>(
internal abstract class BaseSettingsUi<C : BaseContentPanel, D, P, R, W>(
protected val scope: CoroutineScope,
initial: D,
private val app: KiloAppService = service(),
private val workspaces: KiloWorkspaceService = service(),
private val hint: String? = null,
private val loginBanner: Boolean = true,
) : SettingsPanel() {
protected lateinit var form: C
@@ -28,6 +43,17 @@ internal abstract class BaseSettingsUi<C : BaseContentPanel, D, P, R>(
protected var draft = initial
protected val saving get() = save
protected val saveError get() = error
protected var appState: KiloAppStateDto = app.state.value
private set
protected var modelState: ModelStateDto = app.models.value
private set
protected var projectDirectory: String? = null
private set
protected val hasProjectDirectory get() = projectDirectory != null || hint != null
protected var workspaceLoading = false
private set
protected var workspaceLoaded = false
private set
private var baseline = initial
private var pending: D? = null
@@ -35,9 +61,72 @@ internal abstract class BaseSettingsUi<C : BaseContentPanel, D, P, R>(
private var error: String? = null
private var disposed = false
protected fun setSettingsContent(content: C) {
@RequiresEdt
protected fun startSettings(content: C) {
form = content
setContent(content)
syncContent()
start()
}
private fun start() {
jobs += scope.launch {
app.state.collect { state -> withContext(edt) { updateApp(state) } }
}
jobs += scope.launch {
app.models.collect { state -> withContext(edt) { updateModels(state) } }
}
jobs += scope.launch { app.connect() }
val path = hint ?: return
jobs += scope.launch {
val dir = workspaces.resolveProjectDirectory(path)
withContext(edt) {
projectDirectory = dir
workspaceLoaded = false
syncContent()
load()
}
}
}
@RequiresEdt
private fun updateApp(state: KiloAppStateDto) {
appState = state
if (state.status != KiloAppStatusDto.READY) {
workspaceLoading = false
unavailable(state)
syncContent()
return
}
acceptBase(draft(state))
syncContent()
load()
}
@RequiresEdt
private fun updateModels(state: ModelStateDto) {
modelState = state
models(state)
syncContent()
}
@RequiresEdt
private fun load() {
val root = projectDirectory ?: return
if (appState.status != KiloAppStatusDto.READY || workspaceLoading || workspaceLoaded) return
workspaceLoading = true
clearWorkspaceError()
syncContent()
jobs += scope.launch {
val state = loadWorkspace(root)
withContext(edt) {
applyWorkspace(state)
workspaceLoaded = true
workspaceLoading = false
acceptBase(draft(appState))
syncContent()
}
}
}
@RequiresEdt
@@ -167,6 +256,15 @@ internal abstract class BaseSettingsUi<C : BaseContentPanel, D, P, R>(
@RequiresEdt
protected abstract fun failedText(): String
@RequiresEdt
protected abstract fun draft(state: KiloAppStateDto): D
@RequiresBackgroundThread
protected abstract suspend fun loadWorkspace(root: String): W
@RequiresEdt
protected abstract fun applyWorkspace(result: W)
@RequiresEdt
protected open fun saved(base: D, draft: D): Boolean = base == draft
@@ -188,6 +286,15 @@ internal abstract class BaseSettingsUi<C : BaseContentPanel, D, P, R>(
@RequiresEdt
protected open fun logSaveCompletedAfterDispose(change: P) = Unit
@RequiresEdt
protected open fun unavailable(state: KiloAppStateDto) = Unit
@RequiresEdt
protected open fun models(state: ModelStateDto) = Unit
@RequiresEdt
protected open fun clearWorkspaceError() = Unit
private fun openProfile(src: JComponent) {
val settings = Settings.KEY.getData(DataManager.getInstance().getDataContext(src))
if (settings != null) {
@@ -207,4 +314,8 @@ internal abstract class BaseSettingsUi<C : BaseContentPanel, D, P, R>(
{ cfg: Configurable -> cfg.focusOn(UserProfileConfigurable.FOCUS_ACCOUNT_COMBO) },
)
}
private companion object {
val edt = Dispatchers.EDT + ModalityState.any().asContextElement()
}
}
@@ -1,127 +0,0 @@
package ai.kilocode.client.settings.base
import ai.kilocode.client.app.KiloAppService
import ai.kilocode.client.app.KiloWorkspaceService
import ai.kilocode.rpc.dto.KiloAppStateDto
import ai.kilocode.rpc.dto.KiloAppStatusDto
import ai.kilocode.rpc.dto.ModelStateDto
import com.intellij.openapi.application.EDT
import com.intellij.openapi.application.ModalityState
import com.intellij.openapi.application.asContextElement
import com.intellij.openapi.components.service
import com.intellij.util.concurrency.annotations.RequiresBackgroundThread
import com.intellij.util.concurrency.annotations.RequiresEdt
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
internal abstract class BaseWorkspaceSettingsUi<C : BaseContentPanel, D, P, R, W>(
cs: CoroutineScope,
initial: D,
private val app: KiloAppService = service(),
private val workspaces: KiloWorkspaceService = service(),
private val hint: String? = null,
loginBanner: Boolean = true,
) : BaseSettingsUi<C, D, P, R>(cs, initial, loginBanner) {
protected var appState: KiloAppStateDto = app.state.value
private set
protected var modelState: ModelStateDto = app.models.value
private set
protected var projectDirectory: String? = null
private set
protected val hasProjectDirectory get() = projectDirectory != null || hint != null
protected var workspaceLoading = false
private set
protected var workspaceLoaded = false
private set
@RequiresEdt
protected fun startSettings(content: C) {
setSettingsContent(content)
syncContent()
start()
}
private fun start() {
jobs += scope.launch {
app.state.collect { state -> withContext(edt) { updateApp(state) } }
}
jobs += scope.launch {
app.models.collect { state -> withContext(edt) { updateModels(state) } }
}
jobs += scope.launch { app.connect() }
val path = hint ?: return
jobs += scope.launch {
val dir = workspaces.resolveProjectDirectory(path)
withContext(edt) {
projectDirectory = dir
workspaceLoaded = false
syncContent()
load()
}
}
}
@RequiresEdt
private fun updateApp(state: KiloAppStateDto) {
appState = state
if (state.status != KiloAppStatusDto.READY) {
workspaceLoading = false
unavailable(state)
syncContent()
return
}
acceptBase(draft(state))
syncContent()
load()
}
@RequiresEdt
private fun updateModels(state: ModelStateDto) {
modelState = state
models(state)
syncContent()
}
@RequiresEdt
private fun load() {
val root = projectDirectory ?: return
if (appState.status != KiloAppStatusDto.READY || workspaceLoading || workspaceLoaded) return
workspaceLoading = true
clearWorkspaceError()
syncContent()
jobs += scope.launch {
val state = loadWorkspace(root)
withContext(edt) {
applyWorkspace(state)
workspaceLoaded = true
workspaceLoading = false
acceptBase(draft(appState))
syncContent()
}
}
}
@RequiresEdt
protected abstract fun draft(state: KiloAppStateDto): D
@RequiresBackgroundThread
protected abstract suspend fun loadWorkspace(root: String): W
@RequiresEdt
protected abstract fun applyWorkspace(result: W)
@RequiresEdt
protected open fun unavailable(state: KiloAppStateDto) = Unit
@RequiresEdt
protected open fun models(state: ModelStateDto) = Unit
@RequiresEdt
protected open fun clearWorkspaceError() = Unit
private companion object {
val edt = Dispatchers.EDT + ModalityState.any().asContextElement()
}
}
@@ -7,7 +7,7 @@ import ai.kilocode.client.session.ui.ReasoningPicker
import ai.kilocode.client.session.ui.model.ModelPicker
import ai.kilocode.client.session.ui.model.ModelText
import ai.kilocode.client.settings.base.BaseContentPanel
import ai.kilocode.client.settings.base.BaseWorkspaceSettingsUi
import ai.kilocode.client.settings.base.BaseSettingsUi
import ai.kilocode.client.settings.base.SettingsBannerKind
import ai.kilocode.client.settings.base.SettingsRow
import ai.kilocode.client.settings.base.SettingsRows
@@ -32,7 +32,7 @@ internal class ModelsSettingsUi(
private val app: KiloAppService = service(),
private val workspaces: KiloWorkspaceService = service(),
directory: String? = null,
) : BaseWorkspaceSettingsUi<ModelsSettingsContent, ModelsDraft, ConfigPatchDto, KiloAppStateDto, ModelsWorkspaceDto>(
) : BaseSettingsUi<ModelsSettingsContent, ModelsDraft, ConfigPatchDto, KiloAppStateDto, ModelsWorkspaceDto>(
cs,
ModelsDraft(),
app,
@@ -1,5 +1,10 @@
package ai.kilocode.client.settings.base
import ai.kilocode.client.app.KiloAppService
import ai.kilocode.client.app.KiloWorkspaceService
import ai.kilocode.client.testing.FakeAppRpcApi
import ai.kilocode.client.testing.FakeWorkspaceRpcApi
import ai.kilocode.rpc.dto.KiloAppStateDto
import com.intellij.openapi.application.ApplicationManager
import com.intellij.testFramework.fixtures.BasePlatformTestCase
import com.intellij.util.ui.UIUtil
@@ -14,11 +19,17 @@ import javax.swing.text.JTextComponent
class BaseSettingsUiTest : BasePlatformTestCase() {
private lateinit var scope: CoroutineScope
private lateinit var appScope: CoroutineScope
private lateinit var app: KiloAppService
private lateinit var workspaces: KiloWorkspaceService
private var panel: FakePanel? = null
override fun setUp() {
super.setUp()
scope = CoroutineScope(SupervisorJob())
appScope = CoroutineScope(SupervisorJob())
app = KiloAppService(appScope, FakeAppRpcApi())
workspaces = KiloWorkspaceService(appScope, FakeWorkspaceRpcApi())
}
override fun tearDown() {
@@ -27,6 +38,7 @@ class BaseSettingsUiTest : BasePlatformTestCase() {
if (view != null) edt { view.dispose() }
panel = null
scope.cancel()
appScope.cancel()
} finally {
super.tearDown()
}
@@ -137,7 +149,7 @@ class BaseSettingsUiTest : BasePlatformTestCase() {
}
private fun create(login: Boolean = true): FakePanel {
val view = edt { FakePanel(scope, login) }
val view = edt { FakePanel(scope, app, workspaces, login) }
panel = view
return view
}
@@ -181,15 +193,16 @@ class BaseSettingsUiTest : BasePlatformTestCase() {
private class FakePanel(
cs: CoroutineScope,
app: KiloAppService,
workspaces: KiloWorkspaceService,
login: Boolean,
) : BaseSettingsUi<FakeContent, Draft, Change, Draft>(cs, Draft("old"), login) {
) : BaseSettingsUi<FakeContent, Draft, Change, Draft, Unit>(cs, Draft("old"), app, workspaces, loginBanner = login) {
private val callbacks = mutableListOf<(Draft?) -> Unit>()
var disposedFailures = 0
private set
init {
setSettingsContent(FakeContent())
syncContent()
startSettings(FakeContent())
}
fun edit(value: String) = updateDraft { copy(value = value) }
@@ -210,6 +223,12 @@ class BaseSettingsUiTest : BasePlatformTestCase() {
override fun base(result: Draft): Draft = result
override fun draft(state: KiloAppStateDto): Draft = draft
override suspend fun loadWorkspace(root: String) = Unit
override fun applyWorkspace(result: Unit) = Unit
override fun syncContent() {
val err = saveError
if (saving) {
@@ -18,7 +18,7 @@ import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
class BaseWorkspaceSettingsUiTest : BasePlatformTestCase() {
class BaseSettingsUiWorkspaceTest : BasePlatformTestCase() {
private lateinit var appScope: CoroutineScope
private lateinit var uiScope: CoroutineScope
private lateinit var rpc: FakeAppRpcApi
@@ -54,7 +54,7 @@ class BaseWorkspaceSettingsUiTest : BasePlatformTestCase() {
workspaceRpc.directory = "/resolved"
val view = create("/hint")
flushUntil { edt { view.value() == "new" && view.roots == listOf("/resolved") } }
flushUntil { edt { view.value() == "new" && view.roots == listOf("/resolved") && view.loaded() } }
edt {
assertEquals("new", view.value())
@@ -128,7 +128,7 @@ class BaseWorkspaceSettingsUiTest : BasePlatformTestCase() {
app: KiloAppService,
workspaces: KiloWorkspaceService,
hint: String,
) : BaseWorkspaceSettingsUi<FakeContent, Draft, Change, Draft, String>(
) : BaseSettingsUi<FakeContent, Draft, Change, Draft, String>(
cs,
Draft("old"),
app,