mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-24 16:02:55 +08:00
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:
+1
-11
@@ -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
@@ -1,5 +1,6 @@
|
||||
package ai.kilocode.backend.rpc
|
||||
|
||||
import ai.kilocode.rpc.parsePrUrl
|
||||
import ai.kilocode.rpc.dto.CreateWorktreeRequestDto
|
||||
import ai.kilocode.rpc.dto.GhAvailability
|
||||
import ai.kilocode.rpc.dto.GhState
|
||||
|
||||
+22
-3
@@ -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?) {
|
||||
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
package ai.kilocode.client.agentManager.worktree
|
||||
|
||||
import ai.kilocode.client.session.ui.prompt.PromptFuzzyRanker
|
||||
import com.intellij.openapi.ui.ComboBox
|
||||
import com.intellij.ui.DocumentAdapter
|
||||
import java.awt.Dimension
|
||||
import java.awt.event.FocusAdapter
|
||||
import java.awt.event.FocusEvent
|
||||
import javax.swing.ComboBoxModel
|
||||
import javax.swing.DefaultComboBoxModel
|
||||
import javax.swing.JTextField
|
||||
import javax.swing.event.DocumentEvent
|
||||
import javax.swing.plaf.basic.BasicComboBoxUI
|
||||
import javax.swing.plaf.basic.BasicComboPopup
|
||||
|
||||
internal class BranchPicker(branches: List<String>, private val default: String = "") :
|
||||
ComboBox<String>(model(branches, default)) {
|
||||
private val branches = ordered(branches, default)
|
||||
private val set = this.branches.toSet()
|
||||
private var syncing = false
|
||||
|
||||
val empty: Boolean get() = branches.isEmpty()
|
||||
|
||||
init {
|
||||
isEditable = true
|
||||
if (default.isNotBlank()) selectedItem = default
|
||||
wire()
|
||||
}
|
||||
|
||||
override fun getPreferredSize(): Dimension {
|
||||
ensureEditor()
|
||||
return super.getPreferredSize()
|
||||
}
|
||||
|
||||
fun resolve(): String? {
|
||||
val value = text()
|
||||
if (value.isEmpty()) {
|
||||
val fallback = default.trim()
|
||||
if (fallback.isNotEmpty()) set(fallback)
|
||||
return fallback.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
if (value in set) return value
|
||||
val idx = match(value) ?: return value
|
||||
val target = branches[idx]
|
||||
set(target)
|
||||
return target
|
||||
}
|
||||
|
||||
fun known(value: String?): Boolean = value == null || value in set
|
||||
|
||||
fun focusText() {
|
||||
field()?.apply {
|
||||
requestFocusInWindow()
|
||||
selectAll()
|
||||
}
|
||||
}
|
||||
|
||||
private fun wire() {
|
||||
val field = field() ?: return
|
||||
field.document.addDocumentListener(object : DocumentAdapter() {
|
||||
override fun textChanged(e: DocumentEvent) {
|
||||
if (!syncing) sync(field.text, popup = true)
|
||||
}
|
||||
})
|
||||
field.addFocusListener(object : FocusAdapter() {
|
||||
override fun focusLost(e: FocusEvent) {
|
||||
restore()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun restore() {
|
||||
if (default.isBlank() || text().isNotEmpty()) return
|
||||
set(default)
|
||||
}
|
||||
|
||||
private fun sync(text: String, popup: Boolean) {
|
||||
val value = text.trim()
|
||||
if (value.isEmpty()) return
|
||||
if (popup && isShowing && !isPopupVisible) isPopupVisible = true
|
||||
val idx = match(value) ?: return
|
||||
val list = popupList() ?: return
|
||||
if (list.selectedIndex != idx) list.selectedIndex = idx
|
||||
list.ensureIndexIsVisible(idx)
|
||||
}
|
||||
|
||||
private fun match(text: String): Int? {
|
||||
val rank = PromptFuzzyRanker(text)
|
||||
return branches.withIndex().mapNotNull { item ->
|
||||
rank.score(item.value, emptyList())?.let { score -> item.index to score }
|
||||
}.maxByOrNull { it.second }?.first
|
||||
}
|
||||
|
||||
private fun popupList() = (getAccessibleContext()?.getAccessibleChild(0) as? BasicComboPopup)?.list
|
||||
|
||||
private fun field() = editor.editorComponent as? JTextField
|
||||
|
||||
private fun text() = field()?.text?.trim() ?: editor.item?.toString()?.trim().orEmpty()
|
||||
|
||||
private fun ensureEditor() {
|
||||
if (!isEditable) return
|
||||
val ui = ui as? BasicComboBoxUI ?: return
|
||||
val comp = editor.editorComponent ?: return
|
||||
if (components.none { it === comp }) ui.addEditor()
|
||||
}
|
||||
|
||||
private fun set(value: String) {
|
||||
syncing = true
|
||||
try {
|
||||
selectedItem = value
|
||||
field()?.text = value
|
||||
} finally {
|
||||
syncing = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun ordered(branches: List<String>, default: String): List<String> {
|
||||
val ordered = LinkedHashSet<String>()
|
||||
if (default.isNotBlank()) ordered.add(default)
|
||||
ordered.addAll(branches)
|
||||
return ordered.toList()
|
||||
}
|
||||
|
||||
private fun model(branches: List<String>, default: String): ComboBoxModel<String> {
|
||||
return DefaultComboBoxModel(ordered(branches, default).toTypedArray())
|
||||
}
|
||||
+133
-116
@@ -10,22 +10,29 @@ import ai.kilocode.client.session.ui.model.ModelPicker
|
||||
import ai.kilocode.client.session.ui.model.modelItems
|
||||
import ai.kilocode.client.session.ui.prompt.KiloPromptCompletionProvider
|
||||
import ai.kilocode.client.session.ui.prompt.MentionAction
|
||||
import ai.kilocode.client.session.ui.prompt.PromptFuzzyRanker
|
||||
import ai.kilocode.client.session.ui.prompt.PromptPanel
|
||||
import ai.kilocode.client.session.ui.prompt.SlashAction
|
||||
import ai.kilocode.client.settings.base.BaseContentPanel
|
||||
import ai.kilocode.client.settings.base.SettingsRows
|
||||
import ai.kilocode.client.settings.base.SettingsStackedRow
|
||||
import ai.kilocode.client.ui.UiStyle
|
||||
import ai.kilocode.client.ui.layout.Stack
|
||||
import ai.kilocode.rpc.dto.ModelsWorkspaceDto
|
||||
import ai.kilocode.rpc.parsePrUrl
|
||||
import com.intellij.openapi.application.ApplicationManager
|
||||
import com.intellij.openapi.application.ModalityState
|
||||
import com.intellij.openapi.components.service
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.openapi.ui.ComboBox
|
||||
import com.intellij.openapi.ui.DialogWrapper
|
||||
import com.intellij.ui.DocumentAdapter
|
||||
import com.intellij.ui.components.JBTextField
|
||||
import com.intellij.ui.tabs.JBTabs
|
||||
import com.intellij.ui.tabs.JBTabsFactory
|
||||
import com.intellij.ui.tabs.JBTabsPosition
|
||||
import com.intellij.ui.tabs.TabInfo
|
||||
import com.intellij.ui.tabs.TabsListener
|
||||
import com.intellij.util.ui.FormBuilder
|
||||
import com.intellij.util.ui.JBUI
|
||||
import com.intellij.util.ui.components.BorderLayoutPanel
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
@@ -33,19 +40,16 @@ import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import java.awt.Component
|
||||
import java.awt.GridBagConstraints
|
||||
import java.awt.event.FocusAdapter
|
||||
import java.awt.event.FocusEvent
|
||||
import javax.swing.ComboBoxModel
|
||||
import javax.swing.DefaultComboBoxModel
|
||||
import javax.swing.JComponent
|
||||
import javax.swing.JTextField
|
||||
import javax.swing.event.DocumentEvent
|
||||
import javax.swing.plaf.basic.BasicComboPopup
|
||||
|
||||
private const val NAME_COLUMNS = 67
|
||||
|
||||
/** What the user confirmed in the New Worktree dialog. */
|
||||
data class NewWorktreePlan(val branch: String, val base: String?, val prompt: PendingPrompt?)
|
||||
sealed interface NewWorktreePlan {
|
||||
data class Create(val branch: String, val base: String?, val prompt: PendingPrompt?) : NewWorktreePlan
|
||||
data class Branch(val branch: String) : NewWorktreePlan
|
||||
data class Pr(val url: String) : NewWorktreePlan
|
||||
}
|
||||
|
||||
/**
|
||||
* The New Worktree dialog as seen by its caller: show it, then read what the user confirmed.
|
||||
@@ -57,10 +61,16 @@ interface NewWorktreeHandle {
|
||||
}
|
||||
|
||||
/**
|
||||
* New Worktree dialog with parity to the VS Code Agent Manager dialog: a worktree name (top), an
|
||||
* initial prompt with the same mode / model / reasoning pickers as the chat prompt (center), and the
|
||||
* branch name + base branch (bottom). Creating a worktree starts a session automatically with the
|
||||
* prompt.
|
||||
* New Worktree dialog with parity to the VS Code Agent Manager dialog, split into three tabs:
|
||||
*
|
||||
* - **New** creates a branch: a worktree name (top), an initial prompt with the same mode / model /
|
||||
* reasoning pickers as the chat prompt (center), and the branch name + base branch (bottom).
|
||||
* Creating a worktree starts a session automatically with the prompt.
|
||||
* - **From PR** checks out a GitHub pull request by URL.
|
||||
* - **From Branch** checks out a local branch that no worktree holds yet.
|
||||
*
|
||||
* Both import tabs carry no initial prompt, so the worktree opens with an empty session. The
|
||||
* selected tab alone decides which input the OK button acts on.
|
||||
*
|
||||
* The dialog performs no worktree work itself — it records the confirmed [result] and closes; the
|
||||
* panel then drives the controller, so no view switch or worktree work runs while the modal dialog
|
||||
@@ -103,13 +113,12 @@ internal class NewWorktreeDialog(
|
||||
showEnhance = false,
|
||||
)
|
||||
private val branch = JBTextField(suggestedName)
|
||||
private val bases = baseBranches(branches, defaultBase)
|
||||
private val baseSet = bases.toSet()
|
||||
private val base = ComboBox(baseModel(bases)).apply {
|
||||
isEditable = true
|
||||
selectedItem = defaultBase
|
||||
private val base = BranchPicker(branches, defaultBase)
|
||||
private val url = JBTextField().apply {
|
||||
emptyText.text = KiloBundle.message("worktree.import.pr.placeholder")
|
||||
}
|
||||
private var syncing = false
|
||||
private val pick = BranchPicker(branches)
|
||||
private var tab = DialogTab.NEW
|
||||
|
||||
private var plan: NewWorktreePlan? = null
|
||||
|
||||
@@ -128,25 +137,25 @@ internal class NewWorktreeDialog(
|
||||
private var center: JComponent? = null
|
||||
|
||||
init {
|
||||
wireBase()
|
||||
if (pick.empty) pick.isEnabled = false
|
||||
title = KiloBundle.message("worktree.configure.title")
|
||||
init()
|
||||
setOKButtonText(KiloBundle.message("worktree.dialog.create"))
|
||||
}
|
||||
|
||||
override fun createCenterPanel(): JComponent = content().also { center = it }
|
||||
override fun createCenterPanel(): JComponent = tabs().also { center = it }
|
||||
|
||||
/** The built content, so tests can drive the real Swing tree before the dialog is shown. */
|
||||
internal fun centerComponent(): JComponent = center ?: error("center panel not built")
|
||||
|
||||
override fun result(): NewWorktreePlan? = plan
|
||||
|
||||
override fun getPreferredFocusedComponent(): JComponent = prompt.defaultFocusedComponent
|
||||
override fun getPreferredFocusedComponent(): JComponent = focus()
|
||||
|
||||
// Versioned: DialogWrapper persists the size per key, so a stale entry would keep the old width.
|
||||
override fun getDimensionServiceKey(): String = "ai.kilocode.NewWorktreeDialog.v2"
|
||||
override fun getDimensionServiceKey(): String = "ai.kilocode.NewWorktreeDialog.v3"
|
||||
|
||||
override fun doOKAction() = submitCreate()
|
||||
override fun doOKAction() = submit()
|
||||
|
||||
override fun dispose() {
|
||||
disposed = true
|
||||
@@ -154,7 +163,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 }
|
||||
}
|
||||
|
||||
+17
-6
@@ -20,6 +20,10 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
enum class CreateKind { CREATE, BRANCH, PR }
|
||||
|
||||
data class CreateFailure(val error: String?, val kind: CreateKind, val branch: String)
|
||||
|
||||
/**
|
||||
* Owns the worktree list model and drives the [KiloWorktreeService] off the EDT. Model mutations
|
||||
* are marshalled back onto the EDT via [edt]. Mirrors the History stack's controller shape.
|
||||
@@ -37,7 +41,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
|
||||
|
||||
+31
-1
@@ -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) }
|
||||
|
||||
+4
-2
@@ -1,6 +1,8 @@
|
||||
package ai.kilocode.client.agentManager
|
||||
|
||||
import ai.kilocode.client.agentManager.worktree.WorktreeIcons
|
||||
import ai.kilocode.client.agentManager.worktree.CreateFailure
|
||||
import ai.kilocode.client.agentManager.worktree.CreateKind
|
||||
import ai.kilocode.client.agentManager.worktree.KiloWorktreeService
|
||||
import ai.kilocode.client.agentManager.worktree.WorktreeController
|
||||
import ai.kilocode.client.agentManager.worktree.PendingPrompt
|
||||
@@ -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`() {
|
||||
|
||||
+139
-5
@@ -21,6 +21,9 @@ import com.intellij.openapi.ui.ComboBox
|
||||
import com.intellij.openapi.util.Disposer
|
||||
import com.intellij.testFramework.fixtures.BasePlatformTestCase
|
||||
import com.intellij.ui.components.JBPanel
|
||||
import com.intellij.ui.components.JBTextField
|
||||
import com.intellij.ui.tabs.JBTabs
|
||||
import com.intellij.ui.tabs.TabInfo
|
||||
import com.intellij.util.ui.UIUtil
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
@@ -31,6 +34,7 @@ import java.awt.Component
|
||||
import java.awt.Container
|
||||
import java.awt.event.FocusEvent
|
||||
import javax.swing.JTextField
|
||||
import javax.swing.plaf.basic.BasicComboBoxUI
|
||||
import javax.swing.plaf.basic.BasicComboPopup
|
||||
|
||||
class NewWorktreeDialogTest : BasePlatformTestCase() {
|
||||
@@ -144,6 +148,21 @@ class NewWorktreeDialogTest : BasePlatformTestCase() {
|
||||
}
|
||||
}
|
||||
|
||||
fun `test base picker survives a dropped editor during layout`() {
|
||||
open()
|
||||
|
||||
edt {
|
||||
val picker = combo() as BranchPicker
|
||||
val ui = picker.ui as BasicComboBoxUI
|
||||
ui.removeEditor()
|
||||
|
||||
picker.preferredSize
|
||||
|
||||
val comp = picker.editor.editorComponent
|
||||
assertTrue(picker.components.any { it === comp })
|
||||
}
|
||||
}
|
||||
|
||||
fun `test creating with empty base branch falls back to default`() {
|
||||
open()
|
||||
flushUntil { edt { model().selectionKeyForTest() != null } }
|
||||
@@ -184,6 +203,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)
|
||||
}
|
||||
Reference in New Issue
Block a user