From d25f537a114dec2bbfeb703f476f0c268e83dda1 Mon Sep 17 00:00:00 2001 From: kirillk Date: Wed, 13 May 2026 15:22:22 -0400 Subject: [PATCH] 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 --- .../client/session/history/GitRemoteUrl.kt | 19 +++ .../session/history/HistoryController.kt | 46 +++++++- .../client/session/history/HistoryPanel.kt | 28 ++++- .../resources/messages/KiloBundle.properties | 1 + .../session/history/HistoryControllerTest.kt | 109 ++++++++++++++++++ 5 files changed, 200 insertions(+), 3 deletions(-) create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/GitRemoteUrl.kt diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/GitRemoteUrl.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/GitRemoteUrl.kt new file mode 100644 index 00000000000..ad614060638 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/GitRemoteUrl.kt @@ -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() 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 d66e4665ecb..3fed465de6f 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 @@ -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() 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() 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) { 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 1cdefe26bb0..b3ad33485d8 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 @@ -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, 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 = if (tabs.selectedInfo === cloudInfo) cloudList else localList private fun activeModel(): HistoryModel = 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")) { diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties index aeee59d987c..298028014b7 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -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 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 8cddf763f0a..2f9d3cb960d 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 @@ -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) = HistoryController(sessions, workspace, scope, open = { open -> val id = when (open) { is SessionRef.Local -> open.id