mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-24 16:02:55 +08:00
feat: support JetBrains history session actions
This commit is contained in:
@@ -43,6 +43,7 @@ openApiGenerate {
|
||||
"anyOf<>" to "kotlin.Any",
|
||||
"number" to "kotlin.Double",
|
||||
"decimal" to "kotlin.Double",
|
||||
"integer" to "kotlin.Long",
|
||||
))
|
||||
openapiNormalizer.set(mapOf(
|
||||
"SIMPLIFY_ANYOF_STRING_AND_ENUM_STRING" to "true",
|
||||
|
||||
+41
-8
@@ -21,6 +21,7 @@ import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
@@ -156,6 +157,36 @@ class KiloBackendSessionManager(
|
||||
directories.remove(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a session by sending `PATCH /session/{id}?directory={dir}` with `{"title":"..."}`.
|
||||
*
|
||||
* Uses raw HTTP because the generated Kotlin client is build-time only and
|
||||
* this repo already uses raw HTTP for session create and cloud operations.
|
||||
*/
|
||||
fun rename(id: String, dir: String, title: String): SessionDto {
|
||||
val h = http ?: throw IllegalStateException("Session manager not started")
|
||||
val url = base ?: throw IllegalStateException("Session manager not started")
|
||||
val json = """{"title":"${escape(title)}"}"""
|
||||
val patch = url.toHttpUrl().newBuilder()
|
||||
.addPathSegment("session")
|
||||
.addPathSegment(id)
|
||||
.addQueryParameter("directory", dir)
|
||||
.build()
|
||||
val request = Request.Builder()
|
||||
.url(patch)
|
||||
.method("PATCH", json.toRequestBody("application/json".toMediaType()))
|
||||
.build()
|
||||
|
||||
h.newCall(request).execute().use { response ->
|
||||
val raw = response.body?.string()
|
||||
if (!response.isSuccessful) {
|
||||
log.warn("Session rename failed: HTTP ${response.code}, body=$raw")
|
||||
throw RuntimeException("Session rename failed: HTTP ${response.code} — $raw")
|
||||
}
|
||||
return KiloCliDataParser.parseSession(raw!!)
|
||||
}
|
||||
}
|
||||
|
||||
fun cloudSessions(dir: String, cursor: String?, limit: Int, gitUrl: String?): CloudSessionListDto {
|
||||
val h = http ?: throw IllegalStateException("Session manager not started")
|
||||
val url = base ?: throw IllegalStateException("Session manager not started")
|
||||
@@ -238,9 +269,9 @@ class KiloBackendSessionManager(
|
||||
),
|
||||
summary = s.summary?.let {
|
||||
SessionSummaryDto(
|
||||
additions = it.additions.toInt(),
|
||||
deletions = it.deletions.toInt(),
|
||||
files = it.files.toInt(),
|
||||
additions = it.additions.safeInt(),
|
||||
deletions = it.deletions.safeInt(),
|
||||
files = it.files.safeInt(),
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -259,9 +290,9 @@ class KiloBackendSessionManager(
|
||||
),
|
||||
summary = s.summary?.let {
|
||||
SessionSummaryDto(
|
||||
additions = it.additions.toInt(),
|
||||
deletions = it.deletions.toInt(),
|
||||
files = it.files.toInt(),
|
||||
additions = it.additions.safeInt(),
|
||||
deletions = it.deletions.safeInt(),
|
||||
files = it.files.safeInt(),
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -269,8 +300,8 @@ class KiloBackendSessionManager(
|
||||
private fun statusDto(s: SessionStatus) = SessionStatusDto(
|
||||
type = s.type.value,
|
||||
message = s.message.ifBlank { null },
|
||||
attempt = s.attempt.toInt(),
|
||||
next = s.next.toLong(),
|
||||
attempt = s.attempt.safeInt(),
|
||||
next = s.next,
|
||||
requestID = s.requestID.ifBlank { null },
|
||||
)
|
||||
|
||||
@@ -288,4 +319,6 @@ class KiloBackendSessionManager(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Long.safeInt() = coerceIn(Int.MIN_VALUE.toLong(), Int.MAX_VALUE.toLong()).toInt()
|
||||
}
|
||||
|
||||
+8
-6
@@ -200,8 +200,8 @@ object KiloCliDataParser {
|
||||
val file = d.str("file") ?: return@mapNotNull null
|
||||
DiffFileDto(
|
||||
file = file,
|
||||
additions = d.long("additions")?.toInt() ?: 0,
|
||||
deletions = d.long("deletions")?.toInt() ?: 0,
|
||||
additions = d.long("additions")?.safeInt() ?: 0,
|
||||
deletions = d.long("deletions")?.safeInt() ?: 0,
|
||||
patch = d.str("patch"),
|
||||
)
|
||||
} ?: emptyList()
|
||||
@@ -537,9 +537,9 @@ object KiloCliDataParser {
|
||||
),
|
||||
summary = summary?.let {
|
||||
SessionSummaryDto(
|
||||
additions = it.long("additions")?.toInt() ?: 0,
|
||||
deletions = it.long("deletions")?.toInt() ?: 0,
|
||||
files = it.long("files")?.toInt() ?: 0,
|
||||
additions = it.long("additions")?.safeInt() ?: 0,
|
||||
deletions = it.long("deletions")?.safeInt() ?: 0,
|
||||
files = it.long("files")?.safeInt() ?: 0,
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -564,7 +564,7 @@ object KiloCliDataParser {
|
||||
return SessionStatusDto(
|
||||
type = type,
|
||||
message = st.str("message"),
|
||||
attempt = st.long("attempt")?.toInt(),
|
||||
attempt = st.long("attempt")?.safeInt(),
|
||||
next = st.long("next"),
|
||||
requestID = st.str("requestID"),
|
||||
)
|
||||
@@ -679,6 +679,8 @@ private fun JsonObject.num(key: String): Double? =
|
||||
private fun JsonObject.long(key: String): Long? =
|
||||
this[key]?.jsonPrimitive?.longOrNull
|
||||
|
||||
private fun Long.safeInt() = coerceIn(Int.MIN_VALUE.toLong(), Int.MAX_VALUE.toLong()).toInt()
|
||||
|
||||
private fun JsonObject?.map(key: String): Map<String, String> {
|
||||
val obj = this?.get(key)?.jsonObject ?: return emptyMap()
|
||||
return obj.entries.mapNotNull { (name, value) ->
|
||||
|
||||
+5
@@ -71,6 +71,11 @@ class KiloSessionRpcApiImpl : KiloSessionRpcApi {
|
||||
workspaces.get(dir).deleteSession(id)
|
||||
}
|
||||
|
||||
override suspend fun rename(id: String, directory: String, title: String): ai.kilocode.rpc.dto.SessionDto {
|
||||
val dir = sessions.getDirectory(id, directory)
|
||||
return sessions.rename(id, dir, title)
|
||||
}
|
||||
|
||||
override suspend fun cloudSessions(directory: String, cursor: String?, limit: Int, gitUrl: String?): CloudSessionListDto =
|
||||
sessions.cloudSessions(directory, cursor, limit, gitUrl)
|
||||
|
||||
|
||||
+157
@@ -20,6 +20,7 @@ import kotlin.test.AfterTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
@@ -472,6 +473,134 @@ class KiloBackendSessionManagerTest {
|
||||
assertNotNull(result)
|
||||
}
|
||||
|
||||
// ------ Session rename ------
|
||||
|
||||
@Test
|
||||
fun `rename patches session title and returns updated session`() = runBlocking {
|
||||
mock.sessionRenameResponse = """{
|
||||
"id": "ses_1",
|
||||
"slug": "s1",
|
||||
"projectID": "prj_test",
|
||||
"directory": "/test",
|
||||
"title": "New Name",
|
||||
"version": "1.0.0",
|
||||
"time": {"created": 1000, "updated": 2000}
|
||||
}"""
|
||||
val app = setup()
|
||||
ready(app)
|
||||
|
||||
val session = app.sessions.rename("ses_1", "/test", "New Name")
|
||||
|
||||
assertEquals("ses_1", session.id)
|
||||
assertEquals("New Name", session.title)
|
||||
val path = mock.lastSessionRenamePath ?: error("missing rename request")
|
||||
assertTrue(path.startsWith("/session/ses_1?"), "Expected /session/ses_1?... got $path")
|
||||
assertTrue(path.contains("directory=%2Ftest"), "Expected directory=/test in $path")
|
||||
assertEquals("PATCH", mock.lastSessionRenameMethod)
|
||||
assertEquals("""{"title":"New Name"}""", mock.lastSessionRenameBody)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rename response preserves directory parentId summary and timestamps`() = runBlocking {
|
||||
mock.sessionRenameResponse = """{
|
||||
"id": "ses_1",
|
||||
"slug": "s1",
|
||||
"projectID": "prj_test",
|
||||
"directory": "/worktree/path",
|
||||
"title": "Renamed",
|
||||
"version": "2.0.0",
|
||||
"time": {"created": 1000, "updated": 9999},
|
||||
"parentID": "ses_parent",
|
||||
"summary": {"additions": 5, "deletions": 3, "files": 2}
|
||||
}"""
|
||||
val app = setup()
|
||||
ready(app)
|
||||
|
||||
val session = app.sessions.rename("ses_1", "/test", "Renamed")
|
||||
|
||||
assertEquals("/worktree/path", session.directory)
|
||||
assertEquals("ses_parent", session.parentID)
|
||||
assertEquals(1000.0, session.time.created)
|
||||
assertEquals(9999.0, session.time.updated)
|
||||
assertNotNull(session.summary)
|
||||
assertEquals(5, session.summary!!.additions)
|
||||
assertEquals(3, session.summary!!.deletions)
|
||||
assertEquals(2, session.summary!!.files)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rename url-encodes session id and directory for special characters`() = runBlocking {
|
||||
// Session IDs/directories may contain spaces, slashes, plus signs, and ampersands
|
||||
val app = setup()
|
||||
ready(app)
|
||||
|
||||
app.sessions.rename("ses_a/b c", "/my dir/project", "New Name")
|
||||
|
||||
val path = mock.lastSessionRenamePath ?: error("missing rename request")
|
||||
assertTrue(path.startsWith("/session/ses_a%2Fb%20c?"), "Expected encoded session path in $path")
|
||||
assertTrue(path.contains("directory=%2Fmy%20dir%2Fproject"), "Expected encoded directory in $path")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rename url-encodes ampersand plus and query separators`() = runBlocking {
|
||||
val app = setup()
|
||||
ready(app)
|
||||
|
||||
app.sessions.rename("ses_a+b&c?d", "/path?a=1&b=2", "Title")
|
||||
|
||||
val path: String = mock.lastSessionRenamePath ?: error("missing rename request")
|
||||
val bare = path.substringBefore("?")
|
||||
val query = path.substringAfter("?", "")
|
||||
assertTrue(path.contains("/session/ses_a+b&c%3Fd?"), "Unexpected encoded session id: $path")
|
||||
assertFalse(query.contains("/path?a=1&b=2"), "Directory must be encoded as one query value: $query")
|
||||
assertTrue(query.contains("directory=%2Fpath%3Fa%3D1%26b%3D2"), "Unexpected encoded directory: $query")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rename encodes title in json body`() = runBlocking {
|
||||
val app = setup()
|
||||
ready(app)
|
||||
|
||||
app.sessions.rename("ses_1", "/test", "Has \"quotes\" and \\ backslash")
|
||||
|
||||
assertEquals("""{"title":"Has \"quotes\" and \\ backslash"}""", mock.lastSessionRenameBody)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rename encodes title control characters in json body`() = runBlocking {
|
||||
val app = setup()
|
||||
ready(app)
|
||||
|
||||
app.sessions.rename("ses_1", "/test", "Line\nTab\tReturn\rBell\u0007")
|
||||
|
||||
assertEquals("""{"title":"Line\nTab\tReturn\rBell\u0007"}""", mock.lastSessionRenameBody)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rename surfaces server failure`() = runBlocking {
|
||||
mock.sessionRenameStatus = 500
|
||||
mock.sessionRenameResponse = """{"error":"boom"}"""
|
||||
val app = setup()
|
||||
ready(app)
|
||||
|
||||
val err = assertFailsWith<RuntimeException> {
|
||||
app.sessions.rename("ses_1", "/test", "New Name")
|
||||
}
|
||||
|
||||
assertTrue(err.message.orEmpty().contains("HTTP 500"))
|
||||
assertTrue(err.message.orEmpty().contains("boom"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rename throws before start`() = runBlocking {
|
||||
val app = setup()
|
||||
// Don't connect — manager is not started
|
||||
|
||||
assertFailsWith<IllegalStateException> {
|
||||
app.sessions.rename("ses_1", "/test", "Title")
|
||||
}
|
||||
}
|
||||
|
||||
// ------ Session with summary ------
|
||||
|
||||
@Test
|
||||
@@ -496,4 +625,32 @@ class KiloBackendSessionManagerTest {
|
||||
assertEquals(7, session.summary!!.deletions)
|
||||
assertEquals(3, session.summary!!.files)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `status and summary long values clamp to shared dto int range`() = runBlocking {
|
||||
mock.sessions = """[{
|
||||
"id": "ses_big",
|
||||
"slug": "big",
|
||||
"projectID": "prj",
|
||||
"directory": "/d",
|
||||
"title": "Big",
|
||||
"version": "1",
|
||||
"time": {"created": 1, "updated": 1},
|
||||
"summary": {"additions": 2147483648, "deletions": 9223372036854775807, "files": 3}
|
||||
}]"""
|
||||
mock.sessionStatuses = """{
|
||||
"ses_big": {"type":"retry","attempt":2147483648,"message":"retrying","next":9223372036854775807,"requestID":"req"}
|
||||
}"""
|
||||
val app = setup()
|
||||
ready(app)
|
||||
|
||||
val result = app.sessions.list("/d")
|
||||
val session = result.sessions[0]
|
||||
val status = result.statuses["ses_big"] ?: error("missing status")
|
||||
|
||||
assertEquals(Int.MAX_VALUE, session.summary?.additions)
|
||||
assertEquals(Int.MAX_VALUE, session.summary?.deletions)
|
||||
assertEquals(Int.MAX_VALUE, status.attempt)
|
||||
assertEquals(Long.MAX_VALUE, status.next)
|
||||
}
|
||||
}
|
||||
|
||||
+81
-3
@@ -39,7 +39,7 @@ class GeneratedApiModelSerializationTest {
|
||||
|
||||
@Test
|
||||
fun `empty provider list`() {
|
||||
val src = """{"all":[],"default":{},"connected":[]}"""
|
||||
val src = """{"all":[],"default":{},"connected":[],"failed":[]}"""
|
||||
val obj = json.decodeFromString<ProviderList200Response>(src)
|
||||
assertTrue(obj.all.isEmpty())
|
||||
assertTrue(obj.default.isEmpty())
|
||||
@@ -81,7 +81,8 @@ class GeneratedApiModelSerializationTest {
|
||||
}
|
||||
}],
|
||||
"default": {"code": "anthropic/claude-4"},
|
||||
"connected": ["anthropic"]
|
||||
"connected": ["anthropic"],
|
||||
"failed": []
|
||||
}"""
|
||||
val obj = json.decodeFromString<ProviderList200Response>(src)
|
||||
assertEquals(1, obj.all.size)
|
||||
@@ -132,7 +133,8 @@ class GeneratedApiModelSerializationTest {
|
||||
}
|
||||
}],
|
||||
"default": {},
|
||||
"connected": []
|
||||
"connected": [],
|
||||
"failed": []
|
||||
}"""
|
||||
val obj = json.decodeFromString<ProviderList200Response>(src)
|
||||
val model = obj.all[0].models["free-model"]!!
|
||||
@@ -149,12 +151,22 @@ class GeneratedApiModelSerializationTest {
|
||||
"all": [],
|
||||
"default": {},
|
||||
"connected": [],
|
||||
"failed": [],
|
||||
"future_field": "value"
|
||||
}"""
|
||||
val obj = json.decodeFromString<ProviderList200Response>(src)
|
||||
assertTrue(obj.all.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `provider list defaults omitted collection fields`() {
|
||||
val obj = json.decodeFromString<ProviderList200Response>("{}")
|
||||
assertTrue(obj.all.isEmpty())
|
||||
assertTrue(obj.default.isEmpty())
|
||||
assertTrue(obj.connected.isEmpty())
|
||||
assertTrue(obj.failed.isEmpty())
|
||||
}
|
||||
|
||||
// ------ Agent ------
|
||||
|
||||
@Test
|
||||
@@ -287,4 +299,70 @@ class GeneratedApiModelSerializationTest {
|
||||
val list = json.decodeFromString<List<AppSkills200ResponseInner>>("[]")
|
||||
assertTrue(list.isEmpty())
|
||||
}
|
||||
|
||||
// ------ ProviderList200Response.failed non-empty ------
|
||||
|
||||
@Test
|
||||
fun `provider list failed field handles non-empty list`() {
|
||||
val src = """{
|
||||
"all": [],
|
||||
"default": {},
|
||||
"connected": [],
|
||||
"failed": ["openai", "anthropic"]
|
||||
}"""
|
||||
val obj = json.decodeFromString<ProviderList200Response>(src)
|
||||
assertEquals(listOf("openai", "anthropic"), obj.failed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `provider list failed field handles single failed provider`() {
|
||||
val src = """{"all":[],"default":{},"connected":[],"failed":["openai"]}"""
|
||||
val obj = json.decodeFromString<ProviderList200Response>(src)
|
||||
assertEquals(listOf("openai"), obj.failed)
|
||||
}
|
||||
|
||||
// ------ Model limits with realistic large values ------
|
||||
|
||||
@Test
|
||||
fun `model limit context and output deserialize with large values`() {
|
||||
val src = """{
|
||||
"all": [{
|
||||
"id": "p",
|
||||
"name": "Provider",
|
||||
"source": "api",
|
||||
"env": [],
|
||||
"options": {},
|
||||
"models": {
|
||||
"big-model": {
|
||||
"id": "big-model",
|
||||
"providerID": "p",
|
||||
"name": "Big Model",
|
||||
"api": {"id": "p", "url": "", "npm": ""},
|
||||
"capabilities": {
|
||||
"temperature": true,
|
||||
"reasoning": false,
|
||||
"attachment": false,
|
||||
"toolcall": true,
|
||||
"input": {"text": true, "audio": false, "image": false, "video": false, "pdf": false},
|
||||
"output": {"text": true, "audio": false, "image": false, "video": false, "pdf": false},
|
||||
"interleaved": {"field":"reasoning_content"}
|
||||
},
|
||||
"cost": {"input": 0, "output": 0, "cache": {"read": 0, "write": 0}},
|
||||
"limit": {"context": 1000000, "output": 32000},
|
||||
"status": "active",
|
||||
"options": {},
|
||||
"headers": {},
|
||||
"release_date": "2025-01-01"
|
||||
}
|
||||
}
|
||||
}],
|
||||
"default": {},
|
||||
"connected": [],
|
||||
"failed": []
|
||||
}"""
|
||||
val obj = json.decodeFromString<ProviderList200Response>(src)
|
||||
val model = obj.all[0].models["big-model"]!!
|
||||
assertEquals(1000000.0, model.limit.context)
|
||||
assertEquals(32000.0, model.limit.output)
|
||||
}
|
||||
}
|
||||
|
||||
+30
@@ -836,6 +836,21 @@ class KiloCliDataParserTest {
|
||||
assertEquals(5000L, result.status.next)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parseChatEvent - session status clamps large attempt`() {
|
||||
val data = globalEvent("""
|
||||
"type": "session.status",
|
||||
"properties": {
|
||||
"sessionID": "ses_1",
|
||||
"status": {"type": "retry", "message": "Retrying...", "attempt": 2147483648, "next": 9223372036854775807}
|
||||
}
|
||||
""")
|
||||
|
||||
val result = KiloCliDataParser.parseChatEvent("session.status", data) as ChatEventDto.SessionStatusChanged
|
||||
assertEquals(Int.MAX_VALUE, result.status.attempt)
|
||||
assertEquals(Long.MAX_VALUE, result.status.next)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parseChatEvent - session status offline with requestID`() {
|
||||
val data = globalEvent("""
|
||||
@@ -970,6 +985,21 @@ class KiloCliDataParserTest {
|
||||
assertEquals(3, result.diff[0].additions)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parseChatEvent - session diff clamps large counts`() {
|
||||
val data = globalEvent("""
|
||||
"type": "session.diff",
|
||||
"properties": {
|
||||
"sessionID": "ses_1",
|
||||
"diff": [{"file": "src/A.kt", "additions": 2147483648, "deletions": 9223372036854775807, "patch": "@@ ..."}]
|
||||
}
|
||||
""")
|
||||
|
||||
val result = KiloCliDataParser.parseChatEvent("session.diff", data) as ChatEventDto.SessionDiffChanged
|
||||
assertEquals(Int.MAX_VALUE, result.diff[0].additions)
|
||||
assertEquals(Int.MAX_VALUE, result.diff[0].deletions)
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// parseChatEvent — part with callID
|
||||
// ================================================================
|
||||
|
||||
+27
-3
@@ -23,7 +23,7 @@ class ProjectModelSerializationTest {
|
||||
|
||||
@Test
|
||||
fun `empty provider list`() {
|
||||
val src = """{"all":[],"default":{},"connected":[]}"""
|
||||
val src = """{"all":[],"default":{},"connected":[],"failed":[]}"""
|
||||
val obj = json.decodeFromString<ProviderList200Response>(src)
|
||||
assertTrue(obj.all.isEmpty())
|
||||
assertTrue(obj.default.isEmpty())
|
||||
@@ -64,7 +64,8 @@ class ProjectModelSerializationTest {
|
||||
}
|
||||
}],
|
||||
"default": {"code": "anthropic/claude-4"},
|
||||
"connected": ["anthropic"]
|
||||
"connected": ["anthropic"],
|
||||
"failed": []
|
||||
}"""
|
||||
val obj = json.decodeFromString<ProviderList200Response>(src)
|
||||
assertEquals(1, obj.all.size)
|
||||
@@ -115,7 +116,8 @@ class ProjectModelSerializationTest {
|
||||
}
|
||||
}],
|
||||
"default": {},
|
||||
"connected": []
|
||||
"connected": [],
|
||||
"failed": []
|
||||
}"""
|
||||
val obj = json.decodeFromString<ProviderList200Response>(src)
|
||||
val model = obj.all[0].models["free-model"]!!
|
||||
@@ -132,12 +134,20 @@ class ProjectModelSerializationTest {
|
||||
"all": [],
|
||||
"default": {},
|
||||
"connected": [],
|
||||
"failed": [],
|
||||
"future_field": "value"
|
||||
}"""
|
||||
val obj = json.decodeFromString<ProviderList200Response>(src)
|
||||
assertTrue(obj.all.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `provider list defaults omitted failed field`() {
|
||||
val src = """{"all":[],"default":{},"connected":[]}"""
|
||||
val obj = json.decodeFromString<ProviderList200Response>(src)
|
||||
assertTrue(obj.failed.isEmpty())
|
||||
}
|
||||
|
||||
// ------ Agent ------
|
||||
|
||||
@Test
|
||||
@@ -270,4 +280,18 @@ class ProjectModelSerializationTest {
|
||||
val list = json.decodeFromString<List<AppSkills200ResponseInner>>("[]")
|
||||
assertTrue(list.isEmpty())
|
||||
}
|
||||
|
||||
// ------ ProviderList200Response.failed non-empty ------
|
||||
|
||||
@Test
|
||||
fun `provider list failed field handles non-empty list`() {
|
||||
val src = """{
|
||||
"all": [],
|
||||
"default": {},
|
||||
"connected": [],
|
||||
"failed": ["openai", "gemini"]
|
||||
}"""
|
||||
val obj = json.decodeFromString<ProviderList200Response>(src)
|
||||
assertEquals(listOf("openai", "gemini"), obj.failed)
|
||||
}
|
||||
}
|
||||
|
||||
+75
-10
@@ -26,7 +26,7 @@ class SessionModelSerializationTest {
|
||||
"directory": "/test/project",
|
||||
"title": "My Session",
|
||||
"version": "1.0.0",
|
||||
"time": {"created": 1000.0, "updated": 2000.0}
|
||||
"time": {"created": 1000, "updated": 2000}
|
||||
}"""
|
||||
val obj = json.decodeFromString<Session>(src)
|
||||
assertEquals("ses_abc", obj.id)
|
||||
@@ -34,8 +34,8 @@ class SessionModelSerializationTest {
|
||||
assertEquals("prj_123", obj.projectID)
|
||||
assertEquals("/test/project", obj.directory)
|
||||
assertEquals("My Session", obj.title)
|
||||
assertEquals(1000.0, obj.time.created)
|
||||
assertEquals(2000.0, obj.time.updated)
|
||||
assertEquals(1000L, obj.time.created)
|
||||
assertEquals(2000L, obj.time.updated)
|
||||
assertNull(obj.parentID)
|
||||
assertNull(obj.summary)
|
||||
}
|
||||
@@ -49,14 +49,14 @@ class SessionModelSerializationTest {
|
||||
"directory": "/d",
|
||||
"title": "T",
|
||||
"version": "1.0.0",
|
||||
"time": {"created": 1.0, "updated": 2.0},
|
||||
"time": {"created": 1, "updated": 2},
|
||||
"summary": {"additions": 10, "deletions": 5, "files": 3}
|
||||
}"""
|
||||
val obj = json.decodeFromString<Session>(src)
|
||||
assertNotNull(obj.summary)
|
||||
assertEquals(10.0, obj.summary!!.additions)
|
||||
assertEquals(5.0, obj.summary!!.deletions)
|
||||
assertEquals(3.0, obj.summary!!.files)
|
||||
assertEquals(10, obj.summary!!.additions)
|
||||
assertEquals(5, obj.summary!!.deletions)
|
||||
assertEquals(3, obj.summary!!.files)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -68,7 +68,7 @@ class SessionModelSerializationTest {
|
||||
"directory": "/d",
|
||||
"title": "Fork",
|
||||
"version": "1.0.0",
|
||||
"time": {"created": 1.0, "updated": 2.0, "archived": 3000.0},
|
||||
"time": {"created": 1, "updated": 2, "archived": 3000.0},
|
||||
"parentID": "ses_parent"
|
||||
}"""
|
||||
val obj = json.decodeFromString<Session>(src)
|
||||
@@ -132,9 +132,9 @@ class SessionModelSerializationTest {
|
||||
val src = """{"type":"retry","attempt":2,"message":"Rate limited","next":1500,"requestID":"req_1"}"""
|
||||
val obj = json.decodeFromString<SessionStatus>(src)
|
||||
assertEquals(SessionStatus.Type.RETRY, obj.type)
|
||||
assertEquals(2.0, obj.attempt)
|
||||
assertEquals(2L, obj.attempt)
|
||||
assertEquals("Rate limited", obj.message)
|
||||
assertEquals(1500.0, obj.next)
|
||||
assertEquals(1500L, obj.next)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -162,4 +162,69 @@ class SessionModelSerializationTest {
|
||||
val map = json.decodeFromString<Map<String, SessionStatus>>("{}")
|
||||
assertTrue(map.isEmpty())
|
||||
}
|
||||
|
||||
// ------ Large integer (Long) handling ------
|
||||
|
||||
@Test
|
||||
fun `Session time created and updated deserialize as Long for values larger than Int MAX_VALUE`() {
|
||||
val big = Int.MAX_VALUE.toLong() + 12345L
|
||||
val src = """{
|
||||
"id": "ses_big",
|
||||
"slug": "big",
|
||||
"projectID": "prj",
|
||||
"directory": "/d",
|
||||
"title": "Big Times",
|
||||
"version": "1",
|
||||
"time": {"created": $big, "updated": ${big + 1L}}
|
||||
}"""
|
||||
val obj = json.decodeFromString<Session>(src)
|
||||
assertEquals(big, obj.time.created)
|
||||
assertEquals(big + 1L, obj.time.updated)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Session time millisecond epoch timestamps deserialize correctly`() {
|
||||
// Realistic millisecond timestamp: 2026-01-01T00:00:00Z = 1767139200000
|
||||
val ms = 1767139200000L
|
||||
val src = """{
|
||||
"id": "ses_ms",
|
||||
"slug": "ms",
|
||||
"projectID": "prj",
|
||||
"directory": "/d",
|
||||
"title": "MS Times",
|
||||
"version": "1",
|
||||
"time": {"created": $ms, "updated": $ms}
|
||||
}"""
|
||||
val obj = json.decodeFromString<Session>(src)
|
||||
assertEquals(ms, obj.time.created)
|
||||
assertEquals(ms, obj.time.updated)
|
||||
assertTrue(obj.time.created > Int.MAX_VALUE)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Session time defaults to 0L when fields are omitted`() {
|
||||
// The generated model has default = 0L for created and updated
|
||||
val obj = ai.kilocode.jetbrains.api.model.SessionTime()
|
||||
assertEquals(0L, obj.created)
|
||||
assertEquals(0L, obj.updated)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `SessionStatus attempt and next deserialize as Long for large values`() {
|
||||
val bigAttempt = Int.MAX_VALUE.toLong() + 5L
|
||||
val bigNext = Int.MAX_VALUE.toLong() + 99999L
|
||||
val src = """{"type":"retry","attempt":$bigAttempt,"message":"Retrying","next":$bigNext,"requestID":"req_1"}"""
|
||||
val obj = json.decodeFromString<SessionStatus>(src)
|
||||
assertEquals(SessionStatus.Type.RETRY, obj.type)
|
||||
assertEquals(bigAttempt, obj.attempt)
|
||||
assertEquals(bigNext, obj.next)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `SessionStatus defaults attempt and next to 0L when omitted`() {
|
||||
val src = """{"type":"idle","message":"","requestID":""}"""
|
||||
val obj = json.decodeFromString<SessionStatus>(src)
|
||||
assertEquals(0L, obj.attempt)
|
||||
assertEquals(0L, obj.next)
|
||||
}
|
||||
}
|
||||
|
||||
+14
-3
@@ -41,7 +41,7 @@ class MockCliServer : AutoCloseable {
|
||||
@Volatile var notificationsStatus = 200
|
||||
|
||||
// Project-scoped REST responses
|
||||
@Volatile var providers = """{"all":[],"default":{},"connected":[]}"""
|
||||
@Volatile var providers = """{"all":[],"default":{},"connected":[],"failed":[]}"""
|
||||
@Volatile var agents = "[]"
|
||||
@Volatile var commands = "[]"
|
||||
@Volatile var skills = "[]"
|
||||
@@ -72,6 +72,11 @@ class MockCliServer : AutoCloseable {
|
||||
@Volatile var summarizeStatus = 200
|
||||
@Volatile var lastSummarizePath: String? = null
|
||||
@Volatile var lastSummarizeBody: String? = null
|
||||
@Volatile var sessionRenameStatus = 200
|
||||
@Volatile var sessionRenameResponse = """{"id":"ses_test","slug":"test","projectID":"prj_test","directory":"/test","title":"Renamed","version":"1.0.0","time":{"created":1000,"updated":2000}}"""
|
||||
@Volatile var lastSessionRenamePath: String? = null
|
||||
@Volatile var lastSessionRenameBody: String? = null
|
||||
@Volatile var lastSessionRenameMethod: String? = null
|
||||
|
||||
/** Configurable delay for all endpoint responses (ms). 0 = no delay. */
|
||||
@Volatile var responseDelay: Long = 0
|
||||
@@ -239,10 +244,16 @@ class MockCliServer : AutoCloseable {
|
||||
bare == "/session/status" -> respond(output, sessionStatusesStatus, sessionStatuses)
|
||||
bare == "/session" && method == "GET" -> respond(output, sessionsStatus, sessions)
|
||||
bare == "/session" && method == "POST" -> respond(output, sessionCreateStatus, sessionCreate)
|
||||
bare.matches(Regex("/session/ses_[^/]+")) && method == "GET" ->
|
||||
bare.matches(Regex("/session/ses_.+")) && !bare.contains("/summarize") && method == "GET" ->
|
||||
respond(output, sessionGetStatus, sessionCreate)
|
||||
bare.matches(Regex("/session/ses_[^/]+")) && method == "DELETE" ->
|
||||
bare.matches(Regex("/session/ses_.+")) && !bare.contains("/summarize") && method == "DELETE" ->
|
||||
respond(output, sessionDeleteStatus, "true")
|
||||
bare.matches(Regex("/session/ses_.+")) && !bare.contains("/summarize") && method == "PATCH" -> {
|
||||
lastSessionRenamePath = path
|
||||
lastSessionRenameBody = body
|
||||
lastSessionRenameMethod = method
|
||||
respond(output, sessionRenameStatus, sessionRenameResponse)
|
||||
}
|
||||
bare.matches(Regex("/session/ses_[^/]+/summarize")) && method == "POST" -> {
|
||||
lastSummarizePath = path
|
||||
lastSummarizeBody = body
|
||||
|
||||
@@ -218,7 +218,7 @@ abstract class FixGeneratedApiTask : DefaultTask() {
|
||||
}
|
||||
}
|
||||
|
||||
// Fix 7: Default values for non-nullable primitives in model data classes.
|
||||
// Fix 7: Default values for non-nullable primitives and collections in model data classes.
|
||||
// The CLI API may omit fields that the OpenAPI spec marks as required
|
||||
// (e.g. `attachment`, `reasoning` on dynamically added models).
|
||||
// Add Kotlin defaults so kotlinx.serialization doesn't throw
|
||||
@@ -230,6 +230,9 @@ abstract class FixGeneratedApiTask : DefaultTask() {
|
||||
Regex("""(val \w+:\s*kotlin\.Boolean)(,|\n)""") to { m: MatchResult ->
|
||||
"${m.groupValues[1]} = false${m.groupValues[2]}"
|
||||
},
|
||||
Regex("""(val \w+:\s*kotlin\.Long)(,|\n)""") to { m: MatchResult ->
|
||||
"${m.groupValues[1]} = 0L${m.groupValues[2]}"
|
||||
},
|
||||
Regex("""(val \w+:\s*kotlin\.Int)(,|\n)""") to { m: MatchResult ->
|
||||
"${m.groupValues[1]} = 0${m.groupValues[2]}"
|
||||
},
|
||||
@@ -239,6 +242,12 @@ abstract class FixGeneratedApiTask : DefaultTask() {
|
||||
Regex("""(val \w+:\s*kotlin\.String)(,|\n)""") to { m: MatchResult ->
|
||||
"${m.groupValues[1]} = \"\"${m.groupValues[2]}"
|
||||
},
|
||||
Regex("""(val \w+:\s*kotlin\.collections\.List<[^>]+>)(,|\n)""") to { m: MatchResult ->
|
||||
"${m.groupValues[1]} = emptyList()${m.groupValues[2]}"
|
||||
},
|
||||
Regex("""(val \w+:\s*kotlin\.collections\.Map<[^>]+>)(,|\n)""") to { m: MatchResult ->
|
||||
"${m.groupValues[1]} = emptyMap()${m.groupValues[2]}"
|
||||
},
|
||||
)
|
||||
for ((pattern, transform) in primitiveDefaults) {
|
||||
if (pattern.containsMatchIn(text)) {
|
||||
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
package ai.kilocode.client.actions
|
||||
|
||||
import ai.kilocode.client.plugin.KiloBundle
|
||||
import ai.kilocode.client.session.history.HistoryDataKeys
|
||||
import ai.kilocode.client.session.SessionManager
|
||||
import com.intellij.openapi.actionSystem.ActionUpdateThread
|
||||
import com.intellij.openapi.actionSystem.AnAction
|
||||
import com.intellij.openapi.actionSystem.AnActionEvent
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.openapi.ui.Messages
|
||||
|
||||
class DeleteSessionAction : AnAction() {
|
||||
/** Overridable in tests to avoid showing a real modal dialog. */
|
||||
internal var confirm: (project: Project?, msg: String) -> Boolean = { project, msg ->
|
||||
Messages.showYesNoDialog(
|
||||
project,
|
||||
msg,
|
||||
KiloBundle.message("history.delete.confirm.title"),
|
||||
Messages.getWarningIcon(),
|
||||
) == Messages.YES
|
||||
}
|
||||
|
||||
override fun getActionUpdateThread() = ActionUpdateThread.EDT
|
||||
|
||||
override fun update(e: AnActionEvent) {
|
||||
val selection = e.getData(HistoryDataKeys.SELECTION)
|
||||
val manager = e.getData(SessionManager.KEY)
|
||||
e.presentation.isEnabledAndVisible = manager != null &&
|
||||
selection != null &&
|
||||
selection.selectedLocal.isNotEmpty()
|
||||
}
|
||||
|
||||
override fun actionPerformed(e: AnActionEvent) {
|
||||
val selection = e.getData(HistoryDataKeys.SELECTION) ?: return
|
||||
val controller = e.getData(HistoryDataKeys.CONTROLLER) ?: return
|
||||
val items = selection.selectedLocal.filter { !controller.deleting(it) }
|
||||
if (items.isEmpty()) return
|
||||
|
||||
val msg = if (items.size == 1)
|
||||
KiloBundle.message("history.delete.confirm.message", ai.kilocode.client.session.history.title(items[0]))
|
||||
else
|
||||
KiloBundle.message("history.delete.confirm.message.multiple", items.size)
|
||||
|
||||
if (!confirm(e.project, msg)) return
|
||||
items.forEach { controller.delete(it) }
|
||||
}
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package ai.kilocode.client.actions
|
||||
|
||||
import ai.kilocode.client.session.history.HistoryDataKeys
|
||||
import ai.kilocode.client.session.SessionManager
|
||||
import com.intellij.openapi.actionSystem.ActionUpdateThread
|
||||
import com.intellij.openapi.actionSystem.AnAction
|
||||
import com.intellij.openapi.actionSystem.AnActionEvent
|
||||
|
||||
class OpenSessionAction : AnAction() {
|
||||
override fun getActionUpdateThread() = ActionUpdateThread.EDT
|
||||
|
||||
override fun update(e: AnActionEvent) {
|
||||
val selection = e.getData(HistoryDataKeys.SELECTION)
|
||||
val manager = e.getData(SessionManager.KEY)
|
||||
e.presentation.isEnabledAndVisible = manager != null &&
|
||||
selection != null &&
|
||||
(selection.selectedLocal.size == 1 || selection.cloudItems.size == 1)
|
||||
}
|
||||
|
||||
override fun actionPerformed(e: AnActionEvent) {
|
||||
val selection = e.getData(HistoryDataKeys.SELECTION) ?: return
|
||||
val controller = e.getData(HistoryDataKeys.CONTROLLER) ?: return
|
||||
selection.selectedLocal.singleOrNull()?.let { controller.open(it); return }
|
||||
selection.cloudItems.singleOrNull()?.let { controller.open(it) }
|
||||
}
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
package ai.kilocode.client.actions
|
||||
|
||||
import ai.kilocode.client.plugin.KiloBundle
|
||||
import ai.kilocode.client.session.history.HistoryDataKeys
|
||||
import ai.kilocode.client.session.history.title
|
||||
import ai.kilocode.client.session.SessionManager
|
||||
import com.intellij.openapi.actionSystem.ActionUpdateThread
|
||||
import com.intellij.openapi.actionSystem.AnAction
|
||||
import com.intellij.openapi.actionSystem.AnActionEvent
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.openapi.ui.Messages
|
||||
|
||||
class RenameSessionAction : AnAction() {
|
||||
/** Overridable in tests to avoid showing a real modal dialog. */
|
||||
internal var input: (project: Project?, current: String) -> String? = { project, current ->
|
||||
Messages.showInputDialog(
|
||||
project,
|
||||
KiloBundle.message("history.rename.prompt"),
|
||||
KiloBundle.message("history.rename.title"),
|
||||
null,
|
||||
current,
|
||||
null,
|
||||
)
|
||||
}
|
||||
|
||||
override fun getActionUpdateThread() = ActionUpdateThread.EDT
|
||||
|
||||
override fun update(e: AnActionEvent) {
|
||||
val selection = e.getData(HistoryDataKeys.SELECTION)
|
||||
val manager = e.getData(SessionManager.KEY)
|
||||
e.presentation.isEnabledAndVisible = manager != null &&
|
||||
selection != null &&
|
||||
selection.selectedLocal.size == 1
|
||||
}
|
||||
|
||||
override fun actionPerformed(e: AnActionEvent) {
|
||||
val selection = e.getData(HistoryDataKeys.SELECTION) ?: return
|
||||
val controller = e.getData(HistoryDataKeys.CONTROLLER) ?: return
|
||||
val item = selection.selectedLocal.singleOrNull() ?: return
|
||||
|
||||
val current = title(item)
|
||||
val newTitle = input(e.project, current)?.trim() ?: return
|
||||
|
||||
if (newTitle.isBlank() || newTitle == current) return
|
||||
controller.rename(item, newTitle)
|
||||
}
|
||||
}
|
||||
+6
@@ -124,6 +124,12 @@ class KiloSessionService internal constructor(
|
||||
list(dir)
|
||||
}
|
||||
|
||||
suspend fun renameSession(id: String, dir: String, newTitle: String): ai.kilocode.rpc.dto.SessionDto {
|
||||
val session = call { rename(id, dir, newTitle) }
|
||||
_sessions.value = _sessions.value.map { if (it.id == id) session else it }
|
||||
return session
|
||||
}
|
||||
|
||||
suspend fun cloudSessions(dir: String, cursor: String?, limit: Int, gitUrl: String?): CloudSessionListDto =
|
||||
call { cloudSessions(dir, cursor, limit, gitUrl) }
|
||||
|
||||
|
||||
+1
-1
@@ -113,7 +113,7 @@ class SessionSidePanelManager(
|
||||
deleted = this::removeSession,
|
||||
)
|
||||
Disposer.register(this) { cs.cancel() }
|
||||
return HistoryPanel(this, controller, nav = this::back).component
|
||||
return HistoryPanel(this, controller, nav = this::back, manager = this).component
|
||||
}
|
||||
|
||||
private fun back() {
|
||||
|
||||
+14
-1
@@ -61,9 +61,10 @@ class HistoryController(
|
||||
if (item.id in deleting) return@edt
|
||||
deleting.add(item.id)
|
||||
local.refresh()
|
||||
val dir = item.directory ?: workspace.directory
|
||||
cs.launch {
|
||||
try {
|
||||
sessions.deleteSession(item.id, item.directory ?: workspace.directory)
|
||||
sessions.deleteSession(item.id, dir)
|
||||
edt {
|
||||
deleting.remove(item.id)
|
||||
local.remove(item.id)
|
||||
@@ -79,6 +80,18 @@ class HistoryController(
|
||||
}
|
||||
}
|
||||
|
||||
fun rename(item: LocalHistoryItem, title: String) {
|
||||
val dir = item.directory ?: workspace.directory
|
||||
cs.launch {
|
||||
try {
|
||||
val updated = sessions.renameSession(item.id, dir, title)
|
||||
edt { local.update(LocalHistoryItem(updated)) }
|
||||
} catch (e: Exception) {
|
||||
edt { local.fail(e.message ?: KiloBundle.message("history.error.local.rename")) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun deleting(item: LocalHistoryItem): Boolean = item.id in deleting
|
||||
|
||||
fun open(item: LocalHistoryItem) {
|
||||
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package ai.kilocode.client.session.history
|
||||
|
||||
import com.intellij.openapi.actionSystem.DataKey
|
||||
|
||||
data class HistorySelection(
|
||||
val source: HistorySource,
|
||||
val localItems: List<LocalHistoryItem>,
|
||||
val cloudItems: List<CloudHistoryItem>,
|
||||
) {
|
||||
val selectedLocal: List<LocalHistoryItem> get() = if (source == HistorySource.LOCAL) localItems else emptyList()
|
||||
}
|
||||
|
||||
object HistoryDataKeys {
|
||||
val SELECTION: DataKey<HistorySelection> = DataKey.create("ai.kilocode.client.session.history.HistorySelection")
|
||||
val CONTROLLER: DataKey<HistoryController> = DataKey.create("ai.kilocode.client.session.history.HistoryController")
|
||||
}
|
||||
+10
-20
@@ -3,7 +3,6 @@ package ai.kilocode.client.session.history
|
||||
import ai.kilocode.client.session.ui.PickerRow
|
||||
import ai.kilocode.client.ui.UiStyle
|
||||
import com.intellij.icons.AllIcons
|
||||
import com.intellij.ui.ExperimentalUI
|
||||
import com.intellij.ui.GroupHeaderSeparator
|
||||
import com.intellij.ui.SimpleColoredComponent
|
||||
import com.intellij.ui.SimpleTextAttributes
|
||||
@@ -20,7 +19,7 @@ import javax.swing.JPanel
|
||||
import javax.swing.ListCellRenderer
|
||||
import javax.swing.SwingConstants
|
||||
|
||||
private const val DELETE_CLICK_AREA_WIDTH = 32
|
||||
private const val DELETE_AREA_WIDTH = 32
|
||||
|
||||
internal open class HistoryRenderer<T : HistoryItem>(
|
||||
private val model: HistoryModel<T>,
|
||||
@@ -31,14 +30,14 @@ internal open class HistoryRenderer<T : HistoryItem>(
|
||||
private val empty: Icon = EmptyIcon.create(icon)
|
||||
|
||||
fun isDeleteClick(list: JList<*>, bounds: Rectangle, point: Point): Boolean {
|
||||
val width = JBUI.scale(DELETE_CLICK_AREA_WIDTH)
|
||||
val inset = deleteInset(list)
|
||||
if (list.componentOrientation.isLeftToRight) {
|
||||
val right = bounds.x + bounds.width - inset
|
||||
return point.x in (right - width)..right
|
||||
val width = JBUI.scale(DELETE_AREA_WIDTH)
|
||||
return if (list.componentOrientation.isLeftToRight) {
|
||||
val right = bounds.x + bounds.width
|
||||
point.x in (right - width)..right
|
||||
} else {
|
||||
val left = bounds.x
|
||||
point.x in left..(left + width)
|
||||
}
|
||||
val left = bounds.x + inset
|
||||
return point.x in left..(left + width)
|
||||
}
|
||||
|
||||
fun section(items: List<HistoryItem>, index: Int): String? {
|
||||
@@ -48,13 +47,6 @@ internal open class HistoryRenderer<T : HistoryItem>(
|
||||
if (current == previous) return null
|
||||
return HistoryTime.title(current)
|
||||
}
|
||||
|
||||
private fun deleteInset(list: JList<*>): Int {
|
||||
if (!ExperimentalUI.isNewUI()) return 0
|
||||
val inner = JBUI.CurrentTheme.Popup.Selection.innerInsets()
|
||||
val edge = JBUI.CurrentTheme.Popup.Selection.LEFT_RIGHT_INSET.get()
|
||||
return edge + if (list.componentOrientation.isLeftToRight) inner.right else inner.left
|
||||
}
|
||||
}
|
||||
|
||||
private val sep = GroupHeaderSeparator(JBUI.CurrentTheme.Popup.separatorLabelInsets())
|
||||
@@ -75,7 +67,7 @@ internal open class HistoryRenderer<T : HistoryItem>(
|
||||
}
|
||||
private val row = JPanel(BorderLayout()).apply {
|
||||
add(main, BorderLayout.CENTER)
|
||||
add(del, BorderLayout.EAST)
|
||||
if (deletable) add(del, BorderLayout.EAST)
|
||||
}
|
||||
private val wrap = PickerRow()
|
||||
|
||||
@@ -114,13 +106,11 @@ internal open class HistoryRenderer<T : HistoryItem>(
|
||||
)
|
||||
time.text = value?.let(HistoryTime::relative).orEmpty()
|
||||
time.foreground = weak
|
||||
del.icon = if (deletable && selected) icon else empty
|
||||
if (deletable) del.icon = if (selected) icon else empty
|
||||
|
||||
top.invalidate()
|
||||
return this
|
||||
}
|
||||
|
||||
fun deleteVisible(): Boolean = del.icon === icon
|
||||
}
|
||||
|
||||
internal class LocalHistoryRenderer(model: HistoryModel<LocalHistoryItem>) : HistoryRenderer<LocalHistoryItem>(model, deletable = true)
|
||||
|
||||
+1
-2
@@ -6,10 +6,9 @@ import java.awt.event.MouseEvent
|
||||
|
||||
internal fun title(item: HistoryItem): String = item.title.takeIf { it.isNotBlank() } ?: KiloBundle.message("history.untitled")
|
||||
|
||||
internal fun <T> clicked(list: JBList<T>, e: MouseEvent): T? {
|
||||
internal fun <T> itemAt(list: JBList<T>, e: MouseEvent): T? {
|
||||
val row = list.locationToIndex(e.point)
|
||||
val box = row.takeIf { it >= 0 }?.let { list.getCellBounds(it, it) } ?: return null
|
||||
if (!box.contains(e.point)) return null
|
||||
list.selectedIndex = row
|
||||
return list.model.getElementAt(row)
|
||||
}
|
||||
|
||||
+6
@@ -48,6 +48,12 @@ open class HistoryModel<T : HistoryItem> : AbstractListModel<T>() {
|
||||
filter()
|
||||
}
|
||||
|
||||
fun update(item: T) {
|
||||
if (all.none { it.id == item.id }) return
|
||||
all = HistoryTime.sorted(all.map { if (it.id == item.id) item else it })
|
||||
filter()
|
||||
}
|
||||
|
||||
fun fail(message: String) {
|
||||
loading = false
|
||||
loaded = true
|
||||
|
||||
+73
-43
@@ -1,6 +1,7 @@
|
||||
package ai.kilocode.client.session.history
|
||||
|
||||
import ai.kilocode.client.plugin.KiloBundle
|
||||
import ai.kilocode.client.session.SessionManager
|
||||
import ai.kilocode.client.session.ui.LoadingPanel
|
||||
import ai.kilocode.client.session.ui.style.SessionEditorStyle
|
||||
import ai.kilocode.client.ui.UiStyle
|
||||
@@ -9,10 +10,13 @@ import com.intellij.ide.ui.LafManagerListener
|
||||
import com.intellij.ide.ui.laf.darcula.ui.DarculaButtonUI
|
||||
import com.intellij.openapi.application.ApplicationManager
|
||||
import com.intellij.openapi.Disposable
|
||||
import com.intellij.openapi.ui.Messages
|
||||
import com.intellij.openapi.actionSystem.ActionGroup
|
||||
import com.intellij.openapi.actionSystem.ActionManager
|
||||
import com.intellij.openapi.actionSystem.ActionPlaces
|
||||
import com.intellij.openapi.actionSystem.DataProvider
|
||||
import com.intellij.openapi.util.Disposer
|
||||
import com.intellij.ui.DocumentAdapter
|
||||
import com.intellij.ui.ListUtil
|
||||
import com.intellij.ui.PopupHandler
|
||||
import com.intellij.ui.SearchTextField
|
||||
import com.intellij.ui.ScrollingUtil
|
||||
import com.intellij.ui.components.JBList
|
||||
@@ -51,7 +55,8 @@ class HistoryPanel(
|
||||
private val controller: HistoryController,
|
||||
private val gitUrl: () -> String? = { null },
|
||||
private val nav: () -> Unit = {},
|
||||
) : BorderLayoutPanel(), Disposable {
|
||||
private val manager: SessionManager? = null,
|
||||
) : BorderLayoutPanel(), Disposable, DataProvider {
|
||||
private val localSearch = search(controller.local)
|
||||
private val cloudSearch = search(controller.cloud)
|
||||
private val localList = localList()
|
||||
@@ -188,21 +193,22 @@ class HistoryPanel(
|
||||
}
|
||||
|
||||
private fun localList() = JBList(controller.local).apply {
|
||||
selectionMode = ListSelectionModel.SINGLE_SELECTION
|
||||
isFocusable = false
|
||||
selectionMode = ListSelectionModel.MULTIPLE_INTERVAL_SELECTION
|
||||
isFocusable = true
|
||||
cellRenderer = LocalHistoryRenderer(controller.local)
|
||||
cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)
|
||||
emptyText.text = KiloBundle.message("history.empty")
|
||||
addMouseListener(object : MouseAdapter() {
|
||||
override fun mouseReleased(e: MouseEvent) {
|
||||
if (!UIUtil.isActionClick(e, MouseEvent.MOUSE_RELEASED, true)) return
|
||||
val item = clicked(this@apply, e) ?: return
|
||||
if (deleteClick(this@apply, e)) {
|
||||
override fun mouseClicked(e: MouseEvent) {
|
||||
val row = locationToIndex(e.point)
|
||||
val box = row.takeIf { it >= 0 }?.let { getCellBounds(it, it) } ?: return
|
||||
if (!box.contains(e.point)) return
|
||||
if (e.clickCount == 1 && HistoryRenderer.isDeleteClick(this@apply, box, e.point)) {
|
||||
val item = model.getElementAt(row)
|
||||
confirm(item)
|
||||
e.consume()
|
||||
return
|
||||
} else if (e.clickCount == 2) {
|
||||
selectedValue?.let(::activate)
|
||||
}
|
||||
activate(item)
|
||||
}
|
||||
})
|
||||
registerKeyboardAction(
|
||||
@@ -210,20 +216,19 @@ class HistoryPanel(
|
||||
KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, 0),
|
||||
JComponent.WHEN_FOCUSED,
|
||||
)
|
||||
ListUtil.installAutoSelectOnMouseMove(this)
|
||||
installContextMenu(this)
|
||||
ScrollingUtil.installActions(this)
|
||||
}
|
||||
|
||||
private fun cloudList() = JBList(controller.cloud).apply {
|
||||
selectionMode = ListSelectionModel.SINGLE_SELECTION
|
||||
isFocusable = false
|
||||
isFocusable = true
|
||||
cellRenderer = CloudHistoryRenderer(controller.cloud)
|
||||
cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)
|
||||
emptyText.text = KiloBundle.message("history.empty")
|
||||
addMouseListener(object : MouseAdapter() {
|
||||
override fun mouseReleased(e: MouseEvent) {
|
||||
if (!UIUtil.isActionClick(e, MouseEvent.MOUSE_RELEASED, true)) return
|
||||
clicked(this@apply, e)?.let(::activate)
|
||||
override fun mouseClicked(e: MouseEvent) {
|
||||
if (e.clickCount == 2) selectedValue?.let(::activate)
|
||||
}
|
||||
})
|
||||
registerKeyboardAction(
|
||||
@@ -231,7 +236,7 @@ class HistoryPanel(
|
||||
KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, 0),
|
||||
JComponent.WHEN_FOCUSED,
|
||||
)
|
||||
ListUtil.installAutoSelectOnMouseMove(this)
|
||||
installContextMenu(this)
|
||||
ScrollingUtil.installActions(this)
|
||||
}
|
||||
|
||||
@@ -272,13 +277,6 @@ class HistoryPanel(
|
||||
}
|
||||
}
|
||||
|
||||
private fun deleteClick(list: JBList<LocalHistoryItem>, e: MouseEvent): Boolean {
|
||||
val row = list.locationToIndex(e.point)
|
||||
val box = row.takeIf { it >= 0 }?.let { list.getCellBounds(it, it) } ?: return false
|
||||
if (!box.contains(e.point)) return false
|
||||
return HistoryRenderer.isDeleteClick(list, box, e.point)
|
||||
}
|
||||
|
||||
private fun activate(item: HistoryItem) {
|
||||
when (item) {
|
||||
is LocalHistoryItem -> controller.open(item)
|
||||
@@ -286,18 +284,54 @@ class HistoryPanel(
|
||||
}
|
||||
}
|
||||
|
||||
override fun getData(dataId: String): Any? {
|
||||
if (SessionManager.KEY.`is`(dataId)) return manager
|
||||
if (HistoryDataKeys.CONTROLLER.`is`(dataId)) return controller
|
||||
if (HistoryDataKeys.SELECTION.`is`(dataId)) {
|
||||
val source = selectedSource()
|
||||
val local = if (source == HistorySource.LOCAL) localList.selectedValuesList.filterIsInstance<LocalHistoryItem>() else emptyList()
|
||||
val cloud = if (source == HistorySource.CLOUD) cloudList.selectedValuesList.filterIsInstance<CloudHistoryItem>() else emptyList()
|
||||
return HistorySelection(source, local, cloud)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun installContextMenu(list: JBList<out HistoryItem>) {
|
||||
val group = ActionManager.getInstance().getAction("Kilo.History.ContextMenu")
|
||||
if (group is ActionGroup) {
|
||||
PopupHandler.installPopupMenu(list, group, ActionPlaces.POPUP)
|
||||
}
|
||||
}
|
||||
|
||||
private fun confirm(item: LocalHistoryItem) {
|
||||
if (controller.deleting(item)) return
|
||||
val result = Messages.showYesNoDialog(
|
||||
val result = com.intellij.openapi.ui.Messages.showYesNoDialog(
|
||||
this,
|
||||
KiloBundle.message("history.delete.confirm.message", title(item)),
|
||||
KiloBundle.message("history.delete.confirm.title"),
|
||||
Messages.getWarningIcon(),
|
||||
com.intellij.openapi.ui.Messages.getWarningIcon(),
|
||||
)
|
||||
if (result != Messages.YES) return
|
||||
if (result != com.intellij.openapi.ui.Messages.YES) return
|
||||
controller.delete(item)
|
||||
}
|
||||
|
||||
internal fun confirmDelete(items: List<LocalHistoryItem>) {
|
||||
val active = items.filter { !controller.deleting(it) }
|
||||
if (active.isEmpty()) return
|
||||
val msg = if (active.size == 1)
|
||||
KiloBundle.message("history.delete.confirm.message", title(active[0]))
|
||||
else
|
||||
KiloBundle.message("history.delete.confirm.message.multiple", active.size)
|
||||
val result = com.intellij.openapi.ui.Messages.showYesNoDialog(
|
||||
this,
|
||||
msg,
|
||||
KiloBundle.message("history.delete.confirm.title"),
|
||||
com.intellij.openapi.ui.Messages.getWarningIcon(),
|
||||
)
|
||||
if (result != com.intellij.openapi.ui.Messages.YES) return
|
||||
active.forEach { controller.delete(it) }
|
||||
}
|
||||
|
||||
internal fun itemCount() = activeModel().size
|
||||
|
||||
internal fun selectedSource() = if (tabs.selectedInfo === cloudInfo) HistorySource.CLOUD else HistorySource.LOCAL
|
||||
@@ -306,10 +340,18 @@ class HistoryPanel(
|
||||
activeList().selectedIndex = index
|
||||
}
|
||||
|
||||
internal fun selectIndices(vararg indices: Int) {
|
||||
activeList().selectedIndices = indices
|
||||
}
|
||||
|
||||
internal fun selectedIndex() = activeList().selectedIndex
|
||||
|
||||
internal fun listFocusable() = activeList().isFocusable
|
||||
|
||||
internal fun listSelectionMode() = activeList().selectionMode
|
||||
|
||||
internal fun loadMoreFocusable() = more.isFocusable
|
||||
|
||||
internal fun listCursor() = activeList().cursor.type
|
||||
|
||||
internal fun backText(): String? {
|
||||
@@ -328,7 +370,8 @@ class HistoryPanel(
|
||||
}
|
||||
|
||||
internal fun clickDelete() {
|
||||
localList.selectedValue?.let(controller::delete)
|
||||
val items = localList.selectedValuesList.filterIsInstance<LocalHistoryItem>()
|
||||
items.forEach { controller.delete(it) }
|
||||
}
|
||||
|
||||
internal fun clickCloud() {
|
||||
@@ -354,18 +397,6 @@ class HistoryPanel(
|
||||
return items.indices.mapNotNull { HistoryRenderer.section(items, it) }
|
||||
}
|
||||
|
||||
internal fun deleteVisible(index: Int, selected: Boolean = true): Boolean {
|
||||
val item = controller.local.getElementAt(index)
|
||||
val view = localList.cellRenderer.getListCellRendererComponent(localList, item, index, selected, false)
|
||||
return view is HistoryRenderer<*> && view.deleteVisible()
|
||||
}
|
||||
|
||||
internal fun cloudDeleteVisible(index: Int, selected: Boolean = true): Boolean {
|
||||
val item = controller.cloud.getElementAt(index)
|
||||
val view = cloudList.cellRenderer.getListCellRendererComponent(cloudList, item, index, selected, false)
|
||||
return view is HistoryRenderer<*> && view.deleteVisible()
|
||||
}
|
||||
|
||||
private fun activeList(): JBList<out HistoryItem> = if (tabs.selectedInfo === cloudInfo) cloudList else localList
|
||||
|
||||
private fun activeModel(): HistoryModel<out HistoryItem> = if (tabs.selectedInfo === cloudInfo) controller.cloud else controller.local
|
||||
@@ -392,8 +423,7 @@ class HistoryPanel(
|
||||
private var over = false
|
||||
|
||||
init {
|
||||
isFocusable = false
|
||||
setRequestFocusEnabled(false)
|
||||
isFocusable = true
|
||||
isContentAreaFilled = false
|
||||
isBorderPainted = false
|
||||
isOpaque = false
|
||||
|
||||
+2
-2
@@ -4,7 +4,7 @@ import ai.kilocode.client.plugin.KiloBundle
|
||||
import ai.kilocode.client.session.SessionRef
|
||||
import ai.kilocode.client.session.history.HistoryTime
|
||||
import ai.kilocode.client.session.history.LocalHistoryItem
|
||||
import ai.kilocode.client.session.history.clicked
|
||||
import ai.kilocode.client.session.history.itemAt
|
||||
import ai.kilocode.client.session.history.title
|
||||
import ai.kilocode.client.session.ui.style.SessionEditorStyle
|
||||
import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget
|
||||
@@ -71,7 +71,7 @@ class EmptySessionPanel(
|
||||
emptyText.clear()
|
||||
addMouseListener(object : MouseAdapter() {
|
||||
override fun mouseClicked(e: MouseEvent) {
|
||||
val item = clicked(this@apply, e) ?: return
|
||||
val item = itemAt(this@apply, e) ?: return
|
||||
controller.openSession(SessionRef.Local(item.session))
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -108,7 +108,7 @@ class SessionHeaderPanel(
|
||||
add(output)
|
||||
add(Box.createHorizontalStrut(UiStyle.Gap.sm()))
|
||||
add(cacheRead)
|
||||
add(Box.createHorizontalStrut(UiStyle.Gap.small()))
|
||||
add(Box.createHorizontalStrut(UiStyle.Gap.sm()))
|
||||
add(cacheWrite)
|
||||
}
|
||||
private val todoRow = JPanel(FlowLayout(FlowLayout.LEFT, UiStyle.Gap.md(), 0)).apply {
|
||||
|
||||
@@ -58,5 +58,25 @@
|
||||
<keyboard-shortcut keymap="$default"
|
||||
first-keystroke="alt S"/>
|
||||
</action>
|
||||
|
||||
<action id="Kilo.Session.Open"
|
||||
class="ai.kilocode.client.actions.OpenSessionAction">
|
||||
<keyboard-shortcut keymap="$default" first-keystroke="ENTER"/>
|
||||
</action>
|
||||
|
||||
<action id="Kilo.Session.Rename"
|
||||
class="ai.kilocode.client.actions.RenameSessionAction"
|
||||
use-shortcut-of="RenameElement"/>
|
||||
|
||||
<action id="Kilo.Session.Delete"
|
||||
class="ai.kilocode.client.actions.DeleteSessionAction"
|
||||
use-shortcut-of="$Delete"/>
|
||||
|
||||
<group id="Kilo.History.ContextMenu">
|
||||
<reference ref="Kilo.Session.Open"/>
|
||||
<separator/>
|
||||
<reference ref="Kilo.Session.Rename"/>
|
||||
<reference ref="Kilo.Session.Delete"/>
|
||||
</group>
|
||||
</actions>
|
||||
</idea-plugin>
|
||||
|
||||
@@ -92,6 +92,9 @@ history.untitled=Untitled
|
||||
history.delete.text=Delete
|
||||
history.delete.confirm.title=Delete session?
|
||||
history.delete.confirm.message=Delete "{0}" from local history?
|
||||
history.delete.confirm.message.multiple=Delete {0} sessions from local history?
|
||||
history.rename.title=Rename Session
|
||||
history.rename.prompt=New session name:
|
||||
history.cloud.load.more=Load more
|
||||
history.cloud.repo.only=Only this repository
|
||||
history.group.today=Today
|
||||
@@ -108,6 +111,7 @@ history.time.years={0}y ago
|
||||
history.error.local=Failed to load local history
|
||||
history.error.cloud=Failed to load cloud history
|
||||
history.error.local.delete=Failed to delete session
|
||||
history.error.local.rename=Failed to rename session
|
||||
history.error.cloud.delete=Cloud sessions cannot be deleted yet
|
||||
|
||||
action.Kilo.Settings.text=Settings
|
||||
@@ -126,3 +130,10 @@ action.Kilo.Restart.text=Restart Kilo
|
||||
action.Kilo.Restart.description=Kill and restart the CLI process
|
||||
action.Kilo.Reinstall.text=Reinstall Kilo
|
||||
action.Kilo.Reinstall.description=Re-extract the CLI binary and restart
|
||||
action.Kilo.Session.Open.text=Open
|
||||
action.Kilo.Session.Open.description=Open the selected session
|
||||
action.Kilo.Session.Rename.text=Rename
|
||||
action.Kilo.Session.Rename.description=Rename the selected session
|
||||
action.Kilo.Session.Delete.text=Delete
|
||||
action.Kilo.Session.Delete.description=Delete the selected session(s)
|
||||
action.Kilo.History.ContextMenu.text=History Actions
|
||||
|
||||
+488
@@ -0,0 +1,488 @@
|
||||
package ai.kilocode.client.actions
|
||||
|
||||
import ai.kilocode.client.app.KiloSessionService
|
||||
import ai.kilocode.client.app.KiloWorkspaceService
|
||||
import ai.kilocode.client.app.Workspace
|
||||
import ai.kilocode.client.plugin.KiloBundle
|
||||
import ai.kilocode.client.session.SessionManager
|
||||
import ai.kilocode.client.session.SessionRef
|
||||
import ai.kilocode.client.session.history.CloudHistoryItem
|
||||
import ai.kilocode.client.session.history.HistoryController
|
||||
import ai.kilocode.client.session.history.HistoryDataKeys
|
||||
import ai.kilocode.client.session.history.HistorySelection
|
||||
import ai.kilocode.client.session.history.HistorySource
|
||||
import ai.kilocode.client.session.history.LocalHistoryItem
|
||||
import ai.kilocode.client.testing.FakeSessionRpcApi
|
||||
import ai.kilocode.client.testing.FakeWorkspaceRpcApi
|
||||
import ai.kilocode.rpc.dto.CloudSessionDto
|
||||
import ai.kilocode.rpc.dto.KiloWorkspaceStateDto
|
||||
import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto
|
||||
import ai.kilocode.rpc.dto.SessionDto
|
||||
import ai.kilocode.rpc.dto.SessionTimeDto
|
||||
import com.intellij.openapi.actionSystem.AnActionEvent
|
||||
import com.intellij.openapi.actionSystem.CommonDataKeys
|
||||
import com.intellij.openapi.actionSystem.DataContext
|
||||
import com.intellij.openapi.actionSystem.Presentation
|
||||
import com.intellij.openapi.application.ApplicationManager
|
||||
import com.intellij.openapi.util.Disposer
|
||||
import com.intellij.testFramework.fixtures.BasePlatformTestCase
|
||||
import com.intellij.util.ui.UIUtil
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
|
||||
@Suppress("UnstableApiUsage")
|
||||
class HistorySessionActionsTest : BasePlatformTestCase() {
|
||||
private lateinit var scope: CoroutineScope
|
||||
private lateinit var rpc: FakeSessionRpcApi
|
||||
private lateinit var sessions: KiloSessionService
|
||||
private lateinit var workspace: Workspace
|
||||
private lateinit var controller: HistoryController
|
||||
private lateinit var manager: FakeManager
|
||||
|
||||
override fun setUp() {
|
||||
super.setUp()
|
||||
scope = CoroutineScope(SupervisorJob())
|
||||
rpc = FakeSessionRpcApi()
|
||||
sessions = KiloSessionService(project, scope, rpc)
|
||||
val workspaces = KiloWorkspaceService(scope, FakeWorkspaceRpcApi().also {
|
||||
it.state.value = KiloWorkspaceStateDto(status = KiloWorkspaceStatusDto.READY)
|
||||
})
|
||||
workspace = workspaces.workspace("/test")
|
||||
controller = HistoryController(sessions, workspace, scope)
|
||||
manager = FakeManager()
|
||||
}
|
||||
|
||||
override fun tearDown() {
|
||||
try {
|
||||
scope.cancel()
|
||||
} finally {
|
||||
super.tearDown()
|
||||
}
|
||||
}
|
||||
|
||||
// ------ OpenSessionAction.update ------
|
||||
|
||||
fun `test open action enabled for single local selection`() {
|
||||
val action = OpenSessionAction()
|
||||
val local = localItem("ses_1")
|
||||
val event = event(action, manager, selection(HistorySource.LOCAL, listOf(local)), controller)
|
||||
|
||||
action.update(event)
|
||||
|
||||
assertTrue(event.presentation.isEnabledAndVisible)
|
||||
}
|
||||
|
||||
fun `test open action enabled for single cloud selection`() {
|
||||
val action = OpenSessionAction()
|
||||
val item = cloudItem("cloud_1")
|
||||
val event = event(action, manager, selection(HistorySource.CLOUD, emptyList(), listOf(item)), controller)
|
||||
|
||||
action.update(event)
|
||||
|
||||
assertTrue(event.presentation.isEnabledAndVisible)
|
||||
}
|
||||
|
||||
fun `test open action disabled without manager`() {
|
||||
val action = OpenSessionAction()
|
||||
val local = localItem("ses_1")
|
||||
val event = event(action, null, selection(HistorySource.LOCAL, listOf(local)), controller)
|
||||
|
||||
action.update(event)
|
||||
|
||||
assertFalse(event.presentation.isEnabledAndVisible)
|
||||
}
|
||||
|
||||
fun `test open action disabled with no selection`() {
|
||||
val action = OpenSessionAction()
|
||||
val event = event(action, manager, selection(HistorySource.LOCAL, emptyList()), controller)
|
||||
|
||||
action.update(event)
|
||||
|
||||
assertFalse(event.presentation.isEnabledAndVisible)
|
||||
}
|
||||
|
||||
fun `test open action disabled with multiple local items`() {
|
||||
val action = OpenSessionAction()
|
||||
val items = listOf(localItem("ses_1"), localItem("ses_2"))
|
||||
val event = event(action, manager, selection(HistorySource.LOCAL, items), controller)
|
||||
|
||||
action.update(event)
|
||||
|
||||
assertFalse(event.presentation.isEnabledAndVisible)
|
||||
}
|
||||
|
||||
// ------ OpenSessionAction.actionPerformed ------
|
||||
|
||||
fun `test open action performs opens local item`() {
|
||||
val opened = mutableListOf<String>()
|
||||
val ctrl = HistoryController(sessions, workspace, scope, open = { ref ->
|
||||
when (ref) {
|
||||
is SessionRef.Local -> opened.add(ref.id)
|
||||
is SessionRef.Cloud -> opened.add("cloud:${ref.id}")
|
||||
}
|
||||
})
|
||||
val local = localItem("ses_1")
|
||||
val action = OpenSessionAction()
|
||||
val event = event(action, manager, selection(HistorySource.LOCAL, listOf(local)), ctrl)
|
||||
|
||||
action.actionPerformed(event)
|
||||
flush()
|
||||
|
||||
assertEquals(listOf("ses_1"), opened)
|
||||
}
|
||||
|
||||
fun `test open action performs opens cloud item`() {
|
||||
val opened = mutableListOf<String>()
|
||||
val ctrl = HistoryController(sessions, workspace, scope, open = { ref ->
|
||||
when (ref) {
|
||||
is SessionRef.Local -> opened.add(ref.id)
|
||||
is SessionRef.Cloud -> opened.add("cloud:${ref.id}")
|
||||
}
|
||||
})
|
||||
val item = cloudItem("cloud_1")
|
||||
val action = OpenSessionAction()
|
||||
val event = event(action, manager, selection(HistorySource.CLOUD, emptyList(), listOf(item)), ctrl)
|
||||
|
||||
action.actionPerformed(event)
|
||||
flush()
|
||||
|
||||
assertEquals(listOf("cloud:cloud_1"), opened)
|
||||
}
|
||||
|
||||
// ------ DeleteSessionAction.update ------
|
||||
|
||||
fun `test delete action enabled for non-empty local selection`() {
|
||||
val action = DeleteSessionAction()
|
||||
val local = localItem("ses_1")
|
||||
val event = event(action, manager, selection(HistorySource.LOCAL, listOf(local)), controller)
|
||||
|
||||
action.update(event)
|
||||
|
||||
assertTrue(event.presentation.isEnabledAndVisible)
|
||||
}
|
||||
|
||||
fun `test delete action disabled for cloud-only selection`() {
|
||||
val action = DeleteSessionAction()
|
||||
val item = cloudItem("cloud_1")
|
||||
val event = event(action, manager, selection(HistorySource.CLOUD, emptyList(), listOf(item)), controller)
|
||||
|
||||
action.update(event)
|
||||
|
||||
assertFalse(event.presentation.isEnabledAndVisible)
|
||||
}
|
||||
|
||||
fun `test delete action disabled without manager`() {
|
||||
val action = DeleteSessionAction()
|
||||
val local = localItem("ses_1")
|
||||
val event = event(action, null, selection(HistorySource.LOCAL, listOf(local)), controller)
|
||||
|
||||
action.update(event)
|
||||
|
||||
assertFalse(event.presentation.isEnabledAndVisible)
|
||||
}
|
||||
|
||||
fun `test delete action disabled with empty selection`() {
|
||||
val action = DeleteSessionAction()
|
||||
val event = event(action, manager, selection(HistorySource.LOCAL, emptyList()), controller)
|
||||
|
||||
action.update(event)
|
||||
|
||||
assertFalse(event.presentation.isEnabledAndVisible)
|
||||
}
|
||||
|
||||
// ------ DeleteSessionAction.actionPerformed ------
|
||||
|
||||
fun `test delete action deletes selected local items after confirmation`() {
|
||||
rpc.listed += sessionDto("ses_1", "One")
|
||||
rpc.listed += sessionDto("ses_2", "Two")
|
||||
controller.reloadLocal()
|
||||
flush()
|
||||
|
||||
assertEquals(2, controller.local.items.size)
|
||||
|
||||
val items = controller.local.items.toList()
|
||||
val action = DeleteSessionAction().apply { confirm = { _, _ -> true } }
|
||||
val event = event(action, manager, selection(HistorySource.LOCAL, items), controller)
|
||||
|
||||
action.actionPerformed(event)
|
||||
flush()
|
||||
|
||||
assertEquals(listOf("ses_1", "ses_2"), rpc.deletes.map { it.first })
|
||||
assertTrue(controller.local.items.isEmpty())
|
||||
}
|
||||
|
||||
fun `test delete action skips items already being deleted`() {
|
||||
rpc.listed += sessionDto("ses_1", "One")
|
||||
controller.reloadLocal()
|
||||
flush()
|
||||
|
||||
rpc.deleteGate = kotlinx.coroutines.CompletableDeferred()
|
||||
val item = controller.local.items[0]
|
||||
controller.delete(item)
|
||||
waitFor { controller.deleting(item) }
|
||||
|
||||
val action = DeleteSessionAction().apply { confirm = { _, _ -> true } }
|
||||
val event = event(action, manager, selection(HistorySource.LOCAL, listOf(item)), controller)
|
||||
|
||||
action.actionPerformed(event)
|
||||
flush()
|
||||
|
||||
assertTrue(rpc.deletes.isEmpty())
|
||||
|
||||
rpc.deleteGate?.complete(Unit)
|
||||
waitFor { rpc.deletes.size == 1 }
|
||||
assertEquals(listOf("ses_1"), rpc.deletes.map { it.first })
|
||||
}
|
||||
|
||||
fun `test delete action cancelled when user says no`() {
|
||||
rpc.listed += sessionDto("ses_1", "One")
|
||||
controller.reloadLocal()
|
||||
flush()
|
||||
|
||||
val item = controller.local.items[0]
|
||||
val action = DeleteSessionAction().apply { confirm = { _, _ -> false } }
|
||||
val event = event(action, manager, selection(HistorySource.LOCAL, listOf(item)), controller)
|
||||
|
||||
action.actionPerformed(event)
|
||||
flush()
|
||||
|
||||
assertTrue(rpc.deletes.isEmpty())
|
||||
}
|
||||
|
||||
// ------ RenameSessionAction.update ------
|
||||
|
||||
fun `test rename action enabled for exactly one local item`() {
|
||||
val action = RenameSessionAction()
|
||||
val local = localItem("ses_1")
|
||||
val event = event(action, manager, selection(HistorySource.LOCAL, listOf(local)), controller)
|
||||
|
||||
action.update(event)
|
||||
|
||||
assertTrue(event.presentation.isEnabledAndVisible)
|
||||
}
|
||||
|
||||
fun `test rename action disabled with no selection`() {
|
||||
val action = RenameSessionAction()
|
||||
val event = event(action, manager, selection(HistorySource.LOCAL, emptyList()), controller)
|
||||
|
||||
action.update(event)
|
||||
|
||||
assertFalse(event.presentation.isEnabledAndVisible)
|
||||
}
|
||||
|
||||
fun `test rename action disabled with multiple local items`() {
|
||||
val action = RenameSessionAction()
|
||||
val items = listOf(localItem("ses_1"), localItem("ses_2"))
|
||||
val event = event(action, manager, selection(HistorySource.LOCAL, items), controller)
|
||||
|
||||
action.update(event)
|
||||
|
||||
assertFalse(event.presentation.isEnabledAndVisible)
|
||||
}
|
||||
|
||||
fun `test rename action disabled for cloud selection`() {
|
||||
val action = RenameSessionAction()
|
||||
val item = cloudItem("cloud_1")
|
||||
val event = event(action, manager, selection(HistorySource.CLOUD, emptyList(), listOf(item)), controller)
|
||||
|
||||
action.update(event)
|
||||
|
||||
assertFalse(event.presentation.isEnabledAndVisible)
|
||||
}
|
||||
|
||||
// ------ RenameSessionAction.actionPerformed ------
|
||||
|
||||
fun `test rename action calls controller with trimmed changed title`() {
|
||||
rpc.listed += sessionDto("ses_1", "Original")
|
||||
controller.reloadLocal()
|
||||
flush()
|
||||
|
||||
val item = controller.local.items[0]
|
||||
val action = RenameSessionAction().apply { input = { _, _ -> " Renamed " } }
|
||||
val event = event(action, manager, selection(HistorySource.LOCAL, listOf(item)), controller)
|
||||
|
||||
action.actionPerformed(event)
|
||||
flush()
|
||||
|
||||
assertEquals(listOf(Triple("ses_1", "/test", "Renamed")), rpc.renames)
|
||||
}
|
||||
|
||||
fun `test rename action passes displayed current title to input`() {
|
||||
rpc.listed += sessionDto("ses_1", "Original")
|
||||
controller.reloadLocal()
|
||||
flush()
|
||||
|
||||
val prompts = mutableListOf<String>()
|
||||
val item = controller.local.items[0]
|
||||
val action = RenameSessionAction().apply {
|
||||
input = { _, current ->
|
||||
prompts.add(current)
|
||||
null
|
||||
}
|
||||
}
|
||||
val event = event(action, manager, selection(HistorySource.LOCAL, listOf(item)), controller)
|
||||
|
||||
action.actionPerformed(event)
|
||||
flush()
|
||||
|
||||
assertEquals(listOf("Original"), prompts)
|
||||
assertTrue(rpc.renames.isEmpty())
|
||||
}
|
||||
|
||||
fun `test rename action passes untitled fallback to input`() {
|
||||
rpc.listed += sessionDto("ses_1", "")
|
||||
controller.reloadLocal()
|
||||
flush()
|
||||
|
||||
val prompts = mutableListOf<String>()
|
||||
val item = controller.local.items[0]
|
||||
val action = RenameSessionAction().apply {
|
||||
input = { _, current ->
|
||||
prompts.add(current)
|
||||
null
|
||||
}
|
||||
}
|
||||
val event = event(action, manager, selection(HistorySource.LOCAL, listOf(item)), controller)
|
||||
|
||||
action.actionPerformed(event)
|
||||
flush()
|
||||
|
||||
assertEquals(listOf(KiloBundle.message("history.untitled")), prompts)
|
||||
assertTrue(rpc.renames.isEmpty())
|
||||
}
|
||||
|
||||
fun `test rename action ignores blank input`() {
|
||||
rpc.listed += sessionDto("ses_1", "Original")
|
||||
controller.reloadLocal()
|
||||
flush()
|
||||
|
||||
val item = controller.local.items[0]
|
||||
val action = RenameSessionAction().apply { input = { _, _ -> " " } }
|
||||
val event = event(action, manager, selection(HistorySource.LOCAL, listOf(item)), controller)
|
||||
|
||||
action.actionPerformed(event)
|
||||
flush()
|
||||
|
||||
assertTrue(rpc.renames.isEmpty())
|
||||
}
|
||||
|
||||
fun `test rename action ignores unchanged input`() {
|
||||
rpc.listed += sessionDto("ses_1", "Original")
|
||||
controller.reloadLocal()
|
||||
flush()
|
||||
|
||||
val item = controller.local.items[0]
|
||||
val action = RenameSessionAction().apply { input = { _, _ -> "Original" } }
|
||||
val event = event(action, manager, selection(HistorySource.LOCAL, listOf(item)), controller)
|
||||
|
||||
action.actionPerformed(event)
|
||||
flush()
|
||||
|
||||
assertTrue(rpc.renames.isEmpty())
|
||||
}
|
||||
|
||||
fun `test rename action ignores null input`() {
|
||||
rpc.listed += sessionDto("ses_1", "Original")
|
||||
controller.reloadLocal()
|
||||
flush()
|
||||
|
||||
val item = controller.local.items[0]
|
||||
val action = RenameSessionAction().apply { input = { _, _ -> null } }
|
||||
val event = event(action, manager, selection(HistorySource.LOCAL, listOf(item)), controller)
|
||||
|
||||
action.actionPerformed(event)
|
||||
flush()
|
||||
|
||||
assertTrue(rpc.renames.isEmpty())
|
||||
}
|
||||
|
||||
fun `test frontend descriptor registers history actions`() {
|
||||
val xml = javaClass.classLoader.getResourceAsStream("kilo.jetbrains.frontend.xml")
|
||||
?.bufferedReader()
|
||||
?.use { it.readText() }
|
||||
?: error("missing frontend descriptor")
|
||||
|
||||
assertTrue(xml.contains("id=\"Kilo.Session.Open\""))
|
||||
assertTrue(xml.contains("id=\"Kilo.Session.Rename\""))
|
||||
assertTrue(xml.contains("id=\"Kilo.Session.Delete\""))
|
||||
assertTrue(xml.contains("id=\"Kilo.History.ContextMenu\""))
|
||||
assertTrue(xml.contains("ref=\"Kilo.Session.Open\""))
|
||||
assertTrue(xml.contains("ref=\"Kilo.Session.Rename\""))
|
||||
assertTrue(xml.contains("ref=\"Kilo.Session.Delete\""))
|
||||
}
|
||||
|
||||
// ------ Helpers ------
|
||||
|
||||
private fun event(
|
||||
action: com.intellij.openapi.actionSystem.AnAction,
|
||||
manager: SessionManager?,
|
||||
selection: HistorySelection,
|
||||
ctrl: HistoryController,
|
||||
): AnActionEvent {
|
||||
val presentation = Presentation().apply { copyFrom(action.templatePresentation) }
|
||||
val context = DataContext { id ->
|
||||
when {
|
||||
CommonDataKeys.PROJECT.`is`(id) -> project
|
||||
SessionManager.KEY.`is`(id) -> manager
|
||||
HistoryDataKeys.SELECTION.`is`(id) -> selection
|
||||
HistoryDataKeys.CONTROLLER.`is`(id) -> ctrl
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
return AnActionEvent.createFromDataContext("", presentation, context)
|
||||
}
|
||||
|
||||
private fun selection(
|
||||
source: HistorySource,
|
||||
local: List<LocalHistoryItem>,
|
||||
cloud: List<CloudHistoryItem> = emptyList(),
|
||||
) = HistorySelection(source, local, cloud)
|
||||
|
||||
private fun localItem(id: String, title: String = id) = LocalHistoryItem(sessionDto(id, title))
|
||||
|
||||
private fun sessionDto(id: String, title: String) = SessionDto(
|
||||
id = id,
|
||||
projectID = "prj",
|
||||
directory = "/test",
|
||||
title = title,
|
||||
version = "1",
|
||||
time = SessionTimeDto(created = 1.0, updated = 2.0),
|
||||
)
|
||||
|
||||
private fun cloudItem(id: String, title: String = id) = CloudHistoryItem(
|
||||
CloudSessionDto(
|
||||
id = id,
|
||||
title = title,
|
||||
createdAt = "2026-01-01T00:00:00Z",
|
||||
updatedAt = "2026-01-02T00:00:00Z",
|
||||
version = 1.0,
|
||||
)
|
||||
)
|
||||
|
||||
private fun flush() = runBlocking {
|
||||
repeat(10) {
|
||||
delay(100)
|
||||
ApplicationManager.getApplication().invokeAndWait { UIUtil.dispatchAllInvocationEvents() }
|
||||
}
|
||||
}
|
||||
|
||||
private fun waitFor(done: () -> Boolean) = runBlocking {
|
||||
withTimeout(5_000) {
|
||||
while (!done()) {
|
||||
delay(25)
|
||||
ApplicationManager.getApplication().invokeAndWait { UIUtil.dispatchAllInvocationEvents() }
|
||||
}
|
||||
}
|
||||
ApplicationManager.getApplication().invokeAndWait { UIUtil.dispatchAllInvocationEvents() }
|
||||
}
|
||||
|
||||
private class FakeManager : SessionManager {
|
||||
override fun newSession() {}
|
||||
override fun showHistory() {}
|
||||
override fun openSession(ref: SessionRef) {}
|
||||
}
|
||||
}
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
package ai.kilocode.client.app
|
||||
|
||||
import ai.kilocode.client.testing.FakeSessionRpcApi
|
||||
import ai.kilocode.rpc.dto.SessionDto
|
||||
import ai.kilocode.rpc.dto.SessionTimeDto
|
||||
import com.intellij.testFramework.fixtures.BasePlatformTestCase
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@Suppress("UnstableApiUsage")
|
||||
class KiloSessionServiceTest : BasePlatformTestCase() {
|
||||
private lateinit var scope: CoroutineScope
|
||||
private lateinit var rpc: FakeSessionRpcApi
|
||||
private lateinit var service: KiloSessionService
|
||||
|
||||
override fun setUp() {
|
||||
super.setUp()
|
||||
scope = CoroutineScope(SupervisorJob())
|
||||
rpc = FakeSessionRpcApi()
|
||||
service = KiloSessionService(project, scope, rpc)
|
||||
}
|
||||
|
||||
override fun tearDown() {
|
||||
try {
|
||||
scope.cancel()
|
||||
} finally {
|
||||
super.tearDown()
|
||||
}
|
||||
}
|
||||
|
||||
fun `test rename replaces cached session in sessions value`() = runBlocking(Dispatchers.Default) {
|
||||
rpc.listed += session("ses_1", "Original")
|
||||
rpc.listed += session("ses_2", "Other")
|
||||
service.list("/test")
|
||||
|
||||
assertEquals(2, service.sessions.value.size)
|
||||
assertEquals("Original", service.sessions.value.find { it.id == "ses_1" }!!.title)
|
||||
|
||||
service.renameSession("ses_1", "/test", "Renamed")
|
||||
|
||||
assertEquals(2, service.sessions.value.size)
|
||||
assertEquals("Renamed", service.sessions.value.find { it.id == "ses_1" }!!.title)
|
||||
assertEquals("Other", service.sessions.value.find { it.id == "ses_2" }!!.title)
|
||||
}
|
||||
|
||||
fun `test rename of unknown id does not insert new item`() = runBlocking(Dispatchers.Default) {
|
||||
rpc.listed += session("ses_1", "Original")
|
||||
service.list("/test")
|
||||
|
||||
assertEquals(1, service.sessions.value.size)
|
||||
|
||||
service.renameSession("ses_unknown", "/test", "Should Not Insert")
|
||||
|
||||
// Size remains 1 — no unexpected insert
|
||||
assertEquals(1, service.sessions.value.size)
|
||||
assertEquals("ses_1", service.sessions.value[0].id)
|
||||
}
|
||||
|
||||
fun `test rename failure propagates exception without mutating cache`() = runBlocking(Dispatchers.Default) {
|
||||
rpc.listed += session("ses_1", "Original")
|
||||
service.list("/test")
|
||||
|
||||
val before = service.sessions.value.toList()
|
||||
rpc.renameThrows = RuntimeException("server error")
|
||||
|
||||
var threw = false
|
||||
try {
|
||||
service.renameSession("ses_1", "/test", "Renamed")
|
||||
} catch (_: RuntimeException) {
|
||||
threw = true
|
||||
}
|
||||
|
||||
assertTrue(threw)
|
||||
assertEquals(before.map { it.id to it.title }, service.sessions.value.map { it.id to it.title })
|
||||
}
|
||||
|
||||
fun `test list populates sessions value`() = runBlocking(Dispatchers.Default) {
|
||||
rpc.listed += session("ses_1", "One")
|
||||
rpc.listed += session("ses_2", "Two")
|
||||
|
||||
service.list("/test")
|
||||
|
||||
assertEquals(2, service.sessions.value.size)
|
||||
assertTrue(service.sessions.value.any { it.id == "ses_1" })
|
||||
assertTrue(service.sessions.value.any { it.id == "ses_2" })
|
||||
}
|
||||
|
||||
private fun session(id: String, title: String) = SessionDto(
|
||||
id = id,
|
||||
projectID = "prj",
|
||||
directory = "/test",
|
||||
title = title,
|
||||
version = "1",
|
||||
time = SessionTimeDto(created = 1.0, updated = 2.0),
|
||||
)
|
||||
}
|
||||
+234
-7
@@ -4,6 +4,7 @@ import ai.kilocode.client.app.KiloSessionService
|
||||
import ai.kilocode.client.app.KiloWorkspaceService
|
||||
import ai.kilocode.client.app.Workspace
|
||||
import ai.kilocode.client.plugin.KiloBundle
|
||||
import ai.kilocode.client.session.SessionManager
|
||||
import ai.kilocode.client.session.SessionRef
|
||||
import ai.kilocode.client.testing.FakeSessionRpcApi
|
||||
import ai.kilocode.client.testing.FakeWorkspaceRpcApi
|
||||
@@ -197,7 +198,7 @@ class HistoryControllerTest : BasePlatformTestCase() {
|
||||
flush()
|
||||
|
||||
val local = panel.defaultFocusedComponent
|
||||
assertFalse(panel.listFocusable())
|
||||
assertTrue(panel.listFocusable())
|
||||
assertEquals(-1, panel.selectedIndex())
|
||||
key(local, KeyEvent.VK_DOWN)
|
||||
assertEquals(0, panel.selectedIndex())
|
||||
@@ -211,7 +212,7 @@ class HistoryControllerTest : BasePlatformTestCase() {
|
||||
|
||||
val cloud = panel.defaultFocusedComponent
|
||||
assertNotSame(local, cloud)
|
||||
assertFalse(panel.listFocusable())
|
||||
assertTrue(panel.listFocusable())
|
||||
assertEquals(-1, panel.selectedIndex())
|
||||
key(cloud, KeyEvent.VK_DOWN)
|
||||
assertEquals(0, panel.selectedIndex())
|
||||
@@ -297,17 +298,237 @@ class HistoryControllerTest : BasePlatformTestCase() {
|
||||
assertEquals(KiloBundle.message("history.time.hours", 10), HistoryTime.relative(offset, now.toEpochMilli()))
|
||||
}
|
||||
|
||||
fun `test local renderer exposes delete and cloud renderer hides it`() {
|
||||
rpc.listed += session("ses_1", "Local")
|
||||
rpc.cloud += cloud("cloud_1", "Cloud")
|
||||
fun `test list is focusable and uses multiple interval selection`() {
|
||||
val panel = HistoryPanel(parent, controller())
|
||||
flush()
|
||||
|
||||
assertTrue(panel.deleteVisible(0))
|
||||
assertTrue(panel.listFocusable())
|
||||
assertEquals(javax.swing.ListSelectionModel.MULTIPLE_INTERVAL_SELECTION, panel.listSelectionMode())
|
||||
}
|
||||
|
||||
fun `test load more button is focusable`() {
|
||||
rpc.cloud += cloud("cloud_1", "Cloud")
|
||||
rpc.cloudCursor = "next"
|
||||
val controller = controller()
|
||||
val panel = HistoryPanel(parent, controller)
|
||||
flush()
|
||||
|
||||
assertTrue(panel.loadMoreFocusable())
|
||||
}
|
||||
|
||||
fun `test rename updates local item title`() {
|
||||
rpc.listed += session("ses_1", "Original")
|
||||
val controller = controller()
|
||||
flush()
|
||||
|
||||
controller.reloadLocal()
|
||||
flush()
|
||||
|
||||
val item = controller.local.items[0]
|
||||
controller.rename(item, "Renamed")
|
||||
flush()
|
||||
|
||||
assertEquals("Renamed", controller.local.items[0].title)
|
||||
assertEquals(listOf(Triple("ses_1", "/test", "Renamed")), rpc.renames)
|
||||
}
|
||||
|
||||
fun `test data context exposes selection and controller`() {
|
||||
rpc.listed += session("ses_1", "Alpha")
|
||||
val controller = controller()
|
||||
val panel = HistoryPanel(parent, controller)
|
||||
flush()
|
||||
|
||||
panel.select(0)
|
||||
|
||||
val sel = panel.getData(HistoryDataKeys.SELECTION.name) as? HistorySelection
|
||||
assertNotNull(sel)
|
||||
assertEquals(1, sel!!.selectedLocal.size)
|
||||
assertEquals("ses_1", sel.selectedLocal[0].id)
|
||||
|
||||
val ctrl = panel.getData(HistoryDataKeys.CONTROLLER.name)
|
||||
assertSame(controller, ctrl)
|
||||
}
|
||||
|
||||
// ------ Multi-selection and cloud selection data context ------
|
||||
|
||||
fun `test data context exposes local multi-selection`() {
|
||||
rpc.listed += session("ses_1", "Alpha")
|
||||
rpc.listed += session("ses_2", "Beta")
|
||||
val controller = controller()
|
||||
val panel = HistoryPanel(parent, controller)
|
||||
flush()
|
||||
|
||||
panel.selectIndices(0, 1)
|
||||
|
||||
val sel = panel.getData(HistoryDataKeys.SELECTION.name) as? HistorySelection
|
||||
assertNotNull(sel)
|
||||
assertEquals(HistorySource.LOCAL, sel!!.source)
|
||||
assertEquals(2, sel.selectedLocal.size)
|
||||
assertTrue(sel.selectedLocal.map { it.id }.containsAll(listOf("ses_1", "ses_2")))
|
||||
}
|
||||
|
||||
fun `test data context exposes cloud selection`() {
|
||||
rpc.cloud += cloud("cloud_1", "Cloud One")
|
||||
val controller = controller()
|
||||
val panel = HistoryPanel(parent, controller)
|
||||
flush()
|
||||
|
||||
panel.clickCloud()
|
||||
flush()
|
||||
assertFalse(panel.cloudDeleteVisible(0))
|
||||
panel.select(0)
|
||||
|
||||
val sel = panel.getData(HistoryDataKeys.SELECTION.name) as? HistorySelection
|
||||
assertNotNull(sel)
|
||||
assertEquals(HistorySource.CLOUD, sel!!.source)
|
||||
assertTrue(sel.selectedLocal.isEmpty())
|
||||
assertEquals(1, sel.cloudItems.size)
|
||||
assertEquals("cloud_1", sel.cloudItems[0].id)
|
||||
}
|
||||
|
||||
fun `test data context exposes session manager`() {
|
||||
val manager = FakeManager()
|
||||
val controller = controller()
|
||||
val panel = HistoryPanel(parent, controller, manager = manager)
|
||||
flush()
|
||||
|
||||
assertSame(manager, panel.getData(SessionManager.KEY.name))
|
||||
}
|
||||
|
||||
fun `test data context returns null for absent session manager`() {
|
||||
val controller = controller()
|
||||
val panel = HistoryPanel(parent, controller)
|
||||
flush()
|
||||
|
||||
assertNull(panel.getData(SessionManager.KEY.name))
|
||||
}
|
||||
|
||||
fun `test local list uses multiple interval selection mode`() {
|
||||
val panel = HistoryPanel(parent, controller())
|
||||
flush()
|
||||
|
||||
assertEquals(javax.swing.ListSelectionModel.MULTIPLE_INTERVAL_SELECTION, panel.listSelectionMode())
|
||||
}
|
||||
|
||||
fun `test cloud list uses single selection mode`() {
|
||||
val panel = HistoryPanel(parent, controller())
|
||||
flush()
|
||||
|
||||
panel.clickCloud()
|
||||
flush()
|
||||
|
||||
assertEquals(javax.swing.ListSelectionModel.SINGLE_SELECTION, panel.listSelectionMode())
|
||||
}
|
||||
|
||||
// ------ Rename failure and directory selection ------
|
||||
|
||||
fun `test rename failure keeps original title and sets error`() {
|
||||
rpc.listed += session("ses_1", "Original")
|
||||
val controller = controller()
|
||||
controller.reloadLocal()
|
||||
flush()
|
||||
|
||||
rpc.renameThrows = IllegalStateException("rename failed")
|
||||
val item = controller.local.items[0]
|
||||
controller.rename(item, "Renamed")
|
||||
flush()
|
||||
|
||||
assertEquals("Original", controller.local.items[0].title)
|
||||
assertNotNull(controller.local.error)
|
||||
}
|
||||
|
||||
fun `test rename uses item directory when present`() {
|
||||
val dto = session("ses_1", "Original").copy(directory = "/worktree/path")
|
||||
rpc.listed += dto
|
||||
val controller = controller()
|
||||
controller.reloadLocal()
|
||||
flush()
|
||||
|
||||
val item = controller.local.items[0]
|
||||
assertEquals("/worktree/path", item.directory)
|
||||
|
||||
controller.rename(item, "Renamed")
|
||||
flush()
|
||||
|
||||
assertEquals(listOf(Triple("ses_1", "/worktree/path", "Renamed")), rpc.renames)
|
||||
}
|
||||
|
||||
fun `test rename falls back to workspace directory when item directory is workspace`() {
|
||||
// When session.directory matches workspace directory (not a worktree override)
|
||||
rpc.listed += session("ses_1", "Original")
|
||||
val controller = controller()
|
||||
controller.reloadLocal()
|
||||
flush()
|
||||
|
||||
val item = controller.local.items[0]
|
||||
controller.rename(item, "Renamed")
|
||||
flush()
|
||||
|
||||
assertEquals(listOf(Triple("ses_1", "/test", "Renamed")), rpc.renames)
|
||||
}
|
||||
|
||||
// ------ HistoryModel update/sorting ------
|
||||
|
||||
fun `test model update re-sorts items by updated time`() {
|
||||
val now = java.time.Instant.now()
|
||||
rpc.listed += session("ses_1", "Alpha", now.toEpochMilli().toDouble())
|
||||
rpc.listed += session("ses_2", "Beta", now.minusSeconds(100).toEpochMilli().toDouble())
|
||||
val controller = controller()
|
||||
controller.reloadLocal()
|
||||
flush()
|
||||
|
||||
// ses_1 is newer so comes first
|
||||
assertEquals("ses_1", controller.local.items[0].id)
|
||||
|
||||
// Update ses_2 to be newer
|
||||
val updated = session("ses_2", "Beta Updated", now.plusSeconds(100).toEpochMilli().toDouble())
|
||||
controller.local.update(LocalHistoryItem(updated))
|
||||
|
||||
// Now ses_2 should come first
|
||||
assertEquals("ses_2", controller.local.items[0].id)
|
||||
assertEquals("Beta Updated", controller.local.items[0].title)
|
||||
}
|
||||
|
||||
fun `test model update with unknown id leaves model unchanged`() {
|
||||
rpc.listed += session("ses_1", "Alpha")
|
||||
val controller = controller()
|
||||
controller.reloadLocal()
|
||||
flush()
|
||||
|
||||
val before = controller.local.items.toList()
|
||||
val unknown = session("unknown_id", "Unknown")
|
||||
controller.local.update(LocalHistoryItem(unknown))
|
||||
|
||||
assertEquals(before.map { it.id }, controller.local.items.map { it.id })
|
||||
}
|
||||
|
||||
fun `test model update removes renamed item from active filter`() {
|
||||
rpc.listed += session("ses_1", "Alpha")
|
||||
rpc.listed += session("ses_2", "Beta")
|
||||
val controller = controller()
|
||||
controller.reloadLocal()
|
||||
flush()
|
||||
|
||||
controller.local.setFilter("alpha")
|
||||
assertEquals(listOf("ses_1"), controller.local.visibleItems.map { it.id })
|
||||
|
||||
controller.local.update(LocalHistoryItem(session("ses_1", "Gamma")))
|
||||
|
||||
assertTrue(controller.local.visibleItems.isEmpty())
|
||||
}
|
||||
|
||||
fun `test model update adds renamed item to active filter`() {
|
||||
rpc.listed += session("ses_1", "Alpha")
|
||||
rpc.listed += session("ses_2", "Beta")
|
||||
val controller = controller()
|
||||
controller.reloadLocal()
|
||||
flush()
|
||||
|
||||
controller.local.setFilter("gamma")
|
||||
assertTrue(controller.local.visibleItems.isEmpty())
|
||||
|
||||
controller.local.update(LocalHistoryItem(session("ses_2", "Gamma")))
|
||||
|
||||
assertEquals(listOf("ses_2"), controller.local.visibleItems.map { it.id })
|
||||
}
|
||||
|
||||
private fun controller() = HistoryController(sessions, workspace, scope)
|
||||
@@ -320,6 +541,12 @@ class HistoryControllerTest : BasePlatformTestCase() {
|
||||
opened.add(id)
|
||||
})
|
||||
|
||||
private class FakeManager : SessionManager {
|
||||
override fun newSession() {}
|
||||
override fun showHistory() {}
|
||||
override fun openSession(ref: SessionRef) {}
|
||||
}
|
||||
|
||||
private fun collect(controller: HistoryController): MutableList<String> {
|
||||
val events = mutableListOf<String>()
|
||||
val listener = object : ListDataListener {
|
||||
|
||||
+16
@@ -86,6 +86,9 @@ class FakeSessionRpcApi : KiloSessionRpcApi {
|
||||
val questionReplies = mutableListOf<Triple<String, String, QuestionReplyDto>>()
|
||||
val questionRejects = mutableListOf<Pair<String, String>>()
|
||||
val deletes = mutableListOf<Pair<String, String>>()
|
||||
var deleteGate: CompletableDeferred<Unit>? = null
|
||||
val renames = mutableListOf<Triple<String, String, String>>()
|
||||
var renameThrows: Exception? = null
|
||||
val lists = mutableListOf<String>()
|
||||
val recentCalls = mutableListOf<Pair<String, Int>>()
|
||||
val cloudCalls = mutableListOf<CloudCall>()
|
||||
@@ -127,10 +130,23 @@ class FakeSessionRpcApi : KiloSessionRpcApi {
|
||||
|
||||
override suspend fun delete(id: String, directory: String) {
|
||||
assertNotEdt("delete")
|
||||
deleteGate?.await()
|
||||
deletes.add(id to directory)
|
||||
listed.removeAll { it.id == id }
|
||||
}
|
||||
|
||||
override suspend fun rename(id: String, directory: String, title: String): SessionDto {
|
||||
assertNotEdt("rename")
|
||||
renameThrows?.let { throw it }
|
||||
renames.add(Triple(id, directory, title))
|
||||
val updated = listed.indexOfFirst { it.id == id }
|
||||
if (updated >= 0) {
|
||||
listed[updated] = listed[updated].copy(title = title)
|
||||
return listed[updated]
|
||||
}
|
||||
return session.copy(id = id, title = title)
|
||||
}
|
||||
|
||||
override suspend fun cloudSessions(directory: String, cursor: String?, limit: Int, gitUrl: String?): CloudSessionListDto {
|
||||
assertNotEdt("cloudSessions")
|
||||
cloudCalls.add(CloudCall(directory, cursor, limit, gitUrl))
|
||||
|
||||
@@ -51,6 +51,9 @@ interface KiloSessionRpcApi : RemoteApi<Unit> {
|
||||
/** Delete a session. */
|
||||
suspend fun delete(id: String, directory: String)
|
||||
|
||||
/** Rename a session. */
|
||||
suspend fun rename(id: String, directory: String, title: String): SessionDto
|
||||
|
||||
/** List cloud-backed sessions. */
|
||||
suspend fun cloudSessions(directory: String, cursor: String?, limit: Int, gitUrl: String?): CloudSessionListDto
|
||||
|
||||
|
||||
Reference in New Issue
Block a user