fix(jetbrains): keep provider auth overlay fixed

This commit is contained in:
kirillk
2026-06-16 14:25:29 -04:00
parent f0ad6db182
commit e13ee2fe23
9 changed files with 200 additions and 43 deletions
@@ -66,6 +66,7 @@ abstract class KiloReadyConfigurable : SearchableConfigurable, Configurable.NoSc
override fun disposeUIResources() {
val panel = ready
val cs = scope
if (panel is SettingsOverlayPanel) panel.setOverlayHost(null)
shell = null
scope = null
ready = null
@@ -94,7 +95,9 @@ abstract class KiloReadyConfigurable : SearchableConfigurable, Configurable.NoSc
val cs = scope ?: return
val panel = createReadyComponent(cs)
ready = panel
shell?.setContent(panel)
val root = shell
if (panel is SettingsOverlayPanel) panel.setOverlayHost(root)
root?.setContent(panel)
onReadyComponentCreated(panel)
}
@@ -2,10 +2,12 @@ package ai.kilocode.client.settings.base
import ai.kilocode.client.ui.LayeredOverlayPanel
import ai.kilocode.client.ui.UiStyle
import com.intellij.util.concurrency.annotations.RequiresEdt
import java.awt.Rectangle
internal open class SettingsOverlayPanel : LayeredOverlayPanel() {
val progress = SettingsProgressOverlay()
private var host: SettingsOverlayPanel? = null
init {
addOverlay(progress) { pane, child ->
@@ -19,31 +21,55 @@ internal open class SettingsOverlayPanel : LayeredOverlayPanel() {
}
}
fun showProgress(text: String) {
progress.showProgress(text)
syncOverlay()
}
fun showProgress(text: String, cancelText: String, cancel: () -> Unit) {
progress.showProgress(text, cancelText, cancel)
syncOverlay()
}
fun updateProgress(text: String) {
progress.updateProgress(text)
syncOverlay()
}
fun showError(text: String) {
progress.showError(text)
syncOverlay()
}
fun clearProgress() {
@RequiresEdt
fun setOverlayHost(host: SettingsOverlayPanel?) {
if (host === this) {
this.host = null
return
}
this.host?.clearProgress()
this.host = host
progress.clearProgress()
syncOverlay()
}
@RequiresEdt
fun showProgress(text: String) {
val panel = target()
panel.progress.showProgress(text)
panel.syncOverlay()
}
@RequiresEdt
fun showProgress(text: String, cancelText: String, cancel: () -> Unit) {
val panel = target()
panel.progress.showProgress(text, cancelText, cancel)
panel.syncOverlay()
}
@RequiresEdt
fun updateProgress(text: String) {
val panel = target()
panel.progress.updateProgress(text)
panel.syncOverlay()
}
@RequiresEdt
fun showError(text: String) {
val panel = target()
panel.progress.showError(text)
panel.syncOverlay()
}
@RequiresEdt
fun clearProgress() {
val panel = target()
panel.progress.clearProgress()
panel.syncOverlay()
}
private fun target(): SettingsOverlayPanel = host ?: this
private fun syncOverlay() {
overlay.revalidate()
overlay.repaint()
@@ -29,6 +29,7 @@ internal class SettingsProgressOverlay : JPanel(BorderLayout(UiStyle.Gap.md(), 0
button.isVisible = false
isVisible = false
syncColors()
UiStyle.Components.actionButton(button)
}
fun showProgress(text: String) {
@@ -72,6 +73,7 @@ internal class SettingsProgressOverlay : JPanel(BorderLayout(UiStyle.Gap.md(), 0
}
button.text = text
button.addActionListener { action() }
UiStyle.Components.actionButton(button)
button.isVisible = true
}
@@ -88,6 +90,7 @@ internal class SettingsProgressOverlay : JPanel(BorderLayout(UiStyle.Gap.md(), 0
override fun updateUI() {
super.updateUI()
syncColors()
cancel?.let { UiStyle.Components.actionButton(it) }
}
private fun syncColors() {
@@ -22,7 +22,6 @@ import javax.swing.JList
import javax.swing.JPanel
import javax.swing.ListCellRenderer
import javax.swing.SwingConstants
import javax.swing.UIManager
private const val ACTION_GAP = 8
@@ -93,25 +92,27 @@ internal class ProviderListRenderer(
private val mark = icon.align(HAlign.CENTER, VAlign.TOP)
private val title = SimpleColoredComponent()
private val desc = JBLabel()
private val text = JPanel(BorderLayout()).apply {
add(title, BorderLayout.NORTH)
add(desc, BorderLayout.SOUTH)
}
private val text = Stack.vertical().next(title).next(desc)
private val actions = Stack.horizontal(JBUI.scale(ACTION_GAP))
private val row = Stack.horizontal(UiStyle.Gap.md()).next(mark).next(text)
private val actionPane = actions.align(HAlign.RIGHT, VAlign.CENTER)
private val row = JPanel(BorderLayout(UiStyle.Gap.md(), 0)).apply {
add(mark, BorderLayout.WEST)
add(text, BorderLayout.CENTER)
add(actionPane, BorderLayout.EAST)
}
private val wrap = PickerRow()
init {
isOpaque = true
top.isOpaque = true
UiStyle.Components.transparent(row, mark, icon, title, text, desc, actions)
UiStyle.Components.transparent(row, mark, icon, title, text, desc, actions, actionPane)
row.border = JBUI.Borders.empty(
UiStyle.Gap.md(),
UiStyle.Gap.lg(),
UiStyle.Gap.md(),
UiStyle.Gap.pad(),
)
wrap.setContent(row, actions)
wrap.setContent(row)
add(top, BorderLayout.NORTH)
add(wrap, BorderLayout.CENTER)
}
@@ -145,12 +146,13 @@ internal class ProviderListRenderer(
desc.foreground = weak
actions.removeAll()
for (action in visibleActions(value, selected)) {
val visible = visibleActions(value, selected)
actions.isVisible = visible.isNotEmpty()
actionPane.isVisible = visible.isNotEmpty()
for (action in visible) {
actions.add(ActionLabel(action).apply {
isEnabled = value.enabled(action)
foreground = if (isEnabled) UIManager.getColor("Button.foreground") ?: UIUtil.getLabelForeground()
else UIManager.getColor("Button.disabledText") ?: UIUtil.getContextHelpForeground()
background = UIManager.getColor("Button.background")
UiStyle.Components.actionLabel(this, isEnabled)
})
}
top.invalidate()
@@ -168,11 +170,7 @@ internal class ProviderListRenderer(
private class ActionLabel(action: ProviderListAction) : JBLabel(text(action)) {
init {
horizontalAlignment = SwingConstants.CENTER
border = JBUI.Borders.compound(
JBUI.Borders.customLine(UIUtil.getBoundsColor()),
JBUI.Borders.empty(UiStyle.Gap.sm(), UiStyle.Gap.pad()),
)
isOpaque = true
UiStyle.Components.actionLabel(this)
}
}
}
@@ -225,7 +225,7 @@ internal class ProvidersSettingsUi(
withContext(edt) {
if (!active(id)) return@withContext
setBusy(false)
showError("${e::class.simpleName}: ${e.message}")
clearProgress()
}
} catch (e: CancellationException) {
LOG.info("provider settings ui $name: coroutine cancelled durationMs=${System.currentTimeMillis() - start}")
@@ -6,6 +6,7 @@ import com.intellij.util.ui.JBFont
import com.intellij.util.ui.JBUI
import com.intellij.util.ui.UIUtil
import java.awt.Color
import javax.swing.AbstractButton
import javax.swing.JComponent
import javax.swing.UIManager
@@ -197,5 +198,35 @@ object UiStyle {
fun transparent(vararg components: JComponent) {
components.forEach { it.isOpaque = false }
}
fun actionForeground(enabled: Boolean): Color = if (enabled) {
UIManager.getColor("Button.foreground") ?: UIUtil.getLabelForeground()
} else {
UIManager.getColor("Button.disabledText") ?: UIUtil.getContextHelpForeground()
}
fun actionBackground(): Color = UIManager.getColor("Button.background") ?: UIUtil.getPanelBackground()
fun actionBorder() = JBUI.Borders.compound(
JBUI.Borders.customLine(UIUtil.getBoundsColor()),
JBUI.Borders.empty(Gap.sm(), Gap.pad()),
)
fun actionLabel(component: JComponent, enabled: Boolean = component.isEnabled) {
component.foreground = actionForeground(enabled)
component.background = actionBackground()
component.border = actionBorder()
component.isOpaque = true
}
fun actionButton(button: AbstractButton) {
button.foreground = actionForeground(button.isEnabled)
button.background = actionBackground()
button.border = actionBorder()
button.isOpaque = true
button.isBorderPainted = true
button.isContentAreaFilled = false
button.isFocusPainted = false
}
}
}
@@ -126,8 +126,33 @@ class KiloReadyConfigurableTest : BasePlatformTestCase() {
assertTrue(view.disposedOnEdt)
}
private fun create(): FakeConfigurable {
val view = FakeConfigurable()
fun `test ready settings overlay is hosted by outer shell`() {
val view = create(overlay = true)
val root = edt { view.createComponent() as SettingsPanel }
flushUntil { rpc.connected }
rpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY)
flushUntil { edt { view.readyPanel != null } }
edt {
val ready = requireNotNull(view.readyPanel)
ready.showProgress("Authorizing provider")
assertTrue(text(root).contains("Authorizing provider"))
assertTrue(root.progress.isVisible)
assertFalse(ready.progress.isVisible)
assertTrue(root.overlay.components.any { it === root.progress })
assertFalse(ready.overlay.components.any { it === root.progress })
}
edt { view.disposeUIResources() }
cfg = null
edt { assertFalse(root.progress.isVisible) }
}
private fun create(overlay: Boolean = false): FakeConfigurable {
val view = FakeConfigurable(overlay)
cfg = view
return view
}
@@ -175,9 +200,11 @@ class KiloReadyConfigurableTest : BasePlatformTestCase() {
visit(root)
}
private class FakeConfigurable : KiloReadyConfigurable() {
private class FakeConfigurable(private val overlay: Boolean = false) : KiloReadyConfigurable() {
val field = JPanel()
val focuses = mutableListOf<String>()
var readyPanel: SettingsPanel? = null
private set
var created = 0
private set
var disposed = 0
@@ -196,6 +223,12 @@ class KiloReadyConfigurableTest : BasePlatformTestCase() {
override fun createReadyComponent(cs: CoroutineScope): JComponent {
created++
if (overlay) {
val panel = SettingsPanel()
panel.setContent(JPanel().apply { add(JLabel("Ready content")) })
readyPanel = panel
return panel
}
return JPanel().apply { add(JLabel("Ready content")) }
}
@@ -119,6 +119,8 @@ class SettingsRowsTest : BasePlatformTestCase() {
assertFalse(text(panel.overlay).contains("Sign in to Kilo Code"))
assertTrue(panel.overlay.components.any { it === panel.progress })
assertTrue(text(panel.progress).contains("Loading models..."))
val scroll = components(panel.content).filterIsInstance<JScrollPane>().single()
assertFalse(components(scroll.viewport.view as JComponent).any { it === panel.progress })
}
fun `test settings panel tracks viewport width without horizontal scroll`() {
@@ -2,6 +2,7 @@ package ai.kilocode.client.settings.providers
import ai.kilocode.client.app.KiloProviderService
import ai.kilocode.client.testing.FakeProviderRpcApi
import ai.kilocode.client.ui.UiStyle
import ai.kilocode.rpc.dto.CustomProviderConfigDto
import ai.kilocode.rpc.dto.ModelDto
import ai.kilocode.rpc.dto.ProviderAuthMethodDto
@@ -21,10 +22,12 @@ import com.intellij.util.ui.UIUtil
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import java.awt.BorderLayout
import java.awt.Container
import java.awt.Dimension
@@ -327,6 +330,24 @@ class ProvidersSettingsUiTest : BasePlatformTestCase() {
}
}
fun `test renderer lays out action labels with visible bounds`() {
edt {
val row = ProviderListRow(provider("openai", "OpenAI"), "Popular providers", listOf(ProviderListAction.CONNECT))
val list = JBList(listOf(row))
val renderer = ProviderListRenderer(com.intellij.ui.CollectionListModel(listOf(row)))
renderer.getListCellRendererComponent(list, row, 0, true, false)
renderer.setSize(320, renderer.preferredSize.height)
renderer.doLayout()
components(renderer).filterIsInstance<Container>().forEach { it.doLayout() }
val label = components(renderer).filterIsInstance<JBLabel>().single { it.text == "Connect" }
assertTrue(label.isShowing || label.isVisible)
assertTrue(label.width > 0)
assertTrue(label.height > 0)
}
}
fun `test renderer hides unselected unconnected action labels`() {
edt {
val row = ProviderListRow(provider("cloudflare", "Cloudflare"), "All providers", listOf(ProviderListAction.CONNECT))
@@ -437,6 +458,7 @@ class ProvidersSettingsUiTest : BasePlatformTestCase() {
val area = ProviderListRenderer.actionBounds(list, bounds, row, selected = true).getValue(ProviderListAction.CONNECT)
assertTrue(kotlin.math.abs((bounds.y + bounds.height / 2) - (area.y + area.height / 2)) <= 1)
assertTrue(bounds.contains(area))
}
}
@@ -489,6 +511,10 @@ class ProvidersSettingsUiTest : BasePlatformTestCase() {
edt {
assertTrue(text(panel).contains("Cancel"))
val cancel = components(panel).filterIsInstance<JButton>().single { it.text == "Cancel" && it.isVisible }
assertEquals(UiStyle.Components.actionForeground(true), cancel.foreground)
assertEquals(UiStyle.Components.actionBackground(), cancel.background)
assertEquals(requireNotNull(UiStyle.Components.actionBorder()).getBorderInsets(cancel), requireNotNull(cancel.border).getBorderInsets(cancel))
assertTrue(rows(panel).single().disabled)
assertTrue(ProviderListRenderer.visibleActions(rows(panel).single(), selected = true).isEmpty())
panel.reload()
@@ -551,6 +577,41 @@ class ProvidersSettingsUiTest : BasePlatformTestCase() {
}
}
fun `test provider oauth timeout clears progress without error`() {
val ready = CompletableDeferred<ProviderOAuthReadyDto>()
val rpc = installProvider(
ProviderSettingsDto(
providers = listOf(provider("github-copilot", "GitHub Copilot")),
auth = mapOf("github-copilot" to listOf(ProviderAuthMethodDto("oauth", "OAuth"))),
),
)
rpc.authorizesReady.add(ready)
val panel = edt { createUi() }
flushUntil { rpc.stateCalls.size == 1 && edt { rows(panel).map { it.key } == listOf("github-copilot") } }
edt { triggerPrimary(panel) }
flushUntil { rpc.authorizes.size == 1 && edt { text(panel).contains("Starting OAuth for GitHub Copilot") } }
val timeout = runBlocking {
try {
withTimeout(1) { delay(Long.MAX_VALUE) }
error("timeout expected")
} catch (e: TimeoutCancellationException) {
e
}
}
ready.completeExceptionally(timeout)
flushUntil { edt { rows(panel).single().disabled.not() && !text(panel).contains("Starting OAuth") } }
edt {
val visible = text(panel)
assertFalse(visible.contains("TimeoutCancellationException"))
assertFalse(visible.contains("OAuth timed out"))
assertFalse(visible.contains("Cancel"))
assertTrue(rpc.callbacks.isEmpty())
}
}
fun `test provider action failure returns error state`() = runBlocking {
val cs = CoroutineScope(SupervisorJob())
scope = cs