feat(jetbrains): support agent manager renaming

Allow JetBrains Agent Manager users to rename worktree display labels and worktree sessions without changing git branch names.
This commit is contained in:
kirillk
2026-07-29 14:05:04 -04:00
parent 91e2f0612d
commit d522ff80e2
22 changed files with 771 additions and 22 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": minor
---
Support renaming Agent Manager worktrees and worktree sessions from the JetBrains plugin.
@@ -5,6 +5,7 @@ import ai.kilocode.rpc.KiloWorktreeRpcApi
import ai.kilocode.rpc.dto.CreateWorktreeRequestDto
import ai.kilocode.rpc.dto.CreateWorktreeResultDto
import ai.kilocode.rpc.dto.RemoveWorktreeResultDto
import ai.kilocode.rpc.dto.RenameWorktreeResultDto
import ai.kilocode.rpc.dto.WorktreeBranchesDto
import ai.kilocode.rpc.dto.WorktreeDto
import ai.kilocode.rpc.dto.WorktreeListDto
@@ -12,19 +13,27 @@ import com.intellij.execution.configurations.GeneralCommandLine
import com.intellij.execution.process.CapturingProcessHandler
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.builtins.MapSerializer
import kotlinx.serialization.builtins.serializer
import kotlinx.serialization.json.Json
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.StandardCopyOption
class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi {
companion object {
private val LOG = KiloLog.create(KiloWorktreeRpcApiImpl::class.java)
internal val LOG = KiloLog.create(KiloWorktreeRpcApiImpl::class.java)
}
override suspend fun list(directory: String): WorktreeListDto = withContext(Dispatchers.IO) {
val base = Path.of(directory).normalize()
val res = runGit(base, "worktree", "list", "--porcelain")
if (!res.ok) WorktreeListDto() else WorktreeListDto(managedWorktrees(parseWorktreeList(res.stdout)))
if (!res.ok) return@withContext WorktreeListDto()
val items = managedWorktrees(parseWorktreeList(res.stdout))
val store = worktreeNameStore(items)
val names = store?.let(::readWorktreeNames).orEmpty()
WorktreeListDto(overlayWorktreeNames(items, names))
}
override suspend fun listBranches(directory: String): WorktreeBranchesDto = withContext(Dispatchers.IO) {
@@ -53,8 +62,9 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi {
CreateWorktreeResultDto(error = res.stderr.ifBlank { "git worktree add failed" })
} else {
LOG.info("worktree created: branch=$branch dir=$dir")
val path = dir.toRealPath().toString()
CreateWorktreeResultDto(
worktree = WorktreeDto(dir.toString(), dir.fileName.toString(), branch, dir.toString()),
worktree = WorktreeDto(path, dir.fileName.toString(), branch, path),
)
}
}
@@ -87,6 +97,29 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi {
RemoveWorktreeResultDto(ok = true)
}
override suspend fun rename(directory: String, path: String, name: String): RenameWorktreeResultDto =
withContext(Dispatchers.IO) {
val title = name.trim()
if (title.isEmpty()) return@withContext RenameWorktreeResultDto(error = "Name is required")
val base = Path.of(directory).normalize()
val res = runGit(base, "worktree", "list", "--porcelain")
if (!res.ok) return@withContext RenameWorktreeResultDto(error = res.stderr.ifBlank { "git worktree list failed" })
val items = managedWorktrees(parseWorktreeList(res.stdout))
val store = worktreeNameStore(items)
?: return@withContext RenameWorktreeResultDto(error = "Main worktree not found")
val target = items.firstOrNull { samePath(it.path, path) && !it.main }
?: return@withContext RenameWorktreeResultDto(error = "Worktree not found")
return@withContext try {
val names = readWorktreeNames(store).toMutableMap()
names[target.path] = title
writeWorktreeNames(store, names)
RenameWorktreeResultDto(worktree = target.copy(name = title))
} catch (e: Exception) {
LOG.warn("worktree rename failed: path=$path message=${e.message}", e)
RenameWorktreeResultDto(error = e.message ?: "worktree rename failed")
}
}
private data class GitResult(val exit: Int, val stdout: String, val stderr: String) {
val ok get() = exit == 0
}
@@ -102,6 +135,10 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi {
}
}
private val json = Json { prettyPrint = true }
private val codec = MapSerializer(String.serializer(), String.serializer())
private const val WORKTREE_NAMES_FILE = "worktree-names.json"
/** Parse `git worktree list --porcelain`. First entry is the main working tree. */
internal fun parseWorktreeList(raw: String): List<WorktreeDto> {
val out = mutableListOf<WorktreeDto>()
@@ -145,3 +182,53 @@ internal fun managedWorktrees(items: List<WorktreeDto>): List<WorktreeDto> {
path.startsWith(storage) && path != storage
}
}
internal fun overlayWorktreeNames(items: List<WorktreeDto>, names: Map<String, String>): List<WorktreeDto> {
if (names.isEmpty()) return items
return items.map { item ->
val name = names[item.path]?.trim()
if (item.main || name.isNullOrEmpty()) item else item.copy(name = name)
}
}
internal fun readWorktreeNames(file: Path): Map<String, String> {
if (!Files.exists(file)) return emptyMap()
return try {
val raw = Files.readString(file)
json.decodeFromString(codec, raw)
.filterValues { it.isNotBlank() }
} catch (e: Exception) {
KiloWorktreeRpcApiImpl.LOG.warn("worktree names read failed: file=$file message=${e.message}", e)
emptyMap()
}
}
internal fun writeWorktreeNames(file: Path, names: Map<String, String>) {
Files.createDirectories(file.parent)
val data = names.filterValues { it.isNotBlank() }
val tmp = Files.createTempFile(file.parent, ".worktree-names", ".tmp")
try {
Files.writeString(tmp, json.encodeToString(codec, data))
try {
Files.move(tmp, file, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING)
} catch (_: Exception) {
Files.move(tmp, file, StandardCopyOption.REPLACE_EXISTING)
}
} finally {
Files.deleteIfExists(tmp)
}
}
private fun worktreeNameStore(items: List<WorktreeDto>): Path? {
val main = items.firstOrNull { it.main } ?: return null
return Path.of(main.path).normalize().resolve(".kilo").resolve(WORKTREE_NAMES_FILE)
}
private fun samePath(a: String, b: String): Boolean {
return realPath(a) == realPath(b)
}
private fun realPath(path: String): Path {
val file = Path.of(path).normalize()
return if (Files.exists(file)) file.toRealPath() else file
}
@@ -1,6 +1,7 @@
package ai.kilocode.backend.rpc
import ai.kilocode.rpc.dto.CreateWorktreeRequestDto
import ai.kilocode.rpc.dto.WorktreeDto
import com.intellij.execution.configurations.GeneralCommandLine
import com.intellij.execution.process.CapturingProcessHandler
import kotlinx.coroutines.runBlocking
@@ -113,6 +114,30 @@ class KiloWorktreeRpcApiImplTest {
assertEquals(listOf("/repo"), list.map { it.path })
}
@Test
fun `overlayWorktreeNames applies labels only to non-main worktrees`() {
val main = WorktreeDto("/repo", "repo", "main", "/repo", main = true)
val child = WorktreeDto("/repo/.kilo/worktrees/feature-x", "feature-x", "feature/x", "/repo/.kilo/worktrees/feature-x")
val out = overlayWorktreeNames(listOf(main, child), mapOf(main.path to "Main Label", child.path to "Feature Label"))
assertEquals("repo", out[0].name)
assertEquals("Feature Label", out[1].name)
}
@Test
fun `worktree names store round trips and tolerates missing or corrupt files`() {
val file = repo.resolve(".kilo").resolve("worktree-names.json")
assertTrue(readWorktreeNames(file).isEmpty())
writeWorktreeNames(file, mapOf("/repo/.kilo/worktrees/feature-x" to "Feature Label", "/blank" to ""))
assertEquals(mapOf("/repo/.kilo/worktrees/feature-x" to "Feature Label"), readWorktreeNames(file))
Files.writeString(file, "not json")
assertTrue(readWorktreeNames(file).isEmpty())
}
@Test
fun `remove reports locked and force removes a locked worktree`() = runBlocking {
initRepo()
@@ -161,6 +186,20 @@ class KiloWorktreeRpcApiImplTest {
assertFalse(after.any { it.branch == "feature/x" }, "removed worktree should be gone")
}
@Test
fun `rename persists a custom worktree name and list overlays it`() = runBlocking {
initRepo()
val created = assertNotNull(api.create(repo.toString(), CreateWorktreeRequestDto("feature/x")).worktree)
val renamed = api.rename(repo.toString(), created.path, "Feature Label")
assertNull(renamed.error)
assertEquals("Feature Label", assertNotNull(renamed.worktree).name)
val listed = api.list(repo.toString()).worktrees.single { it.path == created.path }
assertEquals("Feature Label", listed.name)
assertEquals(mapOf(created.path to "Feature Label"), readWorktreeNames(repo.resolve(".kilo").resolve("worktree-names.json")))
}
@Test
fun `remove reports failure when git cannot remove the worktree`() = runBlocking {
initRepo()
@@ -21,7 +21,10 @@ import com.intellij.icons.AllIcons
import com.intellij.ide.DeleteProvider
import com.intellij.ide.ui.LafManagerListener
import com.intellij.openapi.Disposable
import com.intellij.openapi.actionSystem.ActionManager
import com.intellij.openapi.actionSystem.ActionUpdateThread
import com.intellij.openapi.actionSystem.AnAction
import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.actionSystem.DataContext
import com.intellij.openapi.actionSystem.DataSink
import com.intellij.openapi.actionSystem.PlatformDataKeys
@@ -47,13 +50,14 @@ class AgentManagerPanel(
private val project: Project? = null,
) : BorderLayoutPanel(), Disposable, UiDataProvider {
private val provider = WorktreeDeleteProvider()
private val edit = RenameAction()
private val list = ActiveList(
KiloBundle.message("worktree.empty"),
showSearch = false,
onCell = { key, id ->
if (id != DELETE_CELL) return@ActiveList
val item = item(key) ?: return@ActiveList
if (deletable(item)) showDeletePopup(item, id)
if (id == RENAME_CELL && renameable(item)) beginRename(item, id)
if (id == DELETE_CELL && deletable(item)) showDeletePopup(item, id)
},
onOpen = { row, focus ->
val item = (row as? WorktreeRow)?.dto ?: return@ActiveList
@@ -76,6 +80,9 @@ class AgentManagerPanel(
}
controller.onCreateFailure = { err -> notifyCreateFailed(err) }
controller.onRemoveSuccess = { item -> close(item) }
ActionManager.getInstance().getAction("RenameElement")?.shortcutSet?.let { set ->
edit.registerCustomShortcutSet(set, list, this)
}
}
val component: JComponent get() = this
@@ -98,6 +105,28 @@ class AgentManagerPanel(
controller.remove(item, force, onFailure = { result -> notifyFailed(item, result, force) })
}
private fun beginRename(item: WorktreeDto, cell: String? = null) {
list.rename(
item.id,
cell,
current = { key -> item(key)?.takeIf(::renameable)?.name },
commit = { key, name -> item(key)?.takeIf(::renameable)?.let { renameWorktree(it, name) } },
)
}
private fun renameWorktree(item: WorktreeDto, name: String) {
controller.rename(
item,
name,
onSuccess = { updated ->
project?.service<KiloVfsManager>()?.updatePresentation(WorktreeSessionEditorKind.ID, worktreeSessionParams(updated))
},
onFailure = { err ->
KiloNotifications.error(project, KiloBundle.message("worktree.rename.failed.title", name), err)
},
)
}
private fun open(item: WorktreeDto, focus: Boolean) {
val target = project ?: return
if (item.main || controller.isPending(item.id)) return
@@ -132,6 +161,11 @@ class AgentManagerPanel(
return item?.id?.let(controller::isDeleting) != true
}
private fun renameable(item: WorktreeDto?): Boolean {
if (item == null || item.main) return false
return !controller.isPending(item.id) && !controller.isDeleting(item.id)
}
/**
* After a delete, move the selection to the row that took the deleted row's place (the next
* worktree) rather than letting the list reset to the top. [index] is the removed row's index,
@@ -222,6 +256,22 @@ class AgentManagerPanel(
}
}
private inner class RenameAction : AnAction(
KiloBundle.message("worktree.rename.action"),
null,
AllIcons.Actions.Edit,
) {
override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.EDT
override fun update(e: AnActionEvent) {
e.presentation.isEnabled = renameable(selectedRow()?.dto)
}
override fun actionPerformed(e: AnActionEvent) {
selectedRow()?.dto?.takeIf(::renameable)?.let { beginRename(it) }
}
}
private data class WorktreeRow(val dto: WorktreeDto, val pending: Boolean, override val deleting: Boolean) : ActiveListItem {
override val key: String get() = dto.id
override val title: String get() = dto.name
@@ -230,15 +280,24 @@ class AgentManagerPanel(
override val icon = WorktreeIcons.forRow(dto.locked, pending)
override val search: String get() = listOfNotNull(dto.name, dto.branch, dto.path, dto.lockReason).joinToString(" ")
override val cells: List<ActiveListCell>
get() = if (dto.main || pending) emptyList() else listOf(ActiveListCell(
DELETE_CELL,
KiloBundle.message("worktree.delete.action"),
icon = AllIcons.Actions.GC,
iconOnly = true,
))
get() = if (dto.main || pending) emptyList() else listOf(
ActiveListCell(
RENAME_CELL,
KiloBundle.message("worktree.rename.action"),
icon = AllIcons.Actions.Edit,
iconOnly = true,
),
ActiveListCell(
DELETE_CELL,
KiloBundle.message("worktree.delete.action"),
icon = AllIcons.Actions.GC,
iconOnly = true,
),
)
}
private companion object {
const val RENAME_CELL = "rename"
const val DELETE_CELL = "delete"
}
}
@@ -7,6 +7,7 @@ import ai.kilocode.rpc.KiloWorktreeRpcApi
import ai.kilocode.rpc.dto.CreateWorktreeRequestDto
import ai.kilocode.rpc.dto.CreateWorktreeResultDto
import ai.kilocode.rpc.dto.RemoveWorktreeResultDto
import ai.kilocode.rpc.dto.RenameWorktreeResultDto
import ai.kilocode.rpc.dto.WorktreeBranchesDto
import ai.kilocode.rpc.dto.WorktreeListDto
import com.intellij.openapi.components.Service
@@ -58,4 +59,11 @@ class KiloWorktreeService internal constructor(
LOG.warn("worktree remove failed for $path", e)
RemoveWorktreeResultDto(error = e.message ?: "worktree remove failed")
}
suspend fun rename(directory: String, path: String, name: String): RenameWorktreeResultDto = try {
call { rename(directory, path, name) }
} catch (e: Exception) {
LOG.warn("worktree rename failed for $path", e)
RenameWorktreeResultDto(error = e.message ?: "worktree rename failed")
}
}
@@ -5,6 +5,7 @@ import ai.kilocode.rpc.dto.CreateWorktreeRequestDto
import ai.kilocode.rpc.dto.RemoveWorktreeResultDto
import ai.kilocode.rpc.dto.WorktreeDto
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.components.service
import com.intellij.ui.CollectionListModel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@@ -54,6 +55,7 @@ class WorktreeController(
val extra = result.worktrees.filter { !it.main }
val rows = extra + pending.values
model.replaceAll(rows)
cache().putAll(rows)
defaultBranch = main?.branch?.takeIf { it.isNotBlank() && it != "(detached)" } ?: "main"
val worktreeBranches = rows.mapTo(HashSet()) { it.branch }
branches = branchInfo.branches.filter { it !in worktreeBranches }
@@ -85,6 +87,7 @@ class WorktreeController(
val idx = model.getElementIndex(temp)
if (created != null) {
if (idx >= 0) model.setElementAt(created, idx) else model.add(created)
cache().put(created)
onSelect?.invoke(created.id)
telemetry("Worktree Created", mapOf("branch" to branch))
return@edt
@@ -136,10 +139,53 @@ class WorktreeController(
}
}
fun rename(
dto: WorktreeDto,
name: String,
onSuccess: (WorktreeDto) -> Unit = {},
onFailure: (String?) -> Unit = {},
) {
val title = name.trim()
if (title.isEmpty() || title == dto.name) return
edt {
val idx = index(dto.id)
if (idx < 0) return@edt
val row = dto.copy(name = title)
model.setElementAt(row, idx)
cache().put(row)
}
cs.launch {
val result = service.rename(directory, dto.path, title)
edt {
val updated = result.worktree
if (updated != null) {
index(dto.id).takeIf { it >= 0 }?.let { model.setElementAt(updated, it) }
cache().put(updated)
telemetry("Worktree Renamed", mapOf("path" to dto.path))
onSuccess(updated)
return@edt
}
index(dto.id).takeIf { it >= 0 }?.let { model.setElementAt(dto, it) }
cache().put(dto)
telemetry("Worktree Rename Failed", mapOf("path" to dto.path))
onFailure(result.error)
reload()
}
}
}
private fun refresh(dto: WorktreeDto) {
val idx = model.getElementIndex(dto)
if (idx >= 0) model.setElementAt(dto, idx)
}
private fun index(id: String): Int {
return (0 until model.size).firstOrNull { model.getElementAt(it).id == id } ?: -1
}
private fun cache(): WorktreeNameCache {
return ApplicationManager.getApplication().service()
}
}
private fun edt(block: () -> Unit) {
@@ -0,0 +1,31 @@
package ai.kilocode.client.agentManager.worktree
import ai.kilocode.rpc.dto.WorktreeDto
import com.intellij.openapi.components.Service
@Service(Service.Level.APP)
class WorktreeNameCache {
private val names = linkedMapOf<String, String>()
fun get(path: String): String? = names[path]
fun put(path: String, name: String) {
names[path] = name
}
fun put(item: WorktreeDto) {
names[item.path] = item.name
}
fun remove(path: String) {
names.remove(path)
}
fun clear() {
names.clear()
}
fun putAll(items: List<WorktreeDto>) {
items.forEach(::put)
}
}
@@ -25,7 +25,9 @@ object WorktreeSessionEditorKind : KiloEditorKind {
override val id: String = ID
override fun title(params: Map<String, String>): String = params[PATH]?.let(::name) ?: KiloBundle.message("worktree.session.title")
override fun title(params: Map<String, String>): String = params[PATH]?.let { path ->
service<WorktreeNameCache>().get(path) ?: name(path)
} ?: KiloBundle.message("worktree.session.title")
override fun icon(params: Map<String, String>): Icon = WorktreeIcons.branch
override fun fileType(params: Map<String, String>): FileType = WorktreeSessionFileType
override fun presentablePath(params: Map<String, String>): String = params[PATH] ?: title(params)
@@ -137,6 +137,18 @@ open class WorktreeSessionEditorManager(
}
}
@RequiresEdt
open fun renameSession(id: String, title: String) {
val name = title.trim()
if (id == NEW || name.isBlank()) return
list.rename(id, name) { ok, err ->
onListChanged?.invoke()
if (ok) return@rename
notify(KiloBundle.message("worktree.session.rename.failed.title", name), err)
}
onListChanged?.invoke()
}
@RequiresEdt
override fun present(ui: SessionUi?) {
right.removeAll()
@@ -14,6 +14,7 @@ import ai.kilocode.client.ui.list.ActiveListBadge
import ai.kilocode.client.ui.list.ActiveListCell
import ai.kilocode.client.ui.list.ActiveListConfig
import ai.kilocode.client.ui.list.ActiveListDeleteOptions
import ai.kilocode.client.ui.list.ActiveListEditOptions
import ai.kilocode.client.ui.list.ActiveListItem
import ai.kilocode.client.ui.list.ActiveListRowHeight
import ai.kilocode.client.ui.list.ActiveListSelection
@@ -50,8 +51,10 @@ class WorktreeSessionEditorPanel(
private val controller: WorktreeSessionListController,
private val worktree: ai.kilocode.client.app.Workspace,
private val confirm: ((RelativePoint, ActiveListDeleteOptions, () -> Unit) -> Unit)? = null,
private val edit: ((RelativePoint, ActiveListEditOptions, (String) -> Unit) -> Unit)? = null,
) : BorderLayoutPanel(), Disposable, UiDataProvider {
private val add = NewAction()
private val rename = RenameAction()
private val delete = DeleteAction()
private val list = ActiveList(
KiloBundle.message("worktree.session.list.empty"),
@@ -61,7 +64,10 @@ class WorktreeSessionEditorPanel(
selection = ListSelectionModel.MULTIPLE_INTERVAL_SELECTION,
),
showSearch = false,
onCell = { key, id -> if (id == DELETE_CELL) confirmDelete(listOf(key), DELETE_CELL) },
onCell = { key, id ->
if (id == RENAME_CELL) beginRename(key, RENAME_CELL)
if (id == DELETE_CELL) confirmDelete(listOf(key), DELETE_CELL)
},
onOpen = { row, focus -> open(row, focus) },
onSelect = { updateActions() },
)
@@ -81,6 +87,9 @@ class WorktreeSessionEditorPanel(
bindTheme()
manager.onPresent = { key -> select(key) }
manager.onListChanged = { sync() }
ActionManager.getInstance().getAction("RenameElement")?.shortcutSet?.let { set ->
rename.registerCustomShortcutSet(set, list, this)
}
addHierarchyListener {
if (isShowing) start()
}
@@ -106,6 +115,12 @@ class WorktreeSessionEditorPanel(
confirmDelete(selectedKeys())
}
@RequiresEdt
fun renameSelected() {
val key = selectedKeys().firstOrNull { it != SessionHost.NEW && it !in manager.deleting() } ?: return
beginRename(key)
}
@RequiresEdt
private fun confirmDelete(ids: List<String>, cell: String? = null) {
val active = ids.filter { it != SessionHost.NEW && it !in manager.deleting() }.distinct()
@@ -125,6 +140,19 @@ class WorktreeSessionEditorPanel(
handler(list.point(active[0], cell), opts) { manager.deleteSessions(active) }
}
@RequiresEdt
private fun beginRename(key: String, cell: String? = null) {
if (key == SessionHost.NEW || key in manager.deleting()) return
val value = title(key)
if (!list.select(key)) return
val handler = edit ?: { anchor: RelativePoint, opts: ActiveListEditOptions, commit: (String) -> Unit ->
list.editName(anchor, opts, commit)
}
handler(list.point(key, cell), ActiveListEditOptions(value)) { name ->
manager.renameSession(key, name)
}
}
@RequiresEdt
private fun start() {
if (started) return
@@ -136,7 +164,7 @@ class WorktreeSessionEditorPanel(
private fun toolbar(): JComponent {
val toolbar = ActionManager.getInstance().createActionToolbar(
ActionPlaces.TOOLBAR,
DefaultActionGroup(add, delete),
DefaultActionGroup(add, rename, delete),
true,
)
toolbar.targetComponent = this
@@ -250,6 +278,22 @@ class WorktreeSessionEditorPanel(
}
}
private inner class RenameAction : AnAction(
KiloBundle.message("worktree.session.rename.action"),
null,
AllIcons.Actions.Edit,
) {
override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.EDT
override fun update(e: AnActionEvent) {
e.presentation.isEnabled = selectedKeys().any { it != SessionHost.NEW && it !in manager.deleting() }
}
override fun actionPerformed(e: AnActionEvent) {
renameSelected()
}
}
private object NewRow : ActiveListItem {
override val key: String get() = SessionHost.NEW
override val title: String get() = KiloBundle.message("worktree.session.new")
@@ -274,16 +318,25 @@ class WorktreeSessionEditorPanel(
override val cells: List<ActiveListCell>
get() {
if (selectedKeys().size != 1) return emptyList()
return listOf(ActiveListCell(
DELETE_CELL,
KiloBundle.message("worktree.session.delete.action"),
icon = AllIcons.Actions.GC,
iconOnly = true,
))
return listOf(
ActiveListCell(
RENAME_CELL,
KiloBundle.message("worktree.session.rename.action"),
icon = AllIcons.Actions.Edit,
iconOnly = true,
),
ActiveListCell(
DELETE_CELL,
KiloBundle.message("worktree.session.delete.action"),
icon = AllIcons.Actions.GC,
iconOnly = true,
),
)
}
}
private companion object {
const val RENAME_CELL = "rename"
const val DELETE_CELL = "delete"
}
}
@@ -6,6 +6,7 @@ import ai.kilocode.log.KiloLog
import ai.kilocode.rpc.dto.SessionDto
import com.intellij.openapi.application.ApplicationManager
import com.intellij.ui.CollectionListModel
import com.intellij.util.concurrency.annotations.RequiresEdt
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@@ -74,6 +75,40 @@ class WorktreeSessionListController(
}
}
@RequiresEdt
fun rename(id: String, title: String, done: (Boolean, String?) -> Unit) {
val name = title.trim()
if (id.isBlank()) return edt { done(false, "Missing session id") }
if (name.isBlank()) return edt { done(false, "Missing session title") }
val prior = (0 until model.size)
.map { model.getElementAt(it) }
.firstOrNull { it.id == id }
?: return edt { done(false, "Session not found") }
val optimistic = prior.copy(title = name)
edt {
index(id).takeIf { it >= 0 }?.let { model.setElementAt(optimistic, it) }
}
cs.launch {
val result = runCatching { service.renameSession(id, dir, name) }
val updated = result.getOrNull()
if (updated != null) {
edt {
index(id).takeIf { it >= 0 }?.let { model.setElementAt(updated, it) }
capture("Worktree Session Renamed", mapOf("sessionId" to id))
done(true, null)
}
return@launch
}
val err = result.exceptionOrNull()
LOG.warn("worktree session rename failed id=$id dir=$dir message=${err?.message}", err)
edt {
index(id).takeIf { it >= 0 }?.let { model.setElementAt(prior, it) }
done(false, err?.message)
}
reload()
}
}
companion object {
private val LOG = KiloLog.create(WorktreeSessionListController::class.java)
}
@@ -85,6 +120,10 @@ class WorktreeSessionListController(
LOG.warn("worktree session telemetry failed event=$event message=${e.message}", e)
}
}
private fun index(id: String): Int {
return (0 until model.size).firstOrNull { model.getElementAt(it).id == id } ?: -1
}
}
private fun edt(block: () -> Unit) {
@@ -88,6 +88,33 @@ internal class ActiveList(
trackBalloon(showActiveListDeletePopup(anchor, opts, confirm))
}
@RequiresEdt
fun editName(anchor: RelativePoint, opts: ActiveListEditOptions, commit: (String) -> Unit) {
trackBalloon(showActiveListEditPopup(anchor, opts, commit))
}
@RequiresEdt
fun rename(
key: String,
cell: String? = null,
current: (String) -> String?,
commit: (String, String) -> Unit,
) {
if (!select(key)) return
val value = current(key) ?: return
editName(point(key, cell), ActiveListEditOptions(value)) { name -> commit(key, name) }
}
@RequiresEdt
fun renameSelected(current: (String) -> String?, commit: (String, String) -> Unit): Boolean {
for (key in selectedKeys()) {
if (current(key) == null) continue
rename(key, null, current, commit)
return true
}
return false
}
@RequiresEdt
fun setBusy(value: Boolean) {
search?.isEnabled = !value
@@ -0,0 +1,120 @@
package ai.kilocode.client.ui.list
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.ui.UiStyle
import ai.kilocode.client.ui.layout.Stack
import ai.kilocode.client.ui.layout.StackAxis
import com.intellij.openapi.ui.DialogWrapper
import com.intellij.openapi.ui.popup.Balloon
import com.intellij.openapi.ui.popup.JBPopupFactory
import com.intellij.ui.DocumentAdapter
import com.intellij.ui.awt.RelativePoint
import com.intellij.ui.components.JBLabel
import com.intellij.ui.components.JBTextField
import com.intellij.util.ui.JBUI
import com.intellij.util.ui.UIUtil
import com.intellij.util.ui.components.BorderLayoutPanel
import java.awt.Container
import java.awt.event.ActionEvent
import javax.swing.AbstractAction
import javax.swing.JButton
import javax.swing.JComponent
import javax.swing.SwingUtilities
import javax.swing.event.DocumentEvent
data class ActiveListEditOptions(
val value: String,
val label: String? = null,
val button: String = KiloBundle.message("common.rename"),
)
internal fun activeListEditContent(
opts: ActiveListEditOptions,
hide: () -> Unit,
commit: (String) -> Unit,
): JComponent {
val field = JBTextField(opts.value, 24)
val action = object : AbstractAction(opts.button) {
override fun actionPerformed(e: ActionEvent) {
val text = field.text.trim()
if (!enabled(text, opts.value)) return
hide()
commit(text)
}
}.apply { putValue(DialogWrapper.DEFAULT_ACTION, true) }
val button = DialogWrapper.createJButtonForAction(action, null)
fun sync() {
action.isEnabled = enabled(field.text, opts.value)
}
field.document.addDocumentListener(object : DocumentAdapter() {
override fun textChanged(e: DocumentEvent) = sync()
})
val content = Stack(StackAxis.VERTICAL, UiStyle.Gap.sm()).apply {
border = JBUI.Borders.empty(UiStyle.Gap.lg())
opts.label?.takeIf { it.isNotBlank() }?.let { text ->
next(JBLabel(text).apply {
foreground = UIUtil.getContextHelpForeground()
})
}
next(field)
next(BorderLayoutPanel().andTransparent().addToRight(button))
}
sync()
return content
}
internal fun showActiveListEditPopup(
anchor: RelativePoint,
opts: ActiveListEditOptions,
commit: (String) -> Unit,
): Balloon {
lateinit var balloon: Balloon
val content = activeListEditContent(opts, hide = { balloon.hide(true) }, commit)
balloon = JBPopupFactory.getInstance()
.createBalloonBuilder(content)
.setFillColor(UIUtil.getToolTipBackground())
.setBorderColor(JBUI.CurrentTheme.Tooltip.borderColor())
.setCloseButtonEnabled(true)
.setHideOnCloseClick(true)
.setHideOnClickOutside(true)
.setHideOnKeyOutside(true)
.setHideOnAction(false)
.setShowCallout(true)
.setAnimationCycle(0)
.setRequestFocus(true)
.createBalloon()
balloon.show(anchor, Balloon.Position.below)
SwingUtilities.getRootPane(content)?.defaultButton = activeListEditButton(content)
activeListEditField(content)?.let { field ->
SwingUtilities.invokeLater {
field.requestFocusInWindow()
field.selectAll()
}
}
return balloon
}
private fun enabled(text: String, value: String): Boolean {
val next = text.trim()
return next.isNotBlank() && next != value.trim()
}
private fun activeListEditButton(root: Container): JButton? {
for (child in root.components) {
if (child is JButton) return child
if (child is Container) activeListEditButton(child)?.let { return it }
}
return null
}
private fun activeListEditField(root: Container): JBTextField? {
for (child in root.components) {
if (child is JBTextField) return child
if (child is Container) activeListEditField(child)?.let { return it }
}
return null
}
@@ -1,6 +1,7 @@
common.delete=Delete
common.deleting=Deleting…
common.open=Open
common.rename=Rename
common.save=Save
session.action.cancel=Cancel
@@ -313,6 +314,8 @@ worktree.delete.confirm.detail=This removes the working tree and its branch.
worktree.delete.locked.confirm=Confirm deleting this locked worktree
worktree.delete.force=Force delete this worktree
worktree.delete.failed.title=Couldn''t delete worktree "{0}"
worktree.rename.action=Rename worktree
worktree.rename.failed.title=Failed to rename worktree "{0}"
worktree.create.failed.title=Couldn''t create worktree
worktree.session.title=Worktree Session
worktree.session.fileType.displayName=Kilo Worktree Session
@@ -320,11 +323,13 @@ worktree.session.fileType.description=Kilo worktree session virtual file
worktree.session.list.empty=No sessions
worktree.session.list.search.placeholder=Search sessions
worktree.session.new.action=New session
worktree.session.rename.action=Rename session
worktree.session.delete.action=Delete session
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.
worktree.session.delete.failed.title=Failed to delete session "{0}"
worktree.session.rename.failed.title=Failed to rename session "{0}"
worktree.session.new=New session
worktree.session.untitled=Untitled session
worktree.menu.from=New Worktree from {0}
@@ -3,13 +3,16 @@ package ai.kilocode.client.agentManager
import ai.kilocode.client.agentManager.worktree.WorktreeIcons
import ai.kilocode.client.agentManager.worktree.KiloWorktreeService
import ai.kilocode.client.agentManager.worktree.WorktreeController
import ai.kilocode.client.agentManager.worktree.WorktreeNameCache
import ai.kilocode.client.agentManager.worktree.WorktreeNames
import ai.kilocode.client.testing.FakeWorktreeRpcApi
import ai.kilocode.client.testing.TestCoroutines
import ai.kilocode.rpc.dto.CreateWorktreeResultDto
import ai.kilocode.rpc.dto.RemoveWorktreeResultDto
import ai.kilocode.rpc.dto.RenameWorktreeResultDto
import ai.kilocode.rpc.dto.WorktreeDto
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.components.service
import com.intellij.testFramework.fixtures.BasePlatformTestCase
import com.intellij.util.ui.UIUtil
import kotlinx.coroutines.CompletableDeferred
@@ -29,6 +32,7 @@ class WorktreeControllerTest : BasePlatformTestCase() {
override fun tearDown() {
try {
cache().clear()
coroutines.close(::pump)
} finally {
super.tearDown()
@@ -173,6 +177,48 @@ class WorktreeControllerTest : BasePlatformTestCase() {
assertEquals(0, controller.model.size)
}
fun `test rename optimistically updates and keeps successful result`() {
val item = WorktreeDto("/repo/.kilo/worktrees/feature-x", "feature-x", "feature/x", "/repo/.kilo/worktrees/feature-x")
val gate = CompletableDeferred<Unit>()
rpc.listed += item
rpc.beforeRename = { gate.await() }
val controller = controller()
controller.reload()
flush()
ApplicationManager.getApplication().invokeAndWait { controller.rename(controller.model.getElementAt(0), "Feature Label") }
assertEquals("Feature Label", controller.model.getElementAt(0).name)
assertEquals("Feature Label", cache().get(item.path))
assertTrue(rpc.renames.isEmpty())
gate.complete(Unit)
flush()
assertEquals(listOf(Triple("/test", item.path, "Feature Label")), rpc.renames.toList())
assertEquals("Feature Label", controller.model.getElementAt(0).name)
assertEquals("Feature Label", cache().get(item.path))
}
fun `test rename failure reverts row and invokes callback`() {
val item = WorktreeDto("/repo/.kilo/worktrees/feature-x", "feature-x", "feature/x", "/repo/.kilo/worktrees/feature-x")
rpc.listed += item
rpc.renameResult = { _, _ -> RenameWorktreeResultDto(error = "cannot rename") }
val failures = mutableListOf<String?>()
val controller = controller()
controller.reload()
flush()
ApplicationManager.getApplication().invokeAndWait {
controller.rename(controller.model.getElementAt(0), "Feature Label", onFailure = { failures += it })
}
flush()
assertEquals("feature-x", controller.model.getElementAt(0).name)
assertEquals(listOf("cannot rename"), failures)
assertEquals("feature-x", cache().get(item.path))
}
fun `test reload derives default branch from the main worktree`() {
rpc.listed += WorktreeDto("/repo", "repo", "trunk", "/repo", main = true)
rpc.listed += WorktreeDto("/repo/.kilo/worktrees/feature-x", "feature-x", "feature/x", "/repo/.kilo/worktrees/feature-x")
@@ -261,6 +307,8 @@ class WorktreeControllerTest : BasePlatformTestCase() {
private fun flush() = coroutines.drain(::pump)
private fun cache(): WorktreeNameCache = ApplicationManager.getApplication().service()
private fun pump() {
ApplicationManager.getApplication().invokeAndWait { UIUtil.dispatchAllInvocationEvents() }
}
@@ -2,6 +2,7 @@ package ai.kilocode.client.agentManager
import ai.kilocode.client.agentManager.worktree.WorktreeSessionEditorKind
import ai.kilocode.client.agentManager.worktree.WorktreeSessionFileType
import ai.kilocode.client.agentManager.worktree.WorktreeNameCache
import ai.kilocode.client.agentManager.worktree.ensureWorktreeSessionEditorKind
import ai.kilocode.client.agentManager.worktree.unregisterWorktreeSessionEditorKind
import ai.kilocode.client.agentManager.worktree.worktreeSessionParams
@@ -15,6 +16,14 @@ import com.intellij.openapi.vfs.VirtualFilePathWrapper
import com.intellij.testFramework.fixtures.BasePlatformTestCase
class WorktreeSessionEditorKindTest : BasePlatformTestCase() {
override fun tearDown() {
try {
service<WorktreeNameCache>().clear()
} finally {
super.tearDown()
}
}
fun `test worktree session params use only the worktree path`() {
val item = WorktreeDto("/repo/.kilo/worktrees/feature-x", "feature-x", "feature/x", "/repo/.kilo/worktrees/feature-x")
val params = worktreeSessionParams(item)
@@ -42,4 +51,11 @@ class WorktreeSessionEditorKindTest : BasePlatformTestCase() {
assertNull(service<KiloVirtualFileKindRegistry>().get(WorktreeSessionEditorKind.ID))
assertNull(fs.findOrCreateFile(path))
}
fun `test worktree session title uses cached label`() {
val path = "/repo/.kilo/worktrees/feature-x"
service<WorktreeNameCache>().put(path, "Feature Label")
assertEquals("Feature Label", WorktreeSessionEditorKind.title(mapOf("path" to path)))
}
}
@@ -216,8 +216,44 @@ class WorktreeSessionEditorManagerTest : BasePlatformTestCase() {
assertEquals(listOf("Failed to delete session \"Session ses_1\"" to "delete unavailable"), notified)
}
private fun manager(focus: Boolean = false): WorktreeSessionEditorManager {
fun `test rename updates session title optimistically and keeps success`() {
val session = session("ses_1", updated = 1.0)
rpc.listed += session
val controller = WorktreeSessionListController(sessions, DIR, coroutines.scope)
val manager = manager(controller = controller)
edt { manager.start() }
flush()
edt { manager.renameSession(session.id, "Renamed Session") }
assertEquals("Renamed Session", edt { controller.model.getElementAt(0).title })
flush()
assertEquals(listOf(Triple(session.id, DIR, "Renamed Session")), rpc.renames)
assertEquals("Renamed Session", edt { controller.model.getElementAt(0).title })
assertTrue(notified.isEmpty())
}
fun `test rename failure reverts session title and notifies`() {
val session = session("ses_1", updated = 1.0)
rpc.listed += session
rpc.renameThrows = IllegalStateException("rename unavailable")
val controller = WorktreeSessionListController(sessions, DIR, coroutines.scope)
val manager = manager(controller = controller)
edt { manager.start() }
flush()
edt { manager.renameSession(session.id, "Renamed Session") }
flush()
assertEquals("Session ses_1", edt { controller.model.getElementAt(0).title })
assertEquals(listOf("Failed to rename session \"Renamed Session\"" to "rename unavailable"), notified)
}
private fun manager(
focus: Boolean = false,
controller: WorktreeSessionListController = WorktreeSessionListController(sessions, DIR, coroutines.scope),
): WorktreeSessionEditorManager {
return WorktreeSessionEditorManager(
parent = testRootDisposable,
project = project,
@@ -75,6 +75,7 @@ class WorktreeSessionEditorPanelTest : BasePlatformTestCase() {
assertEquals(0.25f, splitter.proportion, 0.01f)
val buttons = components(panel).filterIsInstance<ActionButton>().mapNotNull { it.presentation.text }
assertTrue(buttons.contains("New session"))
assertTrue(buttons.contains("Rename session"))
assertTrue(buttons.contains("Delete session"))
assertNotNull(UIUtil.findComponentOfType(panel, JBList::class.java))
assertNull(UIUtil.findComponentOfType(panel, SearchTextField::class.java))
@@ -278,7 +279,7 @@ class WorktreeSessionEditorPanelTest : BasePlatformTestCase() {
flush()
edt { panel.selectSessions(listOf("ses_1")) }
assertEquals(listOf(DELETE_CELL), row("ses_1").cells.map { it.id })
assertEquals(listOf(RENAME_CELL, DELETE_CELL), row("ses_1").cells.map { it.id })
edt { panel.selectSessions(listOf("ses_1", "ses_2")) }
@@ -286,6 +287,27 @@ class WorktreeSessionEditorPanelTest : BasePlatformTestCase() {
assertTrue(row("ses_2").cells.isEmpty())
}
fun `test multi select rename resets to first visible selected session`() {
val edits = mutableListOf<String>()
val view = edt {
WorktreeSessionEditorPanel(testRootDisposable, manager, controller, workspace, edit = { _, opts, _ -> edits += opts.value })
}
rpc.listed += session("ses_1", 1.0)
rpc.listed += session("ses_2", 2.0)
edt { controller.reload() }
flush()
edt {
view.selectSessions(listOf("ses_1", "ses_2"))
view.renameSelected()
}
val list = edt { UIUtil.findComponentOfType(view, JBList::class.java)!! }
assertEquals(listOf("Session ses_2"), edits)
assertEquals(listOf(0), edt { list.selectedIndices.toList() })
assertTrue(manager.renamed.isEmpty())
}
fun `test delete action skips deleting selected sessions`() {
manager.deletingIds += "ses_1"
rpc.listed += session("ses_1", 1.0)
@@ -385,6 +407,7 @@ class WorktreeSessionEditorPanelTest : BasePlatformTestCase() {
val refs = mutableListOf<String>()
val focuses = mutableListOf<Boolean>()
val deleted = mutableListOf<String>()
val renamed = mutableListOf<Pair<String, String>>()
override fun hasPendingNew(): Boolean = pending
@@ -404,6 +427,10 @@ class WorktreeSessionEditorPanelTest : BasePlatformTestCase() {
override fun deleteSessions(ids: List<String>) {
deleted += ids
}
override fun renameSession(id: String, title: String) {
renamed += id to title
}
}
private class SessionSink : DataSink {
@@ -425,6 +452,7 @@ class WorktreeSessionEditorPanelTest : BasePlatformTestCase() {
private companion object {
const val DIR = "/repo/.kilo/worktrees/feature-x"
const val RENAME_CELL = "rename"
const val DELETE_CELL = "delete"
}
}
@@ -4,6 +4,7 @@ import ai.kilocode.rpc.KiloWorktreeRpcApi
import ai.kilocode.rpc.dto.CreateWorktreeRequestDto
import ai.kilocode.rpc.dto.CreateWorktreeResultDto
import ai.kilocode.rpc.dto.RemoveWorktreeResultDto
import ai.kilocode.rpc.dto.RenameWorktreeResultDto
import ai.kilocode.rpc.dto.WorktreeBranchesDto
import ai.kilocode.rpc.dto.WorktreeDto
import ai.kilocode.rpc.dto.WorktreeListDto
@@ -20,12 +21,22 @@ class FakeWorktreeRpcApi : KiloWorktreeRpcApi {
val creates = CopyOnWriteArrayList<CreateWorktreeRequestDto>()
val removes = CopyOnWriteArrayList<Triple<String, String, String?>>()
val removeForces = CopyOnWriteArrayList<Boolean>()
val renames = CopyOnWriteArrayList<Triple<String, String, String>>()
var beforeCreate: suspend () -> Unit = {}
var beforeRemove: suspend () -> Unit = {}
var beforeRename: suspend () -> Unit = {}
var createResult: (CreateWorktreeRequestDto) -> CreateWorktreeResultDto = { req ->
CreateWorktreeResultDto(WorktreeDto(req.branch, req.branch, req.branch, req.branch))
}
var removeResult: (String, String?, Boolean) -> RemoveWorktreeResultDto = { _, _, _ -> RemoveWorktreeResultDto(ok = true) }
var renameResult: (String, String) -> RenameWorktreeResultDto = { path, name ->
val idx = listed.indexOfFirst { it.path == path }
if (idx < 0) RenameWorktreeResultDto(error = "missing") else {
val item = listed[idx].copy(name = name)
listed[idx] = item
RenameWorktreeResultDto(worktree = item)
}
}
override suspend fun list(directory: String): WorktreeListDto {
assertNotEdt("list")
@@ -51,4 +62,11 @@ class FakeWorktreeRpcApi : KiloWorktreeRpcApi {
beforeRemove()
return removeResult(path, branch, force)
}
override suspend fun rename(directory: String, path: String, name: String): RenameWorktreeResultDto {
assertNotEdt("rename")
renames.add(Triple(directory, path, name))
beforeRename()
return renameResult(path, name)
}
}
@@ -0,0 +1,62 @@
package ai.kilocode.client.ui.list
import com.intellij.testFramework.fixtures.BasePlatformTestCase
import com.intellij.ui.components.JBTextField
import java.awt.Component
import java.awt.Container
import javax.swing.JButton
@Suppress("UnstableApiUsage")
class ActiveListEditPopupTest : BasePlatformTestCase() {
fun `test edit content disables unchanged and blank values`() {
val content = activeListEditContent(
ActiveListEditOptions(value = "Current"),
hide = {},
commit = {},
)
val field = component<JBTextField>(content)
val button = component<JButton>(content)
assertFalse(button.isEnabled)
field.text = " "
assertFalse(button.isEnabled)
field.text = "Current"
assertFalse(button.isEnabled)
field.text = "Next"
assertTrue(button.isEnabled)
}
fun `test edit content commits trimmed value and hides`() {
val hides = mutableListOf<Unit>()
val commits = mutableListOf<String>()
val content = activeListEditContent(
ActiveListEditOptions(value = "Current"),
hide = { hides += Unit },
commit = { commits += it },
)
val field = component<JBTextField>(content)
val button = component<JButton>(content)
field.text = " Next "
button.doClick()
assertEquals(1, hides.size)
assertEquals(listOf("Next"), commits)
}
private inline fun <reified T : Component> component(root: Component): T {
val found = components(root).filterIsInstance<T>().firstOrNull()
assertNotNull(found)
return found!!
}
private fun components(root: Component): List<Component> {
val out = mutableListOf<Component>()
fun visit(item: Component) {
out += item
if (item is Container) item.components.forEach { visit(it) }
}
visit(root)
return out
}
}
@@ -3,6 +3,7 @@ package ai.kilocode.rpc
import ai.kilocode.rpc.dto.CreateWorktreeRequestDto
import ai.kilocode.rpc.dto.CreateWorktreeResultDto
import ai.kilocode.rpc.dto.RemoveWorktreeResultDto
import ai.kilocode.rpc.dto.RenameWorktreeResultDto
import ai.kilocode.rpc.dto.WorktreeBranchesDto
import ai.kilocode.rpc.dto.WorktreeListDto
import com.intellij.platform.rpc.RemoteApiProviderService
@@ -28,4 +29,5 @@ interface KiloWorktreeRpcApi : RemoteApi<Unit> {
suspend fun listBranches(directory: String): WorktreeBranchesDto
suspend fun create(directory: String, request: CreateWorktreeRequestDto): CreateWorktreeResultDto
suspend fun remove(directory: String, path: String, branch: String? = null, force: Boolean = false): RemoveWorktreeResultDto
suspend fun rename(directory: String, path: String, name: String): RenameWorktreeResultDto
}
@@ -40,3 +40,9 @@ data class RemoveWorktreeResultDto(
val error: String? = null,
val locked: Boolean = false, // removal was blocked by a worktree lock; retry with force
)
@Serializable
data class RenameWorktreeResultDto(
val worktree: WorktreeDto? = null,
val error: String? = null,
)