feat(jetbrains): refine worktree session header

This commit is contained in:
kirillk
2026-08-10 16:16:03 -04:00
parent 745ee1ecce
commit 17d42485b2
11 changed files with 527 additions and 30 deletions
@@ -0,0 +1,161 @@
package ai.kilocode.client.agentManager.worktree
import ai.kilocode.client.ui.FilledBadgeIcon
import ai.kilocode.client.ui.UiStyle
import ai.kilocode.client.ui.layout.HAlign
import ai.kilocode.client.ui.layout.VAlign
import ai.kilocode.client.ui.layout.align
import ai.kilocode.rpc.dto.GhState
import ai.kilocode.rpc.dto.WorktreePrDto
import ai.kilocode.rpc.dto.WorktreeStatsDto
import com.intellij.ide.BrowserUtil
import com.intellij.ui.SimpleColoredComponent
import com.intellij.ui.SimpleTextAttributes
import com.intellij.ui.components.JBLabel
import com.intellij.util.concurrency.annotations.RequiresEdt
import com.intellij.util.ui.JBUI
import com.intellij.util.ui.UIUtil
import com.intellij.util.ui.components.BorderLayoutPanel
import java.awt.BorderLayout
import java.awt.Cursor
import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent
import javax.swing.JComponent
internal class WorktreePrHeaderView(
openDiff: () -> Unit,
) : BorderLayoutPanel() {
private val status = JBLabel()
private val title = SimpleColoredComponent()
private val changes = WorktreeStatsView(openDiff)
private val statusPane = status.align(HAlign.LEFT, VAlign.CENTER)
private val changesPane = changes.align(HAlign.RIGHT, VAlign.CENTER) as JComponent
private var pull: WorktreePrDto? = null
private var state: GhState? = null
private var text: String? = null
private var tip: String? = null
private var url: String? = null
private var changesLeft = false
init {
isOpaque = false
status.border = JBUI.Borders.empty(0, UiStyle.Gap.md(), 0, UiStyle.Gap.xs())
title.border = JBUI.Borders.empty(0, UiStyle.Gap.sm())
title.isOpaque = false
changesPane.border = JBUI.Borders.emptyRight(UiStyle.Gap.pad())
addToLeft(statusPane)
addToCenter(title)
addToRight(changesPane)
val listener = object : MouseAdapter() {
override fun mouseClicked(event: MouseEvent) {
url?.let(BrowserUtil::browse)
}
}
status.addMouseListener(listener)
title.addMouseListener(listener)
syncClick(null)
}
@RequiresEdt
fun update(stats: WorktreeStatsDto?, pull: WorktreePrDto?, name: String) {
changes.update(stats, null as WorktreePrDto?)
this.pull = pull
if (pull == null) {
syncPr(false)
syncStatus(null)
clearTitle()
syncClick(null)
return
}
syncPr(true)
val title = pull.title.trim()
val text = if (title.isBlank()) null else title
val tip = prTooltip(pull, name.takeIf { it.isNotBlank() && it != pull.title.trim() })
syncStatus(pull.state)
syncTitle("#${pull.number}", text, tip)
syncClick(pull.url)
status.toolTipText = tip
}
private fun syncStatus(next: GhState?) {
if (state == next) {
val visible = next != null
if (status.isVisible != visible) status.isVisible = visible
return
}
state = next
status.icon = next?.let { FilledBadgeIcon(stateLabel(it), style(it)) }
status.isVisible = next != null
changed()
}
private fun syncPr(value: Boolean) {
if (value) {
if (statusPane.parent !== this) add(statusPane, BorderLayout.WEST)
if (title.parent !== this) add(title, BorderLayout.CENTER)
placeChanges(left = false)
title.isVisible = true
return
}
if (statusPane.parent === this) remove(statusPane)
placeChanges(left = true)
title.isVisible = false
}
private fun placeChanges(left: Boolean) {
val constraint = if (left) BorderLayout.WEST else BorderLayout.EAST
val border = if (left) JBUI.Borders.emptyLeft(UiStyle.Gap.md()) else JBUI.Borders.emptyRight(UiStyle.Gap.pad())
if (changesPane.parent === this && changesLeft == left) return
if (changesPane.parent === this) remove(changesPane)
changesLeft = left
changesPane.border = border
add(changesPane, constraint)
changed()
}
private fun clearTitle() {
if (text == null && tip == null) return
text = null
tip = null
title.clear()
title.toolTipText = null
status.toolTipText = null
changed()
}
private fun syncTitle(prefix: String, next: String?, nextTip: String?) {
val full = listOfNotNull(prefix, next).joinToString(" ")
var changed = false
if (text != full) {
text = full
title.clear()
if (next == null) {
title.append(prefix, SimpleTextAttributes(SimpleTextAttributes.STYLE_BOLD, UIUtil.getLabelForeground()))
} else {
title.append("$prefix ", SimpleTextAttributes.GRAYED_ATTRIBUTES)
title.append(next, SimpleTextAttributes(SimpleTextAttributes.STYLE_BOLD, UIUtil.getLabelForeground()))
}
changed = true
}
if (tip != nextTip) {
tip = nextTip
title.toolTipText = nextTip
if (pull == null) status.toolTipText = null
changed = true
}
if (changed) changed()
}
private fun syncClick(next: String?) {
if (url == next) return
url = next
val cursor = if (next != null) Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) else Cursor.getDefaultCursor()
status.cursor = cursor
title.cursor = cursor
}
private fun changed() {
revalidate()
repaint()
}
}
@@ -11,6 +11,7 @@ import ai.kilocode.client.session.SessionRef
import ai.kilocode.client.session.history.HistorySection
import ai.kilocode.client.session.history.HistoryTime
import ai.kilocode.client.session.history.LocalHistoryItem
import ai.kilocode.client.plugin.KiloPluginSettings
import ai.kilocode.client.util.bindTheme
import ai.kilocode.client.ui.list.ActiveList
import ai.kilocode.client.ui.list.ActiveListBadge
@@ -41,6 +42,7 @@ import com.intellij.openapi.actionSystem.UiDataProvider
import com.intellij.openapi.components.service
import com.intellij.openapi.project.Project
import com.intellij.openapi.util.Disposer
import com.intellij.openapi.util.IconLoader
import com.intellij.ui.IdeBorderFactory
import com.intellij.ui.OnePixelSplitter
import com.intellij.ui.SideBorder
@@ -51,6 +53,7 @@ import com.intellij.util.ui.components.BorderLayoutPanel
import java.awt.BorderLayout
import java.awt.Color
import javax.swing.JComponent
import javax.swing.Icon
import javax.swing.ListSelectionModel
import javax.swing.JPanel
import javax.swing.event.ListDataEvent
@@ -68,6 +71,8 @@ class WorktreeSessionEditorPanel(
private val add = NewAction()
private val rename = RenameAction()
private val delete = DeleteAction()
private val toggle = ToggleAction()
private val toolbar = ActionManager.getInstance().createActionToolbar(ActionPlaces.TOOLBAR, DefaultActionGroup(toggle, add, rename, delete), true)
private val group = ActionManager.getInstance().getAction("Kilo.WorktreeSession.RowMenu") as? ActionGroup ?: DefaultActionGroup()
private val list = ActiveList(
KiloBundle.message("worktree.session.list.empty"),
@@ -86,7 +91,8 @@ class WorktreeSessionEditorPanel(
(row as? SessionRow)?.session?.takeIf { canRename(it) || canDelete(it) }
}),
)
private val statsView = WorktreeStatsView(::openBranchDiff)
private val prHeader = WorktreePrHeaderView(::openBranchDiff)
private val splitter = OnePixelSplitter(false, 0.25f)
private var started = false
private var stats: WorktreeStatsDto? = null
private var pr: WorktreePrDto? = null
@@ -94,16 +100,15 @@ class WorktreeSessionEditorPanel(
init {
Disposer.register(parent, this)
isOpaque = true
val left = object : JPanel(BorderLayout()) {
override fun getBackground(): Color = activeListToolWindowBackground()
}
left.add(toolbar(), BorderLayout.NORTH)
left.add(list, BorderLayout.CENTER)
toolbar.targetComponent = this
toolbar.component.background = activeListToolWindowBackground()
toolbar.updateActionsImmediately()
list.installPopup(group)
val splitter = OnePixelSplitter(false, 0.25f)
splitter.firstComponent = left
splitter.firstComponent = list
splitter.secondComponent = manager.component
addToTop(header())
addToCenter(splitter)
syncExpanded(KiloPluginSettings.getWorktreeSessionListExpanded())
bindModel()
manager.onPresent = { key -> select(key) }
manager.onListChanged = {
@@ -128,7 +133,7 @@ class WorktreeSessionEditorPanel(
override fun getBackground(): Color = activeListToolWindowBackground()
@RequiresEdt
fun preferredFocus(): JComponent = list.preferredFocus()
fun preferredFocus(): JComponent = if (expanded()) list.preferredFocus() else manager.component
@RequiresEdt
fun selectSessions(keys: List<String>) {
@@ -205,24 +210,45 @@ class WorktreeSessionEditorPanel(
}
@RequiresEdt
private fun toolbar(): JComponent {
val toolbar = ActionManager.getInstance().createActionToolbar(
ActionPlaces.TOOLBAR,
DefaultActionGroup(add, rename, delete),
true,
)
toolbar.targetComponent = this
toolbar.component.background = activeListToolWindowBackground()
toolbar.updateActionsImmediately()
private fun header(): JComponent {
return object : JPanel(BorderLayout()) {
override fun getBackground(): Color = activeListToolWindowBackground()
}.apply {
border = IdeBorderFactory.createBorder(SideBorder.BOTTOM)
add(toolbar.component, BorderLayout.WEST)
add(statsView, BorderLayout.EAST)
add(toolbarPanel(), BorderLayout.WEST)
add(prHeader, BorderLayout.CENTER)
}
}
@RequiresEdt
private fun toolbarPanel(): JComponent {
return object : JPanel(BorderLayout()) {
override fun getBackground(): Color = activeListToolWindowBackground()
}.apply {
border = IdeBorderFactory.createBorder(SideBorder.RIGHT)
add(toolbar.component, BorderLayout.CENTER)
}
}
@RequiresEdt
private fun toggleExpanded() {
syncExpanded(!expanded())
KiloPluginSettings.setWorktreeSessionListExpanded(expanded())
}
@RequiresEdt
private fun expanded(): Boolean = splitter.firstComponent != null
@RequiresEdt
private fun syncExpanded(value: Boolean) {
val changed = expanded() != value
if (changed) splitter.firstComponent = if (value) list else null
toolbar.updateActionsImmediately()
if (!changed) return
splitter.revalidate()
splitter.repaint()
}
@RequiresEdt
private fun openBranchDiff() {
val target = project ?: return
@@ -288,16 +314,33 @@ class WorktreeSessionEditorPanel(
}
private fun bindStatus() {
val target = project ?: return
val key = normalizeWorktreePath(worktree.directory)
syncHeader()
service<WorktreeNameCache>().addListener(this) { path, _ ->
if (normalizeWorktreePath(path) == key) syncHeader()
}
val target = project ?: return
WorktreeStatusBinding(
target,
this,
onStats = { value -> stats = value[key]; statsView.update(stats, pr) },
onPr = { value -> pr = value[key]; statsView.update(stats, pr) },
onStats = { value -> stats = value[key]; syncHeader() },
onPr = { value -> pr = value[key]; syncHeader() },
)
}
@RequiresEdt
private fun syncHeader() {
prHeader.update(stats, pr, worktreeName())
}
@RequiresEdt
private fun worktreeName(): String {
val key = normalizeWorktreePath(worktree.directory)
return service<WorktreeNameCache>().get(worktree.directory)
?: service<WorktreeNameCache>().get(key)
?: key.trimEnd('/').substringAfterLast('/').ifBlank { key }
}
override fun uiDataSnapshot(sink: DataSink) {
sink[WorktreeSessionDataKeys.PANEL] = this
selectedSession()?.let { sink[WorktreeSessionDataKeys.SESSION] = it }
@@ -310,6 +353,20 @@ class WorktreeSessionEditorPanel(
manager.onListChanged = null
}
private inner class ToggleAction : AnAction() {
override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.EDT
override fun update(e: AnActionEvent) {
val expanded = expanded()
e.presentation.text = KiloBundle.message(if (expanded) "worktree.session.list.collapse" else "worktree.session.list.expand")
e.presentation.icon = if (expanded) LAYOUT_FULL else LAYOUT_PARTIAL
}
override fun actionPerformed(e: AnActionEvent) {
toggleExpanded()
}
}
private inner class NewAction : AnAction(
KiloBundle.message("worktree.session.new.action"),
null,
@@ -330,6 +387,7 @@ class WorktreeSessionEditorPanel(
override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.EDT
override fun update(e: AnActionEvent) {
e.presentation.isVisible = expanded()
e.presentation.isEnabled = selectedKeys().isNotEmpty()
}
@@ -346,6 +404,7 @@ class WorktreeSessionEditorPanel(
override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.EDT
override fun update(e: AnActionEvent) {
e.presentation.isVisible = expanded()
e.presentation.isEnabled = selectedKeys().any { it != SessionHost.NEW && it !in manager.deleting() }
}
@@ -362,6 +421,11 @@ class WorktreeSessionEditorPanel(
override val section: String get() = HistoryTime.title(HistorySection.TODAY)
}
private companion object {
val LAYOUT_PARTIAL: Icon = IconLoader.getIcon("/icons/layout-left-partial.svg", WorktreeSessionEditorPanel::class.java)
val LAYOUT_FULL: Icon = IconLoader.getIcon("/icons/layout-left-full.svg", WorktreeSessionEditorPanel::class.java)
}
private inner class SessionRow(
val session: SessionDto,
val kind: SessionActivityKind?,
@@ -19,6 +19,8 @@ import com.intellij.util.ui.JBFont
import com.intellij.util.ui.JBUI
import com.intellij.xml.util.XmlStringUtil
import java.awt.BorderLayout
import java.awt.Component
import java.awt.Container
import java.awt.Cursor
import java.awt.Dimension
import java.awt.event.MouseAdapter
@@ -57,12 +59,12 @@ internal class WorktreeStatsView(
changeHit.act = openDiff
prHit.act = { url?.let(BrowserUtil::browse) }
diff.toolTipText = KiloBundle.message("worktree.stats.tooltip", 0, 0, 0, 0)
changeHit.addMouseListener(object : MouseAdapter() {
installClick(changeHit, object : MouseAdapter() {
override fun mouseClicked(event: MouseEvent) {
changeHit.act?.invoke()
}
})
prHit.addMouseListener(object : MouseAdapter() {
installClick(prHit, object : MouseAdapter() {
override fun mouseClicked(event: MouseEvent) {
prHit.act?.invoke()
}
@@ -126,13 +128,23 @@ internal class WorktreeStatsView(
}
private fun applyCursors() {
changeHit.cursor = actionCursor(changeHit.act != null)
prHit.cursor = actionCursor(prHit.act != null)
applyCursor(changeHit, changeHit.act != null)
applyCursor(prHit, prHit.act != null)
}
private fun actionCursor(active: Boolean) =
if (active) Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) else Cursor.getDefaultCursor()
private fun installClick(comp: Component, listener: MouseAdapter) {
comp.addMouseListener(listener)
if (comp is Container) comp.components.forEach { installClick(it, listener) }
}
private fun applyCursor(comp: Component, active: Boolean) {
comp.cursor = actionCursor(active)
if (comp is Container) comp.components.forEach { applyCursor(it, active) }
}
override fun getPreferredSize(): Dimension {
val ins = insets
val size = row.preferredSize
@@ -5,6 +5,7 @@ import com.intellij.ide.util.PropertiesComponent
object KiloPluginSettings {
private const val AUTO_APPROVE_KEY = "kilo.session.autoApprove"
private const val PERMISSION_RULES_EXPANDED_KEY = "kilo.session.permissionRulesExpanded"
private const val WORKTREE_SESSION_LIST_EXPANDED_KEY = "kilo.worktree.sessionListExpanded"
fun getAutoApprove(): Boolean = PropertiesComponent.getInstance().getBoolean(AUTO_APPROVE_KEY, false)
@@ -25,4 +26,14 @@ object KiloPluginSettings {
internal fun unsetPermissionRulesExpanded() {
PropertiesComponent.getInstance().unsetValue(PERMISSION_RULES_EXPANDED_KEY)
}
fun getWorktreeSessionListExpanded(): Boolean = PropertiesComponent.getInstance().getBoolean(WORKTREE_SESSION_LIST_EXPANDED_KEY, true)
fun setWorktreeSessionListExpanded(value: Boolean) {
PropertiesComponent.getInstance().setValue(WORKTREE_SESSION_LIST_EXPANDED_KEY, value.toString())
}
internal fun unsetWorktreeSessionListExpanded() {
PropertiesComponent.getInstance().unsetValue(WORKTREE_SESSION_LIST_EXPANDED_KEY)
}
}
@@ -0,0 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="3" y="3" width="3.5" height="10" fill="#6C707E"/>
<path d="M2.5 2.5H13.5V13.5H2.5V2.5Z" stroke="#6C707E" stroke-width="1" stroke-linecap="square" stroke-linejoin="round"/>
<path d="M6.5 2.5V13.5" stroke="#6C707E" stroke-width="1" stroke-linecap="square"/>
</svg>

After

Width:  |  Height:  |  Size: 374 B

@@ -0,0 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="3" y="3" width="3.5" height="10" fill="#CED0D6"/>
<path d="M2.5 2.5H13.5V13.5H2.5V2.5Z" stroke="#CED0D6" stroke-width="1" stroke-linecap="square" stroke-linejoin="round"/>
<path d="M6.5 2.5V13.5" stroke="#CED0D6" stroke-width="1" stroke-linecap="square"/>
</svg>

After

Width:  |  Height:  |  Size: 374 B

@@ -0,0 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="3" y="3" width="3.5" height="10" fill="#EBECF0"/>
<path d="M2.5 2.5H13.5V13.5H2.5V2.5Z" stroke="#6C707E" stroke-width="1" stroke-linecap="square" stroke-linejoin="round"/>
<path d="M6.5 2.5V13.5" stroke="#6C707E" stroke-width="1" stroke-linecap="square"/>
</svg>

After

Width:  |  Height:  |  Size: 374 B

@@ -0,0 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="3" y="3" width="3.5" height="10" fill="#43454A"/>
<path d="M2.5 2.5H13.5V13.5H2.5V2.5Z" stroke="#CED0D6" stroke-width="1" stroke-linecap="square" stroke-linejoin="round"/>
<path d="M6.5 2.5V13.5" stroke="#CED0D6" stroke-width="1" stroke-linecap="square"/>
</svg>

After

Width:  |  Height:  |  Size: 374 B

@@ -353,6 +353,8 @@ worktree.session.list.empty=No sessions
worktree.session.new.action=New session
worktree.session.rename.action=Rename session
worktree.session.delete.action=Delete session
worktree.session.list.expand=Show sessions
worktree.session.list.collapse=Hide sessions
worktree.session.delete.confirm.message=Delete session "{0}"?
worktree.session.delete.confirm.message.multiple=Delete {0} sessions?
worktree.session.delete.confirm.detail=This permanently removes the session.
@@ -0,0 +1,165 @@
package ai.kilocode.client.agentManager.worktree
import ai.kilocode.client.ui.FilledBadgeIcon
import ai.kilocode.client.util.edtWait
import ai.kilocode.rpc.dto.GhState
import ai.kilocode.rpc.dto.WorktreePrDto
import ai.kilocode.rpc.dto.WorktreeStatsDto
import com.intellij.testFramework.fixtures.BasePlatformTestCase
import com.intellij.ui.SimpleColoredComponent
import com.intellij.ui.SimpleTextAttributes
import com.intellij.ui.components.JBLabel
import com.intellij.util.ui.UIUtil
import java.awt.Container
import java.awt.Cursor
import java.awt.Point
import java.awt.event.InputEvent
import java.awt.event.MouseEvent
import javax.swing.JComponent
class WorktreePrHeaderViewTest : BasePlatformTestCase() {
fun `test PR state title and colors render`() {
val view = edt { WorktreePrHeaderView {} }
val pull = pull(GhState.OPEN)
edt { view.update(null, pull, "friendly-name") }
val badge = edt { badge(view) }
val title = edt { title(view) }
val fragments = edt { fragments(title) }
assertEquals(stateLabel(GhState.OPEN), (badge.icon as FilledBadgeIcon).text)
assertSame(style(GhState.OPEN), (badge.icon as FilledBadgeIcon).style)
assertTrue(badge.isVisible)
assertEquals(listOf("#123 ", "Implement header"), fragments.map { it.text })
assertEquals(SimpleTextAttributes.GRAYED_ATTRIBUTES.fgColor, fragments[0].attrs.fgColor)
assertEquals(SimpleTextAttributes.STYLE_BOLD, fragments[1].attrs.style)
assertEquals(Cursor.HAND_CURSOR, title.cursor.type)
assertEquals(Cursor.HAND_CURSOR, badge.cursor.type)
assertTrue(title.toolTipText.contains("Open #123 Implement header"))
}
fun `test PR states use shared badge styles`() {
val view = edt { WorktreePrHeaderView {} }
GhState.entries.forEach { state ->
edt { view.update(null, pull(state), "worktree") }
val icon = edt { badge(view).icon as FilledBadgeIcon }
assertEquals(stateLabel(state), icon.text)
assertSame(style(state), icon.style)
}
}
fun `test no PR hides status and title`() {
val view = edt { WorktreePrHeaderView {} }
edt { view.update(null, null, "feature-x") }
assertNull(edt { components(view).filterIsInstance<JBLabel>().firstOrNull { it.icon is FilledBadgeIcon } })
val title = edt { title(view) }
assertFalse(edt { title.isVisible })
assertTrue(edt { fragments(title).isEmpty() })
assertEquals(Cursor.DEFAULT_CURSOR, title.cursor.type)
}
fun `test no PR aligns changes badge to the left`() {
val view = edt { WorktreePrHeaderView {} }
val changes = edt { UIUtil.findComponentOfType(view, WorktreeStatsView::class.java)!! }
edt {
view.update(WorktreeStatsDto("/repo", additions = 2), null, "feature-x")
view.setSize(400, 32)
view.doLayout()
}
assertTrue(edt { changes.parent.x < 20 })
}
fun `test changes view visibility follows stats`() {
val view = edt { WorktreePrHeaderView {} }
val changes = edt { UIUtil.findComponentOfType(view, WorktreeStatsView::class.java)!! }
edt { view.update(WorktreeStatsDto("/repo"), null, "feature-x") }
assertFalse(edt { changes.isVisible })
edt { view.update(WorktreeStatsDto("/repo", additions = 2, deletions = 1, ahead = 1, behind = 1), null, "feature-x") }
assertTrue(edt { changes.isVisible })
}
fun `test nested changes badge click opens diff`() {
var opened = 0
val view = edt { WorktreePrHeaderView { opened++ } }
edt { view.update(WorktreeStatsDto("/repo", additions = 2), null, "feature-x") }
edt { click(components(view).filterIsInstance<JBLabel>().single { it.text == "+2" }) }
assertEquals(1, opened)
}
fun `test repeated update keeps child instances`() {
val view = edt { WorktreePrHeaderView {} }
val stats = WorktreeStatsDto("/repo", additions = 2)
val pull = pull(GhState.DRAFT)
edt { view.update(stats, pull, "feature-x") }
val labels = edt { components(view).filterIsInstance<JBLabel>() }
val title = edt { title(view) }
val changes = edt { UIUtil.findComponentOfType(view, WorktreeStatsView::class.java)!! }
edt { view.update(stats, pull, "feature-x") }
assertEquals(labels, edt { components(view).filterIsInstance<JBLabel>() })
assertSame(title, edt { title(view) })
assertSame(changes, edt { UIUtil.findComponentOfType(view, WorktreeStatsView::class.java) })
}
private fun pull(state: GhState) = WorktreePrDto(
path = "/repo",
number = 123,
state = state,
url = "https://github.com/kilo/test/pull/123",
title = "Implement header",
)
private fun badge(view: WorktreePrHeaderView): JBLabel {
return components(view).filterIsInstance<JBLabel>().single { it.icon is FilledBadgeIcon }
}
private fun title(view: WorktreePrHeaderView): SimpleColoredComponent {
return components(view).filterIsInstance<SimpleColoredComponent>().single()
}
private fun fragments(title: SimpleColoredComponent): List<Fragment> {
val out = mutableListOf<Fragment>()
val iter = title.iterator()
while (iter.hasNext()) {
iter.next()
out += Fragment(iter.fragment, iter.textAttributes)
}
return out
}
private data class Fragment(val text: String, val attrs: SimpleTextAttributes)
private fun components(root: java.awt.Component): List<java.awt.Component> {
val out = mutableListOf<java.awt.Component>()
fun visit(item: java.awt.Component) {
out += item
if (item is Container) item.components.forEach { visit(it) }
}
visit(root)
return out
}
private fun click(target: JComponent) {
target.setSize(target.preferredSize)
val point = Point(target.width.coerceAtLeast(2) / 2, target.height.coerceAtLeast(2) / 2)
listOf(
MouseEvent(target, MouseEvent.MOUSE_PRESSED, System.currentTimeMillis(), InputEvent.BUTTON1_DOWN_MASK, point.x, point.y, 1, false, MouseEvent.BUTTON1),
MouseEvent(target, MouseEvent.MOUSE_RELEASED, System.currentTimeMillis(), 0, point.x, point.y, 1, false, MouseEvent.BUTTON1),
MouseEvent(target, MouseEvent.MOUSE_CLICKED, System.currentTimeMillis(), 0, point.x, point.y, 1, false, MouseEvent.BUTTON1),
).forEach(target::dispatchEvent)
UIUtil.dispatchAllInvocationEvents()
}
private fun <T> edt(block: () -> T): T = edtWait(block)
}
@@ -12,6 +12,8 @@ import ai.kilocode.client.session.history.LocalHistoryItem
import ai.kilocode.client.testing.FakeSessionRpcApi
import ai.kilocode.client.testing.TestCoroutines
import ai.kilocode.client.testing.fire
import ai.kilocode.client.plugin.KiloPluginSettings
import ai.kilocode.client.ui.list.ActiveList
import ai.kilocode.client.ui.list.ActiveListBadge
import ai.kilocode.client.ui.list.ActiveListItem
import ai.kilocode.client.ui.list.activeListSectionTitle
@@ -56,6 +58,7 @@ class WorktreeSessionEditorPanelTest : BasePlatformTestCase() {
override fun setUp() {
super.setUp()
KiloPluginSettings.unsetWorktreeSessionListExpanded()
coroutines = TestCoroutines()
rpc = FakeSessionRpcApi()
sessions = KiloSessionService(project, coroutines.scope, rpc)
@@ -67,6 +70,7 @@ class WorktreeSessionEditorPanelTest : BasePlatformTestCase() {
override fun tearDown() {
try {
TestDialogManager.setTestDialog(TestDialog.DEFAULT)
KiloPluginSettings.unsetWorktreeSessionListExpanded()
coroutines.close(::pump)
} finally {
super.tearDown()
@@ -76,9 +80,12 @@ class WorktreeSessionEditorPanelTest : BasePlatformTestCase() {
fun `test panel builds splitter toolbar list and right component`() {
edt {
val splitter = UIUtil.findComponentOfType(panel, OnePixelSplitter::class.java)!!
assertSame(UIUtil.findComponentOfType(panel, ActiveList::class.java), splitter.firstComponent)
assertSame(manager.component, splitter.secondComponent)
assertEquals(0.25f, splitter.proportion, 0.01f)
assertNotNull(UIUtil.findComponentOfType(panel, WorktreePrHeaderView::class.java))
val buttons = components(panel).filterIsInstance<ActionButton>().mapNotNull { it.presentation.text }
assertTrue(buttons.contains("Hide sessions"))
assertTrue(buttons.contains("New session"))
assertTrue(buttons.contains("Rename session"))
assertTrue(buttons.contains("Delete session"))
@@ -92,7 +99,8 @@ class WorktreeSessionEditorPanelTest : BasePlatformTestCase() {
val scroll = edt { SwingUtilities.getAncestorOfClass(JBScrollPane::class.java, list) as JBScrollPane }
val button = edt { components(panel).filterIsInstance<ActionButton>().single { it.presentation.text == "New session" } }
val toolbar = edt { button.parent }
val row = edt { toolbar.parent }
val toolbarPanel = edt { toolbar.parent as JComponent }
val header = edt { UIUtil.findComponentOfType(panel, WorktreePrHeaderView::class.java)!!.parent as JComponent }
assertEquals(activeListToolWindowBackground(), edt { panel.background })
assertNull(edt { panel.border })
@@ -101,10 +109,53 @@ class WorktreeSessionEditorPanelTest : BasePlatformTestCase() {
assertEquals(activeListToolWindowBackground(), edt { scroll.viewport.background })
assertEquals(activeListToolWindowBackground(), edt { (scroll.viewport.view as JComponent).background })
assertEquals(activeListToolWindowBackground(), edt { toolbar.background })
assertEquals(activeListToolWindowBackground(), edt { row.background })
assertEquals(activeListToolWindowBackground(), edt { toolbarPanel.background })
assertEquals(activeListToolWindowBackground(), edt { header.background })
assertEquals(0, edt { scroll.border.getBorderInsets(scroll).left })
assertEquals(0, edt { scroll.viewportBorder.getBorderInsets(scroll).left })
assertTrue(edt { (row as JComponent).border.getBorderInsets(row).bottom > 0 })
assertTrue(edt { toolbarPanel.border.getBorderInsets(toolbarPanel).right > 0 })
assertTrue(edt { header.border.getBorderInsets(header).bottom > 0 })
}
fun `test expand collapse hides session list and edit toolbar but keeps header actions`() {
edt {
val splitter = UIUtil.findComponentOfType(panel, OnePixelSplitter::class.java)!!
val list = splitter.firstComponent
assertSame(list, splitter.firstComponent)
assertTrue(shown("Rename session"))
assertTrue(shown("Delete session"))
toggle().click()
assertNull(splitter.firstComponent)
assertTrue(shown("New session"))
assertFalse(shown("Rename session"))
assertFalse(shown("Delete session"))
assertNotNull(UIUtil.findComponentOfType(panel, WorktreePrHeaderView::class.java))
assertFalse(KiloPluginSettings.getWorktreeSessionListExpanded())
toggle().click()
assertSame(list, splitter.firstComponent)
assertTrue(shown("Rename session"))
assertTrue(shown("Delete session"))
assertTrue(KiloPluginSettings.getWorktreeSessionListExpanded())
}
}
fun `test collapsed state persists for new panels`() {
edt { toggle().click() }
val view = edt { WorktreeSessionEditorPanel(testRootDisposable, manager, controller, workspace, confirm = { _, _, run -> run() }) }
edt {
val splitter = UIUtil.findComponentOfType(view, OnePixelSplitter::class.java)!!
assertNull(splitter.firstComponent)
assertTrue(shown(view, "New session"))
assertFalse(shown(view, "Rename session"))
assertFalse(shown(view, "Delete session"))
}
}
fun `test editor kind delegates preferred focus to panel`() {
@@ -417,6 +468,17 @@ class WorktreeSessionEditorPanelTest : BasePlatformTestCase() {
return out
}
private fun toggle(root: java.awt.Component = panel): ActionButton {
val actions = setOf("New session", "Rename session", "Delete session")
return components(root).filterIsInstance<ActionButton>().first { it.presentation.text !in actions }
}
private fun shown(text: String): Boolean = shown(panel, text)
private fun shown(root: java.awt.Component, text: String): Boolean {
return components(root).filterIsInstance<ActionButton>().firstOrNull { it.presentation.text == text }?.isVisible == true
}
private fun click(target: javax.swing.JComponent) {
target.setSize(target.preferredSize)
val point = Point(target.width.coerceAtLeast(2) / 2, target.height.coerceAtLeast(2) / 2)