fix(jetbrains): stabilize worktree list updates

This commit is contained in:
kirillk
2026-08-03 17:31:29 -04:00
parent c78461d59a
commit 319d1f4859
44 changed files with 297 additions and 271 deletions
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Improve Agent Manager worktree activity refreshes and keep worktree list filtering responsive.
@@ -45,9 +45,9 @@ class KiloBackendActivityManager(
directory: (String) -> String?,
chatEvents: SharedFlow<ChatEventDto>,
) {
if (status?.isActive == true || events?.isActive == true) stop()
this.statuses = statuses
this.directory = directory
if (status?.isActive == true || events?.isActive == true) return
status = cs.launch {
statuses.collect { synchronized(lock) { recompute() } }
}
@@ -103,6 +103,23 @@ class KiloBackendActivityManagerTest {
assertFalse("ses_unknown" in snap)
}
@Test
fun `start rebinds collectors to the latest flows`() = runBlocking {
val nextStatuses = MutableStateFlow<Map<String, SessionStatusDto>>(emptyMap())
val nextEvents = MutableSharedFlow<ChatEventDto>(extraBufferCapacity = 16)
directories["ses_old"] = "/repo/old"
directories["ses_new"] = "/repo/new"
start()
manager.start(nextStatuses, { directories[it] }, nextEvents)
statuses.value = mapOf("ses_old" to SessionStatusDto("busy"))
nextStatuses.value = mapOf("ses_new" to SessionStatusDto("busy"))
val snap = await("ses_new", SessionActivityKindDto.RUNNING)
assertFalse("ses_old" in snap)
assertEquals("/repo/new", snap["ses_new"]?.directory)
}
private suspend fun await(id: String, kind: SessionActivityKindDto) = withTimeout(5_000) {
manager.activity.first { it[id]?.kind == kind }
}
@@ -1,6 +1,7 @@
package ai.kilocode.client.agentManager.worktree
import ai.kilocode.client.telemetry.Telemetry
import ai.kilocode.client.util.edt
import ai.kilocode.client.session.SessionActivityKind
import ai.kilocode.rpc.dto.CreateWorktreeRequestDto
import ai.kilocode.rpc.dto.RemoveWorktreeResultDto
@@ -226,12 +227,3 @@ class WorktreeController(
return ApplicationManager.getApplication().service()
}
}
private fun edt(block: () -> Unit) {
val app = ApplicationManager.getApplication()
if (app.isDispatchThread) {
block()
return
}
app.invokeLater(block)
}
@@ -16,6 +16,7 @@ class WorktreeNameCache {
private val names = linkedMapOf<String, String>()
private val listeners = mutableListOf<(String, String?) -> Unit>()
@RequiresEdt
fun get(path: String): String? = names[path]
@RequiresEdt
@@ -33,6 +34,7 @@ class WorktreeNameCache {
if (names.remove(path) != null) fire(path, null)
}
@RequiresEdt
fun clear() {
names.clear()
}
@@ -41,10 +43,12 @@ class WorktreeNameCache {
* Bulk sync from a worktree list reload. Does not notify: it mirrors the list model that
* triggered it, so notifying would only echo back into that same list.
*/
@RequiresEdt
fun putAll(items: List<WorktreeDto>) {
items.forEach { names[it.path] = it.name }
}
@RequiresEdt
fun addListener(parent: Disposable, listener: (path: String, name: String?) -> Unit) {
listeners.add(listener)
Disposer.register(parent) { listeners.remove(listener) }
@@ -15,6 +15,7 @@ import ai.kilocode.client.session.history.HistoryTime
import ai.kilocode.client.session.history.LocalHistoryItem
import ai.kilocode.client.util.UiTimerSource
import ai.kilocode.client.util.UiTimers
import ai.kilocode.client.util.edt
import ai.kilocode.client.vfs.KiloVfsManager
import ai.kilocode.rpc.dto.RenameWorktreeResultDto
import ai.kilocode.rpc.dto.SessionDto
@@ -253,11 +254,6 @@ open class WorktreeSessionEditorManager(
}
}
private fun edt(block: () -> Unit) {
val app = ApplicationManager.getApplication()
if (app.isDispatchThread) block() else app.invokeLater(block)
}
// Mirrors the CLI's Session.isDefaultTitle (packages/opencode/src/session/session.ts): a session
// keeps a "New session - <ISO>" / "Child session - <ISO>" placeholder until the agent names it.
private val DEFAULT_SESSION_TITLE =
@@ -2,9 +2,9 @@ package ai.kilocode.client.agentManager.worktree
import ai.kilocode.client.app.KiloSessionService
import ai.kilocode.client.telemetry.Telemetry
import ai.kilocode.client.util.edt
import ai.kilocode.log.KiloLog
import ai.kilocode.rpc.dto.SessionDto
import com.intellij.openapi.application.ApplicationManager
import com.intellij.ui.CollectionListModel
import com.intellij.util.concurrency.annotations.RequiresEdt
import kotlinx.coroutines.CoroutineScope
@@ -125,12 +125,3 @@ class WorktreeSessionListController(
return (0 until model.size).firstOrNull { model.getElementAt(it).id == id } ?: -1
}
}
private fun edt(block: () -> Unit) {
val app = ApplicationManager.getApplication()
if (app.isDispatchThread) {
block()
return
}
app.invokeLater(block)
}
@@ -2,8 +2,8 @@ package ai.kilocode.client.session.controller
import ai.kilocode.client.util.UiTimerSource
import ai.kilocode.client.util.UiTimers
import ai.kilocode.client.util.edt
import com.intellij.openapi.Disposable
import com.intellij.openapi.application.ApplicationManager
internal class DelayedState(
private val ms: Long,
@@ -62,15 +62,6 @@ internal class DelayedState(
return now + ms.coerceAtMost(Long.MAX_VALUE - now)
}
private fun edt(block: () -> Unit) {
val app = ApplicationManager.getApplication()
if (app.isDispatchThread) {
block()
return
}
app.invokeLater(block)
}
override fun dispose() {
alive = false
cancel()
@@ -28,6 +28,7 @@ import ai.kilocode.client.telemetry.Telemetry
import ai.kilocode.client.util.UiTimer
import ai.kilocode.client.util.UiTimerSource
import ai.kilocode.client.util.UiTimers
import ai.kilocode.client.util.edtLater as edt
import ai.kilocode.rpc.dto.ChatEventDto
import ai.kilocode.rpc.dto.ConfigWarningDto
import ai.kilocode.rpc.dto.ConfigUpdateDto
@@ -2322,10 +2323,6 @@ class SessionController(
check(ApplicationManager.getApplication().isDispatchThread) { "SessionController state must be accessed on EDT" }
}
private fun edt(block: () -> Unit) {
ApplicationManager.getApplication().invokeLater(block)
}
private fun runEdt(block: () -> Unit) {
val application = ApplicationManager.getApplication()
if (application.isDispatchThread) {
@@ -1,10 +1,10 @@
package ai.kilocode.client.session.controller
import ai.kilocode.client.util.edt
import ai.kilocode.log.ChatLogSummary
import ai.kilocode.log.KiloLog
import ai.kilocode.rpc.dto.ChatEventDto
import com.intellij.openapi.Disposable
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.util.Disposer
import java.awt.Component
import java.awt.event.HierarchyEvent
@@ -33,7 +33,6 @@ internal class SessionUpdateQueue(
private val LOG = KiloLog.create(SessionUpdateQueue::class.java)
}
private val app = ApplicationManager.getApplication()
private val condenser = SessionQueueCondenser()
private val pending = mutableListOf<ChatEventDto>()
private val lock = Any()
@@ -58,7 +57,7 @@ internal class SessionUpdateQueue(
init {
Disposer.register(parent, this)
if (comp != null && watch != null) edt {
if (comp != null && watch != null) run {
visible.set(comp.isShowing)
comp.addHierarchyListener(watch)
}
@@ -81,7 +80,7 @@ internal class SessionUpdateQueue(
fun holdFlush(hold: Boolean) {
if (disposed.get()) return
edt {
run {
LOG.debug { "${ChatLogSummary.sid(sid())} hold=$hold" }
this.hold = hold
}
@@ -90,7 +89,7 @@ internal class SessionUpdateQueue(
fun requestFlush(forced: Boolean, source: String = "api") {
if (disposed.get()) return
if (!forced && !visible.get()) return
edt { flushNow(forced, source) }
run { flushNow(forced, source) }
}
override fun dispose() {
@@ -102,7 +101,7 @@ internal class SessionUpdateQueue(
if (comp != null && watch != null) comp.removeHierarchyListener(watch)
synchronized(lock) { pending.clear() }
}
if (app.isDispatchThread) cleanup() else app.invokeLater(cleanup)
edt(cleanup)
}
private fun flushNow(forced: Boolean, source: String) {
@@ -133,15 +132,5 @@ internal class SessionUpdateQueue(
requestFlush(true, "visible")
}
private fun edt(block: () -> Unit) {
if (disposed.get()) return
if (app.isDispatchThread) {
block()
return
}
app.invokeLater {
if (disposed.get()) return@invokeLater
block()
}
}
private fun run(block: () -> Unit) = edt({ !disposed.get() }, block)
}
@@ -5,9 +5,9 @@ import ai.kilocode.client.app.Workspace
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.session.SessionRef
import ai.kilocode.client.telemetry.Telemetry
import ai.kilocode.client.util.edt
import ai.kilocode.rpc.dto.CloudSessionDto
import ai.kilocode.rpc.dto.SessionDto
import com.intellij.openapi.application.ApplicationManager
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -222,15 +222,6 @@ class HistoryController(
}
}
private fun edt(block: () -> Unit) {
val app = ApplicationManager.getApplication()
if (app.isDispatchThread) {
block()
return
}
app.invokeLater(block)
}
private fun localItem(session: SessionDto) = LocalHistoryItem(session)
private fun cloudItem(session: CloudSessionDto) = CloudHistoryItem(session)
@@ -87,6 +87,7 @@ internal class ActiveListView(
private var press: Press? = null
private var popups = 0
private var hovered = -1
private var heightKey: ActiveListHeightKey? = null
internal var onSelect: (() -> Unit)? = null
fun setEmptyText(text: String) {
@@ -271,6 +272,7 @@ internal class ActiveListView(
@RequiresEdt
fun update(items: List<ActiveListItem>, selection: ActiveListSelection = ActiveListSelection.Preserve) {
checkEdt()
if (this.items != items) heightKey = null
this.items = items
val key = when (selection) {
is ActiveListSelection.Key -> selection.key
@@ -301,40 +303,13 @@ internal class ActiveListView(
@RequiresEdt
fun trackPopup(popup: JBPopup) {
checkEdt()
var tracked = false
fun activate() {
if (tracked) return
tracked = true
popups++
list.repaint()
}
popup.addListener(object : JBPopupListener {
override fun beforeShown(event: LightweightWindowEvent) = activate()
override fun onClosed(event: LightweightWindowEvent) {
if (!tracked) return
tracked = false
popups = maxOf(0, popups - 1)
list.repaint()
}
})
if (popup.isVisible) activate()
trackPopupState(popup.isVisible) { listener -> popup.addListener(listener) }
}
@RequiresEdt
fun trackBalloon(balloon: Balloon) {
checkEdt()
var tracked = true
popups++
list.repaint()
balloon.addListener(object : JBPopupListener {
override fun onClosed(event: LightweightWindowEvent) {
if (!tracked) return
tracked = false
popups = maxOf(0, popups - 1)
list.repaint()
}
})
trackPopupState(true) { listener -> balloon.addListener(listener) }
}
@RequiresEdt
@@ -380,6 +355,9 @@ internal class ActiveListView(
@RequiresEdt
private fun syncCellHeight(rows: List<ActiveListItem>) {
checkEdt()
val key = ActiveListHeightKey(cfg, list.width, rows.map(::activeListHeightRow))
if (heightKey == key) return
heightKey = key
renderer.setBodyHeight(null)
if (cfg.height == ActiveListRowHeight.PREFERRED) {
if (list.fixedCellHeight == -1) return
@@ -503,6 +481,27 @@ internal class ActiveListView(
return Hit(item, id)
}
private fun trackPopupState(visible: Boolean, add: (JBPopupListener) -> Unit) {
var tracked = false
fun activate() {
if (tracked) return
tracked = true
popups++
list.repaint()
}
add(object : JBPopupListener {
override fun beforeShown(event: LightweightWindowEvent) = activate()
override fun onClosed(event: LightweightWindowEvent) {
if (!tracked) return
tracked = false
popups = maxOf(0, popups - 1)
list.repaint()
}
})
if (visible) activate()
}
private fun checkEdt() {
check(ApplicationManager.getApplication().isDispatchThread) { "Active list updates must run on EDT" }
}
@@ -546,6 +545,42 @@ internal class ActiveListView(
private data class Press(val key: String, val id: String)
private data class ActiveListHeightKey(
val cfg: ActiveListConfig,
val width: Int,
val rows: List<ActiveListHeightRow>,
)
}
private data class ActiveListHeightRow(
val key: String,
val title: String,
val note: String?,
val description: String?,
val icon: Any?,
val section: String?,
val badges: List<ActiveListBadge>,
val trailing: String?,
val cells: List<ActiveListCell>,
val disabled: Boolean,
val deleting: Boolean,
)
private fun activeListHeightRow(item: ActiveListItem): ActiveListHeightRow {
return ActiveListHeightRow(
item.key,
item.title,
item.note,
item.description,
item.icon,
item.section,
item.badges,
item.trailing,
item.cells,
item.disabled,
item.deleting,
)
}
private fun activeListIndex(items: List<ActiveListItem>, key: String?): Int {
@@ -582,9 +617,11 @@ private fun activeListTextMatches(query: String, text: String): Boolean {
private fun activeListAcronym(text: String, query: String): Boolean {
val words = activeListWords(text)
val seen = HashSet<Pair<Int, Int>>()
fun attempt(wi: Int, qi: Int): Boolean {
if (qi == query.length) return true
if (wi >= words.size) return false
if (!seen.add(wi to qi)) return false
val word = words[wi]
var count = 0
while (qi + count < query.length && count < word.length && word[count] == query[qi + count]) {
@@ -0,0 +1,37 @@
package ai.kilocode.client.util
import com.intellij.openapi.application.ApplicationManager
internal fun edt(block: () -> Unit) {
val app = ApplicationManager.getApplication()
if (app.isDispatchThread) {
block()
return
}
app.invokeLater(block)
}
internal fun edt(alive: () -> Boolean, block: () -> Unit) {
if (!alive()) return
val app = ApplicationManager.getApplication()
if (app.isDispatchThread) {
if (alive()) block()
return
}
app.invokeLater {
if (alive()) block()
}
}
internal fun edtLater(block: () -> Unit) {
ApplicationManager.getApplication().invokeLater(block)
}
internal fun <T> edtWait(block: () -> T): T {
val app = ApplicationManager.getApplication()
if (app.isDispatchThread) return block()
var out: T? = null
app.invokeAndWait { out = block() }
@Suppress("UNCHECKED_CAST")
return out as T
}
@@ -1,5 +1,6 @@
package ai.kilocode.client.agentManager
import ai.kilocode.client.util.edtWait
import ai.kilocode.client.agentManager.worktree.KiloWorktreeService
import ai.kilocode.client.agentManager.worktree.WorktreeController
import ai.kilocode.client.agentManager.worktree.WorktreeEditorMatcher
@@ -408,12 +409,7 @@ class AgentManagerPanelTest : BasePlatformTestCase() {
flush()
}
private fun <T> edt(block: () -> T): T {
val out = arrayOfNulls<Any?>(1)
ApplicationManager.getApplication().invokeAndWait { out[0] = block() }
@Suppress("UNCHECKED_CAST")
return out[0] as T
}
private fun <T> edt(block: () -> T): T = edtWait(block)
private fun flush() = coroutines.drain(::pump)
@@ -0,0 +1,62 @@
package ai.kilocode.client.agentManager.worktree
import ai.kilocode.client.util.edtWait
import ai.kilocode.rpc.dto.WorktreeDto
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.components.service
import com.intellij.openapi.util.Disposer
import com.intellij.testFramework.fixtures.BasePlatformTestCase
@Suppress("UnstableApiUsage")
class WorktreeNameCacheTest : BasePlatformTestCase() {
override fun tearDown() {
try {
edt { cache().clear() }
} finally {
super.tearDown()
}
}
fun `test put and remove notify only when value changes`() = edt {
val events = mutableListOf<Pair<String, String?>>()
cache().addListener(testRootDisposable) { path, name -> events += path to name }
cache().put("/repo/wt", "First")
cache().put("/repo/wt", "First")
cache().put("/repo/wt", "Second")
cache().remove("/repo/wt")
cache().remove("/repo/wt")
assertEquals(
listOf("/repo/wt" to "First", "/repo/wt" to "Second", "/repo/wt" to null),
events,
)
}
fun `test bulk sync updates names without notifying`() = edt {
val events = mutableListOf<Pair<String, String?>>()
cache().addListener(testRootDisposable) { path, name -> events += path to name }
cache().putAll(listOf(WorktreeDto("/repo/wt", "Feature", "feature/x", "/repo/wt")))
assertEquals("Feature", cache().get("/repo/wt"))
assertTrue(events.isEmpty())
}
fun `test listener is removed with parent disposable`() = edt {
val parent = Disposer.newDisposable("worktree-cache-listener")
Disposer.register(testRootDisposable, parent)
val events = mutableListOf<Pair<String, String?>>()
cache().addListener(parent) { path, name -> events += path to name }
cache().put("/repo/one", "One")
Disposer.dispose(parent)
cache().put("/repo/two", "Two")
assertEquals(listOf("/repo/one" to "One"), events)
}
private fun cache(): WorktreeNameCache = ApplicationManager.getApplication().service()
private fun <T> edt(block: () -> T): T = edtWait(block)
}
@@ -1,5 +1,6 @@
package ai.kilocode.client.agentManager.worktree
import ai.kilocode.client.util.edtWait
import ai.kilocode.client.app.KiloAppService
import ai.kilocode.client.app.KiloSessionService
import ai.kilocode.client.app.KiloWorkspaceService
@@ -373,12 +374,7 @@ class WorktreeSessionEditorManagerTest : BasePlatformTestCase() {
}
}
private fun <T> edt(block: () -> T): T {
val out = arrayOfNulls<Any?>(1)
com.intellij.openapi.application.ApplicationManager.getApplication().invokeAndWait { out[0] = block() }
@Suppress("UNCHECKED_CAST")
return out[0] as T
}
private fun <T> edt(block: () -> T): T = edtWait(block)
private companion object {
const val DIR = "/repo/.kilo/worktrees/feature-x"
@@ -1,5 +1,6 @@
package ai.kilocode.client.agentManager.worktree
import ai.kilocode.client.util.edtWait
import ai.kilocode.client.app.KiloSessionService
import ai.kilocode.client.app.Workspace
import ai.kilocode.client.session.SessionActivityKind
@@ -405,12 +406,7 @@ class WorktreeSessionEditorPanelTest : BasePlatformTestCase() {
}
}
private fun <T> edt(block: () -> T): T {
val out = arrayOfNulls<Any?>(1)
com.intellij.openapi.application.ApplicationManager.getApplication().invokeAndWait { out[0] = block() }
@Suppress("UNCHECKED_CAST")
return out[0] as T
}
private fun <T> edt(block: () -> T): T = edtWait(block)
private fun components(root: java.awt.Component): List<java.awt.Component> {
val out = mutableListOf<java.awt.Component>()
@@ -1,5 +1,6 @@
package ai.kilocode.client.session.controller
import ai.kilocode.client.util.edtWait
import ai.kilocode.client.testing.TestUiTimers
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.util.Disposer
@@ -138,7 +139,5 @@ class DelayedStateTest : BasePlatformTestCase() {
return state
}
private fun edt(block: () -> Unit) {
ApplicationManager.getApplication().invokeAndWait(block)
}
private fun edt(block: () -> Unit) = edtWait(block)
}
@@ -1,5 +1,6 @@
package ai.kilocode.client.session.controller
import ai.kilocode.client.util.edtWait
import ai.kilocode.client.app.KiloAppService
import ai.kilocode.client.app.KiloSessionService
import ai.kilocode.client.session.model.SessionModel
@@ -267,16 +268,9 @@ abstract class SessionControllerTestBase : BasePlatformTestCase() {
}
}
protected fun edt(block: () -> Unit) {
ApplicationManager.getApplication().invokeAndWait(block)
}
protected fun edt(block: () -> Unit) = edtWait(block)
protected fun <T> edt(block: () -> T): T {
var result: T? = null
ApplicationManager.getApplication().invokeAndWait { result = block() }
@Suppress("UNCHECKED_CAST")
return result as T
}
protected fun <T> edt(block: () -> T): T = edtWait(block)
/** Emit a chat event into the fake RPC flow. */
protected fun emit(event: ChatEventDto, flush: Boolean = true) {
@@ -1,5 +1,6 @@
package ai.kilocode.client.session.model
import ai.kilocode.client.util.edtWait
import ai.kilocode.rpc.dto.DiffFileDto
import ai.kilocode.rpc.dto.KiloAppStateDto
import ai.kilocode.rpc.dto.KiloAppStatusDto
@@ -1197,10 +1198,5 @@ class SessionModelTest : BasePlatformTestCase() {
assertEquals(expected.trimIndent().trim(), model.toString().trim())
}
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 <T> edt(block: () -> T): T = edtWait(block)
}
@@ -1,5 +1,6 @@
package ai.kilocode.client.session.views
import ai.kilocode.client.util.edtWait
import ai.kilocode.client.session.ui.style.SessionEditorStyle
import ai.kilocode.client.session.ui.style.SessionUiStyle
import ai.kilocode.client.ui.UiStyle
@@ -185,12 +186,7 @@ class LoginRequiredViewTest : BasePlatformTestCase() {
// ------ helpers ------
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 <T> edt(block: () -> T): T = edtWait(block)
private inline fun <reified T> findAll(root: Container): List<T> =
findAllCls(root, T::class.java)
@@ -1,5 +1,6 @@
package ai.kilocode.client.session.views.base
import ai.kilocode.client.util.edtWait
import ai.kilocode.client.session.ui.style.SessionEditorStyle
import ai.kilocode.client.session.ui.style.SessionUiStyle
import ai.kilocode.client.ui.UiStyle
@@ -436,12 +437,7 @@ class BaseQuestionViewTest : BasePlatformTestCase() {
// ------ helpers ------
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 <T> edt(block: () -> T): T = edtWait(block)
private fun region(panel: BaseQuestionView, region: String) = (panel.layout as BorderLayout).getLayoutComponent(region)
@@ -1,5 +1,6 @@
package ai.kilocode.client.settings
import ai.kilocode.client.util.edtWait
import ai.kilocode.client.settings.profile.UserProfileConfigurable
import ai.kilocode.client.settings.context.ContextConfigurable
import ai.kilocode.client.settings.models.ModelsConfigurable
@@ -141,12 +142,7 @@ class KiloSettingsConfigurableTest : BasePlatformTestCase() {
// -- helpers --
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 <T> edt(block: () -> T): T = edtWait(block)
private fun links(root: Container): List<ActionLink> = buildList {
for (comp in root.components) {
@@ -1,5 +1,6 @@
package ai.kilocode.client.settings
import ai.kilocode.client.util.edtWait
import ai.kilocode.client.app.KiloAppService
import ai.kilocode.client.settings.profile.ProfileUi
import ai.kilocode.client.settings.profile.formatResetDate
@@ -1067,12 +1068,7 @@ class UserProfileConfigurableTest : BasePlatformTestCase() {
}
}
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 <T> edt(block: () -> T): T = edtWait(block)
private fun flush() = coroutines.drain(::pump)
@@ -1,5 +1,6 @@
package ai.kilocode.client.settings.agents
import ai.kilocode.client.util.edtWait
import ai.kilocode.client.settings.rules.RulesConfigurable
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.options.SearchableConfigurable
@@ -42,12 +43,7 @@ class AgentBehaviorConfigurableTest : BasePlatformTestCase() {
assertFalse(cfg.isModified)
}
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 <T> edt(block: () -> T): T = edtWait(block)
private fun links(root: Container): List<ActionLink> = buildList {
for (comp in root.components) {
@@ -1,5 +1,6 @@
package ai.kilocode.client.settings.agents
import ai.kilocode.client.util.edtWait
import ai.kilocode.cli.KiloCliParser
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.settings.base.SettingsRow
@@ -101,10 +102,5 @@ class AgentCreateDialogTest : BasePlatformTestCase() {
return out
}
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 <T> edt(block: () -> T): T = edtWait(block)
}
@@ -1,5 +1,6 @@
package ai.kilocode.client.settings.agents
import ai.kilocode.client.util.edtWait
import ai.kilocode.cli.KiloCliParser
import ai.kilocode.client.app.KiloAppService
import ai.kilocode.client.plugin.KiloBundle
@@ -232,10 +233,5 @@ class AgentEditDialogTest : BasePlatformTestCase() {
return out
}
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 <T> edt(block: () -> T): T = edtWait(block)
}
@@ -1,5 +1,6 @@
package ai.kilocode.client.settings.agents
import ai.kilocode.client.util.edtWait
import ai.kilocode.cli.KiloCliParser
import ai.kilocode.client.app.KiloAgentBehaviorService
import ai.kilocode.client.app.KiloAppService
@@ -498,12 +499,7 @@ class AgentsSettingsUiTest : BasePlatformTestCase() {
MouseEvent.BUTTON1,
)
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 <T> edt(block: () -> T): T = edtWait(block)
private fun flushUntil(done: () -> Boolean) = runBlocking {
repeat(300) {
@@ -1,5 +1,6 @@
package ai.kilocode.client.settings.agents
import ai.kilocode.client.util.edtWait
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.settings.base.SettingsRow
import ai.kilocode.client.settings.base.SettingsStackedRow
@@ -197,10 +198,5 @@ class McpEditDialogTest : BasePlatformTestCase() {
return out
}
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 <T> edt(block: () -> T): T = edtWait(block)
}
@@ -1,5 +1,6 @@
package ai.kilocode.client.settings.agents
import ai.kilocode.client.util.edtWait
import ai.kilocode.client.app.KiloAgentBehaviorService
import ai.kilocode.client.app.KiloAppService
import ai.kilocode.client.plugin.KiloBundle
@@ -430,12 +431,7 @@ class McpSettingsUiTest : BasePlatformTestCase() {
MouseEvent.BUTTON1,
)
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 <T> edt(block: () -> T): T = edtWait(block)
private fun flushUntil(done: () -> Boolean) = runBlocking {
repeat(300) {
@@ -1,5 +1,6 @@
package ai.kilocode.client.settings.agents
import ai.kilocode.client.util.edtWait
import ai.kilocode.client.app.KiloAgentBehaviorService
import ai.kilocode.client.app.KiloAppService
import ai.kilocode.client.app.KiloWorkspaceService
@@ -583,12 +584,7 @@ class SkillsSettingsUiTest : BasePlatformTestCase() {
MouseEvent.BUTTON1,
)
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 <T> edt(block: () -> T): T = edtWait(block)
private fun flushUntil(done: () -> Boolean) = runBlocking {
repeat(300) {
@@ -1,5 +1,6 @@
package ai.kilocode.client.settings.autoapprove
import ai.kilocode.client.util.edtWait
import ai.kilocode.client.app.KiloAppService
import ai.kilocode.client.app.KiloWorkspaceService
import ai.kilocode.client.testing.FakeAppRpcApi
@@ -433,12 +434,7 @@ class AutoApproveSettingsUiTest : BasePlatformTestCase() {
combo.selectedItem = item
}
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 <T> edt(block: () -> T): T = edtWait(block)
private fun flushUntil(done: () -> Boolean) = runBlocking {
repeat(200) {
@@ -1,5 +1,6 @@
package ai.kilocode.client.settings.autoapprove
import ai.kilocode.client.util.edtWait
import ai.kilocode.client.testing.fire
import ai.kilocode.client.ui.UiStyle
import ai.kilocode.client.ui.list.ActiveListItem
@@ -336,10 +337,5 @@ class SettingsInlineListTest : BasePlatformTestCase() {
return out
}
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 <T> edt(block: () -> T): T = edtWait(block)
}
@@ -1,5 +1,6 @@
package ai.kilocode.client.settings.base
import ai.kilocode.client.util.edtWait
import ai.kilocode.client.app.KiloAppService
import ai.kilocode.client.app.KiloWorkspaceService
import ai.kilocode.client.testing.FakeAppRpcApi
@@ -172,12 +173,7 @@ class BaseSettingsUiTest : BasePlatformTestCase() {
edt { UIUtil.dispatchAllInvocationEvents() }
}
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 <T> edt(block: () -> T): T = edtWait(block)
private fun text(root: Container): String {
val out = mutableListOf<String>()
@@ -1,5 +1,6 @@
package ai.kilocode.client.settings.base
import ai.kilocode.client.util.edtWait
import ai.kilocode.client.app.KiloAppService
import ai.kilocode.client.app.KiloWorkspaceService
import ai.kilocode.client.testing.FakeAppRpcApi
@@ -102,12 +103,7 @@ class BaseSettingsUiWorkspaceTest : BasePlatformTestCase() {
config = ConfigDto(model = model),
)
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 <T> edt(block: () -> T): T = edtWait(block)
private fun flushUntil(done: () -> Boolean) = runBlocking {
repeat(20) {
@@ -1,5 +1,6 @@
package ai.kilocode.client.settings.base
import ai.kilocode.client.util.edtWait
import ai.kilocode.client.app.KiloAppService
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.testing.FakeAppRpcApi
@@ -190,12 +191,7 @@ class KiloReadyConfigurableTest : BasePlatformTestCase() {
assertTrue(done())
}
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 <T> edt(block: () -> T): T = edtWait(block)
private fun text(root: Container): String {
val out = mutableListOf<String>()
@@ -1,5 +1,6 @@
package ai.kilocode.client.settings.base
import ai.kilocode.client.util.edtWait
import ai.kilocode.client.testing.fire
import ai.kilocode.client.session.ui.PickerRow
import ai.kilocode.client.ui.FilledBadgeIcon
@@ -856,10 +857,5 @@ class SettingsListViewTest : BasePlatformTestCase() {
false,
)
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 <T> edt(block: () -> T): T = edtWait(block)
}
@@ -1,5 +1,6 @@
package ai.kilocode.client.settings.context
import ai.kilocode.client.util.edtWait
import ai.kilocode.client.app.KiloAppService
import ai.kilocode.client.app.KiloWorkspaceService
import ai.kilocode.client.settings.base.SettingsToggle
@@ -284,12 +285,7 @@ class ContextSettingsUiTest : BasePlatformTestCase() {
.filterIsInstance<HoverIcon>()
.single { it.toolTipText == tip }
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 <T> edt(block: () -> T): T = edtWait(block)
private fun flushUntil(done: () -> Boolean) = runBlocking {
repeat(200) {
@@ -1,5 +1,6 @@
package ai.kilocode.client.settings.models
import ai.kilocode.client.util.edtWait
import ai.kilocode.client.app.KiloAppService
import ai.kilocode.client.app.KiloWorkspaceService
import ai.kilocode.client.session.ui.model.ModelPicker
@@ -365,12 +366,7 @@ class ModelsSettingsUiTest : BasePlatformTestCase() {
private fun requireUi(): ModelsSettingsUi = requireNotNull(ui)
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 <T> edt(block: () -> T): T = edtWait(block)
private fun flushUntil(done: () -> Boolean) = runBlocking {
repeat(200) {
@@ -1,5 +1,6 @@
package ai.kilocode.client.settings.providers
import ai.kilocode.client.util.edtWait
import ai.kilocode.client.app.KiloProviderService
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.testing.FakeProviderRpcApi
@@ -1290,12 +1291,7 @@ class ProvidersSettingsUiTest : BasePlatformTestCase() {
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 <T> edt(block: () -> T): T = edtWait(block)
private fun flushUntil(done: () -> Boolean) = runBlocking {
repeat(200) {
@@ -1,5 +1,6 @@
package ai.kilocode.client.settings.rules
import ai.kilocode.client.util.edtWait
import ai.kilocode.client.app.KiloAgentBehaviorService
import ai.kilocode.client.app.KiloAppService
import ai.kilocode.client.app.KiloWorkspaceService
@@ -358,12 +359,7 @@ class RulesSettingsUiTest : BasePlatformTestCase() {
MouseEvent.BUTTON1,
)
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 <T> edt(block: () -> T): T = edtWait(block)
private fun flushUntil(done: () -> Boolean) {
repeat(200) {
@@ -1,5 +1,6 @@
package ai.kilocode.client.testing
import ai.kilocode.client.util.edtWait
import ai.kilocode.client.util.UiTimer
import ai.kilocode.client.util.UiTimerSource
import com.intellij.openapi.application.ApplicationManager
@@ -28,14 +29,7 @@ class TestUiTimers : UiTimerSource {
}
}
private fun edt(action: () -> Unit) {
val app = ApplicationManager.getApplication()
if (app.isDispatchThread) {
action()
return
}
app.invokeAndWait(action)
}
private fun edt(action: () -> Unit) = edtWait(action)
private inner class TestTimer(
private val ms: Int,
@@ -0,0 +1,34 @@
package ai.kilocode.client.ui.list
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.measureTime
class ActiveListMatchTest {
@Test
fun `matches words and camel case acronyms`() {
assertTrue(activeListMatches("wc", item("WorktreeController")))
assertTrue(activeListMatches("ws ses", item("Worktree Session Editor")))
assertTrue(activeListMatches("feature", item("feature-branch")))
assertFalse(activeListMatches("zz", item("WorktreeController")))
}
@Test
fun `matcher handles repeated ambiguous prefixes quickly`() {
val text = List(28) { "aaaaaaaaaa" }.joinToString("-")
val query = "aaaaaaaaaab"
val duration = measureTime {
assertFalse(activeListMatches(query, item(text)))
}
assertTrue(duration < 200.milliseconds, "ambiguous match took $duration")
}
private fun item(title: String) = object : ActiveListItem {
override val key = title
override val title = title
}
}
@@ -1,5 +1,6 @@
package ai.kilocode.client.ui.picker
import ai.kilocode.client.util.edtWait
import com.intellij.CommonBundle
import com.intellij.ide.ui.laf.darcula.ui.DarculaButtonUI
import com.intellij.openapi.application.ApplicationManager
@@ -69,12 +70,7 @@ class PickerPopupTest : BasePlatformTestCase() {
return out
}
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 <T> edt(block: () -> T): T = edtWait(block)
private class TestRenderer(model: CollectionListModel<String>) : PickerListRenderer<String>(
model = model,