From b37e3fd2a109301536ab6e46d47259829e4bfca1 Mon Sep 17 00:00:00 2001 From: kirillk Date: Mon, 25 May 2026 14:42:46 -0400 Subject: [PATCH] fix(jetbrains): refine migration wizard layout --- .../migration/ui/MigrationOverlayPanel.kt | 14 +- .../migration/ui/MigrationWizardPanel.kt | 236 ++++++------------ .../session/views/base/BaseQuestionView.kt | 41 ++- .../ai/kilocode/client/ui/layout/Align.kt | 134 +++++----- .../migration/SessionUiMigrationTest.kt | 30 +++ .../views/base/BaseQuestionViewTest.kt | 45 +++- 6 files changed, 260 insertions(+), 240 deletions(-) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/migration/ui/MigrationOverlayPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/migration/ui/MigrationOverlayPanel.kt index 4c441c2a317..a9268d86e73 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/migration/ui/MigrationOverlayPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/migration/ui/MigrationOverlayPanel.kt @@ -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(BorderLayout()) { @@ -40,13 +43,16 @@ class MigrationOverlayPanel : JBPanel(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() } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/migration/ui/MigrationWizardPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/migration/ui/MigrationWizardPanel.kt index 6b2e77eef8e..a19777087e9 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/migration/ui/MigrationWizardPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/migration/ui/MigrationWizardPanel.kt @@ -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() 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) { @@ -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) } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/BaseQuestionView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/BaseQuestionView.kt index a63ee4b2ab9..76ba0440f34 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/BaseQuestionView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/BaseQuestionView.kt @@ -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() private val actionOrder = mutableListOf() - 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) { 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 } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/layout/Align.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/layout/Align.kt index 77ca35f481b..724a9a6db0e 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/layout/Align.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/layout/Align.kt @@ -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 { +private fun place(mode: Any, avail: Int, min: Int, pref: Int, max: Int): Pair { val effMax = maxOf(min, max) return when (mode) { HAlign.TRACK, VAlign.TRACK -> avail to 0 diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/migration/SessionUiMigrationTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/migration/SessionUiMigrationTest.kt index 2f68c705de3..0ab9f2d0152 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/migration/SessionUiMigrationTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/migration/SessionUiMigrationTest.kt @@ -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(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(ui) + overlay.doLayout() + val align = find(overlay) + align.doLayout() + val wizard = find(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(ui) fakeMigration._state.value = MigrationUiState.Needed(detection = sampleDetection()) @@ -108,4 +137,5 @@ class SessionUiMigrationTest : SessionUiTestBase() { settings = null, hasData = true, ) + } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/BaseQuestionViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/BaseQuestionViewTest.kt index 3bd3a27db48..48647eab1c7 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/BaseQuestionViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/BaseQuestionViewTest.kt @@ -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(west as Container).isNotEmpty()) assertTrue("center should contain header and description text", findAll(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()) } }