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) +}