mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-21 05:52:35 +08:00
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:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": minor
|
||||
---
|
||||
|
||||
Support renaming Agent Manager worktrees and worktree sessions from the JetBrains plugin.
|
||||
+90
-3
@@ -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
|
||||
}
|
||||
|
||||
+39
@@ -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()
|
||||
|
||||
+67
-8
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
+8
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
+46
@@ -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) {
|
||||
|
||||
+31
@@ -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)
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -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)
|
||||
|
||||
+12
@@ -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()
|
||||
|
||||
+61
-8
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
+39
@@ -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) {
|
||||
|
||||
+27
@@ -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
|
||||
|
||||
+120
@@ -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}
|
||||
|
||||
+48
@@ -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() }
|
||||
}
|
||||
|
||||
+16
@@ -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)))
|
||||
}
|
||||
}
|
||||
|
||||
+37
-1
@@ -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,
|
||||
|
||||
+29
-1
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
+18
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+62
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user