fix(jetbrains): align worktree action badges

This commit is contained in:
kirillk
2026-08-07 15:26:50 -04:00
parent b2015b8642
commit c8694bcffd
10 changed files with 160 additions and 20 deletions
@@ -0,0 +1,23 @@
package ai.kilocode.client.actions
import ai.kilocode.client.agentManager.SidePanelKeys
import ai.kilocode.client.agentManager.worktree.WorktreeDataKeys
import com.intellij.openapi.actionSystem.ActionUpdateThread
import com.intellij.openapi.actionSystem.AnAction
import com.intellij.openapi.actionSystem.AnActionEvent
class OpenWorktreeDiffAction : AnAction() {
override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.EDT
override fun update(e: AnActionEvent) {
val panel = e.getData(SidePanelKeys.WORKTREE_PANEL)
val item = e.getData(WorktreeDataKeys.WORKTREE)
e.presentation.isEnabledAndVisible = panel != null && panel.canOpenDiff(item)
}
override fun actionPerformed(e: AnActionEvent) {
val panel = e.getData(SidePanelKeys.WORKTREE_PANEL) ?: return
val item = e.getData(WorktreeDataKeys.WORKTREE) ?: return
if (panel.canOpenDiff(item)) panel.openDiff(item)
}
}
@@ -0,0 +1,23 @@
package ai.kilocode.client.actions
import ai.kilocode.client.agentManager.SidePanelKeys
import ai.kilocode.client.agentManager.worktree.WorktreeDataKeys
import com.intellij.openapi.actionSystem.ActionUpdateThread
import com.intellij.openapi.actionSystem.AnAction
import com.intellij.openapi.actionSystem.AnActionEvent
class OpenWorktreePrAction : AnAction() {
override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.EDT
override fun update(e: AnActionEvent) {
val panel = e.getData(SidePanelKeys.WORKTREE_PANEL)
val item = e.getData(WorktreeDataKeys.WORKTREE)
e.presentation.isEnabledAndVisible = panel != null && panel.canOpenPr(item)
}
override fun actionPerformed(e: AnActionEvent) {
val panel = e.getData(SidePanelKeys.WORKTREE_PANEL) ?: return
val item = e.getData(WorktreeDataKeys.WORKTREE) ?: return
if (panel.canOpenPr(item)) panel.openPr(item)
}
}
@@ -98,7 +98,7 @@ class AgentManagerPanel(
},
onSelect = { selectedRow()?.dto?.id?.let { selected = it } },
menu = ActiveListMenu(WorktreeDataKeys.WORKTREE, group, element = { row ->
(row as? WorktreeRow)?.dto?.takeIf { canRename(it) || canDelete(it) }
(row as? WorktreeRow)?.dto?.takeIf { canRename(it) || canDelete(it) || canOpenPr(it) || canOpenDiff(it) }
}),
)
private var selected: String? = null
@@ -206,6 +206,26 @@ class AgentManagerPanel(
internal fun canDelete(item: WorktreeDto?): Boolean = deletable(item)
internal fun canOpenPr(item: WorktreeDto?): Boolean = prUrl(item) != null
internal fun openPr(item: WorktreeDto) = prUrl(item)?.let { BrowserUtil.browse(it) }
/** The PR URL for [item], or null when it has none or is not in a stable, openable state. */
private fun prUrl(item: WorktreeDto?): String? {
if (item == null || item.main) return null
if (controller.isPending(item.id) || controller.isDeleting(item.id)) return null
return prs[normalizeWorktreePath(item.path)]?.url
}
internal fun canOpenDiff(item: WorktreeDto?): Boolean {
if (item == null || item.main || project == null) return false
return !controller.isPending(item.id) && !controller.isDeleting(item.id)
}
internal fun openDiff(item: WorktreeDto) {
if (canOpenDiff(item)) openBranchDiff(item.path)
}
private fun showDeletePopup(item: WorktreeDto, cell: String? = null) {
val opts = ActiveListDeleteOptions(
message = KiloBundle.message("worktree.delete.confirm.message", item.name),
@@ -8,10 +8,7 @@ import ai.kilocode.client.ui.list.ACTIVE_LIST_CHANGES_CELL
import ai.kilocode.client.ui.list.ACTIVE_LIST_PR_CELL
import ai.kilocode.client.ui.list.ActiveListBadge
import ai.kilocode.client.ui.list.ActiveListHitCell
import ai.kilocode.client.ui.layout.HAlign
import ai.kilocode.client.ui.layout.Stack
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
@@ -47,10 +44,9 @@ internal class WorktreeStatsView(
// ids back and routes the click. Standalone (toolbar) usage keeps its own listeners below.
private val changeHit = HitRegion(ACTIVE_LIST_CHANGES_CELL).apply { add(change, BorderLayout.CENTER) }
private val prHit = HitRegion(ACTIVE_LIST_PR_CELL).apply { add(pr, BorderLayout.CENTER) }
// Change badge and PR link stack vertically, each pinned to the trailing edge.
private val changeLine = changeHit.align(HAlign.RIGHT, VAlign.CENTER)
private val prLine = prHit.align(HAlign.RIGHT, VAlign.CENTER)
private val row = Stack.vertical(UiStyle.Gap.sm()).next(changeLine).next(prLine)
// Change badge and PR link share one row: the changes trail first, the PR link is pinned last so
// it is always the rightmost element.
private val row = Stack.horizontal(UiStyle.Gap.md()).next(changeHit).next(prHit)
private var url: String? = null
private var stats: WorktreeStatsDto? = null
private var pull: WorktreePrDto? = null
@@ -59,7 +55,7 @@ internal class WorktreeStatsView(
add(row)
changeHit.act = openDiff
prHit.act = { url?.let(BrowserUtil::browse) }
diff.toolTipText = KiloBundle.message("worktree.stats.diff.tooltip", 0, 0)
diff.toolTipText = KiloBundle.message("worktree.stats.tooltip", 0, 0, 0, 0)
changeHit.addMouseListener(object : MouseAdapter() {
override fun mouseClicked(event: MouseEvent) {
changeHit.act?.invoke()
@@ -107,18 +103,20 @@ internal class WorktreeStatsView(
ahead.isVisible = s.ahead > 0
diff.update(s.additions, s.deletions)
diff.isVisible = s.additions > 0 || s.deletions > 0
val diffTip = KiloBundle.message("worktree.stats.diff.tooltip", s.additions, s.deletions)
diff.toolTipText = diffTip
changeHit.tip = diffTip
// One descriptive tooltip for the whole changes region: ahead/behind, the +/- diff, and what
// clicking does.
val changeTip = KiloBundle.message("worktree.stats.tooltip", s.ahead, s.behind, s.additions, s.deletions)
diff.toolTipText = changeTip
changeHit.tip = changeTip
changeHit.toolTipText = changeTip
url = link
pr.icon = badge?.let { FilledBadgeIcon(it.text, it.style) }
pr.toolTipText = tip
prHit.tip = tip
prHit.toolTipText = tip
pr.isVisible = badge != null
val changesVisible = behind.isVisible || ahead.isVisible || diff.isVisible
changeLine.isVisible = changesVisible
changeHit.isVisible = changesVisible
prLine.isVisible = pr.isVisible
prHit.isVisible = pr.isVisible
isVisible = changesVisible || pr.isVisible
applyCursors()
@@ -85,14 +85,20 @@ internal class ActiveListRenderer(
// instead of drifting to the far right of the row.
private val header = titleGroup.align(HAlign.LEFT, VAlign.CENTER)
private val desc = JBLabel()
private val text = Stack.vertical().next(header).next(desc)
private val metrics = WorktreeStatsView(fill = false)
private val metricsPane = metrics.align(HAlign.RIGHT, VAlign.CENTER)
// The description (branch) line carries the changes/PR metrics on its trailing edge so they sit
// on the branch row instead of spanning the full row height.
private val descLine = JPanel(BorderLayout(UiStyle.Gap.md(), 0)).apply {
add(desc, BorderLayout.CENTER)
add(metricsPane, BorderLayout.EAST)
}
private val text = Stack.vertical().next(header).next(descLine)
private val textPane = text.align(HAlign.TRACK, VAlign.CENTER)
private val trail = JBLabel().apply { horizontalAlignment = SwingConstants.RIGHT }
private val metrics = WorktreeStatsView(fill = false)
private val trailPane = trail.align(HAlign.RIGHT, VAlign.CENTER)
private val endPane = JPanel(BorderLayout()).apply {
add(trailPane, BorderLayout.CENTER)
add(metrics, BorderLayout.EAST)
}
private val cells = Stack.horizontal(activeListCellGap())
private val cellPane = cells.align(HAlign.RIGHT, VAlign.CENTER)
@@ -135,8 +141,10 @@ internal class ActiveListRenderer(
text,
textPane,
desc,
trail,
descLine,
metrics,
metricsPane,
trail,
trailPane,
endPane,
cells,
@@ -214,6 +222,11 @@ internal class ActiveListRenderer(
trail.text = end
trail.isVisible = end.isNotBlank() && data == null
metrics.isVisible = data != null && !value.deleting
// Hide the wrapper too so a metrics-less row does not reserve the trailing gap on its
// description, and collapse the whole second row when it would be empty so title-only rows
// stay vertically centered.
metricsPane.isVisible = metrics.isVisible
descLine.isVisible = desc.isVisible || metrics.isVisible
trail.foreground = weak
val hovered = (list as? ActiveListActive)?.hoveredIndex() == index
@@ -241,6 +241,12 @@
<action id="Kilo.Worktree.Delete"
class="ai.kilocode.client.actions.DeleteWorktreeAction"/>
<action id="Kilo.Worktree.OpenPr"
class="ai.kilocode.client.actions.OpenWorktreePrAction"/>
<action id="Kilo.Worktree.OpenDiff"
class="ai.kilocode.client.actions.OpenWorktreeDiffAction"/>
<action id="Kilo.WorktreeSession.Rename"
class="ai.kilocode.client.actions.RenameWorktreeSessionAction"/>
@@ -250,6 +256,9 @@
<group id="Kilo.Worktree.RowMenu">
<reference ref="Kilo.Worktree.Rename"/>
<separator/>
<reference ref="Kilo.Worktree.OpenPr"/>
<reference ref="Kilo.Worktree.OpenDiff"/>
<separator/>
<reference ref="Kilo.Worktree.Delete"/>
</group>
@@ -369,7 +369,8 @@ worktree.configure.branch.required=Branch name is required
worktree.stats.diff.tooltip={0} additions, {1} deletions
worktree.stats.ahead.tooltip=Commits ahead of base branch
worktree.stats.behind.tooltip=Commits behind base branch
worktree.pr.tooltip=Pull request #{0} ({1})
worktree.stats.tooltip={0} commits ahead, {1} behind, +{2} -{3}. Click to open the diff against the base branch.
worktree.pr.tooltip=Pull request #{0} ({1}). Click to open it in your browser.
worktree.gh.missing.title=GitHub CLI not found
worktree.gh.missing.content=Install gh to show pull request badges for worktrees.
worktree.gh.install=Install
@@ -794,6 +795,10 @@ action.Kilo.Worktree.Rename.text=Rename
action.Kilo.Worktree.Rename.description=Rename the selected worktree
action.Kilo.Worktree.Delete.text=Delete
action.Kilo.Worktree.Delete.description=Delete the selected worktree
action.Kilo.Worktree.OpenPr.text=Open Pull Request
action.Kilo.Worktree.OpenPr.description=Open the pull request in your browser
action.Kilo.Worktree.OpenDiff.text=Open Diff
action.Kilo.Worktree.OpenDiff.description=Open the diff against the base branch
action.Kilo.WorktreeSession.Rename.text=Rename
action.Kilo.WorktreeSession.Rename.description=Rename the selected worktree session
action.Kilo.WorktreeSession.Delete.text=Delete
@@ -341,6 +341,8 @@ class HistorySessionActionsTest : BasePlatformTestCase() {
assertTrue(xml.contains("id=\"Kilo.Session.Delete\""))
assertTrue(xml.contains("id=\"Kilo.Worktree.Rename\""))
assertTrue(xml.contains("id=\"Kilo.Worktree.Delete\""))
assertTrue(xml.contains("id=\"Kilo.Worktree.OpenPr\""))
assertTrue(xml.contains("id=\"Kilo.Worktree.OpenDiff\""))
assertTrue(xml.contains("id=\"Kilo.WorktreeSession.Rename\""))
assertTrue(xml.contains("id=\"Kilo.WorktreeSession.Delete\""))
assertTrue(xml.contains("id=\"Kilo.Worktree.RowMenu\""))
@@ -352,9 +354,18 @@ class HistorySessionActionsTest : BasePlatformTestCase() {
assertTrue(xml.contains("ref=\"Kilo.Session.Delete\""))
assertTrue(xml.contains("ref=\"Kilo.Worktree.Rename\""))
assertTrue(xml.contains("ref=\"Kilo.Worktree.Delete\""))
assertTrue(xml.contains("ref=\"Kilo.Worktree.OpenPr\""))
assertTrue(xml.contains("ref=\"Kilo.Worktree.OpenDiff\""))
assertTrue(xml.contains("ref=\"Kilo.WorktreeSession.Rename\""))
assertTrue(xml.contains("ref=\"Kilo.WorktreeSession.Delete\""))
assertTrue(xml.contains("ref=\"${'$'}Copy\""))
// Row menu order: rename, (separator), open pr, open diff, (separator), delete.
val rename = xml.indexOf("ref=\"Kilo.Worktree.Rename\"")
val openPr = xml.indexOf("ref=\"Kilo.Worktree.OpenPr\"")
val openDiff = xml.indexOf("ref=\"Kilo.Worktree.OpenDiff\"")
val delete = xml.indexOf("ref=\"Kilo.Worktree.Delete\"")
assertTrue(rename in 0 until openPr && openPr < openDiff && openDiff < delete)
}
// ------ Helpers ------
@@ -9,6 +9,7 @@ import ai.kilocode.client.agentManager.worktree.WorktreeSessionEditorKind
import ai.kilocode.client.agentManager.worktree.WorktreeStatusService
import ai.kilocode.client.agentManager.worktree.ensureWorktreeSessionEditorKind
import ai.kilocode.client.agentManager.worktree.worktreeSessionParams
import ai.kilocode.client.diff.KiloDiffEditorKind
import ai.kilocode.client.testing.FakeWorktreeRpcApi
import ai.kilocode.client.testing.TestCoroutines
import ai.kilocode.client.testing.TestUiTimers
@@ -22,7 +23,11 @@ import ai.kilocode.client.vfs.KiloPath
import ai.kilocode.client.vfs.KiloVfsManager
import ai.kilocode.client.vfs.KiloVirtualFile
import ai.kilocode.client.vfs.KiloVirtualFileSystem
import ai.kilocode.rpc.dto.GhAvailability
import ai.kilocode.rpc.dto.GhState
import ai.kilocode.rpc.dto.WorktreeDto
import ai.kilocode.rpc.dto.WorktreePrDto
import ai.kilocode.rpc.dto.WorktreePrListDto
import ai.kilocode.rpc.dto.WorktreeStatsDto
import ai.kilocode.rpc.dto.WorktreeStatsListDto
import ai.kilocode.rpc.dto.SessionActivityDto
@@ -416,6 +421,39 @@ class AgentManagerPanelTest : BasePlatformTestCase() {
assertEquals(3, metrics.behind)
}
fun `test open diff opens the branch diff editor`() {
val item = WorktreeDto("${project.basePath!!}/.kilo/worktrees/feature-x", "feature-x", "feature/x", "${project.basePath!!}/.kilo/worktrees/feature-x")
rpc.listed += item
val controller = WorktreeController(service, project.basePath!!, coroutines.scope)
val panel = edt { AgentManagerPanel(testRootDisposable, controller, project) }
edt { controller.reload() }
flush()
assertTrue(edt { panel.canOpenDiff(item) })
edt { panel.openDiff(item) }
val file = edt { FileEditorManager.getInstance(project).openFiles.single() as KiloVirtualFile }
assertEquals(KiloDiffEditorKind.ID, file.path.kind)
assertEquals(item.path, file.path.params["directory"])
}
fun `test open pr availability reflects pr status`() {
val item = WorktreeDto("${project.basePath!!}/.kilo/worktrees/feature-x", "feature-x", "feature/x", "${project.basePath!!}/.kilo/worktrees/feature-x")
rpc.listed += item
rpc.prResult = WorktreePrListDto(GhAvailability.OK, listOf(WorktreePrDto(item.path, 7, GhState.OPEN, "https://example.test/pr/7")))
val timers = TestUiTimers()
ApplicationManager.getApplication().replaceService(KiloWorktreeService::class.java, service, testRootDisposable)
project.replaceService(WorktreeStatusService::class.java, WorktreeStatusService(project, coroutines.scope, timers), testRootDisposable)
val controller = WorktreeController(service, project.basePath!!, coroutines.scope)
val panel = edt { AgentManagerPanel(testRootDisposable, controller, project) }
edt { controller.reload() }
timers.advanceBy(300)
flush()
assertTrue(edt { panel.canOpenPr(item) })
assertFalse(edt { panel.canOpenPr(null) })
}
fun `test worktree row hides badge while pending or deleting`() {
val path = "feature/y"
val activity = MutableStateFlow(mapOf(
@@ -919,7 +919,7 @@ class SettingsListViewTest : BasePlatformTestCase() {
UIUtil.dispatchAllInvocationEvents()
val area = activeListCellBounds(view.list, 0, selected = true).getValue(ACTIVE_LIST_CHANGES_CELL)
assertEquals(KiloBundle.message("worktree.stats.diff.tooltip", 3, 2), view.list.getToolTipText(event(view.list, center(area))))
assertEquals(KiloBundle.message("worktree.stats.tooltip", 0, 0, 3, 2), view.list.getToolTipText(event(view.list, center(area))))
hover(view, center(area))
assertEquals(Cursor.HAND_CURSOR, view.list.cursor.type)