From d522ff80e25e220c46cda6ed621e29025c302259 Mon Sep 17 00:00:00 2001 From: kirillk Date: Wed, 29 Jul 2026 14:05:04 -0400 Subject: [PATCH] feat(jetbrains): support agent manager renaming Allow JetBrains Agent Manager users to rename worktree display labels and worktree sessions without changing git branch names. --- .changeset/rename-jetbrains-worktrees.md | 5 + .../backend/rpc/KiloWorktreeRpcApiImpl.kt | 93 +++++++++++++- .../backend/rpc/KiloWorktreeRpcApiImplTest.kt | 39 ++++++ .../client/agentManager/AgentManagerPanel.kt | 75 +++++++++-- .../worktree/KiloWorktreeService.kt | 8 ++ .../worktree/WorktreeController.kt | 46 +++++++ .../worktree/WorktreeNameCache.kt | 31 +++++ .../worktree/WorktreeSessionEditorKind.kt | 4 +- .../worktree/WorktreeSessionEditorManager.kt | 12 ++ .../worktree/WorktreeSessionEditorPanel.kt | 69 ++++++++-- .../worktree/WorktreeSessionListController.kt | 39 ++++++ .../ai/kilocode/client/ui/list/ActiveList.kt | 27 ++++ .../client/ui/list/ActiveListEditPopup.kt | 120 ++++++++++++++++++ .../resources/messages/KiloBundle.properties | 5 + .../agentManager/WorktreeControllerTest.kt | 48 +++++++ .../WorktreeSessionEditorKindTest.kt | 16 +++ .../WorktreeSessionEditorManagerTest.kt | 38 +++++- .../WorktreeSessionEditorPanelTest.kt | 30 ++++- .../client/testing/FakeWorktreeRpcApi.kt | 18 +++ .../client/ui/list/ActiveListEditPopupTest.kt | 62 +++++++++ .../ai/kilocode/rpc/KiloWorktreeRpcApi.kt | 2 + .../kotlin/ai/kilocode/rpc/dto/WorktreeDto.kt | 6 + 22 files changed, 771 insertions(+), 22 deletions(-) create mode 100644 .changeset/rename-jetbrains-worktrees.md create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeNameCache.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListEditPopup.kt create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/list/ActiveListEditPopupTest.kt diff --git a/.changeset/rename-jetbrains-worktrees.md b/.changeset/rename-jetbrains-worktrees.md new file mode 100644 index 00000000000..c0f522234de --- /dev/null +++ b/.changeset/rename-jetbrains-worktrees.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": minor +--- + +Support renaming Agent Manager worktrees and worktree sessions from the JetBrains plugin. diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImpl.kt index 58870587ff2..775bafcdfd0 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImpl.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImpl.kt @@ -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 { val out = mutableListOf() @@ -145,3 +182,53 @@ internal fun managedWorktrees(items: List): List { path.startsWith(storage) && path != storage } } + +internal fun overlayWorktreeNames(items: List, names: Map): List { + 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 { + 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) { + 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): 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 +} diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImplTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImplTest.kt index 55283955517..2e8d22f0b01 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImplTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImplTest.kt @@ -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() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/AgentManagerPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/AgentManagerPanel.kt index 800a7150ce6..258d724281d 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/AgentManagerPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/AgentManagerPanel.kt @@ -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()?.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 - 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" } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/KiloWorktreeService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/KiloWorktreeService.kt index a05affde3cb..69faf565f31 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/KiloWorktreeService.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/KiloWorktreeService.kt @@ -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") + } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeController.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeController.kt index 87c61936321..0b2ad5fde76 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeController.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeController.kt @@ -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) { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeNameCache.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeNameCache.kt new file mode 100644 index 00000000000..7839c06e59c --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeNameCache.kt @@ -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() + + 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) { + items.forEach(::put) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorKind.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorKind.kt index 560cfa1d83a..a1645c7dc73 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorKind.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorKind.kt @@ -25,7 +25,9 @@ object WorktreeSessionEditorKind : KiloEditorKind { override val id: String = ID - override fun title(params: Map): String = params[PATH]?.let(::name) ?: KiloBundle.message("worktree.session.title") + override fun title(params: Map): String = params[PATH]?.let { path -> + service().get(path) ?: name(path) + } ?: KiloBundle.message("worktree.session.title") override fun icon(params: Map): Icon = WorktreeIcons.branch override fun fileType(params: Map): FileType = WorktreeSessionFileType override fun presentablePath(params: Map): String = params[PATH] ?: title(params) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorManager.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorManager.kt index 01e3ada9c7e..d60ec6cea99 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorManager.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorManager.kt @@ -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() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorPanel.kt index 0fcb764d867..0bb70dd2efd 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorPanel.kt @@ -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, 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 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" } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionListController.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionListController.kt index 4621950e001..ce80da59876 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionListController.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionListController.kt @@ -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) { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveList.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveList.kt index b21ed05c369..9f0283da99b 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveList.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveList.kt @@ -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 diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListEditPopup.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListEditPopup.kt new file mode 100644 index 00000000000..b7a926aad28 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListEditPopup.kt @@ -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 +} diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties index e0a75c38e22..b7b9e5b9ea8 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -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} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/WorktreeControllerTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/WorktreeControllerTest.kt index fd56dc068f7..0a1ccf16ea9 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/WorktreeControllerTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/WorktreeControllerTest.kt @@ -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() + 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() + 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() } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/WorktreeSessionEditorKindTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/WorktreeSessionEditorKindTest.kt index 113d7613dcc..a91866fb260 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/WorktreeSessionEditorKindTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/WorktreeSessionEditorKindTest.kt @@ -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().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().get(WorktreeSessionEditorKind.ID)) assertNull(fs.findOrCreateFile(path)) } + + fun `test worktree session title uses cached label`() { + val path = "/repo/.kilo/worktrees/feature-x" + service().put(path, "Feature Label") + + assertEquals("Feature Label", WorktreeSessionEditorKind.title(mapOf("path" to path))) + } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorManagerTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorManagerTest.kt index d0efb12c1d1..82b73685e40 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorManagerTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorManagerTest.kt @@ -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, diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorPanelTest.kt index 3aac4483282..88ca2761c3d 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorPanelTest.kt @@ -75,6 +75,7 @@ class WorktreeSessionEditorPanelTest : BasePlatformTestCase() { assertEquals(0.25f, splitter.proportion, 0.01f) val buttons = components(panel).filterIsInstance().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() + 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() val focuses = mutableListOf() val deleted = mutableListOf() + val renamed = mutableListOf>() override fun hasPendingNew(): Boolean = pending @@ -404,6 +427,10 @@ class WorktreeSessionEditorPanelTest : BasePlatformTestCase() { override fun deleteSessions(ids: List) { 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" } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorktreeRpcApi.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorktreeRpcApi.kt index 44900372f70..f06f7b33226 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorktreeRpcApi.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorktreeRpcApi.kt @@ -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() val removes = CopyOnWriteArrayList>() val removeForces = CopyOnWriteArrayList() + val renames = CopyOnWriteArrayList>() 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) + } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/list/ActiveListEditPopupTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/list/ActiveListEditPopupTest.kt new file mode 100644 index 00000000000..9c355806663 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/list/ActiveListEditPopupTest.kt @@ -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(content) + val button = component(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() + val commits = mutableListOf() + val content = activeListEditContent( + ActiveListEditOptions(value = "Current"), + hide = { hides += Unit }, + commit = { commits += it }, + ) + val field = component(content) + val button = component(content) + + field.text = " Next " + button.doClick() + + assertEquals(1, hides.size) + assertEquals(listOf("Next"), commits) + } + + private inline fun component(root: Component): T { + val found = components(root).filterIsInstance().firstOrNull() + assertNotNull(found) + return found!! + } + + private fun components(root: Component): List { + val out = mutableListOf() + fun visit(item: Component) { + out += item + if (item is Container) item.components.forEach { visit(it) } + } + visit(root) + return out + } +} diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloWorktreeRpcApi.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloWorktreeRpcApi.kt index 13794bc3601..0b04d417e3a 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloWorktreeRpcApi.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloWorktreeRpcApi.kt @@ -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 { 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 } diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/WorktreeDto.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/WorktreeDto.kt index d0dddc1da53..bf9008691c5 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/WorktreeDto.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/WorktreeDto.kt @@ -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, +)