refactor(jetbrains): polish profile settings

This commit is contained in:
kirillk
2026-05-18 12:01:55 -04:00
parent 80ccb18f8c
commit 89b2e2a81b
5 changed files with 222 additions and 37 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Polish the JetBrains user profile settings layout with a compact account stack, copyable email, simplified organization names, and a refreshable balance card.
@@ -3,19 +3,24 @@ package ai.kilocode.client.settings.profile
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.ui.UiStyle
import ai.kilocode.rpc.dto.ProfileDto
import com.intellij.icons.AllIcons
import com.intellij.ui.JBColor
import com.intellij.ui.RoundedLineBorder
import com.intellij.ui.components.JBLabel
import com.intellij.ui.dsl.builder.AlignX
import com.intellij.ui.dsl.builder.BottomGap
import com.intellij.ui.dsl.builder.RightGap
import com.intellij.ui.dsl.builder.TopGap
import com.intellij.ui.dsl.builder.panel
import com.intellij.util.ui.JBFont
import com.intellij.util.ui.JBUI
import com.intellij.util.ui.JBValue
import java.awt.BorderLayout
import java.awt.Font
import java.awt.GridBagConstraints
import java.awt.GridBagLayout
import java.text.DecimalFormat
import javax.swing.Box
import javax.swing.DefaultComboBoxModel
import javax.swing.JButton
import javax.swing.JComboBox
import javax.swing.JPanel
import javax.swing.SwingConstants
/**
* Retained logged-in UI. Labels, combo box, and buttons are built once and
@@ -25,48 +30,76 @@ internal class LoggedInProfileUi(
private val dashboard: () -> Unit,
private val logout: () -> Unit,
private val organization: (String?) -> Unit,
private val refresh: () -> Unit,
) : JPanel(BorderLayout()) {
private val nameLabel = JBLabel().apply { font = font.deriveFont(Font.BOLD) }
private val emailLabel = JBLabel().apply { foreground = UiStyle.Colors.weak() }
private val emailLabel = JBLabel().apply {
foreground = UiStyle.Colors.weak()
setCopyable(true)
}
private val balanceLabel = JBLabel().apply { font = font.deriveFont(Font.BOLD) }
private val balanceContainer = panel {
row {
label(KiloBundle.message("profile.balance.title")).gap(RightGap.SMALL)
cell(balanceLabel)
private val titleLabel = JBLabel(KiloBundle.message("profile.balance.title")).apply {
foreground = UiStyle.Colors.weak()
}
private val valueLabel = JBLabel().apply {
horizontalAlignment = SwingConstants.CENTER
font = JBFont.h1().asBold()
}
private val refreshBtn = JButton(KiloBundle.message("profile.action.refresh"), AllIcons.Actions.Refresh)
.also {
it.addActionListener {
if (refreshing) return@addActionListener
setRefreshing(true)
refresh()
}
}
private val balanceCard = JPanel(BorderLayout()).apply {
border = JBUI.Borders.compound(
RoundedLineBorder(JBColor.border(), JBValue.UIInteger("Component.arc", 8).get()),
JBUI.Borders.empty(JBUI.scale(12), JBUI.scale(16)),
)
add(titleLabel, BorderLayout.NORTH)
add(JPanel(GridBagLayout()).apply {
add(valueLabel, GridBagConstraints().apply {
gridx = 0
gridy = 0
anchor = GridBagConstraints.CENTER
})
add(refreshBtn, GridBagConstraints().apply {
gridx = 0
gridy = 1
anchor = GridBagConstraints.CENTER
})
}, BorderLayout.CENTER)
}
private val comboModel = DefaultComboBoxModel<String>()
val combo = JComboBox(comboModel)
private val orgContainer = panel {
group(KiloBundle.message("profile.group.organization")) {
row(KiloBundle.message("profile.label.account")) {
cell(combo).align(AlignX.FILL)
}
}
}
val dashboardBtn = JButton(KiloBundle.message("profile.action.dashboard"))
.also { it.addActionListener { dashboard() } }
val logoutBtn = JButton(KiloBundle.message("profile.action.logout"))
.also { it.addActionListener { logout() } }
private val content = panel {
group(KiloBundle.message("profile.group.account")) {
row { cell(nameLabel) }
row { cell(emailLabel) }
}
row { cell(balanceContainer) }.topGap(TopGap.SMALL)
row { cell(orgContainer) }
row {
cell(dashboardBtn).gap(RightGap.SMALL)
cell(logoutBtn)
}.bottomGap(BottomGap.SMALL)
private val buttons = JPanel().apply {
layout = javax.swing.BoxLayout(this, javax.swing.BoxLayout.X_AXIS)
add(dashboardBtn)
add(Box.createHorizontalStrut(JBUI.scale(6)))
add(logoutBtn)
}
private val content = JPanel(GridBagLayout()).apply {
addRow(nameLabel, 0)
addRow(emailLabel, 1, UiStyle.Gap.lg())
addRow(combo, 2, UiStyle.Gap.lg())
addRow(balanceCard, 3, UiStyle.Gap.lg())
addRow(buttons, 4, UiStyle.Gap.lg())
}
private var applying = false
private var refreshing = false
private var currentProf: ProfileDto? = null
init {
@@ -83,6 +116,17 @@ internal class LoggedInProfileUi(
add(content, BorderLayout.NORTH)
}
private fun JPanel.addRow(comp: java.awt.Component, y: Int, top: Int = 0) {
add(comp, GridBagConstraints().apply {
gridx = 0
gridy = y
weightx = 1.0
fill = GridBagConstraints.HORIZONTAL
anchor = GridBagConstraints.WEST
insets = JBUI.insets(top, 0, 0, 0)
})
}
fun update(profile: ProfileDto) {
currentProf = profile
@@ -94,22 +138,52 @@ internal class LoggedInProfileUi(
if (showEmail && emailLabel.text != profile.email) emailLabel.text = profile.email
val bal = profile.balance
var changed = false
if (bal != null) {
val fmt = DecimalFormat("$#,##0.00")
val balText = fmt.format(bal.balance)
if (balanceLabel.text != balText) balanceLabel.text = balText
balanceContainer.isVisible = true
if (valueLabel.text != balText) {
valueLabel.text = balText
changed = true
}
if (!balanceCard.isVisible) {
balanceCard.isVisible = true
changed = true
}
} else {
balanceContainer.isVisible = false
if (balanceCard.isVisible) {
balanceCard.isVisible = false
changed = true
}
}
applyOrganizations(profile)
if (changed) syncLayout()
}
fun setRefreshing(refreshing: Boolean) {
this.refreshing = refreshing
val text = if (refreshing) {
KiloBundle.message("profile.action.refreshing")
} else {
KiloBundle.message("profile.action.refresh")
}
if (refreshBtn.text != text) refreshBtn.text = text
refreshBtn.maximumSize = refreshBtn.preferredSize
syncLayout()
}
private fun syncLayout() {
balanceCard.revalidate()
content.revalidate()
revalidate()
repaint()
}
private fun applyOrganizations(profile: ProfileDto) {
val orgs = profile.organizations
val options = listOf(KiloBundle.message("profile.personalAccount")) +
orgs.map { "${it.name} (${it.role.lowercase()})" }
orgs.map { it.name }
val target = profile.currentOrgId
?.let { id -> orgs.indexOfFirst { it.id == id }.takeIf { it >= 0 }?.plus(1) }
@@ -128,8 +202,8 @@ internal class LoggedInProfileUi(
}
val show = orgs.isNotEmpty()
if (orgContainer.isVisible != show) {
orgContainer.isVisible = show
if (combo.isVisible != show) {
combo.isVisible = show
revalidate()
repaint()
}
@@ -52,6 +52,7 @@ internal class ProfileUi(
dashboard = { browse(DASHBOARD_URL) },
logout = ::logout,
organization = ::organization,
refresh = ::refreshProfile,
)
private var prof = profile
@@ -186,6 +187,26 @@ internal class ProfileUi(
}
}
private fun refreshProfile() {
cs.launch {
try {
val profile = app.refreshProfile()
val state = app.state.value
withContext(edt) {
update(profile ?: state.profile, state.status)
account.setRefreshing(false)
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
withContext(edt) {
applyState()
account.setRefreshing(false)
}
}
}
}
private fun cancel() {
auth = null
sync()
@@ -132,13 +132,15 @@ profile.label.account=Active account:
profile.notLoggedIn=Not logged in
profile.status.connecting=Connecting to Kilo...
profile.status.error=Connection error
profile.balance.title=Balance:
profile.balance.title=BALANCE
profile.personalAccount=Personal Account
profile.switchingAccount=Switching account...
profile.action.login=Login with Kilo Code
profile.action.logout=Log Out
profile.action.dashboard=Dashboard
profile.action.retry=Retry
profile.action.refresh=Refresh
profile.action.refreshing=Refreshing....
profile.login.signingIn=Signing in to Kilo Code
profile.login.urlLabel=Open this URL:
profile.login.codeLabel=Enter this code:
@@ -10,6 +10,7 @@ import ai.kilocode.rpc.dto.ProfileDto
import ai.kilocode.rpc.dto.ProfileOrganizationDto
import com.intellij.openapi.application.ApplicationManager
import com.intellij.testFramework.fixtures.BasePlatformTestCase
import com.intellij.ui.components.JBLabel
import com.intellij.util.ui.UIUtil
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
@@ -20,7 +21,9 @@ import java.awt.Component
import java.awt.Container
import javax.swing.AbstractButton
import javax.swing.JComboBox
import javax.swing.JEditorPane
import javax.swing.JLabel
import javax.swing.SwingUtilities
@Suppress("UnstableApiUsage")
class UserProfileConfigurableTest : BasePlatformTestCase() {
@@ -110,7 +113,10 @@ class UserProfileConfigurableTest : BasePlatformTestCase() {
edt {
val t = text(panel)
assertTrue(t, t.contains("\$10.00"))
combos(panel).single().selectedIndex = 1
val combo = combos(panel).single()
assertEquals("Acme", combo.getItemAt(1))
assertFalse(combo.getItemAt(1).toString().contains("admin", ignoreCase = true))
combo.selectedIndex = 1
}
flush()
@@ -121,6 +127,67 @@ class UserProfileConfigurableTest : BasePlatformTestCase() {
assertEquals(listOf("org_1"), rpc.orgSelections)
}
fun `test logged in profile uses compact stack and copyable email`() {
val profile = ProfileDto(
email = "alice@test.com",
name = "Alice",
organizations = listOf(ProfileOrganizationDto(id = "org_1", name = "Acme", role = "MEMBER")),
balance = ProfileBalanceDto(10.0),
)
app._state.value = KiloAppStateDto(KiloAppStatusDto.READY, profile = profile)
edt { panel.update(profile, KiloAppStatusDto.READY) }
edt {
val t = text(panel)
assertTrue(t, t.contains("Alice"))
assertTrue(t, t.contains("alice@test.com"))
assertTrue(t, t.contains("BALANCE"))
assertTrue(t, t.contains("Refresh"))
assertFalse(t, t.contains("Active account"))
assertFalse(t, t.contains("Organization"))
val mail = labels(panel).filterIsInstance<JBLabel>().first { it.text == "alice@test.com" }
assertTrue(editorPanes(mail).isNotEmpty())
panel.setSize(800, 600)
layout(panel)
val refresh = buttons(panel).first { it.text == "Refresh" }
val card = refresh.parent
val dash = buttons(panel).first { it.text == "Dashboard" }
val cardLoc = SwingUtilities.convertPoint(card.parent, card.location, panel)
val dashLoc = SwingUtilities.convertPoint(dash.parent, dash.location, panel)
assertTrue(dashLoc.y >= cardLoc.y + card.height)
}
}
fun `test refresh updates balance UI`() {
val profile = ProfileDto(
email = "alice@test.com",
name = "Alice",
balance = ProfileBalanceDto(10.0),
)
val updated = profile.copy(balance = ProfileBalanceDto(25.0))
rpc.fakeProfile = profile
app._state.value = KiloAppStateDto(KiloAppStatusDto.READY, profile = profile)
edt { panel.update(profile, KiloAppStatusDto.READY) }
edt {
assertTrue(text(panel).contains("\$10.00"))
rpc.fakeProfile = updated
buttons(panel).first { it.text == "Refresh" }.doClick()
assertTrue(text(panel).contains("Refreshing...."))
}
flush()
edt {
val t = text(panel)
assertTrue(t, t.contains("\$25.00"))
assertTrue(t, t.contains("Refresh"))
assertFalse(t, t.contains("Refreshing...."))
assertTrue(buttons(panel).first { it.text == "Refresh" }.isEnabled)
}
}
fun `test logged out update retains login button`() {
edt {
val btn = buttons(panel).first { it.text == "Login with Kilo Code" }
@@ -252,6 +319,21 @@ class UserProfileConfigurableTest : BasePlatformTestCase() {
}
}
private fun layout(root: Container) {
root.doLayout()
for (comp in root.components) {
if (comp is Container) layout(comp)
}
}
private fun editorPanes(root: Container): List<JEditorPane> = buildList {
for (comp in root.components) {
if (!comp.isVisible) continue
if (comp is JEditorPane) add(comp)
if (comp is Container) addAll(editorPanes(comp))
}
}
private fun text(root: Container): String {
val acc = mutableListOf<String>()
collectText(root, acc)
@@ -263,6 +345,7 @@ class UserProfileConfigurableTest : BasePlatformTestCase() {
if (!comp.isVisible) continue
when (comp) {
is AbstractButton -> comp.text?.let { acc.add(it) }
is JEditorPane -> comp.text?.let { acc.add(it) }
is JLabel -> comp.text?.let { acc.add(it) }
}
if (comp is Container) collectText(comp, acc)