From a89936bf8e197358b8be6ce40a319a75d3478ae1 Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 4 Aug 2026 10:49:58 -0400 Subject: [PATCH] feat(jetbrains): use active list for history rows --- .changeset/polite-history-rows.md | 5 + .../session/history/HistoryListRenderer.kt | 165 ---------- .../client/session/history/HistoryPanel.kt | 284 +++++++----------- .../client/session/history/HistoryRows.kt | 92 ++++++ .../ai/kilocode/client/ui/list/ActiveList.kt | 23 +- .../kilocode/client/ui/list/ActiveListView.kt | 6 + .../session/history/HistoryControllerTest.kt | 87 +++--- 7 files changed, 280 insertions(+), 382 deletions(-) create mode 100644 .changeset/polite-history-rows.md delete mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryListRenderer.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryRows.kt diff --git a/.changeset/polite-history-rows.md b/.changeset/polite-history-rows.md new file mode 100644 index 00000000000..2ebbc04443f --- /dev/null +++ b/.changeset/polite-history-rows.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Use the shared active list for session history rows with hover-revealed local delete actions. diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryListRenderer.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryListRenderer.kt deleted file mode 100644 index 12971f4e695..00000000000 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryListRenderer.kt +++ /dev/null @@ -1,165 +0,0 @@ -package ai.kilocode.client.session.history - -import ai.kilocode.client.session.ui.PickerRow -import ai.kilocode.client.session.SessionActivityKind -import ai.kilocode.client.ui.FilledBadgeIcon -import ai.kilocode.client.ui.UiStyle -import com.intellij.icons.AllIcons -import com.intellij.ui.GroupHeaderSeparator -import com.intellij.ui.SimpleColoredComponent -import com.intellij.ui.SimpleTextAttributes -import com.intellij.ui.components.JBLabel -import com.intellij.util.ui.EmptyIcon -import com.intellij.util.ui.JBUI -import com.intellij.util.ui.UIUtil -import java.awt.BorderLayout -import java.awt.Point -import java.awt.Rectangle -import java.awt.Component -import javax.swing.Icon -import javax.swing.JList -import javax.swing.JPanel -import javax.swing.ListCellRenderer -import javax.swing.SwingConstants - -private const val DELETE_AREA_WIDTH = 32 - -internal open class HistoryRenderer( - private val model: HistoryModel, - private val deletable: Boolean, - private val activity: () -> Map, - private val titles: () -> Map = { emptyMap() }, -) : JPanel(BorderLayout()), ListCellRenderer { - companion object { - private val icon: Icon = AllIcons.Actions.GC - private val empty: Icon = EmptyIcon.create(icon) - - fun isDeleteClick(list: JList<*>, bounds: Rectangle, point: Point): Boolean { - val width = JBUI.scale(DELETE_AREA_WIDTH) - return if (list.componentOrientation.isLeftToRight) { - val right = bounds.x + bounds.width - point.x in (right - width)..right - } else { - val left = bounds.x - point.x in left..(left + width) - } - } - - fun section(items: List, index: Int): String? { - val item = items.getOrNull(index) ?: return null - val current = HistoryTime.section(item) - val previous = items.getOrNull(index - 1)?.let(HistoryTime::section) - if (current == previous) return null - return HistoryTime.title(current) - } - } - - private val sep = GroupHeaderSeparator(JBUI.CurrentTheme.Popup.separatorLabelInsets()) - private val top = JPanel(BorderLayout()).apply { - border = JBUI.Borders.empty() - add(sep, BorderLayout.NORTH) - } - private val title = SimpleColoredComponent() - private val badge = BadgeLabel() - private val time = JBLabel() - private val del = JBLabel().apply { - horizontalAlignment = SwingConstants.CENTER - verticalAlignment = SwingConstants.CENTER - border = JBUI.Borders.emptyLeft(JBUI.CurrentTheme.ActionsList.elementIconGap()) - } - private val head = JPanel(BorderLayout(UiStyle.Gap.xs(), 0)).apply { - add(title, BorderLayout.CENTER) - add(badge, BorderLayout.EAST) - } - private val main = JPanel(BorderLayout(UiStyle.Gap.md(), 0)).apply { - add(head, BorderLayout.CENTER) - add(time, BorderLayout.EAST) - } - private val row = JPanel(BorderLayout()).apply { - add(main, BorderLayout.CENTER) - if (deletable) add(del, BorderLayout.EAST) - } - private val wrap = PickerRow() - private var text = "" - - init { - isOpaque = true - top.isOpaque = true - row.border = JBUI.Borders.empty( - UiStyle.Gap.lg(), - UiStyle.Gap.lg(), - UiStyle.Gap.lg(), - UiStyle.Gap.lg(), - ) - UiStyle.Components.transparent(row, main, head, title, badge, time, del) - wrap.setContent(row) - add(top, BorderLayout.NORTH) - add(wrap, BorderLayout.CENTER) - } - - override fun getListCellRendererComponent( - list: JList, - value: T?, - index: Int, - selected: Boolean, - focus: Boolean, - ): JPanel { - val focused = selected || list.hasFocus() || focus - val fg = UIUtil.getListForeground(selected, focused) - val weak = if (selected) fg else UIUtil.getContextHelpForeground() - - background = list.background - top.background = list.background - wrap.update(list, selected, focused) - sep.caption = section(model.visibleItems, index) - sep.setHideLine(index == 0) - top.isVisible = sep.caption != null - - title.clear() - text = value?.let { titles()[it.id] ?: title(it) }.orEmpty() - title.append( - text, - SimpleTextAttributes(SimpleTextAttributes.STYLE_BOLD, fg), - ) - time.text = value?.let(HistoryTime::relative).orEmpty() - time.foreground = weak - badge.setKind(value?.id?.let(activity()::get)) - if (deletable) del.icon = if (selected) icon else empty - - top.invalidate() - return this - } - - internal fun runningVisible() = badge.isVisible - - internal fun badgeText() = badge.kind?.label() - - internal fun titleText() = text - - private class BadgeLabel : JBLabel() { - var kind: SessionActivityKind? = null - private set - - init { - border = JBUI.Borders.emptyLeft(JBUI.CurrentTheme.ActionsList.elementIconGap()) - alignmentY = Component.CENTER_ALIGNMENT - } - - fun setKind(value: SessionActivityKind?) { - kind = value - isVisible = value != null - icon = value?.let { FilledBadgeIcon(it.label(), it.style()) } - } - } -} - -internal class LocalHistoryRenderer( - model: HistoryModel, - activity: () -> Map = { emptyMap() }, - titles: () -> Map = { emptyMap() }, -) : HistoryRenderer(model, deletable = true, activity, titles) - -internal class CloudHistoryRenderer( - model: HistoryModel, - activity: () -> Map = { emptyMap() }, -) : HistoryRenderer(model, deletable = false, activity) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryPanel.kt index f521687e4d0..a5834b1bef0 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryPanel.kt @@ -4,35 +4,35 @@ import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.session.SessionManager import ai.kilocode.client.session.ui.LoadingPanel import ai.kilocode.client.session.ui.style.SessionEditorStyle -import ai.kilocode.client.ui.UiStyle import ai.kilocode.client.ui.HoverIcon +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 ai.kilocode.client.ui.list.ActiveList +import ai.kilocode.client.ui.list.ActiveListConfig +import ai.kilocode.client.ui.list.ActiveListDeleteOptions +import ai.kilocode.client.ui.list.ActiveListItem +import ai.kilocode.client.ui.list.ActiveListSelection import ai.kilocode.client.util.UiTimerSource import ai.kilocode.client.util.UiTimers import com.intellij.icons.AllIcons import com.intellij.ide.ui.LafManagerListener -import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.Disposable import com.intellij.openapi.actionSystem.ActionGroup import com.intellij.openapi.actionSystem.ActionManager -import com.intellij.openapi.actionSystem.ActionPlaces import com.intellij.openapi.actionSystem.DataProvider +import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.util.Disposer -import com.intellij.util.concurrency.annotations.RequiresEdt import com.intellij.ui.DocumentAdapter -import com.intellij.ui.PopupHandler import com.intellij.ui.SearchTextField -import com.intellij.ui.ScrollingUtil import com.intellij.ui.components.JBCheckBox -import com.intellij.ui.components.JBList -import com.intellij.ui.components.JBScrollPane import com.intellij.ui.tabs.JBTabs import com.intellij.ui.tabs.JBTabsFactory import com.intellij.ui.tabs.JBTabsPosition import com.intellij.ui.tabs.TabInfo import com.intellij.ui.tabs.TabsListener +import com.intellij.util.concurrency.annotations.RequiresEdt import com.intellij.util.ui.Centerizer import com.intellij.util.ui.JBUI import com.intellij.util.ui.UIUtil @@ -49,10 +49,8 @@ import java.awt.event.MouseAdapter import java.awt.event.MouseEvent import javax.swing.JButton import javax.swing.JComponent -import javax.swing.JList import javax.swing.KeyStroke import javax.swing.ListSelectionModel -import javax.swing.ScrollPaneConstants import javax.swing.SwingUtilities import javax.swing.event.DocumentEvent import javax.swing.event.ListDataEvent @@ -68,6 +66,8 @@ class HistoryPanel( private val localSearch = search(controller.local) private val cloudSearch = search(controller.cloud) private var snapshot = HistoryActivitySnapshot() + private var localRows = emptyList() + private var cloudRows = emptyList() private val localList = localList() private val cloudList = cloudList() private val more = LoadMoreButton() @@ -106,8 +106,8 @@ class HistoryPanel( Disposer.register(parent, this) border = JBUI.Borders.empty(UiStyle.Gap.lg(), UiStyle.Gap.lg(), UiStyle.Gap.lg(), 0) more.addActionListener { controller.loadMoreCloud() } - bind(localList, controller.local) - bind(cloudList, controller.cloud) + bind(controller.local) + bind(controller.cloud) bindTheme() controller.onRepoOnlyChanged = { value -> repoOnly.isSelected = value @@ -154,16 +154,9 @@ class HistoryPanel( SwingUtilities.updateComponentTreeUI(localPanel) SwingUtilities.updateComponentTreeUI(cloudPanel) load.applyStyle(SessionEditorStyle.current()) - updateRenderer(localList) - updateRenderer(cloudList) sync() } - private fun updateRenderer(list: JBList) { - val view = list.cellRenderer - if (view is JComponent) SwingUtilities.updateComponentTreeUI(view) - } - private fun search(model: HistoryModel) = SearchTextField(false).apply { textEditor.emptyText.text = KiloBundle.message("history.search.placeholder") textEditor.document.addDocumentListener(object : DocumentAdapter() { @@ -182,7 +175,7 @@ class HistoryPanel( JComponent.WHEN_FOCUSED, ) textEditor.registerKeyboardAction( - { activeList().selectedValue?.let(::activate) }, + { activeList().selected()?.let(::activate) }, KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, 0), JComponent.WHEN_FOCUSED, ) @@ -202,20 +195,17 @@ class HistoryPanel( } } - private fun panel(search: SearchTextField, list: JList, footer: JComponent? = null): JComponent { + private fun panel(search: SearchTextField, list: ActiveList, footer: JComponent? = null): JComponent { return BorderLayoutPanel().apply { val north = BorderLayoutPanel().apply { add(search, BorderLayout.CENTER) - if (list === cloudList) { - add(repoOnly, BorderLayout.SOUTH) - } + if (list === cloudList) add(repoOnly, BorderLayout.SOUTH) border = JBUI.Borders.emptyRight(UiStyle.Gap.lg()) } add(north, BorderLayout.NORTH) - add(JBScrollPane(list).apply { - border = JBUI.Borders.empty() - viewportBorder = JBUI.Borders.emptyRight(UiStyle.Gap.lg()) - horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER + add(BorderLayoutPanel().apply { + border = JBUI.Borders.emptyRight(UiStyle.Gap.lg()) + addToCenter(list) }, BorderLayout.CENTER) footer?.let { add(Centerizer(it, Centerizer.TYPE.HORIZONTAL).apply { @@ -225,55 +215,35 @@ class HistoryPanel( } } - private fun localList() = HistoryList(controller.local).apply { - selectionMode = ListSelectionModel.MULTIPLE_INTERVAL_SELECTION - isFocusable = true - cellRenderer = LocalHistoryRenderer(controller.local, { snapshot.activity }, { snapshot.titles }) - cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) - emptyText.text = KiloBundle.message("history.empty") - addMouseListener(object : MouseAdapter() { - override fun mouseClicked(e: MouseEvent) { - val row = locationToIndex(e.point) - val box = row.takeIf { it >= 0 }?.let { getCellBounds(it, it) } ?: return - if (!box.contains(e.point)) return - if (e.clickCount == 1 && HistoryRenderer.isDeleteClick(this@apply, box, e.point)) { - val item = model.getElementAt(row) - confirm(item) - } else if (e.clickCount == 2) { - selectedValue?.let(::activate) - } - } - }) - registerKeyboardAction( - { selectedValue?.let(::activate) }, - KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, 0), - JComponent.WHEN_FOCUSED, - ) + private fun localList() = ActiveList( + KiloBundle.message("history.empty"), + cfg = ActiveListConfig(selection = ListSelectionModel.MULTIPLE_INTERVAL_SELECTION, hoverActions = true), + showSearch = false, + openOnClick = false, + onCell = { key, id -> + if (id != HISTORY_DELETE_CELL) return@ActiveList + val item = localRows.firstOrNull { it.key == key }?.item ?: return@ActiveList + showDeletePopup(listOf(item), HISTORY_DELETE_CELL) + }, + onOpen = { row, _ -> activate(row) }, + ).apply { + setListCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)) installContextMenu(this) - ScrollingUtil.installActions(this) } - private fun cloudList() = HistoryList(controller.cloud).apply { - selectionMode = ListSelectionModel.SINGLE_SELECTION - isFocusable = true - cellRenderer = CloudHistoryRenderer(controller.cloud) { snapshot.activity } - cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) - emptyText.text = KiloBundle.message("history.empty") - addMouseListener(object : MouseAdapter() { - override fun mouseClicked(e: MouseEvent) { - if (e.clickCount == 2) selectedValue?.let(::activate) - } - }) - registerKeyboardAction( - { selectedValue?.let(::activate) }, - KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, 0), - JComponent.WHEN_FOCUSED, - ) + private fun cloudList() = ActiveList( + KiloBundle.message("history.empty"), + cfg = ActiveListConfig(selection = ListSelectionModel.SINGLE_SELECTION), + showSearch = false, + openOnClick = false, + onCell = { _, _ -> }, + onOpen = { row, _ -> activate(row) }, + ).apply { + setListCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)) installContextMenu(this) - ScrollingUtil.installActions(this) } - private fun bind(list: JBList, model: HistoryModel) { + private fun bind(model: HistoryModel) { val listener = object : ListDataListener { override fun intervalAdded(e: ListDataEvent) = sync() @@ -283,10 +253,10 @@ class HistoryPanel( } model.addListDataListener(listener) Disposer.register(this) { model.removeListDataListener(listener) } - list.setPaintBusy(model.loading) } private fun sync() { + syncRows() syncList(localList, controller.local) syncList(cloudList, controller.cloud) more.isEnabled = controller.cloud.cursor != null && !controller.cloud.loading @@ -297,24 +267,33 @@ class HistoryPanel( repaint() } + private fun syncRows() { + localRows = localHistoryRows(controller.local.visibleItems, snapshot, controller::deleting) + cloudRows = cloudHistoryRows(controller.cloud.visibleItems, snapshot) + restore(localList, localRows) + restore(cloudList, cloudRows) + } + + private fun restore(list: ActiveList, rows: List) { + val keys = list.selectedKeys() + list.update(rows, ActiveListSelection.PreserveNoScroll) + val indices = keys.mapNotNull { key -> rows.indexOfFirst { it.key == key }.takeIf { it >= 0 } }.toIntArray() + if (indices.isEmpty()) { + list.clearSelection() + return + } + list.setSelectionIndices(indices) + } + @RequiresEdt internal fun syncActivity() { val next = HistoryActivitySnapshot( activity = manager?.activity() ?: controller.activity(), titles = manager?.titles().orEmpty(), ) - val changed = snapshot.changed(next) + if (snapshot.changed(next).isEmpty()) return snapshot = next - repaintRows(localList, controller.local, changed) - repaintRows(cloudList, controller.cloud, changed) - } - - private fun repaintRows(list: JBList, model: HistoryModel, ids: Set) { - if (ids.isEmpty()) return - model.visibleItems.forEachIndexed { index, item -> - if (item.id !in ids) return@forEachIndexed - list.getCellBounds(index, index)?.let(list::repaint) - } + syncRows() } private fun loading(): Boolean { @@ -322,19 +301,19 @@ class HistoryPanel( return controller.local.loading || controller.cloud.loading } - private fun syncList(list: JBList, model: HistoryModel) { - list.setPaintBusy(model.loading) - list.emptyText.text = when { + private fun syncList(list: ActiveList, model: HistoryModel) { + list.setBusy(model.loading) + list.setEmptyText(when { model.loading -> KiloBundle.message("history.loading") model.error != null -> model.error.orEmpty() else -> KiloBundle.message("history.empty") - } + }) } - private fun activate(item: HistoryItem) { - when (item) { - is LocalHistoryItem -> controller.open(item) - is CloudHistoryItem -> controller.open(item) + private fun activate(row: ActiveListItem) { + when (row) { + is LocalHistoryRow -> controller.open(row.item) + is CloudHistoryRow -> controller.open(row.item) } } @@ -343,70 +322,66 @@ class HistoryPanel( if (HistoryDataKeys.CONTROLLER.`is`(dataId)) return controller if (HistoryDataKeys.SELECTION.`is`(dataId)) { val source = selectedSource() - val local = if (source == HistorySource.LOCAL) localList.selectedValuesList.filterIsInstance() else emptyList() - val cloud = if (source == HistorySource.CLOUD) cloudList.selectedValuesList.filterIsInstance() else emptyList() + val local = if (source == HistorySource.LOCAL) { + localList.selectedItems().filterIsInstance().map { it.item } + } else { + emptyList() + } + val cloud = if (source == HistorySource.CLOUD) { + cloudList.selectedItems().filterIsInstance().map { it.item } + } else { + emptyList() + } return HistorySelection(source, local, cloud) } return null } - private fun installContextMenu(list: JBList) { + private fun installContextMenu(list: ActiveList) { val group = ActionManager.getInstance().getAction("Kilo.History.ContextMenu") - if (group is ActionGroup) { - PopupHandler.installPopupMenu(list, group, ActionPlaces.POPUP) - } + if (group is ActionGroup) list.installPopup(group) } - private fun confirm(item: LocalHistoryItem) { - if (controller.deleting(item)) return - val result = com.intellij.openapi.ui.Messages.showYesNoDialog( - this, - KiloBundle.message("history.delete.confirm.message", title(item)), - KiloBundle.message("history.delete.confirm.title"), - com.intellij.openapi.ui.Messages.getWarningIcon(), - ) - if (result != com.intellij.openapi.ui.Messages.YES) return - controller.delete(item) + private fun showDeletePopup(items: List, cell: String? = null) { + val active = items.filter { !controller.deleting(it) } + if (active.isEmpty()) return + val msg = if (active.size == 1) { + KiloBundle.message("history.delete.confirm.message", title(active[0])) + } else { + KiloBundle.message("history.delete.confirm.message.multiple", active.size) + } + controller.requestDelete(active.size) + localList.confirmDelete( + localList.point(active[0].id, cell), + ActiveListDeleteOptions(message = msg), + ) { active.forEach(controller::delete) } } internal fun confirmDelete(items: List) { - val active = items.filter { !controller.deleting(it) } - if (active.isEmpty()) return - val msg = if (active.size == 1) - KiloBundle.message("history.delete.confirm.message", title(active[0])) - else - KiloBundle.message("history.delete.confirm.message.multiple", active.size) - val result = com.intellij.openapi.ui.Messages.showYesNoDialog( - this, - msg, - KiloBundle.message("history.delete.confirm.title"), - com.intellij.openapi.ui.Messages.getWarningIcon(), - ) - if (result != com.intellij.openapi.ui.Messages.YES) return - active.forEach { controller.delete(it) } + showDeletePopup(items) } - internal fun itemCount() = activeModel().size + internal fun itemCount() = activeRows().size internal fun selectedSource() = if (tabs.selectedInfo === cloudInfo) HistorySource.CLOUD else HistorySource.LOCAL internal fun select(index: Int) { - activeList().selectedIndex = index + activeList().selectIndex(index) } internal fun selectIndices(vararg indices: Int) { - activeList().selectedIndices = indices + activeList().setSelectionIndices(indices) } - internal fun selectedIndex() = activeList().selectedIndex + internal fun selectedIndex() = activeList().selectedIndex() - internal fun listFocusable() = activeList().isFocusable + internal fun listFocusable() = activeList().preferredFocus().isFocusable - internal fun listSelectionMode() = activeList().selectionMode + internal fun listSelectionMode() = (activeList().preferredFocus() as javax.swing.JList<*>).selectionMode internal fun loadMoreFocusable() = more.isFocusable - internal fun listCursor() = activeList().cursor.type + internal fun listCursor() = activeList().preferredFocus().cursor.type internal fun backText(): String? { val view = activeInfo().foreSideComponent ?: return null @@ -424,8 +399,8 @@ class HistoryPanel( } internal fun clickDelete() { - val items = localList.selectedValuesList.filterIsInstance() - items.forEach { controller.delete(it) } + val items = localList.selectedItems().filterIsInstance().map { it.item } + showDeletePopup(items) } internal fun clickCloud() { @@ -446,38 +421,13 @@ class HistoryPanel( if (tabs.selectedInfo === cloudInfo) cloudSearch.text = value else localSearch.text = value } - internal fun groupTitles(): List { - val items = activeModel().visibleItems - return items.indices.mapNotNull { HistoryRenderer.section(items, it) } - } + internal fun groupTitles(): List = activeRows().mapNotNull { it.section } - internal fun runningBadgeVisible(index: Int): Boolean { - return badgeText(index) != null - } + internal fun runningBadgeVisible(index: Int): Boolean = activeRows().getOrNull(index)?.badges?.isNotEmpty() == true - internal fun badgeText(index: Int): String? { - val list = activeList() - val item = list.model.getElementAt(index) ?: return null - @Suppress("UNCHECKED_CAST") - val renderer = list.cellRenderer as javax.swing.ListCellRenderer - @Suppress("UNCHECKED_CAST") - val typed = list as JList - val view = renderer.getListCellRendererComponent(typed, item, index, false, false) - if (view !is HistoryRenderer<*>) return null - return view.badgeText() - } + internal fun badgeText(index: Int): String? = activeRows().getOrNull(index)?.badges?.firstOrNull()?.text - internal fun titleText(index: Int): String? { - val list = activeList() - val item = list.model.getElementAt(index) ?: return null - @Suppress("UNCHECKED_CAST") - val renderer = list.cellRenderer as javax.swing.ListCellRenderer - @Suppress("UNCHECKED_CAST") - val typed = list as JList - val view = renderer.getListCellRendererComponent(typed, item, index, false, false) - if (view !is HistoryRenderer<*>) return null - return view.titleText() - } + internal fun titleText(index: Int): String? = activeRows().getOrNull(index)?.title internal fun repoOnlyVisible() = repoOnly.isVisible @@ -487,22 +437,20 @@ class HistoryPanel( repoOnly.doClick() } - private fun activeList(): JBList = if (tabs.selectedInfo === cloudInfo) cloudList else localList + private fun activeList(): ActiveList = if (tabs.selectedInfo === cloudInfo) cloudList else localList - private fun activeModel(): HistoryModel = if (tabs.selectedInfo === cloudInfo) controller.cloud else controller.local + private fun activeRows(): List = if (tabs.selectedInfo === cloudInfo) cloudRows else localRows private fun activeSearch(): SearchTextField = if (tabs.selectedInfo === cloudInfo) cloudSearch else localSearch private fun activeInfo(): TabInfo = if (tabs.selectedInfo === cloudInfo) cloudInfo else localInfo private fun move(step: Int) { - val list = activeList() - val size = list.model.size + val size = itemCount() if (size <= 0) return - val cur = list.selectedIndex.takeIf { it >= 0 } ?: if (step > 0) -1 else size + val cur = selectedIndex().takeIf { it >= 0 } ?: if (step > 0) -1 else size val idx = (cur + step).coerceIn(0, size - 1) - list.selectedIndex = idx - ScrollingUtil.ensureIndexIsVisible(list, idx, 0) + activeList().selectIndex(idx) } override fun dispose() { @@ -552,10 +500,6 @@ class HistoryPanel( } } - private class HistoryList(model: HistoryModel) : JBList(model) { - override fun getScrollableTracksViewportWidth() = true - } - internal fun showingLoading() = !controller.local.loaded && !controller.cloud.loaded && (controller.local.loading || controller.cloud.loading) private companion object { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryRows.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryRows.kt new file mode 100644 index 00000000000..aabf45f70dc --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryRows.kt @@ -0,0 +1,92 @@ +package ai.kilocode.client.session.history + +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.session.SessionActivityKind +import ai.kilocode.client.ui.list.ActiveListBadge +import ai.kilocode.client.ui.list.ActiveListCell +import ai.kilocode.client.ui.list.ActiveListItem +import com.intellij.icons.AllIcons + +internal const val HISTORY_DELETE_CELL = "delete" + +internal data class LocalHistoryRow( + val item: LocalHistoryItem, + private val text: String, + private val kind: SessionActivityKind?, + override val section: String?, + override val deleting: Boolean, +) : ActiveListItem { + override val key: String get() = item.id + override val title: String get() = text + override val trailing: String get() = HistoryTime.relative(item) + override val search: String get() = listOfNotNull(text, item.id, item.directory).joinToString(" ") + override val badges: List + get() { + if (deleting) return emptyList() + return listOfNotNull(kind?.let { ActiveListBadge(it.label(), it.style()) }) + } + override val cells: List + get() { + if (deleting) return emptyList() + return listOf( + ActiveListCell( + HISTORY_DELETE_CELL, + KiloBundle.message("common.delete"), + icon = AllIcons.Actions.GC, + iconOnly = true, + ), + ) + } +} + +internal data class CloudHistoryRow( + val item: CloudHistoryItem, + private val text: String, + private val kind: SessionActivityKind?, + override val section: String?, +) : ActiveListItem { + override val key: String get() = item.id + override val title: String get() = text + override val trailing: String get() = HistoryTime.relative(item) + override val search: String get() = listOf(text, item.id).joinToString(" ") + override val badges: List + get() = listOfNotNull(kind?.let { ActiveListBadge(it.label(), it.style()) }) +} + +internal fun localHistoryRows( + items: List, + snapshot: HistoryActivitySnapshot, + deleting: (LocalHistoryItem) -> Boolean, +): List { + return items.mapIndexed { idx, item -> + LocalHistoryRow( + item = item, + text = snapshot.titles[item.id] ?: title(item), + kind = snapshot.activity[item.id], + section = historySection(items, idx), + deleting = deleting(item), + ) + } +} + +internal fun cloudHistoryRows( + items: List, + snapshot: HistoryActivitySnapshot, +): List { + return items.mapIndexed { idx, item -> + CloudHistoryRow( + item = item, + text = title(item), + kind = snapshot.activity[item.id], + section = historySection(items, idx), + ) + } +} + +private fun historySection(items: List, idx: Int): String? { + val item = items.getOrNull(idx) ?: return null + val cur = HistoryTime.section(item) + val prev = items.getOrNull(idx - 1)?.let(HistoryTime::section) + if (cur == prev) return null + return HistoryTime.title(cur) +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveList.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveList.kt index 53f84f77a76..f6456bedad8 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveList.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveList.kt @@ -1,9 +1,12 @@ package ai.kilocode.client.ui.list import ai.kilocode.client.ui.UiStyle +import com.intellij.openapi.actionSystem.ActionGroup +import com.intellij.openapi.actionSystem.ActionPlaces import com.intellij.openapi.options.advanced.AdvancedSettings import com.intellij.openapi.ui.popup.Balloon import com.intellij.ui.DocumentAdapter +import com.intellij.ui.PopupHandler import com.intellij.ui.SearchTextField import com.intellij.ui.awt.RelativePoint import com.intellij.ui.components.JBScrollPane @@ -12,6 +15,7 @@ import com.intellij.util.ui.JBUI import com.intellij.util.ui.UIUtil import com.intellij.util.ui.components.BorderLayoutPanel import java.awt.Color +import java.awt.Cursor import java.awt.event.KeyEvent import javax.swing.JComponent import javax.swing.KeyStroke @@ -36,11 +40,12 @@ internal class ActiveList( onOpen: ((ActiveListItem, Boolean) -> Unit)? = null, matcher: (String, ActiveListItem) -> Boolean = ::activeListMatches, enter: () -> Boolean = ::activeListEnterFocus, + openOnClick: Boolean = true, onActivate: ((ActiveListItem) -> Unit)? = null, onClick: ((ActiveListItem) -> Unit)? = null, onSelect: (() -> Unit)? = null, ) : BorderLayoutPanel() { - private val view = ActiveListView(emptyText, cfg, surface, matcher, enter, onOpen, onActivate, onClick, onCell) + private val view = ActiveListView(emptyText, cfg, surface, matcher, enter, openOnClick, onOpen, onActivate, onClick, onCell) private val search: SearchTextField? = if (showSearch) SearchTextField(false) else null private val scroll = object : JBScrollPane(view) { override fun getBackground(): Color { @@ -109,6 +114,22 @@ internal class ActiveList( @RequiresEdt fun focusList() = view.focusList() + @RequiresEdt + fun setEmptyText(text: String) = view.setEmptyText(text) + + @RequiresEdt + fun installPopup(group: ActionGroup) = PopupHandler.installPopupMenu(view.list, group, ActionPlaces.POPUP) + + @RequiresEdt + fun setListCursor(cursor: Cursor) { + view.list.cursor = cursor + } + + @RequiresEdt + fun setSelectionIndices(indices: IntArray) { + view.list.selectedIndices = indices + } + @RequiresEdt fun preferredFocus(): JComponent = view.list diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListView.kt index aae03cf2d80..84ddb3e5941 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListView.kt @@ -40,6 +40,7 @@ internal class ActiveListView( private val surface: ActiveListSurface = ActiveListSurface.Default, private val matcher: (String, ActiveListItem) -> Boolean = ::activeListMatches, private val enter: () -> Boolean = ::activeListEnterFocus, + private val openOnClick: Boolean = true, private val onOpen: ((ActiveListItem, Boolean) -> Unit)? = null, private val onActivate: ((ActiveListItem) -> Unit)? = null, private val onClick: ((ActiveListItem) -> Unit)? = null, @@ -126,6 +127,11 @@ internal class ActiveListView( val hit = hit(e, enabled = false) ?: return if (hit.id != null) return if (hit.item.deleting) return + if (!openOnClick) { + onClick?.invoke(hit.item) ?: return + e.consume() + return + } val action = onOpen if (action != null) action(hit.item, false) else onClick?.invoke(hit.item) ?: return e.consume() diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/history/HistoryControllerTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/history/HistoryControllerTest.kt index 6ad1142c41b..d75bf864c5d 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/history/HistoryControllerTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/history/HistoryControllerTest.kt @@ -24,15 +24,11 @@ import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.util.Disposer import com.intellij.testFramework.fixtures.BasePlatformTestCase import com.intellij.ui.SearchTextField -import com.intellij.ui.SimpleColoredComponent -import com.intellij.ui.components.JBLabel -import com.intellij.ui.components.JBList import com.intellij.ui.components.JBScrollPane import com.intellij.util.ui.UIUtil import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeout -import java.awt.BorderLayout import java.awt.Cursor import java.awt.event.KeyEvent import java.time.Instant @@ -42,6 +38,7 @@ import java.time.temporal.ChronoUnit import java.util.concurrent.atomic.AtomicInteger import javax.swing.JComponent import javax.swing.KeyStroke +import javax.swing.Scrollable import javax.swing.ScrollPaneConstants import javax.swing.event.ListDataEvent import javax.swing.event.ListDataListener @@ -176,61 +173,51 @@ class HistoryControllerTest : BasePlatformTestCase() { assertEquals(mapOf("ses_1" to SessionActivityKind.RUNNING), activity) } - fun `test local history renderer shows running badge for active id`() { + fun `test local history rows show running badge for active id`() { val item = LocalHistoryItem(session("ses_1", "Running")) - val controller = controller() - controller.local.replace(listOf(item)) - val renderer = LocalHistoryRenderer(controller.local, activity = { mapOf("ses_1" to SessionActivityKind.RUNNING) }) + val snapshot = HistoryActivitySnapshot(activity = mapOf("ses_1" to SessionActivityKind.RUNNING)) - renderer.getListCellRendererComponent(javax.swing.JList(arrayOf(item)), item, 0, false, false) + val row = localHistoryRows(listOf(item), snapshot) { false }[0] - assertTrue(renderer.runningVisible()) + assertEquals(KiloBundle.message("session.part.tool.running"), row.badges.single().text) } - fun `test local history renderer uses title overlay`() { + fun `test local history rows use title overlay`() { val item = LocalHistoryItem(session("ses_1", "Stored")) - val controller = controller() - controller.local.replace(listOf(item)) - val renderer = LocalHistoryRenderer(controller.local, titles = { mapOf("ses_1" to "Live") }) + val snapshot = HistoryActivitySnapshot(titles = mapOf("ses_1" to "Live")) - renderer.getListCellRendererComponent(javax.swing.JList(arrayOf(item)), item, 0, false, false) + val row = localHistoryRows(listOf(item), snapshot) { false }[0] - assertEquals("Live", renderer.titleText()) + assertEquals("Live", row.title) } - fun `test cloud history renderer hides running badge for inactive id`() { + fun `test cloud history rows hide running badge for inactive id`() { val item = CloudHistoryItem(cloud("cloud_1", "Cloud")) - val controller = controller() - controller.cloud.replace(listOf(item), null) - val renderer = CloudHistoryRenderer(controller.cloud) { emptyMap() } - renderer.getListCellRendererComponent(javax.swing.JList(arrayOf(item)), item, 0, false, false) + val row = cloudHistoryRows(listOf(item), HistoryActivitySnapshot())[0] - assertFalse(renderer.runningVisible()) + assertTrue(row.badges.isEmpty()) } - fun `test history renderer uses trailing time with squeezable title`() { + fun `test local history rows use trailing time and delete cell`() { val item = LocalHistoryItem(session("ses_1", "Long ".repeat(80))) - val controller = controller() - controller.local.replace(listOf(item)) - val renderer = LocalHistoryRenderer(controller.local) - val view = renderer.getListCellRendererComponent(javax.swing.JList(arrayOf(item)), item, 0, false, false) + val row = localHistoryRows(listOf(item), HistoryActivitySnapshot()) { false }[0] + val cell = row.cells.single() - val title = UIUtil.uiTraverser(view).filter(SimpleColoredComponent::class.java).firstOrNull() ?: error("missing title") - val head = title.parent - assertTrue(head.layout is BorderLayout) - assertSame(title, (head.layout as BorderLayout).getLayoutComponent(BorderLayout.CENTER)) + assertEquals(HistoryTime.relative(item), row.trailing) + assertEquals(HISTORY_DELETE_CELL, cell.id) + assertTrue(cell.iconOnly) + } - val time = UIUtil.uiTraverser(view).filter(JBLabel::class.java) - .first { it.text == HistoryTime.relative(item) } - val main = time.parent - assertTrue(main.layout is BorderLayout) - assertSame(time, (main.layout as BorderLayout).getLayoutComponent(BorderLayout.EAST)) + fun `test deleting local history rows hide actions and badges`() { + val item = LocalHistoryItem(session("ses_1", "Running")) + val snapshot = HistoryActivitySnapshot(activity = mapOf("ses_1" to SessionActivityKind.RUNNING)) - val row = main.parent as JComponent - val ins = row.border.getBorderInsets(row) - assertEquals(ins.left, ins.right) - assertEquals(UiStyle.Gap.lg(), ins.right) + val row = localHistoryRows(listOf(item), snapshot) { true }[0] + + assertTrue(row.deleting) + assertTrue(row.cells.isEmpty()) + assertTrue(row.badges.isEmpty()) } fun `test history panel sync updates running badges`() { @@ -921,17 +908,25 @@ class HistoryControllerTest : BasePlatformTestCase() { assertTrue(searches.all { inset(it.parent as JComponent) == UiStyle.Gap.lg() }) val scrolls = UIUtil.uiTraverser(panel.component).filter(JBScrollPane::class.java).toList() - .filter { it.viewport.view is JBList<*> } + .filter { it.viewport.view is Scrollable } assertTrue(scrolls.isNotEmpty()) assertTrue(scrolls.all { it.horizontalScrollBarPolicy == ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER }) - assertTrue(scrolls.all { it.viewportBorder.getBorderInsets(it).right == UiStyle.Gap.lg() }) + assertTrue(scrolls.all { padded(it as JComponent) }) - val lists = UIUtil.uiTraverser(panel.component).filter(JBList::class.java).toList() - assertTrue(lists.isNotEmpty()) - assertTrue(lists.all { it.getScrollableTracksViewportWidth() }) + val views = scrolls.map { it.viewport.view as Scrollable } + assertTrue(views.all { it.getScrollableTracksViewportWidth() }) } - private fun inset(component: JComponent) = component.border.getBorderInsets(component).right + private fun inset(component: JComponent) = component.border?.getBorderInsets(component)?.right ?: -1 + + private fun padded(component: JComponent): Boolean { + var cur: java.awt.Component? = component + while (cur != null) { + if (cur is JComponent && inset(cur) == UiStyle.Gap.lg()) return true + cur = cur.parent + } + return false + } private fun collect(controller: HistoryController): MutableList { val events = mutableListOf()