feat(jetbrains): add cloud history repo-only filter

Resolve remaining parity gap from PR #10097 review: VS Code's cloud
history has an 'Only this repository' checkbox that filters sessions by
git remote URL; JetBrains had the RPC plumbing but no UI or URL lookup.

- Add GitRemoteUrl.kt helper that resolves 'origin' via git subprocess
- HistoryController gains gitUrl (resolved once on first cloud load) and
  repoOnly (defaults true when a URL exists); applyRepoOnly() toggles
  the filter and reloads cloud history
- HistoryPanel adds a JBCheckBox on the cloud tab, visible only when a
  git remote URL is found, with left indent aligned to list item text
- Add bundle key history.cloud.repo.only
- Extend HistoryControllerTest with 8 new cases covering URL filtering,
  null fallback, load-more retention, and checkbox panel behaviour
This commit is contained in:
kirillk
2026-05-13 15:22:22 -04:00
parent 9a08ccf576
commit d25f537a11
5 changed files with 200 additions and 3 deletions
@@ -0,0 +1,19 @@
package ai.kilocode.client.session.history
/**
* Resolves the origin remote URL for a given directory by running
* `git remote get-url origin` as a subprocess.
*
* Returns null when the directory is not a git repo, has no origin remote,
* or the command fails for any reason.
*
* Overridable in tests via [resolve] parameter.
*/
internal fun resolveGitRemoteUrl(dir: String): String? = runCatching {
val proc = ProcessBuilder("git", "remote", "get-url", "origin")
.directory(java.io.File(dir))
.start()
val out = proc.inputStream.bufferedReader().readText().trim()
val code = proc.waitFor()
if (code == 0 && out.isNotEmpty()) out else null
}.getOrNull()
@@ -16,6 +16,7 @@ class HistoryController(
private val cs: CoroutineScope,
open: (SessionRef) -> Unit = {},
private val deleted: (String) -> Unit = {},
private val gitUrlProvider: () -> String? = { resolveGitRemoteUrl(workspace.directory) },
) {
companion object {
const val CLOUD_LIMIT = 50
@@ -24,6 +25,24 @@ class HistoryController(
val local = HistoryModel<LocalHistoryItem>()
val cloud = CloudHistoryModel()
/** Resolved once on first cloud load; null means no remote found. Written from IO, read on EDT. */
@Volatile
var gitUrl: String? = null
private set
/** Whether to filter cloud history by the current repository. */
var repoOnly: Boolean = false
private set
/** Notified on EDT when [repoOnly] changes (e.g. to update checkbox state). */
var onRepoOnlyChanged: ((Boolean) -> Unit)? = null
private fun updateRepoOnly(value: Boolean) {
if (repoOnly == value) return
repoOnly = value
edt { onRepoOnlyChanged?.invoke(value) }
}
private val deleting = mutableSetOf<String>()
private val opener = open
@@ -54,6 +73,11 @@ class HistoryController(
loadCloud(reset = false)
}
fun applyRepoOnly(value: Boolean) {
updateRepoOnly(value)
edt { reloadCloud() }
}
fun delete(item: LocalHistoryItem) {
edt {
if (item.id in deleting) return@edt
@@ -104,8 +128,10 @@ class HistoryController(
val cursor = cloud.cursor.takeUnless { reset }
edt { cloud.start(reset) }
cs.launch {
val url = resolveUrlIfNeeded()
val filter = if (repoOnly) url else null
try {
val result = sessions.cloudSessions(workspace.directory, cursor, CLOUD_LIMIT, null)
val result = sessions.cloudSessions(workspace.directory, cursor, CLOUD_LIMIT, filter)
val items = result.sessions.map(::cloudItem)
edt {
if (reset) cloud.replace(items, result.nextCursor)
@@ -116,6 +142,24 @@ class HistoryController(
}
}
}
/**
* Resolves [gitUrl] on first cloud load. Subsequent calls return the cached value.
* Also enables [repoOnly] by default when a URL is found the first time.
*
* Must be called from a coroutine. Writes [gitUrl] directly (volatile) and then
* propagates state updates to EDT via [edt].
*/
private suspend fun resolveUrlIfNeeded(): String? {
if (gitUrl != null) return gitUrl
val url = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
gitUrlProvider()
}
// Write gitUrl directly (volatile) so it is visible before EDT callbacks fire.
gitUrl = url
if (url != null) updateRepoOnly(true)
return url
}
}
private fun edt(block: () -> Unit) {
@@ -22,6 +22,7 @@ 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
@@ -64,6 +65,11 @@ class HistoryPanel(
private val localList = localList()
private val cloudList = cloudList()
private val more = LoadMoreButton()
private val repoOnly = JBCheckBox(KiloBundle.message("history.cloud.repo.only"), true).apply {
isVisible = false
border = JBUI.Borders.emptyLeft(UiStyle.Gap.lg())
addActionListener { controller.applyRepoOnly(isSelected) }
}
private val localPanel = panel(localSearch, localList)
private val cloudPanel = panel(cloudSearch, cloudList, more)
private val cards = CardLayout()
@@ -96,6 +102,9 @@ class HistoryPanel(
bind(localList, controller.local)
bind(cloudList, controller.cloud)
bindTheme()
controller.onRepoOnlyChanged = { value ->
repoOnly.isSelected = value
}
addHierarchyListener { e ->
if (e.changeFlags and HierarchyEvent.SHOWING_CHANGED.toLong() == 0L) return@addHierarchyListener
if (isShowing && stale) {
@@ -186,7 +195,13 @@ class HistoryPanel(
private fun panel(search: SearchTextField, list: JList<out HistoryItem>, footer: JComponent? = null): JComponent {
return BorderLayoutPanel().apply {
add(search, BorderLayout.NORTH)
val north = BorderLayoutPanel().apply {
add(search, BorderLayout.CENTER)
if (list === cloudList) {
add(repoOnly, BorderLayout.SOUTH)
}
}
add(north, BorderLayout.NORTH)
add(JBScrollPane(list).apply {
border = JBUI.Borders.empty()
viewportBorder = JBUI.Borders.empty()
@@ -265,6 +280,7 @@ class HistoryPanel(
syncList(cloudList, controller.cloud)
more.isEnabled = controller.cloud.cursor != null && !controller.cloud.loading
more.isVisible = controller.cloud.cursor != null || controller.cloud.loading
repoOnly.isVisible = controller.gitUrl != null
cards.show(body, if (loading()) CARD_LOAD else CARD_TABS)
revalidate()
repaint()
@@ -404,6 +420,14 @@ class HistoryPanel(
return items.indices.mapNotNull { HistoryRenderer.section(items, it) }
}
internal fun repoOnlyVisible() = repoOnly.isVisible
internal fun repoOnlySelected() = repoOnly.isSelected
internal fun clickRepoOnly() {
repoOnly.doClick()
}
private fun activeList(): JBList<out HistoryItem> = if (tabs.selectedInfo === cloudInfo) cloudList else localList
private fun activeModel(): HistoryModel<out HistoryItem> = if (tabs.selectedInfo === cloudInfo) controller.cloud else controller.local
@@ -423,7 +447,7 @@ class HistoryPanel(
}
override fun dispose() {
// no-op
controller.onRepoOnlyChanged = null
}
private class LoadMoreButton : JButton(KiloBundle.message("history.cloud.load.more")) {
@@ -96,6 +96,7 @@ history.delete.confirm.message.multiple=Delete {0} sessions from local history?
history.rename.title=Rename Session
history.rename.prompt=New session name:
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
@@ -546,8 +546,117 @@ class HistoryControllerTest : BasePlatformTestCase() {
assertEquals(listOf("ses_2"), controller.local.visibleItems.map { it.id })
}
fun `test cloud load passes git url when repo only enabled`() {
rpc.cloud += cloud("cloud_1", "Cloud One")
val url = "git@github.com:test/repo.git"
val controller = controllerWithGit(url)
controller.reloadCloud()
flush()
assertEquals(1, rpc.cloudCalls.size)
assertEquals(url, rpc.cloudCalls[0].gitUrl)
assertEquals(true, controller.repoOnly)
assertEquals(url, controller.gitUrl)
}
fun `test cloud load passes null when no git url`() {
rpc.cloud += cloud("cloud_1", "Cloud One")
val controller = controllerWithGit(null)
controller.reloadCloud()
flush()
assertEquals(1, rpc.cloudCalls.size)
assertNull(rpc.cloudCalls[0].gitUrl)
assertEquals(false, controller.repoOnly)
assertNull(controller.gitUrl)
}
fun `test cloud load passes null when repo only disabled`() {
rpc.cloud += cloud("cloud_1", "Cloud One")
val url = "git@github.com:test/repo.git"
val controller = controllerWithGit(url)
controller.reloadCloud()
flush()
assertEquals(url, rpc.cloudCalls[0].gitUrl)
controller.applyRepoOnly(false)
flush()
assertEquals(2, rpc.cloudCalls.size)
assertNull(rpc.cloudCalls[1].gitUrl)
}
fun `test load more passes git url when repo only enabled`() {
rpc.cloud += cloud("cloud_1", "Cloud One")
rpc.cloudCursor = "next_1"
val url = "git@github.com:test/repo.git"
val controller = controllerWithGit(url)
controller.reloadCloud()
flush()
rpc.cloud.clear()
rpc.cloud += cloud("cloud_2", "Cloud Two")
rpc.cloudCursor = null
controller.loadMoreCloud()
flush()
assertEquals(2, rpc.cloudCalls.size)
assertEquals(url, rpc.cloudCalls[1].gitUrl)
}
fun `test repo only checkbox visible only when git url exists`() {
val url = "git@github.com:test/repo.git"
val panel = HistoryPanel(parent, controllerWithGit(url))
flush()
panel.clickCloud()
flush()
assertTrue(panel.repoOnlyVisible())
assertTrue(panel.repoOnlySelected())
}
fun `test repo only checkbox hidden when no git url`() {
val panel = HistoryPanel(parent, controllerWithGit(null))
flush()
panel.clickCloud()
flush()
assertFalse(panel.repoOnlyVisible())
}
fun `test repo only checkbox toggle reloads cloud history`() {
rpc.cloud += cloud("cloud_1", "Cloud One")
val url = "git@github.com:test/repo.git"
val panel = HistoryPanel(parent, controllerWithGit(url))
flush()
panel.clickCloud()
flush()
val before = rpc.cloudCalls.size
panel.clickRepoOnly()
flush()
assertTrue(rpc.cloudCalls.size > before)
assertFalse(panel.repoOnlySelected())
}
private fun controller() = HistoryController(sessions, workspace, scope)
private fun controllerWithGit(url: String?) = HistoryController(
sessions,
workspace,
scope,
gitUrlProvider = { url },
)
private fun controller(opened: MutableList<String>) = HistoryController(sessions, workspace, scope, open = { open ->
val id = when (open) {
is SessionRef.Local -> open.id