feat(jetbrains): reorder worktrees with drag and drop

This commit is contained in:
kirillk
2026-08-21 09:36:44 -04:00
parent 0161c3a4ba
commit 59d1ec8a22
15 changed files with 654 additions and 4 deletions
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": minor
---
Reorder Agent Manager worktrees by dragging them in the JetBrains plugin.
@@ -328,6 +328,23 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi {
}
}
override suspend fun reorder(directory: String, paths: List<String>): Boolean =
withContext(Dispatchers.IO) {
val base = Path.of(directory).normalize()
val res = runGit(base, "worktree", "list", "--porcelain")
if (!res.ok) return@withContext false
val items = managedWorktrees(parseWorktreeList(res.stdout))
val store = worktreeNameStore(items) ?: return@withContext false
return@withContext try {
val state = readWorktreeState(store)
writeWorktreeState(store, state.copy(worktreeOrder = paths).reconcile(worktreePaths(items)))
true
} catch (e: Exception) {
LOG.warn("worktree reorder failed: dir=$directory message=${e.message}", e)
false
}
}
private data class GitResult(val exit: Int, val stdout: String, val stderr: String) {
val ok get() = exit == 0
}
@@ -241,6 +241,39 @@ class KiloWorktreeRpcApiImplTest {
assertEquals(listOf(first.path, second.path), readWorktreeState(repo.resolve(".kilo").resolve("worktree-names.json")).worktreeOrder)
}
@Test
fun `reorder persists a new order that a later list returns`() = runBlocking {
initRepo()
val first = assertNotNull(api.create(repo.toString(), CreateWorktreeRequestDto("zebra")).worktree)
val second = assertNotNull(api.create(repo.toString(), CreateWorktreeRequestDto("alpha")).worktree)
assertTrue(api.reorder(repo.toString(), listOf(second.path, first.path)))
val listed = api.list(repo.toString()).worktrees.filter { !it.main }
assertEquals(listOf(second.path, first.path), listed.map { it.path })
assertEquals(
listOf(second.path, first.path),
readWorktreeState(repo.resolve(".kilo").resolve("worktree-names.json")).worktreeOrder,
)
}
@Test
fun `reorder drops unknown paths and appends omitted worktrees`() = runBlocking {
initRepo()
val first = assertNotNull(api.create(repo.toString(), CreateWorktreeRequestDto("zebra")).worktree)
val second = assertNotNull(api.create(repo.toString(), CreateWorktreeRequestDto("alpha")).worktree)
assertTrue(api.reorder(repo.toString(), listOf("/does/not/exist", second.path)))
val order = readWorktreeState(repo.resolve(".kilo").resolve("worktree-names.json")).worktreeOrder
assertEquals(listOf(second.path, first.path), order)
}
@Test
fun `reorder returns false when the repo has no worktrees`() = runBlocking {
assertFalse(api.reorder(repo.toString(), listOf("/repo/.kilo/worktrees/x")))
}
@Test
fun `remove prunes names and order from worktree state`() = runBlocking {
initRepo()
@@ -31,6 +31,7 @@ import ai.kilocode.client.ui.list.ActiveListDeleteOptions
import ai.kilocode.client.ui.list.ActiveListItem
import ai.kilocode.client.ui.list.ActiveListMenu
import ai.kilocode.client.ui.list.ActiveListMetrics
import ai.kilocode.client.ui.list.ActiveListReorder
import ai.kilocode.client.ui.list.ActiveListSelection
import ai.kilocode.client.ui.list.ActiveListSurface
import ai.kilocode.client.ui.list.activeListToolWindowBackground
@@ -94,6 +95,10 @@ class AgentManagerPanel(
menu = ActiveListMenu(WorktreeDataKeys.WORKTREE, group, element = { row ->
(row as? WorktreeRow)?.dto?.takeIf { canRename(it) || canDelete(it) || canOpenPr(it) || canOpenDiff(it) }
}),
reorder = ActiveListReorder(
movable = { row -> row is WorktreeRow && !row.current && !row.pending && !row.deleting },
onMove = { move -> controller.reorder(move.keys) },
),
)
private var selected: String? = null
private var stats: Map<String, WorktreeStatsDto> = emptyMap()
@@ -127,4 +127,11 @@ class KiloWorktreeService internal constructor(
LOG.warn("worktree adopt failed for $path", e)
RenameWorktreeResultDto(error = e.message ?: "worktree adopt failed")
}
suspend fun reorder(directory: String, paths: List<String>): Boolean = try {
call { reorder(directory, paths) }
} catch (e: Exception) {
LOG.warn("worktree reorder failed for $directory", e)
false
}
}
@@ -240,6 +240,25 @@ class WorktreeController(
}
}
/**
* Applies a new display order given as worktree row [keys] (ids). Reorders the model optimistically
* then persists the resulting paths via [KiloWorktreeService.reorder]; on failure the list reloads
* from git ground truth. Pending rows keep their relative slots (stable sort) and are not persisted.
*/
fun reorder(keys: List<String>) {
val rows = (0 until model.size).map { model.getElementAt(it) }
val rank = keys.withIndex().associate { it.value to it.index }
val sorted = rows.sortedBy { rank[it.id] ?: Int.MAX_VALUE }
if (sorted == rows) return
model.replaceAll(sorted)
val paths = sorted.filter { !isPending(it.id) }.map { it.path }
cs.launch {
val ok = service.reorder(directory, paths)
if (!ok) edt { reload() }
edt { telemetry("Worktree Reordered", mapOf("count" to paths.size.toString())) }
}
}
/**
* Applies a name recorded elsewhere (e.g. adopted from a session title in an editor tab) to the
* matching row, so the worktree list reflects it live. No-ops when the path is not in this list
@@ -45,8 +45,9 @@ internal class ActiveList(
onClick: ((ActiveListItem) -> Unit)? = null,
onSelect: (() -> Unit)? = null,
menu: ActiveListMenu<*>? = null,
reorder: ActiveListReorder? = null,
) : BorderLayoutPanel() {
private val view = ActiveListView(emptyText, cfg, surface, matcher, enter, openOnClick, onOpen, onActivate, onClick, menu, onCell)
private val view = ActiveListView(emptyText, cfg, surface, matcher, enter, openOnClick, onOpen, onActivate, onClick, menu, reorder, onCell)
private val search: SearchTextField? = if (showSearch) SearchTextField(false) else null
private val scroll = object : JBScrollPane(view) {
override fun getBackground(): Color {
@@ -224,7 +224,7 @@ internal fun activeListCellAt(
return activeListCellAt(list, index, point, selected, false)
}
private fun activeListLayout(component: Component) {
internal fun activeListLayout(component: Component) {
if (component !is Container) return
component.doLayout()
for (child in component.components) activeListLayout(child)
@@ -20,10 +20,17 @@ import com.intellij.util.ui.EmptyIcon
import com.intellij.util.ui.JBUI
import com.intellij.util.ui.UIUtil
import ai.kilocode.rpc.dto.WorktreeStatsDto
import java.awt.AlphaComposite
import java.awt.BasicStroke
import java.awt.BorderLayout
import java.awt.Cursor
import java.awt.Dimension
import java.awt.Graphics
import java.awt.Graphics2D
import java.awt.Point
import java.awt.RenderingHints
import java.awt.Rectangle
import java.awt.image.BufferedImage
import javax.swing.JList
import javax.swing.JPanel
import javax.swing.ListCellRenderer
@@ -125,6 +132,7 @@ internal class ActiveListRenderer(
)
private val wrap = PickerRow()
private var bodyHeight: Int? = null
private var gap = false
init {
isOpaque = true
@@ -199,6 +207,19 @@ internal class ActiveListRenderer(
Dimension(0, height + JBUI.scale(2))
})
if (value is ActiveListGap) {
gap = true
layers.isVisible = false
pill.isVisible = false
glyph.isVisible = false
wrap.update(list, false, false)
wrap.setPreferredSize(Dimension(0, bodyHeight ?: value.height))
top.invalidate()
return this
}
gap = false
layers.isVisible = true
title.clear()
title.append(value.title, SimpleTextAttributes(SimpleTextAttributes.STYLE_BOLD, titleFg))
value.note?.takeIf { it.isNotBlank() }?.let {
@@ -245,6 +266,33 @@ internal class ActiveListRenderer(
return this
}
override fun paintChildren(g: Graphics) {
super.paintChildren(g)
if (!gap) return
val g2 = g.create() as Graphics2D
try {
g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON)
val inset = UiStyle.Gap.xs()
val arc = UiStyle.Arc.component()
val x = wrap.x + inset
val y = wrap.y + inset
val width = (wrap.width - inset * 2 - 1).coerceAtLeast(0)
val height = (wrap.height - inset * 2 - 1).coerceAtLeast(0)
g2.color = JBUI.CurrentTheme.List.Selection.background(true)
g2.stroke = BasicStroke(
JBUI.scale(1).toFloat(),
BasicStroke.CAP_ROUND,
BasicStroke.JOIN_ROUND,
0f,
floatArrayOf(JBUI.scale(3).toFloat(), JBUI.scale(3).toFloat()),
0f,
)
g2.drawRoundRect(x, y, width, height, arc, arc)
} finally {
g2.dispose()
}
}
fun setBodyHeight(height: Int?) {
if (bodyHeight == height) return
bodyHeight = height
@@ -265,6 +313,34 @@ internal class ActiveListRenderer(
return height
}
/**
* Paints the row body — the section-header band excluded — into an image, together with the
* body's origin inside the cell so callers can map a grab point in cell coordinates onto the
* image. Rendered as the focused selection so the dragged copy reads as a lifted row.
*/
fun rowImage(
list: JList<out ActiveListItem>,
value: ActiveListItem,
index: Int,
width: Int,
): Pair<BufferedImage, Point>? {
getListCellRendererComponent(list, value, index, true, true)
val size = preferredSize
setBounds(0, 0, width, size.height)
activeListLayout(this)
if (wrap.width <= 0 || wrap.height <= 0) return null
val image = UIUtil.createImage(list, wrap.width, wrap.height, BufferedImage.TYPE_INT_ARGB)
val g2 = image.createGraphics()
try {
g2.composite = AlphaComposite.getInstance(AlphaComposite.SRC_OVER, 0.9f)
g2.translate(-wrap.x, -wrap.y)
wrap.paint(g2)
} finally {
g2.dispose()
}
return image to Point(wrap.x, wrap.y)
}
private fun syncBadges(item: ActiveListItem) {
val items = if (item.deleting) emptyList() else item.badges
while (badges.componentCount > items.size) badges.remove(badges.componentCount - 1)
@@ -0,0 +1,84 @@
package ai.kilocode.client.ui.list
import com.intellij.ide.dnd.DnDDragStartBean
import com.intellij.ide.dnd.DnDImage
import com.intellij.ide.dnd.DnDSupport
import com.intellij.ide.dnd.SmoothAutoScroller
import com.intellij.ui.components.JBList
import java.awt.GraphicsEnvironment
import java.awt.Point
import javax.swing.JList
import javax.swing.TransferHandler
internal class ActiveListReorder(
val movable: (ActiveListItem) -> Boolean = { true },
val onMove: (ActiveListMove) -> Unit,
)
internal data class ActiveListMove(
val key: String,
val from: Int,
val to: Int,
val keys: List<String>,
)
internal data class ActiveListGap(
val source: ActiveListItem,
val height: Int,
) : ActiveListItem {
override val key: String get() = source.key
override val title: String get() = ""
override val section: String? get() = source.section
override val disabled: Boolean get() = true
}
internal fun activeListGapRows(rows: List<ActiveListItem>, key: String, index: Int, height: Int): List<ActiveListItem> {
val from = rows.indexOfFirst { it.key == key }
if (from < 0) return rows
val source = rows[from]
val out = rows.toMutableList()
out.removeAt(from)
out.add(index.coerceIn(0, out.size), ActiveListGap(source, height))
return out
}
internal fun activeListSectionRun(rows: List<ActiveListItem>, index: Int): IntRange {
if (index !in rows.indices) return 0 until 0
val section = rows[index].section
val start = generateSequence(index) { it - 1 }.takeWhile { it >= 0 && rows[it].section == section }.last()
val end = generateSequence(index) { it + 1 }.takeWhile { it < rows.size && rows[it].section == section }.last()
return start..end
}
internal fun installActiveListReorder(view: ActiveListView, list: JBList<ActiveListItem>, reorder: ActiveListReorder) {
if (GraphicsEnvironment.isHeadless()) return
list.transferHandler = TransferHandler(null)
list.dragEnabled = true
SmoothAutoScroller.installDropTargetAsNecessary(list)
DnDSupport.createBuilder(list)
.setBeanProvider { info -> view.pickable(info.point)?.let { DnDDragStartBean(ActiveListPick(list, it)) } }
.setImageProvider { info ->
val pair = view.dragImage(info.point) ?: return@setImageProvider null
DnDImage(pair.first, pair.second)
}
.setTargetChecker { event ->
val pick = event.attachedObject as? ActiveListPick
if (pick?.list !== list) {
event.setDropPossible(false)
return@setTargetChecker true
}
event.setDropPossible(true)
view.over(pick.key, event.getPointOn(list))
true
}
.setDropHandlerWithResult { event ->
val pick = event.attachedObject as? ActiveListPick
if (pick?.list !== list) return@setDropHandlerWithResult false
view.drop()
true
}
.setDropEndedCallback { view.cancel() }
.install()
}
private data class ActiveListPick(val list: JList<*>, val key: String)
@@ -21,6 +21,7 @@ import com.intellij.xml.util.XmlStringUtil
import java.awt.Color
import java.awt.Cursor
import java.awt.Dimension
import java.awt.Image
import java.awt.Point
import java.awt.Rectangle
import java.awt.event.FocusAdapter
@@ -47,6 +48,7 @@ internal class ActiveListView(
private val onActivate: ((ActiveListItem) -> Unit)? = null,
private val onClick: ((ActiveListItem) -> Unit)? = null,
private val menu: ActiveListMenu<*>? = null,
private val reorder: ActiveListReorder? = null,
private val onCell: (String, String) -> Unit,
) : Stack(StackAxis.VERTICAL), Scrollable {
private val model = CollectionListModel<ActiveListItem>()
@@ -98,6 +100,7 @@ internal class ActiveListView(
private var popups = 0
private var hovered = -1
private var heightKey: ActiveListHeightKey? = null
private var drag: Drag? = null
// Cursor for the row body; buttons override it on hover via [cursorAt].
private var baseCursor: Cursor = Cursor.getDefaultCursor()
internal var onSelect: (() -> Unit)? = null
@@ -173,7 +176,7 @@ internal class ActiveListView(
}
override fun mouseMoved(e: MouseEvent) {
if (hover) {
if (hover && drag == null) {
val idx = list.locationToIndex(e.point)
.takeIf { it >= 0 && list.getCellBounds(it, it)?.contains(e.point) == true }
?: -1
@@ -200,6 +203,7 @@ internal class ActiveListView(
override fun focusLost(e: FocusEvent) = list.repaint()
})
reorder?.let { installActiveListReorder(this, list, it) }
ScrollingUtil.installActions(list)
next(list)
}
@@ -343,7 +347,10 @@ internal class ActiveListView(
private fun sync(prefer: String? = list.selectedValue?.key, at: Int? = null, scroll: Boolean = true) {
checkEdt()
val q = filter.trim()
val rows = if (q.isBlank()) items else items.filter { matcher(q, it) }
val base = if (q.isBlank()) items else items.filter { matcher(q, it) }
val state = drag
if (state != null && base.none { it.key == state.key }) drag = null
val rows = drag?.let { activeListGapRows(base, it.key, it.index, it.height) } ?: base
// Rebuilding the model fires a list-wide repaint, so skip it when the visible rows are
// structurally unchanged (e.g. a stats/name refresh that produced identical rows) and only
// reconcile selection below. Row types are data classes, so equality is by value.
@@ -435,6 +442,7 @@ internal class ActiveListView(
private fun open(focus: Boolean) {
val item = list.selectedValue ?: return
if (item is ActiveListGap) return
if (item.deleting) return
val action = onOpen
if (action != null) {
@@ -446,6 +454,7 @@ internal class ActiveListView(
private fun source() {
val item = list.selectedValue ?: return
if (item is ActiveListGap) return
if (item.deleting) return
onOpen?.invoke(item, true)
}
@@ -457,6 +466,7 @@ internal class ActiveListView(
* action is destructive (e.g. delete) does nothing on double-click.
*/
private fun activate(item: ActiveListItem) {
if (item is ActiveListGap) return
if (item.deleting) return
val action = onActivate
if (action != null) {
@@ -473,6 +483,7 @@ internal class ActiveListView(
}
private fun primary(item: ActiveListItem) {
if (item is ActiveListGap) return
if (item.deleting) return
val cells = activeListVisibleCells(item, true)
val cell = cells.firstOrNull { it.enabled && it.primary }
@@ -507,6 +518,95 @@ internal class ActiveListView(
if (action != null) action() else onCell(item.key, id)
}
@RequiresEdt
fun pickable(point: Point): String? {
checkEdt()
val cfg = reorder ?: return null
if (!list.isEnabled || filter.isNotBlank() || drag != null) return null
val idx = rowAt(point) ?: return null
val item = model.getElementAt(idx)
if (item is ActiveListGap || item.disabled || item.deleting) return null
if (!cfg.movable(item)) return null
val selected = list.isSelectedIndex(idx)
if (activeListCellAt(list, idx, point, selected, menu?.takeIf { it.available(item) } != null) != null) return null
return item.key
}
/**
* The dragged row painted as an image, plus the AWT image offset that keeps the grabbed pixel
* under the cursor. AWT places the image origin at `cursor + offset`, so the offset is the
* negated grab point inside the image — otherwise the copy floats off to the lower right.
*/
@RequiresEdt
fun dragImage(point: Point): Pair<Image, Point>? {
checkEdt()
val idx = rowAt(point) ?: return null
val item = model.getElementAt(idx)
if (item is ActiveListGap) return null
val bounds = list.getCellBounds(idx, idx) ?: return null
val (image, origin) = renderer.rowImage(list, item, idx, bounds.width) ?: return null
// Clamp so grabbing the section-header band still anchors inside the body image.
val x = (point.x - bounds.x - origin.x).coerceIn(0, image.width)
val y = (point.y - bounds.y - origin.y).coerceIn(0, image.height)
return image to Point(-x, -y)
}
@RequiresEdt
fun over(key: String, point: Point) {
checkEdt()
if (filter.isNotBlank()) return
val current = drag
val base = items
val from = activeListIndex(base, key)
if (from < 0) {
cancel()
return
}
val bounds = list.getCellBounds(from, from)
val state = current ?: Drag(key, from, from, bounds?.height ?: list.fixedCellHeight.takeIf { it > 0 } ?: renderer.bodyPreferredHeight(list, base[from], from, true, true))
if (current == null) {
press = null
setHovered(-1)
drag = state
}
val run = activeListSectionRun(base, from)
if (run.isEmpty()) return
val idx = rowAt(point) ?: state.index
val next = idx.coerceIn(run.first, run.last)
if (state.index == next && current != null) return
drag = state.copy(index = next)
sync(key, next, scroll = false)
}
@RequiresEdt
fun drop() {
checkEdt()
val state = drag ?: return
drag = null
val source = activeListIndex(items, state.key)
if (source < 0) {
sync()
return
}
val rows = items.toMutableList()
val item = rows.removeAt(source)
val target = state.index.coerceIn(0, rows.size)
rows.add(target, item)
items = rows
heightKey = null
sync(state.key, target, scroll = false)
if (source == target) return
reorder?.onMove?.invoke(ActiveListMove(state.key, source, target, rows.map { it.key }))
}
@RequiresEdt
fun cancel() {
checkEdt()
if (drag == null) return
drag = null
sync()
}
@RequiresEdt
fun setBaseCursor(cursor: Cursor) {
checkEdt()
@@ -527,6 +627,7 @@ internal class ActiveListView(
val bounds = idx.takeIf { it >= 0 }?.let { list.getCellBounds(it, it) } ?: return baseCursor
if (!bounds.contains(point)) return baseCursor
val item = model.getElementAt(idx)
if (item is ActiveListGap) return baseCursor
if (menu == null && item.cells.isEmpty() && item.metrics == null) return baseCursor
val hit = activeListHits(list, idx, list.isSelectedIndex(idx))
.firstOrNull { it.enabled && it.bounds.contains(point) }
@@ -539,6 +640,7 @@ internal class ActiveListView(
val bounds = idx.takeIf { it >= 0 }?.let { list.getCellBounds(it, it) } ?: return null
if (!bounds.contains(e.point)) return null
val item = model.getElementAt(idx)
if (item is ActiveListGap) return null
val selected = list.isSelectedIndex(idx)
val id = if (enabled) {
activeListCellAt(list, idx, e.point, selected, menu?.takeIf { it.available(item) } != null)
@@ -557,6 +659,7 @@ internal class ActiveListView(
val bounds = idx.takeIf { it >= 0 }?.let { list.getCellBounds(it, it) } ?: return false
if (!bounds.contains(point)) return false
val item = model.getElementAt(idx)
if (item is ActiveListGap) return false
if (item.disabled || item.deleting || !cfg.available(item)) return false
val rect = activeListCellBounds(list, idx, list.isSelectedIndex(idx))[ACTIVE_LIST_MENU_CELL] ?: return false
if (!rect.contains(point)) return false
@@ -603,6 +706,15 @@ internal class ActiveListView(
return e.isShiftDown || e.isMetaDown || e.isControlDown
}
private fun rowAt(point: Point): Int? {
val idx = list.locationToIndex(point)
if (idx < 0) return null
val bounds = list.getCellBounds(idx, idx) ?: return null
if (bounds.contains(point)) return idx
if (point.y < bounds.y) return 0
return (model.size - 1).takeIf { it >= 0 }
}
override fun getBackground(): Color {
if (surface == ActiveListSurface.ToolWindow) return activeListToolWindowBackground()
return super.getBackground() ?: UIUtil.getPanelBackground()
@@ -637,6 +749,8 @@ internal class ActiveListView(
private data class Press(val key: String, val id: String)
private data class Drag(val key: String, val from: Int, val index: Int, val height: Int)
private data class ActiveListHeightKey(
val cfg: ActiveListConfig,
val width: Int,
@@ -22,6 +22,7 @@ import ai.kilocode.client.testing.fire
import ai.kilocode.client.ui.list.ActiveListBadge
import ai.kilocode.client.ui.list.ActiveListItem
import ai.kilocode.client.ui.list.ActiveListMetrics
import ai.kilocode.client.ui.list.ActiveListView
import ai.kilocode.client.ui.list.ACTIVE_LIST_PR_CELL
import ai.kilocode.client.ui.list.activeListCellBounds
import ai.kilocode.client.ui.list.activeListToolWindowBackground
@@ -610,6 +611,107 @@ class AgentManagerPanelTest : BasePlatformTestCase() {
flush()
}
fun `test dragging a worktree above another reorders the model and persists the path order`() {
val a = worktree("aardvark")
val b = worktree("beluga")
rpc.listed += main()
rpc.listed += a
rpc.listed += b
val controller = WorktreeController(service, project.basePath!!, coroutines.scope)
val panel = edt { AgentManagerPanel(testRootDisposable, controller, project) }
edt { controller.reload() }
flush()
val view = edt { UIUtil.findComponentOfType(panel, ActiveListView::class.java)!! }
layout(view)
// Display order: current (main) row is index 0, then a (1), b (2).
edt {
assertEquals(b.id, view.pickable(rowCenter(view, 2)))
view.over(b.id, rowCenter(view, 1))
view.drop()
}
flush()
assertEquals(listOf(b.path, a.path), edt { worktreeIds(controller) })
assertEquals(listOf(listOf(b.path, a.path)), rpc.reorders.toList())
}
fun `test the current and pending rows are not draggable`() {
rpc.listed += main()
rpc.listed += worktree("aardvark")
val controller = WorktreeController(service, project.basePath!!, coroutines.scope)
val panel = edt { AgentManagerPanel(testRootDisposable, controller, project) }
edt { controller.reload() }
flush()
val gate = CompletableDeferred<Unit>()
rpc.beforeCreate = { gate.await() }
edt { controller.create("feature/pending", null) }
val view = edt { UIUtil.findComponentOfType(panel, ActiveListView::class.java)!! }
layout(view)
edt {
val size = view.list.model.size
// Row 0 is the current (main) row; the last row is the pending create.
assertNull(view.pickable(rowCenter(view, 0)))
assertNull(view.pickable(rowCenter(view, size - 1)))
}
gate.complete(Unit)
flush()
}
fun `test a failed reorder rpc reloads from git ground truth`() {
val a = worktree("aardvark")
val b = worktree("beluga")
rpc.listed += main()
rpc.listed += a
rpc.listed += b
rpc.reorderResult = false
val controller = WorktreeController(service, project.basePath!!, coroutines.scope)
val panel = edt { AgentManagerPanel(testRootDisposable, controller, project) }
edt { controller.reload() }
flush()
val view = edt { UIUtil.findComponentOfType(panel, ActiveListView::class.java)!! }
layout(view)
edt {
view.over(b.id, rowCenter(view, 1))
view.drop()
}
flush()
// The optimistic swap is discarded; reload restores the backend (listed) order.
assertEquals(listOf(a.path, b.path), edt { worktreeIds(controller) })
assertEquals(listOf(listOf(b.path, a.path)), rpc.reorders.toList())
}
private fun main(): WorktreeDto {
val base = project.basePath!!
return WorktreeDto(base, "repo", "main", base, main = true)
}
private fun worktree(name: String): WorktreeDto {
val path = "${project.basePath!!}/.kilo/worktrees/$name"
return WorktreeDto(path, name, name, path)
}
private fun worktreeIds(controller: WorktreeController): List<String> {
return (0 until controller.model.size).map { controller.model.getElementAt(it).path }
}
private fun layout(view: ActiveListView) {
edt {
view.list.setSize(360, 600)
view.list.doLayout()
UIUtil.dispatchAllInvocationEvents()
}
}
private fun rowCenter(view: ActiveListView, index: Int): Point {
val bounds = view.list.getCellBounds(index, index)!!
return Point(bounds.x + 8, bounds.y + bounds.height / 2)
}
private fun <T> edt(block: () -> T): T = edtWait(block)
private fun flush() = coroutines.drain(::pump)
@@ -29,6 +29,8 @@ class FakeWorktreeRpcApi : KiloWorktreeRpcApi {
val removeForces = CopyOnWriteArrayList<Boolean>()
val renames = CopyOnWriteArrayList<Triple<String, String, String>>()
val adopts = CopyOnWriteArrayList<Triple<String, String, String>>()
val reorders = CopyOnWriteArrayList<List<String>>()
var reorderResult = true
val opens = CopyOnWriteArrayList<String>()
val ghCalls = CopyOnWriteArrayList<String>()
var beforeCreate: suspend () -> Unit = {}
@@ -123,4 +125,10 @@ class FakeWorktreeRpcApi : KiloWorktreeRpcApi {
adopts.add(Triple(directory, path, name))
return adoptResult(path, name)
}
override suspend fun reorder(directory: String, paths: List<String>): Boolean {
assertNotEdt("reorder")
reorders.add(paths)
return reorderResult
}
}
@@ -0,0 +1,173 @@
package ai.kilocode.client.ui.list
import com.intellij.testFramework.fixtures.BasePlatformTestCase
import com.intellij.util.ui.UIUtil
import java.awt.Point
import java.awt.Rectangle
class ActiveListReorderTest : BasePlatformTestCase() {
fun `test gap rows remove the dragged row and insert a placeholder at the index`() {
val rows = rows("a", "b", "c")
val out = activeListGapRows(rows, "c", 0, 20)
assertEquals(listOf("c", "a", "b"), out.map { it.key })
assertTrue(out[0] is ActiveListGap)
assertEquals(3, out.size)
}
fun `test gap rows keep the dragged key so selection stays anchored`() {
val rows = rows("a", "b", "c")
val out = activeListGapRows(rows, "b", 2, 20)
assertEquals(listOf("a", "c", "b"), out.map { it.key })
}
fun `test section run spans only rows sharing the section`() {
val rows = listOf(row("cur", null), row("a", "wt"), row("b", "wt"), row("c", "wt"))
assertEquals(0..0, activeListSectionRun(rows, 0))
assertEquals(1..3, activeListSectionRun(rows, 1))
assertEquals(1..3, activeListSectionRun(rows, 3))
}
fun `test pick up opens a gap and drop reorders firing the move`() {
val moves = mutableListOf<ActiveListMove>()
val view = view(moves)
view.update(sectioned())
layout(view)
val point = center(view, 3)
assertEquals("c", view.pickable(point))
view.over("c", center(view, 1))
// The real row is gone from the model; a gap holds its old key at the new index.
val display = display(view)
assertEquals(listOf("cur", "c", "a", "b"), display.map { it.key })
assertTrue(display[1] is ActiveListGap)
view.drop()
assertEquals(listOf("cur", "c", "a", "b"), display(view).map { it.key })
val move = moves.single()
assertEquals("c", move.key)
assertEquals(3, move.from)
assertEquals(1, move.to)
assertEquals(listOf("cur", "c", "a", "b"), move.keys)
}
fun `test drag cannot leave its section and never displaces the current row`() {
val moves = mutableListOf<ActiveListMove>()
val view = view(moves)
view.update(sectioned())
layout(view)
view.over("c", center(view, 0))
// Clamped to the first worktree slot, never above the current row.
assertEquals(listOf("cur", "c", "a", "b"), display(view).map { it.key })
}
fun `test cancel restores the original order and fires nothing`() {
val moves = mutableListOf<ActiveListMove>()
val view = view(moves)
view.update(sectioned())
layout(view)
view.over("c", center(view, 1))
view.cancel()
assertEquals(listOf("cur", "a", "b", "c"), display(view).map { it.key })
assertTrue(moves.isEmpty())
}
fun `test an external update that drops the dragged key cancels the drag`() {
val moves = mutableListOf<ActiveListMove>()
val view = view(moves)
view.update(sectioned())
layout(view)
view.over("c", center(view, 1))
// A refresh that no longer contains the dragged worktree (e.g. it was deleted mid-drag).
view.update(listOf(row("cur", null), row("a", "wt"), row("b", "wt")))
assertEquals(listOf("cur", "a", "b"), display(view).map { it.key })
assertTrue(moves.isEmpty())
}
fun `test pickable rejects immovable rows and an active filter`() {
val view = view(mutableListOf())
view.update(sectioned())
layout(view)
assertNull(view.pickable(center(view, 0)))
view.filter("a")
layout(view)
assertNull(view.pickable(center(view, view.list.model.size - 1)))
}
fun `test drag image anchors the grabbed pixel under the cursor`() {
val view = view(mutableListOf())
view.update(sectioned())
layout(view)
val point = center(view, 3)
val image = view.dragImage(point) ?: error("expected a drag image")
assertTrue(image.first.getWidth(null) > 0)
assertTrue(image.first.getHeight(null) > 0)
// AWT draws the image at cursor + offset, so the offset is the negated grab point and the
// dragged copy sits exactly under the pointer instead of down and to the right of it.
val bounds = view.list.getCellBounds(3, 3)!!
assertEquals(-(point.x - bounds.x), image.second.x)
assertTrue(image.second.x <= 0)
assertTrue(image.second.y <= 0)
assertTrue(-image.second.y <= image.first.getHeight(null))
}
fun `test grabbing a section header row still anchors inside the body image`() {
val view = view(mutableListOf())
view.update(sectioned())
layout(view)
// Row 1 carries the "wt" section band above its body; grab inside that band.
val bounds = view.list.getCellBounds(1, 1)!!
val image = view.dragImage(Point(bounds.x + 8, bounds.y + 1)) ?: error("expected a drag image")
assertEquals(0, image.second.y)
}
private fun view(moves: MutableList<ActiveListMove>): ActiveListView {
return ActiveListView(
empty = "",
reorder = ActiveListReorder(
movable = { it.section != null },
onMove = { moves += it },
),
onCell = { _, _ -> },
)
}
private fun layout(view: ActiveListView) {
view.setBounds(0, 0, 300, 600)
view.doLayout()
view.list.setBounds(0, 0, 300, 600)
view.list.doLayout()
UIUtil.dispatchAllInvocationEvents()
}
private fun display(view: ActiveListView): List<ActiveListItem> {
return (0 until view.list.model.size).map { view.list.model.getElementAt(it) }
}
private fun center(view: ActiveListView, index: Int): Point {
val bounds: Rectangle = view.list.getCellBounds(index, index) ?: error("no bounds for $index")
return Point(bounds.x + 8, bounds.y + bounds.height / 2)
}
private fun sectioned(): List<ActiveListItem> {
return listOf(row("cur", null), row("a", "wt"), row("b", "wt"), row("c", "wt"))
}
private fun rows(vararg keys: String): List<ActiveListItem> = keys.map { row(it, "wt") }
private fun row(key: String, section: String?): ActiveListItem = object : ActiveListItem {
override val key = key
override val title = key
override val section = section
}
}
@@ -61,4 +61,10 @@ interface KiloWorktreeRpcApi : RemoteApi<Unit> {
* null error when it was skipped because a custom name already exists.
*/
suspend fun adopt(directory: String, path: String, name: String): RenameWorktreeResultDto
/**
* Records [paths] as the worktree display order for [directory], reconciled against
* `git worktree list` (unknown paths dropped, missing ones appended). Returns true when written.
*/
suspend fun reorder(directory: String, paths: List<String>): Boolean
}