mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-24 16:02:55 +08:00
feat(desktop): polish JetBrains account switcher
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": patch
|
||||
---
|
||||
|
||||
Show account login, switching, and balance controls on the empty JetBrains session screen.
|
||||
+39
@@ -13,6 +13,7 @@ import ai.kilocode.client.session.ui.ReasoningPicker
|
||||
import ai.kilocode.client.session.ui.mode.ModePicker
|
||||
import ai.kilocode.client.session.ui.model.ModelPicker
|
||||
import ai.kilocode.client.session.ui.prompt.PromptPanel
|
||||
import ai.kilocode.client.session.ui.account.SessionAccountOverlay
|
||||
import ai.kilocode.client.session.ui.SessionRootPanel
|
||||
import ai.kilocode.client.session.ui.SessionMessageListPanel
|
||||
import ai.kilocode.client.session.ui.header.SessionHeaderPanel
|
||||
@@ -21,17 +22,24 @@ import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget
|
||||
import ai.kilocode.client.session.controller.EVENT_FLUSH_MS
|
||||
import ai.kilocode.client.session.controller.SessionController
|
||||
import ai.kilocode.client.session.controller.SessionControllerEvent
|
||||
import ai.kilocode.client.session.ui.style.SessionUiStyle
|
||||
import ai.kilocode.client.session.views.PermissionView
|
||||
import ai.kilocode.client.session.views.question.QuestionView
|
||||
import ai.kilocode.client.settings.profile.UserProfileConfigurable
|
||||
import ai.kilocode.log.ChatLogSummary
|
||||
import com.intellij.util.ui.JBUI
|
||||
import ai.kilocode.log.KiloLog
|
||||
import com.intellij.ide.ui.LafManagerListener
|
||||
import com.intellij.openapi.application.ApplicationManager
|
||||
import com.intellij.openapi.editor.colors.EditorColorsListener
|
||||
import com.intellij.openapi.editor.colors.EditorColorsManager
|
||||
import com.intellij.openapi.Disposable
|
||||
import com.intellij.openapi.options.Configurable
|
||||
import com.intellij.openapi.options.ConfigurableWithId
|
||||
import com.intellij.openapi.options.ShowSettingsUtil
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.openapi.util.registry.Registry
|
||||
import java.util.function.Predicate
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import java.awt.BorderLayout
|
||||
import javax.swing.BoxLayout
|
||||
@@ -79,10 +87,12 @@ class SessionUi(
|
||||
beforeUpdate = { if (opening) false else scroll.atBottom() },
|
||||
afterUpdate = { if (!opening) scroll.followBottom(it) },
|
||||
loaded = ::onSessionLoaded,
|
||||
openProfileAction = ::openProfileSettings,
|
||||
)
|
||||
|
||||
|
||||
private lateinit var root: SessionRootPanel
|
||||
private lateinit var account: SessionAccountOverlay
|
||||
|
||||
private lateinit var sessionContent: JPanel
|
||||
|
||||
@@ -137,6 +147,23 @@ class SessionUi(
|
||||
private fun buildUi() {
|
||||
root = SessionRootPanel()
|
||||
|
||||
account = SessionAccountOverlay(
|
||||
select = { org -> controller.selectOrganization(org) },
|
||||
login = { controller.openProfile() },
|
||||
profile = { controller.openProfile() },
|
||||
)
|
||||
root.addOverlay(account) { pane, child ->
|
||||
val size = child.preferredSize
|
||||
val top = JBUI.scale(SessionUiStyle.View.Prompt.PANEL_VERTICAL_PADDING)
|
||||
val right = JBUI.scale(SessionUiStyle.View.Prompt.PANEL_HORIZONTAL_PADDING)
|
||||
java.awt.Rectangle(
|
||||
pane.width - size.width - right,
|
||||
top,
|
||||
size.width,
|
||||
size.height,
|
||||
)
|
||||
}
|
||||
|
||||
sessionContent = JPanel(BorderLayout())
|
||||
|
||||
blankBody = JPanel(BorderLayout()).apply {
|
||||
@@ -234,6 +261,8 @@ class SessionUi(
|
||||
}
|
||||
|
||||
is SessionControllerEvent.ConnectionChanged -> Unit
|
||||
|
||||
is SessionControllerEvent.AccountOverlayChanged -> account.onEvent(event)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -340,6 +369,16 @@ class SessionUi(
|
||||
refresh()
|
||||
}
|
||||
|
||||
private fun openProfileSettings() {
|
||||
ShowSettingsUtil.getInstance().showSettingsDialog(
|
||||
project,
|
||||
Predicate { cfg: Configurable ->
|
||||
cfg is ConfigurableWithId && cfg.getId() == UserProfileConfigurable.ID
|
||||
},
|
||||
{ cfg: Configurable -> cfg.focusOn(UserProfileConfigurable.FOCUS_ACCOUNT_COMBO) },
|
||||
)
|
||||
}
|
||||
|
||||
override fun dispose() {}
|
||||
}
|
||||
|
||||
|
||||
+90
@@ -25,6 +25,8 @@ import ai.kilocode.rpc.dto.KiloAppStatusDto
|
||||
import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto
|
||||
import ai.kilocode.rpc.dto.LoadErrorDto
|
||||
import ai.kilocode.rpc.dto.ModelSelectionDto
|
||||
import ai.kilocode.rpc.dto.ProfileDto
|
||||
import ai.kilocode.rpc.dto.ProfileStatusDto
|
||||
import ai.kilocode.rpc.dto.PermissionAlwaysRulesDto
|
||||
import ai.kilocode.rpc.dto.PermissionReplyDto
|
||||
import ai.kilocode.rpc.dto.PermissionRequestDto
|
||||
@@ -74,8 +76,11 @@ class SessionController(
|
||||
private val beforeUpdate: () -> Boolean = { false },
|
||||
private val afterUpdate: (Boolean) -> Unit = {},
|
||||
private val loaded: (Boolean) -> Unit = {},
|
||||
private val openProfileAction: () -> Unit = {},
|
||||
) : Disposable {
|
||||
|
||||
private data class OrganizationTarget(val org: String?)
|
||||
|
||||
companion object {
|
||||
private val LOG = KiloLog.create(SessionController::class.java)
|
||||
internal const val RECENT_LIMIT = 5
|
||||
@@ -111,6 +116,11 @@ class SessionController(
|
||||
private var connectionState: SessionControllerEvent.ConnectionChanged? = null
|
||||
private var connectionTargetState: SessionControllerEvent.ConnectionChanged? = null
|
||||
private val connectionDelay = DelayedState(displayMs)
|
||||
private var acctState: SessionControllerEvent.AccountOverlayChanged =
|
||||
SessionControllerEvent.AccountOverlayChanged.Hide
|
||||
private var acctAllowed = false
|
||||
private var lastProfile: ProfileDto? = null
|
||||
private var target: OrganizationTarget? = null
|
||||
|
||||
val ready: Boolean get() = model.isReady()
|
||||
internal val blank: Boolean get() = ref == null && model.isEmpty() && !model.showSession
|
||||
@@ -371,6 +381,7 @@ class SessionController(
|
||||
model.version = app.version
|
||||
syncModelSelection()
|
||||
syncConnectionState()
|
||||
refreshAccountOverlay()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -845,6 +856,79 @@ class SessionController(
|
||||
else -> KiloBundle.message("session.status.considering")
|
||||
}
|
||||
|
||||
fun selectOrganization(org: String?) {
|
||||
assertEdt()
|
||||
val next = OrganizationTarget(org)
|
||||
if (target == next) return
|
||||
target = next
|
||||
refreshAccountOverlay()
|
||||
cs.launch {
|
||||
try {
|
||||
app.setOrganization(org)
|
||||
} catch (e: Exception) {
|
||||
LOG.warn("account switch failed org=$org message=${e.message}", e)
|
||||
edt {
|
||||
if (disposed) return@edt
|
||||
target = null
|
||||
refreshAccountOverlay()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun openProfile() {
|
||||
assertEdt()
|
||||
openProfileAction()
|
||||
}
|
||||
|
||||
private fun accountSnapshot(): SessionControllerEvent.AccountOverlaySnapshot {
|
||||
val state = model.app
|
||||
val prof = state.profile
|
||||
val pending = prof == null && state.progress?.profile == ProfileStatusDto.PENDING
|
||||
val current = when {
|
||||
prof != null -> prof
|
||||
pending -> lastProfile
|
||||
else -> null
|
||||
}
|
||||
if (prof != null) {
|
||||
lastProfile = prof
|
||||
if (target?.org == prof.currentOrgId) target = null
|
||||
}
|
||||
if (!pending && prof == null) {
|
||||
lastProfile = null
|
||||
target = null
|
||||
}
|
||||
return SessionControllerEvent.AccountOverlaySnapshot(
|
||||
status = state.status,
|
||||
profile = current,
|
||||
transient = pending,
|
||||
switching = target != null,
|
||||
targetOrgId = target?.org,
|
||||
)
|
||||
}
|
||||
|
||||
private fun showAccountOverlay() {
|
||||
acctAllowed = true
|
||||
setAccountOverlayState(SessionControllerEvent.AccountOverlayChanged.Show(accountSnapshot()))
|
||||
}
|
||||
|
||||
private fun hideAccountOverlay() {
|
||||
acctAllowed = false
|
||||
setAccountOverlayState(SessionControllerEvent.AccountOverlayChanged.Hide)
|
||||
}
|
||||
|
||||
private fun refreshAccountOverlay() {
|
||||
if (!acctAllowed) return
|
||||
setAccountOverlayState(SessionControllerEvent.AccountOverlayChanged.Show(accountSnapshot()))
|
||||
}
|
||||
|
||||
private fun setAccountOverlayState(event: SessionControllerEvent.AccountOverlayChanged) {
|
||||
if (acctState == event) return
|
||||
fire(event) {
|
||||
acctState = event
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshRecents(force: Boolean = false) {
|
||||
assertEdt()
|
||||
if (!canUseRecents()) return
|
||||
@@ -896,6 +980,11 @@ class SessionController(
|
||||
setRecentSessionsState(RecentsState.Idle)
|
||||
}
|
||||
}
|
||||
when (event) {
|
||||
is SessionControllerEvent.ViewChanged.ShowRecents -> showAccountOverlay()
|
||||
is SessionControllerEvent.ViewChanged.ShowProgress -> hideAccountOverlay()
|
||||
is SessionControllerEvent.ViewChanged.ShowSession -> hideAccountOverlay()
|
||||
}
|
||||
}
|
||||
|
||||
private fun openLocal() {
|
||||
@@ -1004,6 +1093,7 @@ class SessionController(
|
||||
val block: () -> Unit = {
|
||||
if (!disposed) {
|
||||
viewState?.let(listener::onEvent)
|
||||
listener.onEvent(acctState)
|
||||
connectionState?.let(listener::onEvent)
|
||||
}
|
||||
}
|
||||
|
||||
+20
@@ -2,6 +2,8 @@ package ai.kilocode.client.session.controller
|
||||
|
||||
import ai.kilocode.client.session.model.SessionModel
|
||||
import ai.kilocode.client.session.model.SessionModelEvent
|
||||
import ai.kilocode.rpc.dto.KiloAppStatusDto
|
||||
import ai.kilocode.rpc.dto.ProfileDto
|
||||
import ai.kilocode.rpc.dto.SessionDto
|
||||
|
||||
/**
|
||||
@@ -34,6 +36,24 @@ sealed class SessionControllerEvent {
|
||||
}
|
||||
}
|
||||
|
||||
data class AccountOverlaySnapshot(
|
||||
val status: KiloAppStatusDto,
|
||||
val profile: ProfileDto?,
|
||||
val transient: Boolean = false,
|
||||
val switching: Boolean = false,
|
||||
val targetOrgId: String? = null,
|
||||
)
|
||||
|
||||
sealed class AccountOverlayChanged : SessionControllerEvent() {
|
||||
data class Show(val account: AccountOverlaySnapshot) : AccountOverlayChanged() {
|
||||
override fun toString() = "AccountOverlayChanged show loggedIn=${account.profile != null}"
|
||||
}
|
||||
|
||||
data object Hide : AccountOverlayChanged() {
|
||||
override fun toString() = "AccountOverlayChanged hide"
|
||||
}
|
||||
}
|
||||
|
||||
sealed class ConnectionChanged : SessionControllerEvent() {
|
||||
data object Hide : ConnectionChanged() {
|
||||
override fun toString() = "ConnectionChanged hide"
|
||||
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
package ai.kilocode.client.session.ui.account
|
||||
|
||||
internal data class AccountChoice(val org: String?, val title: String) {
|
||||
override fun toString() = title
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
package ai.kilocode.client.session.ui.account
|
||||
|
||||
import ai.kilocode.client.session.ui.PickerRow
|
||||
import ai.kilocode.client.ui.UiStyle
|
||||
import com.intellij.icons.AllIcons
|
||||
import com.intellij.ui.components.JBLabel
|
||||
import com.intellij.util.ui.EmptyIcon
|
||||
import com.intellij.util.ui.JBUI
|
||||
import com.intellij.util.ui.UIUtil
|
||||
import java.awt.BorderLayout
|
||||
import java.awt.Component
|
||||
import javax.swing.JList
|
||||
import javax.swing.JPanel
|
||||
import javax.swing.ListCellRenderer
|
||||
import javax.swing.SwingConstants
|
||||
|
||||
internal class AccountPickerRenderer(
|
||||
private val active: () -> String?,
|
||||
) : JPanel(BorderLayout()), ListCellRenderer<AccountChoice> {
|
||||
companion object {
|
||||
val checked: javax.swing.Icon = AllIcons.Actions.Checked
|
||||
val empty: javax.swing.Icon = EmptyIcon.create(checked)
|
||||
}
|
||||
|
||||
private val icon = JBLabel().apply {
|
||||
horizontalAlignment = SwingConstants.CENTER
|
||||
verticalAlignment = SwingConstants.CENTER
|
||||
}
|
||||
private val title = JBLabel().apply {
|
||||
horizontalAlignment = SwingConstants.LEFT
|
||||
verticalAlignment = SwingConstants.CENTER
|
||||
}
|
||||
private val row = JPanel(BorderLayout(UiStyle.Gap.md(), 0))
|
||||
private val wrap = PickerRow()
|
||||
|
||||
init {
|
||||
UiStyle.Components.transparent(this, icon, title, row)
|
||||
row.border = JBUI.Borders.empty(
|
||||
UiStyle.Gap.md(),
|
||||
UiStyle.Gap.lg(),
|
||||
UiStyle.Gap.md(),
|
||||
UiStyle.Gap.lg(),
|
||||
)
|
||||
row.add(icon, BorderLayout.WEST)
|
||||
row.add(title, BorderLayout.CENTER)
|
||||
wrap.setContent(row)
|
||||
add(wrap, BorderLayout.CENTER)
|
||||
}
|
||||
|
||||
override fun getListCellRendererComponent(
|
||||
list: JList<out AccountChoice>,
|
||||
value: AccountChoice,
|
||||
index: Int,
|
||||
selected: Boolean,
|
||||
focused: Boolean,
|
||||
): Component {
|
||||
val focus = selected || list.hasFocus() || focused
|
||||
val fg = UIUtil.getListForeground(selected, focus)
|
||||
background = list.background
|
||||
wrap.update(list, selected, focus)
|
||||
icon.icon = icon(value)
|
||||
title.text = value.title
|
||||
title.foreground = fg
|
||||
return this
|
||||
}
|
||||
|
||||
internal fun icon(value: AccountChoice): javax.swing.Icon =
|
||||
if (value.org == active()) checked else empty
|
||||
}
|
||||
+365
@@ -0,0 +1,365 @@
|
||||
package ai.kilocode.client.session.ui.account
|
||||
|
||||
import ai.kilocode.client.plugin.KiloBundle
|
||||
import ai.kilocode.client.session.controller.SessionControllerEvent
|
||||
import ai.kilocode.client.ui.FilledBadgeIcon
|
||||
import ai.kilocode.client.ui.HoverIcon
|
||||
import ai.kilocode.client.ui.PickerButton
|
||||
import ai.kilocode.client.ui.RoundedContentPanel
|
||||
import ai.kilocode.client.ui.UiStyle
|
||||
import com.intellij.icons.AllIcons
|
||||
import com.intellij.openapi.ui.popup.JBPopupFactory
|
||||
import com.intellij.ui.CollectionListModel
|
||||
import com.intellij.ui.ListUtil
|
||||
import com.intellij.ui.ScrollPaneFactory
|
||||
import com.intellij.ui.ScrollingUtil
|
||||
import com.intellij.ui.components.JBLabel
|
||||
import com.intellij.ui.components.JBList
|
||||
import com.intellij.util.ui.JBUI
|
||||
import com.intellij.util.ui.UIUtil
|
||||
import com.intellij.util.ui.components.BorderLayoutPanel
|
||||
import java.awt.CardLayout
|
||||
import java.awt.Cursor
|
||||
import java.awt.GridBagConstraints
|
||||
import java.awt.GridBagLayout
|
||||
import java.awt.event.KeyEvent
|
||||
import java.awt.event.MouseAdapter
|
||||
import java.awt.event.MouseEvent
|
||||
import java.text.DecimalFormat
|
||||
import javax.swing.Box
|
||||
import javax.swing.BoxLayout
|
||||
import javax.swing.JButton
|
||||
import javax.swing.JComponent
|
||||
import javax.swing.JPanel
|
||||
import javax.swing.KeyStroke
|
||||
import javax.swing.ListSelectionModel
|
||||
import javax.swing.ScrollPaneConstants
|
||||
|
||||
/**
|
||||
* Compact account overlay shown in the top-right of the empty session screen.
|
||||
*
|
||||
* Displays logged-out prompt or logged-in account/balance info.
|
||||
* Visibility is controlled entirely by [onEvent] — never set [isVisible] externally.
|
||||
*/
|
||||
internal class SessionAccountOverlay(
|
||||
private val select: (String?) -> Unit,
|
||||
private val login: () -> Unit,
|
||||
private val profile: () -> Unit,
|
||||
) : BorderLayoutPanel() {
|
||||
|
||||
companion object {
|
||||
private const val CARD_OUT = "out"
|
||||
private const val CARD_IN = "in"
|
||||
}
|
||||
|
||||
private val loginLabel = JBLabel(KiloBundle.message("profile.notLoggedIn")).apply {
|
||||
foreground = UiStyle.Colors.weak()
|
||||
}
|
||||
private val loginBtn = JButton(KiloBundle.message("profile.action.login")).apply {
|
||||
isOpaque = false
|
||||
addActionListener { login() }
|
||||
}
|
||||
private val outCard = JPanel(GridBagLayout()).apply {
|
||||
isOpaque = false
|
||||
add(loginLabel, GridBagConstraints().apply {
|
||||
gridx = 0; gridy = 0; anchor = GridBagConstraints.WEST
|
||||
})
|
||||
add(loginBtn, GridBagConstraints().apply {
|
||||
gridx = 0; gridy = 1; anchor = GridBagConstraints.CENTER
|
||||
insets = JBUI.insetsTop(UiStyle.Gap.sm())
|
||||
})
|
||||
}
|
||||
|
||||
private val picker = PickerButton().apply {
|
||||
isEnabled = false
|
||||
text = " "
|
||||
cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)
|
||||
addMouseListener(object : MouseAdapter() {
|
||||
override fun mouseClicked(e: MouseEvent) {
|
||||
if (!isEnabled || choices.isEmpty()) return
|
||||
showPopup()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private val fmt = DecimalFormat("$#,##0.00")
|
||||
private var balanceText: String? = null
|
||||
|
||||
private val balance = JBLabel().apply {
|
||||
isVisible = false
|
||||
}
|
||||
|
||||
private val profileBtn = HoverIcon().apply {
|
||||
icon = AllIcons.General.User
|
||||
toolTipText = KiloBundle.message("action.Kilo.ShowProfile.description")
|
||||
accessibleContext.accessibleName = KiloBundle.message("action.Kilo.ShowProfile.text")
|
||||
addActionListener { profile() }
|
||||
}
|
||||
|
||||
private val row = JPanel().apply {
|
||||
layout = BoxLayout(this, BoxLayout.X_AXIS)
|
||||
isOpaque = false
|
||||
add(picker)
|
||||
add(Box.createHorizontalStrut(UiStyle.Gap.md()))
|
||||
add(balance)
|
||||
add(Box.createHorizontalStrut(UiStyle.Gap.md()))
|
||||
add(profileBtn)
|
||||
}
|
||||
|
||||
private val panel = RoundedContentPanel(UiStyle.Gap.lg(), UiStyle.Gap.lg()).apply {
|
||||
addToCenter(row)
|
||||
}
|
||||
|
||||
private val inCard = JPanel(GridBagLayout()).apply {
|
||||
isOpaque = false
|
||||
add(panel, GridBagConstraints().apply {
|
||||
gridx = 0; gridy = 0; fill = GridBagConstraints.HORIZONTAL
|
||||
})
|
||||
}
|
||||
|
||||
private val cardLayout = CardLayout()
|
||||
private val cards = JPanel(cardLayout).apply {
|
||||
isOpaque = false
|
||||
add(outCard, CARD_OUT)
|
||||
add(inCard, CARD_IN)
|
||||
}
|
||||
|
||||
private var choices: List<AccountChoice> = emptyList()
|
||||
private var currentOrgId: String? = null
|
||||
|
||||
init {
|
||||
isOpaque = false
|
||||
isVisible = false
|
||||
addToCenter(cards)
|
||||
}
|
||||
|
||||
fun onEvent(event: SessionControllerEvent.AccountOverlayChanged) {
|
||||
var layout = false
|
||||
var paint = false
|
||||
when (event) {
|
||||
is SessionControllerEvent.AccountOverlayChanged.Hide -> {
|
||||
if (isVisible) {
|
||||
isVisible = false
|
||||
layout = true
|
||||
paint = true
|
||||
}
|
||||
}
|
||||
is SessionControllerEvent.AccountOverlayChanged.Show -> {
|
||||
val snap = event.account
|
||||
val prof = snap.profile
|
||||
if (prof == null) {
|
||||
if (!snap.transient) {
|
||||
layout = showCard(CARD_OUT) || layout
|
||||
if (!isVisible) {
|
||||
isVisible = true
|
||||
layout = true
|
||||
}
|
||||
}
|
||||
} else {
|
||||
layout = updateLoggedIn(prof, snap.switching, snap.targetOrgId) || layout
|
||||
layout = showCard(CARD_IN) || layout
|
||||
if (!isVisible) {
|
||||
isVisible = true
|
||||
layout = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (layout) revalidate()
|
||||
if (layout || paint) repaint()
|
||||
}
|
||||
|
||||
private fun activeCard(): String? {
|
||||
for (i in 0 until cards.componentCount) {
|
||||
val comp = cards.getComponent(i)
|
||||
if (comp.isVisible) return if (comp === inCard) CARD_IN else CARD_OUT
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun showCard(card: String): Boolean {
|
||||
if (activeCard() == card) return false
|
||||
cardLayout.show(cards, card)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun updateLoggedIn(prof: ai.kilocode.rpc.dto.ProfileDto, switching: Boolean, target: String?): Boolean {
|
||||
var layout = false
|
||||
val orgs = prof.organizations
|
||||
val next = listOf(AccountChoice(null, KiloBundle.message("profile.personalAccount"))) +
|
||||
orgs.map { org -> AccountChoice(org.id, org.name) }
|
||||
if (next != choices) {
|
||||
choices = next
|
||||
layout = true
|
||||
}
|
||||
|
||||
if (currentOrgId != prof.currentOrgId) currentOrgId = prof.currentOrgId
|
||||
|
||||
val activeId = if (switching) target else prof.currentOrgId
|
||||
val active = choices.firstOrNull { it.org == activeId } ?: choices.firstOrNull()
|
||||
val title = "${active?.title ?: " "} ▾"
|
||||
if (picker.text != title) {
|
||||
picker.text = title
|
||||
layout = true
|
||||
}
|
||||
|
||||
val enabled = !switching
|
||||
if (picker.isEnabled != enabled) {
|
||||
picker.isEnabled = enabled
|
||||
picker.repaint()
|
||||
}
|
||||
|
||||
val tip = if (switching) {
|
||||
KiloBundle.message("profile.switchingAccount")
|
||||
} else {
|
||||
KiloBundle.message("session.account.switcher")
|
||||
}
|
||||
if (picker.toolTipText != tip) picker.toolTipText = tip
|
||||
|
||||
if (!picker.isVisible) {
|
||||
picker.isVisible = true
|
||||
layout = true
|
||||
}
|
||||
|
||||
layout = syncBalance(prof) || layout
|
||||
return layout
|
||||
}
|
||||
|
||||
private fun syncBalance(prof: ai.kilocode.rpc.dto.ProfileDto): Boolean {
|
||||
var layout = false
|
||||
val next = prof.balance?.let { fmt.format(it.balance) }
|
||||
if (next == null) {
|
||||
if (balance.isVisible) {
|
||||
balance.isVisible = false
|
||||
layout = true
|
||||
}
|
||||
if (balance.icon != null) {
|
||||
balance.icon = null
|
||||
}
|
||||
if (balance.toolTipText != null) balance.toolTipText = null
|
||||
balanceText = null
|
||||
} else {
|
||||
if (!balance.isVisible) {
|
||||
balance.isVisible = true
|
||||
layout = true
|
||||
}
|
||||
if (balanceText != next || balance.icon == null) {
|
||||
balance.icon = FilledBadgeIcon(
|
||||
next,
|
||||
UiStyle.Colors.badgeBg(),
|
||||
UiStyle.Colors.badgeFg(),
|
||||
)
|
||||
layout = true
|
||||
}
|
||||
val tip = KiloBundle.message("session.account.balance", next)
|
||||
if (balance.toolTipText != tip) balance.toolTipText = tip
|
||||
balanceText = next
|
||||
}
|
||||
return layout
|
||||
}
|
||||
|
||||
private fun showPopup() {
|
||||
val bg = UiStyle.Colors.cardBg()
|
||||
val model = CollectionListModel(choices)
|
||||
val list = JBList(model).apply {
|
||||
selectionMode = ListSelectionModel.SINGLE_SELECTION
|
||||
background = bg
|
||||
border = JBUI.Borders.empty(UiStyle.Gap.xs(), 0)
|
||||
cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)
|
||||
}
|
||||
list.cellRenderer = AccountPickerRenderer { currentOrgId }
|
||||
|
||||
val idx = choices.indexOfFirst { it.org == currentOrgId }.takeIf { it >= 0 } ?: 0
|
||||
if (idx >= 0) {
|
||||
list.selectedIndex = idx
|
||||
ScrollingUtil.ensureIndexIsVisible(list, idx, 0)
|
||||
}
|
||||
|
||||
lateinit var popup: com.intellij.openapi.ui.popup.JBPopup
|
||||
|
||||
fun activate(choice: AccountChoice) {
|
||||
if (choice.org != currentOrgId) select(choice.org)
|
||||
popup.closeOk(null)
|
||||
}
|
||||
|
||||
list.addMouseListener(object : MouseAdapter() {
|
||||
override fun mouseReleased(e: MouseEvent) {
|
||||
if (!UIUtil.isActionClick(e, MouseEvent.MOUSE_RELEASED, true)) return
|
||||
val row = list.locationToIndex(e.point)
|
||||
val bounds = row.takeIf { it >= 0 }?.let { list.getCellBounds(it, it) } ?: return
|
||||
if (!bounds.contains(e.point)) return
|
||||
activate(model.getElementAt(row))
|
||||
}
|
||||
})
|
||||
|
||||
ListUtil.installAutoSelectOnMouseMove(list)
|
||||
ScrollingUtil.installActions(list)
|
||||
|
||||
list.registerKeyboardAction(
|
||||
{ list.selectedValue?.let(::activate) },
|
||||
KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, 0),
|
||||
JComponent.WHEN_FOCUSED,
|
||||
)
|
||||
list.registerKeyboardAction(
|
||||
{ popup.cancel() },
|
||||
KeyStroke.getKeyStroke(KeyEvent.VK_ESCAPE, 0),
|
||||
JComponent.WHEN_FOCUSED,
|
||||
)
|
||||
|
||||
val scroll = ScrollPaneFactory.createScrollPane(list).apply {
|
||||
horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER
|
||||
verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED
|
||||
border = JBUI.Borders.empty()
|
||||
viewportBorder = JBUI.Borders.empty()
|
||||
background = bg
|
||||
viewport.background = bg
|
||||
viewport.isOpaque = true
|
||||
}
|
||||
val content = RoundedContentPanel(UiStyle.Gap.sm(), UiStyle.Gap.sm()).apply {
|
||||
addToCenter(scroll)
|
||||
}
|
||||
|
||||
popup = JBPopupFactory.getInstance()
|
||||
.createComponentPopupBuilder(content, list)
|
||||
.setRequestFocus(true)
|
||||
.setFocusable(true)
|
||||
.setCancelOnClickOutside(true)
|
||||
.setCancelKeyEnabled(true)
|
||||
.setCancelOnWindowDeactivation(true)
|
||||
.setResizable(false)
|
||||
.setMovable(false)
|
||||
.createPopup()
|
||||
|
||||
popup.showUnderneathOf(picker)
|
||||
}
|
||||
|
||||
internal fun loggedInVisible() = isVisible && cards.let {
|
||||
var card = CARD_OUT
|
||||
for (i in 0 until it.componentCount) {
|
||||
val comp = it.getComponent(i)
|
||||
if (comp.isVisible) card = if (comp === inCard) CARD_IN else CARD_OUT
|
||||
}
|
||||
card == CARD_IN
|
||||
}
|
||||
|
||||
internal fun loggedOutVisible() = isVisible && cards.let {
|
||||
for (i in 0 until it.componentCount) {
|
||||
val comp = it.getComponent(i)
|
||||
if (comp.isVisible) return@let comp === outCard
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
internal fun accountTitle(): String? = picker.text?.removeSuffix(" ▾")?.ifBlank { null }
|
||||
internal fun pickerEnabled() = picker.isEnabled
|
||||
internal fun pickerVisible() = picker.isVisible
|
||||
internal fun choiceCount() = choices.size
|
||||
internal fun selectedIndex() = choices.indexOfFirst { it.org == currentOrgId }.takeIf { it >= 0 } ?: 0
|
||||
internal fun panelBackground() = panel.background
|
||||
internal fun panelBorderColor() = UiStyle.Colors.cardBorder()
|
||||
internal fun balanceVisible() = balance.isVisible
|
||||
internal fun balanceIcon() = balance.icon
|
||||
internal fun balanceText() = balanceText
|
||||
internal fun profileIcon() = profileBtn.icon
|
||||
internal fun clickProfile() = profileBtn.doClick()
|
||||
}
|
||||
+6
-36
@@ -2,6 +2,7 @@ package ai.kilocode.client.session.ui.model
|
||||
|
||||
import ai.kilocode.client.plugin.KiloBundle
|
||||
import ai.kilocode.client.session.ui.PickerRow
|
||||
import ai.kilocode.client.ui.FilledBadgeIcon
|
||||
import ai.kilocode.client.ui.UiStyle
|
||||
import com.intellij.icons.AllIcons
|
||||
import com.intellij.ui.CollectionListModel
|
||||
@@ -12,18 +13,12 @@ import com.intellij.ui.SimpleColoredComponent
|
||||
import com.intellij.ui.SimpleTextAttributes
|
||||
import com.intellij.ui.components.JBLabel
|
||||
import com.intellij.util.ui.EmptyIcon
|
||||
import com.intellij.util.ui.JBFont
|
||||
import com.intellij.util.ui.JBUI
|
||||
import com.intellij.util.ui.UIUtil
|
||||
import java.awt.BorderLayout
|
||||
import java.awt.Component
|
||||
import java.awt.FlowLayout
|
||||
import java.awt.Graphics
|
||||
import java.awt.Graphics2D
|
||||
import java.awt.Point
|
||||
import java.awt.Rectangle
|
||||
import java.awt.RenderingHints
|
||||
import java.awt.font.FontRenderContext
|
||||
import javax.swing.Icon
|
||||
import javax.swing.JList
|
||||
import javax.swing.JPanel
|
||||
@@ -70,7 +65,11 @@ internal class ModelPickerRenderer(
|
||||
verticalAlignment = SwingConstants.CENTER
|
||||
}
|
||||
private val title = SimpleColoredComponent()
|
||||
private val badge = BadgeIcon
|
||||
private val badge = FilledBadgeIcon(
|
||||
KiloBundle.message("model.picker.free"),
|
||||
ModelText.freeBg(),
|
||||
JBColor.namedColor("Kilo.ModelPicker.freeBadgeForeground", JBColor.WHITE),
|
||||
)
|
||||
private val provider = JBLabel()
|
||||
private val head = JPanel(FlowLayout(FlowLayout.LEFT, 0, 0)).apply {
|
||||
add(title)
|
||||
@@ -157,33 +156,4 @@ internal class ModelPickerRenderer(
|
||||
internal fun badgeVisible(): Boolean = head.getComponent(1).isVisible
|
||||
|
||||
private class BadgeLabel(icon: Icon) : JBLabel(icon)
|
||||
|
||||
private object BadgeIcon : Icon {
|
||||
private val text = KiloBundle.message("model.picker.free")
|
||||
|
||||
override fun getIconWidth(): Int {
|
||||
val font = JBFont.small()
|
||||
val w = font.getStringBounds(text, FontRenderContext(null, true, true)).width.toInt()
|
||||
return w + JBUI.scale(12)
|
||||
}
|
||||
|
||||
override fun getIconHeight(): Int = JBUI.scale(16)
|
||||
|
||||
override fun paintIcon(c: Component?, g: Graphics, x: Int, y: Int) {
|
||||
val g2 = g.create() as Graphics2D
|
||||
try {
|
||||
g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON)
|
||||
g2.translate(x, y)
|
||||
g2.color = ModelText.freeBg()
|
||||
g2.fillRoundRect(0, 0, iconWidth, iconHeight, JBUI.scale(4), JBUI.scale(4))
|
||||
g2.color = JBColor.namedColor("Kilo.ModelPicker.freeBadgeForeground", JBColor.WHITE)
|
||||
g2.font = JBFont.small()
|
||||
val fm = g2.fontMetrics
|
||||
val y = (iconHeight + fm.ascent - fm.descent) / 2
|
||||
g2.drawString(text, JBUI.scale(6), y)
|
||||
} finally {
|
||||
g2.dispose()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+13
-45
@@ -10,6 +10,7 @@ import ai.kilocode.client.session.ui.style.SessionUiStyle
|
||||
import ai.kilocode.client.session.ui.mode.ModePicker
|
||||
import ai.kilocode.client.session.ui.model.ModelPicker
|
||||
import ai.kilocode.client.ui.HoverIcon
|
||||
import ai.kilocode.client.ui.RoundedContentPanel
|
||||
import ai.kilocode.client.ui.UiStyle
|
||||
import ai.kilocode.client.ui.iconButton
|
||||
import ai.kilocode.log.ChatLogSummary
|
||||
@@ -357,55 +358,22 @@ class PromptPanel(
|
||||
}
|
||||
}
|
||||
|
||||
private inner class PromptShell : BorderLayoutPanel() {
|
||||
private val arc = JBValue.UIInteger("Button.arc", SessionUiStyle.View.Prompt.CORNER_ARC)
|
||||
private inner class PromptShell : RoundedContentPanel(
|
||||
JBUI.scale(SessionUiStyle.View.Prompt.SHELL_VERTICAL_PADDING),
|
||||
JBUI.scale(SessionUiStyle.View.Prompt.SHELL_HORIZONTAL_PADDING),
|
||||
) {
|
||||
private val focus = JBValue.UIInteger("Component.focusWidth", SessionUiStyle.View.Prompt.FOCUS_WIDTH)
|
||||
|
||||
init {
|
||||
isOpaque = false
|
||||
border = JBUI.Borders.empty(
|
||||
JBUI.scale(SessionUiStyle.View.Prompt.SHELL_VERTICAL_PADDING),
|
||||
JBUI.scale(SessionUiStyle.View.Prompt.SHELL_HORIZONTAL_PADDING),
|
||||
)
|
||||
override fun contentColor() = style.editorScheme.defaultBackground
|
||||
|
||||
override fun outlineColor() = if (UIUtil.isFocusAncestor(editor)) {
|
||||
JBUI.CurrentTheme.Focus.focusColor()
|
||||
} else {
|
||||
SessionUiStyle.View.line()
|
||||
}
|
||||
|
||||
override fun updateUI() {
|
||||
super.updateUI()
|
||||
border = JBUI.Borders.empty(
|
||||
JBUI.scale(SessionUiStyle.View.Prompt.SHELL_VERTICAL_PADDING),
|
||||
JBUI.scale(SessionUiStyle.View.Prompt.SHELL_HORIZONTAL_PADDING),
|
||||
)
|
||||
}
|
||||
override fun outlineWidth() = if (UIUtil.isFocusAncestor(editor)) focus.get() else JBUI.scale(1)
|
||||
|
||||
override fun paintComponent(g: Graphics) {
|
||||
val g2 = g.create() as Graphics2D
|
||||
try {
|
||||
g2.setRenderingHint(
|
||||
RenderingHints.KEY_ANTIALIASING,
|
||||
RenderingHints.VALUE_ANTIALIAS_ON,
|
||||
)
|
||||
g2.color = style.editorScheme.defaultBackground
|
||||
val size = arc.get()
|
||||
g2.fillRoundRect(0, 0, width, height, size, size)
|
||||
val active = UIUtil.isFocusAncestor(editor)
|
||||
g2.color = if (active) {
|
||||
JBUI.CurrentTheme.Focus.focusColor()
|
||||
} else {
|
||||
SessionUiStyle.View.line()
|
||||
}
|
||||
val bw = if (active) focus.get() else JBUI.scale(1)
|
||||
for (idx in 0 until bw) {
|
||||
val inset = idx
|
||||
val w = width - inset * 2 - 1
|
||||
val h = height - inset * 2 - 1
|
||||
if (w > 0 && h > 0) {
|
||||
g2.drawRoundRect(inset, inset, w, h, size, size)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
g2.dispose()
|
||||
}
|
||||
super.paintComponent(g)
|
||||
}
|
||||
override fun cornerArc() = JBUI.scale(JBUI.getInt("Button.arc", SessionUiStyle.View.Prompt.CORNER_ARC))
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -1,6 +1,7 @@
|
||||
package ai.kilocode.client.settings.profile
|
||||
|
||||
import ai.kilocode.client.plugin.KiloBundle
|
||||
import ai.kilocode.client.ui.RoundedContentPanel
|
||||
import ai.kilocode.client.ui.UiStyle
|
||||
import ai.kilocode.log.KiloLog
|
||||
import ai.kilocode.rpc.dto.ProfileDto
|
||||
@@ -61,7 +62,7 @@ internal class LoggedInProfileUi(
|
||||
}
|
||||
}
|
||||
|
||||
private val balanceCard = ProfileCardPanel(UiStyle.Gap.pad(), UiStyle.Gap.xl()).apply {
|
||||
private val balanceCard = RoundedContentPanel(UiStyle.Gap.pad(), UiStyle.Gap.xl()).apply {
|
||||
name = "kilo.profile.balanceCard"
|
||||
addToTop(titleLabel)
|
||||
addToCenter(JPanel(GridBagLayout()).apply {
|
||||
|
||||
+2
-1
@@ -2,6 +2,7 @@ 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.ui.UiStyle
|
||||
import ai.kilocode.rpc.dto.KiloAppStatusDto
|
||||
import com.intellij.icons.AllIcons
|
||||
@@ -94,7 +95,7 @@ internal class LoggedOutProfileUi(
|
||||
accessibleContext.accessibleDescription = KiloBundle.message("profile.login.qr.description")
|
||||
}
|
||||
|
||||
private val codePanel = ProfileCardPanel(UiStyle.Gap.sm(), UiStyle.Gap.md()).apply {
|
||||
private val codePanel = RoundedContentPanel(UiStyle.Gap.sm(), UiStyle.Gap.md()).apply {
|
||||
name = "kilo.login.codePanel"
|
||||
addMouseListener(object : MouseAdapter() {
|
||||
override fun mouseClicked(e: MouseEvent) {
|
||||
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
package ai.kilocode.client.ui
|
||||
|
||||
import com.intellij.util.ui.JBFont
|
||||
import com.intellij.util.ui.JBUI
|
||||
import java.awt.Color
|
||||
import java.awt.Component
|
||||
import java.awt.Graphics
|
||||
import java.awt.Graphics2D
|
||||
import java.awt.RenderingHints
|
||||
import java.awt.font.FontRenderContext
|
||||
import javax.swing.Icon
|
||||
|
||||
internal class FilledBadgeIcon(
|
||||
private val text: String,
|
||||
private val bg: Color,
|
||||
private val fg: Color,
|
||||
) : Icon {
|
||||
override fun getIconWidth(): Int {
|
||||
val font = JBFont.small()
|
||||
val width = font.getStringBounds(text, FontRenderContext(null, true, true)).width.toInt()
|
||||
return width + UiStyle.Gap.lg() * 2
|
||||
}
|
||||
|
||||
override fun getIconHeight() = JBUI.scale(16)
|
||||
|
||||
override fun paintIcon(c: Component?, g: Graphics, x: Int, y: Int) {
|
||||
val g2 = g.create() as Graphics2D
|
||||
try {
|
||||
g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON)
|
||||
g2.translate(x, y)
|
||||
g2.color = bg
|
||||
g2.fillRoundRect(0, 0, iconWidth, iconHeight, iconHeight, iconHeight)
|
||||
g2.color = fg
|
||||
g2.font = JBFont.small()
|
||||
val fm = g2.fontMetrics
|
||||
val base = (iconHeight + fm.ascent - fm.descent) / 2
|
||||
g2.drawString(text, UiStyle.Gap.lg(), base)
|
||||
} finally {
|
||||
g2.dispose()
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
-12
@@ -1,22 +1,19 @@
|
||||
package ai.kilocode.client.ui
|
||||
|
||||
import com.intellij.ui.JBColor
|
||||
import com.intellij.ui.components.JBLabel
|
||||
import com.intellij.util.ui.JBUI
|
||||
import com.intellij.util.ui.UIUtil
|
||||
import java.awt.Graphics
|
||||
import java.awt.Graphics2D
|
||||
import java.awt.RenderingHints
|
||||
import java.awt.event.MouseAdapter
|
||||
import java.awt.event.MouseEvent
|
||||
import javax.swing.UIManager
|
||||
|
||||
open class PickerButton : JBLabel() {
|
||||
private var over = false
|
||||
|
||||
init {
|
||||
border = pickerBorder()
|
||||
background = picker()
|
||||
background = UiStyle.Colors.picker()
|
||||
// The custom rounded fill needs parent background around the corners.
|
||||
isOpaque = false
|
||||
addMouseListener(object : MouseAdapter() {
|
||||
@@ -33,14 +30,14 @@ open class PickerButton : JBLabel() {
|
||||
override fun updateUI() {
|
||||
super.updateUI()
|
||||
border = pickerBorder()
|
||||
background = picker()
|
||||
background = UiStyle.Colors.picker()
|
||||
}
|
||||
|
||||
override fun paintComponent(g: Graphics) {
|
||||
val g2 = g.create() as Graphics2D
|
||||
try {
|
||||
g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON)
|
||||
g2.color = if (isEnabled && over) JBUI.CurrentTheme.ActionButton.hoverBackground() else picker()
|
||||
g2.color = if (isEnabled && over) JBUI.CurrentTheme.ActionButton.hoverBackground() else UiStyle.Colors.picker()
|
||||
val arc = JBUI.scale(JBUI.getInt("Button.arc", 6))
|
||||
g2.fillRoundRect(0, 0, width, height, arc, arc)
|
||||
} finally {
|
||||
@@ -55,11 +52,5 @@ open class PickerButton : JBLabel() {
|
||||
repaint()
|
||||
}
|
||||
|
||||
private fun picker() = JBColor.lazy {
|
||||
UIManager.getColor("ComboBoxButton.background")
|
||||
?: UIManager.getColor("ComboBox.nonEditableBackground")
|
||||
?: UIUtil.getPanelBackground()
|
||||
}
|
||||
|
||||
private fun pickerBorder() = JBUI.Borders.empty(UiStyle.Gap.xs(), UiStyle.Gap.lg())
|
||||
}
|
||||
|
||||
+24
-9
@@ -1,13 +1,13 @@
|
||||
package ai.kilocode.client.settings.profile
|
||||
package ai.kilocode.client.ui
|
||||
|
||||
import ai.kilocode.client.ui.UiStyle
|
||||
import com.intellij.util.ui.JBUI
|
||||
import com.intellij.util.ui.components.BorderLayoutPanel
|
||||
import java.awt.Color
|
||||
import java.awt.Graphics
|
||||
import java.awt.Graphics2D
|
||||
import java.awt.RenderingHints
|
||||
|
||||
internal class ProfileCardPanel(
|
||||
open class RoundedContentPanel(
|
||||
top: Int,
|
||||
left: Int,
|
||||
bottom: Int = top,
|
||||
@@ -16,14 +16,14 @@ internal class ProfileCardPanel(
|
||||
|
||||
init {
|
||||
isOpaque = false
|
||||
background = UiStyle.Colors.cardBg()
|
||||
background = contentColor()
|
||||
border = JBUI.Borders.empty(top, left, bottom, right)
|
||||
}
|
||||
|
||||
override fun updateUI() {
|
||||
super.updateUI()
|
||||
isOpaque = false
|
||||
background = UiStyle.Colors.cardBg()
|
||||
background = contentColor()
|
||||
}
|
||||
|
||||
override fun paintComponent(g: Graphics) {
|
||||
@@ -33,14 +33,29 @@ internal class ProfileCardPanel(
|
||||
RenderingHints.KEY_ANTIALIASING,
|
||||
RenderingHints.VALUE_ANTIALIAS_ON,
|
||||
)
|
||||
val arc = UiStyle.Arc.component()
|
||||
g2.color = UiStyle.Colors.cardBg()
|
||||
val arc = cornerArc()
|
||||
g2.color = contentColor()
|
||||
g2.fillRoundRect(0, 0, width, height, arc, arc)
|
||||
g2.color = UiStyle.Colors.cardBorder()
|
||||
g2.drawRoundRect(0, 0, width - 1, height - 1, arc, arc)
|
||||
val line = outlineColor()
|
||||
if (line != null) {
|
||||
g2.color = line
|
||||
for (idx in 0 until outlineWidth()) {
|
||||
val w = width - idx * 2 - 1
|
||||
val h = height - idx * 2 - 1
|
||||
if (w > 0 && h > 0) g2.drawRoundRect(idx, idx, w, h, arc, arc)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
g2.dispose()
|
||||
}
|
||||
super.paintComponent(g)
|
||||
}
|
||||
|
||||
protected open fun contentColor(): Color = UiStyle.Colors.cardBg()
|
||||
|
||||
protected open fun outlineColor(): Color? = UiStyle.Colors.cardBorder()
|
||||
|
||||
protected open fun outlineWidth(): Int = JBUI.scale(1)
|
||||
|
||||
protected open fun cornerArc(): Int = UiStyle.Arc.component()
|
||||
}
|
||||
@@ -53,9 +53,36 @@ object UiStyle {
|
||||
UIManager.getColor("TextField.background") ?: UIUtil.getPanelBackground()
|
||||
}
|
||||
|
||||
/** Standard picker/combobox surface, contrasted against the default panel background by the active theme. */
|
||||
fun picker(): Color = JBColor.lazy {
|
||||
UIManager.getColor("ComboBoxButton.background")
|
||||
?: UIManager.getColor("ComboBox.nonEditableBackground")
|
||||
?: UIUtil.getPanelBackground()
|
||||
}
|
||||
|
||||
/** Filled badge surface using platform badge/info colors with a soft theme-derived fallback. */
|
||||
fun badgeBg(): Color = JBColor.lazy {
|
||||
UIManager.getColor("Badge.background")
|
||||
?: UIManager.getColor("Label.infoBackground")
|
||||
?: blend(cardBg(), fg(), 0.16f)
|
||||
}
|
||||
|
||||
/** Filled badge text color paired with [badgeBg]. */
|
||||
fun badgeFg(): Color = JBColor(Color.BLACK, UIUtil.getLabelForeground())
|
||||
|
||||
/** Card border color shared across profile cards. */
|
||||
fun cardBorder(): Color = JBColor.namedColor("Component.borderColor", JBColor.border())
|
||||
|
||||
/**
|
||||
* Floating panel background: white in light themes, black in dark themes.
|
||||
* Used for account switcher popup panels and any overlay panels that need
|
||||
* a high-contrast base distinct from the standard editor/sidebar background.
|
||||
*/
|
||||
fun floatingPanel(): Color = JBColor.namedColor(
|
||||
"Kilo.FloatingPanel.background",
|
||||
JBColor(java.awt.Color.WHITE, java.awt.Color.BLACK),
|
||||
)
|
||||
|
||||
fun errorLabelForeground(): Color = JBColor.namedColor("Label.errorForeground", UIUtil.getErrorForeground())
|
||||
|
||||
fun warningLabelForeground(): Color = JBColor.lazy {
|
||||
|
||||
@@ -6,6 +6,8 @@ session.connection.retry=Try again
|
||||
session.connection.warning.config=Configuration warnings
|
||||
|
||||
session.empty.welcome=Kilo Code is an AI coding assistant. Ask it to build features, fix bugs, or explain your codebase.
|
||||
session.account.balance=Balance: {0}
|
||||
session.account.switcher=Switch account
|
||||
session.empty.loading=Loading...
|
||||
session.empty.recent=RECENT
|
||||
session.showHistory=Show History
|
||||
|
||||
+76
-1
@@ -11,10 +11,13 @@ import ai.kilocode.client.session.ui.ConnectionPanel
|
||||
import ai.kilocode.client.session.ui.EmptySessionPanel
|
||||
import ai.kilocode.client.session.ui.LoadingPanel
|
||||
import ai.kilocode.client.session.ui.prompt.PromptPanel
|
||||
import ai.kilocode.client.session.ui.account.SessionAccountOverlay
|
||||
import ai.kilocode.client.session.ui.SessionMessageListPanel
|
||||
import ai.kilocode.client.session.ui.SessionRootPanel
|
||||
import ai.kilocode.client.session.ui.header.SessionHeaderPanel
|
||||
import ai.kilocode.client.session.ui.style.SessionUiStyle
|
||||
import ai.kilocode.client.session.controller.SessionControllerEvent
|
||||
import com.intellij.util.ui.JBUI
|
||||
import ai.kilocode.client.session.views.PermissionView
|
||||
import ai.kilocode.client.session.views.question.QuestionView
|
||||
import ai.kilocode.rpc.dto.MessageWithPartsDto
|
||||
@@ -42,7 +45,7 @@ class SessionUiLayoutTest : SessionUiTestBase() {
|
||||
|
||||
assertSame(root.content, stack.parent)
|
||||
assertSame(stack, connection.parent)
|
||||
assertEquals(1, root.overlay.componentCount)
|
||||
assertTrue(root.overlay.components.any { it is SessionAccountOverlay })
|
||||
assertEquals(listOf(connection, prompt), stack.components.toList())
|
||||
}
|
||||
|
||||
@@ -349,4 +352,76 @@ class SessionUiLayoutTest : SessionUiTestBase() {
|
||||
meta = PermissionMeta(raw = emptyMap()),
|
||||
)
|
||||
)
|
||||
|
||||
// --- account overlay layout tests ---
|
||||
|
||||
fun `test account overlay is registered in root overlay layer`() {
|
||||
val root = find<SessionRootPanel>(ui)
|
||||
val overlay = find<SessionAccountOverlay>(ui)
|
||||
|
||||
assertSame(root.overlay, overlay.parent)
|
||||
}
|
||||
|
||||
fun `test account overlay hidden before recents complete`() {
|
||||
rpc.recentGate = kotlinx.coroutines.CompletableDeferred()
|
||||
rpc.recent.add(session("ses_1"))
|
||||
ui = newUi(displayMs = 1_000)
|
||||
|
||||
settleShort(100)
|
||||
|
||||
val overlay = find<SessionAccountOverlay>(ui)
|
||||
assertFalse(overlay.isVisible)
|
||||
|
||||
rpc.recentGate!!.complete(Unit)
|
||||
}
|
||||
|
||||
fun `test account overlay shows after recents complete`() {
|
||||
rpc.recent.add(session("ses_1"))
|
||||
ui = newUi(displayMs = 1_000)
|
||||
|
||||
settle()
|
||||
|
||||
val overlay = find<SessionAccountOverlay>(ui)
|
||||
assertTrue(overlay.isVisible)
|
||||
}
|
||||
|
||||
fun `test account overlay hides after first prompt`() {
|
||||
rpc.recent.add(session("ses_1"))
|
||||
ui = newUi(displayMs = 1_000)
|
||||
settle()
|
||||
|
||||
val overlay = find<SessionAccountOverlay>(ui)
|
||||
assertTrue(overlay.isVisible)
|
||||
|
||||
com.intellij.openapi.application.ApplicationManager.getApplication().invokeAndWait {
|
||||
controller().prompt("hello")
|
||||
}
|
||||
settle()
|
||||
|
||||
assertFalse(overlay.isVisible)
|
||||
}
|
||||
|
||||
fun `test explicit session does not show overlay`() {
|
||||
ui = newUi(id = "ses_test")
|
||||
settle()
|
||||
|
||||
val overlay = find<SessionAccountOverlay>(ui)
|
||||
assertFalse(overlay.isVisible)
|
||||
}
|
||||
|
||||
fun `test account overlay uses prompt panel top and right insets`() {
|
||||
rpc.recent.add(session("ses_1"))
|
||||
ui = newUi(displayMs = 1_000)
|
||||
settle()
|
||||
layout()
|
||||
|
||||
val root = find<SessionRootPanel>(ui)
|
||||
val overlay = find<SessionAccountOverlay>(ui)
|
||||
val top = JBUI.scale(SessionUiStyle.View.Prompt.PANEL_VERTICAL_PADDING)
|
||||
val right = JBUI.scale(SessionUiStyle.View.Prompt.PANEL_HORIZONTAL_PADDING)
|
||||
|
||||
assertTrue(overlay.isVisible)
|
||||
assertEquals(top, overlay.y)
|
||||
assertEquals(root.overlay.width - overlay.width - right, overlay.x)
|
||||
}
|
||||
}
|
||||
|
||||
+139
@@ -2,6 +2,11 @@ package ai.kilocode.client.session.controller
|
||||
|
||||
import ai.kilocode.client.session.SessionRef
|
||||
import ai.kilocode.client.session.model.SessionState
|
||||
import ai.kilocode.rpc.dto.KiloAppStateDto
|
||||
import ai.kilocode.rpc.dto.KiloAppStatusDto
|
||||
import ai.kilocode.rpc.dto.ProfileBalanceDto
|
||||
import ai.kilocode.rpc.dto.ProfileDto
|
||||
import ai.kilocode.rpc.dto.ProfileOrganizationDto
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
|
||||
class ViewSwitchingTest : SessionControllerTestBase() {
|
||||
@@ -62,6 +67,8 @@ class ViewSwitchingTest : SessionControllerTestBase() {
|
||||
|
||||
assertTrue(rpc.recentCalls.contains("/test" to SessionController.RECENT_LIMIT))
|
||||
assertControllerEvents("""
|
||||
AccountOverlayChanged hide
|
||||
AccountOverlayChanged show loggedIn=false
|
||||
AppChanged
|
||||
WorkspaceChanged
|
||||
WorkspaceReady
|
||||
@@ -79,6 +86,8 @@ class ViewSwitchingTest : SessionControllerTestBase() {
|
||||
|
||||
assertTrue(rpc.recentCalls.contains("/test" to SessionController.RECENT_LIMIT))
|
||||
assertControllerEvents("""
|
||||
AccountOverlayChanged hide
|
||||
AccountOverlayChanged show loggedIn=false
|
||||
AppChanged
|
||||
WorkspaceChanged
|
||||
WorkspaceReady
|
||||
@@ -95,6 +104,7 @@ class ViewSwitchingTest : SessionControllerTestBase() {
|
||||
|
||||
assertTrue(rpc.recentCalls.isEmpty())
|
||||
assertControllerEvents("""
|
||||
AccountOverlayChanged hide
|
||||
AppChanged
|
||||
WorkspaceChanged
|
||||
ViewChanged progress
|
||||
@@ -347,4 +357,133 @@ class ViewSwitchingTest : SessionControllerTestBase() {
|
||||
version = "1",
|
||||
time = ai.kilocode.rpc.dto.SessionTimeDto(created = 1.0, updated = 2.0),
|
||||
)
|
||||
|
||||
// --- account overlay controller tests ---
|
||||
|
||||
fun `test empty session with workspace ready emits account overlay show`() {
|
||||
projectRpc.state.value = workspaceReady()
|
||||
rpc.recent.add(session("ses_1"))
|
||||
val m = controller()
|
||||
val events = collect(m)
|
||||
|
||||
flush()
|
||||
|
||||
assertTrue(events.any { it is SessionControllerEvent.AccountOverlayChanged.Show })
|
||||
val show = events.filterIsInstance<SessionControllerEvent.AccountOverlayChanged.Show>().last()
|
||||
assertEquals("AccountOverlayChanged show loggedIn=false", show.toString())
|
||||
}
|
||||
|
||||
fun `test empty session overlay show includes logged in profile`() {
|
||||
projectRpc.state.value = workspaceReady()
|
||||
rpc.recent.add(session("ses_1"))
|
||||
val prof = ProfileDto(
|
||||
email = "user@example.com",
|
||||
name = "Test User",
|
||||
balance = ProfileBalanceDto(10.0),
|
||||
)
|
||||
appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY, profile = prof)
|
||||
val m = controller()
|
||||
val events = collect(m)
|
||||
|
||||
flush()
|
||||
|
||||
val show = events.filterIsInstance<SessionControllerEvent.AccountOverlayChanged.Show>().last()
|
||||
assertEquals("AccountOverlayChanged show loggedIn=true", show.toString())
|
||||
assertEquals(prof.email, show.account.profile?.email)
|
||||
}
|
||||
|
||||
fun `test first prompt hides overlay`() {
|
||||
projectRpc.state.value = workspaceReady()
|
||||
rpc.recent.add(session("ses_1"))
|
||||
val m = controller()
|
||||
flush()
|
||||
val events = collect(m)
|
||||
|
||||
edt { m.prompt("hello") }
|
||||
flush()
|
||||
|
||||
assertTrue(events.any { it is SessionControllerEvent.AccountOverlayChanged.Hide })
|
||||
assertFalse(events.filterIsInstance<SessionControllerEvent.AccountOverlayChanged.Show>().any { it.account.profile != null })
|
||||
}
|
||||
|
||||
fun `test explicit local session load never shows overlay`() {
|
||||
projectRpc.state.value = workspaceReady()
|
||||
rpc.recent.add(session("ses_1"))
|
||||
val m = controller("ses_test")
|
||||
val events = collect(m)
|
||||
|
||||
flush()
|
||||
|
||||
assertFalse(events.any { it is SessionControllerEvent.AccountOverlayChanged.Show })
|
||||
}
|
||||
|
||||
fun `test explicit cloud import never shows overlay`() {
|
||||
projectRpc.state.value = workspaceReady()
|
||||
rpc.importedCloudSession = session("ses_imported")
|
||||
rpc.recent.add(session("ses_1"))
|
||||
val m = controller("cloud:cloud_1")
|
||||
val events = collect(m)
|
||||
|
||||
flush()
|
||||
|
||||
assertFalse(events.any { it is SessionControllerEvent.AccountOverlayChanged.Show })
|
||||
}
|
||||
|
||||
fun `test app profile change refreshes overlay while allowed`() {
|
||||
projectRpc.state.value = workspaceReady()
|
||||
rpc.recent.add(session("ses_1"))
|
||||
val m = controller()
|
||||
val events = collect(m)
|
||||
flush()
|
||||
|
||||
val prof = ProfileDto(email = "user@example.com", balance = ProfileBalanceDto(20.0))
|
||||
appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY, profile = prof)
|
||||
flush()
|
||||
|
||||
val shows = events.filterIsInstance<SessionControllerEvent.AccountOverlayChanged.Show>()
|
||||
assertTrue(shows.isNotEmpty())
|
||||
assertTrue(shows.last().account.profile?.email == "user@example.com")
|
||||
}
|
||||
|
||||
fun `test selecting personal account emits switching overlay`() {
|
||||
projectRpc.state.value = workspaceReady()
|
||||
rpc.recent.add(session("ses_1"))
|
||||
appRpc.state.value = KiloAppStateDto(
|
||||
KiloAppStatusDto.READY,
|
||||
profile = ProfileDto(
|
||||
email = "user@example.com",
|
||||
currentOrgId = "org_1",
|
||||
organizations = listOf(ProfileOrganizationDto("org_1", "Kilo", "OWNER")),
|
||||
),
|
||||
)
|
||||
val m = controller()
|
||||
val events = collect(m)
|
||||
flush()
|
||||
events.clear()
|
||||
|
||||
edt { m.selectOrganization(null) }
|
||||
flush()
|
||||
|
||||
val show = events.filterIsInstance<SessionControllerEvent.AccountOverlayChanged.Show>()
|
||||
.first { it.account.switching }
|
||||
assertTrue(show.account.switching)
|
||||
assertNull(show.account.targetOrgId)
|
||||
assertEquals(null, appRpc.orgSelections.last())
|
||||
}
|
||||
|
||||
fun `test replay includes current overlay event`() {
|
||||
projectRpc.state.value = workspaceReady()
|
||||
rpc.recent.add(session("ses_1"))
|
||||
val m = controller()
|
||||
flush()
|
||||
|
||||
// Add a new listener after initial events are done
|
||||
val replayed = collect(m)
|
||||
|
||||
assertTrue(replayed.any { it is SessionControllerEvent.AccountOverlayChanged.Show })
|
||||
}
|
||||
|
||||
fun `test overlay hide event has correct string`() {
|
||||
assertEquals("AccountOverlayChanged hide", SessionControllerEvent.AccountOverlayChanged.Hide.toString())
|
||||
}
|
||||
}
|
||||
|
||||
+320
@@ -0,0 +1,320 @@
|
||||
package ai.kilocode.client.session.ui.account
|
||||
|
||||
import ai.kilocode.client.session.controller.SessionControllerEvent
|
||||
import ai.kilocode.client.session.controller.SessionControllerEvent.AccountOverlaySnapshot
|
||||
import ai.kilocode.client.session.controller.SessionControllerTestBase
|
||||
import ai.kilocode.client.ui.FilledBadgeIcon
|
||||
import ai.kilocode.client.ui.UiStyle
|
||||
import ai.kilocode.rpc.dto.KiloAppStatusDto
|
||||
import ai.kilocode.rpc.dto.ProfileBalanceDto
|
||||
import ai.kilocode.rpc.dto.ProfileDto
|
||||
import ai.kilocode.rpc.dto.ProfileOrganizationDto
|
||||
import com.intellij.icons.AllIcons
|
||||
|
||||
@Suppress("UnstableApiUsage")
|
||||
class SessionAccountOverlayTest : SessionControllerTestBase() {
|
||||
|
||||
private lateinit var panel: SessionAccountOverlay
|
||||
private val selected = mutableListOf<String?>()
|
||||
private var loginCalls = 0
|
||||
private var profileCalls = 0
|
||||
|
||||
override fun setUp() {
|
||||
super.setUp()
|
||||
panel = SessionAccountOverlay(
|
||||
select = { org -> selected.add(org) },
|
||||
login = { loginCalls++ },
|
||||
profile = { profileCalls++ },
|
||||
)
|
||||
}
|
||||
|
||||
private fun show(snap: AccountOverlaySnapshot) {
|
||||
edt { panel.onEvent(SessionControllerEvent.AccountOverlayChanged.Show(snap)) }
|
||||
}
|
||||
|
||||
private fun hide() {
|
||||
edt { panel.onEvent(SessionControllerEvent.AccountOverlayChanged.Hide) }
|
||||
}
|
||||
|
||||
private fun snap(profile: ProfileDto?) =
|
||||
AccountOverlaySnapshot(status = KiloAppStatusDto.READY, profile = profile)
|
||||
|
||||
private fun profile(
|
||||
email: String = "user@example.com",
|
||||
name: String? = null,
|
||||
organizations: List<ProfileOrganizationDto> = emptyList(),
|
||||
balance: ProfileBalanceDto? = null,
|
||||
currentOrgId: String? = null,
|
||||
) = ProfileDto(
|
||||
email = email,
|
||||
name = name,
|
||||
organizations = organizations,
|
||||
balance = balance,
|
||||
currentOrgId = currentOrgId,
|
||||
)
|
||||
|
||||
private fun org(id: String, name: String, role: String = "MEMBER") =
|
||||
ProfileOrganizationDto(id = id, name = name, role = role)
|
||||
|
||||
// --- test 1: logged-out state shows login prompt ---
|
||||
|
||||
fun `test logged out state is visible with login button`() {
|
||||
show(snap(null))
|
||||
|
||||
assertTrue(panel.isVisible)
|
||||
assertTrue(panel.loggedOutVisible())
|
||||
}
|
||||
|
||||
// --- test 2: logged-in personal account shows picker title ---
|
||||
|
||||
fun `test logged in personal account shows picker title`() {
|
||||
val prof = profile(
|
||||
email = "user@example.com",
|
||||
name = "Test User",
|
||||
balance = ProfileBalanceDto(10.0),
|
||||
)
|
||||
show(snap(prof))
|
||||
|
||||
assertTrue(panel.isVisible)
|
||||
assertTrue(panel.loggedInVisible())
|
||||
assertTrue(panel.pickerVisible())
|
||||
assertEquals("Personal Account", panel.accountTitle())
|
||||
}
|
||||
|
||||
fun `test logged in with email fallback still shows personal account title`() {
|
||||
val prof = profile(email = "user@example.com")
|
||||
show(snap(prof))
|
||||
|
||||
assertEquals("Personal Account", panel.accountTitle())
|
||||
}
|
||||
|
||||
// --- test 3: logged-in org account shows org title in picker ---
|
||||
|
||||
fun `test logged in org account shows org title in picker`() {
|
||||
val acme = org("org_1", "Acme", "MEMBER")
|
||||
val prof = profile(
|
||||
email = "user@example.com",
|
||||
organizations = listOf(acme),
|
||||
balance = ProfileBalanceDto(25.0),
|
||||
currentOrgId = "org_1",
|
||||
)
|
||||
show(snap(prof))
|
||||
|
||||
assertTrue(panel.isVisible)
|
||||
assertTrue(panel.loggedInVisible())
|
||||
assertTrue(panel.pickerVisible())
|
||||
assertEquals("Acme", panel.accountTitle())
|
||||
// personal + acme = 2 choices
|
||||
assertEquals(2, panel.choiceCount())
|
||||
// selected index is 1 (org_1 is the second item)
|
||||
assertEquals(1, panel.selectedIndex())
|
||||
}
|
||||
|
||||
// --- test 4: programmatic update does not call select callback ---
|
||||
|
||||
fun `test programmatic update does not call select callback`() {
|
||||
val acme = org("org_1", "Acme")
|
||||
val prof = profile(
|
||||
email = "user@example.com",
|
||||
organizations = listOf(acme),
|
||||
currentOrgId = null,
|
||||
)
|
||||
// Show with personal account selected
|
||||
show(snap(prof))
|
||||
selected.clear()
|
||||
|
||||
// Show again with same profile - no user selection
|
||||
show(snap(prof))
|
||||
|
||||
assertEquals(0, selected.size)
|
||||
}
|
||||
|
||||
// --- test 5: switching disables picker ---
|
||||
|
||||
fun `test switching true disables picker`() {
|
||||
val acme = org("org_1", "Acme")
|
||||
val prof = profile(
|
||||
email = "user@example.com",
|
||||
organizations = listOf(acme),
|
||||
currentOrgId = null,
|
||||
)
|
||||
val switchingSnap = AccountOverlaySnapshot(
|
||||
status = KiloAppStatusDto.READY,
|
||||
profile = prof,
|
||||
switching = true,
|
||||
targetOrgId = "org_1",
|
||||
)
|
||||
show(switchingSnap)
|
||||
|
||||
assertFalse(panel.pickerEnabled())
|
||||
}
|
||||
|
||||
fun `test switching false enables picker`() {
|
||||
val acme = org("org_1", "Acme")
|
||||
val prof = profile(
|
||||
email = "user@example.com",
|
||||
organizations = listOf(acme),
|
||||
currentOrgId = null,
|
||||
)
|
||||
show(snap(prof))
|
||||
|
||||
assertTrue(panel.pickerEnabled())
|
||||
}
|
||||
|
||||
// --- test 6: switching with targetOrgId shows the target account title ---
|
||||
|
||||
fun `test switching with targetOrgId shows target account title`() {
|
||||
val acme = org("org_1", "Acme")
|
||||
val prof = profile(
|
||||
email = "user@example.com",
|
||||
organizations = listOf(acme),
|
||||
currentOrgId = null,
|
||||
)
|
||||
val switchingSnap = AccountOverlaySnapshot(
|
||||
status = KiloAppStatusDto.READY,
|
||||
profile = prof,
|
||||
switching = true,
|
||||
targetOrgId = "org_1",
|
||||
)
|
||||
show(switchingSnap)
|
||||
|
||||
// Should display the target org while switching
|
||||
assertEquals("Acme", panel.accountTitle())
|
||||
assertFalse(panel.pickerEnabled())
|
||||
}
|
||||
|
||||
fun `test switching to personal account shows personal account title`() {
|
||||
val acme = org("org_1", "Acme")
|
||||
val prof = profile(
|
||||
email = "user@example.com",
|
||||
organizations = listOf(acme),
|
||||
currentOrgId = "org_1",
|
||||
)
|
||||
val switchingSnap = AccountOverlaySnapshot(
|
||||
status = KiloAppStatusDto.READY,
|
||||
profile = prof,
|
||||
switching = true,
|
||||
targetOrgId = null,
|
||||
)
|
||||
show(switchingSnap)
|
||||
|
||||
assertEquals("Personal Account", panel.accountTitle())
|
||||
assertFalse(panel.pickerEnabled())
|
||||
}
|
||||
|
||||
fun `test account switcher uses card background and border`() {
|
||||
val prof = profile(email = "user@example.com")
|
||||
show(snap(prof))
|
||||
|
||||
assertEquals(UiStyle.Colors.cardBg(), panel.panelBackground())
|
||||
assertEquals(UiStyle.Colors.cardBorder(), panel.panelBorderColor())
|
||||
}
|
||||
|
||||
// --- test 7: transient null profile keeps existing logged-in content ---
|
||||
|
||||
fun `test transient null profile keeps logged in card`() {
|
||||
val prof = profile(email = "user@example.com", name = "Test User")
|
||||
show(snap(prof))
|
||||
assertTrue(panel.loggedInVisible())
|
||||
assertEquals("Personal Account", panel.accountTitle())
|
||||
|
||||
// Show transient null (pending switch)
|
||||
val transientSnap = AccountOverlaySnapshot(
|
||||
status = KiloAppStatusDto.READY,
|
||||
profile = null,
|
||||
transient = true,
|
||||
)
|
||||
show(transientSnap)
|
||||
|
||||
// Should remain visible and logged-in, not flash to logged-out
|
||||
assertTrue(panel.isVisible)
|
||||
assertTrue(panel.loggedInVisible())
|
||||
}
|
||||
|
||||
// --- test 8: hide event hides component ---
|
||||
|
||||
fun `test hide event hides component`() {
|
||||
val prof = profile(email = "user@example.com")
|
||||
show(snap(prof))
|
||||
assertTrue(panel.isVisible)
|
||||
|
||||
hide()
|
||||
|
||||
assertFalse(panel.isVisible)
|
||||
}
|
||||
|
||||
// --- test 9: renderer uses check icon for active account ---
|
||||
|
||||
fun `test renderer active account uses check icon`() {
|
||||
val choice = AccountChoice("org_1", "Acme")
|
||||
val renderer = AccountPickerRenderer { "org_1" }
|
||||
|
||||
assertSame(AccountPickerRenderer.checked, renderer.icon(choice))
|
||||
}
|
||||
|
||||
// --- test 10: renderer uses empty icon for inactive account ---
|
||||
|
||||
fun `test renderer inactive account reserves icon space`() {
|
||||
val choice = AccountChoice(null, "Personal Account")
|
||||
val renderer = AccountPickerRenderer { "org_1" }
|
||||
|
||||
assertSame(AccountPickerRenderer.empty, renderer.icon(choice))
|
||||
assertEquals(AllIcons.Actions.Checked.iconWidth, renderer.icon(choice).iconWidth)
|
||||
}
|
||||
|
||||
// --- test 11: balance badge appears when profile has balance ---
|
||||
|
||||
fun `test logged in account shows balance badge`() {
|
||||
val prof = profile(balance = ProfileBalanceDto(10.0))
|
||||
show(snap(prof))
|
||||
|
||||
assertTrue(panel.balanceVisible())
|
||||
assertTrue(panel.balanceIcon() is FilledBadgeIcon)
|
||||
assertEquals("\$10.00", panel.balanceText())
|
||||
}
|
||||
|
||||
// --- test 12: balance badge hides when balance is missing ---
|
||||
|
||||
fun `test logged in account hides balance badge without balance`() {
|
||||
show(snap(profile(balance = null)))
|
||||
|
||||
assertFalse(panel.balanceVisible())
|
||||
assertNull(panel.balanceIcon())
|
||||
}
|
||||
|
||||
// --- test 13: balance badge updates when profile balance changes ---
|
||||
|
||||
fun `test balance badge updates retained label`() {
|
||||
show(snap(profile(balance = ProfileBalanceDto(10.0))))
|
||||
assertEquals("\$10.00", panel.balanceText())
|
||||
|
||||
show(snap(profile(balance = ProfileBalanceDto(25.0))))
|
||||
|
||||
assertTrue(panel.balanceVisible())
|
||||
assertEquals("\$25.00", panel.balanceText())
|
||||
}
|
||||
|
||||
// --- test 14: profile button uses toolbar icon and invokes callback ---
|
||||
|
||||
fun `test profile button uses profile icon and opens settings`() {
|
||||
show(snap(profile(email = "user@example.com")))
|
||||
|
||||
assertSame(AllIcons.General.User, panel.profileIcon())
|
||||
panel.clickProfile()
|
||||
|
||||
assertEquals(1, profileCalls)
|
||||
}
|
||||
|
||||
// --- test 15: transient null profile keeps logged in balance badge ---
|
||||
|
||||
fun `test transient null profile keeps logged in balance badge`() {
|
||||
show(snap(profile(balance = ProfileBalanceDto(10.0))))
|
||||
val icon = panel.balanceIcon()
|
||||
|
||||
show(AccountOverlaySnapshot(status = KiloAppStatusDto.READY, profile = null, transient = true))
|
||||
|
||||
assertTrue(panel.loggedInVisible())
|
||||
assertTrue(panel.balanceVisible())
|
||||
assertSame(icon, panel.balanceIcon())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user