mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-28 19:11:03 +08:00
Merge pull request #12612 from Kilo-Org/lovely-wallflower
feat(jetbrains): improve session changes and diff review
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": minor
|
||||
---
|
||||
|
||||
Improve JetBrains session change tracking: show the files each assistant turn modified with expandable per-file diffs, open inline and branch diffs in a refreshable diff viewer, and surface branch changes in the session header.
|
||||
+20
-10
@@ -27,6 +27,7 @@ import ai.kilocode.rpc.dto.CustomProviderSaveDto
|
||||
import ai.kilocode.rpc.dto.DiffFileDto
|
||||
import ai.kilocode.rpc.dto.MessageDto
|
||||
import ai.kilocode.rpc.dto.MessageErrorDto
|
||||
import ai.kilocode.rpc.dto.MessageSummaryDto
|
||||
import ai.kilocode.rpc.dto.MessageTimeDto
|
||||
import ai.kilocode.rpc.dto.MessageWithPartsDto
|
||||
import ai.kilocode.rpc.dto.McpConfigDto
|
||||
@@ -261,16 +262,7 @@ object KiloCliDataParser {
|
||||
|
||||
"session.diff" -> {
|
||||
val sid = props.str("sessionID") ?: return null
|
||||
val diffs = props["diff"]?.jsonArray?.mapNotNull { elem ->
|
||||
val d = elem.jsonObject
|
||||
val file = d.str("file") ?: return@mapNotNull null
|
||||
DiffFileDto(
|
||||
file = file,
|
||||
additions = d.long("additions")?.safeInt() ?: 0,
|
||||
deletions = d.long("deletions")?.safeInt() ?: 0,
|
||||
patch = d.str("patch"),
|
||||
)
|
||||
} ?: emptyList()
|
||||
val diffs = parseDiffs(props["diff"])
|
||||
ChatEventDto.SessionDiffChanged(sid, diffs)
|
||||
}
|
||||
|
||||
@@ -1047,6 +1039,8 @@ object KiloCliDataParser {
|
||||
val time = obj["time"]?.jsonObject
|
||||
val tokens = obj["tokens"]?.jsonObject
|
||||
val error = obj["error"]?.jsonObject
|
||||
val raw = obj["summary"].obj()?.get("diffs")
|
||||
val summary = if (raw == null) null else MessageSummaryDto(parseDiffs(raw))
|
||||
|
||||
return MessageDto(
|
||||
id = obj.str("id") ?: "",
|
||||
@@ -1063,9 +1057,25 @@ object KiloCliDataParser {
|
||||
cost = obj.num("cost"),
|
||||
tokens = tokens?.let(::parseTokens),
|
||||
error = error?.let { parseError(it) },
|
||||
summary = summary,
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseDiffs(raw: JsonElement?): List<DiffFileDto> {
|
||||
val arr = raw.arr() ?: return emptyList()
|
||||
return arr.mapNotNull { elem ->
|
||||
val item = elem.obj() ?: return@mapNotNull null
|
||||
val file = item.str("file") ?: return@mapNotNull null
|
||||
DiffFileDto(
|
||||
file = file,
|
||||
additions = item.long("additions")?.safeInt() ?: 0,
|
||||
deletions = item.long("deletions")?.safeInt() ?: 0,
|
||||
patch = item.str("patch"),
|
||||
status = item.str("status"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun parsePart(obj: JsonObject): PartDto {
|
||||
val state = obj["state"]?.jsonObject
|
||||
val tokens = obj["tokens"]?.jsonObject
|
||||
|
||||
+14
@@ -11,6 +11,7 @@ import ai.kilocode.rpc.KiloSessionRpcApi
|
||||
import ai.kilocode.rpc.dto.ChatEventDto
|
||||
import ai.kilocode.rpc.dto.CloudSessionListDto
|
||||
import ai.kilocode.rpc.dto.ConfigUpdateDto
|
||||
import ai.kilocode.rpc.dto.DiffFileDto
|
||||
import ai.kilocode.rpc.dto.MessageWithPartsDto
|
||||
import ai.kilocode.rpc.dto.ModelSelectionDto
|
||||
import ai.kilocode.rpc.dto.PermissionAlwaysRulesDto
|
||||
@@ -26,6 +27,8 @@ import ai.kilocode.rpc.dto.SessionStatusDto
|
||||
import com.intellij.openapi.components.service
|
||||
import ai.kilocode.log.KiloLog
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.onCompletion
|
||||
@@ -141,6 +144,17 @@ class KiloSessionRpcApiImpl internal constructor(
|
||||
override suspend fun messages(id: String, directory: String): List<MessageWithPartsDto> =
|
||||
ready { chat.messages(id, directory) }
|
||||
|
||||
override suspend fun diff(id: String, directory: String): List<DiffFileDto> = ready {
|
||||
// GET /session/:id/diff returns the cumulative, deduplicated, unquoted snapshot diff. Prefer it
|
||||
// over concatenating per-message summaries (which duplicate files per turn and skip unquoting).
|
||||
val api = app.api ?: throw IllegalStateException("Kilo API is unavailable")
|
||||
withContext(Dispatchers.IO) { api.sessionDiff(sessionID = id, directory = directory) }
|
||||
.mapNotNull { file ->
|
||||
val path = file.file ?: return@mapNotNull null
|
||||
DiffFileDto(path, file.additions.toInt(), file.deletions.toInt(), file.patch, file.status?.value)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun attachmentPart(id: String, directory: String, messageId: String, partId: String, attachmentKey: String?): PartDto? =
|
||||
ready { chat.attachmentPart(id, directory, messageId, partId, attachmentKey) }
|
||||
|
||||
|
||||
+188
@@ -15,6 +15,7 @@ import ai.kilocode.jetbrains.api.model.Agent
|
||||
import ai.kilocode.rpc.KiloWorkspaceRpcApi
|
||||
import ai.kilocode.rpc.isManagedWorktreeStorage
|
||||
import ai.kilocode.rpc.dto.ConfigTargetDto
|
||||
import ai.kilocode.rpc.dto.DiffFileDto
|
||||
import ai.kilocode.rpc.dto.FileSearchResultDto
|
||||
import ai.kilocode.rpc.dto.KiloWorkspaceStateDto
|
||||
import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto
|
||||
@@ -55,6 +56,10 @@ import java.nio.charset.StandardCharsets
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.InvalidPathException
|
||||
import java.nio.file.Path
|
||||
import kotlin.io.path.fileSize
|
||||
import kotlin.io.path.inputStream
|
||||
import kotlin.io.path.isRegularFile
|
||||
import kotlin.io.path.readBytes
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
@@ -76,6 +81,7 @@ class KiloWorkspaceRpcApiImpl internal constructor(
|
||||
private val GLOBAL = MODERN + LEGACY + "config.json"
|
||||
private val LOCAL_DIRS = listOf(".kilo", ".kilocode", ".opencode")
|
||||
private const val DIFF_CAP = 200_000
|
||||
private const val LARGE_FILE = 2 * 1024 * 1024L
|
||||
private val JSON = Json { ignoreUnknownKeys = true }
|
||||
private val CONFIG = """{
|
||||
"${'$'}schema": "$SCHEMA"
|
||||
@@ -248,6 +254,39 @@ class KiloWorkspaceRpcApiImpl internal constructor(
|
||||
text.takeIf { it.isNotBlank() }?.take(DIFF_CAP)
|
||||
}
|
||||
|
||||
override suspend fun branchDiff(directory: String, patches: Boolean): List<DiffFileDto> = withContext(Dispatchers.IO) {
|
||||
val base = file(clean(directory) ?: directory) ?: return@withContext emptyList()
|
||||
if (!gitAvailable(base)) return@withContext emptyList()
|
||||
val anc = mergeBase(base)
|
||||
// --relative scopes diff output to the opened directory and emits project-relative paths, so
|
||||
// tracked entries match the untracked list (ls-files is already cwd-relative) in monorepos.
|
||||
val stats = parseNumstat(git(base, "-c", "core.quotepath=false", "diff", "--numstat", "--relative", "--no-color", "--no-renames", anc))
|
||||
val status = parseNameStatus(git(base, "-c", "core.quotepath=false", "diff", "--name-status", "--relative", "--no-color", "--no-renames", anc))
|
||||
val untrackedPaths = git(base, "-c", "core.quotepath=false", "ls-files", "--others", "--exclude-standard")
|
||||
.lineSequence()
|
||||
.filter { it.isNotBlank() }
|
||||
.toList()
|
||||
// Stats-only DTOs (empty patch); the badge path stops here. untracked() streams a line
|
||||
// count on this path instead of materializing each file as a String.
|
||||
val files = stats.map { DiffFileDto(it.path, it.additions, it.deletions, "", status[it.path] ?: "modified") } +
|
||||
untrackedPaths.map { untracked(base, it, withPatch = false) }
|
||||
if (!patches) return@withContext files
|
||||
// Fetch patches lazily and stop once the running total reaches DIFF_CAP, so a branch with
|
||||
// hundreds of changed files doesn't spawn a git subprocess (or read a file) per entry.
|
||||
capDiff(files, DIFF_CAP) { file ->
|
||||
if (file.status == "untracked") untracked(base, file.file, withPatch = true).patch.orEmpty()
|
||||
else fileDiff(base, anc, file.file)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun branchName(directory: String): String? = withContext(Dispatchers.IO) {
|
||||
val base = file(clean(directory) ?: directory) ?: return@withContext null
|
||||
if (!gitAvailable(base)) return@withContext null
|
||||
git(base, "branch", "--show-current").trim().ifBlank {
|
||||
git(base, "rev-parse", "--short", "HEAD").trim()
|
||||
}.ifBlank { null }
|
||||
}
|
||||
|
||||
override suspend fun openFile(path: String, line: Int?, column: Int?): Boolean {
|
||||
val item = clean(path) ?: return false
|
||||
val target = file(item)?.takeIf { it.isAbsolute } ?: return false
|
||||
@@ -368,6 +407,55 @@ class KiloWorkspaceRpcApiImpl internal constructor(
|
||||
return runWorkspaceGit(base, *args)
|
||||
}
|
||||
|
||||
/** Merge-base of the resolved default branch and HEAD, or HEAD when no base can be determined. */
|
||||
private fun mergeBase(base: Path): String {
|
||||
val ref = defaultBranch(base) ?: return "HEAD"
|
||||
return git(base, "merge-base", ref, "HEAD").trim().ifBlank { "HEAD" }
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the base branch ref, preferring the remote's declared default (origin HEAD), then the
|
||||
* common origin and local main or master branches, so repos whose default is develop or trunk —
|
||||
* or worktrees where only the remote branch exists locally — still resolve. Fully-qualified refs
|
||||
* are used so a tag named "main" can't be mistaken for the branch.
|
||||
*/
|
||||
private fun defaultBranch(base: Path): String? {
|
||||
git(base, "symbolic-ref", "--quiet", "refs/remotes/origin/HEAD").trim()
|
||||
.removePrefix("refs/remotes/")
|
||||
.takeIf { it.isNotBlank() }
|
||||
?.let { return it }
|
||||
return listOf(
|
||||
"refs/remotes/origin/main" to "origin/main",
|
||||
"refs/remotes/origin/master" to "origin/master",
|
||||
"refs/heads/main" to "main",
|
||||
"refs/heads/master" to "master",
|
||||
).firstOrNull { git(base, "rev-parse", "--verify", "--quiet", it.first).isNotBlank() }?.second
|
||||
}
|
||||
|
||||
private fun fileDiff(base: Path, anc: String, path: String): String =
|
||||
git(base, "-c", "core.quotepath=false", "diff", "--relative", "--no-color", "--no-ext-diff", "--no-renames", "--unified=2147483647", anc, "--", path)
|
||||
|
||||
private fun untracked(base: Path, rel: String, withPatch: Boolean): DiffFileDto {
|
||||
return runCatching {
|
||||
val path = base.resolve(rel).normalize()
|
||||
if (!path.startsWith(base) || !path.isRegularFile() || path.fileSize() > LARGE_FILE) return@runCatching DiffFileDto(rel, 0, 0, "", "untracked")
|
||||
if (!withPatch) {
|
||||
// Badge path (runs on every turn end / revert): count lines by streaming bytes rather
|
||||
// than allocating the whole file. null = binary (NUL byte), reported as 0/0.
|
||||
val count = countLines(path) ?: return@runCatching DiffFileDto(rel, 0, 0, "", "untracked")
|
||||
return@runCatching DiffFileDto(rel, count, 0, "", "untracked")
|
||||
}
|
||||
val bytes = path.readBytes()
|
||||
if (bytes.any { it == 0.toByte() }) return@runCatching DiffFileDto(rel, 0, 0, "", "untracked")
|
||||
val text = bytes.toString(StandardCharsets.UTF_8)
|
||||
val additions = lines(text).size
|
||||
DiffFileDto(rel, additions, 0, untrackedPatch(rel, text, additions), "untracked")
|
||||
}.getOrElse { err ->
|
||||
LOG.debug { "Failed to read untracked file for branch diff: $rel (${err.message})" }
|
||||
DiffFileDto(rel, 0, 0, "", "untracked")
|
||||
}
|
||||
}
|
||||
|
||||
private fun agent(a: Agent) = AgentInfo(
|
||||
name = a.name,
|
||||
displayName = a.displayName,
|
||||
@@ -427,6 +515,106 @@ internal fun resolveProjectDirectoryHint(hint: String, bases: List<String>): Str
|
||||
return bases.firstOrNull() ?: hint
|
||||
}
|
||||
|
||||
/**
|
||||
* Assemble capped diff DTOs. [fetch] lazily produces each file's full-context patch. Oversized
|
||||
* patches are skipped, but a single generated/large file should not blank every later small file;
|
||||
* after a bounded number of misses, stop fetching so large branches still don't run a git subprocess
|
||||
* (or read a file) per entry only to discard the output. Files past the cap keep their stats but
|
||||
* carry an empty patch, which the client renders from stats alone.
|
||||
*/
|
||||
internal fun capDiff(files: List<DiffFileDto>, cap: Int, fetch: (DiffFileDto) -> String): List<DiffFileDto> {
|
||||
var used = 0
|
||||
var misses = 0
|
||||
var full = false
|
||||
return files.map { file ->
|
||||
if (full) return@map file.copy(patch = "")
|
||||
val text = fetch(file)
|
||||
when {
|
||||
text.isBlank() -> file.copy(patch = "")
|
||||
used + text.length <= cap -> {
|
||||
used += text.length
|
||||
if (used >= cap) full = true
|
||||
file.copy(patch = text)
|
||||
}
|
||||
else -> {
|
||||
misses++
|
||||
full = misses >= MAX_OVERSIZED_PATCHES || used >= cap
|
||||
file.copy(patch = "")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val MAX_OVERSIZED_PATCHES = 3
|
||||
|
||||
private fun untrackedPatch(path: String, text: String, additions: Int): String = buildString {
|
||||
appendLine("diff --git a/$path b/$path")
|
||||
appendLine("new file mode 100644")
|
||||
appendLine("--- /dev/null")
|
||||
appendLine("+++ b/$path")
|
||||
appendLine("@@ -0,0 +1,$additions @@")
|
||||
lines(text).forEach { line -> appendLine("+$line") }
|
||||
if (text.isNotEmpty() && !text.endsWith("\n")) appendLine("\\ No newline at end of file")
|
||||
}.removeSuffix("\n")
|
||||
|
||||
private fun lines(text: String): List<String> {
|
||||
if (text.isEmpty()) return emptyList()
|
||||
return text.removeSuffix("\n").split('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Count lines the way [lines] does (trailing newline ignored, empty file = 0) by streaming bytes,
|
||||
* so the stats-only untracked path doesn't allocate the whole file. Returns null for binary content
|
||||
* (a NUL byte), matching the with-patch path's binary guard.
|
||||
*/
|
||||
private fun countLines(path: Path): Int? {
|
||||
var newlines = 0
|
||||
var last = 0
|
||||
var any = false
|
||||
path.inputStream().buffered().use { input ->
|
||||
val buf = ByteArray(8192)
|
||||
while (true) {
|
||||
val n = input.read(buf)
|
||||
if (n <= 0) break
|
||||
any = true
|
||||
for (i in 0 until n) {
|
||||
val b = buf[i].toInt()
|
||||
if (b == 0) return null
|
||||
if (b == '\n'.code) newlines++
|
||||
}
|
||||
last = buf[n - 1].toInt()
|
||||
}
|
||||
}
|
||||
if (!any) return 0
|
||||
return if (last == '\n'.code) newlines else newlines + 1
|
||||
}
|
||||
|
||||
internal data class DiffStat(val path: String, val additions: Int, val deletions: Int)
|
||||
|
||||
internal fun parseNameStatus(text: String): Map<String, String> = text.lineSequence()
|
||||
.mapNotNull { line ->
|
||||
val parts = line.split('\t')
|
||||
if (parts.size < 2) return@mapNotNull null
|
||||
val path = parts.drop(1).joinToString("\t").takeIf { it.isNotBlank() } ?: return@mapNotNull null
|
||||
val status = when (parts[0].firstOrNull()) {
|
||||
'A' -> "added"
|
||||
'D' -> "deleted"
|
||||
'M' -> "modified"
|
||||
else -> null
|
||||
} ?: return@mapNotNull null
|
||||
path to status
|
||||
}
|
||||
.toMap()
|
||||
|
||||
internal fun parseNumstat(text: String): List<DiffStat> = text.lineSequence()
|
||||
.mapNotNull { line ->
|
||||
val parts = line.split('\t')
|
||||
if (parts.size < 3) return@mapNotNull null
|
||||
val path = parts.drop(2).joinToString("\t").takeIf { it.isNotBlank() } ?: return@mapNotNull null
|
||||
DiffStat(path, parts[0].toIntOrNull() ?: 0, parts[1].toIntOrNull() ?: 0)
|
||||
}
|
||||
.toList()
|
||||
|
||||
internal fun workspaceGitAvailable(base: Path, cache: ConcurrentHashMap<String, Boolean> = ConcurrentHashMap()): Boolean {
|
||||
if (Files.exists(base.resolve(".git"))) return true
|
||||
return cache.getOrPut(base.toString()) {
|
||||
|
||||
+31
@@ -1,8 +1,10 @@
|
||||
package ai.kilocode.backend.cli
|
||||
|
||||
import ai.kilocode.rpc.dto.ChatEventDto
|
||||
import ai.kilocode.rpc.dto.DiffFileDto
|
||||
import ai.kilocode.rpc.dto.MessageDto
|
||||
import ai.kilocode.rpc.dto.MessageErrorDto
|
||||
import ai.kilocode.rpc.dto.MessageSummaryDto
|
||||
import ai.kilocode.rpc.dto.MessageTimeDto
|
||||
import ai.kilocode.rpc.dto.PartDto
|
||||
import ai.kilocode.rpc.dto.PartTimeDto
|
||||
@@ -15,6 +17,7 @@ import ai.kilocode.rpc.dto.SessionStatusDto
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
@@ -233,6 +236,34 @@ class ChatDtoSerializationTest {
|
||||
assertEquals("a.png", decoded.filename)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `MessageDto summary diffs are preserved in round-trip`() {
|
||||
val msg = msg("msg_1").copy(
|
||||
summary = MessageSummaryDto(
|
||||
diffs = listOf(DiffFileDto("src/A.kt", 2, 1, "@@ patch", "modified")),
|
||||
),
|
||||
)
|
||||
|
||||
val encoded = json.encodeToString(MessageDto.serializer(), msg)
|
||||
assertTrue(encoded.contains(""""summary""""))
|
||||
assertTrue(encoded.contains(""""diffs""""))
|
||||
|
||||
val decoded = json.decodeFromString(MessageDto.serializer(), encoded)
|
||||
val diff = decoded.summary?.diffs?.single()
|
||||
assertEquals("src/A.kt", diff?.file)
|
||||
assertEquals("@@ patch", diff?.patch)
|
||||
assertEquals("modified", diff?.status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `MessageDto summary defaults to null`() {
|
||||
val encoded = json.encodeToString(MessageDto.serializer(), msg("msg_1"))
|
||||
|
||||
val decoded = json.decodeFromString(MessageDto.serializer(), encoded)
|
||||
|
||||
assertNull(decoded.summary)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `PromptDto variant is preserved in round-trip`() {
|
||||
val prompt = PromptDto(
|
||||
|
||||
+18
-3
@@ -86,7 +86,10 @@ class KiloCliDataParserTest {
|
||||
"id": "msg_1",
|
||||
"sessionID": "ses_123",
|
||||
"role": "assistant",
|
||||
"time": { "created": 1000.0 }
|
||||
"time": { "created": 1000.0 },
|
||||
"summary": {
|
||||
"diffs": [{"file": "src/A.kt", "additions": 3, "deletions": 1, "patch": "@@ ..."}]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -98,6 +101,11 @@ class KiloCliDataParserTest {
|
||||
assertEquals("ses_123", result.sessionID)
|
||||
assertEquals("msg_1", result.info.id)
|
||||
assertEquals("assistant", result.info.role)
|
||||
val diff = result.info.summary?.diffs?.single()
|
||||
assertEquals("src/A.kt", diff?.file)
|
||||
assertEquals(3, diff?.additions)
|
||||
assertEquals(1, diff?.deletions)
|
||||
assertEquals("@@ ...", diff?.patch)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -120,6 +128,7 @@ class KiloCliDataParserTest {
|
||||
assertTrue(result is ChatEventDto.MessageUpdated)
|
||||
assertEquals("ses_456", result.sessionID)
|
||||
assertEquals("user", result.info.role)
|
||||
assertNull(result.info.summary)
|
||||
}
|
||||
|
||||
// ---- parseChatEvent — specific event types ----
|
||||
@@ -1358,11 +1367,14 @@ class KiloCliDataParserTest {
|
||||
fun `parseMessages - user and assistant messages`() {
|
||||
val raw = """[
|
||||
{
|
||||
"info": { "id": "m1", "sessionID": "s1", "role": "user", "time": { "created": 1.0 } },
|
||||
"info": {
|
||||
"id": "m1", "sessionID": "s1", "role": "user", "time": { "created": 1.0 },
|
||||
"summary": { "diffs": [{"file": "src/A.kt", "additions": 2, "deletions": 1, "patch": "@@ patch"}] }
|
||||
},
|
||||
"parts": [{ "id": "p1", "sessionID": "s1", "messageID": "m1", "type": "text", "text": "Hello" }]
|
||||
},
|
||||
{
|
||||
"info": { "id": "m2", "sessionID": "s1", "role": "assistant", "time": { "created": 2.0 } },
|
||||
"info": { "id": "m2", "sessionID": "s1", "role": "assistant", "time": { "created": 2.0 }, "summary": true },
|
||||
"parts": [{ "id": "p2", "sessionID": "s1", "messageID": "m2", "type": "text", "text": "Hi there" }]
|
||||
}
|
||||
]"""
|
||||
@@ -1370,8 +1382,11 @@ class KiloCliDataParserTest {
|
||||
val result = KiloCliDataParser.parseMessages(raw)
|
||||
assertEquals(2, result.size)
|
||||
assertEquals("user", result[0].info.role)
|
||||
assertEquals("src/A.kt", result[0].info.summary?.diffs?.single()?.file)
|
||||
assertEquals("@@ patch", result[0].info.summary?.diffs?.single()?.patch)
|
||||
assertEquals("Hello", result[0].parts[0].text)
|
||||
assertEquals("assistant", result[1].info.role)
|
||||
assertNull(result[1].info.summary)
|
||||
assertEquals("Hi there", result[1].parts[0].text)
|
||||
}
|
||||
|
||||
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
package ai.kilocode.backend.rpc
|
||||
|
||||
import ai.kilocode.rpc.dto.DiffFileDto
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class BranchDiffTest {
|
||||
private fun stat(path: String, status: String = "modified") = DiffFileDto(path, 1, 0, "", status)
|
||||
|
||||
@Test
|
||||
fun `capDiff fills patches in order until the cap is reached`() {
|
||||
val files = listOf(stat("a.txt"), stat("b.txt"), stat("c.txt"))
|
||||
|
||||
val diff = capDiff(files, cap = 5) { "12345" }
|
||||
|
||||
assertEquals(listOf("a.txt", "b.txt", "c.txt"), diff.map { it.file })
|
||||
assertEquals("12345", diff[0].patch)
|
||||
assertEquals("", diff[1].patch)
|
||||
assertEquals("", diff[2].patch)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `capDiff skips one oversized patch and keeps later small patches`() {
|
||||
val fetched = mutableListOf<String>()
|
||||
val files = listOf(stat("big.txt"), stat("small.txt"), stat("tiny.txt"))
|
||||
|
||||
val diff = capDiff(files, cap = 4) { file ->
|
||||
fetched += file.file
|
||||
if (file.file == "big.txt") "0123456789" else "x"
|
||||
}
|
||||
|
||||
assertEquals(listOf("big.txt", "small.txt", "tiny.txt"), fetched)
|
||||
assertEquals(listOf("", "x", "x"), diff.map { it.patch })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `capDiff stops fetching after bounded oversized misses`() {
|
||||
val fetched = mutableListOf<String>()
|
||||
val files = listOf(stat("big1.txt"), stat("big2.txt"), stat("big3.txt"), stat("later.txt"))
|
||||
|
||||
val diff = capDiff(files, cap = 4) { file ->
|
||||
fetched += file.file
|
||||
"0123456789"
|
||||
}
|
||||
|
||||
assertEquals(listOf("big1.txt", "big2.txt", "big3.txt"), fetched)
|
||||
assertEquals(listOf("", "", "", ""), diff.map { it.patch })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `capDiff keeps stats and skips blank patches without exhausting the budget`() {
|
||||
val fetched = mutableListOf<String>()
|
||||
val files = listOf(stat("empty.txt"), stat("kept.txt"))
|
||||
|
||||
val diff = capDiff(files, cap = 10) { file ->
|
||||
fetched += file.file
|
||||
if (file.file == "empty.txt") "" else "patch"
|
||||
}
|
||||
|
||||
assertEquals(listOf("empty.txt", "kept.txt"), fetched)
|
||||
assertEquals("", diff[0].patch)
|
||||
assertEquals("patch", diff[1].patch)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `capDiff dispatches fetch per file so tracked and untracked share the budget`() {
|
||||
val files = listOf(stat("A.kt", "modified"), stat("New.kt", "untracked"))
|
||||
|
||||
val diff = capDiff(files, cap = 100) { file ->
|
||||
if (file.status == "untracked") "untracked-patch" else "tracked-patch"
|
||||
}
|
||||
|
||||
assertEquals("tracked-patch", diff[0].patch)
|
||||
assertEquals("untracked-patch", diff[1].patch)
|
||||
assertEquals("untracked", diff[1].status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parses git numstat output`() {
|
||||
val stats = parseNumstat("1\t2\tsrc/A.kt\n0\t3\tsrc/B.kt\n-\t-\tbin.png\n")
|
||||
|
||||
assertEquals(
|
||||
listOf(
|
||||
DiffStat("src/A.kt", 1, 2),
|
||||
DiffStat("src/B.kt", 0, 3),
|
||||
DiffStat("bin.png", 0, 0),
|
||||
),
|
||||
stats,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parses git name status output`() {
|
||||
val status = parseNameStatus("M\tsrc/A.kt\nA\tsrc/B.kt\nD\tsrc/Old.kt\n??\tsrc/Skip.kt\n")
|
||||
|
||||
assertEquals(
|
||||
mapOf(
|
||||
"src/A.kt" to "modified",
|
||||
"src/B.kt" to "added",
|
||||
"src/Old.kt" to "deleted",
|
||||
),
|
||||
status,
|
||||
)
|
||||
}
|
||||
}
|
||||
+5
@@ -8,6 +8,7 @@ import ai.kilocode.client.session.SessionActivityKind
|
||||
import ai.kilocode.rpc.dto.ChatEventDto
|
||||
import ai.kilocode.rpc.dto.CloudSessionListDto
|
||||
import ai.kilocode.rpc.dto.ConfigUpdateDto
|
||||
import ai.kilocode.rpc.dto.DiffFileDto
|
||||
import ai.kilocode.rpc.dto.MessageWithPartsDto
|
||||
import ai.kilocode.rpc.dto.ModelSelectionDto
|
||||
import ai.kilocode.rpc.dto.PermissionAlwaysRulesDto
|
||||
@@ -214,6 +215,10 @@ class KiloSessionService internal constructor(
|
||||
call { messages(id, dir) }
|
||||
.also { log.debug { "${ChatLogSummary.sid(id)} ${ChatLogSummary.history(it)} ${ChatLogSummary.dir(dir)}" } }
|
||||
|
||||
// Errors propagate so the diff editor can distinguish a real failure (retry link) from "no changes".
|
||||
suspend fun diff(id: String, dir: String): List<DiffFileDto> =
|
||||
call { diff(id, dir) }
|
||||
|
||||
suspend fun attachmentPart(id: String, dir: String, message: String, part: String, key: String?): PartDto? =
|
||||
call { attachmentPart(id, dir, message, part, key) }
|
||||
|
||||
|
||||
+20
@@ -4,6 +4,7 @@ package ai.kilocode.client.app
|
||||
|
||||
import ai.kilocode.rpc.KiloWorkspaceRpcApi
|
||||
import ai.kilocode.rpc.dto.ConfigTargetDto
|
||||
import ai.kilocode.rpc.dto.DiffFileDto
|
||||
import ai.kilocode.rpc.dto.FileSearchResultDto
|
||||
import ai.kilocode.rpc.dto.KiloWorkspaceStateDto
|
||||
import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto
|
||||
@@ -159,6 +160,25 @@ class KiloWorkspaceService internal constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Committed branch changes vs the default-branch merge-base. Errors propagate so the diff editor
|
||||
* can surface a retry (a swallowed failure is indistinguishable from "no changes"); pass
|
||||
* [patches] = false on the badge path to fetch stats only and skip materializing patch text.
|
||||
*/
|
||||
suspend fun branchDiff(directory: String, patches: Boolean = true): List<DiffFileDto> =
|
||||
call { branchDiff(directory, patches) }
|
||||
|
||||
suspend fun branchName(directory: String): String? {
|
||||
return try {
|
||||
call { branchName(directory) }
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
LOG.warn("branch name lookup failed for directory=$directory", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun openPath(directory: String, path: String, line: Int? = null, column: Int? = null): Boolean {
|
||||
val match = files(directory, path).firstOrNull() ?: return false
|
||||
return try {
|
||||
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
package ai.kilocode.client.diff
|
||||
|
||||
import ai.kilocode.client.plugin.KiloBundle
|
||||
import ai.kilocode.rpc.dto.DiffFileDto
|
||||
import com.intellij.diff.DiffContentFactory
|
||||
import com.intellij.diff.requests.DiffRequest
|
||||
import com.intellij.diff.requests.SimpleDiffRequest
|
||||
import com.intellij.diff.util.DiffUserDataKeys
|
||||
import com.intellij.openapi.fileTypes.FileTypeManager
|
||||
import com.intellij.openapi.project.Project
|
||||
|
||||
internal fun diffRequest(
|
||||
project: Project,
|
||||
dto: DiffFileDto,
|
||||
branch: String? = null,
|
||||
labels: Pair<String, String> = KiloBundle.message("diff.editor.side.base") to KiloBundle.message("diff.editor.side.current"),
|
||||
): DiffRequest {
|
||||
val sides = DiffPatchReconstruct.sides(dto)
|
||||
val type = FileTypeManager.getInstance().getFileTypeByFileName(dto.file)
|
||||
val factory = DiffContentFactory.getInstance()
|
||||
val left = when {
|
||||
DiffPatchReconstruct.added(dto.patch) -> factory.createEmpty()
|
||||
sides.renderable -> factory.create(project, sides.before, type)
|
||||
else -> factory.createEmpty()
|
||||
}
|
||||
val right = when {
|
||||
DiffPatchReconstruct.deleted(dto.patch) -> factory.createEmpty()
|
||||
sides.renderable -> factory.create(project, sides.after, type)
|
||||
else -> factory.create(project, dto.patch ?: KiloBundle.message("diff.editor.patch.unavailable"), type)
|
||||
}
|
||||
return SimpleDiffRequest(diffTitle(dto.file, branch), left, right, labels.first, labels.second).also {
|
||||
it.putUserData(DiffUserDataKeys.FORCE_READ_ONLY, true)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun diffTitle(file: String, branch: String?): String {
|
||||
val name = branch.takeIf { !it.isNullOrBlank() } ?: return file
|
||||
return KiloBundle.message("diff.editor.file.title", file, name)
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package ai.kilocode.client.diff
|
||||
|
||||
import ai.kilocode.rpc.dto.DiffFileDto
|
||||
import com.intellij.openapi.vcs.FileStatus
|
||||
|
||||
internal fun fileStatus(dto: DiffFileDto): FileStatus = when (dto.status) {
|
||||
"added" -> FileStatus.ADDED
|
||||
"deleted" -> FileStatus.DELETED
|
||||
"untracked" -> FileStatus.UNKNOWN
|
||||
"modified" -> FileStatus.MODIFIED
|
||||
else -> when {
|
||||
DiffPatchReconstruct.added(dto.patch) -> FileStatus.ADDED
|
||||
DiffPatchReconstruct.deleted(dto.patch) -> FileStatus.DELETED
|
||||
else -> FileStatus.MODIFIED
|
||||
}
|
||||
}
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
package ai.kilocode.client.diff
|
||||
|
||||
import com.intellij.openapi.actionSystem.AnAction
|
||||
import com.intellij.openapi.editor.Editor
|
||||
import com.intellij.openapi.editor.TextAnnotationGutterProvider
|
||||
import com.intellij.openapi.editor.colors.ColorKey
|
||||
import com.intellij.openapi.editor.colors.EditorFontType
|
||||
import com.intellij.ui.EditorTextField
|
||||
import java.awt.Color
|
||||
|
||||
object DiffLineNumbers {
|
||||
data class Row(val old: Int?, val new: Int?)
|
||||
|
||||
private val HUNK = Regex("^@@ -(\\d+)(?:,\\d+)? \\+(\\d+)(?:,\\d+)? @@")
|
||||
|
||||
fun rows(patch: String): List<Row> {
|
||||
val rows = mutableListOf<Pair<String, Row>>()
|
||||
var old = 0
|
||||
var new = 0
|
||||
var hunk = false
|
||||
patch.lineSequence().forEach { line ->
|
||||
// Hunk headers (and any pre-hunk file/VCS headers) are the only lines stripped, matching
|
||||
// pureDiff's hunk-aware body. In-hunk lines are kept verbatim even when they look like a
|
||||
// header (e.g. a deleted "-- " comment renders as "--- ..."), so the counters stay aligned.
|
||||
if (line.startsWith("@@")) {
|
||||
HUNK.find(line)?.let { match ->
|
||||
old = match.groupValues[1].toInt()
|
||||
new = match.groupValues[2].toInt()
|
||||
}
|
||||
hunk = true
|
||||
return@forEach
|
||||
}
|
||||
if (!hunk) return@forEach
|
||||
when {
|
||||
line.startsWith("+") -> rows.add(line to Row(null, new++))
|
||||
line.startsWith("-") -> rows.add(line to Row(old++, null))
|
||||
line.startsWith("\\") -> rows.add(line to Row(null, null))
|
||||
else -> rows.add(line to Row(old++, new++))
|
||||
}
|
||||
}
|
||||
return rows.trimBlankEdges().map { it.second }
|
||||
}
|
||||
|
||||
private fun List<Pair<String, Row>>.trimBlankEdges(): List<Pair<String, Row>> {
|
||||
// isNotEmpty (not isNotBlank) mirrors pureDiff's trim('\n'): a blank context line renders as
|
||||
// a single space that survives the body trim, so an empty-string edge is the only one dropped.
|
||||
val start = indexOfFirst { it.first.isNotEmpty() }
|
||||
if (start < 0) return emptyList()
|
||||
val end = indexOfLast { it.first.isNotEmpty() }
|
||||
return subList(start, end + 1)
|
||||
}
|
||||
}
|
||||
|
||||
fun installDiffGutter(field: EditorTextField, rows: List<DiffLineNumbers.Row>) {
|
||||
val ed = field.getEditor(true) ?: return
|
||||
ed.settings.isLineNumbersShown = false
|
||||
ed.gutter.closeAllAnnotations()
|
||||
ed.gutter.registerTextAnnotation(DiffGutter(rows))
|
||||
}
|
||||
|
||||
private const val FIGURE = '\u2007'
|
||||
|
||||
private class DiffGutter(private val rows: List<DiffLineNumbers.Row>) : TextAnnotationGutterProvider {
|
||||
private val oldWidth = width { it.old }
|
||||
private val newWidth = width { it.new }
|
||||
|
||||
override fun getLineText(line: Int, editor: Editor): String? {
|
||||
val row = rows.getOrNull(line) ?: return null
|
||||
// The gutter paints with a proportional font, so pad with the figure space (digit-width)
|
||||
// to right-align both columns. Each column keeps a fixed width even when a side is blank,
|
||||
// and trailing figure spaces add a right inset before the code text.
|
||||
return "${col(row.old, oldWidth)}$FIGURE${col(row.new, newWidth)}$FIGURE$FIGURE"
|
||||
}
|
||||
|
||||
override fun getToolTip(line: Int, editor: Editor): String? = null
|
||||
|
||||
override fun getStyle(line: Int, editor: Editor): EditorFontType = EditorFontType.PLAIN
|
||||
|
||||
override fun getColor(line: Int, editor: Editor): ColorKey? = null
|
||||
|
||||
override fun getBgColor(line: Int, editor: Editor): Color? = null
|
||||
|
||||
override fun gutterClosed() = Unit
|
||||
|
||||
override fun getPopupActions(line: Int, editor: Editor): List<AnAction>? = null
|
||||
|
||||
override fun useMargin(): Boolean = false
|
||||
|
||||
private fun width(pick: (DiffLineNumbers.Row) -> Int?): Int =
|
||||
rows.mapNotNull(pick).maxOrNull()?.toString()?.length ?: 1
|
||||
|
||||
private fun col(value: Int?, width: Int): String = value?.toString().orEmpty().padStart(width, FIGURE)
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
package ai.kilocode.client.diff
|
||||
|
||||
import ai.kilocode.rpc.dto.DiffFileDto
|
||||
|
||||
internal data class DiffSides(
|
||||
val before: String,
|
||||
val after: String,
|
||||
val renderable: Boolean,
|
||||
)
|
||||
|
||||
internal object DiffPatchReconstruct {
|
||||
private val HUNK = Regex("^@@ -\\d+(?:,(\\d+))? \\+\\d+(?:,(\\d+))? @@")
|
||||
|
||||
fun sides(dto: DiffFileDto): DiffSides {
|
||||
val patch = dto.patch
|
||||
if (patch.isNullOrBlank() || binary(patch)) return DiffSides("", "", false)
|
||||
val before = StringBuilder()
|
||||
val after = StringBuilder()
|
||||
var hunks = 0
|
||||
var oldLen = 0
|
||||
var newLen = 0
|
||||
var oldSeen = 0
|
||||
var newSeen = 0
|
||||
// Drop the trailing empty element that split('\n') yields for a newline-terminated patch (the
|
||||
// usual case for git output). Counting it as a body line would inflate oldSeen/newSeen past the
|
||||
// header lengths and wrongly reject every full-context diff. Mirrors DiffLineNumbers' edge trim;
|
||||
// real blank context lines are " " (space-prefixed), never "", so no content is lost.
|
||||
for (line in patch.split('\n').dropLastWhile { it.isEmpty() }) {
|
||||
if (line.startsWith("@@")) {
|
||||
hunks += 1
|
||||
HUNK.find(line)?.let { match ->
|
||||
oldLen += match.groupValues[1].ifEmpty { "1" }.toInt()
|
||||
newLen += match.groupValues[2].ifEmpty { "1" }.toInt()
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (hunks == 0) continue
|
||||
if (line.startsWith("\\")) continue
|
||||
when (line.firstOrNull()) {
|
||||
' ' -> {
|
||||
before.appendLine(line.substring(1))
|
||||
after.appendLine(line.substring(1))
|
||||
oldSeen += 1
|
||||
newSeen += 1
|
||||
}
|
||||
'-' -> { before.appendLine(line.substring(1)); oldSeen += 1 }
|
||||
'+' -> { after.appendLine(line.substring(1)); newSeen += 1 }
|
||||
else -> {
|
||||
before.appendLine("")
|
||||
after.appendLine("")
|
||||
oldSeen += 1
|
||||
newSeen += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
// Both producers (CLI snapshot and branchDiff) emit a single full-context hunk. A patch with
|
||||
// several hunks, or one whose header lengths don't match the reconstructed body, has elided
|
||||
// context: reconstructing would place every line at the wrong number, so fall back to the
|
||||
// raw-patch view (renderable = false) instead of showing a misaligned side-by-side diff.
|
||||
if (hunks != 1 || oldSeen != oldLen || newSeen != newLen) return DiffSides("", "", false)
|
||||
val left = if (added(patch)) "" else before.toString().removeSuffix("\n")
|
||||
val right = if (deleted(patch)) "" else after.toString().removeSuffix("\n")
|
||||
return DiffSides(left, right, true)
|
||||
}
|
||||
|
||||
fun added(patch: String?): Boolean = patch?.lineSequence()?.any { it == "--- /dev/null" } == true
|
||||
|
||||
fun deleted(patch: String?): Boolean = patch?.lineSequence()?.any { it == "+++ /dev/null" } == true
|
||||
|
||||
private fun binary(patch: String): Boolean = patch.lineSequence().any { it.startsWith("Binary files ") }
|
||||
}
|
||||
+613
@@ -0,0 +1,613 @@
|
||||
package ai.kilocode.client.diff
|
||||
|
||||
import ai.kilocode.client.app.KiloWorkspaceService
|
||||
import ai.kilocode.client.plugin.KiloBundle
|
||||
import ai.kilocode.client.ui.DiffStatBadge
|
||||
import ai.kilocode.client.ui.UiStyle
|
||||
import ai.kilocode.client.ui.layout.Stack
|
||||
import ai.kilocode.rpc.dto.DiffFileDto
|
||||
import com.intellij.diff.chains.DiffRequestProducer
|
||||
import com.intellij.diff.chains.SimpleDiffRequestChain
|
||||
import com.intellij.diff.impl.CacheDiffRequestChainProcessor
|
||||
import com.intellij.diff.impl.DiffRequestProcessorListener
|
||||
import com.intellij.icons.AllIcons
|
||||
import com.intellij.openapi.Disposable
|
||||
import com.intellij.openapi.actionSystem.ActionManager
|
||||
import com.intellij.openapi.actionSystem.ActionPlaces
|
||||
import com.intellij.openapi.actionSystem.ActionUpdateThread
|
||||
import com.intellij.openapi.actionSystem.AnActionEvent
|
||||
import com.intellij.openapi.actionSystem.CommonShortcuts
|
||||
import com.intellij.openapi.actionSystem.DefaultActionGroup
|
||||
import com.intellij.openapi.actionSystem.Separator
|
||||
import com.intellij.openapi.application.ApplicationManager
|
||||
import com.intellij.openapi.application.ModalityState
|
||||
import com.intellij.openapi.components.service
|
||||
import com.intellij.openapi.editor.EditorFactory
|
||||
import com.intellij.openapi.editor.event.DocumentEvent
|
||||
import com.intellij.openapi.editor.event.DocumentListener
|
||||
import com.intellij.openapi.fileEditor.FileDocumentManager
|
||||
import com.intellij.openapi.project.DumbAwareAction
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.openapi.progress.ProgressIndicator
|
||||
import com.intellij.openapi.util.Disposer
|
||||
import com.intellij.openapi.util.Key
|
||||
import com.intellij.openapi.util.UserDataHolder
|
||||
import com.intellij.openapi.vcs.FileStatus
|
||||
import com.intellij.openapi.vfs.AsyncFileListener
|
||||
import com.intellij.openapi.vfs.VirtualFileManager
|
||||
import com.intellij.openapi.vfs.newvfs.events.VFileEvent
|
||||
import com.intellij.ui.IdeBorderFactory
|
||||
import com.intellij.ui.EditorNotificationPanel
|
||||
import com.intellij.ui.OnePixelSplitter
|
||||
import com.intellij.ui.PopupHandler
|
||||
import com.intellij.ui.SideBorder
|
||||
import com.intellij.ui.SimpleColoredComponent
|
||||
import com.intellij.ui.SimpleTextAttributes
|
||||
import com.intellij.ui.TreeSpeedSearch
|
||||
import com.intellij.ui.components.JBLabel
|
||||
import com.intellij.ui.components.JBScrollPane
|
||||
import com.intellij.ui.treeStructure.Tree
|
||||
import com.intellij.util.concurrency.annotations.RequiresEdt
|
||||
import com.intellij.util.ui.JBUI
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.awt.BorderLayout
|
||||
import java.awt.Color
|
||||
import java.awt.Component
|
||||
import java.nio.file.InvalidPathException
|
||||
import java.nio.file.Path
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import javax.swing.Icon
|
||||
import javax.swing.JComponent
|
||||
import javax.swing.JPanel
|
||||
import javax.swing.ScrollPaneConstants
|
||||
import javax.swing.JViewport
|
||||
import javax.swing.JTree
|
||||
import javax.swing.tree.DefaultMutableTreeNode
|
||||
import javax.swing.tree.DefaultTreeModel
|
||||
import javax.swing.tree.TreeCellRenderer
|
||||
import javax.swing.tree.TreeModel
|
||||
import javax.swing.tree.TreeNode
|
||||
import javax.swing.tree.TreePath
|
||||
|
||||
@RequiresEdt
|
||||
internal fun buildDiffEditor(
|
||||
project: Project,
|
||||
params: Map<String, String>,
|
||||
files: List<DiffFileDto>,
|
||||
parent: Disposable,
|
||||
branch: String? = null,
|
||||
scope: CoroutineScope,
|
||||
refresh: ((DiffEditorData) -> Unit) -> Job,
|
||||
replace: (DiffEditorData) -> Unit,
|
||||
): JComponent = DiffEditorView(project, params, files, parent, branch, scope, refresh, replace).component
|
||||
|
||||
internal val DIFF_FILE_KEY: Key<String> = Key.create("kilo.diff.file")
|
||||
|
||||
internal class DiffEditorView(
|
||||
private val project: Project,
|
||||
private val params: Map<String, String>,
|
||||
initial: List<DiffFileDto>,
|
||||
private val parent: Disposable,
|
||||
branch: String?,
|
||||
private val scope: CoroutineScope,
|
||||
private val load: ((DiffEditorData) -> Unit) -> Job,
|
||||
private val replace: (DiffEditorData) -> Unit,
|
||||
) : Disposable {
|
||||
private val disposed = AtomicBoolean(false)
|
||||
private val outdated = AtomicBoolean(false)
|
||||
private val refreshing = AtomicBoolean(false)
|
||||
private val tree = buildFileTree(initial)
|
||||
private val badge = DiffStatBadge(0, 0, inset = UiStyle.Gap.pad())
|
||||
private val splitter = OnePixelSplitter(false, 0.25f)
|
||||
private val select = Debouncer<Int>(scope, parent) { show(it) }
|
||||
private val banner = EditorNotificationPanel(EditorNotificationPanel.Status.Warning).apply {
|
||||
text(KiloBundle.message("diff.editor.outdated"))
|
||||
createActionLabel(KiloBundle.message("diff.editor.refresh")) { refresh() }
|
||||
isVisible = false
|
||||
}
|
||||
private val root = JPanel(BorderLayout()).apply {
|
||||
add(banner, BorderLayout.NORTH)
|
||||
add(splitter, BorderLayout.CENTER)
|
||||
}
|
||||
private var files = initial
|
||||
private var branch = branch
|
||||
private var syncing = false
|
||||
private var requested: String? = initial.firstOrNull()?.file
|
||||
private var refreshJob: Job? = null
|
||||
private var processor = processor(initial, selected(initial.firstOrNull()?.file))
|
||||
private val openFileAction = object : DumbAwareAction(
|
||||
KiloBundle.message("diff.editor.openFile"),
|
||||
KiloBundle.message("diff.editor.openFile"),
|
||||
AllIcons.Actions.EditSource,
|
||||
) {
|
||||
override fun getActionUpdateThread() = ActionUpdateThread.EDT
|
||||
|
||||
override fun update(e: AnActionEvent) {
|
||||
val file = selectedFile()
|
||||
e.presentation.isEnabled = file != null && fileStatus(file) != FileStatus.DELETED && path(file) != null
|
||||
}
|
||||
|
||||
override fun actionPerformed(e: AnActionEvent) {
|
||||
val file = selectedFile() ?: return
|
||||
val path = path(file) ?: return
|
||||
scope.launch { service<KiloWorkspaceService>().openFile(path) }
|
||||
}
|
||||
}
|
||||
val component: JComponent = root
|
||||
|
||||
init {
|
||||
Disposer.register(parent, this)
|
||||
Disposer.register(parent, processor)
|
||||
tree.addTreeSelectionListener {
|
||||
if (syncing) return@addTreeSelectionListener
|
||||
val file = selectedFile() ?: return@addTreeSelectionListener
|
||||
val index = files.indexOfFirst { it.file == file.file }
|
||||
if (index >= 0) select.request(index)
|
||||
}
|
||||
openFileAction.registerCustomShortcutSet(CommonShortcuts.getEditSource(), tree)
|
||||
installMenu()
|
||||
// Tie the listener to the processor it observes, not to the long-lived parent: applyFiles
|
||||
// disposes the old processor on each refresh, and registering under parent would leak a
|
||||
// removal hook (holding the dead processor) for every refresh across the editor's lifetime.
|
||||
processor.addListener(DiffRequestProcessorListener { syncTree() }, processor)
|
||||
splitter.firstComponent = buildTreePanel(tree, initial, badge, processor.component, ::refresh)
|
||||
splitter.secondComponent = processor.component
|
||||
processor.updateRequest()
|
||||
applyBadge(initial)
|
||||
select(initial.firstOrNull()?.file)
|
||||
listen()
|
||||
}
|
||||
|
||||
override fun dispose() {
|
||||
disposed.set(true)
|
||||
// Bind the refresh coroutine to this view's lifecycle (load already does so via `parent`): an
|
||||
// in-flight refresh started just before the editor closes would otherwise keep running on the
|
||||
// project scope, holding the `done` closure and through it this view, its tree, and processor.
|
||||
refreshJob?.cancel()
|
||||
refreshJob = null
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
fun applyFiles(next: List<DiffFileDto>, nextBranch: String? = branch) {
|
||||
if (same(files, next) && branch == nextBranch) return
|
||||
val path = selectedFile()?.file ?: activePath() ?: files.firstOrNull()?.file
|
||||
val index = selected(path, next)
|
||||
val old = processor
|
||||
files = next
|
||||
branch = nextBranch
|
||||
requested = next.getOrNull(index)?.file
|
||||
tree.model = buildFileModel(next)
|
||||
expandAll(tree)
|
||||
processor = processor(next, index)
|
||||
Disposer.register(parent, processor)
|
||||
processor.addListener(DiffRequestProcessorListener { syncTree() }, processor)
|
||||
splitter.firstComponent = buildTreePanel(tree, next, badge, processor.component, ::refresh)
|
||||
splitter.secondComponent = processor.component
|
||||
processor.updateRequest()
|
||||
Disposer.dispose(old)
|
||||
applyBadge(next)
|
||||
select(next.getOrNull(index)?.file)
|
||||
root.revalidate()
|
||||
root.repaint()
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
internal fun refresh() {
|
||||
if (disposed.get() || project.isDisposed) return
|
||||
if (!refreshing.compareAndSet(false, true)) return
|
||||
saveDocuments()
|
||||
outdated.set(false)
|
||||
banner.isVisible = false
|
||||
root.revalidate()
|
||||
root.repaint()
|
||||
refreshJob?.cancel()
|
||||
refreshJob = load { data ->
|
||||
refreshing.set(false)
|
||||
if (!disposed.get() && !project.isDisposed) {
|
||||
if (data is DiffEditorData.Files) applyFiles(data.files, data.branch)
|
||||
if (data !is DiffEditorData.Files) replace(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun markOutdated() {
|
||||
if (!outdated.compareAndSet(false, true)) return
|
||||
ApplicationManager.getApplication().invokeLater({ showOutdated() }, ModalityState.any()) {
|
||||
disposed.get() || project.isDisposed
|
||||
}
|
||||
}
|
||||
|
||||
private fun show(index: Int) {
|
||||
if (disposed.get() || project.isDisposed || index !in files.indices) return
|
||||
val path = files[index].file
|
||||
if (activePath() == path) {
|
||||
requested = null
|
||||
return
|
||||
}
|
||||
requested = path
|
||||
processor.setCurrentRequest(index)
|
||||
}
|
||||
|
||||
private fun installMenu() {
|
||||
val group = DefaultActionGroup(
|
||||
openFileAction,
|
||||
Separator.getInstance(),
|
||||
TreeAction(KiloBundle.message("diff.editor.refresh"), AllIcons.Actions.Refresh, ::refresh),
|
||||
)
|
||||
PopupHandler.installPopupMenu(tree, group, ActionPlaces.POPUP)
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
private fun showOutdated() {
|
||||
if (disposed.get() || project.isDisposed) return
|
||||
banner.isVisible = true
|
||||
root.revalidate()
|
||||
root.repaint()
|
||||
}
|
||||
|
||||
private fun listen() {
|
||||
val dir = params["directory"] ?: return
|
||||
val root = clean(dir) ?: return
|
||||
EditorFactory.getInstance().eventMulticaster.addDocumentListener(
|
||||
object : DocumentListener {
|
||||
override fun documentChanged(event: DocumentEvent) {
|
||||
val file = FileDocumentManager.getInstance().getFile(event.document) ?: return
|
||||
if (inside(root, file.path)) markOutdated()
|
||||
}
|
||||
},
|
||||
parent,
|
||||
)
|
||||
VirtualFileManager.getInstance().addAsyncFileListenerBackgroundable(
|
||||
object : AsyncFileListener {
|
||||
override fun prepareChange(events: List<VFileEvent>): AsyncFileListener.ChangeApplier? {
|
||||
if (outdated.get()) return null
|
||||
if (events.none { inside(root, it.path) }) return null
|
||||
return object : AsyncFileListener.ChangeApplier {
|
||||
override fun afterVfsChange() {
|
||||
markOutdated()
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
parent,
|
||||
)
|
||||
}
|
||||
|
||||
private fun saveDocuments() {
|
||||
val dir = params["directory"] ?: return
|
||||
val root = clean(dir) ?: return
|
||||
val manager = FileDocumentManager.getInstance()
|
||||
manager.saveDocuments { doc ->
|
||||
val file = manager.getFile(doc) ?: return@saveDocuments false
|
||||
inside(root, file.path)
|
||||
}
|
||||
}
|
||||
|
||||
private fun processor(next: List<DiffFileDto>, index: Int): CacheDiffRequestChainProcessor {
|
||||
val producers = next.map { file -> producer(file) }
|
||||
val chain = SimpleDiffRequestChain.fromProducers(producers, index.coerceIn(0, (next.size - 1).coerceAtLeast(0)))
|
||||
return CacheDiffRequestChainProcessor(project, chain)
|
||||
}
|
||||
|
||||
private fun producer(file: DiffFileDto): DiffRequestProducer = object : DiffRequestProducer {
|
||||
override fun getName(): String = file.file
|
||||
|
||||
override fun process(context: UserDataHolder, indicator: ProgressIndicator) = diffRequest(project, file, branch, labels()).also {
|
||||
it.putUserData(DIFF_FILE_KEY, file.file)
|
||||
}
|
||||
}
|
||||
|
||||
private fun labels(): Pair<String, String> {
|
||||
if (params["source"] == "branch") {
|
||||
return KiloBundle.message("diff.editor.side.base") to KiloBundle.message("diff.editor.side.current")
|
||||
}
|
||||
return KiloBundle.message("diff.editor.side.original") to KiloBundle.message("diff.editor.side.modified")
|
||||
}
|
||||
|
||||
private fun syncTree() {
|
||||
if (disposed.get()) return
|
||||
val path = activePath() ?: return
|
||||
val target = reverseSyncTarget(path, requested, selectedFile()?.file)
|
||||
if (path == requested) requested = null
|
||||
if (target == null) return
|
||||
select(target)
|
||||
}
|
||||
|
||||
private fun path(file: DiffFileDto): String? {
|
||||
if (fileStatus(file) == FileStatus.DELETED) return null
|
||||
val dir = params["directory"] ?: return null
|
||||
val root = clean(dir) ?: return null
|
||||
return try {
|
||||
val raw = Path.of(file.file)
|
||||
val path = (if (raw.isAbsolute) raw else root.resolve(raw)).normalize()
|
||||
// Constrain "open file" to the diff's directory: reject a server-supplied entry that
|
||||
// escapes via `..` or an absolute path outside the base rather than opening it blindly.
|
||||
if (!path.startsWith(root)) return null
|
||||
path.toString()
|
||||
} catch (_: InvalidPathException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun select(path: String?) {
|
||||
if (path == null) return
|
||||
syncing = true
|
||||
selectTreeNode(tree, path)
|
||||
syncing = false
|
||||
}
|
||||
|
||||
private fun activePath(): String? = processor.activeRequest?.getUserData(DIFF_FILE_KEY)
|
||||
|
||||
private fun selectedFile(): DiffFileDto? {
|
||||
val node = tree.lastSelectedPathComponent as? DefaultMutableTreeNode ?: return null
|
||||
return (node.userObject as? Node)?.file
|
||||
}
|
||||
|
||||
private fun applyBadge(next: List<DiffFileDto>) {
|
||||
badge.update(next.sumOf { it.additions }, next.sumOf { it.deletions })
|
||||
}
|
||||
|
||||
private fun selected(path: String?, next: List<DiffFileDto> = files): Int {
|
||||
val index = next.indexOfFirst { it.file == path }
|
||||
if (index >= 0) return index
|
||||
return 0
|
||||
}
|
||||
|
||||
private fun same(a: List<DiffFileDto>, b: List<DiffFileDto>): Boolean = a == b
|
||||
|
||||
private fun clean(dir: String): Path? = try {
|
||||
Path.of(dir).normalize()
|
||||
} catch (_: InvalidPathException) {
|
||||
null
|
||||
}
|
||||
|
||||
private fun inside(root: Path, raw: String): Boolean = try {
|
||||
val path = Path.of(raw).normalize()
|
||||
if (!path.startsWith(root)) return false
|
||||
val rel = root.relativize(path).toString().replace('\\', '/')
|
||||
if (rel == ".git/HEAD" || rel.startsWith(".git/refs/")) return true
|
||||
rel != ".git" && !rel.startsWith(".git/")
|
||||
} catch (_: InvalidPathException) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
internal fun reverseSyncTarget(active: String?, requested: String?, selected: String?): String? {
|
||||
if (active == null) return null
|
||||
if (requested != null) return null
|
||||
if (active == selected) return null
|
||||
return active
|
||||
}
|
||||
|
||||
private class Debouncer<T>(
|
||||
private val scope: CoroutineScope,
|
||||
parent: Disposable,
|
||||
private val delay: Long = 300,
|
||||
private val action: suspend (T) -> Unit,
|
||||
) {
|
||||
private var job: Job? = null
|
||||
|
||||
init {
|
||||
Disposer.register(parent) { job?.cancel() }
|
||||
}
|
||||
|
||||
fun request(value: T) {
|
||||
job?.cancel()
|
||||
job = scope.launch {
|
||||
delay(delay)
|
||||
withContext(Dispatchers.Main) { action(value) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
internal fun emptyChangesComponent(): JComponent = JPanel(BorderLayout()).apply {
|
||||
add(com.intellij.ui.components.JBLabel(KiloBundle.message("diff.editor.empty")), BorderLayout.CENTER)
|
||||
}
|
||||
|
||||
private fun buildFileTree(files: List<DiffFileDto>): Tree {
|
||||
val tree = DiffTree(buildFileModel(files)).apply {
|
||||
isRootVisible = false
|
||||
showsRootHandles = true
|
||||
isOpaque = true
|
||||
cellRenderer = Renderer()
|
||||
}
|
||||
TreeSpeedSearch(tree) { path ->
|
||||
val node = path.lastPathComponent as? DefaultMutableTreeNode
|
||||
(node?.userObject as? Node)?.name.orEmpty()
|
||||
}
|
||||
expandAll(tree)
|
||||
return tree
|
||||
}
|
||||
|
||||
private fun buildFileModel(files: List<DiffFileDto>): DefaultTreeModel {
|
||||
val root = DefaultMutableTreeNode(Node("", "", true, null))
|
||||
for (file in files) addFile(root, file)
|
||||
updateStats(root)
|
||||
return DefaultTreeModel(root)
|
||||
}
|
||||
|
||||
private fun buildTreePanel(tree: Tree, files: List<DiffFileDto>, badge: DiffStatBadge, target: JComponent, refresh: () -> Unit): JComponent {
|
||||
val toolbar = ActionManager.getInstance().createActionToolbar(
|
||||
ActionPlaces.TOOLBAR,
|
||||
treeToolbarGroup(tree, refresh),
|
||||
true,
|
||||
)
|
||||
toolbar.targetComponent = target
|
||||
toolbar.component.background = JBUI.CurrentTheme.ToolWindow.background()
|
||||
toolbar.updateActionsImmediately()
|
||||
val row = object : JPanel(BorderLayout()) {
|
||||
override fun getBackground(): Color = JBUI.CurrentTheme.ToolWindow.background()
|
||||
}.apply {
|
||||
border = IdeBorderFactory.createBorder(SideBorder.BOTTOM)
|
||||
add(toolbar.component, BorderLayout.WEST)
|
||||
badge.update(files.sumOf { it.additions }, files.sumOf { it.deletions })
|
||||
add(
|
||||
Stack.horizontal(gap = UiStyle.Gap.sm()).apply {
|
||||
border = JBUI.Borders.empty(0, 0, 0, UiStyle.Gap.pad())
|
||||
next(JBLabel(fileCount(files.size)).apply { foreground = UiStyle.Colors.weak() })
|
||||
next(badge)
|
||||
},
|
||||
BorderLayout.EAST,
|
||||
)
|
||||
}
|
||||
return object : JPanel(BorderLayout()) {
|
||||
override fun getBackground(): Color = JBUI.CurrentTheme.ToolWindow.background()
|
||||
}.apply {
|
||||
add(row, BorderLayout.NORTH)
|
||||
add(
|
||||
JBScrollPane(tree).apply {
|
||||
border = JBUI.Borders.empty()
|
||||
viewportBorder = JBUI.Borders.empty()
|
||||
horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER
|
||||
},
|
||||
BorderLayout.CENTER,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun treeToolbarGroup(tree: Tree, refresh: () -> Unit) = DefaultActionGroup(
|
||||
TreeAction(KiloBundle.message("diff.editor.refresh"), AllIcons.Actions.Refresh, refresh),
|
||||
Separator.getInstance(),
|
||||
TreeAction(KiloBundle.message("diff.editor.tree.expandAll"), AllIcons.Actions.Expandall) { expandAll(tree) },
|
||||
TreeAction(KiloBundle.message("diff.editor.tree.collapseAll"), AllIcons.Actions.Collapseall) { collapseAll(tree) },
|
||||
)
|
||||
|
||||
private fun fileCount(count: Int): String = KiloBundle.message(
|
||||
if (count == 1) "session.changes.count.one" else "session.changes.count.other",
|
||||
count,
|
||||
)
|
||||
|
||||
private fun expandAll(tree: Tree) {
|
||||
var i = 0
|
||||
while (i < tree.rowCount) {
|
||||
tree.expandRow(i)
|
||||
i += 1
|
||||
}
|
||||
}
|
||||
|
||||
private fun collapseAll(tree: Tree) {
|
||||
for (i in tree.rowCount - 1 downTo 0) tree.collapseRow(i)
|
||||
}
|
||||
|
||||
private fun addFile(root: DefaultMutableTreeNode, file: DiffFileDto) {
|
||||
var node = root
|
||||
val parts = file.file.split('/').filter { it.isNotBlank() }
|
||||
for ((index, part) in parts.withIndex()) {
|
||||
val path = parts.take(index + 1).joinToString("/")
|
||||
val leaf = index == parts.lastIndex
|
||||
val child = child(node, path) ?: DefaultMutableTreeNode(Node(part, path, !leaf, if (leaf) file else null)).also(node::add)
|
||||
node = child
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateStats(node: DefaultMutableTreeNode): Stats {
|
||||
val item = node.userObject as? Node ?: return Stats(0, 0)
|
||||
if (item.file != null) return Stats(item.additions, item.deletions)
|
||||
val stats = (0 until node.childCount)
|
||||
.map { updateStats(node.getChildAt(it) as? DefaultMutableTreeNode ?: return@map Stats(0, 0)) }
|
||||
.fold(Stats(0, 0)) { acc, child -> Stats(acc.additions + child.additions, acc.deletions + child.deletions) }
|
||||
item.additions = stats.additions
|
||||
item.deletions = stats.deletions
|
||||
return stats
|
||||
}
|
||||
|
||||
private fun child(node: DefaultMutableTreeNode, path: String): DefaultMutableTreeNode? {
|
||||
for (i in 0 until node.childCount) {
|
||||
val child = node.getChildAt(i) as? DefaultMutableTreeNode ?: continue
|
||||
if ((child.userObject as? Node)?.path == path) return child
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun selectTreeNode(tree: Tree, path: String) {
|
||||
val node = find(tree.model.root as? DefaultMutableTreeNode ?: return, path) ?: return
|
||||
val selection = TreePath(node.path)
|
||||
tree.selectionPath = selection
|
||||
tree.scrollPathToVisible(selection)
|
||||
}
|
||||
|
||||
private fun find(node: DefaultMutableTreeNode, path: String): DefaultMutableTreeNode? {
|
||||
if ((node.userObject as? Node)?.path == path) return node
|
||||
for (i in 0 until node.childCount) {
|
||||
val found = find(node.getChildAt(i) as? DefaultMutableTreeNode ?: continue, path)
|
||||
if (found != null) return found
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private data class Stats(val additions: Int, val deletions: Int)
|
||||
|
||||
private class Node(val name: String, val path: String, val dir: Boolean, val file: DiffFileDto?) {
|
||||
var additions: Int = file?.additions ?: 0
|
||||
var deletions: Int = file?.deletions ?: 0
|
||||
}
|
||||
|
||||
private class DiffTree(model: TreeModel) : Tree(model) {
|
||||
override fun getBackground(): Color = JBUI.CurrentTheme.ToolWindow.background()
|
||||
|
||||
override fun getScrollableTracksViewportHeight(): Boolean {
|
||||
val view = parent as? JViewport ?: return super.getScrollableTracksViewportHeight()
|
||||
return preferredSize.height < view.height || super.getScrollableTracksViewportHeight()
|
||||
}
|
||||
}
|
||||
|
||||
private class Renderer : JPanel(BorderLayout()), TreeCellRenderer {
|
||||
private val text = SimpleColoredComponent()
|
||||
private val badge = DiffStatBadge(0, 0, DiffStatBadge.Variant.COMPACT)
|
||||
|
||||
init {
|
||||
UiStyle.Components.transparent(this, text)
|
||||
border = JBUI.Borders.empty(0, UiStyle.Gap.sm(), 0, UiStyle.Gap.xl())
|
||||
add(text, BorderLayout.CENTER)
|
||||
add(badge, BorderLayout.EAST)
|
||||
}
|
||||
|
||||
override fun getTreeCellRendererComponent(
|
||||
tree: JTree,
|
||||
value: Any?,
|
||||
selected: Boolean,
|
||||
expanded: Boolean,
|
||||
leaf: Boolean,
|
||||
row: Int,
|
||||
hasFocus: Boolean,
|
||||
): Component {
|
||||
val node = value as? DefaultMutableTreeNode
|
||||
val item = node?.userObject as? Node
|
||||
text.clear()
|
||||
text.icon = if (item?.dir == true) AllIcons.Nodes.Folder else AllIcons.FileTypes.Text
|
||||
val name = item?.name?.ifBlank { item.path }.orEmpty()
|
||||
val color = item?.file?.let(::fileStatus)?.color
|
||||
if (color == null) text.append(name) else text.append(name, SimpleTextAttributes(SimpleTextAttributes.STYLE_PLAIN, color))
|
||||
val changed = item != null && (item.additions != 0 || item.deletions != 0)
|
||||
badge.isVisible = changed
|
||||
if (changed) badge.update(item.additions, item.deletions)
|
||||
return this
|
||||
}
|
||||
}
|
||||
|
||||
private class TreeAction(
|
||||
text: String,
|
||||
icon: Icon,
|
||||
private val action: () -> Unit,
|
||||
) : DumbAwareAction(text, text, icon) {
|
||||
override fun getActionUpdateThread() = ActionUpdateThread.EDT
|
||||
|
||||
override fun actionPerformed(e: AnActionEvent) = action()
|
||||
}
|
||||
|
||||
private val TreeNode.path: Array<TreeNode>
|
||||
get() {
|
||||
val list = mutableListOf<TreeNode>()
|
||||
var node: TreeNode? = this
|
||||
while (node != null) {
|
||||
list += node
|
||||
node = node.parent
|
||||
}
|
||||
return list.asReversed().toTypedArray()
|
||||
}
|
||||
+207
@@ -0,0 +1,207 @@
|
||||
package ai.kilocode.client.diff
|
||||
|
||||
import ai.kilocode.client.app.KiloAppService
|
||||
import ai.kilocode.client.app.KiloSessionService
|
||||
import ai.kilocode.client.app.KiloWorkspaceService
|
||||
import ai.kilocode.client.plugin.KiloBundle
|
||||
import ai.kilocode.client.ui.UiStyle
|
||||
import ai.kilocode.client.ui.layout.Stack
|
||||
import ai.kilocode.client.vfs.KiloEditorKind
|
||||
import ai.kilocode.client.vfs.KiloEditorKindRegistry
|
||||
import ai.kilocode.client.vfs.KiloVirtualFile
|
||||
import ai.kilocode.log.KiloLog
|
||||
import ai.kilocode.rpc.dto.DiffFileDto
|
||||
import ai.kilocode.rpc.dto.KiloAppStatusDto
|
||||
import com.intellij.openapi.Disposable
|
||||
import com.intellij.openapi.components.Service
|
||||
import com.intellij.openapi.components.service
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.openapi.util.Disposer
|
||||
import com.intellij.ui.AnimatedIcon
|
||||
import com.intellij.ui.components.ActionLink
|
||||
import com.intellij.ui.components.JBLabel
|
||||
import com.intellij.util.concurrency.annotations.RequiresEdt
|
||||
import com.intellij.util.ui.Centerizer
|
||||
import com.intellij.util.ui.JBUI
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.awt.BorderLayout
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import javax.swing.JComponent
|
||||
import javax.swing.JPanel
|
||||
|
||||
internal object KiloDiffEditorKind : KiloEditorKind {
|
||||
const val ID = "kilo-diff"
|
||||
|
||||
override val id: String = ID
|
||||
|
||||
override fun title(params: Map<String, String>): String {
|
||||
return params["title"].takeIfPresent()
|
||||
?: params["branch"].takeIfPresent()?.let { KiloBundle.message("diff.editor.branch.title.named", it) }
|
||||
?: KiloBundle.message(if (params["source"] == "branch") "diff.editor.branch.title" else "diff.editor.session.title")
|
||||
}
|
||||
|
||||
override fun presentablePath(params: Map<String, String>): String = title(params)
|
||||
|
||||
override fun isValid(params: Map<String, String>): Boolean {
|
||||
val dir = params["directory"].takeIfPresent() ?: return false
|
||||
if (dir.isBlank()) return false
|
||||
if (params["source"] == "branch") return true
|
||||
if (params["source"] == "inline") return params["token"].takeIfPresent() != null
|
||||
return params["sessionId"].takeIfPresent() != null
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
override fun createContent(project: Project, file: KiloVirtualFile, parent: Disposable): JComponent {
|
||||
val panel = JPanel(BorderLayout())
|
||||
panel.add(connecting(), BorderLayout.CENTER)
|
||||
val service = project.service<KiloDiffEditorService>()
|
||||
var current: Disposable? = null
|
||||
fun render(data: DiffEditorData) {
|
||||
current?.let { Disposer.dispose(it) }
|
||||
val child = Disposer.newDisposable(parent, "Kilo diff editor content")
|
||||
current = child
|
||||
panel.removeAll()
|
||||
panel.add(
|
||||
when (data) {
|
||||
DiffEditorData.Connecting -> connecting()
|
||||
DiffEditorData.Empty -> emptyChangesComponent()
|
||||
is DiffEditorData.Error -> failed(data.message)
|
||||
is DiffEditorData.Files -> buildDiffEditor(
|
||||
project,
|
||||
file.path.params,
|
||||
data.files,
|
||||
child,
|
||||
data.branch,
|
||||
service.scope,
|
||||
{ done -> service.refresh(file.path.params, done) },
|
||||
::render,
|
||||
)
|
||||
},
|
||||
BorderLayout.CENTER,
|
||||
)
|
||||
panel.revalidate()
|
||||
panel.repaint()
|
||||
}
|
||||
service.load(file.path.params, parent, ::render)
|
||||
return panel
|
||||
}
|
||||
}
|
||||
|
||||
@Service(Service.Level.PROJECT)
|
||||
internal class KiloDiffEditorService(
|
||||
private val project: Project,
|
||||
private val cs: CoroutineScope,
|
||||
) {
|
||||
internal val scope: CoroutineScope
|
||||
get() = cs
|
||||
|
||||
fun load(params: Map<String, String>, parent: Disposable, done: (DiffEditorData) -> Unit) {
|
||||
val disposed = AtomicBoolean(false)
|
||||
val job = cs.launch {
|
||||
val app = service<KiloAppService>()
|
||||
app.connect()
|
||||
withContext(Dispatchers.Main) {
|
||||
if (alive(disposed)) done(DiffEditorData.Connecting)
|
||||
}
|
||||
val state = app.state.first { it.status == KiloAppStatusDto.READY || it.status == KiloAppStatusDto.ERROR }
|
||||
if (state.status == KiloAppStatusDto.ERROR) {
|
||||
withContext(Dispatchers.Main) {
|
||||
if (alive(disposed)) done(DiffEditorData.Error(KiloBundle.message("session.connection.error.app")))
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
val data = runCatching { fetch(params) }
|
||||
.getOrElse {
|
||||
if (it is CancellationException) throw it
|
||||
LOG.warn("diff editor load failed source=${params["source"]} dir=${params["directory"]}", it)
|
||||
DiffEditorData.Error(it.message ?: it::class.java.simpleName)
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
if (alive(disposed)) done(data)
|
||||
}
|
||||
}
|
||||
Disposer.register(parent) {
|
||||
disposed.set(true)
|
||||
job.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
fun refresh(params: Map<String, String>, done: (DiffEditorData) -> Unit) = cs.launch {
|
||||
val data = runCatching { fetch(params) }
|
||||
.getOrElse {
|
||||
if (it is CancellationException) throw it
|
||||
LOG.warn("diff editor refresh failed source=${params["source"]} dir=${params["directory"]}", it)
|
||||
DiffEditorData.Error(it.message ?: it::class.java.simpleName)
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
if (!project.isDisposed) done(data)
|
||||
}
|
||||
}
|
||||
|
||||
private fun alive(disposed: AtomicBoolean): Boolean = !project.isDisposed && !disposed.get()
|
||||
|
||||
internal suspend fun fetch(params: Map<String, String>): DiffEditorData {
|
||||
val dir = params["directory"].takeIfPresent() ?: return DiffEditorData.Empty
|
||||
val workspace = service<KiloWorkspaceService>()
|
||||
val store = project.service<KiloInlineDiffStore>()
|
||||
val files = when (params["source"]) {
|
||||
// branch is authoritative here (no store seeding): recompute on every load/refresh so a
|
||||
// re-open or Refresh always reflects the current worktree instead of a stale click seed.
|
||||
"branch" -> workspace.branchDiff(dir)
|
||||
"inline" -> store.get(params["token"].orEmpty()).orEmpty()
|
||||
else -> project.service<KiloSessionService>().diff(params["sessionId"].orEmpty(), dir)
|
||||
}
|
||||
if (files.isEmpty()) return DiffEditorData.Empty
|
||||
val branch = params["branch"].takeIfPresent()
|
||||
?: if (params["source"] == "branch") workspace.branchName(dir) else null
|
||||
return DiffEditorData.Files(files, branch)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
private val LOG = KiloLog.create(KiloDiffEditorService::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed interface DiffEditorData {
|
||||
data object Connecting : DiffEditorData
|
||||
data object Empty : DiffEditorData
|
||||
data class Error(val message: String) : DiffEditorData
|
||||
data class Files(val files: List<DiffFileDto>, val branch: String? = null) : DiffEditorData
|
||||
}
|
||||
|
||||
internal fun diffParams(source: String, directory: String, sessionId: String?, title: String, branch: String? = null, token: String? = null): Map<String, String> =
|
||||
linkedMapOf(
|
||||
"source" to source,
|
||||
"directory" to directory,
|
||||
"title" to title,
|
||||
).apply {
|
||||
if (!sessionId.isNullOrBlank()) put("sessionId", sessionId)
|
||||
if (!branch.isNullOrBlank()) put("branch", branch)
|
||||
if (!token.isNullOrBlank()) put("token", token)
|
||||
}
|
||||
|
||||
fun ensureDiffEditorKind() {
|
||||
service<KiloEditorKindRegistry>().register(KiloDiffEditorKind)
|
||||
}
|
||||
|
||||
private fun connecting(): JComponent = Stack.horizontal(gap = UiStyle.Gap.sm()).apply {
|
||||
border = JBUI.Borders.empty(UiStyle.Gap.pad())
|
||||
next(JBLabel(AnimatedIcon.Default()))
|
||||
next(JBLabel(KiloBundle.message("session.connection.connecting")))
|
||||
}.let { Centerizer(it, Centerizer.TYPE.BOTH) }
|
||||
|
||||
private fun failed(message: String): JComponent = Stack.horizontal(gap = UiStyle.Gap.sm()).apply {
|
||||
border = JBUI.Borders.empty(UiStyle.Gap.pad())
|
||||
next(JBLabel(message))
|
||||
next(ActionLink(KiloBundle.message("session.connection.retry")) {
|
||||
service<KiloAppService>().retryAsync()
|
||||
})
|
||||
}.let { Centerizer(it, Centerizer.TYPE.BOTH) }
|
||||
|
||||
private fun String?.takeIfPresent(): String? = takeIf { !it.isNullOrBlank() }
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
package ai.kilocode.client.diff
|
||||
|
||||
import ai.kilocode.rpc.dto.DiffFileDto
|
||||
import com.intellij.openapi.components.Service
|
||||
import java.util.Collections
|
||||
|
||||
/**
|
||||
* Hands off the diff payload for a "Open in Diff Viewer" click to the editor that opens for it.
|
||||
*
|
||||
* Bounded by a small access-ordered LRU: this is a project-level service, so without eviction every
|
||||
* click would retain the full patch text of its turn for the IDE session's lifetime. [MAX] entries is
|
||||
* ample for the handful of diff editors a user keeps open, and the eldest entry is dropped after that.
|
||||
*/
|
||||
@Service(Service.Level.PROJECT)
|
||||
class KiloInlineDiffStore {
|
||||
private val items = Collections.synchronizedMap(
|
||||
object : LinkedHashMap<String, List<DiffFileDto>>(16, 0.75f, true) {
|
||||
override fun removeEldestEntry(eldest: Map.Entry<String, List<DiffFileDto>>): Boolean = size > MAX
|
||||
},
|
||||
)
|
||||
|
||||
fun put(token: String, files: List<DiffFileDto>) {
|
||||
items[token] = files
|
||||
}
|
||||
|
||||
fun get(token: String): List<DiffFileDto>? = items[token]
|
||||
|
||||
fun pop(token: String): List<DiffFileDto>? = items.remove(token)
|
||||
|
||||
private companion object {
|
||||
const val MAX = 32
|
||||
}
|
||||
}
|
||||
+2
@@ -5,6 +5,7 @@ import ai.kilocode.client.plugin.KiloBundle
|
||||
import ai.kilocode.client.telemetry.Telemetry
|
||||
import ai.kilocode.client.ui.md.MdView
|
||||
import ai.kilocode.rpc.isManagedWorktreeStorage
|
||||
import ai.kilocode.rpc.dto.DiffFileDto
|
||||
import ai.kilocode.rpc.dto.WorkspaceFileDto
|
||||
import com.intellij.icons.AllIcons
|
||||
import com.intellij.openapi.fileTypes.FileTypeManager
|
||||
@@ -27,6 +28,7 @@ import javax.swing.JComponent
|
||||
import javax.swing.JList
|
||||
|
||||
typealias SessionFileOpener = (href: String, anchor: RelativePoint?) -> Unit
|
||||
typealias SessionDiffOpener = (files: List<DiffFileDto>, title: String, key: String) -> Unit
|
||||
|
||||
fun MdView.LinkEvent.anchor(): RelativePoint? {
|
||||
val component = component ?: return null
|
||||
|
||||
+90
-2
@@ -4,6 +4,10 @@ import ai.kilocode.client.app.KiloAppService
|
||||
import ai.kilocode.client.app.KiloSessionService
|
||||
import ai.kilocode.client.app.KiloWorkspaceService
|
||||
import ai.kilocode.client.app.Workspace
|
||||
import ai.kilocode.client.diff.KiloDiffEditorKind
|
||||
import ai.kilocode.client.diff.KiloInlineDiffStore
|
||||
import ai.kilocode.client.diff.diffParams
|
||||
import ai.kilocode.client.diff.ensureDiffEditorKind
|
||||
import ai.kilocode.client.migration.KiloMigrationService
|
||||
import ai.kilocode.client.migration.MigrationUiController
|
||||
import ai.kilocode.client.migration.MigrationUiState
|
||||
@@ -55,6 +59,7 @@ import ai.kilocode.client.util.UiTimers
|
||||
import ai.kilocode.client.vfs.KiloVfsManager
|
||||
import ai.kilocode.log.ChatLogSummary
|
||||
import ai.kilocode.rpc.dto.ModelLimitDto
|
||||
import ai.kilocode.rpc.dto.DiffFileDto
|
||||
import ai.kilocode.rpc.dto.PromptDto
|
||||
import ai.kilocode.rpc.dto.PromptPartDto
|
||||
import ai.kilocode.rpc.dto.SessionRevertDto
|
||||
@@ -82,8 +87,10 @@ import com.intellij.openapi.util.registry.Registry
|
||||
import com.intellij.openapi.wm.IdeFocusManager
|
||||
import com.intellij.util.concurrency.annotations.RequiresEdt
|
||||
import java.util.function.Predicate
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.awt.BorderLayout
|
||||
@@ -202,6 +209,11 @@ class SessionUi(
|
||||
}
|
||||
private var editorTheme = style.editorScheme
|
||||
private var colorTheme = UIManager.getLookAndFeel()
|
||||
private var wasBusy = false
|
||||
// Kept separate so a background stat refresh (turn end / revert) can supersede another refresh
|
||||
// but never cancel an in-flight user-initiated open.
|
||||
private var refreshJob: Job? = null
|
||||
private var openJob: Job? = null
|
||||
private var disposed = false
|
||||
|
||||
init {
|
||||
@@ -214,6 +226,7 @@ class SessionUi(
|
||||
bindStyle()
|
||||
bindMigration()
|
||||
onStateChanged(controller.model.state)
|
||||
refreshBranchChanges()
|
||||
loaded?.let(::finishOpen)
|
||||
}
|
||||
|
||||
@@ -370,9 +383,10 @@ class SessionUi(
|
||||
deleteQueued = { id -> controller.deleteQueuedMessage(id) },
|
||||
banner = RevertBanner(controller.model, ::redo, controller::redoAll, ::cancelRevert, focus),
|
||||
).also {
|
||||
it.setDiffOpener(::openInlineDiff, controller.id)
|
||||
it.onHover = { view, on -> if (on) popup.show(view) else popup.notifyExit(view) }
|
||||
}
|
||||
header = SessionHeaderPanel(controller, this)
|
||||
header = SessionHeaderPanel(controller, this) { openBranchChanges() }
|
||||
|
||||
scroll = SessionScroll(root, sessionContent, messageBody, blankBody)
|
||||
scroll.onScroll = {
|
||||
@@ -716,6 +730,7 @@ class SessionUi(
|
||||
|
||||
@RequiresEdt
|
||||
private fun onRevertChanged(revert: SessionRevertDto?) {
|
||||
refreshBranchChanges()
|
||||
syncPromptRevert()
|
||||
val rollback = pendingRollback
|
||||
if (rollback != null) {
|
||||
@@ -804,6 +819,74 @@ class SessionUi(
|
||||
BrowserUtil.browse(url)
|
||||
}
|
||||
|
||||
private fun openInlineDiff(files: List<DiffFileDto>, title: String, key: String) {
|
||||
cs.launch {
|
||||
val branch = workspaces.branchName(workspace.directory)
|
||||
val label = branch?.let { KiloBundle.message("diff.editor.inline.title.named", title, it) } ?: title
|
||||
withContext(Dispatchers.Main) {
|
||||
ensureDiffEditorKind()
|
||||
project.service<KiloInlineDiffStore>().put(key, files)
|
||||
project.service<KiloVfsManager>().open(
|
||||
KiloDiffEditorKind.ID,
|
||||
diffParams("inline", workspace.directory, controller.id, label, token = key),
|
||||
)
|
||||
Telemetry.send("Diff Editor Opened", mapOf("source" to "inline"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Badge-only refresh: fetches stats (no patch text) and updates the header count. */
|
||||
private fun refreshBranchChanges() {
|
||||
refreshJob?.cancel()
|
||||
refreshJob = cs.launch {
|
||||
val files = runCatching { workspaces.branchDiff(workspace.directory, patches = false) }
|
||||
.getOrElse {
|
||||
if (it is CancellationException) throw it
|
||||
LOG.warn("branch changes badge refresh failed dir=${workspace.directory}", it)
|
||||
return@launch
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
if (disposed || project.isDisposed) return@withContext
|
||||
header.setBranchChanges(files)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** User clicked the badge: opens the branch diff editor. Never cancelled by a background refresh. */
|
||||
private fun openBranchChanges() {
|
||||
openJob?.cancel()
|
||||
openJob = cs.launch {
|
||||
val dir = workspace.directory
|
||||
val branch = workspaces.branchName(dir)
|
||||
val files = runCatching { workspaces.branchDiff(dir, patches = false) }
|
||||
.getOrElse {
|
||||
if (it is CancellationException) throw it
|
||||
LOG.warn("branch changes open failed dir=$dir", it)
|
||||
emptyList()
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
if (disposed || project.isDisposed) return@withContext
|
||||
header.setBranchChanges(files)
|
||||
openBranchDiff(branch)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
private fun openBranchDiff(branch: String?) {
|
||||
// No store seeding: the diff editor's fetch recomputes branchDiff authoritatively, so a
|
||||
// re-open or Refresh always reflects the current worktree (and nothing is retained for its life).
|
||||
ensureDiffEditorKind()
|
||||
val dir = workspace.directory
|
||||
val title = branch?.let { KiloBundle.message("diff.editor.branch.title.named", it) }
|
||||
?: KiloBundle.message("diff.editor.branch.title")
|
||||
project.service<KiloVfsManager>().open(
|
||||
KiloDiffEditorKind.ID,
|
||||
diffParams("branch", dir, null, title, branch),
|
||||
)
|
||||
Telemetry.send("Diff Editor Opened", mapOf("source" to "branch"))
|
||||
}
|
||||
|
||||
private fun openAttachment(messageId: String, item: FileAttachment) {
|
||||
val url = item.url.takeIf { it.isNotBlank() } ?: run {
|
||||
LOG.info("kind=attachment-open skipped=true reason=blank-url message=$messageId part=${item.id} name=${attachmentName(item)} mime=${item.mime}")
|
||||
@@ -854,12 +937,15 @@ class SessionUi(
|
||||
|
||||
private fun onStateChanged(state: SessionState) {
|
||||
if (disposed) return
|
||||
val busy = state.isBusy()
|
||||
if (wasBusy && state is SessionState.Idle) refreshBranchChanges()
|
||||
wasBusy = busy
|
||||
if (state is SessionState.Reverting) overlay.clear()
|
||||
if (state is SessionState.Error) {
|
||||
pendingRollback = null
|
||||
pendingRedo = null
|
||||
}
|
||||
prompt.setBusy(state.isBusy())
|
||||
prompt.setBusy(busy)
|
||||
load.setState(state)
|
||||
scroll.setQuestionPending(questionPending(state))
|
||||
scroll.show(body(state))
|
||||
@@ -927,6 +1013,8 @@ class SessionUi(
|
||||
|
||||
override fun dispose() {
|
||||
disposed = true
|
||||
refreshJob?.cancel()
|
||||
openJob?.cancel()
|
||||
hide.stop()
|
||||
popup.hideAll()
|
||||
modalFocus = null
|
||||
|
||||
+136
-42
@@ -162,6 +162,7 @@ class SessionController(
|
||||
// then reconciled when the operation releases so an underlying server turn is not lost.
|
||||
private var revertDeferred: SessionState? = null
|
||||
private var creating: CompletableDeferred<String?>? = null
|
||||
private val pending = LinkedHashMap<String, Permission>()
|
||||
private val childJobs: MutableMap<String, Job> = mutableMapOf()
|
||||
private val childIds: MutableSet<String> = mutableSetOf()
|
||||
private val childParts: MutableMap<PartKey, String> = mutableMapOf()
|
||||
@@ -363,6 +364,7 @@ class SessionController(
|
||||
return
|
||||
}
|
||||
val id = sid ?: return
|
||||
updateModel { (childIds + id).forEach(::purgePending) }
|
||||
capture("Session Stop Clicked", sessionProps(id))
|
||||
cs.launch {
|
||||
try {
|
||||
@@ -386,6 +388,11 @@ class SessionController(
|
||||
return
|
||||
}
|
||||
val current = model.state
|
||||
// Clear the local queue before re-surfacing the visible card. approve() may synchronously
|
||||
// re-enqueue a skill-shell card via show() (skill-shell asks always need a human), so that
|
||||
// enqueue must be the last writer — otherwise a trailing clear() would drop it and leave a
|
||||
// ghost card that is not in pending, which a later Stop/idle purge could not clear.
|
||||
pending.clear()
|
||||
val skip = if (current is SessionState.AwaitingPermission) {
|
||||
approve(current.permission)
|
||||
setOf(current.permission.id)
|
||||
@@ -720,33 +727,29 @@ class SessionController(
|
||||
private fun approve(id: String, restore: () -> Permission) {
|
||||
assertEdt()
|
||||
LOG.debug { "${ChatLogSummary.sid(sid ?: ref?.key ?: "pending")} kind=permission-auto rid=$id" }
|
||||
// Skill-shell batches must be answered by a human: the server refuses non-interactive
|
||||
// approvals, so show the card (its manual reply sets interactive=true) rather than send a
|
||||
// machine reply. Decide and enqueue synchronously on the EDT so back-to-back asks keep
|
||||
// arrival (FIFO) order, matching asked()'s non-auto path; only the RPC needs a coroutine.
|
||||
if (!autoApprove || restore().meta.raw["skillShell"] == "true") {
|
||||
show(restore())
|
||||
return
|
||||
}
|
||||
updateModel { model.setState(SessionState.Busy(KiloBundle.message("session.status.considering"))) }
|
||||
cs.launch {
|
||||
try {
|
||||
// Skill-shell batches must be answered by a human: the server refuses
|
||||
// non-interactive approvals, so auto-approve must show the card (whose
|
||||
// manual reply sets interactive=true) rather than send a machine reply.
|
||||
if (!autoApprove || restore().meta.raw["skillShell"] == "true") {
|
||||
edt {
|
||||
if (disposed) return@edt
|
||||
model.setState(SessionState.AwaitingPermission(restore()))
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
edt {
|
||||
if (disposed) return@edt
|
||||
model.setState(SessionState.Busy(KiloBundle.message("session.status.considering")))
|
||||
}
|
||||
sessions.replyPermission(id, directory, PermissionReplyDto("once"))
|
||||
capture("Permission Auto Approved", sessionProps() + mapOf("tool" to restore().name, "source" to "single"))
|
||||
LOG.debug { "${ChatLogSummary.sid(sid ?: ref?.key ?: "pending")} kind=permission-auto rid=$id ok=true" }
|
||||
} catch (e: Exception) {
|
||||
LOG.warn("${ChatLogSummary.sid(sid ?: ref?.key ?: "pending")} kind=permission-auto rid=$id dir=${ChatLogSummary.dir(directory)} failed message=${e.message}", e)
|
||||
edt {
|
||||
if (disposed) return@edt
|
||||
model.setState(SessionState.AwaitingPermission(restore().copy(
|
||||
// Queue the error card too, so pending stays the single source of truth and a
|
||||
// later Stop / TurnClose / idle purge can clear it instead of stranding it.
|
||||
show(restore().copy(
|
||||
state = PermissionRequestState.ERROR,
|
||||
message = e.message ?: KiloBundle.message("session.permission.error"),
|
||||
)))
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -762,18 +765,30 @@ class SessionController(
|
||||
try {
|
||||
val permissions = sessions.pendingPermissions(directory).filter { it.sessionID in ids && it.id !in skip }
|
||||
val count = replyAll(permissions)
|
||||
// Skill-shell requests are skipped by replyAll; surface one as a card so it
|
||||
// isn't stranded (never machine-approved, never shown).
|
||||
val card = skillShellCard(permissions)?.let { toPermission(it) }
|
||||
if (count == 0 && card == null) return@launch
|
||||
// Skill-shell requests are skipped by replyAll; queue all of them so they aren't
|
||||
// stranded (never machine-approved, never shown) or overwritten by later cards.
|
||||
val cards = permissions.filter { it.metadata["skillShell"] == "true" }.map(::toPermission)
|
||||
if (count == 0 && cards.isEmpty()) return@launch
|
||||
runEdt {
|
||||
if (disposed) return@runEdt
|
||||
if (card != null) {
|
||||
updateModel { model.setState(SessionState.AwaitingPermission(card)) }
|
||||
if (cards.isNotEmpty()) {
|
||||
updateModel {
|
||||
cards.forEach(::enqueue)
|
||||
if (model.state !is SessionState.AwaitingPermission && model.state !is SessionState.AwaitingQuestion) {
|
||||
promote()
|
||||
}
|
||||
}
|
||||
return@runEdt
|
||||
}
|
||||
val current = model.state
|
||||
if (current is SessionState.AwaitingPermission && current.permission.sessionId in ids) {
|
||||
// A card in `skip` was handled synchronously by the caller (approve() either
|
||||
// replied to it — already Busy — or re-showed a skill-shell card we must keep).
|
||||
// Never transition it to Busy here or the preserved skill-shell card vanishes
|
||||
// with no reply path left.
|
||||
if (current is SessionState.AwaitingPermission &&
|
||||
current.permission.sessionId in ids &&
|
||||
current.permission.id !in skip
|
||||
) {
|
||||
model.setState(SessionState.Busy(KiloBundle.message("session.status.considering")))
|
||||
}
|
||||
}
|
||||
@@ -803,6 +818,12 @@ class SessionController(
|
||||
|
||||
private fun updatePermission(id: String, state: PermissionRequestState, message: String? = null) {
|
||||
assertEdt()
|
||||
pending[id]?.let { perm ->
|
||||
pending[id] = perm.copy(
|
||||
state = state,
|
||||
message = message ?: perm.message,
|
||||
)
|
||||
}
|
||||
val current = model.state
|
||||
if (current !is SessionState.AwaitingPermission) return
|
||||
if (current.permission.id != id) return
|
||||
@@ -1149,6 +1170,9 @@ class SessionController(
|
||||
if (child in childParts.values) return
|
||||
childIds.remove(child)
|
||||
childJobs.remove(child)?.cancel()
|
||||
// A sub-agent that finished/was cancelled with an unanswered permission would otherwise leave
|
||||
// a queue entry that a later promote() surfaces as a live card for a session that no longer exists.
|
||||
purgePending(child)
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
@@ -1166,6 +1190,7 @@ class SessionController(
|
||||
childJobs.clear()
|
||||
childIds.clear()
|
||||
childParts.clear()
|
||||
pending.clear()
|
||||
}
|
||||
|
||||
private suspend fun recoverChildPermissions(child: String) {
|
||||
@@ -1173,21 +1198,23 @@ class SessionController(
|
||||
val permissions = sessions.pendingPermissions(directory).filter { it.sessionID == child }
|
||||
if (permissions.isEmpty()) return
|
||||
LOG.debug { "${ChatLogSummary.sid(sid ?: "pending")} kind=child-recovery child=$child permissions=${permissions.size}" }
|
||||
// A skill-shell request must surface as a card even under auto-approve (replyAll
|
||||
// skips it); prefer it over the last pending so a human can answer.
|
||||
val show = if (autoApprove) {
|
||||
// Under auto-approve, replyAll approves the ordinary permissions and skips skill-shell
|
||||
// ones (they need a human); queue only those. Otherwise queue every pending permission.
|
||||
val queue = if (autoApprove) {
|
||||
replyAll(permissions)
|
||||
skillShellCard(permissions) ?: return
|
||||
permissions.filter { it.metadata["skillShell"] == "true" }
|
||||
} else {
|
||||
skillShellCard(permissions) ?: permissions.last()
|
||||
permissions
|
||||
}
|
||||
val last = toPermission(show)
|
||||
if (queue.isEmpty()) return
|
||||
val items = queue.map(::toPermission)
|
||||
runEdt {
|
||||
if (disposed) return@runEdt
|
||||
if (child !in childIds) return@runEdt
|
||||
// Do not overwrite an existing root or other child AwaitingPermission state
|
||||
if (model.state is SessionState.AwaitingPermission) return@runEdt
|
||||
updateModel { model.setState(SessionState.AwaitingPermission(last)) }
|
||||
items.forEach(::enqueue)
|
||||
if (model.state !is SessionState.AwaitingPermission && model.state !is SessionState.AwaitingQuestion) {
|
||||
updateModel { promote() }
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
LOG.warn("${ChatLogSummary.sid(sid ?: "pending")} kind=child-recovery child=$child dir=${ChatLogSummary.dir(directory)} failed message=${e.message}", e)
|
||||
@@ -1236,6 +1263,9 @@ class SessionController(
|
||||
return
|
||||
}
|
||||
}
|
||||
// After auto-approve only skill-shell permissions still need a human card; queue those.
|
||||
// Otherwise queue the whole pending set so each request is resolved in turn.
|
||||
val queue = if (autoApprove) permissions.filter { it.metadata["skillShell"] == "true" } else permissions
|
||||
val branch = when {
|
||||
permissions.isNotEmpty() -> "permission"
|
||||
questions.isNotEmpty() -> "question"
|
||||
@@ -1249,9 +1279,10 @@ class SessionController(
|
||||
if (disposed) return@runEdt
|
||||
if (sid != id) return@runEdt
|
||||
updateModel {
|
||||
if (permissions.isNotEmpty()) {
|
||||
// Prefer a skill-shell request (needs a human) over the last pending.
|
||||
model.setState(SessionState.AwaitingPermission(toPermission(skillCard ?: permissions.last())))
|
||||
pending.entries.removeIf { it.value.sessionId == id }
|
||||
if (queue.isNotEmpty()) {
|
||||
queue.map(::toPermission).forEach(::enqueue)
|
||||
promote()
|
||||
} else if (questions.isNotEmpty()) {
|
||||
model.setState(SessionState.AwaitingQuestion(toQuestion(questions.last())))
|
||||
} else if (status != null) {
|
||||
@@ -1358,9 +1389,13 @@ class SessionController(
|
||||
revertDeferred = SessionState.Idle
|
||||
return
|
||||
}
|
||||
// The turn is done, so any still-queued permission for it is a ghost the CLI abandoned
|
||||
// server-side without a reply event — drop it before deciding whether to keep a card.
|
||||
purgePending(event.sessionID)
|
||||
// Keep pending questions visible for follow-up flows that arrive just before close.
|
||||
val current = model.state
|
||||
if (current is SessionState.AwaitingQuestion) return
|
||||
if (current is SessionState.AwaitingPermission) return
|
||||
val clobberOk = event.reason == "completed"
|
||||
|| current is SessionState.Busy
|
||||
|| current is SessionState.Retry
|
||||
@@ -1504,15 +1539,22 @@ class SessionController(
|
||||
approve(event.request)
|
||||
return
|
||||
}
|
||||
val perm = toPermission(event.request)
|
||||
model.setState(SessionState.AwaitingPermission(perm))
|
||||
show(toPermission(event.request))
|
||||
}
|
||||
|
||||
private fun replied(event: ChatEventDto.PermissionReplied) {
|
||||
val current = model.state
|
||||
if (current is SessionState.AwaitingPermission && current.permission.id == event.requestID) {
|
||||
model.setState(SessionState.Busy(KiloBundle.message("session.status.considering")))
|
||||
val front = current is SessionState.AwaitingPermission && current.permission.id == event.requestID
|
||||
pending.remove(event.requestID)
|
||||
// Front card resolved: advance to the next queued permission, else resume Busy.
|
||||
if (front) {
|
||||
model.setState(afterResolve())
|
||||
return
|
||||
}
|
||||
// A queued (non-front) permission or an unrelated prompt is active: leave it in place.
|
||||
if (current is SessionState.AwaitingPermission || current is SessionState.AwaitingQuestion) return
|
||||
// Otherwise (busy/idle/etc.) only surface a still-queued permission; never force Busy.
|
||||
promote()
|
||||
}
|
||||
|
||||
private fun asked(event: ChatEventDto.QuestionAsked) {
|
||||
@@ -1522,14 +1564,59 @@ class SessionController(
|
||||
private fun replied(event: ChatEventDto.QuestionReplied) {
|
||||
val current = model.state
|
||||
if (current is SessionState.AwaitingQuestion && current.question.id == event.requestID) {
|
||||
model.setState(SessionState.Busy(KiloBundle.message("session.status.considering")))
|
||||
model.setState(afterResolve())
|
||||
}
|
||||
}
|
||||
|
||||
private fun rejected(event: ChatEventDto.QuestionRejected) {
|
||||
val current = model.state
|
||||
if (current is SessionState.AwaitingQuestion && current.question.id == event.requestID) {
|
||||
model.setState(SessionState.Idle)
|
||||
model.setState(afterResolve(idle = true))
|
||||
}
|
||||
}
|
||||
|
||||
private fun afterResolve(idle: Boolean = false): SessionState {
|
||||
return pending.values.firstOrNull()?.let { SessionState.AwaitingPermission(it) }
|
||||
?: if (idle) SessionState.Idle else SessionState.Busy(KiloBundle.message("session.status.considering"))
|
||||
}
|
||||
|
||||
private fun enqueue(perm: Permission) {
|
||||
pending[perm.id] = perm
|
||||
}
|
||||
|
||||
private fun promote() {
|
||||
val perm = pending.values.firstOrNull() ?: return
|
||||
model.setState(SessionState.AwaitingPermission(perm))
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue [perm] and surface it if no card/question is already up. Wrapped in updateModel so the
|
||||
* transcript's bottom-follow is preserved (permission cards live inside the scroll pane), and
|
||||
* kept synchronous so callers on the EDT enqueue in arrival (FIFO) order.
|
||||
*/
|
||||
@RequiresEdt
|
||||
private fun show(perm: Permission) = updateModel {
|
||||
enqueue(perm)
|
||||
if (model.state !is SessionState.AwaitingPermission && model.state !is SessionState.AwaitingQuestion) {
|
||||
promote()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop queued permissions for [session] and clear/re-promote the visible card when it belonged to
|
||||
* one of them. The CLI deletes an outstanding permission server-side on turn interruption without
|
||||
* emitting permission.replied (`Permission.ask` cleans up in `Effect.ensuring`), so on TurnClose /
|
||||
* idle / child untrack a still-queued entry is a ghost that would otherwise resurface on the next
|
||||
* promote() and fail to reply with NotFoundError.
|
||||
*/
|
||||
@RequiresEdt
|
||||
private fun purgePending(session: String?) {
|
||||
if (session == null) return
|
||||
val removed = pending.entries.removeIf { it.value.sessionId == session }
|
||||
if (!removed) return
|
||||
val current = model.state
|
||||
if (current is SessionState.AwaitingPermission && current.permission.sessionId == session) {
|
||||
model.setState(afterResolve(idle = true))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1548,6 +1635,10 @@ class SessionController(
|
||||
"idle" -> {
|
||||
val current = model.state
|
||||
if (current is SessionState.LoginRequired || current is SessionState.Reverting) return
|
||||
purgePending(sid)
|
||||
// purgePending may promote a still-queued permission from another (unpurged) child
|
||||
// session; mirror idle() and leave that card in place rather than clobbering it with Idle.
|
||||
if (model.state is SessionState.AwaitingPermission) return
|
||||
SessionState.Idle
|
||||
}
|
||||
"busy" -> {
|
||||
@@ -1649,6 +1740,9 @@ class SessionController(
|
||||
revertDeferred = SessionState.Idle
|
||||
return
|
||||
}
|
||||
// An idle session cannot have a live permission outstanding — purge any ghost left by an
|
||||
// abort/error that originated on the server or another client (local abort() already clears).
|
||||
purgePending(sid)
|
||||
// Treat session.idle as an explicit signal to return to Idle.
|
||||
// Only apply if we're not in a more specific non-terminal state.
|
||||
val current = model.state
|
||||
|
||||
+1
-1
@@ -183,7 +183,7 @@ internal class SessionScroll(
|
||||
} finally {
|
||||
auto = false
|
||||
}
|
||||
tail = atBottom()
|
||||
tail = near()
|
||||
syncValue()
|
||||
updateJump()
|
||||
if (tail) {
|
||||
|
||||
+204
@@ -0,0 +1,204 @@
|
||||
package ai.kilocode.client.session.ui
|
||||
|
||||
import ai.kilocode.client.plugin.KiloBundle
|
||||
import ai.kilocode.client.session.SessionDiffOpener
|
||||
import ai.kilocode.client.session.SessionFileOpener
|
||||
import ai.kilocode.client.session.model.Content
|
||||
import ai.kilocode.client.session.ui.popup.HeaderPopupBody
|
||||
import ai.kilocode.client.session.ui.popup.HeaderPopupRequest
|
||||
import ai.kilocode.client.session.ui.selection.SessionCopyTarget
|
||||
import ai.kilocode.client.session.ui.selection.SessionSelection
|
||||
import ai.kilocode.client.session.ui.selection.hoverPlaceholder
|
||||
import ai.kilocode.client.session.ui.style.SessionEditorStyle
|
||||
import ai.kilocode.client.session.ui.style.SessionUiStyle
|
||||
import ai.kilocode.client.session.views.SessionViewIcons
|
||||
import ai.kilocode.client.session.views.base.PartHeader
|
||||
import ai.kilocode.client.session.views.base.SecondarySessionPartView
|
||||
import ai.kilocode.client.session.views.tool.EditFileChange
|
||||
import ai.kilocode.client.session.views.tool.POPUP_OPTS
|
||||
import ai.kilocode.client.session.views.tool.PatchBody
|
||||
import ai.kilocode.client.session.views.tool.setFont
|
||||
import ai.kilocode.client.session.views.tool.setForeground
|
||||
import ai.kilocode.client.session.views.tool.setIcon
|
||||
import ai.kilocode.client.telemetry.Telemetry
|
||||
import ai.kilocode.client.ui.DiffBars
|
||||
import ai.kilocode.client.ui.ToolbarButtonAction
|
||||
import ai.kilocode.client.ui.UiStyle
|
||||
import ai.kilocode.client.ui.toolbarButton
|
||||
import ai.kilocode.rpc.dto.DiffFileDto
|
||||
import com.intellij.openapi.util.Disposer
|
||||
import com.intellij.ui.components.JBLabel
|
||||
import com.intellij.util.concurrency.annotations.RequiresEdt
|
||||
import javax.swing.JComponent
|
||||
|
||||
class ModifiedFilesView private constructor(
|
||||
private val openFile: SessionFileOpener,
|
||||
private val selection: SessionSelection? = null,
|
||||
private val parts: Header = Header(),
|
||||
private val body: PatchBody = PatchBody(selection, openFile),
|
||||
) : SecondarySessionPartView(parts.panel, { body.mountFiles(emptyList()) }), SessionCopyTarget {
|
||||
override val contentId = CONTENT_ID
|
||||
|
||||
private var style = SessionEditorStyle.current()
|
||||
private var files = emptyList<EditFileChange>()
|
||||
private var diffs = emptyList<DiffFileDto>()
|
||||
private var openDiff: SessionDiffOpener = { _, _, _ -> }
|
||||
private var sessionId: String? = null
|
||||
private var turnId: String = CONTENT_ID
|
||||
|
||||
constructor(
|
||||
openFile: SessionFileOpener,
|
||||
selection: SessionSelection? = null,
|
||||
) : this(openFile, selection, Header(), PatchBody(selection, openFile))
|
||||
|
||||
init {
|
||||
body.parent = this
|
||||
parts.diff.addActionListener { openDiffViewer() }
|
||||
isVisible = false
|
||||
bindHeader(parts.glyph, parts.title, parts.count, parts.panel.left, parts.bars, parts.anchor)
|
||||
applyStyle(style)
|
||||
}
|
||||
|
||||
override val copyEligible: Boolean get() = diffs.isNotEmpty()
|
||||
override val copyAnchor: JComponent get() = parts.anchor
|
||||
override val copyToolbar: JComponent get() = parts.diff
|
||||
|
||||
fun setDiffOpener(openDiff: SessionDiffOpener, sessionId: String?, turnId: String) {
|
||||
this.openDiff = openDiff
|
||||
this.sessionId = sessionId
|
||||
this.turnId = turnId
|
||||
}
|
||||
|
||||
/** Returns true when anything visible changed, so the parent only relayouts on a real change. */
|
||||
@RequiresEdt
|
||||
fun setDiffs(diffs: List<DiffFileDto>): Boolean {
|
||||
val next = diffs.map(::file)
|
||||
this.diffs = diffs
|
||||
if (files == next) {
|
||||
val visible = next.isNotEmpty()
|
||||
parts.diff.isEnabled = visible
|
||||
if (isVisible == visible) return false
|
||||
isVisible = visible
|
||||
revalidate()
|
||||
repaint()
|
||||
return true
|
||||
}
|
||||
files = next
|
||||
val visible = files.isNotEmpty()
|
||||
val additions = files.sumOf { it.additions }
|
||||
val deletions = files.sumOf { it.deletions }
|
||||
if (isVisible != visible) isVisible = visible
|
||||
if (!visible) collapse()
|
||||
parts.update(files.size, additions, deletions)
|
||||
parts.diff.isEnabled = visible
|
||||
if (isExpanded()) body.updateFiles(files)
|
||||
revalidate()
|
||||
repaint()
|
||||
return true
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
override fun expand(): Boolean {
|
||||
val changed = super.expand()
|
||||
if (!changed) return false
|
||||
body.updateFiles(files)
|
||||
body.applyStyle(style)
|
||||
return true
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
override fun update(content: Content) = Unit
|
||||
|
||||
override fun copyText(): String? = null
|
||||
|
||||
@RequiresEdt
|
||||
override fun headerPopup(): HeaderPopupRequest? {
|
||||
if (isExpanded() || files.isEmpty()) return null
|
||||
return HeaderPopupRequest(row, build = { buildPopup(files) }) {
|
||||
Telemetry.send("Header Popup Shown", mapOf("surface" to "session", "tool" to "changes"))
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
override fun applyStyle(style: SessionEditorStyle) {
|
||||
this.style = style
|
||||
parts.applyStyle(style)
|
||||
body.applyStyle(style)
|
||||
refresh()
|
||||
}
|
||||
|
||||
override fun dispose() {
|
||||
body.disposeBody()
|
||||
super.dispose()
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
internal fun bodyCreated() = body.created()
|
||||
|
||||
@RequiresEdt
|
||||
internal fun bodyVisible() = body.attached(this)
|
||||
|
||||
@RequiresEdt
|
||||
internal fun countText() = parts.count.text
|
||||
|
||||
private fun openDiffViewer() {
|
||||
if (diffs.isEmpty()) return
|
||||
openDiff(diffs, KiloBundle.message("diff.editor.changedFiles.title"), "turn:${sessionId ?: "pending"}:$turnId")
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
private fun buildPopup(files: List<EditFileChange>): HeaderPopupBody {
|
||||
val owner = Disposer.newDisposable("Modified files popup body")
|
||||
val popup = PatchBody(selection, openFile, POPUP_OPTS).also { it.parent = owner }
|
||||
val panel = popup.mountFiles(files)
|
||||
popup.applyStyle(style)
|
||||
return HeaderPopupBody(panel, owner, style.editorBackground, SessionUiStyle.View.Popup.WIDE_MAX_WIDTH)
|
||||
}
|
||||
|
||||
private class Header {
|
||||
val glyph = JBLabel()
|
||||
val title = JBLabel(KiloBundle.message("session.changes.modified"))
|
||||
val count = JBLabel()
|
||||
val diff = toolbarButton(
|
||||
ToolbarButtonAction(SessionViewIcons.openDiff, KiloBundle.message("session.part.tool.openDiff")) {},
|
||||
).apply { isEnabled = false }
|
||||
val anchor = hoverPlaceholder(diff)
|
||||
val bars = DiffBars(0, 0)
|
||||
// Left-aligned header: icon, title, file count, sticks change badge, open-in-diff.
|
||||
val panel = PartHeader().apply {
|
||||
leading(glyph)
|
||||
left(title)
|
||||
titleGap()
|
||||
left(count, PartHeader.centered(bars), PartHeader.centered(anchor))
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
fun update(total: Int, additions: Int, deletions: Int) {
|
||||
val text = KiloBundle.message(if (total == 1) "session.changes.count.one" else "session.changes.count.other", total)
|
||||
if (count.text != text) count.text = text
|
||||
bars.update(additions, deletions)
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
fun applyStyle(style: SessionEditorStyle) {
|
||||
setIcon(glyph, SessionViewIcons.edit)
|
||||
setForeground(glyph, SessionUiStyle.View.Tool.completed())
|
||||
setFont(title, style.boldEditorFont)
|
||||
setFont(count, style.transcriptFont)
|
||||
setForeground(title, UiStyle.Colors.fg())
|
||||
setForeground(count, UiStyle.Colors.weak())
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val CONTENT_ID = "session-modified-files"
|
||||
}
|
||||
}
|
||||
|
||||
private fun file(dto: DiffFileDto) = EditFileChange(
|
||||
path = dto.file,
|
||||
type = "",
|
||||
additions = dto.additions,
|
||||
deletions = dto.deletions,
|
||||
patch = dto.patch.orEmpty(),
|
||||
)
|
||||
+30
-3
@@ -1,5 +1,6 @@
|
||||
package ai.kilocode.client.session.ui
|
||||
|
||||
import ai.kilocode.client.session.SessionDiffOpener
|
||||
import ai.kilocode.client.session.SessionFileOpener
|
||||
import ai.kilocode.client.session.model.SessionModel
|
||||
import ai.kilocode.client.session.model.SessionModelEvent
|
||||
@@ -79,6 +80,8 @@ class SessionMessageListPanel(
|
||||
private var hiddenTool: ToolCallRef? = null
|
||||
private var hovered: PartView? = null
|
||||
private var revertingMessage: String? = null
|
||||
private var openDiff: SessionDiffOpener = { _, _, _ -> }
|
||||
private var sessionId: String? = null
|
||||
|
||||
var onHover: ((PartView, Boolean) -> Unit)? = null
|
||||
|
||||
@@ -157,13 +160,21 @@ class SessionMessageListPanel(
|
||||
|
||||
// Message events: structural changes are handled via turn events above.
|
||||
is SessionModelEvent.MessageAdded,
|
||||
is SessionModelEvent.MessageUpdated,
|
||||
is SessionModelEvent.MessageRemoved,
|
||||
is SessionModelEvent.TodosUpdated,
|
||||
is SessionModelEvent.SessionUpdated,
|
||||
is SessionModelEvent.HeaderUpdated,
|
||||
is SessionModelEvent.Compacted -> Unit
|
||||
|
||||
is SessionModelEvent.MessageUpdated -> {
|
||||
// message.updated fires on every streamed metadata delta (time/tokens/cost). Only
|
||||
// relayout the transcript when the turn's modified-files card actually changed,
|
||||
// not on each delta or when this message isn't a turn anchor.
|
||||
if (turnViews[event.info.info.id]?.setDiffs(event.info.info.summary?.diffs.orEmpty()) == true) {
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
|
||||
is SessionModelEvent.DiffUpdated -> {
|
||||
banner?.update()
|
||||
refresh()
|
||||
@@ -175,6 +186,12 @@ class SessionMessageListPanel(
|
||||
rebuild()
|
||||
}
|
||||
|
||||
fun setDiffOpener(openDiff: SessionDiffOpener, sessionId: String?) {
|
||||
this.openDiff = openDiff
|
||||
this.sessionId = sessionId
|
||||
turnViews.values.forEach { it.setDiffOpener(openDiff, sessionId) }
|
||||
}
|
||||
|
||||
// ------ public lookup API ------
|
||||
|
||||
/** Find the [MessageView] for a message by id, or null if not present. */
|
||||
@@ -223,13 +240,16 @@ class SessionMessageListPanel(
|
||||
// ------ private event handlers ------
|
||||
|
||||
private fun onTurnAdded(turn: ai.kilocode.client.session.model.Turn) {
|
||||
val tv = TurnView(turn.id, openFile, style, openUrl, selection, openAttachment, resize, repo, ::hover, revert, deleteQueued)
|
||||
val tv = TurnView(turn.id, openFile, style, openUrl, selection, openAttachment, resize, repo, ::hover, revert, deleteQueued).also {
|
||||
it.setDiffOpener(openDiff, sessionId)
|
||||
}
|
||||
turnViews[turn.id] = tv
|
||||
for (msgId in turn.messageIds) {
|
||||
val msg = model.message(msgId) ?: continue
|
||||
val mv = tv.addMessage(msg)
|
||||
register(msgId, tv, mv)
|
||||
}
|
||||
tv.setDiffs(diffsOf(turn))
|
||||
tv.syncCopyToolbars()
|
||||
syncQueued(tv)
|
||||
syncReverted()
|
||||
@@ -258,6 +278,7 @@ class SessionMessageListPanel(
|
||||
val mv = tv.addMessage(msg)
|
||||
register(id, tv, mv)
|
||||
}
|
||||
tv.setDiffs(diffsOf(turn))
|
||||
tv.syncCopyToolbars()
|
||||
syncQueued(tv)
|
||||
syncReverted()
|
||||
@@ -288,13 +309,16 @@ class SessionMessageListPanel(
|
||||
removeAll()
|
||||
|
||||
for (turn in model.turns()) {
|
||||
val tv = TurnView(turn.id, openFile, style, openUrl, selection, openAttachment, resize, repo, ::hover, revert, deleteQueued)
|
||||
val tv = TurnView(turn.id, openFile, style, openUrl, selection, openAttachment, resize, repo, ::hover, revert, deleteQueued).also {
|
||||
it.setDiffOpener(openDiff, sessionId)
|
||||
}
|
||||
turnViews[turn.id] = tv
|
||||
for (msgId in turn.messageIds) {
|
||||
val msg = model.message(msgId) ?: continue
|
||||
val mv = tv.addMessage(msg)
|
||||
register(msgId, tv, mv)
|
||||
}
|
||||
tv.setDiffs(diffsOf(turn))
|
||||
tv.syncCopyToolbars()
|
||||
syncQueued(tv)
|
||||
add(tv)
|
||||
@@ -429,6 +453,9 @@ class SessionMessageListPanel(
|
||||
add(progress)
|
||||
}
|
||||
|
||||
private fun diffsOf(turn: ai.kilocode.client.session.model.Turn) =
|
||||
model.message(turn.id)?.info?.summary?.diffs.orEmpty()
|
||||
|
||||
private fun register(msgId: String, tv: TurnView, mv: MessageView) {
|
||||
msgToTurn[msgId] = tv
|
||||
msgToView[msgId] = mv
|
||||
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
package ai.kilocode.client.session.ui.header
|
||||
|
||||
import ai.kilocode.client.plugin.KiloBundle
|
||||
import ai.kilocode.client.session.ui.style.SessionEditorStyle
|
||||
import ai.kilocode.client.ui.DiffStatBadge
|
||||
import ai.kilocode.client.ui.UiStyle
|
||||
import ai.kilocode.client.ui.layout.Stack
|
||||
import ai.kilocode.rpc.dto.DiffFileDto
|
||||
import com.intellij.ui.components.JBLabel
|
||||
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.JComponent
|
||||
import javax.swing.JPanel
|
||||
import javax.swing.KeyStroke
|
||||
|
||||
internal class BranchChangesBadge(
|
||||
private val open: () -> Unit,
|
||||
) : JPanel(null) {
|
||||
private val count = JBLabel()
|
||||
private val stat = DiffStatBadge(0, 0, DiffStatBadge.Variant.COMPACT)
|
||||
private val row = Stack.horizontal(gap = UiStyle.Gap.sm()).next(count).next(stat)
|
||||
private var files = emptyList<DiffFileDto>()
|
||||
private var additions = 0
|
||||
private var deletions = 0
|
||||
private var over = false
|
||||
|
||||
init {
|
||||
isOpaque = false
|
||||
isVisible = false
|
||||
isFocusable = true
|
||||
cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)
|
||||
toolTipText = KiloBundle.message("diff.editor.branch.tooltip")
|
||||
getAccessibleContext().accessibleName = KiloBundle.message("diff.editor.branch.tooltip")
|
||||
border = JBUI.Borders.empty(0, UiStyle.Gap.sm())
|
||||
add(row)
|
||||
addMouseListener(object : MouseAdapter() {
|
||||
override fun mouseEntered(event: MouseEvent) = hover(true)
|
||||
override fun mouseExited(event: MouseEvent) = hover(false)
|
||||
override fun mouseClicked(event: MouseEvent) = activate()
|
||||
})
|
||||
// Keep the action reachable without a mouse (the HoverIcon this replaced was an
|
||||
// AbstractButton). Enter/Space fire the same guarded action as a click.
|
||||
val action = object : AbstractAction() {
|
||||
override fun actionPerformed(e: ActionEvent) = activate()
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
private fun activate() {
|
||||
if (isEnabled) open()
|
||||
}
|
||||
|
||||
fun applyStyle(style: SessionEditorStyle) {
|
||||
count.font = style.smallFont
|
||||
count.foreground = UiStyle.Colors.weak()
|
||||
}
|
||||
|
||||
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 = Dimension(Int.MAX_VALUE, preferredSize.height)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
fun update(next: List<DiffFileDto>): Boolean {
|
||||
if (files == next) return false
|
||||
files = next
|
||||
additions = files.sumOf { it.additions }
|
||||
deletions = files.sumOf { it.deletions }
|
||||
val text = KiloBundle.message(
|
||||
if (files.size == 1) "session.changes.count.one" else "session.changes.count.other",
|
||||
files.size,
|
||||
)
|
||||
count.text = text
|
||||
stat.update(additions, deletions)
|
||||
isVisible = files.isNotEmpty()
|
||||
revalidate()
|
||||
repaint()
|
||||
return true
|
||||
}
|
||||
|
||||
override fun paintComponent(g: Graphics) {
|
||||
if (over && isEnabled) paintHover(g)
|
||||
super.paintComponent(g)
|
||||
}
|
||||
|
||||
internal fun countText() = count.text
|
||||
|
||||
internal fun stats() = additions to deletions
|
||||
|
||||
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 companion object {
|
||||
const val ACTIVATE = "kilo.branch.changes.activate"
|
||||
}
|
||||
}
|
||||
+73
-8
@@ -11,6 +11,7 @@ import ai.kilocode.client.session.views.todo.TodoListPanel
|
||||
import ai.kilocode.client.ui.HoverIcon
|
||||
import ai.kilocode.client.ui.UiStyle
|
||||
import ai.kilocode.client.ui.layout.Stack
|
||||
import ai.kilocode.rpc.dto.DiffFileDto
|
||||
import ai.kilocode.rpc.dto.TodoDto
|
||||
import ai.kilocode.rpc.dto.TokensDto
|
||||
import com.intellij.icons.AllIcons
|
||||
@@ -19,6 +20,7 @@ import com.intellij.openapi.Disposable
|
||||
import com.intellij.openapi.util.IconLoader
|
||||
import com.intellij.ui.JBColor
|
||||
import com.intellij.ui.components.JBLabel
|
||||
import com.intellij.util.ui.SwingTextTrimmer
|
||||
import com.intellij.util.ui.JBUI
|
||||
import com.intellij.util.ui.components.BorderLayoutPanel
|
||||
import java.awt.BorderLayout
|
||||
@@ -40,6 +42,7 @@ import javax.swing.SwingUtilities
|
||||
class SessionHeaderPanel(
|
||||
private val controller: SessionController,
|
||||
parent: Disposable,
|
||||
onOpenBranchDiff: (() -> Unit)? = null,
|
||||
) : BorderLayoutPanel(), SessionEditorStyleTarget {
|
||||
|
||||
companion object {
|
||||
@@ -52,7 +55,15 @@ class SessionHeaderPanel(
|
||||
internal const val EXPANDED_KEY = "kilo.session.header.expanded"
|
||||
}
|
||||
|
||||
private val title = JBLabel()
|
||||
private val title = JBLabel().apply {
|
||||
putClientProperty(SwingTextTrimmer.KEY, SwingTextTrimmer.ELLIPSIS_AT_RIGHT)
|
||||
cursor = java.awt.Cursor.getPredefinedCursor(java.awt.Cursor.HAND_CURSOR)
|
||||
addMouseListener(object : MouseAdapter() {
|
||||
override fun mouseClicked(event: MouseEvent) {
|
||||
toggle()
|
||||
}
|
||||
})
|
||||
}
|
||||
private val cost = JBLabel()
|
||||
private val context = JBLabel()
|
||||
private val todos = JBLabel()
|
||||
@@ -65,7 +76,9 @@ class SessionHeaderPanel(
|
||||
accessibleContext.accessibleName = KiloBundle.message("session.header.compact")
|
||||
addActionListener { controller.compact() }
|
||||
}
|
||||
private val changes = BranchChangesBadge { onOpenBranchDiff?.invoke() }
|
||||
private val expand = JBLabel().apply {
|
||||
border = JBUI.Borders.empty(0, UiStyle.Gap.sm())
|
||||
cursor = java.awt.Cursor.getPredefinedCursor(java.awt.Cursor.HAND_CURSOR)
|
||||
toolTipText = KiloBundle.message("session.header.expand")
|
||||
accessibleContext.accessibleName = KiloBundle.message("session.header.expand")
|
||||
@@ -102,8 +115,38 @@ class SessionHeaderPanel(
|
||||
iconTextGap = UiStyle.Gap.xs()
|
||||
}
|
||||
private val top = BorderLayoutPanel()
|
||||
private val center = BorderLayoutPanel().apply {
|
||||
border = JBUI.Borders.empty(0, UiStyle.Gap.md(), 0, 0)
|
||||
// Lays the title out first with the branch-changes badge hugging its trailing edge,
|
||||
// both vertically centered. The title ellipsizes so the badge stays visible on long titles.
|
||||
private val centerGroup = object : JPanel(null) {
|
||||
override fun getPreferredSize(): Dimension {
|
||||
val ins = insets
|
||||
val t = title.preferredSize
|
||||
var w = t.width
|
||||
var h = t.height
|
||||
if (changes.isVisible) {
|
||||
val b = changes.preferredSize
|
||||
w += UiStyle.Gap.sm() + b.width
|
||||
h = maxOf(h, b.height)
|
||||
}
|
||||
return Dimension(w + ins.left + ins.right, h + ins.top + ins.bottom)
|
||||
}
|
||||
|
||||
override fun doLayout() {
|
||||
val ins = insets
|
||||
val availW = maxOf(0, width - ins.left - ins.right)
|
||||
val availH = maxOf(0, height - ins.top - ins.bottom)
|
||||
val t = title.preferredSize
|
||||
val gap = if (changes.isVisible) UiStyle.Gap.sm() else 0
|
||||
val b = if (changes.isVisible) changes.preferredSize else Dimension(0, 0)
|
||||
val badgeW = minOf(b.width, availW)
|
||||
val titleW = minOf(t.width, maxOf(0, availW - badgeW - gap))
|
||||
val titleH = minOf(t.height, availH)
|
||||
title.setBounds(ins.left, ins.top + (availH - titleH) / 2, titleW, titleH)
|
||||
if (changes.isVisible) {
|
||||
val badgeH = minOf(b.height, availH)
|
||||
changes.setBounds(ins.left + titleW + gap, ins.top + (availH - badgeH) / 2, badgeW, badgeH)
|
||||
}
|
||||
}
|
||||
}
|
||||
private val right = Stack.horizontal()
|
||||
.next(cost)
|
||||
@@ -159,10 +202,11 @@ class SessionHeaderPanel(
|
||||
isOpaque = true
|
||||
updateUI()
|
||||
|
||||
center.add(title, BorderLayout.CENTER)
|
||||
center.add(right, BorderLayout.EAST)
|
||||
centerGroup.add(title)
|
||||
centerGroup.add(changes)
|
||||
top.add(expand, BorderLayout.WEST)
|
||||
top.add(center, BorderLayout.CENTER)
|
||||
top.add(centerGroup, BorderLayout.CENTER)
|
||||
top.add(right, BorderLayout.EAST)
|
||||
add(top, BorderLayout.NORTH)
|
||||
timeline.addMouseListener(object : MouseAdapter() {
|
||||
override fun mousePressed(event: MouseEvent) {
|
||||
@@ -261,6 +305,11 @@ class SessionHeaderPanel(
|
||||
refresh()
|
||||
}
|
||||
|
||||
fun setBranchChanges(files: List<DiffFileDto>) {
|
||||
if (!changes.update(files)) return
|
||||
refresh()
|
||||
}
|
||||
|
||||
override fun applyStyle(style: SessionEditorStyle) {
|
||||
this.style = style
|
||||
background = style.editorBackground
|
||||
@@ -268,9 +317,10 @@ class SessionHeaderPanel(
|
||||
top.background = style.editorBackground
|
||||
top.isOpaque = true
|
||||
top.border = JBUI.Borders.empty(UiStyle.Gap.md(), UiStyle.Gap.sm(), UiStyle.Gap.md(), UiStyle.Gap.sm())
|
||||
center.background = style.editorBackground
|
||||
center.isOpaque = true
|
||||
centerGroup.background = style.editorBackground
|
||||
centerGroup.isOpaque = true
|
||||
right.background = style.editorBackground
|
||||
changes.background = style.editorBackground
|
||||
tokens.background = style.editorBackground
|
||||
todoRow.background = style.editorBackground
|
||||
todoBox.background = style.editorBackground
|
||||
@@ -278,6 +328,7 @@ class SessionHeaderPanel(
|
||||
viewport.background = style.editorBackground
|
||||
title.font = style.boldFont
|
||||
title.foreground = style.editorForeground
|
||||
changes.applyStyle(style)
|
||||
cost.font = style.regularFont
|
||||
cost.foreground = style.editorForeground
|
||||
cost.icon = null
|
||||
@@ -303,6 +354,8 @@ class SessionHeaderPanel(
|
||||
|
||||
internal fun titleText(): String = title.text
|
||||
|
||||
internal fun titleLabel() = title
|
||||
|
||||
internal fun costText(): String = costValue
|
||||
|
||||
internal fun costTip() = cost.toolTipText
|
||||
@@ -340,6 +393,18 @@ class SessionHeaderPanel(
|
||||
|
||||
internal fun compactButton() = compact
|
||||
|
||||
internal fun changesBadge() = changes
|
||||
|
||||
internal fun changesVisible() = changes.isVisible
|
||||
|
||||
internal fun changesText() = changes.countText()
|
||||
|
||||
internal fun changesStat() = changes.stats()
|
||||
|
||||
internal fun centerGroupPanel() = centerGroup
|
||||
|
||||
internal fun rightPanel() = right
|
||||
|
||||
internal fun expandButton() = expand
|
||||
|
||||
internal fun isExpanded() = body.parent === this
|
||||
|
||||
+18
-3
@@ -3,6 +3,7 @@ package ai.kilocode.client.session.ui.popup
|
||||
import ai.kilocode.client.session.ui.style.SessionUiStyle
|
||||
import com.intellij.openapi.Disposable
|
||||
import com.intellij.ui.EditorTextField
|
||||
import com.intellij.ui.components.JBScrollPane
|
||||
import com.intellij.ui.components.JBTextArea
|
||||
import com.intellij.util.ui.JBUI
|
||||
import java.awt.BorderLayout
|
||||
@@ -15,6 +16,7 @@ import javax.swing.JComponent
|
||||
import javax.swing.JEditorPane
|
||||
import javax.swing.JPanel
|
||||
import javax.swing.JScrollPane
|
||||
import javax.swing.ScrollPaneConstants
|
||||
|
||||
class HeaderPopupRequest(
|
||||
val anchor: JComponent,
|
||||
@@ -35,16 +37,29 @@ private class HeaderPopupPanel(
|
||||
private val child: JComponent,
|
||||
private val maxWidth: Int,
|
||||
) : JPanel(BorderLayout()) {
|
||||
init {
|
||||
// One scroll pane wraps every popup body (single-file edit, multi-file patch, session changes),
|
||||
// so bodies taller than the max height scroll instead of clipping. Bodies that carry their own
|
||||
// inner scroll pane render at full height inside the viewport, so only this outer pane scrolls.
|
||||
private val scroll = JBScrollPane(
|
||||
child,
|
||||
ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED,
|
||||
ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER,
|
||||
).apply {
|
||||
// Transparent so the balloon fill shows uniformly behind nested popup content.
|
||||
isOpaque = false
|
||||
add(child, BorderLayout.CENTER)
|
||||
viewport.isOpaque = false
|
||||
border = JBUI.Borders.empty()
|
||||
}
|
||||
|
||||
init {
|
||||
isOpaque = false
|
||||
add(scroll, BorderLayout.CENTER)
|
||||
}
|
||||
|
||||
override fun getPreferredSize(): Dimension {
|
||||
val width = contentWidth(child).takeIf { it > 0 }?.coerceAtMost(maxWidth) ?: maxWidth
|
||||
fit(child, width)
|
||||
val height = super.getPreferredSize().height.coerceAtMost(JBUI.scale(SessionUiStyle.View.Popup.MAX_HEIGHT))
|
||||
val height = child.preferredSize.height.coerceAtMost(JBUI.scale(SessionUiStyle.View.Popup.MAX_HEIGHT))
|
||||
return Dimension(width, height)
|
||||
}
|
||||
|
||||
|
||||
+14
@@ -1,7 +1,9 @@
|
||||
package ai.kilocode.client.session.ui.selection
|
||||
|
||||
import com.intellij.util.concurrency.annotations.RequiresEdt
|
||||
import java.awt.Dimension
|
||||
import javax.swing.JComponent
|
||||
import javax.swing.JPanel
|
||||
|
||||
internal interface SessionCopyTarget {
|
||||
val copyEligible: Boolean get() = true
|
||||
@@ -13,3 +15,15 @@ internal interface SessionCopyTarget {
|
||||
@RequiresEdt
|
||||
fun copyText(): String?
|
||||
}
|
||||
|
||||
internal fun hoverPlaceholder(toolbar: JComponent): JComponent = object : JPanel() {
|
||||
init {
|
||||
isOpaque = false
|
||||
}
|
||||
|
||||
override fun getPreferredSize(): Dimension = Dimension(toolbar.preferredSize)
|
||||
|
||||
override fun getMinimumSize(): Dimension = Dimension(toolbar.minimumSize)
|
||||
|
||||
override fun getMaximumSize(): Dimension = Dimension(toolbar.maximumSize)
|
||||
}
|
||||
|
||||
+21
@@ -37,6 +37,27 @@ object SessionUiStyle {
|
||||
const val BODY_EXTRA_HEIGHT = 16
|
||||
}
|
||||
|
||||
/**
|
||||
* Single source of truth for the spacing of every session-card header (see `PartHeader`).
|
||||
* Keep header gaps here so all cards stay aligned; do not hardcode header spacing elsewhere.
|
||||
*/
|
||||
object Header {
|
||||
/** Leading inset from the card edge to the first header element. */
|
||||
fun left() = JBUI.scale(Layout.HORIZONTAL_PADDING)
|
||||
|
||||
/** Trailing inset from the collapse/expand arrow to the card edge. */
|
||||
fun right() = JBUI.scale(Layout.HORIZONTAL_PADDING)
|
||||
|
||||
/** Gap between the leading glyph icon and the title. */
|
||||
fun icon() = UiStyle.Gap.sm()
|
||||
|
||||
/** Universal gap between every element after the title. */
|
||||
fun gap() = JBUI.scale(Layout.GAP)
|
||||
|
||||
/** Larger gap separating the title from the elements that follow it (one standard step above [gap]). */
|
||||
fun title() = UiStyle.Gap.lg()
|
||||
}
|
||||
|
||||
object Popup {
|
||||
const val MAX_WIDTH = 350
|
||||
const val WIDE_MAX_WIDTH = MAX_WIDTH * 2
|
||||
|
||||
+14
-2
@@ -1,5 +1,6 @@
|
||||
package ai.kilocode.client.session.views
|
||||
|
||||
import ai.kilocode.client.session.SessionDiffOpener
|
||||
import ai.kilocode.client.session.SessionFileOpener
|
||||
import ai.kilocode.client.session.model.Compaction
|
||||
import ai.kilocode.client.session.model.Content
|
||||
@@ -18,6 +19,7 @@ import ai.kilocode.client.session.ui.selection.SessionCopyTarget
|
||||
import ai.kilocode.client.session.ui.selection.SessionSelection
|
||||
import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget
|
||||
import ai.kilocode.client.session.views.base.PartView
|
||||
import ai.kilocode.client.session.views.tool.EditToolView
|
||||
import ai.kilocode.client.session.ui.style.SessionUiStyle
|
||||
import ai.kilocode.client.plugin.KiloBundle
|
||||
import ai.kilocode.client.ui.ToolbarButtonAction
|
||||
@@ -92,6 +94,8 @@ class MessageView(
|
||||
private var prompt: PromptView? = null
|
||||
private var promptBox: JPanel? = null
|
||||
private var wrap: PromptWrap? = null
|
||||
private var openDiff: SessionDiffOpener = { _, _, _ -> }
|
||||
private var sessionId: String? = null
|
||||
|
||||
init {
|
||||
isOpaque = false
|
||||
@@ -106,6 +110,14 @@ class MessageView(
|
||||
}
|
||||
}
|
||||
|
||||
fun setDiffOpener(openDiff: SessionDiffOpener, sessionId: String?) {
|
||||
this.openDiff = openDiff
|
||||
this.sessionId = sessionId
|
||||
// Rebind parts created before the opener was wired (e.g. history load), matching the
|
||||
// late-binding TurnView already does for its ModifiedFilesView card.
|
||||
for (view in parts.values) if (view is EditToolView) view.setDiffOpener(openDiff, sessionId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Suppress the running/pending question tool part that matches [ref] while
|
||||
* the linked question request is active. Pass null to stop suppressing.
|
||||
@@ -340,9 +352,9 @@ class MessageView(
|
||||
}
|
||||
|
||||
private fun view(content: Content) = if (msg.info.role == SessionUiStyle.View.Message.USER_ROLE) {
|
||||
ViewFactory.createUser(content, openFile, openUrl, selection, repo, promptMentions(msg)) { openAttachment(msg.info.id, it) }
|
||||
ViewFactory.createUser(content, openFile, openUrl, selection, repo, promptMentions(msg), { openAttachment(msg.info.id, it) }, openDiff, sessionId)
|
||||
} else {
|
||||
ViewFactory.create(content, openFile, openUrl, selection, repo) { openAttachment(msg.info.id, it) }
|
||||
ViewFactory.create(content, openFile, openUrl, selection, repo, { openAttachment(msg.info.id, it) }, openDiff, sessionId)
|
||||
}
|
||||
|
||||
private fun syncPromptMentions() {
|
||||
|
||||
+11
-15
@@ -12,6 +12,7 @@ import ai.kilocode.client.session.ui.popup.HeaderPopupRequest
|
||||
import ai.kilocode.client.session.ui.style.SessionEditorStyle
|
||||
import ai.kilocode.client.session.ui.selection.SessionSelection
|
||||
import ai.kilocode.client.session.ui.style.SessionUiStyle
|
||||
import ai.kilocode.client.session.views.base.PartHeader
|
||||
import ai.kilocode.client.session.views.base.SecondarySessionPartView
|
||||
import ai.kilocode.client.telemetry.Telemetry
|
||||
import ai.kilocode.client.ui.UiStyle
|
||||
@@ -71,7 +72,9 @@ class ReasoningView(
|
||||
init {
|
||||
row.border = JBUI.Borders.empty(
|
||||
JBUI.scale(SessionUiStyle.View.Reasoning.HEADER_VERTICAL_PADDING),
|
||||
JBUI.scale(SessionUiStyle.View.Layout.HORIZONTAL_PADDING),
|
||||
SessionUiStyle.View.Header.left(),
|
||||
JBUI.scale(SessionUiStyle.View.Reasoning.HEADER_VERTICAL_PADDING),
|
||||
SessionUiStyle.View.Header.right(),
|
||||
)
|
||||
bindHeader(parts.title, parts.icon)
|
||||
applyStyle(style)
|
||||
@@ -281,6 +284,8 @@ class ReasoningView(
|
||||
md.background = style.editorBackground
|
||||
md.component.border = JBUI.Borders.empty()
|
||||
md.set(text)
|
||||
// The shared popup wrapper (HeaderPopupBody) provides the scroll pane, so pass the content
|
||||
// panel directly instead of nesting a second scroll pane here.
|
||||
val panel = TrackPanel().apply {
|
||||
isOpaque = true
|
||||
background = style.editorBackground
|
||||
@@ -290,15 +295,7 @@ class ReasoningView(
|
||||
)
|
||||
add(md.component, BorderLayout.CENTER)
|
||||
}
|
||||
val scroll = JBScrollPane(panel).apply {
|
||||
border = JBUI.Borders.empty()
|
||||
isOpaque = true
|
||||
background = style.editorBackground
|
||||
viewport.background = style.editorBackground
|
||||
horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER
|
||||
verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED
|
||||
}
|
||||
return HeaderPopupBody(scroll, md, style.editorBackground)
|
||||
return HeaderPopupBody(panel, md, style.editorBackground)
|
||||
}
|
||||
|
||||
private fun bodyMaxHeight(): Int {
|
||||
@@ -336,7 +333,7 @@ class ReasoningView(
|
||||
}
|
||||
|
||||
class ReasoningParts(
|
||||
val header: JPanel,
|
||||
val header: PartHeader,
|
||||
val title: JBLabel,
|
||||
val icon: JBLabel,
|
||||
private val selection: SessionSelection?,
|
||||
@@ -391,10 +388,9 @@ class ReasoningBody(
|
||||
private fun reasoningParts(selection: SessionSelection? = null): ReasoningParts {
|
||||
val title = JBLabel(KiloBundle.message("session.part.reasoning")).apply { foreground = UiStyle.Colors.weak() }
|
||||
val icon = JBLabel(SessionViewIcons.brain).apply { foreground = UiStyle.Colors.weak() }
|
||||
val header = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.Layout.GAP), 0)).apply {
|
||||
isOpaque = false
|
||||
add(icon, BorderLayout.WEST)
|
||||
add(title, BorderLayout.CENTER)
|
||||
val header = PartHeader().apply {
|
||||
leading(icon)
|
||||
left(title)
|
||||
}
|
||||
return ReasoningParts(header, title, icon, selection)
|
||||
}
|
||||
|
||||
+2
@@ -15,10 +15,12 @@ object SessionViewIcons {
|
||||
val chevronExpanded: Icon = chevronDown
|
||||
val code = icon("code")
|
||||
val codeLines = icon("code-lines")
|
||||
val edit = codeLines
|
||||
val console = icon("console")
|
||||
val eye = icon("eye")
|
||||
val glasses = icon("glasses")
|
||||
val mcp = icon("mcp")
|
||||
val openDiff = icon("open-diff")
|
||||
val ruleApprove = icon("check-small")
|
||||
val ruleApproveActive = icon("check-small-active")
|
||||
val ruleDeny = icon("close-small")
|
||||
|
||||
+42
-2
@@ -1,8 +1,10 @@
|
||||
package ai.kilocode.client.session.views
|
||||
|
||||
import ai.kilocode.client.session.SessionDiffOpener
|
||||
import ai.kilocode.client.session.SessionFileOpener
|
||||
import ai.kilocode.client.session.model.FileAttachment
|
||||
import ai.kilocode.client.session.model.Message
|
||||
import ai.kilocode.client.session.ui.ModifiedFilesView
|
||||
import ai.kilocode.client.session.ui.SessionLayoutPanel
|
||||
import ai.kilocode.client.session.ui.SessionView
|
||||
import ai.kilocode.client.session.ui.style.SessionEditorStyle
|
||||
@@ -10,6 +12,7 @@ import ai.kilocode.client.session.ui.selection.SessionSelection
|
||||
import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget
|
||||
import ai.kilocode.client.session.ui.style.SessionUiStyle
|
||||
import ai.kilocode.client.session.views.base.PartView
|
||||
import ai.kilocode.rpc.dto.DiffFileDto
|
||||
import com.intellij.openapi.Disposable
|
||||
import com.intellij.openapi.util.Disposer
|
||||
import com.intellij.openapi.util.registry.Registry
|
||||
@@ -40,7 +43,10 @@ class TurnView(
|
||||
) : SessionLayoutPanel(SessionUiStyle.SessionLayout.GAP), Disposable, SessionEditorStyleTarget, SessionView {
|
||||
|
||||
private val messages = LinkedHashMap<String, MessageView>()
|
||||
private var modified: ModifiedFilesView? = null
|
||||
private var settled = true
|
||||
private var openDiff: SessionDiffOpener = { _, _, _ -> }
|
||||
private var sessionId: String? = null
|
||||
|
||||
override val sessionViewKind = SessionView.Kind.Default
|
||||
|
||||
@@ -51,6 +57,13 @@ class TurnView(
|
||||
isOpaque = false
|
||||
}
|
||||
|
||||
fun setDiffOpener(openDiff: SessionDiffOpener, sessionId: String?) {
|
||||
this.openDiff = openDiff
|
||||
this.sessionId = sessionId
|
||||
modified?.setDiffOpener(openDiff, sessionId, id)
|
||||
messages.values.forEach { it.setDiffOpener(openDiff, sessionId) }
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
fun setSettled(value: Boolean) {
|
||||
if (settled == value) return
|
||||
@@ -64,14 +77,35 @@ class TurnView(
|
||||
|
||||
/** Add a new [MessageView] for [msg] at the end of this turn. */
|
||||
fun addMessage(msg: Message): MessageView {
|
||||
val view = MessageView(msg, openFile, style, openUrl, selection, openAttachment, resize, repo, hover, revert)
|
||||
val view = MessageView(msg, openFile, style, openUrl, selection, openAttachment, resize, repo, hover, revert).also {
|
||||
it.setDiffOpener(openDiff, sessionId)
|
||||
}
|
||||
messages[msg.info.id] = view
|
||||
add(view)
|
||||
val idx = modified?.let { components.indexOf(it) } ?: componentCount
|
||||
add(view, idx)
|
||||
syncCopyToolbars()
|
||||
revalidate()
|
||||
return view
|
||||
}
|
||||
|
||||
/** Returns true when the modified-files card was created or its content changed. */
|
||||
@RequiresEdt
|
||||
fun setDiffs(diffs: List<DiffFileDto>): Boolean {
|
||||
val existing = modified
|
||||
val card = existing ?: if (diffs.isEmpty()) null else ModifiedFilesView(openFile, selection).also {
|
||||
it.setDiffOpener(openDiff, sessionId, id)
|
||||
it.resize = resize
|
||||
it.hover = hover
|
||||
it.applyStyle(style)
|
||||
modified = it
|
||||
add(it)
|
||||
}
|
||||
val created = existing == null && card != null
|
||||
val changed = card?.setDiffs(diffs) ?: false
|
||||
if (created || changed) revalidate()
|
||||
return created || changed
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
fun setQueued(active: Boolean, onDelete: (String) -> Unit) {
|
||||
val anchor = messages.values.firstOrNull { it.role == SessionUiStyle.View.Message.USER_ROLE } ?: return
|
||||
@@ -111,6 +145,7 @@ class TurnView(
|
||||
override fun applyStyle(style: SessionEditorStyle) {
|
||||
this.style = style
|
||||
for (view in messages.values) view.applyStyle(style)
|
||||
modified?.applyStyle(style)
|
||||
syncCopyToolbars()
|
||||
revalidate()
|
||||
repaint()
|
||||
@@ -121,6 +156,11 @@ class TurnView(
|
||||
remove(it)
|
||||
Disposer.dispose(it)
|
||||
}
|
||||
modified?.let {
|
||||
remove(it)
|
||||
Disposer.dispose(it)
|
||||
}
|
||||
modified = null
|
||||
messages.clear()
|
||||
}
|
||||
}
|
||||
|
||||
+19
-2
@@ -1,5 +1,6 @@
|
||||
package ai.kilocode.client.session.views
|
||||
|
||||
import ai.kilocode.client.session.SessionDiffOpener
|
||||
import ai.kilocode.client.session.SessionFileOpener
|
||||
import ai.kilocode.client.session.views.base.GenericView
|
||||
import ai.kilocode.client.session.views.base.PartView
|
||||
@@ -36,6 +37,12 @@ object ViewFactory {
|
||||
openFile: SessionFileOpener,
|
||||
): PartView = create(content, openFile, openUrl = {}, selection = null, repo = null)
|
||||
|
||||
fun create(
|
||||
content: Content,
|
||||
openFile: SessionFileOpener,
|
||||
openUrl: (String) -> Unit,
|
||||
): PartView = create(content, openFile, openUrl = openUrl, selection = null, repo = null)
|
||||
|
||||
fun create(
|
||||
content: Content,
|
||||
openFile: SessionFileOpener,
|
||||
@@ -43,6 +50,8 @@ object ViewFactory {
|
||||
selection: SessionSelection? = null,
|
||||
repo: String? = null,
|
||||
openAttachment: (FileAttachment) -> Unit = { AttachmentView.openDefault(it, openFile, openUrl) },
|
||||
openDiff: SessionDiffOpener = { _, _, _ -> },
|
||||
sessionId: String? = null,
|
||||
): PartView = when (content) {
|
||||
is Text -> TextView(content, openFile = openFile, openUrl = openUrl, selection = selection)
|
||||
is Reasoning -> ReasoningView(content, openFile = openFile, openUrl = openUrl, selection = selection)
|
||||
@@ -55,7 +64,7 @@ object ViewFactory {
|
||||
GlobToolView.canRender(content) -> GlobToolView(content, selection = selection, repo = repo)
|
||||
SearchToolView.canRender(content) -> SearchToolView(content, selection = selection, repo = repo)
|
||||
ReadToolView.canRender(content) -> ReadToolView(content, openFile, selection = selection)
|
||||
EditToolView.canRender(content) -> EditToolView(content, openFile, selection = selection)
|
||||
EditToolView.canRender(content) -> EditToolView(content, openFile, selection, openDiff, sessionId)
|
||||
TaskToolView.canRender(content) -> TaskToolView(content, selection = selection)
|
||||
else -> ToolView(content, selection = selection)
|
||||
}
|
||||
@@ -69,6 +78,12 @@ object ViewFactory {
|
||||
openFile: SessionFileOpener,
|
||||
): PartView = createUser(content, openFile, openUrl = {}, selection = null, repo = null)
|
||||
|
||||
fun createUser(
|
||||
content: Content,
|
||||
openFile: SessionFileOpener,
|
||||
openUrl: (String) -> Unit,
|
||||
): PartView = createUser(content, openFile, openUrl = openUrl, selection = null, repo = null)
|
||||
|
||||
fun createUser(
|
||||
content: Content,
|
||||
openFile: SessionFileOpener,
|
||||
@@ -77,9 +92,11 @@ object ViewFactory {
|
||||
repo: String? = null,
|
||||
mentions: List<PromptMention> = emptyList(),
|
||||
openAttachment: (FileAttachment) -> Unit = { AttachmentView.openDefault(it, openFile, openUrl) },
|
||||
openDiff: SessionDiffOpener = { _, _, _ -> },
|
||||
sessionId: String? = null,
|
||||
): PartView = when (content) {
|
||||
is Text -> PromptView(content, openFile = openFile, openAttachment = openAttachment, openUrl = openUrl, selection = selection, mentions = mentions)
|
||||
else -> create(content, openFile, openUrl, selection, repo, openAttachment)
|
||||
else -> create(content, openFile, openUrl, selection, repo, openAttachment, openDiff, sessionId)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+12
-2
@@ -3,7 +3,6 @@ package ai.kilocode.client.session.views.base
|
||||
import ai.kilocode.client.session.ui.style.SessionUiStyle
|
||||
import ai.kilocode.client.session.views.SessionViewIcons
|
||||
import com.intellij.ui.components.JBLabel
|
||||
import com.intellij.util.ui.JBUI
|
||||
import java.awt.BorderLayout
|
||||
import java.awt.Color
|
||||
import java.awt.Component
|
||||
@@ -29,7 +28,7 @@ abstract class AbstractSessionPartView(
|
||||
) : this(header, { body }, expanded, expandable)
|
||||
|
||||
protected val arrow = JBLabel()
|
||||
protected val row = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.Layout.GAP), 0))
|
||||
protected val row = JPanel(BorderLayout(SessionUiStyle.View.Header.gap(), 0))
|
||||
private val bound = linkedSetOf<Component>()
|
||||
private var body: JComponent? = null
|
||||
|
||||
@@ -124,6 +123,10 @@ abstract class AbstractSessionPartView(
|
||||
items.forEach { bind(it) }
|
||||
}
|
||||
|
||||
protected fun unbindHeader(vararg items: Component) {
|
||||
items.forEach { unbind(it) }
|
||||
}
|
||||
|
||||
protected fun refresh() {
|
||||
revalidate()
|
||||
repaint()
|
||||
@@ -151,6 +154,13 @@ abstract class AbstractSessionPartView(
|
||||
component.addMouseListener(mouse)
|
||||
}
|
||||
|
||||
private fun unbind(component: Component) {
|
||||
if (!bound.remove(component)) return
|
||||
component.removeMouseListener(click)
|
||||
component.removeMouseListener(mouse)
|
||||
component.cursor = Cursor.getDefaultCursor()
|
||||
}
|
||||
|
||||
private fun body(): JComponent {
|
||||
val item = body
|
||||
if (item != null) return item
|
||||
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
package ai.kilocode.client.session.views.base
|
||||
|
||||
import ai.kilocode.client.session.ui.style.SessionUiStyle.View.Header
|
||||
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 java.awt.BorderLayout
|
||||
import java.awt.Component
|
||||
import javax.swing.JComponent
|
||||
import javax.swing.JPanel
|
||||
|
||||
/**
|
||||
* Shared session-card header. A [BorderLayout] row with a left group, an optional
|
||||
* flexible middle that absorbs remaining width and clips (e.g. a file path), and a
|
||||
* right group that hugs the trailing edge.
|
||||
*
|
||||
* The collapse/expand arrow is owned by [AbstractSessionPartView] and sits to the
|
||||
* right of this header, so together they realise the west (left) / center (right
|
||||
* group) / east (arrow) layout.
|
||||
*
|
||||
* All spacing comes from [Header]: [leading] applies the icon-to-title gap, while every
|
||||
* other element is separated by the universal [Header.gap]. Text labels center vertically
|
||||
* by default, so add them directly. Fixed-size controls (icons, badges, diff bars) must be
|
||||
* added via [centered] so they keep their preferred size and stay centered.
|
||||
*/
|
||||
class PartHeader : JPanel(BorderLayout(Header.gap(), 0)) {
|
||||
val left = Stack.horizontal(Header.gap())
|
||||
val right = Stack.horizontal(Header.gap())
|
||||
|
||||
init {
|
||||
isOpaque = false
|
||||
add(left, BorderLayout.WEST)
|
||||
add(right, BorderLayout.EAST)
|
||||
}
|
||||
|
||||
/** Adds the leading glyph and reserves the tighter icon-to-title gap before the title. */
|
||||
fun leading(icon: Component): PartHeader {
|
||||
left.next(icon).gap(Header.icon())
|
||||
return this
|
||||
}
|
||||
|
||||
/** Reserves the larger title-to-elements gap before the next left element. */
|
||||
fun titleGap(): PartHeader {
|
||||
left.gap(Header.title())
|
||||
return this
|
||||
}
|
||||
|
||||
fun left(vararg items: Component): PartHeader {
|
||||
items.forEach { left.next(it) }
|
||||
return this
|
||||
}
|
||||
|
||||
fun right(vararg items: Component): PartHeader {
|
||||
items.forEach { right.next(it) }
|
||||
return this
|
||||
}
|
||||
|
||||
/** Flexible middle that absorbs remaining width and clips its content. */
|
||||
fun fill(component: JComponent): PartHeader {
|
||||
add(component, BorderLayout.CENTER)
|
||||
return this
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Wraps a fixed-size control so it keeps its preferred size and stays centered. */
|
||||
fun centered(component: Component): JComponent = component.align(HAlign.CENTER, VAlign.CENTER)
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -17,7 +17,9 @@ abstract class PrimarySessionPartView(
|
||||
row.background = SessionUiStyle.View.Surface.headerBgColor()
|
||||
row.border = JBUI.Borders.empty(
|
||||
JBUI.scale(SessionUiStyle.View.Layout.VERTICAL_PADDING),
|
||||
JBUI.scale(SessionUiStyle.View.Layout.HORIZONTAL_PADDING),
|
||||
SessionUiStyle.View.Header.left(),
|
||||
JBUI.scale(SessionUiStyle.View.Layout.VERTICAL_PADDING),
|
||||
SessionUiStyle.View.Header.right(),
|
||||
)
|
||||
syncBorder()
|
||||
}
|
||||
|
||||
+3
-1
@@ -22,7 +22,9 @@ abstract class SecondarySessionPartView(
|
||||
row.background = SessionUiStyle.View.Surface.headerBgColor()
|
||||
row.border = JBUI.Borders.empty(
|
||||
JBUI.scale(SessionUiStyle.View.Layout.VERTICAL_PADDING),
|
||||
JBUI.scale(SessionUiStyle.View.Layout.HORIZONTAL_PADDING),
|
||||
SessionUiStyle.View.Header.left(),
|
||||
JBUI.scale(SessionUiStyle.View.Layout.VERTICAL_PADDING),
|
||||
SessionUiStyle.View.Header.right(),
|
||||
)
|
||||
syncBorder()
|
||||
}
|
||||
|
||||
+12
-19
@@ -7,15 +7,14 @@ import ai.kilocode.client.session.model.ToolExecState
|
||||
import ai.kilocode.client.session.ui.style.SessionEditorStyle
|
||||
import ai.kilocode.client.session.ui.style.SessionUiStyle
|
||||
import ai.kilocode.client.session.views.SessionViewIcons
|
||||
import ai.kilocode.client.session.views.base.PartHeader
|
||||
import ai.kilocode.client.session.views.base.PrimarySessionPartView
|
||||
import ai.kilocode.client.ui.UiStyle
|
||||
import ai.kilocode.client.ui.layout.Stack
|
||||
import com.intellij.ui.components.JBLabel
|
||||
import com.intellij.util.ui.JBUI
|
||||
import java.awt.BorderLayout
|
||||
import java.awt.Font
|
||||
import javax.swing.Box
|
||||
import javax.swing.JComponent
|
||||
import javax.swing.JPanel
|
||||
|
||||
class TodoWriteView(tool: Tool, private val parts: TodoParts = todoParts()) :
|
||||
PrimarySessionPartView(parts.header, parts.list, expanded = true) {
|
||||
@@ -26,7 +25,7 @@ class TodoWriteView(tool: Tool, private val parts: TodoParts = todoParts()) :
|
||||
private var style = SessionEditorStyle.current()
|
||||
|
||||
init {
|
||||
bindHeader(parts.glyph, parts.title, parts.sub, parts.center, parts.controls)
|
||||
bindHeader(parts.glyph, parts.title, parts.sub, parts.left, parts.right)
|
||||
parts.list.border = JBUI.Borders.compound(
|
||||
JBUI.Borders.customLine(
|
||||
SessionUiStyle.View.Outline.color(),
|
||||
@@ -92,12 +91,12 @@ class TodoWriteView(tool: Tool, private val parts: TodoParts = todoParts()) :
|
||||
}
|
||||
|
||||
class TodoParts(
|
||||
val header: JPanel,
|
||||
val header: PartHeader,
|
||||
val glyph: JBLabel,
|
||||
val title: JBLabel,
|
||||
val sub: JBLabel,
|
||||
val center: JPanel,
|
||||
val controls: JComponent,
|
||||
val left: Stack,
|
||||
val right: Stack,
|
||||
val list: TodoListPanel,
|
||||
)
|
||||
|
||||
@@ -105,19 +104,13 @@ private fun todoParts(): TodoParts {
|
||||
val glyph = JBLabel(SessionViewIcons.checklist)
|
||||
val title = JBLabel(KiloBundle.message("session.part.todo.title"))
|
||||
val sub = JBLabel().apply { foreground = UiStyle.Colors.weak() }
|
||||
val center = JPanel(BorderLayout(UiStyle.Gap.md(), 0)).apply {
|
||||
isOpaque = false
|
||||
add(title, BorderLayout.WEST)
|
||||
add(sub, BorderLayout.CENTER)
|
||||
val header = PartHeader().apply {
|
||||
leading(glyph)
|
||||
left(title)
|
||||
titleGap()
|
||||
left(sub)
|
||||
}
|
||||
val controls = Box.createHorizontalBox()
|
||||
val header = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.Layout.GAP), 0)).apply {
|
||||
isOpaque = false
|
||||
add(glyph, BorderLayout.WEST)
|
||||
add(center, BorderLayout.CENTER)
|
||||
add(controls, BorderLayout.EAST)
|
||||
}
|
||||
return TodoParts(header, glyph, title, sub, center, controls, TodoListPanel())
|
||||
return TodoParts(header, glyph, title, sub, header.left, header.right, TodoListPanel())
|
||||
}
|
||||
|
||||
private fun subtitle(tool: Tool): String {
|
||||
|
||||
+2
-2
@@ -34,7 +34,7 @@ abstract class BaseSearchToolView(
|
||||
protected abstract fun viewName(): String
|
||||
|
||||
init {
|
||||
bindHeader(parts.glyph, parts.title, parts.sub, parts.state, parts.center, parts.controls, parts.slot)
|
||||
bindHeader(parts.glyph, parts.title, parts.sub, parts.state, parts.left, parts.right, parts.slot)
|
||||
parts.targets.forEach { bindHeader(it) }
|
||||
applyStyle(style)
|
||||
sync()
|
||||
@@ -104,7 +104,7 @@ abstract class BaseSearchToolView(
|
||||
@RequiresEdt
|
||||
internal fun headerComponent() = parts.header
|
||||
@RequiresEdt
|
||||
internal fun centerComponent() = parts.center
|
||||
internal fun centerComponent() = parts.fill
|
||||
@RequiresEdt
|
||||
internal fun targetComponents() = parts.targets
|
||||
|
||||
|
||||
+87
-7
@@ -1,21 +1,30 @@
|
||||
package ai.kilocode.client.session.views.tool
|
||||
|
||||
import ai.kilocode.client.diff.DiffLineNumbers
|
||||
import ai.kilocode.client.plugin.KiloBundle
|
||||
import ai.kilocode.client.session.SessionDiffOpener
|
||||
import ai.kilocode.client.session.SessionFileOpener
|
||||
import ai.kilocode.client.session.model.Content
|
||||
import ai.kilocode.client.session.model.Tool
|
||||
import ai.kilocode.client.session.model.ToolKind
|
||||
import ai.kilocode.client.session.ui.popup.HeaderPopupBody
|
||||
import ai.kilocode.client.session.ui.popup.HeaderPopupRequest
|
||||
import ai.kilocode.client.session.ui.selection.SessionCopyTarget
|
||||
import ai.kilocode.client.session.ui.selection.SessionSelection
|
||||
import ai.kilocode.client.session.ui.selection.hoverPlaceholder
|
||||
import ai.kilocode.client.session.ui.style.SessionEditorStyle
|
||||
import ai.kilocode.client.session.ui.style.SessionUiStyle
|
||||
import ai.kilocode.client.session.views.SessionViewIcons
|
||||
import ai.kilocode.client.session.views.base.PartHeader
|
||||
import ai.kilocode.client.session.views.base.SecondarySessionPartView
|
||||
import ai.kilocode.client.telemetry.Telemetry
|
||||
import ai.kilocode.client.ui.DiffStatBadge
|
||||
import ai.kilocode.client.ui.ToolbarButtonAction
|
||||
import ai.kilocode.client.ui.UiStyle
|
||||
import ai.kilocode.client.ui.md.MdCodeBlockBorder
|
||||
import ai.kilocode.client.ui.md.MdCodeBlockOptions
|
||||
import ai.kilocode.client.ui.toolbarButton
|
||||
import ai.kilocode.rpc.dto.DiffFileDto
|
||||
import com.intellij.openapi.actionSystem.DataSink
|
||||
import com.intellij.openapi.actionSystem.UiDataProvider
|
||||
import com.intellij.openapi.util.Disposer
|
||||
@@ -23,8 +32,8 @@ import com.intellij.ui.EditorTextField
|
||||
import com.intellij.ui.components.JBLabel
|
||||
import com.intellij.util.concurrency.annotations.RequiresEdt
|
||||
import com.intellij.util.ui.JBFont
|
||||
import com.intellij.util.ui.JBUI
|
||||
import java.awt.Dimension
|
||||
import javax.swing.JComponent
|
||||
import javax.swing.ScrollPaneConstants
|
||||
|
||||
/**
|
||||
@@ -38,34 +47,73 @@ class EditToolView(
|
||||
private val selection: SessionSelection? = null,
|
||||
private val parts: ToolParts = toolParts(tool, openFile),
|
||||
private var body: EditBody = editBody(tool, selection, openFile),
|
||||
) : SecondarySessionPartView(parts.header, { body.mount(tool) }), UiDataProvider {
|
||||
) : SecondarySessionPartView(parts.header, { body.mount(tool) }), UiDataProvider, SessionCopyTarget {
|
||||
|
||||
override val contentId: String = tool.id
|
||||
|
||||
private var item = tool
|
||||
private var style = SessionEditorStyle.current()
|
||||
private var multi = editFiles(tool).size > 1
|
||||
private var opener: SessionDiffOpener = { _, _, _ -> }
|
||||
private var sessionId: String? = null
|
||||
private var canDiff = false
|
||||
private val badge = DiffStatBadge(0, 0)
|
||||
private val diff = toolbarButton(
|
||||
ToolbarButtonAction(SessionViewIcons.openDiff, KiloBundle.message("session.part.tool.openDiff"), ::openDiffViewer),
|
||||
)
|
||||
private val diffAnchor = hoverPlaceholder(diff)
|
||||
private val filesTag = JBLabel().apply {
|
||||
foreground = UiStyle.Colors.weak()
|
||||
font = JBFont.small()
|
||||
border = JBUI.Borders.emptyRight(SessionUiStyle.View.Layout.HORIZONTAL_PADDING)
|
||||
isVisible = false
|
||||
}
|
||||
|
||||
init {
|
||||
body.parent = this
|
||||
parts.controls.add(filesTag)
|
||||
parts.controls.add(badge)
|
||||
bindHeader(parts.glyph, parts.title, parts.sub, parts.state, parts.center, parts.controls, parts.slot, filesTag, badge)
|
||||
// Left-aligned header: icon, title, file name (single) or file count (multi), change badge, open-in-diff.
|
||||
parts.left.next(parts.link)
|
||||
parts.left.next(filesTag)
|
||||
parts.left.next(PartHeader.centered(badge))
|
||||
parts.left.next(PartHeader.centered(diffAnchor))
|
||||
// parts.link is intentionally omitted: FileLinkLabel installs its own click handler that opens
|
||||
// the file, and binding it here would also toggle the card on the same click (see ReadToolView,
|
||||
// which likewise omits it). Header toggling still works via parts.left/row.
|
||||
bindHeader(parts.glyph, parts.title, parts.sub, parts.state, parts.left, parts.right, parts.slot, filesTag, badge, diffAnchor)
|
||||
applyStyle(style)
|
||||
sync()
|
||||
}
|
||||
|
||||
override val copyEligible: Boolean get() = canDiff
|
||||
override val copyAnchor: JComponent get() = diffAnchor
|
||||
override val copyToolbar: JComponent get() = diff
|
||||
|
||||
constructor(
|
||||
tool: Tool,
|
||||
openFile: SessionFileOpener,
|
||||
selection: SessionSelection?,
|
||||
openDiff: SessionDiffOpener,
|
||||
sessionId: String?,
|
||||
) : this(tool, openFile, selection) {
|
||||
opener = openDiff
|
||||
this.sessionId = sessionId
|
||||
}
|
||||
|
||||
/**
|
||||
* Late-bind the diff opener. The transcript builds this view before the session-level opener is
|
||||
* known, so [ai.kilocode.client.session.views.MessageView] rebinds it once the opener is wired.
|
||||
*/
|
||||
@RequiresEdt
|
||||
fun setDiffOpener(openDiff: SessionDiffOpener, sessionId: String?) {
|
||||
opener = openDiff
|
||||
this.sessionId = sessionId
|
||||
}
|
||||
|
||||
override fun uiDataSnapshot(sink: DataSink) {
|
||||
selection?.provideCopy(sink) { body.markdown() ?: diffMarkdown(item) }
|
||||
}
|
||||
|
||||
override fun copyText(): String? = null
|
||||
|
||||
@RequiresEdt
|
||||
override fun expand(): Boolean {
|
||||
val changed = super.expand()
|
||||
@@ -184,11 +232,27 @@ class EditToolView(
|
||||
changed = setForeground(parts.link, UiStyle.Colors.fg()) || changed
|
||||
changed = setText(parts.state, stateText(item)) || changed
|
||||
changed = setForeground(parts.state, color(item)) || changed
|
||||
syncDiffAction(count)
|
||||
changed = syncFilesTag(count) || changed
|
||||
changed = syncBadge() || changed
|
||||
return changed
|
||||
}
|
||||
|
||||
private fun syncDiffAction(count: Int) {
|
||||
// Mirrors toDiffFiles(item).isNotEmpty() without re-parsing the metadata JSON or allocating a
|
||||
// DiffFileDto per file on every streaming delta: files present, else a single-file patch.
|
||||
val show = count > 0 || editDiff(item).isNotBlank()
|
||||
if (canDiff == show && diff.isEnabled == show) return
|
||||
canDiff = show
|
||||
diff.isEnabled = show
|
||||
}
|
||||
|
||||
private fun openDiffViewer() {
|
||||
val files = toDiffFiles(item)
|
||||
if (files.isEmpty()) return
|
||||
opener(files, diffTitle(item), "tool:${sessionId ?: "pending"}:${item.id}")
|
||||
}
|
||||
|
||||
private fun syncFilesTag(count: Int): Boolean {
|
||||
val show = count > 1
|
||||
var changed = setVisible(filesTag, show)
|
||||
@@ -224,6 +288,20 @@ class EditToolView(
|
||||
}
|
||||
}
|
||||
|
||||
private fun toDiffFiles(tool: Tool): List<DiffFileDto> {
|
||||
val files = editFiles(tool).map { DiffFileDto(it.path, it.additions, it.deletions, it.patch, it.type.ifBlank { null }) }
|
||||
if (files.isNotEmpty()) return files
|
||||
val patch = editDiff(tool)
|
||||
if (patch.isBlank()) return emptyList()
|
||||
val stat = diffStat(tool)
|
||||
return listOf(DiffFileDto(editPath(tool), stat.first, stat.second, patch))
|
||||
}
|
||||
|
||||
private fun diffTitle(tool: Tool): String =
|
||||
// Keep the file name for a single-file edit so each per-tool diff tab is identifiable
|
||||
// (SessionUi decorates it into "<name> (branch)"); reserve the generic label for multi-file patches.
|
||||
if (editFiles(tool).size > 1) KiloBundle.message("session.part.tool.patch") else tail(editPath(tool))
|
||||
|
||||
/** Picks the multi-file patch body for apply_patch spanning several files, else the single diff. */
|
||||
private fun editBody(tool: Tool, selection: SessionSelection?, openFile: SessionFileOpener): EditBody =
|
||||
if (editFiles(tool).size > 1) PatchBody(selection, openFile) else diffBody(selection)
|
||||
@@ -240,15 +318,17 @@ private fun diffBody(selection: SessionSelection?) = ToolMarkdownBody(
|
||||
),
|
||||
selection,
|
||||
render = ::diffMarkdown,
|
||||
gutter = { editDiff(it).takeIf { patch -> patch.isNotBlank() }?.let(DiffLineNumbers::rows) },
|
||||
)
|
||||
|
||||
private fun popupDiffBody(selection: SessionSelection?) = ToolMarkdownBody(
|
||||
POPUP_OPTS,
|
||||
selection,
|
||||
render = ::diffMarkdown,
|
||||
gutter = { editDiff(it).takeIf { patch -> patch.isNotBlank() }?.let(DiffLineNumbers::rows) },
|
||||
)
|
||||
|
||||
private val POPUP_OPTS = MdCodeBlockOptions(
|
||||
internal val POPUP_OPTS = MdCodeBlockOptions(
|
||||
border = MdCodeBlockBorder.None,
|
||||
verticalPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED,
|
||||
editorOnly = true,
|
||||
|
||||
+37
-9
@@ -1,5 +1,7 @@
|
||||
package ai.kilocode.client.session.views.tool
|
||||
|
||||
import ai.kilocode.client.diff.DiffLineNumbers
|
||||
import ai.kilocode.client.diff.installDiffGutter
|
||||
import ai.kilocode.client.session.SessionFileOpener
|
||||
import ai.kilocode.client.session.model.Tool
|
||||
import ai.kilocode.client.session.ui.selection.SessionSelection
|
||||
@@ -17,6 +19,7 @@ import com.intellij.openapi.Disposable
|
||||
import com.intellij.openapi.util.Disposer
|
||||
import com.intellij.ui.EditorTextField
|
||||
import com.intellij.ui.components.JBScrollPane
|
||||
import com.intellij.util.ui.NamedColorUtil
|
||||
import com.intellij.util.concurrency.annotations.RequiresEdt
|
||||
import com.intellij.util.ui.JBUI
|
||||
import java.awt.Component
|
||||
@@ -62,13 +65,17 @@ class PatchBody(
|
||||
private val links = mutableListOf<FileLinkLabel>()
|
||||
private var style = SessionEditorStyle.current()
|
||||
private var signature = ""
|
||||
private val rows = mutableListOf<List<DiffLineNumbers.Row>>()
|
||||
|
||||
@RequiresEdt
|
||||
override fun mount(tool: Tool): JComponent {
|
||||
override fun mount(tool: Tool): JComponent = mountFiles(editFiles(tool))
|
||||
|
||||
@RequiresEdt
|
||||
internal fun mountFiles(files: List<EditFileChange>): JComponent {
|
||||
root?.let { return it }
|
||||
val panel = Stack.vertical()
|
||||
root = panel
|
||||
rebuild(tool)
|
||||
rebuild(files)
|
||||
return panel
|
||||
}
|
||||
|
||||
@@ -83,9 +90,14 @@ class PatchBody(
|
||||
|
||||
@RequiresEdt
|
||||
override fun update(tool: Tool): Boolean {
|
||||
return updateFiles(editFiles(tool))
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
internal fun updateFiles(files: List<EditFileChange>): Boolean {
|
||||
if (root == null) return false
|
||||
if (signatureOf(tool) == signature) return false
|
||||
rebuild(tool)
|
||||
if (signatureOf(files) == signature) return false
|
||||
rebuild(files)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -119,19 +131,20 @@ class PatchBody(
|
||||
owner = null
|
||||
views.clear()
|
||||
links.clear()
|
||||
rows.clear()
|
||||
panel?.removeAll()
|
||||
signature = ""
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
private fun rebuild(tool: Tool) {
|
||||
private fun rebuild(files: List<EditFileChange>) {
|
||||
val panel = root ?: return
|
||||
val parent = parent ?: error("Patch body has no parent")
|
||||
disposeBody()
|
||||
val disposable = Disposer.newDisposable("Patch body")
|
||||
Disposer.register(parent, disposable)
|
||||
owner = disposable
|
||||
editFiles(tool).filter { it.patch.isNotBlank() }.forEachIndexed { index, file ->
|
||||
files.filter { it.patch.isNotBlank() }.forEachIndexed { index, file ->
|
||||
if (index > 0) panel.gap(JBUI.scale(SessionUiStyle.View.Code.BLOCK_GAP))
|
||||
panel.next(header(file))
|
||||
panel.gap(UiStyle.Gap.sm())
|
||||
@@ -139,15 +152,18 @@ class PatchBody(
|
||||
Disposer.register(disposable, md)
|
||||
applyMd(md)
|
||||
md.set(patchMarkdown(file.patch))
|
||||
val nums = DiffLineNumbers.rows(file.patch)
|
||||
rows.add(nums)
|
||||
installGutter(md, nums)
|
||||
views.add(md)
|
||||
panel.next(md.component)
|
||||
}
|
||||
signature = signatureOf(tool)
|
||||
signature = signatureOf(files)
|
||||
panel.revalidate()
|
||||
panel.repaint()
|
||||
}
|
||||
|
||||
private fun signatureOf(tool: Tool): String = editFiles(tool)
|
||||
private fun signatureOf(files: List<EditFileChange>): String = files
|
||||
.joinToString("\u0000") { "${it.path}\u0001${it.additions}\u0001${it.deletions}\u0001${it.patch}" }
|
||||
|
||||
@RequiresEdt
|
||||
@@ -164,7 +180,10 @@ class PatchBody(
|
||||
.next(DiffStatBadge(file.additions, file.deletions))
|
||||
return JBUI.Panels.simplePanel(row).apply {
|
||||
isOpaque = false
|
||||
border = JBUI.Borders.emptyLeft(SessionUiStyle.View.Code.VIEWPORT_HORIZONTAL_PADDING)
|
||||
border = JBUI.Borders.compound(
|
||||
JBUI.Borders.customLineBottom(NamedColorUtil.getBoundsColor()),
|
||||
JBUI.Borders.emptyLeft(SessionUiStyle.View.Code.VIEWPORT_HORIZONTAL_PADDING),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,9 +196,18 @@ class PatchBody(
|
||||
md.preBg = style.editorBackground
|
||||
md.codeFont = style.editorFamily
|
||||
md.component.border = JBUI.Borders.empty()
|
||||
rows.getOrNull(views.indexOf(md))?.let { installGutter(md, it) }
|
||||
return before != md.font
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
private fun installGutter(md: MdView, rows: List<DiffLineNumbers.Row>) {
|
||||
((md.component as? JPanel)?.components
|
||||
?.filterIsInstance<JBScrollPane>()
|
||||
?.mapNotNull { it.viewport.view as? EditorTextField }
|
||||
?: emptyList()).forEach { installDiffGutter(it, rows) }
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val DIFF_OPTS = MdCodeBlockOptions(
|
||||
border = MdCodeBlockBorder.Bottom,
|
||||
|
||||
+1
-1
@@ -34,7 +34,7 @@ class ReadToolView(
|
||||
|
||||
init {
|
||||
parts.text?.let { selection?.register(it, this) }
|
||||
bindHeader(parts.glyph, parts.title, parts.sub, parts.state, parts.center, parts.controls, parts.slot)
|
||||
bindHeader(parts.glyph, parts.title, parts.sub, parts.state, parts.left, parts.right, parts.slot)
|
||||
parts.text?.text = preview(item)
|
||||
applyStyle(style)
|
||||
sync()
|
||||
|
||||
+1
-1
@@ -43,7 +43,7 @@ class ShellToolView(
|
||||
|
||||
init {
|
||||
body.parent = this
|
||||
bindHeader(parts.glyph, parts.title, parts.sub, parts.state, parts.center, parts.controls, parts.slot)
|
||||
bindHeader(parts.glyph, parts.title, parts.sub, parts.state, parts.left, parts.right, parts.slot)
|
||||
applyStyle(style)
|
||||
sync()
|
||||
}
|
||||
|
||||
+1
-1
@@ -42,7 +42,7 @@ class TaskToolView(
|
||||
private var collapsed = false
|
||||
|
||||
init {
|
||||
bindHeader(parts.glyph, parts.title, parts.sub, parts.state, parts.center, parts.controls, parts.slot)
|
||||
bindHeader(parts.glyph, parts.title, parts.sub, parts.state, parts.left, parts.right, parts.slot)
|
||||
applyStyle(style)
|
||||
sync()
|
||||
if (item.childTools.isNotEmpty()) expand()
|
||||
|
||||
+18
@@ -1,5 +1,7 @@
|
||||
package ai.kilocode.client.session.views.tool
|
||||
|
||||
import ai.kilocode.client.diff.DiffLineNumbers
|
||||
import ai.kilocode.client.diff.installDiffGutter
|
||||
import ai.kilocode.client.session.model.Tool
|
||||
import ai.kilocode.client.session.ui.selection.SessionSelection
|
||||
import ai.kilocode.client.session.ui.style.SessionEditorStyle
|
||||
@@ -31,22 +33,26 @@ class ToolMarkdownBody(
|
||||
private val opts: MdCodeBlockOptions,
|
||||
private val selection: SessionSelection?,
|
||||
private val render: (Tool) -> String,
|
||||
private val gutter: ((Tool) -> List<DiffLineNumbers.Row>?)? = null,
|
||||
private val font: (SessionEditorStyle) -> Font = SessionEditorStyle::editorFont,
|
||||
private val chrome: (MdView) -> Unit = {},
|
||||
) : EditBody {
|
||||
override var parent: Disposable? = null
|
||||
private var view: MdView? = null
|
||||
private var item: Tool? = null
|
||||
|
||||
/** Builds the body on first call, wiring it into [parent]'s disposable tree, then returns it. */
|
||||
@RequiresEdt
|
||||
override fun mount(tool: Tool): JComponent {
|
||||
view?.let { return it.component }
|
||||
item = tool
|
||||
val owner = parent ?: error("Tool markdown body has no parent")
|
||||
val md = MdViewFactory.create(SessionEditorStyle.current(), selection, MdCodeBlockFactory.default(opts))
|
||||
Disposer.register(owner, md)
|
||||
view = md
|
||||
applyStyle(SessionEditorStyle.current())
|
||||
update(tool)
|
||||
syncGutter(tool)
|
||||
return md.component
|
||||
}
|
||||
|
||||
@@ -61,11 +67,13 @@ class ToolMarkdownBody(
|
||||
|
||||
@RequiresEdt
|
||||
override fun update(tool: Tool): Boolean {
|
||||
item = tool
|
||||
val md = view ?: return false
|
||||
val value = render(tool)
|
||||
if (md.markdown() == value) return false
|
||||
md.set(value)
|
||||
chrome(md)
|
||||
syncGutter(tool)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -81,9 +89,19 @@ class ToolMarkdownBody(
|
||||
md.codeFont = style.editorFamily
|
||||
md.component.border = JBUI.Borders.empty()
|
||||
chrome(md)
|
||||
// EditorTextField drops its editor in removeNotify, so collapse/re-expand yields a fresh
|
||||
// editor with no annotation provider. Re-install the gutter here (as PatchBody.applyMd does)
|
||||
// so the old/new line-number gutter survives a re-expansion, not just the first mount.
|
||||
item?.let(::syncGutter)
|
||||
return before != md.font
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
private fun syncGutter(tool: Tool) {
|
||||
val rows = gutter?.invoke(tool) ?: return
|
||||
codeEditors().forEach { installDiffGutter(it, rows) }
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
override fun markdown(): String? = view?.markdown()
|
||||
|
||||
|
||||
+44
-47
@@ -12,12 +12,10 @@ import ai.kilocode.client.session.ui.selection.SessionCopyTarget
|
||||
import ai.kilocode.client.session.ui.style.SessionEditorStyle
|
||||
import ai.kilocode.client.session.ui.style.SessionUiStyle
|
||||
import ai.kilocode.client.session.views.SessionViewIcons
|
||||
import ai.kilocode.client.session.views.base.PartHeader
|
||||
import ai.kilocode.client.ui.UiStyle
|
||||
import ai.kilocode.client.ui.editor.BashCommandHighlighter
|
||||
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.cli.KiloCliParser
|
||||
import ai.kilocode.log.KiloLog
|
||||
import com.intellij.openapi.actionSystem.DataSink
|
||||
@@ -44,7 +42,6 @@ import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.intOrNull
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import java.awt.BorderLayout
|
||||
import java.awt.Color
|
||||
import java.awt.Cursor
|
||||
import java.awt.Dimension
|
||||
@@ -62,15 +59,16 @@ private val LOG = KiloLog.create(ToolParts::class.java)
|
||||
enum class ToolBodyMode { EDITOR, TEXT }
|
||||
|
||||
class ToolParts(
|
||||
val header: JPanel,
|
||||
val header: PartHeader,
|
||||
val glyph: JBLabel,
|
||||
val title: JBLabel,
|
||||
val sub: JBLabel,
|
||||
val link: FileLinkLabel,
|
||||
val slot: JPanel,
|
||||
val state: JBLabel,
|
||||
val center: JPanel,
|
||||
val controls: JComponent,
|
||||
val left: Stack,
|
||||
val right: Stack,
|
||||
val fill: JComponent,
|
||||
val extra: JBLabel? = null,
|
||||
val targets: List<JBLabel> = emptyList(),
|
||||
private val mode: ToolBodyMode = ToolBodyMode.EDITOR,
|
||||
@@ -406,29 +404,20 @@ internal fun toolParts(
|
||||
val title = clip(JBLabel())
|
||||
val sub = clip(JBLabel()).apply { foreground = UiStyle.Colors.weak() }
|
||||
val link = clip(FileLinkLabel(openFile))
|
||||
val slot = Stack.fitHorizontal().apply {
|
||||
val slot = Stack.fitHorizontal(SessionUiStyle.View.Header.gap()).apply {
|
||||
minimumSize = Dimension(0, minimumSize.height)
|
||||
next(sub)
|
||||
next(link)
|
||||
}
|
||||
val state = clip(JBLabel()).apply { foreground = UiStyle.Colors.weak() }
|
||||
val center = JPanel(BorderLayout(UiStyle.Gap.md(), 0)).apply {
|
||||
isOpaque = false
|
||||
minimumSize = Dimension(0, minimumSize.height)
|
||||
}
|
||||
val controls = Stack.horizontal()
|
||||
val header = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.Layout.GAP), 0)).apply {
|
||||
isOpaque = false
|
||||
center.add(title, BorderLayout.WEST)
|
||||
center.add(slot, BorderLayout.CENTER)
|
||||
add(glyph, BorderLayout.WEST)
|
||||
add(center, BorderLayout.CENTER)
|
||||
add(controls, BorderLayout.EAST)
|
||||
}
|
||||
val parts = ToolParts(header, glyph, title, sub, link, slot, state, center, controls, mode = mode)
|
||||
return parts.also {
|
||||
controls.add(it.state)
|
||||
val header = PartHeader().apply {
|
||||
leading(glyph)
|
||||
left(title)
|
||||
titleGap()
|
||||
fill(slot)
|
||||
right(state)
|
||||
}
|
||||
return ToolParts(header, glyph, title, sub, link, slot, state, header.left, header.right, fill = slot, mode = mode)
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
@@ -442,30 +431,24 @@ internal fun searchParts(count: Int): ToolParts {
|
||||
}
|
||||
}
|
||||
val link = clip(FileLinkLabel())
|
||||
val slot = Stack.fitHorizontal().apply {
|
||||
val slot = Stack.fitHorizontal(SessionUiStyle.View.Header.gap()).apply {
|
||||
minimumSize = Dimension(0, minimumSize.height)
|
||||
next(sub)
|
||||
next(link)
|
||||
}
|
||||
val state = clip(JBLabel()).apply { foreground = UiStyle.Colors.weak() }
|
||||
val stack = Stack.fitHorizontal(UiStyle.Gap.md()).apply { targets.forEach { next(it) } }
|
||||
val target = stack.align(HAlign.TRACK, VAlign.CENTER)
|
||||
val center = JPanel(BorderLayout(UiStyle.Gap.md(), 0)).apply {
|
||||
isOpaque = false
|
||||
val target = Stack.fitHorizontal(SessionUiStyle.View.Header.gap()).apply {
|
||||
minimumSize = Dimension(0, minimumSize.height)
|
||||
add(title, BorderLayout.WEST)
|
||||
add(target, BorderLayout.CENTER)
|
||||
targets.forEach { next(it) }
|
||||
}
|
||||
val controls = Stack.horizontal()
|
||||
val header = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.Layout.GAP), 0)).apply {
|
||||
isOpaque = false
|
||||
add(glyph, BorderLayout.WEST)
|
||||
add(center, BorderLayout.CENTER)
|
||||
add(controls, BorderLayout.EAST)
|
||||
}
|
||||
return ToolParts(header, glyph, title, sub, link, slot, state, center, controls, targets = targets, mode = ToolBodyMode.EDITOR).also {
|
||||
controls.add(it.state)
|
||||
val header = PartHeader().apply {
|
||||
leading(glyph)
|
||||
left(title)
|
||||
titleGap()
|
||||
fill(target)
|
||||
right(state)
|
||||
}
|
||||
return ToolParts(header, glyph, title, sub, link, slot, state, header.left, header.right, fill = target, targets = targets, mode = ToolBodyMode.EDITOR)
|
||||
}
|
||||
|
||||
internal fun icon(tool: Tool) = when (tool.name) {
|
||||
@@ -476,7 +459,7 @@ internal fun icon(tool: Tool) = when (tool.name) {
|
||||
"codesearch" -> SessionViewIcons.code
|
||||
"task" -> SessionViewIcons.task
|
||||
"bash" -> SessionViewIcons.console
|
||||
"edit", "write", "apply_patch" -> SessionViewIcons.codeLines
|
||||
"edit", "write", "apply_patch" -> SessionViewIcons.edit
|
||||
"todowrite", "todoread" -> SessionViewIcons.checklist
|
||||
"question" -> SessionViewIcons.bubble
|
||||
"skill" -> SessionViewIcons.brain
|
||||
@@ -855,13 +838,27 @@ internal fun diffStat(tool: Tool): Pair<Int, Int> {
|
||||
return added to removed
|
||||
}
|
||||
|
||||
/** Display-only diff body without VCS/file metadata headers (Index, diff --git, ---, +++, etc.). */
|
||||
internal fun pureDiff(diff: String): String = diff.lineSequence()
|
||||
.filterNot(::diffMeta)
|
||||
.joinToString("\n")
|
||||
.trim('\n')
|
||||
/**
|
||||
* Display-only diff body. Strips the pre-hunk file/VCS headers (Index, diff --git, ---, +++, etc.)
|
||||
* and the `@@` hunk markers, but keeps every in-hunk line verbatim — a deleted `-- ` comment that
|
||||
* renders as `--- ...` is diff content, not a header, so it must survive here and in
|
||||
* [ai.kilocode.client.diff.DiffLineNumbers.rows] for the gutter line numbers to stay aligned.
|
||||
*/
|
||||
internal fun pureDiff(diff: String): String {
|
||||
val out = StringBuilder()
|
||||
var hunk = false
|
||||
diff.lineSequence().forEach { line ->
|
||||
if (line.startsWith("@@")) {
|
||||
hunk = true
|
||||
return@forEach
|
||||
}
|
||||
if (!hunk && diffMeta(line)) return@forEach
|
||||
out.append(line).append('\n')
|
||||
}
|
||||
return out.toString().trim('\n')
|
||||
}
|
||||
|
||||
private fun diffMeta(line: String): Boolean = line.startsWith("Index:") ||
|
||||
internal fun diffMeta(line: String): Boolean = line.startsWith("Index:") ||
|
||||
line.startsWith("====") ||
|
||||
line.startsWith("diff --git ") ||
|
||||
line.startsWith("@@") ||
|
||||
|
||||
+1
-1
@@ -31,7 +31,7 @@ class ToolView(
|
||||
private var disposed = false
|
||||
|
||||
init {
|
||||
bindHeader(parts.glyph, parts.title, parts.sub, parts.state, parts.center, parts.controls, parts.slot)
|
||||
bindHeader(parts.glyph, parts.title, parts.sub, parts.state, parts.left, parts.right, parts.slot)
|
||||
applyStyle(style)
|
||||
sync()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package ai.kilocode.client.ui
|
||||
|
||||
import com.intellij.util.ui.JBUI
|
||||
import java.awt.Color
|
||||
import java.awt.Dimension
|
||||
import java.awt.Graphics
|
||||
import java.awt.Graphics2D
|
||||
import java.awt.RenderingHints
|
||||
import javax.swing.JPanel
|
||||
|
||||
internal class DiffBars(
|
||||
additions: Int,
|
||||
deletions: Int,
|
||||
) : JPanel() {
|
||||
private var additions = additions
|
||||
private var deletions = deletions
|
||||
|
||||
init {
|
||||
isOpaque = false
|
||||
}
|
||||
|
||||
fun update(additions: Int, deletions: Int) {
|
||||
if (this.additions == additions && this.deletions == deletions) return
|
||||
this.additions = additions
|
||||
this.deletions = deletions
|
||||
repaint()
|
||||
}
|
||||
|
||||
override fun getPreferredSize(): Dimension = JBUI.size(WIDTH, HEIGHT)
|
||||
|
||||
override fun getMinimumSize(): Dimension = preferredSize
|
||||
|
||||
override fun getMaximumSize(): Dimension = preferredSize
|
||||
|
||||
override fun paintComponent(g: Graphics) {
|
||||
val g2 = g.create() as Graphics2D
|
||||
try {
|
||||
g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON)
|
||||
val barHeight = JBUI.scale(HEIGHT)
|
||||
val y = maxOf(0, (height - barHeight) / 2)
|
||||
blocks().forEachIndexed { index, color ->
|
||||
g2.color = color
|
||||
g2.fillRoundRect(
|
||||
JBUI.scale(index * STEP),
|
||||
y,
|
||||
JBUI.scale(BAR_WIDTH),
|
||||
barHeight,
|
||||
JBUI.scale(ARC),
|
||||
JBUI.scale(ARC),
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
g2.dispose()
|
||||
}
|
||||
super.paintComponent(g)
|
||||
}
|
||||
|
||||
private fun blocks(): List<Color> {
|
||||
val total = additions + deletions
|
||||
if (total <= 0) return List(COUNT) { UiStyle.Colors.weak() }
|
||||
val added = ((additions.toDouble() / total) * COUNT).toInt().coerceIn(0, COUNT)
|
||||
val removed = ((deletions.toDouble() / total) * COUNT).toInt().coerceIn(0, COUNT - added)
|
||||
val neutral = COUNT - added - removed
|
||||
return List(added) { UiStyle.Colors.addedForeground() } +
|
||||
List(removed) { UiStyle.Colors.removedForeground() } +
|
||||
List(neutral) { UiStyle.Colors.weak() }
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val COUNT = 5
|
||||
const val BAR_WIDTH = 2
|
||||
const val STEP = 4
|
||||
const val HEIGHT = 14
|
||||
const val WIDTH = 18
|
||||
const val ARC = 2
|
||||
}
|
||||
}
|
||||
+40
-5
@@ -6,6 +6,7 @@ import com.intellij.ui.components.JBLabel
|
||||
import com.intellij.util.ui.JBFont
|
||||
import com.intellij.util.ui.JBUI
|
||||
import java.awt.Color
|
||||
import java.awt.Dimension
|
||||
import java.awt.Graphics
|
||||
import java.awt.Graphics2D
|
||||
import java.awt.GridBagLayout
|
||||
@@ -15,7 +16,31 @@ import javax.swing.JPanel
|
||||
internal class DiffStatBadge(
|
||||
additions: Int,
|
||||
deletions: Int,
|
||||
private val variant: Variant = Variant.REGULAR,
|
||||
private val inset: Int = 0,
|
||||
) : JPanel(GridBagLayout()) {
|
||||
constructor(additions: Int, deletions: Int) : this(additions, deletions, Variant.REGULAR, 0)
|
||||
|
||||
internal enum class Variant {
|
||||
REGULAR,
|
||||
COMPACT;
|
||||
|
||||
fun height() = when (this) {
|
||||
REGULAR -> JBUI.scale(16)
|
||||
COMPACT -> JBUI.scale(14)
|
||||
}
|
||||
|
||||
fun gap() = when (this) {
|
||||
REGULAR -> UiStyle.Gap.sm()
|
||||
COMPACT -> UiStyle.Gap.xs()
|
||||
}
|
||||
|
||||
fun pad() = when (this) {
|
||||
REGULAR -> UiStyle.Gap.sm()
|
||||
COMPACT -> UiStyle.Gap.sm()
|
||||
}
|
||||
}
|
||||
|
||||
private val removed = JBLabel().apply {
|
||||
foreground = UiStyle.Colors.removedForeground()
|
||||
font = JBFont.small()
|
||||
@@ -27,26 +52,36 @@ internal class DiffStatBadge(
|
||||
|
||||
init {
|
||||
isOpaque = false
|
||||
border = JBUI.Borders.empty(0, UiStyle.Gap.sm(), 0, UiStyle.Gap.sm())
|
||||
border = JBUI.Borders.empty(0, variant.pad(), 0, variant.pad() + inset)
|
||||
add(
|
||||
Stack.horizontal(UiStyle.Gap.sm())
|
||||
Stack.horizontal(variant.gap())
|
||||
.next(removed)
|
||||
.next(added),
|
||||
)
|
||||
update(additions, deletions)
|
||||
}
|
||||
|
||||
override fun getPreferredSize(): Dimension {
|
||||
val dim = super.getPreferredSize()
|
||||
return Dimension(dim.width, variant.height())
|
||||
}
|
||||
|
||||
fun update(additions: Int, deletions: Int) {
|
||||
removed.text = "-$deletions"
|
||||
added.text = "+$additions"
|
||||
removed.isVisible = deletions > 0
|
||||
added.isVisible = additions > 0
|
||||
if (removed.isVisible) removed.text = "-$deletions"
|
||||
if (added.isVisible) added.text = "+$additions"
|
||||
}
|
||||
|
||||
override fun paintComponent(g: Graphics) {
|
||||
val g2 = g.create() as Graphics2D
|
||||
try {
|
||||
val w = maxOf(0, width - inset)
|
||||
val h = minOf(height, variant.height())
|
||||
val y = (height - h) / 2
|
||||
g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON)
|
||||
g2.color = backgroundColor()
|
||||
g2.fillRoundRect(0, 0, width, height, height, height)
|
||||
g2.fillRoundRect(0, y, w, h, h, h)
|
||||
} finally {
|
||||
g2.dispose()
|
||||
}
|
||||
|
||||
+3
@@ -1,5 +1,6 @@
|
||||
package ai.kilocode.client.vfs
|
||||
|
||||
import ai.kilocode.client.diff.ensureDiffEditorKind
|
||||
import ai.kilocode.client.session.ui.attachment.ensureAttachmentEditorKind
|
||||
import com.intellij.openapi.components.service
|
||||
import com.intellij.openapi.fileEditor.FileEditor
|
||||
@@ -13,6 +14,7 @@ import com.intellij.openapi.vfs.VirtualFile
|
||||
class KiloFileEditorProvider : FileEditorProvider, DumbAware {
|
||||
override fun accept(project: Project, file: VirtualFile): Boolean {
|
||||
ensureAttachmentEditorKind()
|
||||
ensureDiffEditorKind()
|
||||
val path = path(file) ?: return false
|
||||
return service<KiloEditorKindRegistry>().get(path.kind) != null
|
||||
}
|
||||
@@ -21,6 +23,7 @@ class KiloFileEditorProvider : FileEditorProvider, DumbAware {
|
||||
|
||||
override fun createEditor(project: Project, file: VirtualFile): FileEditor {
|
||||
ensureAttachmentEditorKind()
|
||||
ensureDiffEditorKind()
|
||||
val path = path(file) ?: error("Invalid Kilo virtual file: ${file.path}")
|
||||
val kilo = file as? KiloVirtualFile ?: KiloVirtualFile(path)
|
||||
val kind = service<KiloEditorKindRegistry>().get(kilo.path.kind) ?: error("Unknown Kilo editor kind: ${kilo.path.kind}")
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
<!-- Copyright 2026 Kilo Code contributors. Use of this source code is governed by the Apache 2.0 license. -->
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6.5 4.5H3.5C2.94772 4.5 2.5 4.94772 2.5 5.5V12.5C2.5 13.0523 2.94772 13.5 3.5 13.5H10.5C11.0523 13.5 11.5 13.0523 11.5 12.5V9.5" stroke="#6C707E" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M9.5 2.5H13.5V6.5" stroke="#6C707E" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M8.5 7.5L13.5 2.5" stroke="#6C707E" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 589 B |
@@ -0,0 +1,6 @@
|
||||
<!-- Copyright 2026 Kilo Code contributors. Use of this source code is governed by the Apache 2.0 license. -->
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6.5 4.5H3.5C2.94772 4.5 2.5 4.94772 2.5 5.5V12.5C2.5 13.0523 2.94772 13.5 3.5 13.5H10.5C11.0523 13.5 11.5 13.0523 11.5 12.5V9.5" stroke="#CED0D6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M9.5 2.5H13.5V6.5" stroke="#CED0D6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M8.5 7.5L13.5 2.5" stroke="#CED0D6" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 589 B |
@@ -124,7 +124,30 @@ session.status.offline=Connection offline
|
||||
|
||||
session.part.reasoning=Reasoning
|
||||
session.part.compaction=context compacted
|
||||
session.changes.modified=Modified
|
||||
session.changes.count.one={0} file
|
||||
session.changes.count.other={0} files
|
||||
diff.editor.session.title=Session Changes
|
||||
diff.editor.inline.title.named={0} ({1})
|
||||
diff.editor.changedFiles.title=Changed files
|
||||
diff.editor.branch.title=Changes vs base branch
|
||||
diff.editor.branch.title.named=Changes vs base branch ({0})
|
||||
diff.editor.file.title={0} ({1})
|
||||
diff.editor.side.base=Base
|
||||
diff.editor.side.current=Current
|
||||
diff.editor.side.original=Original
|
||||
diff.editor.side.modified=Modified
|
||||
diff.editor.branch.tooltip=Compare with base branch
|
||||
diff.editor.session.tooltip=Open changes in editor
|
||||
diff.editor.empty=No changes
|
||||
diff.editor.patch.unavailable=Diff unavailable
|
||||
diff.editor.outdated=Changes on disk are not shown
|
||||
diff.editor.openFile=Open File
|
||||
diff.editor.refresh=Refresh
|
||||
diff.editor.tree.expandAll=Expand All
|
||||
diff.editor.tree.collapseAll=Collapse All
|
||||
session.part.tool.copy=Copy
|
||||
session.part.tool.openDiff=Open in Diff Viewer
|
||||
session.part.tool.error=Error
|
||||
session.part.tool.agent={0} Agent
|
||||
session.part.tool.pending=Pending
|
||||
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
package ai.kilocode.client.diff
|
||||
|
||||
import ai.kilocode.client.session.views.tool.pureDiff
|
||||
import com.intellij.testFramework.fixtures.BasePlatformTestCase
|
||||
|
||||
class DiffLineNumbersTest : BasePlatformTestCase() {
|
||||
fun `test rows align with pure diff display lines`() {
|
||||
fixtures().forEach { patch ->
|
||||
assertEquals(pureDiff(patch).trim('\n').lines().size, DiffLineNumbers.rows(patch).size)
|
||||
}
|
||||
}
|
||||
|
||||
fun `test modified hunk emits old and new counters`() {
|
||||
val patch = """
|
||||
@@ -1,3 +1,3 @@
|
||||
keep
|
||||
-old
|
||||
+new
|
||||
done
|
||||
""".trimIndent()
|
||||
|
||||
assertEquals(
|
||||
listOf(
|
||||
DiffLineNumbers.Row(1, 1),
|
||||
DiffLineNumbers.Row(2, null),
|
||||
DiffLineNumbers.Row(null, 2),
|
||||
DiffLineNumbers.Row(3, 3),
|
||||
),
|
||||
DiffLineNumbers.rows(patch),
|
||||
)
|
||||
}
|
||||
|
||||
fun `test multi hunk resets counters`() {
|
||||
val patch = """
|
||||
@@ -1,1 +1,1 @@
|
||||
-old
|
||||
+new
|
||||
@@ -10,1 +20,1 @@
|
||||
keep
|
||||
""".trimIndent()
|
||||
|
||||
assertEquals(
|
||||
listOf(
|
||||
DiffLineNumbers.Row(1, null),
|
||||
DiffLineNumbers.Row(null, 1),
|
||||
DiffLineNumbers.Row(10, 20),
|
||||
),
|
||||
DiffLineNumbers.rows(patch),
|
||||
)
|
||||
}
|
||||
|
||||
fun `test in-hunk header-shaped lines stay content`() {
|
||||
// A deleted "-- foo" comment renders as "--- foo" and an added "++ bar" as "+++ bar";
|
||||
// both are hunk content, so they keep incrementing the counters instead of being dropped.
|
||||
val patch = """
|
||||
--- a/q.sql
|
||||
+++ b/q.sql
|
||||
@@ -1,2 +1,2 @@
|
||||
--- old comment
|
||||
+++ new comment
|
||||
keep
|
||||
""".trimIndent()
|
||||
|
||||
assertEquals(
|
||||
listOf(
|
||||
DiffLineNumbers.Row(1, null),
|
||||
DiffLineNumbers.Row(null, 1),
|
||||
DiffLineNumbers.Row(2, 2),
|
||||
),
|
||||
DiffLineNumbers.rows(patch),
|
||||
)
|
||||
}
|
||||
|
||||
fun `test no newline marker emits empty row`() {
|
||||
val patch = """
|
||||
@@ -1 +1 @@
|
||||
-old
|
||||
\ No newline at end of file
|
||||
+new
|
||||
""".trimIndent()
|
||||
|
||||
assertEquals(
|
||||
listOf(
|
||||
DiffLineNumbers.Row(1, null),
|
||||
DiffLineNumbers.Row(null, null),
|
||||
DiffLineNumbers.Row(null, 1),
|
||||
),
|
||||
DiffLineNumbers.rows(patch),
|
||||
)
|
||||
}
|
||||
|
||||
private fun fixtures() = listOf(
|
||||
"""
|
||||
diff --git a/src/App.kt b/src/App.kt
|
||||
index 111..222 100644
|
||||
--- a/src/App.kt
|
||||
+++ b/src/App.kt
|
||||
@@ -1,2 +1,2 @@
|
||||
keep
|
||||
-old
|
||||
+new
|
||||
""".trimIndent(),
|
||||
"""
|
||||
--- /dev/null
|
||||
+++ b/src/New.kt
|
||||
@@ -0,0 +1,2 @@
|
||||
+one
|
||||
+two
|
||||
""".trimIndent(),
|
||||
"""
|
||||
--- a/src/Old.kt
|
||||
+++ /dev/null
|
||||
@@ -1,2 +0,0 @@
|
||||
-one
|
||||
-two
|
||||
""".trimIndent(),
|
||||
"@@ -1 +1 @@\r\n-old\r\n+new\r\n",
|
||||
"""
|
||||
--- a/q.sql
|
||||
+++ b/q.sql
|
||||
@@ -1,2 +1,2 @@
|
||||
--- old comment
|
||||
+++ new comment
|
||||
keep
|
||||
""".trimIndent(),
|
||||
)
|
||||
}
|
||||
+187
@@ -0,0 +1,187 @@
|
||||
package ai.kilocode.client.diff
|
||||
|
||||
import ai.kilocode.rpc.dto.DiffFileDto
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class DiffPatchReconstructTest {
|
||||
@Test
|
||||
fun `reconstructs modified full context patch`() {
|
||||
val dto = DiffFileDto(
|
||||
file = "src/A.kt",
|
||||
additions = 1,
|
||||
deletions = 1,
|
||||
patch = """
|
||||
diff --git a/src/A.kt b/src/A.kt
|
||||
index 111..222 100644
|
||||
--- a/src/A.kt
|
||||
+++ b/src/A.kt
|
||||
@@ -1,3 +1,3 @@
|
||||
one
|
||||
-two
|
||||
+TWO
|
||||
three
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
val sides = DiffPatchReconstruct.sides(dto)
|
||||
|
||||
assertTrue(sides.renderable)
|
||||
assertEquals("one\ntwo\nthree", sides.before)
|
||||
assertEquals("one\nTWO\nthree", sides.after)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `newline terminated full context patch stays renderable`() {
|
||||
// Real git patches end with a newline, so split('\n') yields a trailing "" that must not be
|
||||
// counted as a hunk body line. Without the edge trim this reconstructs as non-renderable and
|
||||
// falls back to the all-green raw-patch view.
|
||||
val dto = DiffFileDto(
|
||||
file = "src/A.kt",
|
||||
additions = 1,
|
||||
deletions = 1,
|
||||
patch = """
|
||||
diff --git a/src/A.kt b/src/A.kt
|
||||
index 111..222 100644
|
||||
--- a/src/A.kt
|
||||
+++ b/src/A.kt
|
||||
@@ -1,3 +1,3 @@
|
||||
one
|
||||
-two
|
||||
+TWO
|
||||
three
|
||||
""".trimIndent() + "\n",
|
||||
)
|
||||
|
||||
val sides = DiffPatchReconstruct.sides(dto)
|
||||
|
||||
assertTrue(sides.renderable)
|
||||
assertEquals("one\ntwo\nthree", sides.before)
|
||||
assertEquals("one\nTWO\nthree", sides.after)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `added file has empty before side`() {
|
||||
val dto = DiffFileDto(
|
||||
file = "src/A.kt",
|
||||
additions = 2,
|
||||
deletions = 0,
|
||||
patch = """
|
||||
diff --git a/src/A.kt b/src/A.kt
|
||||
--- /dev/null
|
||||
+++ b/src/A.kt
|
||||
@@ -0,0 +1,2 @@
|
||||
+one
|
||||
+two
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
val sides = DiffPatchReconstruct.sides(dto)
|
||||
|
||||
assertEquals("", sides.before)
|
||||
assertEquals("one\ntwo", sides.after)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `synthesized untracked patch renders as added file`() {
|
||||
val dto = DiffFileDto(
|
||||
file = "src/New.kt",
|
||||
additions = 2,
|
||||
deletions = 0,
|
||||
patch = """
|
||||
diff --git a/src/New.kt b/src/New.kt
|
||||
new file mode 100644
|
||||
--- /dev/null
|
||||
+++ b/src/New.kt
|
||||
@@ -0,0 +1,2 @@
|
||||
+one
|
||||
+two
|
||||
\ No newline at end of file
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
val sides = DiffPatchReconstruct.sides(dto)
|
||||
|
||||
assertTrue(DiffPatchReconstruct.added(dto.patch))
|
||||
assertEquals("", sides.before)
|
||||
assertEquals("one\ntwo", sides.after)
|
||||
assertTrue(sides.renderable)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deleted file has empty after side`() {
|
||||
val dto = DiffFileDto(
|
||||
file = "src/A.kt",
|
||||
additions = 0,
|
||||
deletions = 2,
|
||||
patch = """
|
||||
diff --git a/src/A.kt b/src/A.kt
|
||||
--- a/src/A.kt
|
||||
+++ /dev/null
|
||||
@@ -1,2 +0,0 @@
|
||||
-one
|
||||
-two
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
val sides = DiffPatchReconstruct.sides(dto)
|
||||
|
||||
assertEquals("one\ntwo", sides.before)
|
||||
assertEquals("", sides.after)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `multi hunk partial context patch is not renderable`() {
|
||||
val dto = DiffFileDto(
|
||||
file = "src/A.kt",
|
||||
additions = 2,
|
||||
deletions = 2,
|
||||
patch = """
|
||||
diff --git a/src/A.kt b/src/A.kt
|
||||
--- a/src/A.kt
|
||||
+++ b/src/A.kt
|
||||
@@ -1,3 +1,3 @@
|
||||
one
|
||||
-two
|
||||
+TWO
|
||||
@@ -20,3 +20,3 @@
|
||||
twenty
|
||||
-x
|
||||
+X
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
val sides = DiffPatchReconstruct.sides(dto)
|
||||
|
||||
assertFalse(sides.renderable)
|
||||
assertEquals("", sides.before)
|
||||
assertEquals("", sides.after)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `single hunk with mismatched header length is not renderable`() {
|
||||
// header claims 3 old / 3 new lines but the body only carries 2 of each (context elided).
|
||||
val dto = DiffFileDto(
|
||||
file = "src/A.kt",
|
||||
additions = 1,
|
||||
deletions = 1,
|
||||
patch = """
|
||||
--- a/src/A.kt
|
||||
+++ b/src/A.kt
|
||||
@@ -1,3 +1,3 @@
|
||||
-two
|
||||
+TWO
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
assertFalse(DiffPatchReconstruct.sides(dto).renderable)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `binary and blank patches are not renderable`() {
|
||||
assertFalse(DiffPatchReconstruct.sides(DiffFileDto("a.bin", 0, 0, "Binary files a/a.bin and b/a.bin differ")).renderable)
|
||||
assertFalse(DiffPatchReconstruct.sides(DiffFileDto("a.kt", 0, 0, "")).renderable)
|
||||
}
|
||||
}
|
||||
+435
@@ -0,0 +1,435 @@
|
||||
package ai.kilocode.client.diff
|
||||
|
||||
import ai.kilocode.client.plugin.KiloBundle
|
||||
import ai.kilocode.client.ui.DiffStatBadge
|
||||
import ai.kilocode.rpc.dto.DiffFileDto
|
||||
import com.intellij.openapi.actionSystem.ActionToolbar
|
||||
import com.intellij.openapi.actionSystem.Separator
|
||||
import com.intellij.openapi.Disposable
|
||||
import com.intellij.openapi.application.ApplicationManager
|
||||
import com.intellij.openapi.fileEditor.FileDocumentManager
|
||||
import com.intellij.openapi.util.Disposer
|
||||
import com.intellij.openapi.vcs.FileStatus
|
||||
import com.intellij.ui.SimpleColoredComponent
|
||||
import com.intellij.testFramework.fixtures.BasePlatformTestCase
|
||||
import com.intellij.ui.EditorNotificationPanel
|
||||
import com.intellij.ui.components.JBLabel
|
||||
import com.intellij.ui.components.JBScrollPane
|
||||
import com.intellij.ui.treeStructure.Tree
|
||||
import com.intellij.util.ui.JBUI
|
||||
import com.intellij.util.ui.UIUtil
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import java.awt.BorderLayout
|
||||
import java.awt.Component
|
||||
import java.awt.Container
|
||||
import javax.swing.SwingUtilities
|
||||
import javax.swing.tree.DefaultMutableTreeNode
|
||||
import javax.swing.tree.TreePath
|
||||
|
||||
class KiloDiffEditorContentTest : BasePlatformTestCase() {
|
||||
fun `test tree toolbar shows aggregate badge`() {
|
||||
val parent = Disposer.newDisposable()
|
||||
try {
|
||||
val view = view(files(), parent)
|
||||
val badges = components(view).filterIsInstance<DiffStatBadge>()
|
||||
|
||||
assertTrue(badges.any { it.addedLabelForTest().text == "+5" && it.removedLabelForTest().text == "-4" })
|
||||
} finally {
|
||||
Disposer.dispose(parent)
|
||||
}
|
||||
}
|
||||
|
||||
fun `test tree toolbar shows changed file count`() {
|
||||
val parent = Disposer.newDisposable()
|
||||
try {
|
||||
val view = view(files(), parent)
|
||||
|
||||
assertTrue(components(view).filterIsInstance<JBLabel>().any { it.text == "2 files" })
|
||||
} finally {
|
||||
Disposer.dispose(parent)
|
||||
}
|
||||
}
|
||||
|
||||
fun `test tree toolbar shows singular changed file count`() {
|
||||
val parent = Disposer.newDisposable()
|
||||
try {
|
||||
val view = view(listOf(file("src/App.kt", 2, 1)), parent)
|
||||
|
||||
assertTrue(components(view).filterIsInstance<JBLabel>().any { it.text == "1 file" })
|
||||
} finally {
|
||||
Disposer.dispose(parent)
|
||||
}
|
||||
}
|
||||
|
||||
fun `test tree renderer shows compact row change badge`() {
|
||||
val parent = Disposer.newDisposable()
|
||||
try {
|
||||
val view = view(files(), parent)
|
||||
val tree = components(view).filterIsInstance<Tree>().single()
|
||||
val badge = rowBadge(renderer(tree, leaf(tree)))
|
||||
|
||||
assertTrue(badge.isVisible)
|
||||
assertTrue(badge.preferredSize.height < DiffStatBadge(1, 1).preferredSize.height)
|
||||
} finally {
|
||||
Disposer.dispose(parent)
|
||||
}
|
||||
}
|
||||
|
||||
fun `test tree renderer uses file status color`() {
|
||||
val parent = Disposer.newDisposable()
|
||||
try {
|
||||
val color = FileStatus.ADDED.color ?: return
|
||||
val view = view(listOf(file("src/App.kt", 2, 0, status = "added")), parent)
|
||||
val tree = components(view).filterIsInstance<Tree>().single()
|
||||
val row = renderer(tree, leaf(tree))
|
||||
val text = components(row).filterIsInstance<SimpleColoredComponent>().single()
|
||||
val iter = text.iterator()
|
||||
|
||||
assertTrue(iter.hasNext())
|
||||
iter.next()
|
||||
assertEquals(color, iter.textAttributes.fgColor)
|
||||
} finally {
|
||||
Disposer.dispose(parent)
|
||||
}
|
||||
}
|
||||
|
||||
fun `test explicit and patch-derived file statuses`() {
|
||||
assertEquals(FileStatus.ADDED, fileStatus(file("src/New.kt", 1, 0, status = "added")))
|
||||
assertEquals(FileStatus.MODIFIED, fileStatus(file("src/App.kt", 1, 1, status = "modified")))
|
||||
assertEquals(FileStatus.DELETED, fileStatus(file("src/Old.kt", 0, 1, status = "deleted")))
|
||||
assertEquals(FileStatus.UNKNOWN, fileStatus(file("src/Unknown.kt", 1, 0, status = "untracked")))
|
||||
assertEquals(FileStatus.ADDED, fileStatus(file("src/New.kt", 1, 0, patch = "--- /dev/null\n+++ b/src/New.kt")))
|
||||
assertEquals(FileStatus.DELETED, fileStatus(file("src/Old.kt", 0, 1, patch = "--- a/src/Old.kt\n+++ /dev/null")))
|
||||
assertEquals(FileStatus.MODIFIED, fileStatus(file("src/App.kt", 1, 1, patch = "@@ -1 +1 @@\n-old\n+new")))
|
||||
}
|
||||
|
||||
fun `test row renderer places badge east of filename`() {
|
||||
val parent = Disposer.newDisposable()
|
||||
try {
|
||||
val view = view(files(), parent)
|
||||
val tree = components(view).filterIsInstance<Tree>().single()
|
||||
val row = renderer(tree, leaf(tree)) as Container
|
||||
val layout = row.layout as BorderLayout
|
||||
val east = layout.getLayoutComponent(BorderLayout.EAST)
|
||||
val center = layout.getLayoutComponent(BorderLayout.CENTER)
|
||||
|
||||
assertTrue(east is DiffStatBadge)
|
||||
assertNotNull(center)
|
||||
} finally {
|
||||
Disposer.dispose(parent)
|
||||
}
|
||||
}
|
||||
|
||||
fun `test row badge hidden when node has no changes`() {
|
||||
val parent = Disposer.newDisposable()
|
||||
try {
|
||||
val view = view(listOf(file("src/Empty.kt", 0, 0)), parent)
|
||||
val tree = components(view).filterIsInstance<Tree>().single()
|
||||
val badge = rowBadge(renderer(tree, leaf(tree)))
|
||||
|
||||
assertFalse(badge.isVisible)
|
||||
} finally {
|
||||
Disposer.dispose(parent)
|
||||
}
|
||||
}
|
||||
|
||||
fun `test tree expands all rows on show`() {
|
||||
val parent = Disposer.newDisposable()
|
||||
try {
|
||||
val view = view(files(), parent)
|
||||
val tree = components(view).filterIsInstance<Tree>().single()
|
||||
|
||||
assertEquals(4, tree.rowCount)
|
||||
} finally {
|
||||
Disposer.dispose(parent)
|
||||
}
|
||||
}
|
||||
|
||||
fun `test tree paints tool window background`() {
|
||||
val parent = Disposer.newDisposable()
|
||||
try {
|
||||
val view = view(files(), parent)
|
||||
val tree = components(view).filterIsInstance<Tree>().single()
|
||||
val scroll = SwingUtilities.getAncestorOfClass(JBScrollPane::class.java, tree) as JBScrollPane
|
||||
val row = (scroll.parent.layout as BorderLayout).getLayoutComponent(BorderLayout.NORTH) as Container
|
||||
val toolbar = (row.layout as BorderLayout).getLayoutComponent(BorderLayout.WEST)
|
||||
|
||||
assertTrue(tree.isOpaque)
|
||||
assertEquals(JBUI.CurrentTheme.ToolWindow.background(), tree.background)
|
||||
assertEquals(JBUI.CurrentTheme.ToolWindow.background(), row.background)
|
||||
assertEquals(JBUI.CurrentTheme.ToolWindow.background(), toolbar.background)
|
||||
assertEquals(0, scroll.border.getBorderInsets(scroll).top)
|
||||
assertEquals(0, scroll.border.getBorderInsets(scroll).left)
|
||||
assertEquals(0, scroll.border.getBorderInsets(scroll).bottom)
|
||||
assertEquals(0, scroll.border.getBorderInsets(scroll).right)
|
||||
assertEquals(0, scroll.viewportBorder.getBorderInsets(scroll).top)
|
||||
assertEquals(0, scroll.viewportBorder.getBorderInsets(scroll).left)
|
||||
assertEquals(0, scroll.viewportBorder.getBorderInsets(scroll).bottom)
|
||||
assertEquals(0, scroll.viewportBorder.getBorderInsets(scroll).right)
|
||||
} finally {
|
||||
Disposer.dispose(parent)
|
||||
}
|
||||
}
|
||||
|
||||
fun `test tree toolbar installs actions in order`() {
|
||||
val parent = Disposer.newDisposable()
|
||||
try {
|
||||
// Assert against the toolbar the view actually installs (not a freshly built group), so
|
||||
// this guards the real regression: the tree toolbar losing or rewiring its actions.
|
||||
val view = view(files(), parent)
|
||||
val tree = components(view).filterIsInstance<Tree>().single()
|
||||
val scroll = SwingUtilities.getAncestorOfClass(JBScrollPane::class.java, tree) as JBScrollPane
|
||||
val row = (scroll.parent.layout as BorderLayout).getLayoutComponent(BorderLayout.NORTH) as Container
|
||||
val toolbar = (row.layout as BorderLayout).getLayoutComponent(BorderLayout.WEST) as ActionToolbar
|
||||
val actions = toolbar.actionGroup.getChildren(null).toList()
|
||||
assertEquals(KiloBundle.message("diff.editor.refresh"), actions[0].templatePresentation.text)
|
||||
assertTrue(actions[1] is Separator)
|
||||
assertEquals(KiloBundle.message("diff.editor.tree.expandAll"), actions[2].templatePresentation.text)
|
||||
assertEquals(KiloBundle.message("diff.editor.tree.collapseAll"), actions[3].templatePresentation.text)
|
||||
assertEquals(4, actions.size)
|
||||
} finally {
|
||||
Disposer.dispose(parent)
|
||||
}
|
||||
}
|
||||
|
||||
fun `test row renderer reuses badge instance`() {
|
||||
val parent = Disposer.newDisposable()
|
||||
try {
|
||||
val view = view(files(), parent)
|
||||
val tree = components(view).filterIsInstance<Tree>().single()
|
||||
val leaf = leaf(tree)
|
||||
val first = renderer(tree, leaf)
|
||||
val second = renderer(tree, leaf)
|
||||
|
||||
assertSame(first, second)
|
||||
assertSame(rowBadge(first), rowBadge(second))
|
||||
} finally {
|
||||
Disposer.dispose(parent)
|
||||
}
|
||||
}
|
||||
|
||||
fun `test branch is included in diff title`() {
|
||||
val request = diffRequest(project, file("src/App.kt", 1, 1), "feature/test")
|
||||
|
||||
assertEquals("src/App.kt (feature/test)", request.title)
|
||||
}
|
||||
|
||||
fun `test diff params includes inline token`() {
|
||||
val params = diffParams("inline", "/repo", "ses_1", "Session Changes", token = "tool:ses_1:p1")
|
||||
|
||||
assertEquals("inline", params["source"])
|
||||
assertEquals("/repo", params["directory"])
|
||||
assertEquals("ses_1", params["sessionId"])
|
||||
assertEquals("Session Changes", params["title"])
|
||||
assertEquals("tool:ses_1:p1", params["token"])
|
||||
}
|
||||
|
||||
fun `test inline params require directory and token`() {
|
||||
assertTrue(KiloDiffEditorKind.isValid(diffParams("inline", "/repo", null, "Session Changes", token = "turn:ses_1:u1")))
|
||||
assertFalse(KiloDiffEditorKind.isValid(mapOf("source" to "inline", "directory" to "/repo", "title" to "Session Changes")))
|
||||
assertFalse(KiloDiffEditorKind.isValid(mapOf("source" to "inline", "token" to "turn:ses_1:u1", "title" to "Session Changes")))
|
||||
}
|
||||
|
||||
fun `test inline editor title uses params title`() {
|
||||
assertEquals("Session Changes", KiloDiffEditorKind.title(diffParams("inline", "/repo", null, "Session Changes", token = "token")))
|
||||
}
|
||||
|
||||
fun `test reload updates aggregate badge`() {
|
||||
val parent = Disposer.newDisposable()
|
||||
try {
|
||||
val editor = editor(files(), parent)
|
||||
|
||||
editor.applyFiles(listOf(file("src/App.kt", 7, 6)), "feature/test")
|
||||
val badges = components(editor.component).filterIsInstance<DiffStatBadge>()
|
||||
|
||||
assertTrue(badges.any { it.addedLabelForTest().text == "+7" && it.removedLabelForTest().text == "-6" })
|
||||
} finally {
|
||||
Disposer.dispose(parent)
|
||||
}
|
||||
}
|
||||
|
||||
fun `test reload preserves selected file`() {
|
||||
val parent = Disposer.newDisposable()
|
||||
try {
|
||||
val editor = editor(files(), parent)
|
||||
val tree = components(editor.component).filterIsInstance<Tree>().single()
|
||||
tree.selectionPath = TreePath(leaf(tree).path)
|
||||
|
||||
editor.applyFiles(
|
||||
listOf(file("src/App.kt", 4, 2), file("test/AppTest.kt", 1, 1)),
|
||||
"feature/test",
|
||||
)
|
||||
|
||||
assertSame(leaf(tree), tree.lastSelectedPathComponent)
|
||||
} finally {
|
||||
Disposer.dispose(parent)
|
||||
}
|
||||
}
|
||||
|
||||
fun `test outdated banner is hidden initially`() {
|
||||
val parent = Disposer.newDisposable()
|
||||
try {
|
||||
val editor = editor(files(), parent)
|
||||
|
||||
assertFalse(banner(editor).isVisible)
|
||||
} finally {
|
||||
Disposer.dispose(parent)
|
||||
}
|
||||
}
|
||||
|
||||
fun `test outdated banner appears when files change`() {
|
||||
val parent = Disposer.newDisposable()
|
||||
try {
|
||||
val editor = editor(files(), parent)
|
||||
|
||||
editor.markOutdated()
|
||||
UIUtil.dispatchAllInvocationEvents()
|
||||
|
||||
assertTrue(banner(editor).isVisible)
|
||||
} finally {
|
||||
Disposer.dispose(parent)
|
||||
}
|
||||
}
|
||||
|
||||
fun `test outdated banner appears for unsaved ide document changes`() {
|
||||
val parent = Disposer.newDisposable()
|
||||
try {
|
||||
val psi = myFixture.addFileToProject("src/App.kt", "old")
|
||||
val doc = FileDocumentManager.getInstance().getDocument(psi.virtualFile)!!
|
||||
val dir = psi.virtualFile.parent.parent.path
|
||||
val editor = editor(files(), parent, dir = dir)
|
||||
|
||||
ApplicationManager.getApplication().runWriteAction { doc.setText("new") }
|
||||
UIUtil.dispatchAllInvocationEvents()
|
||||
|
||||
assertTrue(banner(editor).isVisible)
|
||||
} finally {
|
||||
Disposer.dispose(parent)
|
||||
}
|
||||
}
|
||||
|
||||
fun `test manual refresh clears banner and updates files`() {
|
||||
val parent = Disposer.newDisposable()
|
||||
try {
|
||||
val next = listOf(file("src/App.kt", 9, 8))
|
||||
val editor = editor(files(), parent) { done ->
|
||||
done(DiffEditorData.Files(next, "feature/test"))
|
||||
Job().also { it.complete() }
|
||||
}
|
||||
editor.markOutdated()
|
||||
UIUtil.dispatchAllInvocationEvents()
|
||||
|
||||
editor.refresh()
|
||||
val badges = components(editor.component).filterIsInstance<DiffStatBadge>()
|
||||
|
||||
assertFalse(banner(editor).isVisible)
|
||||
assertTrue(badges.any { it.addedLabelForTest().text == "+9" && it.removedLabelForTest().text == "-8" })
|
||||
} finally {
|
||||
Disposer.dispose(parent)
|
||||
}
|
||||
}
|
||||
|
||||
fun `test editor construction does not refresh`() {
|
||||
val parent = Disposer.newDisposable()
|
||||
var calls = 0
|
||||
try {
|
||||
editor(files(), parent) {
|
||||
calls += 1
|
||||
Job().also { it.complete() }
|
||||
}
|
||||
|
||||
assertEquals(0, calls)
|
||||
} finally {
|
||||
Disposer.dispose(parent)
|
||||
}
|
||||
}
|
||||
|
||||
fun `test reverse sync skips active requested path`() {
|
||||
assertNull(reverseSyncTarget("src/App.kt", "src/App.kt", "test/AppTest.kt"))
|
||||
}
|
||||
|
||||
fun `test reverse sync waits while requested path is pending`() {
|
||||
assertNull(reverseSyncTarget("src/App.kt", "test/AppTest.kt", "src/App.kt"))
|
||||
}
|
||||
|
||||
fun `test reverse sync returns active path for diff-driven navigation`() {
|
||||
assertEquals("test/AppTest.kt", reverseSyncTarget("test/AppTest.kt", null, "src/App.kt"))
|
||||
}
|
||||
|
||||
private fun renderer(tree: Tree, node: DefaultMutableTreeNode): Component =
|
||||
tree.cellRenderer.getTreeCellRendererComponent(
|
||||
tree,
|
||||
node,
|
||||
false,
|
||||
tree.isExpanded(TreePath(node.path)),
|
||||
node.isLeaf,
|
||||
0,
|
||||
false,
|
||||
)
|
||||
|
||||
private fun leaf(tree: Tree): DefaultMutableTreeNode {
|
||||
val root = tree.model.root as DefaultMutableTreeNode
|
||||
val src = root.getChildAt(0) as DefaultMutableTreeNode
|
||||
return src.getChildAt(0) as DefaultMutableTreeNode
|
||||
}
|
||||
|
||||
private fun rowBadge(row: Component): DiffStatBadge = components(row).filterIsInstance<DiffStatBadge>().single()
|
||||
|
||||
private fun banner(editor: DiffEditorView): EditorNotificationPanel = components(editor.component)
|
||||
.filterIsInstance<EditorNotificationPanel>()
|
||||
.single()
|
||||
|
||||
private fun view(files: List<DiffFileDto>, parent: Disposable): Component = editor(files, parent).component
|
||||
|
||||
private fun editor(
|
||||
files: List<DiffFileDto>,
|
||||
parent: Disposable,
|
||||
dir: String = project.basePath.orEmpty(),
|
||||
load: ((DiffEditorData) -> Unit) -> Job = { Job().also { it.complete() } },
|
||||
): DiffEditorView {
|
||||
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
Disposer.register(parent) { scope.cancel() }
|
||||
return DiffEditorView(
|
||||
project,
|
||||
mapOf("directory" to dir, "source" to "branch"),
|
||||
files,
|
||||
parent,
|
||||
"feature/test",
|
||||
scope,
|
||||
load,
|
||||
) {}
|
||||
}
|
||||
|
||||
private fun components(root: Component): List<Component> {
|
||||
val out = mutableListOf<Component>()
|
||||
fun visit(node: Component) {
|
||||
out.add(node)
|
||||
if (node is Container) node.components.forEach(::visit)
|
||||
}
|
||||
visit(root)
|
||||
return out
|
||||
}
|
||||
|
||||
private fun files() = listOf(
|
||||
file("src/App.kt", 2, 1),
|
||||
file("test/AppTest.kt", 3, 3),
|
||||
)
|
||||
|
||||
private fun file(
|
||||
path: String,
|
||||
additions: Int,
|
||||
deletions: Int,
|
||||
patch: String? = "@@ -1 +1 @@\n-old\n+new",
|
||||
status: String? = null,
|
||||
) = DiffFileDto(
|
||||
file = path,
|
||||
additions = additions,
|
||||
deletions = deletions,
|
||||
patch = patch,
|
||||
status = status,
|
||||
)
|
||||
}
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
package ai.kilocode.client.diff
|
||||
|
||||
import ai.kilocode.client.app.KiloWorkspaceService
|
||||
import ai.kilocode.client.testing.FakeWorkspaceRpcApi
|
||||
import ai.kilocode.client.testing.TestCoroutines
|
||||
import ai.kilocode.rpc.dto.DiffFileDto
|
||||
import com.intellij.openapi.components.service
|
||||
import com.intellij.openapi.application.ApplicationManager
|
||||
import com.intellij.testFramework.fixtures.BasePlatformTestCase
|
||||
import com.intellij.testFramework.replaceService
|
||||
import com.intellij.util.ui.UIUtil
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class KiloInlineDiffStoreTest : BasePlatformTestCase() {
|
||||
private lateinit var coroutines: TestCoroutines
|
||||
private lateinit var workspace: FakeWorkspaceRpcApi
|
||||
private lateinit var service: KiloDiffEditorService
|
||||
|
||||
override fun setUp() {
|
||||
super.setUp()
|
||||
coroutines = TestCoroutines()
|
||||
workspace = FakeWorkspaceRpcApi()
|
||||
service = KiloDiffEditorService(project, coroutines.scope)
|
||||
project.replaceService(KiloInlineDiffStore::class.java, KiloInlineDiffStore(), testRootDisposable)
|
||||
ApplicationManager.getApplication()
|
||||
.replaceService(KiloWorkspaceService::class.java, KiloWorkspaceService(coroutines.scope, workspace), testRootDisposable)
|
||||
}
|
||||
|
||||
override fun tearDown() {
|
||||
try {
|
||||
coroutines.close { UIUtil.dispatchAllInvocationEvents() }
|
||||
} finally {
|
||||
super.tearDown()
|
||||
}
|
||||
}
|
||||
|
||||
fun `test pop returns then clears while get remains persistent`() {
|
||||
val store = project.service<KiloInlineDiffStore>()
|
||||
val files = listOf(file("src/A.kt", 2, 1))
|
||||
|
||||
store.put("inline", files)
|
||||
assertEquals(files, store.get("inline"))
|
||||
assertEquals(files, store.get("inline"))
|
||||
|
||||
store.put("branch:/test", files)
|
||||
assertEquals(files, store.pop("branch:/test"))
|
||||
assertNull(store.pop("branch:/test"))
|
||||
}
|
||||
|
||||
fun `test branch fetch recomputes authoritatively and ignores any store seed`() = runBlocking {
|
||||
val store = project.service<KiloInlineDiffStore>()
|
||||
val stale = listOf(file("src/Stale.kt", 3, 1))
|
||||
val fresh = file("src/Fresh.kt", 1, 0)
|
||||
workspace.branchDiffs.add(fresh)
|
||||
workspace.branchName = "main"
|
||||
// A leftover seed under the branch token must never be consumed as a side channel: it would
|
||||
// otherwise poison a re-open or Refresh with content from an earlier click.
|
||||
store.put("branch:/test", stale)
|
||||
val params = diffParams("branch", "/test", null, "Branch", "main")
|
||||
|
||||
val first = withContext(coroutines.dispatcher) { service.fetch(params) } as DiffEditorData.Files
|
||||
val second = withContext(coroutines.dispatcher) { service.fetch(params) } as DiffEditorData.Files
|
||||
|
||||
assertEquals(listOf(fresh), first.files)
|
||||
assertEquals(listOf(fresh), second.files)
|
||||
assertEquals(listOf("/test", "/test"), workspace.branchDiffCalls)
|
||||
assertEquals(stale, store.get("branch:/test"))
|
||||
}
|
||||
|
||||
private fun file(path: String, additions: Int, deletions: Int) = DiffFileDto(path, additions, deletions)
|
||||
}
|
||||
+83
-4
@@ -1,10 +1,17 @@
|
||||
package ai.kilocode.client.session
|
||||
|
||||
import ai.kilocode.client.plugin.KiloBundle
|
||||
import ai.kilocode.client.session.ui.ModifiedFilesView
|
||||
import ai.kilocode.client.session.ui.SessionMessageListPanel
|
||||
import ai.kilocode.client.session.ui.prompt.PromptPanel
|
||||
import ai.kilocode.client.session.ui.selection.SessionCopyTarget
|
||||
import ai.kilocode.client.session.ui.style.SessionUiStyle
|
||||
import ai.kilocode.client.session.views.tool.ShellToolView
|
||||
import ai.kilocode.client.session.views.tool.ToolView
|
||||
import ai.kilocode.rpc.dto.ChatEventDto
|
||||
import ai.kilocode.rpc.dto.DiffFileDto
|
||||
import ai.kilocode.rpc.dto.MessageErrorDto
|
||||
import ai.kilocode.rpc.dto.MessageSummaryDto
|
||||
import ai.kilocode.rpc.dto.MessageWithPartsDto
|
||||
import ai.kilocode.rpc.dto.PermissionRequestDto
|
||||
import ai.kilocode.rpc.dto.PartDto
|
||||
@@ -14,10 +21,6 @@ import ai.kilocode.rpc.dto.QuestionRequestDto
|
||||
import ai.kilocode.rpc.dto.SessionRevertDto
|
||||
import ai.kilocode.rpc.dto.SessionStatusDto
|
||||
import ai.kilocode.rpc.dto.ToolRefDto
|
||||
import ai.kilocode.client.session.ui.prompt.PromptPanel
|
||||
import ai.kilocode.client.session.views.tool.ShellToolView
|
||||
import ai.kilocode.client.session.views.tool.ToolView
|
||||
import ai.kilocode.client.plugin.KiloBundle
|
||||
import com.intellij.ui.EditorTextField
|
||||
import com.intellij.ui.components.JBScrollPane
|
||||
import com.intellij.ui.components.JBRadioButton
|
||||
@@ -345,6 +348,57 @@ class SessionScrollTest : SessionUiTestBase() {
|
||||
assertEquals(value, bar.value)
|
||||
}
|
||||
|
||||
fun `test expanding modified files at bottom preserves clicked header position`() {
|
||||
val mid = "modified_expand_bottom"
|
||||
val pid = "modified_expand_bottom_part"
|
||||
rpc.history.addAll(history(23) + modifiedHistory(mid, pid) + historyRange(1, start = 23))
|
||||
ui = newUi(id = "ses_test")
|
||||
settle()
|
||||
drainScroll()
|
||||
val bar = scrollBar()
|
||||
setBottom(bar)
|
||||
drainScroll()
|
||||
val view = modifiedView()
|
||||
assertFalse(view.bodyVisible())
|
||||
val y = visibleY(view)
|
||||
val value = bar.value
|
||||
|
||||
view.toggle()
|
||||
drainScroll()
|
||||
|
||||
assertTrue(view.bodyVisible())
|
||||
assertEquals(y, visibleY(view))
|
||||
assertEquals(value, bar.value)
|
||||
}
|
||||
|
||||
fun `test preserve re-enables tail when viewport is near bottom`() {
|
||||
showMessages()
|
||||
fillTranscript(24)
|
||||
val bar = scrollBar()
|
||||
val messages = find<SessionMessageListPanel>(ui)
|
||||
setBottom(bar)
|
||||
drainScroll()
|
||||
val anchor = messages.components.filterIsInstance<JComponent>().first()
|
||||
|
||||
val value = bottom(bar) - JBUI.scale(16)
|
||||
setValue(bar, value)
|
||||
drainScroll()
|
||||
assertEquals(value, bar.value)
|
||||
assertFalse(ui.scroll.following())
|
||||
assertTrue(jumpButton().isVisible)
|
||||
|
||||
ui.scroll.preserve(anchor) {}
|
||||
drainScroll()
|
||||
|
||||
assertFalse(jumpButton().isVisible)
|
||||
assertTrue(ui.scroll.following())
|
||||
|
||||
emit(ChatEventDto.MessageUpdated("ses_test", message("preserve_shrink_tail")))
|
||||
drainScroll()
|
||||
|
||||
assertBottom(bar)
|
||||
}
|
||||
|
||||
fun `test expanding tool in middle preserves clicked header position`() {
|
||||
val mid = "tool_expand_middle"
|
||||
val pid = "tool_expand_middle_part"
|
||||
@@ -1149,6 +1203,12 @@ class SessionScrollTest : SessionUiTestBase() {
|
||||
?: error("missing tool $mid/$pid\n${messages.dumpDetailed()}")
|
||||
}
|
||||
|
||||
private fun modifiedView(): ModifiedFilesView {
|
||||
val messages = find<SessionMessageListPanel>(ui)
|
||||
return findAll<ModifiedFilesView>(messages).singleOrNull()
|
||||
?: error("missing modified files card\n${messages.dumpDetailed()}")
|
||||
}
|
||||
|
||||
private fun bodyVisible(view: JComponent): Boolean = when (view) {
|
||||
is ShellToolView -> view.bodyVisible()
|
||||
is ToolView -> view.bodyVisible()
|
||||
@@ -1278,6 +1338,25 @@ class SessionScrollTest : SessionUiTestBase() {
|
||||
listOf(toolPart(pid, mid)),
|
||||
)
|
||||
|
||||
private fun modifiedHistory(mid: String, pid: String) = MessageWithPartsDto(
|
||||
message(mid).copy(summary = MessageSummaryDto(listOf(modifiedFile()))),
|
||||
listOf(part(pid, mid, "text", text(0))),
|
||||
)
|
||||
|
||||
private fun modifiedFile() = DiffFileDto(
|
||||
file = "src/Changed.kt",
|
||||
additions = 80,
|
||||
deletions = 80,
|
||||
patch = buildString {
|
||||
appendLine("diff --git a/src/Changed.kt b/src/Changed.kt")
|
||||
appendLine("--- a/src/Changed.kt")
|
||||
appendLine("+++ b/src/Changed.kt")
|
||||
appendLine("@@ -1,80 +1,80 @@")
|
||||
repeat(80) { i -> appendLine("-old line $i") }
|
||||
repeat(80) { i -> appendLine("+new line $i") }
|
||||
},
|
||||
)
|
||||
|
||||
private fun historyRange(count: Int, start: Int) = List(count) { offset ->
|
||||
val i = start + offset
|
||||
val id = "hist_range_$i"
|
||||
|
||||
+28
@@ -28,6 +28,7 @@ import ai.kilocode.rpc.dto.KiloAppStateDto
|
||||
import ai.kilocode.rpc.dto.KiloAppStatusDto
|
||||
import ai.kilocode.rpc.dto.ProfileDto
|
||||
import ai.kilocode.rpc.dto.SessionRevertDto
|
||||
import ai.kilocode.rpc.dto.DiffFileDto
|
||||
import com.intellij.util.ui.JBUI
|
||||
import ai.kilocode.client.session.views.permission.PermissionView
|
||||
import ai.kilocode.client.session.views.question.QuestionView
|
||||
@@ -114,6 +115,33 @@ class SessionUiLayoutTest : SessionUiTestBase() {
|
||||
assertNull(drop.dropTarget)
|
||||
}
|
||||
|
||||
fun `test branch changes badge refreshes on finish and revert`() {
|
||||
workspaceRpc.branchDiffs.clear()
|
||||
workspaceRpc.branchDiffs.add(DiffFileDto("src/A.kt", 2, 1))
|
||||
val header = find<SessionHeaderPanel>(ui)
|
||||
|
||||
controller().model.setState(SessionState.Busy("running"))
|
||||
controller().model.setState(SessionState.Idle)
|
||||
settle()
|
||||
|
||||
assertEquals(2 to 1, header.changesStat())
|
||||
|
||||
workspaceRpc.branchDiffs.clear()
|
||||
workspaceRpc.branchDiffs.add(DiffFileDto("src/B.kt", 4, 3))
|
||||
controller().model.setState(SessionState.Busy("running"))
|
||||
controller().model.setState(SessionState.Idle)
|
||||
settle()
|
||||
|
||||
assertEquals(4 to 3, header.changesStat())
|
||||
|
||||
workspaceRpc.branchDiffs.clear()
|
||||
workspaceRpc.branchDiffs.add(DiffFileDto("src/C.kt", 1, 0))
|
||||
controller().model.setRevert(SessionRevertDto("msg1", "part1", diff = "patch"))
|
||||
settle()
|
||||
|
||||
assertEquals(1 to 0, header.changesStat())
|
||||
}
|
||||
|
||||
fun `test prompt file drag leave does not immediately hide drop overlay`() {
|
||||
val prompt = find<PromptPanel>(ui)
|
||||
val drop = find<SessionDropOverlay>(ui)
|
||||
|
||||
+2
-1
@@ -46,6 +46,7 @@ abstract class SessionUiTestBase : BasePlatformTestCase() {
|
||||
protected lateinit var sessions: KiloSessionService
|
||||
protected lateinit var app: KiloAppService
|
||||
protected lateinit var workspaces: KiloWorkspaceService
|
||||
protected lateinit var workspaceRpc: FakeWorkspaceRpcApi
|
||||
protected lateinit var rpc: FakeSessionRpcApi
|
||||
protected lateinit var appRpc: FakeAppRpcApi
|
||||
protected lateinit var workspace: Workspace
|
||||
@@ -60,7 +61,7 @@ abstract class SessionUiTestBase : BasePlatformTestCase() {
|
||||
appRpc = FakeAppRpcApi().also {
|
||||
it.state.value = KiloAppStateDto(KiloAppStatusDto.READY)
|
||||
}
|
||||
val workspaceRpc = FakeWorkspaceRpcApi().also {
|
||||
workspaceRpc = FakeWorkspaceRpcApi().also {
|
||||
it.state.value = KiloWorkspaceStateDto(status = KiloWorkspaceStatusDto.READY)
|
||||
}
|
||||
|
||||
|
||||
+6
-1
@@ -3,8 +3,10 @@ package ai.kilocode.client.session.controller
|
||||
import ai.kilocode.client.session.model.SessionModelEvent
|
||||
import ai.kilocode.rpc.dto.AgentDto
|
||||
import ai.kilocode.rpc.dto.ConfigDto
|
||||
import ai.kilocode.rpc.dto.DiffFileDto
|
||||
import ai.kilocode.rpc.dto.KiloAppStateDto
|
||||
import ai.kilocode.rpc.dto.KiloAppStatusDto
|
||||
import ai.kilocode.rpc.dto.MessageSummaryDto
|
||||
import ai.kilocode.rpc.dto.MessageTimeDto
|
||||
import ai.kilocode.rpc.dto.MessageWithPartsDto
|
||||
import ai.kilocode.rpc.dto.ModelDto
|
||||
@@ -13,7 +15,9 @@ import ai.kilocode.rpc.dto.ProviderDto
|
||||
class HistoryLoadingTest : SessionControllerTestBase() {
|
||||
|
||||
fun `test existing session loads history on init`() {
|
||||
val m = msg("msg1", "ses_test", "user")
|
||||
val m = msg("msg1", "ses_test", "user").copy(
|
||||
summary = MessageSummaryDto(listOf(DiffFileDto("src/A.kt", 2, 1, "@@ patch"))),
|
||||
)
|
||||
val part = part("prt1", "ses_test", "msg1", "text", text = "hello")
|
||||
rpc.history.add(MessageWithPartsDto(m, listOf(part)))
|
||||
|
||||
@@ -30,6 +34,7 @@ class HistoryLoadingTest : SessionControllerTestBase() {
|
||||
""",
|
||||
c,
|
||||
)
|
||||
assertEquals("src/A.kt", c.model.message("msg1")?.info?.summary?.diffs?.single()?.file)
|
||||
}
|
||||
|
||||
fun `test non-empty history shows messages view`() {
|
||||
|
||||
+291
@@ -0,0 +1,291 @@
|
||||
package ai.kilocode.client.session.controller
|
||||
|
||||
import ai.kilocode.client.plugin.KiloPluginSettings
|
||||
import ai.kilocode.client.session.model.PermissionRequestState
|
||||
import ai.kilocode.client.session.model.SessionState
|
||||
import ai.kilocode.rpc.dto.ChatEventDto
|
||||
import ai.kilocode.rpc.dto.ConfigDto
|
||||
import ai.kilocode.rpc.dto.KiloAppStateDto
|
||||
import ai.kilocode.rpc.dto.KiloAppStatusDto
|
||||
import ai.kilocode.rpc.dto.PartDto
|
||||
import ai.kilocode.rpc.dto.PermissionRequestDto
|
||||
import ai.kilocode.rpc.dto.QuestionInfoDto
|
||||
import ai.kilocode.rpc.dto.QuestionReplyDto
|
||||
import ai.kilocode.rpc.dto.QuestionRequestDto
|
||||
import ai.kilocode.rpc.dto.SessionStatusDto
|
||||
|
||||
class PermissionQueueTest : SessionControllerTestBase() {
|
||||
|
||||
override fun setUp() {
|
||||
super.setUp()
|
||||
edt { KiloPluginSettings.unsetAutoApprove() }
|
||||
}
|
||||
|
||||
fun `test two permissions advance in FIFO order`() {
|
||||
val (m, _, _) = prompted()
|
||||
|
||||
emit(ChatEventDto.PermissionAsked("ses_test", permission("perm1")))
|
||||
emit(ChatEventDto.PermissionAsked("ses_test", permission("perm2")))
|
||||
|
||||
assertPermission(m, "perm1")
|
||||
|
||||
emit(ChatEventDto.PermissionReplied("ses_test", "perm1"))
|
||||
assertPermission(m, "perm2")
|
||||
|
||||
emit(ChatEventDto.PermissionReplied("ses_test", "perm2"))
|
||||
assertTrue(m.model.state is SessionState.Busy)
|
||||
}
|
||||
|
||||
fun `test duplicate permission ask does not reset active card`() {
|
||||
val (m, _, events) = prompted()
|
||||
|
||||
emit(ChatEventDto.PermissionAsked("ses_test", permission("perm1", "edit")))
|
||||
events.clear()
|
||||
emit(ChatEventDto.PermissionAsked("ses_test", permission("perm1", "read")))
|
||||
|
||||
assertPermission(m, "perm1", "edit")
|
||||
assertModelEvents("", events)
|
||||
}
|
||||
|
||||
fun `test non-front resolution leaves active permission shown`() {
|
||||
val (m, _, _) = prompted()
|
||||
|
||||
emit(ChatEventDto.PermissionAsked("ses_test", permission("perm1")))
|
||||
emit(ChatEventDto.PermissionAsked("ses_test", permission("perm2")))
|
||||
emit(ChatEventDto.PermissionReplied("ses_test", "perm2"))
|
||||
|
||||
assertPermission(m, "perm1")
|
||||
|
||||
emit(ChatEventDto.PermissionReplied("ses_test", "perm1"))
|
||||
assertTrue(m.model.state is SessionState.Busy)
|
||||
}
|
||||
|
||||
fun `test recovered permissions advance in FIFO order`() {
|
||||
rpc.pendingPermissionList.add(permission("perm1"))
|
||||
rpc.pendingPermissionList.add(permission("perm2"))
|
||||
appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY, config = ConfigDto(model = "kilo/gpt-5"))
|
||||
projectRpc.state.value = workspaceReady()
|
||||
|
||||
val m = controller("ses_test")
|
||||
flush()
|
||||
|
||||
assertPermission(m, "perm1")
|
||||
|
||||
emit(ChatEventDto.PermissionReplied("ses_test", "perm1"))
|
||||
assertPermission(m, "perm2")
|
||||
|
||||
emit(ChatEventDto.PermissionReplied("ses_test", "perm2"))
|
||||
assertTrue(m.model.state is SessionState.Busy)
|
||||
}
|
||||
|
||||
fun `test late permission reply while idle does not force busy`() {
|
||||
val (m, _, _) = prompted()
|
||||
|
||||
emit(ChatEventDto.PermissionReplied("ses_test", "perm_gone"))
|
||||
|
||||
assertTrue(m.model.state is SessionState.Idle)
|
||||
}
|
||||
|
||||
fun `test turn close purges outstanding permission ghost`() {
|
||||
val (m, _, _) = prompted()
|
||||
|
||||
emit(ChatEventDto.PermissionAsked("ses_test", permission("perm1")))
|
||||
assertPermission(m, "perm1")
|
||||
|
||||
// The CLI abandons an outstanding permission server-side when a turn is interrupted, without
|
||||
// emitting permission.replied, so TurnClose must drop the ghost instead of leaving it shown.
|
||||
emit(ChatEventDto.TurnClose("ses_test", "aborted"))
|
||||
assertTrue(m.model.state is SessionState.Idle)
|
||||
|
||||
// The next request surfaces itself rather than the purged ghost (which would fail to reply).
|
||||
emit(ChatEventDto.PermissionAsked("ses_test", permission("perm2")))
|
||||
assertPermission(m, "perm2")
|
||||
}
|
||||
|
||||
fun `test session idle purges outstanding permission ghost`() {
|
||||
val (m, _, _) = prompted()
|
||||
|
||||
emit(ChatEventDto.PermissionAsked("ses_test", permission("perm1")))
|
||||
assertPermission(m, "perm1")
|
||||
|
||||
emit(ChatEventDto.SessionIdle("ses_test"))
|
||||
assertTrue(m.model.state is SessionState.Idle)
|
||||
|
||||
emit(ChatEventDto.PermissionAsked("ses_test", permission("perm2")))
|
||||
assertPermission(m, "perm2")
|
||||
}
|
||||
|
||||
fun `test stop purges outstanding permission ghost`() {
|
||||
val (m, _, _) = prompted()
|
||||
|
||||
emit(ChatEventDto.PermissionAsked("ses_test", permission("perm1")))
|
||||
assertPermission(m, "perm1")
|
||||
|
||||
edt { m.abort() }
|
||||
flush()
|
||||
assertTrue(m.model.state is SessionState.Idle)
|
||||
|
||||
emit(ChatEventDto.PermissionAsked("ses_test", permission("perm2")))
|
||||
assertPermission(m, "perm2")
|
||||
}
|
||||
|
||||
fun `test auto approve skill shell permissions stay queued in FIFO order`() {
|
||||
edt { KiloPluginSettings.setAutoApprove(true) }
|
||||
val (m, _, _) = prompted()
|
||||
|
||||
emit(ChatEventDto.PermissionAsked("ses_test", skillPermission("perm1")))
|
||||
emit(ChatEventDto.PermissionAsked("ses_test", skillPermission("perm2")))
|
||||
|
||||
assertPermission(m, "perm1")
|
||||
|
||||
emit(ChatEventDto.PermissionReplied("ses_test", "perm1"))
|
||||
assertPermission(m, "perm2")
|
||||
}
|
||||
|
||||
fun `test auto approve failure card is queued and purged by stop`() {
|
||||
edt { KiloPluginSettings.setAutoApprove(true) }
|
||||
rpc.replyPermissionThrows = RuntimeException("boom")
|
||||
val (m, _, _) = prompted()
|
||||
|
||||
emit(ChatEventDto.PermissionAsked("ses_test", permission("perm1")))
|
||||
flush()
|
||||
|
||||
// The failed auto-approval surfaces as an error card; it must be in the queue so purge sees it.
|
||||
val state = m.model.state as? SessionState.AwaitingPermission ?: error("Expected error card")
|
||||
assertEquals("perm1", state.permission.id)
|
||||
assertEquals(PermissionRequestState.ERROR, state.permission.state)
|
||||
|
||||
edt { m.abort() }
|
||||
flush()
|
||||
assertTrue(m.model.state is SessionState.Idle)
|
||||
}
|
||||
|
||||
fun `test auto approve drain queues multiple skill shell permissions`() {
|
||||
rpc.pendingPermissionList.add(skillPermission("perm1"))
|
||||
rpc.pendingPermissionList.add(skillPermission("perm2"))
|
||||
val (m, _, _) = prompted()
|
||||
|
||||
edt { m.setAutoApprove(true) }
|
||||
flush()
|
||||
|
||||
assertPermission(m, "perm1")
|
||||
|
||||
emit(ChatEventDto.PermissionReplied("ses_test", "perm1"))
|
||||
assertPermission(m, "perm2")
|
||||
}
|
||||
|
||||
fun `test toggling auto approve on keeps a visible skill shell card queued for purge`() {
|
||||
// A skill-shell card is up while auto-approve is off; enabling auto-approve must not strand it.
|
||||
// Skill-shell asks always need a human, so setAutoApprove re-shows the card via show(); that
|
||||
// enqueue has to survive pending.clear() (be the last writer) or the card becomes a ghost that
|
||||
// is no longer in pending, which a later Stop could not purge and answering would NotFoundError.
|
||||
val (m, _, _) = prompted()
|
||||
|
||||
emit(ChatEventDto.PermissionAsked("ses_test", skillPermission("perm1")))
|
||||
assertPermission(m, "perm1")
|
||||
|
||||
edt { m.setAutoApprove(true) }
|
||||
flush()
|
||||
|
||||
// Still shown, and still tracked in pending — so Stop can clear it.
|
||||
assertPermission(m, "perm1")
|
||||
|
||||
edt { m.abort() }
|
||||
flush()
|
||||
assertTrue(m.model.state is SessionState.Idle)
|
||||
}
|
||||
|
||||
fun `test toggling auto approve on keeps skill shell card while draining other permissions`() {
|
||||
// Visible skill-shell card plus another auto-approvable permission on the server. Enabling
|
||||
// auto-approve drains/replies the other one, but the drain must not flip the preserved
|
||||
// skill-shell card to Busy — that would hide it with no reply path left.
|
||||
rpc.pendingPermissionList.add(permission("perm2"))
|
||||
val (m, _, _) = prompted()
|
||||
|
||||
emit(ChatEventDto.PermissionAsked("ses_test", skillPermission("perm1")))
|
||||
assertPermission(m, "perm1")
|
||||
|
||||
edt { m.setAutoApprove(true) }
|
||||
flush()
|
||||
|
||||
assertTrue(rpc.permissionReplies.any { it.first == "perm2" })
|
||||
assertPermission(m, "perm1")
|
||||
|
||||
edt { m.abort() }
|
||||
flush()
|
||||
assertTrue(m.model.state is SessionState.Idle)
|
||||
}
|
||||
|
||||
fun `test replying active question shows queued permission`() {
|
||||
val (m, _, _) = prompted()
|
||||
|
||||
emit(ChatEventDto.QuestionAsked("ses_test", question("q1")))
|
||||
emit(ChatEventDto.PermissionAsked("ses_test", permission("perm1")))
|
||||
|
||||
assertTrue(m.model.state is SessionState.AwaitingQuestion)
|
||||
|
||||
edt { m.replyQuestion("q1", QuestionReplyDto(listOf(listOf("A")))) }
|
||||
emit(ChatEventDto.QuestionReplied("ses_test", "q1"))
|
||||
|
||||
assertPermission(m, "perm1")
|
||||
}
|
||||
|
||||
fun `test status idle keeps a promoted child permission instead of clobbering to idle`() {
|
||||
// A root card in front of a queued child permission: when the root session reports idle,
|
||||
// purgePending clears the root card and promotes the child's still-live permission. The
|
||||
// status handler must leave that promotion in place rather than overwriting it with Idle.
|
||||
val (m, _, _) = prompted()
|
||||
|
||||
emit(ChatEventDto.PermissionAsked("ses_test", permission("perm1")))
|
||||
emit(taskPart("ses_child"), flush = false)
|
||||
emit(ChatEventDto.PermissionAsked("ses_child", childPermission("child_perm1")))
|
||||
assertPermission(m, "perm1")
|
||||
|
||||
emit(ChatEventDto.SessionStatusChanged("ses_test", SessionStatusDto("idle")))
|
||||
|
||||
assertPermission(m, "child_perm1")
|
||||
}
|
||||
|
||||
private fun taskPart(child: String) = ChatEventDto.PartUpdated(
|
||||
sessionID = "ses_test",
|
||||
part = PartDto(
|
||||
id = "part_task",
|
||||
sessionID = "ses_test",
|
||||
messageID = "msg1",
|
||||
type = "tool",
|
||||
tool = "task",
|
||||
metadata = mapOf("sessionId" to child),
|
||||
input = mapOf("subagent_type" to "explore", "description" to "Find files"),
|
||||
),
|
||||
)
|
||||
|
||||
private fun childPermission(id: String) = PermissionRequestDto(
|
||||
id = id,
|
||||
sessionID = "ses_child",
|
||||
permission = "edit",
|
||||
patterns = listOf("*.kt"),
|
||||
always = emptyList(),
|
||||
)
|
||||
|
||||
private fun assertPermission(c: SessionController, id: String, name: String = "edit") {
|
||||
val state = c.model.state as? SessionState.AwaitingPermission ?: error("Expected AwaitingPermission")
|
||||
assertEquals(id, state.permission.id)
|
||||
assertEquals(name, state.permission.name)
|
||||
}
|
||||
|
||||
private fun permission(id: String, name: String = "edit") = PermissionRequestDto(
|
||||
id = id,
|
||||
sessionID = "ses_test",
|
||||
permission = name,
|
||||
patterns = listOf("*.kt"),
|
||||
always = emptyList(),
|
||||
)
|
||||
|
||||
private fun skillPermission(id: String) = permission(id).copy(metadata = mapOf("skillShell" to "true"))
|
||||
|
||||
private fun question(id: String) = QuestionRequestDto(
|
||||
id = id,
|
||||
sessionID = "ses_test",
|
||||
questions = listOf(QuestionInfoDto("Pick one", "Choice")),
|
||||
)
|
||||
}
|
||||
+153
@@ -0,0 +1,153 @@
|
||||
package ai.kilocode.client.session.ui
|
||||
|
||||
import ai.kilocode.client.session.views.SessionViewIcons
|
||||
import ai.kilocode.client.ui.DiffStatBadge
|
||||
import ai.kilocode.rpc.dto.DiffFileDto
|
||||
import com.intellij.openapi.editor.EditorFactory
|
||||
import com.intellij.openapi.util.Disposer
|
||||
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 javax.swing.AbstractButton
|
||||
|
||||
class ModifiedFilesViewTest : BasePlatformTestCase() {
|
||||
private lateinit var view: ModifiedFilesView
|
||||
|
||||
override fun setUp() {
|
||||
super.setUp()
|
||||
view = ModifiedFilesView({ _, _ -> })
|
||||
}
|
||||
|
||||
override fun tearDown() {
|
||||
try {
|
||||
Disposer.dispose(view)
|
||||
} finally {
|
||||
super.tearDown()
|
||||
}
|
||||
}
|
||||
|
||||
fun `test view is hidden without changes and shows count after diff`() {
|
||||
assertFalse(view.isVisible)
|
||||
|
||||
view.setDiffs(listOf(file("src/A.kt", 2, 1, PATCH)))
|
||||
|
||||
assertTrue(view.isVisible)
|
||||
assertEquals("1 file", view.countText())
|
||||
}
|
||||
|
||||
fun `test header uses edit icon`() {
|
||||
val labels = components(view).filterIsInstance<JBLabel>()
|
||||
|
||||
assertTrue(labels.any { it.icon === SessionViewIcons.edit })
|
||||
}
|
||||
|
||||
fun `test expand renders one link and badge per file`() {
|
||||
val opened = mutableListOf<String>()
|
||||
Disposer.dispose(view)
|
||||
view = ModifiedFilesView({ href, _ -> opened.add(href) })
|
||||
view.setDiffs(listOf(
|
||||
file("src/A.kt", 2, 0, ADD),
|
||||
file("pkg/B.kt", 1, 1, UPDATE),
|
||||
))
|
||||
|
||||
assertFalse(view.bodyCreated())
|
||||
|
||||
view.toggle()
|
||||
|
||||
assertTrue(view.isExpanded())
|
||||
assertTrue(view.bodyVisible())
|
||||
assertTrue(view.bodyCreated())
|
||||
assertEquals(2, components(view).filterIsInstance<DiffStatBadge>().size)
|
||||
|
||||
val links = components(view).filterIsInstance<JBLabel>().filter { it.text?.contains("<u>") == true }
|
||||
assertTrue(links.any { it.text!!.contains("A.kt") && it.toolTipText == "src/A.kt" })
|
||||
assertTrue(links.any { it.text!!.contains("B.kt") && it.toolTipText == "pkg/B.kt" })
|
||||
}
|
||||
|
||||
fun `test popup is available only when collapsed`() {
|
||||
view.setDiffs(listOf(file("src/A.kt", 2, 1, PATCH)))
|
||||
|
||||
assertNotNull(view.headerPopup())
|
||||
|
||||
view.toggle()
|
||||
|
||||
assertNull(view.headerPopup())
|
||||
}
|
||||
|
||||
fun `test open in diff uses changed files title`() {
|
||||
val titles = mutableListOf<String>()
|
||||
view.setDiffOpener({ _, title, _ -> titles.add(title) }, "ses", "turn")
|
||||
view.setDiffs(listOf(file("src/A.kt", 2, 1, PATCH)))
|
||||
|
||||
openDiffButton().doClick()
|
||||
|
||||
assertEquals("Changed files", titles.single())
|
||||
}
|
||||
|
||||
fun `test dispose releases created editors`() {
|
||||
val base = EditorFactory.getInstance().allEditors.size
|
||||
view.setDiffs(listOf(file("src/A.kt", 2, 1, PATCH)))
|
||||
|
||||
repeat(20) {
|
||||
view.expand()
|
||||
view.collapse()
|
||||
view.setDiffs(listOf(file("src/A.kt", it + 1, 1, PATCH)))
|
||||
}
|
||||
|
||||
Disposer.dispose(view)
|
||||
UIUtil.dispatchAllInvocationEvents()
|
||||
|
||||
assertEquals(base, EditorFactory.getInstance().allEditors.size)
|
||||
}
|
||||
|
||||
private fun components(root: Component): List<Component> {
|
||||
val out = mutableListOf<Component>()
|
||||
fun visit(node: Component) {
|
||||
out.add(node)
|
||||
if (node is Container) node.components.forEach(::visit)
|
||||
}
|
||||
visit(root)
|
||||
return out
|
||||
}
|
||||
|
||||
private fun openDiffButton(): AbstractButton = view.copyToolbar as AbstractButton
|
||||
|
||||
private fun file(path: String, additions: Int, deletions: Int, patch: String) = DiffFileDto(
|
||||
file = path,
|
||||
additions = additions,
|
||||
deletions = deletions,
|
||||
patch = patch,
|
||||
)
|
||||
|
||||
private companion object {
|
||||
val PATCH = """
|
||||
diff --git a/src/A.kt b/src/A.kt
|
||||
--- a/src/A.kt
|
||||
+++ b/src/A.kt
|
||||
@@ -1,1 +1,2 @@
|
||||
-old
|
||||
+new
|
||||
+more
|
||||
""".trimIndent()
|
||||
|
||||
val ADD = """
|
||||
diff --git a/src/A.kt b/src/A.kt
|
||||
--- /dev/null
|
||||
+++ b/src/A.kt
|
||||
@@ -0,0 +1,2 @@
|
||||
+one
|
||||
+two
|
||||
""".trimIndent()
|
||||
|
||||
val UPDATE = """
|
||||
diff --git a/pkg/B.kt b/pkg/B.kt
|
||||
--- a/pkg/B.kt
|
||||
+++ b/pkg/B.kt
|
||||
@@ -1,1 +1,1 @@
|
||||
-before
|
||||
+after
|
||||
""".trimIndent()
|
||||
}
|
||||
}
|
||||
+37
@@ -32,6 +32,7 @@ import ai.kilocode.client.ui.HoverIcon
|
||||
import ai.kilocode.client.ui.layout.Stack
|
||||
import ai.kilocode.rpc.dto.DiffFileDto
|
||||
import ai.kilocode.rpc.dto.MessageDto
|
||||
import ai.kilocode.rpc.dto.MessageSummaryDto
|
||||
import ai.kilocode.rpc.dto.MessageTimeDto
|
||||
import ai.kilocode.rpc.dto.MessageWithPartsDto
|
||||
import ai.kilocode.rpc.dto.PartDto
|
||||
@@ -62,6 +63,16 @@ import javax.swing.RepaintManager
|
||||
import javax.swing.SwingUtilities
|
||||
import javax.swing.border.Border
|
||||
|
||||
private val PATCH = """
|
||||
diff --git a/src/A.kt b/src/A.kt
|
||||
--- a/src/A.kt
|
||||
+++ b/src/A.kt
|
||||
@@ -1,1 +1,2 @@
|
||||
-old
|
||||
+new
|
||||
+more
|
||||
""".trimIndent()
|
||||
|
||||
/**
|
||||
* Tests for [SessionMessageListPanel] — structural and index integrity.
|
||||
*
|
||||
@@ -99,6 +110,28 @@ class SessionMessageListPanelTest : BasePlatformTestCase() {
|
||||
assertEquals("", panel.dump())
|
||||
}
|
||||
|
||||
fun `test modified files card follows turn anchor summary`() {
|
||||
model.upsertMessage(msg("u1", "user").copy(summary = summary("src/A.kt")))
|
||||
|
||||
val turn = panel.findTurn("u1")!!
|
||||
val card = components(turn).filterIsInstance<ModifiedFilesView>().single()
|
||||
|
||||
assertSame(card, turn.components.last())
|
||||
assertTrue(card.isVisible)
|
||||
assertEquals("1 file", card.countText())
|
||||
}
|
||||
|
||||
fun `test message updated summary updates modified files card`() {
|
||||
model.upsertMessage(msg("u1", "user"))
|
||||
assertTrue(components(panel.findTurn("u1")!!).filterIsInstance<ModifiedFilesView>().isEmpty())
|
||||
|
||||
model.upsertMessage(msg("u1", "user").copy(summary = summary("src/A.kt")))
|
||||
|
||||
val card = components(panel.findTurn("u1")!!).filterIsInstance<ModifiedFilesView>().single()
|
||||
assertTrue(card.isVisible)
|
||||
assertEquals("1 file", card.countText())
|
||||
}
|
||||
|
||||
fun `test transcript content has symmetric side padding`() {
|
||||
model.upsertMessage(msg("a1", "assistant"))
|
||||
|
||||
@@ -1260,6 +1293,10 @@ class SessionMessageListPanelTest : BasePlatformTestCase() {
|
||||
id = id, sessionID = "ses", role = role, time = MessageTimeDto(0.0),
|
||||
)
|
||||
|
||||
private fun summary(path: String) = MessageSummaryDto(
|
||||
diffs = listOf(DiffFileDto(path, 2, 1, PATCH)),
|
||||
)
|
||||
|
||||
private fun part(id: String, mid: String, type: String, text: String? = null) = PartDto(
|
||||
id = id, sessionID = "ses", messageID = mid, type = type, text = text,
|
||||
)
|
||||
|
||||
+109
@@ -1,5 +1,6 @@
|
||||
package ai.kilocode.client.session.ui.header
|
||||
|
||||
import ai.kilocode.client.plugin.KiloBundle
|
||||
import ai.kilocode.client.session.model.Reasoning
|
||||
import ai.kilocode.client.session.model.StepFinish
|
||||
import ai.kilocode.client.session.model.Tool
|
||||
@@ -19,12 +20,15 @@ import ai.kilocode.rpc.dto.TodoDto
|
||||
import ai.kilocode.rpc.dto.TokensDto
|
||||
import com.intellij.icons.AllIcons
|
||||
import com.intellij.ide.util.PropertiesComponent
|
||||
import ai.kilocode.rpc.dto.DiffFileDto
|
||||
import java.awt.Cursor
|
||||
import java.awt.Color
|
||||
import java.awt.Point
|
||||
import java.awt.event.MouseEvent
|
||||
import java.awt.event.MouseWheelEvent
|
||||
import java.awt.image.BufferedImage
|
||||
import javax.swing.JComponent
|
||||
import javax.swing.RepaintManager
|
||||
import javax.swing.UIManager
|
||||
|
||||
class SessionHeaderPanelTest : SessionControllerTestBase() {
|
||||
@@ -122,6 +126,96 @@ class SessionHeaderPanelTest : SessionControllerTestBase() {
|
||||
assertEquals(1, rpc.compacts.size)
|
||||
}
|
||||
|
||||
fun `test branch changes badge shows count stats and hides when empty`() {
|
||||
val c = promptedHeader()
|
||||
val panel = SessionHeaderPanel(c, parent)
|
||||
|
||||
assertFalse(panel.changesVisible())
|
||||
|
||||
panel.setBranchChanges(listOf(
|
||||
DiffFileDto("src/A.kt", 2, 1),
|
||||
DiffFileDto("src/B.kt", 0, 3),
|
||||
DiffFileDto("src/C.kt", 5, 0),
|
||||
))
|
||||
|
||||
assertTrue(panel.changesVisible())
|
||||
assertEquals("3 files", panel.changesText())
|
||||
assertEquals(7 to 4, panel.changesStat())
|
||||
|
||||
panel.setBranchChanges(emptyList())
|
||||
|
||||
assertFalse(panel.changesVisible())
|
||||
}
|
||||
|
||||
fun `test branch changes badge invokes callback when clicked`() {
|
||||
val c = promptedHeader()
|
||||
var opened = 0
|
||||
val panel = SessionHeaderPanel(c, parent) { opened++ }
|
||||
val badge = panel.changesBadge()
|
||||
|
||||
panel.setBranchChanges(listOf(DiffFileDto("src/A.kt", 2, 1)))
|
||||
|
||||
assertTrue(badge.isVisible)
|
||||
assertEquals(KiloBundle.message("diff.editor.branch.tooltip"), badge.toolTipText)
|
||||
assertEquals(KiloBundle.message("diff.editor.branch.tooltip"), badge.accessibleContext.accessibleName)
|
||||
|
||||
click(badge)
|
||||
|
||||
assertEquals(1, opened)
|
||||
}
|
||||
|
||||
fun `test branch changes badge is hidden without files even with callback`() {
|
||||
val c = promptedHeader()
|
||||
val panel = SessionHeaderPanel(c, parent) {}
|
||||
|
||||
assertFalse(panel.changesVisible())
|
||||
}
|
||||
|
||||
fun `test branch changes badge no-op update does not repaint`() {
|
||||
val c = promptedHeader()
|
||||
val panel = SessionHeaderPanel(c, parent)
|
||||
val files = listOf(DiffFileDto("src/A.kt", 2, 1))
|
||||
panel.setBranchChanges(files)
|
||||
val prev = RepaintManager.currentManager(panel)
|
||||
val tracker = TrackingRepaintManager(panel.changesBadge())
|
||||
|
||||
try {
|
||||
RepaintManager.setCurrentManager(tracker)
|
||||
panel.setBranchChanges(files)
|
||||
|
||||
assertTrue(tracker.dirty.isEmpty())
|
||||
assertTrue(tracker.invalid.isEmpty())
|
||||
} finally {
|
||||
RepaintManager.setCurrentManager(prev)
|
||||
}
|
||||
}
|
||||
|
||||
fun `test clicking session title toggles expansion`() {
|
||||
val c = promptedHeader()
|
||||
val panel = SessionHeaderPanel(c, parent)
|
||||
|
||||
assertFalse(panel.isExpanded())
|
||||
|
||||
click(panel.titleLabel())
|
||||
assertTrue(panel.isExpanded())
|
||||
|
||||
click(panel.titleLabel())
|
||||
assertFalse(panel.isExpanded())
|
||||
}
|
||||
|
||||
fun `test top row places expand center group and right controls`() {
|
||||
val c = promptedHeader()
|
||||
val panel = SessionHeaderPanel(c, parent)
|
||||
val top = panel.expandButton().parent
|
||||
val layout = top.layout as java.awt.BorderLayout
|
||||
|
||||
assertSame(panel.expandButton(), layout.getLayoutComponent(java.awt.BorderLayout.WEST))
|
||||
assertSame(panel.centerGroupPanel(), layout.getLayoutComponent(java.awt.BorderLayout.CENTER))
|
||||
assertSame(panel.rightPanel(), layout.getLayoutComponent(java.awt.BorderLayout.EAST))
|
||||
assertSame(panel.centerGroupPanel(), panel.changesBadge().parent)
|
||||
assertSame(panel.rightPanel(), panel.compactButton().parent)
|
||||
}
|
||||
|
||||
fun `test todo list starts collapsed and toggles independently`() {
|
||||
val c = promptedHeader()
|
||||
val panel = SessionHeaderPanel(c, parent)
|
||||
@@ -677,4 +771,19 @@ class SessionHeaderPanelTest : SessionControllerTestBase() {
|
||||
private fun reset() {
|
||||
PropertiesComponent.getInstance().unsetValue(SessionHeaderPanel.EXPANDED_KEY)
|
||||
}
|
||||
|
||||
private class TrackingRepaintManager(private val watched: JComponent) : RepaintManager() {
|
||||
val dirty = mutableListOf<JComponent>()
|
||||
val invalid = mutableListOf<JComponent>()
|
||||
|
||||
override fun addDirtyRegion(c: JComponent, x: Int, y: Int, w: Int, h: Int) {
|
||||
if (c === watched) dirty.add(c)
|
||||
super.addDirtyRegion(c, x, y, w, h)
|
||||
}
|
||||
|
||||
override fun addInvalidComponent(invalidComponent: JComponent) {
|
||||
if (invalidComponent === watched) invalid.add(invalidComponent)
|
||||
super.addInvalidComponent(invalidComponent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
package ai.kilocode.client.session.ui.popup
|
||||
|
||||
import ai.kilocode.client.session.ui.style.SessionUiStyle
|
||||
import com.intellij.openapi.util.Disposer
|
||||
import com.intellij.testFramework.fixtures.BasePlatformTestCase
|
||||
import com.intellij.ui.components.JBScrollPane
|
||||
import com.intellij.util.ui.JBUI
|
||||
import com.intellij.util.ui.UIUtil
|
||||
import java.awt.Component
|
||||
import java.awt.Container
|
||||
import java.awt.Dimension
|
||||
import javax.swing.JPanel
|
||||
|
||||
class HeaderPopupBodyTest : BasePlatformTestCase() {
|
||||
|
||||
fun `test tall popup content scrolls and caps height`() {
|
||||
val tall = JPanel().apply {
|
||||
preferredSize = Dimension(JBUI.scale(200), JBUI.scale(SessionUiStyle.View.Popup.MAX_HEIGHT * 3))
|
||||
}
|
||||
val owner = Disposer.newDisposable("popup body")
|
||||
Disposer.register(testRootDisposable, owner)
|
||||
val body = HeaderPopupBody(tall, owner, UIUtil.getPanelBackground())
|
||||
|
||||
val scroll = descendants(body.component).filterIsInstance<JBScrollPane>().single()
|
||||
assertSame(tall, scroll.viewport.view)
|
||||
assertEquals(JBUI.scale(SessionUiStyle.View.Popup.MAX_HEIGHT), body.component.preferredSize.height)
|
||||
}
|
||||
|
||||
fun `test short popup content is not capped`() {
|
||||
val short = JPanel().apply {
|
||||
preferredSize = Dimension(JBUI.scale(200), JBUI.scale(40))
|
||||
}
|
||||
val owner = Disposer.newDisposable("popup body")
|
||||
Disposer.register(testRootDisposable, owner)
|
||||
val body = HeaderPopupBody(short, owner, UIUtil.getPanelBackground())
|
||||
|
||||
assertEquals(JBUI.scale(40), body.component.preferredSize.height)
|
||||
}
|
||||
|
||||
private fun descendants(root: Component): List<Component> {
|
||||
val out = mutableListOf<Component>()
|
||||
fun visit(node: Component) {
|
||||
out.add(node)
|
||||
if (node is Container) node.components.forEach(::visit)
|
||||
}
|
||||
visit(root)
|
||||
return out
|
||||
}
|
||||
}
|
||||
+56
@@ -9,6 +9,7 @@ import ai.kilocode.client.session.views.tool.EditToolView
|
||||
import ai.kilocode.client.session.views.tool.ReadToolView
|
||||
import ai.kilocode.client.session.views.tool.ToolView
|
||||
import ai.kilocode.client.ui.DiffStatBadge
|
||||
import ai.kilocode.rpc.dto.DiffFileDto
|
||||
import com.intellij.openapi.diff.DiffColors
|
||||
import com.intellij.openapi.editor.EditorFactory
|
||||
import com.intellij.openapi.util.Disposer
|
||||
@@ -23,6 +24,7 @@ import kotlinx.serialization.json.put
|
||||
import java.awt.Component
|
||||
import java.awt.Container
|
||||
import java.awt.event.MouseEvent
|
||||
import javax.swing.AbstractButton
|
||||
|
||||
@Suppress("UnstableApiUsage")
|
||||
class EditToolViewTest : BasePlatformTestCase() {
|
||||
@@ -130,6 +132,55 @@ class EditToolViewTest : BasePlatformTestCase() {
|
||||
assertEquals(listOf("src/A.kt"), opened)
|
||||
}
|
||||
|
||||
fun `test open in diff action fires for edit and patch`() {
|
||||
val edit = mutableListOf<List<DiffFileDto>>()
|
||||
val titles = mutableListOf<String>()
|
||||
val editView = track(EditToolView(tool(), { _, _ -> }, null, { files, title, _ ->
|
||||
edit.add(files)
|
||||
titles.add(title)
|
||||
}, "ses"))
|
||||
val editButton = openDiffButton(editView)
|
||||
assertTrue(editButton.isEnabled)
|
||||
editButton.doClick()
|
||||
assertEquals(1, edit.single().size)
|
||||
// Single-file edit keeps the file name so its diff tab is identifiable (not a generic "Edit").
|
||||
assertEquals("App.kt", titles.single())
|
||||
|
||||
val patch = mutableListOf<List<DiffFileDto>>()
|
||||
val patchView = track(EditToolView(tool().also {
|
||||
it.input = emptyMap()
|
||||
it.metadata = mapOf("files" to filesMeta(
|
||||
FileChange("src/A.kt", 2, 0, ADD_HUNK),
|
||||
FileChange("src/B.kt", 1, 1, UPDATE_HUNK),
|
||||
))
|
||||
}, { _, _ -> }, null, { files, title, _ ->
|
||||
patch.add(files)
|
||||
titles.add(title)
|
||||
}, "ses"))
|
||||
val patchButton = openDiffButton(patchView)
|
||||
assertTrue(patchButton.isEnabled)
|
||||
patchButton.doClick()
|
||||
assertEquals(2, patch.single().size)
|
||||
assertEquals("Patch", titles.last())
|
||||
}
|
||||
|
||||
fun `test open in diff uses a late-bound opener`() {
|
||||
// Mirrors the real wiring: the view is built before the session-level opener is known, then
|
||||
// MessageView rebinds it. Without late binding the button click is a no-op.
|
||||
val fired = mutableListOf<List<DiffFileDto>>()
|
||||
val view = track(EditToolView(tool()))
|
||||
val button = openDiffButton(view)
|
||||
assertTrue(button.isEnabled)
|
||||
|
||||
button.doClick()
|
||||
assertTrue(fired.isEmpty())
|
||||
|
||||
view.setDiffOpener({ files, _, _ -> fired.add(files) }, "ses")
|
||||
button.doClick()
|
||||
|
||||
assertEquals(1, fired.single().size)
|
||||
}
|
||||
|
||||
fun `test single file apply_patch keeps link and hides count tag`() {
|
||||
val view = track(EditToolView(tool().also {
|
||||
it.input = emptyMap()
|
||||
@@ -222,6 +273,8 @@ class EditToolViewTest : BasePlatformTestCase() {
|
||||
click(link, 0)
|
||||
|
||||
assertEquals(listOf("/repo/src/App.kt"), opened)
|
||||
// The link is not bound for toggling, so opening the file must not also collapse the card.
|
||||
assertTrue(view.isExpanded())
|
||||
}
|
||||
|
||||
fun `test metadata only patch falls back to raw text`() {
|
||||
@@ -397,6 +450,9 @@ class EditToolViewTest : BasePlatformTestCase() {
|
||||
if (child is DiffStatBadge) nested + child else nested
|
||||
}
|
||||
|
||||
private fun openDiffButton(view: EditToolView): AbstractButton =
|
||||
view.copyToolbar as AbstractButton
|
||||
|
||||
private fun tool() = Tool("p1", "edit", toolKind("edit")).also {
|
||||
it.state = ToolExecState.COMPLETED
|
||||
it.title = "src/App.kt"
|
||||
|
||||
+7
-3
@@ -9,9 +9,10 @@ import ai.kilocode.client.session.views.tool.GlobToolView
|
||||
import ai.kilocode.client.session.views.tool.ReadToolView
|
||||
import ai.kilocode.client.session.views.tool.SearchToolView
|
||||
import ai.kilocode.client.session.views.tool.ToolView
|
||||
import ai.kilocode.client.ui.UiStyle
|
||||
import ai.kilocode.client.session.ui.style.SessionUiStyle
|
||||
import com.intellij.openapi.util.Disposer
|
||||
import com.intellij.testFramework.fixtures.BasePlatformTestCase
|
||||
import com.intellij.util.ui.JBUI
|
||||
import java.awt.BorderLayout
|
||||
import java.awt.Container
|
||||
import java.awt.Dimension
|
||||
@@ -111,12 +112,15 @@ class SearchToolViewTest : BasePlatformTestCase() {
|
||||
assertEquals(style.regularFont, view.targetFont(1))
|
||||
}
|
||||
|
||||
fun `test search header title target gap uses standard medium gap`() {
|
||||
fun `test search header uses standard layout gap between regions`() {
|
||||
val view = SearchToolView(tool().also {
|
||||
it.input = mapOf("pattern" to "TODO", "include" to "*.kt")
|
||||
})
|
||||
|
||||
assertEquals(UiStyle.Gap.md(), (view.centerComponent().layout as BorderLayout).hgap)
|
||||
assertEquals(
|
||||
JBUI.scale(SessionUiStyle.View.Layout.GAP),
|
||||
(view.headerComponent().layout as BorderLayout).hgap,
|
||||
)
|
||||
}
|
||||
|
||||
fun `test completed search starts collapsed and expands output`() {
|
||||
|
||||
+5
-6
@@ -8,11 +8,11 @@ import ai.kilocode.client.session.ui.style.SessionEditorStyle
|
||||
import ai.kilocode.client.session.ui.style.SessionUiStyle
|
||||
import ai.kilocode.client.session.views.base.SecondarySessionPartView
|
||||
import ai.kilocode.client.session.views.tool.ToolView
|
||||
import ai.kilocode.client.ui.UiStyle
|
||||
import com.intellij.openapi.editor.DefaultLanguageHighlighterColors
|
||||
import com.intellij.openapi.util.Disposer
|
||||
import com.intellij.testFramework.fixtures.BasePlatformTestCase
|
||||
import com.intellij.ui.scale.JBUIScale
|
||||
import com.intellij.util.ui.JBUI
|
||||
import java.awt.BorderLayout
|
||||
import java.awt.Color
|
||||
import java.awt.image.BufferedImage
|
||||
@@ -279,10 +279,10 @@ class ToolViewTest : BasePlatformTestCase() {
|
||||
assertSmallEditorFont(view.stateFont(), style)
|
||||
}
|
||||
|
||||
fun `test tool header title subtitle gap uses standard medium gap`() {
|
||||
fun `test tool header uses standard layout gap`() {
|
||||
val view = track(ToolView(tool("p1", "bash", ToolExecState.COMPLETED).also { it.output = "done" }))
|
||||
|
||||
assertEquals(UiStyle.Gap.md(), centerGap(view))
|
||||
assertEquals(JBUI.scale(SessionUiStyle.View.Layout.GAP), headerGap(view))
|
||||
}
|
||||
|
||||
fun `test applyStyle updates tool fonts in place`() {
|
||||
@@ -434,11 +434,10 @@ class ToolViewTest : BasePlatformTestCase() {
|
||||
assertTrue(font.size < style.editorSize)
|
||||
}
|
||||
|
||||
private fun centerGap(view: ToolView): Int {
|
||||
private fun headerGap(view: ToolView): Int {
|
||||
val row = view.components.filterIsInstance<JPanel>().single()
|
||||
val header = (row.layout as BorderLayout).getLayoutComponent(BorderLayout.CENTER) as JPanel
|
||||
val center = (header.layout as BorderLayout).getLayoutComponent(BorderLayout.CENTER) as JPanel
|
||||
return (center.layout as BorderLayout).hgap
|
||||
return (header.layout as BorderLayout).hgap
|
||||
}
|
||||
|
||||
private fun paint(border: Border): Color {
|
||||
|
||||
+75
@@ -7,12 +7,18 @@ import ai.kilocode.client.session.model.Text
|
||||
import ai.kilocode.client.session.model.Tool
|
||||
import ai.kilocode.client.session.model.ToolExecState
|
||||
import ai.kilocode.client.session.model.toolKind
|
||||
import ai.kilocode.client.session.ui.ModifiedFilesView
|
||||
import ai.kilocode.client.session.ui.style.SessionUiStyle
|
||||
import ai.kilocode.client.session.views.tool.EditToolView
|
||||
import ai.kilocode.rpc.dto.DiffFileDto
|
||||
import ai.kilocode.rpc.dto.MessageDto
|
||||
import ai.kilocode.rpc.dto.MessageTimeDto
|
||||
import com.intellij.testFramework.fixtures.BasePlatformTestCase
|
||||
import com.intellij.util.ui.JBUI
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import java.awt.image.BufferedImage
|
||||
import javax.swing.AbstractButton
|
||||
import javax.swing.JComponent
|
||||
import javax.swing.JPanel
|
||||
import javax.swing.RepaintManager
|
||||
@@ -84,6 +90,32 @@ class TurnViewTest : BasePlatformTestCase() {
|
||||
assertEquals("user#u1, assistant#a1", tv.dump())
|
||||
}
|
||||
|
||||
fun `test modified files card stays last in turn`() {
|
||||
val tv = TurnView("u1", openFile)
|
||||
tv.addMessage(msg("u1", "user"))
|
||||
|
||||
tv.setDiffs(listOf(diff("src/A.kt")))
|
||||
|
||||
val card = tv.components.last() as ModifiedFilesView
|
||||
assertTrue(card.isVisible)
|
||||
|
||||
tv.addMessage(msg("a1", "assistant"))
|
||||
|
||||
assertSame(card, tv.components.last())
|
||||
assertEquals(listOf("u1", "a1"), tv.messageIds())
|
||||
}
|
||||
|
||||
fun `test modified files card hides for empty diffs`() {
|
||||
val tv = TurnView("u1", openFile)
|
||||
|
||||
tv.setDiffs(listOf(diff("src/A.kt")))
|
||||
val card = tv.components.last() as ModifiedFilesView
|
||||
|
||||
tv.setDiffs(emptyList())
|
||||
|
||||
assertFalse(card.isVisible)
|
||||
}
|
||||
|
||||
// ------ MessageView ------
|
||||
|
||||
fun `test new MessageView is empty`() {
|
||||
@@ -294,6 +326,20 @@ class TurnViewTest : BasePlatformTestCase() {
|
||||
}
|
||||
}
|
||||
|
||||
fun `test setDiffOpener rebinds edit tool parts built before wiring`() {
|
||||
// The transcript builds the MessageView (and its EditToolView) before the session-level
|
||||
// opener is known, exactly like history load. Rebinding must reach the existing part.
|
||||
val message = msg("a1", "assistant").also { it.parts["t1"] = editTool() }
|
||||
val mv = MessageView(message, openFile)
|
||||
|
||||
val fired = mutableListOf<List<DiffFileDto>>()
|
||||
mv.setDiffOpener({ files, _, _ -> fired.add(files) }, "ses")
|
||||
|
||||
openDiffButton(mv).doClick()
|
||||
|
||||
assertEquals(1, fired.single().size)
|
||||
}
|
||||
|
||||
fun `test MessageView pre-populates parts from Message on creation`() {
|
||||
val message = msg("a1", "assistant")
|
||||
val text = ai.kilocode.client.session.model.Text("p1").also { it.content.append("preloaded") }
|
||||
@@ -344,6 +390,23 @@ class TurnViewTest : BasePlatformTestCase() {
|
||||
private fun msg(id: String, role: String): Message =
|
||||
Message(MessageDto(id = id, sessionID = "ses", role = role, time = MessageTimeDto(0.0)))
|
||||
|
||||
private fun diff(path: String) = DiffFileDto(path, additions = 2, deletions = 1, patch = PATCH)
|
||||
|
||||
private fun editTool() = Tool("t1", "edit", toolKind("edit")).also {
|
||||
it.state = ToolExecState.COMPLETED
|
||||
it.title = "src/App.kt"
|
||||
it.input = mapOf("filePath" to "/repo/src/App.kt")
|
||||
it.metadata = mapOf("filediff" to buildJsonObject {
|
||||
put("file", "src/App.kt")
|
||||
put("additions", 2)
|
||||
put("deletions", 1)
|
||||
put("patch", PATCH)
|
||||
}.toString())
|
||||
}
|
||||
|
||||
private fun openDiffButton(view: MessageView): AbstractButton =
|
||||
(view.part("t1") as EditToolView).copyToolbar as AbstractButton
|
||||
|
||||
private fun reasoning(id: String, content: String) = Reasoning(id).also {
|
||||
it.done = false
|
||||
it.content.append(content)
|
||||
@@ -375,4 +438,16 @@ class TurnViewTest : BasePlatformTestCase() {
|
||||
super.addInvalidComponent(invalidComponent)
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val PATCH = """
|
||||
diff --git a/src/A.kt b/src/A.kt
|
||||
--- a/src/A.kt
|
||||
+++ b/src/A.kt
|
||||
@@ -1,1 +1,2 @@
|
||||
-old
|
||||
+new
|
||||
+more
|
||||
""".trimIndent()
|
||||
}
|
||||
}
|
||||
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
package ai.kilocode.client.session.views.base
|
||||
|
||||
import ai.kilocode.client.session.ui.style.SessionUiStyle.View.Header
|
||||
import ai.kilocode.client.ui.DiffBars
|
||||
import ai.kilocode.client.ui.HoverIcon
|
||||
import com.intellij.testFramework.fixtures.BasePlatformTestCase
|
||||
import com.intellij.ui.components.JBLabel
|
||||
import java.awt.Component
|
||||
import java.awt.Container
|
||||
import java.awt.Dimension
|
||||
import javax.swing.SwingUtilities
|
||||
import kotlin.math.abs
|
||||
|
||||
class PartHeaderTest : BasePlatformTestCase() {
|
||||
fun `test labels and fixed controls are vertically centered`() {
|
||||
val title = JBLabel("Edit")
|
||||
val icon = HoverIcon()
|
||||
val bars = DiffBars(3, 1)
|
||||
val header = PartHeader().apply {
|
||||
left(title, PartHeader.centered(icon))
|
||||
right(PartHeader.centered(bars))
|
||||
}
|
||||
|
||||
sized(header, 400)
|
||||
|
||||
val mid = header.height / 2
|
||||
assertNear(mid, centerY(header, title))
|
||||
assertNear(mid, centerY(header, icon))
|
||||
assertNear(mid, centerY(header, bars))
|
||||
}
|
||||
|
||||
fun `test leading uses icon gap and universal gap between elements`() {
|
||||
val glyph = JBLabel("g")
|
||||
val title = JBLabel("Edit")
|
||||
val extra = JBLabel("x")
|
||||
val header = PartHeader().apply {
|
||||
leading(glyph)
|
||||
left(title, extra)
|
||||
}
|
||||
|
||||
sized(header, 400)
|
||||
|
||||
assertEquals(Header.icon(), title.x - (glyph.x + glyph.width))
|
||||
assertEquals(Header.gap(), extra.x - (title.x + title.width))
|
||||
}
|
||||
|
||||
fun `test title gap separates the title from following elements`() {
|
||||
val glyph = JBLabel("g")
|
||||
val title = JBLabel("Edit")
|
||||
val name = JBLabel("main.tf")
|
||||
val extra = JBLabel("x")
|
||||
val header = PartHeader().apply {
|
||||
leading(glyph)
|
||||
left(title)
|
||||
titleGap()
|
||||
left(name, extra)
|
||||
}
|
||||
|
||||
sized(header, 400)
|
||||
|
||||
assertEquals(Header.title(), name.x - (title.x + title.width))
|
||||
assertEquals(Header.gap(), extra.x - (name.x + name.width))
|
||||
assertTrue(Header.title() > Header.gap())
|
||||
}
|
||||
|
||||
fun `test right group hugs the trailing edge`() {
|
||||
val bars = DiffBars(1, 1)
|
||||
val header = PartHeader().apply {
|
||||
left(JBLabel("Modified"))
|
||||
right(PartHeader.centered(bars))
|
||||
}
|
||||
|
||||
sized(header, 400)
|
||||
|
||||
val edge = SwingUtilities.convertPoint(bars.parent, bars.x + bars.width, 0, header).x
|
||||
assertTrue("right group should reach the trailing edge, was $edge of ${header.width}", edge >= header.width - 2)
|
||||
}
|
||||
|
||||
fun `test fill middle absorbs width and clips long content`() {
|
||||
val path = JBLabel("a".repeat(400))
|
||||
val header = PartHeader().apply {
|
||||
left(JBLabel("Edit"))
|
||||
fill(path)
|
||||
right(JBLabel("done"))
|
||||
}
|
||||
|
||||
sized(header, 240)
|
||||
|
||||
assertTrue("fill child should not exceed header width", path.width <= header.width)
|
||||
}
|
||||
|
||||
private fun sized(header: PartHeader, width: Int) {
|
||||
header.size = Dimension(width, header.preferredSize.height)
|
||||
layout(header)
|
||||
}
|
||||
|
||||
private fun layout(root: Container) {
|
||||
root.doLayout()
|
||||
root.components.filterIsInstance<Container>().forEach { layout(it) }
|
||||
}
|
||||
|
||||
private fun centerY(header: PartHeader, comp: Component): Int =
|
||||
SwingUtilities.convertPoint(comp.parent, comp.x + comp.width / 2, comp.y + comp.height / 2, header).y
|
||||
|
||||
private fun assertNear(expected: Int, actual: Int) {
|
||||
assertTrue("expected ~$expected but was $actual", abs(expected - actual) <= 1)
|
||||
}
|
||||
}
|
||||
+4
-5
@@ -80,12 +80,12 @@ class TodoWriteViewTest : BasePlatformTestCase() {
|
||||
assertEquals(style.regularFont, view.rowFont(1))
|
||||
}
|
||||
|
||||
fun `test todo header title subtitle gap uses standard medium gap`() {
|
||||
fun `test todo header uses standard layout gap`() {
|
||||
val view = TodoWriteView(tool("todowrite", ToolExecState.COMPLETED).also {
|
||||
it.todos = listOf(TodoDto("Next", "pending", "medium"))
|
||||
})
|
||||
|
||||
assertEquals(UiStyle.Gap.md(), centerGap(view))
|
||||
assertEquals(JBUI.scale(SessionUiStyle.View.Layout.GAP), headerGap(view))
|
||||
}
|
||||
|
||||
fun `test todo body uses next standard inner padding`() {
|
||||
@@ -157,11 +157,10 @@ class TodoWriteViewTest : BasePlatformTestCase() {
|
||||
assertTrue(view.rowText(0).contains("New"))
|
||||
}
|
||||
|
||||
private fun centerGap(view: TodoWriteView): Int {
|
||||
private fun headerGap(view: TodoWriteView): Int {
|
||||
val row = view.components.filterIsInstance<JPanel>().first()
|
||||
val header = (row.layout as BorderLayout).getLayoutComponent(BorderLayout.CENTER) as JPanel
|
||||
val center = (header.layout as BorderLayout).getLayoutComponent(BorderLayout.CENTER) as JPanel
|
||||
return (center.layout as BorderLayout).hgap
|
||||
return (header.layout as BorderLayout).hgap
|
||||
}
|
||||
|
||||
private fun tool(name: String, state: ToolExecState) = Tool("p1", name, toolKind(name)).also { it.state = state }
|
||||
|
||||
+10
@@ -5,6 +5,7 @@ import ai.kilocode.rpc.dto.ChatEventDto
|
||||
import ai.kilocode.rpc.dto.CloudSessionDto
|
||||
import ai.kilocode.rpc.dto.CloudSessionListDto
|
||||
import ai.kilocode.rpc.dto.ConfigUpdateDto
|
||||
import ai.kilocode.rpc.dto.DiffFileDto
|
||||
import ai.kilocode.rpc.dto.MessageWithPartsDto
|
||||
import ai.kilocode.rpc.dto.ModelSelectionDto
|
||||
import ai.kilocode.rpc.dto.PermissionAlwaysRulesDto
|
||||
@@ -47,6 +48,7 @@ class FakeSessionRpcApi : KiloSessionRpcApi {
|
||||
/** Message history returned by [messages]. */
|
||||
val history = mutableListOf<MessageWithPartsDto>()
|
||||
val histories = mutableMapOf<String, MutableList<MessageWithPartsDto>>()
|
||||
val diffs = mutableMapOf<String, MutableList<DiffFileDto>>()
|
||||
var historyGate: CompletableDeferred<Unit>? = null
|
||||
var historyCalls = 0
|
||||
private set
|
||||
@@ -252,6 +254,11 @@ class FakeSessionRpcApi : KiloSessionRpcApi {
|
||||
return histories[id]?.toList() ?: history.toList()
|
||||
}
|
||||
|
||||
override suspend fun diff(id: String, directory: String): List<DiffFileDto> {
|
||||
assertNotEdt("diff")
|
||||
return diffs[id]?.toList().orEmpty()
|
||||
}
|
||||
|
||||
override suspend fun attachmentPart(id: String, directory: String, messageId: String, partId: String, attachmentKey: String?): PartDto? {
|
||||
assertNotEdt("attachmentPart")
|
||||
attachmentParts.add(AttachmentCall(id, directory, messageId, partId, attachmentKey))
|
||||
@@ -276,8 +283,11 @@ class FakeSessionRpcApi : KiloSessionRpcApi {
|
||||
configs.add(directory to config)
|
||||
}
|
||||
|
||||
var replyPermissionThrows: Exception? = null
|
||||
|
||||
override suspend fun replyPermission(requestId: String, directory: String, reply: PermissionReplyDto) {
|
||||
assertNotEdt("replyPermission")
|
||||
replyPermissionThrows?.let { throw it }
|
||||
permissionReplies.add(Triple(requestId, directory, reply))
|
||||
}
|
||||
|
||||
|
||||
+17
@@ -2,6 +2,7 @@ package ai.kilocode.client.testing
|
||||
|
||||
import ai.kilocode.rpc.KiloWorkspaceRpcApi
|
||||
import ai.kilocode.rpc.dto.ConfigTargetDto
|
||||
import ai.kilocode.rpc.dto.DiffFileDto
|
||||
import ai.kilocode.rpc.dto.FileSearchResultDto
|
||||
import ai.kilocode.rpc.dto.KiloWorkspaceStateDto
|
||||
import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto
|
||||
@@ -34,6 +35,10 @@ class FakeWorkspaceRpcApi : KiloWorkspaceRpcApi {
|
||||
var searchResult = FileSearchResultDto()
|
||||
var search: ((String) -> FileSearchResultDto)? = null
|
||||
var gitChanges: String? = null
|
||||
val branchDiffs = mutableListOf<DiffFileDto>()
|
||||
val branchDiffCalls = CopyOnWriteArrayList<String>()
|
||||
val branchDiffPatchCalls = CopyOnWriteArrayList<Boolean>()
|
||||
var branchName: String? = null
|
||||
var openResult = true
|
||||
var localConfigPath = "/test/.kilo/kilo.jsonc"
|
||||
var globalConfigPath = "/config/kilo.jsonc"
|
||||
@@ -94,6 +99,18 @@ class FakeWorkspaceRpcApi : KiloWorkspaceRpcApi {
|
||||
return gitChanges
|
||||
}
|
||||
|
||||
override suspend fun branchDiff(directory: String, patches: Boolean): List<DiffFileDto> {
|
||||
assertNotEdt("branchDiff")
|
||||
branchDiffCalls.add(directory)
|
||||
branchDiffPatchCalls.add(patches)
|
||||
return branchDiffs.toList()
|
||||
}
|
||||
|
||||
override suspend fun branchName(directory: String): String? {
|
||||
assertNotEdt("branchName")
|
||||
return branchName
|
||||
}
|
||||
|
||||
override suspend fun openFile(path: String, line: Int?, column: Int?): Boolean {
|
||||
assertNotEdt("openFile")
|
||||
opened.add(path)
|
||||
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
package ai.kilocode.client.ui
|
||||
|
||||
import com.intellij.testFramework.fixtures.BasePlatformTestCase
|
||||
|
||||
class DiffStatBadgeTest : BasePlatformTestCase() {
|
||||
fun `test hides deletion label when deletions are zero`() {
|
||||
val badge = DiffStatBadge(3, 0)
|
||||
|
||||
assertTrue(badge.addedLabelForTest().isVisible)
|
||||
assertEquals("+3", badge.addedLabelForTest().text)
|
||||
assertFalse(badge.removedLabelForTest().isVisible)
|
||||
}
|
||||
|
||||
fun `test hides addition label when additions are zero`() {
|
||||
val badge = DiffStatBadge(0, 2)
|
||||
|
||||
assertTrue(badge.removedLabelForTest().isVisible)
|
||||
assertEquals("-2", badge.removedLabelForTest().text)
|
||||
assertFalse(badge.addedLabelForTest().isVisible)
|
||||
}
|
||||
|
||||
fun `test both zero leaves badge empty`() {
|
||||
val badge = DiffStatBadge(0, 0)
|
||||
|
||||
assertFalse(badge.removedLabelForTest().isVisible)
|
||||
assertFalse(badge.addedLabelForTest().isVisible)
|
||||
}
|
||||
|
||||
fun `test update toggles zero side visibility`() {
|
||||
val badge = DiffStatBadge(1, 1)
|
||||
|
||||
badge.update(0, 4)
|
||||
assertTrue(badge.removedLabelForTest().isVisible)
|
||||
assertFalse(badge.addedLabelForTest().isVisible)
|
||||
|
||||
badge.update(5, 0)
|
||||
assertFalse(badge.removedLabelForTest().isVisible)
|
||||
assertTrue(badge.addedLabelForTest().isVisible)
|
||||
|
||||
badge.update(0, 0)
|
||||
assertFalse(badge.removedLabelForTest().isVisible)
|
||||
assertFalse(badge.addedLabelForTest().isVisible)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package ai.kilocode.rpc
|
||||
import ai.kilocode.rpc.dto.ChatEventDto
|
||||
import ai.kilocode.rpc.dto.CloudSessionListDto
|
||||
import ai.kilocode.rpc.dto.ConfigUpdateDto
|
||||
import ai.kilocode.rpc.dto.DiffFileDto
|
||||
import ai.kilocode.rpc.dto.MessageWithPartsDto
|
||||
import ai.kilocode.rpc.dto.ModelSelectionDto
|
||||
import ai.kilocode.rpc.dto.PermissionAlwaysRulesDto
|
||||
@@ -99,6 +100,9 @@ interface KiloSessionRpcApi : RemoteApi<Unit> {
|
||||
/** Load message history for a session. */
|
||||
suspend fun messages(id: String, directory: String): List<MessageWithPartsDto>
|
||||
|
||||
/** Load cumulative file changes for a session. */
|
||||
suspend fun diff(id: String, directory: String): List<DiffFileDto>
|
||||
|
||||
/** Load one attachment part from a session without returning full history to the frontend. */
|
||||
suspend fun attachmentPart(id: String, directory: String, messageId: String, partId: String, attachmentKey: String?): PartDto?
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package ai.kilocode.rpc
|
||||
|
||||
import ai.kilocode.rpc.dto.ConfigTargetDto
|
||||
import ai.kilocode.rpc.dto.DiffFileDto
|
||||
import ai.kilocode.rpc.dto.FileSearchResultDto
|
||||
import ai.kilocode.rpc.dto.KiloWorkspaceStateDto
|
||||
import ai.kilocode.rpc.dto.ModelsWorkspaceDto
|
||||
@@ -54,6 +55,17 @@ interface KiloWorkspaceRpcApi : RemoteApi<Unit> {
|
||||
/** Current uncommitted git changes as a unified diff for @git-changes mentions. */
|
||||
suspend fun gitChanges(directory: String): String?
|
||||
|
||||
/**
|
||||
* Committed branch changes compared with the default branch merge-base.
|
||||
*
|
||||
* [patches] = false returns file stats only (additions/deletions/status) and skips materializing
|
||||
* the full patch text — used by the header badge, which only needs counts.
|
||||
*/
|
||||
suspend fun branchDiff(directory: String, patches: Boolean = true): List<DiffFileDto>
|
||||
|
||||
/** Current git branch name for branch-scoped UI labels. */
|
||||
suspend fun branchName(directory: String): String?
|
||||
|
||||
/** Open an absolute backend file path in the IDE. */
|
||||
suspend fun openFile(path: String, line: Int? = null, column: Int? = null): Boolean
|
||||
|
||||
|
||||
@@ -18,6 +18,12 @@ data class MessageDto(
|
||||
val cost: Double? = null,
|
||||
val tokens: TokensDto? = null,
|
||||
val error: MessageErrorDto? = null,
|
||||
val summary: MessageSummaryDto? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MessageSummaryDto(
|
||||
val diffs: List<DiffFileDto> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
@@ -403,6 +409,7 @@ data class DiffFileDto(
|
||||
val additions: Int,
|
||||
val deletions: Int,
|
||||
val patch: String? = null,
|
||||
val status: String? = null,
|
||||
)
|
||||
|
||||
// --- Config Update ---
|
||||
|
||||
Reference in New Issue
Block a user