fix(jetbrains): align worktree editor titles

This commit is contained in:
kirillk
2026-08-11 11:04:02 -04:00
parent a39dc5ddfd
commit 686208eb04
8 changed files with 86 additions and 29 deletions
@@ -11,6 +11,7 @@ import ai.kilocode.client.agentManager.worktree.WorktreeNameCache
import ai.kilocode.client.agentManager.worktree.WorktreeEditorMatchers
import ai.kilocode.client.agentManager.worktree.WorktreeSessionEditorMatcher
import ai.kilocode.client.agentManager.worktree.WorktreeSessionEditorKind
import ai.kilocode.client.agentManager.worktree.WorktreeTitle
import ai.kilocode.client.agentManager.worktree.ensureWorktreeSessionEditorKind
import ai.kilocode.client.agentManager.worktree.prTooltip
import ai.kilocode.client.agentManager.worktree.normalizeWorktreePath
@@ -123,7 +124,10 @@ class AgentManagerPanel(
bindStatus()
bindEditorSelection()
// Reflect names adopted or renamed in a worktree session editor tab in the list live.
service<WorktreeNameCache>().addListener(this) { path, name -> controller.applyName(path, name) }
service<WorktreeNameCache>().addListener(this) { path, name ->
controller.applyName(path, name)
project?.service<KiloVfsManager>()?.updatePresentation(WorktreeSessionEditorKind.ID, mapOf("path" to path))
}
ActionManager.getInstance().getAction("RenameElement")?.shortcutSet?.let { set ->
edit.registerCustomShortcutSet(set, list, this)
}
@@ -331,7 +335,9 @@ class AgentManagerPanel(
(0 until controller.model.size).map {
val item = controller.model.getElementAt(it)
val key = normalizeWorktreePath(item.path)
WorktreeRow(item, controller.isPending(item.id), controller.isDeleting(item.id), controller.kind(item.path), stats[key], prs[key])
val pull = prs[key]
service<WorktreeNameCache>().putPr(item.path, pull)
WorktreeRow(item, controller.isPending(item.id), controller.isDeleting(item.id), controller.kind(item.path), stats[key], pull)
},
ActiveListSelection.PreserveNoScroll,
)
@@ -442,13 +448,12 @@ class AgentManagerPanel(
val pr: WorktreePrDto?,
) : ActiveListItem {
override val key: String get() = dto.id
override val title: String get() = pr?.title?.trim()?.takeIf { it.isNotBlank() } ?: dto.name
override val description: String get() = defaultName
override val title: String get() = WorktreeTitle.text(dto.name, dto.path, pr)
override val description: String get() = WorktreeTitle.fallback(dto.path)
override val tooltip: String? get() = null
override val icon = WorktreeIcons.forRow(dto.locked, pending)
override val search: String get() = listOfNotNull(dto.name, dto.branch, dto.path, dto.lockReason).joinToString(" ")
private val defaultName: String get() = dto.path.trimEnd('/').substringAfterLast('/')
private val customName: String? get() = dto.name.takeIf { it != defaultName }
private val customName: String? get() = WorktreeTitle.custom(dto.name, dto.path)
override val badges: List<ActiveListBadge>
get() {
if (pending || deleting) return emptyList()
@@ -1,6 +1,7 @@
package ai.kilocode.client.agentManager.worktree
import ai.kilocode.rpc.dto.WorktreeDto
import ai.kilocode.rpc.dto.WorktreePrDto
import com.intellij.openapi.Disposable
import com.intellij.openapi.components.Service
import com.intellij.openapi.util.Disposer
@@ -14,11 +15,15 @@ import com.intellij.util.concurrency.annotations.RequiresEdt
@Service(Service.Level.APP)
class WorktreeNameCache {
private val names = linkedMapOf<String, String>()
private val pulls = linkedMapOf<String, WorktreePrDto>()
private val listeners = mutableListOf<(String, String?) -> Unit>()
@RequiresEdt
fun get(path: String): String? = names[path]
@RequiresEdt
fun title(path: String): String = WorktreeTitle.text(names[path], path, pulls[path])
@RequiresEdt
fun put(path: String, name: String) {
if (names[path] == name) return
@@ -29,14 +34,30 @@ class WorktreeNameCache {
@RequiresEdt
fun put(item: WorktreeDto) = put(item.path, item.name)
@RequiresEdt
fun putPr(path: String, pull: WorktreePrDto?) {
val prev = pulls[path]
if (pull == null) {
if (prev == null) return
pulls.remove(path)
fire(path, names[path])
return
}
if (prev == pull) return
pulls[path] = pull
fire(path, names[path])
}
@RequiresEdt
fun remove(path: String) {
if (names.remove(path) != null) fire(path, null)
val changed = names.remove(path) != null || pulls.remove(path) != null
if (changed) fire(path, null)
}
@RequiresEdt
fun clear() {
names.clear()
pulls.clear()
}
/**
@@ -30,22 +30,24 @@ internal class WorktreePrHeaderView(
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 val actions = BorderLayoutPanel()
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
actions.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())
actions.addToRight(changesPane)
addToLeft(statusPane)
addToCenter(title)
addToRight(changesPane)
addToRight(actions)
val listener = object : MouseAdapter() {
override fun mouseClicked(event: MouseEvent) {
url?.let(BrowserUtil::browse)
@@ -93,23 +95,11 @@ internal class WorktreePrHeaderView(
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()
}
@@ -25,7 +25,7 @@ object WorktreeSessionEditorKind : KiloEditorKind {
override val id: String = ID
override fun title(params: Map<String, String>): String = params[PATH]?.let { path ->
service<WorktreeNameCache>().get(path) ?: name(path)
service<WorktreeNameCache>().title(path)
} ?: KiloBundle.message("worktree.session.title")
override fun icon(params: Map<String, String>): Icon = WorktreeIcons.branch
override fun fileType(params: Map<String, String>): FileType = WorktreeSessionFileType
@@ -46,11 +46,6 @@ object WorktreeSessionEditorKind : KiloEditorKind {
return WorktreeSessionEditorPanel(parent, manager, controller, worktree, project)
}
private fun name(path: String): String {
val value = path.trimEnd('/', '\\')
return value.substringAfterLast('/').substringAfterLast('\\').takeIf { it.isNotBlank() } ?: value
}
private const val PATH = "path"
}
@@ -0,0 +1,20 @@
package ai.kilocode.client.agentManager.worktree
import ai.kilocode.rpc.dto.WorktreePrDto
internal object WorktreeTitle {
fun text(name: String?, path: String, pull: WorktreePrDto? = null): String {
return pull?.title?.trim()?.takeIf { it.isNotBlank() }
?: name?.takeIf { it.isNotBlank() }
?: fallback(path)
}
fun custom(name: String?, path: String): String? {
return name?.takeIf { it != fallback(path) }
}
fun fallback(path: String): String {
val value = path.trimEnd('/', '\\')
return value.substringAfterLast('/').substringAfterLast('\\').takeIf { it.isNotBlank() } ?: value
}
}
@@ -10,6 +10,8 @@ import ai.kilocode.client.vfs.KiloEditorKindRegistry
import ai.kilocode.client.vfs.KiloPath
import ai.kilocode.client.vfs.KiloVirtualFileKindRegistry
import ai.kilocode.client.vfs.KiloVirtualFileSystem
import ai.kilocode.rpc.dto.GhState
import ai.kilocode.rpc.dto.WorktreePrDto
import ai.kilocode.rpc.dto.WorktreeDto
import com.intellij.openapi.components.service
import com.intellij.openapi.vfs.VirtualFilePathWrapper
@@ -58,4 +60,12 @@ class WorktreeSessionEditorKindTest : BasePlatformTestCase() {
assertEquals("Feature Label", WorktreeSessionEditorKind.title(mapOf("path" to path)))
}
fun `test worktree session title uses same winning label as worktree list`() {
val path = "/repo/.kilo/worktrees/feature-x"
service<WorktreeNameCache>().put(path, "Feature Label")
service<WorktreeNameCache>().putPr(path, WorktreePrDto(path, 3, GhState.OPEN, "https://example.test/pr/3", "PR Label"))
assertEquals("PR Label", WorktreeSessionEditorKind.title(mapOf("path" to path)))
}
}
@@ -1,7 +1,9 @@
package ai.kilocode.client.agentManager.worktree
import ai.kilocode.client.util.edtWait
import ai.kilocode.rpc.dto.GhState
import ai.kilocode.rpc.dto.WorktreeDto
import ai.kilocode.rpc.dto.WorktreePrDto
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.components.service
import com.intellij.openapi.util.Disposer
@@ -43,6 +45,19 @@ class WorktreeNameCacheTest : BasePlatformTestCase() {
assertTrue(events.isEmpty())
}
fun `test title prefers PR title then worktree name`() = edt {
val path = "/repo/wt"
cache().put(path, "Feature")
assertEquals("Feature", cache().title(path))
cache().putPr(path, WorktreePrDto(path, 12, GhState.OPEN, "https://example.test/pr/12", "PR Title"))
assertEquals("PR Title", cache().title(path))
cache().putPr(path, WorktreePrDto(path, 12, GhState.OPEN, "https://example.test/pr/12", " "))
assertEquals("Feature", cache().title(path))
}
fun `test listener is removed with parent disposable`() = edt {
val parent = Disposer.newDisposable("worktree-cache-listener")
Disposer.register(testRootDisposable, parent)
@@ -16,6 +16,7 @@ import java.awt.Point
import java.awt.event.InputEvent
import java.awt.event.MouseEvent
import javax.swing.JComponent
import javax.swing.SwingUtilities
class WorktreePrHeaderViewTest : BasePlatformTestCase() {
fun `test PR state title and colors render`() {
@@ -61,7 +62,7 @@ class WorktreePrHeaderViewTest : BasePlatformTestCase() {
assertEquals(Cursor.DEFAULT_CURSOR, title.cursor.type)
}
fun `test no PR aligns changes badge to the left`() {
fun `test no PR keeps changes badge on the right`() {
val view = edt { WorktreePrHeaderView {} }
val changes = edt { UIUtil.findComponentOfType(view, WorktreeStatsView::class.java)!! }
@@ -71,7 +72,7 @@ class WorktreePrHeaderViewTest : BasePlatformTestCase() {
view.doLayout()
}
assertTrue(edt { changes.parent.x < 20 })
assertTrue(edt { SwingUtilities.convertPoint(changes.parent, Point(0, 0), view).x > 300 })
}
fun `test changes view visibility follows stats`() {