From 67b96293cbafd9442c723d427ecb1d4359ef5aea Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 25 Aug 2026 15:26:56 -0400 Subject: [PATCH] feat(jetbrains): add From PR and From Branch tabs to New Worktree dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../backend/rpc/KiloWorktreeRpcApiImpl.kt | 12 +- .../backend/rpc/KiloWorktreeRpcApiImplTest.kt | 1 + .../client/agentManager/AgentManagerPanel.kt | 25 +- .../agentManager/worktree/BranchPicker.kt | 127 +++++++++ .../worktree/NewWorktreeDialog.kt | 249 ++++++++++-------- .../worktree/WorktreeController.kt | 23 +- .../resources/messages/KiloBundle.properties | 12 + .../agentManager/AgentManagerPanelTest.kt | 32 ++- .../agentManager/WorktreeControllerTest.kt | 6 +- .../worktree/NewWorktreeDialogTest.kt | 144 +++++++++- .../src/main/kotlin/ai/kilocode/rpc/PrUrl.kt | 12 + 11 files changed, 499 insertions(+), 144 deletions(-) create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/BranchPicker.kt create mode 100644 packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/PrUrl.kt diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImpl.kt index 44bd19f386f..2ef869b45ae 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImpl.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImpl.kt @@ -3,6 +3,7 @@ package ai.kilocode.backend.rpc import ai.kilocode.backend.app.KiloBackendAppService import ai.kilocode.log.KiloLog import ai.kilocode.rpc.KiloWorktreeRpcApi +import ai.kilocode.rpc.parsePrUrl import ai.kilocode.rpc.dto.BranchStatusDto import ai.kilocode.rpc.dto.CreateWorktreeRequestDto import ai.kilocode.rpc.dto.CreateWorktreeResultDto @@ -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///pull/` (and ssh-style hosts) into its parts. */ -internal fun parsePrUrl(url: String): PrRef? { - val match = PR_URL.find(url.trim()) ?: return null - val number = match.groupValues[3].toIntOrNull() ?: return null - return PrRef(match.groupValues[1], match.groupValues[2], number) -} - /** 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 "" diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImplTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImplTest.kt index e19a9a386b1..fd7de316a9b 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImplTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImplTest.kt @@ -1,5 +1,6 @@ package ai.kilocode.backend.rpc +import ai.kilocode.rpc.parsePrUrl import ai.kilocode.rpc.dto.CreateWorktreeRequestDto import ai.kilocode.rpc.dto.GhAvailability import ai.kilocode.rpc.dto.GhState diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/AgentManagerPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/AgentManagerPanel.kt index fd8410e51f2..f5b424ae8f5 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/AgentManagerPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/AgentManagerPanel.kt @@ -1,8 +1,11 @@ package ai.kilocode.client.agentManager import ai.kilocode.client.KiloNotifications +import ai.kilocode.client.agentManager.worktree.CreateFailure +import ai.kilocode.client.agentManager.worktree.CreateKind import ai.kilocode.client.agentManager.worktree.NewWorktreeDialog import ai.kilocode.client.agentManager.worktree.NewWorktreeHandle +import ai.kilocode.client.agentManager.worktree.NewWorktreePlan import ai.kilocode.client.agentManager.worktree.GhBanner import ai.kilocode.client.agentManager.worktree.WorktreeController import ai.kilocode.client.agentManager.worktree.WorktreeDataKeys @@ -24,6 +27,7 @@ import ai.kilocode.client.diff.diffParams import ai.kilocode.client.diff.ensureDiffEditorKind import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.session.SessionActivityKind +import ai.kilocode.client.telemetry.Telemetry import ai.kilocode.client.ui.UiStyle import ai.kilocode.client.ui.list.ActiveList import ai.kilocode.client.ui.list.ActiveListBadge @@ -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?) { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/BranchPicker.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/BranchPicker.kt new file mode 100644 index 00000000000..beafaea6797 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/BranchPicker.kt @@ -0,0 +1,127 @@ +package ai.kilocode.client.agentManager.worktree + +import ai.kilocode.client.session.ui.prompt.PromptFuzzyRanker +import com.intellij.openapi.ui.ComboBox +import com.intellij.ui.DocumentAdapter +import java.awt.Dimension +import java.awt.event.FocusAdapter +import java.awt.event.FocusEvent +import javax.swing.ComboBoxModel +import javax.swing.DefaultComboBoxModel +import javax.swing.JTextField +import javax.swing.event.DocumentEvent +import javax.swing.plaf.basic.BasicComboBoxUI +import javax.swing.plaf.basic.BasicComboPopup + +internal class BranchPicker(branches: List, private val default: String = "") : + ComboBox(model(branches, default)) { + private val branches = ordered(branches, default) + private val set = this.branches.toSet() + private var syncing = false + + val empty: Boolean get() = branches.isEmpty() + + init { + isEditable = true + if (default.isNotBlank()) selectedItem = default + wire() + } + + override fun getPreferredSize(): Dimension { + ensureEditor() + return super.getPreferredSize() + } + + fun resolve(): String? { + val value = text() + if (value.isEmpty()) { + val fallback = default.trim() + if (fallback.isNotEmpty()) set(fallback) + return fallback.takeIf { it.isNotEmpty() } + } + if (value in set) return value + val idx = match(value) ?: return value + val target = branches[idx] + set(target) + return target + } + + fun known(value: String?): Boolean = value == null || value in set + + fun focusText() { + field()?.apply { + requestFocusInWindow() + selectAll() + } + } + + private fun wire() { + val field = field() ?: return + field.document.addDocumentListener(object : DocumentAdapter() { + override fun textChanged(e: DocumentEvent) { + if (!syncing) sync(field.text, popup = true) + } + }) + field.addFocusListener(object : FocusAdapter() { + override fun focusLost(e: FocusEvent) { + restore() + } + }) + } + + private fun restore() { + if (default.isBlank() || text().isNotEmpty()) return + set(default) + } + + private fun sync(text: String, popup: Boolean) { + val value = text.trim() + if (value.isEmpty()) return + if (popup && isShowing && !isPopupVisible) isPopupVisible = true + val idx = match(value) ?: return + val list = popupList() ?: return + if (list.selectedIndex != idx) list.selectedIndex = idx + list.ensureIndexIsVisible(idx) + } + + private fun match(text: String): Int? { + val rank = PromptFuzzyRanker(text) + return branches.withIndex().mapNotNull { item -> + rank.score(item.value, emptyList())?.let { score -> item.index to score } + }.maxByOrNull { it.second }?.first + } + + private fun popupList() = (getAccessibleContext()?.getAccessibleChild(0) as? BasicComboPopup)?.list + + private fun field() = editor.editorComponent as? JTextField + + private fun text() = field()?.text?.trim() ?: editor.item?.toString()?.trim().orEmpty() + + private fun ensureEditor() { + if (!isEditable) return + val ui = ui as? BasicComboBoxUI ?: return + val comp = editor.editorComponent ?: return + if (components.none { it === comp }) ui.addEditor() + } + + private fun set(value: String) { + syncing = true + try { + selectedItem = value + field()?.text = value + } finally { + syncing = false + } + } +} + +private fun ordered(branches: List, default: String): List { + val ordered = LinkedHashSet() + if (default.isNotBlank()) ordered.add(default) + ordered.addAll(branches) + return ordered.toList() +} + +private fun model(branches: List, default: String): ComboBoxModel { + return DefaultComboBoxModel(ordered(branches, default).toTypedArray()) +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/NewWorktreeDialog.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/NewWorktreeDialog.kt index 0f7366015c8..48268101376 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/NewWorktreeDialog.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/NewWorktreeDialog.kt @@ -10,22 +10,29 @@ import ai.kilocode.client.session.ui.model.ModelPicker import ai.kilocode.client.session.ui.model.modelItems import ai.kilocode.client.session.ui.prompt.KiloPromptCompletionProvider import ai.kilocode.client.session.ui.prompt.MentionAction -import ai.kilocode.client.session.ui.prompt.PromptFuzzyRanker import ai.kilocode.client.session.ui.prompt.PromptPanel import ai.kilocode.client.session.ui.prompt.SlashAction +import ai.kilocode.client.settings.base.BaseContentPanel +import ai.kilocode.client.settings.base.SettingsRows +import ai.kilocode.client.settings.base.SettingsStackedRow import ai.kilocode.client.ui.UiStyle import ai.kilocode.client.ui.layout.Stack import ai.kilocode.rpc.dto.ModelsWorkspaceDto +import ai.kilocode.rpc.parsePrUrl import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.application.ModalityState import com.intellij.openapi.components.service import com.intellij.openapi.project.Project -import com.intellij.openapi.ui.ComboBox import com.intellij.openapi.ui.DialogWrapper -import com.intellij.ui.DocumentAdapter import com.intellij.ui.components.JBTextField +import com.intellij.ui.tabs.JBTabs +import com.intellij.ui.tabs.JBTabsFactory +import com.intellij.ui.tabs.JBTabsPosition +import com.intellij.ui.tabs.TabInfo +import com.intellij.ui.tabs.TabsListener import com.intellij.util.ui.FormBuilder import com.intellij.util.ui.JBUI +import com.intellij.util.ui.components.BorderLayoutPanel import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -33,19 +40,16 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.launch import java.awt.Component import java.awt.GridBagConstraints -import java.awt.event.FocusAdapter -import java.awt.event.FocusEvent -import javax.swing.ComboBoxModel -import javax.swing.DefaultComboBoxModel import javax.swing.JComponent -import javax.swing.JTextField -import javax.swing.event.DocumentEvent -import javax.swing.plaf.basic.BasicComboPopup private const val NAME_COLUMNS = 67 /** What the user confirmed in the New Worktree dialog. */ -data class NewWorktreePlan(val branch: String, val base: String?, val prompt: PendingPrompt?) +sealed interface NewWorktreePlan { + data class Create(val branch: String, val base: String?, val prompt: PendingPrompt?) : NewWorktreePlan + data class Branch(val branch: String) : NewWorktreePlan + data class Pr(val url: String) : NewWorktreePlan +} /** * The New Worktree dialog as seen by its caller: show it, then read what the user confirmed. @@ -57,10 +61,16 @@ interface NewWorktreeHandle { } /** - * New Worktree dialog with parity to the VS Code Agent Manager dialog: a worktree name (top), an - * initial prompt with the same mode / model / reasoning pickers as the chat prompt (center), and the - * branch name + base branch (bottom). Creating a worktree starts a session automatically with the - * prompt. + * New Worktree dialog with parity to the VS Code Agent Manager dialog, split into three tabs: + * + * - **New** creates a branch: a worktree name (top), an initial prompt with the same mode / model / + * reasoning pickers as the chat prompt (center), and the branch name + base branch (bottom). + * Creating a worktree starts a session automatically with the prompt. + * - **From PR** checks out a GitHub pull request by URL. + * - **From Branch** checks out a local branch that no worktree holds yet. + * + * Both import tabs carry no initial prompt, so the worktree opens with an empty session. The + * selected tab alone decides which input the OK button acts on. * * The dialog performs no worktree work itself — it records the confirmed [result] and closes; the * panel then drives the controller, so no view switch or worktree work runs while the modal dialog @@ -103,13 +113,12 @@ internal class NewWorktreeDialog( showEnhance = false, ) private val branch = JBTextField(suggestedName) - private val bases = baseBranches(branches, defaultBase) - private val baseSet = bases.toSet() - private val base = ComboBox(baseModel(bases)).apply { - isEditable = true - selectedItem = defaultBase + private val base = BranchPicker(branches, defaultBase) + private val url = JBTextField().apply { + emptyText.text = KiloBundle.message("worktree.import.pr.placeholder") } - private var syncing = false + private val pick = BranchPicker(branches) + private var tab = DialogTab.NEW private var plan: NewWorktreePlan? = null @@ -128,25 +137,25 @@ internal class NewWorktreeDialog( private var center: JComponent? = null init { - wireBase() + if (pick.empty) pick.isEnabled = false title = KiloBundle.message("worktree.configure.title") init() setOKButtonText(KiloBundle.message("worktree.dialog.create")) } - override fun createCenterPanel(): JComponent = content().also { center = it } + override fun createCenterPanel(): JComponent = tabs().also { center = it } /** The built content, so tests can drive the real Swing tree before the dialog is shown. */ internal fun centerComponent(): JComponent = center ?: error("center panel not built") override fun result(): NewWorktreePlan? = plan - override fun getPreferredFocusedComponent(): JComponent = prompt.defaultFocusedComponent + override fun getPreferredFocusedComponent(): JComponent = focus() // Versioned: DialogWrapper persists the size per key, so a stale entry would keep the old width. - override fun getDimensionServiceKey(): String = "ai.kilocode.NewWorktreeDialog.v2" + override fun getDimensionServiceKey(): String = "ai.kilocode.NewWorktreeDialog.v3" - override fun doOKAction() = submitCreate() + override fun doOKAction() = submit() override fun dispose() { disposed = true @@ -154,7 +163,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, default: String): List { - val ordered = LinkedHashSet() - if (default.isNotBlank()) ordered.add(default) - ordered.addAll(branches) - return ordered.toList() - } - - private fun baseModel(branches: List): ComboBoxModel { - return DefaultComboBoxModel(branches.toTypedArray()) - } - private fun variantTitle(value: String): String = value.replaceFirstChar { it.titlecase() } + + private enum class DialogTab { NEW, PR, BRANCH } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeController.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeController.kt index 2a7e8ff20a1..9cd0f8939b6 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeController.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeController.kt @@ -20,6 +20,10 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch +enum class CreateKind { CREATE, BRANCH, PR } + +data class CreateFailure(val error: String?, val kind: CreateKind, val branch: String) + /** * Owns the worktree list model and drives the [KiloWorktreeService] off the EDT. Model mutations * are marshalled back onto the EDT via [edt]. Mirrors the History stack's controller shape. @@ -37,7 +41,7 @@ class WorktreeController( private val tasks = LinkedHashMap() private val moves = LinkedHashSet() 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)) } } diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties index aeff0f994ae..765851c3d4f 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -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 diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/AgentManagerPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/AgentManagerPanelTest.kt index f436a11ab0d..035a6f33438 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/AgentManagerPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/AgentManagerPanelTest.kt @@ -128,7 +128,7 @@ class AgentManagerPanelTest : BasePlatformTestCase() { fun `test configure creates the worktree only after the dialog closes`() { val order = mutableListOf() - val plan = NewWorktreePlan("feature/y", "main", PendingPrompt("build it")) + val plan = NewWorktreePlan.Create("feature/y", "main", PendingPrompt("build it")) val controller = WorktreeController(service, "/test", coroutines.scope) val panel = edt { AgentManagerPanel(testRootDisposable, controller, project, dialog = { _, _ -> FakeWorktreeDialog(plan, order) }) @@ -160,6 +160,36 @@ class AgentManagerPanelTest : BasePlatformTestCase() { assertTrue(rpc.creates.isEmpty()) } + fun `test configure imports an existing branch`() { + val order = mutableListOf() + val plan = NewWorktreePlan.Branch("feature/x") + val controller = WorktreeController(service, "/test", coroutines.scope) + val panel = edt { + AgentManagerPanel(testRootDisposable, controller, project, dialog = { _, _ -> FakeWorktreeDialog(plan, order) }) + } + + edt { panel.configure() } + flush() + + val req = rpc.creates.single() + assertEquals("feature/x", req.branch) + assertTrue("branch import checks out an existing branch", req.existingBranch) + } + + fun `test configure imports a pull request`() { + val order = mutableListOf() + val plan = NewWorktreePlan.Pr("https://github.com/o/r/pull/7") + val controller = WorktreeController(service, "/test", coroutines.scope) + val panel = edt { + AgentManagerPanel(testRootDisposable, controller, project, dialog = { _, _ -> FakeWorktreeDialog(plan, order) }) + } + + edt { panel.configure() } + flush() + + assertEquals(listOf("https://github.com/o/r/pull/7"), rpc.prImports.toList()) + } + fun `test panel hides worktree search field`() { val controller = WorktreeController(service, "/test", coroutines.scope) val panel = edt { AgentManagerPanel(testRootDisposable, controller) } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/WorktreeControllerTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/WorktreeControllerTest.kt index 9e1aca1bc63..e3b5c0eac16 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/WorktreeControllerTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/WorktreeControllerTest.kt @@ -1,6 +1,8 @@ package ai.kilocode.client.agentManager import ai.kilocode.client.agentManager.worktree.WorktreeIcons +import ai.kilocode.client.agentManager.worktree.CreateFailure +import ai.kilocode.client.agentManager.worktree.CreateKind import ai.kilocode.client.agentManager.worktree.KiloWorktreeService import ai.kilocode.client.agentManager.worktree.WorktreeController import ai.kilocode.client.agentManager.worktree.PendingPrompt @@ -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() + val failures = mutableListOf() 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`() { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/NewWorktreeDialogTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/NewWorktreeDialogTest.kt index 2896016d108..a637c8e36dc 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/NewWorktreeDialogTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/NewWorktreeDialogTest.kt @@ -21,6 +21,9 @@ import com.intellij.openapi.ui.ComboBox import com.intellij.openapi.util.Disposer import com.intellij.testFramework.fixtures.BasePlatformTestCase import com.intellij.ui.components.JBPanel +import com.intellij.ui.components.JBTextField +import com.intellij.ui.tabs.JBTabs +import com.intellij.ui.tabs.TabInfo import com.intellij.util.ui.UIUtil import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob @@ -31,6 +34,7 @@ import java.awt.Component import java.awt.Container import java.awt.event.FocusEvent import javax.swing.JTextField +import javax.swing.plaf.basic.BasicComboBoxUI import javax.swing.plaf.basic.BasicComboPopup class NewWorktreeDialogTest : BasePlatformTestCase() { @@ -144,6 +148,21 @@ class NewWorktreeDialogTest : BasePlatformTestCase() { } } + fun `test base picker survives a dropped editor during layout`() { + open() + + edt { + val picker = combo() as BranchPicker + val ui = picker.ui as BasicComboBoxUI + ui.removeEditor() + + picker.preferredSize + + val comp = picker.editor.editorComponent + assertTrue(picker.components.any { it === comp }) + } + } + fun `test creating with empty base branch falls back to default`() { open() flushUntil { edt { model().selectionKeyForTest() != null } } @@ -184,6 +203,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 = 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().single() + private fun prompt(): PromptPanel = descendants(newTab()).filterIsInstance().single() - private fun combo(): ComboBox<*> = descendants(root()).filterIsInstance>().single() + private fun combo(): ComboBox<*> = descendants(newTab()).filterIsInstance>().single() private fun field(): JTextField = combo().editor.editorComponent as JTextField private fun popup(): BasicComboPopup = combo().accessibleContext.getAccessibleChild(0) as BasicComboPopup - private fun root(): Component = requireNotNull(dialog).centerComponent() + private fun tabs(): JBTabs = requireNotNull(dialog).centerComponent() as JBTabs + + private fun newTab(): Component = tabs().tabs[0].component + + private fun prTab(): Component = tabs().tabs[1].component + + private fun branchTab(): Component = tabs().tabs[2].component + + private fun selectPr() = select(1) + + private fun selectBranch() = select(2) + + private fun select(index: Int) = edt { + val info: TabInfo = tabs().tabs[index] + tabs().select(info, false) + UIUtil.dispatchAllInvocationEvents() + } + + private fun url(): JBTextField = descendants(prTab()).filterIsInstance().single() + + private fun pick(): ComboBox<*> = descendants(branchTab()).filterIsInstance>().single() + + private fun pickField(): JTextField = pick().editor.editorComponent as JTextField + + private fun submit() = requireNotNull(dialog).submit() private fun descendants(root: Component): List { val out = mutableListOf() diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/PrUrl.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/PrUrl.kt new file mode 100644 index 00000000000..d7c530b08e2 --- /dev/null +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/PrUrl.kt @@ -0,0 +1,12 @@ +package ai.kilocode.rpc + +data class PrRef(val owner: String, val repo: String, val number: Int) + +private val PR_URL = Regex("github\\.com[/:]([^/]+)/([^/]+?)(?:\\.git)?/pull/(\\d+)") + +/** Parses `https://github.com///pull/` (and ssh-style hosts) into its parts. */ +fun parsePrUrl(url: String): PrRef? { + val match = PR_URL.find(url.trim()) ?: return null + val number = match.groupValues[3].toIntOrNull() ?: return null + return PrRef(match.groupValues[1], match.groupValues[2], number) +}