feat(jetbrains): use active list for history rows

This commit is contained in:
kirillk
2026-08-04 10:49:58 -04:00
parent 319d1f4859
commit a89936bf8e
7 changed files with 280 additions and 382 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Use the shared active list for session history rows with hover-revealed local delete actions.
@@ -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<T : HistoryItem>(
private val model: HistoryModel<T>,
private val deletable: Boolean,
private val activity: () -> Map<String, SessionActivityKind>,
private val titles: () -> Map<String, String> = { emptyMap() },
) : JPanel(BorderLayout()), ListCellRenderer<T> {
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<HistoryItem>, 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<out T>,
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<LocalHistoryItem>,
activity: () -> Map<String, SessionActivityKind> = { emptyMap() },
titles: () -> Map<String, String> = { emptyMap() },
) : HistoryRenderer<LocalHistoryItem>(model, deletable = true, activity, titles)
internal class CloudHistoryRenderer(
model: HistoryModel<CloudHistoryItem>,
activity: () -> Map<String, SessionActivityKind> = { emptyMap() },
) : HistoryRenderer<CloudHistoryItem>(model, deletable = false, activity)
@@ -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<LocalHistoryRow>()
private var cloudRows = emptyList<CloudHistoryRow>()
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<out HistoryItem>) {
val view = list.cellRenderer
if (view is JComponent) SwingUtilities.updateComponentTreeUI(view)
}
private fun search(model: HistoryModel<out HistoryItem>) = 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<out HistoryItem>, 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 <T : HistoryItem> bind(list: JBList<T>, model: HistoryModel<T>) {
private fun bind(model: HistoryModel<out HistoryItem>) {
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<ActiveListItem>) {
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 <T : HistoryItem> repaintRows(list: JBList<T>, model: HistoryModel<T>, ids: Set<String>) {
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 <T : HistoryItem> syncList(list: JBList<T>, model: HistoryModel<T>) {
list.setPaintBusy(model.loading)
list.emptyText.text = when {
private fun syncList(list: ActiveList, model: HistoryModel<out HistoryItem>) {
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<LocalHistoryItem>() else emptyList()
val cloud = if (source == HistorySource.CLOUD) cloudList.selectedValuesList.filterIsInstance<CloudHistoryItem>() else emptyList()
val local = if (source == HistorySource.LOCAL) {
localList.selectedItems().filterIsInstance<LocalHistoryRow>().map { it.item }
} else {
emptyList()
}
val cloud = if (source == HistorySource.CLOUD) {
cloudList.selectedItems().filterIsInstance<CloudHistoryRow>().map { it.item }
} else {
emptyList()
}
return HistorySelection(source, local, cloud)
}
return null
}
private fun installContextMenu(list: JBList<out HistoryItem>) {
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<LocalHistoryItem>, 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<LocalHistoryItem>) {
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<LocalHistoryItem>()
items.forEach { controller.delete(it) }
val items = localList.selectedItems().filterIsInstance<LocalHistoryRow>().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<String> {
val items = activeModel().visibleItems
return items.indices.mapNotNull { HistoryRenderer.section(items, it) }
}
internal fun groupTitles(): List<String> = 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<HistoryItem>
@Suppress("UNCHECKED_CAST")
val typed = list as JList<HistoryItem>
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<HistoryItem>
@Suppress("UNCHECKED_CAST")
val typed = list as JList<HistoryItem>
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<out HistoryItem> = if (tabs.selectedInfo === cloudInfo) cloudList else localList
private fun activeList(): ActiveList = if (tabs.selectedInfo === cloudInfo) cloudList else localList
private fun activeModel(): HistoryModel<out HistoryItem> = if (tabs.selectedInfo === cloudInfo) controller.cloud else controller.local
private fun activeRows(): List<ActiveListItem> = 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<T : HistoryItem>(model: HistoryModel<T>) : JBList<T>(model) {
override fun getScrollableTracksViewportWidth() = true
}
internal fun showingLoading() = !controller.local.loaded && !controller.cloud.loaded && (controller.local.loading || controller.cloud.loading)
private companion object {
@@ -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<ActiveListBadge>
get() {
if (deleting) return emptyList()
return listOfNotNull(kind?.let { ActiveListBadge(it.label(), it.style()) })
}
override val cells: List<ActiveListCell>
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<ActiveListBadge>
get() = listOfNotNull(kind?.let { ActiveListBadge(it.label(), it.style()) })
}
internal fun localHistoryRows(
items: List<LocalHistoryItem>,
snapshot: HistoryActivitySnapshot,
deleting: (LocalHistoryItem) -> Boolean,
): List<LocalHistoryRow> {
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<CloudHistoryItem>,
snapshot: HistoryActivitySnapshot,
): List<CloudHistoryRow> {
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<HistoryItem>, 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)
}
@@ -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
@@ -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()
@@ -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<String> {
val events = mutableListOf<String>()