mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-19 01:51:21 +08:00
feat(jetbrains): add native session history
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": patch
|
||||
---
|
||||
|
||||
Add a native session history panel to the JetBrains plugin.
|
||||
+2
-1
@@ -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)
|
||||
|
||||
+22
@@ -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
|
||||
}
|
||||
}
|
||||
+2
@@ -10,5 +10,7 @@ interface SessionManager {
|
||||
|
||||
fun newSession()
|
||||
|
||||
fun showHistory()
|
||||
|
||||
fun openSession(session: SessionDto)
|
||||
}
|
||||
|
||||
+41
@@ -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<SessionUiFactory>().create(project, workspace, manager, id, loading)
|
||||
},
|
||||
private val resolve: (String) -> Workspace = { dir -> service<KiloWorkspaceService>().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<String, SessionUi>()
|
||||
private val all = mutableSetOf<SessionUi>()
|
||||
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<SessionUiFactory>()
|
||||
val cs = factory.scope()
|
||||
val controller = HistoryController(
|
||||
sessions = project.service<KiloSessionService>(),
|
||||
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
|
||||
|
||||
+1
-1
@@ -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))
|
||||
}
|
||||
|
||||
+11
-5
@@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
+98
-14
@@ -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<HistoryItem> {
|
||||
private val title = JBLabel()
|
||||
private val meta = JBLabel()
|
||||
private const val DELETE_CLICK_AREA_WIDTH = 32
|
||||
|
||||
internal class HistoryListRenderer(
|
||||
private val model: CollectionListModel<HistoryItem>,
|
||||
private val source: () -> HistorySource,
|
||||
private val deletable: Boolean,
|
||||
) : JPanel(BorderLayout()), ListCellRenderer<HistoryItem> {
|
||||
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<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 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<HistoryItem> {
|
||||
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
|
||||
}
|
||||
|
||||
+7
-1
@@ -22,6 +22,9 @@ class HistoryModel {
|
||||
private set
|
||||
var cursor: String? = null
|
||||
private set
|
||||
private val deleting = mutableSetOf<String>()
|
||||
|
||||
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<HistoryItem>, 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
|
||||
|
||||
+162
-64
@@ -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<HistoryItem>()
|
||||
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<HistoryItem>()
|
||||
private val cloudRows = CollectionListModel<HistoryItem>()
|
||||
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<HistoryItem>, 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<HistoryItem>, 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<HistoryItem>, items: List<HistoryItem>, 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<HistoryItem>, 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<HistoryItem>, 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<String> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+71
@@ -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<HistoryItem>): List<HistoryItem> = items.sortedWith(
|
||||
compareByDescending<HistoryItem> { millis(it) ?: Long.MIN_VALUE }
|
||||
.thenBy { it.title.lowercase() }
|
||||
.thenBy { it.id },
|
||||
)
|
||||
}
|
||||
@@ -40,6 +40,9 @@
|
||||
class="ai.kilocode.client.actions.KiloSettingsAction"
|
||||
icon="AllIcons.General.GearPlain"/>
|
||||
|
||||
<action id="Kilo.History"
|
||||
class="ai.kilocode.client.actions.HistoryAction"/>
|
||||
|
||||
<action id="Kilo.SendPrompt"
|
||||
class="ai.kilocode.client.actions.SendPromptAction"
|
||||
text="Send Prompt"
|
||||
|
||||
@@ -73,8 +73,21 @@ history.empty=No sessions
|
||||
history.loading=Loading...
|
||||
history.untitled=Untitled
|
||||
history.delete.text=Delete
|
||||
history.delete.confirm.title=Delete session?
|
||||
history.delete.confirm.message=Delete "{0}" from local history?
|
||||
history.cloud.load.more=Load more
|
||||
history.cloud.repo.only=Only this repository
|
||||
history.group.today=Today
|
||||
history.group.yesterday=Yesterday
|
||||
history.group.week=This Week
|
||||
history.group.month=This Month
|
||||
history.group.older=Older
|
||||
history.time.moments=Moments ago
|
||||
history.time.minutes={0} min ago
|
||||
history.time.hours={0}h ago
|
||||
history.time.days={0}d ago
|
||||
history.time.months={0}mo ago
|
||||
history.time.years={0}y ago
|
||||
history.error.local=Failed to load local history
|
||||
history.error.cloud=Failed to load cloud history
|
||||
history.error.local.delete=Failed to delete session
|
||||
@@ -84,6 +97,8 @@ action.Kilo.Settings.text=Settings
|
||||
action.Kilo.Settings.description=Kilo Code settings
|
||||
action.Kilo.NewSession.text=New Session
|
||||
action.Kilo.NewSession.description=Start a new Kilo session
|
||||
action.Kilo.History.text=History
|
||||
action.Kilo.History.description=Show session history
|
||||
action.Kilo.SendPrompt.text=Send Prompt
|
||||
action.Kilo.SendPrompt.description=Send the current Kilo prompt
|
||||
action.Kilo.StopSession.text=Stop Session
|
||||
|
||||
+61
-1
@@ -19,6 +19,8 @@ import com.intellij.testFramework.fixtures.BasePlatformTestCase
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import javax.swing.JLabel
|
||||
import javax.swing.JComponent
|
||||
import javax.swing.JPanel
|
||||
|
||||
@Suppress("UnstableApiUsage")
|
||||
@@ -184,7 +186,64 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() {
|
||||
assertEquals(0, manager.component.componentCount)
|
||||
}
|
||||
|
||||
private fun manager(): SessionSidePanelManager {
|
||||
fun `test show history swaps active component`() {
|
||||
val history = JLabel("History")
|
||||
val manager = manager(history = { _, _, _ -> 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
|
||||
|
||||
+51
-2
@@ -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<HistoryModelEvent> {
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user