diff --git a/.changeset/jetbrains-gh-sync-on-focus.md b/.changeset/jetbrains-gh-sync-on-focus.md new file mode 100644 index 000000000000..9ed5564165a9 --- /dev/null +++ b/.changeset/jetbrains-gh-sync-on-focus.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Refresh GitHub state as soon as you return to the IDE or switch between the Chat and Agents tabs, so authorizing gh or merging a pull request elsewhere shows up without waiting for the next poll. diff --git a/.changeset/jetbrains-github-integration-toggle.md b/.changeset/jetbrains-github-integration-toggle.md new file mode 100644 index 000000000000..2fbdc17701eb --- /dev/null +++ b/.changeset/jetbrains-github-integration-toggle.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": minor +--- + +Add an Integrations settings page with a GitHub toggle. Turning it off stops Kilo from running the GitHub CLI, hides pull request badges and pull request import, and can be done straight from the gh warning banner. diff --git a/.changeset/jetbrains-mode-switch-cancels-sessions.md b/.changeset/jetbrains-mode-switch-cancels-sessions.md new file mode 100644 index 000000000000..3a2bdf00306d --- /dev/null +++ b/.changeset/jetbrains-mode-switch-cancels-sessions.md @@ -0,0 +1,9 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Fix switching chat mode cancelling running tasks in every open worktree, and explain any task Kilo stops on its own + +Picking a mode in the chat prompt used to be saved as the CLI's global default, which made the CLI reload and cancel every task that was running anywhere. The mode now stays in the IDE and travels with each message, and it is still remembered for new chats. + +When Kilo does stop a task without being asked — a settings or provider change, for example — the chat now shows why, offers Retry, and raises a notification, instead of quietly reporting "Stopped". diff --git a/.changeset/jetbrains-worktree-setup-script.md b/.changeset/jetbrains-worktree-setup-script.md new file mode 100644 index 000000000000..278b0925a2b6 --- /dev/null +++ b/.changeset/jetbrains-worktree-setup-script.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": minor +--- + +Run the worktree setup script (`.kilo/setup-script`) in a terminal when a new worktree is created, and add actions to open, create, or run it from the worktree row menu. diff --git a/.changeset/jetbrains-worktree-uncommitted-badge.md b/.changeset/jetbrains-worktree-uncommitted-badge.md new file mode 100644 index 000000000000..0e3b8c74316b --- /dev/null +++ b/.changeset/jetbrains-worktree-uncommitted-badge.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": minor +--- + +Show committed changes against the base branch on Agent Manager worktree rows, and split uncommitted changes into their own comparison in the worktree session editor header diff --git a/.changeset/quiet-worktrees-refuse-conflicts.md b/.changeset/quiet-worktrees-refuse-conflicts.md new file mode 100644 index 000000000000..065ceed6cb98 --- /dev/null +++ b/.changeset/quiet-worktrees-refuse-conflicts.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Fix moving a session to a worktree so it reliably transfers all changes, including from a repository subdirectory, and clearly explains that unresolved merge conflicts must be resolved first instead of failing with a confusing git error. diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendActivityManager.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendActivityManager.kt index 24c1752d3cc0..7535438feddb 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendActivityManager.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendActivityManager.kt @@ -46,7 +46,7 @@ class KiloBackendActivityManager( directory: (String) -> String?, chatEvents: SharedFlow, ) { - if (status?.isActive == true || events?.isActive == true) stop() + if (status?.isActive == true || events?.isActive == true) detach() this.statuses = statuses this.directory = directory status = cs.launch { @@ -64,10 +64,7 @@ class KiloBackendActivityManager( } fun stop() { - status?.cancel() - events?.cancel() - status = null - events = null + detach() statuses = null directory = { null } synchronized(lock) { @@ -79,6 +76,37 @@ class KiloBackendActivityManager( log.info("Activity manager stopped") } + /** + * Cancels the collectors without discarding what they recorded. + * + * [start] runs on every reload, including the one a disposal triggers in the same breath as + * cancelling the running turns. Clearing state there would erase the interruption badges that + * disposal just recorded, so an in-place restart keeps them and lets fresh collectors carry on. + * A real teardown still goes through [stop]. + */ + private fun detach() { + status?.cancel() + events?.cancel() + status = null + events = null + } + + /** + * Badge [ids] as having lost a turn nobody asked to stop. + * + * Called directly instead of being driven from [ChatEventDto.SessionInterrupted]: the disposal that + * cancels those turns reloads the app immediately, the reload restarts the event collector, and the + * chat event flow replays nothing — an emission racing that restart can land in the gap where + * nothing is subscribed and be dropped. A direct call is ordered with the disposal that caused it. + */ + fun interrupt(ids: Collection) { + if (ids.isEmpty()) return + synchronized(lock) { + errors.addAll(ids) + recompute() + } + } + private fun handle(event: ChatEventDto) { when (event) { is ChatEventDto.PermissionAsked -> permissions.getOrPut(event.sessionID) { mutableSetOf() }.add(event.request.id) @@ -89,6 +117,8 @@ class KiloBackendActivityManager( // A Stop publishes MessageAbortedError. That is a deliberate user action, not a failure, so // it must not badge the session list, worktree rows, or the Agents tab attention dot. is ChatEventDto.Error -> if (event.error?.aborted != true) event.sessionID?.let { errors.add(it) } + // A cancellation nobody asked for is a failure, but the abort reporting it is + // indistinguishable from a Stop, so that badge arrives through [interrupt] instead. is ChatEventDto.TurnOpen -> errors.remove(event.sessionID) // Not every failure publishes a session error — a turn whose provider ended the response in // error writes the failure onto the message and only reports it through this close reason. The diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt index 519efd34eb55..f14abf46e97b 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt @@ -19,6 +19,7 @@ import ai.kilocode.jetbrains.api.model.KiloNotifications200ResponseInner import ai.kilocode.jetbrains.api.model.KiloProfile200Response import ai.kilocode.jetbrains.api.model.ProviderOauthAuthorizeRequest import ai.kilocode.jetbrains.api.model.ProviderOauthCallbackRequest +import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.ConfigDto import ai.kilocode.rpc.dto.DeviceAuthDto import ai.kilocode.rpc.dto.ConfigPatchDto @@ -826,13 +827,13 @@ class KiloBackendAppService private constructor( } } "global.disposed" -> { - logSessionDisposalRisk("global.disposed") + reportDisposal("global.disposed") log.info("SSE global.disposed — triggering full application reload") val current = _appState.value if (current is KiloAppState.Ready) load() } "server.instance.disposed" -> { - logSessionDisposalRisk("server.instance.disposed") + reportDisposal("server.instance.disposed") log.info("SSE server.instance.disposed — triggering full application reload") val current = _appState.value if (current is KiloAppState.Ready) load() @@ -843,10 +844,22 @@ class KiloBackendAppService private constructor( } } - private fun logSessionDisposalRisk(event: String) { + /** + * Warn, and tell every running session that the CLI is about to cancel it. + * + * Disposing an instance cancels every runner it owns, and the CLI reports that as the same + * `MessageAbortedError` a user Stop produces. Naming the cause here is the only way the UI can + * tell the difference and explain itself instead of quietly reporting "Stopped". + */ + private fun reportDisposal(event: String) { val active = sessions.statuses.value.filterValues { it.type != "idle" } if (active.isEmpty()) return log.warn("SSE $event while sessions are active; sessions may be cancelled count=${active.size} statuses=${active.values.map { it.type }.distinct()}") + // Badged here rather than off the event below, because the reload that follows this restarts the + // activity collector and the event could be dropped in the gap. Both still matter: the badge + // marks the worktree row, the event lets the open session name its own reason. + activity.interrupt(active.keys) + chat.interrupt(active.keys, ChatEventDto.SessionInterrupted.RELOAD) } private suspend fun clear() { diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendChatManager.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendChatManager.kt index 4ebd49744e29..43833a6d6f65 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendChatManager.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendChatManager.kt @@ -4,7 +4,6 @@ import ai.kilocode.backend.cli.KiloCliDataParser import ai.kilocode.log.ChatLogSummary import ai.kilocode.log.KiloLog import ai.kilocode.rpc.dto.ChatEventDto -import ai.kilocode.rpc.dto.ConfigUpdateDto import ai.kilocode.rpc.dto.MessageWithPartsDto import ai.kilocode.rpc.dto.ModelSelectionDto import ai.kilocode.rpc.dto.PermissionAlwaysRulesDto @@ -327,25 +326,22 @@ class KiloBackendChatManager( } } - // ------ config update ------ - - fun updateConfig(dir: String, update: ConfigUpdateDto) { - val http = requireClient() - val url = requireBase() - - val partial = KiloCliDataParser.buildConfigPartial(update) - - val request = Request.Builder() - .url("$url/global/config") - .patch(partial.toRequestBody(JSON_TYPE)) - .build() - - http.newCall(request).execute().use { response -> - if (!response.isSuccessful) { - val msg = response.body?.string() ?: "unknown error" - log.warn("config update failed: HTTP ${response.code} — $msg") - } else { - log.info("Config updated: model=${update.model}, agent=${update.agent}, temp=${update.temperature}") + // ------ interruption ------ + + /** + * Tell every session in [ids] that the CLI stopped its turn for [reason]. + * + * Synthesized into the same stream the CLI events use so a session's own controller sees it in + * order with the abort it explains. The CLI cannot express this itself: it reports a server-side + * cancellation as the same `MessageAbortedError` a user Stop produces, so the UI would otherwise + * report work nobody stopped as "Stopped". + */ + fun interrupt(ids: Collection, reason: String) { + if (ids.isEmpty()) return + cs.launch { + for (id in ids) { + log.warn("${ChatLogSummary.sid(id)} kind=interrupt route=chat-events reason=$reason") + _events.emit(ChatEventDto.SessionInterrupted(id, reason)) } } } diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt index 1e401e45b5cd..5f82ef59fe87 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt @@ -20,7 +20,6 @@ import ai.kilocode.rpc.dto.CommandDto import ai.kilocode.rpc.dto.CommandFileDto import ai.kilocode.rpc.dto.ConfigDto import ai.kilocode.rpc.dto.ConfigPatchDto -import ai.kilocode.rpc.dto.ConfigUpdateDto import ai.kilocode.rpc.dto.CompactionConfigDto import ai.kilocode.rpc.dto.CustomModelDto import ai.kilocode.rpc.dto.CustomProviderConfigDto @@ -915,31 +914,6 @@ object KiloCliDataParser { return "{${fields.joinToString(",")}}" } - /** - * Build the partial JSON body for `PATCH /global/config`. - */ - fun buildConfigPartial(update: ConfigUpdateDto): String { - val sb = StringBuilder("{") - var first = true - fun sep() { if (!first) sb.append(","); first = false } - - val model = update.model - if (model != null) { - sep(); sb.append(""""model":${escape(model)}""") - } - val agent = update.agent - if (agent != null) { - sep(); sb.append(""""default_agent":${escape(agent)}""") - } - val temp = update.temperature - if (temp != null) { - val target = agent ?: "ask" - sep(); sb.append(""""agent":{"$target":{"temperature":$temp}}""") - } - sb.append("}") - return sb.toString() - } - fun buildConfigPatch(patch: ConfigPatchDto): String { val allowed = setOf("model", "small_model", "subagent_model", "subagent_variant", "default_agent") val obj = buildJsonObject { diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/diff/GitComparison.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/diff/GitComparison.kt new file mode 100644 index 000000000000..85e3492132d1 --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/diff/GitComparison.kt @@ -0,0 +1,249 @@ +package ai.kilocode.backend.diff + +import ai.kilocode.backend.rpc.CmdOut +import ai.kilocode.backend.rpc.baseBranch +import ai.kilocode.backend.rpc.parseWorktreeList +import ai.kilocode.rpc.dto.DiffFileDto +import com.intellij.execution.configurations.GeneralCommandLine +import com.intellij.execution.process.CapturingProcessHandler +import com.intellij.openapi.progress.ProcessCanceledException +import kotlinx.coroutines.CancellationException +import java.nio.charset.StandardCharsets +import java.nio.file.Files +import java.nio.file.LinkOption +import java.nio.file.Path +import kotlin.io.path.fileSize +import kotlin.io.path.inputStream + +internal class GitComparison private constructor( + private val dir: Path, + private val head: String, + val base: String, + private val tip: String, + private val from: String, + private val to: String?, +) { + enum class Mode { Base, Local } + + companion object { + private const val CAP = 8 * 1024 * 1024 + private const val LARGE = 2 * 1024 * 1024L + private val CANDIDATES = listOf( + "refs/remotes/origin/main", + "refs/remotes/origin/master", + "refs/heads/main", + "refs/heads/master", + ) + + fun open(dir: Path, mode: Mode, fallback: String? = null): GitComparison? { + if (!Files.isDirectory(dir)) return null + val inside = runGitCommand(dir, listOf("rev-parse", "--is-inside-work-tree")) + if (inside.exit == 128 && inside.stderr.startsWith("fatal: not a git repository")) return null + if (inside.checked().trim() != "true") return null + val head = revision(dir, "HEAD") ?: return null + if (mode == Mode.Local) return GitComparison(dir, head, "", head, head, null) + val declared = git(dir, "symbolic-ref", "--quiet", "refs/remotes/origin/HEAD", optional = true).trim() + val selected = (listOf(declared).filter { it.isNotEmpty() } + CANDIDATES) + .firstNotNullOfOrNull { ref -> revision(dir, ref)?.let { ref to it } } + val ref = selected?.first ?: (fallback + ?: baseBranch(parseWorktreeList(git(dir, "worktree", "list", "--porcelain")))) + ?.takeIf { it != "HEAD" }?.let { "refs/heads/$it" } ?: "HEAD" + val tip = selected?.second ?: if (ref == "HEAD") head else revision(dir, ref) ?: head + val ancestor = git(dir, "merge-base", tip, head, optional = true).trim().ifEmpty { tip } + val label = ref.removePrefix("refs/remotes/").removePrefix("refs/heads/") + return GitComparison(dir, head, label, tip, ancestor, head) + } + + private fun revision(dir: Path, ref: String): String? = + git(dir, "rev-parse", "--verify", "--quiet", "$ref^{commit}", optional = true).trim().ifEmpty { null } + + private fun git(dir: Path, vararg args: String, optional: Boolean = false): String { + val out = runGitCommand(dir, args.toList()) + if (optional && out.exit == 1 && out.stderr.isBlank()) return "" + return out.checked() + } + } + + fun files(patches: Boolean): List { + val stats = parseNumstat(diff("--numstat", "-z")).associateBy { it.path } + val status = parseNameStatus(diff("--name-status", "-z")) + val others = if (to != null) emptyList() else { + git(dir, "ls-files", "--others", "--exclude-standard", "-z").split('\u0000').filter { it.isNotEmpty() } + } + val files = stats.values.map { DiffFileDto(it.path, it.additions, it.deletions, "", status[it.path] ?: "modified") } + + others.map { untracked(it, false) } + if (!patches) return files + return capDiff(files, CAP) { file, budget -> + if (file.status == "untracked") untracked(file.file, true) + else detail(file, stats.getValue(file.file), budget) + } + } + + fun counts(): Pair { + val parts = git(dir, "rev-list", "--left-right", "--count", "$tip...$head").trim().split(Regex("\\s+")) + return parts[0].toInt() to parts[1].toInt() + } + + fun unpushed(): Int { + val out = runGitCommand(dir, listOf("rev-list", "--count", "@{upstream}..$head")) + if (!out.ok) return 0 + return out.stdout.trim().toIntOrNull() ?: 0 + } + + private fun diff(vararg args: String, paths: List = emptyList()): String { + val revs = if (to == null) listOf(from) else listOf(from, to) + val opts = listOf( + "--literal-pathspecs", "-c", "core.quotepath=false", "diff", "--relative", "--no-color", + "--no-ext-diff", "--no-textconv", "--find-renames", "--rename-empty", + ) + args + revs + listOf("--") + paths + return runGitCommand(dir, opts).checked() + } + + private fun detail(file: DiffFileDto, stat: DiffStat, budget: Int): DiffFileDto? { + val paths = listOfNotNull(stat.old, stat.path).distinct() + val patch = diff("--unified=2147483647", paths = paths) + if (patch.length > budget) return null + if (stat.binary || patch.lineSequence().any { it.startsWith("@@ ") || it.startsWith("Binary files ") }) { + return file.copy(patch = patch) + } + val old = "$from:./${stat.old ?: stat.path}" + val next = to?.let { "$it:./${stat.path}" } + val before = if (file.status == "added") 0L else git(dir, "cat-file", "-s", old).trim().toLong() + val path = dir.resolve(stat.path).normalize() + val after = when { + file.status == "deleted" -> 0L + next != null -> git(dir, "cat-file", "-s", next).trim().toLong() + Files.isSymbolicLink(path) -> Files.readSymbolicLink(path).toString().length.toLong() + else -> path.fileSize() + } + if (before + after + patch.length > budget) return null + val left = if (file.status == "added") "" else git(dir, "show", old) + val right = when { + file.status == "deleted" -> "" + next != null -> git(dir, "show", next) + Files.isSymbolicLink(path) -> Files.readSymbolicLink(path).toString() + else -> Files.readString(path) + } + if ('\u0000' in left || '\u0000' in right) return file.copy(patch = patch) + return file.copy(patch = patch, before = left, after = right) + } + + private fun untracked(rel: String, patches: Boolean): DiffFileDto { + val file = DiffFileDto(rel, 0, 0, "", "untracked") + val path = dir.resolve(rel).normalize() + if (!path.startsWith(dir) || !Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS) || path.fileSize() > LARGE) return file + if (!patches) return file.copy(additions = countLines(path) ?: 0) + val bytes = path.inputStream().use { it.readNBytes(LARGE.toInt() + 1) } + if (bytes.size > LARGE || bytes.any { it == 0.toByte() }) return file + val text = bytes.toString(StandardCharsets.UTF_8) + val additions = lines(text).size + return file.copy(additions = additions, patch = untrackedPatch(rel, text, additions)) + } +} + +internal fun runGitCommand(dir: Path, args: List): CmdOut { + return try { + val cmd = GeneralCommandLine(listOf("git") + args).withWorkDirectory(dir.toFile()) + .withCharset(StandardCharsets.UTF_8).withEnvironment("LC_ALL", "C") + val out = CapturingProcessHandler(cmd).runProcess(30_000) + CmdOut(if (out.isTimeout) -1 else out.exitCode, out.stdout, out.stderr) + } catch (err: CancellationException) { + throw err + } catch (err: ProcessCanceledException) { + throw err + } catch (err: Exception) { + CmdOut(-1, "", err.message ?: "git failed") + } +} + +private fun CmdOut.checked(): String { + check(ok) { "Git comparison failed (exit=$exit): ${stderr.trim()}" } + return stdout +} + +internal fun capDiff(files: List, cap: Int, fetch: (DiffFileDto, Int) -> DiffFileDto?): List { + var used = 0 + var misses = 0 + var full = false + return files.map { file -> + if (full) return@map file + val data = fetch(file, cap - used) + val size = data?.let { it.patch.orEmpty().length + it.before.orEmpty().length + it.after.orEmpty().length } + if (data != null && size != null && size <= cap - used) { + used += size + if (used >= cap) full = true + return@map data + } + misses++ + full = misses >= 3 + file + } +} + +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 { + if (text.isEmpty()) return emptyList() + return text.removeSuffix("\n").split('\n') +} + +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, val old: String? = null, val binary: Boolean = false) + +internal fun parseNameStatus(text: String): Map = buildMap { + val tokens = text.split('\u0000').iterator() + while (tokens.hasNext()) { + val code = tokens.next() + if (code.isEmpty()) continue + val first = tokens.next() + val path = if (code.startsWith('R') || code.startsWith('C')) tokens.next() else first + val status = when (code.first()) { + 'A' -> "added" + 'D' -> "deleted" + 'R' -> "renamed" + else -> "modified" + } + put(path, status) + } +} + +internal fun parseNumstat(text: String): List = buildList { + val tokens = text.split('\u0000').iterator() + while (tokens.hasNext()) { + val token = tokens.next() + if (token.isEmpty()) continue + val parts = token.split('\t', limit = 3) + val old = if (parts[2].isEmpty()) tokens.next() else null + val path = if (old != null) tokens.next() else parts[2] + add(DiffStat(path, parts[0].toIntOrNull() ?: 0, parts[1].toIntOrNull() ?: 0, old, parts[0] == "-")) + } +} diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt index 49764a2e2c12..80b21f39f1ec 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt @@ -11,7 +11,6 @@ import ai.kilocode.log.ChatLogSummary 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 @@ -286,9 +285,6 @@ class KiloSessionRpcApiImpl internal constructor( log.warn("${ChatLogSummary.sid(id)} kind=subscription route=rpc-events stop=true failed message=${cause.message}", cause) } - override suspend fun updateConfig(directory: String, config: ConfigUpdateDto) = - ready { chat.updateConfig(directory, config) } - // ------ permission / question resolution ------ override suspend fun replyPermission(requestId: String, directory: String, reply: PermissionReplyDto) { diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt index 15bfc0664279..dad362cf92de 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt @@ -6,6 +6,7 @@ import ai.kilocode.backend.app.LoadError import ai.kilocode.backend.cli.KiloCliDataParser import ai.kilocode.backend.cli.buildKiloCliEnv import ai.kilocode.backend.cli.KiloCliConfigPath +import ai.kilocode.backend.diff.GitComparison import ai.kilocode.backend.workspace.AgentData import ai.kilocode.backend.workspace.AgentInfo import ai.kilocode.backend.workspace.KiloBackendWorkspaceManager @@ -20,6 +21,8 @@ import ai.kilocode.rpc.dto.FileSearchResultDto import ai.kilocode.rpc.dto.KiloWorkspaceStateDto import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto import ai.kilocode.rpc.dto.ModelsWorkspaceDto +import ai.kilocode.rpc.dto.SetupScriptKind +import ai.kilocode.rpc.dto.SetupScriptTargetDto import ai.kilocode.rpc.dto.WorkspaceFileDto import com.intellij.execution.configurations.GeneralCommandLine import com.intellij.execution.process.CapturingProcessHandler @@ -31,6 +34,7 @@ import com.intellij.openapi.fileEditor.FileEditorManager import com.intellij.openapi.fileEditor.OpenFileDescriptor import com.intellij.openapi.project.Project import com.intellij.openapi.project.ProjectManager +import com.intellij.openapi.util.SystemInfo import com.intellij.openapi.util.io.FileUtil import com.intellij.openapi.vfs.VirtualFile import com.intellij.openapi.vfs.LocalFileSystem @@ -58,10 +62,6 @@ 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 @@ -83,8 +83,6 @@ 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 BRANCH_DIFF_CAP = 8 * 1024 * 1024 - private const val LARGE_FILE = 2 * 1024 * 1024L private val JSON = Json { ignoreUnknownKeys = true } private val CONFIG = """{ "${'$'}schema": "$SCHEMA" @@ -258,28 +256,13 @@ class KiloWorkspaceRpcApiImpl internal constructor( } override suspend fun branchDiff(directory: String, patches: Boolean): List = 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 BRANCH_DIFF_CAP, so a branch with - // hundreds of changed files doesn't spawn a git subprocess (or read a file) per entry. - capDiff(files, BRANCH_DIFF_CAP) { file -> - if (file.status == "untracked") untracked(base, file.file, withPatch = true).patch.orEmpty() - else fileDiff(base, anc, file.file) - } + val dir = file(clean(directory) ?: directory) ?: return@withContext emptyList() + GitComparison.open(dir, GitComparison.Mode.Base)?.files(patches).orEmpty() + } + + override suspend fun localDiff(directory: String, patches: Boolean): List = withContext(Dispatchers.IO) { + val dir = file(clean(directory) ?: directory) ?: return@withContext emptyList() + GitComparison.open(dir, GitComparison.Mode.Local)?.files(patches).orEmpty() } override suspend fun branchName(directory: String): String? = withContext(Dispatchers.IO) { @@ -325,10 +308,20 @@ class KiloWorkspaceRpcApiImpl internal constructor( globalConfig() }) - private suspend fun openConfig(path: Path): Boolean { + override suspend fun setupScriptTarget(directory: String): SetupScriptTargetDto = withContext(Dispatchers.IO) { + resolveSetupScript(repoRoot(directory), SystemInfo.isWindows) + } + + override suspend fun openSetupScript(directory: String): Boolean { + val resolved = withContext(Dispatchers.IO) { resolveSetupScript(repoRoot(directory), SystemInfo.isWindows) } + val content = if (resolved.kind == SetupScriptKind.POWERSHELL) SetupScriptTemplate.POWERSHELL else SetupScriptTemplate.POSIX + return openConfig(Path.of(resolved.path), content) + } + + private suspend fun openConfig(path: Path, content: String = CONFIG): Boolean { val target = withContext(Dispatchers.IO) { Files.createDirectories(path.parent) - if (!Files.exists(path)) Files.writeString(path, CONFIG, StandardCharsets.UTF_8) + if (!Files.exists(path)) Files.writeString(path, content, StandardCharsets.UTF_8) path } val vf = LocalFileSystem.getInstance().refreshAndFindFileByPath(target.toString()) ?: return false @@ -340,8 +333,11 @@ class KiloWorkspaceRpcApiImpl internal constructor( return true } + private fun repoRoot(directory: String): Path = + file(clean(directory) ?: directory)?.takeIf { it.isAbsolute } ?: Path.of(directory).normalize() + private fun localConfig(directory: String): Path { - val root = file(clean(directory) ?: directory)?.takeIf { it.isAbsolute } ?: Path.of(directory).normalize() + val root = repoRoot(directory) val dirs = LOCAL_DIRS.map { root.resolve(it) } + root val found = dirs.asSequence() .flatMap { dir -> (MODERN + LEGACY).asSequence().map { name -> dir.resolve(name) } } @@ -428,55 +424,6 @@ 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, @@ -533,6 +480,35 @@ internal fun normalizeWorkspacePath(path: String): String? { } } +// Candidate names in the .kilo/ directory, in resolution order. Disjoint by design: a POSIX script is +// never resolved on Windows and vice versa, matching the VS Code extension. +private val SETUP_POSIX_CANDIDATES = listOf( + "setup-script" to SetupScriptKind.POSIX, + "setup-script.sh" to SetupScriptKind.POSIX, +) +private val SETUP_WINDOWS_CANDIDATES = listOf( + "setup-script.ps1" to SetupScriptKind.POWERSHELL, + "setup-script.cmd" to SetupScriptKind.CMD, + "setup-script.bat" to SetupScriptKind.CMD, +) +private val SETUP_DEFAULT_POSIX = "setup-script" to SetupScriptKind.POSIX +private val SETUP_DEFAULT_WINDOWS = "setup-script.ps1" to SetupScriptKind.POWERSHELL + +/** + * Resolves the worktree setup script in `/.kilo/` for the given platform. POSIX and Windows + * candidate lists are disjoint (a POSIX script is never resolved on Windows and vice versa); the + * first existing candidate wins, otherwise the platform default path is returned with `exists = false`. + * Pure and unit-testable without touching [SystemInfo]. + */ +internal fun resolveSetupScript(root: Path, windows: Boolean): SetupScriptTargetDto { + val dir = root.resolve(".kilo") + val candidates = if (windows) SETUP_WINDOWS_CANDIDATES else SETUP_POSIX_CANDIDATES + val found = candidates.firstOrNull { (name, _) -> Files.exists(dir.resolve(name)) } + val (name, kind) = found ?: (if (windows) SETUP_DEFAULT_WINDOWS else SETUP_DEFAULT_POSIX) + val raw = dir.resolve(name).toString() + return SetupScriptTargetDto(raw, FileUtil.getLocationRelativeToUserHome(raw, false), found != null, kind) +} + internal fun resolveProjectDirectoryHint(hint: String, bases: List): String { val clean = normalizeWorkspacePath(hint) val match = bases.firstOrNull { base -> @@ -544,106 +520,6 @@ internal fun resolveProjectDirectoryHint(hint: String, bases: List): 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, cap: Int, fetch: (DiffFileDto) -> String): List { - 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 { - 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 = 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 = 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 = ConcurrentHashMap()): Boolean { if (Files.exists(base.resolve(".git"))) return true return cache.getOrPut(base.toString()) { diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImpl.kt index 50f0fc1457b8..3ce1288a7dfd 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImpl.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImpl.kt @@ -1,6 +1,8 @@ package ai.kilocode.backend.rpc import ai.kilocode.backend.app.KiloBackendAppService +import ai.kilocode.backend.diff.GitComparison +import ai.kilocode.backend.diff.runGitCommand import ai.kilocode.log.KiloLog import ai.kilocode.rpc.KiloWorktreeRpcApi import ai.kilocode.rpc.parsePrUrl @@ -14,6 +16,8 @@ import ai.kilocode.rpc.dto.MoveStage import ai.kilocode.rpc.dto.RemoveWorktreeResultDto import ai.kilocode.rpc.dto.RenameWorktreeResultDto import ai.kilocode.rpc.dto.WorktreeBranchesDto +import ai.kilocode.rpc.dto.WorktreeDirtyDto +import ai.kilocode.rpc.dto.WorktreeDirtyListDto import ai.kilocode.rpc.dto.WorktreeDto import ai.kilocode.rpc.dto.WorktreeListDto import ai.kilocode.rpc.dto.WorktreePrDto @@ -56,21 +60,16 @@ import java.nio.file.Files import java.nio.file.Path import java.nio.file.StandardCopyOption import java.util.concurrent.ConcurrentHashMap -import kotlin.io.path.fileSize -import kotlin.io.path.inputStream -import kotlin.io.path.isRegularFile class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi { companion object { internal val LOG = KiloLog.create(KiloWorktreeRpcApiImpl::class.java) - private const val BASE_TTL = 60_000L private const val GH_PROBE_TTL = 300_000L private const val GH_STATUS_TTL = 3_000L private const val PR_TTL = 90_000L } - private val bases = ConcurrentHashMap>() private val prs = ConcurrentHashMap>() private val branches = ConcurrentHashMap>() private val resolver = PrResolver(gh = ::runGh, git = ::runGit) @@ -157,6 +156,12 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi { WorktreeStatsListDto(parallel(items.filter { !it.main }) { item -> stats(item, fallback) }) } + override suspend fun dirty(directory: String): WorktreeDirtyListDto = withContext(Dispatchers.IO) { + val root = Path.of(directory).normalize() + val items = sync(root) ?: return@withContext WorktreeDirtyListDto() + WorktreeDirtyListDto(parallel(items.filter { !it.main }) { item -> dirty(item) }) + } + /** * Lists the managed worktrees of [root] after reconciling git's metadata with the disk, so * callers never probe a directory that no longer exists. Returns null when [root] itself is gone @@ -189,8 +194,8 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi { return synced.filter { Files.isDirectory(Path.of(it.path)) } } - override suspend fun ghStatus(directory: String): GhAvailability = withContext(Dispatchers.IO) { - probeGh(Path.of(directory).normalize(), "rpc") + override suspend fun ghStatus(directory: String, github: Boolean): GhAvailability = withContext(Dispatchers.IO) { + probeGh(Path.of(directory).normalize(), "rpc", github) } override suspend fun prStatus(directory: String): WorktreePrListDto = withContext(Dispatchers.IO) { @@ -224,9 +229,13 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi { dto } - override suspend fun branchStatus(directory: String): BranchStatusDto = withContext(Dispatchers.IO) { + override suspend fun branchStatus(directory: String, github: Boolean): BranchStatusDto = withContext(Dispatchers.IO) { val now = System.currentTimeMillis() - branches[directory]?.takeIf { now - it.time < PR_TTL }?.let { return@withContext it.value } + // Keyed by directory + mode so flipping the GitHub integration setting cannot serve a + // cross-mode entry (a disabled-mode entry always has a null pr; an enabled-mode entry may + // not) for up to PR_TTL after the flip. + val key = "$directory|$github" + branches[key]?.takeIf { now - it.time < PR_TTL }?.let { return@withContext it.value } val root = Path.of(directory).normalize() if (!Files.isDirectory(root)) { LOG.info("branch status skipped, directory does not exist: $root") @@ -234,8 +243,8 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi { } val branch = runGit(root, "branch", "--show-current").stdout.trim() val worktree = isLinkedWorktree(root) - val availability = ghAvailable(root) - val lookup = if (availability == GhAvailability.OK && branch.isNotBlank()) { + val availability = ghAvailable(root, github) + val lookup = if (github && availability == GhAvailability.OK && branch.isNotBlank()) { resolver.resolve(directory, branch, baseBranch(root)) } else { PrLookup() @@ -247,7 +256,7 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi { availability = if (availability == GhAvailability.OK) lookup.availability else availability, pr = lookup.pr, ) - branches[directory] = Timed(System.currentTimeMillis(), dto) + branches[key] = Timed(System.currentTimeMillis(), dto) dto } @@ -278,7 +287,7 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi { leftover = worktree val target = Path.of(worktree.path).normalize() emit(MoveProgressDto(MoveStage.TRANSFERRING)) - val applied = withContext(Dispatchers.IO) { WorktreeTransfer.apply(captured, base, target) } + val applied = withContext(Dispatchers.IO) { WorktreeTransfer.apply(captured, target) } if (!applied.ok) { leftover = null rollback(directory, worktree, branch, "transfer failure") @@ -583,15 +592,7 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi { private fun runGit(base: Path, vararg args: String): CmdOut = runGit(base, args.toList()) - private fun runGit(base: Path, args: List): CmdOut { - return try { - val cmd = GeneralCommandLine(listOf("git") + args).withWorkDirectory(base.toFile()) - val out = CapturingProcessHandler(cmd).runProcess(30_000) - CmdOut(if (out.isTimeout) -1 else out.exitCode, out.stdout, out.stderr) - } catch (e: Exception) { - CmdOut(-1, "", e.message ?: "git failed") - } - } + private fun runGit(base: Path, args: List): CmdOut = runGitCommand(base, args) private fun runGh(base: Path, vararg args: String): CmdOut = runGh(base, args.toList()) @@ -628,56 +629,47 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi { private fun stats(item: WorktreeDto, fallback: String): WorktreeStatsDto { val dir = Path.of(item.path).normalize() - return runCatching { - val base = base(item, fallback) - val anc = runGit(dir, "merge-base", "HEAD", base).stdout.trim().takeIf { it.isNotBlank() } ?: base - val diff = runGit(dir, "-c", "core.quotepath=false", "diff", "--numstat", "--no-renames", anc) - val tracked = if (diff.ok) parseNumstat(diff.stdout) else emptyList() - val untrackedFiles = runGit(dir, "-c", "core.quotepath=false", "ls-files", "--others", "--exclude-standard") - .stdout - .lineSequence() - .filter { it.isNotBlank() } - .toList() - val untracked = untrackedFiles.sumOf { countUntracked(dir, it) } - val counts = aheadBehind(dir, base) - WorktreeStatsDto( - item.path, - tracked.sumOf { it.additions } + untracked, - tracked.sumOf { it.deletions }, - counts.second, - counts.first, - files = tracked.size + untrackedFiles.size, - ) - }.getOrElse { err -> - LOG.warn("worktree stats failed: path=${item.path} message=${err.message}", err) - WorktreeStatsDto(item.path) - } + val comparison = GitComparison.open(dir, GitComparison.Mode.Base, fallback) ?: return WorktreeStatsDto(item.path) + val files = comparison.files(false) + val counts = comparison.counts() + return WorktreeStatsDto( + item.path, + additions = files.sumOf { it.additions }, + deletions = files.sumOf { it.deletions }, + ahead = counts.second, + behind = counts.first, + files = files.size, + base = comparison.base, + ) } - private fun base(item: WorktreeDto, fallback: String): String { - val now = System.currentTimeMillis() - bases[item.path]?.takeIf { now - it.time < BASE_TTL }?.let { return it.value } + private fun dirty(item: WorktreeDto): WorktreeDirtyDto { val dir = Path.of(item.path).normalize() - val upstream = runGit(dir, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}") - val value = upstream.stdout.trim().takeIf { upstream.ok && it.isNotBlank() } ?: fallback - bases[item.path] = Timed(now, value) - return value - } - - private fun aheadBehind(dir: Path, base: String): Pair { - val out = runGit(dir, "rev-list", "--left-right", "--count", "$base...HEAD") - if (!out.ok) return 0 to 0 - val parts = out.stdout.trim().split(Regex("\\s+")) - return (parts.getOrNull(0)?.toIntOrNull() ?: 0) to (parts.getOrNull(1)?.toIntOrNull() ?: 0) + val comparison = GitComparison.open(dir, GitComparison.Mode.Local) ?: return WorktreeDirtyDto(item.path) + val files = comparison.files(false) + return WorktreeDirtyDto( + item.path, + additions = files.sumOf { it.additions }, + deletions = files.sumOf { it.deletions }, + files = files.size, + untracked = files.count { it.status == "untracked" }, + unpushed = comparison.unpushed(), + ) } - private fun ghAvailable(root: Path): GhAvailability { + /** + * Resolves git/gh availability. When [github] is false, only git presence is checked and `gh` + * is never spawned — used while the user has turned off the GitHub integration setting. + */ + private fun ghAvailable(root: Path, github: Boolean = true): GhAvailability { if (!Files.isDirectory(root)) { LOG.info("gh availability skipped dir=$root missing=true") return GhAvailability.OK } - val status = probeGh(root, "availability") - if (status != GhAvailability.MISSING) return status + val status = probeGh(root, "availability", github) + // A git-only probe can only return GIT_MISSING or OK, so the gh-binary re-check below is + // unreachable while github is false; the guard documents that explicitly. + if (!github || status != GhAvailability.MISSING) return status val now = System.currentTimeMillis() ghProbe?.takeIf { now - it.time < GH_PROBE_TTL }?.let { return it.value } val res = runGh(root, "--version") @@ -686,7 +678,12 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi { return value } - private fun probeGh(root: Path, reason: String): GhAvailability = synchronized(ghLock) { + /** + * Resolves git/gh availability. When [github] is false, resolves git presence only and never + * spawns `gh` or touches [ghCache] (the cache mixes gh-auth verdicts with a boolean this probe + * would otherwise poison with a git-only "OK"). + */ + private fun probeGh(root: Path, reason: String, github: Boolean = true): GhAvailability = synchronized(ghLock) { // A stale/removed worktree directory makes the process spawn fail, which would be // misreported as GIT_MISSING. Treat a missing directory as "nothing to report" and // don't cache it, so the next probe on a real directory still runs. @@ -695,12 +692,14 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi { return@synchronized GhAvailability.OK } val now = System.currentTimeMillis() - ghCache?.takeIf { now - it.time < GH_STATUS_TTL }?.let { - LOG.info("gh probe cache hit reason=$reason value=${it.value}") - return@synchronized it.value + if (github) { + ghCache?.takeIf { now - it.time < GH_STATUS_TTL }?.let { + LOG.info("gh probe cache hit reason=$reason value=${it.value}") + return@synchronized it.value + } } val start = System.currentTimeMillis() - LOG.info("gh probe start reason=$reason dir=$root") + LOG.info("gh probe start reason=$reason dir=$root github=$github") val git = runGit(root, "--version") if (!git.ok) { // The directory can disappear between the check above and the spawn; a failed working @@ -710,10 +709,14 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi { return@synchronized GhAvailability.OK } val value = GhAvailability.GIT_MISSING - ghCache = Timed(System.currentTimeMillis(), value) + if (github) ghCache = Timed(System.currentTimeMillis(), value) LOG.info("gh probe result reason=$reason value=$value exit=${git.exit} ms=${System.currentTimeMillis() - start} stderr=${snippet(git.stderr)}") return@synchronized value } + if (!github) { + LOG.info("gh probe result reason=$reason value=OK github=false ms=${System.currentTimeMillis() - start}") + return@synchronized GhAvailability.OK + } val res = runGh(root, "auth", "status") val value = if (res.ok) GhAvailability.OK else classifyGhError(res.stderr.ifBlank { res.stdout }) ghCache = Timed(System.currentTimeMillis(), value) @@ -1050,36 +1053,3 @@ private fun realPath(path: String): Path { val file = Path.of(path).normalize() return if (Files.exists(file)) file.toRealPath() else file } - -private fun countUntracked(base: Path, rel: String): Int { - return runCatching { - val path = base.resolve(rel).normalize() - if (!path.startsWith(base) || !path.isRegularFile() || path.fileSize() > 2 * 1024 * 1024L) return@runCatching 0 - countLines(path) ?: 0 - }.getOrElse { err -> - KiloWorktreeRpcApiImpl.LOG.debug { "worktree stats untracked read failed: path=$rel message=${err.message}" } - 0 - } -} - -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 -} diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/SetupScriptTemplate.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/SetupScriptTemplate.kt new file mode 100644 index 000000000000..d7f02cf5cf1a --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/SetupScriptTemplate.kt @@ -0,0 +1,99 @@ +package ai.kilocode.backend.rpc + +/** + * Default templates for the worktree setup script (`.kilo/setup-script`), ported verbatim from the + * VS Code extension (`packages/kilo-vscode/src/agent-manager/setup-script-template.ts`) so a repo + * behaves the same regardless of which IDE created the file. + */ +internal object SetupScriptTemplate { + /** Default POSIX template for worktree setup scripts. */ + val POSIX = """#!/bin/sh +# Kilo Code Worktree Setup Script +# This script runs before the agent starts in a worktree (new sessions only). +# +# Available environment variables: +# WORKTREE_PATH - Absolute path to the worktree directory +# REPO_PATH - Absolute path to the main repository +# +# Kilo already copies root-level .env and .env.* files before this script runs. +# Use this script for dependencies, nested env files, local config, databases, +# certificates, or other project-specific setup that is not committed to git. +# +# Example tasks: +# - Copy nested env files from main repo +# - Install dependencies +# - Run database migrations +# - Set up local configuration + +set -e # Exit on error + +echo "Setting up worktree: ${'$'}WORKTREE_PATH" + +# Uncomment and modify as needed: + +# Copy a nested environment file +# if [ -f "${'$'}REPO_PATH/apps/web/.env.local" ] && [ ! -f "${'$'}WORKTREE_PATH/apps/web/.env.local" ]; then +# cp "${'$'}REPO_PATH/apps/web/.env.local" "${'$'}WORKTREE_PATH/apps/web/.env.local" +# echo "Copied apps/web/.env.local" +# fi + +# Install dependencies (Node.js) +# if [ -f "${'$'}WORKTREE_PATH/package.json" ]; then +# cd "${'$'}WORKTREE_PATH" +# npm install +# fi + +# Install dependencies (Python) +# if [ -f "${'$'}WORKTREE_PATH/requirements.txt" ]; then +# cd "${'$'}WORKTREE_PATH" +# pip install -r requirements.txt +# fi + +echo "Setup complete!" +""" + + /** Default PowerShell template for worktree setup scripts on Windows. */ + val POWERSHELL = """# Kilo Code Worktree Setup Script +# This script runs before the agent starts in a worktree (new sessions only). +# +# Available environment variables: +# ${'$'}env:WORKTREE_PATH - Absolute path to the worktree directory +# ${'$'}env:REPO_PATH - Absolute path to the main repository +# +# Kilo already copies root-level .env and .env.* files before this script runs. +# Use this script for dependencies, nested env files, local config, databases, +# certificates, or other project-specific setup that is not committed to git. +# +# Example tasks: +# - Copy nested env files from main repo +# - Install dependencies +# - Run database migrations +# - Set up local configuration + +${'$'}ErrorActionPreference = "Stop" + +Write-Host "Setting up worktree: ${'$'}env:WORKTREE_PATH" + +# Uncomment and modify as needed: + +# Copy a nested environment file +# if ((Test-Path "${'$'}env:REPO_PATH/apps/web/.env.local") -and !(Test-Path "${'$'}env:WORKTREE_PATH/apps/web/.env.local")) { +# Copy-Item "${'$'}env:REPO_PATH/apps/web/.env.local" "${'$'}env:WORKTREE_PATH/apps/web/.env.local" +# Write-Host "Copied apps/web/.env.local" +# } + +# Install dependencies (Node.js) +# if (Test-Path "${'$'}env:WORKTREE_PATH/package.json") { +# Set-Location "${'$'}env:WORKTREE_PATH" +# npm install +# } + +# Install dependencies (Python) +# if (Test-Path "${'$'}env:WORKTREE_PATH/requirements.txt") { +# Set-Location "${'$'}env:WORKTREE_PATH" +# pip install -r requirements.txt +# } + +Write-Host "Setup complete!" +""" +} diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/WorktreeTransfer.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/WorktreeTransfer.kt index d7b2fa39c32e..4845923a9107 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/WorktreeTransfer.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/WorktreeTransfer.kt @@ -6,6 +6,9 @@ import com.intellij.execution.process.CapturingProcessHandler import java.nio.charset.StandardCharsets import java.nio.file.Files import java.nio.file.Path +import java.nio.file.StandardCopyOption.COPY_ATTRIBUTES +import java.nio.file.StandardCopyOption.REPLACE_EXISTING +import java.nio.file.attribute.BasicFileAttributes import java.util.concurrent.TimeUnit import kotlin.io.path.fileSize @@ -26,54 +29,63 @@ internal object WorktreeTransfer { private const val MAX_FILE = 10L * 1024 * 1024 // 10 MB, same cap as VS Code private const val TIMEOUT = 30_000 - /** Read-only snapshot of a working tree. Patch fields point at temp files owned by the caller. */ + /** Read-only snapshot of a working tree. Patch and untracked paths point at temp files owned by the caller. */ data class Snapshot( val branch: String, val head: String, val staged: Path?, val unstaged: Path?, - val untracked: List, + val untracked: Map, + val storage: Path, ) data class ApplyResult(val ok: Boolean, val error: String? = null) /** - * Captures the current git state from [root]. Read-only: [root] is never modified. The returned - * snapshot owns temp patch files that the caller must delete via [cleanup] in a `finally`. + * Captures the current git state from the worktree containing [dir] without modifying it. The returned + * snapshot owns temp files that the caller must delete via [cleanup] in a `finally`. */ - fun capture(root: Path): Snapshot { - val branch = git(root, "branch", "--show-current").stdout.trim() + fun capture(dir: Path): Snapshot { + val resolved = git(dir, "rev-parse", "--show-toplevel") + if (!resolved.ok) error("git rev-parse --show-toplevel failed: ${resolved.stderr.trim()}") + val root = Path.of(resolved.stdout.trimEnd('\r', '\n').ifEmpty { + error("git rev-parse --show-toplevel returned no directory") + }).toAbsolutePath().normalize() + val current = git(root, "branch", "--show-current") + if (!current.ok) error("git branch --show-current failed: ${current.stderr.trim()}") + val branch = current.stdout.trim() val rev = git(root, "rev-parse", "HEAD") if (!rev.ok) error("git rev-parse HEAD failed: ${rev.stderr.trim()}") val head = rev.stdout.trim().ifEmpty { error("git rev-parse HEAD returned no commit") } - val temps = mutableListOf() + val conflicts = gitLines(root, "ls-files", "--unmerged", "-z") + .mapNotNull { it.substringAfter('\t', "").takeIf(String::isNotEmpty) } + .distinct() + if (conflicts.isNotEmpty()) { + error("Resolve merge conflicts before moving to a worktree: ${conflicts.joinToString(", ")}") + } + val storage = Files.createTempDirectory("kilo-worktree-snapshot") try { - val unstaged = capturePatch(root, "diff", "--binary")?.also { temps.add(it) } - val staged = capturePatch(root, "diff", "--cached", "--binary")?.also { temps.add(it) } - val listed = git(root, "ls-files", "--others", "--exclude-standard") - if (!listed.ok) error("git ls-files failed: ${listed.stderr.trim()}") - val untracked = listed.stdout - .lineSequence() - .map { it.trim() } - .filter { it.isNotEmpty() } - .filter { rel -> - val full = root.resolve(rel).normalize() - if (!full.startsWith(root)) { - LOG.warn("worktree move: skipping untracked file outside root: $rel") - return@filter false - } - val size = runCatching { full.fileSize() }.getOrDefault(0L) - if (size > MAX_FILE) { - LOG.info("worktree move: skipping untracked file $rel: $size bytes exceeds ${MAX_FILE} limit") - false - } else { - true + val unstaged = capturePatch(root, storage, "diff", "--binary") + val staged = capturePatch(root, storage, "diff", "--cached", "--binary") + val untracked = gitLines(root, "ls-files", "--others", "--exclude-standard", "-z") + .associateWith { rel -> + runCatching { + val full = root.resolve(rel).normalize() + require(full.startsWith(root)) { "File is outside the source worktree" } + val attrs = Files.readAttributes(full, BasicFileAttributes::class.java) + require(attrs.isRegularFile) { "Not a regular file" } + require(attrs.size() <= MAX_FILE) { "File exceeds the $MAX_FILE byte transfer limit" } + val copy = Files.createTempFile(storage, "file", ".tmp") + Files.copy(full, copy, REPLACE_EXISTING) + require(copy.fileSize() <= MAX_FILE) { "File exceeds the $MAX_FILE byte transfer limit" } + copy + }.getOrElse { err -> + throw IllegalStateException("Failed to capture untracked file $rel: ${err.message}", err) } } - .toList() - return Snapshot(branch, head, staged, unstaged, untracked) + return Snapshot(branch, head, staged, unstaged, untracked, storage) } catch (e: Exception) { - temps.forEach { Files.deleteIfExists(it) } + discard(storage) throw e } } @@ -82,39 +94,56 @@ internal object WorktreeTransfer { * Applies [snapshot] into [target]: staged patch (re-staged), unstaged patch, then untracked * file copies. Returns the first failure encountered so the caller can roll the worktree back. */ - fun apply(snapshot: Snapshot, source: Path, target: Path): ApplyResult { + fun apply(snapshot: Snapshot, target: Path): ApplyResult { + val root = target.toAbsolutePath().normalize() snapshot.staged?.let { patch -> - val res = git(target, "apply", "--whitespace=nowarn", patch.toString()) + val res = git(root, "apply", "--index", "--whitespace=nowarn", patch.toString()) if (!res.ok) { val msg = res.stderr.trim().ifBlank { "Patch did not apply" } LOG.warn("worktree move: staged patch failed: $msg") return ApplyResult(false, "Staged patch failed: $msg") } - val files = parsePatchFiles(patch) - if (files.isNotEmpty()) git(target, "add", "--", *files.toTypedArray()) } snapshot.unstaged?.let { patch -> - val res = git(target, "apply", "--whitespace=nowarn", patch.toString()) + val res = git(root, "apply", "--whitespace=nowarn", patch.toString()) if (!res.ok) { val msg = res.stderr.trim().ifBlank { "Patch did not apply" } LOG.warn("worktree move: unstaged patch failed: $msg") return ApplyResult(false, "Unstaged patch failed: $msg") } } - for (rel in snapshot.untracked) { - runCatching { - val src = source.resolve(rel).normalize() - val dst = target.resolve(rel).normalize() + snapshot.untracked.forEach { (rel, copy) -> + val dst = root.resolve(rel).normalize() + if (!dst.startsWith(root)) return ApplyResult(false, "Untracked file is outside the target worktree: $rel") + val err = runCatching { Files.createDirectories(dst.parent) - Files.copy(src, dst) - }.onFailure { err -> LOG.warn("worktree move: failed to copy untracked file $rel: ${err.message}", err) } + Files.copy(copy, dst, COPY_ATTRIBUTES) + }.exceptionOrNull() + if (err != null) { + LOG.warn("worktree move: failed to write untracked file $rel: ${err.message}", err) + return ApplyResult(false, "Failed to write untracked file $rel: ${err.message}") + } } return ApplyResult(true) } fun cleanup(snapshot: Snapshot?) { snapshot ?: return - listOfNotNull(snapshot.staged, snapshot.unstaged).forEach { Files.deleteIfExists(it) } + discard(snapshot.storage) + } + + private fun discard(dir: Path) { + if (!Files.exists(dir)) return + runCatching { + Files.walk(dir).use { files -> files.sorted(Comparator.reverseOrder()).forEach(::delete) } + }.onFailure { err -> LOG.warn("worktree move: failed to clean up snapshot storage $dir: ${err.message}", err) } + } + + private fun delete(file: Path) { + if (runCatching { Files.deleteIfExists(file) }.isSuccess) return + runCatching { file.toFile().setWritable(true) } + runCatching { Files.deleteIfExists(file) } + .onFailure { err -> LOG.warn("worktree move: failed to delete $file: ${err.message}", err) } } /** @@ -127,8 +156,8 @@ internal object WorktreeTransfer { * inherits a minimal environment where a bare `git` can be missing. Output is still redirected * to a file because decoding stdout would corrupt `--binary` patches. */ - private fun capturePatch(root: Path, vararg args: String): Path? { - val file = Files.createTempFile("kilo-worktree-patch", ".diff") + private fun capturePatch(root: Path, storage: Path, vararg args: String): Path? { + val file = Files.createTempFile(storage, "patch", ".diff") val label = "git ${args.joinToString(" ")}" try { val cmd = GeneralCommandLine(listOf("git") + args).withWorkDirectory(root.toFile()) @@ -154,14 +183,28 @@ internal object WorktreeTransfer { } } - /** Extracts target paths from a unified diff's `diff --git a/... b/...` headers. */ - private fun parsePatchFiles(patch: Path): List { - // Read as Latin-1 so every byte maps to a char; header lines stay intact even in binary patches. - val header = Regex("^diff --git a/.+ b/(.+)$") - return runCatching { - Files.readAllLines(patch, StandardCharsets.ISO_8859_1) - .mapNotNull { header.find(it)?.groupValues?.get(1) } - }.getOrDefault(emptyList()) + private fun gitLines(root: Path, vararg args: String): List { + val file = Files.createTempFile("kilo-worktree-list", ".raw") + val label = "git ${args.joinToString(" ")}" + try { + val cmd = GeneralCommandLine(listOf("git") + args).withWorkDirectory(root.toFile()) + val proc = cmd.toProcessBuilder() + .redirectOutput(file.toFile()) + .redirectErrorStream(false) + .start() + val err = proc.errorStream.use { it.readBytes().toString(StandardCharsets.UTF_8) } + if (!proc.waitFor(TIMEOUT.toLong(), TimeUnit.MILLISECONDS)) { + proc.destroyForcibly() + error("$label timed out after ${TIMEOUT}ms") + } + val exit = proc.exitValue() + if (exit != 0) error("$label failed (exit $exit): ${err.trim()}") + return String(Files.readAllBytes(file), StandardCharsets.UTF_8) + .split('\u0000') + .filter { it.isNotEmpty() } + } finally { + Files.deleteIfExists(file) + } } private data class GitResult(val exit: Int, val stdout: String, val stderr: String) { @@ -170,7 +213,9 @@ internal object WorktreeTransfer { private fun git(root: Path, vararg args: String): GitResult { return try { - val cmd = GeneralCommandLine(listOf("git") + args).withWorkDirectory(root.toFile()) + val cmd = GeneralCommandLine(listOf("git") + args) + .withWorkDirectory(root.toFile()) + .withCharset(StandardCharsets.UTF_8) val out = CapturingProcessHandler(cmd).runProcess(TIMEOUT) GitResult(if (out.isTimeout) -1 else out.exitCode, out.stdout, out.stderr) } catch (e: Exception) { diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendActivityManagerTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendActivityManagerTest.kt index 19f69b72ef37..c0989a28f784 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendActivityManagerTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendActivityManagerTest.kt @@ -249,6 +249,64 @@ class KiloBackendActivityManagerTest { assertEquals("/repo/new", snap["ses_new"]?.directory) } + @Test + fun `interrupt badges the session as failed`() = runBlocking { + directories["ses_1"] = "/repo/wt" + statuses.value = mapOf("ses_1" to SessionStatusDto("busy")) + start() + + manager.interrupt(listOf("ses_1")) + statuses.value = mapOf("ses_1" to SessionStatusDto("idle")) + + await("ses_1", SessionActivityKindDto.ERROR) + } + + /** + * The disposal that cancels a turn reloads the app in the same breath, and that reload calls + * [KiloBackendActivityManager.start] again. Clearing on that in-place restart erased the badge the + * disposal had just recorded, leaving a lost turn resting as if it had finished cleanly. + */ + @Test + fun `interrupt badge survives the reload that follows a disposal`() = runBlocking { + directories["ses_1"] = "/repo/wt" + statuses.value = mapOf("ses_1" to SessionStatusDto("busy")) + start() + + manager.interrupt(listOf("ses_1")) + + // What load() does after a disposal: same flows, fresh collectors. + val reloaded = MutableStateFlow(mapOf("ses_1" to SessionStatusDto("idle"))) + manager.start(reloaded, { directories[it] }, events) + + await("ses_1", SessionActivityKindDto.ERROR) + } + + @Test + fun `resumed work clears an interrupt badge`() = runBlocking { + directories["ses_1"] = "/repo/wt" + start() + manager.interrupt(listOf("ses_1")) + await("ses_1", SessionActivityKindDto.ERROR) + + events.emit(ChatEventDto.TurnOpen("ses_1")) + statuses.value = mapOf("ses_1" to SessionStatusDto("busy")) + + await("ses_1", SessionActivityKindDto.RUNNING) + } + + /** A real teardown is a disconnect, not a restart, so nothing may outlive it. */ + @Test + fun `stop clears an interrupt badge`() = runBlocking { + directories["ses_1"] = "/repo/wt" + start() + manager.interrupt(listOf("ses_1")) + await("ses_1", SessionActivityKindDto.ERROR) + + manager.stop() + + assertEquals(emptyMap(), manager.activity.value) + } + private suspend fun await(id: String, kind: SessionActivityKindDto) = withTimeout(5_000) { manager.activity.first { it[id]?.kind == kind } } diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendAppServiceTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendAppServiceTest.kt index d8009e795870..a59c6799abc6 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendAppServiceTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendAppServiceTest.kt @@ -9,13 +9,17 @@ import ai.kilocode.backend.testing.FakeCliServer import ai.kilocode.backend.testing.MockCliServer import ai.kilocode.backend.testing.TestLog import ai.kilocode.rpc.dto.AgentConfigPatchDto +import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.CompactionPatchDto import ai.kilocode.rpc.dto.ConfigPatchDto +import ai.kilocode.rpc.dto.SessionActivityKindDto import ai.kilocode.rpc.dto.WatcherPatchDto import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.first @@ -836,6 +840,87 @@ class KiloBackendAppServiceTest { assertNotNull(svc.config) } + /** + * Disposing an instance cancels every runner it owns, and the CLI reports that as the same + * `MessageAbortedError` a user Stop produces. Naming the cause here is the only thing that lets the + * UI explain the lost turn instead of reporting it as "Stopped" — three sessions once died to a + * config reload with no trace the user could see. + */ + @Test + fun `disposal while a session is busy names the reason for that session`() = runBlocking { + val svc = create() + svc.connect() + ready(svc) + mock.awaitSseConnection() + + mock.pushEvent("session.status", """{"sessionID":"ses_abc","status":{"type":"busy","message":"Running..."}}""") + withTimeout(5_000) { svc.sessions.statuses.first { it["ses_abc"]?.type == "busy" } } + + val received = scope.async(start = CoroutineStart.UNDISPATCHED) { + svc.chat.events.first { it is ChatEventDto.SessionInterrupted } + } + mock.pushEvent("global.disposed", """{"type":"global.disposed"}""") + + val event = assertIs(withTimeout(15_000) { received.await() }) + assertEquals("ses_abc", event.sessionID) + assertEquals(ChatEventDto.SessionInterrupted.RELOAD, event.reason) + } + + /** + * The reload a disposal triggers restarts the activity collector, so the badge has to be recorded in + * a way that survives it — otherwise a worktree row rests as if its cancelled turn had finished. + */ + @Test + fun `disposal badges the cancelled session after the reload settles`() = runBlocking { + // A badge needs a resolvable directory, which a status event alone does not carry. + mock.sessions = """[{"id":"ses_abc","slug":"abc","projectID":"prj_test","directory":"/test/project","title":"Work","version":"1.0.0","time":{"created":1000,"updated":1000}}]""" + val svc = create() + svc.connect() + ready(svc) + mock.awaitSseConnection() + svc.sessions.list("/test/project") + + mock.pushEvent("session.status", """{"sessionID":"ses_abc","status":{"type":"busy","message":"Running..."}}""") + withTimeout(5_000) { svc.sessions.statuses.first { it["ses_abc"]?.type == "busy" } } + + mock.pushEvent("global.disposed", """{"type":"global.disposed"}""") + // Disposal badges the session and then reloads, and the reload is what restarts the activity + // collector — so the restart proves the badge was already taken, and waiting for it is what + // makes the assertion below about surviving the restart instead of racing it. Reporting idle + // before that point would race too: the status map is fed by a separate collector that can + // reach it before the disposal watcher runs, leaving the cancelled turn looking finished. + assertTrue( + log.awaitMessage(20_000, count = 2) { it == "INFO: Activity manager started" }, + "Disposal must reload the app and restart the activity collector; logs=${log.messages}", + ) + + // The cancelled turn then reports idle, which is what lets the badge show: live work + // deliberately outranks a past error so a resumed row keeps spinning instead. + mock.pushEvent("session.status", """{"sessionID":"ses_abc","status":{"type":"idle"}}""") + + val badged = withTimeoutOrNull(20_000) { + svc.activity.activity.first { it["ses_abc"]?.kind == SessionActivityKindDto.ERROR } + } + assertNotNull(badged, "Disposal must leave a badge on the session it cancelled; logs=${log.messages}") + assertEquals("/test/project", badged["ses_abc"]?.directory) + } + + @Test + fun `disposal with no busy session names nothing`() = runBlocking { + val svc = create() + svc.connect() + ready(svc) + mock.awaitSseConnection() + + val received = scope.async(start = CoroutineStart.UNDISPATCHED) { + svc.chat.events.first { it is ChatEventDto.SessionInterrupted } + } + mock.pushEvent("global.disposed", """{"type":"global.disposed"}""") + + assertNull(withTimeoutOrNull(2_000) { received.await() }) + received.cancel() + } + @Test fun `restart lifecycle transitions correctly`() = runBlocking { val svc = create() diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendChatManagerTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendChatManagerTest.kt index 3225fef1a03c..7a5a0b0c19a9 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendChatManagerTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendChatManagerTest.kt @@ -229,6 +229,30 @@ class KiloBackendChatManagerTest { assertTrue(log.messages.any { it.contains("route=chat-events parse=false type=session.error") }, log.messages.joinToString("\n")) } + /** + * A cancellation the CLI starts on its own reaches the UI as the same `MessageAbortedError` a Stop + * produces, so the reason has to travel on its own event through the same stream. + */ + @Test + fun `interrupt emits one reason event per running session`() = runBlocking { + val port = mock.start() + val chat = KiloBackendChatManager(scope, TestLog()) + chat.start(OkHttpClient(), port, MutableSharedFlow()) + + // UNDISPATCHED runs the collector up to its first suspension on this thread, so the subscription + // is registered before interrupt() emits — the flow buffers rather than blocks, so a late + // subscriber would simply miss the event. + val received = async(start = CoroutineStart.UNDISPATCHED) { + chat.events.first { it is ChatEventDto.SessionInterrupted } + } + chat.interrupt(listOf("ses_abc"), ChatEventDto.SessionInterrupted.RELOAD) + + val event = withTimeout(5_000) { received.await() } + assertTrue(event is ChatEventDto.SessionInterrupted) + assertEquals("ses_abc", event.sessionID) + assertEquals(ChatEventDto.SessionInterrupted.RELOAD, event.reason) + } + @Test fun `global message event type is extracted from payload`() = runBlocking { val port = mock.start() diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt index 892102f64ac9..7743b48c2408 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt @@ -7,7 +7,6 @@ import ai.kilocode.rpc.dto.AgentConfigPatchDto import ai.kilocode.rpc.dto.CompactionPatchDto import ai.kilocode.rpc.dto.ConfigDto import ai.kilocode.rpc.dto.ConfigPatchDto -import ai.kilocode.rpc.dto.ConfigUpdateDto import ai.kilocode.rpc.dto.EditorContextDto import ai.kilocode.rpc.dto.McpConfigDto import ai.kilocode.rpc.dto.PermissionAlwaysRulesDto @@ -2306,29 +2305,6 @@ class KiloCliDataParserTest { assertEquals("""{"messageID":"m\"\\1","partID":"p\"\\1"}""", result) } - // ---- buildConfigPartial ---- - - @Test - fun `buildConfigPartial - model only`() { - val result = KiloCliDataParser.buildConfigPartial(ConfigUpdateDto(model = "anthropic/claude-4")) - assertEquals("""{"model":"anthropic/claude-4"}""", result) - } - - @Test - fun `buildConfigPartial - agent and temperature`() { - val result = KiloCliDataParser.buildConfigPartial( - ConfigUpdateDto(agent = "code", temperature = 0.7) - ) - assertTrue(result.contains(""""default_agent":"code"""")) - assertTrue(result.contains(""""agent":{"code":{"temperature":0.7}}""")) - } - - @Test - fun `buildConfigPartial - empty update`() { - val result = KiloCliDataParser.buildConfigPartial(ConfigUpdateDto()) - assertEquals("{}", result) - } - @Test fun `buildConfigPatch - top-level model set`() { val patch = ConfigPatchDto(values = linkedMapOf("model" to "anthropic/claude")) @@ -2470,12 +2446,6 @@ class KiloCliDataParserTest { assertEquals("{\"model\":\"kilo/a\\\\b\\\"c\"}", KiloCliDataParser.buildConfigPatch(patch)) } - @Test - fun `buildConfigPartial - temperature without agent defaults to ask`() { - val result = KiloCliDataParser.buildConfigPartial(ConfigUpdateDto(temperature = 0.5)) - assertTrue(result.contains(""""agent":{"ask":{"temperature":0.5}}""")) - } - // ---- buildPermissionReplyJson ---- @Test diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/BranchDiffTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/BranchDiffTest.kt index 6521c61ba44a..760120582b78 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/BranchDiffTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/BranchDiffTest.kt @@ -1,5 +1,9 @@ package ai.kilocode.backend.rpc +import ai.kilocode.backend.diff.DiffStat +import ai.kilocode.backend.diff.capDiff +import ai.kilocode.backend.diff.parseNameStatus +import ai.kilocode.backend.diff.parseNumstat import ai.kilocode.rpc.dto.DiffFileDto import kotlin.test.Test import kotlin.test.assertEquals @@ -10,13 +14,16 @@ class BranchDiffTest { @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 fetched = mutableListOf() - val diff = capDiff(files, cap = 5) { "12345" } + val diff = capDiff(files, cap = 5) { file, _ -> + fetched += file.file + file.copy(patch = "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) + assertEquals(listOf("a.txt"), fetched) + assertEquals(files.map { it.file }, diff.map { it.file }) + assertEquals(listOf("12345", "", ""), diff.map { it.patch }) } @Test @@ -24,9 +31,9 @@ class BranchDiffTest { val fetched = mutableListOf() val files = listOf(stat("big.txt"), stat("small.txt"), stat("tiny.txt")) - val diff = capDiff(files, cap = 4) { file -> + val diff = capDiff(files, cap = 4) { file, _ -> fetched += file.file - if (file.file == "big.txt") "0123456789" else "x" + file.copy(patch = if (file.file == "big.txt") "0123456789" else "x") } assertEquals(listOf("big.txt", "small.txt", "tiny.txt"), fetched) @@ -38,13 +45,13 @@ class BranchDiffTest { val fetched = mutableListOf() val files = listOf(stat("big1.txt"), stat("big2.txt"), stat("big3.txt"), stat("later.txt")) - val diff = capDiff(files, cap = 4) { file -> + val diff = capDiff(files, cap = 4) { file, _ -> fetched += file.file - "0123456789" + file.copy(patch = "0123456789") } assertEquals(listOf("big1.txt", "big2.txt", "big3.txt"), fetched) - assertEquals(listOf("", "", "", ""), diff.map { it.patch }) + assertEquals(files, diff) } @Test @@ -52,64 +59,83 @@ class BranchDiffTest { val fetched = mutableListOf() val files = listOf(stat("empty.txt"), stat("kept.txt")) - val diff = capDiff(files, cap = 10) { file -> + val diff = capDiff(files, cap = 10) { file, _ -> fetched += file.file - if (file.file == "empty.txt") "" else "patch" + file.copy(patch = if (file.file == "empty.txt") "" else "patch") } assertEquals(listOf("empty.txt", "kept.txt"), fetched) - assertEquals("", diff[0].patch) - assertEquals("patch", diff[1].patch) + assertEquals(listOf("", "patch"), diff.map { it.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" + val diff = capDiff(files, cap = 100) { file, _ -> + file.copy(patch = 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) + assertEquals(listOf("tracked-patch", "untracked-patch"), diff.map { it.patch }) + assertEquals("untracked", diff.last().status) } @Test fun `capDiff keeps all patches under a large budget`() { val files = (1..110).map { i -> stat("src/File$i.kt") } - val diff = capDiff(files, cap = 8 * 1024 * 1024) { file -> "patch-${file.file}\n".repeat(30) } + val diff = capDiff(files, cap = 8 * 1024 * 1024) { file, _ -> file.copy(patch = "patch-${file.file}\n".repeat(30)) } assertEquals(files.map { it.file }, diff.map { it.file }) assertEquals(files.map { file -> "patch-${file.file}\n".repeat(30) }, diff.map { it.patch }) } @Test - fun `parses git numstat output`() { - val stats = parseNumstat("1\t2\tsrc/A.kt\n0\t3\tsrc/B.kt\n-\t-\tbin.png\n") + fun `capDiff includes whole contents in the shared budget and preserves counts when omitted`() { + val files = listOf(stat("rename.txt", "renamed"), stat("later.txt")) + val budgets = mutableListOf() + val diff = capDiff(files, 8) { file, budget -> + budgets += budget + file.copy(patch = "ab", before = "old", after = "new") + } + + assertEquals(listOf(8), budgets) + assertEquals("old", diff.first().before) + assertEquals(files.last(), diff.last()) + assertEquals(files, capDiff(files, 7) { file, _ -> file.copy(patch = "ab", before = "old", after = "new") }) + assertEquals(files, capDiff(files, 7) { _, _ -> null }) + } + + @Test + fun `parses NUL numstat with old and new rename paths and binary and empty entries`() { + val stats = parseNumstat("1\t2\tsrc/A.kt\u00000\t3\tsrc/B.kt\u0000-\t-\tbin.png\u00000\t0\t\u0000old\tname\n.txt\u0000new é.txt\u00000\t0\tempty\u0000") assertEquals( listOf( DiffStat("src/A.kt", 1, 2), DiffStat("src/B.kt", 0, 3), - DiffStat("bin.png", 0, 0), + DiffStat("bin.png", 0, 0, binary = true), + DiffStat("new é.txt", 0, 0, old = "old\tname\n.txt"), + DiffStat("empty", 0, 0), ), stats, ) + assertEquals(emptyList(), parseNumstat("")) } @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") + fun `parses NUL name status without treating renamed paths as extra files`() { + val status = parseNameStatus("M\u0000src/A.kt\u0000A\u0000src/B.kt\u0000D\u0000src/Old.kt\u0000R100\u0000old\tname\n.txt\u0000new é.txt\u0000") assertEquals( mapOf( "src/A.kt" to "modified", "src/B.kt" to "added", "src/Old.kt" to "deleted", + "new é.txt" to "renamed", ), status, ) + assertEquals(emptyMap(), parseNameStatus("")) } } diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/BranchLocalDiffTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/BranchLocalDiffTest.kt new file mode 100644 index 000000000000..3163e52cfc4f --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/BranchLocalDiffTest.kt @@ -0,0 +1,397 @@ +package ai.kilocode.backend.rpc + +import ai.kilocode.backend.diff.GitComparison +import ai.kilocode.rpc.dto.DiffFileDto +import com.intellij.execution.configurations.GeneralCommandLine +import com.intellij.execution.process.CapturingProcessHandler +import kotlinx.coroutines.runBlocking +import java.nio.file.Files +import java.nio.file.Path +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class BranchLocalDiffTest { + private val root = Files.createTempDirectory("kilo-branch-local-diff").toRealPath() + private val repo = Files.createDirectory(root.resolve("repo")) + private val config = Files.createFile(root.resolve("empty.config")) + private val hooks = Files.createDirectory(root.resolve("hooks")) + private val api = KiloWorkspaceRpcApiImpl() + private val trees = KiloWorktreeRpcApiImpl() + private val timeout = 30_000 + + @AfterTest + fun tearDown() { + Files.walk(root).use { paths -> + paths.sorted(Comparator.reverseOrder()).forEach { Files.deleteIfExists(it) } + } + } + + @Test + fun `mixed committed and net local changes match their summaries and patches`() = runBlocking { + init() + val dir = worktree() + Files.writeString(dir.resolve("tracked.txt"), "one\ncommitted\nthree\n") + Files.writeString(dir.resolve("committed.txt"), "committed only\n") + git(dir, "rm", "deleted.txt") + commit(dir) + Files.writeString(dir.resolve("tracked.txt"), "one\nstaged\nthree\n") + Files.writeString(dir.resolve("staged.txt"), "index\n") + Files.writeString(dir.resolve("net.txt"), "temporary\n") + git(dir, "add", "tracked.txt", "staged.txt", "net.txt") + Files.writeString(dir.resolve("tracked.txt"), "one\nunstaged\nthree\nextra\n") + Files.writeString(dir.resolve("staged.txt"), "index\nworktree\n") + Files.writeString(dir.resolve("net.txt"), "same\n") + Files.delete(dir.resolve("committed.txt")) + Files.writeString(dir.resolve("untracked.txt"), "untracked\nlast") + Files.writeString(dir.resolve("ignored.txt"), "ignored\n") + val status = git(dir, "status", "--porcelain", "-z") + + val (base, local) = parity(dir) + + assertEquals(setOf("tracked.txt", "committed.txt", "deleted.txt"), base.map { it.file }.toSet()) + assertEquals(2, base.sumOf { it.additions }) + assertEquals(2, base.sumOf { it.deletions }) + assertEquals(setOf("tracked.txt", "committed.txt", "staged.txt", "untracked.txt"), local.map { it.file }.toSet()) + assertEquals(6, local.sumOf { it.additions }) + assertEquals(2, local.sumOf { it.deletions }) + val patch = base.single { it.file == "tracked.txt" }.patch.orEmpty() + assertTrue(patch.contains(" one\n") && patch.contains("+committed\n") && patch.contains(" three\n"), patch) + assertFalse(patch.contains("staged"), patch) + assertEquals(status, git(dir, "status", "--porcelain", "-z")) + } + + @Test + fun `fully pushed branch keeps base changes and retains unpushed metadata separately`() = runBlocking { + init() + val origin = Files.createDirectory(root.resolve("origin")) + git(origin, "init", "--bare", "--initial-branch=main") + git(repo, "remote", "add", "origin", origin.toString()) + git(repo, "push", "-u", "origin", "main") + git(repo, "symbolic-ref", "refs/remotes/origin/HEAD", "refs/remotes/origin/main") + val dir = worktree() + Files.writeString(dir.resolve("feature.txt"), "one\n") + commit(dir) + git(dir, "push", "-u", "origin", "feature") + + assertEquals(listOf("feature.txt"), parity(dir).first.map { it.file }) + assertEquals(0, trees.dirty(repo.toString()).items.single().unpushed) + Files.writeString(dir.resolve("feature.txt"), "one\ntwo\n") + commit(dir) + assertEquals(2, parity(dir).first.single().additions) + assertEquals(1, trees.dirty(repo.toString()).items.single().unpushed) + } + + @Test + fun `diverged base reports ahead and behind in the correct direction`() = runBlocking { + init() + val dir = worktree() + Files.writeString(dir.resolve("feature.txt"), "feature\n") + commit(dir) + Files.writeString(repo.resolve("main.txt"), "main one\n") + commit(repo) + Files.writeString(repo.resolve("main.txt"), "main one\nmain two\n") + commit(repo) + + assertEquals(listOf("feature.txt"), parity(dir).first.map { it.file }) + val stats = trees.stats(repo.toString()).items.single() + assertEquals(1, stats.ahead) + assertEquals(2, stats.behind) + assertEquals("main", stats.base) + } + + @Test + fun `subdirectory comparisons stay relative and exclude changes outside their scope`() = runBlocking { + init() + Files.createDirectories(repo.resolve("module")) + Files.writeString(repo.resolve("module/old name[1].txt"), "rename contents\n") + commit(repo) + val dir = worktree() + git(dir, "mv", "module/old name[1].txt", "module/new name[2].txt") + Files.writeString(dir.resolve("outside.txt"), "outside\n") + commit(dir) + Files.writeString(dir.resolve("module/new name[2].txt"), "rename contents\nlocal\n") + Files.writeString(dir.resolve("module/untracked.txt"), "untracked\n") + Files.writeString(dir.resolve("outside.txt"), "outside\nlocal\n") + Files.writeString(dir.resolve("untracked.txt"), "outside untracked\n") + + val (base, local) = parity(dir) + val subdir = dir.resolve("module").toString() + val committed = api.branchDiff(subdir, true) + val working = api.localDiff(subdir, true) + assertEquals(base.filter { it.file.startsWith("module/") }.map { it.copy(file = it.file.removePrefix("module/"), patch = "") }, committed.map { it.copy(patch = "") }) + assertEquals(local.filter { it.file.startsWith("module/") }.map { it.copy(file = it.file.removePrefix("module/"), patch = "") }, working.map { it.copy(patch = "") }) + assertEquals("rename contents\n", committed.single().before) + assertEquals("rename contents\n", committed.single().after) + assertTrue(committed.single().patch.orEmpty().contains("rename from old name[1].txt")) + assertEquals(setOf("new name[2].txt", "untracked.txt"), working.map { it.file }.toSet()) + } + + @Test + fun `rename binary and empty files remain counted in both comparisons`() = runBlocking { + init() + val dir = worktree() + git(dir, "mv", "rename.txt", "renamed.txt") + Files.write(dir.resolve("binary.bin"), byteArrayOf(0, 1, 2)) + Files.writeString(dir.resolve("empty.txt"), "") + commit(dir) + git(dir, "mv", "local.txt", "local-renamed.txt") + Files.write(dir.resolve("binary.bin"), byteArrayOf(0, 3, 4)) + Files.writeString(dir.resolve("local-empty.txt"), "") + Files.write(dir.resolve("untracked.bin"), byteArrayOf(0, 5)) + Files.writeString(dir.resolve("renamed.txt"), "local drift\n") + + val (base, local) = parity(dir) + + assertEquals(3, base.size) + assertEquals(0, base.sumOf { it.additions + it.deletions }) + val renamed = base.single { it.file == "renamed.txt" } + assertEquals("renamed", renamed.status) + assertEquals("rename contents\n", renamed.before) + assertEquals("rename contents\n", renamed.after) + assertTrue(renamed.patch.orEmpty().contains("rename from rename.txt")) + val moved = local.single { it.file == "local-renamed.txt" } + assertEquals("renamed", moved.status) + assertEquals(0, moved.additions + moved.deletions) + assertEquals("local contents\n", moved.before) + assertEquals("local contents\n", moved.after) + assertEquals(5, local.size) + assertNull(base.single { it.file == "binary.bin" }.before) + assertTrue(local.single { it.file == "untracked.bin" }.patch.isNullOrEmpty()) + assertEquals("", base.single { it.file == "empty.txt" }.after) + } + + @Test + fun `committed full context and renamed contents survive working file modifications and deletions`() = runBlocking { + init() + val text = (1..30).joinToString("\n", postfix = "\n") { "context $it" } + Files.writeString(repo.resolve("edited.txt"), text) + Files.writeString(repo.resolve("removed.txt"), text) + commit(repo) + val dir = worktree() + val committed = text.replace("context 15", "committed line") + Files.writeString(dir.resolve("edited.txt"), committed) + Files.writeString(dir.resolve("removed.txt"), committed) + git(dir, "mv", "rename.txt", "renamed.txt") + commit(dir) + Files.writeString(dir.resolve("edited.txt"), "DRIFTED\n") + Files.delete(dir.resolve("removed.txt")) + Files.delete(dir.resolve("renamed.txt")) + + val base = parity(dir).first + + for (name in listOf("edited.txt", "removed.txt")) { + val patch = base.single { it.file == name }.patch.orEmpty() + assertTrue(patch.contains(" context 1\n"), patch) + assertTrue(patch.contains(" context 30\n"), patch) + assertTrue(patch.contains("-context 15\n+committed line\n"), patch) + assertFalse(patch.contains("DRIFTED"), patch) + } + val renamed = base.single { it.file == "renamed.txt" } + assertEquals("rename contents\n", renamed.before) + assertEquals("rename contents\n", renamed.after) + } + + @Test + fun `default selection follows declared origin remote local and primary fallbacks without caching`() = runBlocking { + init() + git(repo, "branch", "-m", "trunk") + val dir = worktree() + val refs = listOf("refs/remotes/origin/develop", "refs/remotes/origin/main", "refs/remotes/origin/master", "refs/heads/main", "refs/heads/master") + refs.forEach { git(repo, "update-ref", it, "HEAD") } + git(repo, "symbolic-ref", "refs/remotes/origin/HEAD", refs.first()) + Files.writeString(dir.resolve("feature.txt"), "feature\n") + commit(dir) + git(dir, "tag", "main") + git(dir, "tag", "master") + git(dir, "branch", "shadow") + git(dir, "branch", "--set-upstream-to=shadow", "feature") + + for (ref in refs) { + assertEquals(listOf("feature.txt"), parity(dir).first.map { it.file }) + assertEquals(ref.removePrefix("refs/remotes/").removePrefix("refs/heads/"), trees.stats(repo.toString()).items.single().base) + git(repo, "update-ref", "-d", ref) + } + assertEquals(listOf("feature.txt"), parity(dir).first.map { it.file }) + assertEquals("trunk", trees.stats(repo.toString()).items.single().base) + git(repo, "checkout", "--detach") + assertTrue(parity(dir).first.isEmpty()) + assertEquals("HEAD", trees.stats(repo.toString()).items.single().base) + } + + @Test + fun `unrelated base falls back to its committed tip without mixing local changes`() = runBlocking { + init() + val dir = worktree() + Files.writeString(dir.resolve("feature.txt"), "feature\n") + commit(dir) + git(repo, "checkout", "--orphan", "isolated") + git(repo, "rm", "-rf", ".") + Files.writeString(repo.resolve("unrelated.txt"), "unrelated\n") + git(repo, "add", "unrelated.txt") + git(repo, "commit", "-m", "unrelated") + git(repo, "branch", "-f", "main", "HEAD") + Files.writeString(dir.resolve("feature.txt"), "local drift\n") + Files.writeString(dir.resolve("untracked.txt"), "untracked\n") + + val base = parity(dir).first + + assertEquals("deleted", base.single { it.file == "unrelated.txt" }.status) + assertTrue(base.single { it.file == "feature.txt" }.patch.orEmpty().contains("+feature\n")) + assertFalse(base.any { it.file == "untracked.txt" }) + assertEquals("main", trees.stats(repo.toString()).items.single().base) + } + + @Test + fun `comparison freezes both committed revisions and local HEAD before loading files`() { + init() + val dir = worktree() + Files.writeString(dir.resolve("feature.txt"), "first\n") + commit(dir) + val base = assertNotNull(GitComparison.open(dir, GitComparison.Mode.Base)) + val local = assertNotNull(GitComparison.open(dir, GitComparison.Mode.Local)) + Files.writeString(dir.resolve("feature.txt"), "second\n") + commit(dir) + git(repo, "update-ref", "refs/heads/main", git(dir, "rev-parse", "HEAD").trim()) + + assertEquals(0 to 1, base.counts()) + assertTrue(base.files(true).single().patch.orEmpty().contains("+first\n")) + assertEquals(1, local.files(false).single().additions) + assertTrue(local.files(true).single().patch.orEmpty().contains("-first\n+second\n")) + assertTrue(assertNotNull(GitComparison.open(dir, GitComparison.Mode.Base)).files(true).isEmpty()) + } + + @Test + fun `large renamed contents and untracked guards retain stats while limiting payloads`() = runBlocking { + init() + Files.writeString(repo.resolve("large.txt"), "line\n".repeat(900_000)) + commit(repo) + val dir = worktree() + git(dir, "mv", "large.txt", "a-large.txt") + Files.writeString(dir.resolve("z-small.txt"), "small\n") + commit(dir) + Files.writeString(dir.resolve("oversized.txt"), "x".repeat(2 * 1024 * 1024 + 1)) + Files.write(dir.resolve("untracked.bin"), byteArrayOf(1, 0, 2)) + Files.writeString(dir.resolve("untracked-empty.txt"), "") + Files.writeString(dir.resolve("untracked-text.txt"), "one\ntwo") + + val (base, local) = parity(dir) + + val large = base.single { it.file == "a-large.txt" } + assertEquals(0, large.additions + large.deletions) + assertTrue(large.patch.isNullOrEmpty()) + assertNull(large.before) + assertNull(large.after) + assertTrue(base.single { it.file == "z-small.txt" }.patch.orEmpty().contains("+small")) + assertEquals(4, local.size) + assertEquals(2, local.sumOf { it.additions }) + assertTrue(local.single { it.file == "oversized.txt" }.patch.isNullOrEmpty()) + assertTrue((base + local).sumOf { it.patch.orEmpty().length + it.before.orEmpty().length + it.after.orEmpty().length } < 8 * 1024 * 1024) + } + + @Test + fun `bulk stats and dirty retain managed-only scope excluding primary`() = runBlocking { + init() + val dir = worktree() + val outside = root.resolve("outside") + git(repo, "worktree", "add", "-b", "outside", outside.toString()) + Files.writeString(repo.resolve("primary.txt"), "primary\n") + Files.writeString(outside.resolve("outside.txt"), "outside\n") + Files.writeString(dir.resolve("managed.txt"), "managed\n") + + assertEquals(listOf(dir.toString()), trees.stats(outside.toString()).items.map { it.path }) + assertEquals(listOf(dir.toString()), trees.dirty(outside.toString()).items.map { it.path }) + assertEquals(listOf("managed.txt"), parity(dir).second.map { it.file }) + assertEquals(listOf("outside.txt"), api.localDiff(outside.toString(), false).map { it.file }) + } + + @Test + fun `clean missing nonGit and noHEAD comparisons are empty`() = runBlocking { + for (dir in listOf(repo, repo.resolve("missing"))) { + assertTrue(api.branchDiff(dir.toString(), true).isEmpty()) + assertTrue(api.localDiff(dir.toString(), true).isEmpty()) + } + git(repo, "init", "--initial-branch=main") + Files.writeString(repo.resolve("untracked.txt"), "unborn\n") + assertTrue(api.branchDiff(repo.toString(), true).isEmpty()) + assertTrue(api.localDiff(repo.toString(), true).isEmpty()) + Files.delete(repo.resolve("untracked.txt")) + init() + val dir = worktree() + val (base, local) = parity(dir) + assertTrue(base.isEmpty()) + assertTrue(local.isEmpty()) + } + + @Test + fun `unexpected Git comparison failures propagate instead of appearing clean`() = runBlocking { + init() + val dir = worktree() + git(repo, "config", "diff.algorithm", "invalid-algorithm") + + assertFailsWith { api.branchDiff(dir.toString(), false) } + assertFailsWith { api.localDiff(dir.toString(), true) } + assertFailsWith { trees.stats(repo.toString()) } + assertFailsWith { trees.dirty(repo.toString()) } + } + + private suspend fun parity(dir: Path): Pair, List> { + val base = api.branchDiff(dir.toString(), true) + val local = api.localDiff(dir.toString(), true) + assertEquals(api.branchDiff(dir.toString(), false), base.map { it.copy(patch = "", before = null, after = null) }) + assertEquals(api.localDiff(dir.toString(), false), local.map { it.copy(patch = "", before = null, after = null) }) + val stats = trees.stats(repo.toString()).items.single { it.path == dir.toString() } + val dirty = trees.dirty(repo.toString()).items.single { it.path == dir.toString() } + assertEquals(base.size, stats.files) + assertEquals(base.sumOf { it.additions }, stats.additions) + assertEquals(base.sumOf { it.deletions }, stats.deletions) + assertEquals(local.size, dirty.files) + assertEquals(local.sumOf { it.additions }, dirty.additions) + assertEquals(local.sumOf { it.deletions }, dirty.deletions) + assertEquals(local.count { it.status == "untracked" }, dirty.untracked) + return base to local + } + + private fun init() { + git(repo, "init", "--initial-branch=main") + git(repo, "config", "user.email", "test@kilo.ai") + git(repo, "config", "user.name", "Kilo Test") + git(repo, "config", "core.autocrlf", "false") + Files.writeString(repo.resolve(".gitignore"), ".kilo/\nignored.txt\n") + Files.writeString(repo.resolve("tracked.txt"), "one\ntwo\nthree\n") + Files.writeString(repo.resolve("deleted.txt"), "remove\n") + Files.writeString(repo.resolve("rename.txt"), "rename contents\n") + Files.writeString(repo.resolve("local.txt"), "local contents\n") + Files.writeString(repo.resolve("net.txt"), "same\n") + commit(repo) + } + + private fun worktree(): Path { + val dir = repo.resolve(".kilo/worktrees/feature") + git(repo, "worktree", "add", "-b", "feature", dir.toString()) + return dir.toRealPath() + } + + private fun commit(dir: Path) { + git(dir, "add", "-A") + git(dir, "commit", "-m", "fixture") + } + + private fun git(dir: Path, vararg args: String): String { + val cmd = GeneralCommandLine(listOf("git", "-c", "commit.gpgsign=false", "-c", "core.hooksPath=$hooks") + args) + .withWorkDirectory(dir.toFile()) + .withEnvironment("GIT_CONFIG_NOSYSTEM", "1") + .withEnvironment("GIT_CONFIG_GLOBAL", config.toString()) + .withEnvironment("GIT_AUTHOR_DATE", "2026-01-01T00:00:00Z") + .withEnvironment("GIT_COMMITTER_DATE", "2026-01-01T00:00:00Z") + val out = CapturingProcessHandler(cmd).runProcess(timeout) + assertFalse(out.isTimeout, "git ${args.joinToString(" ")} timed out: ${out.stderr}") + assertEquals(0, out.exitCode, "git ${args.joinToString(" ")} failed: ${out.stderr}") + return out.stdout + } +} diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImplTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImplTest.kt index b708e5d57f1a..2812bac0f219 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImplTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImplTest.kt @@ -8,12 +8,17 @@ import ai.kilocode.rpc.dto.MoveStage import ai.kilocode.rpc.dto.WorktreeDto import com.intellij.execution.configurations.GeneralCommandLine import com.intellij.execution.process.CapturingProcessHandler +import com.intellij.openapi.util.SystemInfo +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.toList import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Assumptions.assumeFalse +import org.junit.jupiter.api.Assumptions.assumeTrue import java.nio.file.Files import java.nio.file.Path import kotlin.test.AfterTest import kotlin.test.Test +import kotlin.test.assertContentEquals import kotlin.test.assertEquals import kotlin.test.assertFails import kotlin.test.assertFalse @@ -58,6 +63,26 @@ class KiloWorktreeRpcApiImplTest { assertEquals("feature/x", api.branchStatus(dir.toString()).branch) } + @Test + fun `ghStatus reports git only when the github integration is off`() = runBlocking { + initRepo() + assertEquals(GhAvailability.OK, api.ghStatus(repo.toString(), github = false)) + } + + @Test + fun `branch status resolves git state without a pull request when the github integration is off`() = runBlocking { + initRepo() + val dir = repo.resolve(".kilo").resolve("worktrees").resolve("off") + git(repo, "worktree", "add", "-b", "feature/off", dir.toString()) + + val status = api.branchStatus(dir.toString(), github = false) + + assertEquals("feature/off", status.branch) + assertTrue(status.worktree) + assertEquals(GhAvailability.OK, status.availability) + assertNull(status.pr) + } + @Test fun `stats syncs away a worktree deleted from disk`() = runBlocking { initRepo() @@ -694,23 +719,110 @@ class KiloWorktreeRpcApiImplTest { } @Test - fun `stats reports managed worktree diff and ahead counts`() = runBlocking { + fun `stats reports committed diff against the base branch`() = runBlocking { initRepo() val created = assertNotNull(api.create(repo.toString(), CreateWorktreeRequestDto("feature/x")).worktree) val dir = Path.of(created.path) Files.writeString(dir.resolve("tracked.txt"), "one\n") git(dir, "add", "tracked.txt") git(dir, "commit", "-m", "feature") + // Working-tree noise a pull request would not contain. Files.writeString(dir.resolve("notes.txt"), "two\nthree\n") val item = api.stats(repo.toString()).items.single { it.path == created.path } - assertEquals(3, item.additions) + assertEquals(1, item.additions, "only the committed line belongs in the PR number") assertEquals(0, item.deletions) + assertEquals(1, item.files, "untracked notes.txt must not count") assertEquals(1, item.ahead) assertEquals(0, item.behind) - // tracked.txt (committed ahead of base) + notes.txt (untracked) = 2 changed files. - assertEquals(2, item.files) + } + + @Test + fun `stats ignores the branch upstream and uses the base branch`() = runBlocking { + initRepo() + val created = assertNotNull(api.create(repo.toString(), CreateWorktreeRequestDto("feature/x")).worktree) + val dir = Path.of(created.path) + Files.writeString(dir.resolve("tracked.txt"), "one\n") + git(dir, "add", "tracked.txt") + git(dir, "commit", "-m", "feature") + // Emulate a fully pushed branch: an upstream that already contains the commit. + git(repo, "branch", "shadow", "feature/x") + git(dir, "branch", "--set-upstream-to=shadow", "feature/x") + + val item = api.stats(repo.toString()).items.single { it.path == created.path } + + assertEquals(1, item.additions, "an in-sync upstream must not zero out the diff") + assertEquals(1, item.ahead) + } + + @Test + fun `stats is unchanged by uncommitted edits`() = runBlocking { + initRepo() + val created = assertNotNull(api.create(repo.toString(), CreateWorktreeRequestDto("feature/x")).worktree) + val dir = Path.of(created.path) + Files.writeString(dir.resolve("tracked.txt"), "one\n") + git(dir, "add", "tracked.txt") + git(dir, "commit", "-m", "feature") + val before = api.stats(repo.toString()).items.single { it.path == created.path } + + Files.writeString(dir.resolve("tracked.txt"), "one\ntwo\n") + Files.writeString(dir.resolve("untracked.txt"), "three\n") + + val after = api.stats(repo.toString()).items.single { it.path == created.path } + + assertEquals(before.additions, after.additions) + assertEquals(before.files, after.files) + } + + @Test + fun `dirty reports staged unstaged and untracked changes`() = runBlocking { + initRepo() + val created = assertNotNull(api.create(repo.toString(), CreateWorktreeRequestDto("feature/x")).worktree) + val dir = Path.of(created.path) + Files.writeString(dir.resolve("tracked.txt"), "one\n") + git(dir, "add", "tracked.txt") + git(dir, "commit", "-m", "feature") + // Staged edit, unstaged edit, and an untracked file. + Files.writeString(dir.resolve("staged.txt"), "s\n") + git(dir, "add", "staged.txt") + Files.writeString(dir.resolve("tracked.txt"), "one\ntwo\n") + Files.writeString(dir.resolve("untracked.txt"), "u\n") + + val item = api.dirty(repo.toString()).items.single { it.path == created.path } + + assertEquals(3, item.additions, "staged + unstaged + untracked lines") + assertEquals(0, item.deletions) + assertEquals(3, item.files) + assertEquals(1, item.untracked) + } + + @Test + fun `dirty reports zero for a clean worktree`() = runBlocking { + initRepo() + val created = assertNotNull(api.create(repo.toString(), CreateWorktreeRequestDto("feature/x")).worktree) + + val item = api.dirty(repo.toString()).items.single { it.path == created.path } + + assertEquals(0, item.files) + assertEquals(0, item.unpushed, "no upstream means no unpushed count") + } + + @Test + fun `dirty counts commits missing from the upstream`() = runBlocking { + initRepo() + val created = assertNotNull(api.create(repo.toString(), CreateWorktreeRequestDto("feature/x")).worktree) + val dir = Path.of(created.path) + git(repo, "branch", "shadow", "feature/x") + git(dir, "branch", "--set-upstream-to=shadow", "feature/x") + Files.writeString(dir.resolve("tracked.txt"), "one\n") + git(dir, "add", "tracked.txt") + git(dir, "commit", "-m", "feature") + + val item = api.dirty(repo.toString()).items.single { it.path == created.path } + + assertEquals(1, item.unpushed) + assertEquals(0, item.files, "committed work is not uncommitted") } @Test @@ -905,24 +1017,20 @@ class KiloWorktreeRpcApiImplTest { val binary = byteArrayOf(0, 1, 2, 3, 0, 5, 127, -1) Files.write(repo.resolve("blob.bin"), binary) - val snapshot = WorktreeTransfer.capture(repo) - val created = assertNotNull(api.create(repo.toString(), CreateWorktreeRequestDto("feature/x")).worktree) - val target = Path.of(created.path) - // Baseline the source status after worktree creation (which adds .kilo bookkeeping) so the - // assertion isolates the effect of apply, which must not touch the source tree. - val before = statusOf(repo) - - val result = WorktreeTransfer.apply(snapshot, repo, target) - assertTrue(result.ok, "apply should succeed: ${result.error}") - - assertEquals("modified\n", Files.readString(target.resolve("tracked.txt"))) - assertEquals("staged content\n", Files.readString(target.resolve("staged.txt"))) - assertEquals("brand new\n", Files.readString(target.resolve("untracked.txt"))) - assertTrue(binary.contentEquals(Files.readAllBytes(target.resolve("blob.bin"))), "binary file should round-trip") - - // The source working tree must be untouched by capture + apply. - assertEquals(before, statusOf(repo), "source working tree must be unchanged") - WorktreeTransfer.cleanup(snapshot) + preserved { + captured { snapshot -> + val target = target() + val result = WorktreeTransfer.apply(snapshot, target) + assertTrue(result.ok, "apply should succeed: ${result.error}") + + assertEquals("modified\n", Files.readString(target.resolve("tracked.txt"))) + assertEquals("original\n", output(target, "show", ":tracked.txt")) + assertEquals("staged content\n", Files.readString(target.resolve("staged.txt"))) + assertEquals("staged content\n", output(target, "show", ":staged.txt")) + assertEquals("brand new\n", Files.readString(target.resolve("untracked.txt"))) + assertContentEquals(binary, Files.readAllBytes(target.resolve("blob.bin"))) + } + } } @Test @@ -931,35 +1039,44 @@ class KiloWorktreeRpcApiImplTest { Files.writeString(repo.resolve("staged.txt"), "staged content\n") git(repo, "add", "staged.txt") - val snapshot = WorktreeTransfer.capture(repo) - val created = assertNotNull(api.create(repo.toString(), CreateWorktreeRequestDto("feature/x")).worktree) - val target = Path.of(created.path) - // Pre-create the staged file in the target so the new-file patch cannot apply cleanly. - Files.writeString(target.resolve("staged.txt"), "conflicting\n") + preserved { + captured { snapshot -> + val target = target() + Files.writeString(target.resolve("staged.txt"), "conflicting\n") - val result = WorktreeTransfer.apply(snapshot, repo, target) + preserved(target) { + val result = WorktreeTransfer.apply(snapshot, target) - assertFalse(result.ok, "apply should fail when the patch conflicts") - assertNotNull(result.error) - WorktreeTransfer.cleanup(snapshot) + assertFalse(result.ok, "apply should fail when the patch conflicts") + assertTrue(assertNotNull(result.error).contains("staged.txt"), result.error) + } + } + } } @Test fun `capture fails instead of reporting a clean tree when git cannot run`() { // No git repository: a failed capture must throw rather than look like "nothing to move". - val err = assertFails { WorktreeTransfer.capture(repo) } + val err = failure(repo) + + assertTrue(err.message.orEmpty().contains("git rev-parse"), "capture failure should explain itself: $err") + } - assertTrue(err.message.orEmpty().isNotBlank(), "capture failure should explain itself") + @Test + fun `moveToWorktree rejects unresolved merge conflicts before creating a worktree`() = runBlocking { + initRepo() + diverge("README.md") + + conflicted("README.md") } @Test fun `moveToWorktree emits ERROR when capture fails`() = runBlocking { val events = api.moveToWorktree(repo.toString(), "ses_1", "feature/x").toList() - assertEquals(MoveStage.CAPTURING, events.first().stage) - val last = events.last() - assertEquals(MoveStage.ERROR, last.stage) - assertTrue(last.error != null, "the error event should explain the failure") + assertEquals(listOf(MoveStage.CAPTURING, MoveStage.ERROR), events.map { it.stage }) + assertTrue(assertNotNull(events.last().error).isNotBlank(), "the error event should explain the failure") + assertFalse(Files.exists(repo.resolve(".kilo/worktrees/feature-x"))) } @Test @@ -969,15 +1086,16 @@ class KiloWorktreeRpcApiImplTest { // The fork resolves a project-level service that no plain unit test provides, so this move // throws after the worktree exists — exactly the case that used to end the flow silently. - val events = api.moveToWorktree(repo.toString(), "ses_1", "feature/x").toList() - - assertEquals(MoveStage.ERROR, events.last().stage) - assertTrue(events.map { it.stage }.containsAll(listOf(MoveStage.CREATING, MoveStage.TRANSFERRING))) - assertFalse( - Files.exists(repo.resolve(".kilo").resolve("worktrees").resolve("feature-x")), - "a failed move must not leave its worktree behind", - ) - assertTrue(api.list(repo.toString()).worktrees.none { it.branch == "feature/x" }, "git must not track the worktree") + preserved { + val events = api.moveToWorktree(repo.toString(), "ses_1", "feature/x").toList() + + assertEquals( + listOf(MoveStage.CAPTURING, MoveStage.CREATING, MoveStage.TRANSFERRING, MoveStage.FORKING, MoveStage.ERROR), + events.map { it.stage }, + ) + assertTrue(assertNotNull(events.last().error).isNotBlank()) + absent() + } } @Test @@ -1003,11 +1121,646 @@ class KiloWorktreeRpcApiImplTest { assertEquals("hello edited\n", Files.readString(repo.resolve("README.md"))) } - private fun statusOf(dir: Path): String { - val cmd = GeneralCommandLine(listOf("git", "status", "--porcelain")).withWorkDirectory(dir.toFile()) - return CapturingProcessHandler(cmd).runProcess(30_000).stdout + @Test + fun `moveToWorktree rejects unresolved conflicts left by a stash pop without a MERGE_HEAD marker`() = runBlocking { + initRepo() + Files.writeString(repo.resolve("README.md"), "stashed\n") + git(repo, "stash", "push") + Files.writeString(repo.resolve("README.md"), "advanced\n") + git(repo, "commit", "-am", "advance") + val popped = runner(repo)(listOf("stash", "pop")) + assertFalse(popped.ok, "the fixture must produce an unresolved stash conflict") + assertFalse( + Files.exists(metadata(repo, "MERGE_HEAD")), + "this fixture must reproduce a conflict with no MERGE_HEAD marker", + ) + + conflicted("README.md") + } + + @Test + fun `moveToWorktree rejects merge conflicts even alongside unrelated staged and unstaged edits`() = runBlocking { + initRepo() + Files.writeString(repo.resolve("other.txt"), "line\n") + git(repo, "add", "other.txt") + git(repo, "commit", "-m", "add other") + diverge("README.md") + Files.writeString(repo.resolve("other.txt"), "staged change\n") + git(repo, "add", "other.txt") + Files.writeString(repo.resolve("other.txt"), "unstaged change\n") + Files.write(repo.resolve("untracked.bin"), byteArrayOf(0, 1, -1, 127)) + assertEquals("staged change\n", output(repo, "show", ":other.txt")) + assertEquals("MM other.txt\u0000", output(repo, "status", "--porcelain=v1", "-z", "--", "other.txt")) + + conflicted("README.md") + } + + @Test + fun `moveToWorktree rejects an add-add conflict with no common ancestor stage`() = runBlocking { + initRepo() + diverge("new.txt") + + conflicted("new.txt", stages = listOf("2", "3")) + } + + @Test + fun `moveToWorktree lists every conflicted path exactly once when multiple files conflict`() = runBlocking { + initRepo() + Files.writeString(repo.resolve("b.txt"), "base\n") + git(repo, "add", "b.txt") + git(repo, "commit", "-m", "add b") + diverge("README.md", "b.txt") + + conflicted("README.md", "b.txt") + } + + @Test + fun `moveToWorktree reports conflicted filenames without quoting or splitting control characters`() = runBlocking { + assumeFalse(SystemInfo.isWindows, "Windows forbids quotes and control characters in filenames") + initRepo() + val name = "my \"文件\"\tname\n.md" + Files.writeString(repo.resolve(name), "hello\n") + git(repo, "add", name) + git(repo, "commit", "-m", "add unusual filename") + diverge(name) + + conflicted(name) + } + + @Test + fun `moveToWorktree rejects a merge conflict outside the given subdirectory`() = runBlocking { + initRepo() + Files.createDirectories(repo.resolve("sub")) + Files.writeString(repo.resolve("sub/file.txt"), "hello\n") + git(repo, "add", "sub/file.txt") + git(repo, "commit", "-m", "add sub file") + diverge("README.md") + + conflicted("README.md", dir = repo.resolve("sub")) + } + + @Test + fun `moveToWorktree from a subdirectory transfers root nested and sibling paths without flattening`() = runBlocking { + initRepo() + val tracked = listOf("README.md", "sub/nested/tracked.txt", "sibling/tracked.txt") + tracked.forEach { name -> + Files.createDirectories(repo.resolve(name).parent) + Files.writeString(repo.resolve(name), "base $name\n") + } + git(repo, "add", "--", *tracked.toTypedArray()) + git(repo, "commit", "-m", "add nested files") + tracked.forEach { Files.writeString(repo.resolve(it), "staged $it\n") } + git(repo, "add", "--", *tracked.toTypedArray()) + tracked.forEach { Files.writeString(repo.resolve(it), "unstaged $it\n") } + val untracked = listOf("notes.txt", "sub/nested/notes.txt", "sibling/notes.txt") + untracked.forEach { Files.writeString(repo.resolve(it), "untracked $it\n") } + val rev = head(repo, "HEAD") + + preserved { + val events = api.moveToWorktree(repo.resolve("sub").toString(), null, "feature/x").toList() + + assertEquals( + listOf(MoveStage.CAPTURING, MoveStage.CREATING, MoveStage.TRANSFERRING, MoveStage.DONE), + events.map { it.stage }, + events.last().error, + ) + val target = Path.of(assertNotNull(events.last().worktree).path) + assertEquals(rev, head(target, "HEAD")) + tracked.forEach { name -> + assertEquals("staged $name\n", output(target, "show", ":$name")) + assertEquals("unstaged $name\n", Files.readString(target.resolve(name))) + assertEquals("MM $name\u0000", output(target, "status", "--porcelain=v1", "-z", "--", name)) + } + untracked.forEach { name -> + assertEquals("untracked $name\n", Files.readString(target.resolve(name))) + } + assertEquals( + untracked.toSet(), + output(target, "ls-files", "--others", "--exclude-standard", "-z").split('\u0000').filter { it.isNotEmpty() }.toSet(), + ) + assertFalse(Files.exists(target.resolve("tracked.txt"))) + assertFalse(Files.exists(target.resolve("nested"))) + } + } + + @Test + fun `moveToWorktree from a linked checkout captures its own HEAD index and files instead of the main checkout`() = runBlocking { + initRepo() + val source = remote.resolve("source") + git(repo, "worktree", "add", "-b", "source", source.toString()) + Files.writeString(source.resolve("README.md"), "linked base\n") + git(source, "commit", "-am", "advance linked checkout") + val rev = head(source, "HEAD") + assertFalse(rev == head(repo, "HEAD"), "linked and main fixtures must have different commits") + Files.writeString(repo.resolve("README.md"), "main staged\n") + git(repo, "add", "README.md") + Files.writeString(repo.resolve("README.md"), "main unstaged\n") + Files.writeString(repo.resolve("notes.txt"), "main notes\n") + Files.writeString(repo.resolve("main-only.txt"), "main only\n") + Files.writeString(source.resolve("README.md"), "linked staged\n") + git(source, "add", "README.md") + Files.writeString(source.resolve("README.md"), "linked unstaged\n") + Files.writeString(source.resolve("notes.txt"), "linked notes\n") + Files.createDirectories(source.resolve("sub")) + + preserved { + preserved(source) { + val events = api.moveToWorktree(source.resolve("sub").toString(), null, "feature/x").toList() + + assertEquals( + listOf(MoveStage.CAPTURING, MoveStage.CREATING, MoveStage.TRANSFERRING, MoveStage.DONE), + events.map { it.stage }, + events.last().error, + ) + val target = Path.of(assertNotNull(events.last().worktree).path) + assertEquals(repo.resolve(".kilo/worktrees/feature-x").toRealPath(), target.toRealPath()) + assertEquals(rev, head(target, "HEAD")) + assertEquals("linked staged\n", output(target, "show", ":README.md")) + assertEquals("linked unstaged\n", Files.readString(target.resolve("README.md"))) + assertEquals("MM README.md\u0000", output(target, "status", "--porcelain=v1", "-z", "--", "README.md")) + assertEquals("linked notes\n", Files.readString(target.resolve("notes.txt"))) + assertFalse(Files.exists(target.resolve("main-only.txt"))) + assertFalse(Files.exists(source.resolve(".kilo/worktrees/feature-x"))) + } + } } + @Test + fun `worktree transfer preserves staged and unstaged edits to the same file`() = runBlocking { + initRepo() + Files.writeString(repo.resolve("README.md"), "staged version\n") + git(repo, "add", "README.md") + Files.writeString(repo.resolve("README.md"), "unstaged version\n") + + preserved { + captured { snapshot -> + assertEquals(head(repo, "HEAD"), snapshot.head) + val target = target() + val result = WorktreeTransfer.apply(snapshot, target) + + assertTrue(result.ok, "apply should succeed: ${result.error}") + assertEquals("staged version\n", output(target, "show", ":README.md")) + assertEquals("unstaged version\n", Files.readString(target.resolve("README.md"))) + assertEquals("MM README.md\u0000", output(target, "status", "--porcelain=v1", "-z", "--", "README.md")) + } + } + } + + @Test + fun `worktree transfer preserves staged and unstaged versions of the same tracked binary`() = runBlocking { + initRepo() + Files.write(repo.resolve("blob.bin"), byteArrayOf(0, 1, 2, 3, 4, 5, 127, -1)) + git(repo, "add", "blob.bin") + git(repo, "commit", "-m", "add binary") + val staged = byteArrayOf(9, 8, 7, 0, 6, 5, -1, 127) + Files.write(repo.resolve("blob.bin"), staged) + git(repo, "add", "blob.bin") + val index = head(repo, ":blob.bin") + val unstaged = byteArrayOf(0, -1, 5, 6, 7, 8, 9, 10) + Files.write(repo.resolve("blob.bin"), unstaged) + + preserved { + captured { snapshot -> + assertTrue(Files.readString(assertNotNull(snapshot.staged)).contains("GIT binary patch")) + assertTrue(Files.readString(assertNotNull(snapshot.unstaged)).contains("GIT binary patch")) + val target = target() + val result = WorktreeTransfer.apply(snapshot, target) + + assertTrue(result.ok, "apply should succeed: ${result.error}") + assertEquals(index, head(target, ":blob.bin")) + assertContentEquals(unstaged, Files.readAllBytes(target.resolve("blob.bin"))) + assertEquals("MM blob.bin\u0000", output(target, "status", "--porcelain=v1", "-z", "--", "blob.bin")) + } + } + } + + @Test + fun `worktree transfer round trips untracked Unicode quoted and control character filenames`() = runBlocking { + assumeFalse(SystemInfo.isWindows, "Windows forbids quotes and control characters in filenames") + initRepo() + val names = listOf("my new file.txt", "文件.txt", "quoted \"name\"\twith\nlines.txt") + names.forEach { Files.writeString(repo.resolve(it), "content for $it\n") } + + preserved { + captured { snapshot -> + assertEquals(names.toSet(), snapshot.untracked.keys) + val target = target() + val result = WorktreeTransfer.apply(snapshot, target) + + assertTrue(result.ok, "apply should succeed: ${result.error}") + names.forEach { name -> + assertEquals("content for $name\n", Files.readString(target.resolve(name))) + } + assertEquals( + names.toSet(), + output(target, "ls-files", "--others", "--exclude-standard", "-z").split('\u0000').filter { it.isNotEmpty() }.toSet(), + ) + } + } + } + + @Test + fun `captured tracked and untracked content survives edits to the source after capture`() = runBlocking { + initRepo() + Files.writeString(repo.resolve("README.md"), "staged version\n") + git(repo, "add", "README.md") + Files.writeString(repo.resolve("README.md"), "unstaged version\n") + Files.writeString(repo.resolve("untracked.txt"), "original\n") + + captured { snapshot -> + val copy = assertNotNull(snapshot.untracked["untracked.txt"]) + assertFalse(copy.toRealPath().startsWith(repo.toRealPath()), "the snapshot must own an independent temporary copy") + Files.writeString(repo.resolve("README.md"), "mutated after capture\n") + git(repo, "add", "README.md") + Files.writeString(repo.resolve("untracked.txt"), "mutated after capture\n") + assertEquals("original\n", Files.readString(copy)) + + preserved { + val target = target() + val result = WorktreeTransfer.apply(snapshot, target) + + assertTrue(result.ok, "apply should succeed: ${result.error}") + assertEquals("staged version\n", output(target, "show", ":README.md")) + assertEquals("unstaged version\n", Files.readString(target.resolve("README.md"))) + assertEquals("original\n", Files.readString(target.resolve("untracked.txt"))) + } + } + } + + @Test + fun `captured untracked file content survives deletion of the source after capture`() = runBlocking { + initRepo() + val bytes = byteArrayOf(0, -1, 127, 10, 13) + Files.write(repo.resolve("untracked.bin"), bytes) + + captured { snapshot -> + val copy = assertNotNull(snapshot.untracked["untracked.bin"]) + Files.delete(repo.resolve("untracked.bin")) + assertContentEquals(bytes, Files.readAllBytes(copy)) + + preserved { + val target = target() + val result = WorktreeTransfer.apply(snapshot, target) + + assertTrue(result.ok, "apply should succeed: ${result.error}") + assertContentEquals(bytes, Files.readAllBytes(target.resolve("untracked.bin"))) + assertFalse(Files.exists(repo.resolve("untracked.bin")), "apply must not recreate the deleted source") + } + } + } + + @Test + fun `worktree transfer reports failure when an unstaged patch cannot apply`() = runBlocking { + initRepo() + Files.writeString(repo.resolve("README.md"), "unstaged version\n") + + preserved { + captured { snapshot -> + assertNull(snapshot.staged) + assertNotNull(snapshot.unstaged) + val target = target() + Files.writeString(target.resolve("README.md"), "conflicting target\n") + + preserved(target) { + val result = WorktreeTransfer.apply(snapshot, target) + + assertFalse(result.ok, "an unstaged patch failure must not report success") + val error = assertNotNull(result.error) + assertTrue(error.contains("Unstaged patch failed"), error) + assertTrue(error.contains("README.md"), error) + } + } + } + } + + @Test + fun `worktree transfer reports a locked target index without changing its index or working files`() = runBlocking { + initRepo() + Files.writeString(repo.resolve("README.md"), "staged version\n") + git(repo, "add", "README.md") + + preserved { + captured { snapshot -> + assertNotNull(snapshot.staged) + val target = target() + val lock = metadata(target, "index.lock") + Files.createFile(lock) + try { + val index = metadata(target, "index") + val bytes = Files.readAllBytes(index) + preserved(target) { + val result = WorktreeTransfer.apply(snapshot, target) + + assertFalse(result.ok, "apply must not report success when the target index is locked") + val error = assertNotNull(result.error) + assertTrue(error.contains("Staged patch failed"), error) + assertTrue(error.contains("index.lock"), error) + assertContentEquals(bytes, Files.readAllBytes(index), "a failed index write must leave the index intact") + assertTrue(Files.exists(lock), "apply must not remove another process's lock") + } + } finally { + Files.deleteIfExists(lock) + } + } + } + } + + @Test + fun `worktree transfer refuses to overwrite an existing untracked destination`() = runBlocking { + initRepo() + Files.writeString(repo.resolve("untracked.txt"), "source content\n") + + preserved { + captured { snapshot -> + val target = target() + Files.writeString(target.resolve("untracked.txt"), "keep target content\n") + + preserved(target) { + val result = WorktreeTransfer.apply(snapshot, target) + + assertFalse(result.ok, "a pre-existing destination must not be overwritten") + assertTrue(assertNotNull(result.error).contains("untracked.txt"), result.error) + } + } + } + } + + @Test + fun `worktree transfer fails when an untracked file cannot be written to the target`() = runBlocking { + initRepo() + Files.createDirectories(repo.resolve("sub")) + Files.writeString(repo.resolve("sub/deep.txt"), "deep\n") + + preserved { + captured { snapshot -> + val target = target() + Files.writeString(target.resolve("sub"), "blocking file\n") + + preserved(target) { + val result = WorktreeTransfer.apply(snapshot, target) + + assertFalse(result.ok, "apply should fail when an untracked file cannot be written") + assertTrue(assertNotNull(result.error).contains("sub/deep.txt"), result.error) + assertFalse(Files.exists(target.resolve("sub/deep.txt"))) + } + } + } + } + + @Test + fun `worktree transfer names the untracked path when its captured copy is missing`() = runBlocking { + initRepo() + Files.writeString(repo.resolve("untracked.txt"), "source content\n") + + preserved { + captured { snapshot -> + Files.delete(assertNotNull(snapshot.untracked["untracked.txt"])) + val target = target() + + preserved(target) { + val result = WorktreeTransfer.apply(snapshot, target) + + assertFalse(result.ok, "missing snapshot content must not be silently skipped") + assertTrue(assertNotNull(result.error).contains("untracked.txt"), result.error) + assertFalse(Files.exists(target.resolve("untracked.txt"))) + } + } + } + } + + @Test + fun `moveToWorktree rolls back the worktree and branch when copying an untracked file fails`() = runBlocking { + initRepo() + Files.writeString(repo.resolve("README.md"), "staged version\n") + git(repo, "add", "README.md") + Files.writeString(repo.resolve("README.md"), "unstaged version\n") + Files.createDirectories(repo.resolve("sub")) + Files.writeString(repo.resolve("sub/deep.txt"), "source content\n") + + preserved { + val events = api.moveToWorktree(repo.toString(), null, "feature/x").onEach { event -> + if (event.stage == MoveStage.TRANSFERRING) { + val target = repo.resolve(".kilo/worktrees/feature-x") + assertTrue(Files.isDirectory(target)) + assertEquals(head(repo, "HEAD"), head(repo, "refs/heads/feature/x")) + Files.writeString(target.resolve("sub"), "blocking file\n") + } + }.toList() + + assertEquals( + listOf(MoveStage.CAPTURING, MoveStage.CREATING, MoveStage.TRANSFERRING, MoveStage.ERROR), + events.map { it.stage }, + ) + assertTrue(assertNotNull(events.last().error).contains("sub/deep.txt"), events.last().error) + assertNull(events.last().worktree) + absent() + } + } + + @Test + fun `moveToWorktree rejects oversized untracked content instead of moving a partial snapshot`() = runBlocking { + initRepo() + Files.writeString(repo.resolve("README.md"), "staged version\n") + git(repo, "add", "README.md") + Files.writeString(repo.resolve("README.md"), "unstaged version\n") + Files.writeString(repo.resolve("a-small.txt"), "capture before the oversized file\n") + Files.write(repo.resolve("z-large.bin"), ByteArray(10 * 1024 * 1024 + 1) { (it % 251).toByte() }) + + rejected { + assertTrue(it.contains("z-large.bin"), it) + assertTrue(it.contains("exceeds"), it) + assertTrue(it.contains("10485760"), it) + assertTrue(it.contains("limit"), it) + } + } + + @Test + fun `moveToWorktree rejects an unreadable untracked regular file without creating a branch`() = runBlocking { + assumeTrue(Files.getFileStore(repo).supportsFileAttributeView("posix"), "the fixture requires POSIX permissions") + initRepo() + val file = repo.resolve("unreadable.txt") + Files.writeString(file, "must not be skipped\n") + val permissions = Files.getPosixFilePermissions(file) + + preserved { + Files.setPosixFilePermissions(file, emptySet()) + try { + assumeFalse(Files.isReadable(file), "the fixture requires read access to be denied") + val message = "Failed to capture untracked file unreadable.txt" + assertTrue(assertNotNull(failure(repo).message).contains(message)) + val events = api.moveToWorktree(repo.toString(), null, "feature/x").toList() + + assertEquals(listOf(MoveStage.CAPTURING, MoveStage.ERROR), events.map { it.stage }) + assertTrue(assertNotNull(events.last().error).contains(message), events.last().error) + assertNull(events.last().worktree) + absent() + } finally { + Files.setPosixFilePermissions(file, permissions) + } + } + } + + @Test + fun `moveToWorktree rejects an unsupported untracked directory symlink`() = runBlocking { + assumeFalse(SystemInfo.isWindows, "creating symbolic links on Windows may require elevated privileges") + initRepo() + Files.createSymbolicLink(repo.resolve("unsupported"), Path.of(".")) + + rejected { + assertTrue(it.contains("Failed to capture untracked file unsupported"), it) + assertTrue(it.contains("Not a regular file"), it) + } + } + + @Test + fun `moveToWorktree rejects untracked content that cannot be read through a dangling symlink`() = runBlocking { + assumeFalse(SystemInfo.isWindows, "creating symbolic links on Windows may require elevated privileges") + initRepo() + Files.createSymbolicLink(repo.resolve("unreadable.txt"), Path.of("missing.txt")) + + rejected { + assertTrue(it.contains("Failed to capture untracked file unreadable.txt"), it) + } + } + + @Test + fun `cleanup deletes every staged unstaged and untracked temporary file`() = runBlocking { + initRepo() + Files.writeString(repo.resolve("README.md"), "staged version\n") + git(repo, "add", "README.md") + Files.writeString(repo.resolve("README.md"), "unstaged version\n") + Files.writeString(repo.resolve("untracked.txt"), "temp content\n") + Files.write(repo.resolve("untracked.bin"), byteArrayOf(0, -1, 127)) + + preserved { + captured { snapshot -> + assertEquals(setOf("untracked.txt", "untracked.bin"), snapshot.untracked.keys) + val files = listOf(assertNotNull(snapshot.staged), assertNotNull(snapshot.unstaged)) + snapshot.untracked.values + assertEquals(4, files.distinct().size) + assertTrue(Files.isDirectory(snapshot.storage)) + files.forEach { file -> + assertTrue(Files.isRegularFile(file), "snapshot file must exist: $file") + assertTrue(file.toRealPath().startsWith(snapshot.storage.toRealPath())) + assertFalse(file.toRealPath().startsWith(repo.toRealPath()), "snapshot file must be independently owned: $file") + } + + WorktreeTransfer.cleanup(snapshot) + + files.forEach { assertFalse(Files.exists(it), "cleanup must remove $it") } + assertFalse(Files.exists(snapshot.storage), "cleanup must remove the snapshot's owned directory") + } + } + } + + private fun diverge(vararg names: String) { + val base = output(repo, "branch", "--show-current").trim() + git(repo, "checkout", "-b", "conflicting") + names.forEach { Files.writeString(repo.resolve(it), "branch $it\n") } + git(repo, "add", "--", *names) + git(repo, "commit", "-m", "change on branch") + git(repo, "checkout", base) + names.forEach { Files.writeString(repo.resolve(it), "main $it\n") } + git(repo, "add", "--", *names) + git(repo, "commit", "-m", "change on main") + val merged = runner(repo)(listOf("merge", "conflicting")) + assertFalse(merged.ok, "the fixture must produce unresolved conflicts") + } + + private suspend fun conflicted( + vararg names: String, + dir: Path = repo, + stages: List = listOf("1", "2", "3"), + ) { + val entries = output(repo, "ls-files", "--unmerged", "-z").split('\u0000').filter { it.isNotEmpty() } + assertEquals(names.toSet(), entries.map { it.substringAfter('\t') }.toSet(), "the fixture must contain real unmerged entries") + names.forEach { name -> + assertEquals( + stages, + entries.filter { it.substringAfter('\t') == name }.map { it.substringBefore('\t').substringAfterLast(' ') }, + "unexpected index stages for $name", + ) + } + rejected(dir) { + assertEquals("Resolve merge conflicts before moving to a worktree: ${names.joinToString(", ")}", it) + } + } + + private suspend fun rejected(dir: Path = repo, check: (String) -> Unit) { + preserved { + val message = assertNotNull(failure(dir).message) + check(message) + val events = api.moveToWorktree(dir.toString(), null, "feature/x").toList() + + assertEquals(listOf(MoveStage.CAPTURING, MoveStage.ERROR), events.map { it.stage }) + assertEquals(message, events.last().error) + events.forEach { + assertNull(it.worktree) + assertNull(it.session) + } + absent() + } + } + + private fun absent() { + assertFalse(Files.exists(repo.resolve(".kilo/worktrees/feature-x")), "a failed move must not leave a worktree directory") + val listed = parseWorktreeList(output(repo, "worktree", "list", "--porcelain")) + assertEquals(1, listed.size, "a failed move must not leave a worktree registration: $listed") + assertTrue(listed.single().main) + assertEquals( + "", + output(repo, "for-each-ref", "--format=%(refname)", "refs/heads/feature/x"), + "a failed move must not leave its branch ref even if the worktree was removed", + ) + } + + private inline fun preserved(dir: Path = repo, block: () -> Unit) { + val rev = head(dir, "HEAD") + val ref = output(dir, "symbolic-ref", "HEAD") + val index = output(dir, "ls-files", "--stage", "-z") + val status = output(dir, "status", "--porcelain=v1", "-z", "--untracked-files=all", "--", ".", ":(exclude).kilo") + val files = output(dir, "ls-files", "--cached", "--others", "--exclude-standard", "-z", "--", ".", ":(exclude).kilo") + .split('\u0000').filter { it.isNotEmpty() }.distinct().associateWith { bytes(dir.resolve(it)) } + + block() + + assertEquals(rev, head(dir, "HEAD"), "HEAD must be unchanged in $dir") + assertEquals(ref, output(dir, "symbolic-ref", "HEAD"), "branch ref must be unchanged in $dir") + assertEquals(index, output(dir, "ls-files", "--stage", "-z"), "index stages must be unchanged in $dir") + assertEquals( + status, + output(dir, "status", "--porcelain=v1", "-z", "--untracked-files=all", "--", ".", ":(exclude).kilo"), + "status must be unchanged in $dir", + ) + files.forEach { (name, content) -> + assertContentEquals(content, bytes(dir.resolve(name)), "bytes must be unchanged: ${dir.resolve(name)}") + } + } + + private fun bytes(file: Path): ByteArray? { + if (Files.isSymbolicLink(file)) return Files.readSymbolicLink(file).toString().toByteArray() + if (!Files.exists(file)) return null + return Files.readAllBytes(file) + } + + private inline fun captured(dir: Path = repo, block: (WorktreeTransfer.Snapshot) -> Unit) { + val snapshot = WorktreeTransfer.capture(dir) + try { + block(snapshot) + } finally { + WorktreeTransfer.cleanup(snapshot) + } + } + + private fun failure(dir: Path): Throwable = assertFails { captured(dir) { Unit } } + + private suspend fun target(): Path { + val result = api.create(repo.toString(), CreateWorktreeRequestDto("feature/x")) + return Path.of(assertNotNull(result.worktree, "create failed: ${result.error}").path) + } + + private fun metadata(dir: Path, name: String): Path = + Path.of(output(dir, "rev-parse", "--path-format=absolute", "--git-path", name).trimEnd('\r', '\n')) + private fun initRepo() { git(repo, "init") git(repo, "config", "user.email", "test@kilo.ai") diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/SetupScriptResolutionTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/SetupScriptResolutionTest.kt new file mode 100644 index 000000000000..fd929af29a0d --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/SetupScriptResolutionTest.kt @@ -0,0 +1,156 @@ +package ai.kilocode.backend.rpc + +import ai.kilocode.rpc.dto.SetupScriptKind +import kotlinx.coroutines.runBlocking +import java.nio.file.Files +import java.nio.file.Path +import kotlin.io.path.writeText +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Covers [resolveSetupScript] directly (pure, no [com.intellij.openapi.util.SystemInfo]) and + * [KiloWorkspaceRpcApiImpl.setupScriptTarget] (the RPC method, which needs no [com.intellij.openapi.project.Project] + * unlike `openSetupScript`, so it -- like the rest of this file's tests -- runs without a platform + * test fixture). + */ +class SetupScriptResolutionTest { + private val dirs = mutableListOf() + + @AfterTest + fun tearDown() { + dirs.forEach { delete(it) } + dirs.clear() + } + + @Test + fun `posix prefers setup-script over setup-script sh`() { + val root = repo() + write(root, "setup-script") + write(root, "setup-script.sh") + + val target = resolveSetupScript(root, windows = false) + + assertTrue(target.exists) + assertEquals(SetupScriptKind.POSIX, target.kind) + assertEquals(root.resolve(".kilo").resolve("setup-script").toString(), target.path) + } + + @Test + fun `posix falls back to setup-script sh when only that exists`() { + val root = repo() + write(root, "setup-script.sh") + + val target = resolveSetupScript(root, windows = false) + + assertTrue(target.exists) + assertEquals(SetupScriptKind.POSIX, target.kind) + assertEquals(root.resolve(".kilo").resolve("setup-script.sh").toString(), target.path) + } + + @Test + fun `posix default is setup-script when nothing exists`() { + val root = repo() + + val target = resolveSetupScript(root, windows = false) + + assertFalse(target.exists) + assertEquals(SetupScriptKind.POSIX, target.kind) + assertEquals(root.resolve(".kilo").resolve("setup-script").toString(), target.path) + } + + @Test + fun `windows prefers ps1 over cmd and bat`() { + val root = repo() + write(root, "setup-script.ps1") + write(root, "setup-script.cmd") + write(root, "setup-script.bat") + + val target = resolveSetupScript(root, windows = true) + + assertTrue(target.exists) + assertEquals(SetupScriptKind.POWERSHELL, target.kind) + assertEquals(root.resolve(".kilo").resolve("setup-script.ps1").toString(), target.path) + } + + @Test + fun `windows prefers cmd over bat when ps1 is absent`() { + val root = repo() + write(root, "setup-script.cmd") + write(root, "setup-script.bat") + + val target = resolveSetupScript(root, windows = true) + + assertTrue(target.exists) + assertEquals(SetupScriptKind.CMD, target.kind) + assertEquals(root.resolve(".kilo").resolve("setup-script.cmd").toString(), target.path) + } + + @Test + fun `windows default is setup-script ps1 when nothing exists`() { + val root = repo() + + val target = resolveSetupScript(root, windows = true) + + assertFalse(target.exists) + assertEquals(SetupScriptKind.POWERSHELL, target.kind) + assertEquals(root.resolve(".kilo").resolve("setup-script.ps1").toString(), target.path) + } + + @Test + fun `posix candidate is not resolved on windows`() { + val root = repo() + write(root, "setup-script") + write(root, "setup-script.sh") + + val target = resolveSetupScript(root, windows = true) + + assertFalse(target.exists) + assertEquals(SetupScriptKind.POWERSHELL, target.kind) + } + + @Test + fun `windows candidates are not resolved on posix`() { + val root = repo() + write(root, "setup-script.ps1") + write(root, "setup-script.cmd") + write(root, "setup-script.bat") + + val target = resolveSetupScript(root, windows = false) + + assertFalse(target.exists) + assertEquals(SetupScriptKind.POSIX, target.kind) + } + + @Test + fun `rpc method resolves the setup script for a directory`() = runBlocking { + val root = repo() + write(root, "setup-script") + + val target = KiloWorkspaceRpcApiImpl().setupScriptTarget(root.toString()) + + assertTrue(target.exists) + assertEquals(root.resolve(".kilo").resolve("setup-script").toString(), target.path) + } + + private fun repo(): Path { + val dir = Files.createTempDirectory("kilo-setup-script") + dirs.add(dir) + return dir + } + + private fun write(root: Path, name: String) { + val dir = root.resolve(".kilo") + Files.createDirectories(dir) + dir.resolve(name).writeText("# test\n") + } + + private fun delete(dir: Path) { + Files.walk(dir).use { paths -> + paths.sorted(Comparator.reverseOrder()).forEach { Files.deleteIfExists(it) } + } + } +} diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/TestLog.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/TestLog.kt index b9efae14924a..a878b49247a6 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/TestLog.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/TestLog.kt @@ -12,10 +12,11 @@ class TestLog : KiloLog { get() = synchronized(lock) { items.toList() } override var isDebugEnabled: Boolean = true - fun awaitMessage(timeout: Long = 5_000, predicate: (String) -> Boolean): Boolean { + /** Wait until at least [count] captured messages match [predicate]. */ + fun awaitMessage(timeout: Long = 5_000, count: Int = 1, predicate: (String) -> Boolean): Boolean { val end = System.currentTimeMillis() + timeout synchronized(lock) { - while (items.none(predicate)) { + while (items.count(predicate) < count) { val wait = end - System.currentTimeMillis() if (wait <= 0) return false lock.wait(wait) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloToolWindowFactory.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloToolWindowFactory.kt index 45ef35c80e1d..9e2e3a03fbda 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloToolWindowFactory.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloToolWindowFactory.kt @@ -6,6 +6,7 @@ import ai.kilocode.client.app.KiloSessionService import ai.kilocode.client.session.SessionManager import ai.kilocode.client.session.SessionSidePanelManager import ai.kilocode.client.telemetry.Telemetry +import ai.kilocode.client.agentManager.worktree.GhStatusCoordinator import ai.kilocode.client.agentManager.worktree.KiloWorktreeService import ai.kilocode.client.agentManager.SidePanelKeys import ai.kilocode.client.agentManager.SidePanelMode @@ -154,9 +155,14 @@ internal class KiloToolWindowSetupService( } val listener = object : ContentManagerListener { override fun selectionChanged(event: ContentManagerEvent) { - if (event.operation == ContentManagerEvent.ContentOperation.add && event.content === agentContent) { - agentManagerPanel.refresh() - } + if (event.operation != ContentManagerEvent.ContentOperation.add) return + // Either direction: switching tabs is a deliberate act that often follows an + // authorization or PR change made elsewhere. The coordinator drops the submit + // when a probe is already running or too recent, so tab churn cannot stack up + // gh calls. Chat needs nothing further — SessionUi re-reads branch/PR state + // itself once it becomes showing. + service().sync("tab-switch") + if (event.content === agentContent) agentManagerPanel.refresh() } } toolWindow.contentManager.addContentManagerListener(listener) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/OpenConfigActions.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/OpenConfigActions.kt index 01054601b01a..59918e9ef15e 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/OpenConfigActions.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/OpenConfigActions.kt @@ -1,6 +1,7 @@ package ai.kilocode.client.actions import ai.kilocode.client.KiloNotifications +import ai.kilocode.client.agentManager.worktree.WorktreeDataKeys import ai.kilocode.client.app.KiloWorkspaceService import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.telemetry.Telemetry @@ -79,3 +80,38 @@ class OpenGlobalConfigAction : ConfigAction( } } } + +class OpenSetupScriptAction : AnAction( + KiloBundle.message("action.Kilo.OpenSetupScript.text"), + KiloBundle.message("action.Kilo.OpenSetupScript.description"), + null, +), DumbAware { + override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT + + /** Worktree-row-only: hidden on the main worktree row, like Run. */ + override fun update(e: AnActionEvent) { + if (e.getData(WorktreeDataKeys.WORKTREE)?.main == true) { + e.presentation.isEnabledAndVisible = false + return + } + val dir = e.workspaceDirectory() + val service = service() + val target = dir?.let { service.setupScript[it] } + e.presentation.isEnabledAndVisible = dir != null + e.presentation.text = KiloBundle.message( + if (target?.exists == false) "action.Kilo.CreateSetupScript.text" else "action.Kilo.OpenSetupScript.text", + ) + + if (dir != null && target == null) { + service.refreshSetupScriptTarget(dir) + } + } + + override fun actionPerformed(e: AnActionEvent) { + val dir = e.workspaceDirectory() ?: return + Telemetry.send("Worktree Setup Script Opened", mapOf("surface" to "worktree_row")) + service().openSetupScript(dir) { ok -> + if (!ok) KiloNotifications.error(KiloBundle.message("action.Kilo.OpenConfig.failed")) + } + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/OpenWorktreeLocalDiffAction.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/OpenWorktreeLocalDiffAction.kt new file mode 100644 index 000000000000..ebaa0346b7bf --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/OpenWorktreeLocalDiffAction.kt @@ -0,0 +1,23 @@ +package ai.kilocode.client.actions + +import ai.kilocode.client.agentManager.SidePanelKeys +import ai.kilocode.client.agentManager.worktree.WorktreeDataKeys +import com.intellij.openapi.actionSystem.ActionUpdateThread +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent + +class OpenWorktreeLocalDiffAction : AnAction() { + override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.EDT + + override fun update(e: AnActionEvent) { + val panel = e.getData(SidePanelKeys.WORKTREE_PANEL) + val item = e.getData(WorktreeDataKeys.WORKTREE) + e.presentation.isEnabledAndVisible = panel != null && panel.canOpenLocalDiff(item) + } + + override fun actionPerformed(e: AnActionEvent) { + val panel = e.getData(SidePanelKeys.WORKTREE_PANEL) ?: return + val item = e.getData(WorktreeDataKeys.WORKTREE) ?: return + if (panel.canOpenLocalDiff(item)) panel.openLocalDiff(item) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/RunWorktreeSetupScriptAction.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/RunWorktreeSetupScriptAction.kt new file mode 100644 index 000000000000..c08510921fba --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/RunWorktreeSetupScriptAction.kt @@ -0,0 +1,23 @@ +package ai.kilocode.client.actions + +import ai.kilocode.client.agentManager.SidePanelKeys +import ai.kilocode.client.agentManager.worktree.WorktreeDataKeys +import com.intellij.openapi.actionSystem.ActionUpdateThread +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent + +class RunWorktreeSetupScriptAction : AnAction() { + override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.EDT + + override fun update(e: AnActionEvent) { + val panel = e.getData(SidePanelKeys.WORKTREE_PANEL) + val item = e.getData(WorktreeDataKeys.WORKTREE) + e.presentation.isEnabledAndVisible = panel != null && panel.canRunSetup(item) + } + + override fun actionPerformed(e: AnActionEvent) { + val panel = e.getData(SidePanelKeys.WORKTREE_PANEL) ?: return + val item = e.getData(WorktreeDataKeys.WORKTREE) ?: return + if (panel.canRunSetup(item)) panel.runSetup(item) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/AgentManagerPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/AgentManagerPanel.kt index 8f44a2c8f879..de2ec8844fd0 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/AgentManagerPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/AgentManagerPanel.kt @@ -13,6 +13,8 @@ import ai.kilocode.client.agentManager.worktree.WorktreeIcons import ai.kilocode.client.agentManager.worktree.WorktreeStatusBinding import ai.kilocode.client.agentManager.worktree.WorktreeStatusService import ai.kilocode.client.agentManager.worktree.WorktreeNameCache +import ai.kilocode.client.agentManager.worktree.runWorktreeSetupScript +import ai.kilocode.client.app.KiloWorkspaceService import ai.kilocode.client.agentManager.worktree.WorktreeEditorMatchers import ai.kilocode.client.agentManager.worktree.WorktreeSessionEditorMatcher import ai.kilocode.client.agentManager.worktree.WorktreeSessionEditorKind @@ -22,9 +24,8 @@ import ai.kilocode.client.agentManager.worktree.normalizeWorktreePath import ai.kilocode.client.agentManager.worktree.worktreeSessionParams import ai.kilocode.client.ui.prTooltip import ai.kilocode.client.ui.style -import ai.kilocode.client.diff.KiloDiffEditorKind -import ai.kilocode.client.diff.diffParams -import ai.kilocode.client.diff.ensureDiffEditorKind +import ai.kilocode.client.diff.KiloDiffComparison +import ai.kilocode.client.diff.openKiloDiff import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.session.SessionActivityKind import ai.kilocode.client.telemetry.Telemetry @@ -113,7 +114,10 @@ class AgentManagerPanel( open(item, focus) }, menu = ActiveListMenu(WorktreeDataKeys.WORKTREE, group, element = { row -> - (row as? WorktreeRow)?.dto?.takeIf { canRename(it) || canDelete(it) || canOpenPr(it) || canOpenDiff(it) } + (row as? WorktreeRow)?.dto?.takeIf { + canRename(it) || canDelete(it) || canOpenPr(it) || canOpenDiff(it) || canOpenLocalDiff(it) || + canOpenSetupScript(it) || canRunSetup(it) + } }), reorder = ActiveListReorder( movable = { row -> row is WorktreeRow && !row.current && row.progress == null }, @@ -139,9 +143,10 @@ class AgentManagerPanel( } // A fresh worktree changes what git reports, so bypass the refresh throttle instead of // leaving the new row without its stats and PR badge until the next poll. - controller.onCreated = { + controller.onCreated = { created -> project?.service()?.refreshStats() project?.service()?.refreshPr(force = true) + autoRunSetupScript(created) } controller.onReload = { sync() } controller.onCreateFailure = { err -> notifyCreateFailed(err) } @@ -272,8 +277,61 @@ class AgentManagerPanel( return controller.progress(item.id) == null } + @RequiresEdt internal fun openDiff(item: WorktreeDto) { - if (canOpenDiff(item)) openBranchDiff(item.path) + val target = project ?: return + if (!canOpenDiff(item)) return + openKiloDiff(target, item.path, KiloDiffComparison.BASE, item.branch.takeUnless { it == "(detached)" }) + } + + internal fun canOpenLocalDiff(item: WorktreeDto?): Boolean { + if (item == null || item.main || project == null) return false + return controller.progress(item.id) == null + } + + @RequiresEdt + internal fun openLocalDiff(item: WorktreeDto) { + val target = project ?: return + if (!canOpenLocalDiff(item)) return + openKiloDiff(target, item.path, KiloDiffComparison.LOCAL) + } + + /** The Open/Create setup-script action is repo-scoped, so it never targets the main worktree row. */ + internal fun canOpenSetupScript(item: WorktreeDto?): Boolean = item != null && !item.main + + internal fun canRunSetup(item: WorktreeDto?): Boolean { + if (item == null || item.main) return false + if (controller.progress(item.id) != null) return false + val service = service() + val target = service.setupScript[controller.directory] ?: run { + service.refreshSetupScriptTarget(controller.directory) + return false + } + return target.exists + } + + @RequiresEdt + internal fun runSetup(item: WorktreeDto) { + val target = project ?: return + if (!canRunSetup(item)) return + val script = service().setupScript[controller.directory] ?: return + Telemetry.send("Worktree Setup Script Run", mapOf("surface" to "worktree_row")) + runWorktreeSetupScript(target, script, item.path, controller.directory) + } + + /** + * Fires the setup script right after worktree creation, mirroring VS Code's trigger point but not + * its blocking semantics: this does not await the script or gate session creation. Silently does + * nothing when [created] is the main worktree or no script is configured, matching VS Code. + */ + @RequiresEdt + private fun autoRunSetupScript(created: WorktreeDto) { + if (created.main) return + val target = project ?: return + service().ifSetupScriptExists(controller.directory) { script -> + Telemetry.send("Worktree Setup Script Run", mapOf("surface" to "auto")) + runWorktreeSetupScript(target, script, created.path, controller.directory) + } } private fun showDeletePopup(item: WorktreeDto, cell: String? = null) { @@ -508,12 +566,6 @@ class AgentManagerPanel( } } - /** - * Inner (not data) class so [metrics] can bind each row's changes/PR handlers to the panel. - * Value equality is over the data fields only — the derived handlers are intentionally excluded - * so a stats/PR refresh that produced identical rows still skips the model rebuild in - * [ActiveListView]. - */ private inner class WorktreeRow( val dto: WorktreeDto, override val progress: String?, @@ -532,21 +584,30 @@ class AgentManagerPanel( override val section: String? get() = if (current) null else KiloBundle.message("worktree.section.local") override val search: String get() = listOfNotNull(dto.name, dto.branch, dto.path, dto.lockReason).joinToString(" ") private val customName: String? get() = WorktreeTitle.custom(dto.name, dto.path) + override val secondaryBadges: List + get() { + if (progress != null) return emptyList() + val p = pr ?: return emptyList() + return listOf( + ActiveListBadge( + "#${p.number}", + style(p.state), + id = "pull-request", + tooltip = prTooltip(p, customName), + action = { BrowserUtil.browse(p.url) }, + ), + ) + } override val metrics: ActiveListMetrics? get() { if (progress != null) return null - val s = stats - val p = pr - if (s == null && p == null) return null + val s = stats?.takeIf { it.files > 0 } ?: return null return ActiveListMetrics( - additions = s?.additions ?: 0, - deletions = s?.deletions ?: 0, - ahead = s?.ahead ?: 0, - behind = s?.behind ?: 0, - pr = p?.let { ActiveListBadge("#${it.number}", style(it.state)) }, - prTooltip = p?.let { prTooltip(it, customName) }, - onChanges = s?.let { { openBranchDiff(dto.path) } }, - onPr = p?.url?.let { url -> { BrowserUtil.browse(url) } }, + files = s.files, + additions = s.additions, + deletions = s.deletions, + base = s.base, + onChanges = { openDiff(dto) }, ) } @@ -570,16 +631,6 @@ class AgentManagerPanel( return result } } - - @RequiresEdt - private fun openBranchDiff(path: String) { - val target = project ?: return - ensureDiffEditorKind() - target.service().open( - KiloDiffEditorKind.ID, - diffParams("branch", path, null, KiloBundle.message("diff.editor.branch.title")), - ) - } } internal fun worktreeDeletable(item: WorktreeDto?, busy: Boolean): Boolean = item != null && !item.main && !busy diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/GhBanner.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/GhBanner.kt index bf5c3e839f9e..e0e1176bdcef 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/GhBanner.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/GhBanner.kt @@ -65,6 +65,14 @@ internal class GhBanner( BrowserUtil.browse("https://cli.github.com/manual/gh_auth_login") } } + // Offered only for gh problems: a missing git is not the GitHub integration, and turning the + // integration off would not make worktree stats work. The coordinator publishes OK in + // response, which routes back through render() and hides this banner. + if (next == GhAvailability.MISSING || next == GhAvailability.UNAUTH) { + createActionLabel(KiloBundle.message("worktree.gh.disable")) { + setGithubIntegration(false, "worktree_gh_banner") + }.toolTipText = KiloBundle.message("worktree.gh.disable.tooltip") + } synced = next } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/GhStatusCoordinator.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/GhStatusCoordinator.kt index 8a0f36331b1a..5085a7ec550e 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/GhStatusCoordinator.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/GhStatusCoordinator.kt @@ -3,6 +3,7 @@ package ai.kilocode.client.agentManager.worktree import ai.kilocode.client.KiloNotifications import ai.kilocode.client.app.kiloRoot import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.plugin.KiloPluginSettings import ai.kilocode.client.util.UiTimer import ai.kilocode.client.util.UiTimerSource import ai.kilocode.client.util.UiTimers @@ -10,13 +11,16 @@ import ai.kilocode.client.util.edt import ai.kilocode.log.KiloLog import ai.kilocode.rpc.dto.GhAvailability import com.intellij.ide.BrowserUtil +import com.intellij.openapi.application.ApplicationActivationListener import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.components.Service import com.intellij.openapi.components.service import com.intellij.openapi.project.Project import com.intellij.openapi.project.ProjectManager +import com.intellij.openapi.wm.IdeFrame import com.intellij.util.concurrency.annotations.RequiresEdt import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job import kotlinx.coroutines.launch @Service(Service.Level.APP) @@ -33,6 +37,9 @@ class GhStatusCoordinator( private const val FAST = 5_000 private const val SLOW = 60_000 private const val MAX_BACKOFF = 120_000 + // Floor between event-driven syncs. Matches the backend's gh auth status cache TTL, so a + // burst of focus/tab events cannot outrun the answer a probe would get anyway. + private const val EVENT_THROTTLE = 3_000 } private var timers: UiTimerSource = UiTimers @@ -43,8 +50,21 @@ class GhStatusCoordinator( private var busy = false private var failures = 0 private var generation = 0 + private var github = KiloPluginSettings.getGithub() + private var job: Job? = null + private var probed = 0L private val projects = linkedMapOf() + init { + val bus = ApplicationManager.getApplication().messageBus.connect(cs) + bus.subscribe(GithubIntegrationListener.TOPIC, GithubIntegrationListener { enabled -> edt { github(enabled) } }) + // Returning to the IDE usually follows work done elsewhere — `gh auth login` in a terminal, + // a PR merged in a browser — so re-check then instead of waiting out the poll interval. + bus.subscribe(ApplicationActivationListener.TOPIC, object : ApplicationActivationListener { + override fun applicationActivated(ideFrame: IdeFrame) = sync("frame-focus") + }) + } + fun current(): GhAvailability = value fun attach(project: Project): AutoCloseable { @@ -56,8 +76,29 @@ class GhStatusCoordinator( edt { apply(project, next) } } - fun forceProbe(reason: String = "forced") { - edt { probe(reason) } + /** + * Submits an out-of-band probe after an event that may have changed gh state (IDE frame focus, + * tool window tab switch). Coalesces rather than queues: dropped while a probe is already in + * flight or within [EVENT_THROTTLE] of the last one, so the single-probe-at-a-time loop keeps + * its shape no matter how many events arrive. + */ + fun sync(reason: String) { + edt { syncEdt(reason) } + } + + @RequiresEdt + private fun syncEdt(reason: String) { + if (refs == 0) return + if (busy) { + LOG.info("gh sync dropped reason=$reason busy=true") + return + } + val since = timers.now() - probed + if (since < EVENT_THROTTLE) { + LOG.info("gh sync dropped reason=$reason sinceMs=$since") + return + } + probe(reason) } @RequiresEdt @@ -86,6 +127,8 @@ class GhStatusCoordinator( generation++ timer?.stop() timer = null + job?.cancel() + job = null busy = false failures = 0 LOG.info("gh probe loop stop") @@ -93,6 +136,10 @@ class GhStatusCoordinator( @RequiresEdt private fun apply(project: Project?, next: GhAvailability) { + // Ignore a stray backend result (e.g. an in-flight prStatus reporting into report()) that + // resolves after the user turned the integration off — anything but OK/GIT_MISSING implies + // gh ran, which cannot be trusted once disabled. + if (!github && next != GhAvailability.OK && next != GhAvailability.GIT_MISSING) return if (value == next) return val previous = value value = next @@ -127,12 +174,14 @@ class GhStatusCoordinator( busy = true val gen = generation val start = timers.now() - LOG.info("gh probe start reason=$reason state=$value delay=${delay()}") - cs.launch { + probed = start + val mode = github + LOG.info("gh probe start reason=$reason state=$value delay=${delay()} github=$mode") + job = cs.launch { runCatching { val dir = project.kiloRoot() ?: return@runCatching null LOG.info("gh probe dir=$dir") - service().ghStatus(dir) + service().ghStatus(dir, mode) } .onSuccess { next -> if (next == null) { @@ -146,6 +195,30 @@ class GhStatusCoordinator( } } + /** + * Applies a GitHub integration setting change. Disabling cancels the in-flight probe and forces + * the published state to [GhAvailability.OK] so the banner hides at once instead of waiting for + * the next probe; the loop keeps running so a missing git is still reported. + */ + @RequiresEdt + private fun github(enabled: Boolean) { + if (github == enabled) return + github = enabled + job?.cancel() + job = null + busy = false + failures = 0 + generation++ + notified = false + LOG.info("gh probe github=$enabled state=$value refs=$refs") + if (!enabled) { + apply(target(), GhAvailability.OK) + schedule() + return + } + probe("github-enabled") + } + private fun idle(gen: Int) { edt { if (gen != generation || refs == 0) return@edt @@ -195,7 +268,9 @@ class GhStatusCoordinator( return baseDelay() } - private fun baseDelay(): Int = when (value) { + // While the GitHub integration is off the loop only checks whether git exists, so it never needs + // the OK cadence tuned for spotting a gh auth change. + private fun baseDelay(): Int = if (!github) SLOW else when (value) { GhAvailability.OK -> NORMAL GhAvailability.UNAUTH -> FAST GhAvailability.MISSING -> SLOW diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/GithubIntegrationListener.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/GithubIntegrationListener.kt new file mode 100644 index 000000000000..59e6206fb86d --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/GithubIntegrationListener.kt @@ -0,0 +1,34 @@ +package ai.kilocode.client.agentManager.worktree + +import ai.kilocode.client.plugin.KiloPluginSettings +import ai.kilocode.client.telemetry.Telemetry +import com.intellij.openapi.application.ApplicationManager +import com.intellij.util.concurrency.annotations.RequiresEdt +import com.intellij.util.messages.Topic + +fun interface GithubIntegrationListener { + fun changed(enabled: Boolean) + + companion object { + @JvmField + val TOPIC: Topic = Topic.create( + "Kilo github integration", + GithubIntegrationListener::class.java, + ) + } +} + +/** + * Single write path for the GitHub integration toggle: persists the value, records telemetry, and + * publishes [GithubIntegrationListener.TOPIC] so [GhStatusCoordinator], [WorktreeStatusService], the + * chat branch dock, and any open settings page stay in sync. + */ +@RequiresEdt +internal fun setGithubIntegration(enabled: Boolean, surface: String) { + if (KiloPluginSettings.getGithub() == enabled) return + KiloPluginSettings.setGithub(enabled) + Telemetry.send("Github Integration Toggled", mapOf("enabled" to enabled.toString(), "surface" to surface)) + ApplicationManager.getApplication().messageBus + .syncPublisher(GithubIntegrationListener.TOPIC) + .changed(enabled) +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/KiloWorktreeService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/KiloWorktreeService.kt index 71afcb6a28db..ae5689dcc50d 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/KiloWorktreeService.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/KiloWorktreeService.kt @@ -12,6 +12,7 @@ import ai.kilocode.rpc.dto.MoveProgressDto import ai.kilocode.rpc.dto.RemoveWorktreeResultDto import ai.kilocode.rpc.dto.RenameWorktreeResultDto import ai.kilocode.rpc.dto.WorktreeBranchesDto +import ai.kilocode.rpc.dto.WorktreeDirtyListDto import ai.kilocode.rpc.dto.WorktreeListDto import ai.kilocode.rpc.dto.WorktreePrListDto import ai.kilocode.rpc.dto.WorktreeStatsListDto @@ -86,12 +87,19 @@ class KiloWorktreeService internal constructor( WorktreeStatsListDto() } + suspend fun dirty(directory: String): WorktreeDirtyListDto = try { + call { dirty(directory) } + } catch (e: Exception) { + LOG.warn("worktree dirty failed for $directory", e) + WorktreeDirtyListDto() + } + /** * Reports gh availability, or rethrows on RPC/backend failure. Callers ([GhStatusCoordinator]) * distinguish a healthy gh from an unhealthy backend via their own `runCatching` + backoff; * swallowing errors here would publish a false "gh is fine" and reset that backoff. */ - suspend fun ghStatus(directory: String): GhAvailability = call { ghStatus(directory) } + suspend fun ghStatus(directory: String, github: Boolean = true): GhAvailability = call { ghStatus(directory, github) } suspend fun prStatus(directory: String): WorktreePrListDto = try { call { prStatus(directory) } @@ -106,7 +114,7 @@ class KiloWorktreeService internal constructor( * would offer worktree actions against a directory whose real state is unknown. Callers decide * what an unknown status means. */ - suspend fun branchStatus(directory: String): BranchStatusDto = call { branchStatus(directory) } + suspend fun branchStatus(directory: String, github: Boolean = true): BranchStatusDto = call { branchStatus(directory, github) } /** * Long-lived move flow. Routed through [durable] (via [call]) so it survives reconnects and diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/NewWorktreeDialog.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/NewWorktreeDialog.kt index 05be98ba1f30..615d17c1e848 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/NewWorktreeDialog.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/NewWorktreeDialog.kt @@ -4,6 +4,7 @@ import ai.kilocode.client.KiloNotifications import ai.kilocode.client.app.KiloAppService import ai.kilocode.client.app.KiloWorkspaceService import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.plugin.KiloPluginSettings import ai.kilocode.client.session.ui.ReasoningPicker import ai.kilocode.client.session.ui.mode.modeItems import ai.kilocode.client.session.ui.model.ModelPicker @@ -174,14 +175,16 @@ internal class NewWorktreeDialog( private fun tabs(): JComponent { val fresh = TabInfo(newContent()).setText(KiloBundle.message("worktree.dialog.tab.new")) - val pr = TabInfo(prContent()).setText(KiloBundle.message("worktree.dialog.tab.pr")) + // Importing a PR needs gh, which is exactly what the GitHub integration setting turns off, + // so the tab is omitted rather than offered as a guaranteed failure. + val pr = if (KiloPluginSettings.getGithub()) TabInfo(prContent()).setText(KiloBundle.message("worktree.dialog.tab.pr")) else null val local = TabInfo(branchContent()).setText(KiloBundle.message("worktree.dialog.tab.branch")) val tabs: JBTabs = JBTabsFactory.createTabs(project, disposable).apply { presentation.setSingleRow(true) presentation.setTabsPosition(JBTabsPosition.top) presentation.showBorder = false addTab(fresh).setPreferredFocusableComponent(prompt.defaultFocusedComponent) - addTab(pr).setPreferredFocusableComponent(url) + pr?.let { addTab(it).setPreferredFocusableComponent(url) } addTab(local).setPreferredFocusableComponent(pick) addListener(object : TabsListener { override fun beforeSelectionChanged(oldSelection: TabInfo?, newSelection: TabInfo?) { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreePrHeaderView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreePrHeaderView.kt index 72f93229c802..df3d36043eb1 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreePrHeaderView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreePrHeaderView.kt @@ -2,9 +2,11 @@ package ai.kilocode.client.agentManager.worktree import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.session.ui.header.PrHeaderView +import ai.kilocode.client.ui.ChangesPanel import ai.kilocode.client.ui.ToolbarButtonAction +import ai.kilocode.client.ui.hoverIconButton import ai.kilocode.client.ui.hoverTextButton -import ai.kilocode.client.ui.toolbarButton +import ai.kilocode.rpc.dto.WorktreeDirtyDto import ai.kilocode.rpc.dto.WorktreePrDto import ai.kilocode.rpc.dto.WorktreeStatsDto import com.intellij.ide.ui.ProductIcons @@ -17,20 +19,21 @@ import javax.swing.JComponent * Worktree session editor header. A thin wrapper over the shared [PrHeaderView] that adds the * optional Run control plus Open-in-window and Terminal actions into the header's trailing slot. */ -internal class WorktreePrHeaderView( +internal class WorktreePrHeaderView @RequiresEdt constructor( openWorktree: () -> Unit = {}, openEnabled: Boolean = true, openTerminal: () -> Unit = {}, + onLocal: (() -> Unit)? = null, run: JComponent? = null, openDiff: () -> Unit, ) : BorderLayoutPanel() { - private val core = PrHeaderView(openDiff = openDiff) + private val core = PrHeaderView(mode = ChangesPanel.Mode.FULL, openDiff = openDiff, onLocal = onLocal) private val open = hoverTextButton( ToolbarButtonAction(ProductIcons.getInstance().productIcon, KiloBundle.message("worktree.session.open.action"), openWorktree), tooltip = KiloBundle.message("worktree.session.open.tooltip"), ) // Icon-only: the tooltip text stands in for the visible label and doubles as the accessible name. - private val terminal = toolbarButton( + private val terminal = hoverIconButton( ToolbarButtonAction(TerminalIcons.OpenTerminal_13x13, KiloBundle.message("worktree.session.terminal.tooltip"), openTerminal), ) @@ -45,7 +48,19 @@ internal class WorktreePrHeaderView( } @RequiresEdt - fun update(stats: WorktreeStatsDto?, pull: WorktreePrDto?, name: String) { - core.update(stats, pull, name) + fun update(stats: WorktreeStatsDto?, pull: WorktreePrDto?, name: String, dirty: WorktreeDirtyDto? = null) { + core.update( + files = stats?.files ?: 0, + additions = stats?.additions ?: 0, + deletions = stats?.deletions ?: 0, + pull = pull, + name = name, + ahead = stats?.ahead ?: 0, + behind = stats?.behind ?: 0, + localFiles = dirty?.files ?: 0, + localAdditions = dirty?.additions ?: 0, + localDeletions = dirty?.deletions ?: 0, + base = stats?.base.orEmpty(), + ) } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorPanel.kt index f5a9cd904d18..9b95c0db3627 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorPanel.kt @@ -1,9 +1,8 @@ package ai.kilocode.client.agentManager.worktree import ai.kilocode.client.plugin.KiloBundle -import ai.kilocode.client.diff.KiloDiffEditorKind -import ai.kilocode.client.diff.diffParams -import ai.kilocode.client.diff.ensureDiffEditorKind +import ai.kilocode.client.diff.KiloDiffComparison +import ai.kilocode.client.diff.openKiloDiff import ai.kilocode.client.session.SessionActivityKind import ai.kilocode.client.session.SessionHost import ai.kilocode.client.session.SessionManager @@ -29,9 +28,9 @@ import ai.kilocode.client.ui.layout.Stack import ai.kilocode.client.ui.layout.VAlign import ai.kilocode.client.ui.layout.align import ai.kilocode.client.ui.UiStyle -import ai.kilocode.client.vfs.KiloVfsManager import ai.kilocode.log.KiloLog import ai.kilocode.rpc.dto.SessionDto +import ai.kilocode.rpc.dto.WorktreeDirtyDto import ai.kilocode.rpc.dto.WorktreePrDto import ai.kilocode.rpc.dto.WorktreeStatsDto import com.intellij.icons.AllIcons @@ -79,7 +78,7 @@ import javax.swing.border.Border import javax.swing.event.ListDataEvent import javax.swing.event.ListDataListener -class WorktreeSessionEditorPanel( +class WorktreeSessionEditorPanel @RequiresEdt constructor( parent: Disposable, private val manager: WorktreeSessionEditorManager, private val controller: WorktreeSessionListController, @@ -135,13 +134,15 @@ class WorktreeSessionEditorPanel( private val prHeader = WorktreePrHeaderView( openWorktree = ::openInNewFrame, openEnabled = worktree.directory.isNotBlank(), - openDiff = ::openBranchDiff, + openDiff = { openDiff(KiloDiffComparison.BASE) }, + onLocal = { openDiff(KiloDiffComparison.LOCAL) }, openTerminal = ::openTerminal, run = run?.button, ) private val splitter = OnePixelSplitter(false, 0.25f) private var started = false private var stats: WorktreeStatsDto? = null + private var dirty: WorktreeDirtyDto? = null private var pr: WorktreePrDto? = null // Last known persisted visibility, and whether it is known at all: until the stored value arrives // (or the user clicks) the list stays hidden and nothing is written. @@ -441,13 +442,9 @@ class WorktreeSessionEditorPanel( private fun same(path: String?, dir: String): Boolean = FileUtil.pathsEqual(path, dir) @RequiresEdt - private fun openBranchDiff() { + private fun openDiff(comparison: KiloDiffComparison) { val target = project ?: return - ensureDiffEditorKind() - target.service().open( - KiloDiffEditorKind.ID, - diffParams("branch", worktree.directory, null, KiloBundle.message("diff.editor.branch.title")), - ) + openKiloDiff(target, worktree.directory, comparison, parent = this) } /** @@ -534,18 +531,23 @@ class WorktreeSessionEditorPanel( @RequiresEdt private fun selectedKeys(): List = list.selectedKeys().filter { it != SessionHost.NEW && it !in manager.deleting() } + @RequiresEdt private fun bindModel() { val listener = object : ListDataListener { + @RequiresEdt override fun intervalAdded(e: ListDataEvent) = sync() + @RequiresEdt override fun intervalRemoved(e: ListDataEvent) = sync() + @RequiresEdt override fun contentsChanged(e: ListDataEvent) = sync() } controller.model.addListDataListener(listener) Disposer.register(this) { controller.model.removeListDataListener(listener) } } + @RequiresEdt private fun bindStatus() { val key = normalizeWorktreePath(worktree.directory) syncHeader() @@ -561,12 +563,13 @@ class WorktreeSessionEditorPanel( this, onStats = { value -> stats = value[key]; syncHeader() }, onPr = { value -> pr = value[key]; syncHeader() }, + onDirty = { value -> dirty = value[key]; syncHeader() }, ) } @RequiresEdt private fun syncHeader() { - prHeader.update(stats, pr, worktreeName()) + prHeader.update(stats, pr, worktreeName(), dirty) } @RequiresEdt @@ -577,6 +580,7 @@ class WorktreeSessionEditorPanel( ?: key.trimEnd('/').substringAfterLast('/').ifBlank { key } } + @RequiresEdt override fun uiDataSnapshot(sink: DataSink) { sink[WorktreeSessionDataKeys.PANEL] = this selectedSession()?.let { sink[WorktreeSessionDataKeys.SESSION] = it } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSetupScript.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSetupScript.kt new file mode 100644 index 000000000000..99706e029bbe --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSetupScript.kt @@ -0,0 +1,59 @@ +package ai.kilocode.client.agentManager.worktree + +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.rpc.dto.SetupScriptKind +import ai.kilocode.rpc.dto.SetupScriptTargetDto +import com.intellij.openapi.components.service +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.Key +import com.intellij.openapi.util.io.FileUtil +import com.intellij.openapi.wm.ToolWindowManager +import com.intellij.terminal.frontend.toolwindow.TerminalToolWindowTabsManager +import com.intellij.util.concurrency.annotations.RequiresEdt +import org.jetbrains.plugins.terminal.TerminalToolWindowFactory + +/** Tags a terminal tab as the dedicated setup-script runner for a worktree, keyed by its directory. */ +private val SETUP_DIR = Key.create("kilo.worktree.setup.dir") + +/** + * Runs the worktree setup [script] in a terminal tab dedicated to [worktree], distinct from the tab + * the Terminal button opens. Reusing a separate tab means auto-run on worktree creation never types + * into (or steals focus from) a shell the user is actively working in, and env is set once at tab + * creation via [com.intellij.terminal.frontend.toolwindow.TerminalToolWindowTabBuilder.envVariables] + * so the sent command needs no shell-specific env-prefix syntax. + */ +@RequiresEdt +internal fun runWorktreeSetupScript(project: Project, script: SetupScriptTargetDto, worktree: String, repo: String) { + val tabs = TerminalToolWindowTabsManager.getInstance(project) + val existing = tabs.tabs.firstOrNull { FileUtil.pathsEqual(it.content.getUserData(SETUP_DIR), worktree) } + val tab = existing ?: tabs.createTabBuilder() + .workingDirectory(worktree) + .envVariables(mapOf("WORKTREE_PATH" to worktree, "REPO_PATH" to repo)) + .tabName(KiloBundle.message("worktree.setup.terminal.tabName", service().title(worktree))) + .requestFocus(true) + .createTab() + .also { it.content.putUserData(SETUP_DIR, worktree) } + if (existing != null) { + existing.content.manager?.setSelectedContent(existing.content, true) + } + ToolWindowManager.getInstance(project).getToolWindow(TerminalToolWindowFactory.TOOL_WINDOW_ID)?.activate(null) + tab.view.createSendTextBuilder().shouldExecute().send(setupScriptCommand(script)) +} + +/** + * Builds the shell command that invokes [script] through its interpreter. Env comes entirely from the + * tab's [com.intellij.terminal.frontend.toolwindow.TerminalToolWindowTabBuilder.envVariables], so this + * carries no env-var prefix -- only the script path needs quoting, and its syntax is known exactly + * because [script]'s kind determines which interpreter (and therefore which quoting rules) apply. + */ +internal fun setupScriptCommand(script: SetupScriptTargetDto): String = when (script.kind) { + SetupScriptKind.POSIX -> "sh ${quotePosix(script.path)}" + SetupScriptKind.POWERSHELL -> "powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File ${quoteWindows(script.path)}" + SetupScriptKind.CMD -> "cmd.exe /d /s /c ${quoteWindows(script.path)}" +} + +/** Single-quotes [path] for POSIX shells, escaping embedded single quotes the standard `'\''` way. */ +private fun quotePosix(path: String): String = "'" + path.replace("'", "'\\''") + "'" + +/** Double-quotes [path] for PowerShell/cmd, doubling embedded double quotes. */ +private fun quoteWindows(path: String): String = "\"" + path.replace("\"", "\"\"") + "\"" diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeStatsView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeStatsView.kt deleted file mode 100644 index 686712543502..000000000000 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeStatsView.kt +++ /dev/null @@ -1,196 +0,0 @@ -package ai.kilocode.client.agentManager.worktree - -import ai.kilocode.client.plugin.KiloBundle -import ai.kilocode.client.ui.DiffStatBadge -import ai.kilocode.client.ui.FilledBadgeIcon -import ai.kilocode.client.ui.UiStyle -import ai.kilocode.client.ui.list.ACTIVE_LIST_CHANGES_CELL -import ai.kilocode.client.ui.list.ACTIVE_LIST_PR_CELL -import ai.kilocode.client.ui.list.ActiveListBadge -import ai.kilocode.client.ui.list.ActiveListHitCell -import ai.kilocode.client.ui.layout.Stack -import ai.kilocode.client.ui.prTooltip -import ai.kilocode.client.ui.style -import ai.kilocode.rpc.dto.WorktreePrDto -import ai.kilocode.rpc.dto.WorktreeStatsDto -import com.intellij.ide.BrowserUtil -import com.intellij.openapi.util.IconLoader -import com.intellij.ui.components.JBLabel -import com.intellij.util.ui.JBFont -import com.intellij.util.ui.JBUI -import java.awt.BorderLayout -import java.awt.Component -import java.awt.Container -import java.awt.Cursor -import java.awt.event.MouseAdapter -import java.awt.event.MouseEvent -import javax.swing.Icon -import javax.swing.JPanel - -/** - * The trailing ahead/behind/diff and PR badges of a worktree row. - * - * Uses a real layout manager on purpose: a `null` layout resolves min/preferred size through the - * peer, which reports the component's *current* size. Inside the list this view is a single render - * stamp reused for every row, and [Stack] and [ai.kilocode.client.ui.layout.Align] clamp a child's - * preferred width into its `[min, max]` range - so a peer-reported minimum would carry the previous - * row's width into the next row's layout and drift the badges off their hit regions. - */ -internal class WorktreeStatsView( - openDiff: (() -> Unit)? = null, - fill: Boolean = true, -) : JPanel(BorderLayout()) { - companion object { - private val UP: Icon = IconLoader.getIcon("/icons/arrow-up.svg", WorktreeStatsView::class.java) - private val DOWN: Icon = IconLoader.getIcon("/icons/arrow-down-to-line.svg", WorktreeStatsView::class.java) - } - - private val behind = count(DOWN) - private val ahead = count(UP) - private val diff = DiffStatBadge(0, 0, DiffStatBadge.Variant.COMPACT, fill = fill) - private val pr = JBLabel() - private val change = Stack.horizontal(UiStyle.Gap.sm()).next(behind).next(ahead).next(diff) - // The change and PR badges are hit regions so the list can drive their clicks: inside the list - // the view is a render stamp whose own mouse listeners never fire, so the ActiveList reads these - // ids back and routes the click. Standalone (toolbar) usage keeps its own listeners below. - private val changeHit = HitRegion(ACTIVE_LIST_CHANGES_CELL).apply { add(change, BorderLayout.CENTER) } - private val prHit = HitRegion(ACTIVE_LIST_PR_CELL).apply { add(pr, BorderLayout.CENTER) } - // Change badge and PR link share one row: the changes trail first, the PR link is pinned last so - // it is always the rightmost element. - private val row = Stack.horizontal(UiStyle.Gap.md()).next(changeHit).next(prHit) - private var url: String? = null - private var state: State? = null - - init { - add(row, BorderLayout.CENTER) - changeHit.act = openDiff - prHit.act = { url?.let(BrowserUtil::browse) } - diff.toolTipText = KiloBundle.message("worktree.stats.tooltip", 0, 0, 0, 0) - installClick(changeHit, object : MouseAdapter() { - override fun mouseClicked(event: MouseEvent) { - changeHit.act?.invoke() - } - }) - installClick(prHit, object : MouseAdapter() { - override fun mouseClicked(event: MouseEvent) { - prHit.act?.invoke() - } - }) - applyCursors() - } - - /** - * Replaces the click handlers, used by the list renderer to bind each row's changes/PR actions - * to the single reused stamp. Standalone usage leaves the constructor defaults in place. - */ - fun setActions(onChanges: (() -> Unit)?, onPr: (() -> Unit)?) { - changeHit.act = onChanges - prHit.act = onPr - applyCursors() - } - - fun update(stats: WorktreeStatsDto?, pull: WorktreePrDto?) { - sync( - State( - stats, - pull?.let { ActiveListBadge("#${it.number}", style(it.state)) }, - pull?.url, - pull?.let(::prTooltip), - ), - ) - } - - fun update(stats: WorktreeStatsDto?, badge: ActiveListBadge?, prTip: String? = badge?.text) { - sync(State(stats, badge, null, prTip)) - } - - /** - * Applies [next] unless it is already rendered. The memo key must cover everything this method - * writes: inside the list one instance renders every row, so a field left out of the key would - * carry another row's badge, tooltip, or visibility. - */ - private fun sync(next: State) { - if (state == next) return - state = next - val s = next.stats ?: WorktreeStatsDto("") - behind.text = s.behind.toString() - behind.toolTipText = KiloBundle.message("worktree.stats.behind.tooltip") - behind.isVisible = s.behind > 0 - ahead.text = s.ahead.toString() - ahead.toolTipText = KiloBundle.message("worktree.stats.ahead.tooltip") - ahead.isVisible = s.ahead > 0 - diff.update(s.additions, s.deletions) - diff.isVisible = s.additions > 0 || s.deletions > 0 - // One descriptive tooltip for the whole changes region: ahead/behind, the +/- diff, and what - // clicking does. - val changeTip = KiloBundle.message("worktree.stats.tooltip", s.ahead, s.behind, s.additions, s.deletions) - diff.toolTipText = changeTip - changeHit.tip = changeTip - changeHit.toolTipText = changeTip - url = next.link - pr.icon = next.badge?.let { FilledBadgeIcon(it.text, it.style) } - pr.toolTipText = next.tip - prHit.tip = next.tip - prHit.toolTipText = next.tip - pr.isVisible = next.badge != null - val changesVisible = behind.isVisible || ahead.isVisible || diff.isVisible - changeHit.isVisible = changesVisible - prHit.isVisible = pr.isVisible - isVisible = changesVisible || pr.isVisible - applyCursors() - revalidate() - repaint() - } - - private fun applyCursors() { - applyCursor(changeHit, changeHit.act != null) - applyCursor(prHit, prHit.act != null) - } - - private fun actionCursor(active: Boolean) = - if (active) Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) else Cursor.getDefaultCursor() - - private fun installClick(comp: Component, listener: MouseAdapter) { - comp.addMouseListener(listener) - if (comp is Container) comp.components.forEach { installClick(it, listener) } - } - - private fun applyCursor(comp: Component, active: Boolean) { - comp.cursor = actionCursor(active) - if (comp is Container) comp.components.forEach { applyCursor(it, active) } - } - - private fun count(icon: Icon) = JBLabel().apply { - this.icon = icon - iconTextGap = UiStyle.Gap.xs() - font = JBFont.small() - foreground = UiStyle.Colors.weak() - border = JBUI.Borders.empty() - } - - /** Everything [sync] renders, so a repeated row can be skipped without leaking stale state. */ - private data class State( - val stats: WorktreeStatsDto?, - val badge: ActiveListBadge?, - val link: String?, - val tip: String?, - ) - - /** A badge wrapper the ActiveList hit-tests for clicks, cursor, and tooltip. */ - private class HitRegion(override val cellId: String) : JPanel(BorderLayout()), ActiveListHitCell { - var act: (() -> Unit)? = null - var tip: String? = null - - init { - isOpaque = false - } - - override fun cellEnabled(): Boolean = act != null && isVisible - - override fun cellCursor(): Int = Cursor.HAND_CURSOR - - override fun cellTooltip(): String? = tip - - override fun cellAction(): (() -> Unit)? = act - } -} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeStatusBinding.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeStatusBinding.kt index c5dacbb4fbed..305975006dbe 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeStatusBinding.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeStatusBinding.kt @@ -1,6 +1,7 @@ package ai.kilocode.client.agentManager.worktree import ai.kilocode.client.util.edt +import ai.kilocode.rpc.dto.WorktreeDirtyDto import ai.kilocode.rpc.dto.WorktreePrDto import ai.kilocode.rpc.dto.WorktreeStatsDto import com.intellij.openapi.Disposable @@ -17,14 +18,15 @@ import kotlinx.coroutines.launch /** * Shared wiring to the project [WorktreeStatusService] for the surfaces that show worktree * stats/PR badges (the Agent Manager list and the worktree session editor). Owns the attach - * handle and a coroutine scope, forwards each flow value to [onStats]/[onPr] on the EDT, and - * skips delivery once [parent] or the project is disposed. Cleanup is tied to [parent]. + * handle and a coroutine scope, forwards each flow value to [onStats]/[onDirty]/[onPr] on the EDT, + * and skips delivery once [parent] or the project is disposed. Cleanup is tied to [parent]. */ internal class WorktreeStatusBinding( private val project: Project, private val parent: Disposable, private val onStats: (Map) -> Unit, private val onPr: (Map) -> Unit, + private val onDirty: (Map) -> Unit = {}, ) { private val cs = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val handle: AutoCloseable @@ -33,6 +35,7 @@ internal class WorktreeStatusBinding( val service = project.service() handle = service.attach() cs.launch { service.stats.collectLatest { value -> alive { onStats(value) } } } + cs.launch { service.dirty.collectLatest { value -> alive { onDirty(value) } } } cs.launch { service.pr.collectLatest { value -> alive { onPr(value) } } } Disposer.register(parent) { close() } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeStatusService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeStatusService.kt index cd4ba806087a..e2d337d37862 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeStatusService.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeStatusService.kt @@ -1,17 +1,23 @@ package ai.kilocode.client.agentManager.worktree import ai.kilocode.client.app.kiloRoot +import ai.kilocode.client.plugin.KiloPluginSettings import ai.kilocode.client.util.UiTimer import ai.kilocode.client.util.UiTimerSource import ai.kilocode.client.util.UiTimers import ai.kilocode.log.KiloLog import ai.kilocode.rpc.dto.GhAvailability +import ai.kilocode.rpc.dto.WorktreeDirtyDto import ai.kilocode.rpc.dto.WorktreePrDto import ai.kilocode.rpc.dto.WorktreeStatsDto +import com.intellij.openapi.application.ApplicationActivationListener +import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.components.Service import com.intellij.openapi.components.service import com.intellij.openapi.project.Project +import com.intellij.openapi.wm.IdeFrame import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch @@ -33,18 +39,38 @@ class WorktreeStatusService internal constructor( } private val statsFlow = MutableStateFlow>(emptyMap()) + private val dirtyFlow = MutableStateFlow>(emptyMap()) private val prFlow = MutableStateFlow>(emptyMap()) private val ghFlow = MutableStateFlow(GhAvailability.OK) private var debounce: UiTimer? = null private var statsTimer: UiTimer? = null private var prTimer: UiTimer? = null + private var prJob: Job? = null private var refs = 0 private var lastPr = 0L + private var github = KiloPluginSettings.getGithub() + // Bumped whenever a PR lookup starts or is abandoned, so a result that arrives after its reason + // to exist is gone cannot publish. Mirrors GhStatusCoordinator's probe generation. + private var generation = 0 val stats: StateFlow> get() = statsFlow + val dirty: StateFlow> get() = dirtyFlow val pr: StateFlow> get() = prFlow val gh: StateFlow get() = ghFlow + init { + val bus = ApplicationManager.getApplication().messageBus.connect(cs) + bus.subscribe(GithubIntegrationListener.TOPIC, GithubIntegrationListener { enabled -> github(enabled) }) + // A PR can be merged or closed while the IDE sits in the background, so re-check on + // activation. Unforced, so PR_THROTTLE collapses a burst of focus events into one lookup. + bus.subscribe(ApplicationActivationListener.TOPIC, object : ApplicationActivationListener { + override fun applicationActivated(ideFrame: IdeFrame) { + if (ideFrame.project !== project) return + refreshPr() + } + }) + } + fun attach(): AutoCloseable { refs++ if (refs == 1) start() @@ -56,12 +82,12 @@ class WorktreeStatusService internal constructor( fun refreshStats() { if (project.isDisposed || refs == 0) return - val timer = debounce ?: timers.timer(STATS_DEBOUNCE, repeats = false) { loadStats() }.also { debounce = it } + val timer = debounce ?: timers.timer(STATS_DEBOUNCE, repeats = false) { loadStats(); loadDirty() }.also { debounce = it } timer.restart() } fun refreshPr(force: Boolean = false) { - if (project.isDisposed || refs == 0) return + if (project.isDisposed || refs == 0 || !github) return val now = timers.now() if (!force && now - lastPr < PR_THROTTLE) return lastPr = now @@ -72,16 +98,43 @@ class WorktreeStatusService internal constructor( refreshStats() refreshPr(force = true) statsTimer = timers.timer(STATS_POLL) { refreshStats() }.also { it.start() } - prTimer = timers.timer(PR_POLL) { refreshPr(force = true) }.also { it.start() } + if (github) prTimer = timers.timer(PR_POLL) { refreshPr(force = true) }.also { it.start() } } private fun stop() { debounce?.stop() statsTimer?.stop() prTimer?.stop() + prJob?.cancel() + generation++ debounce = null statsTimer = null prTimer = null + prJob = null + } + + /** + * Applies a GitHub integration setting change. Disabling cancels the in-flight PR lookup, stops + * the poll, and clears the PR map so badges, tab titles, and PR actions drop immediately. Git + * stats and dirty counts are unaffected. + */ + private fun github(enabled: Boolean) { + if (github == enabled) return + github = enabled + if (!enabled) { + prTimer?.stop() + prTimer = null + prJob?.cancel() + prJob = null + generation++ + lastPr = 0 + prFlow.value = emptyMap() + ghFlow.value = GhAvailability.OK + return + } + if (refs == 0) return + prTimer = timers.timer(PR_POLL) { refreshPr(force = true) }.also { it.start() } + refreshPr(force = true) } private fun loadStats() { @@ -93,11 +146,30 @@ class WorktreeStatusService internal constructor( } } - private fun loadPr() { + // Resolves the backend root like loadStats rather than reading project.basePath, which is a + // synthetic JetBrains Client path in split/remote mode. Pointing the backend at that path makes + // dirty() answer for a directory that does not exist, which reads as "no local changes". + private fun loadDirty() { cs.launch { + val dir = project.kiloRoot() ?: return@launch + runCatching { service().dirty(dir) } + .onSuccess { dto -> dirtyFlow.value = dto.items.associateBy { normalizeWorktreePath(it.path) } } + .onFailure { err -> LOG.warn("worktree dirty refresh failed dir=$dir", err) } + } + } + + private fun loadPr() { + val gen = ++generation + prJob = cs.launch { val dir = project.kiloRoot() ?: return@launch runCatching { service().prStatus(dir) } .onSuccess { dto -> + // KiloWorktreeService.prStatus swallows the cancellation and answers with an + // empty DTO, so a lookup cancelled by a disable still lands here — and after a + // quick re-enable the github flag is true again. Only the newest lookup may + // publish, or a stale empty result would wipe fresh badges and report a false OK + // over a real UNAUTH. + if (gen != generation) return@onSuccess prFlow.value = dto.items.associateBy { normalizeWorktreePath(it.path) } ghFlow.value = dto.availability service().report(project, dto.availability) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt index 47d723dcac4f..2fde1784546c 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt @@ -8,7 +8,6 @@ import ai.kilocode.client.session.SessionActivityKind import ai.kilocode.client.session.toKind 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 @@ -317,11 +316,6 @@ class KiloSessionService internal constructor( } } - /** Update config (model, agent/mode, temperature). */ - suspend fun updateConfig(dir: String, config: ConfigUpdateDto) { - call { updateConfig(dir, config) } - } - // ------ permission / question resolution ------ /** Reply to a pending permission request. */ diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloWorkspaceService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloWorkspaceService.kt index 2a95d52eaf3b..3353d1787277 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloWorkspaceService.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloWorkspaceService.kt @@ -10,11 +10,14 @@ import ai.kilocode.rpc.dto.KiloWorkspaceStateDto import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto import ai.kilocode.rpc.dto.LoadErrorDto import ai.kilocode.rpc.dto.ModelsWorkspaceDto +import ai.kilocode.rpc.dto.SetupScriptTargetDto import ai.kilocode.rpc.dto.WorkspaceFileDto +import ai.kilocode.client.util.edt import com.intellij.ide.ActivityTracker import com.intellij.openapi.components.Service import ai.kilocode.log.KiloLog import com.intellij.platform.project.ProjectId +import com.intellij.util.concurrency.annotations.RequiresBackgroundThread import fleet.rpc.client.durable import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CancellationException @@ -50,7 +53,9 @@ class KiloWorkspaceService internal constructor( private val workspaces = ConcurrentHashMap() internal val localConfig = ConcurrentHashMap() + internal val setupScript = ConcurrentHashMap() private val pendingLocal = ConcurrentHashMap.newKeySet() + private val pendingSetupScript = ConcurrentHashMap.newKeySet() private val pendingGlobal = AtomicBoolean(false) @Volatile @@ -176,9 +181,15 @@ class KiloWorkspaceService internal constructor( * 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. */ + @RequiresBackgroundThread suspend fun branchDiff(directory: String, patches: Boolean = true): List = call { branchDiff(directory, patches) } + @RequiresBackgroundThread + suspend fun localDiff(directory: String, patches: Boolean = true): List = + call { localDiff(directory, patches) } + + @RequiresBackgroundThread suspend fun branchName(directory: String): String? { return try { call { branchName(directory) } @@ -295,4 +306,50 @@ class KiloWorkspaceService internal constructor( } } + suspend fun setupScriptTarget(directory: String): SetupScriptTargetDto? { + return try { + val target = call { this.setupScriptTarget(directory) } + setupScript[directory] = target + target + } catch (e: Exception) { + LOG.warn("setup script lookup failed for directory=$directory", e) + setupScript[directory] + } + } + + fun refreshSetupScriptTarget(directory: String): Job? { + if (!pendingSetupScript.add(directory)) return null + + return cs.launch { + try { + setupScriptTarget(directory) + } finally { + pendingSetupScript.remove(directory) + ActivityTracker.getInstance().inc() + } + } + } + + fun openSetupScript(directory: String, done: (Boolean) -> Unit) { + cs.launch { + val ok = try { + call { this.openSetupScript(directory) } + } catch (e: Exception) { + LOG.warn("setup script open failed for directory=$directory", e) + false + } + done(ok) + } + } + + /** + * Resolves the setup script for [directory] and invokes [found] with it on the EDT, but only + * when one is actually configured. Silent no-op otherwise, matching VS Code's behavior of doing + * nothing when a worktree has no setup script. + */ + fun ifSetupScriptExists(directory: String, found: (SetupScriptTargetDto) -> Unit): Job = cs.launch { + val target = setupScriptTarget(directory)?.takeIf { it.exists } ?: return@launch + edt { found(target) } + } + } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/DiffFileStatus.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/DiffFileStatus.kt index ff316baa9610..6e088dbac9a0 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/DiffFileStatus.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/DiffFileStatus.kt @@ -7,7 +7,7 @@ internal fun fileStatus(dto: DiffFileDto): FileStatus = when (dto.status) { "added" -> FileStatus.ADDED "deleted" -> FileStatus.DELETED "untracked" -> FileStatus.UNKNOWN - "modified" -> FileStatus.MODIFIED + "modified", "renamed" -> FileStatus.MODIFIED else -> when { DiffPatchReconstruct.added(dto.patch) -> FileStatus.ADDED DiffPatchReconstruct.deleted(dto.patch) -> FileStatus.DELETED diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloDiffEditorContent.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloDiffEditorContent.kt index e364b2f019f8..3babf59df8b5 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloDiffEditorContent.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloDiffEditorContent.kt @@ -91,6 +91,13 @@ internal fun buildDiffEditor( internal val DIFF_FILE_KEY: Key = Key.create("kilo.diff.file") +/** Pane labels for a comparison [source]: committed base/HEAD, uncommitted HEAD/working tree, or a session diff's original/modified. */ +internal fun diffLabels(source: String?): Pair = when (source) { + "branch" -> KiloBundle.message("diff.editor.side.base") to KiloBundle.message("diff.editor.side.head") + "local" -> KiloBundle.message("diff.editor.side.head") to KiloBundle.message("diff.editor.side.working") + else -> KiloBundle.message("diff.editor.side.original") to KiloBundle.message("diff.editor.side.modified") +} + internal class DiffEditorView( private val project: Project, private val params: Map, @@ -205,7 +212,7 @@ internal class DiffEditorView( internal fun refresh() { if (disposed.get() || project.isDisposed) return if (!refreshing.compareAndSet(false, true)) return - saveDocuments() + if (params["source"] != "branch" && params["source"] != "local") saveDocuments() outdated.set(false) banner.isVisible = false root.revalidate() @@ -307,12 +314,7 @@ internal class DiffEditorView( } } - private fun labels(): Pair { - 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 labels(): Pair = diffLabels(params["source"]) private fun syncTree() { if (disposed.get()) return diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloDiffEditorKind.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloDiffEditorKind.kt index e257e62d971d..b38ba3d0daa0 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloDiffEditorKind.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloDiffEditorKind.kt @@ -4,10 +4,12 @@ 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.telemetry.Telemetry 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.KiloVfsManager import ai.kilocode.client.vfs.KiloVirtualFile import ai.kilocode.log.KiloLog import ai.kilocode.rpc.dto.DiffFileDto @@ -17,6 +19,7 @@ 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.openapi.util.io.FileUtil import com.intellij.ui.AnimatedIcon import com.intellij.ui.components.ActionLink import com.intellij.ui.components.JBLabel @@ -26,8 +29,8 @@ 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.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.awt.BorderLayout @@ -41,9 +44,11 @@ internal object KiloDiffEditorKind : KiloEditorKind { override val id: String = ID override fun title(params: Map): String { - return params["title"].takeIfPresent() + params["title"].takeIfPresent()?.let { return it } + val comparison = KiloDiffComparison.entries.firstOrNull { it.source == params["source"] } + return comparison?.title(params["branch"].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") + ?: KiloBundle.message("diff.editor.session.title") } override fun presentablePath(params: Map): String = title(params) @@ -51,7 +56,7 @@ internal object KiloDiffEditorKind : KiloEditorKind { override fun isValid(params: Map): Boolean { val dir = params["directory"].takeIfPresent() ?: return false if (dir.isBlank()) return false - if (params["source"] == "branch") return true + if (params["source"] == "branch" || params["source"] == "local") return true if (params["source"] == "inline") return params["token"].takeIfPresent() != null return params["sessionId"].takeIfPresent() != null } @@ -101,6 +106,37 @@ internal class KiloDiffEditorService( internal val scope: CoroutineScope get() = cs + @RequiresEdt + fun open(directory: String, comparison: KiloDiffComparison, branch: String?, parent: Disposable) { + if (project.isDisposed || Disposer.isDisposed(parent) || !cs.isActive) return + val dir = FileUtil.toCanonicalPath(FileUtil.toSystemIndependentName(directory), '/', true) + .takeIfPresent() ?: return + val name = branch.takeIfPresent() + if (name != null) { + show(dir, comparison, name) + return + } + val job = cs.launch { + val branch = service().branchName(dir) + withContext(Dispatchers.Main) { + if (!project.isDisposed && !Disposer.isDisposed(parent)) show(dir, comparison, branch) + } + } + val guard = Disposable { job.cancel() } + Disposer.register(parent, guard) + job.invokeOnCompletion { Disposer.dispose(guard) } + } + + @RequiresEdt + private fun show(dir: String, comparison: KiloDiffComparison, branch: String?) { + ensureDiffEditorKind() + val opened = project.service().open( + KiloDiffEditorKind.ID, + diffParams(comparison.source, dir, null, comparison.title(branch), branch), + ) + if (opened) Telemetry.send("Diff Editor Opened", mapOf("source" to comparison.source)) + } + fun load(params: Map, parent: Disposable, done: (DiffEditorData) -> Unit) { val disposed = AtomicBoolean(false) val job = cs.launch { @@ -152,15 +188,14 @@ internal class KiloDiffEditorService( val store = project.service() val session = project.service() 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) + "local" -> workspace.localDiff(dir) "inline" -> store.get(params["token"].orEmpty()).orEmpty() else -> session.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 + ?: if (params["source"] == "branch" || params["source"] == "local") workspace.branchName(dir) else null return DiffEditorData.Files(detail(params, dir, files, session), branch) } @@ -178,6 +213,7 @@ internal class KiloDiffEditorService( // return the whole-session before/after and splice in changes from kept turns. Render the // scoped hunk patches directly instead. if (params["token"].takeIfPresent()?.startsWith("revert:") == true) return files + if (params["source"] == "branch" || params["source"] == "local") return files val sessionId = params["sessionId"].takeIfPresent() val message = message(params) LOG.info("diff editor detail source=${params["source"]} files=${files.size} session=${!sessionId.isNullOrBlank()} message=${!message.isNullOrBlank()}") @@ -231,6 +267,27 @@ fun ensureDiffEditorKind() { service().register(KiloDiffEditorKind) } +enum class KiloDiffComparison(internal val source: String, private val title: String, private val named: String) { + BASE("branch", "diff.editor.branch.title", "diff.editor.branch.title.named"), + LOCAL("local", "diff.editor.local.title", "diff.editor.local.title.named"), + ; + + internal fun title(branch: String?): String = + branch.takeIfPresent()?.let { KiloBundle.message(named, it) } ?: KiloBundle.message(title) +} + +@RequiresEdt +fun openKiloDiff( + project: Project, + directory: String, + comparison: KiloDiffComparison, + branch: String? = null, + parent: Disposable = project, +) { + if (project.isDisposed || Disposer.isDisposed(parent)) return + project.service().open(directory, comparison, branch, parent) +} + private fun connecting(): JComponent = Stack.horizontal(gap = UiStyle.Gap.sm()).apply { border = JBUI.Borders.empty(UiStyle.Gap.pad()) next(JBLabel(AnimatedIcon.Default())) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/plugin/KiloPluginSettings.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/plugin/KiloPluginSettings.kt index ac54c6597fc3..7622b460b183 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/plugin/KiloPluginSettings.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/plugin/KiloPluginSettings.kt @@ -7,6 +7,26 @@ object KiloPluginSettings { private const val AUTO_EDITOR_CONTEXT_KEY = "kilo.session.autoEditorContext" private const val SHOW_APPROVAL_REASON_KEY = "kilo.session.showApprovalReason" private const val PERMISSION_RULES_EXPANDED_KEY = "kilo.session.permissionRulesExpanded" + private const val GITHUB_KEY = "kilo.integrations.github" + private const val AGENT_KEY = "kilo.session.agent" + + /** + * Mode the prompt picker last selected, or null when the CLI's own default should win. + * + * IDE-local on purpose. This used to be written to the CLI's global config as `default_agent`, + * which made the CLI dispose every instance it held and cancel every running turn in every + * worktree — a mode switch is a UI preference, not a server reconfiguration. The picked mode + * still travels with each prompt, so nothing here reaches the server. + */ + fun getAgent(): String? = PropertiesComponent.getInstance().getValue(AGENT_KEY)?.takeIf { it.isNotBlank() } + + fun setAgent(value: String) { + PropertiesComponent.getInstance().setValue(AGENT_KEY, value) + } + + internal fun unsetAgent() { + PropertiesComponent.getInstance().unsetValue(AGENT_KEY) + } fun getAutoApprove(): Boolean = PropertiesComponent.getInstance().getBoolean(AUTO_APPROVE_KEY, false) @@ -47,4 +67,14 @@ object KiloPluginSettings { internal fun unsetPermissionRulesExpanded() { PropertiesComponent.getInstance().unsetValue(PERMISSION_RULES_EXPANDED_KEY) } + + fun getGithub(): Boolean = PropertiesComponent.getInstance().getBoolean(GITHUB_KEY, true) + + fun setGithub(value: Boolean) { + PropertiesComponent.getInstance().setValue(GITHUB_KEY, value.toString()) + } + + internal fun unsetGithub() { + PropertiesComponent.getInstance().unsetValue(GITHUB_KEY) + } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt index 9827356ac62b..7bbc702dd2f4 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt @@ -5,7 +5,9 @@ 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.KiloDiffComparison import ai.kilocode.client.diff.KiloDiffEditorKind +import ai.kilocode.client.diff.openKiloDiff import ai.kilocode.client.diff.KiloInlineDiffStore import ai.kilocode.client.diff.diffParams import ai.kilocode.client.diff.ensureDiffEditorKind @@ -14,6 +16,7 @@ import ai.kilocode.client.migration.MigrationUiController import ai.kilocode.client.migration.MigrationUiState import ai.kilocode.client.migration.ui.MigrationWizardPanel import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.plugin.KiloPluginSettings import ai.kilocode.client.session.model.FileAttachment import ai.kilocode.client.session.model.SessionModelEvent import ai.kilocode.client.session.model.SessionState @@ -45,6 +48,7 @@ import ai.kilocode.client.session.ui.attachment.AttachmentEditorKind import ai.kilocode.client.session.ui.attachment.attachmentParams import ai.kilocode.client.session.ui.attachment.ensureAttachmentEditorKind import ai.kilocode.client.session.ui.attachment.isEmbeddedAttachment +import ai.kilocode.client.agentManager.worktree.GithubIntegrationListener import ai.kilocode.client.agentManager.worktree.KiloWorktreeService import ai.kilocode.client.session.ui.header.BranchDock import ai.kilocode.client.session.ui.header.SessionHeaderPanel @@ -240,10 +244,7 @@ class SessionUi( } } 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 branchJob: Job? = null private var disposed = false @@ -813,6 +814,14 @@ class SessionUi( messageBody.syncApprovalReasons(visible) } }) + // refreshBranch cancels the in-flight lookup first, so a flip both drops a running gh call + // and re-reads the branch without one. + bus.subscribe(GithubIntegrationListener.TOPIC, GithubIntegrationListener { + ApplicationManager.getApplication().invokeLater { + if (disposed) return@invokeLater + refreshBranch() + } + }) } private fun onSessionLoaded(show: Boolean) { @@ -1026,20 +1035,36 @@ class SessionUi( Telemetry.send("Subagent Session Opened", mapOf("sessionId" to sessionId)) } - /** Badge-only refresh: fetches stats (no patch text) and updates the dock's changes badge. */ + @RequiresEdt private fun refreshBranchChanges() { val dock = dock ?: return + if (disposed || project.isDisposed) return + val local = manager?.supportsMoveToWorktree == true || manager?.supportsNewWorktree == true 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 + 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) + emptyList() + } + withContext(Dispatchers.Main) { + if (disposed || project.isDisposed) return@withContext + dock.setChanges(files) + } + } + if (local) launch { + val files = runCatching { workspaces.localDiff(workspace.directory, patches = false) } + .getOrElse { + if (it is CancellationException) throw it + LOG.warn("local changes refresh failed dir=${workspace.directory}", it) + emptyList() + } + withContext(Dispatchers.Main) { + if (disposed || project.isDisposed) return@withContext + dock.setLocal(files) } - withContext(Dispatchers.Main) { - if (disposed || project.isDisposed) return@withContext - dock.setChanges(files) } } } @@ -1049,23 +1074,11 @@ class SessionUi( * context-menu action, so it must not depend on [dock] — hosts that hide the dock still offer the * action. Never cancelled by a background refresh. */ + @RequiresEdt 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 - dock?.setChanges(files) - openBranchDiff(branch) - } - } + if (disposed || project.isDisposed) return + refreshBranchChanges() + openBranchDiff() } /** Starts the Move to Worktree flow for the current session through the side-panel manager. */ @@ -1075,18 +1088,8 @@ class SessionUi( } @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().open( - KiloDiffEditorKind.ID, - diffParams("branch", dir, null, title, branch), - ) - Telemetry.send("Diff Editor Opened", mapOf("source" to "branch")) + private fun openBranchDiff() { + openKiloDiff(project, workspace.directory, KiloDiffComparison.BASE, parent = this) } private fun showBranchDock(): Boolean = manager?.showsBranchDock != false @@ -1100,12 +1103,16 @@ class SessionUi( * the hosts that hide the dock (Agent Manager worktree session editors) are exactly the ones most * likely to have a PR. Skipped for readonly hosts, which offer no branch-scoped actions worth the * `gh` round-trip. + * + * With the GitHub integration off, the backend resolves the branch from git alone and returns a + * null PR, so the dock keeps working while no `gh` process is spawned. */ private fun refreshBranch() { if (readonly) return branchJob?.cancel() + val github = KiloPluginSettings.getGithub() branchJob = cs.launch { - val status = runCatching { service().branchStatus(workspace.directory) } + val status = runCatching { service().branchStatus(workspace.directory, github) } .getOrElse { if (it is CancellationException) throw it LOG.warn("branch status refresh failed dir=${workspace.directory}", it) @@ -1248,7 +1255,6 @@ class SessionUi( override fun dispose() { disposed = true refreshJob?.cancel() - openJob?.cancel() branchJob?.cancel() hide.stop() popup.hideAll() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt index a0d833949e36..50add9133779 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt @@ -1,5 +1,6 @@ package ai.kilocode.client.session.controller +import ai.kilocode.client.KiloNotifications import ai.kilocode.client.app.KiloAppService import ai.kilocode.client.app.KiloSessionService import ai.kilocode.client.app.Workspace @@ -32,15 +33,16 @@ import ai.kilocode.client.util.UiTimer import ai.kilocode.client.util.UiTimerSource import ai.kilocode.client.util.UiTimers import ai.kilocode.client.util.edtLater as edt +import ai.kilocode.rpc.dto.AgentsDto import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.ConfigWarningDto -import ai.kilocode.rpc.dto.ConfigUpdateDto import ai.kilocode.rpc.dto.EditorContextDto import ai.kilocode.rpc.dto.PartDto import ai.kilocode.rpc.dto.KiloAppStatusDto import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto import ai.kilocode.rpc.dto.LoadErrorDto import ai.kilocode.rpc.dto.MessageDto +import ai.kilocode.rpc.dto.MessageErrorDto import ai.kilocode.rpc.dto.MessageWithPartsDto import ai.kilocode.rpc.dto.ModelSelectionDto import ai.kilocode.rpc.dto.ProfileDto @@ -106,6 +108,7 @@ class SessionController( private val loaded: (Boolean) -> Unit = {}, private val openProfileAction: () -> Unit = {}, private val telemetry: (String, Map) -> Unit = { event, props -> Telemetry.send(event, props) }, + private val notify: (String, String) -> Unit = { title, body -> KiloNotifications.error(title, body) }, private val timers: UiTimerSource = UiTimers, private val log: KiloLog = LOG, ) : Disposable { @@ -192,6 +195,12 @@ class SessionController( private var prefVariantKey: String? = null private var prefVariant: String? = null private var modelTime: Double? = null + // A Stop this UI asked for, so the abort it produces reads as "Stopped" rather than a failure. + // Reset on every turn open: the flag describes one cancellation, not the session. + private var stopRequested = false + // Why the CLI cancelled the current turn, when it told us. Races the abort it explains, so the + // reason may land before or after the error and both orders have to end up in the same place. + private var cancelReason: String? = null private val snapshots = mutableMapOf() val ready: Boolean get() = model.isReady() @@ -303,6 +312,10 @@ class SessionController( private fun dispatch(data: Dispatch, send: suspend (String) -> Unit) { assertEdt() if (revertOp != null) return + // New work, so the previous cancellation is settled. Turn open clears this too, but a send that + // never reaches a turn would otherwise leave a stale Stop suppressing the next explanation. + stopRequested = false + cancelReason = null val props = data.props + if (data.kind == "command") slashProps() else emptyMap() capture("Conversation Send Clicked", sessionProps(sid ?: ref?.key) + mapOf( "source" to data.source, @@ -380,6 +393,7 @@ class SessionController( return } val id = sid ?: return + stopRequested = true updateModel { (childIds + id).forEach(::purgePending) } capture("Session Stop Clicked", sessionProps(id)) cs.launch { @@ -499,6 +513,9 @@ class SessionController( SessionState.Reverting.Kind.ROLLBACK, message, ) ?: return + // Marked here rather than beside the abort below: the abort's error event can arrive before a + // hop back onto the EDT would, and an unmarked abort reads as a cancellation we did not ask for. + if (busy) stopRequested = true revertJob = cs.launch { try { if (busy) { @@ -590,7 +607,9 @@ class SessionController( val state = model.state val failed = when { // A user stop also lands an errored tail (MessageAbortedError), and it is not a failure. - err != null -> !err.aborted + // A stop the user never asked for is: `error()` promoted it to SessionState.Error, so + // follow the state the transcript is already showing rather than the error name alone. + err != null -> !err.aborted || state is SessionState.Error // A turn that completed cleanly is not retryable even when a session-level error arrives // afterwards: continuing it would ask the model to redo work it already delivered. tail.info.role == "assistant" && tail.info.time.completed != null -> false @@ -686,6 +705,7 @@ class SessionController( SessionState.Reverting.Kind.REDO, message, ) ?: return + if (busy) stopRequested = true revertJob = cs.launch { try { if (busy) { @@ -761,6 +781,15 @@ class SessionController( } } + /** + * Switch this session's mode. + * + * Stays entirely client-side. The pick lives on [SessionModel.agent] for this session and in + * [KiloPluginSettings] as the mode the next new session opens with, and it reaches the CLI only + * as [PromptDto.agent] on each turn. It must never be written to the CLI's global config: the + * CLI disposes every instance it holds when that file changes, which cancels every running turn + * in every worktree. `NewWorktreeDialog` reached the same conclusion for its own picker. + */ fun selectAgent(name: String) { assertEdt() LOG.debug { "${ChatLogSummary.sid(sid ?: ref?.key ?: "pending")} kind=config agent=$name" } @@ -770,13 +799,7 @@ class SessionController( prefAgent = null prefVariantKey = null prefVariant = null - cs.launch { - try { - sessions.updateConfig(directory, ConfigUpdateDto(agent = name)) - } catch (e: Exception) { - LOG.warn("${ChatLogSummary.sid(sid ?: ref?.key ?: "pending")} kind=config agent=$name dir=${ChatLogSummary.dir(directory)} failed message=${e.message}", e) - } - } + KiloPluginSettings.setAgent(name) fire(SessionControllerEvent.WorkspaceReady) { model.agent = name syncModelSelection() @@ -1152,7 +1175,7 @@ class SessionController( } ?: emptyList() if (this@SessionController.model.agent == null) { - this@SessionController.model.agent = state.agents?.default + this@SessionController.model.agent = seedAgent(state.agents) } syncModelSelection() model.refreshHeader() @@ -1617,6 +1640,8 @@ class SessionController( is ChatEventDto.TurnOpen -> { partType = null tool = null + stopRequested = false + cancelReason = null if (revertOp != null) { revertDeferred = SessionState.Busy(KiloBundle.message("session.status.considering")) return @@ -1657,7 +1682,7 @@ class SessionController( is ChatEventDto.SessionCreated -> adoptFollowup(event.info) is ChatEventDto.Error -> { - if (event.error?.aborted != true) { + if (event.error?.aborted != true || unrequested(event.error)) { capture("Session Error", sessionProps(event.sessionID) + mapOf("context" to "event", "errorClass" to (event.error?.type ?: "unknown"))) } error(event, true) @@ -1707,6 +1732,7 @@ class SessionController( } is ChatEventDto.SessionDiffChanged -> model.setDiff(event.diff) is ChatEventDto.TodoUpdated -> model.setTodos(event.todos) + is ChatEventDto.SessionInterrupted -> interrupted(event) } } @@ -1732,6 +1758,7 @@ class SessionController( private fun handleHidden(event: ChatEventDto): Boolean = when (event) { is ChatEventDto.Error, + is ChatEventDto.SessionInterrupted, is ChatEventDto.PermissionAsked, is ChatEventDto.PermissionReplied, is ChatEventDto.QuestionAsked, @@ -1754,6 +1781,7 @@ class SessionController( LOG.debug { ChatLogSummary.event(event) } when (event) { is ChatEventDto.Error -> error(event, false) + is ChatEventDto.SessionInterrupted -> interrupted(event) is ChatEventDto.PermissionAsked -> asked(event) is ChatEventDto.PermissionReplied -> replied(event) is ChatEventDto.QuestionAsked -> asked(event) @@ -1770,7 +1798,8 @@ class SessionController( private fun error(event: ChatEventDto.Error, reveal: Boolean) { partType = null tool = null - if (isPaidModelAuthRequired(event.error)) { + val err = event.error + if (isPaidModelAuthRequired(err)) { loginRetry = retryPrompt() if (reveal) showSession() capture("Account Overlay Shown", sessionProps(event.sessionID) + mapOf( @@ -1780,9 +1809,62 @@ class SessionController( model.setState(SessionState.LoginRequired(KiloBundle.message("session.login.required.description"))) return } - if (event.error?.aborted == true) return - val msg = event.error?.message ?: event.error?.type ?: KiloBundle.message("session.error.unknown") - model.setState(SessionState.Error(msg, event.error?.type)) + if (err != null && err.aborted) { + if (stopRequested) return + surfaceCancelled(event.sessionID, err.type) + return + } + val msg = err?.message ?: err?.type ?: KiloBundle.message("session.error.unknown") + model.setState(SessionState.Error(msg, err?.type)) + } + + /** + * Whether an abort arrived that this UI never asked for. + * + * The CLI cannot tell us: it reports a Stop and a server-side cancellation with the same + * `MessageAbortedError`, so only the client knows whether it pressed Stop. Everything else — + * a config reload disposing instances, a session deleted elsewhere, another editor aborting the + * same session — lands here and has to be explained rather than passed off as the user's doing. + */ + private fun unrequested(err: MessageErrorDto?): Boolean = err != null && err.aborted && !stopRequested + + /** + * Report a cancellation nobody in this UI asked for. + * + * Uses [SessionState.Error] rather than an interrupted outcome so the transcript prints the reason + * and offers Retry: the turn lost its work, which is a failure however politely the CLI phrased it. + * The balloon is for the case that caused this to exist — a session cancelled in a worktree the + * user is not currently looking at, which the transcript alone can never tell them about. + */ + private fun surfaceCancelled(session: String?, kind: String) { + val reason = cancelReason + val text = cancelledMessage(reason) + LOG.warn("${ChatLogSummary.sid(session ?: sid ?: "?")} kind=cancelled requested=false reason=${reason ?: "unknown"}") + model.setState(SessionState.Error(text, kind)) + notify(KiloBundle.message("session.cancelled.title"), text) + } + + private fun cancelledMessage(reason: String?): String = when (reason) { + ChatEventDto.SessionInterrupted.RELOAD -> KiloBundle.message("session.cancelled.reload") + else -> KiloBundle.message("session.cancelled.unknown") + } + + /** + * Record why the CLI stopped this turn, and re-label an already-visible cancellation. + * + * This races the abort it explains — the CLI publishes the cancellation before it finishes + * disposing, and the disposal event that names the cause can land on either side of it. Handling + * both orders here keeps the reason out of the ordering's hands. + */ + private fun interrupted(event: ChatEventDto.SessionInterrupted) { + if (cancelReason == event.reason) return + cancelReason = event.reason + val current = model.state + // Only a cancellation already on screen needs relabelling, and only [surfaceCancelled] puts the + // abort's name on an error state — a provider failure carries its own reason and keeps it. + if (current !is SessionState.Error) return + if (current.kind != MessageErrorDto.ABORTED) return + model.setState(SessionState.Error(cancelledMessage(event.reason), current.kind)) } private fun asked(event: ChatEventDto.PermissionAsked) { @@ -2187,6 +2269,20 @@ class SessionController( open(SessionRef.Local(session)) } + /** + * Mode a session with no history of its own opens in. + * + * The last mode the picker selected wins, so switching mode still sticks across new sessions and + * IDE restarts without the CLI's global config — the write that used to provide this also tore + * down every instance the CLI held. Falls back to the CLI default when the remembered mode is + * gone (renamed, hidden, or removed from a different config). + */ + private fun seedAgent(agents: AgentsDto?): String? { + val remembered = KiloPluginSettings.getAgent() ?: return agents?.default + val offered = agents?.agents ?: return remembered + return if (offered.any { it.name == remembered }) remembered else agents.default + } + private fun syncHistoryAgent(items: List) { val before = model.prefs() val agent = items @@ -2767,6 +2863,7 @@ private fun matchesSession(event: ChatEventDto, id: String): Boolean = when (eve is ChatEventDto.SessionStatusChanged -> event.sessionID == id is ChatEventDto.SessionUpdated -> event.sessionID == id is ChatEventDto.SessionIdle -> event.sessionID == id + is ChatEventDto.SessionInterrupted -> event.sessionID == id is ChatEventDto.SessionQueueChanged -> event.sessionID == id is ChatEventDto.SessionCompacted -> event.sessionID == id is ChatEventDto.SessionDiffChanged -> event.sessionID == id diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/BranchChangesBadge.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/BranchChangesBadge.kt deleted file mode 100644 index ff3e300e4d57..000000000000 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/BranchChangesBadge.kt +++ /dev/null @@ -1,145 +0,0 @@ -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.session.ui.style.SessionUiStyle -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 = 0 - 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 = SessionUiStyle.Text.Secondary.foreground() - } - - 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): Boolean = - set(next.size, next.sumOf { it.additions }, next.sumOf { it.deletions }) - - /** Update from precomputed aggregates (e.g. worktree branch stats without a per-file list). */ - fun update(files: Int, additions: Int, deletions: Int): Boolean = - set(files, additions, deletions) - - private fun set(n: Int, add: Int, del: Int): Boolean { - if (files == n && additions == add && deletions == del) return false - files = n - additions = add - deletions = del - count.text = KiloBundle.message( - if (n == 1) "session.changes.count.one" else "session.changes.count.other", - n, - ) - stat.update(add, del) - isVisible = n > 0 - 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" - } -} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/BranchDock.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/BranchDock.kt index 22ebc4080ca2..47d22859ced9 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/BranchDock.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/BranchDock.kt @@ -3,6 +3,7 @@ package ai.kilocode.client.session.ui.header import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget import ai.kilocode.client.session.ui.style.SessionUiStyle +import ai.kilocode.client.ui.ChangesPanel import ai.kilocode.client.ui.UiStyle import ai.kilocode.client.ui.layout.HAlign import ai.kilocode.client.ui.layout.Stack @@ -24,30 +25,14 @@ import com.intellij.util.ui.components.BorderLayoutPanel import java.awt.Color import java.awt.Dimension -/** - * Branch/PR dock shown above the prompt in the chat side panel. - * - * Two mutually exclusive layouts, stacked so the row height stays stable: - * - PR present: the shared [PrHeaderView] (state badge + PR title + changes badge). - * - No PR: a horizontally centered row with the New Worktree / Move to Worktree toolbar actions and - * the changes badge. - * - * The two actions are registered platform actions (`Kilo.Chat.NewWorktree`, `Kilo.Chat.MoveToWorktree`) - * rendered through an [com.intellij.openapi.actionSystem.ActionToolbar]; each is invisible when it is - * not enabled. The dock exposes its state to those actions via [ChatDockKeys.DOCK]. Collapses to - * nothing unless it has a PR, changes, messages, or an enabled action. - * - * The action row is offered only while the session is idle: an active turn ([setBusy]) withdraws it. - * The PR row stays through a turn — it is informational, not an action. - */ -internal class BranchDock( +internal class BranchDock @RequiresEdt constructor( openDiff: () -> Unit, private val onMove: (() -> Unit)?, private val onNewWorktree: (() -> Unit)? = null, titleStyle: Int = SimpleTextAttributes.STYLE_PLAIN, ) : BorderLayoutPanel(), SessionEditorStyleTarget, UiDataProvider { - private val core = PrHeaderView(titleStyle = titleStyle, openDiff = openDiff) - private val changes = BranchChangesBadge(openDiff) + private val core = PrHeaderView(titleStyle = titleStyle, mode = ChangesPanel.Mode.COMPACT, openDiff = openDiff) + private val changes = ChangesPanel(ChangesPanel.Mode.COMPACT, onBase = openDiff) private val group = DefaultActionGroup().apply { ActionManager.getInstance().getAction("Kilo.Chat.NewWorktree")?.let { add(it) } ActionManager.getInstance().getAction("Kilo.Chat.MoveToWorktree")?.let { add(it) } @@ -58,6 +43,7 @@ internal class BranchDock( .next(changes.align(HAlign.CENTER, VAlign.CENTER)) .align(HAlign.CENTER, VAlign.CENTER) private var files = emptyList() + private var local = 0 private var branch: BranchStatusDto? = null private var hasMessages = false private var hasSession = false @@ -66,15 +52,16 @@ internal class BranchDock( init { isOpaque = true toolbar.targetComponent = this - // Transparent so the toolbar shows the dock's prompt-matching background and tracks LaF. toolbar.component.isOpaque = false addToCenter(Stack.vertical().next(core).next(actionRow).align(HAlign.TRACK, VAlign.CENTER)) isVisible = false sync() } + @RequiresEdt override fun getBackground(): Color = SessionUiStyle.Colors.codeBlockBackground() + @RequiresEdt override fun updateUI() { super.updateUI() border = JBUI.Borders.compound( @@ -83,18 +70,28 @@ internal class BranchDock( ) } + @RequiresEdt override fun uiDataSnapshot(sink: DataSink) { sink[ChatDockKeys.DOCK] = this } @RequiresEdt fun setChanges(files: List) { + if (this.files == files) return this.files = files sync() } + @RequiresEdt + fun setLocal(files: List) { + if (local == files.size) return + local = files.size + sync() + } + @RequiresEdt fun setBranch(branch: BranchStatusDto?) { + if (this.branch == branch) return this.branch = branch sync() } @@ -113,10 +110,6 @@ internal class BranchDock( sync() } - /** - * Whether the chat already has a persisted session. A session-less move transfers only local - * changes; this value affects action wording, not visibility. - */ @RequiresEdt fun setHasSession(value: Boolean) { if (hasSession == value) return @@ -124,48 +117,53 @@ internal class BranchDock( syncToolbar() } - // ---- state read by the toolbar actions ---- - + @RequiresEdt fun newWorktreeEnabled(): Boolean = onNewWorktree != null && dockActive() + @RequiresEdt fun moveEnabled(): Boolean = onMove != null && dockActive() - fun changeCount(): Int = files.size + @RequiresEdt + fun changeCount(): Int = local + @RequiresEdt fun hasSession(): Boolean = hasSession + @RequiresEdt fun triggerNewWorktree() = onNewWorktree?.invoke() ?: Unit + @RequiresEdt fun triggerMove() = onMove?.invoke() ?: Unit - private fun dockActive(): Boolean = gitAvailable() && !busy && (hasMessages || files.isNotEmpty()) + @RequiresEdt + private fun dockActive(): Boolean = gitAvailable() && !busy && (hasMessages || local > 0) private fun gitAvailable(): Boolean { val branch = branch ?: return false return branch.availability != GhAvailability.GIT_MISSING } + @RequiresEdt private fun sync() { val pull = branch?.pr val count = files.size - core.update(count, files.sumOf { it.additions }, files.sumOf { it.deletions }, pull, branch?.branch.orEmpty()) - changes.update(count, files.sumOf { it.additions }, files.sumOf { it.deletions }) - - // PR present -> the informational PR header; otherwise the centered action row. + val additions = files.sumOf { it.additions } + val deletions = files.sumOf { it.deletions } + core.update(count, additions, deletions, pull, branch?.branch.orEmpty()) + changes.update(count, additions, deletions) core.isVisible = pull != null - val rowVisible = pull == null && dockActive() - actionRow.isVisible = rowVisible - - val next = pull != null || rowVisible + val visible = pull == null && gitAvailable() && !busy && + (files.isNotEmpty() || hasMessages || newWorktreeEnabled() || moveEnabled()) + if (actionRow.isVisible != visible) actionRow.isVisible = visible + val next = pull != null || visible if (isVisible != next) isVisible = next syncToolbar() revalidate() repaint() } + @RequiresEdt private fun syncToolbar() { - // Tests need a synchronous refresh to assert action presentations; production nudges the - // platform's action-update pass instead of the deprecated blocking updateActionsImmediately(). if (ApplicationManager.getApplication().isUnitTestMode) { @Suppress("DEPRECATION") toolbar.updateActionsImmediately() @@ -174,19 +172,23 @@ internal class BranchDock( ActivityTracker.getInstance().inc() } + @RequiresEdt override fun getPreferredSize(): Dimension { val base = super.getPreferredSize() - // Reserve a stable row height so the transcript does not jump as stats/PR arrive late. return Dimension(base.width, maxOf(base.height, JBUI.scale(ROW_HEIGHT))) } + @RequiresEdt override fun getMinimumSize(): Dimension = preferredSize + @RequiresEdt override fun getMaximumSize(): Dimension = Dimension(Int.MAX_VALUE, preferredSize.height) + @RequiresEdt override fun applyStyle(style: SessionEditorStyle) { core.applyStyle(style) - changes.applyStyle(style) + changes.font = style.smallFont + changes.foreground = SessionUiStyle.Text.Secondary.foreground() } private companion object { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/PrHeaderView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/PrHeaderView.kt index eec87f1dcffe..aacc8de86388 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/PrHeaderView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/PrHeaderView.kt @@ -2,6 +2,8 @@ package ai.kilocode.client.session.ui.header import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget +import ai.kilocode.client.session.ui.style.SessionUiStyle +import ai.kilocode.client.ui.ChangesPanel import ai.kilocode.client.ui.FilledBadgeIcon import ai.kilocode.client.ui.UiStyle import ai.kilocode.client.ui.layout.HAlign @@ -13,7 +15,6 @@ import ai.kilocode.client.ui.stateLabel import ai.kilocode.client.ui.style import ai.kilocode.rpc.dto.GhState import ai.kilocode.rpc.dto.WorktreePrDto -import ai.kilocode.rpc.dto.WorktreeStatsDto import com.intellij.ide.BrowserUtil import com.intellij.ui.SimpleColoredComponent import com.intellij.ui.SimpleTextAttributes @@ -26,27 +27,28 @@ import java.awt.Component import java.awt.Cursor import java.awt.event.MouseAdapter import java.awt.event.MouseEvent +import javax.swing.JSeparator +import javax.swing.SwingConstants +import javax.swing.SwingUtilities -/** - * Neutral core of the branch/PR header: a state badge (LEFT), the PR title (CENTER, click opens the - * PR in the browser), and a changes badge plus a host-fillable trailing action slot (RIGHT). - * - * Shared by the Agent Manager worktree editor ([ai.kilocode.client.agentManager.worktree.WorktreePrHeaderView]) - * and the chat [BranchDock]. Retained-mode: [update] mutates existing nodes rather than rebuilding. - * Stays non-opaque so the host owns background and borders. - */ -internal class PrHeaderView( +internal class PrHeaderView @RequiresEdt constructor( private val titleStyle: Int = SimpleTextAttributes.STYLE_BOLD, + mode: ChangesPanel.Mode = ChangesPanel.Mode.COMPACT, + onLocal: (() -> Unit)? = null, openDiff: () -> Unit, ) : BorderLayoutPanel(), SessionEditorStyleTarget { private val status = JBLabel() private val title = SimpleColoredComponent() - private val changes = BranchChangesBadge(openDiff) + private val changes = ChangesPanel(mode, onBase = openDiff, onLocal = onLocal) private val statusPane = status.align(HAlign.LEFT, VAlign.CENTER) + // Hidden until the first action is added: hosts with no trailing actions (e.g. BranchDock) show + // just the changes summary, so an always-visible separator would dangle with nothing after it. + private val actionsSeparator = JSeparator(SwingConstants.VERTICAL).apply { isVisible = false } private val actions = Stack.horizontal(UiStyle.Gap.sm()) .next(changes.align(HAlign.CENTER, VAlign.CENTER)) + .next(actionsSeparator) private var style = SessionEditorStyle.current() - private var pull: WorktreePrDto? = null + private var actionCount = 0 private var state: GhState? = null private var number: String? = null private var body: String? = null @@ -55,47 +57,63 @@ internal class PrHeaderView( init { isOpaque = false - actions.isOpaque = false - actions.border = JBUI.Borders.emptyRight(UiStyle.Gap.sm()) + // Standard padding fences the toolbar off from the PR title on the left. + actions.border = JBUI.Borders.empty(0, UiStyle.Gap.md(), 0, UiStyle.Gap.sm()) status.border = JBUI.Borders.empty(0, UiStyle.Gap.md(), 0, UiStyle.Gap.xs()) + status.isVisible = false title.border = JBUI.Borders.empty(0, UiStyle.Gap.sm()) title.isOpaque = false + title.isVisible = false addToLeft(statusPane) addToCenter(title) addToRight(actions.align(HAlign.RIGHT, VAlign.CENTER)) val listener = object : MouseAdapter() { + @RequiresEdt override fun mouseClicked(event: MouseEvent) { - url?.let(BrowserUtil::browse) + if (event.isConsumed || event.isPopupTrigger || !SwingUtilities.isLeftMouseButton(event) || event.clickCount != 1) return + if (isEnabled && event.component.isEnabled) url?.let(BrowserUtil::browse) } } status.addMouseListener(listener) title.addMouseListener(listener) - changes.applyStyle(style) - syncClick(null) + changes.font = style.smallFont + changes.foreground = SessionUiStyle.Text.Secondary.foreground() } - /** Adds a host action into the trailing slot, to the right of the changes badge. */ + @RequiresEdt fun addAction(component: Component) { + actionCount++ actions.next(component.align(HAlign.CENTER, VAlign.CENTER)) + syncSeparator() } - /** Update from worktree branch stats (files/additions/deletions on the stats DTO). */ @RequiresEdt - fun update(stats: WorktreeStatsDto?, pull: WorktreePrDto?, name: String) { - changes.update(stats?.files ?: 0, stats?.additions ?: 0, stats?.deletions ?: 0) + fun update( + files: Int, + additions: Int, + deletions: Int, + pull: WorktreePrDto?, + name: String, + ahead: Int = 0, + behind: Int = 0, + localFiles: Int = 0, + localAdditions: Int = 0, + localDeletions: Int = 0, + base: String = "", + ) { + changes.update(files, additions, deletions, ahead, behind, localFiles, localAdditions, localDeletions, base) + syncSeparator() applyPr(pull, name) } - /** Update from precomputed change aggregates (chat dock aggregates a DiffFileDto list). */ @RequiresEdt - fun update(files: Int, additions: Int, deletions: Int, pull: WorktreePrDto?, name: String) { - changes.update(files, additions, deletions) - applyPr(pull, name) + private fun syncSeparator() { + val visible = actionCount > 0 && changes.isVisible + if (actionsSeparator.isVisible != visible) actionsSeparator.isVisible = visible } @RequiresEdt private fun applyPr(pull: WorktreePrDto?, name: String) { - this.pull = pull if (pull == null) { syncPr(false) syncStatus(null) @@ -110,30 +128,26 @@ internal class PrHeaderView( syncStatus(pull.state) syncTitle("#${pull.number}", body, tip) syncClick(pull.url) - status.toolTipText = tip + if (status.toolTipText != tip) status.toolTipText = tip } + @RequiresEdt private fun syncStatus(next: GhState?) { - if (state == next) { - val visible = next != null - if (status.isVisible != visible) status.isVisible = visible - return - } + if (state == next) return state = next status.icon = next?.let { FilledBadgeIcon(stateLabel(it), style(it)) } status.isVisible = next != null changed() } + @RequiresEdt private fun syncPr(value: Boolean) { - if (value) { - title.isVisible = true - return - } - title.isVisible = false + if (title.isVisible == value) return + title.isVisible = value changed() } + @RequiresEdt private fun clearTitle() { if (number == null && tip == null) return number = null @@ -145,36 +159,38 @@ internal class PrHeaderView( changed() } - private fun syncTitle(number: String, body: String?, nextTip: String?) { + @RequiresEdt + private fun syncTitle(number: String, body: String?, next: String?) { var changed = false if (this.number != number || this.body != body) { this.number = number this.body = body - renderTitle() + syncText() changed = true } - if (tip != nextTip) { - tip = nextTip - title.toolTipText = nextTip - if (pull == null) status.toolTipText = null + if (tip != next) { + tip = next + title.toolTipText = next changed = true } if (changed) changed() } - private fun renderTitle() { + @RequiresEdt + private fun syncText() { val number = number ?: return title.clear() val body = body val attrs = SimpleTextAttributes(titleStyle, UIUtil.getLabelForeground()) if (body == null) { title.append(number, attrs) - } else { - title.append(body, attrs) - title.append(" $number", SimpleTextAttributes.GRAYED_ATTRIBUTES) + return } + title.append(body, attrs) + title.append(" $number", SimpleTextAttributes.GRAYED_ATTRIBUTES) } + @RequiresEdt private fun syncClick(next: String?) { if (url == next) return url = next @@ -183,14 +199,16 @@ internal class PrHeaderView( title.cursor = cursor } + @RequiresEdt override fun applyStyle(style: SessionEditorStyle) { this.style = style - changes.applyStyle(style) - // Re-render the title so its foreground follows the theme, then repaint. - renderTitle() + changes.font = style.smallFont + changes.foreground = SessionUiStyle.Text.Secondary.foreground() + syncText() changed() } + @RequiresEdt private fun changed() { revalidate() repaint() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/KiloSettingsConfigurable.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/KiloSettingsConfigurable.kt index ffaa33910e4c..7778b0ab4cb0 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/KiloSettingsConfigurable.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/KiloSettingsConfigurable.kt @@ -4,6 +4,7 @@ import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.settings.agents.AgentBehaviorConfigurable import ai.kilocode.client.settings.autoapprove.AutoApproveConfigurable import ai.kilocode.client.settings.context.ContextConfigurable +import ai.kilocode.client.settings.integrations.IntegrationsConfigurable import ai.kilocode.client.settings.models.ModelsConfigurable import ai.kilocode.client.settings.providers.ProvidersConfigurable import ai.kilocode.client.settings.profile.UserProfileConfigurable @@ -91,6 +92,14 @@ class KiloSettingsConfigurable : SearchableConfigurable { context.border = JBUI.Borders.emptyBottom(UiStyle.Gap.sm()) panel.next(context) + val integrations = ActionLink(KiloBundle.message("settings.integrations.displayName")) { e -> + val src = e.source as? JComponent ?: return@ActionLink + val settings = Settings.KEY.getData(DataManager.getInstance().getDataContext(src)) ?: return@ActionLink + open(settings, IntegrationsConfigurable.ID) + } + integrations.border = JBUI.Borders.emptyBottom(UiStyle.Gap.sm()) + panel.next(integrations) + val advanced = ActionLink(KiloBundle.message("settings.advanced.displayName")) { e -> val src = e.source as? JComponent ?: return@ActionLink val settings = Settings.KEY.getData(DataManager.getInstance().getDataContext(src)) ?: return@ActionLink diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/integrations/IntegrationsConfigurable.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/integrations/IntegrationsConfigurable.kt new file mode 100644 index 000000000000..4ce5fae6e331 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/integrations/IntegrationsConfigurable.kt @@ -0,0 +1,39 @@ +package ai.kilocode.client.settings.integrations + +import ai.kilocode.client.plugin.KiloBundle +import com.intellij.openapi.options.Configurable +import com.intellij.openapi.options.SearchableConfigurable +import com.intellij.openapi.util.Disposer +import javax.swing.JComponent + +/** + * Integrations settings page. Not a [ai.kilocode.client.settings.base.DraftReadyConfigurable]: its + * settings are local IDE preferences that must stay editable when the CLI is unavailable, and each + * toggle writes immediately, so there is nothing to apply or revert. + */ +class IntegrationsConfigurable : SearchableConfigurable, Configurable.NoMargin, Configurable.NoScroll { + private var ui: IntegrationsSettingsUi? = null + + override fun getId(): String = ID + + override fun getDisplayName(): String = KiloBundle.message("settings.integrations.displayName") + + override fun createComponent(): JComponent = IntegrationsSettingsUi().also { ui = it } + + override fun isModified(): Boolean = false + + override fun apply() = Unit + + override fun reset() { + ui?.sync() + } + + override fun disposeUIResources() { + ui?.let { Disposer.dispose(it) } + ui = null + } + + companion object { + const val ID = "ai.kilocode.jetbrains.settings.integrations" + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/integrations/IntegrationsSettingsUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/integrations/IntegrationsSettingsUi.kt new file mode 100644 index 000000000000..e883cc3b19db --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/integrations/IntegrationsSettingsUi.kt @@ -0,0 +1,48 @@ +package ai.kilocode.client.settings.integrations + +import ai.kilocode.client.agentManager.worktree.GithubIntegrationListener +import ai.kilocode.client.agentManager.worktree.setGithubIntegration +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.plugin.KiloPluginSettings +import ai.kilocode.client.settings.base.BaseContentPanel +import ai.kilocode.client.settings.base.SettingsPanel +import ai.kilocode.client.settings.base.SettingsRow +import ai.kilocode.client.settings.base.SettingsToggle +import com.intellij.openapi.Disposable +import com.intellij.openapi.application.ApplicationManager +import com.intellij.util.concurrency.annotations.RequiresEdt + +/** + * Integrations page. Every setting here is a local IDE preference, so the page renders immediately + * and writes on toggle instead of going through [ai.kilocode.client.settings.base.BaseSettingsUi]'s + * draft/apply flow, which would gate the UI on CLI readiness it does not need. + */ +internal class IntegrationsSettingsUi : SettingsPanel(), Disposable { + private val github = SettingsToggle(KiloPluginSettings.getGithub()) { setGithubIntegration(it, "settings") } + + init { + val content = BaseContentPanel() + content.section( + KiloBundle.message("settings.integrations.github.title"), + KiloBundle.message("settings.integrations.github.description"), + ).row( + SettingsRow( + KiloBundle.message("settings.integrations.github.enabled.title"), + KiloBundle.message("settings.integrations.github.enabled.description"), + github, + ), + ) + setContent(content) + // The gh banner can turn the integration off while this page is open. + ApplicationManager.getApplication().messageBus.connect(this) + .subscribe(GithubIntegrationListener.TOPIC, GithubIntegrationListener { sync() }) + } + + @RequiresEdt + fun sync() { + val value = KiloPluginSettings.getGithub() + if (github.isSelected != value) github.isSelected = value + } + + override fun dispose() = Unit +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/ChangesPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/ChangesPanel.kt new file mode 100644 index 000000000000..4afd9bf1fac0 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/ChangesPanel.kt @@ -0,0 +1,331 @@ +package ai.kilocode.client.ui + +import ai.kilocode.client.plugin.KiloBundle +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 com.intellij.openapi.util.IconLoader +import com.intellij.openapi.util.text.StringUtil +import com.intellij.ui.JBColor +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.BorderLayout +import java.awt.Color +import java.awt.Component +import java.awt.Container +import java.awt.Cursor +import java.awt.Dimension +import java.awt.Font +import java.awt.Graphics +import java.awt.Graphics2D +import java.awt.RenderingHints +import java.awt.event.ActionEvent +import java.awt.event.FocusAdapter +import java.awt.event.FocusEvent +import java.awt.event.KeyEvent +import java.awt.event.MouseAdapter +import java.awt.event.MouseEvent +import javax.accessibility.AccessibleAction +import javax.accessibility.AccessibleContext +import javax.accessibility.AccessibleRole +import javax.swing.AbstractAction +import javax.swing.JComponent +import javax.swing.JPanel +import javax.swing.JSeparator +import javax.swing.KeyStroke +import javax.swing.SwingConstants +import javax.swing.SwingUtilities + +internal class ChangesPanel @RequiresEdt constructor( + private val mode: Mode, + onBase: (() -> Unit)? = null, + onLocal: (() -> Unit)? = null, +) : JPanel(BorderLayout()) { + internal enum class Mode { COMPACT, FULL } + + private val base = Group(fill = true) + private val local = if (mode == Mode.FULL) Group(fill = false) else null + private val ahead = if (mode == Mode.FULL) counter("/icons/arrow-up.svg", "worktree.stats.ahead.tooltip") else null + private val behind = if (mode == Mode.FULL) counter("/icons/arrow-down-to-line.svg", "worktree.stats.behind.tooltip") else null + private val separator = if (mode == Mode.FULL) JSeparator(SwingConstants.VERTICAL) else null + private var state: State? = null + + init { + isOpaque = false + isFocusable = false + isVisible = false + if (mode == Mode.COMPACT) { + add(base, BorderLayout.CENTER) + addMouseListener(base.listener) + } else { + add(Stack.horizontal(UiStyle.Gap.md()).next(local!!).next(separator!!).next(ahead!!).next(behind!!).next(base)) + } + visit(this) { it.isFocusable = false } + font = JBFont.small() + foreground = JBColor.lazy { UiStyle.Colors.weak() } + setActions(onBase, onLocal) + } + + @RequiresEdt + fun setActions(onBase: (() -> Unit)?, onLocal: (() -> Unit)? = null) { + base.action = onBase + local?.action = onLocal + syncActions() + } + + @RequiresEdt + fun update( + files: Int, + additions: Int, + deletions: Int, + ahead: Int = 0, + behind: Int = 0, + localFiles: Int = 0, + localAdditions: Int = 0, + localDeletions: Int = 0, + base: String = "", + ) { + val next = if (mode == Mode.COMPACT) { + State(files, additions, deletions, base = base) + } else { + State(files, additions, deletions, ahead, behind, localFiles, localAdditions, localDeletions, base) + } + if (state == next) return + state = next + val tip = if (base.isBlank()) { + KiloBundle.message("worktree.stats.tooltip", files, additions, deletions) + } else { + KiloBundle.message("worktree.stats.base.tooltip", files, additions, deletions, base) + } + this.base.update(files, additions, deletions, tip) + local?.update( + localFiles, localAdditions, localDeletions, + KiloBundle.message("worktree.dirty.tooltip", localFiles, localAdditions, localDeletions), + ) + this.ahead?.let { counter(it, ahead) } + this.behind?.let { counter(it, behind) } + val right = files > 0 || next.ahead > 0 || next.behind > 0 + separator?.let { if (it.isVisible != (localFiles > 0 && right)) it.isVisible = localFiles > 0 && right } + val visible = right || next.localFiles > 0 + if (isVisible != visible) isVisible = visible + val tooltip = tip.takeIf { mode == Mode.COMPACT && files > 0 } + if (toolTipText != tooltip) toolTipText = tooltip + syncActions() + revalidate() + repaint() + } + + @RequiresEdt + override fun setEnabled(enabled: Boolean) { + if (isEnabled == enabled) return + super.setEnabled(enabled) + syncActions() + } + + @RequiresEdt + override fun setFont(font: Font?) { + if (getFont() != font) super.setFont(font) + visit(this) { if (it is JBLabel && it.font != font) it.font = font } + } + + @RequiresEdt + override fun setForeground(color: Color?) { + if (foreground != color) super.setForeground(color) + visit(this) { + if (it is JBLabel && SwingUtilities.getAncestorOfClass(DiffStatBadge::class.java, it) == null && it.foreground != color) { + it.foreground = color + } + } + } + + @RequiresEdt + private fun syncActions() { + visit(this) { if (it is Group) it.syncAction() } + val next = if (mode == Mode.COMPACT) base.cursor else Cursor.getDefaultCursor() + if (cursor != next) cursor = next + } + + @RequiresEdt + private fun counter(path: String, key: String) = JBLabel().apply { + icon = IconLoader.getIcon(path, ChangesPanel::class.java) + iconTextGap = UiStyle.Gap.xs() + foreground = JBColor.lazy { UiStyle.Colors.weak() } + toolTipText = KiloBundle.message(key) + isFocusable = false + isVisible = false + } + + @RequiresEdt + private fun counter(label: JBLabel, value: Int) { + val text = value.toString() + if (label.text != text) label.text = text + if (label.isVisible != (value > 0)) label.isVisible = value > 0 + label.accessibleContext.accessibleName = "${label.toolTipText}: $value" + } + + @RequiresEdt + private fun visit(component: Component, block: (Component) -> Unit) { + block(component) + if (component is Container) component.components.forEach { visit(it, block) } + } + + private inner class Group @RequiresEdt constructor(fill: Boolean) : JPanel(BorderLayout()) { + private val count = JBLabel().apply { foreground = JBColor.lazy { UiStyle.Colors.weak() } } + private val stat = DiffStatBadge(0, 0, DiffStatBadge.Variant.COMPACT, fill = fill) + private var over = false + var action: (() -> Unit)? = null + val listener = object : MouseAdapter() { + @RequiresEdt + override fun mouseEntered(event: MouseEvent) = hover(interactive()) + + @RequiresEdt + override fun mouseExited(event: MouseEvent) { + hover(interactive() && contains(SwingUtilities.convertPoint(event.component, event.point, this@Group))) + } + + @RequiresEdt + override fun mouseClicked(event: MouseEvent) { + if (event.isConsumed || event.isPopupTrigger || !SwingUtilities.isLeftMouseButton(event) || event.clickCount != 1) return + if (activate(event.component)) event.consume() + } + } + private val activation = object : AbstractAction() { + @RequiresEdt + override fun actionPerformed(event: ActionEvent) { + activate() + } + } + + init { + isOpaque = false + isVisible = false + stat.isVisible = false + add(Stack.horizontal(UiStyle.Gap.sm()).next(count).next(stat).align(HAlign.LEFT, VAlign.CENTER)) + visit(this) { + it.isFocusable = false + it.addMouseListener(listener) + } + getInputMap(WHEN_FOCUSED).apply { + put(KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, 0), ACTIVATE) + put(KeyStroke.getKeyStroke(KeyEvent.VK_SPACE, 0), ACTIVATE) + } + actionMap.put(ACTIVATE, activation) + addFocusListener(object : FocusAdapter() { + @RequiresEdt + override fun focusGained(event: FocusEvent) = repaint() + + @RequiresEdt + override fun focusLost(event: FocusEvent) = repaint() + }) + addPropertyChangeListener("enabled") { syncAction() } + } + + @RequiresEdt + override fun updateUI() { + super.updateUI() + border = JBUI.Borders.empty(0, UiStyle.Gap.sm()) + } + + @RequiresEdt + override fun getPreferredSize(): Dimension = super.getPreferredSize().apply { + height = maxOf(height, JBUI.CurrentTheme.Button.minimumSize().height) + } + + @RequiresEdt + fun update(files: Int, additions: Int, deletions: Int, tip: String) { + val text = KiloBundle.message(if (files == 1) "session.changes.count.one" else "session.changes.count.other", files) + if (count.text != text) count.text = text + if (count.isVisible != (files > 0)) count.isVisible = files > 0 + stat.update(additions, deletions) + val lines = files > 0 && (additions > 0 || deletions > 0) + if (stat.isVisible != lines) stat.isVisible = lines + if (isVisible != (files > 0)) isVisible = files > 0 + val tooltip = tip.takeIf { files > 0 } + visit(this) { if (it is JComponent && it.toolTipText != tooltip) it.toolTipText = tooltip } + val name = tooltip?.let { StringUtil.stripHtml(it, " ") } + if (getAccessibleContext().accessibleName != name) getAccessibleContext().accessibleName = name + } + + @RequiresEdt + fun syncAction() { + val active = interactive() + if (isFocusable != active) isFocusable = active + if (activation.isEnabled != active) activation.isEnabled = active + val cursor = if (active) Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) else Cursor.getDefaultCursor() + visit(this) { if (it.cursor != cursor) it.cursor = cursor } + if (!active) hover(false) + } + + @RequiresEdt + private fun interactive(): Boolean = + action != null && isVisible && isEnabled && this@ChangesPanel.isEnabled && this@ChangesPanel.isVisible + + @RequiresEdt + private fun activate(source: Component = this): Boolean { + if (!interactive() || generateSequence(source) { it.parent }.any { !it.isEnabled || !it.isVisible }) return false + action?.invoke() ?: return false + return true + } + + @RequiresEdt + private fun hover(value: Boolean) { + if (over == value) return + over = value + repaint() + } + + @RequiresEdt + override fun paintComponent(g: Graphics) { + super.paintComponent(g) + if (!interactive() || (!over && !hasFocus())) return + val canvas = g.create() as Graphics2D + try { + canvas.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON) + canvas.color = UiStyle.Colors.actionHoverBackground() + val arc = JBUI.scale(JBUI.getInt("Button.arc", 6)) + canvas.fillRoundRect(0, 0, width, height, arc, arc) + } finally { + canvas.dispose() + } + } + + @RequiresEdt + override fun getAccessibleContext(): AccessibleContext { + if (accessibleContext == null) { + accessibleContext = object : AccessibleJPanel(), AccessibleAction { + override fun getAccessibleRole(): AccessibleRole = AccessibleRole.PUSH_BUTTON + override fun getAccessibleAction(): AccessibleAction = this + + @RequiresEdt + override fun getAccessibleActionCount(): Int = if (interactive()) 1 else 0 + + @RequiresEdt + override fun getAccessibleActionDescription(index: Int): String? = if (index == 0) accessibleName else null + + @RequiresEdt + override fun doAccessibleAction(index: Int): Boolean = index == 0 && activate() + } + } + return accessibleContext + } + } + + private data class State( + val files: Int, + val additions: Int, + val deletions: Int, + val ahead: Int = 0, + val behind: Int = 0, + val localFiles: Int = 0, + val localAdditions: Int = 0, + val localDeletions: Int = 0, + val base: String = "", + ) + + private companion object { + const val ACTIVATE = "kilo.changes.activate" + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/HoverIcon.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/HoverIcon.kt index d453208bf793..3ded8d0bb3a8 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/HoverIcon.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/HoverIcon.kt @@ -11,6 +11,20 @@ import javax.swing.JButton class HoverIcon(private val fill: Boolean = false) : JButton() { private var over = false + private var probe: JButton? = null + + /** + * Makes an icon-only button a square of the height the look-and-feel would give it as a button, + * so it lines up with labelled siblings in the same cluster instead of looking shorter. Taking + * the height from the LAF rather than a fixed token keeps the two in step wherever the LAF + * applies its own button minimum (Darcula raises both to the same floor). + */ + var match = false + set(value) { + if (field == value) return + field = value + revalidate() + } init { iconButton(this) @@ -30,11 +44,48 @@ class HoverIcon(private val fill: Boolean = false) : JButton() { // content plus their (symmetric) border, which already gives equal padding on every side. override fun getPreferredSize(): Dimension { if (!text.isNullOrEmpty()) return super.getPreferredSize() + if (match) { + val side = labelledHeight() + return Dimension(side, side) + } val icon = icon ?: return JBUI.size(MIN, MIN) val side = maxOf(JBUI.scale(MIN), icon.iconWidth + JBUI.scale(PAD), icon.iconHeight + JBUI.scale(PAD)) return Dimension(side, side) } + // Button UIs commonly special-case null/empty text and size purely from the icon, skipping the + // font-metrics contribution a labelled sibling gets from its non-empty text. That makes an + // icon-only button's height track the icon instead of the shared labelled-button height whenever + // the LAF's font metrics are taller than the icon. Measuring through a non-empty placeholder + // forces the text layout path a labelled button uses, so both stay level everywhere. + // + // The probe carries no icon on purpose. Height would otherwise follow whichever is taller, this + // button's icon or the text, and icon-only actions rarely carry the same icon size as the + // labelled sibling they sit next to — a 13px icon beside a 12px one drifts a pixel taller on + // LAFs whose font is shorter than either. Measuring text alone yields the insets+font-metrics + // baseline every labelled sibling shares, independent of icon size on both sides. + // + // The placeholder goes on a detached probe rather than on this button: setText fires property + // changes and calls revalidate()/repaint(), and doing that from a size query would invalidate + // this control and its ancestors on every layout pass, re-queueing validation while the header + // is showing. The probe never joins a hierarchy, so its own invalidation reaches nothing. + private fun labelledHeight(): Int { + val button = probe ?: JButton(" ").also { + iconButton(it) + probe = it + } + button.font = font + button.border = border + button.margin = margin + return button.preferredSize.height + } + + // Dropped so the probe is rebuilt under the incoming LAF instead of measuring with the old one. + override fun updateUI() { + super.updateUI() + probe = null + } + override fun getMinimumSize(): Dimension = preferredSize override fun getMaximumSize(): Dimension = preferredSize diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/ToolbarButton.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/ToolbarButton.kt index d297ddd0a465..a6030be7a58a 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/ToolbarButton.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/ToolbarButton.kt @@ -35,3 +35,19 @@ internal fun hoverTextButton(action: ToolbarButtonAction, tooltip: String? = nul accessibleContext.accessibleName = action.text addActionListener { action.handler() } } + +/** + * Icon-only sibling of [hoverTextButton]: same margin, insets, and overall height, so an icon-only + * action sitting next to labelled ones shares their hover-pill metrics instead of falling back to + * the smaller square [toolbarButton] size. The tooltip stands in for the missing label. + */ +internal fun hoverIconButton(action: ToolbarButtonAction) = HoverIcon().apply { + match = true + icon = action.icon + margin = JBUI.emptyInsets() + border = JBUI.Borders.empty(JBUI.CurrentTheme.Toolbar.toolbarButtonInsets()) + cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) + toolTipText = action.text + accessibleContext.accessibleName = action.text + addActionListener { action.handler() } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListActions.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListActions.kt index c0c8f529e11d..0e098884516c 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListActions.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListActions.kt @@ -13,9 +13,20 @@ internal const val ACTIVE_LIST_RENAME_CELL = "rename" internal const val ACTIVE_LIST_DELETE_CELL = "delete" internal const val ACTIVE_LIST_MENU_CELL = "__menu__" -/** Ids for the trailing metrics badges hit-tested in place (see [ActiveListMetrics]). */ +/** Well-known ids for rich badges hit-tested in place. */ internal const val ACTIVE_LIST_CHANGES_CELL = "__changes__" -internal const val ACTIVE_LIST_PR_CELL = "__pr__" + +internal fun activeListRegions(item: ActiveListItem): Map Unit> { + if (item.progress != null) return emptyMap() + val out = linkedMapOf Unit>() + for (badge in item.leading + item.badges + item.secondaryBadges) { + val id = badge.id + val act = badge.action + if (!id.isNullOrBlank() && act != null) out[id] = act + } + item.metrics?.onChanges?.let { out[ACTIVE_LIST_CHANGES_CELL] = it } + return out +} internal fun activeListRenameCell(label: String = KiloBundle.message("common.rename")) = ActiveListCell( ACTIVE_LIST_RENAME_CELL, diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListModel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListModel.kt index 9698bf5e0877..3f977a1f30c1 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListModel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListModel.kt @@ -15,19 +15,24 @@ import javax.swing.SwingUtilities private const val CELL_GAP = 8 -internal data class ActiveListBadge(val text: String, val style: UiStyle.Badge.Style = UiStyle.Badge.Secondary) +/** + * A pill rendered before or after an [ActiveListItem] title. A non-null [id] opts the badge into + * hit-testing; [action] requires an id to have any effect. + */ +internal data class ActiveListBadge( + val text: String, + val style: UiStyle.Badge.Style = UiStyle.Badge.Secondary, + val id: String? = null, + val tooltip: String? = null, + val action: (() -> Unit)? = null, +) internal data class ActiveListMetrics( + val files: Int = 0, val additions: Int = 0, val deletions: Int = 0, - val ahead: Int = 0, - val behind: Int = 0, - val pr: ActiveListBadge? = null, - val prTooltip: String? = null, - /** Click handler for the changes badge, e.g. open the branch diff. Null leaves it inert. */ + val base: String = "", val onChanges: (() -> Unit)? = null, - /** Click handler for the PR badge, e.g. open the pull request. Null leaves it inert. */ - val onPr: (() -> Unit)? = null, ) internal enum class ActiveListRowHeight { EQUAL, PREFERRED } @@ -78,8 +83,7 @@ internal data class ActiveListCell( /** * A component in a rendered [ActiveListItem] row that the list hit-tests for clicks, hover cursor, - * and tooltips. Implemented by the action-cell buttons and by rich trailing badges (the worktree - * changes/PR metrics) so both flow through the same click/cursor/tooltip plumbing. + * and tooltips. */ internal interface ActiveListHitCell { val cellId: String @@ -92,9 +96,10 @@ internal interface ActiveListHitCell { /** * A row in an [ActiveList]. Carries the display contract shared by settings pages, the worktree * list, and the session history stack: a leading icon, a title whose weight follows - * [ActiveListConfig.title] with an inline [note], a secondary [description] line, inline [badges], - * optional right-aligned [trailing] text, and action [cells]. Action cells are shown only for the - * active focused selection unless [ActiveListCell.alwaysVisible] is true. + * [ActiveListConfig.title] with an inline [note], a secondary [description] line, [leading] badges + * before the title, inline [badges] after it, optional right-aligned [trailing] text, and action + * [cells]. Action cells are shown only for the active focused selection unless + * [ActiveListCell.alwaysVisible] is true. */ internal interface ActiveListItem { val key: String @@ -117,7 +122,10 @@ internal interface ActiveListItem { */ val tinted: Boolean get() = false val section: String? get() = null + val leading: List get() = emptyList() + /** Badges rendered after the title. */ val badges: List get() = emptyList() + val secondaryBadges: List get() = emptyList() /** Right-aligned secondary text, such as a relative timestamp. */ val trailing: String? get() = null val metrics: ActiveListMetrics? get() = null @@ -230,8 +238,6 @@ internal fun activeListCellAt( .firstOrNull { cell -> cell.enabled && bounds[cell.id]?.contains(point) == true } ?.id if (fromCell != null) return fromCell - // Regions that are not backed by an ActiveListCell (the changes/PR badges) are actionable in - // place: match the first enabled one with a handler under the point. return hits.firstOrNull { it.enabled && it.action != null && it.bounds.contains(point) }?.id } @@ -270,7 +276,7 @@ private fun forEachHitCell(component: Component, action: (ActiveListHitCell) -> // Skip hidden subtrees so a badge left visible inside a hidden trailing panel is not // collected as a live hit target. if (!c.isVisible) return - if (c is ActiveListHitCell) action(c) + if (c is ActiveListHitCell && c.cellId.isNotBlank()) action(c) if (c is Container) c.components.forEach(::visit) } visit(component) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListRenderer.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListRenderer.kt index 735f46a93996..9ff81599c98b 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListRenderer.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListRenderer.kt @@ -1,7 +1,7 @@ package ai.kilocode.client.ui.list -import ai.kilocode.client.agentManager.worktree.WorktreeStatsView import ai.kilocode.client.session.ui.PickerRow +import ai.kilocode.client.ui.ChangesPanel import ai.kilocode.client.ui.FilledBadgeIcon import ai.kilocode.client.ui.LayeredOverlayPanel import ai.kilocode.client.ui.UiStyle @@ -17,10 +17,10 @@ import com.intellij.ui.SimpleColoredComponent import com.intellij.ui.SimpleTextAttributes import com.intellij.ui.components.JBLabel import com.intellij.util.IconUtil +import com.intellij.util.concurrency.annotations.RequiresEdt import com.intellij.util.ui.EmptyIcon import com.intellij.util.ui.JBUI import com.intellij.util.ui.UIUtil -import ai.kilocode.rpc.dto.WorktreeStatsDto import java.awt.AlphaComposite import java.awt.BasicStroke import java.awt.BorderLayout @@ -83,10 +83,13 @@ internal class ActiveListRenderer( private val icon = JBLabel().apply { verticalAlignment = SwingConstants.TOP } private val mark = icon.align(HAlign.CENTER, VAlign.CENTER) private val title = SimpleColoredComponent() - private val badges = Stack.horizontal() - // Title in CENTER clips when the group is narrow; trailing tags in EAST keep their full - // preferred width. A squeezed row sacrifices the title text but never drops the tags. + private val leading = Stack.horizontal(JBUI.CurrentTheme.ActionsList.elementIconGap()) + private val badges = Stack.horizontal(JBUI.CurrentTheme.ActionsList.elementIconGap()) + private val secondary = Stack.horizontal(UiStyle.Gap.md()) + // Title in CENTER clips when the group is narrow; tags in WEST/EAST keep their full preferred + // width. A squeezed row sacrifices the title text but never drops the tags. private val titleGroup = JPanel(BorderLayout(UiStyle.Gap.xs(), 0)).apply { + add(leading, BorderLayout.WEST) add(title, BorderLayout.CENTER) add(badges, BorderLayout.EAST) } @@ -94,13 +97,12 @@ internal class ActiveListRenderer( // instead of drifting to the far right of the row. private val header = titleGroup.align(HAlign.LEFT, VAlign.CENTER) private val desc = JBLabel() - private val metrics = WorktreeStatsView() - private val metricsPane = metrics.align(HAlign.RIGHT, VAlign.CENTER) - // The description (branch) line carries the changes/PR metrics on its trailing edge so they sit - // on the branch row instead of spanning the full row height. + private val metrics = ActiveListChangesCell() + private val details = Stack.horizontal(UiStyle.Gap.md()).next(metrics).next(secondary) + private val detailsPane = details.align(HAlign.RIGHT, VAlign.CENTER) private val descLine = JPanel(BorderLayout(UiStyle.Gap.md(), 0)).apply { add(desc, BorderLayout.CENTER) - add(metricsPane, BorderLayout.EAST) + add(detailsPane, BorderLayout.EAST) } private val text = Stack.vertical().next(header).next(descLine) private val textPane = text.align(HAlign.TRACK, VAlign.CENTER) @@ -145,6 +147,7 @@ internal class ActiveListRenderer( mark, icon, title, + leading, badges, titleGroup, header, @@ -153,7 +156,9 @@ internal class ActiveListRenderer( desc, descLine, metrics, - metricsPane, + secondary, + details, + detailsPane, trail, trailPane, endPane, @@ -183,6 +188,7 @@ internal class ActiveListRenderer( add(wrap, BorderLayout.CENTER) } + @RequiresEdt override fun getListCellRendererComponent( list: JList, value: ActiveListItem, @@ -238,17 +244,13 @@ internal class ActiveListRenderer( } desc.foreground = weak val data = if (value.progress != null) null else value.metrics - metrics.update(data?.let { WorktreeStatsDto("", it.additions, it.deletions, it.ahead, it.behind) }, data?.pr, data?.prTooltip ?: data?.pr?.text) - metrics.setActions(data?.onChanges, data?.onPr) + metrics.isEnabled = list.isEnabled && !value.disabled + metrics.update(data) val end = value.progress ?: value.trailing.orEmpty() trail.text = end trail.isVisible = end.isNotBlank() && data == null - metrics.isVisible = data != null - // Hide the wrapper too so a metrics-less row does not reserve the trailing gap on its - // description, and collapse the whole second row when it would be empty so title-only rows - // stay vertically centered. - metricsPane.isVisible = metrics.isVisible - descLine.isVisible = desc.isVisible || metrics.isVisible + detailsPane.isVisible = metrics.isVisible || secondary.isVisible + descLine.isVisible = desc.isVisible || detailsPane.isVisible trail.foreground = weak val hovered = (list as? ActiveListActive)?.hoveredIndex() == index @@ -365,21 +367,21 @@ internal class ActiveListRenderer( } private fun syncBadges(item: ActiveListItem) { - val items = if (item.progress != null) emptyList() else item.badges - while (badges.componentCount > items.size) badges.remove(badges.componentCount - 1) - while (badges.componentCount < items.size) { - badges.add(JBLabel().apply { - border = JBUI.Borders.emptyLeft(JBUI.CurrentTheme.ActionsList.elementIconGap()) - }) - } - badges.isVisible = items.isNotEmpty() + val hidden = item.progress != null + val gap = JBUI.CurrentTheme.ActionsList.elementIconGap() + leading.border = JBUI.Borders.emptyRight(gap) + badges.border = JBUI.Borders.emptyLeft(gap) + syncBadges(leading, if (hidden) emptyList() else item.leading) + syncBadges(badges, if (hidden) emptyList() else item.badges) + syncBadges(secondary, if (hidden) emptyList() else item.secondaryBadges) + } + + private fun syncBadges(stack: JPanel, items: List) { + while (stack.componentCount > items.size) stack.remove(stack.componentCount - 1) + while (stack.componentCount < items.size) stack.add(ActiveListBadgeCell()) + stack.isVisible = items.isNotEmpty() for (i in items.indices) { - val badge = items[i] - val label = badges.getComponent(i) as JBLabel - val current = label.icon as? FilledBadgeIcon - if (current?.text != badge.text || current.style != badge.style) { - label.icon = FilledBadgeIcon(badge.text, badge.style) - } + (stack.getComponent(i) as ActiveListBadgeCell).update(items[i]) } } @@ -400,6 +402,37 @@ internal interface ActiveListActive { fun hoveredIndex(): Int = -1 } +internal class ActiveListChangesCell @RequiresEdt constructor() : JPanel(BorderLayout()), ActiveListHitCell { + private val panel = ChangesPanel(ChangesPanel.Mode.COMPACT) + private var data: ActiveListMetrics? = null + + override val cellId = ACTIVE_LIST_CHANGES_CELL + + init { + UiStyle.Components.transparent(this) + add(panel, BorderLayout.CENTER) + } + + @RequiresEdt + fun update(data: ActiveListMetrics?) { + this.data = data + panel.update(data?.files ?: 0, data?.additions ?: 0, data?.deletions ?: 0, base = data?.base.orEmpty()) + panel.setActions(data?.onChanges.takeIf { isEnabled }) + isVisible = panel.isVisible + toolTipText = panel.toolTipText + } + + @RequiresEdt + override fun cellEnabled(): Boolean = isVisible && isEnabled && data?.onChanges != null + + override fun cellCursor(): Int = Cursor.HAND_CURSOR + + @RequiresEdt + override fun cellTooltip(): String? = toolTipText + + override fun cellAction(): (() -> Unit)? = data?.onChanges +} + internal class ActiveListActionCell : JBLabel(), ActiveListHitCell { private var cell: ActiveListCell? = null @@ -430,3 +463,29 @@ internal class ActiveListActionCell : JBLabel(), ActiveListHitCell { if (text.isNotBlank()) UiStyle.Components.actionLabel(this, enabled) } } + +/** A retained badge pill that opts into list hit-testing when its model carries an id. */ +internal class ActiveListBadgeCell : JBLabel(), ActiveListHitCell { + private var badge: ActiveListBadge? = null + + override var cellId: String = "" + private set + + fun update(badge: ActiveListBadge) { + this.badge = badge + cellId = badge.id.orEmpty() + val current = icon as? FilledBadgeIcon + if (current?.text != badge.text || current.style != badge.style) { + icon = FilledBadgeIcon(badge.text, badge.style) + } + toolTipText = cellTooltip() + } + + override fun cellEnabled(): Boolean = badge?.action != null + + override fun cellCursor(): Int = Cursor.HAND_CURSOR + + override fun cellTooltip(): String? = badge?.tooltip?.takeIf { it.isNotBlank() } + + override fun cellAction(): (() -> Unit)? = badge?.action +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListView.kt index 0d04cb9a02d2..f707a87c120a 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListView.kt @@ -580,23 +580,19 @@ internal class ActiveListView( ?: onActivate?.invoke(item) } - /** - * Dispatches a click on a button. A per-cell action or a metrics handler ([ActiveListMetrics]) - * takes precedence; otherwise the click routes through the list-level [onCell] callback. - */ + /** Dispatches a click from the row model, never the renderer stamp reused across rows. */ private fun fire(item: ActiveListItem, id: String) { - when (id) { - ACTIVE_LIST_CHANGES_CELL -> { - item.metrics?.onChanges?.invoke() - return - } - ACTIVE_LIST_PR_CELL -> { - item.metrics?.onPr?.invoke() - return - } + val cell = item.cells.firstOrNull { it.id == id }?.action + if (cell != null) { + cell() + return + } + val region = activeListRegions(item)[id] + if (region != null) { + region() + return } - val action = item.cells.firstOrNull { it.id == id }?.action - if (action != null) action() else onCell(item.key, id) + onCell(item.key, id) } @RequiresEdt @@ -714,7 +710,7 @@ internal class ActiveListView( if (!bounds.contains(point)) return baseCursor val item = model.getElementAt(idx) if (item is ActiveListGap) return baseCursor - if (menu == null && item.cells.isEmpty() && item.metrics == null) return baseCursor + if (menu == null && item.cells.isEmpty() && activeListRegions(item).isEmpty()) return baseCursor val hit = activeListHits(list, idx, list.isSelectedIndex(idx)) .firstOrNull { it.enabled && it.bounds.contains(point) } ?: return baseCursor diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml b/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml index 333f83f887dc..e04a1433e513 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml +++ b/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml @@ -88,8 +88,16 @@ + + @@ -184,6 +192,9 @@ + + @@ -282,6 +293,12 @@ + + + + @@ -293,6 +310,10 @@ + + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties index 41c5ac8b65fb..f855f2215c8e 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -177,12 +177,17 @@ 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.local.title=Uncommitted changes +diff.editor.local.title.named=Uncommitted changes ({0}) diff.editor.file.title={0} ({1}) diff.editor.side.base=Base diff.editor.side.current=Current +diff.editor.side.head=HEAD +diff.editor.side.working=Working tree diff.editor.side.original=Original diff.editor.side.modified=Modified diff.editor.branch.tooltip=Compare with base branch +diff.editor.local.tooltip=Compare with the last commit diff.editor.session.tooltip=Open changes in editor diff.editor.empty=No changes diff.editor.patch.unavailable=Diff unavailable @@ -242,6 +247,9 @@ session.outcome.incomplete.description=The provider ended this response without session.outcome.incomplete.reason=Technical finish reason: {0} session.outcome.retry=Retry session.outcome.interrupted.note=Stopped +session.cancelled.title=Kilo stopped a running task +session.cancelled.reload=Kilo reloaded its configuration and stopped this task before it finished. Nothing was lost from the conversation \u2014 use Retry to continue. +session.cancelled.unknown=This task was stopped before it finished, and not from this window. Use Retry to continue. session.login.required.title=You need to sign in to use this model session.login.required.description=Go to User Profile settings to sign in, then continue this session. @@ -421,6 +429,7 @@ worktree.session.new.action=New session worktree.session.open.action=Open worktree.session.open.tooltip=Open worktree in new window worktree.session.terminal.tooltip=Open worktree terminal +worktree.setup.terminal.tabName=Setup: {0} worktree.session.rename.action=Rename session worktree.session.delete.action=Delete session worktree.session.list.expand=Show sessions @@ -478,7 +487,9 @@ worktree.import.branch.failed.title=Couldn''t import branch "{0}" worktree.stats.diff.tooltip={0} additions, {1} deletions worktree.stats.ahead.tooltip=Commits ahead of base branch worktree.stats.behind.tooltip=Commits behind base branch -worktree.stats.tooltip={0} commits ahead, {1} behind, +{2} -{3}.
Click to open the diff against the base branch. +worktree.stats.tooltip={0} files changed, +{1} -{2}.
Click to open committed changes against the base branch. +worktree.stats.base.tooltip={0} files changed, +{1} -{2} vs {3}. Click to open committed changes. +worktree.dirty.tooltip={0} uncommitted files, +{1} -{2}.
Click to compare with HEAD. worktree.pr.state.open=Open worktree.pr.state.draft=Draft worktree.pr.state.merged=Merged @@ -492,6 +503,8 @@ worktree.gh.unauth.title=GitHub CLI not authorized worktree.gh.unauth.content=Authorize gh to show pull request badges for worktrees. worktree.gh.authorize=Authorize worktree.gh.learnMore=Learn more +worktree.gh.disable=Turn off GitHub integration +worktree.gh.disable.tooltip=Kilo stops running gh. You can turn this back on in Settings | Tools | Kilo Code | Integrations. action.Kilo.Settings.text=Settings action.Kilo.Settings.description=Kilo Code settings @@ -517,6 +530,7 @@ settings.cli.unavailable.title=Kilo Code is not connected settings.cli.unavailable.message=Settings are available after Kilo Code connects to Core. If this does not resolve, restart the IDE or Kilo Core and try again. settings.models.displayName=Models settings.context.displayName=Context +settings.integrations.displayName=Integrations settings.advanced.displayName=Advanced settings.advanced.logging.title=Logging settings.advanced.logs.title=Diagnostic logs @@ -554,6 +568,10 @@ settings.context.watcher.remove=Remove selected patterns settings.context.watcher.empty=No ignore patterns configured. settings.context.watcher.input.title=Add ignore pattern settings.context.watcher.input.prompt=Enter a glob pattern to ignore: +settings.integrations.github.title=GitHub +settings.integrations.github.description=Kilo uses the GitHub CLI (gh) to resolve pull requests for worktrees. +settings.integrations.github.enabled.title=Enable GitHub Integration +settings.integrations.github.enabled.description=Show pull request badges on worktrees and import a pull request into a worktree. When off, Kilo never runs gh. settings.autoApprove.displayName=Auto-Approve settings.autoApprove.title=Auto-Approve settings.autoApprove.description=Define how tools are allowed to run. Most tools default to Allow. doom_loop and external_directory default to Ask. @@ -922,6 +940,9 @@ action.Kilo.OpenGlobalConfig.text=Open: global {0} action.Kilo.CreateGlobalConfig.text=Create: global {0} action.Kilo.OpenGlobalConfig.description=Open or create the global Kilo config file action.Kilo.OpenConfig.failed=Failed to open Kilo config file +action.Kilo.OpenSetupScript.text=Open Worktree Setup +action.Kilo.CreateSetupScript.text=Create Worktree Setup +action.Kilo.OpenSetupScript.description=Open or create the worktree setup script logs.configuration.level.title=Log level logs.configuration.level.description=Controls the minimum diagnostic log level written by Kilo. Saved user values override JVM properties. logs.configuration.preview.title=Message preview @@ -966,12 +987,16 @@ action.Kilo.Session.CopyShareLink.text=Copy Share Link action.Kilo.Session.CopyShareLink.description=Copy this session's public link to the clipboard action.Kilo.Worktree.Rename.text=Rename action.Kilo.Worktree.Rename.description=Rename the selected worktree -action.Kilo.Worktree.Delete.text=Delete Worktree +action.Kilo.Worktree.Delete.text=Delete action.Kilo.Worktree.Delete.description=Delete the selected worktree -action.Kilo.Worktree.OpenPr.text=Open Pull Request in Browser +action.Kilo.Worktree.OpenPr.text=Open in Browser action.Kilo.Worktree.OpenPr.description=Open the pull request in your browser -action.Kilo.Worktree.OpenDiff.text=Open Diff Against Base Branch +action.Kilo.Worktree.OpenDiff.text=Open Diff Against Base action.Kilo.Worktree.OpenDiff.description=Open the diff against the base branch +action.Kilo.Worktree.OpenLocalDiff.text=Open Uncommitted Changes +action.Kilo.Worktree.OpenLocalDiff.description=Open the diff of uncommitted changes +action.Kilo.Worktree.RunSetupScript.text=Run Worktree Setup +action.Kilo.Worktree.RunSetupScript.description=Run the worktree setup script in a terminal action.Kilo.WorktreeSession.Rename.text=Rename action.Kilo.WorktreeSession.Rename.description=Rename the selected worktree session action.Kilo.WorktreeSession.Delete.text=Delete diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/HistorySessionActionsTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/HistorySessionActionsTest.kt index 2e3fd3060690..3aa017fd1b11 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/HistorySessionActionsTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/HistorySessionActionsTest.kt @@ -373,6 +373,8 @@ class HistorySessionActionsTest : BasePlatformTestCase() { assertTrue(xml.contains("id=\"Kilo.Worktree.Delete\"")) assertTrue(xml.contains("id=\"Kilo.Worktree.OpenPr\"")) assertTrue(xml.contains("id=\"Kilo.Worktree.OpenDiff\"")) + assertTrue(xml.contains("id=\"Kilo.Worktree.OpenLocalDiff\"")) + assertTrue(xml.contains("id=\"Kilo.Worktree.RunSetupScript\"")) assertTrue(xml.contains("id=\"Kilo.WorktreeSession.Rename\"")) assertTrue(xml.contains("id=\"Kilo.WorktreeSession.Delete\"")) assertTrue(xml.contains("id=\"Kilo.Worktree.RowMenu\"")) @@ -386,16 +388,30 @@ class HistorySessionActionsTest : BasePlatformTestCase() { assertTrue(xml.contains("ref=\"Kilo.Worktree.Delete\"")) assertTrue(xml.contains("ref=\"Kilo.Worktree.OpenPr\"")) assertTrue(xml.contains("ref=\"Kilo.Worktree.OpenDiff\"")) + assertTrue(xml.contains("ref=\"Kilo.Worktree.OpenLocalDiff\"")) assertTrue(xml.contains("ref=\"Kilo.WorktreeSession.Rename\"")) assertTrue(xml.contains("ref=\"Kilo.WorktreeSession.Delete\"")) assertTrue(xml.contains("ref=\"${'$'}Copy\"")) - // Row menu order: rename, (separator), open pr, open diff, (separator), delete. - val rename = xml.indexOf("ref=\"Kilo.Worktree.Rename\"") - val openPr = xml.indexOf("ref=\"Kilo.Worktree.OpenPr\"") - val openDiff = xml.indexOf("ref=\"Kilo.Worktree.OpenDiff\"") - val delete = xml.indexOf("ref=\"Kilo.Worktree.Delete\"") - assertTrue(rename in 0 until openPr && openPr < openDiff && openDiff < delete) + // Row menu order: rename, (separator), open pr, open diff, open local diff, (separator), + // open/create setup script, run setup script, (separator), delete. + val rowMenuStart = xml.indexOf("") + val rowMenuEnd = xml.indexOf("", rowMenuStart) + val rowMenu = xml.substring(rowMenuStart, rowMenuEnd) + assertTrue(rowMenu.contains("ref=\"Kilo.OpenSetupScript\"")) + assertTrue(rowMenu.contains("ref=\"Kilo.Worktree.RunSetupScript\"")) + + val rename = rowMenu.indexOf("ref=\"Kilo.Worktree.Rename\"") + val openPr = rowMenu.indexOf("ref=\"Kilo.Worktree.OpenPr\"") + val openDiff = rowMenu.indexOf("ref=\"Kilo.Worktree.OpenDiff\"") + val openLocalDiff = rowMenu.indexOf("ref=\"Kilo.Worktree.OpenLocalDiff\"") + val openSetup = rowMenu.indexOf("ref=\"Kilo.OpenSetupScript\"") + val runSetup = rowMenu.indexOf("ref=\"Kilo.Worktree.RunSetupScript\"") + val delete = rowMenu.indexOf("ref=\"Kilo.Worktree.Delete\"") + assertTrue( + rename in 0 until openPr && openPr < openDiff && openDiff < openLocalDiff && + openLocalDiff < openSetup && openSetup < runSetup && runSetup < delete, + ) } // ------ Helpers ------ diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/KiloRecoveryActionsTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/KiloRecoveryActionsTest.kt index 9d4689d514b5..b9999d13693d 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/KiloRecoveryActionsTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/KiloRecoveryActionsTest.kt @@ -1,5 +1,6 @@ package ai.kilocode.client.actions +import ai.kilocode.client.agentManager.worktree.WorktreeDataKeys import ai.kilocode.client.app.KiloAppService import ai.kilocode.client.app.KiloWorkspaceService import ai.kilocode.client.app.Workspace @@ -9,6 +10,8 @@ import ai.kilocode.client.testing.FakeWorkspaceRpcApi import ai.kilocode.rpc.dto.ConfigTargetDto import ai.kilocode.rpc.dto.KiloWorkspaceStateDto import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto +import ai.kilocode.rpc.dto.SetupScriptTargetDto +import ai.kilocode.rpc.dto.WorktreeDto import com.intellij.openapi.actionSystem.ActionUpdateThread import com.intellij.openapi.actionSystem.CommonDataKeys import com.intellij.openapi.actionSystem.AnAction @@ -24,9 +27,11 @@ import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.withTimeoutOrNull @Suppress("UnstableApiUsage") class KiloRecoveryActionsTest : BasePlatformTestCase() { @@ -108,6 +113,11 @@ class KiloRecoveryActionsTest : BasePlatformTestCase() { assertTrue(xml.contains("")) assertFalse(xml.contains("")) + + // The setup-script action lives only in the worktree row menu, not the tool-window popup. + val settingsGroupStart = xml.indexOf("") + val settingsGroupEnd = xml.indexOf("", settingsGroupStart) + assertFalse(xml.substring(settingsGroupStart, settingsGroupEnd).contains("Kilo.OpenSetupScript")) } fun `test core info action shows version and architecture`() { @@ -302,6 +312,98 @@ class KiloRecoveryActionsTest : BasePlatformTestCase() { assertEquals(0, rpc.localConfigPathCalls) } + fun `test setup script action says open when target exists`() { + rpc.setupScriptExists = true + service().setupScript["/test"] = SetupScriptTargetDto("/test/.kilo/setup-script", "~/.kilo/setup-script", true) + val action = OpenSetupScriptAction() + val event = event(action, workspace = workspace("/test")) + + update(action, event) + + assertTrue(event.presentation.isEnabledAndVisible) + assertEquals("Open Worktree Setup", event.presentation.text) + assertEquals(0, rpc.setupScriptTargetCalls.size) + } + + fun `test setup script action says create when target is missing`() { + rpc.setupScriptExists = false + service().setupScript["/test"] = SetupScriptTargetDto("/test/.kilo/setup-script", "~/.kilo/setup-script", false) + val action = OpenSetupScriptAction() + val event = event(action, workspace = workspace("/test")) + + update(action, event) + + assertTrue(event.presentation.isEnabledAndVisible) + assertEquals("Create Worktree Setup", event.presentation.text) + assertEquals(0, rpc.setupScriptTargetCalls.size) + } + + fun `test setup script action refreshes missing target in background`() { + rpc.setupScriptExists = true + val call = CompletableDeferred() + val gate = CompletableDeferred() + rpc.beforeSetupScriptTarget = { + call.complete(Unit) + gate.await() + } + val action = OpenSetupScriptAction() + val event = event(action, workspace = workspace("/test")) + + update(action, event) + + assertTrue(event.presentation.isEnabledAndVisible) + // No cached target yet: defaults to the "Open" wording, same as a resolved existing script. + assertEquals("Open Worktree Setup", event.presentation.text) + await(call) + assertEquals(1, rpc.setupScriptTargetCalls.size) + + // The fake reads setupScriptExists after the gate, so flipping it here makes the released + // background lookup cache "missing" itself. Writing the cache from the test instead would + // race that write and lose whenever the coroutine resumed first. + rpc.setupScriptExists = false + gate.complete(Unit) + assertTrue("background refresh never cached the resolved target", cached("/test") { !it.exists }) + + val next = event(action, workspace = workspace("/test")) + update(action, next) + + assertEquals("Create Worktree Setup", next.presentation.text) + } + + fun `test setup script action disables without directory`() { + val action = OpenSetupScriptAction() + val event = event(action) + + update(action, event) + + assertFalse(event.presentation.isEnabledAndVisible) + assertEquals(0, rpc.setupScriptTargetCalls.size) + } + + fun `test setup script action hides on the main worktree row`() { + val action = OpenSetupScriptAction() + val event = event(action, workspace = workspace("/test"), worktree = WorktreeDto("/test", "main", "main", "/test", main = true)) + + update(action, event) + + assertFalse(event.presentation.isEnabledAndVisible) + } + + fun `test setup script action visible on a non-main worktree row`() { + rpc.setupScriptExists = true + service().setupScript["/test"] = SetupScriptTargetDto("/test/.kilo/setup-script", "/test/.kilo/setup-script", true) + val action = OpenSetupScriptAction() + val event = event( + action, + workspace = workspace("/test"), + worktree = WorktreeDto("/test/.kilo/worktrees/feature-x", "feature-x", "feature-x", "/test/.kilo/worktrees/feature-x"), + ) + + update(action, event) + + assertTrue(event.presentation.isEnabledAndVisible) + } + fun `test settings popup group updates recursively in background`() { val group = DefaultActionGroup() val wrapped = KiloSettingsAction.popupGroup(group) @@ -318,6 +420,7 @@ class KiloRecoveryActionsTest : BasePlatformTestCase() { assertEquals(1, rpc.localConfigPathCalls) assertEquals(1, rpc.globalConfigPathCalls) + assertEquals(0, rpc.setupScriptTargetCalls.size) } fun `test workspace creation prewarms config targets`() { @@ -334,10 +437,10 @@ class KiloRecoveryActionsTest : BasePlatformTestCase() { assertEquals(1, rpc.globalConfigPathCalls) } - private fun event(action: AnAction, workspace: Workspace? = null, place: String = ""): AnActionEvent { + private fun event(action: AnAction, workspace: Workspace? = null, place: String = "", worktree: WorktreeDto? = null): AnActionEvent { val presentation = Presentation().apply { copyFrom(action.templatePresentation) } presentation.isEnabled = false - return AnActionEvent.createFromDataContext(place, presentation, context(workspace)) + return AnActionEvent.createFromDataContext(place, presentation, context(workspace, worktree)) } private fun update(action: AnAction, event: AnActionEvent) { @@ -350,6 +453,14 @@ class KiloRecoveryActionsTest : BasePlatformTestCase() { withTimeout(5_000) { signal.await() } } + /** Waits for the background setup-script lookup to publish a target matching [want]. */ + private fun cached(dir: String, want: (SetupScriptTargetDto) -> Boolean): Boolean = runBlocking { + withTimeoutOrNull(5_000) { + while (service().setupScript[dir]?.let(want) != true) delay(5) + true + } == true + } + private fun service(): KiloWorkspaceService = ApplicationManager.getApplication().getService(KiloWorkspaceService::class.java) private fun app(): KiloAppService = ApplicationManager.getApplication().getService(KiloAppService::class.java) @@ -360,11 +471,12 @@ class KiloRecoveryActionsTest : BasePlatformTestCase() { field.set(service(), target) } - private fun context(workspace: Workspace?): DataContext { + private fun context(workspace: Workspace?, worktree: WorktreeDto? = null): DataContext { return DataContext { id -> when (id) { SessionManager.WORKSPACE_KEY.name -> workspace CommonDataKeys.PROJECT.name -> project.takeIf { workspace != null } + WorktreeDataKeys.WORKTREE.name -> worktree else -> null } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/AgentManagerPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/AgentManagerPanelTest.kt index e565fbac5bf6..c493cf83f72a 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/AgentManagerPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/AgentManagerPanelTest.kt @@ -32,15 +32,20 @@ import ai.kilocode.client.ui.list.ActiveListBadge import ai.kilocode.client.ui.list.ActiveListItem import ai.kilocode.client.ui.list.ActiveListMetrics import ai.kilocode.client.ui.list.ActiveListView -import ai.kilocode.client.ui.list.ACTIVE_LIST_PR_CELL +import ai.kilocode.client.ui.list.ACTIVE_LIST_CHANGES_CELL import ai.kilocode.client.ui.list.activeListCellBounds import ai.kilocode.client.ui.list.activeListToolWindowBackground import ai.kilocode.client.vfs.KiloPath import ai.kilocode.client.vfs.KiloVfsManager import ai.kilocode.client.vfs.KiloVirtualFile import ai.kilocode.client.vfs.KiloVirtualFileSystem +import ai.kilocode.client.app.KiloWorkspaceService +import ai.kilocode.client.testing.FakeWorkspaceRpcApi import ai.kilocode.rpc.dto.GhAvailability import ai.kilocode.rpc.dto.GhState +import ai.kilocode.rpc.dto.SetupScriptTargetDto +import ai.kilocode.rpc.dto.WorktreeDirtyDto +import ai.kilocode.rpc.dto.WorktreeDirtyListDto import ai.kilocode.rpc.dto.WorktreeDto import ai.kilocode.rpc.dto.WorktreePrDto import ai.kilocode.rpc.dto.WorktreePrListDto @@ -57,11 +62,14 @@ import com.intellij.testFramework.fixtures.BasePlatformTestCase import com.intellij.testFramework.replaceService import com.intellij.ui.SimpleColoredComponent import com.intellij.ui.SimpleTextAttributes +import com.intellij.ui.components.JBLabel import com.intellij.ui.components.JBList import com.intellij.ui.components.JBScrollPane import com.intellij.util.ui.UIUtil import java.awt.Component import java.awt.Container +import java.awt.Cursor +import java.awt.event.InputEvent import java.awt.event.MouseEvent import java.awt.Point import javax.swing.JComponent @@ -635,7 +643,7 @@ class AgentManagerPanelTest : BasePlatformTestCase() { fun `test worktree row shows metrics from status service`() { val item = WorktreeDto("${project.basePath!!}/.kilo/worktrees/feature-x", "feature-x", "feature/x", "${project.basePath!!}/.kilo/worktrees/feature-x") rpc.listed += item - rpc.statsResult = WorktreeStatsListDto(listOf(WorktreeStatsDto(item.path, additions = 5, deletions = 2, ahead = 1, behind = 3))) + rpc.statsResult = WorktreeStatsListDto(listOf(WorktreeStatsDto(item.path, additions = 5, deletions = 2, ahead = 1, behind = 3, files = 2, base = "origin/main"))) val timers = TestUiTimers() ApplicationManager.getApplication().replaceService(KiloWorktreeService::class.java, service, testRootDisposable) project.replaceService(WorktreeStatusService::class.java, WorktreeStatusService(project, coroutines.scope, timers), testRootDisposable) @@ -648,8 +656,60 @@ class AgentManagerPanelTest : BasePlatformTestCase() { val metrics: ActiveListMetrics = row(panel, 0).metrics ?: error("expected metrics") assertEquals(5, metrics.additions) assertEquals(2, metrics.deletions) - assertEquals(1, metrics.ahead) - assertEquals(3, metrics.behind) + assertEquals(2, metrics.files) + assertEquals("origin/main", metrics.base) + } + + fun `test worktree rows show only base files and ignore local changes and commit counters`() { + val committed = worktree("committed") + val local = worktree("local") + val both = worktree("both") + val renamed = worktree("renamed") + rpc.listed += listOf(committed, local, both, renamed) + rpc.statsResult = WorktreeStatsListDto( + listOf( + WorktreeStatsDto(committed.path, additions = 5, deletions = 1, files = 2), + WorktreeStatsDto(local.path, ahead = 3, behind = 2), + WorktreeStatsDto(both.path, additions = 3, files = 1), + WorktreeStatsDto(renamed.path, files = 1), + ), + ) + rpc.dirtyResult = WorktreeDirtyListDto( + listOf( + WorktreeDirtyDto(local.path, additions = 2, files = 1), + WorktreeDirtyDto(both.path, additions = 4, files = 2), + ), + ) + val timers = TestUiTimers() + project.replaceService(WorktreeStatusService::class.java, WorktreeStatusService(project, coroutines.scope, timers), testRootDisposable) + val controller = WorktreeController(service, project.basePath!!, coroutines.scope) + val panel = edt { AgentManagerPanel(testRootDisposable, controller, project) } + edt { controller.reload() } + timers.advanceBy(300) + flush() + + val first = row(panel, 0).metrics ?: error("expected committed changes") + assertEquals(5, first.additions) + assertEquals(2, first.files) + assertNull(row(panel, 1).metrics) + val mixed = row(panel, 2).metrics ?: error("expected base changes only") + assertEquals(3, mixed.additions) + assertEquals(1, mixed.files) + val move = row(panel, 3).metrics ?: error("expected file-only changes") + assertEquals(1, move.files) + assertEquals(0, move.additions) + assertEquals(0, move.deletions) + + edt { + val view = UIUtil.findComponentOfType(panel, ActiveListView::class.java)!! + view.list.setSize(480, 400) + view.list.doLayout() + UIUtil.dispatchAllInvocationEvents() + for (index in listOf(0, 2, 3)) { + assertTrue(activeListCellBounds(view.list, index, selected = false).containsKey(ACTIVE_LIST_CHANGES_CELL)) + } + assertFalse(activeListCellBounds(view.list, 1, selected = false).containsKey(ACTIVE_LIST_CHANGES_CELL)) + } } fun `test open diff opens the branch diff editor`() { @@ -666,6 +726,126 @@ class AgentManagerPanelTest : BasePlatformTestCase() { val file = edt { FileEditorManager.getInstance(project).openFiles.single() as KiloVirtualFile } assertEquals(KiloDiffEditorKind.ID, file.path.kind) assertEquals(item.path, file.path.params["directory"]) + assertEquals("branch", file.path.params["source"]) + assertEquals(item.branch, file.path.params["branch"]) + assertEquals(KiloBundle.message("diff.editor.branch.title.named", item.branch), file.name) + } + + fun `test open local diff opens the uncommitted changes editor`() { + val item = WorktreeDto("${project.basePath!!}/.kilo/worktrees/feature-x", "feature-x", "feature/x", "${project.basePath!!}/.kilo/worktrees/feature-x") + rpc.listed += item + val controller = WorktreeController(service, project.basePath!!, coroutines.scope) + val panel = edt { AgentManagerPanel(testRootDisposable, controller, project) } + edt { controller.reload() } + flush() + + assertTrue(edt { panel.canOpenLocalDiff(item) }) + edt { panel.openLocalDiff(item) } + // No branch is passed for the local comparison, so opening resolves the branch name + // asynchronously (for the editor title) on KiloDiffEditorService's own scope before + // creating the tab, hence pumpUntil rather than this test's own coroutines.drain(). + val editors = FileEditorManager.getInstance(project) + assertTrue(coroutines.pumpUntil { edt { editors.openFiles.isNotEmpty() } }) + + val file = edt { editors.openFiles.single() as KiloVirtualFile } + assertEquals(KiloDiffEditorKind.ID, file.path.kind) + assertEquals(item.path, file.path.params["directory"]) + assertEquals("local", file.path.params["source"]) + assertEquals(KiloBundle.message("diff.editor.local.title"), file.name) + } + + fun `test open local diff is hidden on the main worktree row`() { + val controller = WorktreeController(service, project.basePath!!, coroutines.scope) + val panel = edt { AgentManagerPanel(testRootDisposable, controller, project) } + + assertFalse(edt { panel.canOpenLocalDiff(main()) }) + } + + fun `test setup script actions are hidden and disabled on the main worktree row`() { + val controller = WorktreeController(service, project.basePath!!, coroutines.scope) + val panel = edt { AgentManagerPanel(testRootDisposable, controller, project) } + + assertFalse(edt { panel.canOpenSetupScript(main()) }) + assertFalse(edt { panel.canRunSetup(main()) }) + } + + fun `test can run setup script requires an existing script for a non-main worktree row`() { + val workspaceRpc = FakeWorkspaceRpcApi() + ApplicationManager.getApplication() + .replaceService(KiloWorkspaceService::class.java, KiloWorkspaceService(coroutines.scope, workspaceRpc), testRootDisposable) + val item = worktree("feature-x") + rpc.listed += item + val controller = WorktreeController(service, project.basePath!!, coroutines.scope) + val panel = edt { AgentManagerPanel(testRootDisposable, controller, project) } + edt { controller.reload() } + flush() + + assertTrue(edt { panel.canOpenSetupScript(item) }) + // No cached target yet: hidden, not just disabled, and a background refresh is kicked off. + assertFalse(edt { panel.canRunSetup(item) }) + flush() + assertEquals(listOf(controller.directory), workspaceRpc.setupScriptTargetCalls.toList()) + + service().setupScript[controller.directory] = + SetupScriptTargetDto("${controller.directory}/.kilo/setup-script", "", exists = false) + assertFalse(edt { panel.canRunSetup(item) }) + + service().setupScript[controller.directory] = + SetupScriptTargetDto("${controller.directory}/.kilo/setup-script", "", exists = true) + assertTrue(edt { panel.canRunSetup(item) }) + } + + fun `test worktree changes clicks use the correct branch after equal-count rows render`() { + val first = worktree("first").copy(name = "Custom title") + val second = worktree("second") + rpc.listed += listOf(first, second) + rpc.statsResult = WorktreeStatsListDto(listOf( + WorktreeStatsDto(first.path, additions = 5, deletions = 1, files = 2, base = "origin/main"), + WorktreeStatsDto(second.path, additions = 5, deletions = 1, files = 2, base = "origin/main"), + )) + val timers = TestUiTimers() + project.replaceService(WorktreeStatusService::class.java, WorktreeStatusService(project, coroutines.scope, timers), testRootDisposable) + val controller = WorktreeController(service, project.basePath!!, coroutines.scope) + val panel = edt { AgentManagerPanel(testRootDisposable, controller, project) } + edt { controller.reload() } + timers.advanceBy(300) + flush() + + edt { + val view = UIUtil.findComponentOfType(panel, ActiveListView::class.java)!! + val list = view.list + list.setSize(480, 320) + list.doLayout() + UIUtil.dispatchAllInvocationEvents() + val areas = (0..1).associateWith { activeListCellBounds(list, it, selected = false).getValue(ACTIVE_LIST_CHANGES_CELL) } + activeListCellBounds(list, 0, selected = true) + for (index in listOf(1, 0, 1)) { + val point = center(areas.getValue(index)) + for (id in listOf(MouseEvent.MOUSE_PRESSED, MouseEvent.MOUSE_RELEASED, MouseEvent.MOUSE_CLICKED)) { + fire(list, MouseEvent( + list, + id, + System.currentTimeMillis(), + if (id == MouseEvent.MOUSE_PRESSED) InputEvent.BUTTON1_DOWN_MASK else 0, + point.x, + point.y, + 1, + false, + MouseEvent.BUTTON1, + )) + } + val item = if (index == 0) first else second + val file = FileEditorManager.getInstance(project).selectedFiles.single() as KiloVirtualFile + assertEquals(KiloDiffEditorKind.ID, file.path.kind) + assertEquals(item.path, file.path.params["directory"]) + assertEquals("branch", file.path.params["source"]) + assertEquals(item.branch, file.path.params["branch"]) + assertEquals(KiloBundle.message("diff.editor.branch.title.named", item.branch), file.name) + } + val files = FileEditorManager.getInstance(project).openFiles.filterIsInstance() + assertEquals(2, files.size) + assertTrue(files.all { it.path.kind == KiloDiffEditorKind.ID }) + } } fun `test open pr availability reflects pr status`() { @@ -715,12 +895,14 @@ class AgentManagerPanelTest : BasePlatformTestCase() { val panel = edt { AgentManagerPanel(testRootDisposable, controller, project) } edt { controller.reload() } timers.advanceBy(300) - waitUntil { rows(panel) > 0 && row(panel, 0).metrics != null } + waitUntil { rows(panel) > 0 && row(panel, 0).secondaryBadges.isNotEmpty() } - // The current row keeps the branch as its title; the PR arrives as a badge beside it. val current = row(panel, 0) assertEquals("main", current.title) - assertEquals("#12", current.metrics?.pr?.text) + assertTrue(current.leading.isEmpty()) + assertTrue(current.badges.isEmpty()) + assertEquals("#12", current.secondaryBadges.single().text) + assertEquals("pull-request", current.secondaryBadges.single().id) assertTrue(edt { panel.canOpenPr(main) }) } @@ -740,7 +922,9 @@ class AgentManagerPanelTest : BasePlatformTestCase() { val row = row(panel, 0) assertEquals("Fix bug", row.title) - val tip = row.metrics?.prTooltip ?: error("expected PR tooltip") + assertTrue(row.leading.isEmpty()) + assertTrue(row.badges.isEmpty()) + val tip = row.secondaryBadges.single().tooltip ?: error("expected PR tooltip") assertEquals("Draft #7 Fix <login> bug
(Feature Label)
Click to open the pull request in your browser.", tip) val list = edt { UIUtil.findComponentOfType(panel, JBList::class.java)!! } edt { @@ -748,7 +932,7 @@ class AgentManagerPanelTest : BasePlatformTestCase() { list.doLayout() UIUtil.dispatchAllInvocationEvents() } - val area = edt { activeListCellBounds(list, 0, selected = false).getValue(ACTIVE_LIST_PR_CELL) } + val area = edt { activeListCellBounds(list, 0, selected = false).getValue("pull-request") } assertEquals(tip, edt { list.getToolTipText(MouseEvent(list, MouseEvent.MOUSE_MOVED, System.currentTimeMillis(), 0, center(area).x, center(area).y, 0, false)) }) } @@ -769,7 +953,79 @@ class AgentManagerPanelTest : BasePlatformTestCase() { val row = row(panel, 0) assertEquals("Feature Label", row.title) - assertEquals("Open #8
Click to open the pull request in your browser.", row.metrics?.prTooltip) + assertTrue(row.leading.isEmpty()) + assertEquals("Open #8
Click to open the pull request in your browser.", row.secondaryBadges.single().tooltip) + } + + fun `test pr badge follows stats on the description line and opens the browser without opening an editor`() { + val browser = installBrowser() + val item = worktree("feature-x") + val url = "https://example.test/pr/7" + rpc.listed += item + rpc.prResult = WorktreePrListDto(GhAvailability.OK, listOf(WorktreePrDto(item.path, 7, GhState.OPEN, url, "Feature title"))) + rpc.statsResult = WorktreeStatsListDto(listOf(WorktreeStatsDto(item.path, additions = 5, deletions = 2, ahead = 1, files = 2))) + rpc.dirtyResult = WorktreeDirtyListDto(listOf(WorktreeDirtyDto(item.path, additions = 3, files = 1))) + val timers = TestUiTimers() + project.replaceService(WorktreeStatusService::class.java, WorktreeStatusService(project, coroutines.scope, timers), testRootDisposable) + val controller = WorktreeController(service, project.basePath!!, coroutines.scope) + val panel = edt { AgentManagerPanel(testRootDisposable, controller, project) } + edt { controller.reload() } + timers.advanceBy(300) + flush() + + edt { + val view = UIUtil.findComponentOfType(panel, ActiveListView::class.java)!! + val list = view.list + list.setSize(560, 160) + list.doLayout() + UIUtil.dispatchAllInvocationEvents() + list.clearSelection() + val row = list.model.getElementAt(0) + assertTrue(row.leading.isEmpty()) + assertTrue(row.badges.isEmpty()) + assertEquals("#7", row.secondaryBadges.single().text) + assertEquals("pull-request", row.secondaryBadges.single().id) + val areas = activeListCellBounds(list, 0, selected = false) + val badge = areas.getValue("pull-request") + val changes = areas.getValue(ACTIVE_LIST_CHANGES_CELL) + assertEquals(setOf("pull-request", ACTIVE_LIST_CHANGES_CELL), areas.keys) + val renderer = list.cellRenderer.getListCellRendererComponent(list, row, 0, false, true) + renderer.setSize(list.width, list.getCellBounds(0, 0).height) + components(renderer).filterIsInstance().forEach { it.doLayout() } + val desc = components(renderer).filterIsInstance().single { it.text == row.description } + val title = components(renderer).filterIsInstance().single() + val origin = SwingUtilities.convertPoint(desc, 0, 0, renderer) + val header = SwingUtilities.convertPoint(title, 0, 0, renderer) + val bounds = list.getCellBounds(0, 0) + assertTrue(origin.x + desc.width + bounds.x <= changes.x) + assertTrue(changes.x + changes.width <= badge.x) + assertTrue(kotlin.math.abs(center(badge).y - center(changes).y) <= 1) + assertTrue(kotlin.math.abs(center(badge).y - (bounds.y + origin.y + desc.height / 2)) <= 1) + assertTrue(badge.y >= bounds.y + header.y + title.height) + assertTrue(bounds.contains(badge)) + assertTrue(browser.urls.isEmpty()) + + val point = center(badge) + val hover = MouseEvent(list, MouseEvent.MOUSE_MOVED, System.currentTimeMillis(), 0, point.x, point.y, 0, false) + list.mouseMotionListeners.forEach { it.mouseMoved(hover) } + assertEquals(Cursor.HAND_CURSOR, list.cursor.type) + assertEquals(row.secondaryBadges.single().tooltip, list.getToolTipText(hover)) + for (id in listOf(MouseEvent.MOUSE_PRESSED, MouseEvent.MOUSE_RELEASED, MouseEvent.MOUSE_CLICKED)) { + fire(list, MouseEvent( + list, + id, + System.currentTimeMillis(), + if (id == MouseEvent.MOUSE_PRESSED) InputEvent.BUTTON1_DOWN_MASK else 0, + point.x, + point.y, + 1, + false, + MouseEvent.BUTTON1, + )) + } + assertEquals(listOf(url), browser.urls) + assertTrue(FileEditorManager.getInstance(project).openFiles.isEmpty()) + } } fun `test worktree row hides badge while in progress`() { @@ -788,7 +1044,9 @@ class AgentManagerPanelTest : BasePlatformTestCase() { val pending = row(panel, 0) assertSame(WorktreeIcons.spinner, pending.icon) assertEquals(KiloBundle.message("worktree.progress.creating"), pending.progress) + assertEquals(emptyList(), pending.leading) assertEquals(emptyList(), pending.badges) + assertEquals(emptyList(), pending.secondaryBadges) assertNull(pending.metrics) gate.complete(Unit) flush() diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/GhBannerTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/GhBannerTest.kt index 3fd8fa7fada6..a5fa7549a81d 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/GhBannerTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/GhBannerTest.kt @@ -1,5 +1,6 @@ package ai.kilocode.client.agentManager.worktree +import ai.kilocode.client.plugin.KiloPluginSettings import ai.kilocode.client.testing.FakeWorktreeRpcApi import ai.kilocode.client.testing.TestCoroutines import ai.kilocode.client.testing.fakeRoot @@ -37,6 +38,7 @@ class GhBannerTest : BasePlatformTestCase() { override fun tearDown() { try { + KiloPluginSettings.unsetGithub() coroutines.close(::pump) } finally { super.tearDown() @@ -81,6 +83,44 @@ class GhBannerTest : BasePlatformTestCase() { assertFalse(edt { banner.isVisible }) } + fun `test banner offers turning the github integration off for gh problems`() { + edt { service.report(project, GhAvailability.MISSING) } + pump() + val banner = edt { GhBanner(project, testRootDisposable) } + + val disable = edt { links(banner).single { it.text == "Turn off GitHub integration" } } + assertEquals( + "Kilo stops running gh. You can turn this back on in Settings | Tools | Kilo Code | Integrations.", + edt { disable.toolTipText }, + ) + + edt { service.report(project, GhAvailability.UNAUTH) } + pump() + assertNotNull(edt { links(banner).singleOrNull { it.text == "Turn off GitHub integration" } }) + } + + fun `test banner does not offer turning the integration off for a missing git`() { + edt { service.report(project, GhAvailability.GIT_MISSING) } + pump() + val banner = edt { GhBanner(project, testRootDisposable) } + + assertTrue(edt { links(banner).none { it.text == "Turn off GitHub integration" } }) + } + + fun `test banner disable action turns the setting off and hides the banner`() { + edt { service.report(project, GhAvailability.UNAUTH) } + pump() + val banner = edt { GhBanner(project, testRootDisposable) } + assertTrue(edt { banner.isVisible }) + + edt { links(banner).single { it.text == "Turn off GitHub integration" }.doClick() } + pump() + + assertFalse(KiloPluginSettings.getGithub()) + assertEquals(GhAvailability.OK, edt { service.current() }) + assertFalse(edt { banner.isVisible }) + } + private fun links(root: Component): List = components(root).filterIsInstance() private fun components(root: Component): List = buildList { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/GhStatusCoordinatorTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/GhStatusCoordinatorTest.kt index 82c00dac5fe2..6b8831dca7a2 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/GhStatusCoordinatorTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/GhStatusCoordinatorTest.kt @@ -1,10 +1,12 @@ package ai.kilocode.client.agentManager.worktree +import ai.kilocode.client.plugin.KiloPluginSettings import ai.kilocode.client.testing.FakeWorktreeRpcApi import ai.kilocode.client.testing.TestCoroutines import ai.kilocode.client.testing.fakeRoot import ai.kilocode.client.testing.pumpEdt import ai.kilocode.client.testing.TestUiTimers +import ai.kilocode.client.testing.activateIde import ai.kilocode.client.testing.installBrowser import ai.kilocode.client.util.edtWait import ai.kilocode.rpc.dto.GhAvailability @@ -37,6 +39,7 @@ class GhStatusCoordinatorTest : BasePlatformTestCase() { override fun tearDown() { try { + KiloPluginSettings.unsetGithub() coroutines.close(::pump) } finally { super.tearDown() @@ -140,14 +143,15 @@ class GhStatusCoordinatorTest : BasePlatformTestCase() { assertEquals(1, rpc.ghCalls.size) } - fun `test coordinator skips forced probes while busy instead of queueing`() { + fun `test coordinator drops submitted syncs while busy instead of queueing`() { val gate = CompletableDeferred() rpc.beforeGhStatus = { gate.await() } val handle = edtWait { service.attach(project) } awaitCalls(1) - edtWait { service.forceProbe("test") } - timers.advanceBy(0) + // Past the event throttle, so an in-flight probe is the only thing that can drop the submit. + timers.advanceBy(EVENT_THROTTLE) + edtWait { service.sync("test") } pump() assertEquals(1, rpc.ghCalls.size) @@ -157,11 +161,139 @@ class GhStatusCoordinatorTest : BasePlatformTestCase() { handle.close() } + fun `test coordinator throttles a burst of submitted syncs`() { + val handle = edtWait { service.attach(project) } + drain() + assertEquals(1, rpc.ghCalls.size) + + // Focus and tab-switch events can arrive in bursts; inside the window they collapse to none. + repeat(5) { edtWait { service.sync("burst") } } + drain() + assertEquals(1, rpc.ghCalls.size) + + timers.advanceBy(EVENT_THROTTLE) + edtWait { service.sync("later") } + drain() + + assertEquals(2, rpc.ghCalls.size) + handle.close() + } + + fun `test coordinator ignores submitted syncs while nothing is attached`() { + edtWait { service.sync("detached") } + drain() + + assertTrue(rpc.ghCalls.isEmpty()) + } + + fun `test coordinator syncs when the ide frame is activated`() { + rpc.ghResult = GhAvailability.UNAUTH + val handle = edtWait { service.attach(project) } + drain() + assertEquals(GhAvailability.UNAUTH, service.current()) + assertEquals(1, rpc.ghCalls.size) + timers.advanceBy(EVENT_THROTTLE) + + // The user authorized gh in a terminal and came back to the IDE. + rpc.ghResult = GhAvailability.OK + edtWait { activateIde(project) } + drain() + + assertEquals(2, rpc.ghCalls.size) + assertEquals(GhAvailability.OK, service.current()) + handle.close() + } + + fun `test coordinator does not probe on activation before anything attaches`() { + edtWait { activateIde(project) } + drain() + + assertTrue(rpc.ghCalls.isEmpty()) + } + + fun `test coordinator probes git only while the github integration is off`() { + rpc.ghResult = GhAvailability.UNAUTH + val handle = edtWait { service.attach(project) } + drain() + assertEquals(GhAvailability.UNAUTH, service.current()) + + github(false) + drain() + // Disabling publishes OK immediately so the banner hides without waiting for a probe. + assertEquals(GhAvailability.OK, service.current()) + + val before = rpc.ghCalls.size + // SLOW cadence while disabled: the loop only checks whether git exists. + timers.advanceBy(59_999) + drain() + assertEquals(before, rpc.ghCalls.size) + + timers.advanceBy(1) + drain() + assertEquals(before + 1, rpc.ghCalls.size) + assertFalse("a disabled probe must never ask the backend to run gh", rpc.ghFlags.last()) + assertEquals(GhAvailability.OK, service.current()) + handle.close() + } + + fun `test coordinator still reports a missing git while the github integration is off`() { + rpc.ghResult = GhAvailability.GIT_MISSING + github(false) + val handle = edtWait { service.attach(project) } + drain() + + assertEquals(GhAvailability.GIT_MISSING, service.current()) + assertFalse(rpc.ghFlags.last()) + handle.close() + } + + fun `test coordinator ignores a stale gh report while the github integration is off`() { + val events = mutableListOf() + ApplicationManager.getApplication().messageBus.connect(testRootDisposable) + .subscribe(GhStatusListener.TOPIC, GhStatusListener { events += it }) + github(false) + + // A prStatus lookup that was in flight at the moment of disabling. + report(GhAvailability.UNAUTH) + + assertEquals(GhAvailability.OK, service.current()) + assertTrue(events.isEmpty()) + } + + fun `test coordinator cancels the in flight probe and reprobes when re-enabled`() { + val gate = CompletableDeferred() + rpc.beforeGhStatus = { gate.await() } + val handle = edtWait { service.attach(project) } + awaitCalls(1) + + // Disabling must not wait for the running gh call to finish. + github(false) + assertEquals(GhAvailability.OK, service.current()) + gate.complete(Unit) + drain() + + rpc.beforeGhStatus = {} + rpc.ghResult = GhAvailability.UNAUTH + val before = rpc.ghCalls.size + github(true) + drain() + + assertEquals("re-enabling probes at once instead of waiting out the timer", before + 1, rpc.ghCalls.size) + assertTrue(rpc.ghFlags.last()) + assertEquals(GhAvailability.UNAUTH, service.current()) + handle.close() + } + private fun report(value: GhAvailability) { edtWait { service.report(project, value) } pump() } + private fun github(enabled: Boolean) { + edtWait { setGithubIntegration(enabled, "test") } + pump() + } + private fun drain() = coroutines.drain() private fun awaitCalls(count: Int) { @@ -172,5 +304,8 @@ class GhStatusCoordinatorTest : BasePlatformTestCase() { private companion object { private const val ROOT = "/real/repo" + + /** Mirrors GhStatusCoordinator.EVENT_THROTTLE, the floor between event-driven syncs. */ + private const val EVENT_THROTTLE = 3_000L } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/NewWorktreeDialogTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/NewWorktreeDialogTest.kt index 0edfd4b885cd..a9a7874ad738 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/NewWorktreeDialogTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/NewWorktreeDialogTest.kt @@ -2,6 +2,7 @@ package ai.kilocode.client.agentManager.worktree import ai.kilocode.client.app.KiloAppService import ai.kilocode.client.app.KiloWorkspaceService +import ai.kilocode.client.plugin.KiloPluginSettings import ai.kilocode.client.session.ui.ReasoningPicker import ai.kilocode.client.session.ui.mode.ModePicker import ai.kilocode.client.session.ui.model.ModelPicker @@ -51,13 +52,16 @@ class NewWorktreeDialogTest : BasePlatformTestCase() { val ws = FakeWorkspaceRpcApi().apply { models = workspace() } workspaces = KiloWorkspaceService(scope, ws) sessionRpc = FakeSessionRpcApi() + KiloPluginSettings.unsetAgent() } override fun tearDown() { try { + KiloPluginSettings.unsetGithub() dialog?.let { d -> edt { Disposer.dispose(d.disposable) } } dialog = null scope.cancel() + KiloPluginSettings.unsetAgent() } finally { super.tearDown() } @@ -75,7 +79,7 @@ class NewWorktreeDialogTest : BasePlatformTestCase() { } } - fun `test selecting a mode forwards it with the created prompt and writes no global config`() { + fun `test selecting a mode forwards it with the created prompt only`() { open() flushUntil { edt { model().selectionKeyForTest() != null } } @@ -86,9 +90,10 @@ class NewWorktreeDialogTest : BasePlatformTestCase() { flushUntil { edt { prompt().isSendEnabled } } edt { prompt().send() } + // The prompt is the only place the pick travels: writing it to the CLI's global default_agent + // disposed every instance the CLI held and cancelled every running turn in every worktree. assertEquals("plan", submitted().prompt?.agent) - // Picking a mode must no longer mutate the global default_agent config. - assertTrue(sessionRpc.configs.none { it.second.agent != null }) + assertNull(KiloPluginSettings.getAgent()) } fun `test selecting a model persists it for the default agent`() { @@ -299,6 +304,17 @@ class NewWorktreeDialogTest : BasePlatformTestCase() { assertFalse(edt { prTab().isVisible }) } + fun `test the pr tab is dropped while the github integration is off`() { + // Importing a PR needs gh, so the tab must not be offered as a guaranteed failure. + KiloPluginSettings.setGithub(false) + open() + + edt { + val titles = tabs().tabs.map { it.text } + assertEquals(listOf("New", "From Branch"), titles) + } + } + fun `test an empty branch list disables the branch picker`() { open(branches = emptyList()) selectBranch() diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreePrHeaderViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreePrHeaderViewTest.kt index 1103819e1850..afa0bf731356 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreePrHeaderViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreePrHeaderViewTest.kt @@ -1,19 +1,21 @@ package ai.kilocode.client.agentManager.worktree import ai.kilocode.client.plugin.KiloBundle -import ai.kilocode.client.session.ui.header.BranchChangesBadge +import ai.kilocode.client.ui.ChangesPanel import ai.kilocode.client.ui.FilledBadgeIcon import ai.kilocode.client.ui.HoverIcon import ai.kilocode.client.ui.stateLabel import ai.kilocode.client.ui.style import ai.kilocode.client.util.edtWait import ai.kilocode.rpc.dto.GhState +import ai.kilocode.rpc.dto.WorktreeDirtyDto import ai.kilocode.rpc.dto.WorktreePrDto import ai.kilocode.rpc.dto.WorktreeStatsDto import com.intellij.testFramework.fixtures.BasePlatformTestCase import com.intellij.ui.SimpleColoredComponent import com.intellij.ui.SimpleTextAttributes import com.intellij.ui.components.JBLabel +import com.intellij.util.concurrency.annotations.RequiresEdt import com.intellij.util.ui.UIUtil import org.jetbrains.plugins.terminal.TerminalIcons import java.awt.Container @@ -71,7 +73,7 @@ class WorktreePrHeaderViewTest : BasePlatformTestCase() { fun `test no PR keeps changes badge on the right`() { val view = edt { WorktreePrHeaderView {} } - val changes = edt { UIUtil.findComponentOfType(view, BranchChangesBadge::class.java)!! } + val changes = edt { UIUtil.findComponentOfType(view, ChangesPanel::class.java)!! } val open = edt { components(view).filterIsInstance().single { it.text == "Open" } } val terminal = edt { components(view).filterIsInstance().single { it.icon === TerminalIcons.OpenTerminal_13x13 } } @@ -96,7 +98,7 @@ class WorktreePrHeaderViewTest : BasePlatformTestCase() { fun `test changes view visibility follows stats`() { val view = edt { WorktreePrHeaderView {} } - val changes = edt { UIUtil.findComponentOfType(view, BranchChangesBadge::class.java)!! } + val changes = edt { UIUtil.findComponentOfType(view, ChangesPanel::class.java)!! } edt { view.update(WorktreeStatsDto("/repo"), null, "feature-x") } assertFalse(edt { changes.isVisible }) @@ -110,7 +112,7 @@ class WorktreePrHeaderViewTest : BasePlatformTestCase() { val view = edt { WorktreePrHeaderView { opened++ } } edt { view.update(WorktreeStatsDto("/repo", additions = 2, files = 1), null, "feature-x") } - edt { click(UIUtil.findComponentOfType(view, BranchChangesBadge::class.java)!!) } + edt { click(components(view).filterIsInstance().single { it.text == "1 file" }) } assertEquals(1, opened) } @@ -123,25 +125,82 @@ class WorktreePrHeaderViewTest : BasePlatformTestCase() { edt { view.update(stats, pull, "feature-x") } val labels = edt { components(view).filterIsInstance() } val title = edt { title(view) } - val changes = edt { UIUtil.findComponentOfType(view, BranchChangesBadge::class.java)!! } + val changes = edt { UIUtil.findComponentOfType(view, ChangesPanel::class.java)!! } edt { view.update(stats, pull, "feature-x") } assertEquals(labels, edt { components(view).filterIsInstance() }) assertSame(title, edt { title(view) }) - assertSame(changes, edt { UIUtil.findComponentOfType(view, BranchChangesBadge::class.java) }) + assertSame(changes, edt { UIUtil.findComponentOfType(view, ChangesPanel::class.java) }) } - fun `test changes badge reuses session branch badge with file count`() { + fun `test full changes retain local and base counts with and without a PR`() { val view = edt { WorktreePrHeaderView {} } - val changes = edt { UIUtil.findComponentOfType(view, BranchChangesBadge::class.java)!! } + val changes = edt { UIUtil.findComponentOfType(view, ChangesPanel::class.java)!! } + val stats = WorktreeStatsDto("/repo", additions = 10, deletions = 4, ahead = 3, behind = 2, files = 5, base = "origin/main") + val dirty = WorktreeDirtyDto("/repo", additions = 6, deletions = 1, files = 1, unpushed = 19) + + listOf(null, pull(GhState.OPEN)).forEach { pull -> + edt { view.update(stats, pull, "feature-x", dirty) } + assertEquals( + listOf("1 file", "-1", "+6", "3", "2", "5 files", "-4", "+10"), + edt { labels(changes) }, + ) + assertTrue(edt { changes.isVisible }) + assertEquals( + KiloBundle.message("worktree.stats.base.tooltip", 5, 10, 4, "origin/main"), + edt { components(changes).filterIsInstance().single { it.text == "5 files" }.toolTipText }, + ) + } + } - edt { view.update(WorktreeStatsDto("/repo", additions = 10, deletions = 4, ahead = 3, files = 2), null, "feature-x") } + fun `test local and base child clicks route independently`() { + val opened = mutableListOf() + val view = edt { WorktreePrHeaderView(onLocal = { opened += "local" }, openDiff = { opened += "base" }) } + edt { + view.update(WorktreeStatsDto("/repo", files = 2), null, "feature-x", WorktreeDirtyDto("/repo", files = 1)) + click(components(view).filterIsInstance().single { it.text == "1 file" }) + click(components(view).filterIsInstance().single { it.text == "2 files" }) + } + assertEquals(listOf("local", "base"), opened) + } - assertEquals("2 files", edt { changes.countText() }) - assertEquals(10 to 4, edt { changes.stats() }) - assertTrue(edt { changes.isVisible }) - assertEquals("Compare with base branch", edt { changes.toolTipText }) + fun `test clearing either comparison preserves the other and controls`() { + val view = edt { WorktreePrHeaderView {} } + val changes = edt { UIUtil.findComponentOfType(view, ChangesPanel::class.java)!! } + val dirty = WorktreeDirtyDto("/repo", files = 1) + edt { view.update(null, null, "feature-x", dirty) } + assertEquals(listOf("1 file"), edt { labels(changes) }) + edt { view.update(WorktreeStatsDto("/repo", ahead = 2), null, "feature-x") } + assertEquals(listOf("2"), edt { labels(changes) }) + edt { view.update(null, null, "feature-x") } + assertFalse(edt { changes.isVisible }) + val icons = edt { components(view).filterIsInstance().filter { it.isVisible } } + assertEquals(listOf("Open"), edt { icons.map { it.text }.filterNot { it.isNullOrEmpty() } }) + assertTrue(edt { icons.any { it.icon === TerminalIcons.OpenTerminal_13x13 } }) + } + + fun `test open and terminal controls remain disabled for unavailable worktrees`() { + var opened = 0 + val view = edt { WorktreePrHeaderView(openEnabled = false, openWorktree = { opened++ }, openTerminal = { opened++ }, openDiff = {}) } + edt { + val buttons = components(view).filterIsInstance() + assertTrue(buttons.all { !it.isEnabled }) + buttons.forEach(::click) + } + assertEquals(0, opened) + } + + fun `test icon only terminal shares labelled action metrics`() { + val view = edt { WorktreePrHeaderView(openDiff = {}) } + val open = edt { components(view).filterIsInstance().single { it.text == "Open" } } + val terminal = edt { components(view).filterIsInstance().single { it.icon === TerminalIcons.OpenTerminal_13x13 } } + + assertEquals(edt { open.insets }, edt { terminal.insets }) + assertEquals(edt { open.preferredSize.height }, edt { terminal.preferredSize.height }) + // Icon-only stays square: it matches the labelled height without inheriting its wide minimum. + assertEquals(edt { terminal.preferredSize.height }, edt { terminal.preferredSize.width }) + assertTrue(edt { terminal.preferredSize.width < open.preferredSize.width }) } fun `test terminal button is icon-only and triggers callback`() { @@ -165,14 +224,17 @@ class WorktreePrHeaderViewTest : BasePlatformTestCase() { title = "Implement header", ) + @RequiresEdt private fun badge(view: WorktreePrHeaderView): JBLabel { return components(view).filterIsInstance().single { it.icon is FilledBadgeIcon } } + @RequiresEdt private fun title(view: WorktreePrHeaderView): SimpleColoredComponent { return components(view).filterIsInstance().single() } + @RequiresEdt private fun fragments(title: SimpleColoredComponent): List { val out = mutableListOf() val iter = title.iterator() @@ -185,6 +247,7 @@ class WorktreePrHeaderViewTest : BasePlatformTestCase() { private data class Fragment(val text: String, val attrs: SimpleTextAttributes) + @RequiresEdt private fun components(root: java.awt.Component): List { val out = mutableListOf() fun visit(item: java.awt.Component) { @@ -195,6 +258,14 @@ class WorktreePrHeaderViewTest : BasePlatformTestCase() { return out } + @RequiresEdt + private fun labels(root: java.awt.Component): List { + if (!root.isVisible) return emptyList() + if (root is JBLabel) return listOfNotNull(root.text) + return if (root is Container) root.components.flatMap(::labels) else emptyList() + } + + @RequiresEdt private fun click(target: JComponent) { target.setSize(target.preferredSize) val point = Point(target.width.coerceAtLeast(2) / 2, target.height.coerceAtLeast(2) / 2) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorPanelTest.kt index e81f84e401c7..7f25372768a1 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorPanelTest.kt @@ -1,7 +1,13 @@ package ai.kilocode.client.agentManager.worktree import ai.kilocode.client.util.edtWait +import ai.kilocode.client.app.KiloAppService import ai.kilocode.client.app.KiloSessionService +import ai.kilocode.client.app.KiloWorkspaceService +import ai.kilocode.client.diff.KiloDiffComparison +import ai.kilocode.client.diff.KiloDiffEditorKind +import ai.kilocode.client.diff.KiloDiffEditorService +import ai.kilocode.client.diff.openKiloDiff import ai.kilocode.client.app.Workspace import ai.kilocode.client.migration.FakeMigrationUiController import ai.kilocode.client.session.SessionActivityKind @@ -10,18 +16,43 @@ import ai.kilocode.client.session.SessionRef import ai.kilocode.client.session.history.HistorySection import ai.kilocode.client.session.history.HistoryTime import ai.kilocode.client.session.history.LocalHistoryItem +import ai.kilocode.client.testing.FakeAppRpcApi import ai.kilocode.client.testing.FakeSessionRpcApi +import ai.kilocode.client.testing.FakeWorkspaceRpcApi +import ai.kilocode.client.testing.FakeWorktreeRpcApi import ai.kilocode.client.testing.TestCoroutines +import ai.kilocode.client.testing.TestUiTimers import ai.kilocode.client.testing.pumpEdt import ai.kilocode.client.testing.fire import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.ui.ChangesPanel import ai.kilocode.client.ui.FilledBadgeIcon +import ai.kilocode.client.vfs.KiloEditorKindRegistry +import ai.kilocode.client.vfs.KiloVirtualFile +import ai.kilocode.client.vfs.KiloVirtualFileSystem import ai.kilocode.client.ui.UiStyle import ai.kilocode.client.ui.list.ActiveList import ai.kilocode.client.ui.list.ActiveListItem import ai.kilocode.client.ui.list.activeListSectionTitle import ai.kilocode.client.ui.list.activeListToolWindowBackground +import ai.kilocode.rpc.KiloWorktreeRpcApi +import ai.kilocode.rpc.dto.KiloAppStateDto +import ai.kilocode.rpc.dto.KiloAppStatusDto import ai.kilocode.rpc.dto.RenameWorktreeResultDto +import ai.kilocode.rpc.dto.WorktreeDirtyDto +import ai.kilocode.rpc.dto.WorktreeDirtyListDto +import ai.kilocode.rpc.dto.WorktreeStatsDto +import ai.kilocode.rpc.dto.WorktreeStatsListDto +import com.intellij.openapi.Disposable +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.components.service +import com.intellij.openapi.fileEditor.FileEditorManager +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.Disposer +import com.intellij.testFramework.replaceService +import com.intellij.util.concurrency.annotations.RequiresEdt +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.channels.Channel import ai.kilocode.rpc.dto.SessionDto import ai.kilocode.rpc.dto.SessionTimeDto import com.intellij.openapi.actionSystem.DataKey @@ -94,12 +125,15 @@ class WorktreeSessionEditorPanelTest : BasePlatformTestCase() { private fun view( stored: Boolean? = null, load: ((Boolean?) -> Unit) -> Unit = { done -> done(stored) }, + target: Project? = null, + parent: Disposable = testRootDisposable, ): WorktreeSessionEditorPanel = edt { WorktreeSessionEditorPanel( - testRootDisposable, + parent, manager, controller, workspace, + project = target, confirm = { _, _, run -> run() }, load = load, save = { saves += it }, @@ -731,6 +765,143 @@ class WorktreeSessionEditorPanelTest : BasePlatformTestCase() { assertSame(workspace, sink.workspace) } + fun `test project status delivers base and dirty independently and stops after disposal`() { + val stats = Channel(Channel.UNLIMITED) + val dirty = Channel(Channel.UNLIMITED) + val api = object : KiloWorktreeRpcApi by FakeWorktreeRpcApi() { + override suspend fun stats(directory: String): WorktreeStatsListDto = stats.receive() + override suspend fun dirty(directory: String): WorktreeDirtyListDto = dirty.receive() + } + val (status, timers) = edt { status(api) } + val owner = Disposer.newDisposable(testRootDisposable, "status header") + val view = view(target = project, parent = owner) + val summary = edt { components(view).filterIsInstance().single() } + val nodes = edt { components(summary) } + val handle = edt { status.attach() } + try { + timers.advanceBy(300) + stats.trySend(WorktreeStatsListDto(listOf(WorktreeStatsDto(DIR, files = 2, additions = 5, base = "origin/main")))).getOrThrow() + await(summary, listOf("2 files", "+5")) + dirty.trySend(WorktreeDirtyListDto(listOf(WorktreeDirtyDto(DIR, files = 1, additions = 7)))).getOrThrow() + await(summary, listOf("1 file", "+7", "2 files", "+5")) + + edt { status.refreshStats() } + timers.advanceBy(300) + dirty.trySend(WorktreeDirtyListDto(listOf(WorktreeDirtyDto(DIR, files = 3, deletions = 4)))).getOrThrow() + await(summary, listOf("3 files", "-4", "2 files", "+5")) + stats.trySend(WorktreeStatsListDto(listOf(WorktreeStatsDto(DIR, files = 1, behind = 2, base = "origin/trunk")))).getOrThrow() + await(summary, listOf("3 files", "-4", "2", "1 file")) + + edt { status.refreshStats() } + timers.advanceBy(300) + dirty.trySend(WorktreeDirtyListDto()).getOrThrow() + await(summary, listOf("2", "1 file")) + stats.trySend(WorktreeStatsListDto()).getOrThrow() + await(summary, emptyList()) + edt { + assertFalse(summary.isVisible) + assertEquals(nodes, components(summary)) + assertFalse(manager.showsBranchDock) + Disposer.dispose(owner) + status.refreshStats() + } + timers.advanceBy(300) + val next = WorktreeStatsDto(DIR, files = 9) + stats.trySend(WorktreeStatsListDto(listOf(next))).getOrThrow() + dirty.trySend(WorktreeDirtyListDto(listOf(WorktreeDirtyDto(DIR, files = 8)))).getOrThrow() + assertTrue(coroutines.pumpUntil { status.stats.value[DIR] == next && status.dirty.value[DIR]?.files == 8 }) + assertTrue(edt { labels(summary).isEmpty() }) + } finally { + edt { + Disposer.dispose(owner) + handle.close() + } + stats.close() + dirty.close() + } + } + + fun `test worktree header opens distinct reusable comparison tabs with actual branch name`() { + val rpc = FakeWorktreeRpcApi().apply { + statsResult = WorktreeStatsListDto(listOf(WorktreeStatsDto(DIR, files = 2))) + dirtyResult = WorktreeDirtyListDto(listOf(WorktreeDirtyDto(DIR, files = 1))) + } + val (_, timers) = edt { status(rpc) } + val workspace = FakeWorkspaceRpcApi().apply { branchName = "feature/topic" } + val app = FakeAppRpcApi().apply { state.value = KiloAppStateDto(KiloAppStatusDto.READY) } + ApplicationManager.getApplication().replaceService(KiloWorkspaceService::class.java, KiloWorkspaceService(coroutines.scope, workspace), testRootDisposable) + ApplicationManager.getApplication().replaceService(KiloAppService::class.java, KiloAppService(coroutines.scope, app), testRootDisposable) + project.replaceService(KiloSessionService::class.java, sessions, testRootDisposable) + project.replaceService(KiloDiffEditorService::class.java, KiloDiffEditorService(project, coroutines.scope), testRootDisposable) + val view = view(target = project) + val summary = edt { components(view).filterIsInstance().single() } + val editors = FileEditorManager.getInstance(project) + try { + timers.advanceBy(300) + await(summary, listOf("1 file", "2 files")) + edt { + click(components(summary).filterIsInstance().single { it.text == "1 file" }) + click(components(summary).filterIsInstance().single { it.text == "2 files" }) + } + assertTrue(coroutines.pumpUntil { edt { editors.openFiles.size == 2 } }) + edt { + val files = editors.openFiles.filterIsInstance() + assertEquals(setOf("branch", "local"), files.map { it.path.params["source"] }.toSet()) + files.forEach { file -> + assertEquals(KiloDiffEditorKind.ID, file.path.kind) + assertEquals(DIR, file.path.params["directory"]) + assertEquals("feature/topic", file.path.params["branch"]) + assertFalse(file.path.params.containsKey("sessionId")) + assertFalse(file.path.params.containsKey("token")) + val comparison = KiloDiffComparison.entries.single { it.source == file.path.params["source"] } + assertEquals(comparison.title("feature/topic"), file.name) + openKiloDiff(project, "$DIR/./", comparison, "feature/topic") + assertSame(file, editors.openFiles.filterIsInstance().single { it.path.params["source"] == comparison.source }) + } + assertFalse(manager.showsBranchDock) + } + val gate = CompletableDeferred() + workspace.beforeBranchName = { gate.await() } + edt { + editors.openFiles.forEach { editors.closeFile(it) } + click(components(summary).filterIsInstance().single { it.text == "1 file" }) + } + flush() + edt { Disposer.dispose(view) } + gate.complete(Unit) + flush() + assertTrue(edt { editors.openFiles.isEmpty() }) + } finally { + edt { + editors.openFiles.forEach { editors.closeFile(it) } + service().unregister(KiloDiffEditorKind.ID) + KiloVirtualFileSystem.getInstance().clear() + } + } + } + + @RequiresEdt + private fun status(rpc: KiloWorktreeRpcApi): Pair { + ApplicationManager.getApplication().replaceService(KiloWorktreeService::class.java, KiloWorktreeService(coroutines.scope, rpc), testRootDisposable) + ApplicationManager.getApplication().replaceService(GhStatusCoordinator::class.java, GhStatusCoordinator(coroutines.scope, TestUiTimers()), testRootDisposable) + val timers = TestUiTimers() + val status = WorktreeStatusService(project, coroutines.scope, timers) + project.replaceService(WorktreeStatusService::class.java, status, testRootDisposable) + return status to timers + } + + private fun await(summary: ChangesPanel, expected: List) { + val arrived = coroutines.pumpUntil { edt { labels(summary) == expected } } + assertTrue("Expected $expected, last header ${edt { labels(summary) }}", arrived) + } + + @RequiresEdt + private fun labels(root: java.awt.Component): List { + if (!root.isVisible) return emptyList() + if (root is JBLabel) return listOfNotNull(root.text) + return if (root is Container) root.components.flatMap(::labels) else emptyList() + } + private fun session(id: String, updated: Double) = SessionDto( id = id, projectID = "proj_test", @@ -755,6 +926,7 @@ class WorktreeSessionEditorPanelTest : BasePlatformTestCase() { private fun edt(block: () -> T): T = edtWait(block) + @RequiresEdt private fun components(root: java.awt.Component): List { val out = mutableListOf() fun visit(item: java.awt.Component) { @@ -793,6 +965,7 @@ class WorktreeSessionEditorPanelTest : BasePlatformTestCase() { return components(root).filterIsInstance().firstOrNull { it.presentation.text == text }?.isVisible == true } + @RequiresEdt private fun click(target: javax.swing.JComponent) { target.setSize(target.preferredSize) val point = Point(target.width.coerceAtLeast(2) / 2, target.height.coerceAtLeast(2) / 2) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSetupScriptTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSetupScriptTest.kt new file mode 100644 index 000000000000..ee9ba378a13e --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSetupScriptTest.kt @@ -0,0 +1,49 @@ +package ai.kilocode.client.agentManager.worktree + +import ai.kilocode.rpc.dto.SetupScriptKind +import ai.kilocode.rpc.dto.SetupScriptTargetDto +import com.intellij.testFramework.fixtures.BasePlatformTestCase + +class WorktreeSetupScriptTest : BasePlatformTestCase() { + fun `test posix command invokes sh with a quoted path`() { + val script = SetupScriptTargetDto("/repo/.kilo/setup-script", "/repo/.kilo/setup-script", true, SetupScriptKind.POSIX) + + assertEquals("sh '/repo/.kilo/setup-script'", setupScriptCommand(script)) + } + + fun `test powershell command uses NoProfile and Bypass with a quoted path`() { + val script = SetupScriptTargetDto("C:\\repo\\.kilo\\setup-script.ps1", "C:\\repo\\.kilo\\setup-script.ps1", true, SetupScriptKind.POWERSHELL) + + assertEquals( + "powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File \"C:\\repo\\.kilo\\setup-script.ps1\"", + setupScriptCommand(script), + ) + } + + fun `test cmd command uses d s c with a quoted path`() { + val script = SetupScriptTargetDto("C:\\repo\\.kilo\\setup-script.cmd", "C:\\repo\\.kilo\\setup-script.cmd", true, SetupScriptKind.CMD) + + assertEquals("cmd.exe /d /s /c \"C:\\repo\\.kilo\\setup-script.cmd\"", setupScriptCommand(script)) + } + + fun `test posix quoting escapes an embedded single quote`() { + val script = SetupScriptTargetDto("/repo/it's/.kilo/setup-script", "/repo/it's/.kilo/setup-script", true, SetupScriptKind.POSIX) + + assertEquals("sh '/repo/it'\\''s/.kilo/setup-script'", setupScriptCommand(script)) + } + + fun `test windows quoting doubles an embedded double quote`() { + val script = SetupScriptTargetDto("C:\\repo \"copy\"\\.kilo\\setup-script.ps1", "", true, SetupScriptKind.POWERSHELL) + + assertEquals( + "powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File \"C:\\repo \"\"copy\"\"\\.kilo\\setup-script.ps1\"", + setupScriptCommand(script), + ) + } + + fun `test posix quoting handles a path containing spaces`() { + val script = SetupScriptTargetDto("/repo/my worktree/.kilo/setup-script", "", true, SetupScriptKind.POSIX) + + assertEquals("sh '/repo/my worktree/.kilo/setup-script'", setupScriptCommand(script)) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeStatusServiceTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeStatusServiceTest.kt index 20141f471dbc..ec7061ccc9d4 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeStatusServiceTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeStatusServiceTest.kt @@ -1,19 +1,29 @@ package ai.kilocode.client.agentManager.worktree +import ai.kilocode.client.plugin.KiloPluginSettings import ai.kilocode.client.testing.FakeWorktreeRpcApi import ai.kilocode.client.testing.TestCoroutines import ai.kilocode.client.testing.fakeRoot import ai.kilocode.client.testing.pumpEdt import ai.kilocode.client.testing.TestUiTimers +import ai.kilocode.client.testing.activateIde +import ai.kilocode.client.testing.fakeRoot +import ai.kilocode.client.util.edtWait import ai.kilocode.rpc.dto.GhAvailability import ai.kilocode.rpc.dto.GhState +import ai.kilocode.rpc.dto.WorktreeDirtyDto +import ai.kilocode.rpc.dto.WorktreeDirtyListDto import ai.kilocode.rpc.dto.WorktreePrDto import ai.kilocode.rpc.dto.WorktreePrListDto import ai.kilocode.rpc.dto.WorktreeStatsDto import ai.kilocode.rpc.dto.WorktreeStatsListDto import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.project.ProjectManager import com.intellij.testFramework.fixtures.BasePlatformTestCase import com.intellij.testFramework.replaceService +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.withContext @Suppress("UnstableApiUsage") class WorktreeStatusServiceTest : BasePlatformTestCase() { @@ -38,6 +48,7 @@ class WorktreeStatusServiceTest : BasePlatformTestCase() { override fun tearDown() { try { + KiloPluginSettings.unsetGithub() coroutines.close(::pump) } finally { super.tearDown() @@ -60,6 +71,22 @@ class WorktreeStatusServiceTest : BasePlatformTestCase() { handle.close() } + fun `test attach loads dirty on the same debounce as stats`() { + val path = "${project.basePath}/.kilo/worktrees/feature-x" + rpc.statsResult = WorktreeStatsListDto(listOf(WorktreeStatsDto(path, additions = 4))) + rpc.dirtyResult = WorktreeDirtyListDto(listOf(WorktreeDirtyDto(path, additions = 2, files = 1))) + val key = normalizeWorktreePath(path) + + val handle = service.attach() + timers.advanceBy(300) + drain() + + assertEquals(4, service.stats.value[key]?.additions) + assertEquals(2, service.dirty.value[key]?.additions) + assertEquals(1, service.dirty.value[key]?.files) + handle.close() + } + fun `test refresh is ignored after the last handle closes`() { val path = "${project.basePath}/.kilo/worktrees/feature-x" val key = normalizeWorktreePath(path) @@ -127,7 +154,177 @@ class WorktreeStatusServiceTest : BasePlatformTestCase() { handle.close() } + fun `test stats and dirty both target the resolved backend root`() { + // In split/remote mode project.basePath is a synthetic JetBrains Client path. Sending it to + // the backend makes dirty() answer for a directory that is not there, which the UI would + // render as "no local changes" instead of the real uncommitted counts. + fakeRoot(project, coroutines.scope, testRootDisposable, BACKEND_ROOT) + val remote = WorktreeStatusService(project, coroutines.scope, timers) + + val handle = remote.attach() + timers.advanceBy(300) + drain() + + assertEquals(listOf(BACKEND_ROOT), rpc.statsCalls) + assertEquals(listOf(BACKEND_ROOT), rpc.dirtyCalls) + assertFalse(rpc.dirtyCalls.contains(project.basePath)) + handle.close() + } + + fun `test a pr lookup cancelled by a disable cannot publish after a re-enable`() { + val path = "${project.basePath}/.kilo/worktrees/feature-x" + val key = normalizeWorktreePath(path) + val gate = CompletableDeferred() + // The lookup that gets abandoned. It answers empty and OK, which is what the RPC layer + // synthesizes when it swallows the cancellation. NonCancellable holds it open past the + // cancel so it lands after the replacement, the ordering that makes a stale write dangerous. + rpc.prResult = WorktreePrListDto(GhAvailability.OK) + rpc.beforePrStatus = { withContext(NonCancellable) { gate.await() } } + val handle = service.attach() + assertTrue(coroutines.pumpUntil { rpc.prCalls.isNotEmpty() }) + + github(false) + // The fresh lookup after re-enabling sees a real PR and an unauthorized gh. + rpc.beforePrStatus = {} + rpc.prResult = WorktreePrListDto(GhAvailability.UNAUTH, listOf(WorktreePrDto(path, 7, GhState.OPEN, "https://pr/7"))) + github(true) + drain() + assertEquals(7, service.pr.value[key]?.number) + assertEquals(GhAvailability.UNAUTH, service.gh.value) + + // Now let the abandoned lookup finish. It must not wipe the badge or report a false OK. + gate.complete(Unit) + drain() + + assertEquals(7, service.pr.value[key]?.number) + assertEquals(GhAvailability.UNAUTH, service.gh.value) + handle.close() + } + + fun `test activation reloads pr state within the throttle budget`() { + val path = "${project.basePath}/.kilo/worktrees/feature-x" + val key = normalizeWorktreePath(path) + rpc.prResult = WorktreePrListDto(GhAvailability.OK, listOf(WorktreePrDto(path, 1, GhState.OPEN, "https://pr/1"))) + val handle = service.attach() + drain() + assertEquals(1, service.pr.value[key]?.number) + + // A PR merged while the IDE sat in the background. + rpc.prResult = WorktreePrListDto(GhAvailability.OK, listOf(WorktreePrDto(path, 1, GhState.MERGED, "https://pr/1"))) + timers.advanceBy(30_000) + activateIde(project) + drain() + + assertEquals(GhState.MERGED, service.pr.value[key]?.state) + handle.close() + } + + fun `test activation collapses a burst of focus events into one lookup`() { + rpc.prResult = WorktreePrListDto(GhAvailability.OK) + val handle = service.attach() + drain() + val before = rpc.prCalls.size + + repeat(5) { activateIde(project) } + drain() + + assertEquals("PR_THROTTLE must absorb repeated activation", before, rpc.prCalls.size) + handle.close() + } + + fun `test activation of another project does not reload pr state`() { + rpc.prResult = WorktreePrListDto(GhAvailability.OK) + val handle = service.attach() + drain() + val before = rpc.prCalls.size + timers.advanceBy(30_000) + + // A different project's window gaining focus says nothing about this project's worktrees. + activateIde(ProjectManager.getInstance().defaultProject) + drain() + + assertEquals(before, rpc.prCalls.size) + handle.close() + } + + fun `test attach skips pr polling while the github integration is off`() { + val path = "${project.basePath}/.kilo/worktrees/feature-x" + val key = normalizeWorktreePath(path) + rpc.statsResult = WorktreeStatsListDto(listOf(WorktreeStatsDto(path, additions = 4))) + rpc.prResult = WorktreePrListDto(GhAvailability.OK, listOf(WorktreePrDto(path, 3, GhState.OPEN, "https://pr/3"))) + KiloPluginSettings.setGithub(false) + val off = WorktreeStatusService(project, coroutines.scope, timers) + + val handle = off.attach() + drain() + assertTrue(rpc.prCalls.isEmpty()) + + // Git-backed stats and dirty counts are unaffected by the GitHub integration setting. + timers.advanceBy(300) + drain() + assertEquals(4, off.stats.value[key]?.additions) + + // No PR timer was started, so the poll interval must not produce a lookup either. + timers.advanceBy(120_000) + drain() + assertTrue(rpc.prCalls.isEmpty()) + assertTrue(off.pr.value.isEmpty()) + handle.close() + } + + fun `test disabling clears pr state and stops the poll`() { + val path = "${project.basePath}/.kilo/worktrees/feature-x" + val key = normalizeWorktreePath(path) + rpc.prResult = WorktreePrListDto(GhAvailability.UNAUTH, listOf(WorktreePrDto(path, 1, GhState.OPEN, "https://pr/1"))) + val handle = service.attach() + drain() + assertEquals(1, service.pr.value[key]?.number) + assertEquals(GhAvailability.UNAUTH, service.gh.value) + val before = rpc.prCalls.size + + github(false) + drain() + assertTrue("badges, tab titles, and PR actions all read this map", service.pr.value.isEmpty()) + assertEquals(GhAvailability.OK, service.gh.value) + + timers.advanceBy(120_000) + drain() + assertEquals(before, rpc.prCalls.size) + handle.close() + } + + fun `test re-enabling reloads pr state and resumes the poll`() { + val path = "${project.basePath}/.kilo/worktrees/feature-x" + val key = normalizeWorktreePath(path) + rpc.prResult = WorktreePrListDto(GhAvailability.OK, listOf(WorktreePrDto(path, 1, GhState.OPEN, "https://pr/1"))) + val handle = service.attach() + drain() + github(false) + drain() + assertTrue(service.pr.value.isEmpty()) + + github(true) + drain() + assertEquals("re-enabling loads at once instead of waiting out the poll", 1, service.pr.value[key]?.number) + + rpc.prResult = WorktreePrListDto(GhAvailability.OK, listOf(WorktreePrDto(path, 2, GhState.OPEN, "https://pr/2"))) + timers.advanceBy(120_000) + drain() + assertEquals(2, service.pr.value[key]?.number) + handle.close() + } + + private fun github(enabled: Boolean) { + edtWait { setGithubIntegration(enabled, "test") } + pump() + } + private fun drain() = coroutines.drain(::pump) private fun pump() = pumpEdt() + + private companion object { + /** Stands in for a real repo path that differs from the project's synthetic client basePath. */ + private const val BACKEND_ROOT = "/real/repo" + } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/app/KiloWorkspaceServiceTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/app/KiloWorkspaceServiceTest.kt index 6b437061033d..ea06d5846a39 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/app/KiloWorkspaceServiceTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/app/KiloWorkspaceServiceTest.kt @@ -1,7 +1,9 @@ package ai.kilocode.client.app import ai.kilocode.client.testing.FakeWorkspaceRpcApi +import ai.kilocode.client.testing.pumpEdt import ai.kilocode.rpc.dto.WorkspaceFileDto +import ai.kilocode.rpc.dto.DiffFileDto import com.intellij.testFramework.fixtures.BasePlatformTestCase import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CancellationException @@ -110,6 +112,52 @@ class KiloWorkspaceServiceTest : BasePlatformTestCase() { assertEquals(0, rpc.globalConfigPathCalls) } + fun `test comparison wrappers preserve contents and patches flags`() = runBlocking { + val base = DiffFileDto("src/Base.kt", 1, 1, before = "base", after = "head") + val local = DiffFileDto("src/Local.kt", 2, 1, before = "head", after = "working") + rpc.branchDiffs.add(base) + rpc.localDiffs.add(local) + + withContext(Dispatchers.Default) { + assertEquals(listOf(base), service.branchDiff("/test")) + assertEquals(listOf(base), service.branchDiff("/test", patches = false)) + assertEquals(listOf(local), service.localDiff("/other")) + assertEquals(listOf(local), service.localDiff("/other", patches = false)) + } + + assertEquals(listOf("/test", "/test"), rpc.branchDiffCalls) + assertEquals(listOf("/other", "/other"), rpc.localDiffCalls) + assertEquals(listOf(true, false), rpc.branchDiffPatchCalls) + assertEquals(listOf(true, false), rpc.localDiffPatchCalls) + } + + fun `test comparison failures and cancellation propagate`() = runBlocking { + for (err in listOf(IllegalStateException("unavailable"), CancellationException("cancelled"))) { + rpc.beforeBranchDiff = { throw err } + rpc.beforeLocalDiff = { throw err } + for (local in listOf(false, true)) { + val failure = withContext(Dispatchers.Default) { + runCatching { + if (local) service.localDiff("/test") else service.branchDiff("/test") + }.exceptionOrNull() + } + assertSame(err, failure) + } + } + } + + fun `test branch lookup preserves detached SHA and cancellation`() = runBlocking { + rpc.branchName = "a1b2c3d" + val name = withContext(Dispatchers.Default) { service.branchName("/test") } + assertEquals("a1b2c3d", name) + + val err = CancellationException("cancelled") + rpc.beforeBranchName = { throw err } + val failure = withContext(Dispatchers.Default) { runCatching { service.branchName("/test") }.exceptionOrNull() } + assertSame(err, failure) + assertEquals(listOf("/test", "/test"), rpc.branchNameCalls) + } + fun `test searchFiles sends query to RPC`() = runBlocking { withContext(Dispatchers.Default) { service.searchFiles("/test", "src") @@ -117,4 +165,27 @@ class KiloWorkspaceServiceTest : BasePlatformTestCase() { assertEquals(listOf("src"), rpc.searchQueries) } + + fun `test ifSetupScriptExists invokes callback when a script is configured`() = runBlocking { + rpc.setupScriptExists = true + rpc.setupScriptPath = "/test/.kilo/setup-script" + var seen: String? = null + + service.ifSetupScriptExists("/test") { seen = it.path }.join() + pumpEdt() + + assertEquals("/test/.kilo/setup-script", seen) + assertEquals(listOf("/test"), rpc.setupScriptTargetCalls.toList()) + } + + fun `test ifSetupScriptExists is silent when no script is configured`() = runBlocking { + rpc.setupScriptExists = false + var called = false + + service.ifSetupScriptExists("/test") { called = true }.join() + pumpEdt() + + assertFalse(called) + assertEquals(listOf("/test"), rpc.setupScriptTargetCalls.toList()) + } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/KiloDiffEditorContentTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/KiloDiffEditorContentTest.kt index de63af8b0331..0b3ec7f23a32 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/KiloDiffEditorContentTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/KiloDiffEditorContentTest.kt @@ -497,6 +497,105 @@ class KiloDiffEditorContentTest : BasePlatformTestCase() { } } + fun `test comparisons validate without sessions and have distinct titles`() { + for (comparison in KiloDiffComparison.entries) { + assertTrue(KiloDiffEditorKind.isValid(mapOf("source" to comparison.source, "directory" to "/repo"))) + assertFalse(KiloDiffEditorKind.isValid(mapOf("source" to comparison.source, "directory" to " "))) + assertFalse(KiloDiffEditorKind.isValid(mapOf("source" to comparison.source))) + assertEquals(comparison.title(null), KiloDiffEditorKind.title(mapOf("source" to comparison.source))) + assertEquals( + comparison.title("feature/topic"), + KiloDiffEditorKind.title(mapOf("source" to comparison.source, "branch" to "feature/topic")), + ) + } + assertFalse(KiloDiffComparison.BASE.title(null) == KiloDiffComparison.LOCAL.title(null)) + assertFalse(KiloDiffEditorKind.isValid(mapOf("source" to "session", "directory" to "/repo"))) + } + + fun `test comparison panes match source and authoritative contents survive refresh`() { + for (source in listOf("branch", "local")) { + val labels = diffLabels(source) + assertEquals( + if (source == "branch") listOf("Base", "HEAD") else listOf("HEAD", "Working tree"), + labels.toList(), + ) + + val first = file("src/App.kt", 1, 1).copy(before = "original revision\n", after = "compared revision\n") + val shown = diffRequest(project, first, labels = labels) as SimpleDiffRequest + assertEquals(labels.toList(), shown.contentTitles) + assertEquals(listOf(first.before, first.after), shown.contents.map(::content)) + + // A pure rename (no textual hunk) must still render its authoritative revision contents. + val renamed = file("src/Renamed.kt", 0, 0, patch = null, status = "renamed").copy(before = "unchanged\n", after = "unchanged\n") + val refreshed = diffRequest(project, renamed, labels = labels) as SimpleDiffRequest + assertEquals(labels.toList(), refreshed.contentTitles) + assertEquals(listOf(renamed.before, renamed.after), refreshed.contents.map(::content)) + } + } + + fun `test refreshing to a zero line rename keeps it visible and selectable in the tree`() { + val parent = Disposer.newDisposable(testRootDisposable) + try { + val editor = editor(listOf(file("src/App.kt", 1, 1)), parent, source = "branch") + val tree = components(editor.component).filterIsInstance().single() + assertEquals("App.kt", text(tree, leaf(tree))) + + val renamed = file("src/Renamed.kt", 0, 0, patch = null, status = "renamed") + editor.applyFiles(listOf(renamed)) + + val refreshedTree = components(editor.component).filterIsInstance().single() + assertEquals("Renamed.kt", text(refreshedTree, leaf(refreshedTree))) + assertSame(leaf(refreshedTree), refreshedTree.lastSelectedPathComponent) + } finally { + Disposer.dispose(parent) + } + } + + fun `test git comparison refresh leaves unsaved editor buffers untouched`() { + val psi = myFixture.addFileToProject("src/App.kt", "saved\n") + val manager = FileDocumentManager.getInstance() + val doc = manager.getDocument(psi.virtualFile)!! + ApplicationManager.getApplication().runWriteAction { doc.setText("unsaved\n") } + for (source in listOf("branch", "local")) { + val parent = Disposer.newDisposable(testRootDisposable) + try { + val editor = editor(files(), parent, dir = psi.virtualFile.parent.parent.path, source = source) + editor.refresh() + assertTrue(manager.isDocumentUnsaved(doc)) + assertEquals("unsaved\n", doc.text) + } finally { + Disposer.dispose(parent) + } + } + } + + fun `test session refresh still saves unsaved editor buffers`() { + val psi = myFixture.addFileToProject("src/App.kt", "saved\n") + val manager = FileDocumentManager.getInstance() + val doc = manager.getDocument(psi.virtualFile)!! + ApplicationManager.getApplication().runWriteAction { doc.setText("unsaved\n") } + val parent = Disposer.newDisposable(testRootDisposable) + try { + val editor = editor(files(), parent, dir = psi.virtualFile.parent.parent.path, source = "session") + editor.refresh() + assertFalse(manager.isDocumentUnsaved(doc)) + } finally { + Disposer.dispose(parent) + } + } + + fun `test disposing comparison views cancels pending refreshes`() { + for (source in listOf("branch", "local")) { + val parent = Disposer.newDisposable(testRootDisposable) + val job = Job() + val editor = editor(files(), parent, source = source) { job } + editor.refresh() + assertTrue(job.isActive) + Disposer.dispose(parent) + assertTrue(job.isCancelled) + } + } + fun `test reverse sync skips active requested path`() { assertNull(reverseSyncTarget("src/App.kt", "src/App.kt", "test/AppTest.kt")) } @@ -551,13 +650,14 @@ class KiloDiffEditorContentTest : BasePlatformTestCase() { files: List, parent: Disposable, dir: String = project.basePath.orEmpty(), + source: String = "branch", 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"), + mapOf("directory" to dir, "source" to source), files, parent, "feature/test", diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/KiloInlineDiffStoreTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/KiloInlineDiffStoreTest.kt index dae1b36342a8..92acc2daf841 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/KiloInlineDiffStoreTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/KiloInlineDiffStoreTest.kt @@ -1,11 +1,28 @@ 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.telemetry.KiloTelemetryService +import ai.kilocode.client.testing.FakeAppRpcApi +import ai.kilocode.client.testing.FakeSessionRpcApi import ai.kilocode.client.testing.FakeWorkspaceRpcApi import ai.kilocode.client.testing.TestCoroutines +import ai.kilocode.client.vfs.KiloEditorKindRegistry +import ai.kilocode.client.vfs.KiloVirtualFile +import ai.kilocode.client.vfs.KiloVirtualFileSystem +import ai.kilocode.rpc.KiloSessionRpcApi import ai.kilocode.rpc.dto.DiffFileDto +import ai.kilocode.rpc.dto.KiloAppStateDto +import ai.kilocode.rpc.dto.KiloAppStatusDto import com.intellij.openapi.components.service import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.fileEditor.FileEditorManager +import com.intellij.openapi.util.Disposer +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.cancel +import java.util.concurrent.CopyOnWriteArrayList import com.intellij.testFramework.fixtures.BasePlatformTestCase import com.intellij.testFramework.replaceService import com.intellij.util.ui.UIUtil @@ -15,21 +32,40 @@ import kotlinx.coroutines.withContext class KiloInlineDiffStoreTest : BasePlatformTestCase() { private lateinit var coroutines: TestCoroutines private lateinit var workspace: FakeWorkspaceRpcApi + private lateinit var session: FakeSessionRpcApi private lateinit var service: KiloDiffEditorService + private val sides = CopyOnWriteArrayList>() override fun setUp() { super.setUp() coroutines = TestCoroutines() workspace = FakeWorkspaceRpcApi() + session = FakeSessionRpcApi() service = KiloDiffEditorService(project, coroutines.scope) + val rpc = object : KiloSessionRpcApi by session { + override suspend fun diffSides(sessionId: String?, directory: String, file: DiffFileDto, messageId: String?): DiffFileDto? { + sides.add(Triple(sessionId, directory, messageId)) + return session.diffSides(sessionId, directory, file, messageId) + } + } + val app = FakeAppRpcApi().apply { state.value = KiloAppStateDto(KiloAppStatusDto.READY) } project.replaceService(KiloInlineDiffStore::class.java, KiloInlineDiffStore(), testRootDisposable) + project.replaceService(KiloDiffEditorService::class.java, service, testRootDisposable) + project.replaceService(KiloSessionService::class.java, KiloSessionService(project, coroutines.scope, rpc), testRootDisposable) ApplicationManager.getApplication() .replaceService(KiloWorkspaceService::class.java, KiloWorkspaceService(coroutines.scope, workspace), testRootDisposable) + ApplicationManager.getApplication() + .replaceService(KiloAppService::class.java, KiloAppService(coroutines.scope, app), testRootDisposable) + ApplicationManager.getApplication() + .replaceService(KiloTelemetryService::class.java, KiloTelemetryService(coroutines.scope, app), testRootDisposable) } override fun tearDown() { try { + FileEditorManager.getInstance(project).let { manager -> manager.openFiles.forEach { manager.closeFile(it) } } coroutines.close { UIUtil.dispatchAllInvocationEvents() } + ApplicationManager.getApplication().getService(KiloEditorKindRegistry::class.java).unregister(KiloDiffEditorKind.ID) + KiloVirtualFileSystem.getInstance().clear() } finally { super.tearDown() } @@ -68,5 +104,245 @@ class KiloInlineDiffStoreTest : BasePlatformTestCase() { assertEquals(stale, store.get("branch:/test")) } + fun `test comparisons route independently and bypass drifted enrichment`() = runBlocking { + val patch = "@@ -1 +1 @@\n-before\n+after" + val base = DiffFileDto("src/Committed.kt", 1, 1, patch, before = "base\n", after = "committed\n") + val local = DiffFileDto("src/Local.kt", 1, 1, patch, before = "head\n", after = "working\n") + val rename = DiffFileDto("src/Renamed.kt", 0, 0, before = "same\n", after = "same\n") + workspace.branchDiffs.addAll(listOf(base, rename)) + workspace.localDiffs.addAll(listOf(local, rename)) + session.diffSides[base.file] = base.copy(after = "drifted local edit\n") + session.diffSides[local.file] = local.copy(after = "drifted after request\n") + session.diffSides[rename.file] = rename.copy(after = "deleted locally\n") + + for (comparison in KiloDiffComparison.entries) { + val params = diffParams(comparison.source, "/test", null, "Changes", "feature/topic") + val data = withContext(coroutines.dispatcher) { service.fetch(params) } as DiffEditorData.Files + assertEquals(listOf(if (comparison == KiloDiffComparison.BASE) base else local, rename), data.files) + assertEquals("feature/topic", data.branch) + } + + assertEquals(listOf("/test"), workspace.branchDiffCalls) + assertEquals(listOf("/test"), workspace.localDiffCalls) + assertEquals(listOf(true), workspace.branchDiffPatchCalls) + assertEquals(listOf(true), workspace.localDiffPatchCalls) + assertTrue(sides.isEmpty()) + assertTrue(workspace.branchNameCalls.isEmpty()) + } + + fun `test patch-only comparisons do not request snapshot sides`() = runBlocking { + val file = DiffFileDto("src/A.kt", 1, 1, "@@ -1 +1 @@\n-base\n+head") + workspace.branchDiffs.add(file) + workspace.localDiffs.add(file) + session.diffSides[file.file] = file.copy(before = "wrong base", after = "local drift") + for (comparison in KiloDiffComparison.entries) { + val data = withContext(coroutines.dispatcher) { + service.fetch(diffParams(comparison.source, "/test", null, "Changes")) + } as DiffEditorData.Files + assertEquals(listOf(file), data.files) + } + assertTrue(sides.isEmpty()) + } + + fun `test session turn tool and inline still enrich while revert stays scoped`() = runBlocking { + val file = DiffFileDto("src/A.kt", 1, 1, "@@ -1 +1 @@\n-old\n+new") + val full = file.copy(before = "whole before", after = "whole after") + session.diffs["ses_1"] = mutableListOf(file) + session.diffSides[file.file] = full + val store = project.service() + for (token in listOf(null, "turn:ses_1:msg1", "tool:ses_1:msg2", "inline")) { + if (token != null) store.put(token, listOf(file)) + val params = diffParams(if (token == null) "session" else "inline", "/test", "ses_1", "Session", token = token) + val data = withContext(coroutines.dispatcher) { service.fetch(params) } as DiffEditorData.Files + assertEquals(listOf(full), data.files) + } + assertEquals(listOf(null, "msg1", "msg2", null), sides.map { it.third }) + assertTrue(sides.all { it.first == "ses_1" && it.second == "/test" }) + + store.put("revert:ses_1:msg1", listOf(file)) + val data = withContext(coroutines.dispatcher) { + service.fetch(diffParams("inline", "/test", "ses_1", "Revert", token = "revert:ses_1:msg1")) + } as DiffEditorData.Files + assertEquals(listOf(file), data.files) + assertEquals(4, sides.size) + assertTrue(workspace.branchDiffCalls.isEmpty()) + assertTrue(workspace.localDiffCalls.isEmpty()) + } + + fun `test known and looked-up branch reuse normalized comparison tabs`() { + workspace.branchName = "feature/topic" + for (comparison in KiloDiffComparison.entries) { + openKiloDiff(project, "/repo/work/../wt//", comparison, "feature/topic") + val file = opened().single { it.path.params["source"] == comparison.source } + val params = diffParams(comparison.source, "/repo/wt", null, comparison.title("feature/topic"), "feature/topic") + assertEquals(params, file.path.params) + assertEquals(comparison.title("feature/topic"), file.name) + assertTrue(workspace.branchNameCalls.isEmpty()) + + openKiloDiff(project, "/repo/./wt/", comparison) + coroutines.drain() + + assertEquals(listOf("/repo/wt"), workspace.branchNameCalls) + assertSame(file, opened().single { it.path.params["source"] == comparison.source }) + assertEquals(params, file.path.params) + workspace.branchNameCalls.clear() + } + assertEquals(2, opened().size) + } + + fun `test detached lookup and known SHA share Windows path identity`() { + workspace.branchName = "a1b2c3d" + openKiloDiff(project, "C:\\repo\\temp\\..\\wt\\", KiloDiffComparison.LOCAL) + coroutines.drain() + val file = opened().single() + assertEquals("C:/repo/wt", file.path.params["directory"]) + assertEquals("a1b2c3d", file.path.params["branch"]) + assertEquals(KiloBundle.message("diff.editor.local.title.named", "a1b2c3d"), file.name) + assertEquals(listOf("C:/repo/wt"), workspace.branchNameCalls) + + openKiloDiff(project, "C:/repo/wt/", KiloDiffComparison.LOCAL, "a1b2c3d") + assertSame(file, opened().single()) + } + + fun `test unavailable branch uses stable generic comparison title`() { + for (comparison in KiloDiffComparison.entries) { + openKiloDiff(project, "/repo", comparison) + coroutines.drain() + val file = opened().single { it.path.params["source"] == comparison.source } + assertNull(file.path.params["branch"]) + assertEquals(comparison.title(null), file.name) + openKiloDiff(project, "/repo/", comparison, " ") + coroutines.drain() + assertSame(file, opened().single { it.path.params["source"] == comparison.source }) + } + } + + fun `test closing and reopening local comparison fetches again without seeds`() { + val manager = FileEditorManager.getInstance(project) + val store = project.service() + val stale = listOf(file("src/Stale.kt", 3, 1)) + store.put("local:/test", stale) + openKiloDiff(project, "/test", KiloDiffComparison.LOCAL, "main") + val first = opened().single() + assertNotNull(manager.getSelectedEditor(first)?.component) + coroutines.drain() + assertEquals(listOf("/test"), workspace.localDiffCalls) + manager.closeFile(first) + + openKiloDiff(project, "/test/", KiloDiffComparison.LOCAL, "main") + assertNotNull(manager.getSelectedEditor(opened().single())?.component) + coroutines.drain() + assertEquals(first.path.params, opened().single().path.params) + assertEquals(listOf("/test", "/test"), workspace.localDiffCalls) + assertTrue(workspace.branchDiffCalls.isEmpty()) + assertEquals(stale, store.get("local:/test")) + } + + fun `test disposing opener parent cancels lookup and never opens a tab`() { + val gate = CompletableDeferred() + val cancelled = CompletableDeferred() + val parent = Disposer.newDisposable(testRootDisposable) + workspace.beforeBranchName = { + try { + gate.await() + } finally { + cancelled.complete(Unit) + } + } + openKiloDiff(project, "/test", KiloDiffComparison.LOCAL, parent = parent) + coroutines.drain() + assertEquals(listOf("/test"), workspace.branchNameCalls) + + Disposer.dispose(parent) + coroutines.drain() + assertTrue(cancelled.isCompleted) + gate.complete(Unit) + coroutines.drain() + assertTrue(opened().isEmpty()) + openKiloDiff(project, "/test", KiloDiffComparison.BASE, "main", parent) + assertTrue(opened().isEmpty()) + } + + fun `test cancelled service scope prevents delayed and synchronous opening`() { + val gate = CompletableDeferred() + workspace.beforeBranchName = { gate.await() } + openKiloDiff(project, "/test", KiloDiffComparison.BASE) + coroutines.drain() + coroutines.scope.cancel() + gate.complete(Unit) + UIUtil.dispatchAllInvocationEvents() + openKiloDiff(project, "/test", KiloDiffComparison.LOCAL, "main") + assertTrue(opened().isEmpty()) + } + + fun `test refresh keeps each comparison and loads fresh contents`() { + for (comparison in KiloDiffComparison.entries) { + val files = if (comparison == KiloDiffComparison.BASE) workspace.branchDiffs else workspace.localDiffs + val params = diffParams(comparison.source, "/test", null, comparison.title("main"), "main") + val results = mutableListOf() + val first = file("src/First.kt", 1, 0) + files.add(first) + service.refresh(params, results::add) + coroutines.drain() + assertEquals(DiffEditorData.Files(listOf(first), "main"), results.last()) + + val next = file("src/Next.kt", 2, 1) + files.clear() + files.add(next) + service.refresh(params, results::add) + coroutines.drain() + assertEquals(DiffEditorData.Files(listOf(next), "main"), results.last()) + + files.clear() + service.refresh(params, results::add) + coroutines.drain() + assertEquals(DiffEditorData.Empty, results.last()) + } + assertEquals(listOf(true, true, true), workspace.branchDiffPatchCalls) + assertEquals(listOf(true, true, true), workspace.localDiffPatchCalls) + } + + fun `test local load and refresh cancel without delivering stale contents`() { + val gate = CompletableDeferred() + workspace.beforeLocalDiff = { gate.await() } + workspace.localDiffs.add(file("src/Old.kt", 1, 0)) + val params = diffParams("local", "/test", null, "Local", "main") + val parent = Disposer.newDisposable(testRootDisposable) + val results = mutableListOf() + service.load(params, parent, results::add) + coroutines.drain() + assertEquals(listOf("/test"), workspace.localDiffCalls) + Disposer.dispose(parent) + val job = service.refresh(params, results::add) + coroutines.drain() + job.cancel() + gate.complete(Unit) + coroutines.drain() + assertEquals(listOf(DiffEditorData.Connecting), results) + + workspace.localDiffs.clear() + val next = file("src/New.kt", 2, 0) + workspace.localDiffs.add(next) + service.load(params, Disposer.newDisposable(testRootDisposable), results::add) + coroutines.drain() + assertEquals(DiffEditorData.Files(listOf(next), "main"), results.last()) + } + + fun `test comparison failures surface editor errors without changing sources`() { + workspace.beforeBranchDiff = { error("base unavailable") } + workspace.beforeLocalDiff = { error("local unavailable") } + for (comparison in KiloDiffComparison.entries) { + val results = mutableListOf() + service.refresh(diffParams(comparison.source, "/test", null, "Changes"), results::add) + coroutines.drain() + assertTrue(results.single() is DiffEditorData.Error) + } + assertEquals(listOf("/test"), workspace.branchDiffCalls) + assertEquals(listOf("/test"), workspace.localDiffCalls) + assertTrue(sides.isEmpty()) + } + + private fun opened() = FileEditorManager.getInstance(project).openFiles.filterIsInstance() + private fun file(path: String, additions: Int, deletions: Int) = DiffFileDto(path, additions, deletions) } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt index f3589667685f..7768726d3528 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt @@ -1,6 +1,22 @@ package ai.kilocode.client.session import ai.kilocode.client.agentManager.worktree.KiloWorktreeService +import ai.kilocode.client.app.KiloAppService +import ai.kilocode.client.app.KiloSessionService +import ai.kilocode.client.app.KiloWorkspaceService +import ai.kilocode.client.diff.KiloDiffComparison +import ai.kilocode.client.diff.KiloDiffEditorService +import ai.kilocode.client.diff.openKiloDiff +import ai.kilocode.client.plugin.KiloPluginSettings +import ai.kilocode.client.ui.ChangesPanel +import ai.kilocode.client.telemetry.KiloTelemetryService +import ai.kilocode.client.vfs.KiloVirtualFile +import com.intellij.openapi.fileEditor.FileEditorManager +import com.intellij.openapi.util.Disposer +import com.intellij.ui.components.JBLabel +import java.awt.Component +import java.awt.Container +import java.awt.event.MouseEvent import ai.kilocode.client.session.SessionRef import ai.kilocode.client.session.model.Permission import ai.kilocode.client.session.model.PermissionMeta @@ -31,10 +47,12 @@ import ai.kilocode.rpc.dto.BranchStatusDto import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.ConfigDto import ai.kilocode.rpc.dto.GhAvailability +import ai.kilocode.rpc.dto.GhState 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.WorktreePrDto import ai.kilocode.rpc.dto.DiffFileDto import com.intellij.util.ui.JBUI import ai.kilocode.client.session.views.permission.PermissionView @@ -52,6 +70,17 @@ import kotlinx.coroutines.CompletableDeferred @Suppress("UnstableApiUsage") class SessionUiLayoutTest : SessionUiTestBase() { + /** The worktree RPC installed by [dock], so branch/PR lookups can be asserted. */ + private lateinit var worktree: FakeWorktreeRpcApi + + override fun tearDown() { + try { + KiloPluginSettings.unsetGithub() + } finally { + super.tearDown() + } + } + fun `test root contains content overlay and blocker layers`() { val root = find(ui) @@ -236,7 +265,7 @@ class SessionUiLayoutTest : SessionUiTestBase() { } ApplicationManager.getApplication() .replaceService(KiloWorktreeService::class.java, KiloWorktreeService(scope, worktree), testRootDisposable) - workspaceRpc.branchDiffs.add(DiffFileDto("src/A.kt", 2, 1)) + workspaceRpc.localDiffs.add(DiffFileDto("src/A.kt", 2, 1)) // A brand-new sidebar session has no id until its first prompt, but the local changes alone // are worth moving: the worktree gets the changes and starts its own session. ui = newUi(manager = owner) @@ -267,13 +296,13 @@ class SessionUiLayoutTest : SessionUiTestBase() { fun `test dock branch changes refresh on finish and revert`() { workspaceRpc.branchDiffs.clear() workspaceRpc.branchDiffs.add(DiffFileDto("src/A.kt", 2, 1)) - val badge = find(ui) + val badge = find(ui) controller().model.setState(SessionState.Busy("running")) controller().model.setState(SessionState.Idle) settle() - assertEquals(2 to 1, badge.stats()) + assertEquals(2 to 1, stats(badge)) workspaceRpc.branchDiffs.clear() workspaceRpc.branchDiffs.add(DiffFileDto("src/B.kt", 4, 3)) @@ -281,7 +310,7 @@ class SessionUiLayoutTest : SessionUiTestBase() { controller().model.setState(SessionState.Idle) settle() - assertEquals(4 to 3, badge.stats()) + assertEquals(4 to 3, stats(badge)) workspaceRpc.branchDiffs.clear() workspaceRpc.branchDiffs.add(DiffFileDto("src/C.kt", 1, 0)) @@ -289,7 +318,7 @@ class SessionUiLayoutTest : SessionUiTestBase() { controller().model.setState(SessionState.TurnEnded(Outcome.INCOMPLETE, "unknown")) settle() - assertEquals(1 to 0, badge.stats()) + assertEquals(1 to 0, stats(badge)) workspaceRpc.branchDiffs.clear() workspaceRpc.branchDiffs.add(DiffFileDto("src/D.kt", 5, 2)) @@ -297,14 +326,220 @@ class SessionUiLayoutTest : SessionUiTestBase() { controller().model.setState(SessionState.Error("failed")) settle() - assertEquals(5 to 2, badge.stats()) + assertEquals(5 to 2, stats(badge)) workspaceRpc.branchDiffs.clear() workspaceRpc.branchDiffs.add(DiffFileDto("src/E.kt", 1, 0)) controller().model.setRevert(SessionRevertDto("msg1", "part1", diff = "patch")) settle() - assertEquals(1 to 0, badge.stats()) + assertEquals(1 to 0, stats(badge)) + } + + fun `test dock fetches stats only and does not display local changes`() { + workspaceRpc.localDiffs.add(DiffFileDto("src/Local.kt", 9, 7)) + val dock = dock() + val panels = components(dock).filterIsInstance() + + assertTrue(dock.isVisible) + assertTrue(dock.moveEnabled()) + assertTrue(dock.newWorktreeEnabled()) + assertEquals(1, dock.changeCount()) + assertTrue(panels.none { it.isVisible }) + assertTrue(workspaceRpc.branchDiffPatchCalls.all { !it }) + assertEquals(listOf(false), workspaceRpc.localDiffPatchCalls) + + workspaceRpc.branchDiffs.addAll(listOf(DiffFileDto("src/Base.kt", 2, 1), DiffFileDto("src/Rename.kt", 0, 0))) + controller().model.setState(SessionState.Busy("running")) + controller().model.setState(SessionState.Idle) + settle() + + assertTrue(panels.all { it.isVisible }) + assertTrue(panels.all { stats(it) == (2 to 1) }) + assertEquals(1, dock.changeCount()) + assertTrue(workspaceRpc.branchDiffPatchCalls.all { !it }) + assertEquals(listOf(false, false), workspaceRpc.localDiffPatchCalls) + } + + fun `test dock resolves the pull request while the github integration is on`() { + workspaceRpc.localDiffs.add(DiffFileDto("src/Local.kt", 9, 7)) + + val dock = dock(WorktreePrDto("/test", 7, GhState.OPEN, "https://pr/7")) + + assertEquals(listOf("/test" to true), worktree.branchCalls) + assertTrue(dock.isVisible) + // Read by the PR badge and by the pull-request context-menu actions. + assertEquals(7, ui.pr?.number) + } + + fun `test dock resolves the branch without gh while the github integration is off`() { + KiloPluginSettings.setGithub(false) + workspaceRpc.localDiffs.add(DiffFileDto("src/Local.kt", 9, 7)) + + val dock = dock(WorktreePrDto("/test", 7, GhState.OPEN, "https://pr/7")) + + assertEquals("the backend must be told not to spawn gh", listOf("/test" to false), worktree.branchCalls) + // Git-backed dock content and worktree actions are unaffected by the GitHub setting. + assertTrue(dock.isVisible) + assertTrue(dock.newWorktreeEnabled()) + assertEquals(1, dock.changeCount()) + // Nothing PR-shaped survives. + assertNull(ui.pr) + } + + fun `test committed-only summary does not enable empty-session worktree actions`() { + workspaceRpc.branchDiffs.add(DiffFileDto("src/Committed.kt", 2, 1)) + val dock = dock() + + assertTrue(dock.isVisible) + assertTrue(find(dock).isVisible) + assertEquals(2 to 1, stats(find(dock))) + assertEquals(0, dock.changeCount()) + assertFalse(dock.moveEnabled()) + assertFalse(dock.newWorktreeEnabled()) + } + + fun `test local actions update independently while committed summary is loading`() { + val gate = CompletableDeferred() + workspaceRpc.beforeBranchDiff = { gate.await() } + workspaceRpc.branchDiffs.add(DiffFileDto("src/Committed.kt", 2, 1)) + workspaceRpc.localDiffs.add(DiffFileDto("src/Local.kt", 9, 7)) + val dock = dock() + val panel = find(dock) + + assertFalse(panel.isVisible) + assertTrue(dock.moveEnabled()) + assertEquals(1, dock.changeCount()) + assertTrue(workspaceRpc.branchDiffCalls.isNotEmpty()) + assertEquals(listOf("/test"), workspaceRpc.localDiffCalls) + + gate.complete(Unit) + settle() + + assertEquals(2 to 1, stats(panel)) + assertEquals(1, dock.changeCount()) + assertTrue(dock.moveEnabled()) + } + + fun `test committed summary updates independently while local actions are loading`() { + val gate = CompletableDeferred() + workspaceRpc.beforeLocalDiff = { gate.await() } + workspaceRpc.branchDiffs.add(DiffFileDto("src/Committed.kt", 2, 1)) + workspaceRpc.localDiffs.add(DiffFileDto("src/Local.kt", 9, 7)) + val dock = dock() + val panel = find(dock) + + assertTrue(panel.isVisible) + assertEquals(2 to 1, stats(panel)) + assertFalse(dock.moveEnabled()) + assertEquals(0, dock.changeCount()) + + gate.complete(Unit) + settle() + + assertEquals(2 to 1, stats(panel)) + assertEquals(1, dock.changeCount()) + assertTrue(dock.moveEnabled()) + } + + fun `test refresh failures clear only their comparison state`() { + workspaceRpc.branchDiffs.add(DiffFileDto("src/Committed.kt", 2, 1)) + workspaceRpc.localDiffs.add(DiffFileDto("src/Local.kt", 9, 7)) + val dock = dock() + val panel = find(dock) + workspaceRpc.beforeBranchDiff = { error("branch unavailable") } + controller().model.setState(SessionState.Busy("running")) + controller().model.setState(SessionState.Idle) + settle() + + assertFalse(panel.isVisible) + assertTrue(dock.moveEnabled()) + assertEquals(1, dock.changeCount()) + + workspaceRpc.beforeBranchDiff = null + workspaceRpc.beforeLocalDiff = { error("local unavailable") } + controller().model.setRevert(SessionRevertDto("msg1")) + settle() + + assertTrue(panel.isVisible) + assertEquals(2 to 1, stats(panel)) + assertEquals(0, dock.changeCount()) + assertFalse(dock.moveEnabled()) + assertFalse(dock.newWorktreeEnabled()) + } + + fun `test unavailable worktree actions avoid local comparison requests`() { + settle() + assertTrue(workspaceRpc.localDiffCalls.isEmpty()) + controller().model.setState(SessionState.Busy("running")) + controller().model.setState(SessionState.Idle) + controller().model.setRevert(SessionRevertDto("msg1")) + settle() + + assertTrue(workspaceRpc.localDiffCalls.isEmpty()) + assertTrue(workspaceRpc.branchDiffPatchCalls.all { !it }) + } + + fun `test disposed session cancels independent comparison requests`() { + val gate = CompletableDeferred() + val base = CompletableDeferred() + val local = CompletableDeferred() + workspaceRpc.beforeBranchDiff = { + try { + gate.await() + } finally { + base.complete(Unit) + } + } + workspaceRpc.beforeLocalDiff = { + try { + gate.await() + } finally { + local.complete(Unit) + } + } + val dock = dock() + assertTrue(workspaceRpc.branchDiffCalls.isNotEmpty()) + assertTrue(workspaceRpc.localDiffCalls.isNotEmpty()) + Disposer.dispose(ui) + settle() + assertTrue(base.isCompleted) + assertTrue(local.isCompleted) + gate.complete(Unit) + settle() + assertEquals(0, dock.changeCount()) + assertFalse(find(dock).isVisible) + } + + fun `test session summary uses common normalized base editor identity`() { + workspaceRpc.branchName = "feature/topic" + workspaceRpc.branchDiffs.add(DiffFileDto("src/Committed.kt", 2, 1)) + val dock = dock() + ApplicationManager.getApplication().replaceService(KiloWorkspaceService::class.java, workspaces, testRootDisposable) + ApplicationManager.getApplication().replaceService(KiloAppService::class.java, app, testRootDisposable) + ApplicationManager.getApplication() + .replaceService(KiloTelemetryService::class.java, KiloTelemetryService(scope, appRpc), testRootDisposable) + project.replaceService(KiloSessionService::class.java, sessions, testRootDisposable) + project.replaceService(KiloDiffEditorService::class.java, KiloDiffEditorService(project, scope), testRootDisposable) + val manager = FileEditorManager.getInstance(project) + try { + val panel = components(dock).filterIsInstance().last() + val event = MouseEvent(panel, MouseEvent.MOUSE_CLICKED, 0, 0, 1, 1, 1, false, MouseEvent.BUTTON1) + panel.mouseListeners.forEach { it.mouseClicked(event) } + settle() + val file = manager.openFiles.filterIsInstance().single() + assertEquals("branch", file.path.params["source"]) + assertEquals("/test", file.path.params["directory"]) + assertEquals("feature/topic", file.path.params["branch"]) + assertEquals(KiloDiffComparison.BASE.title("feature/topic"), file.name) + assertFalse(file.path.params.containsKey("sessionId")) + assertFalse(file.path.params.containsKey("token")) + + openKiloDiff(project, "/test/./", KiloDiffComparison.BASE, "feature/topic") + assertSame(file, manager.openFiles.filterIsInstance().single()) + } finally { + manager.openFiles.forEach { manager.closeFile(it) } + } } fun `test prompt file drag leave does not immediately hide drop overlay`() { @@ -991,6 +1226,38 @@ class SessionUiLayoutTest : SessionUiTestBase() { assertEquals(root.overlay.width - overlay.width - right, overlay.x) } + // Reassigns [ui] without disposing the previous instance first, matching every other `ui = + // newUi(...)` swap in this file: SessionController.dispose() cancels the shared `scope`, which + // would kill every later coroutine (including the replacement UI's own branch/local refresh) + // launched on that same scope for the rest of the test. + private fun dock(pr: WorktreePrDto? = null): BranchDock { + val fake = FakeWorktreeRpcApi().apply { + branchResult = BranchStatusDto(branch = "feature/topic", availability = GhAvailability.OK, pr = pr) + } + worktree = fake + ApplicationManager.getApplication() + .replaceService(KiloWorktreeService::class.java, KiloWorktreeService(scope, fake), testRootDisposable) + ui = newUi(manager = object : SessionManager { + override fun newSession() {} + override fun showHistory(back: (() -> Unit)?) {} + override fun openSession(ref: SessionRef) {} + override val supportsMoveToWorktree: Boolean get() = true + override val supportsNewWorktree: Boolean get() = true + }) + settle() + return find(ui) + } + + private fun stats(panel: ChangesPanel): Pair { + val labels = components(panel).filterIsInstance().filter { it.isVisible }.map { it.text.orEmpty() } + val added = labels.firstOrNull { it.startsWith("+") }?.drop(1)?.toIntOrNull() ?: 0 + val removed = labels.firstOrNull { it.startsWith("-") }?.drop(1)?.toIntOrNull() ?: 0 + return added to removed + } + + private fun components(root: Component): List = + listOf(root) + (root as? Container)?.components?.flatMap(::components).orEmpty() + private fun dropCard(drop: SessionDropOverlay) = drop.components .single() .let { it as javax.swing.JComponent } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/ConfigSelectionTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/ConfigSelectionTest.kt index 1a986bc6c9a7..652665aa2bcf 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/ConfigSelectionTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/ConfigSelectionTest.kt @@ -1,5 +1,6 @@ package ai.kilocode.client.session.controller +import ai.kilocode.client.plugin.KiloPluginSettings import ai.kilocode.rpc.dto.AgentDto import ai.kilocode.rpc.dto.AgentConfigDto import ai.kilocode.rpc.dto.ConfigDto @@ -21,7 +22,6 @@ class ConfigSelectionTest : SessionControllerTestBase() { edt { m.selectModel("kilo", "gpt-5") } flush() - assertTrue(rpc.configs.isEmpty()) assertEquals("code", appRpc.selections.single().agent) assertEquals("kilo", appRpc.selections.single().providerID) assertEquals("gpt-5", appRpc.selections.single().modelID) @@ -34,7 +34,11 @@ class ConfigSelectionTest : SessionControllerTestBase() { ) } - fun `test selectAgent updates SessionModel and calls updateConfig`() { + /** + * A mode switch must stay client-side. Writing it to the CLI's global config made the CLI dispose + * every instance it held, cancelling every running turn in every worktree. + */ + fun `test selectAgent stays local and never patches CLI config`() { val m = controller() collect(m) flush() @@ -42,8 +46,7 @@ class ConfigSelectionTest : SessionControllerTestBase() { edt { m.selectAgent("plan") } flush() - assertEquals(1, rpc.configs.size) - assertEquals("plan", rpc.configs[0].second.agent) + assertEquals("plan", KiloPluginSettings.getAgent()) assertSession( """ [plan] [app: DISCONNECTED] [workspace: PENDING] @@ -53,6 +56,32 @@ class ConfigSelectionTest : SessionControllerTestBase() { ) } + fun `test remembered mode seeds a new session ahead of the CLI default`() { + edt { KiloPluginSettings.setAgent("plan") } + projectRpc.state.value = workspaceReady( + agents = listOf( + AgentDto(name = "code", displayName = "Code", mode = "code"), + AgentDto(name = "plan", displayName = "Plan", mode = "code"), + ), + default = "code", + ) + val m = controller() + collect(m) + flush() + + assertEquals("plan", m.model.agent) + } + + fun `test CLI default wins when the remembered mode no longer exists`() { + edt { KiloPluginSettings.setAgent("removed-mode") } + projectRpc.state.value = workspaceReady(default = "code") + val m = controller() + collect(m) + flush() + + assertEquals("code", m.model.agent) + } + fun `test selectModel fires WorkspaceReady event`() { projectRpc.state.value = workspaceReady() val m = controller() diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PromptLifecycleTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PromptLifecycleTest.kt index 768c9e36b397..0e98ab904a72 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PromptLifecycleTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PromptLifecycleTest.kt @@ -502,7 +502,7 @@ class PromptLifecycleTest : SessionControllerTestBase() { flush() assertEquals("plan", m.model.agent) - assertTrue(rpc.configs.none { it.second.agent == "code" }) + assertNull(KiloPluginSettings.getAgent()) assertQuestionReply("q_plan /test [[Continue here]]", rpc.questionReplies) emit(ChatEventDto.MessageUpdated("ses_test", msg("msg_code", "ses_test", "user").copy( @@ -514,7 +514,7 @@ class PromptLifecycleTest : SessionControllerTestBase() { assertEquals("code", m.model.agent) assertEquals("anthropic/claude", m.model.model) assertFalse(m.model.modelOverride) - assertTrue(rpc.configs.none { it.second.agent == "code" }) + assertNull(KiloPluginSettings.getAgent()) assertControllerEvents("WorkspaceReady", events) } @@ -533,7 +533,7 @@ class PromptLifecycleTest : SessionControllerTestBase() { flush() assertEquals("plan", m.model.agent) - assertTrue(rpc.configs.none { it.second.agent == "code" }) + assertNull(KiloPluginSettings.getAgent()) assertQuestionReply("q_plan /test [[Need to adjust scope]]", rpc.questionReplies) } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionCancellationTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionCancellationTest.kt new file mode 100644 index 000000000000..9f6a90c44e96 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionCancellationTest.kt @@ -0,0 +1,188 @@ +package ai.kilocode.client.session.controller + +import ai.kilocode.client.plugin.KiloBundle +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.MessageErrorDto +import ai.kilocode.rpc.dto.MessageWithPartsDto + +/** + * A cancellation nobody asked for has to explain itself. + * + * The CLI reports a Stop and a server-side cancellation with the same `MessageAbortedError`, and the UI + * used to treat both as a Stop: a muted "Stopped" line, no reason, no Retry, no telemetry. Three + * sessions once died to a config reload leaving no trace anywhere the user could see. Only this client + * knows whether it pressed Stop, so that is what separates the two here. + */ +class SessionCancellationTest : SessionControllerTestBase() { + + private val abort = MessageErrorDto(type = MessageErrorDto.ABORTED, message = "Aborted") + + private fun reloadText() = KiloBundle.message("session.cancelled.reload") + + private fun unknownText() = KiloBundle.message("session.cancelled.unknown") + + private fun cancelled(text: String) = SessionState.Error(text, MessageErrorDto.ABORTED) + + fun `test unrequested abort surfaces an error instead of Stopped`() { + val (m, _, _) = prompted() + + emit(ChatEventDto.TurnOpen("ses_test")) + emit(ChatEventDto.Error("ses_test", abort)) + emit(ChatEventDto.TurnClose("ses_test", "interrupted")) + + assertEquals(cancelled(unknownText()), m.model.state) + } + + fun `test interruption names the reason on the error`() { + val (m, _, _) = prompted() + + emit(ChatEventDto.TurnOpen("ses_test")) + emit(ChatEventDto.SessionInterrupted("ses_test", ChatEventDto.SessionInterrupted.RELOAD)) + emit(ChatEventDto.Error("ses_test", abort)) + emit(ChatEventDto.TurnClose("ses_test", "interrupted")) + + assertEquals(cancelled(reloadText()), m.model.state) + } + + /** + * The CLI publishes the cancellation while it is still disposing, so the event naming the cause can + * land on either side of the abort. Both orders have to end up on the same message. + */ + fun `test interruption arriving after the abort relabels the error`() { + val (m, _, _) = prompted() + + emit(ChatEventDto.TurnOpen("ses_test")) + emit(ChatEventDto.Error("ses_test", abort)) + assertEquals(cancelled(unknownText()), m.model.state) + + emit(ChatEventDto.SessionInterrupted("ses_test", ChatEventDto.SessionInterrupted.RELOAD)) + + assertEquals(cancelled(reloadText()), m.model.state) + } + + /** The transcript cannot reach a worktree the user is not looking at; the balloon can. */ + fun `test unrequested abort raises a balloon naming the reason`() { + prompted() + + emit(ChatEventDto.TurnOpen("ses_test")) + emit(ChatEventDto.SessionInterrupted("ses_test", ChatEventDto.SessionInterrupted.RELOAD)) + emit(ChatEventDto.Error("ses_test", abort)) + + assertEquals(listOf(KiloBundle.message("session.cancelled.title") to reloadText()), notifications) + } + + fun `test unrequested abort is reported to telemetry`() { + prompted() + + emit(ChatEventDto.TurnOpen("ses_test")) + emit(ChatEventDto.Error("ses_test", abort)) + + assertTrue( + appRpc.telemetry.any { + it.event == "Session Error" && it.properties["errorClass"] == MessageErrorDto.ABORTED + }, + ) + } + + fun `test unrequested abort offers Retry`() { + val m = failedTurn() + + emit(ChatEventDto.TurnOpen("ses_test")) + emit(ChatEventDto.Error("ses_test", abort)) + emit(ChatEventDto.TurnClose("ses_test", "interrupted")) + + assertEquals(cancelled(unknownText()), m.model.state) + edt { assertTrue("A turn the user never stopped lost work, so it must be continuable", m.canRetry()) } + } + + fun `test stopped turn offers no Retry`() { + val m = failedTurn() + + emit(ChatEventDto.TurnOpen("ses_test")) + edt { m.abort() } + flush() + emit(ChatEventDto.Error("ses_test", abort)) + emit(ChatEventDto.TurnClose("ses_test", "interrupted")) + + assertTrue(notifications.isEmpty()) + edt { assertFalse(m.canRetry()) } + } + + /** A revert aborts a busy session on purpose, so its abort is a Stop and not a lost turn. */ + fun `test revert abort counts as requested`() { + val (m, _, _) = prompted() + + emit(ChatEventDto.TurnOpen("ses_test")) + edt { m.revert("msg1") } + flush() + emit(ChatEventDto.Error("ses_test", abort)) + + assertTrue(notifications.isEmpty()) + assertFalse(m.model.state is SessionState.Error) + } + + /** The flag describes one cancellation. A later turn must not inherit the earlier Stop. */ + fun `test a new turn stops inheriting the previous Stop`() { + val (m, _, _) = prompted() + + emit(ChatEventDto.TurnOpen("ses_test")) + edt { m.abort() } + flush() + emit(ChatEventDto.Error("ses_test", abort)) + emit(ChatEventDto.TurnClose("ses_test", "interrupted")) + assertTrue(notifications.isEmpty()) + + emit(ChatEventDto.TurnOpen("ses_test")) + emit(ChatEventDto.Error("ses_test", abort)) + + assertEquals(cancelled(unknownText()), m.model.state) + } + + /** A reopened session cannot know who stopped its last turn, so history stays a plain "Stopped". */ + fun `test reopened session keeps an aborted tail as Stopped`() { + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY, config = ConfigDto(model = "kilo/gpt-5")) + projectRpc.state.value = workspaceReady() + rpc.history.add(MessageWithPartsDto(msg("msg_user", "ses_test", "user"), emptyList())) + rpc.history.add( + MessageWithPartsDto( + msg("msg_fail", "ses_test", "assistant").copy(parentID = "msg_user", error = abort), + emptyList(), + ), + ) + val m = controller("ses_test") + collect(m) + flush() + + assertTrue(m.model.state is SessionState.TurnEnded) + assertTrue(notifications.isEmpty()) + } + + /** + * A loaded session whose tail can be continued: Retry needs a user message to re-prompt and an + * assistant message parented to it. + */ + private fun failedTurn(): SessionController { + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY, config = ConfigDto(model = "kilo/gpt-5")) + projectRpc.state.value = workspaceReady() + rpc.history.add( + MessageWithPartsDto( + msg("msg_user", "ses_test", "user").copy(providerID = "kilo", modelID = "gpt-5", agent = "code"), + emptyList(), + ), + ) + rpc.history.add( + MessageWithPartsDto( + msg("msg_fail", "ses_test", "assistant").copy(parentID = "msg_user", error = abort), + emptyList(), + ), + ) + val m = controller("ses_test") + collect(m) + flush() + return m + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionControllerTestBase.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionControllerTestBase.kt index 1424560cf62f..b733c27c7d9d 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionControllerTestBase.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionControllerTestBase.kt @@ -13,6 +13,7 @@ import ai.kilocode.client.testing.TestCoroutines import ai.kilocode.client.testing.TestUiTimers import ai.kilocode.client.app.KiloWorkspaceService import ai.kilocode.client.app.Workspace +import ai.kilocode.client.plugin.KiloPluginSettings import ai.kilocode.client.session.SessionRef import ai.kilocode.log.KiloLog import ai.kilocode.rpc.dto.AgentDto @@ -106,12 +107,19 @@ abstract class SessionControllerTestBase : BasePlatformTestCase() { protected lateinit var scope: CoroutineScope protected lateinit var parent: Disposable + /** Balloons a controller raised, instead of real IDE notifications. */ + protected val notifications = mutableListOf>() + override fun setUp() { super.setUp() rpc = FakeSessionRpcApi() appRpc = FakeAppRpcApi() projectRpc = FakeWorkspaceRpcApi() timers = TestUiTimers() + notifications.clear() + // Application-level and shared across tests in a fixture, and it now seeds a new session's + // mode, so a leftover pick from another test would decide this one's starting agent. + KiloPluginSettings.unsetAgent() coroutines = TestCoroutines() scope = coroutines.scope @@ -127,6 +135,7 @@ abstract class SessionControllerTestBase : BasePlatformTestCase() { try { Disposer.dispose(parent) coroutines.close() + KiloPluginSettings.unsetAgent() } finally { super.tearDown() } @@ -184,6 +193,7 @@ abstract class SessionControllerTestBase : BasePlatformTestCase() { beforeUpdate = beforeUpdate, afterUpdate = afterUpdate, telemetry = { event, props -> appRpc.telemetry.add(TelemetryCaptureDto(event, props)) }, + notify = { title, body -> notifications.add(title to body) }, timers = timers, log = log ?: KiloLog.create(SessionController::class.java), ) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/TurnLifecycleTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/TurnLifecycleTest.kt index fdd0b6062604..f6f7c30532bd 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/TurnLifecycleTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/TurnLifecycleTest.kt @@ -251,10 +251,13 @@ class TurnLifecycleTest : SessionControllerTestBase() { val (m, _, _) = prompted() emit(ChatEventDto.TurnOpen("ses_test")) + edt { m.abort() } + flush() emit(ChatEventDto.Error("ses_test", MessageErrorDto(type = "MessageAbortedError", message = "aborted"))) emit(ChatEventDto.TurnClose("ses_test", "interrupted")) assertTrue(appRpc.telemetry.none { it.event == "Session Error" && it.properties["errorClass"] == "MessageAbortedError" }) + assertTrue(notifications.isEmpty()) assertSession( """ [code] [kilo/gpt-5] [interrupted] diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/header/BranchDockTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/header/BranchDockTest.kt index 517ffc7d1823..e7331d615955 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/header/BranchDockTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/header/BranchDockTest.kt @@ -3,6 +3,7 @@ package ai.kilocode.client.session.ui.header import ai.kilocode.client.actions.ChatMoveToWorktreeAction import ai.kilocode.client.actions.ChatNewWorktreeAction import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.ui.ChangesPanel import ai.kilocode.client.ui.FilledBadgeIcon import ai.kilocode.client.util.edtWait import ai.kilocode.rpc.dto.BranchStatusDto @@ -19,6 +20,7 @@ import com.intellij.testFramework.fixtures.BasePlatformTestCase import com.intellij.ui.SimpleColoredComponent import com.intellij.ui.SimpleTextAttributes import com.intellij.ui.components.JBLabel +import com.intellij.util.concurrency.annotations.RequiresEdt import java.awt.Component import java.awt.Container import java.awt.Point @@ -121,7 +123,7 @@ class BranchDockTest : BasePlatformTestCase() { val dock = dock() edt { dock.setBranch(BranchStatusDto(branch = "feature-x", worktree = false, availability = GhAvailability.OK)) - dock.setChanges(listOf(DiffFileDto("src/A.kt", 2, 1))) + dock.setLocal(listOf(DiffFileDto("src/A.kt", 2, 1))) dock.setBusy(true) } assertFalse(edt { dock.isVisible }) @@ -184,7 +186,7 @@ class BranchDockTest : BasePlatformTestCase() { val dock = dock() edt { dock.setBranch(BranchStatusDto(branch = "feature-x", worktree = false, availability = GhAvailability.OK)) - dock.setChanges(listOf(DiffFileDto("src/A.kt", 2, 1))) + dock.setLocal(listOf(DiffFileDto("src/A.kt", 2, 1))) dock.setHasSession(true) } val p = update(ChatMoveToWorktreeAction(), dock) @@ -196,7 +198,7 @@ class BranchDockTest : BasePlatformTestCase() { val dock = dock() edt { dock.setBranch(BranchStatusDto(branch = "feature-x", worktree = false, availability = GhAvailability.OK)) - dock.setChanges(listOf(DiffFileDto("src/A.kt", 2, 1), DiffFileDto("src/B.kt", 1, 0))) + dock.setLocal(listOf(DiffFileDto("src/A.kt", 2, 1), DiffFileDto("src/B.kt", 1, 0))) } // A new sidebar session has no id until its first prompt; the local changes alone are worth // moving, so the action is offered with changes-only wording. @@ -282,6 +284,56 @@ class BranchDockTest : BasePlatformTestCase() { assertEquals(1, fired) } + fun `test local only changes enable both actions without displaying a summary`() { + val dock = dockWithNewWorktree() + edt { + dock.setBranch(BranchStatusDto(branch = "feature-x", availability = GhAvailability.OK)) + dock.setLocal(listOf(DiffFileDto("rename.kt", 0, 0))) + } + assertTrue(edt { dock.isVisible }) + assertTrue(update(ChatMoveToWorktreeAction(), dock).isEnabledAndVisible) + assertTrue(update(ChatNewWorktreeAction(), dock).isEnabledAndVisible) + assertEquals(1, edt { dock.changeCount() }) + assertTrue(edt { components(dock).filterIsInstance().all { !it.isVisible } }) + edt { dock.setLocal(emptyList()) } + assertFalse(edt { dock.isVisible }) + assertFalse(update(ChatMoveToWorktreeAction(), dock).isVisible) + } + + fun `test base changes do not enable transfer actions or inflate local counts`() { + val dock = dockWithNewWorktree() + edt { + dock.setBranch(BranchStatusDto(branch = "feature-x", availability = GhAvailability.OK)) + dock.setChanges(listOf(DiffFileDto("base.kt", 11, 3), DiffFileDto("binary.dat", 0, 0))) + } + assertTrue(edt { dock.isVisible }) + assertFalse(update(ChatMoveToWorktreeAction(), dock).isVisible) + assertFalse(update(ChatNewWorktreeAction(), dock).isVisible) + assertEquals(0, edt { dock.changeCount() }) + edt { dock.setLocal(listOf(DiffFileDto("local.kt", 99, 21))) } + assertEquals(1, edt { dock.changeCount() }) + assertEquals(KiloBundle.message("session.dock.move.tooltip.changes.one"), update(ChatMoveToWorktreeAction(), dock).description) + edt { dock.setChanges(emptyList()) } + assertEquals(1, edt { dock.changeCount() }) + assertTrue(update(ChatMoveToWorktreeAction(), dock).isVisible) + } + + fun `test PR and standalone paths use compact retained summaries`() { + val dock = dock() + edt { + dock.setChanges(listOf(DiffFileDto("base.kt", 5, 2))) + dock.setLocal(listOf(DiffFileDto("local.kt", 99, 21))) + val nodes = components(dock).filterIsInstance() + assertEquals(2, nodes.size) + repeat(100) { n -> + dock.setBranch(prBranch().copy(pr = if (n % 2 == 0) prBranch().pr else null)) + val shown = nodes.single { generateSequence(it as Component) { item -> item.parent }.all { item -> item.isVisible } } + assertEquals(listOf("1 file", "-2", "+5"), components(shown).filterIsInstance().filter { it.isVisible }.map { it.text }) + assertEquals(nodes, components(dock).filterIsInstance()) + } + } + } + private fun dock(): BranchDock = edt { BranchDock(openDiff = {}, onMove = {}) } private fun dockWithNewWorktree(): BranchDock = edt { BranchDock(openDiff = {}, onMove = {}, onNewWorktree = {}) } @@ -293,6 +345,7 @@ class BranchDockTest : BasePlatformTestCase() { pr = WorktreePrDto("/repo", 7, GhState.OPEN, "https://pr/7", "Title"), ) + @RequiresEdt private fun firstAttrs(title: SimpleColoredComponent): SimpleTextAttributes { val iter = title.iterator() check(iter.hasNext()) { "missing title fragment" } @@ -300,11 +353,13 @@ class BranchDockTest : BasePlatformTestCase() { return iter.textAttributes } + @RequiresEdt private fun layout(root: Component) { root.doLayout() if (root is Container) root.components.forEach(::layout) } + @RequiresEdt private fun components(root: Component): List { val out = mutableListOf() fun visit(item: Component) { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/header/PrHeaderViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/header/PrHeaderViewTest.kt index 8c39ba9bdc7e..ed9f67f61f3b 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/header/PrHeaderViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/header/PrHeaderViewTest.kt @@ -1,22 +1,26 @@ package ai.kilocode.client.session.ui.header import ai.kilocode.client.session.ui.style.SessionEditorStyle +import ai.kilocode.client.ui.ChangesPanel import ai.kilocode.client.ui.FilledBadgeIcon +import ai.kilocode.client.ui.UiStyle import ai.kilocode.client.ui.stateLabel import ai.kilocode.client.ui.style import ai.kilocode.client.util.edtWait import ai.kilocode.rpc.dto.GhState import ai.kilocode.rpc.dto.WorktreePrDto -import ai.kilocode.rpc.dto.WorktreeStatsDto import com.intellij.testFramework.fixtures.BasePlatformTestCase import com.intellij.ui.SimpleColoredComponent import com.intellij.ui.SimpleTextAttributes import com.intellij.ui.components.JBLabel +import com.intellij.util.concurrency.annotations.RequiresEdt import com.intellij.util.ui.UIUtil import java.awt.Component import java.awt.Container import java.awt.Cursor import javax.swing.JButton +import javax.swing.JSeparator +import javax.swing.SwingUtilities class PrHeaderViewTest : BasePlatformTestCase() { fun `test PR renders state badge title and link`() { @@ -50,14 +54,13 @@ class PrHeaderViewTest : BasePlatformTestCase() { assertFalse(edt { title(view).isVisible }) } - fun `test changes aggregate from stats overload`() { + fun `test changes default to compact aggregate presentation`() { val view = edt { PrHeaderView {} } - val changes = edt { UIUtil.findComponentOfType(view, BranchChangesBadge::class.java)!! } + val changes = edt { UIUtil.findComponentOfType(view, ChangesPanel::class.java)!! } - edt { view.update(WorktreeStatsDto("/repo", additions = 7, deletions = 4, files = 3), null, "feature-x") } + edt { view.update(3, 7, 4, null, "feature-x", ahead = 2, localFiles = 1, localAdditions = 8) } - assertEquals("3 files", edt { changes.countText() }) - assertEquals(7 to 4, edt { changes.stats() }) + assertEquals(listOf("3 files", "-4", "+7"), edt { components(changes).filterIsInstance().filter { it.isVisible }.map { it.text } }) assertTrue(edt { changes.isVisible }) } @@ -68,22 +71,58 @@ class PrHeaderViewTest : BasePlatformTestCase() { assertTrue(edt { components(view).contains(button) }) } + fun `test action separator tracks actions and visible changes`() { + val view = edt { PrHeaderView {} } + val separator = edt { components(view).filterIsInstance().single() } + + // Changes alone are not a toolbar: with no actions there is nothing to separate them from. + edt { view.update(2, 1, 0, null, "feature-x") } + assertFalse(edt { separator.isVisible }) + + edt { view.addAction(JButton("Open")) } + assertTrue(edt { separator.isVisible }) + + // A clean worktree leaves the separator with nothing on its left, so it goes away too. + edt { view.update(0, 0, 0, null, "feature-x") } + assertFalse(edt { separator.isVisible }) + } + + fun `test toolbar keeps standard left padding before the changes summary`() { + val view = edt { PrHeaderView {} } + val button = JButton("Open") + edt { + view.addAction(button) + view.update(2, 1, 0, null, "feature-x") + layout(view) + } + val separator = edt { components(view).filterIsInstance().single() } + val changes = edt { UIUtil.findComponentOfType(view, ChangesPanel::class.java)!! } + val row = edt { separator.parent } + val wrapper = edt { row.components.single { SwingUtilities.isDescendingFrom(changes, it) } } + + // The row's left inset pads the summary off the PR title without a leading separator. + assertEquals(UiStyle.Gap.md(), edt { wrapper.x }) + assertTrue(edt { components(view).filterIsInstance().none { it.x < wrapper.x } }) + // Order is padding, changes, separator, then the actions. + assertTrue(edt { wrapper.x + wrapper.width <= separator.x }) + assertTrue(edt { separator.x < SwingUtilities.convertPoint(button, 0, 0, view).x }) + } + fun `test repeated update keeps child instances and bounded count`() { val view = edt { PrHeaderView {} } - val stats = WorktreeStatsDto("/repo", additions = 2, files = 1) val pull = pull(GhState.DRAFT) - edt { view.update(stats, pull, "feature-x") } + edt { view.update(1, 2, 0, pull, "feature-x") } val labels = edt { components(view).filterIsInstance() } val title = edt { title(view) } - val changes = edt { UIUtil.findComponentOfType(view, BranchChangesBadge::class.java)!! } + val changes = edt { UIUtil.findComponentOfType(view, ChangesPanel::class.java)!! } val count = edt { components(view).size } - repeat(20) { edt { view.update(stats, pull, "feature-x") } } + repeat(20) { edt { view.update(1, 2, 0, pull, "feature-x") } } assertEquals(labels, edt { components(view).filterIsInstance() }) assertSame(title, edt { title(view) }) - assertSame(changes, edt { UIUtil.findComponentOfType(view, BranchChangesBadge::class.java) }) + assertSame(changes, edt { UIUtil.findComponentOfType(view, ChangesPanel::class.java) }) assertEquals(count, edt { components(view).size }) } @@ -106,12 +145,15 @@ class PrHeaderViewTest : BasePlatformTestCase() { title = "Implement header", ) + @RequiresEdt private fun badge(view: PrHeaderView): JBLabel = components(view).filterIsInstance().single { it.icon is FilledBadgeIcon } + @RequiresEdt private fun title(view: PrHeaderView): SimpleColoredComponent = components(view).filterIsInstance().single() + @RequiresEdt private fun fragments(title: SimpleColoredComponent): List { val out = mutableListOf() val iter = title.iterator() @@ -122,6 +164,7 @@ class PrHeaderViewTest : BasePlatformTestCase() { return out } + @RequiresEdt private fun firstAttrs(title: SimpleColoredComponent): SimpleTextAttributes { val iter = title.iterator() check(iter.hasNext()) { "missing title fragment" } @@ -129,6 +172,7 @@ class PrHeaderViewTest : BasePlatformTestCase() { return iter.textAttributes } + @RequiresEdt private fun components(root: Component): List { val out = mutableListOf() fun visit(item: Component) { @@ -139,5 +183,11 @@ class PrHeaderViewTest : BasePlatformTestCase() { return out } + @RequiresEdt + private fun layout(view: PrHeaderView) { + view.setSize(view.preferredSize) + components(view).forEach { if (it is Container) it.doLayout() } + } + private fun edt(block: () -> T): T = edtWait(block) } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/KiloSettingsConfigurableTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/KiloSettingsConfigurableTest.kt index b5e9b4404f64..12d54d35eb02 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/KiloSettingsConfigurableTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/KiloSettingsConfigurableTest.kt @@ -3,6 +3,7 @@ package ai.kilocode.client.settings import ai.kilocode.client.util.edtWait import ai.kilocode.client.settings.profile.UserProfileConfigurable import ai.kilocode.client.settings.context.ContextConfigurable +import ai.kilocode.client.settings.integrations.IntegrationsConfigurable import ai.kilocode.client.settings.models.ModelsConfigurable import ai.kilocode.client.settings.agents.AgentBehaviorConfigurable import ai.kilocode.client.settings.autoapprove.AutoApproveConfigurable @@ -37,6 +38,10 @@ class KiloSettingsConfigurableTest : BasePlatformTestCase() { assertEquals("ai.kilocode.jetbrains.settings.context", ContextConfigurable.ID) } + fun `test child integrations id matches xml registration`() { + assertEquals("ai.kilocode.jetbrains.settings.integrations", IntegrationsConfigurable.ID) + } + fun `test child advanced id matches xml registration`() { assertEquals("ai.kilocode.jetbrains.settings.advanced", AdvancedConfigurable.ID) } @@ -115,7 +120,10 @@ class KiloSettingsConfigurableTest : BasePlatformTestCase() { edt { val panel = cfg.createComponent() val labels = links(panel as Container).map { it.text } - assertEquals(listOf("User Profile", "Models", "Providers", "Agent Behavior", "Auto-Approve", "Context", "Advanced"), labels) + assertEquals( + listOf("User Profile", "Models", "Providers", "Agent Behavior", "Auto-Approve", "Context", "Integrations", "Advanced"), + labels, + ) } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/SettingsListViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/SettingsListViewTest.kt index 1e17cce1bca6..3d4874c76239 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/SettingsListViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/SettingsListViewTest.kt @@ -4,11 +4,13 @@ import ai.kilocode.client.util.edtWait import ai.kilocode.client.testing.fire import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.session.ui.PickerRow +import ai.kilocode.client.ui.ChangesPanel import ai.kilocode.client.ui.FilledBadgeIcon import ai.kilocode.client.ui.LayeredOverlayPanel import ai.kilocode.client.ui.UiStyle import ai.kilocode.client.ui.list.ActiveListActionCell import ai.kilocode.client.ui.list.ActiveListBadge +import ai.kilocode.client.ui.list.ActiveListBadgeCell import ai.kilocode.client.ui.list.ActiveListActive import ai.kilocode.client.ui.list.ActiveListCell import ai.kilocode.client.ui.list.ActiveListConfig @@ -22,7 +24,6 @@ import ai.kilocode.client.ui.list.ActiveListView import ai.kilocode.client.ui.list.ActiveListWeight import ai.kilocode.client.ui.list.ACTIVE_LIST_CHANGES_CELL import ai.kilocode.client.ui.list.ACTIVE_LIST_MENU_CELL -import ai.kilocode.client.ui.list.ACTIVE_LIST_PR_CELL import ai.kilocode.client.ui.list.activeListCellAt import ai.kilocode.client.ui.list.activeListCellBounds import com.intellij.icons.AllIcons @@ -300,6 +301,27 @@ class SettingsListViewTest : BasePlatformTestCase() { } } + fun `test leading badge renders before the title`() { + edt { + val badge = ActiveListBadge("#12", id = "badge", action = {}) + val row = badgeItem("wt", "Implement feature", badge) + val model = CollectionListModel(listOf(row)) + val list = JBList(model) + val renderer = ActiveListRenderer(model, ActiveListConfig.Equal) + + renderer.getListCellRendererComponent(list, row, 0, false, false) + renderer.setSize(360, renderer.preferredSize.height) + layout(renderer) + + val pill = components(renderer).filterIsInstance().single() + val title = components(renderer).filterIsInstance().single() + val pillX = SwingUtilities.convertPoint(pill.parent, pill.location, renderer).x + val titleX = SwingUtilities.convertPoint(title.parent, title.location, renderer).x + + assertTrue(pillX + pill.width <= titleX) + } + } + fun `test renderer centers leading icon vertically in the row`() { edt { val row = object : ActiveListItem { @@ -692,15 +714,15 @@ class SettingsListViewTest : BasePlatformTestCase() { val key = DataKey.create("test.activeList.menu.edge") val menu = ActiveListMenu(key, DefaultActionGroup(), element = { it }) val view = ActiveListView("Empty", menu = menu) { _, _ -> } - view.update(listOf(metricsItem("with", "Alpha", ActiveListMetrics(pr = ActiveListBadge("#12"))))) + view.update(listOf(metricsItem("with", "Alpha", ActiveListMetrics(files = 1, additions = 5, onChanges = {})))) layout(view) hover(view, center(view.list.getCellBounds(0, 0))) val cells = activeListCellBounds(view.list, 0, selected = true) - val pull = cells.getValue(ACTIVE_LIST_PR_CELL) + val changes = cells.getValue(ACTIVE_LIST_CHANGES_CELL) val menuArea = cells.getValue(ACTIVE_LIST_MENU_CELL) - assertEquals("menu glyph should touch trailing row content", pull.x + pull.width, menuArea.x) + assertEquals("menu glyph should touch trailing row content", changes.x + changes.width, menuArea.x) } } @@ -1048,13 +1070,13 @@ class SettingsListViewTest : BasePlatformTestCase() { val calls = mutableListOf() val onCellCalls = mutableListOf() val view = ActiveListView("Empty") { key, id -> onCellCalls += "$key:$id" } - view.update(listOf(metricsItem("wt", "Alpha", ActiveListMetrics(additions = 3, deletions = 2, onChanges = { calls += "changes" })))) + view.update(listOf(metricsItem("wt", "Alpha", ActiveListMetrics(files = 1, additions = 3, deletions = 2, onChanges = { calls += "changes" })))) view.list.size = Dimension(360, 80) view.list.doLayout() UIUtil.dispatchAllInvocationEvents() val area = activeListCellBounds(view.list, 0, selected = true).getValue(ACTIVE_LIST_CHANGES_CELL) - assertEquals(KiloBundle.message("worktree.stats.tooltip", 0, 0, 3, 2), view.list.getToolTipText(event(view.list, center(area)))) + assertEquals(KiloBundle.message("worktree.stats.tooltip", 1, 3, 2), view.list.getToolTipText(event(view.list, center(area)))) hover(view, center(area)) assertEquals(Cursor.HAND_CURSOR, view.list.cursor.type) @@ -1065,43 +1087,167 @@ class SettingsListViewTest : BasePlatformTestCase() { } } - fun `test pr badge is hit tested and invokes its action`() { + fun `test compact file-only changes reuse the shared panel and open from the count or padding`() { + edt { + val calls = mutableListOf() + val opened = mutableListOf() + val view = ActiveListView("Empty", onOpen = { row, _ -> opened += row.key }) { _, _ -> calls += "fallback" } + view.update(listOf(metricsItem("wt", "Alpha", ActiveListMetrics(files = 1, onChanges = { calls += "base" })))) + layout(view) + + val area = activeListCellBounds(view.list, 0, selected = false).getValue(ACTIVE_LIST_CHANGES_CELL) + val renderer = rendered(view) + val panel = components(renderer).filterIsInstance().single() + val count = components(panel).filterIsInstance().single { it.text == "1 file" } + val point = SwingUtilities.convertPoint(count, count.width / 2, count.height / 2, renderer) + val bounds = view.list.getCellBounds(0, 0) + point.translate(bounds.x, bounds.y) + assertTrue(panel.isVisible) + assertTrue(area.contains(point)) + for (target in listOf(point, Point(area.x + area.width - 1, center(area).y))) { + click(view, target) + fire(view.list, mouse(view, MouseEvent.MOUSE_CLICKED, target)) + } + + assertEquals(listOf("base", "base"), calls) + assertTrue(opened.isEmpty()) + } + } + + fun `test equal compact summaries keep row actions after renderer reuse and width changes`() { + edt { + val calls = mutableListOf() + val opened = mutableListOf() + val view = ActiveListView("Empty", onOpen = { row, _ -> opened += row.key }) { _, _ -> calls += "fallback" } + view.update(listOf( + metricsItem("first", "Alpha", ActiveListMetrics(files = 2, additions = 5, deletions = 1, base = "origin/main", onChanges = { calls += "first" })), + metricsItem("second", "Beta", ActiveListMetrics(files = 2, additions = 5, deletions = 1, base = "origin/main", onChanges = { calls += "second" })), + metricsItem("wide", "Gamma", ActiveListMetrics(files = 123, additions = 4567, base = "origin/develop", onChanges = { calls += "wide" })), + item("plain", "Delta", null), + )) + layout(view) + val renderer = rendered(view) + val panel = components(renderer).filterIsInstance().single() + val retained = components(panel) + + for (width in listOf(260, 480, 320)) { + calls.clear() + view.list.size = Dimension(width, 320) + view.list.doLayout() + UIUtil.dispatchAllInvocationEvents() + val first = activeListCellBounds(view.list, 0, selected = false).getValue(ACTIVE_LIST_CHANGES_CELL) + val second = activeListCellBounds(view.list, 1, selected = false).getValue(ACTIVE_LIST_CHANGES_CELL) + activeListCellBounds(view.list, 2, selected = true) + assertTrue(activeListCellBounds(view.list, 3, selected = false).isEmpty()) + paint(view.list) + + assertEquals(first, activeListCellBounds(view.list, 0, selected = false).getValue(ACTIVE_LIST_CHANGES_CELL)) + assertEquals(second, activeListCellBounds(view.list, 1, selected = true).getValue(ACTIVE_LIST_CHANGES_CELL)) + assertTrue(view.list.getCellBounds(0, 0).contains(first)) + assertTrue(view.list.getCellBounds(1, 1).contains(second)) + assertEquals(KiloBundle.message("worktree.stats.base.tooltip", 2, 5, 1, "origin/main"), view.list.getToolTipText(event(view.list, center(second)))) + hover(view, center(second)) + assertEquals(Cursor.HAND_CURSOR, view.list.cursor.type) + for (point in listOf(center(second), center(first))) { + click(view, point) + fire(view.list, mouse(view, MouseEvent.MOUSE_CLICKED, point)) + } + assertEquals(listOf("second", "first"), calls) + assertTrue(opened.isEmpty()) + assertSame(panel, components(renderer).filterIsInstance().single()) + assertEquals(retained, components(panel)) + } + } + } + + fun `test compact summary clears hit regions for zero files and disables actions with the row`() { + edt { + val calls = mutableListOf() + val view = ActiveListView("Empty") { _, _ -> calls += "fallback" } + val row = metricsItem("wt", "Alpha", ActiveListMetrics(files = 1, onChanges = { calls += "base" })) + view.update(listOf(row)) + layout(view) + val area = activeListCellBounds(view.list, 0, selected = false).getValue(ACTIVE_LIST_CHANGES_CELL) + + view.update(listOf(object : ActiveListItem by row { + override val disabled = true + })) + layout(view) + assertNull(activeListCellAt(view.list, 0, center(area), selected = false)) + hover(view, center(area)) + assertEquals(Cursor.DEFAULT_CURSOR, view.list.cursor.type) + click(view, center(area)) + assertTrue(calls.isEmpty()) + + view.update(listOf(metricsItem("wt", "Alpha", ActiveListMetrics(onChanges = { calls += "base" })))) + layout(view) + assertTrue(activeListCellBounds(view.list, 0, selected = false).isEmpty()) + assertNull(activeListCellAt(view.list, 0, center(area), selected = false)) + click(view, center(area)) + assertTrue(calls.isEmpty()) + } + } + + fun `test leading badge is hit tested and invokes its action`() { edt { val calls = mutableListOf() val view = ActiveListView("Empty") { _, _ -> } - view.update(listOf(metricsItem("wt", "Alpha", ActiveListMetrics(pr = ActiveListBadge("#12"), onPr = { calls += "pr" })))) + view.update(listOf(badgeItem("wt", "Alpha", ActiveListBadge("#12", id = "badge", tooltip = "#12", action = { calls += "badge" })))) view.list.size = Dimension(360, 80) view.list.doLayout() UIUtil.dispatchAllInvocationEvents() - val area = activeListCellBounds(view.list, 0, selected = true).getValue(ACTIVE_LIST_PR_CELL) + val area = activeListCellBounds(view.list, 0, selected = true).getValue("badge") assertEquals("#12", view.list.getToolTipText(event(view.list, center(area)))) hover(view, center(area)) assertEquals(Cursor.HAND_CURSOR, view.list.cursor.type) click(view, center(area)) - assertEquals(listOf("pr"), calls) + assertEquals(listOf("badge"), calls) } } - fun `test pr badge uses custom tooltip when supplied`() { + fun `test leading badge uses custom tooltip when supplied`() { edt { val view = ActiveListView("Empty") { _, _ -> } - view.update(listOf(metricsItem("wt", "Alpha", ActiveListMetrics(pr = ActiveListBadge("#12"), prTooltip = "PR details")))) + view.update(listOf(badgeItem("wt", "Alpha", ActiveListBadge("#12", id = "badge", tooltip = "Badge details", action = {})))) view.list.size = Dimension(360, 80) view.list.doLayout() UIUtil.dispatchAllInvocationEvents() - val area = activeListCellBounds(view.list, 0, selected = true).getValue(ACTIVE_LIST_PR_CELL) + val area = activeListCellBounds(view.list, 0, selected = true).getValue("badge") - assertEquals("PR details", view.list.getToolTipText(event(view.list, center(area)))) + assertEquals("Badge details", view.list.getToolTipText(event(view.list, center(area)))) + } + } + + fun `test decorative badge is not hit tested`() { + edt { + val view = ActiveListView("Empty") { _, _ -> } + view.update(listOf(badgeItem("wt", "Alpha", ActiveListBadge("Local")))) + layout(view) + + assertTrue(activeListCellBounds(view.list, 0, selected = false).isEmpty()) + } + } + + fun `test badge action without an id is inert`() { + edt { + val calls = mutableListOf() + val view = ActiveListView("Empty") { _, _ -> } + view.update(listOf(badgeItem("wt", "Alpha", ActiveListBadge("Local", action = { calls += "badge" })))) + layout(view) + + assertTrue(activeListCellBounds(view.list, 0, selected = false).isEmpty()) + click(view, center(view.list.getCellBounds(0, 0))) + assertTrue(calls.isEmpty()) } } fun `test inert changes badge is not hit tested`() { edt { val view = ActiveListView("Empty") { _, _ -> } - view.update(listOf(metricsItem("wt", "Alpha", ActiveListMetrics(additions = 3, deletions = 2)))) + view.update(listOf(metricsItem("wt", "Alpha", ActiveListMetrics(files = 1, additions = 3, deletions = 2)))) view.list.size = Dimension(360, 80) view.list.doLayout() UIUtil.dispatchAllInvocationEvents() @@ -1115,43 +1261,31 @@ class SettingsListViewTest : BasePlatformTestCase() { } } - fun `test pr badge hit region ignores the metrics of other rows`() { + fun `test leading badge hit region ignores other rows`() { edt { val calls = mutableListOf() val view = ActiveListView("Empty") { _, _ -> } view.update( listOf( - metricsItem( - "wide", - "Alpha", - ActiveListMetrics( - additions = 1234, - deletions = 987, - ahead = 42, - behind = 17, - pr = ActiveListBadge("#12345"), - onPr = { calls += "wide" }, - ), - ), - metricsItem("narrow", "Beta", ActiveListMetrics(pr = ActiveListBadge("#7"), onPr = { calls += "narrow" })), + badgeItem("wide", "Alpha", ActiveListBadge("#12345", id = "badge", action = { calls += "wide" })), + badgeItem("narrow", "Beta", ActiveListBadge("#7", id = "badge", action = { calls += "narrow" })), ), ) view.list.size = Dimension(360, 160) view.list.doLayout() UIUtil.dispatchAllInvocationEvents() - val wide = activeListCellBounds(view.list, 0, selected = false).getValue(ACTIVE_LIST_PR_CELL) - val narrow = activeListCellBounds(view.list, 1, selected = false).getValue(ACTIVE_LIST_PR_CELL) - // Both badges trail their row, so they share a right edge no matter how wide the changes - // beside them are. - assertEquals(wide.x + wide.width, narrow.x + narrow.width) + val wide = activeListCellBounds(view.list, 0, selected = false).getValue("badge") + val narrow = activeListCellBounds(view.list, 1, selected = false).getValue("badge") + // Both badges lead their title, so they share a left edge regardless of badge width. + assertEquals(wide.x, narrow.x) // The renderer is one reused stamp: rendering the wide row, or a full paint pass over // every row, must not move the narrow row's hit region. activeListCellBounds(view.list, 0, selected = false) - assertEquals(narrow, activeListCellBounds(view.list, 1, selected = false).getValue(ACTIVE_LIST_PR_CELL)) + assertEquals(narrow, activeListCellBounds(view.list, 1, selected = false).getValue("badge")) paint(view.list) - assertEquals(narrow, activeListCellBounds(view.list, 1, selected = false).getValue(ACTIVE_LIST_PR_CELL)) + assertEquals(narrow, activeListCellBounds(view.list, 1, selected = false).getValue("badge")) click(view, center(narrow)) click(view, center(wide)) @@ -1159,6 +1293,192 @@ class SettingsListViewTest : BasePlatformTestCase() { } } + fun `test secondary badge without metrics supports cursor click and tooltip with or without description`() { + edt { + for (note in listOf(null, "Description")) { + val calls = mutableListOf() + val fallback = mutableListOf() + val view = ActiveListView("Empty") { key, id -> fallback += "$key:$id" } + val row = secondaryItem( + "row", + "Alpha", + ActiveListBadge("Ready", id = "badge", tooltip = "Badge details", action = { calls += "badge" }), + note, + ) + view.update(listOf(row)) + layout(view) + view.list.clearSelection() + + assertNull(row.metrics) + val area = activeListCellBounds(view.list, 0, selected = false).getValue("badge") + assertTrue(area.width > 0 && area.height > 0) + val renderer = rendered(view) + val title = components(renderer).filterIsInstance().single() + assertTrue(area.y >= topY(renderer, title) + title.height) + assertEquals("badge", activeListCellAt(view.list, 0, center(area), selected = false)) + assertEquals("Badge details", view.list.getToolTipText(event(view.list, center(area)))) + hover(view, center(area)) + assertEquals(Cursor.HAND_CURSOR, view.list.cursor.type) + + click(view, center(area)) + assertEquals(listOf("badge"), calls) + assertTrue(fallback.isEmpty()) + hover(view, Point(2, 2)) + assertEquals(Cursor.DEFAULT_CURSOR, view.list.cursor.type) + } + } + } + + fun `test secondary badge is right aligned after metrics on the description line while title badges remain supported`() { + edt { + val calls = mutableListOf() + val row = object : ActiveListItem { + override val key = "row" + override val title = "Alpha" + override val description = "Description" + override val leading = listOf(ActiveListBadge("Local", id = "leading", action = { calls += "leading" })) + override val badges = listOf(ActiveListBadge("Ready", id = "trailing", action = { calls += "trailing" })) + override val secondaryBadges = listOf(ActiveListBadge("Details", id = "badge", action = { calls += "badge" })) + override val metrics = ActiveListMetrics(files = 1, additions = 5, deletions = 2, onChanges = { calls += "changes" }) + } + val view = ActiveListView("Empty") { _, _ -> } + view.update(listOf(row)) + view.list.size = Dimension(480, 160) + view.list.doLayout() + UIUtil.dispatchAllInvocationEvents() + + val areas = activeListCellBounds(view.list, 0, selected = false) + val badge = areas.getValue("badge") + val changes = areas.getValue(ACTIVE_LIST_CHANGES_CELL) + val leading = areas.getValue("leading") + val trailing = areas.getValue("trailing") + val renderer = rendered(view) + val title = components(renderer).filterIsInstance().single() + val desc = components(renderer).filterIsInstance().single { it.text == "Description" } + val panel = rowPanel(renderer) + val text = (panel.layout as BorderLayout).getLayoutComponent(BorderLayout.CENTER) + val origin = SwingUtilities.convertPoint(text.parent, text.location, renderer) + val start = SwingUtilities.convertPoint(title.parent, title.location, renderer) + val description = SwingUtilities.convertPoint(desc.parent, desc.location, renderer) + + assertTrue(description.x + desc.width <= changes.x) + assertTrue(changes.x + changes.width <= badge.x) + assertEquals(origin.x + text.width, badge.x + badge.width) + assertTrue(kotlin.math.abs(center(badge).y - center(changes).y) <= 1) + assertTrue(kotlin.math.abs(center(badge).y - centerY(renderer, desc)) <= 1) + assertTrue(badge.y >= start.y + title.height) + assertTrue(leading.x + leading.width <= start.x) + assertTrue(start.x + title.width <= trailing.x) + assertTrue(kotlin.math.abs(center(leading).y - centerY(renderer, title)) <= 1) + assertTrue(kotlin.math.abs(center(trailing).y - centerY(renderer, title)) <= 1) + + click(view, center(leading)) + click(view, center(trailing)) + click(view, center(changes)) + click(view, center(badge)) + assertEquals(listOf("leading", "trailing", "changes", "badge"), calls) + } + } + + fun `test secondary badge regions stay correct across renderer reuse row widths and rows without badges`() { + edt { + val calls = mutableListOf() + val view = ActiveListView("Empty") { _, _ -> } + val plain = item("plain", "Gamma", null) + assertTrue(plain.secondaryBadges.isEmpty()) + view.update(listOf( + object : ActiveListItem by secondaryItem("wide", "Alpha", ActiveListBadge("Available", id = "badge", tooltip = "Wide", action = { calls += "wide" })) { + override val metrics = ActiveListMetrics(files = 123, additions = 123, deletions = 9) + }, + secondaryItem("narrow", "Beta", ActiveListBadge("On", id = "badge", tooltip = "Narrow", action = { calls += "narrow" })), + plain, + )) + + for (width in listOf(260, 480, 320)) { + calls.clear() + view.list.size = Dimension(width, 240) + view.list.doLayout() + UIUtil.dispatchAllInvocationEvents() + val renderer = rendered(view) + val wide = activeListCellBounds(view.list, 0, selected = false).getValue("badge") + val cell = components(renderer).filterIsInstance().single() + val narrow = activeListCellBounds(view.list, 1, selected = false).getValue("badge") + assertSame(cell, components(renderer).filterIsInstance().single()) + assertTrue(wide.width > narrow.width) + assertEquals(wide.x + wide.width, narrow.x + narrow.width) + assertTrue(view.list.getCellBounds(0, 0).contains(wide)) + assertTrue(view.list.getCellBounds(1, 1).contains(narrow)) + assertTrue(activeListCellBounds(view.list, 2, selected = false).isEmpty()) + assertTrue(components(renderer).filterIsInstance().none { it.isVisible }) + + paint(view.list) + assertEquals(narrow, activeListCellBounds(view.list, 1, selected = false).getValue("badge")) + assertEquals(wide, activeListCellBounds(view.list, 0, selected = false).getValue("badge")) + assertEquals("Narrow", view.list.getToolTipText(event(view.list, center(narrow)))) + assertEquals("Wide", view.list.getToolTipText(event(view.list, center(wide)))) + assertNull(activeListCellAt(view.list, 0, center(narrow), selected = false)) + + val bounds = view.list.getCellBounds(2, 2) + val point = Point(center(narrow).x, bounds.y + center(narrow).y - view.list.getCellBounds(1, 1).y) + assertNull(activeListCellAt(view.list, 2, point, selected = false)) + assertNull(view.list.getToolTipText(event(view.list, point))) + hover(view, point) + assertEquals(Cursor.DEFAULT_CURSOR, view.list.cursor.type) + click(view, point) + assertTrue(calls.isEmpty()) + click(view, center(narrow)) + click(view, center(wide)) + assertEquals(listOf("narrow", "wide"), calls) + } + } + } + + fun `test progress hides secondary and title badges and restores their actions afterward`() { + edt { + val calls = mutableListOf() + val row = object : ActiveListItem { + override val key = "row" + override val title = "Alpha" + override val description = "Description" + override val leading = listOf(ActiveListBadge("Local", id = "leading", action = { calls += "leading" })) + override val badges = listOf(ActiveListBadge("Ready", id = "trailing", action = { calls += "trailing" })) + override val secondaryBadges = listOf(ActiveListBadge("Details", id = "badge", tooltip = "Badge details", action = { calls += "badge" })) + override val metrics = ActiveListMetrics(files = 1, additions = 5, onChanges = { calls += "changes" }) + } + val pending = object : ActiveListItem by row { + override val progress = "Working" + } + val view = ActiveListView("Empty") { key, id -> calls += "$key:$id" } + view.update(listOf(row)) + layout(view) + val areas = activeListCellBounds(view.list, 0, selected = false) + assertEquals(setOf("leading", "trailing", "badge", ACTIVE_LIST_CHANGES_CELL), areas.keys) + + view.update(listOf(pending)) + layout(view) + assertTrue(activeListCellBounds(view.list, 0, selected = false).isEmpty()) + val renderer = rendered(view) + assertTrue(components(renderer).filterIsInstance().none { it.isVisible }) + assertTrue(components(renderer).filterIsInstance().single { it.text == "Working" }.isVisible) + for (area in areas.values) { + assertNull(activeListCellAt(view.list, 0, center(area), selected = false)) + hover(view, center(area)) + assertEquals(Cursor.DEFAULT_CURSOR, view.list.cursor.type) + click(view, center(area)) + } + assertTrue(calls.isEmpty()) + + view.update(listOf(row)) + layout(view) + val restored = activeListCellBounds(view.list, 0, selected = false) + assertEquals(areas, restored) + val area = restored.getValue("badge") + assertEquals("Badge details", view.list.getToolTipText(event(view.list, center(area)))) + click(view, center(area)) + assertEquals(listOf("badge"), calls) + } + } + private fun paint(list: JBList<*>) { val image = UIUtil.createImage(list, list.width, list.height, BufferedImage.TYPE_INT_ARGB) val g = image.createGraphics() @@ -1182,6 +1502,19 @@ class SettingsListViewTest : BasePlatformTestCase() { override val metrics = data } + private fun badgeItem(id: String, name: String, badge: ActiveListBadge) = object : ActiveListItem { + override val key = id + override val title = name + override val leading = listOf(badge) + } + + private fun secondaryItem(id: String, name: String, badge: ActiveListBadge, note: String? = null) = object : ActiveListItem { + override val key = id + override val title = name + override val description = note + override val secondaryBadges = listOf(badge) + } + private fun sectionItem(id: String, name: String, group: String) = object : ActiveListItem { override val key = id override val title = name @@ -1218,6 +1551,14 @@ class SettingsListViewTest : BasePlatformTestCase() { return actionCells(comp).filter { it.isVisible }.map { it.cellId } } + private fun rendered(view: ActiveListView): ActiveListRenderer { + val list = view.list + val comp = list.cellRenderer.getListCellRendererComponent(list, list.model.getElementAt(0), 0, false, true) + comp.setSize(list.width, list.getCellBounds(0, 0).height) + layout(comp as Container) + return components(comp).filterIsInstance().single() + } + private fun rowPanel(renderer: ActiveListRenderer): JPanel = components(renderer).filterIsInstance().single().content.getComponent(0) as JPanel diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/integrations/IntegrationsConfigurableTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/integrations/IntegrationsConfigurableTest.kt new file mode 100644 index 000000000000..b7ec5e5ecdae --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/integrations/IntegrationsConfigurableTest.kt @@ -0,0 +1,131 @@ +package ai.kilocode.client.settings.integrations + +import ai.kilocode.client.agentManager.worktree.GithubIntegrationListener +import ai.kilocode.client.agentManager.worktree.setGithubIntegration +import ai.kilocode.client.plugin.KiloPluginSettings +import ai.kilocode.client.util.edtWait +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.options.Configurable +import com.intellij.openapi.options.SearchableConfigurable +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.ui.components.OnOffButton +import java.awt.Container +import javax.swing.JComponent +import javax.swing.JLabel + +@Suppress("UnstableApiUsage") +class IntegrationsConfigurableTest : BasePlatformTestCase() { + + override fun tearDown() { + try { + KiloPluginSettings.unsetGithub() + } finally { + super.tearDown() + } + } + + fun `test id matches xml registration`() { + assertEquals("ai.kilocode.jetbrains.settings.integrations", IntegrationsConfigurable().id) + } + + fun `test page is searchable and opts out of platform margin and scrollpane`() { + val cfg: Configurable = IntegrationsConfigurable() + assertTrue(cfg is SearchableConfigurable) + assertTrue(cfg is Configurable.NoMargin) + assertTrue(cfg is Configurable.NoScroll) + } + + fun `test github toggle renders on by default`() { + val cfg = IntegrationsConfigurable() + edt { + val panel = cfg.createComponent() + val all = text(panel as Container) + assertTrue("expected the GitHub section", all.contains("GitHub")) + assertTrue("expected the toggle row", all.contains("Enable GitHub Integration")) + assertTrue("default must be on", toggle(panel).isSelected) + cfg.disposeUIResources() + } + } + + fun `test toggling off persists the setting and publishes the change`() { + val events = mutableListOf() + ApplicationManager.getApplication().messageBus.connect(testRootDisposable) + .subscribe(GithubIntegrationListener.TOPIC, GithubIntegrationListener { events += it }) + val cfg = IntegrationsConfigurable() + edt { + val panel = cfg.createComponent() + toggle(panel as Container).doClick() + assertFalse(KiloPluginSettings.getGithub()) + assertEquals(listOf(false), events) + toggle(panel).doClick() + assertTrue(KiloPluginSettings.getGithub()) + assertEquals(listOf(false, true), events) + cfg.disposeUIResources() + } + } + + fun `test page is inert because the toggle writes immediately`() { + val cfg = IntegrationsConfigurable() + edt { + val panel = cfg.createComponent() + toggle(panel as Container).doClick() + assertFalse("an immediate write leaves nothing to apply", cfg.isModified) + cfg.apply() + assertFalse(KiloPluginSettings.getGithub()) + cfg.disposeUIResources() + } + } + + fun `test open page follows a change made elsewhere`() { + val cfg = IntegrationsConfigurable() + edt { + val panel = cfg.createComponent() + assertTrue(toggle(panel as Container).isSelected) + // The gh banner turning the integration off while the page is open. + setGithubIntegration(false, "worktree_gh_banner") + assertFalse(toggle(panel).isSelected) + cfg.disposeUIResources() + } + } + + fun `test reset resyncs the toggle`() { + val cfg = IntegrationsConfigurable() + edt { + val panel = cfg.createComponent() + val button = toggle(panel as Container) + // Bypass the publisher so only reset() can bring the toggle back in line. + KiloPluginSettings.setGithub(false) + assertTrue(button.isSelected) + cfg.reset() + assertFalse(button.isSelected) + cfg.disposeUIResources() + } + } + + // -- helpers -- + + private fun edt(block: () -> T): T = edtWait(block) + + private fun toggle(root: Container): OnOffButton = buttons(root).single() + + private fun buttons(root: Container): List = buildList { + for (comp in root.components) { + if (comp is OnOffButton) add(comp) + if (comp is Container) addAll(buttons(comp)) + } + } + + private fun text(root: Container): String { + val acc = mutableListOf() + collect(root, acc) + return acc.joinToString("\n") + } + + private fun collect(root: Container, acc: MutableList) { + for (comp in root.components) { + if (comp is JLabel) comp.text?.let { acc.add(it) } + if (comp is JComponent) comp.toolTipText?.let { acc.add(it) } + if (comp is Container) collect(comp, acc) + } + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt index 2c23423c469f..b9f7314525c2 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt @@ -4,7 +4,6 @@ import ai.kilocode.rpc.KiloSessionRpcApi 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 @@ -117,7 +116,6 @@ class FakeSessionRpcApi : KiloSessionRpcApi { val messageDeletes = mutableListOf() var messageDeleteResult = true val unreverts = mutableListOf>() - val configs = mutableListOf>() val permissionReplies = mutableListOf>() val permissionRulesSaved = mutableListOf>() val questionReplies = mutableListOf>() @@ -333,11 +331,6 @@ class FakeSessionRpcApi : KiloSessionRpcApi { return eventFlow?.invoke(id, directory) ?: events } - override suspend fun updateConfig(directory: String, config: ConfigUpdateDto) { - assertNotEdt("updateConfig") - configs.add(directory to config) - } - var replyPermissionThrows: Exception? = null override suspend fun replyPermission(requestId: String, directory: String, reply: PermissionReplyDto) { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorkspaceRpcApi.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorkspaceRpcApi.kt index 154197e06181..6d2cb555ea86 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorkspaceRpcApi.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorkspaceRpcApi.kt @@ -7,6 +7,8 @@ import ai.kilocode.rpc.dto.FileSearchResultDto import ai.kilocode.rpc.dto.KiloWorkspaceStateDto import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto import ai.kilocode.rpc.dto.ModelsWorkspaceDto +import ai.kilocode.rpc.dto.SetupScriptKind +import ai.kilocode.rpc.dto.SetupScriptTargetDto import ai.kilocode.rpc.dto.WorkspaceFileDto import com.intellij.platform.project.ProjectId import kotlinx.coroutines.CompletableDeferred @@ -40,7 +42,14 @@ class FakeWorkspaceRpcApi : KiloWorkspaceRpcApi { val branchDiffs = mutableListOf() val branchDiffCalls = CopyOnWriteArrayList() val branchDiffPatchCalls = CopyOnWriteArrayList() + val localDiffs = mutableListOf() + val localDiffCalls = CopyOnWriteArrayList() + val localDiffPatchCalls = CopyOnWriteArrayList() + var beforeBranchDiff: (suspend () -> Unit)? = null + var beforeLocalDiff: (suspend () -> Unit)? = null var branchName: String? = null + val branchNameCalls = CopyOnWriteArrayList() + var beforeBranchName: (suspend () -> Unit)? = null var openResult = true var localConfigPath = "/test/.kilo/kilo.jsonc" var globalConfigPath = "/config/kilo.jsonc" @@ -51,6 +60,13 @@ class FakeWorkspaceRpcApi : KiloWorkspaceRpcApi { var beforeLocalConfigTarget: (suspend () -> Unit)? = null var beforeGlobalConfigTarget: (suspend () -> Unit)? = null var refreshConfigThrows: Exception? = null + var setupScriptPath = "/test/.kilo/setup-script" + var setupScriptDisplayPath = setupScriptPath + var setupScriptExists = false + var setupScriptKind = SetupScriptKind.POSIX + var beforeSetupScriptTarget: (suspend () -> Unit)? = null + val setupScriptTargetCalls = CopyOnWriteArrayList() + val setupScripts = CopyOnWriteArrayList() val fileCalls = CopyOnWriteArrayList>() val searchQueries = CopyOnWriteArrayList() val opened = CopyOnWriteArrayList() @@ -106,11 +122,22 @@ class FakeWorkspaceRpcApi : KiloWorkspaceRpcApi { assertNotEdt("branchDiff") branchDiffCalls.add(directory) branchDiffPatchCalls.add(patches) + beforeBranchDiff?.invoke() return branchDiffs.toList() } + override suspend fun localDiff(directory: String, patches: Boolean): List { + assertNotEdt("localDiff") + localDiffCalls.add(directory) + localDiffPatchCalls.add(patches) + beforeLocalDiff?.invoke() + return localDiffs.toList() + } + override suspend fun branchName(directory: String): String? { assertNotEdt("branchName") + branchNameCalls.add(directory) + beforeBranchName?.invoke() return branchName } @@ -153,5 +180,18 @@ class FakeWorkspaceRpcApi : KiloWorkspaceRpcApi { return openResult } + override suspend fun setupScriptTarget(directory: String): SetupScriptTargetDto { + assertNotEdt("setupScriptTarget") + setupScriptTargetCalls.add(directory) + beforeSetupScriptTarget?.invoke() + return SetupScriptTargetDto(setupScriptPath, setupScriptDisplayPath, setupScriptExists, setupScriptKind) + } + + override suspend fun openSetupScript(directory: String): Boolean { + assertNotEdt("openSetupScript") + setupScripts.add(directory) + return openResult + } + data class Opened(val path: String, val line: Int?, val column: Int?, val endLine: Int? = null) } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorktreeRpcApi.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorktreeRpcApi.kt index 54d9aa68f562..dab397fb4bd6 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorktreeRpcApi.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorktreeRpcApi.kt @@ -9,6 +9,7 @@ import ai.kilocode.rpc.dto.MoveProgressDto import ai.kilocode.rpc.dto.RemoveWorktreeResultDto import ai.kilocode.rpc.dto.RenameWorktreeResultDto import ai.kilocode.rpc.dto.WorktreeBranchesDto +import ai.kilocode.rpc.dto.WorktreeDirtyListDto import ai.kilocode.rpc.dto.WorktreeDto import ai.kilocode.rpc.dto.WorktreeListDto import ai.kilocode.rpc.dto.WorktreePrListDto @@ -26,6 +27,7 @@ class FakeWorktreeRpcApi : KiloWorktreeRpcApi { val listed = CopyOnWriteArrayList() val branchesList = CopyOnWriteArrayList() var statsResult = WorktreeStatsListDto() + var dirtyResult = WorktreeDirtyListDto() var ghResult = GhAvailability.OK var prResult = WorktreePrListDto() var branchResult = BranchStatusDto() @@ -50,10 +52,19 @@ class FakeWorktreeRpcApi : KiloWorktreeRpcApi { var sessionListThrows: Exception? = null val opens = CopyOnWriteArrayList() val ghCalls = CopyOnWriteArrayList() + /** The `github` flag of each [ghStatus] call, positionally matching [ghCalls]. */ + val ghFlags = CopyOnWriteArrayList() + /** Each [branchStatus] call as directory to `github` flag. */ + val branchCalls = CopyOnWriteArrayList>() + val prCalls = CopyOnWriteArrayList() + val statsCalls = CopyOnWriteArrayList() + val dirtyCalls = CopyOnWriteArrayList() var beforeCreate: suspend () -> Unit = {} var beforeRemove: suspend () -> Unit = {} var beforeRename: suspend () -> Unit = {} var beforeGhStatus: suspend () -> Unit = {} + /** Gate for holding a [prStatus] answer open while the test changes state around it. */ + var beforePrStatus: suspend () -> Unit = {} var adoptResult: (String, String) -> RenameWorktreeResultDto = { path, name -> RenameWorktreeResultDto(worktree = WorktreeDto(path, name, name, path)) } @@ -87,24 +98,40 @@ class FakeWorktreeRpcApi : KiloWorktreeRpcApi { override suspend fun stats(directory: String): WorktreeStatsListDto { assertNotEdt("stats") + statsCalls.add(directory) return statsResult } - override suspend fun ghStatus(directory: String): GhAvailability { + override suspend fun dirty(directory: String): WorktreeDirtyListDto { + assertNotEdt("dirty") + dirtyCalls.add(directory) + return dirtyResult + } + + override suspend fun ghStatus(directory: String, github: Boolean): GhAvailability { assertNotEdt("ghStatus") ghCalls.add(directory) + ghFlags.add(github) beforeGhStatus() + if (!github) return if (ghResult == GhAvailability.GIT_MISSING) ghResult else GhAvailability.OK return ghResult } override suspend fun prStatus(directory: String): WorktreePrListDto { assertNotEdt("prStatus") - return prResult + prCalls.add(directory) + // Snapshot before the gate so a call held open answers with what was configured when it + // started, letting a test stage a newer result for the calls that follow. + val answer = prResult + beforePrStatus() + return answer } - override suspend fun branchStatus(directory: String): BranchStatusDto { + override suspend fun branchStatus(directory: String, github: Boolean): BranchStatusDto { assertNotEdt("branchStatus") + branchCalls.add(directory to github) branchThrows?.let { throw it } + if (!github) return branchResult.copy(pr = null) return branchResult } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/TestIdeActivation.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/TestIdeActivation.kt new file mode 100644 index 000000000000..295a84d89599 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/TestIdeActivation.kt @@ -0,0 +1,31 @@ +package ai.kilocode.client.testing + +import com.intellij.openapi.application.ApplicationActivationListener +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.project.Project +import com.intellij.openapi.wm.IdeFrame +import com.intellij.openapi.wm.StatusBar +import com.intellij.ui.BalloonLayout +import java.awt.Rectangle +import javax.swing.JComponent +import javax.swing.JPanel + +/** + * Publishes IDE frame activation for [project], the way the platform does when its window gains + * focus. Headless tests have no real frame, so this carries a stub [IdeFrame] whose only meaningful + * member is the project the listeners route on. + */ +fun activateIde(project: Project) { + ApplicationManager.getApplication().messageBus + .syncPublisher(ApplicationActivationListener.TOPIC) + .applicationActivated(StubIdeFrame(project)) +} + +private class StubIdeFrame(private val project: Project) : IdeFrame { + override fun getStatusBar(): StatusBar? = null + override fun suggestChildFrameBounds(): Rectangle = Rectangle() + override fun getProject(): Project = project + override fun setFrameTitle(title: String) = Unit + override fun getComponent(): JComponent = JPanel() + override fun getBalloonLayout(): BalloonLayout? = null +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/ChangesPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/ChangesPanelTest.kt new file mode 100644 index 000000000000..d573728e448e --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/ChangesPanelTest.kt @@ -0,0 +1,340 @@ +package ai.kilocode.client.ui + +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.util.edtWait +import com.intellij.openapi.editor.EditorFactory +import com.intellij.openapi.util.text.StringUtil +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.ui.components.JBLabel +import com.intellij.util.concurrency.annotations.RequiresEdt +import com.intellij.util.ui.JBFont +import java.awt.Component +import java.awt.Container +import java.awt.Cursor +import java.awt.event.ActionEvent +import java.awt.event.KeyEvent +import java.awt.event.MouseEvent +import java.awt.image.BufferedImage +import javax.accessibility.AccessibleRole +import javax.swing.JComponent +import javax.swing.JSeparator +import javax.swing.KeyStroke +import javax.swing.RepaintManager +import javax.swing.SwingUtilities + +class ChangesPanelTest : BasePlatformTestCase() { + fun `test compact renders only the shared base file and diff group`() = edt { + val view = ChangesPanel(ChangesPanel.Mode.COMPACT) + assertFalse(view.isVisible) + + view.update(3, 10, 7, ahead = 4, behind = 5, localFiles = 2, localAdditions = 9, localDeletions = 3, base = "origin/main") + + assertEquals(listOf("3 files", "-7", "+10"), labels(view)) + assertEquals(1, groups(view).size) + assertEquals(1, components(view).filterIsInstance().size) + assertEquals(KiloBundle.message("worktree.stats.base.tooltip", 3, 10, 7, "origin/main"), view.toolTipText) + assertEquals(view.toolTipText, groups(view).single().toolTipText) + } + + fun `test full orders local separator ahead behind and base`() = edt { + val view = ChangesPanel(ChangesPanel.Mode.FULL) + view.update(3, 10, 7, ahead = 4, behind = 5, localFiles = 2, localAdditions = 9, localDeletions = 3, base = "origin/main") + layout(view) + + assertEquals(listOf("2 files", "-3", "+9", "4", "5", "3 files", "-7", "+10"), labels(view)) + val row = view.components.single() as Container + val order = row.components.toList() + assertEquals(5, order.size) + assertSame(groups(view).first(), order[0]) + assertTrue(order[1] is JSeparator) + assertEquals("4", (order[2] as JBLabel).text) + assertEquals("5", (order[3] as JBLabel).text) + assertSame(groups(view).last(), order[4]) + assertTrue(order.zipWithNext().all { (left, right) -> left.x + left.width <= right.x }) + assertNull(view.toolTipText) + } + + fun `test zero line files retain targets without empty stat pills`() = edt { + val view = ChangesPanel(ChangesPanel.Mode.FULL, onBase = {}, onLocal = {}) + view.update(1, 0, 0, localFiles = 2) + + assertEquals(listOf("2 files", "1 file"), labels(view)) + assertTrue(groups(view).all { it.isVisible && it.isFocusable }) + assertTrue(components(view).filterIsInstance().all { !it.isVisible }) + assertTrue(separator(view).isVisible) + } + + fun `test clean and missing data clear targets tooltips and separator`() = edt { + val view = ChangesPanel(ChangesPanel.Mode.FULL, onBase = {}, onLocal = {}) + view.update(1, 2, 3, ahead = 4, behind = 5, localFiles = 6, localAdditions = 7, localDeletions = 8) + view.update(0, 0, 0) + + assertFalse(view.isVisible) + assertFalse(separator(view).isVisible) + assertTrue(groups(view).all { !it.isVisible && !it.isFocusable && it.toolTipText == null }) + assertTrue(labels(view).isEmpty()) + + val compact = ChangesPanel(ChangesPanel.Mode.COMPACT, onBase = {}) + compact.update(1, 2, 0) + compact.update(0, 0, 0, ahead = 8, localFiles = 2) + assertFalse(compact.isVisible) + assertNull(compact.toolTipText) + } + + fun `test ahead behind remain independent from file groups`() = edt { + val view = ChangesPanel(ChangesPanel.Mode.FULL) + view.update(0, 0, 0, ahead = 2) + assertEquals(listOf("2"), labels(view)) + assertFalse(separator(view).isVisible) + + view.update(0, 0, 0, behind = 4, localFiles = 1) + assertEquals(listOf("1 file", "4"), labels(view)) + assertTrue(separator(view).isVisible) + + view.update(0, 0, 0, localFiles = 1) + assertEquals(listOf("1 file"), labels(view)) + assertFalse(separator(view).isVisible) + + view.update(1, 0, 0) + assertEquals(listOf("1 file"), labels(view)) + assertFalse(separator(view).isVisible) + } + + fun `test each side updates without stale counts or base tooltip`() = edt { + val view = ChangesPanel(ChangesPanel.Mode.FULL) + view.update(2, 5, 1, localFiles = 1, localAdditions = 2, base = "origin/main") + val groups = groups(view) + + view.update(2, 5, 1, localFiles = 3, localDeletions = 4, base = "origin/main") + assertEquals(listOf("3 files", "-4", "2 files", "-1", "+5"), labels(view)) + assertEquals(KiloBundle.message("worktree.dirty.tooltip", 3, 0, 4), groups.first().toolTipText) + + view.update(2, 5, 1, localFiles = 3, localDeletions = 4, base = "origin/trunk") + assertEquals(KiloBundle.message("worktree.stats.base.tooltip", 2, 5, 1, "origin/trunk"), groups.last().toolTipText) + assertEquals(groups, groups(view)) + } + + fun `test mouse children whitespace keyboard and accessibility activate once`() = edt { + var base = 0 + var local = 0 + val view = ChangesPanel(ChangesPanel.Mode.FULL, onBase = { base++ }, onLocal = { local++ }) + view.update(2, 3, 1, localFiles = 1, localAdditions = 4) + layout(view) + val groups = groups(view) + val left = groups.first() + val right = groups.last() + val targets = visible(right).filterIsInstance() + targets.forEach { click(it) } + assertEquals(targets.size, base) + assertEquals(0, local) + + click(left) + click(visible(left).filterIsInstance().first()) + key(left, KeyEvent.VK_ENTER) + key(left, KeyEvent.VK_SPACE) + assertEquals(4, local) + assertTrue(right.accessibleContext.accessibleAction.doAccessibleAction(0)) + assertFalse(right.accessibleContext.accessibleAction.doAccessibleAction(1)) + assertEquals(targets.size + 1, base) + assertEquals(StringUtil.stripHtml(right.toolTipText, " "), right.accessibleContext.accessibleName) + assertEquals(AccessibleRole.PUSH_BUTTON, right.accessibleContext.accessibleRole) + + val compact = ChangesPanel(ChangesPanel.Mode.COMPACT, onBase = { base++ }) + compact.update(1, 0, 0) + layout(compact) + click(compact) + assertEquals(targets.size + 2, base) + } + + fun `test disabled missing action and nonprimary clicks do not activate`() = edt { + var calls = 0 + val view = ChangesPanel(ChangesPanel.Mode.COMPACT, onBase = { calls++ }) + view.update(1, 2, 3) + layout(view) + val group = groups(view).single() + val child = visible(group).filterIsInstance().first() + click(child, button = MouseEvent.BUTTON3) + click(child, count = 2) + click(child, consumed = true) + child.isEnabled = false + click(child) + child.isEnabled = true + group.isEnabled = false + click(child) + key(group, KeyEvent.VK_ENTER) + assertEquals(Cursor.DEFAULT_CURSOR, group.cursor.type) + group.isEnabled = true + view.isEnabled = false + click(child) + key(group, KeyEvent.VK_SPACE) + assertFalse(group.accessibleContext.accessibleAction.doAccessibleAction(0)) + assertFalse(group.isFocusable) + assertEquals(Cursor.DEFAULT_CURSOR, child.cursor.type) + view.isEnabled = true + view.setActions(null) + click(child) + key(group, KeyEvent.VK_ENTER) + assertFalse(group.isFocusable) + assertEquals(0, group.accessibleContext.accessibleAction.accessibleActionCount) + assertEquals(0, calls) + view.setActions({ calls++ }) + click(child) + assertEquals(1, calls) + } + + fun `test equal aggregate updates still use rebound actions`() = edt { + val calls = mutableListOf() + val view = ChangesPanel(ChangesPanel.Mode.FULL, onBase = { calls += "old base" }, onLocal = { calls += "old local" }) + view.update(1, 0, 0, localFiles = 1) + view.setActions({ calls += "base" }, { calls += "local" }) + view.update(1, 0, 0, localFiles = 1) + layout(view) + groups(view).forEach { click(it) } + assertEquals(listOf("local", "base"), calls) + } + + fun `test child hover paints group and disabled or inert groups do not hover`() = edt { + val view = ChangesPanel(ChangesPanel.Mode.COMPACT, onBase = {}) + view.update(1, 0, 0) + layout(view) + val group = groups(view).single() + val child = visible(group).filterIsInstance().first() + val plain = pixels(group) + child.dispatchEvent(MouseEvent(child, MouseEvent.MOUSE_ENTERED, 0, 0, 1, 1, 0, false)) + assertFalse(plain.contentEquals(pixels(group))) + group.dispatchEvent(MouseEvent(group, MouseEvent.MOUSE_EXITED, 0, 0, -1, -1, 0, false)) + assertTrue(plain.contentEquals(pixels(group))) + view.setActions(null) + child.dispatchEvent(MouseEvent(child, MouseEvent.MOUSE_ENTERED, 0, 0, 1, 1, 0, false)) + assertTrue(plain.contentEquals(pixels(group))) + } + + fun `test identical data actions and fonts do not repaint or invalidate`() = edt { + val view = ChangesPanel(ChangesPanel.Mode.COMPACT, onBase = {}) + view.update(1, 2, 3) + val previous = RepaintManager.currentManager(view) + val tracker = Tracker(view) + RepaintManager.setCurrentManager(tracker) + try { + repeat(100) { + view.update(1, 2, 3, localFiles = it, ahead = it) + view.setActions({}) + view.font = view.font + } + assertEquals(0, tracker.invalidations) + assertEquals(0, tracker.paints) + } finally { + RepaintManager.setCurrentManager(previous) + } + } + + fun `test host styling preserves semantic diff colors and retained nodes through UI refresh`() = edt { + val view = ChangesPanel(ChangesPanel.Mode.FULL) + view.update(2, 3, 4, ahead = 1, localFiles = 1, localAdditions = 5) + val nodes = components(view) + val stats = nodes.filterIsInstance().flatMap(::components).filterIsInstance() + val colors = stats.map { it.foreground } + val font = JBFont.small().asBold() + val color = UiStyle.Colors.fg() + view.font = font + view.foreground = color + SwingUtilities.updateComponentTreeUI(view) + assertEquals(nodes, components(view)) + assertTrue(nodes.filterIsInstance().all { it.font == font }) + assertTrue(nodes.filterIsInstance().filter { it !in stats }.all { it.foreground == color }) + assertEquals(colors, stats.map { it.foreground }) + } + + fun `test retained groups fonts listeners and editor resources stay bounded under churn`() = edt { + val baseline = EditorFactory.getInstance().allEditors.size + val view = ChangesPanel(ChangesPanel.Mode.FULL, onBase = {}, onLocal = {}) + view.update(1, 2, 3, localFiles = 1, localAdditions = 2) + val nodes = components(view) + val listeners = nodes.map { it.mouseListeners.size } + val font = JBFont.small().asBold() + view.font = font + repeat(500) { n -> + view.setActions({}, {}) + view.update(n % 5, n % 11, n % 7, n % 3, n % 2, n % 4, n % 13, n % 17, "origin/main") + view.update(0, 0, 0) + } + view.update(1, 2, 3, localFiles = 1, localAdditions = 2) + assertEquals(nodes, components(view)) + assertEquals(listeners, nodes.map { it.mouseListeners.size }) + assertTrue(nodes.filterIsInstance().all { it.font == font }) + assertEquals(baseline, EditorFactory.getInstance().allEditors.size) + } + + @RequiresEdt + private fun components(root: Component): List = + listOf(root) + if (root is Container) root.components.flatMap(::components) else emptyList() + + @RequiresEdt + private fun visible(root: Component): List = + if (!root.isVisible) emptyList() else listOf(root) + if (root is Container) root.components.flatMap(::visible) else emptyList() + + @RequiresEdt + private fun labels(root: Component): List = visible(root).filterIsInstance().mapNotNull { it.text } + + @RequiresEdt + private fun groups(view: ChangesPanel): List = components(view).filterIsInstance() + .filter { it.accessibleContext?.accessibleRole == AccessibleRole.PUSH_BUTTON } + + @RequiresEdt + private fun separator(view: ChangesPanel): JSeparator = components(view).filterIsInstance().single() + + @RequiresEdt + private fun layout(view: ChangesPanel) { + view.setSize(view.preferredSize) + fun lay(component: Component) { + component.doLayout() + if (component is Container) component.components.forEach(::lay) + } + lay(view) + } + + @RequiresEdt + private fun click(target: JComponent, button: Int = MouseEvent.BUTTON1, count: Int = 1, consumed: Boolean = false) { + listOf(MouseEvent.MOUSE_PRESSED, MouseEvent.MOUSE_RELEASED, MouseEvent.MOUSE_CLICKED).forEach { id -> + val event = MouseEvent(target, id, 0, 0, target.width / 2, target.height / 2, count, false, button) + if (consumed) event.consume() + target.dispatchEvent(event) + } + } + + @RequiresEdt + private fun key(target: JComponent, key: Int) { + target.getActionForKeyStroke(KeyStroke.getKeyStroke(key, 0))!!.actionPerformed(ActionEvent(target, ActionEvent.ACTION_PERFORMED, "")) + } + + @RequiresEdt + private fun pixels(target: JComponent): IntArray { + val image = BufferedImage(target.width, target.height, BufferedImage.TYPE_INT_ARGB) + val graphics = image.createGraphics() + try { + target.paint(graphics) + } finally { + graphics.dispose() + } + return image.getRGB(0, 0, image.width, image.height, null, 0, image.width) + } + + private class Tracker(private val root: JComponent) : RepaintManager() { + var invalidations = 0 + var paints = 0 + + @RequiresEdt + override fun addInvalidComponent(component: JComponent) { + if (SwingUtilities.isDescendingFrom(component, root)) invalidations++ + super.addInvalidComponent(component) + } + + @RequiresEdt + override fun addDirtyRegion(component: JComponent, x: Int, y: Int, width: Int, height: Int) { + if (SwingUtilities.isDescendingFrom(component, root)) paints++ + super.addDirtyRegion(component, x, y, width, height) + } + } + + private fun edt(block: () -> T): T = edtWait(block) +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/HoverIconTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/HoverIconTest.kt new file mode 100644 index 000000000000..95d623bf0edc --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/HoverIconTest.kt @@ -0,0 +1,97 @@ +package ai.kilocode.client.ui + +import ai.kilocode.client.util.edtWait +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.util.ui.JBUI +import javax.swing.Icon +import javax.swing.JButton +import javax.swing.JPanel +import java.awt.Component +import java.awt.Graphics + +class HoverIconTest : BasePlatformTestCase() { + + fun `test icon only button is square`() = edtWait { + val icon = HoverIcon().apply { icon = square(16) } + + val size = icon.preferredSize + + assertEquals(size.height, size.width) + } + + fun `test match sizes to the height a labelled button would get`() = edtWait { + val labelled = JButton("Label").also { iconButton(it) } + val icon = HoverIcon().apply { + this.icon = square(16) + match = true + } + + assertEquals(labelled.preferredSize.height, icon.preferredSize.height) + assertEquals(icon.preferredSize.height, icon.preferredSize.width) + } + + fun `test match height does not follow the icon size`() = edtWait { + // A labelled sibling rarely carries the same icon size as the icon-only action beside it, so + // folding this button's own icon into the measurement makes the two drift apart on any LAF + // whose font is shorter than either icon. The matched height must depend only on font+insets. + val small = HoverIcon().apply { + icon = square(8) + match = true + } + val large = HoverIcon().apply { + icon = square(64) + match = true + } + + assertEquals(small.preferredSize.height, large.preferredSize.height) + } + + fun `test match ignores the icon while the plain square path still tracks it`() = edtWait { + val matched = HoverIcon().apply { + icon = square(64) + match = true + } + val square = HoverIcon().apply { icon = square(64) } + + // Only `match` opts out of icon-driven sizing; the default square path must still grow. + assertTrue(square.preferredSize.height > matched.preferredSize.height) + } + + fun `test measuring does not mutate this button`() = edtWait { + val icon = HoverIcon().apply { + this.icon = square(16) + match = true + } + // AbstractButton.setText fires a text property change and calls revalidate()/repaint(). Doing + // that from a size query invalidates this control and its ancestors on every layout pass, so + // measurement must not touch this button's own text at all. + val changes = mutableListOf() + icon.addPropertyChangeListener("text") { changes += "${it.oldValue} -> ${it.newValue}" } + JPanel().add(icon) + + repeat(5) { icon.preferredSize } + + assertEquals("measuring must not reassign text: $changes", emptyList(), changes) + assertTrue("measuring must not leave text behind", icon.text.isNullOrEmpty()) + } + + fun `test match height survives a look and feel change`() = edtWait { + val icon = HoverIcon().apply { + this.icon = square(16) + match = true + } + val before = icon.preferredSize.height + + // updateUI drops the cached probe; the next measurement rebuilds it under the current LAF. + icon.updateUI() + + assertEquals(before, icon.preferredSize.height) + assertTrue(icon.text.isNullOrEmpty()) + } + + private fun square(px: Int): Icon = object : Icon { + override fun paintIcon(c: Component?, g: Graphics?, x: Int, y: Int) = Unit + override fun getIconWidth(): Int = JBUI.scale(px) + override fun getIconHeight(): Int = JBUI.scale(px) + } +} diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/log/ChatLogSummary.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/log/ChatLogSummary.kt index 6c569ef89a60..1175f8727dd2 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/log/ChatLogSummary.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/log/ChatLogSummary.kt @@ -37,6 +37,7 @@ object ChatLogSummary { is ChatEventDto.SessionCompacted -> event.sessionID is ChatEventDto.SessionDiffChanged -> event.sessionID is ChatEventDto.TodoUpdated -> event.sessionID + is ChatEventDto.SessionInterrupted -> event.sessionID } fun dir(dir: String): String = "dirHash=${hash(dir)}" @@ -246,6 +247,12 @@ object ChatLogSummary { "evt=todo.updated", todos(event.todos), ) + + is ChatEventDto.SessionInterrupted -> join( + sid(event.sessionID), + "evt=session.interrupted", + "reason=${event.reason}", + ) } fun eventBody(event: ChatEventDto): String = event(event).substringAfter("evt=").let { body -> diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloSessionRpcApi.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloSessionRpcApi.kt index c96e4a26c343..125b4a424bf9 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloSessionRpcApi.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloSessionRpcApi.kt @@ -2,7 +2,6 @@ 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 @@ -140,9 +139,6 @@ interface KiloSessionRpcApi : RemoteApi { /** Subscribe to streaming chat events for a specific session. */ suspend fun events(id: String, directory: String): Flow - /** Update config (model, agent/mode, temperature). */ - suspend fun updateConfig(directory: String, config: ConfigUpdateDto) - // ------ permission / question resolution ------ /** Reply to a pending permission request (once, always, or reject). */ diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloWorkspaceRpcApi.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloWorkspaceRpcApi.kt index bebd2c386900..910aa2404429 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloWorkspaceRpcApi.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloWorkspaceRpcApi.kt @@ -5,6 +5,7 @@ import ai.kilocode.rpc.dto.DiffFileDto import ai.kilocode.rpc.dto.FileSearchResultDto import ai.kilocode.rpc.dto.KiloWorkspaceStateDto import ai.kilocode.rpc.dto.ModelsWorkspaceDto +import ai.kilocode.rpc.dto.SetupScriptTargetDto import ai.kilocode.rpc.dto.WorkspaceFileDto import com.intellij.platform.project.ProjectId import com.intellij.platform.rpc.RemoteApiProviderService @@ -55,14 +56,10 @@ interface KiloWorkspaceRpcApi : RemoteApi { /** 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 + suspend fun localDiff(directory: String, patches: Boolean = true): List + /** Current git branch name for branch-scoped UI labels. */ suspend fun branchName(directory: String): String? @@ -83,4 +80,10 @@ interface KiloWorkspaceRpcApi : RemoteApi { /** Open or create the global config file in the IDE. */ suspend fun openGlobalConfig(): Boolean + + /** Resolve the worktree setup script target for the repo rooted at [directory]. */ + suspend fun setupScriptTarget(directory: String): SetupScriptTargetDto + + /** Open or create the worktree setup script in the IDE. */ + suspend fun openSetupScript(directory: String): Boolean } diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloWorktreeRpcApi.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloWorktreeRpcApi.kt index bf2d90229fbd..3e84c967e811 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloWorktreeRpcApi.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloWorktreeRpcApi.kt @@ -8,6 +8,7 @@ import ai.kilocode.rpc.dto.MoveProgressDto import ai.kilocode.rpc.dto.RemoveWorktreeResultDto import ai.kilocode.rpc.dto.RenameWorktreeResultDto import ai.kilocode.rpc.dto.WorktreeBranchesDto +import ai.kilocode.rpc.dto.WorktreeDirtyListDto import ai.kilocode.rpc.dto.WorktreeListDto import ai.kilocode.rpc.dto.WorktreePrListDto import ai.kilocode.rpc.dto.WorktreeStatsListDto @@ -41,14 +42,26 @@ interface KiloWorktreeRpcApi : RemoteApi { suspend fun open(directory: String): Boolean suspend fun stats(directory: String): WorktreeStatsListDto - suspend fun ghStatus(directory: String): GhAvailability + + /** + * Uncommitted changes per managed worktree, vs each worktree's own HEAD. Separate from [stats] + * because the baseline differs (HEAD vs the base branch) and because it changes on every file + * save, so callers may refresh it independently. + */ + suspend fun dirty(directory: String): WorktreeDirtyListDto + /** + * Resolves gh/git availability for [directory]. When [github] is false, resolves git state + * only ([GhAvailability.GIT_MISSING] or [GhAvailability.OK]) and never spawns `gh`. + */ + suspend fun ghStatus(directory: String, github: Boolean = true): GhAvailability suspend fun prStatus(directory: String): WorktreePrListDto /** * Single-directory branch status for the chat branch/PR dock: current branch, worktree flag, * gh availability, and the PR for the branch (if any). Cached with the same TTL as [prStatus]. + * When [github] is false, resolves git state only and returns a null PR without spawning `gh`. */ - suspend fun branchStatus(directory: String): BranchStatusDto + suspend fun branchStatus(directory: String, github: Boolean = true): BranchStatusDto /** * Moves the session [sessionId] running in [directory] into a fresh worktree on [branch]: diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt index a48f05bc5ad5..4c288ed23aa8 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt @@ -316,6 +316,25 @@ sealed class ChatEventDto { val sessionID: String, val todos: List = emptyList(), ) : ChatEventDto() + + /** + * The CLI cancelled this session's turn on its own, for [reason]. + * + * Synthesized by the backend, never parsed from a CLI event. The CLI reports a server-side + * cancellation as `MessageAbortedError` — byte-identical to what a user Stop produces — so + * without this signal the UI silently renders "Stopped" for work nobody asked to stop. + */ + @Serializable + @SerialName("session.interrupted") + data class SessionInterrupted( + val sessionID: String, + val reason: String, + ) : ChatEventDto() { + companion object { + /** The CLI disposed its instances — a config, provider, or organization change — and cancelled every running turn. */ + const val RELOAD = "reload" + } + } } // --- Permission DTOs --- @@ -443,12 +462,3 @@ data class DiffFileDto( val before: String? = null, val after: String? = null, ) - -// --- Config Update --- - -@Serializable -data class ConfigUpdateDto( - val model: String? = null, - val agent: String? = null, - val temperature: Double? = null, -) diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/SetupScriptTargetDto.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/SetupScriptTargetDto.kt new file mode 100644 index 000000000000..796467c63234 --- /dev/null +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/SetupScriptTargetDto.kt @@ -0,0 +1,15 @@ +package ai.kilocode.rpc.dto + +import kotlinx.serialization.Serializable + +/** Which interpreter a resolved worktree setup script should be run through. */ +@Serializable +enum class SetupScriptKind { POSIX, POWERSHELL, CMD } + +@Serializable +data class SetupScriptTargetDto( + val path: String, + val displayPath: String, + val exists: Boolean, + val kind: SetupScriptKind = SetupScriptKind.POSIX, +) diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/WorktreeDto.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/WorktreeDto.kt index 54ee736293d1..28c4bde33c6a 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/WorktreeDto.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/WorktreeDto.kt @@ -20,16 +20,38 @@ data class WorktreeListDto(val worktrees: List = emptyList()) @Serializable data class WorktreeStatsDto( val path: String, + /** Committed additions vs `merge-base(base, HEAD)` — GitHub's "Files changed" number. */ val additions: Int = 0, val deletions: Int = 0, val ahead: Int = 0, val behind: Int = 0, val files: Int = 0, + /** Resolved base ref the counts are relative to, e.g. `origin/main`. Empty when unresolved. */ + val base: String = "", ) @Serializable data class WorktreeStatsListDto(val items: List = emptyList()) +/** + * Uncommitted state of one worktree, relative to its own HEAD — staged, unstaged, and untracked + * together. Deliberately separate from [WorktreeStatsDto], which is measured against the base branch. + */ +@Serializable +data class WorktreeDirtyDto( + val path: String, + val additions: Int = 0, + val deletions: Int = 0, + val files: Int = 0, + /** How many of [files] are untracked. Their additions are line counts; deletions are always 0. */ + val untracked: Int = 0, + /** Commits ahead of `@{upstream}`. 0 when the branch has no upstream. */ + val unpushed: Int = 0, +) + +@Serializable +data class WorktreeDirtyListDto(val items: List = emptyList()) + @Serializable enum class GhState { OPEN, DRAFT, MERGED, CLOSED }