mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-10-29 18:52:45 +08:00
feat(jetbrains): refine worktree session header
This commit is contained in:
+161
@@ -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()
|
||||
}
|
||||
}
|
||||
+87
-23
@@ -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?,
|
||||
|
||||
+16
-4
@@ -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
|
||||
|
||||
+11
@@ -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.
|
||||
|
||||
+165
@@ -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)
|
||||
}
|
||||
+65
-3
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user