mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-19 01:51:21 +08:00
fix(jetbrains): prefer headless provider oauth
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": patch
|
||||
---
|
||||
|
||||
Prefer remote-safe provider OAuth methods in JetBrains and show device-code authorization details when available.
|
||||
+217
@@ -0,0 +1,217 @@
|
||||
package ai.kilocode.client.settings.auth
|
||||
|
||||
import ai.kilocode.client.plugin.KiloBundle
|
||||
import ai.kilocode.client.ui.HoverIcon
|
||||
import ai.kilocode.client.ui.RoundedContentPanel
|
||||
import ai.kilocode.client.ui.UiStyle
|
||||
import com.intellij.icons.AllIcons
|
||||
import com.intellij.openapi.ide.CopyPasteManager
|
||||
import com.intellij.openapi.ui.popup.Balloon
|
||||
import com.intellij.openapi.ui.popup.JBPopupFactory
|
||||
import com.intellij.ui.SimpleColoredComponent
|
||||
import com.intellij.ui.SimpleTextAttributes
|
||||
import com.intellij.ui.awt.RelativePoint
|
||||
import com.intellij.ui.components.JBLabel
|
||||
import com.intellij.ui.components.JBTextField
|
||||
import com.intellij.util.concurrency.annotations.RequiresEdt
|
||||
import com.intellij.util.ui.AsyncProcessIcon
|
||||
import com.intellij.util.ui.JBUI
|
||||
import java.awt.BorderLayout
|
||||
import java.awt.FlowLayout
|
||||
import java.awt.GridBagConstraints
|
||||
import java.awt.GridBagLayout
|
||||
import java.awt.Point
|
||||
import java.awt.datatransfer.StringSelection
|
||||
import java.awt.event.FocusAdapter
|
||||
import java.awt.event.FocusEvent
|
||||
import java.awt.event.MouseAdapter
|
||||
import java.awt.event.MouseEvent
|
||||
import javax.swing.JButton
|
||||
import javax.swing.JPanel
|
||||
import javax.swing.SwingConstants
|
||||
import javax.swing.Timer
|
||||
|
||||
internal data class DeviceOAuthInfo(
|
||||
val url: String,
|
||||
val code: String?,
|
||||
val expiresIn: Int,
|
||||
val started: Long,
|
||||
)
|
||||
|
||||
internal data class DeviceOAuthText(
|
||||
val title: String,
|
||||
val qrDescription: String,
|
||||
)
|
||||
|
||||
internal class DeviceOAuthPanel(
|
||||
private val copy: DeviceOAuthText,
|
||||
private val cancel: () -> Unit,
|
||||
private val browse: (String) -> Unit,
|
||||
private val prefix: String,
|
||||
) : JPanel(GridBagLayout()) {
|
||||
val urlField = JBTextField().apply {
|
||||
isEditable = false
|
||||
name = "$prefix.url"
|
||||
columns = 30
|
||||
addFocusListener(object : FocusAdapter() {
|
||||
override fun focusGained(e: FocusEvent) = selectAll()
|
||||
})
|
||||
addMouseListener(object : MouseAdapter() {
|
||||
override fun mouseClicked(e: MouseEvent) = selectAll()
|
||||
})
|
||||
}
|
||||
|
||||
val qrLabel = JBLabel().apply {
|
||||
horizontalAlignment = SwingConstants.CENTER
|
||||
name = "$prefix.qr"
|
||||
accessibleContext.accessibleName = KiloBundle.message("profile.login.qr")
|
||||
accessibleContext.accessibleDescription = copy.qrDescription
|
||||
}
|
||||
|
||||
private val openBtn = JButton(KiloBundle.message("profile.login.openBrowser"))
|
||||
private val cancelBtn = JButton(KiloBundle.message("profile.login.cancel")).also { it.addActionListener { cancel() } }
|
||||
private val copyUrlBtn = HoverIcon().apply {
|
||||
icon = AllIcons.Actions.Copy
|
||||
toolTipText = KiloBundle.message("profile.login.copyUrl")
|
||||
}
|
||||
private val codePanel = RoundedContentPanel(UiStyle.Gap.sm(), UiStyle.Gap.md()).apply {
|
||||
name = "$prefix.codePanel"
|
||||
addMouseListener(object : MouseAdapter() {
|
||||
override fun mouseClicked(e: MouseEvent) {
|
||||
val c = code ?: return
|
||||
copyToClipboard(c, KiloBundle.message("profile.login.codeCopied"), this@DeviceOAuthPanel)
|
||||
}
|
||||
})
|
||||
}
|
||||
private val codeLabel = JBLabel().apply {
|
||||
horizontalAlignment = SwingConstants.CENTER
|
||||
font = UiStyle.Fonts.large()
|
||||
}
|
||||
private val codeHint = JBLabel(KiloBundle.message("profile.login.clickToCopy")).apply {
|
||||
foreground = UiStyle.Colors.weak()
|
||||
horizontalAlignment = SwingConstants.CENTER
|
||||
}
|
||||
private val waitIcon = AsyncProcessIcon("KiloOAuth")
|
||||
private val waitLabel = JBLabel().apply {
|
||||
foreground = UiStyle.Colors.weak()
|
||||
}
|
||||
private var step2: SimpleColoredComponent? = null
|
||||
private var code: String? = null
|
||||
private var started = 0L
|
||||
private var expires = 900
|
||||
private var last: String? = null
|
||||
private val timer = Timer(1000) { syncTime() }
|
||||
|
||||
init {
|
||||
border = JBUI.Borders.empty(UiStyle.Gap.pad())
|
||||
codePanel.add(codeLabel, BorderLayout.CENTER)
|
||||
codePanel.add(codeHint, BorderLayout.SOUTH)
|
||||
build()
|
||||
}
|
||||
|
||||
private fun build() {
|
||||
var row = 0
|
||||
add(JBLabel(copy.title).apply {
|
||||
font = UiStyle.Fonts.heading()
|
||||
horizontalAlignment = SwingConstants.CENTER
|
||||
}, gbc(row++))
|
||||
add(stepLabel(KiloBundle.message("profile.login.step.one"), KiloBundle.message("profile.login.step.url")), gbc(row++, UiStyle.Gap.md()))
|
||||
add(urlRow(), gbc(row++, UiStyle.Gap.sm()))
|
||||
add(qrLabel, gbc(row++, UiStyle.Gap.md()).centered())
|
||||
val s2 = stepLabel(KiloBundle.message("profile.login.step.two"), KiloBundle.message("profile.login.step.code"))
|
||||
step2 = s2
|
||||
add(s2, gbc(row++, UiStyle.Gap.md()))
|
||||
add(codePanel, gbc(row++, UiStyle.Gap.sm()))
|
||||
add(JPanel(FlowLayout(FlowLayout.CENTER, UiStyle.Gap.sm(), 0)).apply {
|
||||
isOpaque = false
|
||||
add(waitIcon)
|
||||
add(waitLabel)
|
||||
}, gbc(row++, UiStyle.Gap.xl()))
|
||||
add(cancelBtn, gbc(row, UiStyle.Gap.sm()).centered())
|
||||
}
|
||||
|
||||
private fun stepLabel(step: String, text: String) = SimpleColoredComponent().apply {
|
||||
append(step, SimpleTextAttributes.REGULAR_BOLD_ATTRIBUTES)
|
||||
append(" $text", SimpleTextAttributes.GRAYED_ATTRIBUTES)
|
||||
}
|
||||
|
||||
private fun urlRow(): JPanel {
|
||||
val row = JPanel(BorderLayout(UiStyle.Gap.xs(), 0))
|
||||
row.add(urlField, BorderLayout.CENTER)
|
||||
row.add(JPanel(FlowLayout(FlowLayout.RIGHT, UiStyle.Gap.sm(), 0)).apply {
|
||||
isOpaque = false
|
||||
add(copyUrlBtn)
|
||||
add(openBtn)
|
||||
}, BorderLayout.EAST)
|
||||
return row
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
fun update(info: DeviceOAuthInfo) {
|
||||
code = info.code
|
||||
urlField.text = info.url
|
||||
urlField.toolTipText = info.url
|
||||
if (info.url != last) {
|
||||
last = info.url
|
||||
openBtn.actionListeners.toList().forEach { openBtn.removeActionListener(it) }
|
||||
openBtn.addActionListener { browse(info.url) }
|
||||
copyUrlBtn.actionListeners.toList().forEach { copyUrlBtn.removeActionListener(it) }
|
||||
copyUrlBtn.addActionListener { copyToClipboard(info.url, KiloBundle.message("profile.login.urlCopied"), copyUrlBtn) }
|
||||
qrLabel.icon = try {
|
||||
QrCode.icon(info.url, JBUI.scale(160))
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
codePanel.isVisible = info.code != null
|
||||
step2?.isVisible = info.code != null
|
||||
if (info.code != null) codeLabel.text = spaced(info.code)
|
||||
started = info.started
|
||||
expires = info.expiresIn
|
||||
syncTime()
|
||||
waitIcon.resume()
|
||||
timer.restart()
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
fun dispose() {
|
||||
timer.stop()
|
||||
waitIcon.suspend()
|
||||
last = null
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
private fun syncTime() {
|
||||
val elapsed = ((System.currentTimeMillis() - started) / 1000).toInt()
|
||||
val remain = (expires - elapsed).coerceAtLeast(0)
|
||||
val min = remain / 60
|
||||
val sec = remain % 60
|
||||
waitLabel.text = KiloBundle.message("profile.login.waitingTimed", "$min:${sec.toString().padStart(2, '0')}")
|
||||
}
|
||||
|
||||
private fun gbc(y: Int, top: Int = 0) = GridBagConstraints().apply {
|
||||
gridx = 0
|
||||
gridy = y
|
||||
weightx = 1.0
|
||||
fill = GridBagConstraints.HORIZONTAL
|
||||
insets = JBUI.insetsTop(top)
|
||||
}
|
||||
|
||||
private fun GridBagConstraints.centered(): GridBagConstraints = apply {
|
||||
fill = GridBagConstraints.NONE
|
||||
anchor = GridBagConstraints.CENTER
|
||||
}
|
||||
|
||||
private fun spaced(code: String): String = code.map { it.toString() }.joinToString(" ")
|
||||
}
|
||||
|
||||
internal fun copyToClipboard(text: String, msg: String, anchor: java.awt.Component) {
|
||||
CopyPasteManager.getInstance().setContents(StringSelection(text))
|
||||
if (anchor is javax.swing.JComponent) {
|
||||
val point = RelativePoint(anchor, Point(anchor.width / 2, 0))
|
||||
JBPopupFactory.getInstance()
|
||||
.createHtmlTextBalloonBuilder(msg, null, null, null)
|
||||
.createBalloon()
|
||||
.show(point, Balloon.Position.above)
|
||||
}
|
||||
}
|
||||
+1
-18
@@ -1,4 +1,4 @@
|
||||
package ai.kilocode.client.settings.profile
|
||||
package ai.kilocode.client.settings.auth
|
||||
|
||||
import com.google.zxing.BarcodeFormat
|
||||
import com.google.zxing.EncodeHintType
|
||||
@@ -8,17 +8,6 @@ import java.awt.image.BufferedImage
|
||||
import javax.swing.ImageIcon
|
||||
|
||||
internal object QrCode {
|
||||
|
||||
/**
|
||||
* Generate a QR code image for [text].
|
||||
*
|
||||
* Uses black modules on a white background regardless of IDE theme — this is
|
||||
* intentional for scanning reliability (QR scanners expect high contrast B/W).
|
||||
*
|
||||
* @param text URL or text to encode; must not be blank.
|
||||
* @param size pixel dimension for both width and height.
|
||||
* @throws IllegalArgumentException if [text] is blank.
|
||||
*/
|
||||
fun image(text: String, size: Int = 160): BufferedImage {
|
||||
require(text.isNotBlank()) { "QR text must not be blank" }
|
||||
val hints = mapOf(EncodeHintType.MARGIN to 2)
|
||||
@@ -32,11 +21,5 @@ internal object QrCode {
|
||||
return img
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience wrapper that returns the QR code as an [ImageIcon].
|
||||
*
|
||||
* @param text URL or text to encode; must not be blank.
|
||||
* @param size pixel dimension for both width and height.
|
||||
*/
|
||||
fun icon(text: String, size: Int = 160): ImageIcon = ImageIcon(image(text, size))
|
||||
}
|
||||
+16
-213
@@ -1,20 +1,13 @@
|
||||
package ai.kilocode.client.settings.profile
|
||||
|
||||
import ai.kilocode.client.plugin.KiloBundle
|
||||
import ai.kilocode.client.ui.HoverIcon
|
||||
import ai.kilocode.client.ui.RoundedContentPanel
|
||||
import ai.kilocode.client.settings.auth.DeviceOAuthInfo
|
||||
import ai.kilocode.client.settings.auth.DeviceOAuthPanel
|
||||
import ai.kilocode.client.settings.auth.DeviceOAuthText
|
||||
import ai.kilocode.client.ui.UiStyle
|
||||
import ai.kilocode.rpc.dto.KiloAppStatusDto
|
||||
import com.intellij.icons.AllIcons
|
||||
import com.intellij.openapi.ide.CopyPasteManager
|
||||
import com.intellij.openapi.util.IconLoader
|
||||
import com.intellij.openapi.ui.popup.Balloon
|
||||
import com.intellij.openapi.ui.popup.JBPopupFactory
|
||||
import com.intellij.ui.SimpleColoredComponent
|
||||
import com.intellij.ui.SimpleTextAttributes
|
||||
import com.intellij.ui.awt.RelativePoint
|
||||
import com.intellij.ui.components.JBLabel
|
||||
import com.intellij.ui.components.JBTextField
|
||||
import com.intellij.util.concurrency.annotations.RequiresEdt
|
||||
import com.intellij.util.ui.AsyncProcessIcon
|
||||
import com.intellij.util.ui.JBUI
|
||||
@@ -23,17 +16,10 @@ import java.awt.CardLayout
|
||||
import java.awt.FlowLayout
|
||||
import java.awt.GridBagConstraints
|
||||
import java.awt.GridBagLayout
|
||||
import java.awt.Point
|
||||
import java.awt.datatransfer.StringSelection
|
||||
import java.awt.event.FocusAdapter
|
||||
import java.awt.event.FocusEvent
|
||||
import java.awt.event.MouseAdapter
|
||||
import java.awt.event.MouseEvent
|
||||
import javax.swing.JButton
|
||||
import javax.swing.JComponent
|
||||
import javax.swing.JPanel
|
||||
import javax.swing.SwingConstants
|
||||
import javax.swing.Timer
|
||||
|
||||
internal enum class OutMode { CONNECTING, APP_ERROR, INITIATING, AUTH, LOGIN_ERROR, EMPTY }
|
||||
|
||||
@@ -66,65 +52,18 @@ internal class LoggedOutProfileUi(
|
||||
private val authRetryBtn = JButton(KiloBundle.message("profile.login.tryAgain"))
|
||||
.also { it.addActionListener { login() } }
|
||||
|
||||
private val cancelBtn = JButton(KiloBundle.message("profile.login.cancel"))
|
||||
.also { it.addActionListener { cancel() } }
|
||||
|
||||
private val openBtn = JButton(KiloBundle.message("profile.login.openBrowser"))
|
||||
|
||||
private val copyUrlBtn = HoverIcon().apply {
|
||||
icon = AllIcons.Actions.Copy
|
||||
toolTipText = KiloBundle.message("profile.login.copyUrl")
|
||||
}
|
||||
|
||||
// -- retained auth card components --
|
||||
val urlField = JBTextField().apply {
|
||||
isEditable = false
|
||||
name = "kilo.login.url"
|
||||
columns = 30
|
||||
// Select all on focus so clicking the field selects the whole URL
|
||||
addFocusListener(object : FocusAdapter() {
|
||||
override fun focusGained(e: FocusEvent) = selectAll()
|
||||
})
|
||||
addMouseListener(object : MouseAdapter() {
|
||||
override fun mouseClicked(e: MouseEvent) = selectAll()
|
||||
})
|
||||
}
|
||||
|
||||
val qrLabel = JBLabel().apply {
|
||||
horizontalAlignment = SwingConstants.CENTER
|
||||
name = "kilo.login.qr"
|
||||
accessibleContext.accessibleName = KiloBundle.message("profile.login.qr")
|
||||
accessibleContext.accessibleDescription = KiloBundle.message("profile.login.qr.description")
|
||||
}
|
||||
|
||||
private val codePanel = RoundedContentPanel(UiStyle.Gap.sm(), UiStyle.Gap.md()).apply {
|
||||
name = "kilo.login.codePanel"
|
||||
addMouseListener(object : MouseAdapter() {
|
||||
override fun mouseClicked(e: MouseEvent) {
|
||||
val c = rawCode ?: return
|
||||
copyToClipboard(c, KiloBundle.message("profile.login.codeCopied"), this@LoggedOutProfileUi)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private val codeLabel = JBLabel().apply {
|
||||
horizontalAlignment = SwingConstants.CENTER
|
||||
font = UiStyle.Fonts.large()
|
||||
}
|
||||
|
||||
private val codeHint = JBLabel(KiloBundle.message("profile.login.clickToCopy")).apply {
|
||||
foreground = UiStyle.Colors.weak()
|
||||
horizontalAlignment = SwingConstants.CENTER
|
||||
}
|
||||
private val auth = DeviceOAuthPanel(
|
||||
DeviceOAuthText(
|
||||
title = KiloBundle.message("profile.login.title"),
|
||||
qrDescription = KiloBundle.message("profile.login.qr.description"),
|
||||
),
|
||||
cancel = cancel,
|
||||
browse = browse,
|
||||
prefix = "kilo.login",
|
||||
)
|
||||
|
||||
private val initiatingIcon = AsyncProcessIcon("KiloInitiating").also { it.suspend() }
|
||||
|
||||
private val waitIcon = AsyncProcessIcon("KiloLogin")
|
||||
|
||||
private val waitLabel = JBLabel().apply {
|
||||
foreground = UiStyle.Colors.weak()
|
||||
}
|
||||
|
||||
private val logoLabel = JBLabel(IconLoader.getIcon("/icons/kilo-profile.svg", LoggedOutProfileUi::class.java)).apply {
|
||||
name = "kilo.profile.logo.loggedOut"
|
||||
horizontalAlignment = SwingConstants.CENTER
|
||||
@@ -136,28 +75,12 @@ internal class LoggedOutProfileUi(
|
||||
horizontalAlignment = SwingConstants.CENTER
|
||||
}
|
||||
|
||||
// -- step 2 label reference for visibility toggling --
|
||||
private var step2Label: SimpleColoredComponent? = null
|
||||
|
||||
// -- countdown state --
|
||||
private var rawCode: String? = null
|
||||
private var pendingStarted = 0L
|
||||
private var pendingExpires = 900
|
||||
|
||||
// -- cached URL for listener/QR deduplication --
|
||||
private var lastPendingUrl: String? = null
|
||||
|
||||
private val timer = Timer(1000) { syncTime() }
|
||||
|
||||
init {
|
||||
codePanel.add(codeLabel, BorderLayout.CENTER)
|
||||
codePanel.add(codeHint, BorderLayout.SOUTH)
|
||||
|
||||
cards.add(connectingCard(), OutMode.CONNECTING.name)
|
||||
cards.add(appErrorCard(), OutMode.APP_ERROR.name)
|
||||
cards.add(emptyCard(), OutMode.EMPTY.name)
|
||||
cards.add(initiatingCard(), OutMode.INITIATING.name)
|
||||
cards.add(authCard(), OutMode.AUTH.name)
|
||||
cards.add(auth, OutMode.AUTH.name)
|
||||
cards.add(loginErrorCard(), OutMode.LOGIN_ERROR.name)
|
||||
add(cards, BorderLayout.NORTH)
|
||||
}
|
||||
@@ -208,57 +131,6 @@ internal class LoggedOutProfileUi(
|
||||
return p
|
||||
}
|
||||
|
||||
private fun authCard(): JPanel {
|
||||
val p = padded()
|
||||
var row = 0
|
||||
|
||||
p.add(JBLabel(KiloBundle.message("profile.login.title")).apply {
|
||||
font = UiStyle.Fonts.heading()
|
||||
horizontalAlignment = SwingConstants.CENTER
|
||||
}, gbc(row++))
|
||||
|
||||
p.add(stepLabel(KiloBundle.message("profile.login.step.one"), KiloBundle.message("profile.login.step.url")),
|
||||
gbc(row++, UiStyle.Gap.md()))
|
||||
|
||||
p.add(urlRow(), gbc(row++, UiStyle.Gap.sm()))
|
||||
|
||||
p.add(qrLabel, gbc(row++, UiStyle.Gap.md()).centered())
|
||||
|
||||
val s2 = stepLabel(KiloBundle.message("profile.login.step.two"), KiloBundle.message("profile.login.step.code"))
|
||||
step2Label = s2
|
||||
p.add(s2, gbc(row++, UiStyle.Gap.md()))
|
||||
|
||||
p.add(codePanel, gbc(row++, UiStyle.Gap.sm()))
|
||||
|
||||
val waitRow = JPanel(FlowLayout(FlowLayout.CENTER, UiStyle.Gap.sm(), 0)).apply {
|
||||
isOpaque = false
|
||||
add(waitIcon)
|
||||
add(waitLabel)
|
||||
}
|
||||
p.add(waitRow, gbc(row++, UiStyle.Gap.xl()))
|
||||
|
||||
p.add(cancelBtn, gbc(row, UiStyle.Gap.sm()).centered())
|
||||
|
||||
return p
|
||||
}
|
||||
|
||||
private fun stepLabel(step: String, text: String) = SimpleColoredComponent().apply {
|
||||
append(step, SimpleTextAttributes.REGULAR_BOLD_ATTRIBUTES)
|
||||
append(" $text", SimpleTextAttributes.GRAYED_ATTRIBUTES)
|
||||
}
|
||||
|
||||
private fun urlRow(): JPanel {
|
||||
val row = JPanel(BorderLayout(UiStyle.Gap.xs(), 0))
|
||||
row.add(urlField, BorderLayout.CENTER)
|
||||
val btns = JPanel(FlowLayout(FlowLayout.RIGHT, UiStyle.Gap.sm(), 0)).apply {
|
||||
isOpaque = false
|
||||
add(copyUrlBtn)
|
||||
add(openBtn)
|
||||
}
|
||||
row.add(btns, BorderLayout.EAST)
|
||||
return row
|
||||
}
|
||||
|
||||
private fun loginErrorCard(): JPanel {
|
||||
val p = padded()
|
||||
p.add(errLabel, gbc(0))
|
||||
@@ -274,46 +146,7 @@ internal class LoggedOutProfileUi(
|
||||
|
||||
if (target == OutMode.AUTH && login is LoginState.Pending) {
|
||||
val auth = login.auth
|
||||
val url = auth.verificationUrl
|
||||
val code = auth.code
|
||||
|
||||
rawCode = code
|
||||
urlField.text = url
|
||||
urlField.toolTipText = url
|
||||
|
||||
// Wire listeners and generate QR only when URL changes (avoids re-wiring on every re-sync)
|
||||
if (url != lastPendingUrl) {
|
||||
lastPendingUrl = url
|
||||
|
||||
openBtn.actionListeners.toList().forEach { openBtn.removeActionListener(it) }
|
||||
openBtn.addActionListener { browse(url) }
|
||||
copyUrlBtn.actionListeners.toList().forEach { copyUrlBtn.removeActionListener(it) }
|
||||
copyUrlBtn.addActionListener {
|
||||
copyToClipboard(url, KiloBundle.message("profile.login.urlCopied"), copyUrlBtn)
|
||||
}
|
||||
|
||||
// QR code — expensive; only regenerate when URL changes
|
||||
try {
|
||||
qrLabel.icon = QrCode.icon(url, JBUI.scale(160))
|
||||
} catch (_: Exception) {
|
||||
qrLabel.icon = null
|
||||
}
|
||||
}
|
||||
|
||||
// Code display
|
||||
codePanel.isVisible = code != null
|
||||
step2Label?.isVisible = code != null
|
||||
if (code != null) {
|
||||
codeLabel.text = spacedCode(code)
|
||||
}
|
||||
|
||||
// Countdown: only reset when entering auth for the first time for this pending
|
||||
if (mode != OutMode.AUTH) {
|
||||
pendingStarted = login.started
|
||||
pendingExpires = auth.expiresIn
|
||||
syncTime()
|
||||
timer.restart()
|
||||
}
|
||||
this.auth.update(DeviceOAuthInfo(auth.verificationUrl, auth.code, auth.expiresIn, login.started))
|
||||
}
|
||||
|
||||
if (target == OutMode.LOGIN_ERROR && login is LoginState.Error) {
|
||||
@@ -322,16 +155,11 @@ internal class LoggedOutProfileUi(
|
||||
|
||||
if (mode != target) {
|
||||
if (mode == OutMode.AUTH) {
|
||||
timer.stop()
|
||||
waitIcon.suspend()
|
||||
lastPendingUrl = null
|
||||
auth.dispose()
|
||||
}
|
||||
if (mode == OutMode.INITIATING) initiatingIcon.suspend()
|
||||
cardLayout.show(cards, target.name)
|
||||
mode = target
|
||||
if (target == OutMode.AUTH) {
|
||||
waitIcon.resume()
|
||||
}
|
||||
if (target == OutMode.INITIATING) initiatingIcon.resume()
|
||||
revalidate()
|
||||
repaint()
|
||||
@@ -344,10 +172,8 @@ internal class LoggedOutProfileUi(
|
||||
/** Stop the timer and suspend all animated icons. Safe to call multiple times. */
|
||||
@RequiresEdt
|
||||
fun dispose() {
|
||||
timer.stop()
|
||||
waitIcon.suspend()
|
||||
initiatingIcon.suspend()
|
||||
lastPendingUrl = null
|
||||
auth.dispose()
|
||||
}
|
||||
|
||||
private fun resolveMode(status: KiloAppStatusDto, login: LoginState): OutMode = when {
|
||||
@@ -359,15 +185,6 @@ internal class LoggedOutProfileUi(
|
||||
else -> OutMode.EMPTY
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
private fun syncTime() {
|
||||
val elapsed = ((System.currentTimeMillis() - pendingStarted) / 1000).toInt()
|
||||
val remain = (pendingExpires - elapsed).coerceAtLeast(0)
|
||||
val min = remain / 60
|
||||
val sec = remain % 60
|
||||
waitLabel.text = KiloBundle.message("profile.login.waitingTimed", "$min:${sec.toString().padStart(2, '0')}")
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
private fun padded() = JPanel(GridBagLayout()).apply {
|
||||
@@ -386,18 +203,4 @@ internal class LoggedOutProfileUi(
|
||||
fill = GridBagConstraints.NONE
|
||||
anchor = GridBagConstraints.CENTER
|
||||
}
|
||||
|
||||
private fun spacedCode(code: String): String = code.map { it.toString() }.joinToString(" ")
|
||||
}
|
||||
|
||||
/** Copy [text] to the platform clipboard and show a brief confirmation balloon anchored to [anchor]. */
|
||||
private fun copyToClipboard(text: String, msg: String, anchor: java.awt.Component) {
|
||||
CopyPasteManager.getInstance().setContents(StringSelection(text))
|
||||
if (anchor is javax.swing.JComponent) {
|
||||
val point = RelativePoint(anchor, Point(anchor.width / 2, 0))
|
||||
JBPopupFactory.getInstance()
|
||||
.createHtmlTextBalloonBuilder(msg, null, null, null)
|
||||
.createBalloon()
|
||||
.show(point, Balloon.Position.above)
|
||||
}
|
||||
}
|
||||
|
||||
+10
@@ -76,6 +76,16 @@ internal fun providerMethods(provider: ProviderSettingsProviderDto, state: Provi
|
||||
return listOf(ProviderAuthMethodDto("api", "API key"))
|
||||
}
|
||||
|
||||
internal fun providerOAuthMethodIndex(methods: List<ProviderAuthMethodDto>): String? {
|
||||
val indexed = methods.withIndex().filter { it.value.type == "oauth" }
|
||||
if (indexed.isEmpty()) return null
|
||||
val remote = indexed.firstOrNull { entry ->
|
||||
val label = entry.value.label.lowercase()
|
||||
listOf("headless", "remote", "device", "vps").any { label.contains(it) }
|
||||
}
|
||||
return (remote ?: indexed.first()).index.toString()
|
||||
}
|
||||
|
||||
internal fun hiddenProvider(provider: ProviderSettingsProviderDto) = provider.id == "openai-compatible"
|
||||
|
||||
internal fun configured(provider: ProviderSettingsProviderDto, state: ProviderSettingsDto, ids: Set<String>) =
|
||||
|
||||
+105
-36
@@ -4,6 +4,9 @@ import ai.kilocode.client.app.KiloProviderService
|
||||
import ai.kilocode.client.plugin.KiloBundle
|
||||
import ai.kilocode.client.settings.base.BaseContentPanel
|
||||
import ai.kilocode.client.settings.base.SettingsPanel
|
||||
import ai.kilocode.client.settings.auth.DeviceOAuthInfo
|
||||
import ai.kilocode.client.settings.auth.DeviceOAuthPanel
|
||||
import ai.kilocode.client.settings.auth.DeviceOAuthText
|
||||
import ai.kilocode.client.ui.UiStyle
|
||||
import ai.kilocode.client.ui.layout.Stack
|
||||
import ai.kilocode.log.KiloLog
|
||||
@@ -69,6 +72,10 @@ import javax.swing.Timer
|
||||
|
||||
private val edt = Dispatchers.EDT + ModalityState.any().asContextElement()
|
||||
|
||||
private val OAUTH_CODE_RE = Regex("""code:\s*(\S+)""", RegexOption.IGNORE_CASE)
|
||||
|
||||
private fun oauthCode(text: String?): String? = text?.let { OAUTH_CODE_RE.find(it)?.groupValues?.getOrNull(1) }
|
||||
|
||||
internal class ProvidersSettingsUi(
|
||||
private val cs: CoroutineScope,
|
||||
private val directory: String,
|
||||
@@ -90,15 +97,19 @@ internal class ProvidersSettingsUi(
|
||||
{ !busy },
|
||||
) { reload() }
|
||||
private val view = ProvidersContent(::connect, ::oauth, ::disconnect, ::enable)
|
||||
private val search = SearchTextField(false).apply {
|
||||
textEditor.emptyText.text = KiloBundle.message("settings.providers.search")
|
||||
}
|
||||
private var state = ProviderSettingsDto()
|
||||
private var job: Job? = null
|
||||
private var request = 0
|
||||
private var disposed = false
|
||||
private var busy = false
|
||||
private var timer: Timer? = null
|
||||
private var oauth: DeviceOAuthPanel? = null
|
||||
|
||||
init {
|
||||
content.add(toolbar(), BorderLayout.NORTH)
|
||||
content.add(header(), BorderLayout.NORTH)
|
||||
setContent(view)
|
||||
reload()
|
||||
}
|
||||
@@ -139,8 +150,7 @@ internal class ProvidersSettingsUi(
|
||||
@RequiresEdt
|
||||
private fun oauth(provider: ProviderSettingsProviderDto) {
|
||||
checkEdt()
|
||||
val methods = state.auth[provider.id].orEmpty().filter { it.type == "oauth" }
|
||||
val method = state.auth[provider.id].orEmpty().indexOf(methods.firstOrNull()).coerceAtLeast(0).toString()
|
||||
val method = providerOAuthMethodIndex(state.auth[provider.id].orEmpty()) ?: return
|
||||
if (!launch("authorize provider=${provider.id}") { id ->
|
||||
val ready = service<KiloProviderService>().authorize(ProviderOAuthAuthorizeDto(directory, provider.id, method))
|
||||
val code = withContext(edt) {
|
||||
@@ -153,11 +163,28 @@ internal class ProvidersSettingsUi(
|
||||
return@withContext null
|
||||
}
|
||||
input
|
||||
} else null
|
||||
} else {
|
||||
val url = ready.url
|
||||
if (ready.method == "auto" && url != null) {
|
||||
showOAuthDevice(
|
||||
id,
|
||||
provider,
|
||||
DeviceOAuthInfo(
|
||||
url = url,
|
||||
code = oauthCode(ready.instructions),
|
||||
expiresIn = (KiloProviderService.OAUTH_RPC_TIMEOUT_MS / 1000).toInt(),
|
||||
started = System.currentTimeMillis(),
|
||||
),
|
||||
)
|
||||
}
|
||||
null
|
||||
}
|
||||
}
|
||||
val current = withContext(edt) { active(id) }
|
||||
if (!current) return@launch
|
||||
withContext(edt) { syncOAuthWaiting(id) }
|
||||
withContext(edt) {
|
||||
if (oauth == null) syncOAuthWaiting(id)
|
||||
}
|
||||
val result = service<KiloProviderService>().callback(ProviderOAuthCallbackDto(directory, provider.id, method, code))
|
||||
apply(id, result.state, result.error)
|
||||
}) return
|
||||
@@ -208,6 +235,32 @@ internal class ProvidersSettingsUi(
|
||||
return toolbar.component
|
||||
}
|
||||
|
||||
private fun header(): JComponent {
|
||||
search.textEditor.registerKeyboardAction(
|
||||
{ view.primary() },
|
||||
KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, 0),
|
||||
JComponent.WHEN_FOCUSED,
|
||||
)
|
||||
search.textEditor.registerKeyboardAction(
|
||||
{ view.move(-1) },
|
||||
KeyStroke.getKeyStroke(KeyEvent.VK_UP, 0),
|
||||
JComponent.WHEN_FOCUSED,
|
||||
)
|
||||
search.textEditor.registerKeyboardAction(
|
||||
{ view.move(1) },
|
||||
KeyStroke.getKeyStroke(KeyEvent.VK_DOWN, 0),
|
||||
JComponent.WHEN_FOCUSED,
|
||||
)
|
||||
search.textEditor.document.addDocumentListener(object : DocumentAdapter() {
|
||||
override fun textChanged(e: DocumentEvent) {
|
||||
view.filter(search.text)
|
||||
}
|
||||
})
|
||||
return Stack.vertical(UiStyle.Gap.sm())
|
||||
.next(toolbar())
|
||||
.next(search)
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
private fun launch(name: String, block: suspend (Int) -> Unit): Boolean {
|
||||
checkEdt()
|
||||
@@ -225,6 +278,7 @@ internal class ProvidersSettingsUi(
|
||||
withContext(edt) {
|
||||
if (!active(id)) return@withContext
|
||||
setBusy(false)
|
||||
clearOAuthDevice()
|
||||
clearProgress()
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
@@ -235,6 +289,7 @@ internal class ProvidersSettingsUi(
|
||||
withContext(edt) {
|
||||
if (!active(id)) return@withContext
|
||||
setBusy(false)
|
||||
clearOAuthDevice()
|
||||
showError("${e::class.simpleName}: ${e.message}")
|
||||
}
|
||||
}
|
||||
@@ -248,6 +303,7 @@ internal class ProvidersSettingsUi(
|
||||
LOG.info("provider settings ui apply: start providers=${next.providers.size} errors=${next.errors.size} message=${error != null}")
|
||||
state = next
|
||||
setBusy(false)
|
||||
clearOAuthDevice()
|
||||
view.update(next)
|
||||
val text = error ?: next.errors.joinToString("; ") { it.detail ?: it.resource }.takeIf { it.isNotBlank() }
|
||||
if (text != null) showError(text) else clearProgress()
|
||||
@@ -286,10 +342,39 @@ internal class ProvidersSettingsUi(
|
||||
job?.cancel()
|
||||
job = null
|
||||
stopTimer()
|
||||
clearOAuthDevice()
|
||||
setBusy(false)
|
||||
clearProgress()
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
private fun showOAuthDevice(id: Int, provider: ProviderSettingsProviderDto, info: DeviceOAuthInfo) {
|
||||
checkEdt()
|
||||
if (!active(id)) return
|
||||
clearProgress()
|
||||
val panel = DeviceOAuthPanel(
|
||||
DeviceOAuthText(
|
||||
title = KiloBundle.message("settings.providers.oauth.starting", provider.name),
|
||||
qrDescription = KiloBundle.message("profile.login.qr.description"),
|
||||
),
|
||||
cancel = { cancelOAuth(id) },
|
||||
browse = { BrowserUtil.browse(it) },
|
||||
prefix = "kilo.provider.oauth",
|
||||
)
|
||||
oauth?.dispose()
|
||||
oauth = panel
|
||||
panel.update(info)
|
||||
setModalContent(panel)
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
private fun clearOAuthDevice() {
|
||||
checkEdt()
|
||||
oauth?.dispose()
|
||||
oauth = null
|
||||
setModalContent(null)
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
private fun stopTimer() {
|
||||
checkEdt()
|
||||
@@ -303,6 +388,8 @@ internal class ProvidersSettingsUi(
|
||||
if (busy == next) return
|
||||
busy = next
|
||||
if (!next) stopTimer()
|
||||
search.isEnabled = !next
|
||||
search.textEditor.isEnabled = !next
|
||||
view.setBusy(next)
|
||||
}
|
||||
|
||||
@@ -339,10 +426,8 @@ internal class ProvidersContent(
|
||||
selectionMode = ListSelectionModel.SINGLE_SELECTION
|
||||
emptyText.text = KiloBundle.message("settings.providers.noMatches")
|
||||
}
|
||||
private val search = SearchTextField(false).apply {
|
||||
textEditor.emptyText.text = KiloBundle.message("settings.providers.search")
|
||||
}
|
||||
private var state = ProviderSettingsDto()
|
||||
private var filter = ""
|
||||
private var busy = false
|
||||
|
||||
init {
|
||||
@@ -352,26 +437,6 @@ internal class ProvidersContent(
|
||||
KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, 0),
|
||||
JComponent.WHEN_FOCUSED,
|
||||
)
|
||||
search.textEditor.registerKeyboardAction(
|
||||
{ primary() },
|
||||
KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, 0),
|
||||
JComponent.WHEN_FOCUSED,
|
||||
)
|
||||
search.textEditor.registerKeyboardAction(
|
||||
{ move(-1) },
|
||||
KeyStroke.getKeyStroke(KeyEvent.VK_UP, 0),
|
||||
JComponent.WHEN_FOCUSED,
|
||||
)
|
||||
search.textEditor.registerKeyboardAction(
|
||||
{ move(1) },
|
||||
KeyStroke.getKeyStroke(KeyEvent.VK_DOWN, 0),
|
||||
JComponent.WHEN_FOCUSED,
|
||||
)
|
||||
search.textEditor.document.addDocumentListener(object : DocumentAdapter() {
|
||||
override fun textChanged(e: DocumentEvent) {
|
||||
sync()
|
||||
}
|
||||
})
|
||||
list.addMouseListener(object : MouseAdapter() {
|
||||
override fun mouseReleased(e: MouseEvent) {
|
||||
if (!UIUtil.isActionClick(e, MouseEvent.MOUSE_RELEASED, true)) return
|
||||
@@ -385,9 +450,7 @@ internal class ProvidersContent(
|
||||
}
|
||||
})
|
||||
ScrollingUtil.installActions(list)
|
||||
next(search)
|
||||
.gap(UiStyle.Gap.sm())
|
||||
.next(list)
|
||||
next(list)
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
@@ -405,16 +468,22 @@ internal class ProvidersContent(
|
||||
checkEdt()
|
||||
if (busy == next) return
|
||||
busy = next
|
||||
search.isEnabled = !next
|
||||
search.textEditor.isEnabled = !next
|
||||
list.isEnabled = !next
|
||||
sync()
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
fun filter(text: String) {
|
||||
checkEdt()
|
||||
if (filter == text) return
|
||||
filter = text
|
||||
sync()
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
private fun sync(prefer: String? = list.selectedValue?.key, at: Int? = null) {
|
||||
checkEdt()
|
||||
val rows = providerListRows(state, search.text, disabledRows = busy)
|
||||
val rows = providerListRows(state, filter, disabledRows = busy)
|
||||
model.replaceAll(rows)
|
||||
val idx = at?.let { providerListIndex(rows, it) }?.takeIf { it >= 0 }
|
||||
?: providerListIndex(rows, prefer).takeIf { it >= 0 }
|
||||
@@ -432,7 +501,7 @@ internal class ProvidersContent(
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
private fun move(step: Int) {
|
||||
fun move(step: Int) {
|
||||
checkEdt()
|
||||
val size = model.size
|
||||
if (size <= 0) return
|
||||
@@ -441,7 +510,7 @@ internal class ProvidersContent(
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
private fun primary() {
|
||||
fun primary() {
|
||||
checkEdt()
|
||||
val row = list.selectedValue ?: return
|
||||
val action = ProviderListRenderer.visibleActions(row, true).firstOrNull() ?: return
|
||||
|
||||
+98
-9
@@ -14,6 +14,7 @@ import ai.kilocode.rpc.dto.ProviderSettingsProviderDto
|
||||
import com.intellij.openapi.application.ApplicationManager
|
||||
import com.intellij.testFramework.replaceService
|
||||
import com.intellij.testFramework.fixtures.BasePlatformTestCase
|
||||
import com.intellij.ui.SimpleColoredComponent
|
||||
import com.intellij.ui.SearchTextField
|
||||
import com.intellij.ui.components.JBList
|
||||
import com.intellij.ui.components.JBLabel
|
||||
@@ -40,6 +41,7 @@ import javax.swing.JButton
|
||||
import javax.swing.JComponent
|
||||
import javax.swing.JScrollPane
|
||||
import javax.swing.KeyStroke
|
||||
import javax.swing.JTextField
|
||||
import javax.swing.UIManager
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
@@ -96,29 +98,31 @@ class ProvidersSettingsUiTest : BasePlatformTestCase() {
|
||||
edt { assertEquals(listOf(ProviderListAction.OAUTH, ProviderListAction.CONNECT), rows(content).single().actions) }
|
||||
}
|
||||
|
||||
fun `test content uses toolbar search and direct list`() {
|
||||
fun `test content uses direct list without scroll or search`() {
|
||||
val content = content()
|
||||
edt {
|
||||
assertEquals(1, components(content).filterIsInstance<SearchTextField>().size)
|
||||
assertEquals(1, components(content).filterIsInstance<JBList<ProviderListRow>>().size)
|
||||
assertTrue(components(content).filterIsInstance<SearchTextField>().isEmpty())
|
||||
assertTrue(components(content).filterIsInstance<JScrollPane>().isEmpty())
|
||||
assertTrue(components(content).filterIsInstance<JButton>().none { it.text == "Refresh" })
|
||||
}
|
||||
}
|
||||
|
||||
fun `test toolbar is outside scrollable provider content`() {
|
||||
fun `test toolbar and search are outside scrollable provider content`() {
|
||||
installProvider(ProviderSettingsDto())
|
||||
val panel = edt { createUi() }
|
||||
|
||||
edt {
|
||||
val layout = panel.content.layout as BorderLayout
|
||||
val toolbar = layout.getLayoutComponent(BorderLayout.NORTH)
|
||||
val header = layout.getLayoutComponent(BorderLayout.NORTH)
|
||||
val scroll = layout.getLayoutComponent(BorderLayout.CENTER)
|
||||
|
||||
assertNotNull(toolbar)
|
||||
assertNotNull(header)
|
||||
assertTrue(scroll is JScrollPane)
|
||||
assertFalse(components(content(panel)).contains(toolbar))
|
||||
assertEquals(1, components(content(panel)).filterIsInstance<SearchTextField>().size)
|
||||
assertEquals(1, components(header).filterIsInstance<SearchTextField>().size)
|
||||
assertTrue(components(scroll).filterIsInstance<SearchTextField>().isEmpty())
|
||||
assertFalse(components(content(panel)).contains(header))
|
||||
assertTrue(components(content(panel)).filterIsInstance<SearchTextField>().isEmpty())
|
||||
assertEquals(1, components(content(panel)).filterIsInstance<JBList<ProviderListRow>>().size)
|
||||
}
|
||||
}
|
||||
@@ -263,7 +267,7 @@ class ProvidersSettingsUiTest : BasePlatformTestCase() {
|
||||
),
|
||||
)
|
||||
|
||||
search(content).text = "open"
|
||||
content.filter("open")
|
||||
|
||||
val rows = rows(content)
|
||||
assertEquals(listOf("openai"), rows.map { it.key })
|
||||
@@ -552,6 +556,89 @@ class ProvidersSettingsUiTest : BasePlatformTestCase() {
|
||||
}
|
||||
}
|
||||
|
||||
fun `test provider oauth prefers headless method original index`() {
|
||||
val rpc = installProvider(
|
||||
ProviderSettingsDto(
|
||||
providers = listOf(provider("openai", "OpenAI")),
|
||||
auth = mapOf(
|
||||
"openai" to listOf(
|
||||
ProviderAuthMethodDto("oauth", "ChatGPT Pro/Plus"),
|
||||
ProviderAuthMethodDto("oauth", "ChatGPT Pro/Plus (headless)"),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
val panel = edt { createUi() }
|
||||
|
||||
flushUntil { rpc.stateCalls.size == 1 && edt { rows(panel).map { it.key } == listOf("openai") } }
|
||||
edt { triggerPrimary(panel) }
|
||||
flushUntil { rpc.authorizes.size == 1 }
|
||||
|
||||
assertEquals("1", rpc.authorizes.single().method)
|
||||
}
|
||||
|
||||
fun `test provider oauth falls back to first oauth method original index`() {
|
||||
val rpc = installProvider(
|
||||
ProviderSettingsDto(
|
||||
providers = listOf(provider("github-copilot", "GitHub Copilot")),
|
||||
auth = mapOf(
|
||||
"github-copilot" to listOf(
|
||||
ProviderAuthMethodDto("oauth", "OAuth"),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
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 }
|
||||
|
||||
assertEquals("0", rpc.authorizes.single().method)
|
||||
}
|
||||
|
||||
fun `test provider oauth auto response shows device auth panel`() {
|
||||
val callback = CompletableDeferred<ai.kilocode.rpc.dto.ProviderActionResultDto>()
|
||||
val rpc = installProvider(
|
||||
ProviderSettingsDto(
|
||||
providers = listOf(provider("openai", "OpenAI")),
|
||||
auth = mapOf(
|
||||
"openai" to listOf(
|
||||
ProviderAuthMethodDto("oauth", "ChatGPT Pro/Plus"),
|
||||
ProviderAuthMethodDto("oauth", "ChatGPT Pro/Plus (headless)"),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
rpc.ready = ProviderOAuthReadyDto(
|
||||
method = "auto",
|
||||
url = "https://auth.openai.com/device",
|
||||
instructions = "Enter code: ABCD-EFGH",
|
||||
)
|
||||
rpc.callbacksReady.add(callback)
|
||||
val panel = edt { createUi() }
|
||||
|
||||
flushUntil { rpc.stateCalls.size == 1 && edt { rows(panel).map { it.key } == listOf("openai") } }
|
||||
edt { triggerPrimary(panel) }
|
||||
flushUntil { rpc.callbacks.size == 1 && edt { text(panel).contains("Waiting for authorization... (1:30)") } }
|
||||
|
||||
edt {
|
||||
val t = text(panel)
|
||||
assertTrue(t, t.contains("Starting OAuth for OpenAI"))
|
||||
assertTrue(t, t.contains("Open this URL"))
|
||||
assertTrue(t, t.contains("A B C D - E F G H"))
|
||||
assertTrue(t, t.contains("Open Browser"))
|
||||
assertTrue(t, t.contains("Cancel"))
|
||||
assertEquals("https://auth.openai.com/device", fieldsByName(panel, "kilo.provider.oauth.url").single().text)
|
||||
val qr = components(panel).filterIsInstance<JBLabel>().single { it.name == "kilo.provider.oauth.qr" }
|
||||
assertNotNull(qr.icon)
|
||||
}
|
||||
|
||||
edt { components(panel).filterIsInstance<JButton>().single { it.text == "Cancel" && it.isVisible }.doClick() }
|
||||
flushUntil { edt { rpc.callbacks.size == 1 && rows(panel).single().disabled.not() } }
|
||||
callback.complete(ai.kilocode.rpc.dto.ProviderActionResultDto(providerState(provider("stale", "Stale"))))
|
||||
}
|
||||
|
||||
fun `test provider oauth cancel before authorize completion skips callback`() {
|
||||
val ready = CompletableDeferred<ProviderOAuthReadyDto>()
|
||||
val rpc = installProvider(
|
||||
@@ -710,7 +797,7 @@ class ProvidersSettingsUiTest : BasePlatformTestCase() {
|
||||
|
||||
private fun list(component: JComponent) = components(component).filterIsInstance<JBList<ProviderListRow>>().single()
|
||||
|
||||
private fun search(component: JComponent) = components(component).filterIsInstance<SearchTextField>().single()
|
||||
private fun fieldsByName(root: Container, name: String): List<JTextField> = components(root).filterIsInstance<JTextField>().filter { it.name == name }
|
||||
|
||||
private fun center(rect: Rectangle) = Point(rect.x + rect.width / 2, rect.y + rect.height / 2)
|
||||
|
||||
@@ -737,6 +824,8 @@ class ProvidersSettingsUiTest : BasePlatformTestCase() {
|
||||
when (comp) {
|
||||
is JButton -> comp.text?.let { out.add(it) }
|
||||
is JBLabel -> comp.text?.let { out.add(it) }
|
||||
is JTextField -> comp.text?.let { out.add(it) }
|
||||
is SimpleColoredComponent -> comp.toString().takeIf { it.isNotBlank() }?.let { out.add(it) }
|
||||
}
|
||||
}
|
||||
return out.joinToString("\n")
|
||||
|
||||
Reference in New Issue
Block a user