mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-10-30 19:32:39 +08:00
fix(kilo-jetbrains): show settings progress as overlay
This commit is contained in:
+9
-149
@@ -1,156 +1,16 @@
|
||||
package ai.kilocode.client.session.ui
|
||||
|
||||
import ai.kilocode.client.ui.UiStyle
|
||||
import ai.kilocode.client.ui.layout.HAlign
|
||||
import ai.kilocode.client.ui.layout.VAlign
|
||||
import ai.kilocode.client.ui.layout.align
|
||||
import com.intellij.util.concurrency.annotations.RequiresEdt
|
||||
import com.intellij.util.ui.JBDimension
|
||||
import com.intellij.util.ui.components.BorderLayoutPanel
|
||||
import java.awt.BorderLayout
|
||||
import java.awt.Container
|
||||
import java.awt.Dimension
|
||||
import java.awt.Rectangle
|
||||
import javax.swing.JComponent
|
||||
import javax.swing.JLayeredPane
|
||||
import javax.swing.JPanel
|
||||
import ai.kilocode.client.ui.LayeredOverlayPanel
|
||||
|
||||
class SessionRootPanel : JLayeredPane() {
|
||||
class SessionRootPanel(
|
||||
private val sessionOverlay: Overlay = Overlay(),
|
||||
private val sessionBlocker: Blocker = Blocker(),
|
||||
) : LayeredOverlayPanel(overlay = sessionOverlay, blocker = sessionBlocker) {
|
||||
override val overlay: Overlay get() = sessionOverlay
|
||||
|
||||
val content: JPanel = BorderLayoutPanel()
|
||||
override val blocker: Blocker get() = sessionBlocker
|
||||
|
||||
val overlay = Overlay()
|
||||
class Overlay : LayeredOverlayPanel.Overlay()
|
||||
|
||||
val blocker = Blocker()
|
||||
|
||||
init {
|
||||
layout = null
|
||||
add(content)
|
||||
setLayer(content, DEFAULT_LAYER)
|
||||
add(overlay)
|
||||
setLayer(overlay, PALETTE_LAYER)
|
||||
add(blocker)
|
||||
setLayer(blocker, MODAL_LAYER)
|
||||
blocker.isVisible = false
|
||||
}
|
||||
|
||||
fun addOverlay(child: JComponent, bounds: (JPanel, JComponent) -> Rectangle) {
|
||||
overlay.addOverlay(child, bounds)
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
fun setModalContent(child: JComponent?) {
|
||||
blocker.removeAll()
|
||||
if (child != null) blocker.add(child.align(HAlign.CENTER, VAlign.CENTER), BorderLayout.CENTER)
|
||||
blocker.isVisible = child != null
|
||||
if (child != null) blocker.requestFocusInWindow()
|
||||
invalidate()
|
||||
blocker.invalidate()
|
||||
child?.invalidate()
|
||||
if (width > 0 && height > 0) {
|
||||
doLayout()
|
||||
child?.let(::layoutTree)
|
||||
}
|
||||
blocker.revalidate()
|
||||
blocker.repaint()
|
||||
revalidate()
|
||||
repaint()
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
fun setBlocked(value: Boolean) {
|
||||
blocker.isVisible = value
|
||||
if (value) blocker.requestFocusInWindow()
|
||||
invalidate()
|
||||
blocker.invalidate()
|
||||
if (width > 0 && height > 0) doLayout()
|
||||
revalidate()
|
||||
repaint()
|
||||
}
|
||||
|
||||
override fun doLayout() {
|
||||
components
|
||||
.sortedBy { getLayer(it) }
|
||||
.forEach { child ->
|
||||
child.setBounds(0, 0, width, height)
|
||||
child.doLayout()
|
||||
}
|
||||
}
|
||||
|
||||
override fun getPreferredSize(): Dimension {
|
||||
// Only content and overlay contribute to preferred size; blocker is invisible by default.
|
||||
val w = listOf(content, overlay).maxOfOrNull { it.preferredSize.width } ?: 0
|
||||
val h = listOf(content, overlay).maxOfOrNull { it.preferredSize.height } ?: 0
|
||||
return JBDimension(w, h)
|
||||
}
|
||||
|
||||
class Overlay : BorderLayoutPanel() {
|
||||
|
||||
private val items = linkedMapOf<JComponent, (JPanel, JComponent) -> Rectangle>()
|
||||
|
||||
init {
|
||||
layout = null
|
||||
// The overlay must let mouse events fall through outside visible children.
|
||||
isOpaque = false
|
||||
}
|
||||
|
||||
fun addOverlay(child: JComponent, bounds: (JPanel, JComponent) -> Rectangle) {
|
||||
items[child] = bounds
|
||||
add(child)
|
||||
}
|
||||
|
||||
override fun contains(x: Int, y: Int): Boolean {
|
||||
for (child in components) {
|
||||
if (child.isVisible && child.bounds.contains(x, y)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
override fun doLayout() {
|
||||
items.forEach { (child, bounds) ->
|
||||
child.bounds = bounds(this, child)
|
||||
child.doLayout()
|
||||
}
|
||||
}
|
||||
|
||||
override fun getPreferredSize(): Dimension {
|
||||
val pref = super.getPreferredSize()
|
||||
val w = maxOf(pref.width, components.maxOfOrNull { it.preferredSize.width } ?: 0)
|
||||
val h = maxOf(pref.height, components.maxOfOrNull { it.preferredSize.height } ?: 0)
|
||||
return JBDimension(w, h)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Full-area blocking overlay rendered above the scroll overlay at MODAL_LAYER.
|
||||
* When visible: consumes all mouse events across the full panel area.
|
||||
* When hidden: passes all mouse events through (isVisible=false).
|
||||
*/
|
||||
class Blocker : JPanel() {
|
||||
init {
|
||||
layout = BorderLayout()
|
||||
isFocusable = true
|
||||
}
|
||||
|
||||
override fun updateUI() {
|
||||
super.updateUI()
|
||||
background = UiStyle.Colors.bg()
|
||||
isOpaque = true
|
||||
}
|
||||
|
||||
override fun contains(x: Int, y: Int): Boolean {
|
||||
if (!isVisible) return false
|
||||
return super.contains(x, y)
|
||||
}
|
||||
|
||||
override fun doLayout() {
|
||||
super.doLayout()
|
||||
components.forEach { layoutTree(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun layoutTree(comp: java.awt.Component) {
|
||||
comp.doLayout()
|
||||
if (comp is Container) comp.components.forEach { layoutTree(it) }
|
||||
class Blocker : LayeredOverlayPanel.Blocker()
|
||||
}
|
||||
|
||||
+2
-1
@@ -3,6 +3,7 @@ package ai.kilocode.client.settings.models
|
||||
import ai.kilocode.client.plugin.KiloBundle
|
||||
import com.intellij.openapi.application.ApplicationManager
|
||||
import com.intellij.openapi.application.ModalityState
|
||||
import com.intellij.openapi.options.Configurable
|
||||
import com.intellij.openapi.project.ProjectManager
|
||||
import com.intellij.openapi.options.SearchableConfigurable
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -11,7 +12,7 @@ import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import javax.swing.JComponent
|
||||
|
||||
class ModelsConfigurable : SearchableConfigurable {
|
||||
class ModelsConfigurable : SearchableConfigurable, Configurable.NoScroll {
|
||||
private var ui: ModelsSettingsUi? = null
|
||||
private var scope: CoroutineScope? = null
|
||||
|
||||
|
||||
+96
-113
@@ -8,11 +8,12 @@ import ai.kilocode.client.session.ui.model.ModelPicker
|
||||
import ai.kilocode.client.session.ui.model.ModelText
|
||||
import ai.kilocode.client.settings.profile.UserProfileConfigurable
|
||||
import ai.kilocode.client.settings.profile.edt
|
||||
import ai.kilocode.client.settings.ui.SettingsBannerKind
|
||||
import ai.kilocode.client.settings.ui.SettingsContentPanel
|
||||
import ai.kilocode.client.settings.ui.SettingsPanel
|
||||
import ai.kilocode.client.settings.ui.SettingsRow
|
||||
import ai.kilocode.client.settings.ui.SettingsRows
|
||||
import ai.kilocode.client.ui.UiStyle
|
||||
import ai.kilocode.client.ui.layout.HAlign
|
||||
import ai.kilocode.client.ui.layout.Stack
|
||||
import ai.kilocode.client.ui.layout.VAlign
|
||||
import ai.kilocode.client.ui.layout.align
|
||||
import ai.kilocode.rpc.dto.AgentDto
|
||||
@@ -29,48 +30,29 @@ import com.intellij.openapi.options.ConfigurableWithId
|
||||
import com.intellij.openapi.options.ShowSettingsUtil
|
||||
import com.intellij.openapi.options.ex.Settings
|
||||
import com.intellij.openapi.project.ProjectManager
|
||||
import com.intellij.ui.EditorNotificationPanel
|
||||
import com.intellij.ui.InlineBanner
|
||||
import com.intellij.ui.TitledSeparator
|
||||
import com.intellij.ui.components.JBLabel
|
||||
import com.intellij.ui.components.JBScrollPane
|
||||
import com.intellij.util.concurrency.annotations.RequiresEdt
|
||||
import com.intellij.util.ui.UIUtil
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.awt.BorderLayout
|
||||
import java.util.function.Predicate
|
||||
import javax.swing.JComponent
|
||||
import javax.swing.JPanel
|
||||
|
||||
internal class ModelsSettingsUi(
|
||||
private val cs: CoroutineScope,
|
||||
private val app: KiloAppService = service(),
|
||||
private val workspaces: KiloWorkspaceService = service(),
|
||||
private val directory: String? = null,
|
||||
) : JPanel(BorderLayout()) {
|
||||
) : SettingsPanel() {
|
||||
|
||||
private val status = JBLabel(KiloBundle.message("settings.models.loading"))
|
||||
private val banner = InlineBanner(
|
||||
KiloBundle.message("settings.models.login.message"),
|
||||
EditorNotificationPanel.Status.Warning,
|
||||
).showCloseButton(false)
|
||||
private val content = Stack.vertical()
|
||||
private val rows = SettingsRows()
|
||||
private val modes = SettingsRows()
|
||||
private val defaults = ModelSettingPicker()
|
||||
private val small = ModelSettingPicker()
|
||||
private val subagent = ModelSettingPicker()
|
||||
private val variant = ReasoningPicker()
|
||||
private val variantRow = SettingsRow(
|
||||
KiloBundle.message("settings.models.subagentVariant.title"),
|
||||
KiloBundle.message("settings.models.subagentVariant.description"),
|
||||
variant.align(HAlign.RIGHT, VAlign.CENTER),
|
||||
)
|
||||
private val pickers = mutableMapOf<String, ModelSettingPicker>()
|
||||
private val form = ModelsSettingsContent(app, ::update, ::selectSubagent)
|
||||
private val defaults = form.defaults
|
||||
private val small = form.small
|
||||
private val subagent = form.subagent
|
||||
private val variant = form.variant
|
||||
private val variantRow = form.variantRow
|
||||
private val pickers = form.pickers
|
||||
private val jobs = mutableListOf<Job>()
|
||||
|
||||
private var baseline = ModelsDraft()
|
||||
@@ -87,50 +69,7 @@ internal class ModelsSettingsUi(
|
||||
private var pending: ModelsDraft? = null
|
||||
|
||||
init {
|
||||
status.foreground = UIUtil.getContextHelpForeground()
|
||||
defaults.picker.onSelect = { update { copy(model = it.key) } }
|
||||
defaults.picker.onClear = { update { copy(model = null) } }
|
||||
small.picker.onSelect = { update { copy(small = it.key) } }
|
||||
small.picker.onClear = { update { copy(small = null) } }
|
||||
small.picker.includeSmall = true
|
||||
subagent.picker.onSelect = { item -> selectSubagent(item) }
|
||||
subagent.picker.onClear = { update { copy(subagent = null, variant = null) } }
|
||||
variant.onSelect = { item -> update { copy(variant = item.id) } }
|
||||
listOf(defaults, small, subagent).forEach { picker ->
|
||||
picker.picker.favorites = { app.favorites.value }
|
||||
picker.picker.onFavoriteToggle = { app.toggleModelFavorite(it.provider, it.id) }
|
||||
}
|
||||
|
||||
banner.addAction(KiloBundle.message("settings.models.login.action"), Runnable { openProfile(banner) })
|
||||
content.next(banner)
|
||||
content.gap(UiStyle.Gap.md())
|
||||
content.next(status)
|
||||
content.gap(UiStyle.Gap.lg())
|
||||
content.next(TitledSeparator(KiloBundle.message("settings.models.displayName")))
|
||||
rows.row(SettingsRow(
|
||||
KiloBundle.message("settings.models.defaultModel.title"),
|
||||
KiloBundle.message("settings.models.defaultModel.description"),
|
||||
defaults,
|
||||
))
|
||||
rows.row(SettingsRow(
|
||||
KiloBundle.message("settings.models.smallModel.title"),
|
||||
KiloBundle.message("settings.models.smallModel.description"),
|
||||
small,
|
||||
))
|
||||
rows.row(SettingsRow(
|
||||
KiloBundle.message("settings.models.subagentModel.title"),
|
||||
KiloBundle.message("settings.models.subagentModel.description"),
|
||||
subagent,
|
||||
))
|
||||
rows.row(variantRow)
|
||||
content.next(rows)
|
||||
content.next(TitledSeparator(KiloBundle.message("settings.models.modeModels.title")))
|
||||
content.next(JBLabel(KiloBundle.message("settings.models.modeModels.description")).apply {
|
||||
foreground = UIUtil.getContextHelpForeground()
|
||||
font = UiStyle.Fonts.small()
|
||||
})
|
||||
content.next(modes)
|
||||
add(JBScrollPane(content).apply { border = null }, BorderLayout.CENTER)
|
||||
setContent(form)
|
||||
sync()
|
||||
start()
|
||||
}
|
||||
@@ -145,7 +84,7 @@ internal class ModelsSettingsUi(
|
||||
fun resetDraft() {
|
||||
checkEdt()
|
||||
draft = baseline
|
||||
if (!saving) status.text = ""
|
||||
if (!saving) clearProgress()
|
||||
sync()
|
||||
}
|
||||
|
||||
@@ -158,8 +97,7 @@ internal class ModelsSettingsUi(
|
||||
if (patch.values.isEmpty() && patch.agents.isEmpty()) return
|
||||
pending = next
|
||||
saving = true
|
||||
status.text = KiloBundle.message("settings.models.save.pending")
|
||||
status.foreground = UIUtil.getContextHelpForeground()
|
||||
showProgress(KiloBundle.message("settings.models.save.pending"))
|
||||
sync()
|
||||
cs.launch {
|
||||
val state = app.updateConfig(patch)
|
||||
@@ -171,7 +109,7 @@ internal class ModelsSettingsUi(
|
||||
draft = next
|
||||
pending = null
|
||||
saving = false
|
||||
status.text = ""
|
||||
clearProgress()
|
||||
sync()
|
||||
return@withContext
|
||||
}
|
||||
@@ -179,8 +117,7 @@ internal class ModelsSettingsUi(
|
||||
draft = next
|
||||
pending = null
|
||||
saving = false
|
||||
status.text = KiloBundle.message("settings.models.save.failed")
|
||||
status.foreground = UiStyle.Colors.errorLabelForeground()
|
||||
clearProgress()
|
||||
sync()
|
||||
}
|
||||
}
|
||||
@@ -285,40 +222,28 @@ internal class ModelsSettingsUi(
|
||||
ready = appState.status == KiloAppStatusDto.READY,
|
||||
authenticated = appState.profile != null,
|
||||
)
|
||||
val structural = banner.isVisible != bannerVisible
|
||||
banner.isVisible = bannerVisible
|
||||
if (!saving) {
|
||||
if (bannerVisible) {
|
||||
top.showNotLoggedIn { openProfile(it) }
|
||||
} else {
|
||||
when (state) {
|
||||
ModelsStatus.UNAVAILABLE -> {
|
||||
status.text = KiloBundle.message("settings.models.unavailable")
|
||||
status.foreground = UIUtil.getContextHelpForeground()
|
||||
}
|
||||
ModelsStatus.LOADING -> {
|
||||
status.text = KiloBundle.message("settings.models.loading")
|
||||
status.foreground = UIUtil.getContextHelpForeground()
|
||||
}
|
||||
ModelsStatus.LOAD_FAILED -> {
|
||||
status.text = KiloBundle.message("settings.models.load.failed")
|
||||
status.foreground = UiStyle.Colors.errorLabelForeground()
|
||||
}
|
||||
ModelsStatus.NO_PROVIDERS -> {
|
||||
status.text = KiloBundle.message("settings.models.noProviders")
|
||||
status.foreground = UIUtil.getContextHelpForeground()
|
||||
}
|
||||
ModelsStatus.MODES_FAILED -> {
|
||||
status.text = KiloBundle.message("settings.models.modes.failed")
|
||||
status.foreground = UiStyle.Colors.warningLabelForeground()
|
||||
}
|
||||
ModelsStatus.READY,
|
||||
ModelsStatus.SAVING -> {
|
||||
status.text = ""
|
||||
status.foreground = UIUtil.getContextHelpForeground()
|
||||
}
|
||||
ModelsStatus.LOAD_FAILED -> top.showBanner(
|
||||
KiloBundle.message("settings.models.load.failed"),
|
||||
emptyList(),
|
||||
SettingsBannerKind.ERROR,
|
||||
)
|
||||
ModelsStatus.NO_PROVIDERS -> top.showBanner(KiloBundle.message("settings.models.noProviders"), emptyList())
|
||||
ModelsStatus.MODES_FAILED -> top.showBanner(KiloBundle.message("settings.models.modes.failed"), emptyList())
|
||||
else -> top.hideBanner()
|
||||
}
|
||||
}
|
||||
val statusVisible = status.text.isNotBlank()
|
||||
var layout = structural || status.isVisible != statusVisible
|
||||
status.isVisible = statusVisible
|
||||
if (saving || state == ModelsStatus.SAVING) {
|
||||
showProgress(KiloBundle.message("settings.models.save.pending"))
|
||||
} else if (state == ModelsStatus.UNAVAILABLE || state == ModelsStatus.LOADING) {
|
||||
showProgress(KiloBundle.message("settings.models.loading"))
|
||||
} else {
|
||||
clearProgress()
|
||||
}
|
||||
var layout = false
|
||||
defaults.setItems(allItems, draft.model)
|
||||
small.setItems(smallItems, draft.small)
|
||||
subagent.setItems(allItems, draft.subagent)
|
||||
@@ -364,7 +289,7 @@ internal class ModelsSettingsUi(
|
||||
var layout = false
|
||||
val names = agents.map { it.name }
|
||||
if (names != pickers.keys.toList()) {
|
||||
modes.removeAll()
|
||||
form.modes.removeAll()
|
||||
pickers.clear()
|
||||
agents.forEach { agent ->
|
||||
val picker = ModelSettingPicker()
|
||||
@@ -373,7 +298,7 @@ internal class ModelsSettingsUi(
|
||||
picker.picker.onSelect = { item -> update { copy(agents = this.agents + (agent.name to item.key)) } }
|
||||
picker.picker.onClear = { update { copy(agents = this.agents + (agent.name to null)) } }
|
||||
pickers[agent.name] = picker
|
||||
modes.row(SettingsRow(
|
||||
form.modes.row(agent.name, SettingsRow(
|
||||
agent.displayName ?: title(agent.name),
|
||||
agent.description,
|
||||
picker,
|
||||
@@ -381,7 +306,10 @@ internal class ModelsSettingsUi(
|
||||
}
|
||||
layout = true
|
||||
}
|
||||
for ((name, picker) in pickers) {
|
||||
agents.forEach { agent ->
|
||||
val name = agent.name
|
||||
val picker = pickers[name] ?: return@forEach
|
||||
form.modes.update(name, agent.displayName ?: title(name), agent.description, picker)
|
||||
val value = draft.agents[name]
|
||||
picker.setItems(allItems, value)
|
||||
picker.isEnabled = ready
|
||||
@@ -441,6 +369,61 @@ internal class ModelsSettingsUi(
|
||||
|
||||
private const val KILO_PROVIDER = "kilo"
|
||||
|
||||
private class ModelsSettingsContent(
|
||||
app: KiloAppService,
|
||||
update: (ModelsDraft.() -> ModelsDraft) -> Unit,
|
||||
select: (ModelPicker.Item) -> Unit,
|
||||
) : SettingsContentPanel() {
|
||||
val defaults = ModelSettingPicker()
|
||||
val small = ModelSettingPicker()
|
||||
val subagent = ModelSettingPicker()
|
||||
val variant = ReasoningPicker()
|
||||
val variantRow = SettingsRow(
|
||||
KiloBundle.message("settings.models.subagentVariant.title"),
|
||||
KiloBundle.message("settings.models.subagentVariant.description"),
|
||||
variant.align(HAlign.RIGHT, VAlign.CENTER),
|
||||
)
|
||||
val modes: SettingsRows
|
||||
val pickers = linkedMapOf<String, ModelSettingPicker>()
|
||||
|
||||
init {
|
||||
defaults.picker.onSelect = { update { copy(model = it.key) } }
|
||||
defaults.picker.onClear = { update { copy(model = null) } }
|
||||
small.picker.onSelect = { update { copy(small = it.key) } }
|
||||
small.picker.onClear = { update { copy(small = null) } }
|
||||
small.picker.includeSmall = true
|
||||
subagent.picker.onSelect = { item -> select(item) }
|
||||
subagent.picker.onClear = { update { copy(subagent = null, variant = null) } }
|
||||
variant.onSelect = { item -> update { copy(variant = item.id) } }
|
||||
listOf(defaults, small, subagent).forEach { picker ->
|
||||
picker.picker.favorites = { app.favorites.value }
|
||||
picker.picker.onFavoriteToggle = { app.toggleModelFavorite(it.provider, it.id) }
|
||||
}
|
||||
|
||||
val rows = section(KiloBundle.message("settings.models.displayName"))
|
||||
rows.row(SettingsRow(
|
||||
KiloBundle.message("settings.models.defaultModel.title"),
|
||||
KiloBundle.message("settings.models.defaultModel.description"),
|
||||
defaults,
|
||||
))
|
||||
rows.row(SettingsRow(
|
||||
KiloBundle.message("settings.models.smallModel.title"),
|
||||
KiloBundle.message("settings.models.smallModel.description"),
|
||||
small,
|
||||
))
|
||||
rows.row(SettingsRow(
|
||||
KiloBundle.message("settings.models.subagentModel.title"),
|
||||
KiloBundle.message("settings.models.subagentModel.description"),
|
||||
subagent,
|
||||
))
|
||||
rows.row(variantRow)
|
||||
modes = section(
|
||||
KiloBundle.message("settings.models.modeModels.title"),
|
||||
KiloBundle.message("settings.models.modeModels.description"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun variantTitle(value: String): String = value.replaceFirstChar { it.titlecase() }
|
||||
|
||||
private fun title(value: String): String = value.replace('-', ' ').replace('_', ' ').replaceFirstChar { it.titlecase() }
|
||||
|
||||
+13
-6
@@ -2,9 +2,11 @@ package ai.kilocode.client.settings.profile
|
||||
|
||||
import ai.kilocode.client.app.KiloAppService
|
||||
import ai.kilocode.client.plugin.KiloBundle
|
||||
import ai.kilocode.client.settings.ui.SettingsPanel
|
||||
import com.intellij.openapi.application.ApplicationManager
|
||||
import com.intellij.openapi.application.ModalityState
|
||||
import com.intellij.openapi.components.service
|
||||
import com.intellij.openapi.options.Configurable
|
||||
import com.intellij.openapi.options.SearchableConfigurable
|
||||
import com.intellij.openapi.wm.IdeFocusManager
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -25,9 +27,10 @@ import javax.swing.JComponent
|
||||
* and a link to the Kilo dashboard. This is a status/action panel — it
|
||||
* has no persistent settings, so [isModified] always returns false.
|
||||
*/
|
||||
class UserProfileConfigurable : SearchableConfigurable {
|
||||
class UserProfileConfigurable : SearchableConfigurable, Configurable.NoScroll {
|
||||
|
||||
private var ui: JComponent? = null
|
||||
private var ui: ProfileUi? = null
|
||||
private var shell: SettingsPanel? = null
|
||||
private var scope: CoroutineScope? = null
|
||||
private var watchJob: Job? = null
|
||||
private var focus = false
|
||||
@@ -36,12 +39,12 @@ class UserProfileConfigurable : SearchableConfigurable {
|
||||
|
||||
override fun getDisplayName(): String = KiloBundle.message("settings.profile.displayName")
|
||||
|
||||
override fun getPreferredFocusedComponent(): JComponent? = (ui as? ProfileUi)?.preferredFocus()
|
||||
override fun getPreferredFocusedComponent(): JComponent? = ui?.preferredFocus()
|
||||
|
||||
override fun focusOn(label: String) {
|
||||
if (label != FOCUS_ACCOUNT_COMBO) return
|
||||
focus = true
|
||||
val panel = ui as? ProfileUi ?: return
|
||||
val panel = ui ?: return
|
||||
requestFocus(panel)
|
||||
}
|
||||
|
||||
@@ -49,10 +52,13 @@ class UserProfileConfigurable : SearchableConfigurable {
|
||||
val cs = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
scope = cs
|
||||
val panel = buildPanel(cs)
|
||||
val root = SettingsPanel()
|
||||
root.setContent(panel)
|
||||
ui = panel
|
||||
shell = root
|
||||
startWatching(cs, panel)
|
||||
if (focus) requestFocus(panel)
|
||||
return panel
|
||||
return root
|
||||
}
|
||||
|
||||
private fun requestFocus(panel: ProfileUi) {
|
||||
@@ -93,10 +99,11 @@ class UserProfileConfigurable : SearchableConfigurable {
|
||||
override fun disposeUIResources() {
|
||||
// Dispose UI first to invalidate pending login attempts before scope cancellation.
|
||||
// Capturing local refs before nulling fields so the EDT callback is self-contained.
|
||||
val panel = ui as? ProfileUi
|
||||
val panel = ui
|
||||
val job = watchJob
|
||||
val cs = scope
|
||||
ui = null
|
||||
shell = null
|
||||
watchJob = null
|
||||
scope = null
|
||||
|
||||
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
package ai.kilocode.client.settings.ui
|
||||
|
||||
import ai.kilocode.client.ui.UiStyle
|
||||
import ai.kilocode.client.ui.layout.Stack
|
||||
import ai.kilocode.client.ui.layout.StackAxis
|
||||
import com.intellij.ui.TitledSeparator
|
||||
import com.intellij.ui.components.JBLabel
|
||||
import com.intellij.util.ui.UIUtil
|
||||
|
||||
internal open class SettingsContentPanel : Stack(StackAxis.VERTICAL) {
|
||||
fun section(title: String, description: String? = null): SettingsRows {
|
||||
next(TitledSeparator(title))
|
||||
if (description != null) {
|
||||
next(JBLabel(description).apply {
|
||||
foreground = UIUtil.getContextHelpForeground()
|
||||
font = UiStyle.Fonts.small()
|
||||
})
|
||||
}
|
||||
val rows = SettingsRows()
|
||||
next(rows)
|
||||
return rows
|
||||
}
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
package ai.kilocode.client.settings.ui
|
||||
|
||||
import ai.kilocode.client.ui.LayeredOverlayPanel
|
||||
import ai.kilocode.client.ui.UiStyle
|
||||
import ai.kilocode.client.ui.layout.Stack
|
||||
import com.intellij.ui.components.JBScrollPane
|
||||
import java.awt.BorderLayout
|
||||
import java.awt.Rectangle
|
||||
import javax.swing.JComponent
|
||||
|
||||
internal open class SettingsPanel : LayeredOverlayPanel() {
|
||||
val top = SettingsTop()
|
||||
val settings = Stack.vertical()
|
||||
val progress = SettingsProgressOverlay()
|
||||
|
||||
init {
|
||||
val body = Stack.vertical()
|
||||
.next(top)
|
||||
.gap(UiStyle.Gap.lg())
|
||||
.next(settings)
|
||||
content.add(JBScrollPane(body).apply { border = null }, BorderLayout.CENTER)
|
||||
addOverlay(progress) { pane, child ->
|
||||
val size = child.preferredSize
|
||||
Rectangle(
|
||||
((pane.width - size.width) / 2).coerceAtLeast(0),
|
||||
UiStyle.Gap.pad(),
|
||||
size.width,
|
||||
size.height,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun setContent(component: JComponent) {
|
||||
settings.removeAll()
|
||||
settings.next(component)
|
||||
revalidate()
|
||||
repaint()
|
||||
}
|
||||
|
||||
fun showProgress(text: String) {
|
||||
progress.showProgress(text)
|
||||
overlay.revalidate()
|
||||
overlay.repaint()
|
||||
}
|
||||
|
||||
fun clearProgress() {
|
||||
progress.clearProgress()
|
||||
overlay.revalidate()
|
||||
overlay.repaint()
|
||||
}
|
||||
}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
package ai.kilocode.client.settings.ui
|
||||
|
||||
import ai.kilocode.client.ui.UiStyle
|
||||
import com.intellij.codeInsight.hint.HintUtil
|
||||
import com.intellij.ui.components.JBLabel
|
||||
import com.intellij.util.ui.JBUI
|
||||
import java.awt.BorderLayout
|
||||
import java.awt.Graphics
|
||||
import java.awt.Graphics2D
|
||||
import java.awt.RenderingHints
|
||||
import javax.swing.JPanel
|
||||
|
||||
internal class SettingsProgressOverlay : JPanel(BorderLayout()) {
|
||||
private var label: JBLabel? = null
|
||||
|
||||
init {
|
||||
val view = JBLabel()
|
||||
label = view
|
||||
isOpaque = false
|
||||
border = JBUI.Borders.empty(UiStyle.Gap.lg(), UiStyle.Gap.pad(), UiStyle.Gap.lg(), UiStyle.Gap.pad())
|
||||
add(view, BorderLayout.CENTER)
|
||||
isVisible = false
|
||||
syncColors()
|
||||
}
|
||||
|
||||
fun showProgress(text: String) {
|
||||
val view = requireNotNull(label)
|
||||
if (view.text != text) view.text = text
|
||||
if (!isVisible) isVisible = true
|
||||
revalidate()
|
||||
repaint()
|
||||
}
|
||||
|
||||
fun clearProgress() {
|
||||
val view = requireNotNull(label)
|
||||
if (!isVisible && view.text.isNullOrBlank()) return
|
||||
view.text = ""
|
||||
isVisible = false
|
||||
revalidate()
|
||||
repaint()
|
||||
}
|
||||
|
||||
override fun updateUI() {
|
||||
super.updateUI()
|
||||
syncColors()
|
||||
}
|
||||
|
||||
private fun syncColors() {
|
||||
val hint = HintUtil.getInformationHint()
|
||||
background = hint.textBackground
|
||||
foreground = hint.textForeground
|
||||
label?.foreground = foreground
|
||||
}
|
||||
|
||||
override fun paintComponent(g: Graphics) {
|
||||
val g2 = g.create() as Graphics2D
|
||||
try {
|
||||
g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON)
|
||||
g2.color = background
|
||||
val arc = UiStyle.Arc.component()
|
||||
g2.fillRoundRect(0, 0, width, height, arc, arc)
|
||||
} finally {
|
||||
g2.dispose()
|
||||
}
|
||||
super.paintComponent(g)
|
||||
}
|
||||
}
|
||||
+69
-18
@@ -6,8 +6,6 @@ import ai.kilocode.client.ui.layout.Stack
|
||||
import ai.kilocode.client.ui.layout.StackAxis
|
||||
import ai.kilocode.client.ui.layout.VAlign
|
||||
import ai.kilocode.client.ui.layout.align
|
||||
import com.intellij.ui.JBColor
|
||||
import com.intellij.ui.SeparatorComponent
|
||||
import com.intellij.ui.components.JBLabel
|
||||
import com.intellij.util.ui.JBUI
|
||||
import com.intellij.util.ui.UIUtil
|
||||
@@ -18,38 +16,91 @@ import javax.swing.JPanel
|
||||
class SettingsRow(
|
||||
title: String,
|
||||
description: String? = null,
|
||||
value: JComponent,
|
||||
value: JComponent? = null,
|
||||
) : JPanel(BorderLayout()) {
|
||||
|
||||
private val titleLabel = JBLabel(title).apply { font = UiStyle.Fonts.bold() }
|
||||
private val descriptionLabel = JBLabel(description.orEmpty()).apply {
|
||||
font = UiStyle.Fonts.hint()
|
||||
foreground = UIUtil.getContextHelpForeground()
|
||||
isVisible = description != null
|
||||
}
|
||||
private val labels = Stack.vertical(UiStyle.Gap.sm())
|
||||
private val valuePanel = JPanel(BorderLayout())
|
||||
private var current: JComponent? = null
|
||||
|
||||
init {
|
||||
border = JBUI.Borders.empty(UiStyle.Gap.pad(), 0, UiStyle.Gap.pad(), 0)
|
||||
|
||||
val labels = Stack.vertical(UiStyle.Gap.sm())
|
||||
labels.next(JBLabel(title).apply { font = UiStyle.Fonts.bold() })
|
||||
if (description != null) {
|
||||
labels.next(JBLabel(description).apply {
|
||||
font = UiStyle.Fonts.hint()
|
||||
foreground = UIUtil.getContextHelpForeground()
|
||||
})
|
||||
}
|
||||
|
||||
valuePanel.isOpaque = false
|
||||
labels.next(titleLabel)
|
||||
labels.next(descriptionLabel)
|
||||
add(labels, BorderLayout.CENTER)
|
||||
add(value.align(HAlign.RIGHT, VAlign.CENTER), BorderLayout.EAST)
|
||||
add(valuePanel, BorderLayout.EAST)
|
||||
setValue(value)
|
||||
}
|
||||
|
||||
fun update(
|
||||
title: String,
|
||||
description: String? = null,
|
||||
value: JComponent? = null,
|
||||
) {
|
||||
if (titleLabel.text != title) titleLabel.text = title
|
||||
val text = description.orEmpty()
|
||||
if (descriptionLabel.text != text) descriptionLabel.text = text
|
||||
val visible = description != null
|
||||
if (descriptionLabel.isVisible != visible) descriptionLabel.isVisible = visible
|
||||
setValue(value)
|
||||
}
|
||||
|
||||
private fun setValue(value: JComponent?) {
|
||||
if (value != null && current !== value) {
|
||||
valuePanel.removeAll()
|
||||
valuePanel.add(value.align(HAlign.RIGHT, VAlign.CENTER), BorderLayout.CENTER)
|
||||
current = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class SettingsRows : Stack(StackAxis.VERTICAL) {
|
||||
private var rows = 0
|
||||
private val keyed = linkedMapOf<String, SettingsRow>()
|
||||
|
||||
fun row(child: SettingsRow): SettingsRows {
|
||||
if (rows > 0) next(SeparatorComponent(0, JBColor.border(), null))
|
||||
next(child)
|
||||
rows += 1
|
||||
return this
|
||||
}
|
||||
|
||||
fun row(key: String, child: SettingsRow): SettingsRow {
|
||||
keyed.remove(key)?.let { remove(it) }
|
||||
keyed[key] = child
|
||||
next(child)
|
||||
return child
|
||||
}
|
||||
|
||||
fun update(
|
||||
key: String,
|
||||
title: String,
|
||||
description: String? = null,
|
||||
value: JComponent? = null,
|
||||
): SettingsRow? {
|
||||
val row = keyed[key] ?: return null
|
||||
row.update(title, description, value)
|
||||
return row
|
||||
}
|
||||
|
||||
fun remove(key: String): SettingsRow? {
|
||||
val row = keyed.remove(key) ?: return null
|
||||
remove(row)
|
||||
revalidate()
|
||||
repaint()
|
||||
return row
|
||||
}
|
||||
|
||||
fun retain(keys: Set<String>) {
|
||||
keyed.keys.toList().filter { it !in keys }.forEach { remove(it) }
|
||||
}
|
||||
|
||||
override fun removeAll() {
|
||||
rows = 0
|
||||
keyed.clear()
|
||||
super.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
package ai.kilocode.client.settings.ui
|
||||
|
||||
import ai.kilocode.client.plugin.KiloBundle
|
||||
import ai.kilocode.client.ui.UiStyle
|
||||
import ai.kilocode.client.ui.layout.Stack
|
||||
import ai.kilocode.client.ui.layout.StackAxis
|
||||
import com.intellij.ui.EditorNotificationPanel
|
||||
import com.intellij.ui.InlineBanner
|
||||
import java.awt.BorderLayout
|
||||
import javax.swing.JComponent
|
||||
import javax.swing.JPanel
|
||||
|
||||
internal data class SettingsAction(
|
||||
val text: String,
|
||||
val run: (JComponent) -> Unit,
|
||||
)
|
||||
|
||||
internal enum class SettingsBannerKind { WARNING, ERROR }
|
||||
|
||||
internal class SettingsTop : Stack(StackAxis.VERTICAL, UiStyle.Gap.md()) {
|
||||
private val slot = JPanel(BorderLayout())
|
||||
private var spec: BannerSpec? = null
|
||||
|
||||
init {
|
||||
slot.isOpaque = false
|
||||
slot.isVisible = false
|
||||
next(slot)
|
||||
isVisible = false
|
||||
}
|
||||
|
||||
fun showBanner(
|
||||
text: String,
|
||||
actions: List<SettingsAction>,
|
||||
kind: SettingsBannerKind = SettingsBannerKind.WARNING,
|
||||
) {
|
||||
val next = BannerSpec(text, actions.map { it.text }, kind)
|
||||
if (spec != next) {
|
||||
val banner = InlineBanner(text, status(kind)).showCloseButton(false)
|
||||
actions.forEach { action ->
|
||||
banner.addAction(action.text, Runnable { action.run(banner) })
|
||||
}
|
||||
slot.removeAll()
|
||||
slot.add(banner, BorderLayout.CENTER)
|
||||
spec = next
|
||||
}
|
||||
show(slot)
|
||||
}
|
||||
|
||||
fun showNotLoggedIn(run: (JComponent) -> Unit) {
|
||||
showBanner(
|
||||
KiloBundle.message("settings.login.message"),
|
||||
listOf(SettingsAction(KiloBundle.message("settings.login.action"), run)),
|
||||
)
|
||||
}
|
||||
|
||||
fun hideBanner() {
|
||||
if (!slot.isVisible) return
|
||||
slot.isVisible = false
|
||||
sync(layout = true)
|
||||
}
|
||||
|
||||
private fun show(component: JComponent) {
|
||||
if (component.isVisible) {
|
||||
sync(layout = false)
|
||||
return
|
||||
}
|
||||
component.isVisible = true
|
||||
sync(layout = true)
|
||||
}
|
||||
|
||||
private fun sync(layout: Boolean) {
|
||||
val visible = slot.isVisible
|
||||
val changed = isVisible != visible
|
||||
isVisible = visible
|
||||
if (changed || layout) parent?.revalidate()
|
||||
parent?.repaint()
|
||||
}
|
||||
|
||||
private fun status(kind: SettingsBannerKind) = when (kind) {
|
||||
SettingsBannerKind.WARNING -> EditorNotificationPanel.Status.Warning
|
||||
SettingsBannerKind.ERROR -> EditorNotificationPanel.Status.Error
|
||||
}
|
||||
|
||||
private data class BannerSpec(
|
||||
val text: String,
|
||||
val actions: List<String>,
|
||||
val kind: SettingsBannerKind,
|
||||
)
|
||||
}
|
||||
+158
@@ -0,0 +1,158 @@
|
||||
package ai.kilocode.client.ui
|
||||
|
||||
import ai.kilocode.client.ui.layout.HAlign
|
||||
import ai.kilocode.client.ui.layout.VAlign
|
||||
import ai.kilocode.client.ui.layout.align
|
||||
import com.intellij.util.concurrency.annotations.RequiresEdt
|
||||
import com.intellij.util.ui.JBDimension
|
||||
import com.intellij.util.ui.components.BorderLayoutPanel
|
||||
import java.awt.BorderLayout
|
||||
import java.awt.Container
|
||||
import java.awt.Dimension
|
||||
import java.awt.Rectangle
|
||||
import javax.swing.JComponent
|
||||
import javax.swing.JLayeredPane
|
||||
import javax.swing.JPanel
|
||||
|
||||
open class LayeredOverlayPanel(
|
||||
content: JPanel = BorderLayoutPanel(),
|
||||
overlay: Overlay = Overlay(),
|
||||
blocker: Blocker = Blocker(),
|
||||
) : JLayeredPane() {
|
||||
|
||||
private val baseContent = content
|
||||
|
||||
private val baseOverlay = overlay
|
||||
|
||||
private val baseBlocker = blocker
|
||||
|
||||
open val content: JPanel get() = baseContent
|
||||
|
||||
open val overlay: Overlay get() = baseOverlay
|
||||
|
||||
open val blocker: Blocker get() = baseBlocker
|
||||
|
||||
init {
|
||||
layout = null
|
||||
add(baseContent)
|
||||
setLayer(baseContent, DEFAULT_LAYER)
|
||||
add(baseOverlay)
|
||||
setLayer(baseOverlay, PALETTE_LAYER)
|
||||
add(baseBlocker)
|
||||
setLayer(baseBlocker, MODAL_LAYER)
|
||||
baseBlocker.isVisible = false
|
||||
}
|
||||
|
||||
fun addOverlay(child: JComponent, bounds: (JPanel, JComponent) -> Rectangle) {
|
||||
overlay.addOverlay(child, bounds)
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
fun setModalContent(child: JComponent?) {
|
||||
blocker.removeAll()
|
||||
if (child != null) blocker.add(child.align(HAlign.CENTER, VAlign.CENTER), BorderLayout.CENTER)
|
||||
blocker.isVisible = child != null
|
||||
if (child != null) blocker.requestFocusInWindow()
|
||||
invalidate()
|
||||
blocker.invalidate()
|
||||
child?.invalidate()
|
||||
if (width > 0 && height > 0) {
|
||||
doLayout()
|
||||
child?.let(::layoutTree)
|
||||
}
|
||||
blocker.revalidate()
|
||||
blocker.repaint()
|
||||
revalidate()
|
||||
repaint()
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
fun setBlocked(value: Boolean) {
|
||||
blocker.isVisible = value
|
||||
if (value) blocker.requestFocusInWindow()
|
||||
invalidate()
|
||||
blocker.invalidate()
|
||||
if (width > 0 && height > 0) doLayout()
|
||||
revalidate()
|
||||
repaint()
|
||||
}
|
||||
|
||||
override fun doLayout() {
|
||||
components
|
||||
.sortedBy { getLayer(it) }
|
||||
.forEach { child ->
|
||||
child.setBounds(0, 0, width, height)
|
||||
child.doLayout()
|
||||
}
|
||||
}
|
||||
|
||||
override fun getPreferredSize(): Dimension {
|
||||
val w = listOf(content, overlay).maxOfOrNull { it.preferredSize.width } ?: 0
|
||||
val h = listOf(content, overlay).maxOfOrNull { it.preferredSize.height } ?: 0
|
||||
return JBDimension(w, h)
|
||||
}
|
||||
|
||||
open class Overlay : BorderLayoutPanel() {
|
||||
|
||||
private val items = linkedMapOf<JComponent, (JPanel, JComponent) -> Rectangle>()
|
||||
|
||||
init {
|
||||
layout = null
|
||||
isOpaque = false
|
||||
}
|
||||
|
||||
fun addOverlay(child: JComponent, bounds: (JPanel, JComponent) -> Rectangle) {
|
||||
items[child] = bounds
|
||||
add(child)
|
||||
}
|
||||
|
||||
override fun contains(x: Int, y: Int): Boolean {
|
||||
for (child in components) {
|
||||
if (child.isVisible && child.bounds.contains(x, y)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
override fun doLayout() {
|
||||
items.forEach { (child, bounds) ->
|
||||
child.bounds = bounds(this, child)
|
||||
child.doLayout()
|
||||
}
|
||||
}
|
||||
|
||||
override fun getPreferredSize(): Dimension {
|
||||
val pref = super.getPreferredSize()
|
||||
val w = maxOf(pref.width, components.maxOfOrNull { it.preferredSize.width } ?: 0)
|
||||
val h = maxOf(pref.height, components.maxOfOrNull { it.preferredSize.height } ?: 0)
|
||||
return JBDimension(w, h)
|
||||
}
|
||||
}
|
||||
|
||||
open class Blocker : JPanel() {
|
||||
init {
|
||||
layout = BorderLayout()
|
||||
isFocusable = true
|
||||
}
|
||||
|
||||
override fun updateUI() {
|
||||
super.updateUI()
|
||||
background = UiStyle.Colors.bg()
|
||||
isOpaque = true
|
||||
}
|
||||
|
||||
override fun contains(x: Int, y: Int): Boolean {
|
||||
if (!isVisible) return false
|
||||
return super.contains(x, y)
|
||||
}
|
||||
|
||||
override fun doLayout() {
|
||||
super.doLayout()
|
||||
components.forEach { layoutTree(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun layoutTree(comp: java.awt.Component) {
|
||||
comp.doLayout()
|
||||
if (comp is Container) comp.components.forEach { layoutTree(it) }
|
||||
}
|
||||
@@ -191,6 +191,8 @@ action.Kilo.ToolWindowToolbar.text=Kilo Toolbar
|
||||
settings.kilo.displayName=Kilo Code
|
||||
settings.kilo.description=Configure Kilo Code AI coding assistant features and account settings.
|
||||
settings.models.displayName=Models
|
||||
settings.login.message=Sign in to Kilo Code to access account-backed features and manage billing.
|
||||
settings.login.action=Open User Profile
|
||||
settings.models.defaultModel.title=Default Model
|
||||
settings.models.defaultModel.description=The model Kilo uses when no mode-specific model is configured.
|
||||
settings.models.smallModel.title=Small Model
|
||||
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
package ai.kilocode.client.settings.ui
|
||||
|
||||
import com.intellij.testFramework.fixtures.BasePlatformTestCase
|
||||
import com.intellij.codeInsight.hint.HintUtil
|
||||
import com.intellij.ui.SeparatorComponent
|
||||
import com.intellij.ui.components.JBLabel
|
||||
import ai.kilocode.client.ui.UiStyle
|
||||
import java.awt.Container
|
||||
import java.awt.Rectangle
|
||||
import javax.swing.AbstractButton
|
||||
import javax.swing.JButton
|
||||
import javax.swing.JComponent
|
||||
import javax.swing.JLabel
|
||||
import javax.swing.text.JTextComponent
|
||||
|
||||
class SettingsRowsTest : BasePlatformTestCase() {
|
||||
|
||||
fun `test rows do not insert separators`() {
|
||||
val rows = SettingsRows()
|
||||
|
||||
rows.row(SettingsRow("One", value = JButton("A")))
|
||||
rows.row(SettingsRow("Two", value = JButton("B")))
|
||||
|
||||
assertEquals(2, rows.componentCount)
|
||||
assertTrue(components(rows).none { it is SeparatorComponent })
|
||||
}
|
||||
|
||||
fun `test keyed update preserves row and value`() {
|
||||
val rows = SettingsRows()
|
||||
val value = JButton("A")
|
||||
val row = rows.row("one", SettingsRow("One", "Before", value))
|
||||
|
||||
val updated = rows.update("one", "Updated", "After", value)
|
||||
|
||||
assertSame(row, updated)
|
||||
assertSame(value, components(row).first { it === value })
|
||||
assertTrue(text(row).contains("Updated"))
|
||||
assertTrue(text(row).contains("After"))
|
||||
}
|
||||
|
||||
fun `test removing keyed row removes only that row`() {
|
||||
val rows = SettingsRows()
|
||||
val one = rows.row("one", SettingsRow("One", value = JButton("A")))
|
||||
val two = rows.row("two", SettingsRow("Two", value = JButton("B")))
|
||||
|
||||
assertSame(one, rows.remove("one"))
|
||||
|
||||
assertEquals(1, rows.componentCount)
|
||||
assertSame(two, rows.getComponent(0))
|
||||
}
|
||||
|
||||
fun `test retain keeps requested keyed rows`() {
|
||||
val rows = SettingsRows()
|
||||
val one = rows.row("one", SettingsRow("One", value = JButton("A")))
|
||||
rows.row("two", SettingsRow("Two", value = JButton("B")))
|
||||
|
||||
rows.retain(setOf("one"))
|
||||
|
||||
assertEquals(1, rows.componentCount)
|
||||
assertSame(one, rows.getComponent(0))
|
||||
}
|
||||
|
||||
fun `test top banner renders login action`() {
|
||||
val top = SettingsTop()
|
||||
|
||||
top.showNotLoggedIn {}
|
||||
|
||||
assertTrue(text(top).contains("Sign in to Kilo Code"))
|
||||
assertTrue(top.isVisible)
|
||||
}
|
||||
|
||||
fun `test settings panel keeps banner in scroll content and progress in overlay`() {
|
||||
val panel = SettingsPanel()
|
||||
|
||||
panel.top.showNotLoggedIn {}
|
||||
panel.showProgress("Loading models...")
|
||||
|
||||
assertTrue(text(panel.content).contains("Sign in to Kilo Code"))
|
||||
assertFalse(text(panel.overlay).contains("Sign in to Kilo Code"))
|
||||
assertTrue(panel.overlay.components.any { it === panel.progress })
|
||||
assertTrue(text(panel.progress).contains("Loading models..."))
|
||||
}
|
||||
|
||||
fun `test settings progress overlay is centered near top`() {
|
||||
val panel = SettingsPanel().apply { setSize(400, 300) }
|
||||
|
||||
panel.showProgress("Loading")
|
||||
panel.doLayout()
|
||||
|
||||
val size = panel.progress.preferredSize
|
||||
assertEquals(
|
||||
Rectangle((400 - size.width) / 2, UiStyle.Gap.pad(), size.width, size.height),
|
||||
panel.progress.bounds,
|
||||
)
|
||||
assertTrue(panel.overlay.contains(panel.progress.x + 1, panel.progress.y + 1))
|
||||
assertFalse(panel.overlay.contains(1, 1))
|
||||
}
|
||||
|
||||
fun `test settings progress overlay retains label across updates`() {
|
||||
val panel = SettingsPanel()
|
||||
|
||||
panel.showProgress("Loading")
|
||||
val label = components(panel.progress).filterIsInstance<JBLabel>().single { it.text == "Loading" }
|
||||
|
||||
panel.showProgress("Saving")
|
||||
|
||||
assertSame(label, components(panel.progress).filterIsInstance<JBLabel>().single { it.text == "Saving" })
|
||||
|
||||
panel.clearProgress()
|
||||
assertFalse(panel.progress.isVisible)
|
||||
}
|
||||
|
||||
fun `test settings progress overlay uses information colors`() {
|
||||
val panel = SettingsPanel()
|
||||
val hint = HintUtil.getInformationHint()
|
||||
|
||||
panel.showProgress("Loading")
|
||||
|
||||
val label = components(panel.progress).filterIsInstance<JBLabel>().single { it.text == "Loading" }
|
||||
assertEquals(hint.textBackground, panel.progress.background)
|
||||
assertEquals(hint.textForeground, panel.progress.foreground)
|
||||
assertEquals(hint.textForeground, label.foreground)
|
||||
}
|
||||
|
||||
private fun text(component: JComponent): String = components(component)
|
||||
.mapNotNull {
|
||||
when (it) {
|
||||
is JLabel -> it.text
|
||||
is AbstractButton -> it.text
|
||||
is JTextComponent -> it.text
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
.joinToString("\n")
|
||||
|
||||
private fun components(component: JComponent): List<java.awt.Component> {
|
||||
val out = mutableListOf<java.awt.Component>()
|
||||
fun visit(c: java.awt.Component) {
|
||||
out += c
|
||||
if (c is Container) c.components.forEach { visit(it) }
|
||||
}
|
||||
visit(component)
|
||||
return out
|
||||
}
|
||||
}
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
package ai.kilocode.client.ui
|
||||
|
||||
import com.intellij.testFramework.fixtures.BasePlatformTestCase
|
||||
import com.intellij.util.ui.components.BorderLayoutPanel
|
||||
import java.awt.Dimension
|
||||
import java.awt.Rectangle
|
||||
import javax.swing.JLayeredPane
|
||||
|
||||
@Suppress("UnstableApiUsage")
|
||||
class LayeredOverlayPanelTest : BasePlatformTestCase() {
|
||||
|
||||
fun `test root owns content overlay and blocker layers`() {
|
||||
val root = LayeredOverlayPanel()
|
||||
|
||||
assertEquals(3, root.componentCount)
|
||||
assertSame(root.content, root.components.first { it === root.content })
|
||||
assertSame(root.overlay, root.components.first { it === root.overlay })
|
||||
assertSame(root.blocker, root.components.first { it === root.blocker })
|
||||
assertEquals(JLayeredPane.DEFAULT_LAYER, root.getLayer(root.content))
|
||||
assertEquals(JLayeredPane.PALETTE_LAYER, root.getLayer(root.overlay))
|
||||
assertEquals(JLayeredPane.MODAL_LAYER, root.getLayer(root.blocker))
|
||||
}
|
||||
|
||||
fun `test root layout fills all immediate children`() {
|
||||
val root = LayeredOverlayPanel().apply { setSize(320, 180) }
|
||||
|
||||
root.doLayout()
|
||||
|
||||
assertEquals(Rectangle(0, 0, 320, 180), root.content.bounds)
|
||||
assertEquals(Rectangle(0, 0, 320, 180), root.overlay.bounds)
|
||||
assertEquals(Rectangle(0, 0, 320, 180), root.blocker.bounds)
|
||||
}
|
||||
|
||||
fun `test root preferred size is max of content and overlay`() {
|
||||
val root = LayeredOverlayPanel().apply {
|
||||
content.preferredSize = Dimension(300, 120)
|
||||
overlay.preferredSize = Dimension(180, 220)
|
||||
}
|
||||
|
||||
assertEquals(Dimension(300, 220), root.preferredSize)
|
||||
}
|
||||
|
||||
fun `test addOverlay applies callback bounds and delegates child layout`() {
|
||||
val root = LayeredOverlayPanel().apply { setSize(400, 260) }
|
||||
val child = Probe()
|
||||
|
||||
root.addOverlay(child) { _, item ->
|
||||
Rectangle(12, 34, item.preferredSize.width, item.preferredSize.height)
|
||||
}
|
||||
root.doLayout()
|
||||
|
||||
assertEquals(Rectangle(12, 34, 80, 24), child.bounds)
|
||||
assertTrue(child.laid)
|
||||
}
|
||||
|
||||
fun `test overlay contains only visible overlay children`() {
|
||||
val root = LayeredOverlayPanel().apply { setSize(400, 260) }
|
||||
val child = Probe()
|
||||
|
||||
root.addOverlay(child) { _, item -> Rectangle(12, 34, item.preferredSize.width, item.preferredSize.height) }
|
||||
root.doLayout()
|
||||
|
||||
assertTrue(root.overlay.contains(20, 40))
|
||||
assertFalse(root.overlay.contains(4, 4))
|
||||
|
||||
child.isVisible = false
|
||||
assertFalse(root.overlay.contains(20, 40))
|
||||
}
|
||||
|
||||
fun `test modal content is centered inside blocker`() {
|
||||
val root = LayeredOverlayPanel().apply { setSize(200, 100) }
|
||||
val child = Probe()
|
||||
|
||||
root.setModalContent(child)
|
||||
root.doLayout()
|
||||
|
||||
assertTrue(root.blocker.isVisible)
|
||||
assertEquals(1, root.blocker.componentCount)
|
||||
assertEquals(Rectangle(60, 38, 80, 24), child.bounds)
|
||||
}
|
||||
|
||||
fun `test clearing modal content hides and removes blocker children`() {
|
||||
val root = LayeredOverlayPanel().apply { setSize(200, 100) }
|
||||
root.setModalContent(Probe())
|
||||
root.doLayout()
|
||||
|
||||
root.setModalContent(null)
|
||||
|
||||
assertFalse(root.blocker.isVisible)
|
||||
assertEquals(0, root.blocker.componentCount)
|
||||
}
|
||||
|
||||
fun `test blocker contains reflects visibility`() {
|
||||
val root = LayeredOverlayPanel().apply { setSize(200, 100) }
|
||||
root.doLayout()
|
||||
|
||||
root.setBlocked(false)
|
||||
assertFalse(root.blocker.contains(50, 50))
|
||||
|
||||
root.setBlocked(true)
|
||||
assertTrue(root.blocker.contains(50, 50))
|
||||
}
|
||||
|
||||
private class Probe : BorderLayoutPanel() {
|
||||
var laid = false
|
||||
|
||||
init {
|
||||
preferredSize = Dimension(80, 24)
|
||||
}
|
||||
|
||||
override fun doLayout() {
|
||||
laid = true
|
||||
super.doLayout()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user