mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-28 11:05:31 +08:00
Merge pull request #13521 from Kilo-Org/sturdy-reef
feat(jetbrains): improve worktree session UX
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": minor
|
||||
---
|
||||
|
||||
Show a branch-aware tip on the empty session screen: suggest running work in a worktree with a link when you're on a plain checkout, and confirm isolation when you're already in one.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": patch
|
||||
---
|
||||
|
||||
Label the Kilo tool window's create buttons as + Session and + Worktree with a compact plus icon.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": minor
|
||||
---
|
||||
|
||||
Add a toggle to show or hide the session list in a worktree editor tab. The choice is remembered per worktree, and while the list is hidden the toggle shows the session count or flags a session that needs your attention.
|
||||
+62
-14
@@ -87,7 +87,7 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi {
|
||||
val items = managedWorktrees(parseWorktreeList(res.stdout))
|
||||
val alive = items.filter { it.main || Files.isDirectory(Path.of(it.path)) }
|
||||
val store = worktreeNameStore(alive)
|
||||
val state = store?.let { syncWorktreeState(it, worktreePaths(alive)) } ?: WorktreeState()
|
||||
val state = store?.let { syncWorktreeState(it, worktreePaths(alive), livePaths(alive)) } ?: WorktreeState()
|
||||
val named = overlayWorktreeNames(alive, state.names)
|
||||
WorktreeListDto(orderWorktrees(named, state.worktreeOrder))
|
||||
}
|
||||
@@ -454,7 +454,7 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi {
|
||||
val target = items.firstOrNull { samePath(it.path, path) && !it.main }
|
||||
?: return@withContext RenameWorktreeResultDto(error = "Worktree not found")
|
||||
return@withContext try {
|
||||
val state = readWorktreeState(store).reconcile(worktreePaths(items))
|
||||
val state = readWorktreeState(store).reconcile(worktreePaths(items), livePaths(items))
|
||||
val names = state.names.toMutableMap()
|
||||
names[target.path] = title
|
||||
writeWorktreeState(store, state.copy(names = names))
|
||||
@@ -478,7 +478,7 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi {
|
||||
val target = items.firstOrNull { samePath(it.path, path) && !it.main }
|
||||
?: return@withContext RenameWorktreeResultDto(error = "Worktree not found")
|
||||
return@withContext try {
|
||||
val state = readWorktreeState(store).reconcile(worktreePaths(items))
|
||||
val state = readWorktreeState(store).reconcile(worktreePaths(items), livePaths(items))
|
||||
val names = state.names.toMutableMap()
|
||||
// Only adopt while the worktree is still default. A recorded name means the user (or a
|
||||
// prior adoption) already titled it, so leave it untouched and report a no-op.
|
||||
@@ -502,7 +502,7 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi {
|
||||
val store = worktreeNameStore(items) ?: return@withContext false
|
||||
return@withContext try {
|
||||
val state = readWorktreeState(store)
|
||||
writeWorktreeState(store, state.copy(worktreeOrder = paths).reconcile(worktreePaths(items)))
|
||||
writeWorktreeState(store, state.copy(worktreeOrder = paths).reconcile(worktreePaths(items), livePaths(items)))
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
LOG.warn("worktree reorder failed: dir=$directory message=${e.message}", e)
|
||||
@@ -510,6 +510,35 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi {
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun sessionList(directory: String): Boolean? =
|
||||
withContext(Dispatchers.IO) {
|
||||
val base = Path.of(directory).normalize()
|
||||
val res = runGit(base, "worktree", "list", "--porcelain")
|
||||
if (!res.ok) return@withContext null
|
||||
val items = managedWorktrees(parseWorktreeList(res.stdout))
|
||||
val store = worktreeNameStore(items) ?: return@withContext null
|
||||
val target = items.firstOrNull { samePath(it.path, directory) } ?: return@withContext null
|
||||
readWorktreeState(store).sessionList[target.path]
|
||||
}
|
||||
|
||||
override suspend fun setSessionList(directory: String, visible: Boolean): Boolean =
|
||||
withContext(Dispatchers.IO) {
|
||||
val base = Path.of(directory).normalize()
|
||||
val res = runGit(base, "worktree", "list", "--porcelain")
|
||||
if (!res.ok) return@withContext false
|
||||
val items = managedWorktrees(parseWorktreeList(res.stdout))
|
||||
val store = worktreeNameStore(items) ?: return@withContext false
|
||||
val target = items.firstOrNull { samePath(it.path, directory) } ?: return@withContext false
|
||||
return@withContext try {
|
||||
val state = readWorktreeState(store).reconcile(worktreePaths(items), livePaths(items))
|
||||
writeWorktreeState(store, state.copy(sessionList = state.sessionList + (target.path to visible)))
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
LOG.warn("worktree session list state failed: dir=$directory message=${e.message}", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private data class Timed<T>(val time: Long, val value: T)
|
||||
|
||||
private fun runGit(base: Path, vararg args: String): CmdOut = runGit(base, args.toList())
|
||||
@@ -741,17 +770,26 @@ private const val WORKTREE_NAMES_FILE = "jetbrains.json"
|
||||
private data class WorktreeNamesFile(
|
||||
val names: Map<String, String> = emptyMap(),
|
||||
val worktreeOrder: List<String> = emptyList(),
|
||||
val sessionList: Map<String, Boolean> = emptyMap(),
|
||||
)
|
||||
|
||||
internal data class WorktreeState(
|
||||
val names: Map<String, String> = emptyMap(),
|
||||
val worktreeOrder: List<String> = emptyList(),
|
||||
val sessionList: Map<String, Boolean> = emptyMap(),
|
||||
) {
|
||||
fun reconcile(paths: List<String>): WorktreeState {
|
||||
/**
|
||||
* Drops state for worktrees git no longer reports. Names and order cover linked worktrees only
|
||||
* ([paths]), while the session list is also kept for the main working tree, which has a worktree
|
||||
* editor of its own — hence the wider [live] set.
|
||||
*/
|
||||
fun reconcile(paths: List<String>, live: List<String>): WorktreeState {
|
||||
val set = paths.toSet()
|
||||
val all = live.toSet()
|
||||
val order = (worktreeOrder.filter { it in set } + paths.filter { it !in worktreeOrder }).distinct()
|
||||
val next = names.filterKeys { it in set }
|
||||
return WorktreeState(next, order)
|
||||
val visible = sessionList.filterKeys { it in all }
|
||||
return WorktreeState(next, order, visible)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -843,9 +881,13 @@ internal fun readWorktreeState(file: Path): WorktreeState {
|
||||
return try {
|
||||
val raw = Files.readString(file)
|
||||
val element = json.parseToJsonElement(raw)
|
||||
if (element is JsonObject && ("names" in element || "worktreeOrder" in element)) {
|
||||
if (element is JsonObject && ("names" in element || "worktreeOrder" in element || "sessionList" in element)) {
|
||||
val data = json.decodeFromJsonElement<WorktreeNamesFile>(element)
|
||||
return WorktreeState(data.names.filterValues { it.isNotBlank() }, data.worktreeOrder.filter { it.isNotBlank() })
|
||||
return WorktreeState(
|
||||
data.names.filterValues { it.isNotBlank() },
|
||||
data.worktreeOrder.filter { it.isNotBlank() },
|
||||
data.sessionList.filterKeys { it.isNotBlank() },
|
||||
)
|
||||
}
|
||||
val names = json.decodeFromJsonElement(codec, element).filterValues { it.isNotBlank() }
|
||||
WorktreeState(names, names.keys.toList())
|
||||
@@ -856,8 +898,8 @@ internal fun readWorktreeState(file: Path): WorktreeState {
|
||||
}
|
||||
|
||||
internal fun writeWorktreeNames(file: Path, names: Map<String, String>) {
|
||||
val order = readWorktreeState(file).worktreeOrder
|
||||
writeWorktreeState(file, WorktreeState(names, order))
|
||||
val state = readWorktreeState(file)
|
||||
writeWorktreeState(file, state.copy(names = names))
|
||||
}
|
||||
|
||||
internal fun writeWorktreeState(file: Path, state: WorktreeState) {
|
||||
@@ -865,6 +907,7 @@ internal fun writeWorktreeState(file: Path, state: WorktreeState) {
|
||||
val data = WorktreeNamesFile(
|
||||
names = state.names.filterValues { it.isNotBlank() },
|
||||
worktreeOrder = state.worktreeOrder.filter { it.isNotBlank() }.distinct(),
|
||||
sessionList = state.sessionList.filterKeys { it.isNotBlank() },
|
||||
)
|
||||
val tmp = Files.createTempFile(file.parent, ".worktree-names", ".tmp")
|
||||
try {
|
||||
@@ -879,9 +922,9 @@ internal fun writeWorktreeState(file: Path, state: WorktreeState) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun syncWorktreeState(file: Path, paths: List<String>): WorktreeState {
|
||||
private fun syncWorktreeState(file: Path, paths: List<String>, live: List<String>): WorktreeState {
|
||||
val state = readWorktreeState(file)
|
||||
val next = state.reconcile(paths)
|
||||
val next = state.reconcile(paths, live)
|
||||
if (next == state) return next
|
||||
try {
|
||||
writeWorktreeState(file, next)
|
||||
@@ -903,14 +946,19 @@ private fun removeWorktreeState(file: Path, path: String) {
|
||||
val state = readWorktreeState(file)
|
||||
val names = state.names.filterKeys { !samePath(it, path) }
|
||||
val order = state.worktreeOrder.filter { !samePath(it, path) }
|
||||
if (names == state.names && order == state.worktreeOrder) return
|
||||
writeWorktreeState(file, state.copy(names = names, worktreeOrder = order))
|
||||
val visible = state.sessionList.filterKeys { !samePath(it, path) }
|
||||
if (names == state.names && order == state.worktreeOrder && visible == state.sessionList) return
|
||||
writeWorktreeState(file, state.copy(names = names, worktreeOrder = order, sessionList = visible))
|
||||
}
|
||||
|
||||
private fun worktreePaths(items: List<WorktreeDto>): List<String> {
|
||||
return items.filter { !it.main }.map { it.path }
|
||||
}
|
||||
|
||||
private fun livePaths(items: List<WorktreeDto>): List<String> {
|
||||
return items.map { it.path }
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
+82
@@ -384,6 +384,88 @@ class KiloWorktreeRpcApiImplTest {
|
||||
assertEquals(listOf(second.path), state.worktreeOrder)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `session list visibility round trips beside names and order`() {
|
||||
val file = repo.resolve(".kilo").resolve("jetbrains.json")
|
||||
val path = "/repo/.kilo/worktrees/feature-x"
|
||||
|
||||
writeWorktreeState(file, WorktreeState(mapOf(path to "Feature"), listOf(path), mapOf(path to true, "/repo" to false)))
|
||||
|
||||
val state = readWorktreeState(file)
|
||||
assertEquals(mapOf(path to true, "/repo" to false), state.sessionList)
|
||||
assertEquals(mapOf(path to "Feature"), state.names)
|
||||
assertEquals(listOf(path), state.worktreeOrder)
|
||||
|
||||
// A file that only ever recorded visibility must not be mistaken for the legacy name map.
|
||||
Files.writeString(file, """{"sessionList":{"$path":true}}""")
|
||||
assertEquals(mapOf(path to true), readWorktreeState(file).sessionList)
|
||||
assertTrue(readWorktreeState(file).names.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reconcile drops visibility for vanished worktrees but keeps the main tree`() {
|
||||
val main = "/repo"
|
||||
val live = "/repo/.kilo/worktrees/live"
|
||||
val dead = "/repo/.kilo/worktrees/dead"
|
||||
val state = WorktreeState(sessionList = mapOf(main to true, live to false, dead to true))
|
||||
|
||||
val next = state.reconcile(listOf(live), listOf(main, live))
|
||||
|
||||
assertEquals(mapOf(main to true, live to false), next.sessionList)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `session list visibility is unknown until set and then persists per worktree`() = runBlocking {
|
||||
initRepo()
|
||||
val created = assertNotNull(api.create(repo.toString(), CreateWorktreeRequestDto("feature/x")).worktree)
|
||||
val main = api.list(repo.toString()).worktrees.single { it.main }
|
||||
|
||||
assertNull(api.sessionList(created.path), "a fresh worktree has no stored choice")
|
||||
assertNull(api.sessionList(main.path))
|
||||
|
||||
assertTrue(api.setSessionList(created.path, true))
|
||||
assertTrue(api.setSessionList(main.path, false))
|
||||
|
||||
assertEquals(true, api.sessionList(created.path))
|
||||
assertEquals(false, api.sessionList(main.path))
|
||||
// The main working tree keeps its entry across a list, which reconciles the file.
|
||||
api.list(repo.toString())
|
||||
assertEquals(false, api.sessionList(main.path))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `create and list never record session list visibility`() = runBlocking {
|
||||
initRepo()
|
||||
val created = assertNotNull(api.create(repo.toString(), CreateWorktreeRequestDto("feature/x")).worktree)
|
||||
|
||||
api.list(repo.toString())
|
||||
|
||||
assertTrue(readWorktreeState(repo.resolve(".kilo").resolve("jetbrains.json")).sessionList.isEmpty())
|
||||
assertNull(api.sessionList(created.path))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `remove prunes session list visibility`() = runBlocking {
|
||||
initRepo()
|
||||
val first = assertNotNull(api.create(repo.toString(), CreateWorktreeRequestDto("zebra")).worktree)
|
||||
val second = assertNotNull(api.create(repo.toString(), CreateWorktreeRequestDto("alpha")).worktree)
|
||||
assertTrue(api.setSessionList(first.path, true))
|
||||
assertTrue(api.setSessionList(second.path, true))
|
||||
|
||||
assertTrue(api.remove(repo.toString(), first.path, first.branch).ok)
|
||||
|
||||
assertEquals(
|
||||
mapOf(second.path to true),
|
||||
readWorktreeState(repo.resolve(".kilo").resolve("jetbrains.json")).sessionList,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `session list visibility reports nothing outside a repo`() = runBlocking {
|
||||
assertNull(api.sessionList(repo.toString()))
|
||||
assertFalse(api.setSessionList(repo.toString(), true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rename persists a custom worktree name and list overlays it`() = runBlocking {
|
||||
initRepo()
|
||||
|
||||
+2
@@ -19,6 +19,7 @@ import ai.kilocode.log.KiloLog
|
||||
import com.intellij.openapi.actionSystem.ActionGroup
|
||||
import com.intellij.openapi.actionSystem.ActionManager
|
||||
import com.intellij.openapi.actionSystem.DataProvider
|
||||
import com.intellij.openapi.actionSystem.Separator
|
||||
import com.intellij.openapi.components.Service
|
||||
import com.intellij.openapi.components.service
|
||||
import com.intellij.openapi.project.DumbAware
|
||||
@@ -178,6 +179,7 @@ internal class KiloToolWindowSetupService(
|
||||
val actions = listOfNotNull(
|
||||
ActionManager.getInstance().getAction("Kilo.NewSession"),
|
||||
ActionManager.getInstance().getAction("Kilo.NewWorktree"),
|
||||
Separator.create(),
|
||||
ActionManager.getInstance().getAction("Kilo.History"),
|
||||
)
|
||||
toolWindow.setTitleActions(actions)
|
||||
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
package ai.kilocode.client.actions
|
||||
|
||||
import com.intellij.openapi.util.IconLoader
|
||||
import javax.swing.Icon
|
||||
|
||||
internal object KiloActionIcons {
|
||||
val add: Icon = IconLoader.getIcon("/icons/add-small.svg", KiloActionIcons::class.java)
|
||||
}
|
||||
+6
-2
@@ -5,15 +5,15 @@ import ai.kilocode.client.session.SessionManager
|
||||
import ai.kilocode.client.telemetry.Telemetry
|
||||
import ai.kilocode.client.agentManager.SidePanelKeys
|
||||
import ai.kilocode.client.agentManager.SidePanelMode
|
||||
import com.intellij.icons.AllIcons
|
||||
import com.intellij.openapi.actionSystem.AnAction
|
||||
import com.intellij.openapi.actionSystem.AnActionEvent
|
||||
import com.intellij.openapi.actionSystem.ex.ActionUtil
|
||||
import com.intellij.openapi.project.DumbAware
|
||||
|
||||
class NewSessionAction : AnAction(
|
||||
KiloBundle.message("action.Kilo.NewSession.text"),
|
||||
KiloBundle.message("action.Kilo.NewSession.description"),
|
||||
AllIcons.General.Add,
|
||||
KiloActionIcons.add,
|
||||
), DumbAware {
|
||||
override fun actionPerformed(e: AnActionEvent) {
|
||||
Telemetry.send("New Session Clicked", mapOf("surface" to "tool_window"))
|
||||
@@ -23,5 +23,9 @@ class NewSessionAction : AnAction(
|
||||
override fun update(e: AnActionEvent) {
|
||||
e.presentation.isVisible = e.getData(SidePanelKeys.MODE) != SidePanelMode.AGENT_MANAGER
|
||||
e.presentation.isEnabled = e.getData(SessionManager.KEY) != null
|
||||
e.presentation.icon = KiloActionIcons.add
|
||||
if (!e.isFromActionToolbar) return
|
||||
e.presentation.text = KiloBundle.message("action.Kilo.NewSession.toolbar")
|
||||
e.presentation.putClientProperty(ActionUtil.SHOW_TEXT_IN_TOOLBAR, true)
|
||||
}
|
||||
}
|
||||
|
||||
+11
-3
@@ -2,23 +2,31 @@ package ai.kilocode.client.actions
|
||||
|
||||
import ai.kilocode.client.agentManager.SidePanelKeys
|
||||
import ai.kilocode.client.agentManager.SidePanelMode
|
||||
import ai.kilocode.client.plugin.KiloBundle
|
||||
import ai.kilocode.client.telemetry.Telemetry
|
||||
import com.intellij.icons.AllIcons
|
||||
import com.intellij.openapi.actionSystem.ActionUpdateThread
|
||||
import com.intellij.openapi.actionSystem.AnAction
|
||||
import com.intellij.openapi.actionSystem.AnActionEvent
|
||||
import com.intellij.openapi.actionSystem.ex.ActionUtil
|
||||
import com.intellij.openapi.project.DumbAware
|
||||
|
||||
/**
|
||||
* `+` toolbar action shown in Agent Manager mode. Opens the New Worktree dialog (New + Import tabs).
|
||||
*/
|
||||
class NewWorktreeAction : AnAction(), DumbAware {
|
||||
class NewWorktreeAction : AnAction(
|
||||
KiloBundle.message("action.Kilo.NewWorktree.text"),
|
||||
KiloBundle.message("action.Kilo.NewWorktree.description"),
|
||||
KiloActionIcons.add,
|
||||
), DumbAware {
|
||||
override fun getActionUpdateThread() = ActionUpdateThread.BGT
|
||||
|
||||
override fun update(e: AnActionEvent) {
|
||||
e.presentation.isVisible = e.getData(SidePanelKeys.MODE) == SidePanelMode.AGENT_MANAGER
|
||||
e.presentation.isEnabled = e.getData(SidePanelKeys.WORKTREE_PANEL) != null
|
||||
e.presentation.icon = AllIcons.General.Add
|
||||
e.presentation.icon = KiloActionIcons.add
|
||||
if (!e.isFromActionToolbar) return
|
||||
e.presentation.text = KiloBundle.message("action.Kilo.NewWorktree.toolbar")
|
||||
e.presentation.putClientProperty(ActionUtil.SHOW_TEXT_IN_TOOLBAR, true)
|
||||
}
|
||||
|
||||
override fun actionPerformed(e: AnActionEvent) {
|
||||
|
||||
+14
@@ -152,4 +152,18 @@ class KiloWorktreeService internal constructor(
|
||||
LOG.warn("worktree reorder failed for $directory", e)
|
||||
false
|
||||
}
|
||||
|
||||
suspend fun sessionList(directory: String): Boolean? = try {
|
||||
call { sessionList(directory) }
|
||||
} catch (e: Exception) {
|
||||
LOG.warn("worktree session list state failed for $directory", e)
|
||||
null
|
||||
}
|
||||
|
||||
suspend fun setSessionList(directory: String, visible: Boolean): Boolean = try {
|
||||
call { setSessionList(directory, visible) }
|
||||
} catch (e: Exception) {
|
||||
LOG.warn("worktree session list state write failed for $directory", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
+15
@@ -12,6 +12,21 @@ internal fun aggregateWorktreeActivity(
|
||||
|
||||
internal fun normalizeWorktreePath(path: String): String = normalize(path)
|
||||
|
||||
/**
|
||||
* Activity a collapsed session list should surface: the top-ranked session that needs the user, from
|
||||
* sessions other than [current] (whose state the open chat already shows) and not being deleted.
|
||||
* Running work is not an attention state — it would only put a spinner in the header.
|
||||
*/
|
||||
internal fun attention(
|
||||
activity: Map<String, SessionActivityKind>,
|
||||
current: String? = null,
|
||||
deleting: Set<String> = emptySet(),
|
||||
): SessionActivityKind? = activity
|
||||
.filterKeys { it != current && it !in deleting }
|
||||
.values
|
||||
.filter { it != SessionActivityKind.RUNNING }
|
||||
.minByOrNull(::rank)
|
||||
|
||||
private fun normalize(path: String): String = path.trimEnd('/')
|
||||
|
||||
/**
|
||||
|
||||
+109
-28
@@ -11,7 +11,6 @@ import ai.kilocode.client.session.SessionRef
|
||||
import ai.kilocode.client.session.history.HistorySection
|
||||
import ai.kilocode.client.session.history.HistoryTime
|
||||
import ai.kilocode.client.session.history.LocalHistoryItem
|
||||
import ai.kilocode.client.plugin.KiloPluginSettings
|
||||
import ai.kilocode.client.telemetry.Telemetry
|
||||
import ai.kilocode.client.ui.list.ActiveList
|
||||
import ai.kilocode.client.ui.list.ActiveListBadge
|
||||
@@ -25,7 +24,11 @@ import ai.kilocode.client.ui.list.ActiveListSelection
|
||||
import ai.kilocode.client.ui.list.ActiveListSurface
|
||||
import ai.kilocode.client.ui.list.ActiveListWeight
|
||||
import ai.kilocode.client.ui.list.activeListToolWindowBackground
|
||||
import ai.kilocode.client.ui.layout.HAlign
|
||||
import ai.kilocode.client.ui.layout.Stack
|
||||
import ai.kilocode.client.ui.layout.VAlign
|
||||
import ai.kilocode.client.ui.layout.align
|
||||
import ai.kilocode.client.ui.UiStyle
|
||||
import ai.kilocode.client.vfs.KiloVfsManager
|
||||
import ai.kilocode.log.KiloLog
|
||||
import ai.kilocode.rpc.dto.SessionDto
|
||||
@@ -48,7 +51,6 @@ import com.intellij.openapi.components.service
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.openapi.project.ProjectManager
|
||||
import com.intellij.openapi.util.Disposer
|
||||
import com.intellij.openapi.util.IconLoader
|
||||
import com.intellij.openapi.util.Key
|
||||
import com.intellij.openapi.util.io.FileUtil
|
||||
import com.intellij.openapi.wm.IdeFocusManager
|
||||
@@ -60,6 +62,8 @@ import com.intellij.ui.OnePixelSplitter
|
||||
import com.intellij.ui.SideBorder
|
||||
import com.intellij.ui.awt.RelativePoint
|
||||
import com.intellij.util.concurrency.annotations.RequiresEdt
|
||||
import com.intellij.util.ui.JBInsets
|
||||
import com.intellij.util.ui.JBUI
|
||||
import com.intellij.util.ui.UIUtil
|
||||
import com.intellij.util.ui.components.BorderLayoutPanel
|
||||
import org.jetbrains.plugins.terminal.TerminalToolWindowFactory
|
||||
@@ -67,9 +71,11 @@ import java.awt.BorderLayout
|
||||
import java.awt.Color
|
||||
import java.awt.Frame
|
||||
import javax.swing.JComponent
|
||||
import javax.swing.Icon
|
||||
import javax.swing.JSeparator
|
||||
import javax.swing.ListSelectionModel
|
||||
import javax.swing.JPanel
|
||||
import javax.swing.SwingConstants
|
||||
import javax.swing.border.Border
|
||||
import javax.swing.event.ListDataEvent
|
||||
import javax.swing.event.ListDataListener
|
||||
|
||||
@@ -82,12 +88,19 @@ class WorktreeSessionEditorPanel(
|
||||
private val confirm: ((RelativePoint, ActiveListDeleteOptions, () -> Unit) -> Unit)? = null,
|
||||
private val edit: ((RelativePoint, ActiveListEditOptions, (String) -> Unit) -> Unit)? = null,
|
||||
private val openWorktree: ((String) -> Unit)? = null,
|
||||
// Persisted per-worktree session list visibility; null means the user has not chosen yet.
|
||||
private val load: ((Boolean?) -> Unit) -> Unit = { done ->
|
||||
service<WorktreeSessionListVisibility>().load(worktree.directory, done)
|
||||
},
|
||||
private val save: (Boolean) -> Unit = { value ->
|
||||
service<WorktreeSessionListVisibility>().save(worktree.directory, value)
|
||||
},
|
||||
) : BorderLayoutPanel(), Disposable, UiDataProvider {
|
||||
private val add = NewAction()
|
||||
private val rename = RenameAction()
|
||||
private val delete = DeleteAction()
|
||||
private val toggle = ToggleAction()
|
||||
private val toolbar = ActionManager.getInstance().createActionToolbar(ActionPlaces.TOOLBAR, DefaultActionGroup(toggle, add, rename, delete), true)
|
||||
private val toggle = WorktreeSessionListToggle { flip() }
|
||||
private val toolbar = ActionManager.getInstance().createActionToolbar(ActionPlaces.TOOLBAR, DefaultActionGroup(add, rename, delete), true)
|
||||
private val group = ActionManager.getInstance().getAction("Kilo.WorktreeSession.RowMenu") as? ActionGroup ?: DefaultActionGroup()
|
||||
private val list = ActiveList(
|
||||
KiloBundle.message("worktree.session.list.empty"),
|
||||
@@ -112,6 +125,10 @@ class WorktreeSessionEditorPanel(
|
||||
private var started = false
|
||||
private var stats: WorktreeStatsDto? = null
|
||||
private var pr: WorktreePrDto? = null
|
||||
// Last known persisted visibility, and whether it is known at all: until the stored value arrives
|
||||
// (or the user clicks) the list stays hidden and nothing is written.
|
||||
private var pref: Boolean? = null
|
||||
private var ready = false
|
||||
|
||||
init {
|
||||
Disposer.register(parent, this)
|
||||
@@ -122,11 +139,11 @@ class WorktreeSessionEditorPanel(
|
||||
toolbar.component.isOpaque = false
|
||||
syncToolbar()
|
||||
list.installPopup(group)
|
||||
splitter.firstComponent = list
|
||||
// The list starts detached: a worktree opens with its sessions hidden until a stored choice or
|
||||
// a second session says otherwise.
|
||||
splitter.secondComponent = manager.component
|
||||
addToTop(top())
|
||||
addToCenter(splitter)
|
||||
syncExpanded(KiloPluginSettings.getWorktreeSessionListExpanded())
|
||||
bindModel()
|
||||
manager.onPresent = { key -> key?.let { list.select(it) } }
|
||||
manager.onListChanged = {
|
||||
@@ -145,6 +162,7 @@ class WorktreeSessionEditorPanel(
|
||||
}
|
||||
bindStatus()
|
||||
sync()
|
||||
load(::restore)
|
||||
}
|
||||
|
||||
override fun getBackground(): Color = activeListToolWindowBackground()
|
||||
@@ -249,18 +267,88 @@ class WorktreeSessionEditorPanel(
|
||||
private fun toolbarPanel(): JComponent {
|
||||
return object : JPanel(BorderLayout()) {
|
||||
override fun getBackground(): Color = activeListToolWindowBackground()
|
||||
|
||||
// The padding and the divider colour both come from the theme, so they are re-read on
|
||||
// Look-and-Feel changes instead of being captured once at construction.
|
||||
override fun updateUI() {
|
||||
super.updateUI()
|
||||
border = toolbarPanelBorder()
|
||||
}
|
||||
}.apply {
|
||||
border = IdeBorderFactory.createBorder(SideBorder.RIGHT)
|
||||
// The toggle is centred at its own height instead of tracking the strip, so its hover
|
||||
// box keeps the strip's padding above and below it like a regular toolbar button.
|
||||
add(
|
||||
Stack.horizontal(gap = UiStyle.Gap.sm())
|
||||
.next(toggle.align(HAlign.LEFT, VAlign.CENTER))
|
||||
.next(JSeparator(SwingConstants.VERTICAL)),
|
||||
BorderLayout.WEST,
|
||||
)
|
||||
add(toolbar.component, BorderLayout.CENTER)
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
private fun toggleExpanded() {
|
||||
syncExpanded(!expanded())
|
||||
KiloPluginSettings.setWorktreeSessionListExpanded(expanded())
|
||||
/**
|
||||
* Standard horizontal-toolbar padding on the three free sides. The right edge stays flush so the
|
||||
* divider still sits directly against the header content beside it.
|
||||
*
|
||||
* The theme insets arrive pre-scaled while [JBUI.Borders.empty] scales what it is handed, so the
|
||||
* unscaled values are read back to avoid scaling twice on HiDPI.
|
||||
*/
|
||||
private fun toolbarPanelBorder(): Border {
|
||||
val ins = (JBUI.CurrentTheme.Toolbar.horizontalToolbarInsets() as? JBInsets)?.unscaled
|
||||
return JBUI.Borders.merge(
|
||||
JBUI.Borders.empty(ins?.top ?: STRIP_PAD, ins?.left ?: STRIP_PAD, ins?.bottom ?: STRIP_PAD, 0),
|
||||
IdeBorderFactory.createBorder(SideBorder.RIGHT),
|
||||
true,
|
||||
)
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
private fun flip() {
|
||||
syncExpanded(!expanded())
|
||||
ready = true
|
||||
pref = expanded()
|
||||
save(expanded())
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the stored visibility once the backend answers. A click that landed first already
|
||||
* decided, so a late answer must not overwrite it.
|
||||
*/
|
||||
@RequiresEdt
|
||||
private fun restore(value: Boolean?) {
|
||||
if (ready) return
|
||||
ready = true
|
||||
pref = value
|
||||
value?.let(::syncExpanded)
|
||||
resolve()
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows the list the first time this worktree holds more than one session. Only that promotion is
|
||||
* persisted, so a worktree the user never touched keeps writing nothing while it has one session.
|
||||
*/
|
||||
@RequiresEdt
|
||||
private fun resolve() {
|
||||
if (!ready || pref != null || count() < AUTO) return
|
||||
syncExpanded(true)
|
||||
pref = true
|
||||
save(true)
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
private fun count(): Int {
|
||||
val deleting = manager.deleting()
|
||||
return controller.sessions().count { it.id !in deleting }
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
private fun syncToggle() = toggle.update(
|
||||
expanded(),
|
||||
count(),
|
||||
attention(manager.activity(), manager.currentKey(), manager.deleting()),
|
||||
)
|
||||
|
||||
@RequiresEdt
|
||||
private fun expanded(): Boolean = splitter.firstComponent != null
|
||||
|
||||
@@ -281,6 +369,7 @@ class WorktreeSessionEditorPanel(
|
||||
val changed = expanded() != value
|
||||
if (changed) splitter.firstComponent = if (value) list else null
|
||||
syncToolbar()
|
||||
syncToggle()
|
||||
if (!changed) return
|
||||
splitter.revalidate()
|
||||
splitter.repaint()
|
||||
@@ -413,6 +502,8 @@ class WorktreeSessionEditorPanel(
|
||||
// the list hold that key until a refresh brings it in.
|
||||
val shown = if (pending) SessionHost.NEW else key
|
||||
list.update(rows, shown?.let { ActiveListSelection.Key(it) } ?: ActiveListSelection.Preserve)
|
||||
resolve()
|
||||
syncToggle()
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
@@ -481,20 +572,6 @@ class WorktreeSessionEditorPanel(
|
||||
manager.onListChanged = null
|
||||
}
|
||||
|
||||
private inner class ToggleAction : AnAction() {
|
||||
override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.EDT
|
||||
|
||||
override fun update(e: AnActionEvent) {
|
||||
val expanded = expanded()
|
||||
e.presentation.text = KiloBundle.message(if (expanded) "worktree.session.list.collapse" else "worktree.session.list.expand")
|
||||
e.presentation.icon = if (expanded) LAYOUT_FULL else LAYOUT_PARTIAL
|
||||
}
|
||||
|
||||
override fun actionPerformed(e: AnActionEvent) {
|
||||
toggleExpanded()
|
||||
}
|
||||
}
|
||||
|
||||
private inner class NewAction : AnAction(
|
||||
KiloBundle.message("worktree.session.new.action"),
|
||||
null,
|
||||
@@ -552,8 +629,12 @@ class WorktreeSessionEditorPanel(
|
||||
private companion object {
|
||||
private val LOG = KiloLog.create(WorktreeSessionEditorPanel::class.java)
|
||||
private val TERMINAL_DIR = Key.create<String>("kilo.worktree.terminal.dir")
|
||||
val LAYOUT_PARTIAL: Icon = IconLoader.getIcon("/icons/layout-left-partial.svg", WorktreeSessionEditorPanel::class.java)
|
||||
val LAYOUT_FULL: Icon = IconLoader.getIcon("/icons/layout-left-full.svg", WorktreeSessionEditorPanel::class.java)
|
||||
|
||||
/** Sessions a worktree must hold before the list shows itself without being asked. */
|
||||
private const val AUTO = 2
|
||||
|
||||
/** Classic UI leaves the toolbar inset key unset; matches the platform's own fallback. */
|
||||
private const val STRIP_PAD = 2
|
||||
}
|
||||
|
||||
private inner class SessionRow(
|
||||
|
||||
+165
@@ -0,0 +1,165 @@
|
||||
package ai.kilocode.client.agentManager.worktree
|
||||
|
||||
import ai.kilocode.client.plugin.KiloBundle
|
||||
import ai.kilocode.client.session.SessionActivityKind
|
||||
import ai.kilocode.client.ui.FilledBadgeIcon
|
||||
import ai.kilocode.client.ui.UiStyle
|
||||
import ai.kilocode.client.ui.layout.Stack
|
||||
import com.intellij.openapi.util.IconLoader
|
||||
import com.intellij.ui.components.JBLabel
|
||||
import com.intellij.util.concurrency.annotations.RequiresEdt
|
||||
import com.intellij.util.ui.JBUI
|
||||
import java.awt.Cursor
|
||||
import java.awt.Dimension
|
||||
import java.awt.Graphics
|
||||
import java.awt.Graphics2D
|
||||
import java.awt.RenderingHints
|
||||
import java.awt.event.ActionEvent
|
||||
import java.awt.event.KeyEvent
|
||||
import java.awt.event.MouseAdapter
|
||||
import java.awt.event.MouseEvent
|
||||
import javax.swing.AbstractAction
|
||||
import javax.swing.Icon
|
||||
import javax.swing.JComponent
|
||||
import javax.swing.JPanel
|
||||
import javax.swing.KeyStroke
|
||||
|
||||
/**
|
||||
* Shows/hides the worktree editor's session list. Sits left of the toolbar and, while the list is
|
||||
* hidden, carries the session count in a neutral badge — or the activity icon of a background session
|
||||
* that needs the user, so a pending question is visible with the list collapsed.
|
||||
*
|
||||
* Not a [ai.kilocode.client.ui.HoverIcon]: that hosts a single icon and pins icon-only buttons to
|
||||
* 24x24, which would clip the trailing badge. The hover treatment is reproduced here instead.
|
||||
*/
|
||||
internal class WorktreeSessionListToggle(
|
||||
private val onClick: () -> Unit,
|
||||
) : JPanel(null) {
|
||||
private val glyph = JBLabel(LAYOUT_PARTIAL)
|
||||
private val badge = JBLabel()
|
||||
private val row = Stack.horizontal(gap = UiStyle.Gap.sm()).next(glyph).next(badge)
|
||||
private var state = State()
|
||||
private var over = false
|
||||
|
||||
init {
|
||||
isOpaque = false
|
||||
isFocusable = true
|
||||
cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)
|
||||
badge.isVisible = false
|
||||
label(KiloBundle.message("worktree.session.list.expand"))
|
||||
add(row)
|
||||
addMouseListener(object : MouseAdapter() {
|
||||
override fun mouseEntered(event: MouseEvent) = hover(true)
|
||||
override fun mouseExited(event: MouseEvent) = hover(false)
|
||||
override fun mouseClicked(event: MouseEvent) {
|
||||
if (isEnabled) onClick()
|
||||
}
|
||||
})
|
||||
// Keep the toggle reachable without a mouse, the way the toolbar action it replaced was.
|
||||
val action = object : AbstractAction() {
|
||||
override fun actionPerformed(e: ActionEvent) {
|
||||
if (isEnabled) onClick()
|
||||
}
|
||||
}
|
||||
getInputMap(JComponent.WHEN_FOCUSED).apply {
|
||||
put(KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, 0), ACTIVATE)
|
||||
put(KeyStroke.getKeyStroke(KeyEvent.VK_SPACE, 0), ACTIVATE)
|
||||
}
|
||||
actionMap.put(ACTIVATE, action)
|
||||
}
|
||||
|
||||
override fun updateUI() {
|
||||
super.updateUI()
|
||||
border = JBUI.Borders.empty(JBUI.CurrentTheme.Toolbar.toolbarButtonInsets())
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
fun update(expanded: Boolean, count: Int, kind: SessionActivityKind?) {
|
||||
val next = State(expanded, count, kind)
|
||||
if (next == state) return
|
||||
state = next
|
||||
glyph.icon = if (expanded) LAYOUT_FULL else LAYOUT_PARTIAL
|
||||
val icon = badge(next)
|
||||
badge.icon = icon
|
||||
badge.isVisible = icon != null
|
||||
label(KiloBundle.message(if (expanded) "worktree.session.list.collapse" else "worktree.session.list.expand"))
|
||||
revalidate()
|
||||
repaint()
|
||||
}
|
||||
|
||||
override fun getPreferredSize(): Dimension {
|
||||
val ins = insets
|
||||
val size = row.preferredSize
|
||||
return Dimension(size.width + ins.left + ins.right, JBUI.scale(24))
|
||||
}
|
||||
|
||||
override fun getMinimumSize(): Dimension = preferredSize
|
||||
|
||||
override fun getMaximumSize(): Dimension = preferredSize
|
||||
|
||||
override fun doLayout() {
|
||||
val ins = insets
|
||||
val w = maxOf(0, width - ins.left - ins.right)
|
||||
val h = maxOf(0, height - ins.top - ins.bottom)
|
||||
val size = row.preferredSize
|
||||
val rowW = minOf(size.width, w)
|
||||
val rowH = minOf(size.height, h)
|
||||
row.setBounds(ins.left, ins.top + (h - rowH) / 2, rowW, rowH)
|
||||
}
|
||||
|
||||
override fun paintComponent(g: Graphics) {
|
||||
if (over && isEnabled) paintHover(g)
|
||||
super.paintComponent(g)
|
||||
}
|
||||
|
||||
/**
|
||||
* Trailing badge while the list is hidden: a session that needs the user outranks the count, and a
|
||||
* lone session needs no count at all.
|
||||
*/
|
||||
private fun badge(state: State): Icon? = when {
|
||||
state.expanded -> null
|
||||
state.kind != null -> state.kind.icon()
|
||||
state.count >= COUNT -> FilledBadgeIcon(state.count.toString(), UiStyle.Badge.Secondary)
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun label(text: String) {
|
||||
toolTipText = text
|
||||
// getAccessibleContext() lazily creates the context; the field itself is still null here.
|
||||
getAccessibleContext().accessibleName = text
|
||||
}
|
||||
|
||||
private fun hover(value: Boolean) {
|
||||
if (over == value) return
|
||||
over = value
|
||||
repaint()
|
||||
}
|
||||
|
||||
private fun paintHover(g: Graphics) {
|
||||
val g2 = g.create() as Graphics2D
|
||||
try {
|
||||
g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON)
|
||||
g2.color = UiStyle.Colors.actionHoverBackground()
|
||||
val arc = JBUI.scale(JBUI.getInt("Button.arc", 6))
|
||||
g2.fillRoundRect(0, 0, width, height, arc, arc)
|
||||
} finally {
|
||||
g2.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
private data class State(
|
||||
val expanded: Boolean = false,
|
||||
val count: Int = 0,
|
||||
val kind: SessionActivityKind? = null,
|
||||
)
|
||||
|
||||
private companion object {
|
||||
const val ACTIVATE = "kilo.worktree.sessionList.activate"
|
||||
|
||||
/** A single session is the norm, so the count only earns a badge from the second one on. */
|
||||
const val COUNT = 2
|
||||
private val OWNER = WorktreeSessionListToggle::class.java
|
||||
val LAYOUT_PARTIAL: Icon = IconLoader.getIcon("/icons/layout-left-partial.svg", OWNER)
|
||||
val LAYOUT_FULL: Icon = IconLoader.getIcon("/icons/layout-left-full.svg", OWNER)
|
||||
}
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
package ai.kilocode.client.agentManager.worktree
|
||||
|
||||
import ai.kilocode.client.util.edt
|
||||
import ai.kilocode.log.KiloLog
|
||||
import com.intellij.openapi.components.Service
|
||||
import com.intellij.openapi.components.service
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Service(Service.Level.APP)
|
||||
internal class WorktreeSessionListVisibility(private val cs: CoroutineScope) {
|
||||
fun load(path: String, done: (Boolean?) -> Unit) {
|
||||
cs.launch {
|
||||
val value = service<KiloWorktreeService>().sessionList(path)
|
||||
edt { done(value) }
|
||||
}
|
||||
}
|
||||
|
||||
fun save(path: String, visible: Boolean) {
|
||||
cs.launch {
|
||||
val ok = service<KiloWorktreeService>().setSessionList(path, visible)
|
||||
if (!ok) LOG.warn("worktree session list state write was not persisted: path=$path visible=$visible")
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val LOG = KiloLog.create(WorktreeSessionListVisibility::class.java)
|
||||
}
|
||||
}
|
||||
-11
@@ -7,7 +7,6 @@ object KiloPluginSettings {
|
||||
private const val AUTO_EDITOR_CONTEXT_KEY = "kilo.session.autoEditorContext"
|
||||
private const val SHOW_APPROVAL_REASON_KEY = "kilo.session.showApprovalReason"
|
||||
private const val PERMISSION_RULES_EXPANDED_KEY = "kilo.session.permissionRulesExpanded"
|
||||
private const val WORKTREE_SESSION_LIST_EXPANDED_KEY = "kilo.worktree.sessionListExpanded"
|
||||
|
||||
fun getAutoApprove(): Boolean = PropertiesComponent.getInstance().getBoolean(AUTO_APPROVE_KEY, false)
|
||||
|
||||
@@ -48,14 +47,4 @@ object KiloPluginSettings {
|
||||
internal fun unsetPermissionRulesExpanded() {
|
||||
PropertiesComponent.getInstance().unsetValue(PERMISSION_RULES_EXPANDED_KEY)
|
||||
}
|
||||
|
||||
fun getWorktreeSessionListExpanded(): Boolean = PropertiesComponent.getInstance().getBoolean(WORKTREE_SESSION_LIST_EXPANDED_KEY, true)
|
||||
|
||||
fun setWorktreeSessionListExpanded(value: Boolean) {
|
||||
PropertiesComponent.getInstance().setValue(WORKTREE_SESSION_LIST_EXPANDED_KEY, value.toString())
|
||||
}
|
||||
|
||||
internal fun unsetWorktreeSessionListExpanded() {
|
||||
PropertiesComponent.getInstance().unsetValue(WORKTREE_SESSION_LIST_EXPANDED_KEY)
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -101,6 +101,7 @@ abstract class SessionHost(
|
||||
activity = { activity() },
|
||||
titles = { titles() },
|
||||
timers = timers,
|
||||
newWorktree = if (supportsNewWorktree) ({ newWorktree() }) else null,
|
||||
)
|
||||
|
||||
@RequiresEdt
|
||||
|
||||
+1
@@ -52,6 +52,7 @@ interface SessionManager {
|
||||
history = { showHistory() },
|
||||
activity = { activity() },
|
||||
titles = { titles() },
|
||||
newWorktree = if (supportsNewWorktree) ({ newWorktree() }) else null,
|
||||
)
|
||||
|
||||
fun openSession(session: SessionDto) {
|
||||
|
||||
+15
-3
@@ -72,6 +72,7 @@ import ai.kilocode.client.util.UiTimerSource
|
||||
import ai.kilocode.client.util.UiTimers
|
||||
import ai.kilocode.client.vfs.KiloVfsManager
|
||||
import ai.kilocode.log.ChatLogSummary
|
||||
import ai.kilocode.rpc.dto.BranchStatusDto
|
||||
import ai.kilocode.rpc.dto.ModelLimitDto
|
||||
import ai.kilocode.rpc.dto.DiffFileDto
|
||||
import ai.kilocode.rpc.dto.PromptDto
|
||||
@@ -213,6 +214,12 @@ class SessionUi(
|
||||
private lateinit var load: LoadingPanel
|
||||
private lateinit var migrationWizard: MigrationWizardPanel
|
||||
private var empty: EmptySessionPanel? = null
|
||||
|
||||
/**
|
||||
* Last observed branch/worktree status. Retained so an empty panel created after the fetch shows
|
||||
* its tip immediately instead of waiting for the next refresh.
|
||||
*/
|
||||
private var branch: BranchStatusDto? = null
|
||||
private var modalFocus: (() -> JComponent)? = null
|
||||
private var style = SessionEditorStyle.current()
|
||||
private val selection = SessionSelection()
|
||||
@@ -247,8 +254,8 @@ class SessionUi(
|
||||
dock?.let {
|
||||
syncDock()
|
||||
refreshBranchChanges()
|
||||
refreshBranch()
|
||||
}
|
||||
refreshBranch()
|
||||
loaded?.let(::finishOpen)
|
||||
}
|
||||
|
||||
@@ -613,6 +620,7 @@ class SessionUi(
|
||||
val panel = manager?.emptyPanel(this, controller)
|
||||
?: EmptySessionPanel(this, controller, controller.recents(), timers = timers)
|
||||
empty = panel
|
||||
panel.setBranch(branch)
|
||||
scroll.show(panel.view)
|
||||
}
|
||||
|
||||
@@ -1023,7 +1031,9 @@ class SessionUi(
|
||||
* split mode — so the PR always matches the branch checked out in this session's directory.
|
||||
*/
|
||||
private fun refreshBranch() {
|
||||
val dock = dock ?: return
|
||||
// Also feeds the empty panel's branch/worktree tip, so this runs even on surfaces without a
|
||||
// dock (worktree editor tabs). Read-only surfaces show no tip and get no fetch.
|
||||
if (readonly) return
|
||||
branchJob?.cancel()
|
||||
branchJob = cs.launch {
|
||||
val status = runCatching { service<KiloWorktreeService>().branchStatus(workspace.directory) }
|
||||
@@ -1034,7 +1044,9 @@ class SessionUi(
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
if (disposed || project.isDisposed) return@withContext
|
||||
dock.setBranch(status)
|
||||
branch = status
|
||||
dock?.setBranch(status)
|
||||
empty?.setBranch(status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+114
-9
@@ -14,12 +14,16 @@ import ai.kilocode.client.ui.layout.VAlign
|
||||
import ai.kilocode.client.ui.layout.align
|
||||
import ai.kilocode.client.util.UiTimerSource
|
||||
import ai.kilocode.client.util.UiTimers
|
||||
import ai.kilocode.rpc.dto.BranchStatusDto
|
||||
import ai.kilocode.rpc.dto.GhAvailability
|
||||
import ai.kilocode.rpc.dto.SessionDto
|
||||
import com.intellij.icons.AllIcons
|
||||
import com.intellij.ide.BrowserUtil
|
||||
import com.intellij.openapi.Disposable
|
||||
import com.intellij.openapi.util.Disposer
|
||||
import com.intellij.openapi.util.IconLoader
|
||||
import com.intellij.openapi.util.text.HtmlChunk
|
||||
import com.intellij.openapi.util.text.StringUtil
|
||||
import com.intellij.util.concurrency.annotations.RequiresEdt
|
||||
import com.intellij.ui.components.JBLabel
|
||||
import com.intellij.util.ui.Centerizer
|
||||
@@ -39,6 +43,8 @@ import java.awt.event.MouseEvent
|
||||
import javax.swing.JButton
|
||||
import javax.swing.JComponent
|
||||
import javax.swing.SwingUtilities
|
||||
import javax.swing.event.HyperlinkEvent
|
||||
import javax.swing.event.HyperlinkListener
|
||||
|
||||
/**
|
||||
* Empty-session panel.
|
||||
@@ -56,6 +62,7 @@ class EmptySessionPanel(
|
||||
private val browse: (String) -> Unit = BrowserUtil::browse,
|
||||
private val timers: UiTimerSource = UiTimers,
|
||||
private val minimal: Boolean = false,
|
||||
private val newWorktree: (() -> Unit)? = null,
|
||||
) : BorderLayoutPanel(), Disposable, SessionEditorStyleTarget {
|
||||
private var style = SessionEditorStyle.current()
|
||||
val view: Align = align(
|
||||
@@ -71,6 +78,12 @@ class EmptySessionPanel(
|
||||
addActionListener { history() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Branch/worktree status behind the tip under the logo. Null until [setBranch] delivers it, so
|
||||
* the first paint falls back to the generic welcome rather than flashing a wrong claim.
|
||||
*/
|
||||
private var branch: BranchStatusDto? = null
|
||||
|
||||
private val feedback = EmptySessionFeedback(browse)
|
||||
|
||||
private val logo = JBLabel(
|
||||
@@ -79,10 +92,21 @@ class EmptySessionPanel(
|
||||
horizontalAlignment = JBLabel.CENTER
|
||||
}
|
||||
|
||||
private val welcomeLabel = JBLabel(welcomeHtml()).apply {
|
||||
/**
|
||||
* Text is set by [syncTip], which picks the generic welcome or a branch/worktree tip. Copyable
|
||||
* mode swaps the label's internals for an HTML pane, which is what makes the inline worktree
|
||||
* link clickable; auto-wrapping must be set first so that pane's CSS allows line breaks.
|
||||
*/
|
||||
private val welcomeLabel = object : JBLabel() {
|
||||
override fun createHyperlinkListener() = HyperlinkListener { e ->
|
||||
if (e.eventType != HyperlinkEvent.EventType.ACTIVATED) return@HyperlinkListener
|
||||
if (e.description == WORKTREE_HREF) newWorktree?.invoke()
|
||||
}
|
||||
}.apply {
|
||||
foreground = SessionUiStyle.Text.Secondary.foreground()
|
||||
horizontalAlignment = JBLabel.CENTER
|
||||
setAllowAutoWrapping(true)
|
||||
setCopyable(true)
|
||||
}
|
||||
|
||||
private val description = object : BorderLayoutPanel() {
|
||||
@@ -101,6 +125,13 @@ class EmptySessionPanel(
|
||||
add(welcomeLabel, BorderLayout.CENTER)
|
||||
}
|
||||
|
||||
private val descriptionSlot = description.align(HAlign.CENTER, VAlign.CENTER)
|
||||
|
||||
private val header = BorderLayoutPanel(0, UiStyle.Gap.pad()).apply {
|
||||
isOpaque = false
|
||||
add(logo, BorderLayout.NORTH)
|
||||
}
|
||||
|
||||
init {
|
||||
Disposer.register(parent, this)
|
||||
Disposer.register(this, feedback)
|
||||
@@ -119,12 +150,6 @@ class EmptySessionPanel(
|
||||
val gap = UiStyle.Gap.pad()
|
||||
layout = BorderLayout(0, gap)
|
||||
|
||||
val header = BorderLayoutPanel(0, gap).apply {
|
||||
isOpaque = false
|
||||
add(logo, BorderLayout.NORTH)
|
||||
if (!minimal) add(description.align(HAlign.CENTER, VAlign.CENTER), BorderLayout.CENTER)
|
||||
}
|
||||
|
||||
val actions = Stack.vertical(gap = UiStyle.Gap.lg())
|
||||
if (!minimal) actions.next(Centerizer(historyButton, Centerizer.TYPE.HORIZONTAL))
|
||||
actions.next(Centerizer(feedback.button, Centerizer.TYPE.HORIZONTAL))
|
||||
@@ -136,6 +161,68 @@ class EmptySessionPanel(
|
||||
add(header, BorderLayout.NORTH)
|
||||
if (!minimal && recent.hasSessions()) add(recent, BorderLayout.CENTER)
|
||||
add(south, BorderLayout.SOUTH)
|
||||
syncTip()
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the branch/worktree status behind the tip under the logo. Called again whenever the
|
||||
* status is refreshed, so it must stay a no-op when nothing changed.
|
||||
*/
|
||||
@RequiresEdt
|
||||
fun setBranch(status: BranchStatusDto?) {
|
||||
if (branch == status) return
|
||||
branch = status
|
||||
syncTip()
|
||||
}
|
||||
|
||||
/**
|
||||
* The tip under the logo, as an HTML fragment: an isolation reminder on a worktree, a nudge
|
||||
* towards one on a plain checkout, where "run it in a worktree" is an inline link. Null when the
|
||||
* status is unknown, git is missing, or no branch is checked out — the generic welcome covers
|
||||
* those rather than asserting something wrong.
|
||||
*/
|
||||
private fun tip(): String? {
|
||||
val status = branch ?: return null
|
||||
if (status.availability == GhAvailability.GIT_MISSING) return null
|
||||
val name = name()?.let { XmlStringUtil.escapeString(it) }
|
||||
if (status.worktree) {
|
||||
return name?.let { KiloBundle.message("session.empty.worktree", it) }
|
||||
?: KiloBundle.message("session.empty.worktree.unknown")
|
||||
}
|
||||
if (name == null) return null
|
||||
return KiloBundle.message("session.empty.branch", name, worktreePhrase())
|
||||
}
|
||||
|
||||
/**
|
||||
* "run it in a worktree" as a link, or as plain text on surfaces that cannot open the flow, so
|
||||
* the sentence reads the same either way.
|
||||
*/
|
||||
private fun worktreePhrase(): String {
|
||||
val phrase = KiloBundle.message("session.empty.branch.link")
|
||||
if (newWorktree == null) return XmlStringUtil.escapeString(phrase)
|
||||
return HtmlChunk.link(WORKTREE_HREF, phrase).toString()
|
||||
}
|
||||
|
||||
/** Branch name trimmed to fit the fixed-width description, or null when there is no branch. */
|
||||
private fun name(): String? {
|
||||
val value = branch?.branch?.trim().orEmpty()
|
||||
if (value.isEmpty() || value == DETACHED) return null
|
||||
return StringUtil.shortenTextWithEllipsis(value, BRANCH_MAX, 0, true)
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
private fun syncTip() {
|
||||
val tip = tip()
|
||||
welcomeLabel.text = centeredHtml(
|
||||
tip ?: XmlStringUtil.escapeString(KiloBundle.message("session.empty.welcome")),
|
||||
)
|
||||
// Minimal surfaces (worktree/subagent editor tabs) skip the generic blurb but still want a
|
||||
// state-specific tip, so the slot is attached on demand instead of once at construction.
|
||||
val described = tip != null || !minimal
|
||||
if (described && descriptionSlot.parent == null) header.add(descriptionSlot, BorderLayout.CENTER)
|
||||
if (!described && descriptionSlot.parent != null) header.remove(descriptionSlot)
|
||||
revalidate()
|
||||
repaint()
|
||||
}
|
||||
|
||||
internal fun recentCount() = recent.count()
|
||||
@@ -184,6 +271,16 @@ class EmptySessionPanel(
|
||||
|
||||
internal fun explanationText() = KiloBundle.message("session.empty.welcome")
|
||||
|
||||
/** The tip as the user reads it, with the inline link's markup stripped. */
|
||||
internal fun tipText() = tip()?.let { StringUtil.removeHtmlTags(it) }
|
||||
|
||||
/** The plain text currently under the logo: the state-specific tip, or the generic welcome. */
|
||||
internal fun descriptionText() = tipText() ?: KiloBundle.message("session.empty.welcome")
|
||||
|
||||
internal fun worktreeLinked() = tip()?.contains("href=\"$WORKTREE_HREF\"") == true
|
||||
|
||||
internal fun worktreeHref() = WORKTREE_HREF
|
||||
|
||||
internal fun welcomeLabelAlignment() = welcomeLabel.horizontalAlignment
|
||||
|
||||
internal fun descriptionPreferredSize() = description.preferredSize
|
||||
@@ -272,11 +369,19 @@ class EmptySessionPanel(
|
||||
repaint()
|
||||
}
|
||||
|
||||
private fun welcomeHtml() = XmlStringUtil.wrapInHtml(
|
||||
"<div style='text-align:center'>${XmlStringUtil.escapeString(KiloBundle.message("session.empty.welcome"))}</div>"
|
||||
/** [body] must already be escaped or generated HTML — this only wraps and centers it. */
|
||||
private fun centeredHtml(body: String) = XmlStringUtil.wrapInHtml(
|
||||
"<div style='text-align:center'>$body</div>"
|
||||
)
|
||||
|
||||
private companion object {
|
||||
const val ACTIVITY_MS = 3_000
|
||||
|
||||
/** Keeps a long branch name from wrapping the fixed-width description into a wall of text. */
|
||||
const val BRANCH_MAX = 28
|
||||
const val DETACHED = "(detached)"
|
||||
|
||||
/** Href of the inline worktree link; matched in the label's hyperlink listener. */
|
||||
const val WORKTREE_HREF = "worktree"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M7 4H8V7H11V8H8V11H7V8H4V7H7V4Z" fill="#6C707E"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 202 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M7 4H8V7H11V8H8V11H7V8H4V7H7V4Z" fill="#CED0D6"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 202 B |
@@ -42,6 +42,10 @@ session.connection.warning.config=Configuration warnings
|
||||
notification.group.kilo=Kilo Code
|
||||
|
||||
session.empty.welcome=Kilo Code is an AI coding assistant. Ask it to build features, fix bugs, or explain your codebase.
|
||||
session.empty.branch=You''re working directly on {0}. Start a task, or {1} to keep changes isolated.
|
||||
session.empty.branch.link=run it in a worktree
|
||||
session.empty.worktree=You''re in an isolated worktree on {0}. Work freely — your main checkout stays untouched.
|
||||
session.empty.worktree.unknown=You're in an isolated worktree. Work freely — your main checkout stays untouched.
|
||||
session.account.balance=Balance: {0}
|
||||
session.account.switcher=Switch account
|
||||
session.empty.loading=Loading...
|
||||
@@ -472,8 +476,10 @@ action.Kilo.Settings.text=Settings
|
||||
action.Kilo.Settings.description=Kilo Code settings
|
||||
action.Kilo.NewSession.text=New Session
|
||||
action.Kilo.NewSession.description=Start a new Kilo session
|
||||
action.Kilo.NewSession.toolbar=Session
|
||||
action.Kilo.NewWorktree.text=New Worktree
|
||||
action.Kilo.NewWorktree.description=Create a new git worktree
|
||||
action.Kilo.NewWorktree.toolbar=Worktree
|
||||
action.Kilo.History.text=History
|
||||
action.Kilo.History.description=Show session history
|
||||
action.Kilo.ShowProfile.text=Profile
|
||||
|
||||
@@ -17,6 +17,10 @@ session.connection.unsupported.options=الخيار 1: افتح المشروع
|
||||
session.connection.warning.config=تحذيرات التكوين
|
||||
|
||||
session.empty.welcome=Kilo Code هو مساعد برمجة بالذكاء الاصطناعي. اطلب منه بناء ميزات أو إصلاح أخطاء أو شرح قاعدة الكود.
|
||||
session.empty.branch=تعمل مباشرةً على {0}. ابدأ مهمة، أو {1} لإبقاء التغييرات معزولة.
|
||||
session.empty.branch.link=شغّلها في worktree
|
||||
session.empty.worktree=أنت في worktree معزول على {0}. اعمل بحرية — تبقى نسختك الرئيسية دون تغيير.
|
||||
session.empty.worktree.unknown=أنت في worktree معزول. اعمل بحرية — تبقى نسختك الرئيسية دون تغيير.
|
||||
session.empty.loading=جاري التحميل…
|
||||
session.empty.recent=الحديثة
|
||||
session.showHistory=عرض السجل
|
||||
@@ -163,6 +167,8 @@ action.Kilo.Settings.text=الإعدادات
|
||||
action.Kilo.Settings.description=إعدادات Kilo Code
|
||||
action.Kilo.NewSession.text=جلسة جديدة
|
||||
action.Kilo.NewSession.description=بدء جلسة Kilo جديدة
|
||||
action.Kilo.NewSession.toolbar=جلسة
|
||||
action.Kilo.NewWorktree.toolbar=Worktree
|
||||
action.Kilo.History.text=السجل
|
||||
action.Kilo.History.description=عرض سجل الجلسات
|
||||
action.Kilo.SendPrompt.text=إرسال الطلب
|
||||
|
||||
@@ -17,6 +17,10 @@ session.connection.unsupported.options=Opcija 1: Otvorite projekat u kontejneru
|
||||
session.connection.warning.config=Upozorenja konfiguracije
|
||||
|
||||
session.empty.welcome=Kilo Code je AI asistent za kodiranje. Zatražite od njega da gradi funkcije, ispravlja greške ili objašnjava vašu bazu koda.
|
||||
session.empty.branch=Radite direktno na {0}. Započnite zadatak ili {1} da izmjene ostanu izolovane.
|
||||
session.empty.branch.link=pokrenite ga u worktree-u
|
||||
session.empty.worktree=Nalazite se u izolovanom worktree-u na {0}. Radite slobodno — vaš glavni checkout ostaje nepromijenjen.
|
||||
session.empty.worktree.unknown=Nalazite se u izolovanom worktree-u. Radite slobodno — vaš glavni checkout ostaje nepromijenjen.
|
||||
session.empty.loading=Učitavanje…
|
||||
session.empty.recent=NEDAVNO
|
||||
session.showHistory=Prikaži historiju
|
||||
@@ -163,6 +167,8 @@ action.Kilo.Settings.text=Postavke
|
||||
action.Kilo.Settings.description=Postavke Kilo Code
|
||||
action.Kilo.NewSession.text=Nova sesija
|
||||
action.Kilo.NewSession.description=Pokrenite novu Kilo sesiju
|
||||
action.Kilo.NewSession.toolbar=Sesija
|
||||
action.Kilo.NewWorktree.toolbar=Worktree
|
||||
action.Kilo.History.text=Historija
|
||||
action.Kilo.History.description=Prikaži historiju sesija
|
||||
action.Kilo.SendPrompt.text=Pošalji upit
|
||||
|
||||
@@ -17,6 +17,10 @@ session.connection.unsupported.options=Mulighed 1: Åbn projektet i containeren
|
||||
session.connection.warning.config=Konfigurationsadvarsler
|
||||
|
||||
session.empty.welcome=Kilo Code er en AI-kodningsassistent. Bed den om at bygge funktioner, rette fejl eller forklare din kodebase.
|
||||
session.empty.branch=Du arbejder direkte på {0}. Start en opgave, eller {1} for at holde ændringer isoleret.
|
||||
session.empty.branch.link=kør den i et worktree
|
||||
session.empty.worktree=Du er i et isoleret worktree på {0}. Arbejd frit — dit primære checkout forbliver urørt.
|
||||
session.empty.worktree.unknown=Du er i et isoleret worktree. Arbejd frit — dit primære checkout forbliver urørt.
|
||||
session.empty.loading=Indlæser…
|
||||
session.empty.recent=SENESTE
|
||||
session.showHistory=Vis historik
|
||||
@@ -163,6 +167,8 @@ action.Kilo.Settings.text=Indstillinger
|
||||
action.Kilo.Settings.description=Kilo Code-indstillinger
|
||||
action.Kilo.NewSession.text=Ny session
|
||||
action.Kilo.NewSession.description=Start en ny Kilo-session
|
||||
action.Kilo.NewSession.toolbar=Session
|
||||
action.Kilo.NewWorktree.toolbar=Worktree
|
||||
action.Kilo.History.text=Historik
|
||||
action.Kilo.History.description=Vis sessionshistorik
|
||||
action.Kilo.SendPrompt.text=Send prompt
|
||||
|
||||
@@ -17,6 +17,10 @@ session.connection.unsupported.options=Option 1: Öffnen Sie das Projekt im Cont
|
||||
session.connection.warning.config=Konfigurationswarnungen
|
||||
|
||||
session.empty.welcome=Kilo Code ist ein KI-Coding-Assistent. Bitten Sie ihn, Funktionen zu erstellen, Fehler zu beheben oder Ihre Codebasis zu erklären.
|
||||
session.empty.branch=Sie arbeiten direkt auf {0}. Starten Sie eine Aufgabe oder {1}, um Änderungen isoliert zu halten.
|
||||
session.empty.branch.link=führen Sie sie in einem Worktree aus
|
||||
session.empty.worktree=Sie sind in einem isolierten Worktree auf {0}. Sie können hier gefahrlos experimentieren — Ihr Haupt-Checkout bleibt unberührt.
|
||||
session.empty.worktree.unknown=Sie sind in einem isolierten Worktree. Sie können hier gefahrlos experimentieren — Ihr Haupt-Checkout bleibt unberührt.
|
||||
session.empty.loading=Wird geladen…
|
||||
session.empty.recent=ZULETZT
|
||||
session.showHistory=Verlauf anzeigen
|
||||
@@ -163,6 +167,8 @@ action.Kilo.Settings.text=Einstellungen
|
||||
action.Kilo.Settings.description=Kilo Code Einstellungen
|
||||
action.Kilo.NewSession.text=Neue Sitzung
|
||||
action.Kilo.NewSession.description=Neue Kilo-Sitzung starten
|
||||
action.Kilo.NewSession.toolbar=Sitzung
|
||||
action.Kilo.NewWorktree.toolbar=Worktree
|
||||
action.Kilo.History.text=Verlauf
|
||||
action.Kilo.History.description=Sitzungsverlauf anzeigen
|
||||
action.Kilo.SendPrompt.text=Anfrage senden
|
||||
|
||||
@@ -17,6 +17,10 @@ session.connection.unsupported.options=Opción 1: Abre el proyecto en el contene
|
||||
session.connection.warning.config=Advertencias de configuración
|
||||
|
||||
session.empty.welcome=Kilo Code es un asistente de codificación con IA. Pida que construya funciones, corrija errores o explique su base de código.
|
||||
session.empty.branch=Estás trabajando directamente en {0}. Inicia una tarea o {1} para mantener los cambios aislados.
|
||||
session.empty.branch.link=ejecútala en un worktree
|
||||
session.empty.worktree=Estás en un worktree aislado en {0}. Trabaja con libertad — tu checkout principal no se toca.
|
||||
session.empty.worktree.unknown=Estás en un worktree aislado. Trabaja con libertad — tu checkout principal no se toca.
|
||||
session.empty.loading=Cargando…
|
||||
session.empty.recent=RECIENTE
|
||||
session.showHistory=Mostrar historial
|
||||
@@ -163,6 +167,8 @@ action.Kilo.Settings.text=Configuración
|
||||
action.Kilo.Settings.description=Configuración de Kilo Code
|
||||
action.Kilo.NewSession.text=Nueva sesión
|
||||
action.Kilo.NewSession.description=Iniciar una nueva sesión de Kilo
|
||||
action.Kilo.NewSession.toolbar=Sesión
|
||||
action.Kilo.NewWorktree.toolbar=Worktree
|
||||
action.Kilo.History.text=Historial
|
||||
action.Kilo.History.description=Mostrar el historial de sesiones
|
||||
action.Kilo.SendPrompt.text=Enviar mensaje
|
||||
|
||||
@@ -17,6 +17,10 @@ session.connection.unsupported.options=Option 1 : ouvrez le projet dans le conte
|
||||
session.connection.warning.config=Avertissements de configuration
|
||||
|
||||
session.empty.welcome=Kilo Code est un assistant de codage IA. Demandez-lui de créer des fonctionnalités, corriger des bugs ou expliquer votre base de code.
|
||||
session.empty.branch=Vous travaillez directement sur {0}. Lancez une tâche ou {1} pour isoler vos modifications.
|
||||
session.empty.branch.link=exécutez-la dans un worktree
|
||||
session.empty.worktree=Vous êtes dans un worktree isolé sur {0}. Travaillez librement — votre checkout principal reste intact.
|
||||
session.empty.worktree.unknown=Vous êtes dans un worktree isolé. Travaillez librement — votre checkout principal reste intact.
|
||||
session.empty.loading=Chargement…
|
||||
session.empty.recent=RÉCENT
|
||||
session.showHistory=Afficher l'historique
|
||||
@@ -163,6 +167,8 @@ action.Kilo.Settings.text=Paramètres
|
||||
action.Kilo.Settings.description=Paramètres de Kilo Code
|
||||
action.Kilo.NewSession.text=Nouvelle session
|
||||
action.Kilo.NewSession.description=Démarrer une nouvelle session Kilo
|
||||
action.Kilo.NewSession.toolbar=Session
|
||||
action.Kilo.NewWorktree.toolbar=Worktree
|
||||
action.Kilo.History.text=Historique
|
||||
action.Kilo.History.description=Afficher l'historique des sessions
|
||||
action.Kilo.SendPrompt.text=Envoyer l'invite
|
||||
|
||||
@@ -17,6 +17,10 @@ session.connection.unsupported.options=オプション1: JetBrains Gateway を
|
||||
session.connection.warning.config=設定の警告
|
||||
|
||||
session.empty.welcome=Kilo CodeはAIコーディングアシスタントです。機能の作成、バグの修正、またはコードベースの説明を依頼できます。
|
||||
session.empty.branch={0} で直接作業しています。タスクを開始するか、{1}すると変更を隔離できます。
|
||||
session.empty.branch.link=worktree で実行
|
||||
session.empty.worktree={0} の独立した worktree にいます。メインのチェックアウトには影響しないので自由に作業できます。
|
||||
session.empty.worktree.unknown=独立した worktree にいます。メインのチェックアウトには影響しないので自由に作業できます。
|
||||
session.empty.loading=読み込み中…
|
||||
session.empty.recent=最近
|
||||
session.showHistory=履歴を表示
|
||||
@@ -163,6 +167,8 @@ action.Kilo.Settings.text=設定
|
||||
action.Kilo.Settings.description=Kilo Codeの設定
|
||||
action.Kilo.NewSession.text=新しいセッション
|
||||
action.Kilo.NewSession.description=新しいKiloセッションを開始
|
||||
action.Kilo.NewSession.toolbar=セッション
|
||||
action.Kilo.NewWorktree.toolbar=Worktree
|
||||
action.Kilo.History.text=履歴
|
||||
action.Kilo.History.description=セッション履歴を表示
|
||||
action.Kilo.SendPrompt.text=プロンプトを送信
|
||||
|
||||
@@ -17,6 +17,10 @@ session.connection.unsupported.options=옵션 1: JetBrains Gateway로 컨테이
|
||||
session.connection.warning.config=구성 경고
|
||||
|
||||
session.empty.welcome=Kilo Code는 AI 코딩 어시스턴트입니다. 기능 구축, 버그 수정, 코드베이스 설명을 요청하세요.
|
||||
session.empty.branch={0}에서 직접 작업하고 있습니다. 작업을 시작하거나 {1}하여 변경 사항을 분리하세요.
|
||||
session.empty.branch.link=worktree에서 실행
|
||||
session.empty.worktree={0}의 격리된 worktree에 있습니다. 메인 체크아웃은 영향을 받지 않으니 자유롭게 작업하세요.
|
||||
session.empty.worktree.unknown=격리된 worktree에 있습니다. 메인 체크아웃은 영향을 받지 않으니 자유롭게 작업하세요.
|
||||
session.empty.loading=로딩 중…
|
||||
session.empty.recent=최근
|
||||
session.showHistory=기록 보기
|
||||
@@ -163,6 +167,8 @@ action.Kilo.Settings.text=설정
|
||||
action.Kilo.Settings.description=Kilo Code 설정
|
||||
action.Kilo.NewSession.text=새 세션
|
||||
action.Kilo.NewSession.description=새 Kilo 세션 시작
|
||||
action.Kilo.NewSession.toolbar=세션
|
||||
action.Kilo.NewWorktree.toolbar=Worktree
|
||||
action.Kilo.History.text=기록
|
||||
action.Kilo.History.description=세션 기록 표시
|
||||
action.Kilo.SendPrompt.text=프롬프트 전송
|
||||
|
||||
@@ -17,6 +17,10 @@ session.connection.unsupported.options=Optie 1: Open het project in de container
|
||||
session.connection.warning.config=Configuratiewaarschuwingen
|
||||
|
||||
session.empty.welcome=Kilo Code is een AI-codeerassistent. Vraag het om functies te bouwen, bugs te repareren of uw codebase uit te leggen.
|
||||
session.empty.branch=Je werkt direct op {0}. Start een taak of {1} om wijzigingen geïsoleerd te houden.
|
||||
session.empty.branch.link=voer hem uit in een worktree
|
||||
session.empty.worktree=Je zit in een geïsoleerde worktree op {0}. Werk vrij — je hoofdcheckout blijft ongemoeid.
|
||||
session.empty.worktree.unknown=Je zit in een geïsoleerde worktree. Werk vrij — je hoofdcheckout blijft ongemoeid.
|
||||
session.empty.loading=Laden…
|
||||
session.empty.recent=RECENT
|
||||
session.showHistory=Geschiedenis weergeven
|
||||
@@ -163,6 +167,8 @@ action.Kilo.Settings.text=Instellingen
|
||||
action.Kilo.Settings.description=Kilo Code-instellingen
|
||||
action.Kilo.NewSession.text=Nieuwe sessie
|
||||
action.Kilo.NewSession.description=Een nieuwe Kilo-sessie starten
|
||||
action.Kilo.NewSession.toolbar=Sessie
|
||||
action.Kilo.NewWorktree.toolbar=Worktree
|
||||
action.Kilo.History.text=Geschiedenis
|
||||
action.Kilo.History.description=Sessiegeschiedenis weergeven
|
||||
action.Kilo.SendPrompt.text=Prompt verzenden
|
||||
|
||||
@@ -17,6 +17,10 @@ session.connection.unsupported.options=Alternativ 1: Åpne prosjektet i containe
|
||||
session.connection.warning.config=Konfigurasjonsadvarsler
|
||||
|
||||
session.empty.welcome=Kilo Code er en AI-kodingsassistent. Be den om å bygge funksjoner, fikse feil eller forklare kodebasen din.
|
||||
session.empty.branch=Du jobber direkte på {0}. Start en oppgave, eller {1} for å holde endringene isolert.
|
||||
session.empty.branch.link=kjør den i et worktree
|
||||
session.empty.worktree=Du er i et isolert worktree på {0}. Jobb fritt — hovedutsjekkingen din er urørt.
|
||||
session.empty.worktree.unknown=Du er i et isolert worktree. Jobb fritt — hovedutsjekkingen din er urørt.
|
||||
session.empty.loading=Laster…
|
||||
session.empty.recent=NYLIGE
|
||||
session.showHistory=Vis historikk
|
||||
@@ -168,6 +172,8 @@ action.Kilo.Settings.text=Innstillinger
|
||||
action.Kilo.Settings.description=Kilo Code-innstillinger
|
||||
action.Kilo.NewSession.text=Ny økt
|
||||
action.Kilo.NewSession.description=Start en ny Kilo-økt
|
||||
action.Kilo.NewSession.toolbar=Økt
|
||||
action.Kilo.NewWorktree.toolbar=Worktree
|
||||
action.Kilo.History.text=Historikk
|
||||
action.Kilo.History.description=Vis økthistorikk
|
||||
action.Kilo.SendPrompt.text=Send forespørsel
|
||||
|
||||
@@ -17,6 +17,10 @@ session.connection.unsupported.options=Opcja 1: Otwórz projekt w kontenerze lub
|
||||
session.connection.warning.config=Ostrzeżenia konfiguracji
|
||||
|
||||
session.empty.welcome=Kilo Code to asystent kodowania AI. Poproś go o tworzenie funkcji, naprawianie błędów lub wyjaśnianie bazy kodu.
|
||||
session.empty.branch=Pracujesz bezpośrednio na {0}. Rozpocznij zadanie lub {1}, aby odizolować zmiany.
|
||||
session.empty.branch.link=uruchom je w worktree
|
||||
session.empty.worktree=Jesteś w odizolowanym worktree na {0}. Pracuj swobodnie — twój główny checkout pozostaje nietknięty.
|
||||
session.empty.worktree.unknown=Jesteś w odizolowanym worktree. Pracuj swobodnie — twój główny checkout pozostaje nietknięty.
|
||||
session.empty.loading=Ładowanie…
|
||||
session.empty.recent=OSTATNIE
|
||||
session.showHistory=Pokaż historię
|
||||
@@ -168,6 +172,8 @@ action.Kilo.Settings.text=Ustawienia
|
||||
action.Kilo.Settings.description=Ustawienia Kilo Code
|
||||
action.Kilo.NewSession.text=Nowa sesja
|
||||
action.Kilo.NewSession.description=Rozpocznij nową sesję Kilo
|
||||
action.Kilo.NewSession.toolbar=Sesja
|
||||
action.Kilo.NewWorktree.toolbar=Worktree
|
||||
action.Kilo.History.text=Historia
|
||||
action.Kilo.History.description=Pokaż historię sesji
|
||||
action.Kilo.SendPrompt.text=Wyślij monit
|
||||
|
||||
+6
@@ -17,6 +17,10 @@ session.connection.unsupported.options=Opção 1: Abra o projeto no contêiner o
|
||||
session.connection.warning.config=Avisos de configuração
|
||||
|
||||
session.empty.welcome=Kilo Code é um assistente de codificação com IA. Peça que construa funcionalidades, corrija bugs ou explique sua base de código.
|
||||
session.empty.branch=Você está trabalhando diretamente em {0}. Inicie uma tarefa ou {1} para manter as alterações isoladas.
|
||||
session.empty.branch.link=execute-a em um worktree
|
||||
session.empty.worktree=Você está em um worktree isolado em {0}. Trabalhe livremente — seu checkout principal permanece intacto.
|
||||
session.empty.worktree.unknown=Você está em um worktree isolado. Trabalhe livremente — seu checkout principal permanece intacto.
|
||||
session.empty.loading=Carregando…
|
||||
session.empty.recent=RECENTE
|
||||
session.showHistory=Mostrar histórico
|
||||
@@ -168,6 +172,8 @@ action.Kilo.Settings.text=Configurações
|
||||
action.Kilo.Settings.description=Configurações do Kilo Code
|
||||
action.Kilo.NewSession.text=Nova sessão
|
||||
action.Kilo.NewSession.description=Iniciar uma nova sessão do Kilo
|
||||
action.Kilo.NewSession.toolbar=Sessão
|
||||
action.Kilo.NewWorktree.toolbar=Worktree
|
||||
action.Kilo.History.text=Histórico
|
||||
action.Kilo.History.description=Exibir histórico de sessões
|
||||
action.Kilo.SendPrompt.text=Enviar prompt
|
||||
|
||||
@@ -17,6 +17,10 @@ session.connection.unsupported.options=Вариант 1: откройте про
|
||||
session.connection.warning.config=Предупреждения конфигурации
|
||||
|
||||
session.empty.welcome=Kilo Code — это AI-ассистент по программированию. Попросите его разработать функции, исправить ошибки или объяснить кодовую базу.
|
||||
session.empty.branch=Вы работаете напрямую в {0}. Начните задачу или {1}, чтобы изолировать изменения.
|
||||
session.empty.branch.link=запустите её в worktree
|
||||
session.empty.worktree=Вы в изолированном worktree на {0}. Работайте свободно — основная рабочая копия не затрагивается.
|
||||
session.empty.worktree.unknown=Вы в изолированном worktree. Работайте свободно — основная рабочая копия не затрагивается.
|
||||
session.empty.loading=Загрузка…
|
||||
session.empty.recent=НЕДАВНИЕ
|
||||
session.showHistory=Показать историю
|
||||
@@ -168,6 +172,8 @@ action.Kilo.Settings.text=Настройки
|
||||
action.Kilo.Settings.description=Настройки Kilo Code
|
||||
action.Kilo.NewSession.text=Новая сессия
|
||||
action.Kilo.NewSession.description=Запустить новую сессию Kilo
|
||||
action.Kilo.NewSession.toolbar=Сессия
|
||||
action.Kilo.NewWorktree.toolbar=Worktree
|
||||
action.Kilo.History.text=История
|
||||
action.Kilo.History.description=Показать историю сессий
|
||||
action.Kilo.SendPrompt.text=Отправить запрос
|
||||
|
||||
@@ -17,6 +17,10 @@ session.connection.unsupported.options=ตัวเลือกที่ 1: เ
|
||||
session.connection.warning.config=คำเตือนการกำหนดค่า
|
||||
|
||||
session.empty.welcome=Kilo Code คือผู้ช่วยเขียนโค้ด AI ขอให้สร้างฟีเจอร์แก้สันข้อผิดพลาด หรืออธิบายโค้ดเบสของคุณ
|
||||
session.empty.branch=คุณกำลังทำงานบน {0} โดยตรง เริ่มงานใหม่ หรือ {1} เพื่อแยกการเปลี่ยนแปลงออกจากกัน
|
||||
session.empty.branch.link=รันใน worktree
|
||||
session.empty.worktree=คุณอยู่ใน worktree ที่แยกอิสระบน {0} ทำงานได้อย่างอิสระ — เช็คเอาต์หลักของคุณไม่ถูกแตะต้อง
|
||||
session.empty.worktree.unknown=คุณอยู่ใน worktree ที่แยกอิสระ ทำงานได้อย่างอิสระ — เช็คเอาต์หลักของคุณไม่ถูกแตะต้อง
|
||||
session.empty.loading=กำลังโหลด…
|
||||
session.empty.recent=ล่าสุด
|
||||
session.showHistory=แสดงประวัติ
|
||||
@@ -168,6 +172,8 @@ action.Kilo.Settings.text=การตั้งค่า
|
||||
action.Kilo.Settings.description=การตั้งค่า Kilo Code
|
||||
action.Kilo.NewSession.text=เซสชันใหม่
|
||||
action.Kilo.NewSession.description=เริ่มเซสชัน Kilo ใหม่
|
||||
action.Kilo.NewSession.toolbar=เซสชัน
|
||||
action.Kilo.NewWorktree.toolbar=Worktree
|
||||
action.Kilo.History.text=ประวัติ
|
||||
action.Kilo.History.description=แสดงประวัติเซสชัน
|
||||
action.Kilo.SendPrompt.text=ส่งคำขอ
|
||||
|
||||
@@ -17,6 +17,10 @@ session.connection.unsupported.options=Seçenek 1: Projeyi JetBrains Gateway ile
|
||||
session.connection.warning.config=Yapılandırma uyarıları
|
||||
|
||||
session.empty.welcome=Kilo Code, bir yapay zeka kodlama asistanıdır. Özellik oluşturmasını, hata düzeltirlmesi veya kod tabanınızı açıklamasını isteyin.
|
||||
session.empty.branch=Doğrudan {0} üzerinde çalışıyorsunuz. Bir görev başlatın ya da değişiklikleri izole tutmak için {1}.
|
||||
session.empty.branch.link=bir worktree içinde çalıştırın
|
||||
session.empty.worktree={0} üzerinde izole bir worktree içindesiniz. Rahatça çalışın — ana checkout''unuz olduğu gibi kalır.
|
||||
session.empty.worktree.unknown=İzole bir worktree içindesiniz. Rahatça çalışın — ana checkout'unuz olduğu gibi kalır.
|
||||
session.empty.loading=Yükleniyor…
|
||||
session.empty.recent=SON
|
||||
session.showHistory=Geçmişi göster
|
||||
@@ -168,6 +172,8 @@ action.Kilo.Settings.text=Ayarlar
|
||||
action.Kilo.Settings.description=Kilo Code ayarları
|
||||
action.Kilo.NewSession.text=Yeni oturum
|
||||
action.Kilo.NewSession.description=Yeni bir Kilo oturumu başlat
|
||||
action.Kilo.NewSession.toolbar=Oturum
|
||||
action.Kilo.NewWorktree.toolbar=Worktree
|
||||
action.Kilo.History.text=Geçmiş
|
||||
action.Kilo.History.description=Oturum geçmişini göster
|
||||
action.Kilo.SendPrompt.text=İstem gönder
|
||||
|
||||
@@ -17,6 +17,10 @@ session.connection.unsupported.options=Варіант 1: відкрийте пр
|
||||
session.connection.warning.config=Попередження конфігурації
|
||||
|
||||
session.empty.welcome=Kilo Code — це AI-асистент для програмування. Попросіть його створити функції, виправити помилки або пояснити ваш код.
|
||||
session.empty.branch=Ви працюєте безпосередньо в {0}. Почніть завдання або {1}, щоб ізолювати зміни.
|
||||
session.empty.branch.link=запустіть його в worktree
|
||||
session.empty.worktree=Ви в ізольованому worktree на {0}. Працюйте вільно — основна робоча копія залишається незмінною.
|
||||
session.empty.worktree.unknown=Ви в ізольованому worktree. Працюйте вільно — основна робоча копія залишається незмінною.
|
||||
session.empty.loading=Завантаження…
|
||||
session.empty.recent=НЕДАВНІ
|
||||
session.showHistory=Показати історію
|
||||
@@ -163,6 +167,8 @@ action.Kilo.Settings.text=Налаштування
|
||||
action.Kilo.Settings.description=Налаштування Kilo Code
|
||||
action.Kilo.NewSession.text=Нова сесія
|
||||
action.Kilo.NewSession.description=Розпочати нову сесію Kilo
|
||||
action.Kilo.NewSession.toolbar=Сесія
|
||||
action.Kilo.NewWorktree.toolbar=Worktree
|
||||
action.Kilo.History.text=Історія
|
||||
action.Kilo.History.description=Показати історію сесій
|
||||
action.Kilo.SendPrompt.text=Надіслати запит
|
||||
|
||||
+6
@@ -17,6 +17,10 @@ session.connection.unsupported.options=选项 1:使用 JetBrains Gateway 在
|
||||
session.connection.warning.config=配置警告
|
||||
|
||||
session.empty.welcome=Kilo Code 是一个 AI 编程助手。可请它构建功能、修复错误或解释您的代码库。
|
||||
session.empty.branch=你正在 {0} 上直接工作。开始一个任务,或{1}以隔离改动。
|
||||
session.empty.branch.link=在 worktree 中运行
|
||||
session.empty.worktree=你在 {0} 的独立 worktree 中。可以放心工作 — 主工作副本不会受到影响。
|
||||
session.empty.worktree.unknown=你在独立的 worktree 中。可以放心工作 — 主工作副本不会受到影响。
|
||||
session.empty.loading=加载中…
|
||||
session.empty.recent=最近
|
||||
session.showHistory=显示历史
|
||||
@@ -163,6 +167,8 @@ action.Kilo.Settings.text=设置
|
||||
action.Kilo.Settings.description=Kilo Code 设置
|
||||
action.Kilo.NewSession.text=新建会话
|
||||
action.Kilo.NewSession.description=开始新的 Kilo 会话
|
||||
action.Kilo.NewSession.toolbar=会话
|
||||
action.Kilo.NewWorktree.toolbar=Worktree
|
||||
action.Kilo.History.text=历史
|
||||
action.Kilo.History.description=显示会话历史
|
||||
action.Kilo.SendPrompt.text=发送提示
|
||||
|
||||
+6
@@ -17,6 +17,10 @@ session.connection.unsupported.options=選項 1:使用 JetBrains Gateway 在
|
||||
session.connection.warning.config=設定警告
|
||||
|
||||
session.empty.welcome=Kilo Code 是 AI 程式輔助。可請它建置功能、修復錯誤或解釋您的程式程式庫。
|
||||
session.empty.branch=你正在 {0} 上直接工作。開始一個任務,或{1}以隔離變更。
|
||||
session.empty.branch.link=在 worktree 中執行
|
||||
session.empty.worktree=你在 {0} 的獨立 worktree 中。可以放心工作 — 主工作副本不會受到影響。
|
||||
session.empty.worktree.unknown=你在獨立的 worktree 中。可以放心工作 — 主工作副本不會受到影響。
|
||||
session.empty.loading=載入中…
|
||||
session.empty.recent=最近
|
||||
session.showHistory=顯示歷史
|
||||
@@ -163,6 +167,8 @@ action.Kilo.Settings.text=設定
|
||||
action.Kilo.Settings.description=Kilo Code 設定
|
||||
action.Kilo.NewSession.text=新建工作階段
|
||||
action.Kilo.NewSession.description=開始新的 Kilo 工作階段
|
||||
action.Kilo.NewSession.toolbar=會話
|
||||
action.Kilo.NewWorktree.toolbar=Worktree
|
||||
action.Kilo.History.text=歷史
|
||||
action.Kilo.History.description=顯示工作階段歷史
|
||||
action.Kilo.SendPrompt.text=發送提示
|
||||
|
||||
+52
-6
@@ -1,7 +1,11 @@
|
||||
package ai.kilocode.client.actions
|
||||
|
||||
import ai.kilocode.client.agentManager.SidePanelKeys
|
||||
import ai.kilocode.client.agentManager.SidePanelMode
|
||||
import ai.kilocode.client.session.SessionManager
|
||||
import ai.kilocode.client.session.SessionRef
|
||||
import com.intellij.openapi.actionSystem.ActionPlaces
|
||||
import com.intellij.openapi.actionSystem.ActionUiKind
|
||||
import com.intellij.openapi.actionSystem.AnActionEvent
|
||||
import com.intellij.openapi.actionSystem.DataContext
|
||||
import com.intellij.openapi.actionSystem.Presentation
|
||||
@@ -29,19 +33,61 @@ class NewSessionActionTest : BasePlatformTestCase() {
|
||||
val action = NewSessionAction()
|
||||
val presentation = Presentation().apply { copyFrom(action.templatePresentation) }
|
||||
|
||||
ActionUtil.updateAction(action, AnActionEvent.createFromDataContext("", presentation) { null })
|
||||
ActionUtil.updateAction(action, event(action, presentation = presentation))
|
||||
|
||||
assertFalse(presentation.isEnabled)
|
||||
}
|
||||
|
||||
fun `test toolbar presentation uses short text`() {
|
||||
val manager = FakeManager()
|
||||
val action = NewSessionAction()
|
||||
val event = event(action, manager = manager, ui = ActionUiKind.TOOLBAR)
|
||||
|
||||
ActionUtil.updateAction(action, event)
|
||||
|
||||
assertEquals("Session", event.presentation.text)
|
||||
assertEquals(true, event.presentation.getClientProperty(ActionUtil.SHOW_TEXT_IN_TOOLBAR))
|
||||
assertSame(KiloActionIcons.add, event.presentation.icon)
|
||||
}
|
||||
|
||||
fun `test non-toolbar presentation keeps full text`() {
|
||||
val manager = FakeManager()
|
||||
val action = NewSessionAction()
|
||||
val event = event(action, manager = manager)
|
||||
|
||||
ActionUtil.updateAction(action, event)
|
||||
|
||||
assertEquals("New Session", event.presentation.text)
|
||||
assertNull(event.presentation.getClientProperty(ActionUtil.SHOW_TEXT_IN_TOOLBAR))
|
||||
}
|
||||
|
||||
fun `test action hidden on agent manager tab`() {
|
||||
val manager = FakeManager()
|
||||
val action = NewSessionAction()
|
||||
val event = event(action, manager = manager, mode = SidePanelMode.AGENT_MANAGER)
|
||||
|
||||
ActionUtil.updateAction(action, event)
|
||||
|
||||
assertFalse(event.presentation.isVisible)
|
||||
}
|
||||
|
||||
private fun event(manager: SessionManager): AnActionEvent {
|
||||
val presentation = Presentation().apply {
|
||||
copyFrom(NewSessionAction().templatePresentation)
|
||||
}
|
||||
return event(NewSessionAction(), manager = manager)
|
||||
}
|
||||
|
||||
private fun event(
|
||||
action: NewSessionAction,
|
||||
manager: SessionManager? = null,
|
||||
mode: SidePanelMode? = null,
|
||||
ui: ActionUiKind = ActionUiKind.NONE,
|
||||
presentation: Presentation = Presentation().apply { copyFrom(action.templatePresentation) },
|
||||
): AnActionEvent {
|
||||
val context = DataContext { id ->
|
||||
if (SessionManager.KEY.`is`(id)) manager else null
|
||||
if (SessionManager.KEY.`is`(id)) return@DataContext manager
|
||||
if (SidePanelKeys.MODE.`is`(id)) return@DataContext mode
|
||||
null
|
||||
}
|
||||
return AnActionEvent.createFromDataContext("", presentation, context)
|
||||
return AnActionEvent.createEvent(context, presentation, ActionPlaces.TOOLWINDOW_TITLE, ui, null)
|
||||
}
|
||||
|
||||
private class FakeManager : SessionManager {
|
||||
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
package ai.kilocode.client.actions
|
||||
|
||||
import ai.kilocode.client.agentManager.SidePanelKeys
|
||||
import ai.kilocode.client.agentManager.SidePanelMode
|
||||
import com.intellij.openapi.actionSystem.ActionPlaces
|
||||
import com.intellij.openapi.actionSystem.ActionUiKind
|
||||
import com.intellij.openapi.actionSystem.AnActionEvent
|
||||
import com.intellij.openapi.actionSystem.DataContext
|
||||
import com.intellij.openapi.actionSystem.Presentation
|
||||
import com.intellij.openapi.actionSystem.ex.ActionUtil
|
||||
import com.intellij.testFramework.fixtures.BasePlatformTestCase
|
||||
|
||||
@Suppress("UnstableApiUsage")
|
||||
class NewWorktreeActionTest : BasePlatformTestCase() {
|
||||
fun `test toolbar presentation uses short text`() {
|
||||
val action = NewWorktreeAction()
|
||||
val event = event(action, mode = SidePanelMode.AGENT_MANAGER, ui = ActionUiKind.TOOLBAR)
|
||||
|
||||
ActionUtil.updateAction(action, event)
|
||||
|
||||
assertEquals("Worktree", event.presentation.text)
|
||||
assertEquals(true, event.presentation.getClientProperty(ActionUtil.SHOW_TEXT_IN_TOOLBAR))
|
||||
assertSame(KiloActionIcons.add, event.presentation.icon)
|
||||
}
|
||||
|
||||
fun `test non-toolbar presentation keeps full text`() {
|
||||
val action = NewWorktreeAction()
|
||||
val event = event(action, mode = SidePanelMode.AGENT_MANAGER)
|
||||
|
||||
ActionUtil.updateAction(action, event)
|
||||
|
||||
assertEquals("New Worktree", event.presentation.text)
|
||||
assertNull(event.presentation.getClientProperty(ActionUtil.SHOW_TEXT_IN_TOOLBAR))
|
||||
}
|
||||
|
||||
fun `test action visible on agent manager tab`() {
|
||||
val action = NewWorktreeAction()
|
||||
val event = event(action, mode = SidePanelMode.AGENT_MANAGER)
|
||||
|
||||
ActionUtil.updateAction(action, event)
|
||||
|
||||
assertTrue(event.presentation.isVisible)
|
||||
}
|
||||
|
||||
fun `test action hidden on chat tab`() {
|
||||
val action = NewWorktreeAction()
|
||||
val event = event(action, mode = SidePanelMode.CHAT)
|
||||
|
||||
ActionUtil.updateAction(action, event)
|
||||
|
||||
assertFalse(event.presentation.isVisible)
|
||||
}
|
||||
|
||||
fun `test action disabled without worktree panel`() {
|
||||
val action = NewWorktreeAction()
|
||||
val event = event(action, mode = SidePanelMode.AGENT_MANAGER)
|
||||
|
||||
ActionUtil.updateAction(action, event)
|
||||
|
||||
assertFalse(event.presentation.isEnabled)
|
||||
}
|
||||
|
||||
private fun event(
|
||||
action: NewWorktreeAction,
|
||||
mode: SidePanelMode? = null,
|
||||
ui: ActionUiKind = ActionUiKind.NONE,
|
||||
): AnActionEvent {
|
||||
val presentation = Presentation().apply { copyFrom(action.templatePresentation) }
|
||||
val context = DataContext { id ->
|
||||
if (SidePanelKeys.MODE.`is`(id)) return@DataContext mode
|
||||
null
|
||||
}
|
||||
return AnActionEvent.createEvent(context, presentation, ActionPlaces.TOOLWINDOW_TITLE, ui, null)
|
||||
}
|
||||
}
|
||||
+27
@@ -5,6 +5,7 @@ import ai.kilocode.rpc.dto.SessionActivityDto
|
||||
import ai.kilocode.rpc.dto.SessionActivityKindDto
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
|
||||
class WorktreeActivityTest {
|
||||
@Test
|
||||
@@ -46,6 +47,32 @@ class WorktreeActivityTest {
|
||||
assertEquals(SessionActivityKind.QUESTION, questionOverError["/repo/wt"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `attention ranks waiting sessions and ignores running work`() {
|
||||
val kinds = mapOf(
|
||||
"ses_run" to SessionActivityKind.RUNNING,
|
||||
"ses_error" to SessionActivityKind.ERROR,
|
||||
"ses_question" to SessionActivityKind.QUESTION,
|
||||
"ses_permission" to SessionActivityKind.PERMISSION,
|
||||
)
|
||||
|
||||
assertEquals(SessionActivityKind.PERMISSION, attention(kinds))
|
||||
assertEquals(SessionActivityKind.QUESTION, attention(kinds, current = "ses_permission"))
|
||||
assertNull(attention(mapOf("ses_run" to SessionActivityKind.RUNNING)))
|
||||
assertNull(attention(emptyMap()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `attention skips the current and deleting sessions`() {
|
||||
val kinds = mapOf(
|
||||
"ses_open" to SessionActivityKind.QUESTION,
|
||||
"ses_gone" to SessionActivityKind.PERMISSION,
|
||||
)
|
||||
|
||||
assertNull(attention(kinds, current = "ses_open", deleting = setOf("ses_gone")))
|
||||
assertEquals(SessionActivityKind.QUESTION, attention(kinds, deleting = setOf("ses_gone")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `normalizes trailing slashes`() {
|
||||
val result = aggregateWorktreeActivity(mapOf(
|
||||
|
||||
+253
-21
@@ -15,7 +15,8 @@ import ai.kilocode.client.testing.TestCoroutines
|
||||
import ai.kilocode.client.testing.pumpEdt
|
||||
import ai.kilocode.client.testing.fire
|
||||
import ai.kilocode.client.plugin.KiloBundle
|
||||
import ai.kilocode.client.plugin.KiloPluginSettings
|
||||
import ai.kilocode.client.ui.FilledBadgeIcon
|
||||
import ai.kilocode.client.ui.UiStyle
|
||||
import ai.kilocode.client.ui.list.ActiveList
|
||||
import ai.kilocode.client.ui.list.ActiveListItem
|
||||
import ai.kilocode.client.ui.list.activeListSectionTitle
|
||||
@@ -37,16 +38,22 @@ import com.intellij.ui.OnePixelSplitter
|
||||
import com.intellij.ui.SearchTextField
|
||||
import com.intellij.ui.SimpleColoredComponent
|
||||
import com.intellij.ui.SimpleTextAttributes
|
||||
import com.intellij.ui.components.JBLabel
|
||||
import com.intellij.ui.components.JBList
|
||||
import com.intellij.ui.components.JBScrollPane
|
||||
import com.intellij.util.ui.UIUtil
|
||||
import java.awt.Color
|
||||
import com.intellij.util.ui.JBUI
|
||||
import java.awt.Container
|
||||
import javax.swing.JPanel
|
||||
import javax.swing.JSeparator
|
||||
import javax.swing.SwingConstants
|
||||
import java.awt.Point
|
||||
import java.awt.event.ActionEvent
|
||||
import java.awt.event.InputEvent
|
||||
import java.awt.event.KeyEvent
|
||||
import java.awt.event.MouseEvent
|
||||
import javax.swing.Icon
|
||||
import javax.swing.JButton
|
||||
import javax.swing.JComponent
|
||||
import javax.swing.KeyStroke
|
||||
@@ -61,29 +68,44 @@ class WorktreeSessionEditorPanelTest : BasePlatformTestCase() {
|
||||
private lateinit var controller: WorktreeSessionListController
|
||||
private lateinit var manager: FakeManager
|
||||
private lateinit var panel: WorktreeSessionEditorPanel
|
||||
private val saves = mutableListOf<Boolean>()
|
||||
private val workspace = Workspace(DIR, kotlinx.coroutines.flow.MutableStateFlow(ai.kilocode.rpc.dto.KiloWorkspaceStateDto(ai.kilocode.rpc.dto.KiloWorkspaceStatusDto.READY)), {}, {})
|
||||
|
||||
override fun setUp() {
|
||||
super.setUp()
|
||||
KiloPluginSettings.unsetWorktreeSessionListExpanded()
|
||||
coroutines = TestCoroutines()
|
||||
rpc = FakeSessionRpcApi()
|
||||
sessions = KiloSessionService(project, coroutines.scope, rpc)
|
||||
controller = WorktreeSessionListController(sessions, DIR, coroutines.scope)
|
||||
manager = FakeManager()
|
||||
panel = edt { WorktreeSessionEditorPanel(testRootDisposable, manager, controller, workspace, confirm = { _, _, run -> run() }) }
|
||||
// Most tests inspect the session list, so the shared panel starts from a stored "visible".
|
||||
panel = view(stored = true)
|
||||
}
|
||||
|
||||
override fun tearDown() {
|
||||
try {
|
||||
TestDialogManager.setTestDialog(TestDialog.DEFAULT)
|
||||
KiloPluginSettings.unsetWorktreeSessionListExpanded()
|
||||
coroutines.close(::pump)
|
||||
} finally {
|
||||
super.tearDown()
|
||||
}
|
||||
}
|
||||
|
||||
private fun view(
|
||||
stored: Boolean? = null,
|
||||
load: ((Boolean?) -> Unit) -> Unit = { done -> done(stored) },
|
||||
): WorktreeSessionEditorPanel = edt {
|
||||
WorktreeSessionEditorPanel(
|
||||
testRootDisposable,
|
||||
manager,
|
||||
controller,
|
||||
workspace,
|
||||
confirm = { _, _, run -> run() },
|
||||
load = load,
|
||||
save = { saves += it },
|
||||
)
|
||||
}
|
||||
|
||||
fun `test panel builds splitter toolbar list and right component`() {
|
||||
edt {
|
||||
val splitter = UIUtil.findComponentOfType(panel, OnePixelSplitter::class.java)!!
|
||||
@@ -92,7 +114,8 @@ class WorktreeSessionEditorPanelTest : BasePlatformTestCase() {
|
||||
assertEquals(0.25f, splitter.proportion, 0.01f)
|
||||
assertNotNull(UIUtil.findComponentOfType(panel, WorktreePrHeaderView::class.java))
|
||||
val buttons = components(panel).filterIsInstance<ActionButton>().mapNotNull { it.presentation.text }
|
||||
assertTrue(buttons.contains("Hide sessions"))
|
||||
assertEquals("Hide sessions", toggle().toolTipText)
|
||||
assertFalse(buttons.contains("Hide sessions"))
|
||||
assertTrue(buttons.contains("New session"))
|
||||
assertTrue(buttons.contains("Rename session"))
|
||||
assertTrue(buttons.contains("Delete session"))
|
||||
@@ -152,38 +175,230 @@ class WorktreeSessionEditorPanelTest : BasePlatformTestCase() {
|
||||
assertTrue(shown("Rename session"))
|
||||
assertTrue(shown("Delete session"))
|
||||
|
||||
toggle().click()
|
||||
click(toggle())
|
||||
|
||||
assertNull(splitter.firstComponent)
|
||||
assertEquals("Show sessions", toggle().toolTipText)
|
||||
assertTrue(shown("New session"))
|
||||
assertFalse(shown("Rename session"))
|
||||
assertFalse(shown("Delete session"))
|
||||
assertNotNull(UIUtil.findComponentOfType(panel, WorktreePrHeaderView::class.java))
|
||||
assertFalse(KiloPluginSettings.getWorktreeSessionListExpanded())
|
||||
assertEquals(listOf(false), saves)
|
||||
|
||||
toggle().click()
|
||||
click(toggle())
|
||||
|
||||
assertSame(list, splitter.firstComponent)
|
||||
assertEquals("Hide sessions", toggle().toolTipText)
|
||||
assertTrue(shown("Rename session"))
|
||||
assertTrue(shown("Delete session"))
|
||||
assertTrue(KiloPluginSettings.getWorktreeSessionListExpanded())
|
||||
assertEquals(listOf(false, true), saves)
|
||||
}
|
||||
}
|
||||
|
||||
fun `test collapsed state persists for new panels`() {
|
||||
edt { toggle().click() }
|
||||
|
||||
val view = edt { WorktreeSessionEditorPanel(testRootDisposable, manager, controller, workspace, confirm = { _, _, run -> run() }) }
|
||||
fun `test stored visibility decides whether the session list is shown`() {
|
||||
val off = view(stored = false)
|
||||
val on = view(stored = true)
|
||||
|
||||
edt {
|
||||
val splitter = UIUtil.findComponentOfType(view, OnePixelSplitter::class.java)!!
|
||||
assertNull(splitter.firstComponent)
|
||||
assertTrue(shown(view, "New session"))
|
||||
assertFalse(shown(view, "Rename session"))
|
||||
assertFalse(shown(view, "Delete session"))
|
||||
assertNull(UIUtil.findComponentOfType(off, OnePixelSplitter::class.java)!!.firstComponent)
|
||||
assertNotNull(UIUtil.findComponentOfType(on, OnePixelSplitter::class.java)!!.firstComponent)
|
||||
assertFalse(shown(off, "Rename session"))
|
||||
assertTrue(shown(on, "Rename session"))
|
||||
assertTrue(saves.isEmpty())
|
||||
}
|
||||
}
|
||||
|
||||
fun `test a worktree without a stored choice stays hidden for a single session`() {
|
||||
val view = view()
|
||||
rpc.listed += session("ses_1", 1.0)
|
||||
edt { controller.reload() }
|
||||
flush()
|
||||
|
||||
edt {
|
||||
assertNull(UIUtil.findComponentOfType(view, OnePixelSplitter::class.java)!!.firstComponent)
|
||||
assertNull(badge(view))
|
||||
assertTrue(saves.isEmpty())
|
||||
}
|
||||
}
|
||||
|
||||
fun `test a second session shows the list once and stores that choice`() {
|
||||
val view = view()
|
||||
rpc.listed += session("ses_1", 1.0)
|
||||
rpc.listed += session("ses_2", 2.0)
|
||||
edt { controller.reload() }
|
||||
flush()
|
||||
|
||||
edt {
|
||||
assertNotNull(UIUtil.findComponentOfType(view, OnePixelSplitter::class.java)!!.firstComponent)
|
||||
assertEquals(listOf(true), saves)
|
||||
}
|
||||
|
||||
rpc.listed += session("ses_3", 3.0)
|
||||
edt { controller.reload() }
|
||||
flush()
|
||||
|
||||
assertEquals(listOf(true), saves)
|
||||
}
|
||||
|
||||
fun `test a stored hidden list survives extra sessions`() {
|
||||
val view = view(stored = false)
|
||||
rpc.listed += session("ses_1", 1.0)
|
||||
rpc.listed += session("ses_2", 2.0)
|
||||
edt { controller.reload() }
|
||||
flush()
|
||||
|
||||
edt {
|
||||
assertNull(UIUtil.findComponentOfType(view, OnePixelSplitter::class.java)!!.firstComponent)
|
||||
assertTrue(saves.isEmpty())
|
||||
}
|
||||
}
|
||||
|
||||
fun `test a click before the stored value arrives wins`() {
|
||||
var answer: ((Boolean?) -> Unit)? = null
|
||||
val view = view(load = { done -> answer = done })
|
||||
|
||||
edt { click(toggle(view)) }
|
||||
edt { answer!!(false) }
|
||||
|
||||
edt {
|
||||
assertNotNull(UIUtil.findComponentOfType(view, OnePixelSplitter::class.java)!!.firstComponent)
|
||||
assertEquals(listOf(true), saves)
|
||||
}
|
||||
}
|
||||
|
||||
fun `test sessions arriving before the stored answer never force the list open`() {
|
||||
var answer: ((Boolean?) -> Unit)? = null
|
||||
val view = view(load = { done -> answer = done })
|
||||
rpc.listed += session("ses_1", 1.0)
|
||||
rpc.listed += session("ses_2", 2.0)
|
||||
edt { controller.reload() }
|
||||
flush()
|
||||
|
||||
edt { assertNull(UIUtil.findComponentOfType(view, OnePixelSplitter::class.java)!!.firstComponent) }
|
||||
assertTrue(saves.isEmpty())
|
||||
|
||||
edt { answer!!(false) }
|
||||
|
||||
edt {
|
||||
assertNull(UIUtil.findComponentOfType(view, OnePixelSplitter::class.java)!!.firstComponent)
|
||||
assertTrue(saves.isEmpty())
|
||||
}
|
||||
}
|
||||
|
||||
fun `test an empty stored answer promotes a worktree that already holds two sessions`() {
|
||||
var answer: ((Boolean?) -> Unit)? = null
|
||||
val view = view(load = { done -> answer = done })
|
||||
rpc.listed += session("ses_1", 1.0)
|
||||
rpc.listed += session("ses_2", 2.0)
|
||||
edt { controller.reload() }
|
||||
flush()
|
||||
|
||||
edt { answer!!(null) }
|
||||
|
||||
edt {
|
||||
assertNotNull(UIUtil.findComponentOfType(view, OnePixelSplitter::class.java)!!.firstComponent)
|
||||
assertEquals(listOf(true), saves)
|
||||
}
|
||||
}
|
||||
|
||||
fun `test hidden toggle badges the session count from the second session on`() {
|
||||
val view = view(stored = false)
|
||||
rpc.listed += session("ses_1", 1.0)
|
||||
edt { controller.reload() }
|
||||
flush()
|
||||
|
||||
assertNull(edt { badge(view) })
|
||||
|
||||
rpc.listed += session("ses_2", 2.0)
|
||||
edt { controller.reload() }
|
||||
flush()
|
||||
|
||||
val icon = edt { badge(view) as FilledBadgeIcon }
|
||||
assertEquals("2", icon.text)
|
||||
assertSame(UiStyle.Badge.Secondary, icon.style)
|
||||
}
|
||||
|
||||
fun `test shown session list drops the count badge`() {
|
||||
rpc.listed += session("ses_1", 1.0)
|
||||
rpc.listed += session("ses_2", 2.0)
|
||||
edt { controller.reload() }
|
||||
flush()
|
||||
|
||||
assertNull(edt { badge() })
|
||||
}
|
||||
|
||||
fun `test hidden toggle surfaces a session waiting on the user instead of the count`() {
|
||||
val view = view(stored = false)
|
||||
manager.kinds = mapOf("ses_1" to SessionActivityKind.RUNNING, "ses_2" to SessionActivityKind.QUESTION)
|
||||
rpc.listed += session("ses_1", 1.0)
|
||||
rpc.listed += session("ses_2", 2.0)
|
||||
edt { controller.reload() }
|
||||
flush()
|
||||
|
||||
assertSame(SessionActivityKind.QUESTION.icon(), edt { badge(view) })
|
||||
}
|
||||
|
||||
fun `test toolbar strip pads three sides and keeps the divider flush`() {
|
||||
val standard = JBUI.CurrentTheme.Toolbar.horizontalToolbarInsets()!!
|
||||
|
||||
val ins = edt { strip().insets }
|
||||
|
||||
assertEquals(standard.top, ins.top)
|
||||
assertEquals(standard.left, ins.left)
|
||||
assertEquals(standard.bottom, ins.bottom)
|
||||
// Right carries the divider line only: padding there would push it off the header content.
|
||||
assertEquals(1, ins.right)
|
||||
}
|
||||
|
||||
fun `test a vertical separator follows the toggle in the toolbar strip`() {
|
||||
val kids = edt { row().components.toList() }
|
||||
|
||||
assertEquals(2, kids.size)
|
||||
assertTrue(SwingUtilities.isDescendingFrom(toggle(), kids.first()))
|
||||
assertEquals(SwingConstants.VERTICAL, (kids.last() as JSeparator).orientation)
|
||||
}
|
||||
|
||||
fun `test toggle keeps its own height inside a taller toolbar strip`() {
|
||||
edt {
|
||||
val strip = strip()
|
||||
strip.setSize(JBUI.scale(400), JBUI.scale(48))
|
||||
lay(strip)
|
||||
}
|
||||
|
||||
// Tracking the strip height would push the hover box against the strip's top and bottom.
|
||||
assertEquals(JBUI.scale(24), edt { toggle().height })
|
||||
assertTrue(edt { toggle().y } > 0)
|
||||
}
|
||||
|
||||
fun `test attention badge returns after showing and hiding the list again`() {
|
||||
val view = view(stored = false)
|
||||
manager.kinds = mapOf("ses_1" to SessionActivityKind.RUNNING, "ses_2" to SessionActivityKind.QUESTION)
|
||||
rpc.listed += session("ses_1", 1.0)
|
||||
rpc.listed += session("ses_2", 2.0)
|
||||
edt { controller.reload() }
|
||||
flush()
|
||||
|
||||
assertSame(SessionActivityKind.QUESTION.icon(), edt { badge(view) })
|
||||
|
||||
edt { click(toggle(view)) }
|
||||
assertNull(edt { badge(view) })
|
||||
|
||||
edt { click(toggle(view)) }
|
||||
|
||||
assertSame(SessionActivityKind.QUESTION.icon(), edt { badge(view) })
|
||||
}
|
||||
|
||||
fun `test hidden toggle keeps the count while sessions only run`() {
|
||||
val view = view(stored = false)
|
||||
manager.kinds = mapOf("ses_1" to SessionActivityKind.RUNNING, "ses_2" to SessionActivityKind.RUNNING)
|
||||
rpc.listed += session("ses_1", 1.0)
|
||||
rpc.listed += session("ses_2", 2.0)
|
||||
edt { controller.reload() }
|
||||
flush()
|
||||
|
||||
assertEquals("2", edt { (badge(view) as FilledBadgeIcon).text })
|
||||
}
|
||||
|
||||
fun `test editor kind delegates preferred focus to panel`() {
|
||||
edt {
|
||||
assertSame(UIUtil.findComponentOfType(panel, JBList::class.java), panel.preferredFocus())
|
||||
@@ -550,9 +765,26 @@ class WorktreeSessionEditorPanelTest : BasePlatformTestCase() {
|
||||
return out
|
||||
}
|
||||
|
||||
private fun toggle(root: java.awt.Component = panel): ActionButton {
|
||||
val actions = setOf("New session", "Rename session", "Delete session")
|
||||
return components(root).filterIsInstance<ActionButton>().first { it.presentation.text !in actions }
|
||||
private fun toggle(root: java.awt.Component = panel): WorktreeSessionListToggle =
|
||||
components(root).filterIsInstance<WorktreeSessionListToggle>().single()
|
||||
|
||||
/** The row holding the toggle and its separator, left of the toolbar. */
|
||||
private fun row(): JPanel = edt { toggle().parent.parent as JPanel }
|
||||
|
||||
/** The strip panel holding that row plus the action toolbar. */
|
||||
private fun strip(): JPanel = edt { row().parent as JPanel }
|
||||
|
||||
/** Lays a detached subtree out top-down, since validate() is a no-op without a peer. */
|
||||
private fun lay(root: java.awt.Component) {
|
||||
if (root !is Container) return
|
||||
root.doLayout()
|
||||
root.components.forEach(::lay)
|
||||
}
|
||||
|
||||
/** Trailing badge icon of the toggle, or null while it carries none. */
|
||||
private fun badge(root: java.awt.Component = panel): Icon? {
|
||||
val label = components(toggle(root)).filterIsInstance<JBLabel>().getOrNull(1) ?: return null
|
||||
return if (label.isVisible) label.icon else null
|
||||
}
|
||||
|
||||
private fun shown(text: String): Boolean = shown(panel, text)
|
||||
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
package ai.kilocode.client.agentManager.worktree
|
||||
|
||||
import ai.kilocode.client.session.SessionActivityKind
|
||||
import ai.kilocode.client.ui.FilledBadgeIcon
|
||||
import ai.kilocode.client.util.edtWait
|
||||
import com.intellij.testFramework.fixtures.BasePlatformTestCase
|
||||
import com.intellij.ui.components.JBLabel
|
||||
import com.intellij.util.ui.UIUtil
|
||||
import java.awt.Component
|
||||
import java.awt.Container
|
||||
import java.awt.Point
|
||||
import java.awt.event.ActionEvent
|
||||
import java.awt.event.InputEvent
|
||||
import java.awt.event.KeyEvent
|
||||
import java.awt.event.MouseEvent
|
||||
import javax.swing.JComponent
|
||||
import javax.swing.KeyStroke
|
||||
|
||||
class WorktreeSessionListToggleTest : BasePlatformTestCase() {
|
||||
private var clicks = 0
|
||||
private lateinit var toggle: WorktreeSessionListToggle
|
||||
|
||||
override fun setUp() {
|
||||
super.setUp()
|
||||
clicks = 0
|
||||
toggle = edt { WorktreeSessionListToggle { clicks++ } }
|
||||
}
|
||||
|
||||
fun `test the badge follows count expansion and activity without rebuilding labels`() {
|
||||
val glyph = labels()[0]
|
||||
val badge = labels()[1]
|
||||
|
||||
assertFalse(edt { badge.isVisible })
|
||||
assertEquals("Show sessions", edt { toggle.toolTipText })
|
||||
|
||||
edt { toggle.update(expanded = false, count = 1, kind = null) }
|
||||
assertFalse(edt { badge.isVisible })
|
||||
|
||||
edt { toggle.update(expanded = false, count = 3, kind = null) }
|
||||
assertEquals("3", edt { (badge.icon as FilledBadgeIcon).text })
|
||||
|
||||
edt { toggle.update(expanded = false, count = 3, kind = SessionActivityKind.PERMISSION) }
|
||||
assertSame(SessionActivityKind.PERMISSION.icon(), edt { badge.icon })
|
||||
|
||||
edt { toggle.update(expanded = true, count = 3, kind = SessionActivityKind.PERMISSION) }
|
||||
assertFalse(edt { badge.isVisible })
|
||||
assertEquals("Hide sessions", edt { toggle.toolTipText })
|
||||
|
||||
// The retained tree is mutated in place, never rebuilt.
|
||||
assertSame(glyph, labels()[0])
|
||||
assertSame(badge, labels()[1])
|
||||
assertEquals(2, labels().size)
|
||||
}
|
||||
|
||||
fun `test the glyph swaps between the collapsed and expanded icons`() {
|
||||
val glyph = labels()[0]
|
||||
val collapsed = edt { glyph.icon }
|
||||
|
||||
edt { toggle.update(expanded = true, count = 0, kind = null) }
|
||||
val expanded = edt { glyph.icon }
|
||||
|
||||
assertNotSame(collapsed, expanded)
|
||||
|
||||
edt { toggle.update(expanded = false, count = 0, kind = null) }
|
||||
assertSame(collapsed, edt { glyph.icon })
|
||||
}
|
||||
|
||||
fun `test mouse and keyboard both activate the toggle`() {
|
||||
edt {
|
||||
toggle.size = toggle.preferredSize
|
||||
val point = Point(toggle.width / 2, toggle.height / 2)
|
||||
toggle.dispatchEvent(
|
||||
MouseEvent(toggle, MouseEvent.MOUSE_CLICKED, System.currentTimeMillis(), InputEvent.BUTTON1_DOWN_MASK, point.x, point.y, 1, false, MouseEvent.BUTTON1),
|
||||
)
|
||||
}
|
||||
UIUtil.dispatchAllInvocationEvents()
|
||||
|
||||
assertEquals(1, clicks)
|
||||
|
||||
edt {
|
||||
val key = toggle.getInputMap(JComponent.WHEN_FOCUSED).get(KeyStroke.getKeyStroke(KeyEvent.VK_SPACE, 0))
|
||||
toggle.actionMap.get(key).actionPerformed(ActionEvent(toggle, ActionEvent.ACTION_PERFORMED, ""))
|
||||
}
|
||||
|
||||
assertEquals(2, clicks)
|
||||
}
|
||||
|
||||
private fun labels(): List<JBLabel> = edt { components(toggle).filterIsInstance<JBLabel>() }
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
private fun <T> edt(block: () -> T): T = edtWait(block)
|
||||
}
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
package ai.kilocode.client.agentManager.worktree
|
||||
|
||||
import ai.kilocode.client.testing.FakeWorktreeRpcApi
|
||||
import ai.kilocode.client.testing.TestCoroutines
|
||||
import ai.kilocode.client.testing.pumpEdt
|
||||
import com.intellij.openapi.application.ApplicationManager
|
||||
import com.intellij.testFramework.fixtures.BasePlatformTestCase
|
||||
import com.intellij.testFramework.replaceService
|
||||
|
||||
@Suppress("UnstableApiUsage")
|
||||
class WorktreeSessionListVisibilityTest : BasePlatformTestCase() {
|
||||
private lateinit var coroutines: TestCoroutines
|
||||
private lateinit var rpc: FakeWorktreeRpcApi
|
||||
private lateinit var visibility: WorktreeSessionListVisibility
|
||||
|
||||
override fun setUp() {
|
||||
super.setUp()
|
||||
coroutines = TestCoroutines()
|
||||
rpc = FakeWorktreeRpcApi()
|
||||
ApplicationManager.getApplication()
|
||||
.replaceService(KiloWorktreeService::class.java, KiloWorktreeService(coroutines.scope, rpc), testRootDisposable)
|
||||
visibility = WorktreeSessionListVisibility(coroutines.scope)
|
||||
}
|
||||
|
||||
override fun tearDown() {
|
||||
try {
|
||||
coroutines.close(::pump)
|
||||
} finally {
|
||||
super.tearDown()
|
||||
}
|
||||
}
|
||||
|
||||
fun `test load answers with the stored value on the edt`() {
|
||||
rpc.sessionLists[DIR] = true
|
||||
val values = mutableListOf<Boolean?>()
|
||||
val threads = mutableListOf<Boolean>()
|
||||
|
||||
visibility.load(DIR) { value ->
|
||||
values += value
|
||||
threads += ApplicationManager.getApplication().isDispatchThread
|
||||
}
|
||||
drain()
|
||||
|
||||
assertEquals(listOf(DIR), rpc.sessionListReads.toList())
|
||||
assertEquals(listOf<Boolean?>(true), values)
|
||||
assertEquals(listOf(true), threads)
|
||||
}
|
||||
|
||||
fun `test load answers with nothing for a worktree without a stored choice`() {
|
||||
val values = mutableListOf<Boolean?>()
|
||||
|
||||
visibility.load(DIR) { values += it }
|
||||
drain()
|
||||
|
||||
assertEquals(listOf<Boolean?>(null), values)
|
||||
}
|
||||
|
||||
fun `test load degrades to nothing when the backend fails`() {
|
||||
rpc.sessionLists[DIR] = true
|
||||
rpc.sessionListThrows = RuntimeException("backend down")
|
||||
val values = mutableListOf<Boolean?>()
|
||||
|
||||
visibility.load(DIR) { values += it }
|
||||
drain()
|
||||
|
||||
assertEquals(listOf<Boolean?>(null), values)
|
||||
}
|
||||
|
||||
fun `test save records the visibility a later load reads back`() {
|
||||
visibility.save(DIR, false)
|
||||
drain()
|
||||
|
||||
assertEquals(listOf(DIR to false), rpc.sessionListWrites.toList())
|
||||
|
||||
val values = mutableListOf<Boolean?>()
|
||||
visibility.load(DIR) { values += it }
|
||||
drain()
|
||||
|
||||
assertEquals(listOf<Boolean?>(false), values)
|
||||
}
|
||||
|
||||
fun `test save survives a failing backend`() {
|
||||
rpc.sessionListThrows = RuntimeException("backend down")
|
||||
|
||||
visibility.save(DIR, true)
|
||||
drain()
|
||||
|
||||
assertEquals(listOf(DIR to true), rpc.sessionListWrites.toList())
|
||||
}
|
||||
|
||||
private fun drain() = coroutines.drain(::pump)
|
||||
|
||||
private fun pump() = pumpEdt()
|
||||
|
||||
private companion object {
|
||||
const val DIR = "/repo/.kilo/worktrees/feature-x"
|
||||
}
|
||||
}
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
package ai.kilocode.client.plugin
|
||||
|
||||
import com.intellij.testFramework.fixtures.BasePlatformTestCase
|
||||
import java.io.InputStreamReader
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.text.MessageFormat
|
||||
import java.util.Locale
|
||||
import java.util.Properties
|
||||
|
||||
/**
|
||||
* Guards the localized empty-session tip and toolbar labels.
|
||||
*
|
||||
* The tip keys carry `{0}`/`{1}`, so they go through [MessageFormat], where a lone apostrophe
|
||||
* silently swallows the surrounding text and `''` collapses to one. Translations are easy to get
|
||||
* wrong here, so every locale is formatted for real rather than compared as a raw string.
|
||||
*/
|
||||
class KiloBundleLocaleTest : BasePlatformTestCase() {
|
||||
fun `test parameterized tips format cleanly in every locale`() {
|
||||
for (locale in LOCALES) {
|
||||
val props = load(locale)
|
||||
|
||||
val branch = props.getProperty("session.empty.branch")
|
||||
assertNotNull("$locale: missing session.empty.branch", branch)
|
||||
assertEscaped(locale, "session.empty.branch", branch!!)
|
||||
val rendered = format(branch, "main", "LINK_PHRASE")
|
||||
assertTrue("$locale: branch tip dropped the branch name -> $rendered", rendered.contains("main"))
|
||||
assertTrue("$locale: branch tip dropped the link -> $rendered", rendered.contains("LINK_PHRASE"))
|
||||
assertClean(locale, "session.empty.branch", rendered)
|
||||
|
||||
val worktree = props.getProperty("session.empty.worktree")
|
||||
assertNotNull("$locale: missing session.empty.worktree", worktree)
|
||||
assertEscaped(locale, "session.empty.worktree", worktree!!)
|
||||
val tree = format(worktree, "feature/x")
|
||||
assertTrue("$locale: worktree tip dropped the branch name -> $tree", tree.contains("feature/x"))
|
||||
assertClean(locale, "session.empty.worktree", tree)
|
||||
}
|
||||
}
|
||||
|
||||
fun `test plain keys are present and carry no placeholders`() {
|
||||
for (locale in LOCALES) {
|
||||
val props = load(locale)
|
||||
for (key in PLAIN) {
|
||||
val value = props.getProperty(key)
|
||||
assertNotNull("$locale: missing $key", value)
|
||||
assertTrue("$locale: $key is blank", value!!.isNotBlank())
|
||||
assertFalse("$locale: $key should not contain a placeholder -> $value", value.contains("{0}"))
|
||||
assertFalse(
|
||||
"$locale: $key has no placeholders so apostrophes must not be doubled -> $value",
|
||||
value.contains("''"),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun format(pattern: String, vararg args: String) =
|
||||
MessageFormat(pattern, Locale.ROOT).format(args)
|
||||
|
||||
/**
|
||||
* Every apostrophe in a MessageFormat pattern must be doubled. A lone one opens a quoted run
|
||||
* that silently eats itself (and any placeholder it spans), which formatting alone will not
|
||||
* always reveal — so the raw pattern is checked directly.
|
||||
*/
|
||||
private fun assertEscaped(locale: String, key: String, pattern: String) {
|
||||
for (run in Regex("'+").findAll(pattern)) {
|
||||
assertTrue(
|
||||
"$locale: $key has an unescaped apostrophe, double it -> $pattern",
|
||||
run.value.length % 2 == 0,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** After formatting, MessageFormat has consumed its quoting — leftovers mean a bad pattern. */
|
||||
private fun assertClean(locale: String, key: String, rendered: String) {
|
||||
assertFalse("$locale: $key still has a doubled apostrophe -> $rendered", rendered.contains("''"))
|
||||
assertFalse("$locale: $key left an unformatted placeholder -> $rendered", rendered.contains("{"))
|
||||
}
|
||||
|
||||
private fun load(locale: String): Properties {
|
||||
val name = if (locale == "en") "/messages/KiloBundle.properties" else "/messages/KiloBundle_$locale.properties"
|
||||
val stream = javaClass.getResourceAsStream(name)
|
||||
assertNotNull("$locale: $name not on the classpath", stream)
|
||||
return Properties().apply {
|
||||
InputStreamReader(stream!!, StandardCharsets.UTF_8).use { load(it) }
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val LOCALES = listOf(
|
||||
"en", "ar", "bs", "da", "de", "es", "fr", "ja", "ko", "nl",
|
||||
"no", "pl", "pt_BR", "ru", "th", "tr", "uk", "zh_CN", "zh_TW",
|
||||
)
|
||||
|
||||
val PLAIN = listOf(
|
||||
"session.empty.branch.link",
|
||||
"session.empty.worktree.unknown",
|
||||
"action.Kilo.NewSession.toolbar",
|
||||
"action.Kilo.NewWorktree.toolbar",
|
||||
)
|
||||
}
|
||||
}
|
||||
+144
-1
@@ -15,6 +15,8 @@ import ai.kilocode.client.ui.FilledBadgeIcon
|
||||
import ai.kilocode.client.testing.FakeAppRpcApi
|
||||
import ai.kilocode.client.testing.FakeSessionRpcApi
|
||||
import ai.kilocode.client.testing.FakeWorkspaceRpcApi
|
||||
import ai.kilocode.rpc.dto.BranchStatusDto
|
||||
import ai.kilocode.rpc.dto.GhAvailability
|
||||
import ai.kilocode.rpc.dto.KiloAppStateDto
|
||||
import ai.kilocode.rpc.dto.KiloAppStatusDto
|
||||
import ai.kilocode.rpc.dto.KiloWorkspaceStateDto
|
||||
@@ -34,6 +36,8 @@ import kotlinx.coroutines.runBlocking
|
||||
import java.awt.BorderLayout
|
||||
import java.awt.Cursor
|
||||
import javax.swing.JButton
|
||||
import javax.swing.JEditorPane
|
||||
import javax.swing.event.HyperlinkEvent
|
||||
|
||||
@Suppress("UnstableApiUsage")
|
||||
class EmptySessionPanelTest : BasePlatformTestCase() {
|
||||
@@ -159,6 +163,135 @@ class EmptySessionPanelTest : BasePlatformTestCase() {
|
||||
)
|
||||
}
|
||||
|
||||
// ---- branch/worktree tip under the logo ----
|
||||
|
||||
fun `test no tip until branch status arrives`() {
|
||||
val panel = panel(newWorktree = {})
|
||||
|
||||
assertNull(panel.tipText())
|
||||
assertFalse(panel.worktreeLinked())
|
||||
assertEquals(panel.explanationText(), panel.descriptionText())
|
||||
}
|
||||
|
||||
fun `test branch promotes running the task in a worktree`() {
|
||||
val panel = panel(newWorktree = {})
|
||||
|
||||
panel.setBranch(BranchStatusDto(branch = "main", worktree = false))
|
||||
|
||||
assertEquals(
|
||||
"You're working directly on main. Start a task, or run it in a worktree to keep changes isolated.",
|
||||
panel.tipText(),
|
||||
)
|
||||
assertTrue(panel.worktreeLinked())
|
||||
}
|
||||
|
||||
fun `test worktree hints isolation and links nothing`() {
|
||||
val panel = panel(newWorktree = {})
|
||||
|
||||
panel.setBranch(BranchStatusDto(branch = "feature/x", worktree = true))
|
||||
|
||||
assertEquals(
|
||||
"You're in an isolated worktree on feature/x. Work freely — your main checkout stays untouched.",
|
||||
panel.tipText(),
|
||||
)
|
||||
assertFalse(panel.worktreeLinked())
|
||||
}
|
||||
|
||||
fun `test worktree without a branch name falls back to the generic worktree tip`() {
|
||||
val panel = panel()
|
||||
|
||||
panel.setBranch(BranchStatusDto(branch = "(detached)", worktree = true))
|
||||
|
||||
assertEquals(
|
||||
"You're in an isolated worktree. Work freely — your main checkout stays untouched.",
|
||||
panel.tipText(),
|
||||
)
|
||||
}
|
||||
|
||||
fun `test detached plain checkout keeps the generic welcome`() {
|
||||
val panel = panel(newWorktree = {})
|
||||
|
||||
panel.setBranch(BranchStatusDto(branch = "(detached)", worktree = false))
|
||||
|
||||
assertNull(panel.tipText())
|
||||
assertFalse(panel.worktreeLinked())
|
||||
assertEquals(panel.explanationText(), panel.descriptionText())
|
||||
}
|
||||
|
||||
fun `test missing git keeps the generic welcome`() {
|
||||
val panel = panel(newWorktree = {})
|
||||
|
||||
panel.setBranch(
|
||||
BranchStatusDto(branch = "main", worktree = false, availability = GhAvailability.GIT_MISSING),
|
||||
)
|
||||
|
||||
assertNull(panel.tipText())
|
||||
assertFalse(panel.worktreeLinked())
|
||||
}
|
||||
|
||||
fun `test long branch name is shortened`() {
|
||||
val panel = panel()
|
||||
|
||||
panel.setBranch(BranchStatusDto(branch = "feature/a-very-long-branch-name-that-keeps-going"))
|
||||
|
||||
val tip = panel.tipText().orEmpty()
|
||||
assertTrue(tip, tip.contains("…"))
|
||||
assertFalse(tip, tip.contains("keeps-going"))
|
||||
}
|
||||
|
||||
fun `test minimal surface shows a worktree tip but no generic welcome`() {
|
||||
val panel = panel(minimal = true)
|
||||
|
||||
assertFalse(panel.descriptionVisible())
|
||||
|
||||
panel.setBranch(BranchStatusDto(branch = "feature/x", worktree = true))
|
||||
|
||||
assertTrue(panel.descriptionVisible())
|
||||
}
|
||||
|
||||
fun `test phrase stays plain text without a callback`() {
|
||||
val panel = panel()
|
||||
|
||||
panel.setBranch(BranchStatusDto(branch = "main", worktree = false))
|
||||
|
||||
assertEquals(
|
||||
"You're working directly on main. Start a task, or run it in a worktree to keep changes isolated.",
|
||||
panel.tipText(),
|
||||
)
|
||||
assertFalse(panel.worktreeLinked())
|
||||
}
|
||||
|
||||
fun `test activating the inline link invokes the callback`() {
|
||||
var fired = 0
|
||||
val panel = panel(newWorktree = { fired++ })
|
||||
panel.setBranch(BranchStatusDto(branch = "main", worktree = false))
|
||||
|
||||
activateLink(panel)
|
||||
|
||||
assertEquals(1, fired)
|
||||
}
|
||||
|
||||
fun `test activating the inline link ignores other hrefs`() {
|
||||
var fired = 0
|
||||
val panel = panel(newWorktree = { fired++ })
|
||||
panel.setBranch(BranchStatusDto(branch = "main", worktree = false))
|
||||
|
||||
activateLink(panel, href = "https://example.test")
|
||||
|
||||
assertEquals(0, fired)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fires the activation through the editor pane that `setCopyable(true)` installs, so the real
|
||||
* listener wiring is exercised rather than a stand-in.
|
||||
*/
|
||||
private fun activateLink(panel: EmptySessionPanel, href: String = panel.worktreeHref()) {
|
||||
val pane = UIUtil.uiTraverser(panel).filter(JEditorPane::class.java).first()
|
||||
assertNotNull(pane)
|
||||
val event = HyperlinkEvent(pane, HyperlinkEvent.EventType.ACTIVATED, null, href)
|
||||
pane!!.hyperlinkListeners.forEach { it.hyperlinkUpdate(event) }
|
||||
}
|
||||
|
||||
fun `test selecting recent session does not open it`() {
|
||||
val panel = panel(listOf(session("ses_1"), session("ses_2")))
|
||||
|
||||
@@ -373,7 +506,17 @@ class EmptySessionPanelTest : BasePlatformTestCase() {
|
||||
activity: () -> Map<String, SessionActivityKind> = { sessions.activitySnapshot() },
|
||||
titles: () -> Map<String, String> = { emptyMap() },
|
||||
minimal: Boolean = false,
|
||||
) = EmptySessionPanel(testRootDisposable, controller, recents, history, activity, titles, minimal = minimal)
|
||||
newWorktree: (() -> Unit)? = null,
|
||||
) = EmptySessionPanel(
|
||||
testRootDisposable,
|
||||
controller,
|
||||
recents,
|
||||
history,
|
||||
activity,
|
||||
titles,
|
||||
minimal = minimal,
|
||||
newWorktree = newWorktree,
|
||||
)
|
||||
|
||||
private fun flush() = runBlocking {
|
||||
delay(100)
|
||||
|
||||
+22
@@ -15,6 +15,7 @@ import ai.kilocode.rpc.dto.WorktreePrListDto
|
||||
import ai.kilocode.rpc.dto.WorktreeStatsListDto
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.asFlow
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
|
||||
/**
|
||||
@@ -41,6 +42,12 @@ class FakeWorktreeRpcApi : KiloWorktreeRpcApi {
|
||||
val adopts = CopyOnWriteArrayList<Triple<String, String, String>>()
|
||||
val reorders = CopyOnWriteArrayList<List<String>>()
|
||||
var reorderResult = true
|
||||
/** Stored session-list visibility per worktree path, plus the calls that touched it. */
|
||||
val sessionLists = ConcurrentHashMap<String, Boolean>()
|
||||
val sessionListReads = CopyOnWriteArrayList<String>()
|
||||
val sessionListWrites = CopyOnWriteArrayList<Pair<String, Boolean>>()
|
||||
/** When set, both session-list calls throw it instead of answering. */
|
||||
var sessionListThrows: Exception? = null
|
||||
val opens = CopyOnWriteArrayList<String>()
|
||||
val ghCalls = CopyOnWriteArrayList<String>()
|
||||
var beforeCreate: suspend () -> Unit = {}
|
||||
@@ -153,4 +160,19 @@ class FakeWorktreeRpcApi : KiloWorktreeRpcApi {
|
||||
reorders.add(paths)
|
||||
return reorderResult
|
||||
}
|
||||
|
||||
override suspend fun sessionList(directory: String): Boolean? {
|
||||
assertNotEdt("sessionList")
|
||||
sessionListReads.add(directory)
|
||||
sessionListThrows?.let { throw it }
|
||||
return sessionLists[directory]
|
||||
}
|
||||
|
||||
override suspend fun setSessionList(directory: String, visible: Boolean): Boolean {
|
||||
assertNotEdt("setSessionList")
|
||||
sessionListWrites.add(directory to visible)
|
||||
sessionListThrows?.let { throw it }
|
||||
sessionLists[directory] = visible
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,4 +87,13 @@ interface KiloWorktreeRpcApi : RemoteApi<Unit> {
|
||||
* `git worktree list` (unknown paths dropped, missing ones appended). Returns true when written.
|
||||
*/
|
||||
suspend fun reorder(directory: String, paths: List<String>): Boolean
|
||||
|
||||
/**
|
||||
* Returns the persisted session-list visibility for [directory], or null when the user has not
|
||||
* chosen a value yet.
|
||||
*/
|
||||
suspend fun sessionList(directory: String): Boolean?
|
||||
|
||||
/** Records the session-list visibility for [directory]. Returns true when written. */
|
||||
suspend fun setSessionList(directory: String, visible: Boolean): Boolean
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user