From f5ff70ea3b26c9eb7994193cb9f8371691bdd73c Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 11 Aug 2026 19:48:52 -0400 Subject: [PATCH] fix(jetbrains): improve worktree base branch selector --- .changeset/quiet-branches-search.md | 5 + .../worktree/NewWorktreeDialog.kt | 111 +++++++++++++++++- .../ui/prompt/KiloPromptCompletionProvider.kt | 39 +----- .../session/ui/prompt/PromptFuzzyRanker.kt | 37 ++++++ .../resources/messages/KiloBundle.properties | 2 + .../worktree/NewWorktreeDialogTest.kt | 85 +++++++++++++- 6 files changed, 236 insertions(+), 43 deletions(-) create mode 100644 .changeset/quiet-branches-search.md create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptFuzzyRanker.kt diff --git a/.changeset/quiet-branches-search.md b/.changeset/quiet-branches-search.md new file mode 100644 index 00000000000..3c517fa3788 --- /dev/null +++ b/.changeset/quiet-branches-search.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Improve the New Worktree base branch selector with fuzzy matching, default fallback, and validation for unknown branches. 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 fbad47b7f09..efd3a047b77 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 @@ -1,5 +1,6 @@ package ai.kilocode.client.agentManager.worktree +import ai.kilocode.client.KiloNotifications import ai.kilocode.client.app.KiloAppService import ai.kilocode.client.app.KiloWorkspaceService import ai.kilocode.client.plugin.KiloBundle @@ -9,6 +10,7 @@ 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.ui.UiStyle @@ -20,6 +22,7 @@ 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.util.ui.FormBuilder import com.intellij.util.ui.JBUI @@ -30,8 +33,14 @@ 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 = 100 @@ -82,10 +91,13 @@ internal class NewWorktreeDialog( showEnhance = false, ) private val branch = JBTextField(suggestedName) - private val base = ComboBox(baseModel(branches, defaultBase)).apply { + private val bases = baseBranches(branches, defaultBase) + private val baseSet = bases.toSet() + private val base = ComboBox(baseModel(bases)).apply { isEditable = true selectedItem = defaultBase } + private var syncing = false /** The agent (mode) for the new session; model selections persist against it. */ private var agent: String? = null @@ -102,6 +114,7 @@ internal class NewWorktreeDialog( private var center: JComponent? = null init { + wireBase() title = KiloBundle.message("worktree.configure.title") init() setOKButtonText(KiloBundle.message("worktree.dialog.create")) @@ -199,10 +212,96 @@ 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 + KiloNotifications.error( + project, + KiloBundle.message("worktree.configure.base.invalid.title"), + KiloBundle.message("worktree.configure.base.invalid.content", value), + ) + baseField()?.apply { + requestFocusInWindow() + selectAll() + } + syncBase(value, popup = true) + return false + } + private fun submitCreate(text: String = prompt.text()) { val explicit = branch.text.trim() val resolved = explicit.ifEmpty { name.text.trim() }.ifEmpty { suggestedName } - onCreate(resolved, base.editor.item?.toString()?.trim()?.takeIf { it.isNotEmpty() }, pending(text)) + val target = resolvedBase() + if (!validBase(target)) return + onCreate(resolved, target, pending(text)) close(OK_EXIT_CODE) } @@ -245,11 +344,15 @@ internal class NewWorktreeDialog( spec.available, ) - private fun baseModel(branches: List, default: String): DefaultComboBoxModel { + private fun baseBranches(branches: List, default: String): List { val ordered = LinkedHashSet() if (default.isNotBlank()) ordered.add(default) ordered.addAll(branches) - return DefaultComboBoxModel(ordered.toTypedArray()) + return ordered.toList() + } + + private fun baseModel(branches: List): ComboBoxModel { + return DefaultComboBoxModel(branches.toTypedArray()) } private fun variantTitle(value: String): String = value.replaceFirstChar { it.titlecase() } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/KiloPromptCompletionProvider.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/KiloPromptCompletionProvider.kt index f7f5f3617d6..d8fff547bbd 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/KiloPromptCompletionProvider.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/KiloPromptCompletionProvider.kt @@ -20,10 +20,7 @@ import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.fileTypes.FileTypeManager import com.intellij.openapi.project.DumbAware import com.intellij.openapi.progress.runBlockingCancellable -import com.intellij.psi.codeStyle.MinusculeMatcher -import com.intellij.psi.codeStyle.NameUtil import com.intellij.util.textCompletion.TextCompletionProvider -import com.intellij.util.text.matching.MatchingMode import java.util.Collections import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @@ -166,7 +163,7 @@ class KiloPromptCompletionProvider( private fun slash(prefix: String, result: CompletionResultSet) { result.restartCompletionOnAnyPrefixChange() val out = result.withPrefixMatcher(PlainPrefixMatcher.ALWAYS_TRUE) - val rank = Ranker(prefix) + val rank = PromptFuzzyRanker(prefix) val names = clientTokens() val clients = actions.mapNotNull { action -> rank.score(action.name, action.hints)?.let { Hit(client(action), it) } @@ -187,7 +184,7 @@ class KiloPromptCompletionProvider( result.restartCompletionOnAnyPrefixChange() val out = result.withPrefixMatcher(PlainPrefixMatcher.ALWAYS_TRUE) val search = search(prefix) - val rank = Ranker(prefix) + val rank = PromptFuzzyRanker(prefix) val known = mentions.filter { action -> rank.matches(action.name, action.hints) && action.available(search) } known.forEach { action -> out.addElement(prioritize(resource(action))) } if (search.indexing) { @@ -224,38 +221,6 @@ class KiloPromptCompletionProvider( } .withAutoCompletionPolicy(AutoCompletionPolicy.NEVER_AUTOCOMPLETE) - private class Ranker(prefix: String) { - private val start = matcher(prefix) - private val middle = if (prefix.any { separator(it) }) null else matcher("*$prefix") - - fun matches(name: String, hints: List): Boolean = score(name, hints) != null - - fun score(name: String, hints: List): Int? = (listOf(name) + hints).maxOfOrNull { value -> - score(value) ?: Int.MIN_VALUE - }?.takeIf { it != Int.MIN_VALUE } - - private fun score(value: String): Int? { - val exact = start.match(value) - if (exact != null) return START + start.matchingDegree(value, true, exact) - val fallback = middle ?: return null - val fuzzy = fallback.match(value) ?: return null - return fallback.matchingDegree(value, false, fuzzy) - } - - private companion object { - const val START = 10_000 - - fun matcher(prefix: String): MinusculeMatcher = NameUtil.buildMatcher(prefix) - .withMatchingMode(MatchingMode.IGNORE_CASE) - .build() - - fun separator(c: Char): Boolean = when (c) { - '_', '-', ':', '+', '.' -> true - else -> c.isWhitespace() - } - } - } - private fun commandName(text: String): String? { val raw = text.trimStart() if (!raw.startsWith('/')) return null diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptFuzzyRanker.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptFuzzyRanker.kt new file mode 100644 index 00000000000..39ec5d5c58f --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptFuzzyRanker.kt @@ -0,0 +1,37 @@ +package ai.kilocode.client.session.ui.prompt + +import com.intellij.psi.codeStyle.MinusculeMatcher +import com.intellij.psi.codeStyle.NameUtil +import com.intellij.util.text.matching.MatchingMode + +internal class PromptFuzzyRanker(prefix: String) { + private val start = matcher(prefix) + private val middle = if (prefix.any { separator(it) }) null else matcher("*$prefix") + + fun matches(name: String, hints: List): Boolean = score(name, hints) != null + + fun score(name: String, hints: List): Int? = (listOf(name) + hints).maxOfOrNull { value -> + score(value) ?: Int.MIN_VALUE + }?.takeIf { it != Int.MIN_VALUE } + + private fun score(value: String): Int? { + val exact = start.match(value) + if (exact != null) return START + start.matchingDegree(value, true, exact) + val fallback = middle ?: return null + val fuzzy = fallback.match(value) ?: return null + return fallback.matchingDegree(value, false, fuzzy) + } + + private companion object { + const val START = 10_000 + + fun matcher(prefix: String): MinusculeMatcher = NameUtil.buildMatcher(prefix) + .withMatchingMode(MatchingMode.IGNORE_CASE) + .build() + + fun separator(c: Char): Boolean = when (c) { + '_', '-', ':', '+', '.' -> true + else -> c.isWhitespace() + } + } +} 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 e5d31a81d01..1de3a3fc320 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -368,6 +368,8 @@ worktree.configure.title=New Worktree worktree.configure.branch=Branch name: worktree.configure.base=Base branch: worktree.configure.branch.required=Branch name is required +worktree.configure.base.invalid.title=Base branch not found +worktree.configure.base.invalid.content=Select an existing base branch before creating a worktree: {0} 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 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 e67e351cfe8..0aa0fadf2a8 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 @@ -17,6 +17,7 @@ import ai.kilocode.rpc.dto.ModelSelectionDto import ai.kilocode.rpc.dto.ModelsWorkspaceDto import ai.kilocode.rpc.dto.ProviderDto import ai.kilocode.rpc.dto.ProvidersDto +import com.intellij.openapi.ui.ComboBox import com.intellij.openapi.util.Disposer import com.intellij.testFramework.fixtures.BasePlatformTestCase import com.intellij.ui.components.JBPanel @@ -28,6 +29,9 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking import java.awt.Component import java.awt.Container +import java.awt.event.FocusEvent +import javax.swing.JTextField +import javax.swing.plaf.basic.BasicComboPopup class NewWorktreeDialogTest : BasePlatformTestCase() { private lateinit var scope: CoroutineScope @@ -125,7 +129,71 @@ class NewWorktreeDialogTest : BasePlatformTestCase() { assertEquals("gpt-5", payload.model) } - private fun open() { + fun `test base branch fuzzy search selects matching popup item`() { + open(branches = listOf("main", "release/candidate", "feature/refactor-ui")) + + edt { field().text = "relcan" } + + edt { assertEquals("release/candidate", popup().list.selectedValue) } + } + + fun `test empty base branch restores default on focus lost`() { + open() + + edt { + val field = field() + field.text = "" + field.focusListeners.forEach { it.focusLost(FocusEvent(field, FocusEvent.FOCUS_LOST)) } + + assertEquals("main", field.text) + } + } + + fun `test creating with empty base branch falls back to default`() { + open() + flushUntil { edt { model().selectionKeyForTest() != null } } + edt { + field().text = "" + prompt().setText("build the thing") + } + flushUntil { edt { prompt().isSendEnabled } } + edt { prompt().send() } + flushUntil { created.isNotEmpty() } + dialog = null + + assertEquals("main", created.single().second) + } + + fun `test creating with fuzzy base branch uses matching branch`() { + open(branches = listOf("main", "release/candidate", "feature/refactor-ui")) + flushUntil { edt { model().selectionKeyForTest() != null } } + edt { + field().text = "relcan" + prompt().setText("build the thing") + } + flushUntil { edt { prompt().isSendEnabled } } + edt { prompt().send() } + flushUntil { created.isNotEmpty() } + dialog = null + + assertEquals("release/candidate", created.single().second) + } + + fun `test creating with unknown base branch does not create`() { + open(branches = listOf("main", "release/candidate")) + flushUntil { edt { model().selectionKeyForTest() != null } } + edt { + field().text = "zzzzzz" + prompt().setText("build the thing") + } + flushUntil { edt { prompt().isSendEnabled } } + edt { prompt().send() } + flush() + + assertTrue(created.isEmpty()) + } + + private fun open(branches: List = listOf("main")) { dialog = edt { NewWorktreeDialog( JBPanel(), @@ -133,7 +201,7 @@ class NewWorktreeDialogTest : BasePlatformTestCase() { "/test", "agent/foo", "main", - listOf("main"), + branches, onCreate = { branch, base, prompt -> created.add(Triple(branch, base, prompt)) }, app, workspaces, @@ -167,6 +235,12 @@ class NewWorktreeDialogTest : BasePlatformTestCase() { private fun prompt(): PromptPanel = descendants(root()).filterIsInstance().single() + private fun combo(): ComboBox<*> = descendants(root()).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 descendants(root: Component): List { @@ -181,6 +255,13 @@ class NewWorktreeDialogTest : BasePlatformTestCase() { private fun edt(block: () -> T): T = edtWait(block) + private fun flush() = runBlocking { + repeat(20) { + delay(10) + edt { UIUtil.dispatchAllInvocationEvents() } + } + } + private fun flushUntil(done: () -> Boolean) = runBlocking { repeat(200) { delay(10)