diff --git a/.changeset/jetbrains-worktree-pr-detection.md b/.changeset/jetbrains-worktree-pr-detection.md new file mode 100644 index 00000000000..b1bfeef4339 --- /dev/null +++ b/.changeset/jetbrains-worktree-pr-detection.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Detect a worktree's pull request reliably in Agent Manager. Imported PRs — including PRs from forks — hand-made worktrees, and locally renamed branches now show their PR badge, the current repository row gets one too, and a freshly imported PR no longer waits out the status poll. Imported PR branches also get proper git tracking, so `git push` and `git pull` work in the new worktree. diff --git a/.changeset/jetbrains-worktree-tab-artifacts.md b/.changeset/jetbrains-worktree-tab-artifacts.md new file mode 100644 index 00000000000..15a3d02cae2 --- /dev/null +++ b/.changeset/jetbrains-worktree-tab-artifacts.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Stop the New Worktree dialog from flashing the previous tab's content when switching between New, From PR, and From Branch. 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 7c783ea59c0..3cbb16bf42c 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 @@ -3,6 +3,7 @@ package ai.kilocode.backend.rpc import ai.kilocode.backend.app.KiloBackendAppService import ai.kilocode.log.KiloLog import ai.kilocode.rpc.KiloWorktreeRpcApi +import ai.kilocode.rpc.parsePrUrl import ai.kilocode.rpc.dto.BranchStatusDto import ai.kilocode.rpc.dto.CreateWorktreeRequestDto import ai.kilocode.rpc.dto.CreateWorktreeResultDto @@ -72,6 +73,7 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi { private val bases = ConcurrentHashMap>() private val prs = ConcurrentHashMap>() private val branches = ConcurrentHashMap>() + private val resolver = PrResolver(gh = ::runGh, git = ::runGit) private val ghLock = Any() @Volatile private var ghProbe: Timed? = null @@ -153,8 +155,7 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi { val res = runGit(root, "worktree", "list", "--porcelain") if (!res.ok) return@withContext WorktreeStatsListDto() val items = managedWorktrees(parseWorktreeList(res.stdout)) - val main = items.firstOrNull { it.main } - val fallback = main?.branch?.takeIf { it.isNotBlank() && it != "(detached)" } ?: "HEAD" + val fallback = baseBranch(items) ?: "HEAD" WorktreeStatsListDto(parallel(items.filter { !it.main }) { item -> stats(item, fallback) }) } @@ -170,18 +171,17 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi { if (available != GhAvailability.OK) return@withContext WorktreePrListDto(available).also { prs[directory] = Timed(now, it) } val res = runGit(root, "worktree", "list", "--porcelain") if (!res.ok) return@withContext WorktreePrListDto().also { prs[directory] = Timed(now, it) } - val items = managedWorktrees(parseWorktreeList(res.stdout)).filter { !it.main && it.branch != "(detached)" } + val all = managedWorktrees(parseWorktreeList(res.stdout)) + val items = prTargets(all) + val base = baseBranch(all) var status = GhAvailability.OK val data = parallel(items) { item -> if (status != GhAvailability.OK) return@parallel null - val out = runGh(Path.of(item.path).normalize(), "pr", "view", item.branch, "--json", "number,state,isDraft,url,title") - if (!out.ok) { - // prError only ever returns UNAUTH or OK; a missing gh/git binary is already caught - // by the upfront ghAvailable() check before this loop runs. - if (prError(out.stderr) == GhAvailability.UNAUTH) status = GhAvailability.UNAUTH - return@parallel null - } - parsePr(item.path, out.stdout) + val lookup = resolver.resolve(item.path, item.branch, base) + // The resolver only ever reports UNAUTH or OK; a missing gh/git binary is already + // caught by the upfront ghAvailable() check before this loop runs. + if (lookup.availability != GhAvailability.OK) status = lookup.availability + lookup.pr }.filterNotNull() val dto = WorktreePrListDto(status, if (status == GhAvailability.OK) data else emptyList()) prs[directory] = Timed(System.currentTimeMillis(), dto) @@ -195,15 +195,18 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi { val branch = runGit(root, "branch", "--show-current").stdout.trim() val worktree = isLinkedWorktree(root) val availability = ghAvailable(root) - val pr = if (availability == GhAvailability.OK && branch.isNotBlank()) { - val out = runGh(root, "pr", "view", branch, "--json", "number,state,isDraft,url,title,headRefName") - // Only accept a PR whose head branch matches the current branch. Guards against gh - // resolving a PR via upstream/remote configuration that isn't for this branch. - if (out.ok && parsePrHeadRef(out.stdout) == branch) parsePr(directory, out.stdout) else null + val lookup = if (availability == GhAvailability.OK && branch.isNotBlank()) { + resolver.resolve(directory, branch, baseBranch(root)) } else { - null + PrLookup() } - val dto = BranchStatusDto(branch = branch, worktree = worktree, availability = availability, pr = pr) + val dto = BranchStatusDto( + branch = branch, + worktree = worktree, + // A PR lookup that hits an auth failure must not be reported as a branch without a PR. + availability = if (availability == GhAvailability.OK) lookup.availability else availability, + pr = lookup.pr, + ) branches[directory] = Timed(System.currentTimeMillis(), dto) dto } @@ -285,6 +288,23 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi { return Path.of(main.path).normalize() } + /** Branch checked out in the main working tree of the repo containing [root]. */ + private fun baseBranch(root: Path): String? { + val res = runGit(root, "worktree", "list", "--porcelain") + if (!res.ok) return null + return baseBranch(parseWorktreeList(res.stdout)) + } + + /** + * Drops the PR and branch caches so the next poll reflects a mutation immediately. Entries are + * keyed by the requesting directory and a mutation can change any repository the backend has + * answered for, so clear wholesale rather than by key. + */ + private fun invalidate() { + prs.clear() + branches.clear() + } + override suspend fun create(directory: String, request: CreateWorktreeRequestDto): CreateWorktreeResultDto = withContext(Dispatchers.IO) { val base = Path.of(directory).normalize() @@ -303,18 +323,18 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi { GhAvailability.UNAUTH -> return@withContext CreateWorktreeResultDto(error = "GitHub CLI (gh) is not authorized") GhAvailability.OK -> Unit } - val view = runGh(base, "pr", "view", ref.number.toString(), "--repo", "${ref.owner}/${ref.repo}", "--json", "headRefName,title") + val fields = "headRefName,title,isCrossRepository,headRepositoryOwner" + val view = runGh(base, "pr", "view", ref.number.toString(), "--repo", "${ref.owner}/${ref.repo}", "--json", fields) if (!view.ok) { LOG.warn("pr import view failed: url=$url exit=${view.exit} stderr=${view.stderr.trim()}") return@withContext CreateWorktreeResultDto(error = view.stderr.ifBlank { "gh pr view failed" }) } - val branch = parsePrHeadRef(view.stdout).ifBlank { "pr-${ref.number}" } - // The pull ref works for both same-repo and fork PRs without adding a fork remote; the - // leading '+' force-updates a stale local branch from a previous import attempt. - val fetch = runGit(base, "fetch", "origin", "+refs/pull/${ref.number}/head:$branch") - if (!fetch.ok) { - LOG.warn("pr import fetch failed: url=$url exit=${fetch.exit} stderr=${fetch.stderr.trim()}") - return@withContext CreateWorktreeResultDto(error = fetch.stderr.ifBlank { "git fetch failed" }) + val head = parsePrHead(view.stdout) + val branch = prBranchName(head, ref.number) + val failure = fetchPrBranch({ args -> runGit(base, args) }, ref.number, head, branch) + if (failure != null) { + LOG.warn("pr import fetch failed: url=$url exit=${failure.exit} stderr=${failure.stderr.trim()}") + return@withContext CreateWorktreeResultDto(error = failure.stderr.ifBlank { "Failed to check out the pull request branch" }) } addWorktree(base, branch, existing = true, baseRef = null) } @@ -347,6 +367,7 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi { return CreateWorktreeResultDto(error = res.stderr.ifBlank { "git worktree add failed" }) } LOG.info("worktree created: branch=$branch dir=$dir") + invalidate() val path = dir.toRealPath().toString() val list = runGit(base, "worktree", "list", "--porcelain") val items = if (list.ok) managedWorktrees(parseWorktreeList(list.stdout)) else emptyList() @@ -393,7 +414,7 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi { // worktree prunable when its admin metadata is stale while the files remain; those must still // be deleted so a later create of the same slug is not blocked by leftovers. val res = if (!Files.isDirectory(Path.of(target.path))) { - GitResult(0, "", "") + CmdOut(0, "", "") } else { runGit(base, "worktree", "remove", "--force", target.path) } @@ -411,6 +432,7 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi { if (!del.ok) LOG.warn("worktree branch delete failed: branch=$it exit=${del.exit} stderr=${del.stderr.trim()}") } LOG.info("worktree removed: path=$path branch=${branch ?: "(none)"}") + invalidate() removeWorktreeState(store, target.path) val prune = runGit(base, "worktree", "prune") if (!prune.ok) LOG.warn("worktree prune failed: exit=${prune.exit} stderr=${prune.stderr.trim()}") @@ -488,35 +510,35 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi { } } - private data class GitResult(val exit: Int, val stdout: String, val stderr: String) { - val ok get() = exit == 0 - } - private data class Timed(val time: Long, val value: T) - private fun runGit(base: Path, vararg args: String): GitResult { + 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) - GitResult(if (out.isTimeout) -1 else out.exitCode, out.stdout, out.stderr) + CmdOut(if (out.isTimeout) -1 else out.exitCode, out.stdout, out.stderr) } catch (e: Exception) { - GitResult(-1, "", e.message ?: "git failed") + CmdOut(-1, "", e.message ?: "git failed") } } - private fun runGh(base: Path, vararg args: String): GitResult { + private fun runGh(base: Path, vararg args: String): CmdOut = runGh(base, args.toList()) + + private fun runGh(base: Path, args: List): CmdOut { return try { val cmd = GeneralCommandLine(listOf("gh") + args) .withWorkDirectory(base.toFile()) .withParentEnvironmentType(ParentEnvironmentType.CONSOLE) val out = CapturingProcessHandler(cmd).runProcess(30_000) - GitResult(if (out.isTimeout) -1 else out.exitCode, out.stdout, out.stderr) + CmdOut(if (out.isTimeout) -1 else out.exitCode, out.stdout, out.stderr) } catch (e: Exception) { - GitResult(-1, "", e.message ?: "gh failed") + CmdOut(-1, "", e.message ?: "gh failed") } } - private fun add(base: Path, args: List): GitResult { + private fun add(base: Path, args: List): CmdOut { val first = runGit(base, *args.toTypedArray()) if (first.ok || !stale(first.stderr)) return first val prune = runGit(base, "worktree", "prune") @@ -613,13 +635,6 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi { value } - private fun prError(stderr: String): GhAvailability { - val text = stderr.lowercase() - if (text.contains("not logged") || text.contains("gh auth login") || text.contains("authentication")) return GhAvailability.UNAUTH - if (text.contains("not found") || text.contains("no pull requests found")) return GhAvailability.OK - return GhAvailability.OK - } - private fun snippet(text: String): String { return text.trim().replace(Regex("\\s+"), " ").take(180) } @@ -650,21 +665,72 @@ internal fun parsePr(path: String, raw: String): WorktreePrDto? { return WorktreePrDto(path, number, state, url, title) } -internal data class PrRef(val owner: String, val repo: String, val number: Int) +/** Head of a pull request being imported. */ +internal data class PrHead(val ref: String = "", val cross: Boolean = false, val owner: String = "") + +/** Reads the head branch and its repository out of a `gh pr view --json` payload. */ +internal fun parsePrHead(raw: String): PrHead { + val obj = runCatching { json.parseToJsonElement(raw) as? JsonObject }.getOrNull() ?: return PrHead() + val ref = obj["headRefName"]?.jsonPrimitive?.content?.trim().orEmpty() + val cross = obj["isCrossRepository"]?.jsonPrimitive?.booleanOrNull == true + val owner = (obj["headRepositoryOwner"] as? JsonObject)?.get("login")?.jsonPrimitive?.content?.trim().orEmpty() + return PrHead(ref, cross, owner) +} -private val PR_URL = Regex("github\\.com[/:]([^/]+)/([^/]+?)(?:\\.git)?/pull/(\\d+)") +/** + * Local branch name for an imported PR. Fork PRs are prefixed with their owner so two PRs sharing a + * head branch name — `patch-1` is common — can be imported side by side. + */ +internal fun prBranchName(head: PrHead, number: Int): String { + if (head.ref.isBlank()) return "pr-$number" + val owner = head.owner.lowercase() + return if (head.cross && owner.isNotEmpty()) "$owner/${head.ref}" else head.ref +} -/** Parses `https://github.com///pull/` (and ssh-style hosts) into its parts. */ -internal fun parsePrUrl(url: String): PrRef? { - val match = PR_URL.find(url.trim()) ?: return null - val number = match.groupValues[3].toIntOrNull() ?: return null - return PrRef(match.groupValues[1], match.groupValues[2], number) +/** + * Fetches the PR head into [branch] and records which PR it belongs to, mirroring `gh pr checkout`: + * a same-repo PR gets an ordinary upstream (so `git push`/`git pull` work in the imported worktree), + * while a fork PR is tracked through `refs/pull//head`, which `gh` resolves back to the PR + * by number. [run] executes git in the repository. Returns the failing command, or null on success. + */ +internal fun fetchPrBranch(run: (List) -> CmdOut, number: Int, head: PrHead, branch: String): CmdOut? { + val pull = "refs/pull/$number/head" + // A fork head lives in a repository we may have no remote for. The pull ref reaches it without + // adding one, and '+' force-updates a stale branch left by an earlier import attempt. + if (head.cross || head.ref.isBlank()) { + val fetch = run(listOf("fetch", "origin", "+$pull:$branch")) + if (!fetch.ok) return fetch + recordPrBranch(run, branch, pull) + return null + } + val tracking = "refs/remotes/origin/${head.ref}" + val direct = run(listOf("fetch", "origin", "+refs/heads/${head.ref}:$tracking")) + if (!direct.ok) { + // The head branch is gone — merged PR, or the author deleted it — but the pull ref survives. + val fallback = run(listOf("fetch", "origin", "+$pull:$tracking")) + if (!fallback.ok) return fallback + } + val point = run(listOf("branch", "--force", branch, tracking)) + if (!point.ok) return point + recordPrBranch(run, branch, if (direct.ok) "refs/heads/${head.ref}" else pull) + return null } -/** Reads `headRefName` out of a `gh pr view --json` payload. */ -internal fun parsePrHeadRef(raw: String): String { - val obj = runCatching { json.parseToJsonElement(raw) as? JsonObject }.getOrNull() ?: return "" - return obj["headRefName"]?.jsonPrimitive?.content?.trim().orEmpty() +/** + * Records the branch's remote and merge ref. This is what lets a PR be recognised later without + * guessing from the branch name, so a failure only degrades PR detection to slower lookups and must + * never fail the import. + */ +private fun recordPrBranch(run: (List) -> CmdOut, branch: String, merge: String) { + listOf( + listOf("config", "branch.$branch.remote", "origin"), + listOf("config", "branch.$branch.merge", merge), + ).forEach { args -> + val res = run(args) + if (!res.ok) { + KiloWorktreeRpcApiImpl.LOG.warn("pr import config failed: args=$args exit=${res.exit} stderr=${res.stderr.trim()}") + } + } } private val json = Json { prettyPrint = true; ignoreUnknownKeys = true } @@ -737,6 +803,20 @@ internal fun managedWorktrees(items: List): List { } } +/** + * Worktrees eligible for a PR lookup. The main working tree is included — it can sit on a PR branch + * just like a linked worktree — while detached heads have no branch to resolve and prunable entries + * have no checkout left. + */ +internal fun prTargets(items: List): List { + return items.filter { !it.prunable && it.branch != "(detached)" } +} + +/** Branch checked out in the main working tree, or null when it is missing or detached. */ +internal fun baseBranch(items: List): String? { + return items.firstOrNull { it.main }?.branch?.takeIf { it.isNotBlank() && it != "(detached)" } +} + internal fun overlayWorktreeNames(items: List, names: Map): List { if (names.isEmpty()) return items return items.map { item -> diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/PrResolver.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/PrResolver.kt new file mode 100644 index 00000000000..46d4270f7cd --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/PrResolver.kt @@ -0,0 +1,102 @@ +package ai.kilocode.backend.rpc + +import ai.kilocode.rpc.dto.GhAvailability +import ai.kilocode.rpc.dto.WorktreePrDto +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonPrimitive +import java.nio.file.Path + +/** Result of running a `git`/`gh` command. */ +internal data class CmdOut(val exit: Int, val stdout: String, val stderr: String) { + val ok get() = exit == 0 +} + +/** PR for one checkout, plus the gh availability observed while resolving it. */ +internal data class PrLookup(val pr: WorktreePrDto? = null, val availability: GhAvailability = GhAvailability.OK) + +internal const val PR_FIELDS = "number,state,isDraft,url,title" + +/** + * Resolves the pull request a checkout belongs to. A worktree can reach a PR in several ways — + * Kilo's PR import, `gh pr checkout`, a hand-made `git worktree add`, a branch renamed locally, a + * fork PR — so identity is resolved by branch config or head commit rather than by branch name + * alone, in increasing order of cost: + * + * 1. `gh pr view` with no selector. The only form that honours `branch..merge`, so it + * resolves `refs/pull/N/head` branches by PR number and fork PRs through the push remote. + * 2. `gh pr view `. Matches same-repo branches pushed to origin, no branch config needed. + * Cannot match a fork PR: gh compares against `owner:branch` for cross-repository heads. + * 3. `gh pr list --search ""`, accepting only an exact `headRefOid` match. + * + * Commands are injected so the strategy ladder is testable without `gh` or network access. + */ +internal class PrResolver( + private val gh: (Path, List) -> CmdOut, + private val git: (Path, List) -> CmdOut, +) { + /** + * Resolves the PR for the checkout at [path] on [branch]. [base] is the repository's base + * branch; a PR headed by it is not worth a search query, so strategy 3 is skipped there. + */ + fun resolve(path: String, branch: String, base: String?): PrLookup { + val dir = Path.of(path).normalize() + view(dir, path, null)?.let { return it } + view(dir, path, branch)?.let { return it } + if (branch == base) return PrLookup() + return search(dir, path) ?: PrLookup() + } + + /** Null means "no PR here, keep looking"; a value is terminal (a PR, or gh being unusable). */ + private fun view(dir: Path, path: String, branch: String?): PrLookup? { + val args = buildList { + add("pr") + add("view") + branch?.let { add(it) } + add("--json") + add(PR_FIELDS) + } + val out = gh(dir, args) + if (!out.ok) return unusable(out.stderr) + return parsePr(path, out.stdout)?.let { PrLookup(it) } + } + + private fun search(dir: Path, path: String): PrLookup? { + val head = git(dir, listOf("rev-parse", "HEAD")).stdout.trim() + if (head.isEmpty()) return null + val out = gh( + dir, + listOf("pr", "list", "--state", "all", "--search", "$head is:pr", "--limit", "5", "--json", "$PR_FIELDS,headRefOid"), + ) + if (!out.ok) return unusable(out.stderr) + val items = runCatching { json.parseToJsonElement(out.stdout) as? JsonArray }.getOrNull() ?: return null + for (item in items) { + val obj = item as? JsonObject ?: continue + // The search matches commit mentions too, so only an exact head match is our PR. + if (obj["headRefOid"]?.jsonPrimitive?.content != head) continue + parsePr(path, obj.toString())?.let { return PrLookup(it) } + } + return null + } + + private fun unusable(stderr: String): PrLookup? { + val status = prError(stderr) + return if (status == GhAvailability.OK) null else PrLookup(availability = status) + } +} + +/** + * Classifies a failing `gh pr` command. A missing PR is the normal case, so anything that is not a + * recognised authorization failure counts as OK — a missing `gh` binary is caught by the upfront + * availability probe instead. + */ +internal fun prError(stderr: String): GhAvailability { + val text = stderr.lowercase() + if (text.contains("not logged") || text.contains("gh auth login") || text.contains("authentication")) { + return GhAvailability.UNAUTH + } + return GhAvailability.OK +} + +private val json = Json { ignoreUnknownKeys = true } 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 dbaa975e970..89db0c7d006 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 @@ -1,5 +1,6 @@ package ai.kilocode.backend.rpc +import ai.kilocode.rpc.parsePrUrl import ai.kilocode.rpc.dto.CreateWorktreeRequestDto import ai.kilocode.rpc.dto.GhAvailability import ai.kilocode.rpc.dto.GhState @@ -22,11 +23,13 @@ import kotlin.test.assertTrue class KiloWorktreeRpcApiImplTest { private val repo: Path = Files.createTempDirectory("kilo-worktree") + private val remote: Path = Files.createTempDirectory("kilo-origin") private val api = KiloWorktreeRpcApiImpl() @AfterTest fun tearDown() { delete(repo) + delete(remote) } @Test @@ -593,9 +596,118 @@ class KiloWorktreeRpcApiImplTest { } @Test - fun `parsePrHeadRef reads headRefName`() { - assertEquals("feature/login", parsePrHeadRef("""{"headRefName":"feature/login","title":"x"}""")) - assertEquals("", parsePrHeadRef("not json")) + fun `parsePrHead reads head branch and repository`() { + val same = parsePrHead("""{"headRefName":"feature/login","title":"x","isCrossRepository":false}""") + assertEquals("feature/login", same.ref) + assertFalse(same.cross) + + val fork = parsePrHead( + """{"headRefName":"patch-1","isCrossRepository":true,"headRepositoryOwner":{"login":"Contributor"}}""", + ) + assertEquals("patch-1", fork.ref) + assertTrue(fork.cross) + assertEquals("Contributor", fork.owner) + + assertEquals(PrHead(), parsePrHead("not json")) + } + + @Test + fun `prBranchName prefixes fork heads and falls back to the pr number`() { + assertEquals("feature/login", prBranchName(PrHead("feature/login"), 7)) + assertEquals("contributor/patch-1", prBranchName(PrHead("patch-1", cross = true, owner = "Contributor"), 7)) + // A cross-repo PR whose owner gh did not report still needs a usable branch name. + assertEquals("patch-1", prBranchName(PrHead("patch-1", cross = true), 7)) + assertEquals("pr-7", prBranchName(PrHead(), 7)) + } + + @Test + fun `prTargets keeps the main tree and drops detached and prunable entries`() { + val items = listOf( + WorktreeDto("/repo", "repo", "main", "/repo", main = true), + WorktreeDto("/repo/.kilo/worktrees/a", "a", "feature/a", "/repo/.kilo/worktrees/a"), + WorktreeDto("/repo/.kilo/worktrees/detached", "detached", "(detached)", "/repo/.kilo/worktrees/detached"), + WorktreeDto("/repo/.kilo/worktrees/gone", "gone", "feature/gone", "/repo/.kilo/worktrees/gone", prunable = true), + ) + + assertEquals(listOf("/repo", "/repo/.kilo/worktrees/a"), prTargets(items).map { it.path }) + } + + @Test + fun `baseBranch reads the main tree branch and ignores a detached one`() { + val main = WorktreeDto("/repo", "repo", "main", "/repo", main = true) + val linked = WorktreeDto("/repo/.kilo/worktrees/a", "a", "feature/a", "/repo/.kilo/worktrees/a") + + assertEquals("main", baseBranch(listOf(main, linked))) + assertNull(baseBranch(listOf(main.copy(branch = "(detached)"), linked))) + assertNull(baseBranch(listOf(linked))) + } + + @Test + fun `fetchPrBranch tracks the head branch for a same-repo pull request`() { + initRepo() + val origin = originWith(pull = 7, head = "feature/login") + + val failure = fetchPrBranch(runner(repo), 7, PrHead("feature/login"), "feature/login") + + assertNull(failure, "same-repo import should succeed") + assertEquals("origin", config("branch.feature/login.remote")) + assertEquals("refs/heads/feature/login", config("branch.feature/login.merge")) + assertEquals( + head(origin, "refs/heads/feature/login"), + head(repo, "refs/heads/feature/login"), + "local branch should point at the fetched head", + ) + } + + @Test + fun `fetchPrBranch falls back to the pull ref when the head branch is gone`() { + initRepo() + val origin = originWith(pull = 7, head = "feature/login") + git(origin, "update-ref", "-d", "refs/heads/feature/login") + + val failure = fetchPrBranch(runner(repo), 7, PrHead("feature/login"), "feature/login") + + assertNull(failure, "import should fall back to the pull ref") + assertEquals("refs/pull/7/head", config("branch.feature/login.merge")) + assertEquals(head(origin, "refs/pull/7/head"), head(repo, "refs/heads/feature/login")) + } + + @Test + fun `fetchPrBranch tracks the pull ref for a fork pull request`() { + initRepo() + val origin = originWith(pull = 7, head = "patch-1") + // A fork head is not on origin at all; only the pull ref can reach it. + git(origin, "update-ref", "-d", "refs/heads/patch-1") + val fork = PrHead("patch-1", cross = true, owner = "contributor") + + val failure = fetchPrBranch(runner(repo), 7, fork, prBranchName(fork, 7)) + + assertNull(failure, "fork import should succeed") + assertEquals("origin", config("branch.contributor/patch-1.remote")) + assertEquals("refs/pull/7/head", config("branch.contributor/patch-1.merge")) + assertEquals(head(origin, "refs/pull/7/head"), head(repo, "refs/heads/contributor/patch-1")) + } + + @Test + fun `fetchPrBranch force updates a branch left by an earlier import`() { + initRepo() + val origin = originWith(pull = 7, head = "feature/login") + git(repo, "branch", "feature/login") + + val failure = fetchPrBranch(runner(repo), 7, PrHead("feature/login"), "feature/login") + + assertNull(failure, "re-import should refresh the stale branch") + assertEquals(head(origin, "refs/heads/feature/login"), head(repo, "refs/heads/feature/login")) + } + + @Test + fun `fetchPrBranch reports the failing command`() { + initRepo() + + val failure = fetchPrBranch(runner(repo), 7, PrHead("feature/login"), "feature/login") + + assertNotNull(failure, "a repo without origin cannot fetch a pull request") + assertFalse(failure.ok) } @Test @@ -752,6 +864,39 @@ class KiloWorktreeRpcApiImplTest { git(repo, "commit", "-m", "init") } + /** + * Builds an "origin" repository holding [head] plus a `refs/pull//head` ref pointing at it, + * the shape GitHub exposes for a pull request, and registers it as [repo]'s origin. + */ + private fun originWith(pull: Int, head: String): Path { + git(remote, "init") + git(remote, "config", "user.email", "test@kilo.ai") + git(remote, "config", "user.name", "Kilo Test") + Files.writeString(remote.resolve("README.md"), "origin") + git(remote, "add", "README.md") + git(remote, "commit", "-m", "init") + val base = output(remote, "branch", "--show-current").trim() + git(remote, "checkout", "-b", head) + Files.writeString(remote.resolve("pr.txt"), "pr work\n") + git(remote, "add", "pr.txt") + git(remote, "commit", "-m", "pr work") + git(remote, "update-ref", "refs/pull/$pull/head", "refs/heads/$head") + // Leave the PR head unchecked out so tests can delete it to emulate a deleted branch. + git(remote, "checkout", base) + git(repo, "remote", "add", "origin", remote.toString()) + return remote + } + + private fun runner(dir: Path): (List) -> CmdOut = { args -> + val cmd = GeneralCommandLine(listOf("git") + args).withWorkDirectory(dir.toFile()) + val out = CapturingProcessHandler(cmd).runProcess(30_000) + CmdOut(if (out.isTimeout) -1 else out.exitCode, out.stdout, out.stderr) + } + + private fun config(key: String): String = output(repo, "config", "--get", key).trim() + + private fun head(dir: Path, ref: String): String = output(dir, "rev-parse", ref).trim() + private fun git(dir: Path, vararg args: String) { val cmd = GeneralCommandLine(listOf("git") + args).withWorkDirectory(dir.toFile()) val out = CapturingProcessHandler(cmd).runProcess(30_000) diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/PrResolverTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/PrResolverTest.kt new file mode 100644 index 00000000000..8c0de57dbc5 --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/PrResolverTest.kt @@ -0,0 +1,120 @@ +package ai.kilocode.backend.rpc + +import ai.kilocode.rpc.dto.GhAvailability +import ai.kilocode.rpc.dto.GhState +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class PrResolverTest { + private val path = "/repo/.kilo/worktrees/feature-x" + private val calls = mutableListOf>() + + @Test + fun `resolves through branch config without falling back`() { + val resolver = resolver(view = { pr(7, "OPEN") }) + + val lookup = resolver.resolve(path, "feature/x", base = "main") + + val pull = assertNotNull(lookup.pr) + assertEquals(7, pull.number) + assertEquals(path, pull.path) + assertEquals(GhState.OPEN, pull.state) + // The config-driven form answered, so the branch selector and the search never run. + assertEquals(listOf(listOf("pr", "view", "--json", PR_FIELDS)), calls) + } + + @Test + fun `falls back to the branch selector when config resolves nothing`() { + val resolver = resolver(view = { args -> if (args.contains("feature/x")) pr(8, "DRAFT") else missing() }) + + val lookup = resolver.resolve(path, "feature/x", base = "main") + + assertEquals(8, assertNotNull(lookup.pr).number) + assertEquals(GhState.DRAFT, lookup.pr?.state) + assertEquals(2, calls.size, "the head search should not run once the branch selector answered") + } + + @Test + fun `falls back to searching the head commit`() { + val resolver = resolver( + view = { missing() }, + list = { ok("""[{"number":9,"state":"MERGED","isDraft":false,"url":"https://pr/9","title":"Fork work","headRefOid":"$SHA"}]""") }, + ) + + val lookup = resolver.resolve(path, "renamed-locally", base = "main") + + val pull = assertNotNull(lookup.pr, "an exact head match should resolve the PR") + assertEquals(9, pull.number) + assertEquals(GhState.MERGED, pull.state) + assertTrue(calls.any { it.contains("$SHA is:pr") }, "the search should use the head sha") + } + + @Test + fun `rejects a search hit whose head commit differs`() { + val resolver = resolver( + view = { missing() }, + // The GitHub search also matches PRs that merely mention the commit. + list = { ok("""[{"number":9,"state":"OPEN","isDraft":false,"url":"https://pr/9","headRefOid":"deadbeef"}]""") }, + ) + + assertNull(resolver.resolve(path, "renamed-locally", base = "main").pr) + } + + @Test + fun `skips the head search for the base branch`() { + val resolver = resolver(view = { missing() }, list = { throw IllegalStateException("must not search") }) + + assertNull(resolver.resolve("/repo", "main", base = "main").pr) + assertEquals(2, calls.size, "only the two view forms should run for the base branch") + } + + @Test + fun `reports an authorization failure instead of a missing pull request`() { + val resolver = resolver(view = { CmdOut(1, "", "gh auth login required") }) + + val lookup = resolver.resolve(path, "feature/x", base = "main") + + assertNull(lookup.pr) + assertEquals(GhAvailability.UNAUTH, lookup.availability) + assertEquals(1, calls.size, "an unusable gh must stop the ladder immediately") + } + + @Test + fun `treats a missing pull request as a clean result`() { + val resolver = resolver(view = { missing() }, list = { ok("[]") }) + + val lookup = resolver.resolve(path, "feature/x", base = "main") + + assertNull(lookup.pr) + assertEquals(GhAvailability.OK, lookup.availability) + } + + private fun resolver( + view: (List) -> CmdOut, + list: (List) -> CmdOut = { ok("[]") }, + ): PrResolver = PrResolver( + gh = { _, args -> + calls.add(args) + if (args.getOrNull(1) == "list") list(args) else view(args) + }, + git = { _, args -> + calls.add(args) + assertEquals(listOf("rev-parse", "HEAD"), args) + ok("$SHA\n") + }, + ) + + private fun pr(number: Int, state: String): CmdOut = + ok("""{"number":$number,"state":"$state","isDraft":${state == "DRAFT"},"url":"https://pr/$number","title":"Work"}""") + + private fun ok(stdout: String) = CmdOut(0, stdout, "") + + private fun missing() = CmdOut(1, "", "no pull requests found for branch \"feature/x\"") + + private companion object { + const val SHA = "1111111111111111111111111111111111111111" + } +} 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 c575dcb7c39..8f44a2c8f87 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 @@ -1,8 +1,11 @@ package ai.kilocode.client.agentManager import ai.kilocode.client.KiloNotifications +import ai.kilocode.client.agentManager.worktree.CreateFailure +import ai.kilocode.client.agentManager.worktree.CreateKind import ai.kilocode.client.agentManager.worktree.NewWorktreeDialog import ai.kilocode.client.agentManager.worktree.NewWorktreeHandle +import ai.kilocode.client.agentManager.worktree.NewWorktreePlan import ai.kilocode.client.agentManager.worktree.GhBanner import ai.kilocode.client.agentManager.worktree.WorktreeController import ai.kilocode.client.agentManager.worktree.WorktreeDataKeys @@ -24,6 +27,7 @@ import ai.kilocode.client.diff.diffParams import ai.kilocode.client.diff.ensureDiffEditorKind import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.session.SessionActivityKind +import ai.kilocode.client.telemetry.Telemetry import ai.kilocode.client.ui.UiStyle import ai.kilocode.client.ui.list.ActiveList import ai.kilocode.client.ui.list.ActiveListBadge @@ -133,6 +137,13 @@ class AgentManagerPanel( if (list.select(key)) list.focusList() item(key)?.takeIf { controller.progress(it.id) == null }?.let { open(it, focus = false) } } + // 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 = { + project?.service()?.refreshStats() + project?.service()?.refreshPr(force = true) + } + controller.onReload = { sync() } controller.onCreateFailure = { err -> notifyCreateFailed(err) } controller.onMoveFailure = { err -> notifyMoveFailed(err) } controller.onRemoveSuccess = { item, index -> onRemoved(item, index) } @@ -183,7 +194,17 @@ class AgentManagerPanel( if (!handle.showAndGet()) return val plan = handle.result() ?: return onCreate() - controller.create(plan.branch, plan.base, prompt = plan.prompt) + when (plan) { + is NewWorktreePlan.Create -> controller.create(plan.branch, plan.base, prompt = plan.prompt) + is NewWorktreePlan.Branch -> { + Telemetry.send("Worktree Import Submitted", mapOf("kind" to "branch")) + controller.importBranch(plan.branch) + } + is NewWorktreePlan.Pr -> { + Telemetry.send("Worktree Import Submitted", mapOf("kind" to "pr")) + controller.importPr(plan.url) + } + } } internal fun move(sessionId: String?, directory: String) = controller.move(sessionId, directory) @@ -241,7 +262,7 @@ class AgentManagerPanel( /** The PR URL for [item], or null when it has none or is not in a stable, openable state. */ private fun prUrl(item: WorktreeDto?): String? { - if (item == null || item.main) return null + if (item == null) return null if (controller.progress(item.id) != null) return null return prs[normalizeWorktreePath(item.path)]?.url } @@ -322,8 +343,13 @@ class AgentManagerPanel( return controller.model.getElementAt(index.coerceIn(0, size - 1)) } - private fun notifyCreateFailed(err: String?) { - KiloNotifications.error(project, KiloBundle.message("worktree.create.failed.title"), err) + private fun notifyCreateFailed(failure: CreateFailure) { + val title = when (failure.kind) { + CreateKind.CREATE -> KiloBundle.message("worktree.create.failed.title") + CreateKind.BRANCH -> KiloBundle.message("worktree.import.branch.failed.title", failure.branch) + CreateKind.PR -> KiloBundle.message("worktree.import.pr.failed.title") + } + KiloNotifications.error(project, title, failure.error) } private fun notifyMoveFailed(err: String?) { @@ -374,7 +400,8 @@ class AgentManagerPanel( progress = null, kind = controller.kind(item.path), stats = null, - pr = null, + // The main checkout can sit on a PR branch just like a worktree can. + pr = prs[normalizeWorktreePath(item.path)], current = true, ) } @@ -434,6 +461,8 @@ class AgentManagerPanel( override fun dispose() { controller.onSelect = null + controller.onCreated = null + controller.onReload = null controller.onCreateFailure = null controller.onMoveFailure = null controller.onRemoveSuccess = null diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/BranchPicker.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/BranchPicker.kt new file mode 100644 index 00000000000..beafaea6797 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/BranchPicker.kt @@ -0,0 +1,127 @@ +package ai.kilocode.client.agentManager.worktree + +import ai.kilocode.client.session.ui.prompt.PromptFuzzyRanker +import com.intellij.openapi.ui.ComboBox +import com.intellij.ui.DocumentAdapter +import java.awt.Dimension +import java.awt.event.FocusAdapter +import java.awt.event.FocusEvent +import javax.swing.ComboBoxModel +import javax.swing.DefaultComboBoxModel +import javax.swing.JTextField +import javax.swing.event.DocumentEvent +import javax.swing.plaf.basic.BasicComboBoxUI +import javax.swing.plaf.basic.BasicComboPopup + +internal class BranchPicker(branches: List, private val default: String = "") : + ComboBox(model(branches, default)) { + private val branches = ordered(branches, default) + private val set = this.branches.toSet() + private var syncing = false + + val empty: Boolean get() = branches.isEmpty() + + init { + isEditable = true + if (default.isNotBlank()) selectedItem = default + wire() + } + + override fun getPreferredSize(): Dimension { + ensureEditor() + return super.getPreferredSize() + } + + fun resolve(): String? { + val value = text() + if (value.isEmpty()) { + val fallback = default.trim() + if (fallback.isNotEmpty()) set(fallback) + return fallback.takeIf { it.isNotEmpty() } + } + if (value in set) return value + val idx = match(value) ?: return value + val target = branches[idx] + set(target) + return target + } + + fun known(value: String?): Boolean = value == null || value in set + + fun focusText() { + field()?.apply { + requestFocusInWindow() + selectAll() + } + } + + private fun wire() { + val field = field() ?: return + field.document.addDocumentListener(object : DocumentAdapter() { + override fun textChanged(e: DocumentEvent) { + if (!syncing) sync(field.text, popup = true) + } + }) + field.addFocusListener(object : FocusAdapter() { + override fun focusLost(e: FocusEvent) { + restore() + } + }) + } + + private fun restore() { + if (default.isBlank() || text().isNotEmpty()) return + set(default) + } + + private fun sync(text: String, popup: Boolean) { + val value = text.trim() + if (value.isEmpty()) return + if (popup && isShowing && !isPopupVisible) isPopupVisible = true + val idx = match(value) ?: return + val list = popupList() ?: return + if (list.selectedIndex != idx) list.selectedIndex = idx + list.ensureIndexIsVisible(idx) + } + + private fun match(text: String): Int? { + val rank = PromptFuzzyRanker(text) + return branches.withIndex().mapNotNull { item -> + rank.score(item.value, emptyList())?.let { score -> item.index to score } + }.maxByOrNull { it.second }?.first + } + + private fun popupList() = (getAccessibleContext()?.getAccessibleChild(0) as? BasicComboPopup)?.list + + private fun field() = editor.editorComponent as? JTextField + + private fun text() = field()?.text?.trim() ?: editor.item?.toString()?.trim().orEmpty() + + private fun ensureEditor() { + if (!isEditable) return + val ui = ui as? BasicComboBoxUI ?: return + val comp = editor.editorComponent ?: return + if (components.none { it === comp }) ui.addEditor() + } + + private fun set(value: String) { + syncing = true + try { + selectedItem = value + field()?.text = value + } finally { + syncing = false + } + } +} + +private fun ordered(branches: List, default: String): List { + val ordered = LinkedHashSet() + if (default.isNotBlank()) ordered.add(default) + ordered.addAll(branches) + return ordered.toList() +} + +private fun model(branches: List, default: String): ComboBoxModel { + return DefaultComboBoxModel(ordered(branches, default).toTypedArray()) +} 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 0f7366015c8..05be98ba1f3 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 @@ -10,22 +10,29 @@ import ai.kilocode.client.session.ui.model.ModelPicker import ai.kilocode.client.session.ui.model.modelItems import ai.kilocode.client.session.ui.prompt.KiloPromptCompletionProvider import ai.kilocode.client.session.ui.prompt.MentionAction -import ai.kilocode.client.session.ui.prompt.PromptFuzzyRanker import ai.kilocode.client.session.ui.prompt.PromptPanel import ai.kilocode.client.session.ui.prompt.SlashAction +import ai.kilocode.client.settings.base.BaseContentPanel +import ai.kilocode.client.settings.base.SettingsRows +import ai.kilocode.client.settings.base.SettingsStackedRow import ai.kilocode.client.ui.UiStyle import ai.kilocode.client.ui.layout.Stack import ai.kilocode.rpc.dto.ModelsWorkspaceDto +import ai.kilocode.rpc.parsePrUrl import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.application.ModalityState import com.intellij.openapi.components.service import com.intellij.openapi.project.Project -import com.intellij.openapi.ui.ComboBox import com.intellij.openapi.ui.DialogWrapper -import com.intellij.ui.DocumentAdapter import com.intellij.ui.components.JBTextField +import com.intellij.ui.tabs.JBTabs +import com.intellij.ui.tabs.JBTabsFactory +import com.intellij.ui.tabs.JBTabsPosition +import com.intellij.ui.tabs.TabInfo +import com.intellij.ui.tabs.TabsListener import com.intellij.util.ui.FormBuilder import com.intellij.util.ui.JBUI +import com.intellij.util.ui.components.BorderLayoutPanel import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -33,19 +40,16 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.launch import java.awt.Component import java.awt.GridBagConstraints -import java.awt.event.FocusAdapter -import java.awt.event.FocusEvent -import javax.swing.ComboBoxModel -import javax.swing.DefaultComboBoxModel import javax.swing.JComponent -import javax.swing.JTextField -import javax.swing.event.DocumentEvent -import javax.swing.plaf.basic.BasicComboPopup private const val NAME_COLUMNS = 67 /** What the user confirmed in the New Worktree dialog. */ -data class NewWorktreePlan(val branch: String, val base: String?, val prompt: PendingPrompt?) +sealed interface NewWorktreePlan { + data class Create(val branch: String, val base: String?, val prompt: PendingPrompt?) : NewWorktreePlan + data class Branch(val branch: String) : NewWorktreePlan + data class Pr(val url: String) : NewWorktreePlan +} /** * The New Worktree dialog as seen by its caller: show it, then read what the user confirmed. @@ -57,10 +61,16 @@ interface NewWorktreeHandle { } /** - * New Worktree dialog with parity to the VS Code Agent Manager dialog: a worktree name (top), an - * initial prompt with the same mode / model / reasoning pickers as the chat prompt (center), and the - * branch name + base branch (bottom). Creating a worktree starts a session automatically with the - * prompt. + * New Worktree dialog with parity to the VS Code Agent Manager dialog, split into three tabs: + * + * - **New** creates a branch: a worktree name (top), an initial prompt with the same mode / model / + * reasoning pickers as the chat prompt (center), and the branch name + base branch (bottom). + * Creating a worktree starts a session automatically with the prompt. + * - **From PR** checks out a GitHub pull request by URL. + * - **From Branch** checks out a local branch that no worktree holds yet. + * + * Both import tabs carry no initial prompt, so the worktree opens with an empty session. The + * selected tab alone decides which input the OK button acts on. * * The dialog performs no worktree work itself — it records the confirmed [result] and closes; the * panel then drives the controller, so no view switch or worktree work runs while the modal dialog @@ -103,13 +113,12 @@ internal class NewWorktreeDialog( showEnhance = false, ) private val branch = JBTextField(suggestedName) - private val bases = baseBranches(branches, defaultBase) - private val baseSet = bases.toSet() - private val base = ComboBox(baseModel(bases)).apply { - isEditable = true - selectedItem = defaultBase + private val base = BranchPicker(branches, defaultBase) + private val url = JBTextField().apply { + emptyText.text = KiloBundle.message("worktree.import.pr.placeholder") } - private var syncing = false + private val pick = BranchPicker(branches) + private var tab = DialogTab.NEW private var plan: NewWorktreePlan? = null @@ -128,25 +137,25 @@ internal class NewWorktreeDialog( private var center: JComponent? = null init { - wireBase() + if (pick.empty) pick.isEnabled = false title = KiloBundle.message("worktree.configure.title") init() setOKButtonText(KiloBundle.message("worktree.dialog.create")) } - override fun createCenterPanel(): JComponent = content().also { center = it } + override fun createCenterPanel(): JComponent = tabs().also { center = it } /** The built content, so tests can drive the real Swing tree before the dialog is shown. */ internal fun centerComponent(): JComponent = center ?: error("center panel not built") override fun result(): NewWorktreePlan? = plan - override fun getPreferredFocusedComponent(): JComponent = prompt.defaultFocusedComponent + override fun getPreferredFocusedComponent(): JComponent = focus() // Versioned: DialogWrapper persists the size per key, so a stale entry would keep the old width. - override fun getDimensionServiceKey(): String = "ai.kilocode.NewWorktreeDialog.v2" + override fun getDimensionServiceKey(): String = "ai.kilocode.NewWorktreeDialog.v3" - override fun doOKAction() = submitCreate() + override fun doOKAction() = submit() override fun dispose() { disposed = true @@ -154,7 +163,50 @@ internal class NewWorktreeDialog( super.dispose() } - private fun content(): JComponent { + internal fun submit() { + setErrorText(null) + when (tab) { + DialogTab.PR -> submitPr() + DialogTab.BRANCH -> submitBranch() + DialogTab.NEW -> submitCreate() + } + } + + 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")) + 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) + addTab(local).setPreferredFocusableComponent(pick) + addListener(object : TabsListener { + override fun beforeSelectionChanged(oldSelection: TabInfo?, newSelection: TabInfo?) { + // JBTabs defers removing the old body while focus settles, and that body keeps + // its previous bounds. Hide it before layout so stale content cannot paint over + // the newly selected tab. + newSelection?.component?.isVisible = true + oldSelection?.component?.isVisible = false + } + + override fun selectionChanged(oldSelection: TabInfo?, newSelection: TabInfo?) { + tab = when { + newSelection === pr -> DialogTab.PR + newSelection === local -> DialogTab.BRANCH + else -> DialogTab.NEW + } + setOKButtonText(KiloBundle.message(if (tab == DialogTab.NEW) "worktree.dialog.create" else "worktree.dialog.import")) + ui { focus().requestFocusInWindow() } + } + }, disposable) + } + return tabs.component + } + + private fun newContent(): JComponent { wirePickers() loadModels() return Stack.vertical(gap = UiStyle.Gap.pad()) @@ -164,6 +216,27 @@ internal class NewWorktreeDialog( .apply { border = JBUI.Borders.empty(UiStyle.Gap.sm()) } } + private fun prContent(): JComponent = importContent(SettingsStackedRow( + KiloBundle.message("worktree.import.pr.section"), + description = KiloBundle.message("worktree.import.pr.description"), + value = url, + )) + + private fun branchContent(): JComponent = importContent(SettingsStackedRow( + KiloBundle.message("worktree.import.branch.section"), + description = KiloBundle.message(if (pick.empty) "worktree.import.branch.empty" else "worktree.import.branch.description"), + value = pick, + )) + + private fun importContent(row: JComponent): JComponent { + val body = BaseContentPanel().apply { + border = JBUI.Borders.empty(UiStyle.Gap.pad(), UiStyle.Gap.sm(), UiStyle.Gap.pad(), UiStyle.Gap.sm()) + } + body.next(SettingsRows().row(row)) + // Pinned to the top: the import forms are shorter than the New tab, which sizes the dialog. + return BorderLayoutPanel().apply { addToTop(body) } + } + // A FormBuilder that stretches every field to the full width, so the base-branch combo matches // the name field and prompt above it. private fun fields(): JComponent = object : FormBuilder() { @@ -229,99 +302,60 @@ internal class NewWorktreeDialog( ) } - private fun wireBase() { - val field = baseField() ?: return - field.document.addDocumentListener(object : DocumentAdapter() { - override fun textChanged(e: DocumentEvent) { - if (!syncing) syncBase(field.text, popup = true) - } - }) - field.addFocusListener(object : FocusAdapter() { - override fun focusLost(e: FocusEvent) { - restoreBase() - } - }) - } - - private fun restoreBase() { - if (baseText().isNotEmpty() || defaultBase.isBlank()) return - setBase(defaultBase) - } - - private fun syncBase(text: String, popup: Boolean) { - val value = text.trim() - if (value.isEmpty()) return - if (popup && base.isShowing && !base.isPopupVisible) { - base.isPopupVisible = true - } - val idx = matchBase(value) ?: return - val list = popupList() ?: return - if (list.selectedIndex != idx) list.selectedIndex = idx - list.ensureIndexIsVisible(idx) - } - - private fun matchBase(text: String): Int? { - val rank = PromptFuzzyRanker(text) - return bases.withIndex().mapNotNull { item -> - rank.score(item.value, emptyList())?.let { score -> item.index to score } - }.maxByOrNull { it.second }?.first - } - - private fun popupList() = (base.accessibleContext?.getAccessibleChild(0) as? BasicComboPopup)?.list - - private fun baseField() = base.editor.editorComponent as? JTextField - - private fun baseText() = baseField()?.text?.trim() - ?: base.editor.item?.toString()?.trim().orEmpty() - - private fun setBase(value: String) { - syncing = true - try { - base.selectedItem = value - baseField()?.text = value - } finally { - syncing = false - } - } - - private fun resolvedBase(): String? { - val value = baseText() - if (value.isEmpty()) { - val fallback = defaultBase.trim() - if (fallback.isNotEmpty()) setBase(fallback) - return fallback.takeIf { it.isNotEmpty() } - } - if (value in baseSet) return value - val idx = matchBase(value) ?: return value - val target = bases[idx] - setBase(target) - return target - } - private fun validBase(value: String?): Boolean { - if (value == null || value in baseSet) return true + if (base.known(value)) return true KiloNotifications.error( project, KiloBundle.message("worktree.configure.base.invalid.title"), - KiloBundle.message("worktree.configure.base.invalid.content", value), + KiloBundle.message("worktree.configure.base.invalid.content", value.orEmpty()), ) - baseField()?.apply { - requestFocusInWindow() - selectAll() - } - syncBase(value, popup = true) + base.focusText() return false } private fun submitCreate(text: String = prompt.text()) { val explicit = branch.text.trim() val resolved = explicit.ifEmpty { name.text.trim() }.ifEmpty { suggestedName } - val target = resolvedBase() + val target = base.resolve() if (!validBase(target)) return - plan = NewWorktreePlan(resolved, target, pending(text)) + plan = NewWorktreePlan.Create(resolved, target, pending(text)) + close(OK_EXIT_CODE) + } + + private fun submitPr() { + val value = url.text.trim() + if (value.isEmpty()) { + setErrorText(KiloBundle.message("worktree.import.pr.required"), url) + url.requestFocusInWindow() + return + } + if (parsePrUrl(value) == null) { + setErrorText(KiloBundle.message("worktree.import.pr.invalid"), url) + url.requestFocusInWindow() + url.selectAll() + return + } + plan = NewWorktreePlan.Pr(value) close(OK_EXIT_CODE) } + private fun submitBranch() { + val target = pick.resolve() + if (target == null || !pick.known(target)) { + setErrorText(KiloBundle.message("worktree.import.branch.invalid"), pick) + pick.focusText() + return + } + plan = NewWorktreePlan.Branch(target) + close(OK_EXIT_CODE) + } + + private fun focus(): JComponent = when (tab) { + DialogTab.PR -> url + DialogTab.BRANCH -> pick + DialogTab.NEW -> prompt.defaultFocusedComponent + } + /** Bundles the typed prompt with the picked mode / model / reasoning, or null when empty. */ private fun pending(text: String): PendingPrompt? { val body = text.trim() @@ -361,16 +395,7 @@ internal class NewWorktreeDialog( spec.available, ) - private fun baseBranches(branches: List, default: String): List { - val ordered = LinkedHashSet() - if (default.isNotBlank()) ordered.add(default) - ordered.addAll(branches) - return ordered.toList() - } - - private fun baseModel(branches: List): ComboBoxModel { - return DefaultComboBoxModel(branches.toTypedArray()) - } - private fun variantTitle(value: String): String = value.replaceFirstChar { it.titlecase() } + + private enum class DialogTab { NEW, PR, BRANCH } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeController.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeController.kt index a0ba0f8fb4c..a43fdd70465 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeController.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeController.kt @@ -20,6 +20,10 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch +enum class CreateKind { CREATE, BRANCH, PR } + +data class CreateFailure(val error: String?, val kind: CreateKind, val branch: String) + /** * Owns the worktree list model and drives the [KiloWorktreeService] off the EDT. Model mutations * are marshalled back onto the EDT via [edt]. Mirrors the History stack's controller shape. @@ -37,7 +41,17 @@ class WorktreeController( private val tasks = LinkedHashMap() private val moves = LinkedHashSet() var onSelect: ((String) -> Unit)? = null - var onCreateFailure: ((String?) -> Unit)? = null + + /** Fired on the EDT once a worktree exists, so callers can refresh state git just changed. */ + var onCreated: ((WorktreeDto) -> Unit)? = null + + /** + * Fired on the EDT after a reload settled. Replacing the model only notifies list listeners when + * the rows actually changed, so a repo whose sole worktree is the main one would otherwise never + * render its [current] row. + */ + var onReload: (() -> Unit)? = null + var onCreateFailure: ((CreateFailure) -> Unit)? = null var onMoveFailure: ((String?) -> Unit)? = null var onRemoveSuccess: ((WorktreeDto, Int) -> Unit)? = null var onActivityChanged: (() -> Unit)? = null @@ -97,6 +111,7 @@ class WorktreeController( val worktreeBranches = rows.mapTo(HashSet()) { it.branch } branches = branchInfo.branches.filter { it !in worktreeBranches } known = branchInfo.branches.toMutableSet().apply { addAll(rows.map { it.branch }) } + onReload?.invoke() telemetry("Worktree List Loaded", mapOf("count" to extra.size.toString())) } } @@ -109,13 +124,19 @@ class WorktreeController( fun quickCreate() = create(suggestName(), defaultBranch) /** Imports a worktree that checks out an existing local branch. */ - fun importBranch(branch: String) = create(branch, base = null, existingBranch = true) + fun importBranch(branch: String) = create(branch, base = null, existingBranch = true, kind = CreateKind.BRANCH) /** * Creates a worktree. When [prompt] is set, it is stashed for the worktree's first session so the * editor auto-sends it once it opens with its picked mode/model (see [PendingWorktreePrompt]). */ - fun create(branch: String, base: String?, existingBranch: Boolean = false, prompt: PendingPrompt? = null) { + fun create( + branch: String, + base: String?, + existingBranch: Boolean = false, + prompt: PendingPrompt? = null, + kind: CreateKind = CreateKind.CREATE, + ) { val id = "pending:$branch:${System.nanoTime()}" val temp = WorktreeDto(id, branch, branch, id) edt { @@ -126,7 +147,7 @@ class WorktreeController( } cs.launch { val result = service.create(directory, CreateWorktreeRequestDto(branch, base, existingBranch)) - finishCreate(temp, branch, prompt, result) + finishCreate(temp, branch, prompt, result, kind) } } @@ -141,7 +162,7 @@ class WorktreeController( } cs.launch { val result = service.importPr(directory, url) - finishCreate(temp, "pr", null, result) + finishCreate(temp, "pr", null, result, CreateKind.PR) } } @@ -150,6 +171,7 @@ class WorktreeController( branch: String, prompt: PendingPrompt?, result: CreateWorktreeResultDto, + kind: CreateKind, ) { val created = result.worktree edt { @@ -161,12 +183,13 @@ class WorktreeController( cache().put(created) prompt?.let { service().put(created.path, it) } onSelect?.invoke(created.id) + onCreated?.invoke(created) telemetry("Worktree Created", mapOf("branch" to branch)) return@edt } if (idx >= 0) model.remove(temp) telemetry("Worktree Create Failed", mapOf("branch" to branch)) - onCreateFailure?.invoke(result.error) + onCreateFailure?.invoke(CreateFailure(result.error, kind, branch)) } } @@ -249,6 +272,7 @@ class WorktreeController( // open; the tab's identity stays the worktree path alone. event.session?.let { service().put(worktree.path, it) } onSelect?.invoke(worktree.id) + onCreated?.invoke(worktree) telemetry( "Continue in Worktree", mapOf("surface" to "sidebar", "session" to (sessionId != null).toString()), 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 a12c21fb71e..0207e13b1b7 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -415,13 +415,25 @@ worktree.configure.base.invalid.content=Select an existing base branch before cr worktree.dialog.name.placeholder=Worktree name (optional) worktree.dialog.prompt.placeholder=Describe what you want to start working on ({0} to create) worktree.dialog.create=Create Worktree +worktree.dialog.import=Import Worktree +worktree.dialog.tab.new=New +worktree.dialog.tab.pr=From PR +worktree.dialog.tab.branch=From Branch worktree.progress.creating=Creating worktree… worktree.progress.capturing=Capturing changes… worktree.progress.transferring=Transferring changes… worktree.progress.starting=Starting session… worktree.move.failed.title=Failed to move to worktree worktree.import.pr.section=Pull Request +worktree.import.pr.description=Paste a GitHub pull request URL. Kilo will fetch the PR head and open it in a worktree. +worktree.import.pr.placeholder=https://github.com/owner/repo/pull/123 +worktree.import.pr.required=Enter a pull request URL. +worktree.import.pr.invalid=Enter a valid GitHub pull request URL. worktree.import.pr.failed.title=Couldn''t import pull request +worktree.import.branch.section=Branch +worktree.import.branch.description=Choose an existing local branch that is not already checked out in another worktree. +worktree.import.branch.invalid=Select an existing branch before importing. +worktree.import.branch.empty=No local branches are available to import. 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 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 9c47e660c41..5733e92c29f 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 @@ -134,7 +134,7 @@ class AgentManagerPanelTest : BasePlatformTestCase() { fun `test configure creates the worktree only after the dialog closes`() { val order = mutableListOf() - val plan = NewWorktreePlan("feature/y", "main", PendingPrompt("build it")) + val plan = NewWorktreePlan.Create("feature/y", "main", PendingPrompt("build it")) val controller = WorktreeController(service, "/test", coroutines.scope) val panel = edt { AgentManagerPanel(testRootDisposable, controller, project, dialog = { _, _ -> FakeWorktreeDialog(plan, order) }) @@ -166,6 +166,36 @@ class AgentManagerPanelTest : BasePlatformTestCase() { assertTrue(rpc.creates.isEmpty()) } + fun `test configure imports an existing branch`() { + val order = mutableListOf() + val plan = NewWorktreePlan.Branch("feature/x") + val controller = WorktreeController(service, "/test", coroutines.scope) + val panel = edt { + AgentManagerPanel(testRootDisposable, controller, project, dialog = { _, _ -> FakeWorktreeDialog(plan, order) }) + } + + edt { panel.configure() } + flush() + + val req = rpc.creates.single() + assertEquals("feature/x", req.branch) + assertTrue("branch import checks out an existing branch", req.existingBranch) + } + + fun `test configure imports a pull request`() { + val order = mutableListOf() + val plan = NewWorktreePlan.Pr("https://github.com/o/r/pull/7") + val controller = WorktreeController(service, "/test", coroutines.scope) + val panel = edt { + AgentManagerPanel(testRootDisposable, controller, project, dialog = { _, _ -> FakeWorktreeDialog(plan, order) }) + } + + edt { panel.configure() } + flush() + + assertEquals(listOf("https://github.com/o/r/pull/7"), rpc.prImports.toList()) + } + fun `test panel hides worktree search field`() { val controller = WorktreeController(service, "/test", coroutines.scope) val panel = edt { AgentManagerPanel(testRootDisposable, controller) } @@ -649,6 +679,43 @@ class AgentManagerPanelTest : BasePlatformTestCase() { assertTrue(edt { panel.canShowRename(item) }) } + fun `test current row renders without any linked worktrees`() { + rpc.listed += main() + val controller = WorktreeController(service, project.basePath!!, coroutines.scope) + val panel = edt { AgentManagerPanel(testRootDisposable, controller, project) } + + edt { controller.reload() } + flush() + + // Replacing an empty model with an empty list notifies nobody, so the row has to come from + // the reload itself. + assertEquals(1, rows(panel)) + assertEquals("main", row(panel, 0).title) + } + + fun `test current row shows the pr badge for the main checkout`() { + val main = main() + rpc.listed += main + rpc.prResult = WorktreePrListDto( + GhAvailability.OK, + listOf(WorktreePrDto(main.path, 12, GhState.OPEN, "https://example.test/pr/12", "Main work")), + ) + val timers = TestUiTimers() + ApplicationManager.getApplication().replaceService(KiloWorktreeService::class.java, service, testRootDisposable) + 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) + waitUntil { rows(panel) > 0 && row(panel, 0).metrics != null } + + // 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(edt { panel.canOpenPr(main) }) + } + fun `test pr title replaces row name and tooltip reveals custom name`() { val path = "${project.basePath!!}/.kilo/worktrees/feature-x" val item = WorktreeDto(path, "Feature Label", "feature/x", path) @@ -875,6 +942,11 @@ class AgentManagerPanelTest : BasePlatformTestCase() { return edt { list.model.getElementAt(idx) as ActiveListItem } } + private fun rows(panel: AgentManagerPanel): Int { + val list = edt { UIUtil.findComponentOfType(panel, JBList::class.java)!! } + return edt { list.model.size } + } + 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/agentManager/WorktreeControllerTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/WorktreeControllerTest.kt index d798260f495..4e509f37367 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/WorktreeControllerTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/WorktreeControllerTest.kt @@ -1,6 +1,8 @@ package ai.kilocode.client.agentManager import ai.kilocode.client.agentManager.worktree.WorktreeIcons +import ai.kilocode.client.agentManager.worktree.CreateFailure +import ai.kilocode.client.agentManager.worktree.CreateKind import ai.kilocode.client.agentManager.worktree.KiloWorktreeService import ai.kilocode.client.agentManager.worktree.WorktreeController import ai.kilocode.client.agentManager.worktree.PendingPrompt @@ -121,10 +123,35 @@ class WorktreeControllerTest : BasePlatformTestCase() { assertFalse(controller.isPending(controller.model.getElementAt(0).id)) } + fun `test created worktrees are announced once they exist`() { + val controller = controller() + val created = mutableListOf() + controller.onCreated = { created.add(it) } + + ApplicationManager.getApplication().invokeAndWait { controller.create("feature/y", null) } + // Nothing exists on disk while the create is still pending. + assertEquals(emptyList(), created) + flush() + + assertEquals(listOf("feature/y"), created.map { it.branch }) + } + + fun `test a failed create announces nothing`() { + rpc.createResult = { CreateWorktreeResultDto(error = "boom") } + val controller = controller() + val created = mutableListOf() + controller.onCreated = { created.add(it) } + + ApplicationManager.getApplication().invokeAndWait { controller.create("feature/y", null) } + flush() + + assertEquals(emptyList(), created) + } + fun `test create failure removes placeholder and reports the error`() { rpc.createResult = { CreateWorktreeResultDto(error = "boom") } val controller = controller() - val failures = mutableListOf() + val failures = mutableListOf() controller.onCreateFailure = { failures.add(it) } ApplicationManager.getApplication().invokeAndWait { controller.create("feature/y", null) } @@ -134,7 +161,7 @@ class WorktreeControllerTest : BasePlatformTestCase() { assertEquals(0, controller.model.size) assertFalse(controller.isPending(id)) - assertEquals(listOf("boom"), failures) + assertEquals(listOf(CreateFailure("boom", CreateKind.CREATE, "feature/y")), failures) } fun `test reload preserves pending worktrees`() { 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 2896016d108..0edfd4b885c 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 @@ -21,6 +21,9 @@ import com.intellij.openapi.ui.ComboBox import com.intellij.openapi.util.Disposer import com.intellij.testFramework.fixtures.BasePlatformTestCase import com.intellij.ui.components.JBPanel +import com.intellij.ui.components.JBTextField +import com.intellij.ui.tabs.JBTabs +import com.intellij.ui.tabs.TabInfo import com.intellij.util.ui.UIUtil import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob @@ -31,6 +34,7 @@ import java.awt.Component import java.awt.Container import java.awt.event.FocusEvent import javax.swing.JTextField +import javax.swing.plaf.basic.BasicComboBoxUI import javax.swing.plaf.basic.BasicComboPopup class NewWorktreeDialogTest : BasePlatformTestCase() { @@ -144,6 +148,21 @@ class NewWorktreeDialogTest : BasePlatformTestCase() { } } + fun `test base picker survives a dropped editor during layout`() { + open() + + edt { + val picker = combo() as BranchPicker + val ui = picker.ui as BasicComboBoxUI + ui.removeEditor() + + picker.preferredSize + + val comp = picker.editor.editorComponent + assertTrue(picker.components.any { it === comp }) + } + } + fun `test creating with empty base branch falls back to default`() { open() flushUntil { edt { model().selectionKeyForTest() != null } } @@ -184,6 +203,114 @@ class NewWorktreeDialogTest : BasePlatformTestCase() { assertNull(plan()) } + fun `test importing a pr url produces a pr plan`() { + open() + selectPr() + edt { + url().text = "https://github.com/o/r/pull/7" + submit() + } + + assertEquals(NewWorktreePlan.Pr("https://github.com/o/r/pull/7"), taken()) + } + + fun `test blank pr url does not import`() { + open() + selectPr() + edt { submit() } + + assertNull(plan()) + } + + fun `test non-pr url does not import`() { + open() + selectPr() + edt { + url().text = "https://github.com/o/r/issues/7" + submit() + } + + assertNull(plan()) + } + + fun `test picking a branch produces a branch plan`() { + open(branches = listOf("main", "feature/x")) + selectBranch() + edt { + pickField().text = "feature/x" + submit() + } + + assertEquals(NewWorktreePlan.Branch("feature/x"), taken()) + } + + fun `test importing a fuzzy branch resolves to the real branch`() { + open(branches = listOf("main", "feature/refactor-ui")) + selectBranch() + edt { + pickField().text = "refui" + submit() + } + + assertEquals(NewWorktreePlan.Branch("feature/refactor-ui"), taken()) + } + + fun `test importing an unknown branch does not import`() { + open(branches = listOf("main", "feature/x")) + selectBranch() + edt { + pickField().text = "zzzzzz" + submit() + } + + assertNull(plan()) + } + + fun `test the new tab creates while the pr tab imports`() { + open() + + edt { assertEquals(3, tabs().tabs.size) } + selectPr() + edt { + url().text = "https://github.com/o/r/pull/7" + submit() + } + + assertEquals(NewWorktreePlan.Pr("https://github.com/o/r/pull/7"), taken()) + } + + fun `test the deselected tab stops painting`() { + open() + val fresh = newTab() + + selectPr() + + assertFalse(edt { fresh.isVisible }) + assertTrue(edt { prTab().isVisible }) + } + + fun `test reselecting a tab shows it again`() { + open() + selectPr() + + select(0) + + assertTrue(edt { newTab().isVisible }) + assertFalse(edt { prTab().isVisible }) + } + + fun `test an empty branch list disables the branch picker`() { + open(branches = emptyList()) + selectBranch() + + edt { + assertFalse(pick().isEnabled) + submit() + } + + assertNull(plan()) + } + private fun open(branches: List = listOf("main")) { dialog = edt { NewWorktreeDialog( @@ -201,10 +328,13 @@ class NewWorktreeDialogTest : BasePlatformTestCase() { private fun plan(): NewWorktreePlan? = edt { requireNotNull(dialog).result() } + /** Reads the plan after a confirming submit, then forgets the dialog: closing already disposed it. */ + private fun taken(): NewWorktreePlan = requireNotNull(plan()).also { dialog = null } + /** Waits for the dialog to accept a create, then forgets it: closing already disposed it. */ - private fun submitted(): NewWorktreePlan { + private fun submitted(): NewWorktreePlan.Create { flushUntil { plan() != null } - return requireNotNull(plan()).also { dialog = null } + return (requireNotNull(plan()) as NewWorktreePlan.Create).also { dialog = null } } private fun workspace(): ModelsWorkspaceDto { @@ -231,15 +361,39 @@ class NewWorktreeDialogTest : BasePlatformTestCase() { private fun reasoning(): ReasoningPicker = prompt().reasoning - private fun prompt(): PromptPanel = descendants(root()).filterIsInstance().single() + private fun prompt(): PromptPanel = descendants(newTab()).filterIsInstance().single() - private fun combo(): ComboBox<*> = descendants(root()).filterIsInstance>().single() + private fun combo(): ComboBox<*> = descendants(newTab()).filterIsInstance>().single() private fun field(): JTextField = combo().editor.editorComponent as JTextField private fun popup(): BasicComboPopup = combo().accessibleContext.getAccessibleChild(0) as BasicComboPopup - private fun root(): Component = requireNotNull(dialog).centerComponent() + private fun tabs(): JBTabs = requireNotNull(dialog).centerComponent() as JBTabs + + private fun newTab(): Component = tabs().tabs[0].component + + private fun prTab(): Component = tabs().tabs[1].component + + private fun branchTab(): Component = tabs().tabs[2].component + + private fun selectPr() = select(1) + + private fun selectBranch() = select(2) + + private fun select(index: Int) = edt { + val info: TabInfo = tabs().tabs[index] + tabs().select(info, false) + UIUtil.dispatchAllInvocationEvents() + } + + private fun url(): JBTextField = descendants(prTab()).filterIsInstance().single() + + private fun pick(): ComboBox<*> = descendants(branchTab()).filterIsInstance>().single() + + private fun pickField(): JTextField = pick().editor.editorComponent as JTextField + + private fun submit() = requireNotNull(dialog).submit() private fun descendants(root: Component): List { val out = mutableListOf() 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 3fe5072cf19..08b0699b6e7 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 @@ -63,7 +63,9 @@ interface KiloWorktreeRpcApi : RemoteApi { /** * Imports a worktree from a GitHub pull request [url]. Resolves the PR's head branch via `gh`, - * fetches it (adding a fork remote for cross-repo PRs), then checks it out into a new worktree. + * fetches it, records the branch's remote and merge ref so the PR stays identifiable, then + * checks it out into a new worktree. Fork PRs are fetched through `refs/pull//head` and + * get an owner-prefixed local branch; no fork remote is added. */ suspend fun importPr(directory: String, url: String): CreateWorktreeResultDto diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/PrUrl.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/PrUrl.kt new file mode 100644 index 00000000000..d7c530b08e2 --- /dev/null +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/PrUrl.kt @@ -0,0 +1,12 @@ +package ai.kilocode.rpc + +data class PrRef(val owner: String, val repo: String, val number: Int) + +private val PR_URL = Regex("github\\.com[/:]([^/]+)/([^/]+?)(?:\\.git)?/pull/(\\d+)") + +/** Parses `https://github.com///pull/` (and ssh-style hosts) into its parts. */ +fun parsePrUrl(url: String): PrRef? { + val match = PR_URL.find(url.trim()) ?: return null + val number = match.groupValues[3].toIntOrNull() ?: return null + return PrRef(match.groupValues[1], match.groupValues[2], number) +}