From e8f570cc75769434a8bbd8a05defaca0eb3a73d1 Mon Sep 17 00:00:00 2001 From: kirillk Date: Wed, 6 May 2026 13:04:18 -0400 Subject: [PATCH] feat(jetbrains): add native session history --- .changeset/jetbrains-session-history.md | 5 + .../kilocode/client/KiloToolWindowFactory.kt | 3 +- .../kilocode/client/actions/HistoryAction.kt | 22 ++ .../kilocode/client/session/SessionManager.kt | 2 + .../client/session/SessionSidePanelManager.kt | 41 ++++ .../client/session/SessionUiFactory.kt | 2 +- .../session/history/HistoryController.kt | 16 +- .../session/history/HistoryListRenderer.kt | 112 +++++++-- .../client/session/history/HistoryModel.kt | 8 +- .../client/session/history/HistoryPanel.kt | 226 +++++++++++++----- .../client/session/history/HistoryTime.kt | 71 ++++++ .../resources/kilo.jetbrains.frontend.xml | 3 + .../resources/messages/KiloBundle.properties | 15 ++ .../session/SessionSidePanelManagerTest.kt | 62 ++++- .../session/history/HistoryControllerTest.kt | 53 +++- 15 files changed, 552 insertions(+), 89 deletions(-) create mode 100644 .changeset/jetbrains-session-history.md create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/HistoryAction.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryTime.kt diff --git a/.changeset/jetbrains-session-history.md b/.changeset/jetbrains-session-history.md new file mode 100644 index 00000000000..8aa827208cc --- /dev/null +++ b/.changeset/jetbrains-session-history.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Add a native session history panel to the JetBrains plugin. diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloToolWindowFactory.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloToolWindowFactory.kt index b5e8cb2b5e8..52262d1ba8e 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloToolWindowFactory.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloToolWindowFactory.kt @@ -1,5 +1,6 @@ package ai.kilocode.client +import ai.kilocode.client.actions.HistoryAction import ai.kilocode.client.actions.NewSessionAction import ai.kilocode.client.app.KiloWorkspaceService import ai.kilocode.client.app.Workspace @@ -65,7 +66,7 @@ class KiloToolWindowFactory : ToolWindowFactory, DumbAware { manager.newSession() ActionManager.getInstance().getAction("Kilo.Settings")?.let { settings -> - toolWindow.setTitleActions(listOf(NewSessionAction(), settings)) + toolWindow.setTitleActions(listOf(NewSessionAction(), HistoryAction(), settings)) } } catch (e: Exception) { LOG.error("Failed to set up Kilo tool window content", e) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/HistoryAction.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/HistoryAction.kt new file mode 100644 index 00000000000..92d662e7ae5 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/HistoryAction.kt @@ -0,0 +1,22 @@ +package ai.kilocode.client.actions + +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.session.SessionManager +import com.intellij.icons.AllIcons +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.project.DumbAware + +class HistoryAction : AnAction( + KiloBundle.message("action.Kilo.History.text"), + KiloBundle.message("action.Kilo.History.description"), + AllIcons.Vcs.History, +), DumbAware { + override fun actionPerformed(e: AnActionEvent) { + e.getData(SessionManager.KEY)?.showHistory() + } + + override fun update(e: AnActionEvent) { + e.presentation.isEnabled = e.getData(SessionManager.KEY) != null + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionManager.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionManager.kt index a65cde6df18..162fd3c6b2a 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionManager.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionManager.kt @@ -10,5 +10,7 @@ interface SessionManager { fun newSession() + fun showHistory() + fun openSession(session: SessionDto) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionSidePanelManager.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionSidePanelManager.kt index 626a81eecb2..c46a006d5bc 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionSidePanelManager.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionSidePanelManager.kt @@ -1,13 +1,17 @@ package ai.kilocode.client.session +import ai.kilocode.client.app.KiloSessionService import ai.kilocode.client.app.KiloWorkspaceService import ai.kilocode.client.app.Workspace +import ai.kilocode.client.session.history.HistoryController +import ai.kilocode.client.session.history.HistoryPanel import ai.kilocode.rpc.dto.SessionDto import com.intellij.openapi.Disposable import com.intellij.openapi.actionSystem.DataProvider import com.intellij.openapi.components.service import com.intellij.openapi.project.Project import com.intellij.openapi.util.Disposer +import kotlinx.coroutines.cancel import java.awt.BorderLayout import javax.swing.JComponent import javax.swing.JPanel @@ -19,6 +23,7 @@ class SessionSidePanelManager( service().create(project, workspace, manager, id, loading) }, private val resolve: (String) -> Workspace = { dir -> service().workspace(dir) }, + private val history: ((Disposable, (SessionDto) -> Unit, (String) -> Unit) -> JComponent)? = null, ) : SessionManager, Disposable { val component: JPanel = object : JPanel(BorderLayout()), DataProvider { override fun getData(dataId: String): Any? { @@ -30,6 +35,7 @@ class SessionSidePanelManager( private val opened = mutableMapOf() private val all = mutableSetOf() private var current: SessionUi? = null + private var panel: JComponent? = null val defaultFocusedComponent: JComponent? get() = current?.defaultFocusedComponent @@ -50,6 +56,41 @@ class SessionSidePanelManager( show(ui) } + override fun showHistory() { + register(current) + release(current) + val view = panel ?: createHistory().also { panel = it } + if (current == null && component.componentCount == 1 && component.getComponent(0) === view) return + current = null + component.removeAll() + component.add(view, BorderLayout.CENTER) + component.revalidate() + component.repaint() + } + + private fun createHistory(): JComponent { + val custom = history + if (custom != null) return custom(this, this::openSession, this::removeSession) + val factory = service() + val cs = factory.scope() + val controller = HistoryController( + sessions = project.service(), + workspace = root, + cs = cs, + open = { item -> item.local?.let(this::openSession) }, + deleted = this::removeSession, + ) + Disposer.register(this) { cs.cancel() } + return HistoryPanel(this, controller).component + } + + private fun removeSession(id: String) { + val ui = opened.remove(id) ?: return + all.remove(ui) + if (current === ui) current = null + Disposer.dispose(ui) + } + private fun show(ui: SessionUi) { all.add(ui) if (current === ui) return diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUiFactory.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUiFactory.kt index c55e607d926..bd8aed80e28 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUiFactory.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUiFactory.kt @@ -31,7 +31,7 @@ class SessionUiFactory( open = manager::openSession, ) - private fun scope(): CoroutineScope { + fun scope(): CoroutineScope { val parent = cs.coroutineContext[Job] return CoroutineScope(cs.coroutineContext + SupervisorJob(parent)) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryController.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryController.kt index 4861056eff7..e90179897bc 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryController.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryController.kt @@ -14,6 +14,7 @@ class HistoryController( private val workspace: Workspace, private val cs: CoroutineScope, private val open: (HistoryItem) -> Unit = {}, + private val deleted: (String) -> Unit = {}, ) { companion object { const val CLOUD_LIMIT = 50 @@ -28,7 +29,7 @@ class HistoryController( cs.launch { try { val result = sessions.list(workspace.directory) - val items = result.sessions.map(::localItem) + val items = HistoryTime.sorted(result.sessions.map(::localItem)) edt { model.setLocal(items) } } catch (e: Exception) { edt { model.error(HistorySource.LOCAL, e.message ?: KiloBundle.message("history.error.local")) } @@ -37,13 +38,14 @@ class HistoryController( } fun loadCloud(reset: Boolean = true, gitUrl: String? = null) { - if (reset) this.gitUrl = gitUrl val cursor = if (reset) null else model.cursor + val url = if (reset) gitUrl else this.gitUrl + if (reset) this.gitUrl = gitUrl edt { model.startCloud(reset) } cs.launch { try { - val result = sessions.cloudSessions(workspace.directory, cursor, CLOUD_LIMIT, this@HistoryController.gitUrl) - val items = result.sessions.map(::cloudItem) + val result = sessions.cloudSessions(workspace.directory, cursor, CLOUD_LIMIT, url) + val items = HistoryTime.sorted(result.sessions.map(::cloudItem)) edt { model.setCloud(items, result.nextCursor, append = !reset) } } catch (e: Exception) { edt { model.error(HistorySource.CLOUD, e.message ?: KiloBundle.message("history.error.cloud")) } @@ -69,7 +71,10 @@ class HistoryController( cs.launch { try { sessions.deleteSession(item.id, item.directory ?: workspace.directory) - edt { model.deleted(item.id) } + edt { + model.deleted(item.id) + deleted(item.id) + } } catch (e: Exception) { edt { model.error(HistorySource.LOCAL, e.message ?: KiloBundle.message("history.error.local.delete")) } } @@ -77,6 +82,7 @@ class HistoryController( } fun open(item: HistoryItem) { + if (item.source != HistorySource.LOCAL) return edt { open(item) } } } 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 index d45d81b169f..e02a8b142db 100644 --- 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 @@ -1,23 +1,89 @@ package ai.kilocode.client.session.history import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.session.ui.PickerRow +import ai.kilocode.client.ui.UiStyle +import com.intellij.icons.AllIcons +import com.intellij.ui.CollectionListModel +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 com.intellij.util.ui.components.BorderLayoutPanel import java.awt.BorderLayout import java.awt.Component +import java.awt.Point +import java.awt.Rectangle +import javax.swing.Icon import javax.swing.JList +import javax.swing.JPanel import javax.swing.ListCellRenderer +import javax.swing.SwingConstants -class HistoryListRenderer : BorderLayoutPanel(), ListCellRenderer { - private val title = JBLabel() - private val meta = JBLabel() +private const val DELETE_CLICK_AREA_WIDTH = 32 + +internal class HistoryListRenderer( + private val model: CollectionListModel, + private val source: () -> HistorySource, + private val deletable: Boolean, +) : JPanel(BorderLayout()), ListCellRenderer { + companion object { + private val icon: Icon = AllIcons.General.Remove + private val empty: Icon = EmptyIcon.create(icon) + + fun isDeleteClick(list: JList<*>, bounds: Rectangle, point: Point): Boolean { + val width = JBUI.scale(DELETE_CLICK_AREA_WIDTH) + if (list.componentOrientation.isLeftToRight) { + val right = bounds.x + bounds.width + return point.x in (right - width)..right + } + return point.x in bounds.x..(bounds.x + 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 time = JBLabel() + private val del = JBLabel().apply { + horizontalAlignment = SwingConstants.CENTER + verticalAlignment = SwingConstants.CENTER + } + private val main = JPanel(BorderLayout()).apply { + add(title, BorderLayout.CENTER) + add(time, BorderLayout.EAST) + } + private val row = JPanel(BorderLayout()).apply { + add(main, BorderLayout.CENTER) + add(del, BorderLayout.EAST) + } + private val wrap = PickerRow() init { - border = JBUI.Borders.empty(8, 12, 8, 12) - add(title, BorderLayout.CENTER) - add(meta, BorderLayout.EAST) + isOpaque = true + top.isOpaque = true + row.border = JBUI.Borders.empty(UiStyle.Space.LG, UiStyle.Space.LG, UiStyle.Space.LG, UiStyle.Space.LG) + UiStyle.Components.transparent(row) + UiStyle.Components.transparent(main) + UiStyle.Components.transparent(title) + UiStyle.Components.transparent(time) + UiStyle.Components.transparent(del) + wrap.setContent(row) + add(top, BorderLayout.NORTH) + add(wrap, BorderLayout.CENTER) } override fun getListCellRendererComponent( @@ -26,13 +92,31 @@ class HistoryListRenderer : BorderLayoutPanel(), ListCellRenderer { index: Int, selected: Boolean, focus: Boolean, - ): Component { - isOpaque = selected - background = if (selected) list.selectionBackground else list.background - title.foreground = if (selected) list.selectionForeground else UIUtil.getLabelForeground() - meta.foreground = if (selected) list.selectionForeground else UIUtil.getContextHelpForeground() - title.text = value?.title?.takeIf { it.isNotBlank() } ?: KiloBundle.message("history.untitled") - meta.text = value?.updatedAt.orEmpty() + ): JPanel { + val focused = selected || list.hasFocus() || focus + val fg = UIUtil.getListForeground(selected, focused) + val weak = if (selected) fg else UIUtil.getContextHelpForeground() + val section = if (source() == HistorySource.LOCAL) section(model.items, index) else null + + background = list.background + top.background = list.background + wrap.update(list, selected, focused) + sep.caption = section + sep.setHideLine(index == 0) + top.isVisible = section != null + + title.clear() + title.append( + value?.title?.takeIf { it.isNotBlank() } ?: KiloBundle.message("history.untitled"), + SimpleTextAttributes(SimpleTextAttributes.STYLE_BOLD, fg), + ) + time.text = value?.let(HistoryTime::relative).orEmpty() + time.foreground = weak + del.icon = if (deletable && selected) icon else empty + + top.invalidate() return this } + + fun deleteVisible(): Boolean = del.icon === icon } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryModel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryModel.kt index a5625ccff66..54306d430b9 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryModel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryModel.kt @@ -22,6 +22,9 @@ class HistoryModel { private set var cursor: String? = null private set + private val deleting = mutableSetOf() + + fun deleting(id: String): Boolean = id in deleting fun addListener(parent: Disposable, listener: HistoryModelEvent.Listener) { listeners.add(listener) @@ -60,7 +63,7 @@ class HistoryModel { } fun setCloud(items: List, next: String?, append: Boolean) { - cloud = if (append) cloud + items else items + cloud = HistoryTime.sorted(if (append) cloud + items else items) cursor = next cloudLoading = false cloudError = null @@ -68,15 +71,18 @@ class HistoryModel { } fun startDelete(id: String) { + deleting.add(id) fire(HistoryModelEvent.DeleteStarted(id)) } fun deleted(id: String) { + deleting.remove(id) local = local.filterNot { it.id == id } fire(HistoryModelEvent.Deleted(id)) } fun error(source: HistorySource, message: String) { + if (source == HistorySource.LOCAL) deleting.clear() if (source == HistorySource.LOCAL) { localLoading = false localError = message 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 3746a6329fe..bf47f531289 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 @@ -1,23 +1,28 @@ package ai.kilocode.client.session.history import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.ui.UiStyle import com.intellij.openapi.Disposable +import com.intellij.openapi.ui.Messages import com.intellij.openapi.util.Disposer import com.intellij.ui.CollectionListModel import com.intellij.ui.DocumentAdapter import com.intellij.ui.SearchTextField -import com.intellij.ui.components.JBCheckBox import com.intellij.ui.components.JBLabel import com.intellij.ui.components.JBList import com.intellij.ui.components.JBScrollPane import com.intellij.util.ui.JBUI +import com.intellij.util.ui.UIUtil import com.intellij.util.ui.components.BorderLayoutPanel import java.awt.BorderLayout -import java.awt.FlowLayout import java.awt.event.MouseAdapter import java.awt.event.MouseEvent +import javax.swing.Box +import javax.swing.BoxLayout import javax.swing.JButton import javax.swing.JComponent +import javax.swing.JPanel +import javax.swing.JToggleButton import javax.swing.ListSelectionModel import javax.swing.event.DocumentEvent @@ -26,35 +31,29 @@ class HistoryPanel( private val controller: HistoryController, private val gitUrl: () -> String? = { null }, ) : BorderLayoutPanel(), Disposable { - private val rows = CollectionListModel() - private val search = SearchTextField(false).apply { - textEditor.emptyText.text = KiloBundle.message("history.search.placeholder") - } - private val list = JBList(rows).apply { - selectionMode = ListSelectionModel.SINGLE_SELECTION - cellRenderer = HistoryListRenderer() - emptyText.text = KiloBundle.message("history.empty") - addListSelectionListener { sync() } - addMouseListener(object : MouseAdapter() { - override fun mouseClicked(e: MouseEvent) { - if (e.clickCount < 2) return - selectedValue?.let(controller::open) - } - }) - } - private val local = JButton(KiloBundle.message("history.tab.local")) - private val cloud = JButton(KiloBundle.message("history.tab.cloud")) - private val repo = JBCheckBox(KiloBundle.message("history.cloud.repo.only")) - private val delete = JButton(KiloBundle.message("history.delete.text")) - private val more = JButton(KiloBundle.message("history.cloud.load.more")) + private val local = Tab(HistorySource.LOCAL) + private val cloud = Tab(HistorySource.CLOUD) private val status = JBLabel() + private val body = BorderLayoutPanel() + private val localRows = CollectionListModel() + private val cloudRows = CollectionListModel() + private val localRenderer = HistoryListRenderer(localRows, source = { controller.model.source }, deletable = true) + private val cloudRenderer = HistoryListRenderer(cloudRows, source = { controller.model.source }, deletable = false) + private val localSearch = search() + private val cloudSearch = search() + private val localList = list(localRows, HistorySource.LOCAL, localRenderer) + private val cloudList = list(cloudRows, HistorySource.CLOUD, cloudRenderer) + private val localPanel = panel(localSearch, localList) + private val more = JButton(KiloBundle.message("history.cloud.load.more")) + private val cloudPanel = panel(cloudSearch, cloudList, more) + private var loadedCloud = false init { Disposer.register(parent, this) - border = JBUI.Borders.empty(8) + border = JBUI.Borders.empty(UiStyle.Space.LG) add(header(), BorderLayout.NORTH) - add(JBScrollPane(list), BorderLayout.CENTER) - add(footer(), BorderLayout.SOUTH) + add(body, BorderLayout.CENTER) + add(status, BorderLayout.SOUTH) bind(parent) sync() controller.loadLocal() @@ -65,43 +64,61 @@ class HistoryPanel( private fun header(): JComponent { local.addActionListener { controller.selectSource(HistorySource.LOCAL) - controller.loadLocal() } cloud.addActionListener { controller.selectSource(HistorySource.CLOUD) - controller.loadCloud(gitUrl = if (repo.isSelected) gitUrl() else null) - } - repo.addActionListener { - if (controller.model.source == HistorySource.CLOUD) { - controller.loadCloud(gitUrl = if (repo.isSelected) gitUrl() else null) + if (!loadedCloud) { + loadedCloud = true + controller.loadCloud(gitUrl = gitUrl()) } } - search.textEditor.document.addDocumentListener(object : DocumentAdapter() { + more.addActionListener { controller.loadMoreCloud() } + return BorderLayoutPanel().apply { + border = JBUI.Borders.emptyBottom(UiStyle.Space.LG) + add(JPanel().apply { + layout = BoxLayout(this, BoxLayout.X_AXIS) + add(local) + add(Box.createHorizontalStrut(JBUI.scale(UiStyle.Space.SM))) + add(cloud) + }, BorderLayout.WEST) + } + } + + private fun search() = SearchTextField(false).apply { + textEditor.emptyText.text = KiloBundle.message("history.search.placeholder") + textEditor.document.addDocumentListener(object : DocumentAdapter() { override fun textChanged(e: DocumentEvent) { sync() } }) + } + + private fun panel(search: SearchTextField, list: JBList, footer: JComponent? = null): JComponent { return BorderLayoutPanel().apply { - add(BorderLayoutPanel().apply { - add(local, BorderLayout.WEST) - add(cloud, BorderLayout.CENTER) - add(repo, BorderLayout.EAST) - }, BorderLayout.NORTH) - add(search, BorderLayout.CENTER) + add(search, BorderLayout.NORTH) + add(JBScrollPane(list), BorderLayout.CENTER) + footer?.let { + it.border = JBUI.Borders.emptyTop(UiStyle.Space.LG) + add(it, BorderLayout.SOUTH) + } } } - private fun footer(): JComponent { - delete.addActionListener { list.selectedValue?.let(controller::delete) } - more.addActionListener { controller.loadMoreCloud() } - return BorderLayoutPanel().apply { - add(status, BorderLayout.CENTER) - add(BorderLayoutPanel().apply { - layout = FlowLayout(FlowLayout.RIGHT, JBUI.scale(4), 0) - add(delete) - add(more) - }, BorderLayout.EAST) - } + private fun list(rows: CollectionListModel, source: HistorySource, renderer: HistoryListRenderer) = JBList(rows).apply { + selectionMode = ListSelectionModel.SINGLE_SELECTION + cellRenderer = renderer + emptyText.text = KiloBundle.message("history.empty") + addMouseListener(object : MouseAdapter() { + override fun mouseReleased(e: MouseEvent) { + val item = clicked(this@apply, e) ?: return + if (source == HistorySource.LOCAL && UIUtil.isActionClick(e, MouseEvent.MOUSE_RELEASED, true) && deleteClick(this@apply, e)) { + confirm(item) + e.consume() + return + } + if (UIUtil.isActionClick(e, MouseEvent.MOUSE_RELEASED, true)) controller.open(item) + } + }) } private fun bind(parent: Disposable) { @@ -114,24 +131,39 @@ class HistoryPanel( is HistoryModelEvent.Deleted, is HistoryModelEvent.Error, is HistoryModelEvent.SourceChanged -> sync() - is HistoryModelEvent.DeleteStarted -> Unit + is HistoryModelEvent.DeleteStarted -> sync() } } } private fun sync() { - val query = search.text.trim().lowercase() - val items = controller.model.items().filter { item -> - query.isEmpty() || item.title.lowercase().contains(query) || item.id.lowercase().contains(query) + syncRows(localRows, controller.model.local, localSearch.text) + syncRows(cloudRows, controller.model.cloud, cloudSearch.text) + local.isSelected = controller.model.source == HistorySource.LOCAL + cloud.isSelected = controller.model.source == HistorySource.CLOUD + val target = if (controller.model.source == HistorySource.LOCAL) localPanel else cloudPanel + if (body.componentCount != 1 || body.getComponent(0) !== target) { + body.removeAll() + body.add(target, BorderLayout.CENTER) } - rows.replaceAll(items) - local.isEnabled = controller.model.source != HistorySource.LOCAL - cloud.isEnabled = controller.model.source != HistorySource.CLOUD - repo.isVisible = controller.model.source == HistorySource.CLOUD - delete.isEnabled = controller.model.source == HistorySource.LOCAL && list.selectedValue != null - more.isVisible = controller.model.source == HistorySource.CLOUD more.isEnabled = controller.model.cursor != null && !controller.model.cloudLoading + more.isVisible = controller.model.cursor != null || controller.model.cloudLoading status.text = statusText() + revalidate() + repaint() + } + + private fun syncRows(model: CollectionListModel, items: List, value: String) { + val query = value.trim().lowercase() + val selected = if (model === localRows) localList.selectedValue?.id else cloudList.selectedValue?.id + val next = HistoryTime.sorted(items).filter { item -> + query.isEmpty() || item.title.lowercase().contains(query) || item.id.lowercase().contains(query) || item.directory?.lowercase()?.contains(query) == true + } + model.replaceAll(next) + val idx = next.indexOfFirst { it.id == selected } + if (idx >= 0) { + if (model === localRows) localList.selectedIndex = idx else cloudList.selectedIndex = idx + } } private fun statusText(): String { @@ -142,33 +174,99 @@ class HistoryPanel( return "" } - internal fun itemCount() = rows.size + private fun deleteClick(list: JBList, e: MouseEvent): Boolean { + val row = list.locationToIndex(e.point) + val box = row.takeIf { it >= 0 }?.let { list.getCellBounds(it, it) } ?: return false + if (!box.contains(e.point)) return false + return HistoryListRenderer.isDeleteClick(list, box, e.point) + } + + private fun clicked(list: JBList, e: MouseEvent): HistoryItem? { + val row = list.locationToIndex(e.point) + val box = row.takeIf { it >= 0 }?.let { list.getCellBounds(it, it) } ?: return null + if (!box.contains(e.point)) return null + list.selectedIndex = row + return list.model.getElementAt(row) + } + + private fun confirm(item: HistoryItem) { + if (controller.model.deleting(item.id)) return + val result = Messages.showYesNoDialog( + this, + KiloBundle.message("history.delete.confirm.message", item.title.takeIf { it.isNotBlank() } ?: KiloBundle.message("history.untitled")), + KiloBundle.message("history.delete.confirm.title"), + Messages.getWarningIcon(), + ) + if (result != Messages.YES) return + controller.delete(item) + } + + internal fun itemCount() = if (controller.model.source == HistorySource.LOCAL) localRows.size else cloudRows.size internal fun selectedSource() = controller.model.source internal fun select(index: Int) { - list.selectedIndex = index + activeList().selectedIndex = index sync() } internal fun clickDelete() { - delete.doClick() + activeList().selectedValue?.let(controller::delete) } internal fun clickCloud() { cloud.doClick() } + internal fun clickLocal() { + local.doClick() + } + internal fun clickMore() { more.doClick() } internal fun setSearch(value: String) { - search.text = value + if (controller.model.source == HistorySource.LOCAL) localSearch.text = value else cloudSearch.text = value sync() } + internal fun groupTitles(): List { + val model = if (controller.model.source == HistorySource.LOCAL) localRows else cloudRows + return model.items.indices.mapNotNull { HistoryListRenderer.section(model.items, it) } + } + + internal fun deleteVisible(index: Int, selected: Boolean = true): Boolean { + val item = localRows.getElementAt(index) + val view = localList.cellRenderer.getListCellRendererComponent(localList, item, index, selected, false) + return view is HistoryListRenderer && view.deleteVisible() + } + + internal fun cloudDeleteVisible(index: Int, selected: Boolean = true): Boolean { + val item = cloudRows.getElementAt(index) + val view = cloudList.cellRenderer.getListCellRendererComponent(cloudList, item, index, selected, false) + return view is HistoryListRenderer && view.deleteVisible() + } + + private fun activeList() = if (controller.model.source == HistorySource.LOCAL) localList else cloudList + override fun dispose() { // no-op } + + private class Tab(private val source: HistorySource) : JToggleButton( + when (source) { + HistorySource.LOCAL -> KiloBundle.message("history.tab.local") + HistorySource.CLOUD -> KiloBundle.message("history.tab.cloud") + }, + ) { + init { + isFocusable = false + } + + override fun updateUI() { + super.updateUI() + border = JBUI.Borders.empty(UiStyle.Space.SM, UiStyle.Space.LG) + } + } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryTime.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryTime.kt new file mode 100644 index 00000000000..faf3e9f78c9 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryTime.kt @@ -0,0 +1,71 @@ +package ai.kilocode.client.session.history + +import ai.kilocode.client.plugin.KiloBundle +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId +import java.time.temporal.ChronoUnit +import kotlin.math.abs + +private const val SECOND_MS_LIMIT = 10_000_000_000L +private const val MINUTE = 60_000L +private const val HOUR = 60 * MINUTE +private const val DAY = 24 * HOUR + +enum class HistorySection { + TODAY, + YESTERDAY, + WEEK, + MONTH, + OLDER, +} + +internal object HistoryTime { + fun millis(item: HistoryItem): Long? { + if (item.source == HistorySource.CLOUD) return runCatching { Instant.parse(item.updatedAt).toEpochMilli() }.getOrNull() + val raw = item.updatedAt.toDoubleOrNull()?.toLong() ?: return null + if (abs(raw) < SECOND_MS_LIMIT) return raw * 1000 + return raw + } + + fun section(item: HistoryItem, now: Long = System.currentTimeMillis()): HistorySection { + val ms = millis(item) ?: return HistorySection.OLDER + val zone = ZoneId.systemDefault() + val date = Instant.ofEpochMilli(ms).atZone(zone).toLocalDate() + val today = Instant.ofEpochMilli(now).atZone(zone).toLocalDate() + if (date == today) return HistorySection.TODAY + if (date == today.minusDays(1)) return HistorySection.YESTERDAY + if (date.isAfter(today.minusDays(7)) && date.isBefore(today)) return HistorySection.WEEK + if (date.year == today.year && date.month == today.month) return HistorySection.MONTH + return HistorySection.OLDER + } + + fun title(section: HistorySection): String = when (section) { + HistorySection.TODAY -> KiloBundle.message("history.group.today") + HistorySection.YESTERDAY -> KiloBundle.message("history.group.yesterday") + HistorySection.WEEK -> KiloBundle.message("history.group.week") + HistorySection.MONTH -> KiloBundle.message("history.group.month") + HistorySection.OLDER -> KiloBundle.message("history.group.older") + } + + fun relative(item: HistoryItem, now: Long = System.currentTimeMillis()): String { + val ms = millis(item) ?: return item.updatedAt + val diff = (now - ms).coerceAtLeast(0) + if (diff < MINUTE) return KiloBundle.message("history.time.moments") + if (diff < HOUR) return KiloBundle.message("history.time.minutes", (diff / MINUTE).coerceAtLeast(1)) + if (diff < DAY) return KiloBundle.message("history.time.hours", (diff / HOUR).coerceAtLeast(1)) + if (diff < 7 * DAY) return KiloBundle.message("history.time.days", (diff / DAY).coerceAtLeast(1)) + val date = LocalDate.ofInstant(Instant.ofEpochMilli(ms), ZoneId.systemDefault()) + val today = LocalDate.ofInstant(Instant.ofEpochMilli(now), ZoneId.systemDefault()) + val months = ChronoUnit.MONTHS.between(date.withDayOfMonth(1), today.withDayOfMonth(1)) + if (months < 1) return KiloBundle.message("history.time.days", (diff / DAY).coerceAtLeast(1)) + if (months < 12) return KiloBundle.message("history.time.months", months) + return KiloBundle.message("history.time.years", months / 12) + } + + fun sorted(items: List): List = items.sortedWith( + compareByDescending { millis(it) ?: Long.MIN_VALUE } + .thenBy { it.title.lowercase() } + .thenBy { it.id }, + ) +} diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml b/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml index 619584623bd..5eabe4727d6 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml +++ b/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml @@ -40,6 +40,9 @@ class="ai.kilocode.client.actions.KiloSettingsAction" icon="AllIcons.General.GearPlain"/> + + history }) + + manager.newSession() + manager.showHistory() + + assertSame(history, manager.component.getComponent(0)) + assertNull(manager.defaultFocusedComponent) + } + + fun `test opening local history item shows session ui`() { + lateinit var open: (SessionDto) -> Unit + val history = JLabel("History") + val manager = manager(history = { _, fn, _ -> + open = fn + history + }) + + manager.showHistory() + open(session("ses_1")) + + assertTrue(active(manager) is SessionUi) + assertEquals(listOf("/test" to "ses_1"), created) + } + + fun `test new session from history shows blank session`() { + val history = JLabel("History") + val manager = manager(history = { _, _, _ -> history }) + + manager.showHistory() + manager.newSession() + + assertTrue(active(manager) is SessionUi) + assertEquals(listOf("/test" to null), created) + } + + fun `test deleted cached history session is not reused`() { + lateinit var deleted: (String) -> Unit + val manager = manager(history = { _, _, fn -> + deleted = fn + JLabel("History") + }) + val session = session("ses_1") + + manager.openSession(session) + val first = active(manager) + manager.showHistory() + deleted("ses_1") + manager.openSession(session) + + assertNotSame(first, active(manager)) + assertEquals(listOf("/test" to "ses_1", "/test" to "ses_1"), created) + } + + private fun manager( + history: ((com.intellij.openapi.Disposable, (SessionDto) -> Unit, (String) -> Unit) -> JComponent)? = null, + ): SessionSidePanelManager { val manager = SessionSidePanelManager( project = project, root = workspace, @@ -197,6 +256,7 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() { } }, resolve = { workspaces.workspace(it) }, + history = history, ) managers.add(manager) return manager 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 68252a2afd4..0060420b953 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 @@ -20,6 +20,8 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking +import java.time.Instant +import java.time.temporal.ChronoUnit @Suppress("UnstableApiUsage") class HistoryControllerTest : BasePlatformTestCase() { @@ -132,6 +134,53 @@ class HistoryControllerTest : BasePlatformTestCase() { assertEquals(1, panel.itemCount()) } + fun `test panel preserves independent search per source`() { + rpc.listed += session("ses_1", "Alpha") + rpc.listed += session("ses_2", "Beta") + rpc.cloud += cloud("cloud_1", "Cloud Alpha") + rpc.cloud += cloud("cloud_2", "Cloud Beta") + val panel = HistoryPanel(parent, controller()) + flush() + + panel.setSearch("alp") + assertEquals(1, panel.itemCount()) + + panel.clickCloud() + flush() + assertEquals(2, panel.itemCount()) + panel.setSearch("beta") + assertEquals(1, panel.itemCount()) + + panel.clickLocal() + assertEquals(1, panel.itemCount()) + } + + fun `test panel groups sessions by date`() { + val now = Instant.now() + rpc.listed += session("ses_today", "Today", now.toEpochMilli().toDouble()) + rpc.listed += session("ses_yesterday", "Yesterday", now.minus(1, ChronoUnit.DAYS).toEpochMilli().toDouble()) + rpc.listed += session("ses_week", "Week", now.minus(3, ChronoUnit.DAYS).toEpochMilli().toDouble()) + rpc.listed += session("ses_month", "Month", now.minus(10, ChronoUnit.DAYS).toEpochMilli().toDouble()) + rpc.listed += session("ses_older", "Older", now.minus(60, ChronoUnit.DAYS).toEpochMilli().toDouble()) + val panel = HistoryPanel(parent, controller()) + flush() + + assertTrue(panel.groupTitles().containsAll(listOf("Today", "Yesterday", "This Week", "Older"))) + } + + fun `test local renderer exposes delete and cloud renderer hides it`() { + rpc.listed += session("ses_1", "Local") + rpc.cloud += cloud("cloud_1", "Cloud") + val panel = HistoryPanel(parent, controller()) + flush() + + assertTrue(panel.deleteVisible(0)) + + panel.clickCloud() + flush() + assertFalse(panel.cloudDeleteVisible(0)) + } + private fun controller() = HistoryController(sessions, workspace, scope) private fun collect(controller: HistoryController): MutableList { @@ -150,13 +199,13 @@ class HistoryControllerTest : BasePlatformTestCase() { } } - private fun session(id: String, title: String) = SessionDto( + private fun session(id: String, title: String, updated: Double = 2.0) = SessionDto( id = id, projectID = "prj", directory = "/test", title = title, version = "1", - time = SessionTimeDto(created = 1.0, updated = 2.0), + time = SessionTimeDto(created = 1.0, updated = updated), ) private fun cloud(id: String, title: String) = CloudSessionDto(