fix(jetbrains): refine session list selection

This commit is contained in:
kirillk
2026-07-28 17:07:00 -04:00
parent a0dcab0c6a
commit 91e2f0612d
5 changed files with 117 additions and 9 deletions
@@ -11,6 +11,8 @@ import ai.kilocode.client.session.SessionManager
import ai.kilocode.client.session.SessionRef
import ai.kilocode.client.session.SessionUi
import ai.kilocode.client.session.SessionUiFactory
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.rpc.dto.SessionDto
@@ -118,6 +120,7 @@ open class WorktreeSessionEditorManager(
val key = currentKey()
val names = active.associateWith(::title)
deleting.addAll(active)
val target = if (key in active) next(key) else null
onListChanged?.invoke()
active.forEach { id ->
val name = names[id] ?: title(id)
@@ -130,8 +133,7 @@ open class WorktreeSessionEditorManager(
}
active.forEach(::forceSession)
if (key in active) {
val next = latest()
if (next != null) openSession(SessionRef.Local(next)) else newSession()
if (target != null) openSession(SessionRef.Local(target)) else newSession()
}
}
@@ -158,6 +160,16 @@ open class WorktreeSessionEditorManager(
.maxByOrNull { it.time.updated }
}
@RequiresEdt
private fun next(key: String?): SessionDto? {
val rows = HistoryTime.sorted((0 until list.model.size).map { LocalHistoryItem(list.model.getElementAt(it)) })
.map { it.session }
val idx = rows.indexOfFirst { it.id == key }
if (idx < 0) return rows.firstOrNull { it.id !in deleting }
return rows.drop(idx + 1).firstOrNull { it.id !in deleting }
?: rows.take(idx).asReversed().firstOrNull { it.id !in deleting }
}
@RequiresEdt
private fun title(id: String): String {
return (0 until list.model.size)
@@ -258,7 +258,7 @@ class WorktreeSessionEditorPanel(
override val section: String get() = HistoryTime.title(HistorySection.TODAY)
}
private data class SessionRow(
private inner class SessionRow(
val session: SessionDto,
val kind: SessionActivityKind?,
override val deleting: Boolean = false,
@@ -272,12 +272,15 @@ class WorktreeSessionEditorPanel(
override val section: String get() = HistoryTime.title(HistoryTime.section(item))
override val search: String get() = listOf(session.title, session.id, session.directory).joinToString(" ")
override val cells: List<ActiveListCell>
get() = listOf(ActiveListCell(
DELETE_CELL,
KiloBundle.message("worktree.session.delete.action"),
icon = AllIcons.Actions.GC,
iconOnly = true,
))
get() {
if (selectedKeys().size != 1) return emptyList()
return listOf(ActiveListCell(
DELETE_CELL,
KiloBundle.message("worktree.session.delete.action"),
icon = AllIcons.Actions.GC,
iconOnly = true,
))
}
}
private companion object {
@@ -28,6 +28,7 @@ import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent
import javax.swing.JComponent
import javax.swing.KeyStroke
import javax.swing.ListSelectionModel
import javax.swing.Scrollable
import javax.swing.SwingConstants
import javax.swing.event.ListSelectionEvent
@@ -101,12 +102,14 @@ internal class ActiveListView(
if (!UIUtil.isActionClick(e, MouseEvent.MOUSE_PRESSED, true)) return
list.requestFocusInWindow()
press = null
if (selection(e)) return
val hit = hit(e) ?: return
press = Press(hit.item.key, hit.id ?: return)
}
override fun mouseClicked(e: MouseEvent) {
if (e.clickCount == 1 && UIUtil.isActionClick(e, MouseEvent.MOUSE_CLICKED, true)) {
if (selection(e)) return
val hit = hit(e, enabled = false) ?: return
if (hit.id != null) return
if (hit.item.deleting) return
@@ -126,6 +129,10 @@ internal class ActiveListView(
override fun mouseReleased(e: MouseEvent) {
if (!UIUtil.isActionClick(e, MouseEvent.MOUSE_RELEASED, true)) return
if (selection(e)) {
press = null
return
}
val down = press ?: return
press = null
val hit = hit(e) ?: return
@@ -447,6 +454,11 @@ internal class ActiveListView(
private fun enter(): Boolean = AdvancedSettings.getBoolean(ENTER_FOCUS)
private fun selection(e: MouseEvent): Boolean {
if (list.selectionMode == ListSelectionModel.SINGLE_SELECTION) return false
return e.isShiftDown || e.isMetaDown || e.isControlDown
}
override fun getScrollableTracksViewportWidth() = true
override fun getScrollableTracksViewportHeight() = false
@@ -24,6 +24,8 @@ import ai.kilocode.rpc.dto.SessionTimeDto
import com.intellij.openapi.ui.TestDialog
import com.intellij.openapi.ui.TestDialogManager
import com.intellij.openapi.util.Disposer
import com.intellij.openapi.util.registry.Registry
import com.intellij.openapi.util.registry.RegistryKeyDescriptor
import com.intellij.testFramework.fixtures.BasePlatformTestCase
import com.intellij.util.ui.UIUtil
import kotlinx.coroutines.CompletableDeferred
@@ -58,6 +60,7 @@ class WorktreeSessionEditorManagerTest : BasePlatformTestCase() {
it.state.value = KiloWorkspaceStateDto(KiloWorkspaceStatusDto.READY)
})
workspace = workspaces.workspace(DIR)
useInactiveDisposeTimeout()
}
override fun tearDown() {
@@ -159,6 +162,25 @@ class WorktreeSessionEditorManagerTest : BasePlatformTestCase() {
assertEquals(listOf(first.id to DIR), rpc.deletes.toList())
}
fun `test deleting middle shown session falls back to next visible session`() {
val top = session("ses_top", updated = 3.0)
val mid = session("ses_mid", updated = 2.0)
val bottom = session("ses_bottom", updated = 1.0)
rpc.listed += top
rpc.listed += mid
rpc.listed += bottom
val manager = manager()
edt { manager.start() }
flush()
edt { manager.openSession(SessionRef.Local(mid), focus = false) }
edt { manager.deleteSessions(listOf(mid.id)) }
pump()
flush()
assertEquals(listOf(DIR to top.id, DIR to mid.id, DIR to bottom.id), created)
}
fun `test delete marks session deleting then removes on success`() {
val gate = CompletableDeferred<Unit>()
rpc.deleteGate = gate
@@ -251,6 +273,24 @@ class WorktreeSessionEditorManagerTest : BasePlatformTestCase() {
assertTrue(edt(block))
}
private fun useInactiveDisposeTimeout() {
Registry.mutateContributedKeys {
it + (TIMEOUT to RegistryKeyDescriptor(
TIMEOUT,
"Milliseconds before hidden session UI is disposed after switching away.",
"180000",
false,
false,
null,
null,
))
}
Disposer.register(testRootDisposable) {
Registry.mutateContributedKeys { it - TIMEOUT }
}
Registry.get(TIMEOUT).setValue(60_000, testRootDisposable)
}
private fun pump() {
com.intellij.openapi.application.ApplicationManager.getApplication().invokeAndWait {
UIUtil.dispatchAllInvocationEvents()
@@ -266,6 +306,7 @@ class WorktreeSessionEditorManagerTest : BasePlatformTestCase() {
private companion object {
const val DIR = "/repo/.kilo/worktrees/feature-x"
const val TIMEOUT = "kilo.session.inactive.disposeTimeoutMs"
}
}
@@ -179,6 +179,30 @@ class WorktreeSessionEditorPanelTest : BasePlatformTestCase() {
assertEquals(listOf(false), manager.focuses)
}
fun `test modified row click preserves multi selection`() {
rpc.listed += session("ses_1", 1.0)
rpc.listed += session("ses_2", 2.0)
edt { controller.reload() }
flush()
val list = edt { UIUtil.findComponentOfType(panel, JBList::class.java)!! }
edt {
list.setSize(400, 100)
list.doLayout()
list.selectedIndices = intArrayOf(0, 1)
val bounds = list.getCellBounds(1, 1)
val x = bounds.x + 8
val y = bounds.y + bounds.height / 2
fire(list, MouseEvent(list, MouseEvent.MOUSE_CLICKED, System.currentTimeMillis(), InputEvent.SHIFT_DOWN_MASK, x, y, 1, false, MouseEvent.BUTTON1))
assertEquals(listOf(0, 1), list.selectedIndices.toList())
fire(list, MouseEvent(list, MouseEvent.MOUSE_CLICKED, System.currentTimeMillis(), InputEvent.META_DOWN_MASK, x, y, 1, false, MouseEvent.BUTTON1))
assertEquals(listOf(0, 1), list.selectedIndices.toList())
}
assertTrue(manager.refs.isEmpty())
assertTrue(manager.focuses.isEmpty())
}
fun `test row click ignores deleting session`() {
manager.deletingIds += "ses_1"
rpc.listed += session("ses_1", 1.0)
@@ -247,6 +271,21 @@ class WorktreeSessionEditorPanelTest : BasePlatformTestCase() {
assertEquals(listOf("ses_2", "ses_1"), manager.deleted)
}
fun `test multi select hides row delete cells`() {
rpc.listed += session("ses_1", 1.0)
rpc.listed += session("ses_2", 2.0)
edt { controller.reload() }
flush()
edt { panel.selectSessions(listOf("ses_1")) }
assertEquals(listOf(DELETE_CELL), row("ses_1").cells.map { it.id })
edt { panel.selectSessions(listOf("ses_1", "ses_2")) }
assertTrue(row("ses_1").cells.isEmpty())
assertTrue(row("ses_2").cells.isEmpty())
}
fun `test delete action skips deleting selected sessions`() {
manager.deletingIds += "ses_1"
rpc.listed += session("ses_1", 1.0)
@@ -386,5 +425,6 @@ class WorktreeSessionEditorPanelTest : BasePlatformTestCase() {
private companion object {
const val DIR = "/repo/.kilo/worktrees/feature-x"
const val DELETE_CELL = "delete"
}
}