mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-01 15:32:11 +08:00
Merge pull request #13440 from Kilo-Org/bold-island
feat(jetbrains): add From PR and From Branch tabs to New Worktree dialog
This commit is contained in:
@@ -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.
|
||||
@@ -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.
|
||||
+137
-57
@@ -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<String, Timed<String>>()
|
||||
private val prs = ConcurrentHashMap<String, Timed<WorktreePrListDto>>()
|
||||
private val branches = ConcurrentHashMap<String, Timed<BranchStatusDto>>()
|
||||
private val resolver = PrResolver(gh = ::runGh, git = ::runGit)
|
||||
private val ghLock = Any()
|
||||
@Volatile
|
||||
private var ghProbe: Timed<GhAvailability>? = 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<T>(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<String>): 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<String>): 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<String>): GitResult {
|
||||
private fun add(base: Path, args: List<String>): 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 = "")
|
||||
|
||||
private val PR_URL = Regex("github\\.com[/:]([^/]+)/([^/]+?)(?:\\.git)?/pull/(\\d+)")
|
||||
|
||||
/** Parses `https://github.com/<owner>/<repo>/pull/<n>` (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)
|
||||
/** 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)
|
||||
}
|
||||
|
||||
/** 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()
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
|
||||
/**
|
||||
* 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/<number>/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<String>) -> 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
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<String>) -> 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<WorktreeDto>): List<WorktreeDto> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<WorktreeDto>): List<WorktreeDto> {
|
||||
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<WorktreeDto>): String? {
|
||||
return items.firstOrNull { it.main }?.branch?.takeIf { it.isNotBlank() && it != "(detached)" }
|
||||
}
|
||||
|
||||
internal fun overlayWorktreeNames(items: List<WorktreeDto>, names: Map<String, String>): List<WorktreeDto> {
|
||||
if (names.isEmpty()) return items
|
||||
return items.map { item ->
|
||||
|
||||
+102
@@ -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.<name>.merge`, so it
|
||||
* resolves `refs/pull/N/head` branches by PR number and fork PRs through the push remote.
|
||||
* 2. `gh pr view <branch>`. 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 "<HEAD sha>"`, 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<String>) -> CmdOut,
|
||||
private val git: (Path, List<String>) -> 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 }
|
||||
+148
-3
@@ -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/<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<String>) -> 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)
|
||||
|
||||
+120
@@ -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<List<String>>()
|
||||
|
||||
@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<String>) -> CmdOut,
|
||||
list: (List<String>) -> 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"
|
||||
}
|
||||
}
|
||||
+34
-5
@@ -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<WorktreeStatusService>()?.refreshStats()
|
||||
project?.service<WorktreeStatusService>()?.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
|
||||
|
||||
+127
@@ -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<String>, private val default: String = "") :
|
||||
ComboBox<String>(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<String>, default: String): List<String> {
|
||||
val ordered = LinkedHashSet<String>()
|
||||
if (default.isNotBlank()) ordered.add(default)
|
||||
ordered.addAll(branches)
|
||||
return ordered.toList()
|
||||
}
|
||||
|
||||
private fun model(branches: List<String>, default: String): ComboBoxModel<String> {
|
||||
return DefaultComboBoxModel(ordered(branches, default).toTypedArray())
|
||||
}
|
||||
+141
-116
@@ -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<String>, default: String): List<String> {
|
||||
val ordered = LinkedHashSet<String>()
|
||||
if (default.isNotBlank()) ordered.add(default)
|
||||
ordered.addAll(branches)
|
||||
return ordered.toList()
|
||||
}
|
||||
|
||||
private fun baseModel(branches: List<String>): ComboBoxModel<String> {
|
||||
return DefaultComboBoxModel(branches.toTypedArray())
|
||||
}
|
||||
|
||||
private fun variantTitle(value: String): String = value.replaceFirstChar { it.titlecase() }
|
||||
|
||||
private enum class DialogTab { NEW, PR, BRANCH }
|
||||
}
|
||||
|
||||
+30
-6
@@ -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<String, String>()
|
||||
private val moves = LinkedHashSet<String>()
|
||||
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<PendingWorktreePrompt>().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<PendingWorktreeSession>().put(worktree.path, it) }
|
||||
onSelect?.invoke(worktree.id)
|
||||
onCreated?.invoke(worktree)
|
||||
telemetry(
|
||||
"Continue in Worktree",
|
||||
mapOf("surface" to "sidebar", "session" to (sessionId != null).toString()),
|
||||
|
||||
@@ -416,13 +416,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
|
||||
|
||||
+73
-1
@@ -134,7 +134,7 @@ class AgentManagerPanelTest : BasePlatformTestCase() {
|
||||
|
||||
fun `test configure creates the worktree only after the dialog closes`() {
|
||||
val order = mutableListOf<String>()
|
||||
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<String>()
|
||||
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<String>()
|
||||
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<Component> {
|
||||
val out = mutableListOf<Component>()
|
||||
fun visit(item: Component) {
|
||||
|
||||
+29
-2
@@ -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<WorktreeDto>()
|
||||
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<WorktreeDto>(), 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<WorktreeDto>()
|
||||
controller.onCreated = { created.add(it) }
|
||||
|
||||
ApplicationManager.getApplication().invokeAndWait { controller.create("feature/y", null) }
|
||||
flush()
|
||||
|
||||
assertEquals(emptyList<WorktreeDto>(), created)
|
||||
}
|
||||
|
||||
fun `test create failure removes placeholder and reports the error`() {
|
||||
rpc.createResult = { CreateWorktreeResultDto(error = "boom") }
|
||||
val controller = controller()
|
||||
val failures = mutableListOf<String?>()
|
||||
val failures = mutableListOf<CreateFailure>()
|
||||
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`() {
|
||||
|
||||
+159
-5
@@ -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<String> = 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<PromptPanel>().single()
|
||||
private fun prompt(): PromptPanel = descendants(newTab()).filterIsInstance<PromptPanel>().single()
|
||||
|
||||
private fun combo(): ComboBox<*> = descendants(root()).filterIsInstance<ComboBox<*>>().single()
|
||||
private fun combo(): ComboBox<*> = descendants(newTab()).filterIsInstance<ComboBox<*>>().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<JBTextField>().single()
|
||||
|
||||
private fun pick(): ComboBox<*> = descendants(branchTab()).filterIsInstance<ComboBox<*>>().single()
|
||||
|
||||
private fun pickField(): JTextField = pick().editor.editorComponent as JTextField
|
||||
|
||||
private fun submit() = requireNotNull(dialog).submit()
|
||||
|
||||
private fun descendants(root: Component): List<Component> {
|
||||
val out = mutableListOf<Component>()
|
||||
|
||||
+3
-1
@@ -63,7 +63,9 @@ interface KiloWorktreeRpcApi : RemoteApi<Unit> {
|
||||
|
||||
/**
|
||||
* 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/<number>/head` and
|
||||
* get an owner-prefixed local branch; no fork remote is added.
|
||||
*/
|
||||
suspend fun importPr(directory: String, url: String): CreateWorktreeResultDto
|
||||
|
||||
|
||||
@@ -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/<owner>/<repo>/pull/<n>` (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)
|
||||
}
|
||||
Reference in New Issue
Block a user