fix(jetbrains): refine migration wizard layout

This commit is contained in:
kirillk
2026-05-25 14:42:46 -04:00
parent d4b9f95381
commit b37e3fd2a1
6 changed files with 260 additions and 240 deletions
@@ -3,15 +3,18 @@ package ai.kilocode.client.migration.ui
import ai.kilocode.client.migration.MigrationUiSelections
import ai.kilocode.client.migration.MigrationUiState
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.ui.components.JBPanel
import com.intellij.util.ui.JBUI
import com.intellij.util.concurrency.annotations.RequiresEdt
import java.awt.BorderLayout
import javax.swing.JComponent
/**
* Outer container for the migration wizard rendered inside the blocker layer.
*
* Wraps [MigrationWizardPanel] in a bordered overlay with a panel background.
* Wraps [MigrationWizardPanel] in the blocker layer.
* Build once; call [update] on every state change.
*/
class MigrationOverlayPanel : JBPanel<MigrationOverlayPanel>(BorderLayout()) {
@@ -40,13 +43,16 @@ class MigrationOverlayPanel : JBPanel<MigrationOverlayPanel>(BorderLayout()) {
init {
withBackground(UiStyle.Colors.bg())
border = JBUI.Borders.customLine(com.intellij.ui.JBColor.border(), 1)
add(wizard, BorderLayout.CENTER)
add(wizard.align(HAlign.CENTER, VAlign.CENTER), BorderLayout.CENTER)
}
@RequiresEdt
fun update(state: MigrationUiState.Needed) {
wizard.update(state)
revalidate()
repaint()
}
@RequiresEdt
fun preferredFocusComponent(): JComponent = wizard.preferredFocusComponent()
}
@@ -9,34 +9,27 @@ import ai.kilocode.client.migration.MigrationUiState
import ai.kilocode.client.migration.SessionMigrationSummary
import ai.kilocode.client.migration.groupStatus
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.session.views.base.BaseQuestionView
import ai.kilocode.client.ui.UiStyle
import ai.kilocode.client.ui.layout.Stack
import ai.kilocode.rpc.dto.LegacyMigrationDetectionDto
import ai.kilocode.rpc.dto.LegacyMigrationSessionProgressDto
import ai.kilocode.rpc.dto.MigrationItemCategoryDto
import ai.kilocode.rpc.dto.MigrationItemProgressStatusDto
import ai.kilocode.rpc.dto.MigrationSessionPhaseDto
import com.intellij.icons.AllIcons
import com.intellij.ide.BrowserUtil
import com.intellij.ui.components.JBCheckBox
import com.intellij.ui.components.JBLabel
import com.intellij.ui.components.JBScrollPane
import com.intellij.util.ui.JBFont
import com.intellij.util.ui.JBUI
import com.intellij.util.ui.components.BorderLayoutPanel
import com.intellij.util.concurrency.annotations.RequiresEdt
import java.awt.BorderLayout
import java.awt.CardLayout
import java.awt.FlowLayout
import java.awt.GridBagConstraints
import java.awt.GridBagLayout
import javax.swing.JButton
import java.awt.Component
import javax.swing.JComponent
import javax.swing.JPanel
import javax.swing.JButton as JBtn
private const val CARD_WHATS_NEW = "whats-new"
private const val CARD_MIGRATE = "migrate"
private const val ACTION_SKIP = "skip"
private const val ACTION_MIGRATE = "migrate"
private const val ACTION_DONE = "done"
private const val ACTION_CONTINUE = "continue"
/**
* Two-screen migration wizard: "What's New" → "Migrate Your Settings".
* Migration selection wizard.
*
* Build once; call [update] for every state change. Does not rebuild the component tree.
*/
@@ -49,13 +42,6 @@ class MigrationWizardPanel : JPanel(BorderLayout()) {
var onDone: (() -> Unit)? = null
var onContinueFromError: (() -> Unit)? = null
// ------ Card layout ------
private val cards = CardLayout()
private val cardPanel = JPanel(cards)
// ------ What's New screen ------
private val whatsNewPanel = buildWhatsNewPanel()
// ------ Migrate screen row state ------
private val rows = mutableMapOf<MigrationItemCategoryDto, MigrationItemRow>()
private val settingsRow = MigrationItemRow(KiloBundle.message("migration.row.settings"), MigrationItemCategoryDto.settings)
@@ -67,12 +53,8 @@ class MigrationWizardPanel : JPanel(BorderLayout()) {
private val sessionProgress = SessionMigrationProgressPanel()
private val sessionSummary = SessionMigrationSummaryPanel()
private val migrateBtn = JButton(KiloBundle.message("migration.button.migrate"))
private val backBtn = JButton(KiloBundle.message("migration.button.back"))
private val skipBtn = JButton(KiloBundle.message("migration.button.skip"))
private val doneBtn = JButton(KiloBundle.message("migration.button.done"))
private val continueBtn = JButton(KiloBundle.message("migration.button.continue"))
private val question = BaseQuestionView()
private val keepBox = JBCheckBox(KiloBundle.message("migration.keep_legacy_settings"), true)
private val emptyLabel = JBLabel(KiloBundle.message("migration.empty")).apply {
foreground = UiStyle.Colors.weak()
@@ -80,6 +62,8 @@ class MigrationWizardPanel : JPanel(BorderLayout()) {
private var detection: LegacyMigrationDetectionDto? = null
private var selections = MigrationUiSelections()
private var phase = MigrationUiPhase.selecting
private var running = false
init {
isOpaque = false
@@ -95,25 +79,38 @@ class MigrationWizardPanel : JPanel(BorderLayout()) {
row.onSelectionChanged = { _ -> updateMigrateButtonEnabled() }
}
migrateBtn.addActionListener { onStart?.invoke(currentSelections()) }
backBtn.addActionListener { cards.show(cardPanel, CARD_WHATS_NEW) }
skipBtn.addActionListener { onSkip?.invoke() }
doneBtn.addActionListener { onDone?.invoke() }
continueBtn.addActionListener { onContinueFromError?.invoke() }
question.setHeader(
KiloBundle.message("migration.migrate.title"),
KiloBundle.message("migration.migrate.subtitle"),
)
question.setContent(buildContent())
question.setActions(
listOf(
BaseQuestionView.Action(ACTION_SKIP, KiloBundle.message("migration.button.skip"), primary = false) {
onSkip?.invoke()
},
BaseQuestionView.Action(ACTION_MIGRATE, KiloBundle.message("migration.button.migrate"), primary = true) {
onStart?.invoke(currentSelections())
},
BaseQuestionView.Action(ACTION_DONE, KiloBundle.message("migration.button.done"), primary = true) {
onDone?.invoke()
},
BaseQuestionView.Action(ACTION_CONTINUE, KiloBundle.message("migration.button.continue"), primary = true) {
onContinueFromError?.invoke()
},
),
)
question.setActionLeft(keepBox)
sessionSummary.onForceReimport = { ids -> onForce?.invoke(ids) }
cardPanel.isOpaque = false
cardPanel.add(whatsNewPanel, CARD_WHATS_NEW)
cardPanel.add(buildMigratePanel(), CARD_MIGRATE)
add(cardPanel, BorderLayout.CENTER)
cards.show(cardPanel, CARD_WHATS_NEW)
add(question, BorderLayout.CENTER)
updateButtons(MigrationUiPhase.selecting, running = false)
}
// ------ Public update ------
@RequiresEdt
fun update(state: MigrationUiState.Needed) {
val det = state.detection
// Detection and default selections are set once on first update and are stable for the
@@ -125,7 +122,8 @@ class MigrationWizardPanel : JPanel(BorderLayout()) {
applyDefaults(det)
}
val phase = state.phase
phase = state.phase
running = state.running
// Update row visibility based on what data exists
providerRow.isVisible = det.providers.any { it.supported }
@@ -164,11 +162,18 @@ class MigrationWizardPanel : JPanel(BorderLayout()) {
sessionSummary.isVisible = false
}
updateButtons(phase, state.running)
updateButtons(phase, running)
updateMigrateButtonEnabled()
question.revalidate()
question.repaint()
revalidate()
repaint()
}
fun preferredFocusComponent() = migrateBtn
@RequiresEdt
fun preferredFocusComponent(): JComponent = question.actionButtonsForTest()[ACTION_MIGRATE] ?: question
internal fun keepLegacySettingsFileSelectedForTest() = keepBox.isSelected
// ------ Internal helpers ------
@@ -187,6 +192,7 @@ class MigrationWizardPanel : JPanel(BorderLayout()) {
defaults.settings.autoApproval.taskPermission ||
defaults.settings.language ||
defaults.settings.autocomplete
keepBox.isSelected = defaults.keepLegacySettingsFile
}
private fun updateRowProgress(category: MigrationItemCategoryDto, items: List<MigrationItemUiProgress>) {
@@ -201,22 +207,24 @@ class MigrationWizardPanel : JPanel(BorderLayout()) {
}
private fun updateButtons(phase: MigrationUiPhase, running: Boolean) {
backBtn.isVisible = phase == MigrationUiPhase.selecting
skipBtn.isVisible = phase == MigrationUiPhase.selecting
migrateBtn.isVisible = phase == MigrationUiPhase.selecting || phase == MigrationUiPhase.migrating
migrateBtn.isEnabled = !running && phase == MigrationUiPhase.selecting
migrateBtn.text = if (running) KiloBundle.message("migration.button.migrating") else KiloBundle.message("migration.button.migrate")
doneBtn.isVisible = phase == MigrationUiPhase.done
continueBtn.isVisible = phase == MigrationUiPhase.error
question.setActionVisible(ACTION_SKIP, phase == MigrationUiPhase.selecting)
question.setActionVisible(ACTION_MIGRATE, phase == MigrationUiPhase.selecting || phase == MigrationUiPhase.migrating)
question.setActionText(
ACTION_MIGRATE,
if (running) KiloBundle.message("migration.button.migrating") else KiloBundle.message("migration.button.migrate"),
)
question.setActionVisible(ACTION_DONE, phase == MigrationUiPhase.done)
question.setActionVisible(ACTION_CONTINUE, phase == MigrationUiPhase.error)
keepBox.isVisible = phase == MigrationUiPhase.selecting
}
private fun updateMigrateButtonEnabled() {
val any = rows.values.any { it.isVisible && it.selected }
migrateBtn.isEnabled = any && migrateBtn.text == KiloBundle.message("migration.button.migrate")
question.setActionEnabled(ACTION_MIGRATE, any && phase == MigrationUiPhase.selecting && !running)
}
private fun currentSelections(): MigrationUiSelections {
val det = detection ?: return MigrationUiSelections()
val det = detection ?: return MigrationUiSelections(keepLegacySettingsFile = keepBox.isSelected)
val providers = if (providerRow.selected) det.providers.filter { it.supported && it.hasApiKey }.map { it.profileName } else emptyList()
val mcpServers = if (mcpRow.selected) det.mcpServers.map { it.name } else emptyList()
val modes = if (modesRow.selected) det.customModes.map { it.slug } else emptyList()
@@ -229,116 +237,22 @@ class MigrationWizardPanel : JPanel(BorderLayout()) {
sessions = sessions,
defaultModel = modelRow.selected,
settings = if (settingsRow.selected) defaults.settings else MigrationSettingsUiSelections(),
keepLegacySettingsFile = keepBox.isSelected,
)
}
private fun buildWhatsNewPanel(): JPanel {
val panel = JPanel(BorderLayout()).apply { isOpaque = false }
val title = JBLabel(KiloBundle.message("migration.whats_new.title")).apply {
font = JBFont.h2().asBold()
border = JBUI.Borders.emptyBottom(UiStyle.Gap.sm())
private fun buildContent(): JComponent {
return Stack.vertical(gap = UiStyle.Gap.xs()).apply {
alignmentX = Component.LEFT_ALIGNMENT
}
val subtitle = JBLabel(KiloBundle.message("migration.whats_new.subtitle")).apply {
foreground = UiStyle.Colors.weak()
border = JBUI.Borders.emptyBottom(UiStyle.Gap.md())
}
val features = listOf(
KiloBundle.message("migration.whats_new.feature.performance"),
KiloBundle.message("migration.whats_new.feature.interface"),
KiloBundle.message("migration.whats_new.feature.agent_manager"),
KiloBundle.message("migration.whats_new.feature.foundation"),
)
val featurePanel = JPanel(GridBagLayout()).apply { isOpaque = false }
val gc = GridBagConstraints().apply {
fill = GridBagConstraints.HORIZONTAL
weightx = 1.0
gridx = 0
}
for (f in features) {
val row = JPanel(FlowLayout(FlowLayout.LEFT, UiStyle.Gap.xs(), 0)).apply {
isOpaque = false
add(JBLabel(AllIcons.General.InspectionsOK))
add(JBLabel(f))
}
featurePanel.add(row, gc)
}
val content = JPanel(GridBagLayout()).apply {
isOpaque = false
border = JBUI.Borders.empty(UiStyle.Gap.pad())
val c = GridBagConstraints().apply { fill = GridBagConstraints.HORIZONTAL; weightx = 1.0; gridx = 0 }
add(title, c)
add(subtitle, c)
add(featurePanel, c)
}
val continueWnBtn = JButton(KiloBundle.message("migration.button.continue_to_migrate")).apply {
addActionListener { cards.show(cardPanel, CARD_MIGRATE) }
}
val footer = JPanel(FlowLayout(FlowLayout.RIGHT, UiStyle.Gap.sm(), 0)).apply {
isOpaque = false
add(continueWnBtn)
}
panel.add(JBScrollPane(content).apply { border = JBUI.Borders.empty() }, BorderLayout.CENTER)
panel.add(footer, BorderLayout.SOUTH)
return panel
}
private fun buildMigratePanel(): JPanel {
val panel = JPanel(BorderLayout()).apply { isOpaque = false }
val title = JBLabel(KiloBundle.message("migration.migrate.title")).apply {
font = JBFont.h2().asBold()
border = JBUI.Borders.emptyBottom(UiStyle.Gap.sm())
}
val subtitle = JBLabel(KiloBundle.message("migration.migrate.subtitle")).apply {
foreground = UiStyle.Colors.weak()
border = JBUI.Borders.emptyBottom(UiStyle.Gap.md())
}
val sectionLabel = JBLabel(KiloBundle.message("migration.migrate.section")).apply {
font = JBFont.medium()
border = JBUI.Borders.emptyBottom(UiStyle.Gap.xs())
}
val rowsPanel = JPanel(GridBagLayout()).apply {
isOpaque = false
val gc = GridBagConstraints().apply { fill = GridBagConstraints.HORIZONTAL; weightx = 1.0; gridx = 0 }
add(emptyLabel, gc)
add(providerRow, gc)
add(mcpRow, gc)
add(modesRow, gc)
add(sessionsRow, gc)
add(modelRow, gc)
add(settingsRow, gc)
add(sessionProgress, gc)
add(sessionSummary, gc)
}
val content = JPanel(GridBagLayout()).apply {
isOpaque = false
border = JBUI.Borders.empty(UiStyle.Gap.pad())
val gc = GridBagConstraints().apply { fill = GridBagConstraints.HORIZONTAL; weightx = 1.0; gridx = 0 }
add(title, gc)
add(subtitle, gc)
add(sectionLabel, gc)
add(rowsPanel, gc)
}
val footer = JPanel(FlowLayout(FlowLayout.RIGHT, UiStyle.Gap.sm(), 0)).apply {
isOpaque = false
add(backBtn)
add(skipBtn)
add(migrateBtn)
add(doneBtn)
add(continueBtn)
}
panel.add(JBScrollPane(content).apply { border = JBUI.Borders.empty() }, BorderLayout.CENTER)
panel.add(footer, BorderLayout.SOUTH)
return panel
.next(emptyLabel)
.next(providerRow)
.next(mcpRow)
.next(modesRow)
.next(sessionsRow)
.next(modelRow)
.next(settingsRow)
.next(sessionProgress)
.next(sessionSummary)
}
}
@@ -5,7 +5,10 @@ import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget
import ai.kilocode.client.session.ui.style.SessionUiStyle
import ai.kilocode.client.ui.RoundedContentPanel
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 com.intellij.ide.ui.laf.darcula.ui.DarculaButtonUI
import com.intellij.ui.components.JBLabel
import com.intellij.ui.components.JBTextArea
@@ -84,6 +87,8 @@ class BaseQuestionView : RoundedContentPanel(
isVisible = false
}
private val iconWrap = icon.align(HAlign.LEFT, VAlign.TOP)
private val headerText: JBTextArea = makeText("", UiStyle.Colors.fg(), bold = true)
private val descriptionText: JBTextArea = makeText("", UiStyle.Colors.weak(), bold = false)
@@ -95,14 +100,15 @@ class BaseQuestionView : RoundedContentPanel(
private val actionButtons = mutableMapOf<String, JButton>()
private val actionOrder = mutableListOf<String>()
private val actions = Stack.horizontal(gap = UiStyle.Gap.sm())
private val mainActions = Stack.horizontal(gap = UiStyle.Gap.sm())
private val sideActions = Stack.horizontal()
private val footer = JPanel(BorderLayout()).apply {
isOpaque = false
}
init {
header.add(icon, BorderLayout.WEST)
text.next(headerText).next(descriptionText)
header.add(text, BorderLayout.CENTER)
syncNorth()
@@ -153,8 +159,13 @@ class BaseQuestionView : RoundedContentPanel(
this.icon.icon = icon
this.icon.toolTipText = tooltip
this.icon.isVisible = icon != null
val attached = this.iconWrap.parent === header
if (icon != null && !attached) header.add(iconWrap, BorderLayout.WEST)
if (icon == null && attached) header.remove(iconWrap)
this.icon.revalidate()
this.icon.repaint()
header.revalidate()
header.repaint()
}
/**
@@ -182,7 +193,7 @@ class BaseQuestionView : RoundedContentPanel(
fun setActions(actions: List<Action>) {
actionButtons.clear()
actionOrder.clear()
this.actions.removeAll()
mainActions.removeAll()
for (action in actions) {
val btn = makeButton(action.text, action.primary).apply {
isEnabled = action.enabled
@@ -190,7 +201,7 @@ class BaseQuestionView : RoundedContentPanel(
}
actionButtons[action.id] = btn
actionOrder.add(action.id)
this.actions.next(btn)
mainActions.next(btn)
}
syncFooter()
}
@@ -211,9 +222,11 @@ class BaseQuestionView : RoundedContentPanel(
@RequiresEdt
fun setActionLeft(component: JComponent?) {
actionLeft = component
val old = (footer.layout as BorderLayout).getLayoutComponent(BorderLayout.WEST)
if (old != null) footer.remove(old)
component?.let { footer.add(it, BorderLayout.WEST) }
sideActions.removeAll()
component?.let {
it.isOpaque = false
sideActions.next(it).fill(UiStyle.Gap.pad())
}
syncFooter()
}
@@ -226,8 +239,8 @@ class BaseQuestionView : RoundedContentPanel(
val btn = actionButtons[id] ?: return
if (btn.isVisible == visible) return
btn.isVisible = visible
actions.revalidate()
actions.repaint()
mainActions.revalidate()
mainActions.repaint()
}
/**
@@ -279,11 +292,17 @@ class BaseQuestionView : RoundedContentPanel(
private fun syncFooter() {
val layout = footer.layout as BorderLayout
val west = layout.getLayoutComponent(BorderLayout.WEST)
val east = layout.getLayoutComponent(BorderLayout.EAST)
if (actionLeft == null) {
if (west != null) footer.remove(west)
} else if (west == null) {
footer.add(sideActions, BorderLayout.WEST)
}
if (actionOrder.isEmpty()) {
if (east != null) footer.remove(east)
} else if (east == null) {
footer.add(actions, BorderLayout.EAST)
footer.add(mainActions, BorderLayout.EAST)
}
val root = this.layout as BorderLayout
@@ -345,7 +364,7 @@ class BaseQuestionView : RoundedContentPanel(
}
private fun applyFont(area: JBTextArea, bold: Boolean) {
val font = if (bold) style.headerFont else style.hintFont
val font = if (bold) UiStyle.Fonts.heading() else style.hintFont
if (area.font != font) area.font = font
}
@@ -1,7 +1,9 @@
package ai.kilocode.client.ui.layout
import java.awt.Component
import java.awt.Container
import java.awt.Dimension
import java.awt.LayoutManager2
import javax.swing.JPanel
enum class HAlign { TRACK, FIT, LEFT, CENTER, RIGHT }
@@ -37,77 +39,91 @@ enum class VAlign { TRACK, FIT, TOP, CENTER, BOTTOM }
*/
class Align(
child: Component,
private val h: HAlign = HAlign.FIT,
private val v: VAlign = VAlign.FIT,
) : JPanel(null) {
h: HAlign = HAlign.FIT,
v: VAlign = VAlign.FIT,
) : JPanel(Layout(h, v)) {
init {
isOpaque = false
add(child)
}
// -----------------------------------------------------------------------
// Layout
// -----------------------------------------------------------------------
private class Layout(
private val h: HAlign,
private val v: VAlign,
) : LayoutManager2 {
override fun doLayout() {
if (componentCount == 0) return
val child = getComponent(0)
val ins = insets
val availW = maxOf(0, width - ins.left - ins.right)
val availH = maxOf(0, height - ins.top - ins.bottom)
override fun addLayoutComponent(comp: Component, constraints: Any?) = Unit
override fun addLayoutComponent(name: String?, comp: Component) = Unit
override fun removeLayoutComponent(comp: Component) = Unit
val min = child.minimumSize
val max = child.maximumSize
child.setSize(probe(h, availW, min.width, max.width), probe(v, availH, min.height, max.height))
val pref = child.preferredSize
override fun layoutContainer(parent: Container) {
if (parent.componentCount == 0) return
val child = parent.getComponent(0)
val ins = parent.insets
val availW = maxOf(0, parent.width - ins.left - ins.right)
val availH = maxOf(0, parent.height - ins.top - ins.bottom)
val (w, cx) = placeAxis(h, availW, min.width, pref.width, max.width)
val (ht, cy) = placeAxis(v, availH, min.height, pref.height, max.height)
val min = child.minimumSize
val max = child.maximumSize
child.setSize(probe(h, availW, min.width, max.width), probe(v, availH, min.height, max.height))
val pref = child.preferredSize
child.setBounds(ins.left + cx, ins.top + cy, w, ht)
}
val (w, cx) = place(h, availW, min.width, pref.width, max.width)
val (ht, cy) = place(v, availH, min.height, pref.height, max.height)
// -----------------------------------------------------------------------
// Wrapper size negotiation
// -----------------------------------------------------------------------
override fun getMinimumSize(): Dimension {
if (componentCount == 0) return super.getMinimumSize()
val child = getComponent(0)
val ins = insets
val cw = if (h == HAlign.TRACK) 0 else child.minimumSize.width
val ch = if (v == VAlign.TRACK) 0 else child.minimumSize.height
return Dimension(cw + ins.left + ins.right, ch + ins.top + ins.bottom)
}
override fun getPreferredSize(): Dimension {
if (componentCount == 0) return super.getPreferredSize()
val child = getComponent(0)
val ins = insets
val min = child.minimumSize
val max = child.maximumSize
val availW = maxOf(0, width - ins.left - ins.right)
val availH = maxOf(0, height - ins.top - ins.bottom)
if (availW > 0 || availH > 0) {
child.setSize(
if (availW > 0) probe(h, availW, min.width, max.width) else child.width,
if (availH > 0) probe(v, availH, min.height, max.height) else child.height,
)
child.setBounds(ins.left + cx, ins.top + cy, w, ht)
}
val pref = child.preferredSize
val cw = if (h == HAlign.TRACK) 0 else bounded(pref.width, min.width, max.width)
val ch = if (v == VAlign.TRACK) 0 else bounded(pref.height, min.height, max.height)
return Dimension(cw + ins.left + ins.right, ch + ins.top + ins.bottom)
}
override fun getMaximumSize(): Dimension {
if (componentCount == 0) return super.getMaximumSize()
val child = getComponent(0)
val ins = insets
val cw = if (h == HAlign.TRACK) super.getMaximumSize().width else maxOf(child.minimumSize.width, child.maximumSize.width) + ins.left + ins.right
val ch = if (v == VAlign.TRACK) super.getMaximumSize().height else maxOf(child.minimumSize.height, child.maximumSize.height) + ins.top + ins.bottom
return Dimension(cw, ch)
override fun minimumLayoutSize(parent: Container): Dimension {
if (parent.componentCount == 0) return Dimension(0, 0)
val child = parent.getComponent(0)
val ins = parent.insets
val cw = if (h == HAlign.TRACK) 0 else child.minimumSize.width
val ch = if (v == VAlign.TRACK) 0 else child.minimumSize.height
return Dimension(cw + ins.left + ins.right, ch + ins.top + ins.bottom)
}
override fun preferredLayoutSize(parent: Container): Dimension {
if (parent.componentCount == 0) return Dimension(0, 0)
val child = parent.getComponent(0)
val ins = parent.insets
val min = child.minimumSize
val max = child.maximumSize
val availW = maxOf(0, parent.width - ins.left - ins.right)
val availH = maxOf(0, parent.height - ins.top - ins.bottom)
if (availW > 0 || availH > 0) {
child.setSize(
if (availW > 0) probe(h, availW, min.width, max.width) else child.width,
if (availH > 0) probe(v, availH, min.height, max.height) else child.height,
)
}
val pref = child.preferredSize
val cw = if (h == HAlign.TRACK) 0 else bounded(pref.width, min.width, max.width)
val ch = if (v == VAlign.TRACK) 0 else bounded(pref.height, min.height, max.height)
return Dimension(cw + ins.left + ins.right, ch + ins.top + ins.bottom)
}
override fun maximumLayoutSize(target: Container): Dimension {
if (target.componentCount == 0) return Dimension(Int.MAX_VALUE, Int.MAX_VALUE)
val child = target.getComponent(0)
val ins = target.insets
val cw = if (h == HAlign.TRACK) {
Int.MAX_VALUE
} else {
maxOf(child.minimumSize.width, child.maximumSize.width) + ins.left + ins.right
}
val ch = if (v == VAlign.TRACK) {
Int.MAX_VALUE
} else {
maxOf(child.minimumSize.height, child.maximumSize.height) + ins.top + ins.bottom
}
return Dimension(cw, ch)
}
override fun getLayoutAlignmentX(target: Container) = 0.5f
override fun getLayoutAlignmentY(target: Container) = 0.5f
override fun invalidateLayout(target: Container) = Unit
}
}
@@ -121,7 +137,7 @@ class Align(
* - FIT: size = clamp(avail, min, max), offset = 0
* - edge/center: size = clamp(boundedPref, 0, avail), offset positions according to alignment
*/
private fun placeAxis(mode: Any, avail: Int, min: Int, pref: Int, max: Int): Pair<Int, Int> {
private fun place(mode: Any, avail: Int, min: Int, pref: Int, max: Int): Pair<Int, Int> {
val effMax = maxOf(min, max)
return when (mode) {
HAlign.TRACK, VAlign.TRACK -> avail to 0
@@ -3,8 +3,10 @@ package ai.kilocode.client.migration
import ai.kilocode.client.session.SessionUiTestBase
import ai.kilocode.client.session.ui.SessionRootPanel
import ai.kilocode.client.session.ui.prompt.PromptPanel
import ai.kilocode.client.migration.ui.MigrationItemRow
import ai.kilocode.client.migration.ui.MigrationOverlayPanel
import ai.kilocode.client.migration.ui.MigrationWizardPanel
import ai.kilocode.client.ui.layout.Align
import ai.kilocode.rpc.dto.LegacyMigrationDetectionDto
import ai.kilocode.rpc.dto.MigrationProviderInfoDto
import java.awt.Rectangle
@@ -40,6 +42,16 @@ class SessionUiMigrationTest : SessionUiTestBase() {
assertEquals(1, root.blocker.componentCount)
}
fun `test visible migration state lays out content before resize`() {
fakeMigration._state.value = MigrationUiState.Needed(detection = sampleDetection())
settle()
val row = find<MigrationItemRow>(ui)
assertTrue("migration row should be visible", row.isVisible)
assertTrue("migration row width should be laid out before resize: ${row.bounds}", row.width > 0)
assertTrue("migration row height should be laid out before resize: ${row.bounds}", row.height > 0)
}
fun `test migration opens on selection screen with keep file checked`() {
fakeMigration._state.value = MigrationUiState.Needed(detection = sampleDetection())
settle()
@@ -48,6 +60,23 @@ class SessionUiMigrationTest : SessionUiTestBase() {
assertTrue(wizard.keepLegacySettingsFileSelectedForTest())
}
fun `test migration wizard is centered in overlay`() {
fakeMigration._state.value = MigrationUiState.Needed(detection = sampleDetection())
settle()
layout()
val overlay = find<MigrationOverlayPanel>(ui)
overlay.doLayout()
val align = find<Align>(overlay)
align.doLayout()
val wizard = find<MigrationWizardPanel>(overlay)
assertTrue("align wrapper should fill most overlay width", align.width > overlay.width / 2)
assertTrue("align wrapper should fill most overlay height", align.height > overlay.height / 2)
assertTrue("wizard should be horizontally centered: ${wizard.bounds} in ${align.bounds}", kotlin.math.abs(wizard.x - (align.width - wizard.width) / 2) <= 1)
assertTrue("wizard should be vertically centered: ${wizard.bounds} in ${align.bounds}", kotlin.math.abs(wizard.y - (align.height - wizard.height) / 2) <= 1)
}
fun `test hidden state after visible hides blocker`() {
val root = find<SessionRootPanel>(ui)
fakeMigration._state.value = MigrationUiState.Needed(detection = sampleDetection())
@@ -108,4 +137,5 @@ class SessionUiMigrationTest : SessionUiTestBase() {
settings = null,
hasData = true,
)
}
@@ -3,6 +3,7 @@ package ai.kilocode.client.session.views.base
import ai.kilocode.client.session.ui.style.SessionEditorStyle
import ai.kilocode.client.session.ui.style.SessionUiStyle
import ai.kilocode.client.ui.UiStyle
import ai.kilocode.client.ui.layout.Align
import com.intellij.icons.AllIcons
import com.intellij.ide.ui.laf.darcula.ui.DarculaButtonUI
import com.intellij.openapi.application.ApplicationManager
@@ -164,7 +165,7 @@ class BaseQuestionViewTest : BasePlatformTestCase() {
val north = region(panel, BorderLayout.NORTH) as Container
val filler = north.components.last()
assertEquals(UiStyle.Gap.pad(), filler.preferredSize.height)
assertEquals(UiStyle.Gap.md(), filler.preferredSize.height)
assertEquals(0, filler.preferredSize.width)
}
}
@@ -255,15 +256,26 @@ class BaseQuestionViewTest : BasePlatformTestCase() {
fun `test header row uses icon west and text stack center`() {
edt {
val panel = BaseQuestionView()
panel.setHeaderIcon(AllIcons.General.Warning)
val header = headerRow(panel)!!
val layout = header.layout as BorderLayout
val west = layout.getLayoutComponent(BorderLayout.WEST)
val center = layout.getLayoutComponent(BorderLayout.CENTER) as Container
assertTrue("icon should be a JBLabel", west is JBLabel)
assertTrue("icon should be top-aligned", west is Align)
assertTrue("icon wrapper should contain a JBLabel", findAll<JBLabel>(west as Container).isNotEmpty())
assertTrue("center should contain header and description text", findAll<JBTextArea>(center).size >= 2)
}
}
fun `test header row has no west icon gap by default`() {
edt {
val panel = BaseQuestionView()
val header = headerRow(panel)!!
val west = (header.layout as BorderLayout).getLayoutComponent(BorderLayout.WEST)
assertNull("header should not reserve icon space when icon is absent", west)
}
}
fun `test action footer is in south with buttons east`() {
edt {
val panel = BaseQuestionView()
@@ -281,8 +293,31 @@ class BaseQuestionViewTest : BasePlatformTestCase() {
val left = JLabel("left")
panel.setActionLeft(left)
val footer = region(panel, BorderLayout.SOUTH) as JPanel
val west = (footer.layout as BorderLayout).getLayoutComponent(BorderLayout.WEST)
assertSame("action left should be in footer west", left, west)
val west = (footer.layout as BorderLayout).getLayoutComponent(BorderLayout.WEST) as Container
assertNotNull("action left should be in footer west", find(west, left))
}
}
fun `test action left component is transparent`() {
edt {
val panel = BaseQuestionView()
val left = JPanel()
panel.setActionLeft(left)
assertFalse("action left should be transparent", left.isOpaque)
}
}
fun `test footer adds bottom padding gap after side actions`() {
edt {
val panel = BaseQuestionView()
panel.setActionLeft(JLabel("left"))
panel.setActions(listOf(BaseQuestionView.Action("ok", "OK", primary = true) {}))
val footer = region(panel, BorderLayout.SOUTH) as JPanel
val west = (footer.layout as BorderLayout).getLayoutComponent(BorderLayout.WEST) as Container
val filler = west.components.toList().firstOrNull { it.preferredSize.width == UiStyle.Gap.pad() }
assertNotNull("side actions should include trailing gap", filler)
assertEquals(0, filler!!.preferredSize.height)
}
}
@@ -329,7 +364,7 @@ class BaseQuestionViewTest : BasePlatformTestCase() {
val style = SessionEditorStyle.current()
panel.applyStyle(style)
assertEquals("headerText should use headerFont", style.headerFont, panel.headerFont())
assertEquals("headerText should use heading font", UiStyle.Fonts.heading(), panel.headerFont())
assertEquals("descriptionText should use hintFont", style.hintFont, panel.descriptionFont())
}
}