mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-01 15:32:11 +08:00
feat(jetbrains): reorder worktrees with drag and drop
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": minor
|
||||
---
|
||||
|
||||
Reorder Agent Manager worktrees by dragging them in the JetBrains plugin.
|
||||
+17
@@ -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
|
||||
}
|
||||
|
||||
+33
@@ -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()
|
||||
|
||||
+5
@@ -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()
|
||||
|
||||
+7
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+19
@@ -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
|
||||
|
||||
+2
-1
@@ -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 {
|
||||
|
||||
+1
-1
@@ -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)
|
||||
|
||||
+76
@@ -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)
|
||||
|
||||
+84
@@ -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)
|
||||
+116
-2
@@ -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,
|
||||
|
||||
+102
@@ -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)
|
||||
|
||||
+8
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+173
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user