diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreePrHeaderView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreePrHeaderView.kt new file mode 100644 index 00000000000..b1e9c1de9a1 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreePrHeaderView.kt @@ -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() + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorPanel.kt index d6b21bc92b1..5ba6dc629f3 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorPanel.kt @@ -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) { @@ -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().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().get(worktree.directory) + ?: service().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?, diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeStatsView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeStatsView.kt index ab1bee72ec8..7707b8fa076 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeStatsView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeStatsView.kt @@ -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 diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/plugin/KiloPluginSettings.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/plugin/KiloPluginSettings.kt index 16641aab323..0994ac23f4d 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/plugin/KiloPluginSettings.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/plugin/KiloPluginSettings.kt @@ -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) + } } diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/layout-left-full.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/layout-left-full.svg new file mode 100644 index 00000000000..a32d27d8c88 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/layout-left-full.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/layout-left-full_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/layout-left-full_dark.svg new file mode 100644 index 00000000000..4e45249a313 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/layout-left-full_dark.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/layout-left-partial.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/layout-left-partial.svg new file mode 100644 index 00000000000..b78fa5fd9dc --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/layout-left-partial.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/layout-left-partial_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/layout-left-partial_dark.svg new file mode 100644 index 00000000000..7de8b63af77 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/layout-left-partial_dark.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties index cdfe211a04f..e17c5118c5f 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -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. diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreePrHeaderViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreePrHeaderViewTest.kt new file mode 100644 index 00000000000..0c7a2b0bdf5 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreePrHeaderViewTest.kt @@ -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().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().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() } + 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() }) + 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().single { it.icon is FilledBadgeIcon } + } + + private fun title(view: WorktreePrHeaderView): SimpleColoredComponent { + return components(view).filterIsInstance().single() + } + + private fun fragments(title: SimpleColoredComponent): List { + val out = mutableListOf() + 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 { + val out = mutableListOf() + 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 edt(block: () -> T): T = edtWait(block) +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorPanelTest.kt index aa179915b0f..f99d772fb91 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorPanelTest.kt @@ -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().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().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().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().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)