feat(jetbrains): add From PR and From Branch tabs to New Worktree dialog

Split the New Worktree dialog into three tabs — New, From PR, From Branch —
replacing the radio-button import source picker. The selected tab alone
determines what OK does, so import failures now surface tab-specific
notifications.

Extract PR-URL parsing into shared ai.kilocode.rpc.parsePrUrl so both the
frontend dialog and backend importPr use one parser. Add BranchPicker, an
editable branch combo, and guard the platform BasicComboBoxUI.getDisplaySize
NPE by reinstalling the UI editor before size computation.
This commit is contained in:
kirillk
2026-08-25 15:26:56 -04:00
parent 105bc05054
commit 67b96293cb
11 changed files with 499 additions and 144 deletions
@@ -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
@@ -591,17 +592,6 @@ 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)
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 `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 ""
@@ -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
@@ -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
@@ -178,7 +182,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)
@@ -317,8 +331,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?) {
@@ -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())
}
@@ -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,42 @@ 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 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 +208,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 +294,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 +387,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 }
}
@@ -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,7 @@ class WorktreeController(
private val tasks = LinkedHashMap<String, String>()
private val moves = LinkedHashSet<String>()
var onSelect: ((String) -> Unit)? = null
var onCreateFailure: ((String?) -> Unit)? = null
var onCreateFailure: ((CreateFailure) -> Unit)? = null
var onMoveFailure: ((String?) -> Unit)? = null
var onRemoveSuccess: ((WorktreeDto, Int) -> Unit)? = null
var onActivityChanged: (() -> Unit)? = null
@@ -109,13 +113,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 +136,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 +151,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 +160,7 @@ class WorktreeController(
branch: String,
prompt: PendingPrompt?,
result: CreateWorktreeResultDto,
kind: CreateKind,
) {
val created = result.worktree
edt {
@@ -166,7 +177,7 @@ class WorktreeController(
}
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))
}
}
@@ -413,13 +413,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
@@ -128,7 +128,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) })
@@ -160,6 +160,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) }
@@ -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
@@ -105,7 +107,7 @@ class WorktreeControllerTest : BasePlatformTestCase() {
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) }
@@ -115,7 +117,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`() {
@@ -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,94 @@ 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 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 +308,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 +341,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>()
@@ -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)
}