From c8694bcffdfdf0d453d1aab85cda5d39797a8c1c Mon Sep 17 00:00:00 2001 From: kirillk Date: Fri, 7 Aug 2026 15:26:50 -0400 Subject: [PATCH] fix(jetbrains): align worktree action badges --- .../client/actions/OpenWorktreeDiffAction.kt | 23 +++++++++++ .../client/actions/OpenWorktreePrAction.kt | 23 +++++++++++ .../client/agentManager/AgentManagerPanel.kt | 22 ++++++++++- .../worktree/WorktreeStatsView.kt | 24 ++++++------ .../client/ui/list/ActiveListRenderer.kt | 21 ++++++++-- .../resources/kilo.jetbrains.frontend.xml | 9 +++++ .../resources/messages/KiloBundle.properties | 7 +++- .../actions/HistorySessionActionsTest.kt | 11 ++++++ .../agentManager/AgentManagerPanelTest.kt | 38 +++++++++++++++++++ .../settings/base/SettingsListViewTest.kt | 2 +- 10 files changed, 160 insertions(+), 20 deletions(-) create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/OpenWorktreeDiffAction.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/OpenWorktreePrAction.kt diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/OpenWorktreeDiffAction.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/OpenWorktreeDiffAction.kt new file mode 100644 index 00000000000..2ad08de46b7 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/OpenWorktreeDiffAction.kt @@ -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) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/OpenWorktreePrAction.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/OpenWorktreePrAction.kt new file mode 100644 index 00000000000..adba5e18b1f --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/OpenWorktreePrAction.kt @@ -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) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/AgentManagerPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/AgentManagerPanel.kt index 7488a715eb2..e09f70992bf 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/AgentManagerPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/AgentManagerPanel.kt @@ -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), 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 459485d1317..46ac169c843 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 @@ -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() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListRenderer.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListRenderer.kt index 15a19e92913..6b9a14eca8c 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListRenderer.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListRenderer.kt @@ -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 diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml b/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml index 78d6b1a8ea9..414ab991e0e 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml +++ b/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml @@ -241,6 +241,12 @@ + + + + @@ -250,6 +256,9 @@ + + + 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 84710269275..5312aebca82 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -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 diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/HistorySessionActionsTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/HistorySessionActionsTest.kt index 4694ac6cf71..d5ee54489aa 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/HistorySessionActionsTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/HistorySessionActionsTest.kt @@ -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 ------ diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/AgentManagerPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/AgentManagerPanelTest.kt index 82864377f78..415c7f02131 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/AgentManagerPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/AgentManagerPanelTest.kt @@ -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( diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/SettingsListViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/SettingsListViewTest.kt index 24394be4ac1..c83ecee8d43 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/SettingsListViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/SettingsListViewTest.kt @@ -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)