From efac986994b91d7b92cd3352e4d2fcc88336a70a Mon Sep 17 00:00:00 2001 From: Shivanshu Date: Mon, 31 Aug 2026 18:14:45 -0700 Subject: [PATCH 1/8] feat: IDE-features batch - FILES/SEARCH/GIT tabs, git MCP tools, api 1.0.87 - SEARCH tab: global content search + replace over the host's ContentSearchProvider (project_search / project_replace MCP tools, new project.replace permission) - GIT tab: changes accordion (stage/unstage/discard/checkout row actions), commit graph with branch model, agent-review and commit-message prompts - MCP tools: git_status, git_stage, git_unstage, git_stage_all, git_unstage_all, git_discard, git_checkout, git_diff, git_diff_all, git_diff_ref, git_diff_between, project_search, project_replace - Version 1.6.0 (build.gradle.kts, synced into plugin.json by processResources): the release that supersedes git-status and git-log - the host's retirement floor is >= 1.6.0 - boss-plugin-api 1.0.72 -> 1.0.87 (IDE-features surface) --- .gitignore | 4 + build.gradle.kts | 6 +- .../dynamic/codebase/AgentReviewPrompt.kt | 89 + .../dynamic/codebase/CodebaseComponent.kt | 336 +++- .../dynamic/codebase/CodebaseContent.kt | 43 +- .../dynamic/codebase/CodebaseDynamicPlugin.kt | 118 +- .../dynamic/codebase/CodebaseGitMcpTools.kt | 376 ++++ .../plugin/dynamic/codebase/CodebaseUi.kt | 676 +++++++ .../dynamic/codebase/CommitMessagePrompt.kt | 63 + .../plugin/dynamic/codebase/GitBranchModel.kt | 216 +++ .../plugin/dynamic/codebase/GitChangeTree.kt | 158 ++ .../plugin/dynamic/codebase/GitGraphEdges.kt | 108 ++ .../plugin/dynamic/codebase/GitGraphLayout.kt | 77 + .../boss/plugin/dynamic/codebase/GitTab.kt | 745 ++++++++ .../boss/plugin/dynamic/codebase/GitTabUi.kt | 1675 +++++++++++++++++ .../boss/plugin/dynamic/codebase/SearchTab.kt | 950 ++++++++++ .../META-INF/boss-plugin/plugin.json | 16 +- .../dynamic/codebase/AgentReviewPromptTest.kt | 56 + .../codebase/AiUnavailableMessageTest.kt | 54 + .../dynamic/codebase/GitBranchModelTest.kt | 250 +++ .../dynamic/codebase/GitChangeTreeTest.kt | 112 ++ .../codebase/GitGraphBranchViewModelTest.kt | 292 +++ .../dynamic/codebase/GitGraphEdgesTest.kt | 103 + .../dynamic/codebase/GitGraphLayoutTest.kt | 86 + .../codebase/GitRefBadgeMetricsTest.kt | 59 + .../dynamic/codebase/GitTabCompactDiffTest.kt | 60 + .../dynamic/codebase/PanelUiLogicTest.kt | 315 ++++ .../plugin/dynamic/codebase/SearchPathTest.kt | 108 ++ 28 files changed, 7103 insertions(+), 48 deletions(-) create mode 100644 src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/AgentReviewPrompt.kt create mode 100644 src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/CodebaseGitMcpTools.kt create mode 100644 src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/CodebaseUi.kt create mode 100644 src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/CommitMessagePrompt.kt create mode 100644 src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitBranchModel.kt create mode 100644 src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitChangeTree.kt create mode 100644 src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitGraphEdges.kt create mode 100644 src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitGraphLayout.kt create mode 100644 src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitTab.kt create mode 100644 src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitTabUi.kt create mode 100644 src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/SearchTab.kt create mode 100644 src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/AgentReviewPromptTest.kt create mode 100644 src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/AiUnavailableMessageTest.kt create mode 100644 src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitBranchModelTest.kt create mode 100644 src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitChangeTreeTest.kt create mode 100644 src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitGraphBranchViewModelTest.kt create mode 100644 src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitGraphEdgesTest.kt create mode 100644 src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitGraphLayoutTest.kt create mode 100644 src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitRefBadgeMetricsTest.kt create mode 100644 src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitTabCompactDiffTest.kt create mode 100644 src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/PanelUiLogicTest.kt create mode 100644 src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/SearchPathTest.kt diff --git a/.gitignore b/.gitignore index 5e8573d..b635a89 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,7 @@ build/ *.iml local.properties .DS_Store + +# Eclipse/Kotlin-LSP build output. Regenerated on every IDE build; never tracked. +bin/ +.kotlin/ diff --git a/build.gradle.kts b/build.gradle.kts index 942edb0..3143971 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -7,7 +7,7 @@ plugins { } group = "ai.rever.boss.plugin.dynamic" -version = "1.5.8" +version = "1.6.0" java { toolchain { @@ -118,6 +118,10 @@ tasks.register("buildPluginJar") { // Sync version from build.gradle.kts into plugin.json (single source of truth) tasks.processResources { + // The filter rewrites the manifest version at execution time; declaring the + // version as an input makes a version bump re-run the task. Without this the + // processed manifest stays UP-TO-DATE and the new jar carries the old version. + inputs.property("pluginVersion", version) filesMatching("**/plugin.json") { filter { line -> line.replace(Regex(""""version"\s*:\s*"[^"]*""""), """"version": "\$version"""") diff --git a/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/AgentReviewPrompt.kt b/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/AgentReviewPrompt.kt new file mode 100644 index 0000000..aa79f43 --- /dev/null +++ b/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/AgentReviewPrompt.kt @@ -0,0 +1,89 @@ +package ai.rever.boss.plugin.dynamic.codebase + +import ai.rever.boss.plugin.api.GitFileStatusData + +/** + * Prompt builder for the Agent Review feature (P7). + * + * Pure function so it is unit-testable: takes the changed-file listing plus + * whatever diff text was fetched (capped by the caller) and renders the + * review brief the Atlas agent receives. + */ +object AgentReviewPrompt { + + /** Inline diffs up to this many characters; above it, list the files and + * point the agent at the git_diff tools instead (keeps the prompt bounded). + */ + const val INLINE_DIFF_BUDGET = 15_000 + + fun build( + projectPath: String, + staged: List, + unstaged: List, + diffText: String?, + /** Free text from the review panel; empty when the user gave none. */ + instructions: String = "", + /** Quick keeps it to a short pass; Deep asks for a thorough one. */ + deep: Boolean = false, + /** The ref the change is being reviewed against, e.g. "main". */ + baseRef: String = "", + ): String { + val sb = StringBuilder() + if (baseRef.isNotBlank()) { + sb.appendLine("Review this branch's changes against `$baseRef` (index + working tree).") + } else { + sb.appendLine("Review the uncommitted changes in this project (index + working tree vs HEAD).") + } + sb.appendLine() + sb.appendLine("Focus: correctness, bugs, security, naming, and whether the change does what it claims.") + if (deep) { + sb.appendLine( + "Go deep: trace the affected call paths, consider concurrency, error handling, " + + "and edge cases, and check whether tests cover the change.", + ) + } else { + sb.appendLine("Keep it quick: surface the issues that matter most, not an exhaustive audit.") + } + sb.appendLine("Be specific: cite file:line, say what is wrong, suggest a fix.") + sb.appendLine("Do not apply any edits; this is a review only.") + if (instructions.isNotBlank()) { + sb.appendLine() + sb.appendLine("The reviewer also asked:") + sb.appendLine(instructions.trim()) + } + sb.appendLine() + + if (staged.isEmpty() && unstaged.isEmpty()) { + sb.appendLine("There are no uncommitted changes in ") + sb.appendLine(projectPath) + sb.appendLine(".") + return sb.toString().trimEnd() + } + + if (staged.isNotEmpty()) { + sb.appendLine("Staged files:") + staged.forEach { sb.appendLine(" [${it.indexStatus?.name ?: "?"}] ${it.path}") } + sb.appendLine() + } + if (unstaged.isNotEmpty()) { + sb.appendLine("Working-tree changes:") + unstaged.forEach { sb.appendLine(" [${it.workTreeStatus?.name ?: "?"}] ${it.path}") } + sb.appendLine() + } + + if (diffText.isNullOrBlank()) { + sb.appendLine("The diff could not be fetched inline; use the git_diff / git_diff_all tools to read it.") + } else if (diffText.length >= INLINE_DIFF_BUDGET) { + sb.appendLine( + "The full diff exceeds the inline budget (${diffText.length} chars); " + + "use the git_diff / git_diff_ref tools to read the individual file diffs.", + ) + } else { + sb.appendLine("Full diff:") + sb.appendLine("```diff") + sb.appendLine(diffText) + sb.appendLine("```") + } + return sb.toString().trimEnd() + } +} \ No newline at end of file diff --git a/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/CodebaseComponent.kt b/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/CodebaseComponent.kt index bcfce6d..54b7153 100644 --- a/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/CodebaseComponent.kt +++ b/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/CodebaseComponent.kt @@ -3,18 +3,86 @@ package ai.rever.boss.plugin.dynamic.codebase import ai.rever.boss.plugin.api.ContextMenuProvider import ai.rever.boss.plugin.api.DirectoryPickerProvider import ai.rever.boss.plugin.api.FileSystemDataProvider +import ai.rever.boss.plugin.api.GitDataProvider import ai.rever.boss.plugin.api.PanelComponentWithUI import ai.rever.boss.plugin.api.PanelInfo +import ai.rever.boss.plugin.api.PluginStorageProvider +import ai.rever.boss.plugin.api.ProjectSearchProvider import ai.rever.boss.plugin.api.SplitViewOperations +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.Icon +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.AccountTree +import androidx.compose.material.icons.rounded.FolderOpen +import androidx.compose.material.icons.rounded.Inventory2 +import androidx.compose.material.icons.rounded.Search import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import com.arkivanov.decompose.ComponentContext import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +/** The three top tabs of the codebase panel (P7). */ +enum class CodebaseTab(val label: String, val storageKey: String) { + FILES("Files", "files"), + SEARCH("Search", "search"), + GIT("Git", "git"); + + val icon: ImageVector + get() = + when (this) { + FILES -> Icons.Rounded.FolderOpen + SEARCH -> Icons.Rounded.Search + GIT -> Icons.Rounded.AccountTree + } + + companion object { + fun fromStorage(key: String?): CodebaseTab = + entries.firstOrNull { it.storageKey == key } ?: FILES + } +} /** - * Codebase panel component (Dynamic Plugin) + * Codebase panel component (Dynamic Plugin). * - * Provides file browser functionality for project exploration. - * Ported from bundled plugin v8.16.22 with exact UI parity. + * P7: the panel is a FILES/SEARCH/GIT tab switch (Cursor-style, on BOSS's + * palette). FILES keeps the original file tree; SEARCH is global search & + * replace on the host's [ProjectSearchProvider]; GIT is the change groups + + * lane graph + Agent Review. The selected tab persists across restarts via + * [storage]. */ class CodebaseComponent( ctx: ComponentContext, @@ -23,23 +91,265 @@ class CodebaseComponent( private val contextMenuProvider: ContextMenuProvider?, private val directoryPickerProvider: DirectoryPickerProvider?, private val splitViewOperations: SplitViewOperations?, + private val gitDataProvider: GitDataProvider?, + private val searchProvider: ProjectSearchProvider?, + private val storage: PluginStorageProvider?, + private val onAgentReview: (prompt: String) -> Unit, private val scope: CoroutineScope, private val getWindowId: () -> String?, private val getProjectPath: () -> String?, - private val onSelectProject: ((String, String) -> Unit)? + private val onSelectProject: ((String, String) -> Unit)?, + private val aiGateway: () -> ai.rever.boss.plugin.api.AiGatewayAPI? = { null }, + private val aiUnavailable: () -> String? = { null } ) : PanelComponentWithUI, ComponentContext by ctx { @Composable override fun Content() { - CodebaseContent( - fileSystemDataProvider = fileSystemDataProvider, - directoryPickerProvider = directoryPickerProvider, - splitViewOperations = splitViewOperations, - contextMenuProvider = contextMenuProvider, - scope = scope, - getWindowId = getWindowId, - getProjectPath = getProjectPath, - onSelectProject = onSelectProject + var selectedTab by remember { mutableStateOf(CodebaseTab.FILES) } + + // Both view models own a coroutine scope, so they are created once and + // cancelled when the panel leaves composition. They used to be built + // by assigning to state *during* composition and never disposed, which + // leaked a scope (and a 15s git poll) per panel open. + val searchViewModel = remember { + CodebaseSearchViewModel( + provider = searchProvider, + splitViewOperations = splitViewOperations, + getProjectPath = getProjectPath, + ) + } + val gitViewModel = remember { + CodebaseGitViewModel( + git = gitDataProvider, + getProjectPath = getProjectPath, + getWindowId = getWindowId, + onAgentReview = onAgentReview, + aiGateway = aiGateway, + aiUnavailable = aiUnavailable, + ) + } + DisposableEffect(Unit) { + onDispose { + searchViewModel.dispose() + gitViewModel.dispose() + } + } + + LaunchedEffect(Unit) { + val saved = storage?.getString("codebase.tab") + selectedTab = CodebaseTab.fromStorage(saved) + // The change-group layout was a `remember` inside the GIT tab, so + // it reset on every hop to FILES and back. It is a preference; + // persist it beside the selected tab. Seeded BEFORE the collector + // below starts, so the load is not immediately overwritten. + gitViewModel.setChangeLayout( + GitChangeLayout.fromStorage(storage?.getString("codebase.gitLayout")), + ) + gitViewModel.changeLayout.collect { storage?.putString("codebase.gitLayout", it.storageKey) } + } + // The selected project is exposed as a plain getter, not a flow, so it + // is sampled: the header has to follow a project switch made anywhere + // (the top bar, the FILES picker, another panel), and only re-renders + // when the value actually changes. + var projectPath by remember { mutableStateOf(getProjectPath()) } + LaunchedEffect(Unit) { + while (true) { + delay(PROJECT_POLL_MS) + val current = getProjectPath() + if (current != projectPath) projectPath = current + } + } + + Column(modifier = Modifier.fillMaxSize().background(CodebasePalette.Panel)) { + CodebaseProjectHeader(projectPath) + CodebaseTabStrip(selected = selectedTab) { tab -> + selectedTab = tab + scope.launch(Dispatchers.Default) { + storage?.putString("codebase.tab", tab.storageKey) + } + } + when (selectedTab) { + CodebaseTab.FILES -> + CodebaseContent( + fileSystemDataProvider = fileSystemDataProvider, + directoryPickerProvider = directoryPickerProvider, + splitViewOperations = splitViewOperations, + contextMenuProvider = contextMenuProvider, + scope = scope, + getWindowId = getWindowId, + getProjectPath = getProjectPath, + onSelectProject = onSelectProject, + ) + + CodebaseTab.SEARCH -> + CodebaseSearchContent(viewModel = searchViewModel, modifier = Modifier.fillMaxSize()) + + CodebaseTab.GIT -> + CodebaseGitContent(viewModel = gitViewModel, modifier = Modifier.fillMaxSize()) + } + } + } +} + + +/** + * The project the panel is showing, above the tab strip - so which directory + * you are in is answered without switching to FILES. Name on top, the path + * home-collapsed underneath it, the full absolute path on hover. + */ +@Composable +private fun CodebaseProjectHeader(projectPath: String?) { + val hasProject = !projectPath.isNullOrEmpty() + val name = + if (hasProject) PathUtils.name(projectPath!!).ifEmpty { projectPath } else "No project" + val display = if (hasProject) collapseHome(projectPath!!) else "Open a folder in Files" + + CodebaseTooltip( + text = if (hasProject) projectPath!! else "No project selected", + modifier = Modifier.fillMaxWidth(), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .background(CodebasePalette.Panel) + .padding(start = CodebaseMetrics.Gutter, end = CodebaseMetrics.Gutter, top = 5.dp, bottom = 5.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Rounded.Inventory2, + contentDescription = null, + modifier = Modifier.size(CodebaseMetrics.Glyph), + tint = if (hasProject) CodebasePalette.Secondary else CodebasePalette.Muted, + ) + Spacer(Modifier.width(6.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = name, + fontSize = CodebaseMetrics.SecondaryText, + fontWeight = FontWeight.SemiBold, + letterSpacing = 0.2.sp, + color = if (hasProject) CodebasePalette.Foreground else CodebasePalette.Muted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = display, + fontSize = 10.sp, + fontFamily = FontFamily.Monospace, + color = CodebasePalette.Muted, + maxLines = 1, + // The tail identifies the directory; the head is the part + // worth dropping when the panel is narrow. + overflow = TextOverflow.MiddleEllipsis, + ) + } + } + } +} + +/** `/Users/me/src/app` reads as `~/src/app` - the home prefix carries nothing. */ +internal fun collapseHome(path: String): String { + val home = System.getProperty("user.home")?.trimEnd('/').orEmpty() + if (home.isEmpty()) return path + return when { + path == home -> "~" + path.startsWith("$home/") -> "~" + path.removePrefix(home) + else -> path + } +} + +/** How often the selected project is sampled for the header. */ +private const val PROJECT_POLL_MS = 1_000L + +/** + * The panel's tab strip: icon + label while the panel is wide enough, icons + * alone once it is narrow. The panel is user-resizable, so a fixed three-up + * label row is exactly the layout that breaks - the labels either clip or + * squeeze the icons off-centre. + */ +@Composable +private fun CodebaseTabStrip( + selected: CodebaseTab, + onSelect: (CodebaseTab) -> Unit, +) { + BoxWithConstraints(modifier = Modifier.fillMaxWidth()) { + val showLabels = maxWidth >= LABEL_WIDTH_THRESHOLD + Row( + modifier = Modifier + .fillMaxWidth() + .height(CodebaseMetrics.TabStripHeight), + ) { + CodebaseTab.entries.forEach { tab -> + CodebaseTabButton( + tab = tab, + selected = tab == selected, + showLabel = showLabels, + onClick = { onSelect(tab) }, + modifier = Modifier.weight(1f), + ) + } + } + } + CodebaseHRule() +} + +@Composable +private fun CodebaseTabButton( + tab: CodebaseTab, + selected: Boolean, + showLabel: Boolean, + onClick: () -> Unit, + modifier: Modifier, +) { + val interaction = remember { MutableInteractionSource() } + val hovered by interaction.collectIsHoveredAsState() + val tint = + when { + selected -> CodebasePalette.Foreground + hovered -> CodebasePalette.Secondary + else -> CodebasePalette.Muted + } + Box( + modifier = modifier + .fillMaxSize() + .hoverable(interaction) + .background(if (hovered && !selected) CodebasePalette.Hover else Color.Transparent) + .clickable(onClick = onClick), + ) { + Row( + modifier = Modifier.align(Alignment.Center), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = tab.icon, + contentDescription = tab.label, + modifier = Modifier.size(CodebaseMetrics.Glyph), + tint = tint, + ) + if (showLabel) { + Spacer(Modifier.width(5.dp)) + Text( + text = tab.label.uppercase(), + fontSize = 10.sp, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.SemiBold, + letterSpacing = 1.0.sp, + color = tint, + maxLines = 1, + ) + } + } + // Selection sits on the bottom edge, VS Code's active-tab border. + Box( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .height(2.dp) + .background(if (selected) CodebasePalette.Accent else Color.Transparent), ) } } + +/** Below this the labels are dropped and the strip goes icon-only. */ +private val LABEL_WIDTH_THRESHOLD = 240.dp diff --git a/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/CodebaseContent.kt b/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/CodebaseContent.kt index becae21..c631adb 100644 --- a/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/CodebaseContent.kt +++ b/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/CodebaseContent.kt @@ -48,6 +48,7 @@ import androidx.compose.ui.input.pointer.isSecondaryPressed import androidx.compose.ui.input.pointer.isShiftPressed import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalWindowInfo +import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign @@ -68,7 +69,7 @@ private val BossLinkBlue: Color get() = BossThemeColors.AccentColor private val BossErrorRed: Color get() = BossThemeColors.ErrorColor private val BossTextColor: Color get() = BossThemeColors.TextPrimary // Empty-space sizing assumes every visible tree/status row uses this height. -private val TreeRowHeight = 26.dp +private val TreeRowHeight = CodebaseMetrics.RowHeight /** * Main content composable for the Codebase panel. @@ -200,30 +201,24 @@ fun CodebaseContent( } } } else { - // Header with project info - Surface( - modifier = Modifier.fillMaxWidth(), - color = BossHeaderColor, - elevation = 1.dp + // Section header. The project is named once, by the panel's own + // header above the tab strip - repeating it here (at 14.sp, in a + // second elevated bar) gave the panel two competing titles. + Row( + modifier = Modifier + .fillMaxWidth() + .height(CodebaseMetrics.SectionHeaderHeight + 4.dp) + .padding(horizontal = CodebaseMetrics.Gutter), + verticalAlignment = Alignment.CenterVertically ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = Icons.Outlined.FolderOpen, - contentDescription = "Project", - tint = BossLinkBlue, - modifier = Modifier.size(18.dp) - ) - Spacer(modifier = Modifier.width(8.dp)) + run { Text( - text = projectName, - fontSize = 14.sp, - fontWeight = FontWeight.Medium, - color = BossThemeColors.TextPrimary, + text = "EXPLORER", + fontSize = 11.sp, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.SemiBold, + letterSpacing = 1.0.sp, + color = BossThemeColors.TextSecondary, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f) @@ -268,7 +263,7 @@ fun CodebaseContent( } } - Divider(color = BossDarkBorder) + CodebaseHRule() // File tree, fully virtualized: the visible tree is flattened into // one LazyColumn item per row (issue #8), so deep expanded subtrees diff --git a/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/CodebaseDynamicPlugin.kt b/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/CodebaseDynamicPlugin.kt index a50e69a..eb93501 100644 --- a/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/CodebaseDynamicPlugin.kt +++ b/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/CodebaseDynamicPlugin.kt @@ -1,26 +1,34 @@ package ai.rever.boss.plugin.dynamic.codebase +import ai.rever.boss.plugin.api.AiAvailability +import ai.rever.boss.plugin.api.AiGatewayAPI +import ai.rever.boss.plugin.api.AiReadiness import ai.rever.boss.plugin.api.ContextMenuProvider +import ai.rever.boss.plugin.api.CustomPluginEvent import ai.rever.boss.plugin.api.DirectoryPickerProvider import ai.rever.boss.plugin.api.DynamicPlugin import ai.rever.boss.plugin.api.FileSystemDataProvider +import ai.rever.boss.plugin.api.PanelId import ai.rever.boss.plugin.api.PluginContext import ai.rever.boss.plugin.api.ProjectDataProvider import ai.rever.boss.plugin.api.SplitViewOperations import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch /** * Codebase dynamic plugin - Loaded from external JAR. * - * Browse and explore project files with tree view navigation. - * Features context menus for file operations: create, rename, delete, copy path, reveal in Finder. - * Ported from bundled plugin v8.16.22 with exact UI parity. + * P7: FILES/SEARCH/GIT tab panel (Cursor-style). FILES is the original file + * tree; SEARCH is global search & replace (absorbed from the retired + * search-replace plugin); GIT is the changes accordion + lane graph + Agent + * Review (absorbed from the retired git-status/git-log plugins). All 16 + * git_* / project_* MCP tools now register from this plugin. */ class CodebaseDynamicPlugin : DynamicPlugin { override val pluginId: String = "ai.rever.boss.plugin.dynamic.codebase" override val displayName: String = "Codebase (Dynamic)" - override val version: String = "1.0.9" - override val description: String = "Browse and explore project files" + override val version: String = "1.6.0" + override val description: String = "Files, search & replace, and git status/log/graph for the current project" override val author: String = "Risa Labs" override val url: String = "https://github.com/risa-labs-inc/boss-plugin-codebase" @@ -29,29 +37,86 @@ class CodebaseDynamicPlugin : DynamicPlugin { private var directoryPickerProvider: DirectoryPickerProvider? = null private var splitViewOperations: SplitViewOperations? = null private var projectDataProvider: ProjectDataProvider? = null + private var gitDataProvider: ai.rever.boss.plugin.api.GitDataProvider? = null + private var searchProvider: ai.rever.boss.plugin.api.ProjectSearchProvider? = null private var pluginScope: CoroutineScope? = null private var getWindowId: () -> String? = { null } private var getProjectPath: () -> String? = { null } + private var publishReview: (prompt: String) -> Unit = { _ -> } + + /** Kept so AI availability can be re-checked per action, not cached at load. */ + private var pluginContext: PluginContext? = null override fun register(context: PluginContext) { + pluginContext = context // Capture providers from context fileSystemDataProvider = context.fileSystemDataProvider contextMenuProvider = context.contextMenuProvider directoryPickerProvider = context.directoryPickerProvider splitViewOperations = context.splitViewOperations projectDataProvider = context.projectDataProvider + gitDataProvider = context.gitDataProvider + searchProvider = context.projectSearchProvider pluginScope = context.pluginScope getWindowId = { context.windowId } getProjectPath = { context.projectPath } + val storage = context.pluginStorageFactory?.createStorage(pluginId) + + // Agent Review wiring: publish the event the fluck-agent (Atlas) + // listens for, then raise/focus its panel. + val eventBus = context.applicationEventBus + val panelEvents = context.panelEventProvider + // autoStart is always true: the button IS the request to run the + // review. It used to carry a persisted preference that no control + // ever set, so a stored `false` (codebase.reviewAutoStart) left every + // click merely typing the brief into the agent's composer, with no + // way back. The key is now ignored. + publishReview = { prompt -> + eventBus?.publish( + CustomPluginEvent( + sourcePluginId = pluginId, + eventName = EVENT_ATLAS_REVIEW, + payload = mapOf( + "prompt" to prompt, + "projectPath" to (getProjectPath().orEmpty()), + "autoStart" to true, + ), + ), + ) + val window = getWindowId().orEmpty() + pluginScope?.launch { + panelEvents?.openPanel(PanelId("atlas", 16), window) + } + } + context.panelRegistry.registerPanel(CodebaseInfo) { ctx, panelInfo -> CodebaseComponent( ctx = ctx, panelInfo = panelInfo, + // Resolved per call, never cached: plugin load order is not + // guaranteed, so a null now may be a gateway that has simply + // not registered yet. + aiGateway = { pluginContext?.getPluginAPI(AiGatewayAPI::class.java) }, + // The readiness -> message mapping lives in + // AiUnavailableMessage, not inline: it returns null for the + // HAPPY path, and an elvis operator here treated that null as + // "no plugin context" and reported a perfectly configured + // gateway as "AI is unavailable on this host." + aiUnavailable = { + AiUnavailableMessage.of(pluginContext?.let { AiAvailability.check(it) }) + }, fileSystemDataProvider = fileSystemDataProvider, contextMenuProvider = contextMenuProvider, directoryPickerProvider = directoryPickerProvider, splitViewOperations = splitViewOperations, + gitDataProvider = gitDataProvider, + searchProvider = searchProvider, + storage = storage, + onAgentReview = { prompt -> + pluginScope?.launch { publishReview(prompt) } + ?: publishReview(prompt) + }, scope = pluginScope ?: error("Plugin scope not available"), getWindowId = getWindowId, getProjectPath = getProjectPath, @@ -73,5 +138,48 @@ class CodebaseDynamicPlugin : DynamicPlugin { getProjectPath = getProjectPath, ) ) + // P7: the git_* / project_* tools absorbed from the retired + // git-status, git-log and search-replace plugins. A distinct provider + // id - the registry keys providers by id, and a same-id re-registration + // would replace the codebase_* tools above. + context.registerMcpToolProvider( + CodebaseGitMcpToolProvider( + providerId = "$pluginId.git", + gitProvider = gitDataProvider, + searchProvider = searchProvider, + ) + ) + } + + companion object { + const val EVENT_ATLAS_REVIEW = "atlas.review" } } + +/** + * Why AI is unavailable, or null when it is ready. + * + * Its own object because the contract - null means EVERYTHING IS FINE - is + * the shape that invites `?:`. Written inline, `context?.let { …READY -> null… } + * ?: "AI is unavailable on this host."` reported a working gateway as broken, + * because elvis cannot tell "the let returned null" from "there was no + * context". Only a null [readiness], i.e. no plugin context at all, is + * unavailability here. + */ +internal object AiUnavailableMessage { + + const val NO_HOST = "AI is unavailable on this host." + const val NO_GATEWAY = "The AI Gateway plugin is not loaded. Install or enable it in the Toolbox." + const val NO_PROVIDER = "No AI model selected. Pick a provider or a CLI engine in the AI Gateway." + + /** @param readiness null when there is no plugin context to ask. */ + fun of(readiness: AiReadiness?): String? = + when (readiness) { + null -> NO_HOST + AiReadiness.READY -> null + AiReadiness.GATEWAY_MISSING -> NO_GATEWAY + // AiReadiness is documented as an open set; anything new reads as + // "configure a provider", which is the likelier of the two fixes. + else -> NO_PROVIDER + } +} diff --git a/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/CodebaseGitMcpTools.kt b/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/CodebaseGitMcpTools.kt new file mode 100644 index 0000000..1c773de --- /dev/null +++ b/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/CodebaseGitMcpTools.kt @@ -0,0 +1,376 @@ +package ai.rever.boss.plugin.dynamic.codebase + +import ai.rever.boss.plugin.api.GitDataProvider +import ai.rever.boss.plugin.api.McpToolArgs +import ai.rever.boss.plugin.api.McpToolDefinition +import ai.rever.boss.plugin.api.McpToolHandler +import ai.rever.boss.plugin.api.McpToolProvider +import ai.rever.boss.plugin.api.McpToolResult +import ai.rever.boss.plugin.api.ProjectSearchProvider + +/** + * The git / project-search MCP tools absorbed from the retired + * git-status, git-log and search-replace plugins (P7). + * + * Tool names, schemas and RBAC are carried over verbatim - the P6.2 tool + * tiers pin these names, so the provider move is transparent to Atlas. + * Registered alongside the existing codebase_* tools in + * [CodebaseDynamicPlugin.register]; auto-removed on disable/unload. + */ +internal class CodebaseGitMcpToolProvider( + override val providerId: String, + private val gitProvider: GitDataProvider?, + private val searchProvider: ProjectSearchProvider?, +) : McpToolProvider { + + override fun tools(): List = listOf( + // ---- from git-status ---- + McpToolDefinition( + name = "git_status", + description = "Show the working-tree status (staged, unstaged, and untracked files) of " + + "the current BOSS project, in a git-porcelain-style format (XY path).", + readOnly = true, + handler = McpToolHandler { status() }, + ), + McpToolDefinition( + name = "git_stage", + description = "Stage a file for commit in the current project.", + inputSchema = PATH_SCHEMA, + readOnly = false, + handler = McpToolHandler { args -> + val path = args.path() ?: return@McpToolHandler missing("path") + op("Staged", gitProvider?.stage(path)) + }, + ), + McpToolDefinition( + name = "git_unstage", + description = "Unstage a previously staged file in the current project.", + inputSchema = PATH_SCHEMA, + readOnly = false, + handler = McpToolHandler { args -> + val path = args.path() ?: return@McpToolHandler missing("path") + op("Unstaged", gitProvider?.unstage(path)) + }, + ), + McpToolDefinition( + name = "git_stage_all", + description = "Stage all changed files in the current project.", + readOnly = false, + handler = McpToolHandler { op("Staged all", gitProvider?.stageAll()) }, + ), + McpToolDefinition( + name = "git_unstage_all", + description = "Unstage all staged files in the current project.", + readOnly = false, + handler = McpToolHandler { op("Unstaged all", gitProvider?.unstageAll()) }, + ), + McpToolDefinition( + name = "git_discard", + description = "Discard working-tree changes to a file (irreversible). Use with care.", + inputSchema = PATH_SCHEMA, + readOnly = false, + handler = McpToolHandler { args -> + val path = args.path() ?: return@McpToolHandler missing("path") + op("Discarded", gitProvider?.discardChanges(path)) + }, + ), + McpToolDefinition( + name = "git_checkout", + description = "Checkout a commit, branch, or tag in the current project.", + inputSchema = REF_SCHEMA, + readOnly = false, + handler = McpToolHandler { args -> + val ref = args.string("ref") + ?: return@McpToolHandler missing("ref") + op("Checked out $ref", gitProvider?.checkout(ref)) + }, + ), + McpToolDefinition( + name = "git_diff", + description = "Show the unified diff of one file's working-tree (or staged, when " + + "staged=true) changes vs HEAD.", + inputSchema = DIFF_FILE_SCHEMA, + readOnly = true, + handler = McpToolHandler { args -> + val path = args.string("path") + ?: return@McpToolHandler missing("path") + diffTexts(gitProvider?.diffFile(path, args.boolean("staged") ?: false).orEmpty()) + }, + ), + McpToolDefinition( + name = "git_diff_all", + description = "List every changed file (git diff --name-status) as 'STATUS path' lines. " + + "staged=true lists the index instead of the working tree.", + inputSchema = STAGED_SCHEMA, + readOnly = true, + handler = McpToolHandler { args -> + val names = gitProvider?.diffNames(args.boolean("staged") ?: false) ?: emptyList() + if (names.isEmpty()) McpToolResult("No changes.") + else McpToolResult(names.joinToString("\n") { "${(it.indexStatus ?: it.workTreeStatus)?.name ?: "?"} ${it.path}" }) + }, + ), + McpToolDefinition( + name = "git_diff_ref", + description = "Show the changes introduced by a single commit (git show), optionally " + + "restricted to one path.", + inputSchema = DIFF_REF_SCHEMA, + readOnly = true, + handler = McpToolHandler { args -> + val ref = args.string("ref") + ?: return@McpToolHandler missing("ref") + diffTexts(gitProvider?.diffRef(ref, args.string("path")).orEmpty()) + }, + ), + McpToolDefinition( + name = "git_diff_between", + description = "Show the diff between two refs (git diff from to), optionally restricted " + + "to one path.", + inputSchema = DIFF_BETWEEN_SCHEMA, + readOnly = true, + handler = McpToolHandler { args -> + val from = args.string("from") + ?: return@McpToolHandler missing("from") + val to = args.string("to") + ?: return@McpToolHandler missing("to") + diffTexts(gitProvider?.diffBetween(from, to, args.string("path")).orEmpty()) + }, + ), + // ---- from git-log ---- + McpToolDefinition( + name = "git_log", + description = "List recent commits in the current BOSS project (short hash, subject, author).", + inputSchema = LIMIT_SCHEMA, + readOnly = true, + handler = McpToolHandler { args -> + val limit = args.int("limit") ?: 30 + gitProvider?.refreshLog(limit) + val log = gitProvider?.commitLog?.value.orEmpty().take(limit) + if (log.isEmpty()) McpToolResult("No commits.") + else McpToolResult(log.joinToString("\n") { "${it.shortHash} ${it.subject} (${it.author})" }) + }, + ), + McpToolDefinition( + name = "git_cherry_pick", + description = "Cherry-pick a commit onto the current branch of the current project.", + inputSchema = HASH_SCHEMA, + readOnly = false, + handler = McpToolHandler { args -> + val hash = args.hash() + if (hash == null) missing("hash") else op("Cherry-picked $hash", gitProvider?.cherryPick(hash)) + }, + ), + McpToolDefinition( + name = "git_revert", + description = "Revert a commit in the current project (creates a new revert commit).", + inputSchema = HASH_SCHEMA, + readOnly = false, + handler = McpToolHandler { args -> + val hash = args.hash() + if (hash == null) missing("hash") else op("Reverted $hash", gitProvider?.revert(hash)) + }, + ), + // ---- from search-replace ---- + McpToolDefinition( + name = "project_search", + description = "Search file contents across the current BOSS project (find in files). " + + "Supports literal text or a regular expression, case and whole-word matching, and " + + "a glob filter on the project-relative path. Binary and oversized files are skipped; " + + "results are capped by maxResults.", + inputSchema = SEARCH_SCHEMA, + readOnly = true, + handler = McpToolHandler { args -> search(args) }, + ), + McpToolDefinition.withRbac( + name = "project_replace", + description = "Replace occurrences in an EXPLICIT list of files - never project-wide. " + + "dryRun defaults to true (counts what would change without writing). Open buffers " + + "are edited through the editor's undoable path; closed files are written to disk. " + + "For regex queries the replacement supports $1..$9 capture references.", + inputSchema = REPLACE_SCHEMA, + readOnly = false, + requiredPermissions = listOf("project.replace"), + handler = McpToolHandler { args -> replace(args) }, + ), + ) + + // ---- handlers ---- + + private suspend fun search(args: McpToolArgs): McpToolResult { + val provider = searchProvider ?: return unavailable() + val query = args.string("query") ?: return missing("query") + val matches = + provider.searchInProject( + query = query, + pathPattern = args.string("pathPattern"), + isRegex = args.boolean("isRegex") ?: false, + caseSensitive = args.boolean("caseSensitive") ?: false, + wholeWord = args.boolean("wholeWord") ?: false, + maxResults = args.int("maxResults") ?: 100, + ) + if (matches.isEmpty()) return McpToolResult("No matches.") + val lines = + matches.take(MAX_RESULT_LINES).joinToString("\n") { m -> + "${m.path}:${m.line}:${m.column}: ${m.contextLine.trim()}" + } + val more = + if (matches.size > MAX_RESULT_LINES) "\n... [${matches.size - MAX_RESULT_LINES} more matches]" else "" + return McpToolResult("${matches.size} match(es):\n$lines$more") + } + + private suspend fun replace(args: McpToolArgs): McpToolResult { + val provider = searchProvider ?: return unavailable() + val query = args.string("query") ?: return missing("query") + val replacement = args.string("replacement") ?: return missing("replacement") + val files = + args.string("files") + ?.split(",", "\n") + ?.map { it.trim() } + ?.filter { it.isNotEmpty() } + ?: return missing("files") + if (files.isEmpty()) return McpToolResult("Argument 'files' must not be empty.", isError = true) + val dryRun = args.boolean("dryRun") ?: true + val summary = + provider.replaceInProject( + query = query, + replacement = replacement, + files = files, + isRegex = args.boolean("isRegex") ?: false, + caseSensitive = args.boolean("caseSensitive") ?: false, + wholeWord = args.boolean("wholeWord") ?: false, + dryRun = dryRun, + ) + val verb = if (dryRun) "would replace" else "replaced" + var out = "$verb ${summary.totalReplacements} occurrence(s) in ${summary.filesReplaced} file(s)." + val errors = summary.files.filter { it.error != null } + if (errors.isNotEmpty()) { + out += "\nSkipped: " + errors.take(MAX_ERROR_LINES).joinToString("; ") { "${it.path}: ${it.error}" } + } + if (dryRun) out += "\n(dry run - nothing written; re-call with dryRun=false to apply)" + return McpToolResult(out) + } + + private suspend fun status(): McpToolResult { + val provider = gitProvider ?: return unavailableGit() + provider.refreshStatus() + val status = provider.fileStatus.value + if (status.isEmpty()) return McpToolResult("Working tree clean.") + return McpToolResult( + status.joinToString("\n") { s -> + "${statusChar(s.indexStatus)}${statusChar(s.workTreeStatus)} ${s.path}" + }, + ) + } + + private fun statusChar(type: ai.rever.boss.plugin.api.GitFileStatusTypeData?): String = + when (type) { + null -> " " + ai.rever.boss.plugin.api.GitFileStatusTypeData.MODIFIED -> "M" + ai.rever.boss.plugin.api.GitFileStatusTypeData.ADDED -> "A" + ai.rever.boss.plugin.api.GitFileStatusTypeData.DELETED -> "D" + ai.rever.boss.plugin.api.GitFileStatusTypeData.RENAMED -> "R" + ai.rever.boss.plugin.api.GitFileStatusTypeData.COPIED -> "C" + ai.rever.boss.plugin.api.GitFileStatusTypeData.UNTRACKED -> "??" + ai.rever.boss.plugin.api.GitFileStatusTypeData.IGNORED -> "!!" + ai.rever.boss.plugin.api.GitFileStatusTypeData.UNMERGED -> "U" + } + + private fun unavailableGit(): McpToolResult = + McpToolResult("Git is unavailable: no project open or the host predates GitDataProvider.", isError = true) + + private fun unavailable(): McpToolResult = + McpToolResult("Project search is unavailable: the host predates the ProjectSearchProvider implementation.", isError = true) + + private fun op( + label: String, + result: ai.rever.boss.plugin.api.GitOperationResultData?, + ): McpToolResult { + return when (result) { + null -> unavailableGit() + is ai.rever.boss.plugin.api.GitOperationResultData.Success -> + McpToolResult("$label${result.message?.let { ": $it" } ?: ""}") + is ai.rever.boss.plugin.api.GitOperationResultData.Error -> + McpToolResult("$label failed: ${result.message}", isError = true) + } + } + + /** + * Renders EVERY file of a multi-file diff. + * + * The ref tools used to hand a single value to [diffText], which was the first + * file of a commit presented as the whole commit - with nothing to tell the + * caller the rest existed. An agent reading it drew conclusions from one file. + */ + private fun diffTexts(diffs: List): McpToolResult { + if (diffs.isEmpty()) return McpToolResult("No diff.") + if (diffs.size == 1) return diffText(diffs.first()) + val body = diffs.joinToString("\n\n") { renderOne(it) } + return McpToolResult(capDiff("${diffs.size} files changed\n\n$body")) + } + + private fun diffText(diff: ai.rever.boss.plugin.api.GitDiffData?): McpToolResult { + if (diff == null) return McpToolResult("No diff.") + return McpToolResult(capDiff(renderOne(diff))) + } + + private fun renderOne(diff: ai.rever.boss.plugin.api.GitDiffData): String { + val header = + "diff --git ${diff.path} ${diff.oldPath?.let { "$it " } ?: ""}" + + "(+${diff.additions}/-${diff.deletions})" + val raw = diff.rawUnified.ifBlank { + diff.hunks.joinToString("\n") { h -> + "@@ -${h.oldStart},${h.oldLines} +${h.newStart},${h.newLines} @@\n" + + h.lines.joinToString("\n") { l -> l.text } + } + } + return "$header\n$raw" + } + + /** + * Bounds a diff result. The host generates every diff with whole-file context + * (-U100000), so one file is the whole file and a multi-file commit is every whole + * file - unbounded MB into a single tool result. The retired git-status plugin + * capped its copy; this one did not until it was the live one. + */ + private fun capDiff(text: String): String = + if (text.length <= MAX_DIFF_CHARS) { + text + } else { + text.take(MAX_DIFF_CHARS) + "\n… [truncated, ${text.length - MAX_DIFF_CHARS} more chars]" + } + + private fun missing(name: String): McpToolResult = McpToolResult("Missing required argument: $name", isError = true) + + private fun McpToolArgs.path(): String? = string("path") + + private fun McpToolArgs.hash(): String? = string("hash") + + private companion object { + const val MAX_RESULT_LINES = 100 + + /** Ceiling on a single diff tool result; whole-file-context diffs are otherwise unbounded. */ + const val MAX_DIFF_CHARS = 64_000 + + const val MAX_ERROR_LINES = 10 + + const val PATH_SCHEMA = + """{"type":"object","properties":{"path":{"type":"string","description":"File path (repo-relative or absolute)."}},"required":["path"]}""" + const val REF_SCHEMA = + """{"type":"object","properties":{"ref":{"type":"string","description":"Commit hash, branch name, or tag to checkout."}},"required":["ref"]}""" + const val HASH_SCHEMA = + """{"type":"object","properties":{"hash":{"type":"string","description":"Hash of the commit."}},"required":["hash"]}""" + const val LIMIT_SCHEMA = + """{"type":"object","properties":{"limit":{"type":"integer","description":"Max commits to return (default 30)."}}}""" + const val DIFF_FILE_SCHEMA = + """{"type":"object","properties":{"path":{"type":"string","description":"File path (repo-relative or absolute)."},"staged":{"type":"boolean","description":"Diff the index instead of the working tree (default false)."}},"required":["path"]}""" + const val STAGED_SCHEMA = + """{"type":"object","properties":{"staged":{"type":"boolean","description":"List the index instead of the working tree (default false)."}}}""" + const val DIFF_REF_SCHEMA = + """{"type":"object","properties":{"ref":{"type":"string","description":"Commit hash or ref."},"path":{"type":"string","description":"Optional single file to restrict to."}},"required":["ref"]}""" + const val DIFF_BETWEEN_SCHEMA = + """{"type":"object","properties":{"from":{"type":"string","description":"Source ref."},"to":{"type":"string","description":"Target ref."},"path":{"type":"string","description":"Optional single file to restrict to."}},"required":["from","to"]}""" + const val SEARCH_SCHEMA = + """{"type":"object","properties":{"query":{"type":"string","description":"Literal text, or a regular expression when isRegex=true."},"pathPattern":{"type":"string","description":"Optional glob filter on the project-relative path (e.g. **/*.kt)."},"isRegex":{"type":"boolean","description":"Treat query as a regular expression (default false)."},"caseSensitive":{"type":"boolean","description":"Case-sensitive matching (default false)."},"wholeWord":{"type":"boolean","description":"Whole-word match only (default false)."},"maxResults":{"type":"integer","description":"Hard cap on returned matches (default 100)."}},"required":["query"]}""" + const val REPLACE_SCHEMA = + """{"type":"object","properties":{"query":{"type":"string","description":"Literal text, or a regular expression when isRegex=true."},"replacement":{"type":"string","description":"Replacement text; ${'$'}1..${'$'}9 capture references for regex queries."},"files":{"type":"string","description":"Comma-separated file paths to touch (project-relative, or absolute inside the project). Paths outside the project are refused. Never empty."},"isRegex":{"type":"boolean","description":"Treat query as a regular expression (default false)."},"caseSensitive":{"type":"boolean","description":"Case-sensitive matching (default false)."},"wholeWord":{"type":"boolean","description":"Whole-word match only (default false)."},"dryRun":{"type":"boolean","description":"Count without writing (default true)."}},"required":["query","replacement","files"]}""" + } +} \ No newline at end of file diff --git a/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/CodebaseUi.kt b/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/CodebaseUi.kt new file mode 100644 index 0000000..8c59175 --- /dev/null +++ b/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/CodebaseUi.kt @@ -0,0 +1,676 @@ +package ai.rever.boss.plugin.dynamic.codebase + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.TooltipArea +import androidx.compose.foundation.TooltipPlacement +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.KeyboardArrowDown +import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.input.pointer.PointerIcon +import androidx.compose.ui.input.pointer.pointerHoverIcon +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.LineHeightStyle +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import ai.rever.boss.plugin.ui.BossColors +import ai.rever.boss.plugin.ui.BossThemeColors +import androidx.compose.foundation.clickable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import java.awt.Cursor + +/** + * Shared metrics and primitives for the codebase panel's three tabs. + * + * Everything here is sized to VS Code / Cursor's sidebar: 22px list rows, + * 13px primary text, 26px inputs, 14px glyphs. Material 2's own components + * are deliberately avoided in the row-dense surfaces - `OutlinedTextField` + * alone is 56dp tall, which is more than two list rows, and `Button` forces + * 36dp and a 14sp label. Both made the panel look like a settings form + * instead of a sidebar. + */ +object CodebaseMetrics { + /** One list row - VS Code's `list.rowHeight`. */ + val RowHeight = 22.dp + + /** Section header ("CHANGES", "STAGED CHANGES"). */ + val SectionHeaderHeight = 22.dp + + /** A text input; VS Code's is 24-26px including its border. */ + val InputHeight = 26.dp + + /** The panel's top tab strip. */ + val TabStripHeight = 28.dp + + /** File names, commit subjects, match lines. */ + val PrimaryText = 13.sp + + /** Group counts, secondary labels. */ + val SecondaryText = 12.sp + + /** Line numbers, dates, dim path suffixes. */ + val MetaText = 11.sp + + /** Row glyphs and toolbar icons. */ + val Glyph = 14.dp + + /** Hit target of a row / toolbar icon button. */ + val IconButton = 20.dp + + /** Horizontal gutter of the panel body. */ + val Gutter = 8.dp + + /** One level of tree indentation. */ + val Indent = 12.dp + + // Corner radii, from BOSS's BossRadii: inputs 3dp, buttons 5dp. Small + // radii are the design system's "precision instrument" read; Material's + // 4dp/50% defaults are not it. + val InputRadius = 3.dp + val ButtonRadius = 5.dp + val ChipRadius = 3.dp +} + +/** + * The panel's colour tokens, all derived from the host theme so the three + * tabs re-skin together. [Hover] and [Selected] mirror VS Code's + * `list.hoverBackground` / `list.activeSelectionBackground`. + */ +object CodebasePalette { + // Every token below is one of BOSS's semantic colours, reached through the + // stable BossColors/BossThemeColors names. In the host those names are + // getters over BossThemeController.current, so the panel re-skins live when + // the user switches theme - and the light themes (Daylight) work, which a + // hardcoded `Color.White.copy(alpha = …)` wash would not. + // + // panel -> Surface raised -> contextMenuBackground + // ink -> Background line -> Border + // signal -> Accent signalWash -> contextMenuHover + // lineStrong -> contextMenuBorder + val Foreground: Color get() = BossThemeColors.TextPrimary + val Secondary: Color get() = BossThemeColors.TextSecondary + val Muted: Color get() = BossThemeColors.TextMuted + val Accent: Color get() = BossThemeColors.AccentColor + val Data: Color get() = BossThemeColors.SecondaryColor + + /** Panel chrome (this panel's own ground). */ + val Panel: Color get() = BossThemeColors.SurfaceColor + + /** Recessed floor - the fill of an input, so the field reads as inset. */ + val Inset: Color get() = BossThemeColors.BackgroundColor + + /** Raised surface - tooltips, the confirmation sheet. */ + val Raised: Color get() = BossColors.contextMenuBackground + + val Error: Color get() = BossThemeColors.ErrorColor + val Ok: Color get() = BossThemeColors.SuccessColor + val Warn: Color get() = BossThemeColors.WarningColor + + /** signalWash - BOSS's own hover/active wash, correct on light themes too. */ + val Hover: Color get() = BossColors.contextMenuHover + val Selected: Color get() = BossThemeColors.AccentColor.copy(alpha = 0.20f) + + val Border: Color get() = BossThemeColors.BorderColor + val BorderStrong: Color get() = BossColors.contextMenuBorder + val Divider: Color get() = BossThemeColors.BorderColor + + /** + * Git status colours. Cursor's letters, BOSS's status palette - so a + * modified file reads in the same amber the rest of the app warns in + * instead of importing VS Code's own hexes. + */ + val Modified: Color get() = BossThemeColors.WarningColor + val Added: Color get() = BossThemeColors.SuccessColor + val Deleted: Color get() = BossThemeColors.ErrorColor + val Untracked: Color get() = BossThemeColors.SecondaryColor + val Conflict: Color get() = BossThemeColors.ErrorColor + + /** + * Graph lane hues, in order. Six theme tokens rather than six fixed + * colours, so the lanes stay legible against every BOSS ground. + */ + val laneColors: List + get() = listOf(Accent, Ok, Warn, Data, Error, Secondary) + + /** + * Readable content colour on top of an [Accent] fill. + * + * BOSS names this `onSignal`, but that token has no stable alias on the + * api surface this plugin compiles against, so it is derived from the + * accent's own luminance instead - which is correct for every theme, + * including the amber accents where chalk-on-amber would fail. + */ + fun onAccent(accent: Color = Accent): Color = + if (accent.luminance() > 0.55f) Color(0xFF11130F) else Color(0xFFF6F7F4) +} + +/** + * The dim behind a modal sheet. Derived from the theme's own ink rather than + * black, so it dims BOSS's light themes instead of blacking them out. + */ +val CodebaseScrim: Color + get() = BossThemeColors.BackgroundColor.copy(alpha = 0.72f) + +/** A 1px horizontal rule at the panel's divider colour. */ +@Composable +fun CodebaseHRule(modifier: Modifier = Modifier) { + Box( + modifier = modifier + .fillMaxWidth() + .height(1.dp) + .background(CodebasePalette.Divider), + ) +} + +/** + * A hoverable list row: the full-bleed hover fill and fixed height every + * dense row in the panel shares. [content] is laid out in a centred [Row]. + */ +@Composable +fun CodebaseListRow( + onClick: (() -> Unit)? = null, + selected: Boolean = false, + height: androidx.compose.ui.unit.Dp = CodebaseMetrics.RowHeight, + modifier: Modifier = Modifier, + content: @Composable RowScope.(hovered: Boolean) -> Unit, +) { + val interaction = remember { MutableInteractionSource() } + val hovered by interaction.collectIsHoveredAsState() + Row( + modifier = modifier + .fillMaxWidth() + .height(height) + .hoverable(interaction) + .background( + when { + selected -> CodebasePalette.Selected + hovered -> CodebasePalette.Hover + else -> Color.Transparent + }, + ) + .then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier), + verticalAlignment = Alignment.CenterVertically, + ) { + content(hovered) + } +} + +/** + * A collapsible section header: twisty, uppercase title, count badge, and + * [actions] that - VS Code style - only materialise while the header (or a + * row under it) is hovered. [expanded] is hoisted so a parent can persist it. + */ +@Composable +fun CodebaseSectionHeader( + title: String, + count: Int? = null, + expanded: Boolean, + onToggle: () -> Unit, + modifier: Modifier = Modifier, + actions: (@Composable RowScope.() -> Unit)? = null, + /** + * Keep [actions] on screen when the header is not hovered. + * + * Hover-reveal is right for a row of icon buttons, and wrong for anything + * that opens a popup: moving the pointer onto the popup un-hovers the + * header, the anchor leaves the composition, and the popup closes the + * instant it is reached. The GRAPH header's branch picker is exactly that. + */ + actionsAlwaysVisible: Boolean = false, +) { + val interaction = remember { MutableInteractionSource() } + val hovered by interaction.collectIsHoveredAsState() + Row( + modifier = modifier + .fillMaxWidth() + .height(CodebaseMetrics.SectionHeaderHeight) + .hoverable(interaction) + .background(if (hovered) CodebasePalette.Hover else Color.Transparent) + .clickable(onClick = onToggle) + .padding(start = 2.dp, end = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = + if (expanded) Icons.Rounded.KeyboardArrowDown + else Icons.AutoMirrored.Rounded.KeyboardArrowRight, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = CodebasePalette.Secondary, + ) + Text( + text = title, + fontSize = CodebaseMetrics.MetaText, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.SemiBold, + letterSpacing = 1.0.sp, + color = CodebasePalette.Secondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + // weight(1f), filling: with `fill = false` here and a weighted + // Spacer after it, the two split the leftover space evenly and + // parked the badge in the middle of the row instead of at its end. + modifier = Modifier.weight(1f), + ) + if (actions != null && (hovered || actionsAlwaysVisible)) actions() + // The count sits at the right edge, in the same column as the file + // rows' status letters, so the numbers read down one line. + if (count != null && count > 0) { + Spacer(Modifier.width(6.dp)) + CodebaseCountBadge(count) + } + } +} + +/** + * The count on a section or directory row. + * + * A circle, not a pill: `Modifier.padding` around the text made the shape grow + * with the digits, so a one-digit count read as a squashed oval. Sizing the box + * from the digit count keeps 1-99 perfectly round and lets three digits widen + * to a capsule rather than clip. + */ +@Composable +fun CodebaseCountBadge( + count: Int, + color: Color = CodebasePalette.Accent, +) { + val text = count.toString() + val diameter = if (text.length <= 2) BADGE_DIAMETER else BADGE_DIAMETER + 6.dp + Box( + modifier = Modifier + .size(width = diameter, height = BADGE_DIAMETER) + .clip(RoundedCornerShape(percent = 50)) + .background(color.copy(alpha = 0.22f)), + contentAlignment = Alignment.Center, + ) { + Text( + text = text, + // A bare Text centres its LINE BOX, not its glyphs: the font's + // ascent/descent padding is asymmetric, so the digit sat high in + // the circle. Trimming the line height to the glyph centres it. + style = TextStyle( + fontSize = 10.sp, + lineHeight = 10.sp, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Medium, + color = color, + lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = LineHeightStyle.Trim.Both, + ), + textAlign = TextAlign.Center, + ), + maxLines = 1, + ) + } +} + +private val BADGE_DIAMETER = 16.dp + +/** + * A 20dp icon button with a hover tint and a tooltip - the only button shape + * used inside rows and section headers. [visible] false keeps the slot's + * width so a row's text does not reflow as actions appear on hover. + */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun CodebaseIconButton( + icon: ImageVector, + tooltip: String, + onClick: () -> Unit, + enabled: Boolean = true, + visible: Boolean = true, + tint: Color? = null, +) { + if (!visible) { + Spacer(Modifier.size(CodebaseMetrics.IconButton)) + return + } + val interaction = remember { MutableInteractionSource() } + val hovered by interaction.collectIsHoveredAsState() + CodebaseTooltip(tooltip) { + Box( + modifier = Modifier + .size(CodebaseMetrics.IconButton) + .clip(RoundedCornerShape(4.dp)) + .hoverable(interaction, enabled = enabled) + .background(if (hovered && enabled) CodebasePalette.Hover else Color.Transparent) + .then(if (enabled) Modifier.clickable(onClick = onClick) else Modifier), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = icon, + contentDescription = tooltip, + modifier = Modifier.size(CodebaseMetrics.Glyph), + tint = when { + !enabled -> CodebasePalette.Muted.copy(alpha = 0.5f) + tint != null -> tint + hovered -> CodebasePalette.Foreground + else -> CodebasePalette.Secondary + }, + ) + } + } +} + +/** Dark tooltip matching the host's context menus. */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun CodebaseTooltip( + text: String, + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { + TooltipArea( + modifier = modifier, + tooltip = { + Surface( + color = CodebasePalette.Raised, + elevation = 4.dp, + shape = RoundedCornerShape(4.dp), + ) { + Text( + text = text, + fontSize = CodebaseMetrics.MetaText, + color = CodebasePalette.Foreground, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 3.dp), + ) + } + }, + delayMillis = 500, + tooltipPlacement = TooltipPlacement.CursorPoint(offset = DpOffset(0.dp, 16.dp)), + content = content, + ) +} + +/** + * A compact single-line text input. Material 2's `OutlinedTextField` is 56dp + * tall with a 16sp default style; this is [CodebaseMetrics.InputHeight] with + * the panel's 13sp, so three of them still leave room for results. + * + * [leading] and [trailing] sit inside the border, as VS Code's search box + * does with its case, regex and whole-word toggles. + */ +@Composable +fun CodebaseTextField( + value: String, + onValueChange: (String) -> Unit, + placeholder: String, + modifier: Modifier = Modifier, + monospace: Boolean = false, + singleLine: Boolean = true, + maxLines: Int = 1, + minHeight: androidx.compose.ui.unit.Dp = CodebaseMetrics.InputHeight, + keyHandler: Modifier = Modifier, + leading: (@Composable () -> Unit)? = null, + trailing: (@Composable RowScope.() -> Unit)? = null, +) { + val focused = remember { mutableStateOf(false) } + Row( + modifier = modifier + .heightIn(min = minHeight) + .clip(RoundedCornerShape(CodebaseMetrics.InputRadius)) + .background(CodebasePalette.Inset) + .border( + width = 1.dp, + color = if (focused.value) CodebasePalette.Accent else CodebasePalette.Border, + shape = RoundedCornerShape(CodebaseMetrics.InputRadius), + ) + .padding(horizontal = 4.dp), + // A single-line field centres; a multi-line one starts at the top, so + // the placeholder sits where the first line of text will, instead of + // floating in the middle of an empty box. + verticalAlignment = if (singleLine) Alignment.CenterVertically else Alignment.Top, + ) { + if (leading != null) { + leading() + Spacer(Modifier.width(3.dp)) + } + Box(modifier = Modifier.weight(1f).padding(vertical = 4.dp)) { + BasicTextField( + value = value, + onValueChange = onValueChange, + singleLine = singleLine, + maxLines = maxLines, + textStyle = TextStyle( + color = CodebasePalette.Foreground, + fontSize = CodebaseMetrics.PrimaryText, + fontFamily = if (monospace) FontFamily.Monospace else FontFamily.Default, + ), + cursorBrush = SolidColor(CodebasePalette.Foreground), + modifier = Modifier + .fillMaxWidth() + .onFocusChanged { focused.value = it.isFocused } + .then(keyHandler), + ) + if (value.isEmpty()) { + Text( + text = placeholder, + fontSize = CodebaseMetrics.PrimaryText, + color = CodebasePalette.Muted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + if (trailing != null) { + Spacer(Modifier.width(3.dp)) + trailing() + } + } +} + +/** + * A full-width primary action, 26dp tall with a 12sp label - the shape VS + * Code's "Commit" button uses. Material's `Button` is 36dp with a 64dp + * minimum width and its own elevation, which reads as a dialog button. + */ +@Composable +fun CodebasePrimaryButton( + label: String, + onClick: () -> Unit, + enabled: Boolean = true, + modifier: Modifier = Modifier, + icon: ImageVector? = null, +) { + val interaction = remember { MutableInteractionSource() } + val hovered by interaction.collectIsHoveredAsState() + val base = CodebasePalette.Accent + val onAccent = CodebasePalette.onAccent(base) + Row( + modifier = modifier + .height(CodebaseMetrics.InputHeight) + .clip(RoundedCornerShape(CodebaseMetrics.ButtonRadius)) + .hoverable(interaction, enabled = enabled) + .background( + when { + !enabled -> base.copy(alpha = 0.25f) + hovered -> base + else -> base.copy(alpha = 0.85f) + }, + ) + .then(if (enabled) Modifier.clickable(onClick = onClick) else Modifier), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + if (icon != null) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(CodebaseMetrics.Glyph), + tint = if (enabled) onAccent else onAccent.copy(alpha = 0.45f), + ) + Spacer(Modifier.width(5.dp)) + } + Text( + text = label, + fontSize = CodebaseMetrics.SecondaryText, + fontWeight = FontWeight.Medium, + color = if (enabled) onAccent else onAccent.copy(alpha = 0.45f), + maxLines = 1, + ) + } +} + +/** A small toggle chip (Aa / .* / ab), sized to sit inside an input. */ +@Composable +fun CodebaseToggleChip( + label: String, + tooltip: String, + active: Boolean, + onClick: () -> Unit, +) { + val interaction = remember { MutableInteractionSource() } + val hovered by interaction.collectIsHoveredAsState() + CodebaseTooltip(tooltip) { + Box( + modifier = Modifier + .size(18.dp) + .clip(RoundedCornerShape(CodebaseMetrics.ChipRadius)) + .hoverable(interaction) + .background( + when { + active -> CodebasePalette.Accent.copy(alpha = 0.30f) + hovered -> CodebasePalette.Hover + else -> Color.Transparent + }, + ) + .clickable(onClick = onClick), + contentAlignment = Alignment.Center, + ) { + Text( + text = label, + fontSize = 10.sp, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Medium, + color = if (active) CodebasePalette.Foreground else CodebasePalette.Muted, + ) + } + } +} + +/** + * A draggable horizontal splitter. [fraction] is the top pane's share of the + * available height, clamped to [min]..[max] so neither pane can be dragged + * away entirely. + */ +@Composable +fun CodebaseSplitter( + fraction: MutableState, + totalHeightPx: Float, + min: Float = 0.15f, + max: Float = 0.85f, +) { + val interaction = remember { MutableInteractionSource() } + val hovered by interaction.collectIsHoveredAsState() + Box( + modifier = Modifier + .fillMaxWidth() + .height(5.dp) + .hoverable(interaction) + .pointerHoverIcon(PointerIcon(Cursor(Cursor.N_RESIZE_CURSOR))) + .pointerInput(totalHeightPx) { + detectDragGestures { _, dragAmount -> + if (totalHeightPx <= 0f) return@detectDragGestures + val next = fraction.value + dragAmount.y / totalHeightPx + fraction.value = next.coerceIn(min, max) + } + }, + contentAlignment = Alignment.Center, + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(1.dp) + .background(if (hovered) CodebasePalette.Accent else CodebasePalette.Divider), + ) + } +} + +/** + * Centred empty / hint state: optional glyph over dim 12sp text. Used for + * "no results", "not a repository", and the missing-provider hints. + */ +@Composable +fun CodebaseEmptyState( + text: String, + modifier: Modifier = Modifier, + icon: ImageVector? = null, +) { + Box(modifier = modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 20.dp), + ) { + if (icon != null) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = CodebasePalette.Muted.copy(alpha = 0.6f), + ) + Spacer(Modifier.height(8.dp)) + } + Text( + text = text, + fontSize = CodebaseMetrics.SecondaryText, + color = CodebasePalette.Muted, + textAlign = androidx.compose.ui.text.style.TextAlign.Center, + ) + } + } +} + +/** + * Path split into the part VS Code dims and the part it does not: the file + * name reads at full contrast, its directory trails behind it muted. + */ +internal fun splitPathForDisplay(path: String): Pair { + val idx = path.lastIndexOf('/') + return if (idx < 0) path to "" else path.substring(idx + 1) to path.substring(0, idx) +} diff --git a/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/CommitMessagePrompt.kt b/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/CommitMessagePrompt.kt new file mode 100644 index 0000000..70d5bf5 --- /dev/null +++ b/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/CommitMessagePrompt.kt @@ -0,0 +1,63 @@ +package ai.rever.boss.plugin.dynamic.codebase + +import ai.rever.boss.plugin.api.AiMessage +import ai.rever.boss.plugin.api.AiRequest +import ai.rever.boss.plugin.api.GitFileStatusData + +/** + * The request behind the commit box's generate button, and the cleanup its + * reply needs. + * + * Both halves are pure so the prompt's shape and the tidy-up can be pinned by + * tests - a model that wraps its answer in a fence or prefixes it with "Here + * is a commit message:" would otherwise end up in the box verbatim. + */ +internal object CommitMessagePrompt { + + private const val SYSTEM = + "You write git commit messages. Reply with the message only: no preamble, no " + + "markdown fences, no quotes. First line is an imperative subject under 72 " + + "characters with no trailing period. If the change needs explanation, add a " + + "blank line and one short paragraph saying WHY, not restating the diff." + + fun request(files: List, diff: String): AiRequest { + val listing = + files.joinToString("\n") { f -> + val status = if (f.isStaged) f.indexStatus else f.workTreeStatus + "${statusGlyph(status)} ${f.path}" + } + val body = + buildString { + appendLine("Files:") + appendLine(listing) + if (diff.isNotBlank()) { + appendLine() + appendLine("Diff:") + appendLine(diff) + } + } + return AiRequest( + system = SYSTEM, + messages = listOf(AiMessage.user(body)), + temperature = 0.2f, + maxTokens = 400, + timeoutMs = 45_000, + ) + } + + /** + * Strip the wrappers models add around a message: code fences, a leading + * "commit message:" label, and surrounding quotes. + */ + fun clean(raw: String): String { + var text = raw.trim() + if (text.startsWith("```")) { + text = text.removePrefix("```").substringAfter('\n', "").substringBeforeLast("```").trim() + } + text = text.removePrefix("Commit message:").removePrefix("commit message:").trim() + if (text.length > 1 && text.startsWith('"') && text.endsWith('"')) { + text = text.substring(1, text.length - 1).trim() + } + return text.trimEnd() + } +} diff --git a/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitBranchModel.kt b/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitBranchModel.kt new file mode 100644 index 0000000..3da3b01 --- /dev/null +++ b/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitBranchModel.kt @@ -0,0 +1,216 @@ +package ai.rever.boss.plugin.dynamic.codebase + +import ai.rever.boss.plugin.api.GitBranchRefData +import ai.rever.boss.plugin.api.GitCommitNodeData + +/** What a ref decoration on a commit is. An open set is not needed - git has four. */ +internal enum class GitRefKind { + /** The checked-out branch (`HEAD -> name`). */ + HEAD, + + /** A local branch that is not checked out. */ + LOCAL, + + /** An annotated or lightweight tag. */ + TAG, + + /** A remote-tracking branch (`origin/name`). */ + REMOTE, +} + +/** One pill drawn at the end of a commit's subject row. */ +internal data class GitRefPill( + val label: String, + val kind: GitRefKind, +) + +/** + * One entry of the graph's branch picker. + * + * Public only because [CodebaseGitViewModel] is: an internal type on a public + * flow does not compile. Nothing outside this jar has a reason to construct one. + */ +data class GitBranchOption( + val name: String, + val isRemote: Boolean, + val isCurrent: Boolean, +) + +/** + * The structural half of the graph's branch UI: which pills a commit shows, + * and what the picker offers. + * + * Pure on purpose. Both jobs are string surgery over git's `%D` decorations, + * where every mistake ("side " with the marker column still attached, a + * remote pill duplicating its local, `origin/HEAD` rendered as a branch) is + * invisible until someone squints at a screenshot - and this panel has + * shipped that class of bug before. + */ +internal object GitBranchModel { + + /** + * Remotes assumed when the host cannot name them. + * + * A ref is remote because its first segment is a REMOTE's name, which is + * not something `%D` marks - `feature/x` and `origin/x` are the same + * shape. When the branch list is available the real set is passed in; + * these two cover the rest. + */ + val DEFAULT_REMOTES: Set = setOf("origin", "upstream") + + /** Longest ref the pickers will hand back to git. Mirrors the host's own cap. */ + private const val MAX_REF_LENGTH = 255 + + /** + * A ref safe to send through [ai.rever.boss.plugin.api.GitDataProvider]. + * + * The host validates too - it is the real boundary - but refusing here + * means the panel can say why instead of silently drawing an empty graph. + */ + fun isSafeRef(ref: String): Boolean { + if (ref.isBlank()) return false + if (ref.startsWith("-")) return false + if (ref.length > MAX_REF_LENGTH) return false + return ref.none { it.isWhitespace() || it.code < 0x20 || it.code == 0x7F } + } + + /** The remote names implied by a branch list, for [refPills]. */ + fun remoteNamesOf(branches: List): Set = + branches + .asSequence() + .filter { it.isRemote } + .map { it.name.substringBefore('/') } + .filter { it.isNotBlank() } + .toSet() + .ifEmpty { DEFAULT_REMOTES } + + /** + * The pills for one commit's ref decorations, ordered + * HEAD → local → tag → remote. + * + * Dropped along the way: + * - a bare `HEAD` (the detached-HEAD marker names no branch), + * - `origin/HEAD` (a symref at the remote's default branch, not a branch), + * - a remote pill whose short name already appears as a local pill on the + * SAME commit - `main` and `origin/main` side by side says nothing the + * first pill did not. + */ + fun refPills( + refs: List, + remotes: Set = DEFAULT_REMOTES, + ): List { + val parsed = + refs.mapNotNull { raw -> + val ref = raw.trim() + when { + ref.isEmpty() -> null + ref.startsWith("tag:") -> + ref.removePrefix("tag:").trim() + .takeIf { it.isNotEmpty() } + ?.let { GitRefPill(it, GitRefKind.TAG) } + + ref.startsWith("HEAD ->") -> + ref.removePrefix("HEAD ->").trim() + .takeIf { it.isNotEmpty() } + ?.let { GitRefPill(it, GitRefKind.HEAD) } + + ref == "HEAD" -> null + ref.substringBefore('/') in remotes && ref.contains('/') -> + if (ref.substringAfter('/') == "HEAD") null + else GitRefPill(ref, GitRefKind.REMOTE) + + else -> GitRefPill(ref, GitRefKind.LOCAL) + } + } + + val localNames = + parsed.filter { it.kind == GitRefKind.HEAD || it.kind == GitRefKind.LOCAL } + .map { it.label } + .toSet() + + return parsed + .filterNot { it.kind == GitRefKind.REMOTE && it.label.substringAfter('/') in localNames } + .distinctBy { it.kind to it.label } + .sortedBy { it.kind.ordinal } + } + + /** + * Branch names decorating a loaded graph, for [options]' fallback path. + * + * Reuses [refPills] rather than re-parsing `%D`, so the two agree by + * construction, and drops tags - a tag is a place in history, not a branch + * you can ask the graph to follow. + * + * Deliberately NOT [CodebaseGitViewModel.branchesOf], which folds + * `origin/x` onto `x` for the "Diff Against..." picker. Here the remote is + * a different thing to look at and keeps its own name. + */ + fun branchRefsInGraph( + nodes: List, + remotes: Set = DEFAULT_REMOTES, + ): List = + nodes + .asSequence() + .flatMap { refPills(it.refs, remotes).asSequence() } + .filter { it.kind != GitRefKind.TAG } + .map { it.label } + .distinct() + .toList() + + /** + * The branch picker's entries: the checked-out branch first, then the + * other locals A-Z, then the remote-tracking branches A-Z. + * + * [graphRefs] is the fallback for a host that predates + * [ai.rever.boss.plugin.api.GitDataProvider.branches] - the branch names + * decorating the loaded graph. It is a strictly smaller set (a branch + * whose tip is older than the fetched window is missing from it), which is + * why it is only consulted when the real list is empty. + * + * Remote branches are kept even when a local of the same short name + * exists: `origin/main` and `main` are different histories the moment + * either moves, and collapsing them would hide exactly the divergence + * someone opens a graph to look at. + */ + fun options( + branches: List, + graphRefs: List = emptyList(), + current: String = "", + ): List { + val remotes = remoteNamesOf(branches) + val source = + if (branches.isNotEmpty()) { + branches.map { GitBranchOption(it.name.trim(), it.isRemote, it.isCurrent) } + } else { + graphRefs.map { raw -> + val name = raw.trim() + GitBranchOption( + name = name, + isRemote = name.contains('/') && name.substringBefore('/') in remotes, + isCurrent = false, + ) + } + } + + val cleaned = + source + .asSequence() + .map { it.copy(name = it.name.trim()) } + .filter { it.name.isNotEmpty() } + .filterNot { it.name == "HEAD" } + .filterNot { it.isRemote && it.name.substringAfter('/') == "HEAD" } + // isCurrent from the host when it says so, else by name - the + // fallback path has no current marker of its own. + .map { if (current.isNotEmpty() && it.name == current) it.copy(isCurrent = true) else it } + .distinctBy { it.name } + .toList() + + return cleaned.sortedWith( + compareBy( + { !it.isCurrent }, + { it.isRemote }, + { it.name }, + ), + ) + } +} diff --git a/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitChangeTree.kt b/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitChangeTree.kt new file mode 100644 index 0000000..3a35803 --- /dev/null +++ b/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitChangeTree.kt @@ -0,0 +1,158 @@ +package ai.rever.boss.plugin.dynamic.codebase + +import ai.rever.boss.plugin.api.GitFileStatusData + +/** + * The two shapes VS Code offers for a change group: a flat list of paths, and + * a collapsible directory tree. + * + * The flat list is right for a handful of files; past that the repeated + * directory prefixes are most of what you read. The tree collapses shared + * prefixes so the files stand out - and, as in VS Code, a chain of + * single-child directories is compacted onto one row (`src/main/kotlin`) + * rather than costing three rows of indentation to say nothing. + */ +internal object GitChangeTree { + + /** One rendered row: either a directory or a changed file. */ + sealed interface Row { + val depth: Int + + data class Directory( + /** Full project-relative path of this directory, the collapse key. */ + val path: String, + /** What to print - possibly several segments, when the chain was compacted. */ + val label: String, + override val depth: Int, + /** Files under this directory, at any depth. */ + val fileCount: Int, + ) : Row + + data class FileRow( + val file: GitFileStatusData, + /** Just the file name; the directory is the row above. */ + val name: String, + override val depth: Int, + ) : Row + } + + /** + * The files a directory row stands for - itself and every depth beneath. + * + * Group actions operate on this, so staging a directory means staging + * exactly the rows the user can see under it, not a path glob that might + * also catch files in another group. + */ + fun filesUnder( + files: List, + directoryPath: String, + ): List { + if (directoryPath.isEmpty()) return files + val prefix = "$directoryPath/" + return files.filter { it.path.startsWith(prefix) } + } + + private class Node(val path: String, val name: String) { + val dirs = LinkedHashMap() + val files = mutableListOf() + var fileCount = 0 + } + + /** + * Flatten [files] into tree rows, honouring [collapsed] (directory paths + * whose children are hidden). + * + * Directories sort before files and both sort by name, so the order is + * stable across refreshes - a list that reorders under a 2.5s refresh is + * unusable. + */ + fun rows( + files: List, + collapsed: Set, + ): List { + if (files.isEmpty()) return emptyList() + val root = Node("", "") + for (file in files) { + val segments = file.path.split('/').filter { it.isNotEmpty() } + if (segments.isEmpty()) continue + var node = root + node.fileCount++ + for (i in 0 until segments.size - 1) { + val name = segments[i] + val path = if (node.path.isEmpty()) name else "${node.path}/$name" + node = node.dirs.getOrPut(path) { Node(path, name) } + node.fileCount++ + } + node.files.add(file) + } + + val out = mutableListOf() + emit(root, depth = 0, collapsed = collapsed, out = out, prefixLabel = null) + return out + } + + private fun emit( + node: Node, + depth: Int, + collapsed: Set, + out: MutableList, + prefixLabel: String?, + ) { + for (child in node.dirs.values.sortedBy { it.name.lowercase() }) { + // Compact a single-child directory chain onto one row, the way VS + // Code's explorer does: src > main > kotlin reads as src/main/kotlin. + var current = child + val label = StringBuilder(prefixLabel?.let { "$it/${child.name}" } ?: child.name) + while (current.files.isEmpty() && current.dirs.size == 1) { + val only = current.dirs.values.first() + // Stop compacting at a collapsed directory: its own row has to + // exist for the chevron to have something to toggle. + if (current.path in collapsed) break + label.append('/').append(only.name) + current = only + } + out.add( + Row.Directory( + path = current.path, + label = label.toString(), + depth = depth, + fileCount = current.fileCount, + ), + ) + if (current.path !in collapsed) { + emit(current, depth + 1, collapsed, out, prefixLabel = null) + } + } + for (file in node.files.sortedBy { it.path.lowercase() }) { + out.add(Row.FileRow(file = file, name = file.path.substringAfterLast('/'), depth = depth)) + } + } +} + +/** + * How a change group lays its rows out. Persisted per panel under + * `codebase.gitLayout`. + * + * Public only because [CodebaseGitViewModel] is: an internal type on a public + * flow does not compile. + */ +enum class GitChangeLayout(val storageKey: String) { + TREE("tree"), + LIST("list"), + ; + + fun toggled(): GitChangeLayout = if (this == TREE) LIST else TREE + + companion object { + /** + * The stored choice, defaulting to [TREE]. + * + * A flat list is only the better read for a handful of files; a real + * change set is mostly repeated directory prefixes, which is exactly + * what the tree folds away. An unknown key means a manifest from a + * newer build or a corrupted value - the default, not a crash. + */ + fun fromStorage(key: String?): GitChangeLayout = + entries.firstOrNull { it.storageKey == key } ?: TREE + } +} diff --git a/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitGraphEdges.kt b/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitGraphEdges.kt new file mode 100644 index 0000000..c71436c --- /dev/null +++ b/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitGraphEdges.kt @@ -0,0 +1,108 @@ +package ai.rever.boss.plugin.dynamic.codebase + +import ai.rever.boss.plugin.api.GitCommitNodeData + +/** + * What each graph row has to draw. + * + * [GitGraphLayout] answers "which lane is this commit in". That alone renders + * a dot per row and a stub to its neighbour, which is why the graph looked + * sparse: a branch that runs past twenty commits before merging has a line + * through every one of those rows, and nothing modelled it. + * + * Here every parent link becomes an edge from the child's row and lane to the + * parent's, and each row collects the edges crossing it. Pure, because the + * shape of a history is exactly the kind of thing that is painful to verify + * by looking at it. + */ +internal object GitGraphEdges { + + /** A line segment crossing one row, in lane coordinates. */ + data class Segment( + /** Lane the segment occupies at the top edge of the row. */ + val fromLane: Int, + /** Lane it occupies at the bottom edge. */ + val toLane: Int, + /** True when it starts at this row's commit dot rather than the top edge. */ + val fromNode: Boolean, + /** True when it ends at this row's commit dot rather than the bottom edge. */ + val toNode: Boolean, + /** Lane whose colour the segment takes - the branch it belongs to. */ + val colorLane: Int, + ) + + /** Everything one row draws. */ + data class Row( + val lane: Int, + val isMerge: Boolean, + val segments: List, + ) + + /** Lanes wide enough to hold every segment in [rows]. */ + fun laneCount(rows: List): Int = + (rows.flatMap { row -> row.segments.flatMap { listOf(it.fromLane, it.toLane) } + row.lane } + .maxOrNull() ?: 0) + 1 + + fun build( + commits: List, + lanes: List, + ): List { + if (commits.isEmpty()) return emptyList() + val rowOf = HashMap(commits.size) + commits.forEachIndexed { index, c -> rowOf.putIfAbsent(c.hash, index) } + + val segments = List(commits.size) { mutableListOf() } + + commits.forEachIndexed { childRow, commit -> + val childLane = lanes.getOrElse(childRow) { 0 } + for (parentHash in commit.parents) { + // A parent outside the fetched window: the line leaves the + // bottom of the last row rather than stopping mid-air. + val parentRow = rowOf[parentHash] ?: continue + if (parentRow <= childRow) continue + val parentLane = lanes.getOrElse(parentRow) { 0 } + + if (parentRow == childRow + 1) { + // Adjacent rows: the line leaves the child's dot at the + // bottom half of its row and arrives at the parent's dot in + // the top half of the next. BOTH halves are needed - only + // emitting the first left every line stopping at the row + // boundary instead of reaching the commit below. + segments[childRow].add( + Segment(childLane, parentLane, fromNode = true, toNode = false, colorLane = parentLane), + ) + segments[parentRow].add( + Segment(parentLane, parentLane, fromNode = false, toNode = true, colorLane = parentLane), + ) + continue + } + + // Leaves the child's dot, settles into the parent's lane. + segments[childRow].add( + Segment(childLane, parentLane, fromNode = true, toNode = false, colorLane = parentLane), + ) + // Runs straight through everything in between. + for (row in (childRow + 1) until parentRow) { + segments[row].add( + Segment(parentLane, parentLane, fromNode = false, toNode = false, colorLane = parentLane), + ) + } + // Arrives at the parent's dot. + segments[parentRow].add( + Segment(parentLane, parentLane, fromNode = false, toNode = true, colorLane = parentLane), + ) + } + } + + return commits.mapIndexed { index, commit -> + Row( + lane = lanes.getOrElse(index) { 0 }, + isMerge = commit.parents.size > 1, + // Not distinct(): two parents that resolve to the same lane + // produce identical segments, and collapsing them would drop a + // real edge. Drawing one twice costs nothing. + segments = segments[index].toList(), + ) + } + } +} diff --git a/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitGraphLayout.kt b/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitGraphLayout.kt new file mode 100644 index 0000000..7f2e98a --- /dev/null +++ b/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitGraphLayout.kt @@ -0,0 +1,77 @@ +package ai.rever.boss.plugin.dynamic.codebase + +import ai.rever.boss.plugin.api.GitCommitNodeData + +/** + * Lane assignment for the git graph view (P7). + * + * Input is in `git log` order (newest first, topological - a commit's + * children always come before it). Output is one lane index per commit, + * 0 being the leftmost lane. + * + * Rules (deliberately simple, pinned by [GitGraphLayoutTest]): + * 1. A commit with already-placed children continues the rightmost child's + * lane - branch points and merges sit on a child's line. + * 2. A commit without placed children takes the leftmost lane whose current + * holder is an ancestor of it (the line keeps running), else the first + * empty lane, else a new lane opens. + * + * This renders linear history and fork/branch shapes correctly. A merge + * whose both parents are on separate lanes collapses them into one lane at + * the merge row - an accepted v1 look, not a bug to "fix" in the renderer. + */ +object GitGraphLayout { + + fun assignLanes(commits: List): List { + if (commits.isEmpty()) return emptyList() + + val childrenOf = HashMap>() + commits.forEach { c -> + c.parents.forEach { p -> + childrenOf.getOrPut(p) { mutableListOf() }.add(c.hash) + } + } + + // Ancestor sets, bounded by the fetched window - fine for a few hundred commits. + val parentsByHash = commits.associateBy({ it.hash }, { it.parents }) + val ancestorCache = HashMap>() + fun ancestorsOfCommit(c: GitCommitNodeData): HashSet = + ancestorCache.getOrPut(c.hash) { + val set = HashSet() + val queue = ArrayDeque(c.parents) + while (queue.isNotEmpty()) { + val p = queue.removeFirst() + if (set.add(p)) parentsByHash[p]?.let { queue.addAll(it) } + } + set + } + + val laneOf = HashMap() + val laneHolder = HashMap() + val result = IntArray(commits.size) + + commits.forEachIndexed { i, c -> + val lane = + laneOf[c.hash] + ?: run { + val childLanes = childrenOf[c.hash].orEmpty().mapNotNull { laneOf[it] } + when { + childLanes.isNotEmpty() -> childLanes.max() + else -> { + val anc = ancestorsOfCommit(c) + (0 until maxOf(laneHolder.size, 1)).firstOrNull { l -> + val holder = laneHolder[l] + holder != null && holder in anc + } ?: (0 until maxOf(laneHolder.size, 1)).firstOrNull { laneHolder[it] == null } + ?: laneHolder.size + } + } + } + laneOf[c.hash] = lane + laneHolder[lane] = c.hash + result[i] = lane + } + + return result.toList() + } +} \ No newline at end of file diff --git a/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitTab.kt b/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitTab.kt new file mode 100644 index 0000000..bc40966 --- /dev/null +++ b/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitTab.kt @@ -0,0 +1,745 @@ +package ai.rever.boss.plugin.dynamic.codebase + +import ai.rever.boss.plugin.api.AiGatewayAPI +import ai.rever.boss.plugin.api.GitBranchRefData +import ai.rever.boss.plugin.api.GitCommitNodeData +import ai.rever.boss.plugin.api.GitDataProvider +import ai.rever.boss.plugin.api.GitFileStatusData +import ai.rever.boss.plugin.api.GitFileStatusTypeData +import ai.rever.boss.plugin.api.GitOperationResultData +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch + +/** + * The GIT tab of the codebase panel (P7): the changes accordion plus the + * true lane graph, both driven by the host's [GitDataProvider]. + * + * [git] null means the host predates the provider - the UI renders a hint. + * [onAgentReview] composes and publishes the review event; the plugin wires + * it to the application event bus + panel focus, so the view model stays + * free of plugin-context plumbing. The review always asks the agent to RUN + * the brief - pressing the button is the request, so leaving the prompt + * sitting unsent in the agent's composer is never the answer. + * + * Status is *collected*, never snapshotted. The provider refreshes + * asynchronously (and over IPC when this plugin runs out-of-process), so + * reading `fileStatus.value` right after an operation returns the state from + * before it - which is why staging a file used to leave the row where it was + * until the next poll tick. + */ +class CodebaseGitViewModel( + private val git: GitDataProvider?, + private val onAgentReview: (prompt: String) -> Unit, + private val getProjectPath: () -> String?, + private val getWindowId: () -> String? = { null }, + /** Resolves the AI gateway lazily; null on a host without one. */ + private val aiGateway: () -> AiGatewayAPI? = { null }, + /** Why AI is unavailable, or null when it is ready. */ + private val aiUnavailable: () -> String? = { null }, +) { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + val providerOrNull: GitDataProvider? get() = git + + // ---- changes accordion ---- + + private val _fileStatus = MutableStateFlow>(emptyList()) + val fileStatus: StateFlow> = _fileStatus.asStateFlow() + + // ---- graph ---- + + private val _graph = MutableStateFlow>(emptyList()) + val graph: StateFlow> = _graph.asStateFlow() + + private val _graphBusy = MutableStateFlow(false) + val graphBusy: StateFlow = _graphBusy.asStateFlow() + + /** + * The ref the graph is showing, or null for the checked-out branch. + * + * Null rather than the branch's own name on purpose: HEAD moves. Pinning + * the name at load time would leave the graph quietly showing the branch + * the user *was* on after an external `git switch`, while the toolbar + * above it named the new one. + */ + private val _graphRef = MutableStateFlow(null) + val graphRef: StateFlow = _graphRef.asStateFlow() + + /** Every branch of the repository, for the graph's picker. */ + private val _branchRefs = MutableStateFlow>(emptyList()) + + private val _branchOptions = MutableStateFlow>(emptyList()) + val branchOptions: StateFlow> = _branchOptions.asStateFlow() + + /** + * Remote names, so a commit's `origin/x` decoration is drawn as a remote + * pill and a local `feature/x` is not. `%D` does not distinguish them - + * they are the same shape - so the branch list is the only authority. + */ + private val _remoteNames = MutableStateFlow(GitBranchModel.DEFAULT_REMOTES) + val remoteNames: StateFlow> = _remoteNames.asStateFlow() + + // ---- shared ---- + + private val _busy = MutableStateFlow(false) + val busy: StateFlow = _busy.asStateFlow() + + private val _message = MutableStateFlow(null) + val message: StateFlow = _message.asStateFlow() + + /** + * List or tree for the change groups. + * + * Held here rather than in a `remember` inside the tab: the composable is + * torn down on every tab switch, so the choice did not survive a hop to + * FILES and back, let alone a restart. [GitChangeLayout.fromStorage] gives + * the default; the panel component seeds and persists it. + */ + private val _changeLayout = MutableStateFlow(GitChangeLayout.fromStorage(null)) + val changeLayout: StateFlow = _changeLayout.asStateFlow() + + fun setChangeLayout(value: GitChangeLayout) { + _changeLayout.value = value + } + + fun toggleChangeLayout() { + _changeLayout.value = _changeLayout.value.toggled() + } + + // ---- agent review options ---- + + private val _reviewInstructions = MutableStateFlow("") + val reviewInstructions: StateFlow = _reviewInstructions.asStateFlow() + + private val _reviewDeep = MutableStateFlow(false) + val reviewDeep: StateFlow = _reviewDeep.asStateFlow() + + private val _reviewBase = MutableStateFlow("") + val reviewBase: StateFlow = _reviewBase.asStateFlow() + + /** + * Branches offered in "Diff Against...", read off the loaded graph's ref + * decorations. The provider exposes no branch list, and the refs already + * carry every branch that appears in the history the panel has - which is + * the set worth reviewing against. + */ + private val _branches = MutableStateFlow>(emptyList()) + val branches: StateFlow> = _branches.asStateFlow() + + fun setReviewInstructions(value: String) { + _reviewInstructions.value = value + } + + fun setReviewDeep(value: Boolean) { + _reviewDeep.value = value + } + + fun setReviewBase(value: String) { + _reviewBase.value = value + } + + // ---- commit box ---- + + private val _commitMessage = MutableStateFlow("") + val commitMessage: StateFlow = _commitMessage.asStateFlow() + + /** True while a commit message is being generated. */ + private val _generating = MutableStateFlow(false) + val generating: StateFlow = _generating.asStateFlow() + + /** Whether the host reports the current project as a git repository. */ + val isGitRepository: StateFlow = git?.isGitRepository ?: MutableStateFlow(false) + + /** + * The checked-out branch, read off the tip commit's ref decorations + * (`HEAD -> name`). Empty until the graph loads; the API exposes no + * branch member of its own. + */ + private val _currentBranch = MutableStateFlow("") + val currentBranch: StateFlow = _currentBranch.asStateFlow() + + /** + * Why there is no repository, when there is none. Selecting a folder that + * merely *holds* repositories (a `~/Projects` with a dozen checkouts under + * it) is the common case, and "No Git repository" alone reads as a bug + * rather than as the accurate answer it is. + */ + private val _noRepoHint = MutableStateFlow("") + val noRepoHint: StateFlow = _noRepoHint.asStateFlow() + + /** + * False until the first status read has come back. + * + * Without it the tab paints its "no repository" / "no changes" states + * while the very first read is still in flight, which reads as "the panel + * is broken, hit refresh" - and refreshing only appeared to fix it + * because by then the load had finished. + */ + private val _loaded = MutableStateFlow(false) + val loaded: StateFlow = _loaded.asStateFlow() + + init { + // Mirror the provider's status flow for the lifetime of the tab. This + // is the single source of truth for the three groups; operations only + // ask the provider to refresh and let the mirror deliver the result. + if (git != null) { + scope.launch { + git.fileStatus.collect { _fileStatus.value = it } + } + // Start loading with the panel, not with the tab: by the time the + // user switches to GIT the data is already there. + refreshStatus() + loadGraph(reset = true) + } else { + _loaded.value = true + } + } + + fun setCommitMessage(value: String) { + _commitMessage.value = value + } + + private var statusTimer: Job? = null + + /** Ask the provider to re-read the work tree; the collector delivers it. */ + fun refreshStatus() { + scope.launch { + try { + git?.refreshStatus() + _noRepoHint.value = describeMissingRepo(getProjectPath()) + } finally { + _loaded.value = true + } + } + } + + /** + * Poll the work tree while the tab is open. The provider does not watch + * the filesystem for us, so a bare re-read of its cached value would + * never see an edit made outside the panel - the tick has to drive + * [GitDataProvider.refreshStatus]. + */ + fun startStatusTimer() { + if (git == null) return + statusTimer?.cancel() + statusTimer = + scope.launch { + while (isActive) { + delay(REFRESH_MS) + git.refreshStatus() + } + } + } + + /** + * Stop polling. The view model outlives the GIT tab (it is remembered for + * the whole panel so its scope is disposed once), so without this the + * timer kept shelling out `git status` every few seconds while the user + * sat on FILES or SEARCH. + */ + fun stopStatusTimer() { + statusTimer?.cancel() + statusTimer = null + } + + fun loadGraph(reset: Boolean = false) { + scope.launch { + _graphBusy.value = true + try { + val limit = + if (reset) GRAPH_PAGE + else (_graph.value.size + GRAPH_PAGE).coerceAtMost(GRAPH_MAX) + val ref = _graphRef.value + // Always logGraphFor, never logGraph: its default body + // delegates to logGraph for a null ref, so on a host that + // predates 1.0.90 the checked-out history still draws and only + // the picker degrades. + val nodes = git?.logGraphFor(ref, limit) ?: emptyList() + _graph.value = nodes + // The HEAD decoration only names the checked-out branch while + // the graph IS HEAD's; another branch's tip carries no + // `HEAD ->`, and reading it there blanked the toolbar. + if (ref == null) { + branchOf(nodes).takeIf { it.isNotEmpty() }?.let { _currentBranch.value = it } + } + val found = branchesOf(nodes) + _branches.value = found + if (_reviewBase.value.isBlank()) { + _reviewBase.value = defaultBase(found, _currentBranch.value) + } + // Only on a reset: paging deeper into the same history cannot + // change which branches exist, and this is two extra `git + // branch` invocations behind a lock every "Load more" would + // otherwise pay for. + if (reset) refreshBranchOptions() + } finally { + _graphBusy.value = false + } + } + } + + /** + * Re-read the branch list and rebuild the picker. + * + * Not derived from the graph's ref decorations: those only name branches + * whose tip happens to be inside the fetched window of history, so a + * picker built from them cannot offer the branch you actually want to look + * at. The decorations stay as the fallback for a host without + * [GitDataProvider.branches]. + */ + private suspend fun refreshBranchOptions() { + val refs = try { git?.branches().orEmpty() } catch (e: Exception) { emptyList() } + _branchRefs.value = refs + _remoteNames.value = GitBranchModel.remoteNamesOf(refs) + refs.firstOrNull { it.isCurrent }?.let { _currentBranch.value = it.name } + _branchOptions.value = + GitBranchModel.options( + branches = refs, + // The graph's own decorations, not _branches: that list folds + // `origin/x` onto `x` for the "Diff Against..." picker, and + // here the remote is a separate thing to look at. + graphRefs = GitBranchModel.branchRefsInGraph(_graph.value, _remoteNames.value), + current = _currentBranch.value, + ) + } + + /** + * Point the graph at [name], or at the checked-out branch when it is null. + * + * The name comes from a list git produced, but it crosses the plugin API + * on the way back, so it is checked here as well as host-side - a refused + * ref says so instead of drawing an empty graph. + */ + fun selectGraphBranch(name: String?) { + val target = name?.trim()?.takeIf { it.isNotEmpty() } + if (target != null && !GitBranchModel.isSafeRef(target)) { + _message.value = "Failed: \"$target\" is not a usable branch name." + return + } + if (target == _graphRef.value) return + _graphRef.value = target + loadGraph(reset = true) + } + + /** Return the graph to the checked-out branch. */ + fun showCurrentBranch() = selectGraphBranch(null) + + /** The branch the graph is showing, for the section header's chip. */ + fun displayedRef(): String = _graphRef.value ?: _currentBranch.value + + // ---- remote operations ---- + + /** `git fetch --all` - updates remote-tracking refs, touches no work tree. */ + fun fetch() = op(refreshGraph = true) { git?.fetch(prune = false) } + + /** `git pull` into the checked-out branch. */ + fun pull() = op(refreshGraph = true) { git?.pull() } + + /** + * `git push -u origin HEAD`. Fired only from a confirmed action in the UI + * - a push is the one operation here whose effect leaves this machine. + */ + fun push() = op(refreshGraph = true) { git?.push() } + + fun hasMoreGraph(): Boolean = _graph.value.size < GRAPH_MAX + + // ---- row / commit operations ---- + + fun stage(path: String) = op { git?.stage(path) } + + fun unstage(path: String) = op { git?.unstage(path) } + + fun discard(path: String) = op { git?.discardChanges(path) } + + private suspend fun GitDataProvider.discard(path: String) = discardChanges(path) + + fun stageAll() = op { git?.stageAll() } + + /** + * Stage or discard a set of paths as ONE operation. + * + * Issuing the per-file call in a loop from the UI started a coroutine per + * file, and git refuses a second concurrent index write - so "stage all" + * staged the first file and dropped the rest. Sequential, inside a single + * op, also means one busy state and one refresh rather than N. + */ + fun stagePaths(paths: List) = batch(paths) { git?.stage(it) } + + fun discardPaths(paths: List) = batch(paths) { git?.discard(it) } + + fun unstagePaths(paths: List) = batch(paths) { git?.unstage(it) } + + private fun batch( + paths: List, + action: suspend (String) -> GitOperationResultData?, + ) { + if (paths.isEmpty()) return + op { + var failure: GitOperationResultData.Error? = null + for (path in paths) { + val result = action(path) + if (result is GitOperationResultData.Error && failure == null) failure = result + } + // Report the FIRST failure, if any: a later success would + // otherwise overwrite it and the operation would look clean. + failure ?: GitOperationResultData.Success("${paths.size} file(s) updated") + } + } + + fun unstageAll() = op { git?.unstageAll() } + + fun revert(hash: String) = op(refreshGraph = true) { git?.revert(hash) } + + fun cherryPick(hash: String) = op(refreshGraph = true) { git?.cherryPick(hash) } + + fun checkout(ref: String) = op(refreshGraph = true) { git?.checkout(ref) } + + /** + * Commit. With nothing staged but tracked changes present this stages + * them first, matching VS Code's "Commit" on an empty index; the button + * label says so, so the extra `git add` is never a surprise. + */ + fun commit(stageFirst: Boolean = false) { + val message = _commitMessage.value.trim() + if (message.isEmpty()) { + _message.value = "Enter a commit message first." + return + } + if (git == null) { + _message.value = "Git is unavailable on this host." + return + } + scope.launch { + _busy.value = true + _message.value = null + try { + if (stageFirst) { + val staged = git.stageAll() + if (staged is GitOperationResultData.Error) { + _message.value = "Failed: ${staged.message}" + return@launch + } + } + report(git.commit(message)) { + _commitMessage.value = "" + loadGraph(reset = true) + } + git.refreshStatus() + } finally { + _busy.value = false + } + } + } + + fun openFile(path: String) { + git?.openFile(path, getWindowId().orEmpty()) + } + + fun openFileDiff(path: String, staged: Boolean) { + git?.openDiff(path, staged = staged, windowId = getWindowId().orEmpty()) + } + + fun openCommitDiff(hash: String) { + // fromRef only, so the host runs `git show ` rather than + // `git diff ~1 `. A root commit has no `~1`: that form exited + // non-zero and rendered "No changes to show" for the commit that created + // every file in the repository. `git show` also renders merge commits. + git?.openDiff( + filePath = "", + staged = false, + fromRef = hash, + toRef = null, + windowId = getWindowId().orEmpty(), + ) + } + + fun clearMessage() { + _message.value = null + } + + /** + * Draft a commit message from what is actually staged (or, with an empty + * index, from the working tree) and put it in the box for editing. + * + * Deliberately a draft and not a commit: the message is the one part of a + * commit a reviewer reads most and an author should always get the last + * word on. + */ + fun generateCommitMessage() { + if (_generating.value) return + aiUnavailable()?.let { + _message.value = it + return + } + val gateway = aiGateway() ?: return + scope.launch { + _generating.value = true + _message.value = null + try { + val status = _fileStatus.value + val subject = status.filter { it.isStaged }.ifEmpty { status.filter { it.isUnstaged } } + if (subject.isEmpty()) { + _message.value = "Nothing to describe - there are no changes." + return@launch + } + val diff = collectDiff(subject, COMMIT_DIFF_BUDGET) + val reply = + gateway.complete(CommitMessagePrompt.request(subject, diff)).getOrNull() + val text = CommitMessagePrompt.clean(reply?.text.orEmpty()) + if (text.isBlank()) { + _message.value = "The model returned no message." + } else { + _commitMessage.value = text + } + } catch (e: Exception) { + _message.value = e.message ?: "Could not generate a commit message." + } finally { + _generating.value = false + } + } + } + + /** Unified diff text for [files], truncated to [budget] characters. */ + /** + * A compact unified-ish diff for a PROMPT: changed lines plus [context] lines + * around each change, with runs of unchanged text collapsed to an ellipsis. + * + * Built from the parsed hunk lines rather than [GitDiffData.rawUnified], which is + * the whole file (the host diffs with -U100000 for the viewer's side-by-side + * alignment). Pure, so [GitTabCompactDiffTest] can pin it. + */ + internal fun compactDiff( + diff: ai.rever.boss.plugin.api.GitDiffData, + context: Int = 3, + ): String { + val lines = diff.hunks.flatMap { it.lines } + if (lines.none { it.kind != ai.rever.boss.plugin.api.DiffLineKind.CONTEXT }) return "" + val keep = BooleanArray(lines.size) + lines.forEachIndexed { i, l -> + if (l.kind != ai.rever.boss.plugin.api.DiffLineKind.CONTEXT) { + for (j in (i - context).coerceAtLeast(0)..(i + context).coerceAtMost(lines.size - 1)) keep[j] = true + } + } + val sb = StringBuilder() + var printedAny = false + var gap = false + lines.forEachIndexed { i, l -> + if (!keep[i]) { + gap = true + return@forEachIndexed + } + if (gap && printedAny) sb.append(" …\n") + gap = false + val prefix = + when (l.kind) { + ai.rever.boss.plugin.api.DiffLineKind.ADDED -> "+" + ai.rever.boss.plugin.api.DiffLineKind.REMOVED -> "-" + else -> " " + } + sb.append(prefix).append(l.text).append('\n') + printedAny = true + } + return sb.toString() + } + + private suspend fun collectDiff(files: List, budget: Int): String { + val provider = git ?: return "" + val sb = StringBuilder() + for (f in files.distinctBy { it.path }.take(MAX_INLINE_FILES)) { + val diff = + provider.diffFile(f.path, staged = f.isStaged && !f.isUnstaged).firstOrNull() + ?: provider.diffFile(f.path, staged = false).firstOrNull() + ?: continue + // compactDiff, not rawUnified: the host generates diffs with whole-file + // context (-U100000) for the viewer, so rawUnified is the entire file. Fed + // to a char-budgeted prompt that broke on the first oversized block, a + // one-line change in a 500-line file produced NO diff at all. The compact + // form is just the changed lines plus a little context, so the budget now + // bounds real content instead of context padding. + val block = compactDiff(diff).ifBlank { continue } + if (sb.isNotEmpty() && sb.length + block.length > budget) break + val remaining = budget - sb.length + val slice = if (block.length > remaining) block.take(remaining) + "\n… [truncated]\n" else block + sb.append("--- ").append(f.path).append('\n').append(slice).append('\n') + } + return sb.toString() + } + + /** + * Agent Review: gather the changed files and as much diff text as fits + * the inline budget, then hand the composed prompt to [onAgentReview]. + */ + fun startAgentReview() { + scope.launch { + _busy.value = true + try { + val status = _fileStatus.value + val staged = status.filter { it.isStaged } + val unstaged = status.filter { it.isUnstaged } + + var diffText: String? = null + if (git != null) { + val sb = StringBuilder() + for (s in (staged + unstaged).distinctBy { it.path }.take(MAX_INLINE_FILES)) { + val diff = + git.diffFile(s.path, staged = s.isStaged && !s.isUnstaged).firstOrNull() + ?: git.diffFile(s.path, staged = false).firstOrNull() + ?: continue + val block = diff.rawUnified.ifBlank { + diff.hunks.joinToString("\n") { h -> + "@@ -${h.oldStart},${h.oldLines} +${h.newStart},${h.newLines} @@\n" + + h.lines.joinToString("\n") { l -> l.text } + } + } + if (sb.length + block.length > AgentReviewPrompt.INLINE_DIFF_BUDGET) break + if (sb.isNotEmpty()) sb.append("\n") + sb.append("diff --git ").append(s.path).append("\n").append(block) + } + diffText = sb.toString().takeIf { it.isNotBlank() } + } + + onAgentReview( + AgentReviewPrompt.build( + projectPath = getProjectPath().orEmpty(), + staged = staged, + unstaged = unstaged, + diffText = diffText, + instructions = _reviewInstructions.value, + deep = _reviewDeep.value, + baseRef = _reviewBase.value, + ), + ) + } finally { + _busy.value = false + } + } + } + + fun dispose() { + statusTimer?.cancel() + scope.cancel() + } + + /** + * Run one provider operation, surface its result, then ask the provider + * to re-read status - the collector in [init] publishes the new groups. + */ + private fun op( + refreshGraph: Boolean = false, + block: suspend () -> GitOperationResultData?, + ) { + if (git == null) { + _message.value = "Git is unavailable on this host." + return + } + scope.launch { + _busy.value = true + _message.value = null + try { + report(block()) { if (refreshGraph) loadGraph(reset = true) } + git.refreshStatus() + } finally { + _busy.value = false + } + } + } + + private fun report(result: GitOperationResultData?, onSucceed: () -> Unit) { + _message.value = + when (result) { + is GitOperationResultData.Success -> result.message + is GitOperationResultData.Error -> "Failed: ${result.message}" + null -> "No git project open." + } + if (result is GitOperationResultData.Success) onSucceed() + } + + companion object { + const val GRAPH_PAGE = 50 + const val GRAPH_MAX = 200 + private const val REFRESH_MS = 5_000L + private const val MAX_INLINE_FILES = 15 + + /** Diff characters sent when drafting a commit message. */ + private const val COMMIT_DIFF_BUDGET = 12_000 + + /** Immediate children inspected for the no-repository hint. */ + private const val CHILD_SCAN_LIMIT = 200 + + /** + * The branch name out of a graph's ref decorations. `%D` yields + * entries like `HEAD -> feat/x`, `origin/feat/x`, `tag: v1`; only the + * HEAD arrow names the checked-out branch, and a detached HEAD has + * none - which reads as "detached", not as some remote's name. + */ + /** + * A sentence for the empty state, from the selected folder alone: + * names the folder, and counts the repositories directly inside it so + * the fix ("open one of them") is obvious. Immediate children only - + * a recursive scan of an arbitrary folder is not worth a panel hint. + */ + internal fun describeMissingRepo(projectPath: String?): String { + if (projectPath.isNullOrBlank()) return "No project open." + val root = java.io.File(projectPath) + if (!root.isDirectory) return "No Git repository in this project." + val name = root.name.ifEmpty { projectPath } + val nested = + try { + root.listFiles() + ?.asSequence() + ?.filter { it.isDirectory } + ?.take(CHILD_SCAN_LIMIT) + ?.count { java.io.File(it, ".git").exists() } + ?: 0 + } catch (e: Exception) { + 0 + } + return when { + nested == 1 -> "$name is not a Git repository, but one folder inside it is. Open that folder as the project." + nested > 1 -> "$name is not a Git repository. It holds $nested of them - open one as the project." + else -> "$name is not a Git repository." + } + } + + /** + * Every branch named in a graph's ref decorations, de-duplicated and + * with remote copies folded onto their local name. + */ + internal fun branchesOf(nodes: List): List = + nodes + .asSequence() + .flatMap { it.refs.asSequence() } + .map { it.removePrefix("HEAD ->").trim() } + .filterNot { it.isBlank() || it == "HEAD" || it.startsWith("tag:") } + .map { ref -> ref.substringAfter('/', ref).takeIf { ref.startsWith("origin/") } ?: ref } + .distinct() + .sorted() + .toList() + + /** "main"/"master" when the history has one, else the current branch. */ + internal fun defaultBase(branches: List, current: String): String = + branches.firstOrNull { it == "main" } + ?: branches.firstOrNull { it == "master" } + ?: current + + internal fun branchOf(nodes: List): String { + val refs = nodes.firstOrNull()?.refs ?: return "" + refs.firstOrNull { it.startsWith("HEAD ->") } + ?.let { return it.removePrefix("HEAD ->").trim() } + return if (refs.any { it.trim() == "HEAD" }) "detached" else "" + } + } +} + +/** Untracked files carry only a work-tree status; keep them out of the staged list. */ +internal val GitFileStatusData.isUntracked: Boolean + get() = !isStaged && workTreeStatus == GitFileStatusTypeData.UNTRACKED diff --git a/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitTabUi.kt b/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitTabUi.kt new file mode 100644 index 0000000..ed23443 --- /dev/null +++ b/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitTabUi.kt @@ -0,0 +1,1675 @@ +package ai.rever.boss.plugin.dynamic.codebase + +import ai.rever.boss.plugin.api.GitCommitNodeData +import ai.rever.boss.plugin.api.GitFileStatusData +import ai.rever.boss.plugin.api.GitFileStatusTypeData +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.DropdownMenu +import androidx.compose.material.DropdownMenuItem +import androidx.compose.material.Icon +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.AutoAwesome +import androidx.compose.material.icons.outlined.Difference +import androidx.compose.material.icons.outlined.HelpOutline +import androidx.compose.material.icons.outlined.Refresh +import androidx.compose.material.icons.outlined.Replay +import androidx.compose.material.icons.outlined.Settings +import androidx.compose.material.icons.rounded.AccountTree +import androidx.compose.material.icons.rounded.ArrowDownward +import androidx.compose.material.icons.rounded.ArrowUpward +import androidx.compose.material.icons.rounded.CloudDownload +import androidx.compose.material.icons.rounded.LocalOffer +import androidx.compose.material.icons.rounded.MyLocation +import androidx.compose.material.icons.rounded.Add +import androidx.compose.material.icons.rounded.Check +import androidx.compose.material.icons.rounded.CloudQueue +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material.icons.rounded.ContentCopy +import androidx.compose.material.icons.rounded.DoneAll +import androidx.compose.material.icons.rounded.Edit +import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight +import androidx.compose.material.icons.rounded.FormatListBulleted +import androidx.compose.material.icons.rounded.KeyboardArrowDown +import androidx.compose.material.icons.rounded.Remove +import androidx.compose.material.icons.automirrored.rounded.Undo +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.isCtrlPressed +import androidx.compose.ui.input.key.isMetaPressed +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.LineHeightStyle +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.concurrent.TimeUnit + +/** + * The GIT tab - VS Code's Source Control view, plus a commit graph below it. + * + * Layout, top to bottom: a toolbar naming the branch, the commit message box + * with a full-width Commit button, the change groups (Staged / Changes / + * Untracked), a draggable splitter, then the lane graph. The splitter exists + * because the panel is user-resizable: a fixed 50/50 weight left the graph + * unusable in a short panel and the changes list unusable in a tall one. + * + * Destructive rows (discard, revert, cherry-pick, checkout) go through + * [GitConfirmDialog]. Discard in particular was one un-confirmed click away + * from throwing work away. + */ +@Composable +fun CodebaseGitContent( + viewModel: CodebaseGitViewModel, + modifier: Modifier = Modifier, +) { + if (viewModel.providerOrNull == null) { + CodebaseEmptyState("Git is unavailable on this host build.", modifier.fillMaxSize()) + return + } + + val fileStatus by viewModel.fileStatus.collectAsState() + val graph by viewModel.graph.collectAsState() + val graphBusy by viewModel.graphBusy.collectAsState() + val busy by viewModel.busy.collectAsState() + val message by viewModel.message.collectAsState() + val isRepo by viewModel.isGitRepository.collectAsState() + val branch by viewModel.currentBranch.collectAsState() + val commitMessage by viewModel.commitMessage.collectAsState() + val noRepoHint by viewModel.noRepoHint.collectAsState() + val loaded by viewModel.loaded.collectAsState() + val reviewInstructions by viewModel.reviewInstructions.collectAsState() + val reviewDeep by viewModel.reviewDeep.collectAsState() + val reviewBase by viewModel.reviewBase.collectAsState() + val branches by viewModel.branches.collectAsState() + val branchOptions by viewModel.branchOptions.collectAsState() + val remoteNames by viewModel.remoteNames.collectAsState() + val graphRef by viewModel.graphRef.collectAsState() + val generating by viewModel.generating.collectAsState() + val layout by viewModel.changeLayout.collectAsState() + + var settingsOpen by remember { mutableStateOf(false) } + var stagedOpen by remember { mutableStateOf(true) } + var changesOpen by remember { mutableStateOf(true) } + var untrackedOpen by remember { mutableStateOf(true) } + var graphOpen by remember { mutableStateOf(true) } + var confirm by remember { mutableStateOf(null) } + var collapsedDirs by remember { mutableStateOf(emptySet()) } + val splitFraction = remember { mutableStateOf(0.55f) } + + val staged = remember(fileStatus) { fileStatus.filter { it.isStaged } } + val changed = remember(fileStatus) { fileStatus.filter { it.isUnstaged && !it.isUntracked } } + val untracked = remember(fileStatus) { fileStatus.filter { it.isUntracked } } + + // Poll only while this tab is on screen, and keep a graph the user has + // already paged through instead of resetting it on every tab switch. + DisposableEffect(Unit) { + viewModel.refreshStatus() + viewModel.startStatusTimer() + onDispose { viewModel.stopStatusTimer() } + } + + // Nothing staged but tracked edits present: commit stages them first, the + // way VS Code's Commit does, and the label says so. + val commitStagesAll = staged.isEmpty() && changed.isNotEmpty() + val canCommit = (staged.isNotEmpty() || commitStagesAll) && commitMessage.isNotBlank() && !busy + val doCommit = { if (canCommit) viewModel.commit(stageFirst = commitStagesAll) } + + // Box, not a bare Column - see the note in CodebaseSearchContent. Emitted + // after the Column, GitConfirmDialog was measured at zero height, so + // Discard / Revert / Cherry-pick / Checkout silently did nothing. + Box(modifier = modifier.fillMaxSize()) { + Column(modifier = Modifier.fillMaxSize()) { + GitToolbar( + branch = branch, + isRepo = isRepo, + busy = busy, + settingsOpen = settingsOpen, + onRefresh = { + viewModel.refreshStatus() + viewModel.loadGraph(reset = true) + }, + onReview = { viewModel.startAgentReview() }, + onToggleSettings = { settingsOpen = !settingsOpen }, + layout = layout, + onToggleLayout = { viewModel.toggleChangeLayout() }, + ) + + // ── Commit box: field on its own line, button under it ───────────── + // Side by side, the field collapsed to nothing once the panel was + // narrowed - the whole point of stacking them. + Column(modifier = Modifier.padding(horizontal = CodebaseMetrics.Gutter, vertical = 6.dp)) { + CodebaseTextField( + value = commitMessage, + onValueChange = viewModel::setCommitMessage, + placeholder = "Message (${commitShortcutHint()} to commit)", + modifier = Modifier.fillMaxWidth(), + keyHandler = Modifier.onPreviewKeyEvent { event -> + if (event.type == KeyEventType.KeyDown && + event.key == Key.Enter && + (event.isMetaPressed || event.isCtrlPressed) + ) { + doCommit() + true + } else { + false + } + }, + trailing = { + if (generating) { + CircularProgressIndicator( + modifier = Modifier.size(12.dp), + strokeWidth = 1.5.dp, + color = CodebasePalette.Accent, + ) + } else { + CodebaseIconButton( + icon = Icons.Outlined.AutoAwesome, + tooltip = "Generate a commit message from the changes", + onClick = { viewModel.generateCommitMessage() }, + enabled = staged.isNotEmpty() || changed.isNotEmpty(), + ) + } + }, + ) + Spacer(Modifier.height(5.dp)) + CodebasePrimaryButton( + label = if (commitStagesAll) "Commit All" else "Commit", + onClick = doCommit, + enabled = canCommit, + icon = Icons.Rounded.Check, + modifier = Modifier.fillMaxWidth(), + ) + } + CodebaseHRule() + + if (!isRepo) { + if (!loaded) { + Box(modifier = Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = CodebasePalette.Accent, + ) + } + } else { + CodebaseEmptyState( + noRepoHint.ifBlank { "No Git repository in this project." }, + Modifier.weight(1f), + icon = Icons.Rounded.AccountTree, + ) + } + } else { + // ── Changes over graph, split by a draggable divider ─────────── + BoxWithConstraints(modifier = Modifier.weight(1f)) { + val totalPx = with(LocalDensity.current) { maxHeight.toPx() } + val topWeight = if (graphOpen) splitFraction.value else 1f + Column(modifier = Modifier.fillMaxSize()) { + LazyColumn( + modifier = Modifier.weight(topWeight).fillMaxWidth(), + contentPadding = PaddingValues(bottom = 4.dp), + ) { + changeGroup( + title = "STAGED", + files = staged, + expanded = stagedOpen, + onToggle = { stagedOpen = !stagedOpen }, + layout = layout, + collapsedDirs = collapsedDirs, + onToggleDir = { path -> + collapsedDirs = if (path in collapsedDirs) collapsedDirs - path else collapsedDirs + path + }, + headerActions = { + CodebaseIconButton(Icons.Rounded.Remove, "Unstage all", { viewModel.unstageAll() }, enabled = !busy) + }, + directoryActions = { files, hovered -> + CodebaseIconButton( + Icons.Rounded.Remove, + "Unstage this folder", + { viewModel.unstagePaths(files.map { it.path }) }, + enabled = !busy, + visible = hovered, + ) + }, + onOpen = { viewModel.openFileDiff(it.path, staged = true) }, + rowActions = { f, hovered -> + CodebaseIconButton(Icons.Outlined.Difference, "Open changes", { viewModel.openFileDiff(f.path, staged = true) }, visible = hovered) + CodebaseIconButton(Icons.Rounded.Remove, "Unstage", { viewModel.unstage(f.path) }, enabled = !busy, visible = hovered) + }, + ) + changeGroup( + title = "CHANGES", + files = changed, + expanded = changesOpen, + onToggle = { changesOpen = !changesOpen }, + layout = layout, + collapsedDirs = collapsedDirs, + onToggleDir = { path -> + collapsedDirs = if (path in collapsedDirs) collapsedDirs - path else collapsedDirs + path + }, + headerActions = { + CodebaseIconButton(Icons.AutoMirrored.Rounded.Undo, "Discard all changes", onClick = { + confirm = GitConfirmation( + title = "Discard all changes", + body = "Discard changes to ${changed.size} file(s)? This cannot be undone.", + confirmLabel = "Discard all", + destructive = true, + action = { viewModel.discardPaths(changed.map { it.path }) }, + ) + }, enabled = !busy) + // Stage exactly this group. The provider's + // stageAll is `git add -A`, which also stages + // untracked files - surprising under a button + // that sits beside a separate UNTRACKED group. + CodebaseIconButton( + Icons.Rounded.Add, + "Stage all changes", + { viewModel.stagePaths(changed.map { it.path }) }, + enabled = !busy, + ) + }, + directoryActions = { files, hovered -> + CodebaseIconButton( + Icons.AutoMirrored.Rounded.Undo, + "Discard this folder's changes", + { + confirm = GitConfirmation( + title = "Discard folder", + body = "Discard changes to ${files.size} file(s) in this folder? This cannot be undone.", + confirmLabel = "Discard", + destructive = true, + action = { viewModel.discardPaths(files.map { it.path }) }, + ) + }, + enabled = !busy, + visible = hovered, + ) + CodebaseIconButton( + Icons.Rounded.Add, + "Stage this folder", + { viewModel.stagePaths(files.map { it.path }) }, + enabled = !busy, + visible = hovered, + ) + }, + onOpen = { viewModel.openFileDiff(it.path, staged = false) }, + rowActions = { f, hovered -> + // Editing is offered on the working tree only. + // The staged copy of a file is a snapshot in the + // index; "edit" there would silently edit the + // work tree instead, which is a different file. + CodebaseIconButton(Icons.Rounded.Edit, "Edit file", { viewModel.openFile(f.path) }, visible = hovered) + CodebaseIconButton(Icons.Outlined.Difference, "Open changes", { viewModel.openFileDiff(f.path, staged = false) }, visible = hovered) + CodebaseIconButton(Icons.AutoMirrored.Rounded.Undo, "Discard changes", onClick = { + confirm = GitConfirmation( + title = "Discard changes", + body = "Discard changes to ${f.path}? This cannot be undone.", + confirmLabel = "Discard", + destructive = true, + action = { viewModel.discard(f.path) }, + ) + }, enabled = !busy, visible = hovered) + CodebaseIconButton(Icons.Rounded.Add, "Stage changes", { viewModel.stage(f.path) }, enabled = !busy, visible = hovered) + }, + ) + changeGroup( + title = "UNTRACKED", + files = untracked, + expanded = untrackedOpen, + onToggle = { untrackedOpen = !untrackedOpen }, + layout = layout, + collapsedDirs = collapsedDirs, + onToggleDir = { path -> + collapsedDirs = if (path in collapsedDirs) collapsedDirs - path else collapsedDirs + path + }, + headerActions = { + CodebaseIconButton(Icons.Rounded.Add, "Stage all untracked", { viewModel.stagePaths(untracked.map { it.path }) }, enabled = !busy) + }, + directoryActions = { files, hovered -> + CodebaseIconButton( + Icons.Rounded.Add, + "Stage this folder", + { viewModel.stagePaths(files.map { it.path }) }, + enabled = !busy, + visible = hovered, + ) + }, + onOpen = { viewModel.openFile(it.path) }, + rowActions = { f, hovered -> + CodebaseIconButton(Icons.Rounded.Edit, "Edit file", { viewModel.openFile(f.path) }, visible = hovered) + CodebaseIconButton(Icons.Rounded.Add, "Stage file", { viewModel.stage(f.path) }, enabled = !busy, visible = hovered) + }, + ) + if (loaded && staged.isEmpty() && changed.isEmpty() && untracked.isEmpty()) { + item { + CodebaseEmptyState( + "No changes.", + Modifier.fillMaxWidth(), + icon = Icons.Rounded.DoneAll, + ) + } + } + } + + // A plain footer, pinned below the change list and above + // the graph: it acts on the changes, so it belongs at the + // end of them rather than in the toolbar, where it pushed + // the list down the panel. + // + // Deliberately NOT a CodebaseSectionHeader accordion. An + // accordion promises content you expand to work inside, + // and there is none here - one split button and a caption + // saying what it will act on. The header spent a row and a + // collapse state to hide two controls that are the reason + // this footer exists. The rules above and below are the + // separation it was really providing. + CodebaseHRule() + GitAgentReview( + expanded = settingsOpen, + onToggleExpanded = { settingsOpen = !settingsOpen }, + instructions = reviewInstructions, + onInstructionsChange = viewModel::setReviewInstructions, + deep = reviewDeep, + onDeepChange = viewModel::setReviewDeep, + base = reviewBase, + branches = branches, + onBaseChange = viewModel::setReviewBase, + busy = busy, + onFindIssues = { viewModel.startAgentReview() }, + ) + + CodebaseHRule() + CodebaseSectionHeader( + // No count. CodebaseSectionHeader defaults it to null, + // and the number it used to show was `graph.size` - how + // many commits are LOADED, which pages 50 at a time + // behind "Load more". That says nothing about the + // repository, only about how far you have scrolled, and + // it read as a fact about the branch. The change groups + // keep their counts: those are complete sets. + title = "GRAPH", + expanded = graphOpen, + onToggle = { graphOpen = !graphOpen }, + modifier = Modifier.padding(horizontal = 4.dp), + // Always on screen, not hover-revealed: the branch + // picker opens a popup, and un-hovering the header to + // reach that popup would close it - see the parameter's + // note in CodebaseUi. + actionsAlwaysVisible = true, + actions = { + GitGraphHeaderActions( + displayedRef = graphRef ?: branch, + onCurrentBranch = graphRef == null, + options = branchOptions, + busy = busy, + onSelect = { viewModel.selectGraphBranch(it) }, + onBackToCurrent = { viewModel.showCurrentBranch() }, + onFetch = { viewModel.fetch() }, + onPull = { viewModel.pull() }, + onPush = { + confirm = GitConfirmation( + title = "Push", + body = + "Push ${branch.ifBlank { "the current branch" }} to origin? " + + "This publishes your commits to the remote.", + confirmLabel = "Push", + action = { viewModel.push() }, + ) + }, + onRefresh = { viewModel.loadGraph(reset = true) }, + ) + }, + ) + if (graphOpen) { + CodebaseSplitter(splitFraction, totalPx) + GitGraphList( + graph = graph, + graphBusy = graphBusy, + remoteNames = remoteNames, + hasMore = viewModel.hasMoreGraph(), + modifier = Modifier.weight((1f - splitFraction.value).coerceAtLeast(0.05f)), + onOpen = { viewModel.openCommitDiff(it.hash) }, + onAction = { confirm = it }, + viewModel = viewModel, + ) + } + } + } + } + + GitStatusLine(message = message, busy = busy, onDismiss = viewModel::clearMessage) + } + + confirm?.let { c -> + GitConfirmDialog( + confirmation = c, + onDismiss = { confirm = null }, + onConfirm = { + c.action() + confirm = null + }, + ) + } + } +} + +/** The toolbar row: branch name, Agent Review, refresh, settings. */ +@Composable +private fun GitToolbar( + branch: String, + isRepo: Boolean, + busy: Boolean, + settingsOpen: Boolean, + onRefresh: () -> Unit, + onReview: () -> Unit, + onToggleSettings: () -> Unit, + layout: GitChangeLayout, + onToggleLayout: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(CodebaseMetrics.RowHeight + 6.dp) + .padding(horizontal = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Rounded.AccountTree, + contentDescription = null, + modifier = Modifier.size(CodebaseMetrics.Glyph), + tint = CodebasePalette.Secondary, + ) + Spacer(Modifier.width(5.dp)) + Text( + text = when { + !isRepo -> "No repository" + branch.isNotEmpty() -> branch + else -> "…" + }, + fontSize = CodebaseMetrics.SecondaryText, + fontWeight = FontWeight.Medium, + color = CodebasePalette.Foreground, + maxLines = 1, + overflow = TextOverflow.MiddleEllipsis, + modifier = Modifier.weight(1f), + ) + if (busy) { + CircularProgressIndicator( + modifier = Modifier.size(11.dp), + strokeWidth = 1.5.dp, + color = CodebasePalette.Accent, + ) + Spacer(Modifier.width(6.dp)) + } + CodebaseIconButton( + icon = if (layout == GitChangeLayout.TREE) Icons.Rounded.AccountTree else Icons.Rounded.FormatListBulleted, + tooltip = if (layout == GitChangeLayout.TREE) "View as list" else "View as tree", + onClick = onToggleLayout, + ) + CodebaseIconButton(Icons.Outlined.Refresh, "Refresh", onRefresh) + } +} + +/** + * The AGENT REVIEW section: one primary action, with the options that change + * what it reviews folded behind a chevron. + * + * Replaces a settings row whose only control was an auto-start toggle - a + * preference, occupying the space where the action should be. + */ +@Composable +private fun GitAgentReview( + expanded: Boolean, + onToggleExpanded: () -> Unit, + instructions: String, + onInstructionsChange: (String) -> Unit, + deep: Boolean, + onDeepChange: (Boolean) -> Unit, + base: String, + branches: List, + onBaseChange: (String) -> Unit, + busy: Boolean, + onFindIssues: () -> Unit, +) { + var approachOpen by remember { mutableStateOf(false) } + var branchOpen by remember { mutableStateOf(false) } + + Column( + modifier = Modifier + .fillMaxWidth() + // Asymmetric on purpose. The 22dp section header used to sit + // between the rule above and this button; with it gone, 2dp left + // the button all but touching that rule. The bottom needs less + // because the caption below already ends in an 8dp spacer, so the + // block reads as 8dp above / 10dp below inside its two rules. + .padding(start = CodebaseMetrics.Gutter, end = CodebaseMetrics.Gutter, top = 8.dp, bottom = 2.dp), + ) { + // One pill, split by a hairline: the action, and the disclosure that + // opens what it will act on - the split-button shape the reference uses. + Row( + modifier = Modifier + .fillMaxWidth() + .height(CodebaseMetrics.InputHeight) + .clip(RoundedCornerShape(CodebaseMetrics.ButtonRadius)) + .background( + if (busy) CodebasePalette.Accent.copy(alpha = 0.25f) + else CodebasePalette.Accent.copy(alpha = 0.85f), + ), + verticalAlignment = Alignment.CenterVertically, + ) { + val onAccent = CodebasePalette.onAccent() + Row( + modifier = Modifier + .weight(1f) + .fillMaxHeight() + .then(if (busy) Modifier else Modifier.clickable(onClick = onFindIssues)), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Outlined.AutoAwesome, + contentDescription = null, + modifier = Modifier.size(CodebaseMetrics.Glyph), + tint = if (busy) onAccent.copy(alpha = 0.45f) else onAccent, + ) + Spacer(Modifier.width(6.dp)) + Text( + text = if (busy) "Reviewing…" else "Fluck Agent Review", + fontSize = CodebaseMetrics.SecondaryText, + fontWeight = FontWeight.Medium, + color = if (busy) onAccent.copy(alpha = 0.45f) else onAccent, + maxLines = 1, + // "Fluck Agent Review" is two and a half times the width of + // the old "Find Issues", and it shares this pill with a + // sparkle icon and a 26dp chevron segment. Unweighted and + // with the default Clip overflow, a narrow panel cut the + // label off mid-glyph with no ellipsis to say so; the + // weight lets it be measured against what is actually left + // and shorten honestly. + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false), + ) + } + Box( + modifier = Modifier + .width(1.dp) + .fillMaxHeight() + .background(onAccent.copy(alpha = 0.30f)), + ) + Box( + modifier = Modifier + .width(26.dp) + .fillMaxHeight() + .clickable(onClick = onToggleExpanded), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = + if (expanded) Icons.Rounded.KeyboardArrowDown + else Icons.AutoMirrored.Rounded.KeyboardArrowRight, + contentDescription = if (expanded) "Hide review options" else "Review options", + modifier = Modifier.size(CodebaseMetrics.Glyph), + tint = onAccent, + ) + // The options float under the button rather than expanding + // above it: an inline block pushed the button down the panel + // every time it opened, so the control moved out from under + // the cursor that had just opened it. + DropdownMenu( + expanded = expanded, + onDismissRequest = onToggleExpanded, + modifier = Modifier + .width(REVIEW_MENU_WIDTH) + .clip(RoundedCornerShape(8.dp)) + .background(CodebasePalette.Raised) + .border(1.dp, CodebasePalette.BorderStrong, RoundedCornerShape(8.dp)), + ) { + Column(modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp)) { + CodebaseTextField( + value = instructions, + onValueChange = onInstructionsChange, + placeholder = "Optional instructions…", + modifier = Modifier.fillMaxWidth(), + singleLine = false, + maxLines = 4, + minHeight = 56.dp, + ) + Spacer(Modifier.height(8.dp)) + GitOptionDropdown( + label = "Approach", + value = if (deep) "Deep" else "Quick", + options = listOf("Quick", "Deep"), + onSelect = { onDeepChange(it == "Deep") }, + ) + GitOptionDropdown( + label = "Diff against…", + value = base.ifBlank { "HEAD" }, + options = branches, + emptyHint = "No branches in the loaded history.", + onSelect = onBaseChange, + ) + } + } + } + } + Spacer(Modifier.height(4.dp)) + CodebaseTooltip( + text = "Runs the agent over this branch's diff and reports what it finds. " + + "Nothing is edited.", + modifier = Modifier.fillMaxWidth(), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = if (base.isBlank()) "Review uncommitted changes." else "Review diffs vs. $base.", + fontSize = CodebaseMetrics.MetaText, + color = CodebasePalette.Muted, + maxLines = 1, + overflow = TextOverflow.MiddleEllipsis, + modifier = Modifier.weight(1f, fill = false), + ) + } + } + Spacer(Modifier.height(8.dp)) + } +} + +/** + * A label with its current value on the right, opening a real dropdown. + * + * Previously the choices expanded inline underneath, which pushed the button + * down the panel and read as a nested list rather than a control. A menu + * leaves the surrounding layout still, which is what the reference does. + */ +@Composable +private fun GitOptionDropdown( + label: String, + value: String, + options: List, + onSelect: (String) -> Unit, + emptyHint: String? = null, +) { + var open by remember { mutableStateOf(false) } + Box { + CodebaseListRow(onClick = { open = true }, height = CodebaseMetrics.RowHeight + 2.dp) { _ -> + Spacer(Modifier.width(4.dp)) + Text( + text = label, + fontSize = CodebaseMetrics.SecondaryText, + color = CodebasePalette.Secondary, + maxLines = 1, + modifier = Modifier.weight(1f), + ) + Text( + text = value, + fontSize = CodebaseMetrics.SecondaryText, + fontWeight = FontWeight.Medium, + color = CodebasePalette.Foreground, + maxLines = 1, + overflow = TextOverflow.MiddleEllipsis, + modifier = Modifier.weight(1f, fill = false), + ) + Spacer(Modifier.width(4.dp)) + Icon( + imageVector = Icons.Rounded.KeyboardArrowDown, + contentDescription = null, + modifier = Modifier.size(14.dp), + tint = CodebasePalette.Secondary, + ) + Spacer(Modifier.width(2.dp)) + } + DropdownMenu( + expanded = open, + onDismissRequest = { open = false }, + modifier = Modifier + .background(CodebasePalette.Raised) + .heightIn(max = 260.dp), + ) { + if (options.isEmpty()) { + Text( + text = emptyHint ?: "Nothing to choose.", + fontSize = CodebaseMetrics.MetaText, + color = CodebasePalette.Muted, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), + ) + } + options.forEach { option -> + DropdownMenuItem( + onClick = { + onSelect(option) + open = false + }, + contentPadding = PaddingValues(horizontal = 10.dp), + modifier = Modifier.height(24.dp), + ) { + Text( + text = option, + fontSize = CodebaseMetrics.SecondaryText, + color = if (option == value) CodebasePalette.Foreground else CodebasePalette.Secondary, + maxLines = 1, + overflow = TextOverflow.MiddleEllipsis, + modifier = Modifier.weight(1f), + ) + if (option == value) { + Spacer(Modifier.width(8.dp)) + Icon( + Icons.Rounded.Check, + contentDescription = "Selected", + modifier = Modifier.size(13.dp), + tint = CodebasePalette.Accent, + ) + } + } + } + } + } +} + +/** A borderless text action, 11sp - the panel has no room for a Material TextButton. */ +@Composable +private fun GitTextAction(label: String, onClick: () -> Unit, enabled: Boolean = true) { + Text( + text = label, + fontSize = CodebaseMetrics.MetaText, + fontWeight = FontWeight.Medium, + color = if (enabled) CodebasePalette.Accent else CodebasePalette.Muted, + modifier = Modifier + .clip(RoundedCornerShape(CodebaseMetrics.ChipRadius)) + .then(if (enabled) Modifier.clickable(onClick = onClick) else Modifier) + .padding(horizontal = 4.dp, vertical = 2.dp), + ) +} + +/** One change group: a hover-actioned header plus its file rows. */ +private fun LazyListScope.changeGroup( + title: String, + files: List, + expanded: Boolean, + onToggle: () -> Unit, + layout: GitChangeLayout, + collapsedDirs: Set, + onToggleDir: (String) -> Unit, + headerActions: @Composable RowScope.() -> Unit, + onOpen: (GitFileStatusData) -> Unit, + rowActions: @Composable RowScope.(GitFileStatusData, Boolean) -> Unit, + directoryActions: @Composable RowScope.(List, Boolean) -> Unit, +) { + if (files.isEmpty()) return + item(key = "header-$title") { + CodebaseSectionHeader( + title = title, + count = files.size, + expanded = expanded, + onToggle = onToggle, + modifier = Modifier.padding(horizontal = 4.dp), + actions = headerActions, + ) + } + if (!expanded) return + if (layout == GitChangeLayout.LIST) { + items(files, key = { "$title-${it.path}" }) { f -> + GitFileRow(file = f, onOpen = { onOpen(f) }, actions = rowActions) + } + return + } + val rows = GitChangeTree.rows(files, collapsedDirs) + items(rows, key = { row -> + when (row) { + is GitChangeTree.Row.Directory -> "$title-dir-${row.path}" + is GitChangeTree.Row.FileRow -> "$title-file-${row.file.path}" + } + }) { row -> + when (row) { + is GitChangeTree.Row.Directory -> + GitDirectoryRow( + row = row, + collapsed = row.path in collapsedDirs, + onToggle = { onToggleDir(row.path) }, + actions = { hovered -> + directoryActions(GitChangeTree.filesUnder(files, row.path), hovered) + }, + ) + + is GitChangeTree.Row.FileRow -> + GitFileRow( + file = row.file, + displayName = row.name, + displayDir = "", + indent = CodebaseMetrics.Indent + 8.dp + (row.depth * 12).dp, + onOpen = { onOpen(row.file) }, + actions = rowActions, + ) + } + } +} + +/** A collapsible directory row in tree mode. */ +@Composable +private fun GitDirectoryRow( + row: GitChangeTree.Row.Directory, + collapsed: Boolean, + onToggle: () -> Unit, + actions: @Composable RowScope.(Boolean) -> Unit, +) { + CodebaseListRow(onClick = onToggle) { hovered -> + Spacer(Modifier.width(CodebaseMetrics.Indent + (row.depth * 12).dp)) + Icon( + imageVector = + if (collapsed) Icons.AutoMirrored.Rounded.KeyboardArrowRight + else Icons.Rounded.KeyboardArrowDown, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = CodebasePalette.Secondary, + ) + Text( + text = row.label, + fontSize = CodebaseMetrics.PrimaryText, + color = CodebasePalette.Secondary, + maxLines = 1, + overflow = TextOverflow.MiddleEllipsis, + modifier = Modifier.weight(1f), + ) + actions(hovered) + CodebaseCountBadge(row.fileCount, color = CodebasePalette.Secondary) + Spacer(Modifier.width(6.dp)) + } +} + +/** + * One changed file. VS Code's shape: name at full contrast, its directory + * trailing behind it muted, hover actions, then the status letter pinned to + * the right edge - so the letter stays put as actions appear and vanish. + */ +@Composable +private fun GitFileRow( + file: GitFileStatusData, + onOpen: () -> Unit, + actions: @Composable RowScope.(GitFileStatusData, Boolean) -> Unit, + displayName: String? = null, + displayDir: String? = null, + indent: androidx.compose.ui.unit.Dp = CodebaseMetrics.Indent + 8.dp, +) { + val split = remember(file.path) { splitPathForDisplay(file.path) } + val name = displayName ?: split.first + val dir = displayDir ?: split.second + val status = if (file.isStaged) file.indexStatus else file.workTreeStatus + CodebaseListRow(onClick = onOpen) { hovered -> + Spacer(Modifier.width(indent)) + // One weighted slot for the label pair. Left unweighted, a long file + // name is measured against the full row width and shoves the actions + // and the status letter past the right edge of the panel. + CodebaseTooltip(file.path, modifier = Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = name, + fontSize = CodebaseMetrics.PrimaryText, + color = CodebasePalette.Foreground, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false), + ) + if (dir.isNotEmpty()) { + Spacer(Modifier.width(5.dp)) + Text( + text = dir, + fontSize = CodebaseMetrics.MetaText, + color = CodebasePalette.Muted, + maxLines = 1, + overflow = TextOverflow.MiddleEllipsis, + modifier = Modifier.weight(1f, fill = false), + ) + } + } + } + actions(file, hovered) + Text( + text = statusGlyph(status), + fontSize = CodebaseMetrics.SecondaryText, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold, + color = statusColor(status), + modifier = Modifier.width(16.dp), + ) + } +} + +/** The lane graph, its own scroller under the splitter. */ +@Composable +private fun GitGraphList( + graph: List, + graphBusy: Boolean, + remoteNames: Set, + hasMore: Boolean, + modifier: Modifier, + onOpen: (GitCommitNodeData) -> Unit, + onAction: (GitConfirmation) -> Unit, + viewModel: CodebaseGitViewModel, +) { + val lanes = remember(graph) { GitGraphLayout.assignLanes(graph) } + val graphRows = remember(graph, lanes) { GitGraphEdges.build(graph, lanes) } + val laneCount = remember(graphRows) { GitGraphEdges.laneCount(graphRows).coerceAtLeast(1) } + + if (graph.isEmpty()) { + Box(modifier = modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { + if (graphBusy) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = CodebasePalette.Accent, + ) + } else { + CodebaseEmptyState("No commits.") + } + } + return + } + + LazyColumn(modifier = modifier.fillMaxWidth()) { + itemsIndexed(graph, key = { _, n -> n.hash }) { index, node -> + GitCommitRow( + node = node, + graphRow = graphRows.getOrElse(index) { + GitGraphEdges.Row(lane = 0, isMerge = false, segments = emptyList()) + }, + laneCount = laneCount, + remoteNames = remoteNames, + onClick = { onOpen(node) }, + onAction = onAction, + viewModel = viewModel, + ) + } + if (hasMore) { + item { + Box( + modifier = Modifier.fillMaxWidth().height(CodebaseMetrics.RowHeight), + contentAlignment = Alignment.Center, + ) { + if (graphBusy) { + CircularProgressIndicator( + modifier = Modifier.size(12.dp), + strokeWidth = 1.5.dp, + color = CodebasePalette.Accent, + ) + } else { + GitTextAction("Load more", { viewModel.loadGraph(reset = false) }) + } + } + } + } + } +} + +/** + * One commit row: lane canvas, subject, ref badges, relative date, and - + * only while hovered - the history actions, each behind a confirmation. + * The date and actions share a slot so the subject keeps its width. + */ +@Composable +private fun GitCommitRow( + node: GitCommitNodeData, + graphRow: GitGraphEdges.Row, + laneCount: Int, + remoteNames: Set, + onClick: () -> Unit, + onAction: (GitConfirmation) -> Unit, + viewModel: CodebaseGitViewModel, +) { + CodebaseListRow(onClick = onClick, height = GRAPH_ROW_HEIGHT) { hovered -> + GraphRowCanvas(row = graphRow, laneCount = laneCount) + Spacer(Modifier.width(4.dp)) + CodebaseTooltip( + "${node.shortHash} ${node.subject}\n${node.author}", + modifier = Modifier.weight(1f), + ) { + // Pills between the subject and the author, both of which give up + // width to them: a branch head is the one thing on this row you + // scan a graph FOR, and a long subject must not push it off the + // edge. They were modelled and never drawn - GitRefBadge existed + // but nothing called it, so no commit has ever shown a ref. + val pills = remember(node.refs, remoteNames) { + GitBranchModel.refPills(node.refs, remoteNames) + } + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = node.subject, + fontSize = CodebaseMetrics.PrimaryText, + color = CodebasePalette.Foreground, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false), + ) + pills.take(MAX_REF_BADGES).forEach { pill -> + Spacer(Modifier.width(5.dp)) + GitRefBadge(pill) + } + if (node.author.isNotBlank()) { + Spacer(Modifier.width(6.dp)) + Text( + text = node.author.substringBefore(' '), + fontSize = CodebaseMetrics.MetaText, + color = CodebasePalette.Muted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false), + ) + } + } + } + if (hovered) { + CodebaseIconButton(Icons.Outlined.Difference, "Open commit diff", onClick) + CodebaseIconButton( + Icons.Outlined.Replay, + "Revert commit", + onClick = { + onAction( + GitConfirmation( + title = "Revert commit", + body = "Revert ${node.shortHash} (\"${node.subject}\")? This creates a new commit.", + confirmLabel = "Revert", + action = { viewModel.revert(node.hash) }, + ), + ) + }, + ) + CodebaseIconButton( + Icons.Rounded.ContentCopy, + "Cherry-pick commit", + onClick = { + onAction( + GitConfirmation( + title = "Cherry-pick commit", + body = "Apply ${node.shortHash} (\"${node.subject}\") onto the current branch?", + confirmLabel = "Cherry-pick", + action = { viewModel.cherryPick(node.hash) }, + ), + ) + }, + ) + CodebaseIconButton( + Icons.Rounded.Check, + "Checkout commit", + onClick = { + onAction( + GitConfirmation( + title = "Checkout commit", + body = "Check out ${node.shortHash}? This detaches HEAD from the current branch.", + confirmLabel = "Checkout", + destructive = true, + action = { viewModel.checkout(node.shortHash) }, + ), + ) + }, + ) + } else { + Text( + text = formatRelativeDate(node.date), + fontSize = CodebaseMetrics.MetaText, + color = CodebasePalette.Muted, + maxLines = 1, + modifier = Modifier.padding(end = 6.dp), + ) + } + } +} + +/** + * One ref pill. + * + * A branch HEAD reads as a filled chip because it is the thing the graph is + * scanned for; a tag or a remote mirror reads as an outline, because it is + * information about a commit rather than a place you can be. Colour comes from + * the palette's semantic tokens, so both survive a light theme - a hardcoded + * amber would not. + */ +@Composable +private fun GitRefBadge(pill: GitRefPill) { + val color = when (pill.kind) { + GitRefKind.HEAD -> CodebasePalette.Accent + GitRefKind.LOCAL -> CodebasePalette.Warn + GitRefKind.REMOTE -> CodebasePalette.Data + GitRefKind.TAG -> CodebasePalette.Secondary + } + val filled = pill.kind == GitRefKind.HEAD || pill.kind == GitRefKind.LOCAL + val content = if (filled) CodebasePalette.onAccent(color) else color + val glyph = when (pill.kind) { + GitRefKind.HEAD, GitRefKind.LOCAL -> Icons.Rounded.AccountTree + GitRefKind.REMOTE -> Icons.Rounded.CloudQueue + GitRefKind.TAG -> Icons.Rounded.LocalOffer + } + val shape = RoundedCornerShape(percent = 50) + Row( + modifier = Modifier + .widthIn(max = REF_BADGE_MAX_WIDTH) + // An EXPLICIT height, not one that falls out of the font. + // + // Without it the pill measured `padding + the label's full line + // box`, and an untrimmed 10sp line box is ~13-14dp of ascent and + // descent around ~7dp of glyph - so the chip came out very nearly + // as tall as the 26dp row and read as a heavy badge rather than a + // chip sitting inside it. Stating the height makes the proportion + // arithmetic (REF_BADGE_HEIGHT vs GRAPH_ROW_HEIGHT) instead of a + // font metric nobody can predict, and GitRefBadgeMetricsTest pins it. + .height(REF_BADGE_HEIGHT) + .clip(shape) + .then( + if (filled) Modifier.background(color) + else Modifier.border(1.dp, color.copy(alpha = 0.55f), shape) + ) + .padding(start = 3.dp, end = 5.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = glyph, + contentDescription = null, + modifier = Modifier.size(9.dp), + tint = content, + ) + Spacer(Modifier.width(2.dp)) + Text( + text = pill.label, + // Trimmed to the glyphs, like CodebaseCountBadge: a bare Text + // centres its LINE BOX, whose ascent/descent padding is + // asymmetric, so the label sat high in the pill AND forced it + // taller than the text needs. + style = TextStyle( + fontSize = REF_BADGE_TEXT, + lineHeight = REF_BADGE_TEXT, + fontWeight = FontWeight.Medium, + color = content, + lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = LineHeightStyle.Trim.Both, + ), + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +/** + * The GRAPH header's controls: which branch the graph shows, the way back to + * the checked-out one, and the three remote verbs. + * + * The picker is a chip rather than a full-width dropdown row because it shares + * a 22dp section header with four icon buttons and a count badge. It is + * unweighted and width-capped, so it is measured before the "GRAPH" title + * (which carries the weight) - the controls keep their size and the title + * truncates, rather than the controls being pushed off the panel's edge. + */ +@Composable +private fun GitGraphHeaderActions( + displayedRef: String, + onCurrentBranch: Boolean, + options: List, + busy: Boolean, + onSelect: (String) -> Unit, + onBackToCurrent: () -> Unit, + onFetch: () -> Unit, + onPull: () -> Unit, + onPush: () -> Unit, + onRefresh: () -> Unit, +) { + GitBranchChip( + label = displayedRef, + onCurrentBranch = onCurrentBranch, + options = options, + onSelect = onSelect, + ) + // Only while the graph is off HEAD: an always-present "back" that is + // already where it would take you is a control that does nothing. + if (!onCurrentBranch) { + CodebaseIconButton( + Icons.Rounded.MyLocation, + "Back to the current branch", + onBackToCurrent, + tint = CodebasePalette.Accent, + ) + } + CodebaseIconButton(Icons.Rounded.CloudDownload, "Fetch from remote", onFetch, enabled = !busy) + CodebaseIconButton(Icons.Rounded.ArrowDownward, "Pull", onPull, enabled = !busy) + // Push is the only action here whose effect leaves this machine, so it + // goes through GitConfirmDialog like the destructive row actions do. + CodebaseIconButton(Icons.Rounded.ArrowUpward, "Push", onPush, enabled = !busy) + CodebaseIconButton(Icons.Outlined.Refresh, "Reload graph", onRefresh) +} + +/** The branch selector: a truncating chip that opens the branch list. */ +@Composable +private fun GitBranchChip( + label: String, + onCurrentBranch: Boolean, + options: List, + onSelect: (String) -> Unit, +) { + var open by remember { mutableStateOf(false) } + Box { + Row( + modifier = Modifier + .widthIn(max = BRANCH_CHIP_MAX_WIDTH) + .wrapContentWidth() + .clip(RoundedCornerShape(CodebaseMetrics.ChipRadius)) + .background(if (onCurrentBranch) Color.Transparent else CodebasePalette.Selected) + .clickable { open = true } + .padding(horizontal = 4.dp, vertical = 1.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Rounded.AccountTree, + contentDescription = null, + modifier = Modifier.size(11.dp), + // Accent while the graph is showing something other than the + // checked-out branch: that is a state you can forget you are in. + tint = if (onCurrentBranch) CodebasePalette.Secondary else CodebasePalette.Accent, + ) + Spacer(Modifier.width(3.dp)) + Text( + text = label.ifBlank { "HEAD" }, + fontSize = CodebaseMetrics.MetaText, + fontWeight = FontWeight.Medium, + color = if (onCurrentBranch) CodebasePalette.Foreground else CodebasePalette.Accent, + maxLines = 1, + overflow = TextOverflow.MiddleEllipsis, + modifier = Modifier.weight(1f, fill = false), + ) + Icon( + imageVector = Icons.Rounded.KeyboardArrowDown, + contentDescription = "Choose a branch", + modifier = Modifier.size(12.dp), + tint = CodebasePalette.Secondary, + ) + } + DropdownMenu( + expanded = open, + onDismissRequest = { open = false }, + modifier = Modifier + .background(CodebasePalette.Raised) + .heightIn(max = 300.dp), + ) { + if (options.isEmpty()) { + Text( + text = "No branches found.", + fontSize = CodebaseMetrics.MetaText, + color = CodebasePalette.Muted, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), + ) + } + options.forEach { option -> + val selected = option.name == label + DropdownMenuItem( + onClick = { + onSelect(option.name) + open = false + }, + contentPadding = PaddingValues(horizontal = 10.dp), + modifier = Modifier.height(24.dp), + ) { + Icon( + imageVector = + if (option.isRemote) Icons.Rounded.CloudQueue else Icons.Rounded.AccountTree, + contentDescription = null, + modifier = Modifier.size(12.dp), + tint = if (option.isCurrent) CodebasePalette.Accent else CodebasePalette.Secondary, + ) + Spacer(Modifier.width(7.dp)) + Text( + text = option.name, + fontSize = CodebaseMetrics.SecondaryText, + color = if (selected) CodebasePalette.Foreground else CodebasePalette.Secondary, + maxLines = 1, + overflow = TextOverflow.MiddleEllipsis, + modifier = Modifier.weight(1f), + ) + if (option.isCurrent) { + Spacer(Modifier.width(6.dp)) + Text( + text = "current", + fontSize = 9.sp, + color = CodebasePalette.Muted, + maxLines = 1, + ) + } + if (selected) { + Spacer(Modifier.width(6.dp)) + Icon( + Icons.Rounded.Check, + contentDescription = "Shown in the graph", + modifier = Modifier.size(13.dp), + tint = CodebasePalette.Accent, + ) + } + } + } + } + } +} + +/** The operation result line; dismissible, so a stale message never sticks. */ +@Composable +private fun GitStatusLine(message: String?, busy: Boolean, onDismiss: () -> Unit) { + if (message.isNullOrBlank()) return + val failed = message.startsWith("Failed:") + Row( + modifier = Modifier + .fillMaxWidth() + .background(if (failed) CodebasePalette.Error.copy(alpha = 0.14f) else CodebasePalette.Hover) + .padding(start = CodebaseMetrics.Gutter, end = 2.dp, top = 3.dp, bottom = 3.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = message, + fontSize = CodebaseMetrics.MetaText, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + color = if (failed) CodebasePalette.Error else CodebasePalette.Secondary, + modifier = Modifier.weight(1f), + ) + if (busy) { + CircularProgressIndicator( + modifier = Modifier.size(10.dp), + strokeWidth = 1.5.dp, + color = CodebasePalette.Accent, + ) + Spacer(Modifier.width(6.dp)) + } + CodebaseIconButton(Icons.Rounded.Close, "Dismiss", onDismiss) + } +} + +/** A pending destructive/history operation awaiting confirmation. */ +internal data class GitConfirmation( + val title: String, + val body: String, + val confirmLabel: String, + val destructive: Boolean = false, + val action: () -> Unit, +) + +/** + * Confirmation sheet. Drawn in-panel rather than as a Material `AlertDialog`: + * the host's browser surface renders above plugin dialogs, so a dialog can + * end up behind the content it is asking about. + */ +@Composable +private fun GitConfirmDialog( + confirmation: GitConfirmation, + onDismiss: () -> Unit, + onConfirm: () -> Unit, +) { + Box( + modifier = Modifier + .fillMaxSize() + .background(CodebaseScrim) + .onPreviewKeyEvent { e -> + if (e.type == KeyEventType.KeyDown && e.key == Key.Escape) { + onDismiss() + true + } else { + false + } + }, + contentAlignment = Alignment.Center, + ) { + Column( + modifier = Modifier + .padding(horizontal = 12.dp) + .clip(RoundedCornerShape(CodebaseMetrics.ButtonRadius)) + .background(CodebasePalette.Raised) + .padding(14.dp), + ) { + Text( + text = confirmation.title, + fontSize = CodebaseMetrics.PrimaryText, + fontWeight = FontWeight.SemiBold, + color = CodebasePalette.Foreground, + ) + Spacer(Modifier.height(6.dp)) + Text( + text = confirmation.body, + fontSize = CodebaseMetrics.SecondaryText, + color = CodebasePalette.Secondary, + ) + Spacer(Modifier.height(12.dp)) + Row(horizontalArrangement = Arrangement.End, modifier = Modifier.fillMaxWidth()) { + GitTextAction("Cancel", onDismiss) + Spacer(Modifier.width(10.dp)) + Text( + text = confirmation.confirmLabel, + fontSize = CodebaseMetrics.SecondaryText, + fontWeight = FontWeight.SemiBold, + color = if (confirmation.destructive) CodebasePalette.Error else CodebasePalette.Accent, + modifier = Modifier + .clip(RoundedCornerShape(CodebaseMetrics.ChipRadius)) + .clickable(onClick = onConfirm) + .padding(horizontal = 6.dp, vertical = 3.dp), + ) + } + } + } +} + +private fun commitShortcutHint(): String = + if (System.getProperty("os.name").lowercase().contains("mac")) "⌘Enter" else "Ctrl+Enter" + +/** + * Relative age, the way a git UI shows it - "3h", "2d", "5mo". An absolute + * date needs ~11 characters the panel does not have; the tooltip carries the + * full commit detail. + */ +internal fun formatRelativeDate(epochSeconds: Long): String { + if (epochSeconds <= 0) return "" + val deltaSec = (System.currentTimeMillis() / 1000L) - epochSeconds + if (deltaSec < 0) return "now" + val minutes = TimeUnit.SECONDS.toMinutes(deltaSec) + val hours = TimeUnit.SECONDS.toHours(deltaSec) + val days = TimeUnit.SECONDS.toDays(deltaSec) + return when { + minutes < 1 -> "now" + minutes < 60 -> "${minutes}m" + hours < 24 -> "${hours}h" + days < 7 -> "${days}d" + days < 30 -> "${days / 7}w" + days < 365 -> "${days / 30}mo" + else -> "${days / 365}y" + } +} + +/** Absolute commit date, for tooltips and tests. */ +internal fun formatCommitDate(epochSeconds: Long): String { + if (epochSeconds <= 0) return "" + return try { + SimpleDateFormat("MMM d, yyyy", Locale.US).format(Date(epochSeconds * 1000L)) + } catch (e: Exception) { + "" + } +} + +/** One commit row. Fixed, so no child of the row can stretch it. */ +internal val GRAPH_ROW_HEIGHT = 26.dp +private val GRAPH_LANE_WIDTH = 13.dp +private const val GRAPH_MAX_LANES = 6 + +/** Lane hue by index, cycling BOSS's status palette. */ +internal fun laneColor(lane: Int): Color { + val palette = CodebasePalette.laneColors + return palette[lane % palette.size] +} + +/** + * One row of the lane graph. Each row owns its own canvas, so a LazyColumn + * can recycle rows without spanning rows: + * - the top segment of lane L is drawn when the row above sat in L, + * - the bottom segment of lane L when the row below sits in L, + * - when this row's lane differs from the row below's, an elbow at the row + * mid connects the two lanes (and the bottom segment follows the new lane). + * + * Every coordinate goes through `toPx()`. DrawScope works in pixels, so the + * previous `GRAPH_LANE_WIDTH_DP.toFloat()` drew the whole graph at 1/density + * scale - on a Retina panel the lanes bunched into the left quarter of a + * canvas twice as wide, with hairline strokes. + */ +@Composable +private fun GraphRowCanvas( + row: GitGraphEdges.Row, + laneCount: Int, +) { + val drawnLanes = laneCount.coerceIn(1, GRAPH_MAX_LANES) + val palette = CodebasePalette.laneColors + Canvas( + modifier = Modifier + .width(GRAPH_LANE_WIDTH * drawnLanes + 6.dp) + .height(GRAPH_ROW_HEIGHT), + ) { + val laneW = GRAPH_LANE_WIDTH.toPx() + val inset = 3.dp.toPx() + val stroke = 2.dp.toPx() + val radius = 4.dp.toPx() + val h = size.height + val midY = h / 2f + fun cx(lane: Int): Float = (lane.coerceIn(0, drawnLanes - 1) + 0.5f) * laneW + inset + fun color(lane: Int): Color = palette[lane.coerceAtLeast(0) % palette.size] + + for (seg in row.segments) { + val c = color(seg.colorLane) + val x1 = cx(seg.fromLane) + val x2 = cx(seg.toLane) + val yTop = if (seg.fromNode) midY else 0f + val yBottom = if (seg.toNode) midY else h + if (seg.fromLane == seg.toLane) { + drawLine(c, Offset(x1, yTop), Offset(x2, yBottom), stroke) + } else { + // A branch changing lanes reads as a curve, not an elbow: the + // control points sit half a row apart so the bend stays inside + // this row and meets the next one vertically. + val path = androidx.compose.ui.graphics.Path().apply { + moveTo(x1, yTop) + cubicTo(x1, yTop + (yBottom - yTop) * 0.55f, x2, yBottom - (yBottom - yTop) * 0.45f, x2, yBottom) + } + drawPath(path, c, style = androidx.compose.ui.graphics.drawscope.Stroke(width = stroke)) + } + } + + val nodeX = cx(row.lane) + val nodeColor = color(row.lane) + if (row.isMerge) { + // A merge reads as a ring, so a commit that joins history is + // distinguishable from one that just continues it. + drawCircle(nodeColor, radius = radius, center = Offset(nodeX, midY), style = androidx.compose.ui.graphics.drawscope.Stroke(width = stroke)) + drawCircle(nodeColor, radius = radius * 0.35f, center = Offset(nodeX, midY)) + } else { + drawCircle(nodeColor, radius = radius, center = Offset(nodeX, midY)) + } + } +} + +internal fun statusGlyph(type: GitFileStatusTypeData?): String = + when (type) { + null -> "?" + GitFileStatusTypeData.MODIFIED -> "M" + GitFileStatusTypeData.ADDED -> "A" + GitFileStatusTypeData.DELETED -> "D" + GitFileStatusTypeData.RENAMED -> "R" + GitFileStatusTypeData.COPIED -> "C" + GitFileStatusTypeData.UNTRACKED -> "U" + GitFileStatusTypeData.IGNORED -> "I" + GitFileStatusTypeData.UNMERGED -> "!" + } + +/** VS Code's `gitDecoration.*` colours, so the letters read the same way. */ +@Composable +internal fun statusColor(type: GitFileStatusTypeData?): Color = + when (type) { + null -> CodebasePalette.Muted + GitFileStatusTypeData.MODIFIED, GitFileStatusTypeData.RENAMED, GitFileStatusTypeData.COPIED -> + CodebasePalette.Modified + GitFileStatusTypeData.ADDED -> CodebasePalette.Added + GitFileStatusTypeData.UNTRACKED -> CodebasePalette.Untracked + GitFileStatusTypeData.DELETED -> CodebasePalette.Deleted + GitFileStatusTypeData.UNMERGED -> CodebasePalette.Conflict + GitFileStatusTypeData.IGNORED -> CodebasePalette.Muted + } + +/** Width of the review options popup. */ +private val REVIEW_MENU_WIDTH = 260.dp + +/** A ref pill shortens rather than crowding the commit subject out of its row. */ +private val REF_BADGE_MAX_WIDTH = 120.dp + +/** + * Height of a ref pill - deliberately well under [GRAPH_ROW_HEIGHT]. + * + * A chip inside the row, not a band across it: the leftover 10dp is the + * breathing room above and below, which only exists because the pill is + * shorter than the row it is centred in. + */ +internal val REF_BADGE_HEIGHT = 16.dp + +/** + * Label size on a ref pill - well under [CodebaseMetrics.PrimaryText] (13sp), + * so the branch name reads as an annotation on the subject rather than as a + * second subject competing with it. + */ +internal val REF_BADGE_TEXT = 9.sp + +/** + * Pills drawn on one commit before the rest are dropped. + * + * A release commit can carry a dozen refs; past two the row is pills and no + * subject, which is the opposite of useful. The tooltip still names the commit. + */ +private const val MAX_REF_BADGES = 2 + +/** The header's branch chip truncates rather than pushing the icons off the panel. */ +private val BRANCH_CHIP_MAX_WIDTH = 116.dp diff --git a/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/SearchTab.kt b/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/SearchTab.kt new file mode 100644 index 0000000..5ba497e --- /dev/null +++ b/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/SearchTab.kt @@ -0,0 +1,950 @@ +package ai.rever.boss.plugin.dynamic.codebase + +import ai.rever.boss.plugin.api.FileMatch +import ai.rever.boss.plugin.api.ProjectSearchProvider +import ai.rever.boss.plugin.api.ReplaceSummary +import ai.rever.boss.plugin.api.SplitViewOperations +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.Icon +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.FindReplace +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material.icons.rounded.KeyboardArrowDown +import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight +import androidx.compose.material.icons.rounded.FilterAlt +import androidx.compose.material.icons.rounded.FilterAltOff +import androidx.compose.material.icons.rounded.MoreHoriz +import androidx.compose.material.icons.rounded.Search +import androidx.compose.material.icons.rounded.UnfoldLess +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch + +/** + * The SEARCH tab of the codebase panel (P7) - global search & replace. + * + * The engine is host-side ([ProjectSearchProvider]); a null provider means + * the host predates it, which renders as an upgrade hint - never a crash. + * + * Every input is a StateFlow here rather than a plain `var` shadowed by a + * `remember` in the composable. With two copies of the query the toggles + * drifted: flipping Aa / .* / ab changed the view model's flags and left the + * results from the previous flags on screen, because nothing re-ran the + * search. Each setter that changes the result set now schedules one. + */ +class CodebaseSearchViewModel( + private val provider: ProjectSearchProvider?, + private val splitViewOperations: SplitViewOperations?, + private val getProjectPath: () -> String? = { null }, +) { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + val providerOrNull: ProjectSearchProvider? get() = provider + + private val _query = MutableStateFlow("") + val query: StateFlow = _query.asStateFlow() + + private val _pathPattern = MutableStateFlow("") + val pathPattern: StateFlow = _pathPattern.asStateFlow() + + private val _excludePattern = MutableStateFlow("") + val excludePattern: StateFlow = _excludePattern.asStateFlow() + + private val _replacement = MutableStateFlow("") + val replacement: StateFlow = _replacement.asStateFlow() + + private val _isRegex = MutableStateFlow(false) + val isRegex: StateFlow = _isRegex.asStateFlow() + private val _caseSensitive = MutableStateFlow(false) + val caseSensitive: StateFlow = _caseSensitive.asStateFlow() + private val _wholeWord = MutableStateFlow(false) + val wholeWord: StateFlow = _wholeWord.asStateFlow() + + private val _results = MutableStateFlow>(emptyList()) + val results: StateFlow> = _results.asStateFlow() + + /** Grouped by file, path-sorted - the shape the results tree renders. */ + private val _grouped = MutableStateFlow>(emptyList()) + val grouped: StateFlow> = _grouped.asStateFlow() + + private val _searched = MutableStateFlow(false) + val searched: StateFlow = _searched.asStateFlow() + + private val _busy = MutableStateFlow(false) + val busy: StateFlow = _busy.asStateFlow() + + private val _message = MutableStateFlow(null) + val message: StateFlow = _message.asStateFlow() + + private val _dryRun = MutableStateFlow(null) + val dryRun: StateFlow = _dryRun.asStateFlow() + + /** True when the result set was truncated at [MAX_RESULTS]. */ + private val _capped = MutableStateFlow(false) + val capped: StateFlow = _capped.asStateFlow() + + private var searchJob: Job? = null + private var refreshTimer: Job? = null + + fun setQuery(value: String) { + _query.value = value + _dryRun.value = null + scheduleSearch() + } + + fun setPathPattern(value: String) { + _pathPattern.value = value + scheduleSearch() + } + + fun setExcludePattern(value: String) { + _excludePattern.value = value + scheduleSearch() + } + + fun setReplacement(value: String) { + _replacement.value = value + _dryRun.value = null + } + + fun setSearchOption(option: SearchToggle, value: Boolean) { + when (option) { + SearchToggle.REGEX -> _isRegex.value = value + SearchToggle.CASE -> _caseSensitive.value = value + SearchToggle.WORD -> _wholeWord.value = value + } + _dryRun.value = null + // A flag change changes the result set; without this the toggles left + // the previous flags' results on screen. + scheduleSearch() + } + + /** Debounced re-run, so typing does not queue one scan per keystroke. */ + private fun scheduleSearch() { + searchJob?.cancel() + searchJob = + scope.launch { + delay(DEBOUNCE_MS) + execute() + } + } + + /** Run now (the Enter key / the search icon) with no debounce. */ + fun runSearch() { + searchJob?.cancel() + searchJob = scope.launch { execute() } + } + + /** + * Keep the results honest while the tab is open. + * + * A results tree that still lists matches the file no longer contains is + * worse than no results - after a replace, or any edit elsewhere, it + * reports work that is already done. The provider caches each file's + * matches against its mtime (or its buffer version when the file is open), + * so a re-scan with nothing changed is a directory walk and a stat per + * file, and an unchanged result set is dropped by StateFlow equality + * rather than reshuffling the tree. + */ + fun startAutoRefresh() { + refreshTimer?.cancel() + refreshTimer = + scope.launch { + while (isActive) { + delay(REFRESH_MS) + if (_query.value.isNotBlank() && !_busy.value && searchJob?.isActive != true) { + execute() + } + } + } + } + + fun stopAutoRefresh() { + refreshTimer?.cancel() + refreshTimer = null + } + + private suspend fun execute() { + val q = _query.value + if (q.isBlank()) { + _results.value = emptyList() + _grouped.value = emptyList() + _searched.value = false + _message.value = null + _capped.value = false + return + } + _busy.value = true + _message.value = null + try { + // Exclusion is the engine's, not ours. It used to be a filter over the + // returned list, with this plugin carrying its own copy of the glob + // compiler - which meant the provider's cap was reached BEFORE the + // exclude ran, so excluding a busy directory quietly returned fewer + // results than existed. Hence the doubled page size that used to be + // here, which softened the symptom and did not fix it. + val matches = + provider?.searchInProject( + query = q, + pathPattern = _pathPattern.value.ifBlank { null }, + excludePattern = _excludePattern.value.ifBlank { null }, + isRegex = _isRegex.value, + caseSensitive = _caseSensitive.value, + wholeWord = _wholeWord.value, + maxResults = MAX_RESULTS, + ) ?: emptyList() + _results.value = matches + _grouped.value = groupMatchesByFile(matches) + // Now an honest cap: the engine excluded during the walk, so hitting + // MAX_RESULTS means there really are more matches the user wants, not + // that the page was spent on files about to be filtered out. + _capped.value = matches.size >= MAX_RESULTS + _searched.value = true + } catch (e: Exception) { + // An in-progress regex ("(", "[a-") throws on every keystroke; + // report it in the status line instead of clearing the results. + _message.value = e.message ?: "Search failed" + } finally { + _busy.value = false + } + } + + fun cancelSearch() { + searchJob?.cancel() + _busy.value = false + } + + /** + * Open one match at its own line and column. + * + * The previous implementation handed [SplitViewOperations.openTab] an + * anonymous [ai.rever.boss.plugin.api.TabInfo] carrying the path in an + * extra property. `openTab` has an empty default body and no way to read + * that property, so clicking a result did nothing at all. + */ + fun openMatch(match: FileMatch) { + val ops = splitViewOperations ?: return + val name = PathUtils.name(match.path).ifEmpty { match.path } + ops.openFileAtPosition(absolutePathOf(match.path), name, match.line, match.column) + } + + fun openFile(path: String) { + val ops = splitViewOperations ?: return + val name = PathUtils.name(path).ifEmpty { path } + ops.openFileInEditor(absolutePathOf(path), name) + } + + /** + * [FileMatch.path] is project-relative - the host's engine reports + * `file.relativeTo(projectRoot)`. The editor opens absolute paths, so + * handing it the relative one produced "file not found" on every result + * click. [ProjectSearchProvider.replaceInProject] takes either, which is + * why replace worked while opening did not. + */ + internal fun absolutePathOf(path: String): String { + if (path.startsWith("/") || path.matches(WINDOWS_ABSOLUTE)) return path + val root = getProjectPath()?.trimEnd('/') ?: return path + return if (root.isEmpty()) path else "$root/$path" + } + + /** Dry-run first, so the UI can show what will change before writing. */ + fun previewReplacement() { + if (_query.value.isBlank() || _results.value.isEmpty()) return + scope.launch { + _busy.value = true + try { + _dryRun.value = + provider?.replaceInProject( + query = _query.value, + replacement = _replacement.value, + files = matchedFiles(), + isRegex = _isRegex.value, + caseSensitive = _caseSensitive.value, + wholeWord = _wholeWord.value, + dryRun = true, + ) + } catch (e: Exception) { + _message.value = e.message ?: "Preview failed" + } finally { + _busy.value = false + } + } + } + + fun applyReplacement() { + val summary = _dryRun.value ?: return + scope.launch { + _busy.value = true + try { + val applied = + provider?.replaceInProject( + query = _query.value, + replacement = _replacement.value, + files = summary.files.map { it.path }, + isRegex = _isRegex.value, + caseSensitive = _caseSensitive.value, + wholeWord = _wholeWord.value, + dryRun = false, + ) + _message.value = describeReplacement(applied) + _dryRun.value = null + } catch (e: Exception) { + _message.value = e.message ?: "Replacement failed" + } finally { + _busy.value = false + } + // The files on disk changed; re-scan so the tree matches them. + execute() + } + } + + fun clear() { + searchJob?.cancel() + _query.value = "" + _results.value = emptyList() + _grouped.value = emptyList() + _searched.value = false + _message.value = null + _dryRun.value = null + _capped.value = false + } + + fun dismissPreview() { + _dryRun.value = null + } + + private fun matchedFiles(): List = _results.value.map { it.path }.distinct() + + /** + * What actually happened, per file when it went wrong. + * + * [ReplaceSummary.files] carries a reason for every file that was skipped, + * and reporting only the totals hid it: a replace into an open buffer on a + * host whose editor plugin predates `applyEdit` comes back as + * `reason = "unsupported"`, which read as "Replaced 0 in 0 file(s)" - or, + * worse, as an unexplained version complaint. + */ + internal fun describeReplacement(applied: ReplaceSummary?): String { + if (applied == null) return "Replacement failed: the host has no search provider." + val failures = applied.files.filter { !it.error.isNullOrBlank() } + val done = + "Replaced ${applied.totalReplacements} in ${applied.filesReplaced} file(s)" + if (failures.isEmpty()) return done + val first = failures.first() + val name = PathUtils.name(first.path).ifEmpty { first.path } + val tail = if (failures.size > 1) " (+${failures.size - 1} more)" else "" + val prefix = if (applied.totalReplacements > 0) "$done. " else "" + return "${prefix}Failed: $name - ${first.error}$tail" + } + + fun dispose() { + searchJob?.cancel() + refreshTimer?.cancel() + scope.cancel() + } + + private companion object { + const val MAX_RESULTS = 500 + const val DEBOUNCE_MS = 250L + + /** How soon a change on disk or in a buffer shows up in the results. */ + const val REFRESH_MS = 2_500L + + /** `C:\...` / `C:/...` - an absolute path needs no project root. */ + val WINDOWS_ABSOLUTE = Regex("""^[A-Za-z]:[\\/].*""") + } +} + +enum class SearchToggle { REGEX, CASE, WORD } + +/** One file's matches; [path] is project-relative, as the provider returns it. */ +data class FileMatches(val path: String, val matches: List) + +/** Grouped and path-sorted, so the tree does not reshuffle between scans. */ +internal fun groupMatchesByFile(results: List): List = + results + .groupBy { it.path } + .map { (path, matches) -> FileMatches(path, matches.sortedWith(compareBy({ it.line }, { it.column }))) } + .sortedBy { it.path } + +/** + * SEARCH tab body - Cursor's search view on BOSS's palette. + * + * A single 26dp search field carries its own icon and the three match + * toggles; replace and the include-glob are collapsed behind a twisty and a + * `…` the way VS Code hides them, so the default state is one input and a + * full-height result tree. Three stacked `OutlinedTextField`s previously ate + * 168dp of a sidebar before the first result could appear. + */ +@Composable +fun CodebaseSearchContent( + viewModel: CodebaseSearchViewModel, + modifier: Modifier = Modifier, +) { + if (viewModel.providerOrNull == null) { + CodebaseEmptyState("Search is unavailable on this host build.", modifier.fillMaxSize()) + return + } + + val query by viewModel.query.collectAsState() + val glob by viewModel.pathPattern.collectAsState() + val exclude by viewModel.excludePattern.collectAsState() + val replacement by viewModel.replacement.collectAsState() + val isRegex by viewModel.isRegex.collectAsState() + val caseSensitive by viewModel.caseSensitive.collectAsState() + val wholeWord by viewModel.wholeWord.collectAsState() + val results by viewModel.results.collectAsState() + val grouped by viewModel.grouped.collectAsState() + val searched by viewModel.searched.collectAsState() + val busy by viewModel.busy.collectAsState() + val message by viewModel.message.collectAsState() + val dryRun by viewModel.dryRun.collectAsState() + val capped by viewModel.capped.collectAsState() + + var replaceOpen by remember { mutableStateOf(false) } + var filtersOpen by remember { mutableStateOf(glob.isNotEmpty() || exclude.isNotEmpty()) } + // Collapse state per path, plus a generation counter so "collapse all" + // can reset every row without tracking each one. + var collapseGeneration by remember { mutableStateOf(0) } + var allCollapsed by remember { mutableStateOf(false) } + + // Poll only while the tab is on screen. + DisposableEffect(viewModel) { + viewModel.startAutoRefresh() + onDispose { viewModel.stopAutoRefresh() } + } + + val searchKeys = Modifier.onPreviewKeyEvent { e -> + when { + e.type != KeyEventType.KeyDown -> false + e.key == Key.Enter -> { + viewModel.runSearch() + true + } + e.key == Key.Escape -> { + viewModel.clear() + true + } + else -> false + } + } + + // Box, not a bare Column: the confirmation sheet is a sibling of the body + // and has to sit *on top* of it. Emitted after a Column it became a third + // child of the parent layout instead - measured at zero height, so + // Replace All ran its dry run and then appeared to do nothing at all. + Box(modifier = modifier.fillMaxSize()) { + Column(modifier = Modifier.fillMaxSize()) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 2.dp, end = CodebaseMetrics.Gutter, top = 6.dp, bottom = 4.dp), + verticalAlignment = Alignment.Top, + ) { + // The twisty that reveals the replace field, as in VS Code. + Box(modifier = Modifier.size(CodebaseMetrics.InputHeight)) { + CodebaseIconButton( + icon = if (replaceOpen) Icons.Rounded.KeyboardArrowDown else Icons.AutoMirrored.Rounded.KeyboardArrowRight, + tooltip = if (replaceOpen) "Hide Replace" else "Toggle Replace", + onClick = { replaceOpen = !replaceOpen }, + ) + } + Column(modifier = Modifier.weight(1f)) { + CodebaseTextField( + value = query, + onValueChange = viewModel::setQuery, + placeholder = "Search", + modifier = Modifier.fillMaxWidth(), + keyHandler = searchKeys, + leading = { + Icon( + Icons.Rounded.Search, + contentDescription = null, + modifier = Modifier.size(CodebaseMetrics.Glyph), + tint = CodebasePalette.Muted, + ) + }, + trailing = { + CodebaseToggleChip("Aa", "Match case", caseSensitive) { + viewModel.setSearchOption(SearchToggle.CASE, !caseSensitive) + } + CodebaseToggleChip("ab", "Match whole word", wholeWord) { + viewModel.setSearchOption(SearchToggle.WORD, !wholeWord) + } + CodebaseToggleChip(".*", "Use regular expression", isRegex) { + viewModel.setSearchOption(SearchToggle.REGEX, !isRegex) + } + }, + ) + if (replaceOpen) { + Spacer(Modifier.height(4.dp)) + CodebaseTextField( + value = replacement, + onValueChange = viewModel::setReplacement, + placeholder = if (isRegex) "Replace (\$1 for groups)" else "Replace", + modifier = Modifier.fillMaxWidth(), + leading = { + Icon( + Icons.Outlined.FindReplace, + contentDescription = null, + modifier = Modifier.size(CodebaseMetrics.Glyph), + tint = CodebasePalette.Muted, + ) + }, + trailing = { + CodebaseIconButton( + icon = Icons.Outlined.FindReplace, + tooltip = "Replace all", + onClick = { viewModel.previewReplacement() }, + enabled = results.isNotEmpty() && !busy, + ) + }, + ) + } + if (filtersOpen) { + Spacer(Modifier.height(4.dp)) + CodebaseTextField( + value = glob, + onValueChange = viewModel::setPathPattern, + placeholder = "Files to include, e.g. *.kt, src", + modifier = Modifier.fillMaxWidth(), + monospace = true, + leading = { + Icon( + Icons.Rounded.FilterAlt, + contentDescription = null, + modifier = Modifier.size(CodebaseMetrics.Glyph), + tint = CodebasePalette.Muted, + ) + }, + ) + Spacer(Modifier.height(4.dp)) + CodebaseTextField( + value = exclude, + onValueChange = viewModel::setExcludePattern, + placeholder = "Files to exclude, e.g. build, *.min.js", + modifier = Modifier.fillMaxWidth(), + monospace = true, + leading = { + Icon( + Icons.Rounded.FilterAltOff, + contentDescription = null, + modifier = Modifier.size(CodebaseMetrics.Glyph), + tint = CodebasePalette.Muted, + ) + }, + ) + } + } + Box(modifier = Modifier.size(CodebaseMetrics.InputHeight)) { + val filtersActive = glob.isNotEmpty() || exclude.isNotEmpty() + CodebaseIconButton( + icon = Icons.Rounded.MoreHoriz, + tooltip = "Toggle files to include / exclude", + onClick = { filtersOpen = !filtersOpen }, + // Tinted while a filter is narrowing the results, so a + // collapsed row never silently hides why a hit is missing. + tint = when { + filtersActive -> CodebasePalette.Accent + filtersOpen -> CodebasePalette.Foreground + else -> null + }, + ) + } + } + + SearchSummaryRow( + matchCount = results.size, + fileCount = grouped.size, + searched = searched, + busy = busy, + capped = capped, + message = message, + allCollapsed = allCollapsed, + onCollapseAll = { + allCollapsed = !allCollapsed + collapseGeneration++ + }, + onClear = { + viewModel.clear() + allCollapsed = false + }, + ) + CodebaseHRule() + + when { + !searched && !busy -> + CodebaseEmptyState( + "Search across the project.", + Modifier.weight(1f), + icon = Icons.Rounded.Search, + ) + + searched && results.isEmpty() && !busy -> + CodebaseEmptyState( + "No results found.", + Modifier.weight(1f), + ) + + else -> + LazyColumn( + modifier = Modifier.weight(1f).fillMaxWidth(), + contentPadding = PaddingValues(vertical = 2.dp), + ) { + grouped.forEach { entry -> + searchFileGroup( + entry = entry, + expandedInitially = !allCollapsed, + generation = collapseGeneration, + onOpenFile = { viewModel.openFile(entry.path) }, + onOpenMatch = viewModel::openMatch, + ) + } + } + } + } + + dryRun?.let { summary -> + ConfirmReplaceSheet( + summary = summary, + busy = busy, + onDismiss = { viewModel.dismissPreview() }, + onApply = { viewModel.applyReplacement() }, + ) + } + } +} + +/** "12 results in 4 files", collapse-all, clear - VS Code's message row. */ +@Composable +private fun SearchSummaryRow( + matchCount: Int, + fileCount: Int, + searched: Boolean, + busy: Boolean, + capped: Boolean, + message: String?, + allCollapsed: Boolean, + onCollapseAll: () -> Unit, + onClear: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(CodebaseMetrics.RowHeight) + .padding(start = CodebaseMetrics.Gutter, end = 2.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (busy) { + CircularProgressIndicator( + modifier = Modifier.size(10.dp), + strokeWidth = 1.5.dp, + color = CodebasePalette.Accent, + ) + Spacer(Modifier.width(6.dp)) + } + val text = + when { + message != null -> message + !searched -> "" + matchCount == 0 -> "No results" + else -> buildString { + append(matchCount) + append(if (matchCount == 1) " result in " else " results in ") + append(fileCount) + append(if (fileCount == 1) " file" else " files") + if (capped) append(" (capped)") + } + } + Text( + text = text, + fontSize = CodebaseMetrics.MetaText, + color = if (message != null) CodebasePalette.Error else CodebasePalette.Secondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + if (searched) { + CodebaseIconButton( + icon = Icons.Rounded.UnfoldLess, + tooltip = if (allCollapsed) "Expand all" else "Collapse all", + onClick = onCollapseAll, + ) + CodebaseIconButton(Icons.Rounded.Close, "Clear search results", onClear) + } + } +} + +/** One file's group: the file row, then its match rows when expanded. */ +private fun LazyListScope.searchFileGroup( + entry: FileMatches, + expandedInitially: Boolean, + generation: Int, + onOpenFile: () -> Unit, + onOpenMatch: (FileMatch) -> Unit, +) { + item(key = "file-${entry.path}-$generation") { + SearchFileGroupBody( + entry = entry, + expandedInitially = expandedInitially, + onOpenFile = onOpenFile, + onOpenMatch = onOpenMatch, + ) + } +} + +@Composable +private fun SearchFileGroupBody( + entry: FileMatches, + expandedInitially: Boolean, + onOpenFile: () -> Unit, + onOpenMatch: (FileMatch) -> Unit, +) { + var expanded by remember { mutableStateOf(expandedInitially) } + val (name, dir) = remember(entry.path) { splitPathForDisplay(entry.path) } + Column { + CodebaseListRow(onClick = { expanded = !expanded }) { hovered -> + Icon( + imageVector = + if (expanded) Icons.Rounded.KeyboardArrowDown + else Icons.AutoMirrored.Rounded.KeyboardArrowRight, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = CodebasePalette.Secondary, + ) + // One weighted slot for name + directory, so the hover action and + // the count badge keep their place however long the path is. + CodebaseTooltip(entry.path, modifier = Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = name, + fontSize = CodebaseMetrics.PrimaryText, + color = CodebasePalette.Foreground, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false), + ) + if (dir.isNotEmpty()) { + Spacer(Modifier.width(5.dp)) + Text( + text = dir, + fontSize = CodebaseMetrics.MetaText, + color = CodebasePalette.Muted, + maxLines = 1, + overflow = TextOverflow.MiddleEllipsis, + modifier = Modifier.weight(1f, fill = false), + ) + } + } + } + CodebaseIconButton( + icon = Icons.Rounded.Search, + tooltip = "Open file", + onClick = onOpenFile, + visible = hovered, + ) + CodebaseCountBadge(entry.matches.size) + Spacer(Modifier.width(4.dp)) + } + if (!expanded) return@Column + entry.matches.forEach { m -> + CodebaseListRow(onClick = { onOpenMatch(m) }) { _ -> + Spacer(Modifier.width(6.dp)) + Text( + text = m.line.toString(), + fontSize = CodebaseMetrics.MetaText, + fontFamily = FontFamily.Monospace, + color = CodebasePalette.Muted, + textAlign = TextAlign.End, + maxLines = 1, + modifier = Modifier.width(30.dp), + ) + Spacer(Modifier.width(8.dp)) + HighlightedMatchLine( + line = m.contextLine, + matchStart = m.column - 1, + matchLength = m.matchLength, + modifier = Modifier.weight(1f), + ) + } + } + } +} + +/** + * The context line with the hit marked by a background wash rather than a + * colour swap - the way an editor's find-highlight reads, and the only option + * that survives BOSS's light themes, where an accent-coloured glyph on the + * panel ground drops below the text contrast floor. + * + * Long lines are windowed around the match: a 200-column line would otherwise + * push the hit off the right edge of the panel. + */ +@Composable +private fun HighlightedMatchLine( + line: String, + matchStart: Int, + matchLength: Int, + modifier: Modifier = Modifier, +) { + val trimmed = line.trimEnd('\n', '\r') + val start = matchStart.coerceIn(0, trimmed.length) + val end = (start + matchLength).coerceIn(start, trimmed.length) + + // Keep a little context to the left of the hit, drop the rest. + val windowStart = (start - LEAD_CONTEXT).coerceAtLeast(0) + val leadingTrimmed = trimmed.take(windowStart).isNotEmpty() + val before = trimmed.substring(windowStart, start).let { if (leadingTrimmed) "…$it" else it } + val match = trimmed.substring(start, end) + val after = trimmed.substring(end) + + Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) { + if (before.isNotEmpty()) { + Text( + text = before.trimStart(), + fontSize = CodebaseMetrics.SecondaryText, + fontFamily = FontFamily.Monospace, + color = CodebasePalette.Secondary, + maxLines = 1, + softWrap = false, + ) + } + if (match.isNotEmpty()) { + Box( + modifier = Modifier + .clip(RoundedCornerShape(2.dp)) + .background(CodebasePalette.Accent.copy(alpha = 0.32f)), + ) { + Text( + text = match, + fontSize = CodebaseMetrics.SecondaryText, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.SemiBold, + color = CodebasePalette.Foreground, + maxLines = 1, + softWrap = false, + ) + } + } + if (after.isNotEmpty()) { + Text( + text = after, + fontSize = CodebaseMetrics.SecondaryText, + fontFamily = FontFamily.Monospace, + color = CodebasePalette.Secondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + softWrap = false, + modifier = Modifier.weight(1f, fill = false), + ) + } + } +} + +private const val LEAD_CONTEXT = 12 + +/** + * Replace-all confirmation. In-panel rather than a Material `AlertDialog`: + * the host's browser surface draws above plugin dialogs. + */ +@Composable +private fun ConfirmReplaceSheet( + summary: ReplaceSummary, + busy: Boolean, + onDismiss: () -> Unit, + onApply: () -> Unit, +) { + Box( + modifier = Modifier + .fillMaxSize() + .background(CodebaseScrim), + contentAlignment = Alignment.Center, + ) { + Column( + modifier = Modifier + .padding(horizontal = 12.dp) + .clip(RoundedCornerShape(CodebaseMetrics.ButtonRadius)) + .background(CodebasePalette.Raised) + .padding(14.dp), + ) { + Text( + text = "Replace all", + fontSize = CodebaseMetrics.PrimaryText, + fontWeight = FontWeight.SemiBold, + color = CodebasePalette.Foreground, + ) + Spacer(Modifier.height(6.dp)) + Text( + text = "Replace ${summary.totalReplacements} occurrence(s) across " + + "${summary.filesReplaced} file(s)? Open buffers stay undoable; " + + "closed files are written to disk.", + fontSize = CodebaseMetrics.SecondaryText, + color = CodebasePalette.Secondary, + ) + Spacer(Modifier.height(12.dp)) + Row( + horizontalArrangement = Arrangement.End, + modifier = Modifier.fillMaxWidth(), + ) { + CodebasePrimaryButton( + label = "Cancel", + onClick = onDismiss, + enabled = !busy, + modifier = Modifier.width(80.dp), + ) + Spacer(Modifier.width(8.dp)) + CodebasePrimaryButton( + label = "Replace", + onClick = onApply, + enabled = !busy && summary.totalReplacements > 0, + modifier = Modifier.width(90.dp), + ) + } + } + } +} diff --git a/src/main/resources/META-INF/boss-plugin/plugin.json b/src/main/resources/META-INF/boss-plugin/plugin.json index 7185329..73c74ed 100644 --- a/src/main/resources/META-INF/boss-plugin/plugin.json +++ b/src/main/resources/META-INF/boss-plugin/plugin.json @@ -3,12 +3,12 @@ "pluginId": "ai.rever.boss.plugin.dynamic.codebase", "displayName": "Codebase (Dynamic)", "version": "1.0.10", - "apiVersion": "1.0.72", - "minApiVersion": "1.0.72", - "minBossVersion": "9.2.20", + "apiVersion": "1.0.87", + "minApiVersion": "1.0.87", + "minBossVersion": "9.5.7", "mainClass": "ai.rever.boss.plugin.dynamic.codebase.CodebaseDynamicPlugin", "type": "panel", - "description": "Browse and explore project files", + "description": "Files, global search & replace, and git status/log/graph for the current project", "author": "Risa Labs", "url": "https://github.com/risa-labs-inc/boss-plugin-codebase", "panel": { @@ -22,5 +22,11 @@ }, "isolationMode": "out-of-process", "fallback": "in-process", - "stateHolderClass": "ai.rever.boss.plugin.runtime.stateholders.CodebaseStateHolder" + "stateHolderClass": "ai.rever.boss.plugin.runtime.stateholders.CodebaseStateHolder", + "definedPermissions": [ + { + "name": "project.replace", + "description": "Replace occurrences across an explicit file list in the project (project_replace)" + } + ] } diff --git a/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/AgentReviewPromptTest.kt b/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/AgentReviewPromptTest.kt new file mode 100644 index 0000000..05faf09 --- /dev/null +++ b/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/AgentReviewPromptTest.kt @@ -0,0 +1,56 @@ +package ai.rever.boss.plugin.dynamic.codebase + +import ai.rever.boss.plugin.api.GitFileStatusData +import ai.rever.boss.plugin.api.GitFileStatusTypeData +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** Pins the review brief the Atlas agent receives. */ +class AgentReviewPromptTest { + + private fun status(path: String, staged: Boolean, type: GitFileStatusTypeData) = + GitFileStatusData( + path = path, + indexStatus = if (staged) type else null, + workTreeStatus = if (staged) null else type, + isStaged = staged, + isUnstaged = !staged, + ) + + @Test + fun `no changes says so instead of an empty review`() { + val prompt = AgentReviewPrompt.build("/p", emptyList(), emptyList(), null) + assertTrue("no uncommitted changes" in prompt) + assertTrue("/p" in prompt) + } + + @Test + fun `small diff is inlined with the file listing`() { + val staged = listOf(status("a.kt", true, GitFileStatusTypeData.MODIFIED)) + val diff = "diff --git a.kt\n+hello" + val prompt = AgentReviewPrompt.build("/p", staged, emptyList(), diff) + assertTrue("Staged files:" in prompt) + assertTrue("[MODIFIED] a.kt" in prompt) + assertTrue("Full diff:" in prompt) + assertTrue("+hello" in prompt) + assertTrue("Do not apply any edits" in prompt) + } + + @Test + fun `diff over the budget is replaced by tool instructions`() { + val diff = "x".repeat(AgentReviewPrompt.INLINE_DIFF_BUDGET + 1) + val unstaged = listOf(status("big.txt", false, GitFileStatusTypeData.MODIFIED)) + val prompt = AgentReviewPrompt.build("/p", emptyList(), unstaged, diff) + assertFalse(diff in prompt, "the over-budget diff must not be inlined") + assertTrue("git_diff" in prompt) + assertTrue("[MODIFIED] big.txt" in prompt) + } + + @Test + fun `missing diff degrades to tool instructions`() { + val unstaged = listOf(status("a.kt", false, GitFileStatusTypeData.ADDED)) + val prompt = AgentReviewPrompt.build("/p", emptyList(), unstaged, null) + assertTrue("git_diff" in prompt) + } +} \ No newline at end of file diff --git a/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/AiUnavailableMessageTest.kt b/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/AiUnavailableMessageTest.kt new file mode 100644 index 0000000..455f44d --- /dev/null +++ b/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/AiUnavailableMessageTest.kt @@ -0,0 +1,54 @@ +package ai.rever.boss.plugin.dynamic.codebase + +import ai.rever.boss.plugin.api.AiReadiness +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +/** + * Pins the one thing about this mapping that is easy to get wrong: null is + * the SUCCESS value. + * + * Written inline in the plugin's panel registration it read + * `pluginContext?.let { …READY -> null… } ?: "AI is unavailable on this host."`, + * where the elvis operator cannot tell "the let returned null because AI is + * ready" from "there was no plugin context". A fully working gateway - Claude + * CLI resolved, a model selected - therefore reported itself as unavailable, + * and every AI action in the panel refused with a message that was simply + * false. Only a null readiness may produce that string. + */ +class AiUnavailableMessageTest { + + @Test + fun `READY is not an unavailability and maps to null`() { + assertNull(AiUnavailableMessage.of(AiReadiness.READY)) + } + + @Test + fun `only a missing plugin context reports the host as unable`() { + assertEquals(AiUnavailableMessage.NO_HOST, AiUnavailableMessage.of(null)) + } + + @Test + fun `the two failures keep their own fixes`() { + // Collapsing them into one message sends half of users to the wrong place. + assertEquals(AiUnavailableMessage.NO_GATEWAY, AiUnavailableMessage.of(AiReadiness.GATEWAY_MISSING)) + assertEquals(AiUnavailableMessage.NO_PROVIDER, AiUnavailableMessage.of(AiReadiness.NO_PROVIDER)) + } + + @Test + fun `every readiness other than READY produces a message`() { + // AiReadiness is documented as an open set: a value added later must + // still explain itself rather than falling through to null, which the + // panel reads as "AI is fine". + AiReadiness.entries + .filterNot { it == AiReadiness.READY } + .forEach { readiness -> + assertEquals( + true, + !AiUnavailableMessage.of(readiness).isNullOrBlank(), + "$readiness must explain itself", + ) + } + } +} diff --git a/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitBranchModelTest.kt b/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitBranchModelTest.kt new file mode 100644 index 0000000..e7ff818 --- /dev/null +++ b/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitBranchModelTest.kt @@ -0,0 +1,250 @@ +package ai.rever.boss.plugin.dynamic.codebase + +import ai.rever.boss.plugin.api.GitBranchRefData +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Pins the branch-graph's structural rules: which pills a commit shows, in + * what order, and what the picker offers. + * + * Both jobs are string surgery over git's `%D` decorations, and every mistake + * in them is invisible until someone squints at a screenshot - which is how + * [GitRefBadge] came to exist for a release without a single call site. + */ +class GitBranchModelTest { + + // ---- refPills ---- + + @Test + fun `the checked-out branch reads as a HEAD pill without the arrow`() { + assertEquals( + listOf(GitRefPill("main", GitRefKind.HEAD)), + GitBranchModel.refPills(listOf("HEAD -> main")), + ) + } + + @Test + fun `a detached HEAD names no branch and draws no pill`() { + assertEquals(emptyList(), GitBranchModel.refPills(listOf("HEAD"))) + } + + @Test + fun `a tag drops its prefix and reads as a tag`() { + assertEquals( + listOf(GitRefPill("v1.0.90", GitRefKind.TAG)), + GitBranchModel.refPills(listOf("tag: v1.0.90")), + ) + } + + @Test + fun `a remote symref is not a branch`() { + assertEquals(emptyList(), GitBranchModel.refPills(listOf("origin/HEAD"))) + } + + @Test + fun `a remote copy of a local branch on the same commit is not repeated`() { + // "main" and "origin/main" side by side says nothing the first did not. + assertEquals( + listOf(GitRefPill("main", GitRefKind.HEAD)), + GitBranchModel.refPills(listOf("HEAD -> main", "origin/main")), + ) + } + + @Test + fun `a remote branch with no local counterpart is kept`() { + assertEquals( + listOf(GitRefPill("origin/hotfix", GitRefKind.REMOTE)), + GitBranchModel.refPills(listOf("origin/hotfix")), + ) + } + + @Test + fun `a slashed local branch is not mistaken for a remote`() { + // `feature/x` and `origin/x` are the same shape; only the remote list + // separates them, which is why the remotes are passed in. + assertEquals( + listOf(GitRefPill("feature/tab-completion", GitRefKind.LOCAL)), + GitBranchModel.refPills(listOf("feature/tab-completion")), + ) + } + + @Test + fun `a remote named something other than origin is still a remote`() { + assertEquals( + listOf(GitRefPill("fork/main", GitRefKind.REMOTE)), + GitBranchModel.refPills(listOf("fork/main"), remotes = setOf("fork")), + ) + // ...and with the default remote set it reads as a local branch, which + // is the honest answer when nothing has told us "fork" is a remote. + assertEquals( + listOf(GitRefPill("fork/main", GitRefKind.LOCAL)), + GitBranchModel.refPills(listOf("fork/main")), + ) + } + + @Test + fun `pills order HEAD then local then tag then remote`() { + val pills = + GitBranchModel.refPills( + listOf("origin/release", "tag: v9.5.3", "hotfix", "HEAD -> main"), + ) + assertEquals( + listOf(GitRefKind.HEAD, GitRefKind.LOCAL, GitRefKind.TAG, GitRefKind.REMOTE), + pills.map { it.kind }, + ) + assertEquals("main", pills.first().label) + } + + @Test + fun `blank and whitespace-only decorations are dropped`() { + assertEquals(emptyList(), GitBranchModel.refPills(listOf("", " ", "tag:"))) + } + + // ---- options ---- + + private fun local(name: String, current: Boolean = false) = + GitBranchRefData(name = name, isCurrent = current, isRemote = false) + + private fun remote(name: String) = + GitBranchRefData(name = name, isCurrent = false, isRemote = true) + + @Test + fun `the picker puts the current branch first then locals then remotes`() { + val options = + GitBranchModel.options( + branches = listOf(remote("origin/zeta"), local("zeta"), local("alpha"), local("main", current = true)), + ) + assertEquals(listOf("main", "alpha", "zeta", "origin/zeta"), options.map { it.name }) + assertTrue(options.first().isCurrent) + } + + @Test + fun `a remote branch is offered even when a local of the same name exists`() { + // origin/main and main are different histories the moment either + // moves, and that divergence is what a graph is opened to look at. + val options = GitBranchModel.options(listOf(local("main", current = true), remote("origin/main"))) + assertEquals(listOf("main", "origin/main"), options.map { it.name }) + } + + @Test + fun `HEAD pointers are never offered as branches`() { + val options = GitBranchModel.options(listOf(local("main"), remote("origin/HEAD"), local("HEAD"))) + assertEquals(listOf("main"), options.map { it.name }) + } + + @Test + fun `a branch list carrying gits marker column is trimmed`() { + // `git branch --format=%(refname:short)%(HEAD)` emits a SPACE for every + // non-current branch. Left on, the name resolves to nothing at all. + val options = GitBranchModel.options(listOf(local("side "))) + assertEquals(listOf("side"), options.map { it.name }) + } + + @Test + fun `the graph decorations are the fallback when the host has no branch list`() { + val options = + GitBranchModel.options( + branches = emptyList(), + graphRefs = listOf("main", "origin/hotfix"), + current = "main", + ) + assertEquals(listOf("main", "origin/hotfix"), options.map { it.name }) + assertTrue(options.first { it.name == "main" }.isCurrent) + assertTrue(options.first { it.name == "origin/hotfix" }.isRemote) + } + + @Test + fun `the graph decorations are ignored once a real branch list exists`() { + // The decorations only name branches inside the fetched window of + // history; the real list is a superset and the authority. + val options = + GitBranchModel.options( + branches = listOf(local("main", current = true)), + graphRefs = listOf("a-branch-only-the-graph-saw"), + ) + assertEquals(listOf("main"), options.map { it.name }) + } + + @Test + fun `the current branch is marked by name when the host does not mark it`() { + val options = GitBranchModel.options(listOf(local("main"), local("side")), current = "side") + assertEquals("side", options.first().name) + assertTrue(options.first().isCurrent) + } + + @Test + fun `duplicate names collapse to one entry`() { + val options = GitBranchModel.options(listOf(local("main"), local("main"))) + assertEquals(1, options.size) + } + + // ---- branchRefsInGraph ---- + + private fun node(vararg refs: String) = + ai.rever.boss.plugin.api.GitCommitNodeData( + hash = "h", shortHash = "h", subject = "s", author = "a", + authorEmail = "a@a", date = 0L, refs = refs.toList(), parents = emptyList(), + ) + + @Test + fun `graph decorations yield branch names and keep the remote prefix`() { + // CodebaseGitViewModel.branchesOf folds origin/x onto x for the + // "Diff Against..." picker. The graph picker must not: origin/hotfix + // is a different history to look at than a local hotfix would be. + assertEquals( + listOf("main", "origin/hotfix"), + GitBranchModel.branchRefsInGraph(listOf(node("HEAD -> main"), node("origin/hotfix"))), + ) + } + + @Test + fun `graph decorations drop tags and duplicates`() { + assertEquals( + listOf("main"), + GitBranchModel.branchRefsInGraph( + listOf(node("HEAD -> main", "tag: v1"), node("main"), node("HEAD")), + ), + ) + } + + // ---- remoteNamesOf ---- + + @Test + fun `remote names come from the branch list and fall back to the usual two`() { + assertEquals( + setOf("fork", "origin"), + GitBranchModel.remoteNamesOf(listOf(remote("fork/main"), remote("origin/main"), local("main"))), + ) + assertEquals(GitBranchModel.DEFAULT_REMOTES, GitBranchModel.remoteNamesOf(listOf(local("main")))) + assertEquals(GitBranchModel.DEFAULT_REMOTES, GitBranchModel.remoteNamesOf(emptyList())) + } + + // ---- isSafeRef ---- + + @Test + fun `real refs are usable`() { + assertTrue(GitBranchModel.isSafeRef("main")) + assertTrue(GitBranchModel.isSafeRef("feature/tab-completion")) + assertTrue(GitBranchModel.isSafeRef("origin/release-9.5")) + assertTrue(GitBranchModel.isSafeRef("v1.0.90")) + } + + @Test + fun `anything git would read as an option is refused`() { + assertFalse(GitBranchModel.isSafeRef("-n1")) + assertFalse(GitBranchModel.isSafeRef("--upload-pack=sh")) + } + + @Test + fun `blanks whitespace and control characters are refused`() { + assertFalse(GitBranchModel.isSafeRef("")) + assertFalse(GitBranchModel.isSafeRef(" ")) + assertFalse(GitBranchModel.isSafeRef("has space")) + assertFalse(GitBranchModel.isSafeRef("has\nnewline")) + assertFalse(GitBranchModel.isSafeRef("has\u0000nul")) + assertFalse(GitBranchModel.isSafeRef("a".repeat(256))) + } +} diff --git a/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitChangeTreeTest.kt b/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitChangeTreeTest.kt new file mode 100644 index 0000000..8b2466e --- /dev/null +++ b/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitChangeTreeTest.kt @@ -0,0 +1,112 @@ +package ai.rever.boss.plugin.dynamic.codebase + +import ai.rever.boss.plugin.api.GitFileStatusData +import ai.rever.boss.plugin.api.GitFileStatusTypeData +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * The change group's tree mode. Row order and compaction are pinned because + * the group re-renders on a short refresh: rows that reorder or re-indent + * between ticks make the list unusable. + */ +class GitChangeTreeTest { + + private fun file(path: String) = + GitFileStatusData( + path = path, + indexStatus = null, + workTreeStatus = GitFileStatusTypeData.MODIFIED, + isStaged = false, + isUnstaged = true, + ) + + private fun render(paths: List, collapsed: Set = emptySet()) = + GitChangeTree.rows(paths.map { file(it) }, collapsed).map { row -> + when (row) { + is GitChangeTree.Row.Directory -> "${" ".repeat(row.depth)}[${row.label}] (${row.fileCount})" + is GitChangeTree.Row.FileRow -> "${" ".repeat(row.depth)}${row.name}" + } + } + + @Test + fun `files in the root sit at depth zero`() { + assertEquals(listOf("a.kt", "b.kt"), render(listOf("b.kt", "a.kt"))) + } + + @Test + fun `a shared directory becomes one collapsible row`() { + assertEquals( + listOf("[src] (2)", " a.kt", " b.kt"), + render(listOf("src/b.kt", "src/a.kt")), + ) + } + + @Test + fun `a single-child directory chain is compacted onto one row`() { + // Three rows of indentation to say "there is only one way down" is + // what VS Code's explorer compaction exists to avoid. + assertEquals( + listOf("[src/main/kotlin] (1)", " A.kt"), + render(listOf("src/main/kotlin/A.kt")), + ) + } + + @Test + fun `a branching directory is not compacted`() { + val out = render(listOf("src/main/A.kt", "src/test/B.kt")) + assertEquals( + listOf("[src] (2)", " [main] (1)", " A.kt", " [test] (1)", " B.kt"), + out, + ) + } + + @Test + fun `collapsing a directory hides its children but keeps its row`() { + val out = render(listOf("src/a.kt", "src/b.kt", "top.kt"), collapsed = setOf("src")) + assertEquals(listOf("[src] (2)", "top.kt"), out) + } + + @Test + fun `directories come before files at the same level`() { + val out = render(listOf("z.kt", "sub/a.kt")) + assertTrue(out.first().startsWith("[sub]"), out.toString()) + assertEquals("z.kt", out.last()) + } + + @Test + fun `the file count covers every depth beneath a directory`() { + val rows = GitChangeTree.rows( + listOf("src/a.kt", "src/deep/b.kt", "src/deep/c.kt").map { file(it) }, + emptySet(), + ) + val src = rows.filterIsInstance().first { it.label == "src" } + assertEquals(3, src.fileCount) + } + + @Test + fun `order is stable across identical inputs in any order`() { + val a = render(listOf("src/b.kt", "src/a.kt", "z.kt")) + val b = render(listOf("z.kt", "src/a.kt", "src/b.kt")) + assertEquals(a, b) + } + + @Test + fun `an empty change set renders nothing`() { + assertTrue(GitChangeTree.rows(emptyList(), emptySet()).isEmpty()) + } + + @Test + fun `the layout toggle round-trips and defaults to tree`() { + // Nothing stored, or something unrecognisable stored, means TREE: a + // real change set is mostly repeated directory prefixes, which is what + // the tree folds away. + assertEquals(GitChangeLayout.TREE, GitChangeLayout.fromStorage(null)) + assertEquals(GitChangeLayout.TREE, GitChangeLayout.fromStorage("nonsense")) + assertEquals(GitChangeLayout.LIST, GitChangeLayout.fromStorage("list")) + assertEquals(GitChangeLayout.TREE, GitChangeLayout.fromStorage("tree")) + assertEquals(GitChangeLayout.TREE, GitChangeLayout.LIST.toggled()) + assertEquals(GitChangeLayout.LIST, GitChangeLayout.TREE.toggled()) + } +} diff --git a/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitGraphBranchViewModelTest.kt b/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitGraphBranchViewModelTest.kt new file mode 100644 index 0000000..4293798 --- /dev/null +++ b/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitGraphBranchViewModelTest.kt @@ -0,0 +1,292 @@ +package ai.rever.boss.plugin.dynamic.codebase + +import ai.rever.boss.plugin.api.GitBranchRefData +import ai.rever.boss.plugin.api.GitCommitNodeData +import ai.rever.boss.plugin.api.GitDataProvider +import ai.rever.boss.plugin.api.GitFileStatusData +import ai.rever.boss.plugin.api.GitCommitInfoData +import ai.rever.boss.plugin.api.GitOperationResultData +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Pins the graph's branch scoping at the view model. + * + * The interesting case is the LEGACY host: the plugin compiles against + * boss-plugin-api 1.0.87 and calls [GitDataProvider.logGraphFor], but a host + * built against 1.0.89 never overrides it. The interface's default body has to + * carry that call back to [GitDataProvider.logGraph] for a null ref, or the + * graph goes blank on every host but the newest - which is the failure this + * whole additive-with-defaults rule exists to prevent, and it is not something + * a compile catches. + */ +class GitGraphBranchViewModelTest { + + private val viewModels = mutableListOf() + + @AfterTest + fun disposeAll() { + viewModels.forEach { it.dispose() } + viewModels.clear() + } + + // ---- fakes ---- + + private fun node(hash: String, refs: List = emptyList()) = + GitCommitNodeData( + hash = hash, + shortHash = hash.take(7), + subject = hash, + author = "t", + authorEmail = "t@t", + date = 0L, + refs = refs, + parents = emptyList(), + ) + + /** A host at 1.0.89: it knows logGraph and nothing added after it. */ + private open inner class LegacyHost( + val head: List, + ) : GitDataProvider { + val logGraphCalls = mutableListOf() + + override val fileStatus: StateFlow> = MutableStateFlow(emptyList()) + override val commitLog: StateFlow> = MutableStateFlow(emptyList()) + override val isGitRepository: StateFlow = MutableStateFlow(true) + override val isLoading: StateFlow = MutableStateFlow(false) + + override suspend fun refreshStatus() {} + + override suspend fun refreshLog(limit: Int) {} + + override suspend fun logGraph(limit: Int): List { + logGraphCalls += limit + return head + } + + override suspend fun stage(filePath: String) = GitOperationResultData.Success() + + override suspend fun unstage(filePath: String) = GitOperationResultData.Success() + + override suspend fun stageAll() = GitOperationResultData.Success() + + override suspend fun unstageAll() = GitOperationResultData.Success() + + override suspend fun discardChanges(filePath: String) = GitOperationResultData.Success() + + override suspend fun cherryPick(commitHash: String) = GitOperationResultData.Success() + + override suspend fun revert(commitHash: String) = GitOperationResultData.Success() + + override suspend fun checkout(ref: String) = GitOperationResultData.Success() + + override fun getCurrentProjectPath(): String? = "/repo" + + override fun openFile(filePath: String, windowId: String) {} + } + + /** A host at 1.0.90: ref-scoped log and a real branch list. */ + private inner class ModernHost( + head: List, + private val byRef: Map> = emptyMap(), + private val branchList: List = emptyList(), + ) : LegacyHost(head) { + val refsAsked = mutableListOf() + + override suspend fun logGraphFor(ref: String?, limit: Int): List { + refsAsked += ref + return if (ref == null) head else byRef[ref].orEmpty() + } + + override suspend fun branches(): List = branchList + } + + private fun viewModel(provider: GitDataProvider): CodebaseGitViewModel = + CodebaseGitViewModel( + git = provider, + onAgentReview = { _ -> }, + getProjectPath = { "/repo" }, + ).also { viewModels += it } + + /** The view model owns its own scope, so the assertions wait rather than race. */ + private fun awaitUntil(what: String, predicate: () -> Boolean) { + val deadline = System.currentTimeMillis() + 5_000 + while (System.currentTimeMillis() < deadline) { + if (predicate()) return + Thread.sleep(10) + } + throw AssertionError("timed out waiting for: $what") + } + + // ---- tests ---- + + @Test + fun `the graph starts on the checked-out branch`() { + val host = ModernHost(head = listOf(node("h1", listOf("HEAD -> main")))) + val vm = viewModel(host) + + awaitUntil("the initial graph") { vm.graph.value.isNotEmpty() } + + assertNull(vm.graphRef.value, "null means the checked-out branch, not a pinned name") + assertEquals(listOf(null), host.refsAsked) + assertEquals("main", vm.currentBranch.value) + } + + @Test + fun `selecting a branch reloads the graph for that ref`() { + val host = + ModernHost( + head = listOf(node("h1", listOf("HEAD -> main"))), + byRef = mapOf("side" to listOf(node("s1"), node("s2"))), + ) + val vm = viewModel(host) + awaitUntil("the initial graph") { vm.graph.value.isNotEmpty() } + + vm.selectGraphBranch("side") + + awaitUntil("the side graph") { vm.graph.value.size == 2 } + assertEquals("side", vm.graphRef.value) + assertTrue(host.refsAsked.contains("side")) + } + + @Test + fun `the checked-out branch name survives looking at another branch`() { + // Another branch's tip carries no `HEAD ->` decoration. Reading the + // current branch off it blanked the toolbar the moment you looked away + // from HEAD. + val host = + ModernHost( + head = listOf(node("h1", listOf("HEAD -> main"))), + byRef = mapOf("side" to listOf(node("s1"))), + ) + val vm = viewModel(host) + awaitUntil("the initial graph") { vm.currentBranch.value == "main" } + + vm.selectGraphBranch("side") + awaitUntil("the side graph") { vm.graphRef.value == "side" } + + assertEquals("main", vm.currentBranch.value) + } + + @Test + fun `going back to the current branch clears the ref`() { + val host = + ModernHost( + head = listOf(node("h1", listOf("HEAD -> main"))), + byRef = mapOf("side" to listOf(node("s1"))), + ) + val vm = viewModel(host) + awaitUntil("the initial graph") { vm.graph.value.isNotEmpty() } + + vm.selectGraphBranch("side") + awaitUntil("the side graph") { vm.graphRef.value == "side" } + vm.showCurrentBranch() + + awaitUntil("the return to HEAD") { vm.graphRef.value == null } + assertEquals(listOf("h1"), vm.graph.value.map { it.hash }) + } + + @Test + fun `a ref git would read as an option never reaches the host`() { + val host = ModernHost(head = listOf(node("h1", listOf("HEAD -> main")))) + val vm = viewModel(host) + awaitUntil("the initial graph") { vm.graph.value.isNotEmpty() } + val before = host.refsAsked.size + + vm.selectGraphBranch("--upload-pack=sh") + + awaitUntil("the refusal message") { vm.message.value != null } + assertEquals(before, host.refsAsked.size, "nothing was asked of the host") + assertNull(vm.graphRef.value) + } + + @Test + fun `the picker offers every branch the host lists`() { + val host = + ModernHost( + head = listOf(node("h1", listOf("HEAD -> main"))), + branchList = + listOf( + GitBranchRefData("main", isCurrent = true), + // A branch whose tip is older than the fetched window, + // so no graph decoration names it - this is why the + // picker cannot be built from the decorations. + GitBranchRefData("ancient"), + GitBranchRefData("origin/main", isRemote = true), + ), + ) + val vm = viewModel(host) + + awaitUntil("the branch list") { vm.branchOptions.value.isNotEmpty() } + + assertEquals( + listOf("main", "ancient", "origin/main"), + vm.branchOptions.value.map { it.name }, + ) + assertEquals(setOf("origin"), vm.remoteNames.value) + } + + @Test + fun `a host that predates the ref-scoped log still draws the current branch`() { + // LegacyHost overrides logGraph only. logGraphFor's DEFAULT body has to + // carry the null-ref call through to it, or every pre-1.0.90 host shows + // an empty graph. + val host = LegacyHost(head = listOf(node("h1", listOf("HEAD -> main")), node("h2"))) + val vm = viewModel(host) + + awaitUntil("the initial graph") { vm.graph.value.isNotEmpty() } + + assertEquals(listOf("h1", "h2"), vm.graph.value.map { it.hash }) + assertEquals(listOf(CodebaseGitViewModel.GRAPH_PAGE), host.logGraphCalls) + assertEquals("main", vm.currentBranch.value) + } + + @Test + fun `a host that predates the branch list falls back to the graph decorations`() { + val host = + LegacyHost( + head = listOf(node("h1", listOf("HEAD -> main")), node("h2", listOf("origin/hotfix"))), + ) + val vm = viewModel(host) + + awaitUntil("the fallback branch list") { vm.branchOptions.value.isNotEmpty() } + + assertEquals(listOf("main", "origin/hotfix"), vm.branchOptions.value.map { it.name }) + } + + @Test + fun `the change groups open as a tree and the toggle survives the view model`() { + // The layout used to be a `remember` inside the GIT tab composable, so + // it reset on every hop to FILES and back. It lives on the view model + // now, which outlives the tab. + val vm = viewModel(ModernHost(head = listOf(node("h1")))) + + assertEquals(GitChangeLayout.TREE, vm.changeLayout.value) + + vm.toggleChangeLayout() + assertEquals(GitChangeLayout.LIST, vm.changeLayout.value) + + vm.setChangeLayout(GitChangeLayout.TREE) + assertEquals(GitChangeLayout.TREE, vm.changeLayout.value) + } + + @Test + fun `a host that predates the remote verbs says so instead of failing silently`() { + val host = LegacyHost(head = listOf(node("h1", listOf("HEAD -> main")))) + val vm = viewModel(host) + awaitUntil("the initial graph") { vm.graph.value.isNotEmpty() } + + vm.fetch() + + awaitUntil("the unsupported message") { vm.message.value != null } + assertTrue( + vm.message.value.orEmpty().contains("not supported", ignoreCase = true), + "got: ${vm.message.value}", + ) + } +} diff --git a/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitGraphEdgesTest.kt b/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitGraphEdgesTest.kt new file mode 100644 index 0000000..57ffcc5 --- /dev/null +++ b/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitGraphEdgesTest.kt @@ -0,0 +1,103 @@ +package ai.rever.boss.plugin.dynamic.codebase + +import ai.rever.boss.plugin.api.GitCommitNodeData +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * What each graph row draws. + * + * The reason this exists: lane assignment alone gave a dot per row and a stub + * to the neighbour, so a branch running past twenty commits before merging + * drew nothing across those rows. Continuity is the whole point of a graph, + * and it is not something to check by squinting at a screenshot. + */ +class GitGraphEdgesTest { + + private fun commit(hash: String, vararg parents: String) = + GitCommitNodeData( + hash = hash, + shortHash = hash, + subject = hash, + author = "A", + authorEmail = "a@b.c", + date = 0L, + refs = emptyList(), + parents = parents.toList(), + ) + + private fun rows(commits: List): List = + GitGraphEdges.build(commits, GitGraphLayout.assignLanes(commits)) + + @Test + fun `linear history draws one continuous line`() { + val out = rows(listOf(commit("c", "b"), commit("b", "a"), commit("a"))) + // Every row but the last leaves downward, every row but the first arrives. + assertTrue(out[0].segments.any { it.fromNode }, "the tip must start a line") + assertTrue(out[1].segments.any { it.toNode }, "the middle must receive one") + assertTrue(out[1].segments.any { it.fromNode }, "and continue one") + assertTrue(out[2].segments.any { it.toNode }, "the root must receive one") + assertTrue(out.none { it.isMerge }) + } + + @Test + fun `a branch spanning several commits draws through every row between`() { + // c -> a skips over b: the row for b must still carry that line, or + // the branch visually disappears and reappears. + val out = rows(listOf(commit("c", "a"), commit("b", "a"), commit("a"))) + val passThrough = out[1].segments.filter { !it.fromNode && !it.toNode } + assertTrue(passThrough.isNotEmpty(), "no line crosses the skipped row: ${out[1].segments}") + } + + @Test + fun `a merge commit is marked as one`() { + val out = rows(listOf(commit("m", "a", "b"), commit("a", "root"), commit("b", "root"), commit("root"))) + assertTrue(out[0].isMerge, "two parents is a merge") + assertTrue(out[1].segments.isNotEmpty()) + assertTrue(out.drop(1).none { it.isMerge }) + } + + @Test + fun `a merge emits a line toward each parent`() { + val out = rows(listOf(commit("m", "a", "b"), commit("a", "root"), commit("b", "root"), commit("root"))) + assertEquals(2, out[0].segments.count { it.fromNode }, "one line per parent") + } + + @Test + fun `a parent outside the window does not draw a line into nothing`() { + // The oldest fetched commit still has parents; they are not in the list. + val out = rows(listOf(commit("c", "missing"))) + assertTrue(out.single().segments.isEmpty(), "an unknown parent must not produce a segment") + } + + @Test + fun `lane count covers every lane a segment reaches`() { + val out = rows(listOf(commit("m", "a", "b"), commit("a", "root"), commit("b", "root"), commit("root"))) + val maxLane = out.flatMap { r -> r.segments.flatMap { listOf(it.fromLane, it.toLane) } + r.lane }.max() + assertTrue(GitGraphEdges.laneCount(out) > maxLane, "a lane would be clipped") + } + + @Test + fun `an empty history produces no rows`() { + assertEquals(emptyList(), GitGraphEdges.build(emptyList(), emptyList())) + assertEquals(1, GitGraphEdges.laneCount(emptyList())) + } + + @Test + fun `every segment takes the colour of the lane it settles into`() { + // The invariant that keeps a branch one colour along its whole length: + // a segment is coloured by where it ENDS, not where it starts, so the + // curve leaving a fork already wears the colour of the line it joins. + val out = rows( + listOf( + commit("m", "a", "b"), + commit("a", "root"), + commit("b", "root"), + commit("root"), + ), + ) + val wrong = out.flatMap { it.segments }.filter { it.colorLane != it.toLane } + assertTrue(wrong.isEmpty(), "these would change colour mid-branch: $wrong") + } +} diff --git a/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitGraphLayoutTest.kt b/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitGraphLayoutTest.kt new file mode 100644 index 0000000..b06a0b8 --- /dev/null +++ b/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitGraphLayoutTest.kt @@ -0,0 +1,86 @@ +package ai.rever.boss.plugin.dynamic.codebase + +import ai.rever.boss.plugin.api.GitCommitNodeData +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Pins the lane-assignment rules the graph renderer relies on: linear + * history stays in one lane, a fork offsets the older side, and merges + * continue a child's lane (collapsing lanes at the merge row is the + * accepted v1 look). + */ +class GitGraphLayoutTest { + + private fun commit( + hash: String, + vararg parents: String, + ): GitCommitNodeData = + GitCommitNodeData( + hash = hash, + shortHash = hash.take(7), + subject = hash, + author = "t", + authorEmail = "t@t", + date = 0L, + refs = emptyList(), + parents = parents.toList(), + ) + + @Test + fun `empty input yields no lanes`() { + assertEquals(emptyList(), GitGraphLayout.assignLanes(emptyList())) + } + + @Test + fun `linear history sits in a single lane`() { + // newest first: m3 <- m2 <- m1 + val commits = listOf(commit("m3", "m2"), commit("m2", "m1"), commit("m1")) + assertEquals(listOf(0, 0, 0), GitGraphLayout.assignLanes(commits)) + } + + @Test + fun `a fork offsets the older mainline to a second lane`() { + // main: m1 <- m2 <- m3 ; branch off m2: b1 <- b2 <- b3 + // git log order (newest first): b3 b2 b1 m3 m2 m1 + val commits = listOf( + commit("b3", "b2"), + commit("b2", "b1"), + commit("b1", "m2"), + commit("m3", "m2"), + commit("m2", "m1"), + commit("m1"), + ) + assertEquals(listOf(0, 0, 0, 1, 1, 1), GitGraphLayout.assignLanes(commits)) + } + + @Test + fun `a merge continues a child lane without opening new lanes`() { + // main: m1 <- m2 ; branch: b1(m2) <- b2(b1) ; merge m3(m2, b2) + // git log order: m3 b2 m2 b1 m1 + val commits = listOf( + commit("m3", "m2", "b2"), + commit("b2", "b1"), + commit("m2", "m1"), + commit("b1", "m2"), + commit("m1"), + ) + val lanes = GitGraphLayout.assignLanes(commits) + assertEquals(commits.size, lanes.size) + // Merge sits on a child's lane; nothing opens a third lane. + assertTrue(lanes.maxOrNull() == 0, "expected the v1 collapse-to-one-lane look, got $lanes") + } + + @Test + fun `long linear history stays in the first lane`() { + // 40 commits, newest first: c40 <- c39 <- ... <- c1 + val commits = + (40 downTo 1).map { i -> + if (i == 1) commit("c$i") else commit("c$i", "c${i - 1}") + } + val lanes = GitGraphLayout.assignLanes(commits) + assertEquals(commits.size, lanes.size) + assertEquals(IntArray(commits.size) { 0 }.toList(), lanes) + } +} \ No newline at end of file diff --git a/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitRefBadgeMetricsTest.kt b/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitRefBadgeMetricsTest.kt new file mode 100644 index 0000000..52dc3a5 --- /dev/null +++ b/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitRefBadgeMetricsTest.kt @@ -0,0 +1,59 @@ +package ai.rever.boss.plugin.dynamic.codebase + +import kotlin.test.Test +import kotlin.test.assertTrue + +/** + * Holds the ref pill's proportions inside a commit row. + * + * These are the numbers that made the pill read as a heavy badge: with no + * explicit height it measured the label's full, untrimmed line box and came + * out very nearly as tall as the row. The fix was to state the height rather + * than inherit it from a font metric - and a stated number is only worth + * anything if something notices when it drifts back. + * + * Arithmetic, not rendering: this pins intent (a chip inside the row, a label + * quieter than the subject), which is the part that has actually regressed + * here before. It does NOT prove what the pill looks like on screen. + */ +class GitRefBadgeMetricsTest { + + @Test + fun `the pill is a chip inside the row, not a band across it`() { + assertTrue( + REF_BADGE_HEIGHT < GRAPH_ROW_HEIGHT, + "a pill at least as tall as the row has no breathing room: $REF_BADGE_HEIGHT vs $GRAPH_ROW_HEIGHT", + ) + // Roughly two thirds of the row: enough to read, short enough to leave + // clear space above and below. + val ratio = REF_BADGE_HEIGHT.value / GRAPH_ROW_HEIGHT.value + assertTrue(ratio in 0.50f..0.70f, "pill/row ratio drifted to $ratio") + } + + @Test + fun `there is visible space above and below the pill`() { + val slack = GRAPH_ROW_HEIGHT.value - REF_BADGE_HEIGHT.value + // Split by the row's CenterVertically alignment, so half lands on each side. + assertTrue(slack / 2f >= 4f, "only ${slack / 2f}dp above and below the pill") + } + + @Test + fun `the label is noticeably smaller than the commit subject`() { + assertTrue( + REF_BADGE_TEXT.value < CodebaseMetrics.PrimaryText.value, + "the pill label must not compete with the subject", + ) + assertTrue( + REF_BADGE_TEXT.value <= CodebaseMetrics.MetaText.value - 1f, + "'noticeably smaller' means smaller than the row's meta text too: " + + "${REF_BADGE_TEXT.value} vs ${CodebaseMetrics.MetaText.value}", + ) + } + + @Test + fun `the pill still fits its label plus glyph within the height`() { + // The icon (9dp) and the trimmed 9sp label both have to sit inside the + // pill with its own padding; a height under ~12dp would clip them. + assertTrue(REF_BADGE_HEIGHT.value >= 12f, "the glyph and label would clip") + } +} diff --git a/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitTabCompactDiffTest.kt b/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitTabCompactDiffTest.kt new file mode 100644 index 0000000..e28868e --- /dev/null +++ b/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitTabCompactDiffTest.kt @@ -0,0 +1,60 @@ +package ai.rever.boss.plugin.dynamic.codebase + +import ai.rever.boss.plugin.api.DiffHunk +import ai.rever.boss.plugin.api.DiffLine +import ai.rever.boss.plugin.api.DiffLineKind +import ai.rever.boss.plugin.api.GitDiffData +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * compactDiff turns a whole-file-context diff (the host uses -U100000) into the + * changed lines plus a little context - so a char-budgeted prompt is not spent on + * context padding and never comes back empty for a small change in a big file. + */ +class GitTabCompactDiffTest { + private val vm = + CodebaseGitViewModel( + git = null, + onAgentReview = {}, + getProjectPath = { null }, + ) + + private fun ctx(t: String) = DiffLine(t, DiffLineKind.CONTEXT) + + private fun add(t: String) = DiffLine(t, DiffLineKind.ADDED) + + private fun diff(lines: List) = + GitDiffData( + path = "F.kt", + additions = 1, + deletions = 0, + hunks = listOf(DiffHunk(1, lines.size, 1, lines.size, "", lines)), + ) + + @Test + fun `one change in a large file yields a small, non-empty compact diff`() { + val lines = (1..500).map { ctx("line $it") }.toMutableList() + lines[250] = add("the new line") + val out = vm.compactDiff(diff(lines), context = 2) + assertTrue(out.isNotEmpty(), "compact diff was empty for a real change") + assertTrue(out.contains("+the new line"), out) + assertTrue(out.length < 200, "expected a compact result, got ${out.length} chars") + } + + @Test + fun `two distant changes collapse the unchanged run between them`() { + val lines = (1..500).map { ctx("line $it") }.toMutableList() + lines[100] = add("first change") + lines[400] = add("second change") + val out = vm.compactDiff(diff(lines), context = 2) + assertTrue(out.contains("+first change") && out.contains("+second change"), out) + assertTrue(out.contains("…"), "the gap between the two changes should collapse to an ellipsis") + } + + @Test + fun `no changes yields empty`() { + assertEquals("", vm.compactDiff(diff(listOf(ctx("a"), ctx("b"))))) + } +} diff --git a/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/PanelUiLogicTest.kt b/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/PanelUiLogicTest.kt new file mode 100644 index 0000000..da251d7 --- /dev/null +++ b/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/PanelUiLogicTest.kt @@ -0,0 +1,315 @@ +package ai.rever.boss.plugin.dynamic.codebase + +import ai.rever.boss.plugin.api.FileMatch +import ai.rever.boss.plugin.api.GitCommitNodeData +import ai.rever.boss.plugin.api.GitFileStatusData +import ai.rever.boss.plugin.api.GitFileStatusTypeData +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * The panel's pure presentation rules: branch derivation, result grouping, + * the name/directory split every dense row uses, and the relative-date + * format. These are the parts of the FILES/SEARCH/GIT rework that can be + * pinned without a Compose harness. + */ +class PanelUiLogicTest { + + private fun node(refs: List) = + GitCommitNodeData( + hash = "abc123def", + shortHash = "abc123d", + subject = "s", + author = "a", + authorEmail = "a@b.c", + date = 0L, + refs = refs, + parents = emptyList(), + ) + + // ---- branch name ------------------------------------------------------ + + @Test + fun `branch comes from the HEAD arrow, not from a remote ref`() { + val branch = + CodebaseGitViewModel.branchOf( + listOf(node(listOf("origin/feat/x", "HEAD -> feat/x", "tag: v1"))), + ) + assertEquals("feat/x", branch) + } + + @Test + fun `a detached HEAD reports detached rather than a remote name`() { + assertEquals("detached", CodebaseGitViewModel.branchOf(listOf(node(listOf("HEAD", "origin/main"))))) + } + + @Test + fun `no refs and no commits yield an empty branch`() { + assertEquals("", CodebaseGitViewModel.branchOf(listOf(node(emptyList())))) + assertEquals("", CodebaseGitViewModel.branchOf(emptyList())) + } + + // ---- search result grouping ------------------------------------------ + + private fun match(path: String, line: Int, column: Int = 1) = + FileMatch(path = path, line = line, column = column, matchLength = 3, contextLine = "ctx") + + @Test + fun `results group by file, path-sorted, matches in line order`() { + val grouped = + groupMatchesByFile( + listOf( + match("src/z.kt", 9), + match("src/a.kt", 20), + match("src/a.kt", 2), + match("src/z.kt", 1), + ), + ) + + assertEquals(listOf("src/a.kt", "src/z.kt"), grouped.map { it.path }) + assertEquals(listOf(2, 20), grouped[0].matches.map { it.line }) + assertEquals(listOf(1, 9), grouped[1].matches.map { it.line }) + } + + @Test + fun `two matches on one line sort by column`() { + val grouped = groupMatchesByFile(listOf(match("a.kt", 3, column = 40), match("a.kt", 3, column = 5))) + assertEquals(listOf(5, 40), grouped.single().matches.map { it.column }) + } + + // ---- row display ------------------------------------------------------ + + @Test + fun `path splits into name and directory`() { + assertEquals("Foo.kt" to "src/main", splitPathForDisplay("src/main/Foo.kt")) + assertEquals("README.md" to "", splitPathForDisplay("README.md")) + } + + // ---- relative dates --------------------------------------------------- + + @Test + fun `relative date is compact and bucketed`() { + val now = System.currentTimeMillis() / 1000L + assertEquals("now", formatRelativeDate(now)) + assertEquals("5m", formatRelativeDate(now - 5 * 60)) + assertEquals("3h", formatRelativeDate(now - 3 * 3600)) + assertEquals("2d", formatRelativeDate(now - 2 * 86_400)) + assertEquals("2w", formatRelativeDate(now - 15 * 86_400)) + assertEquals("3mo", formatRelativeDate(now - 95 * 86_400)) + assertEquals("2y", formatRelativeDate(now - 800 * 86_400)) + } + + @Test + fun `a missing commit date renders as nothing, not as the epoch`() { + assertEquals("", formatRelativeDate(0L)) + assertEquals("", formatRelativeDate(-1L)) + assertEquals("", formatCommitDate(0L)) + } + + // ---- status grouping -------------------------------------------------- + + private fun status( + path: String, + index: GitFileStatusTypeData?, + workTree: GitFileStatusTypeData?, + staged: Boolean, + unstaged: Boolean, + ) = GitFileStatusData(path, index, workTree, staged, unstaged) + + @Test + fun `untracked files stay out of the changes group`() { + val untracked = status("new.kt", null, GitFileStatusTypeData.UNTRACKED, staged = false, unstaged = true) + val modified = status("old.kt", null, GitFileStatusTypeData.MODIFIED, staged = false, unstaged = true) + + assertTrue(untracked.isUntracked) + assertTrue(!modified.isUntracked) + } + + @Test + fun `a partially staged file appears in both staged and changes`() { + // `git add -p` leaves index and work tree both dirty; the file has to + // show under STAGED CHANGES *and* CHANGES, as it does in VS Code. + val partial = + status( + "half.kt", + GitFileStatusTypeData.MODIFIED, + GitFileStatusTypeData.MODIFIED, + staged = true, + unstaged = true, + ) + assertTrue(partial.isStaged) + assertTrue(partial.isUnstaged) + assertTrue(!partial.isUntracked, "a tracked edit must not be classed untracked") + } + + @Test + fun `every status type has a distinct single-character glyph`() { + val glyphs = GitFileStatusTypeData.entries.map { statusGlyph(it) } + assertTrue(glyphs.all { it.length == 1 }, "multi-character glyph would widen every row: $glyphs") + assertEquals(glyphs.size, glyphs.toSet().size, "duplicate glyphs: $glyphs") + assertEquals("?", statusGlyph(null)) + } + + // ---- agent review options --------------------------------------------- + + @Test + fun `the branch list folds remote refs onto their local name`() { + val branches = + CodebaseGitViewModel.branchesOf( + listOf(node(listOf("HEAD -> feat/x", "origin/feat/x", "tag: v1", "main", "HEAD"))), + ) + assertEquals(listOf("feat/x", "main"), branches) + } + + @Test + fun `the review base defaults to main when the history has one`() { + assertEquals("main", CodebaseGitViewModel.defaultBase(listOf("dev", "main"), "feat/x")) + assertEquals("master", CodebaseGitViewModel.defaultBase(listOf("dev", "master"), "feat/x")) + } + + @Test + fun `without a main branch the review base is the current branch`() { + assertEquals("feat/x", CodebaseGitViewModel.defaultBase(listOf("dev"), "feat/x")) + assertEquals("", CodebaseGitViewModel.defaultBase(emptyList(), "")) + } + + @Test + fun `the review prompt carries the base, the depth and the instructions`() { + val prompt = + AgentReviewPrompt.build( + projectPath = "/p", + staged = emptyList(), + unstaged = listOf( + GitFileStatusData("a.kt", null, GitFileStatusTypeData.MODIFIED, false, true), + ), + diffText = "diff", + instructions = "focus on the parser", + deep = true, + baseRef = "main", + ) + assertTrue(prompt.contains("against `main`"), prompt.take(200)) + assertTrue(prompt.contains("Go deep"), prompt.take(400)) + assertTrue(prompt.contains("focus on the parser"), prompt.take(600)) + } + + @Test + fun `a quick review says so and omits an empty instruction block`() { + val prompt = + AgentReviewPrompt.build( + projectPath = "/p", + staged = emptyList(), + unstaged = listOf( + GitFileStatusData("a.kt", null, GitFileStatusTypeData.MODIFIED, false, true), + ), + diffText = "diff", + ) + assertTrue(prompt.contains("Keep it quick"), prompt.take(400)) + assertTrue(!prompt.contains("The reviewer also asked"), prompt.take(600)) + } + + // ---- generated commit messages ---------------------------------------- + + @Test + fun `a fenced reply is unwrapped`() { + assertEquals( + "fix: handle an empty index", + CommitMessagePrompt.clean("```\nfix: handle an empty index\n```"), + ) + } + + @Test + fun `a labelled or quoted reply is unwrapped`() { + assertEquals("feat: add tree view", CommitMessagePrompt.clean("Commit message: feat: add tree view")) + assertEquals("feat: add tree view", CommitMessagePrompt.clean("\"feat: add tree view\"")) + } + + @Test + fun `a plain multi-line message keeps its body`() { + val raw = "fix: stop double-opening tabs\n\nThe path was compared as a raw string." + assertEquals(raw, CommitMessagePrompt.clean(raw)) + } + + @Test + fun `the request carries the file list and the diff`() { + val request = + CommitMessagePrompt.request( + listOf(GitFileStatusData("src/A.kt", GitFileStatusTypeData.MODIFIED, null, true, false)), + "--- src/A.kt\n@@ -1 +1 @@", + ) + val body = request.messages.single().text + assertTrue(body.contains("src/A.kt"), body) + assertTrue(body.contains("@@ -1 +1 @@"), body) + assertTrue(request.system.contains("imperative"), request.system) + } + + @Test + fun `an empty diff still produces a request from the file list alone`() { + val request = + CommitMessagePrompt.request( + listOf(GitFileStatusData("a.kt", null, GitFileStatusTypeData.MODIFIED, false, true)), + "", + ) + val body = request.messages.single().text + assertTrue(body.contains("a.kt"), body) + assertTrue(!body.contains("Diff:"), body) + } + + // ---- no-repository hint ---------------------------------------------- + + @Test + fun `the hint names the folder when it holds no repositories`() { + val dir = java.nio.file.Files.createTempDirectory("panel-norepo").toFile() + java.io.File(dir, "plain").mkdirs() + + assertEquals( + "${dir.name} is not a Git repository.", + CodebaseGitViewModel.describeMissingRepo(dir.absolutePath), + ) + dir.deleteRecursively() + } + + @Test + fun `the hint counts repositories sitting directly inside the folder`() { + // The case that reads as a bug: a folder of sibling checkouts. + val dir = java.nio.file.Files.createTempDirectory("panel-holder").toFile() + java.io.File(dir, "repo-a/.git").mkdirs() + java.io.File(dir, "repo-b/.git").mkdirs() + java.io.File(dir, "notes").mkdirs() + + val hint = CodebaseGitViewModel.describeMissingRepo(dir.absolutePath) + assertTrue(hint.contains("holds 2 of them"), hint) + assertTrue(hint.startsWith(dir.name), hint) + dir.deleteRecursively() + } + + @Test + fun `a single nested repository is phrased in the singular`() { + val dir = java.nio.file.Files.createTempDirectory("panel-one").toFile() + java.io.File(dir, "only/.git").mkdirs() + + val hint = CodebaseGitViewModel.describeMissingRepo(dir.absolutePath) + assertTrue(hint.contains("one folder inside it is"), hint) + dir.deleteRecursively() + } + + @Test + fun `no project and a missing directory degrade to plain sentences`() { + assertEquals("No project open.", CodebaseGitViewModel.describeMissingRepo(null)) + assertEquals("No project open.", CodebaseGitViewModel.describeMissingRepo(" ")) + assertEquals( + "No Git repository in this project.", + CodebaseGitViewModel.describeMissingRepo("/definitely/not/here"), + ) + } + + // ---- lane colours ----------------------------------------------------- + + @Test + fun `lane colours cycle and never index out of range`() { + val first = laneColor(0) + assertEquals(first, laneColor(CodebasePalette.laneColors.size)) + // A pathological history must not throw. + laneColor(999) + } +} diff --git a/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/SearchPathTest.kt b/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/SearchPathTest.kt new file mode 100644 index 0000000..29ca610 --- /dev/null +++ b/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/SearchPathTest.kt @@ -0,0 +1,108 @@ +package ai.rever.boss.plugin.dynamic.codebase + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * The path handling behind the SEARCH tab: turning the engine's + * project-relative match paths into something the editor can open, and the + * header's home-collapsed path. + * + * Exclude-glob semantics are NOT here: exclusion moved into the host engine + * (ContentSearchService), which already compiled the include side, so the + * cases live next to the one implementation - see ContentSearchServiceTest. + */ +class SearchPathTest { + + private fun vm(root: String?) = + CodebaseSearchViewModel(provider = null, splitViewOperations = null, getProjectPath = { root }) + + // ---- relative -> absolute -------------------------------------------- + + @Test + fun `a relative match path resolves against the project root`() { + // FileMatch.path is project-relative; handing that to the editor is + // what produced "file not found" on every result click. + assertEquals("/w/proj/src/Foo.kt", vm("/w/proj").absolutePathOf("src/Foo.kt")) + } + + @Test + fun `a trailing separator on the project root does not double up`() { + assertEquals("/w/proj/src/Foo.kt", vm("/w/proj/").absolutePathOf("src/Foo.kt")) + } + + @Test + fun `an already absolute path is left alone`() { + assertEquals("/abs/Foo.kt", vm("/w/proj").absolutePathOf("/abs/Foo.kt")) + assertEquals("C:\\abs\\Foo.kt", vm("/w/proj").absolutePathOf("C:\\abs\\Foo.kt")) + assertEquals("D:/abs/Foo.kt", vm("/w/proj").absolutePathOf("D:/abs/Foo.kt")) + } + + @Test + fun `with no project the path is returned unchanged rather than corrupted`() { + assertEquals("src/Foo.kt", vm(null).absolutePathOf("src/Foo.kt")) + assertEquals("src/Foo.kt", vm("").absolutePathOf("src/Foo.kt")) + } + + // ---- replace reporting ------------------------------------------------ + + private fun summary( + total: Int, + files: Int, + results: List, + ) = ai.rever.boss.plugin.api.ReplaceSummary(filesReplaced = files, totalReplacements = total, files = results) + + private fun fileResult(path: String, replacements: Int, error: String? = null) = + ai.rever.boss.plugin.api.FileReplaceResult(path = path, replacements = replacements, error = error) + + @Test + fun `a clean replace reports the totals`() { + val msg = vm(null).describeReplacement(summary(4, 2, listOf(fileResult("a.kt", 2), fileResult("b.kt", 2)))) + assertEquals("Replaced 4 in 2 file(s)", msg) + } + + @Test + fun `a per-file failure is named with its reason`() { + // "Replaced 0 in 0 file(s)" was all the UI said when every file was + // skipped - the reason ("unsupported" from an editor plugin that + // predates applyEdit) never reached the user. + val msg = vm(null).describeReplacement(summary(0, 0, listOf(fileResult("src/a.kt", 0, "unsupported")))) + assertTrue(msg.startsWith("Failed: a.kt - unsupported"), msg) + } + + @Test + fun `a partial replace reports both the totals and the first failure`() { + val msg = + vm(null).describeReplacement( + summary( + 2, + 1, + listOf(fileResult("a.kt", 2), fileResult("b.kt", 0, "binary file"), fileResult("c.kt", 0, "file too large")), + ), + ) + assertTrue(msg.startsWith("Replaced 2 in 1 file(s). Failed: b.kt - binary file"), msg) + assertTrue(msg.endsWith("(+1 more)"), msg) + } + + @Test + fun `a missing provider is reported as such`() { + assertTrue(vm(null).describeReplacement(null).contains("no search provider")) + } + + // ---- header path ------------------------------------------------------ + + @Test + fun `the header collapses the home prefix`() { + val home = System.getProperty("user.home").trimEnd('/') + assertEquals("~/src/app", collapseHome("$home/src/app")) + assertEquals("~", collapseHome(home)) + assertEquals("/opt/elsewhere", collapseHome("/opt/elsewhere")) + } + + @Test + fun `a sibling of home is not mistaken for it`() { + val home = System.getProperty("user.home").trimEnd('/') + assertEquals("${home}2/app", collapseHome("${home}2/app")) + } +} From 7e4080c086c80019278cd3d582e4a5b6adbc3f1d Mon Sep 17 00:00:00 2001 From: Shivanshu Date: Mon, 31 Aug 2026 20:09:16 -0700 Subject: [PATCH 2/8] Address claude-review findings on the IDE absorption (P7) Before-merge items: - startAgentReview now reuses collectDiff (compactDiff + slice) instead of rawUnified with a break loop: the host diffs with -U100000, so rawUnified is the whole file and the first large file emptied the prompt - git_status / git_log sample the provider BEFORE the refresh returns when out-of-process; awaitFresh waits for the post-refresh emission (2s cap, falls back to the latest value on a conflated no-op refresh) - destructive git tools now sit behind a git.write permission (declared in plugin.json): empty requiredPermissions is open to every local session, including unsigned-in, so git_discard (irreversible) could not stay bare. Read-only tools stay open; project_replace keeps project.replace - Replace All over a capped search: the confirm sheet now states the truncation and the Replace button stays disabled while capped, instead of silently covering only the listed files Item 5/6 and polish: - CodebaseDynamicPlugin gains dispose() (nulls context + all providers) - unguarded provider calls (loadGraph, op, status timer, collectDiff diffFile, generateCommitMessage gateway) surface failures via _message instead of dying in a SupervisorJob scope with the spinner just stopping - collectDiff no longer dies on one file that fails to diff - version now read from the bundled manifest (processResources-synced from build.gradle.kts) instead of a third hand-maintained copy - project_replace files arg accepts a JSON array (paths may legally contain commas) with a strict scanner and flat-form fallback; schema documents it - storage reads/writes off the composition dispatcher; project-path poll 1s -> 5s; orphaned/incorrect KDoc relocated; one-line discard alias inlined; stray run{} removed Tests: - new CodebaseGitMcpToolsTest: RBAC tier per tool, missing-arg errors, blank files list, oversized diff truncation, multi-file diff header, files-arg spellings, statusChar totality - SearchPathTest now disposes its view models (was leaking a scope per test) --- .../dynamic/codebase/AgentReviewPrompt.kt | 16 +- .../dynamic/codebase/CodebaseComponent.kt | 24 +- .../dynamic/codebase/CodebaseContent.kt | 90 +++--- .../dynamic/codebase/CodebaseDynamicPlugin.kt | 47 +++- .../dynamic/codebase/CodebaseGitMcpTools.kt | 155 +++++++++-- .../plugin/dynamic/codebase/GitGraphLayout.kt | 2 +- .../boss/plugin/dynamic/codebase/GitTab.kt | 98 ++++--- .../boss/plugin/dynamic/codebase/GitTabUi.kt | 3 - .../boss/plugin/dynamic/codebase/SearchTab.kt | 20 +- .../META-INF/boss-plugin/plugin.json | 4 + .../dynamic/codebase/AgentReviewPromptTest.kt | 2 +- .../codebase/CodebaseGitMcpToolsTest.kt | 260 ++++++++++++++++++ .../dynamic/codebase/GitGraphLayoutTest.kt | 2 +- .../dynamic/codebase/PanelUiLogicTest.kt | 2 +- .../plugin/dynamic/codebase/SearchPathTest.kt | 19 +- 15 files changed, 620 insertions(+), 124 deletions(-) create mode 100644 src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/CodebaseGitMcpToolsTest.kt diff --git a/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/AgentReviewPrompt.kt b/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/AgentReviewPrompt.kt index aa79f43..a8e951c 100644 --- a/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/AgentReviewPrompt.kt +++ b/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/AgentReviewPrompt.kt @@ -25,12 +25,18 @@ object AgentReviewPrompt { instructions: String = "", /** Quick keeps it to a short pass; Deep asks for a thorough one. */ deep: Boolean = false, - /** The ref the change is being reviewed against, e.g. "main". */ + /** The ref the change is being reviewed toward, e.g. "main". Context for + * the agent only - the attached diff is the uncommitted change, never + * a diff against this ref. + */ baseRef: String = "", ): String { val sb = StringBuilder() if (baseRef.isNotBlank()) { - sb.appendLine("Review this branch's changes against `$baseRef` (index + working tree).") + sb.appendLine( + "Review these uncommitted changes (index + working tree) ahead of " + + "landing them on `$baseRef`.", + ) } else { sb.appendLine("Review the uncommitted changes in this project (index + working tree vs HEAD).") } @@ -54,9 +60,7 @@ object AgentReviewPrompt { sb.appendLine() if (staged.isEmpty() && unstaged.isEmpty()) { - sb.appendLine("There are no uncommitted changes in ") - sb.appendLine(projectPath) - sb.appendLine(".") + sb.appendLine("There are no uncommitted changes in $projectPath.") return sb.toString().trimEnd() } @@ -86,4 +90,4 @@ object AgentReviewPrompt { } return sb.toString().trimEnd() } -} \ No newline at end of file +} diff --git a/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/CodebaseComponent.kt b/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/CodebaseComponent.kt index 54b7153..9139243 100644 --- a/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/CodebaseComponent.kt +++ b/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/CodebaseComponent.kt @@ -54,6 +54,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext /** The three top tabs of the codebase panel (P7). */ enum class CodebaseTab(val label: String, val storageKey: String) { @@ -136,16 +137,25 @@ class CodebaseComponent( } LaunchedEffect(Unit) { - val saved = storage?.getString("codebase.tab") + // The reads are blocking I/O - and the tab-switch write below is + // already off the UI thread, so keep this path consistent: a + // blocking storage implementation must not stall the first frame. + val saved = withContext(Dispatchers.Default) { storage?.getString("codebase.tab") } selectedTab = CodebaseTab.fromStorage(saved) // The change-group layout was a `remember` inside the GIT tab, so // it reset on every hop to FILES and back. It is a preference; // persist it beside the selected tab. Seeded BEFORE the collector // below starts, so the load is not immediately overwritten. gitViewModel.setChangeLayout( - GitChangeLayout.fromStorage(storage?.getString("codebase.gitLayout")), + withContext(Dispatchers.Default) { + GitChangeLayout.fromStorage(storage?.getString("codebase.gitLayout")) + }, ) - gitViewModel.changeLayout.collect { storage?.putString("codebase.gitLayout", it.storageKey) } + gitViewModel.changeLayout.collect { layout -> + withContext(Dispatchers.Default) { + storage?.putString("codebase.gitLayout", layout.storageKey) + } + } } // The selected project is exposed as a plain getter, not a flow, so it // is sampled: the header has to follow a project switch made anywhere @@ -258,8 +268,12 @@ internal fun collapseHome(path: String): String { } } -/** How often the selected project is sampled for the header. */ -private const val PROJECT_POLL_MS = 1_000L +/** + * How often the selected project is sampled for the header. The getter changes + * at most once per project switch (minutes apart at best), so 1s only added IPC + * churn - 5s is indistinguishable in practice. + */ +private const val PROJECT_POLL_MS = 5_000L /** * The panel's tab strip: icon + label while the panel is wide enough, icons diff --git a/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/CodebaseContent.kt b/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/CodebaseContent.kt index c631adb..dcf5ed4 100644 --- a/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/CodebaseContent.kt +++ b/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/CodebaseContent.kt @@ -211,54 +211,52 @@ fun CodebaseContent( .padding(horizontal = CodebaseMetrics.Gutter), verticalAlignment = Alignment.CenterVertically ) { - run { - Text( - text = "EXPLORER", - fontSize = 11.sp, - fontFamily = FontFamily.Monospace, - fontWeight = FontWeight.SemiBold, - letterSpacing = 1.0.sp, - color = BossThemeColors.TextSecondary, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f) - ) - // Hidden on host binaries that predate the showHidden - // overloads — the flag would be silently ignored there. - if (viewModel.supportsShowHidden) { - TooltipArea( - tooltip = { - Surface( - color = BossHeaderColor, - shape = RoundedCornerShape(4.dp), - elevation = 4.dp, - border = BorderStroke(1.dp, BossDarkBorder) - ) { - Text( - text = if (showHidden) { - "Hide hidden files (dotfiles)" - } else { - "Show hidden files (dotfiles) — build/ and node_modules/ stay hidden" - }, - fontSize = 11.sp, - color = BossTextColor, - modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp) - ) - } + Text( + text = "EXPLORER", + fontSize = 11.sp, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.SemiBold, + letterSpacing = 1.0.sp, + color = BossThemeColors.TextSecondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + // Hidden on host binaries that predate the showHidden + // overloads — the flag would be silently ignored there. + if (viewModel.supportsShowHidden) { + TooltipArea( + tooltip = { + Surface( + color = BossHeaderColor, + shape = RoundedCornerShape(4.dp), + elevation = 4.dp, + border = BorderStroke(1.dp, BossDarkBorder) + ) { + Text( + text = if (showHidden) { + "Hide hidden files (dotfiles)" + } else { + "Show hidden files (dotfiles) — build/ and node_modules/ stay hidden" + }, + fontSize = 11.sp, + color = BossTextColor, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp) + ) } - ) { - Icon( - imageVector = if (showHidden) Icons.Outlined.Visibility else Icons.Outlined.VisibilityOff, - contentDescription = if (showHidden) "Hide hidden files (dotfiles)" else "Show hidden files (dotfiles)", - tint = if (showHidden) BossAccentBlue else BossDarkTextSecondary, - modifier = Modifier - .size(18.dp) - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null - ) { viewModel.setShowHidden(!showHidden) } - ) } + ) { + Icon( + imageVector = if (showHidden) Icons.Outlined.Visibility else Icons.Outlined.VisibilityOff, + contentDescription = if (showHidden) "Hide hidden files (dotfiles)" else "Show hidden files (dotfiles)", + tint = if (showHidden) BossAccentBlue else BossDarkTextSecondary, + modifier = Modifier + .size(18.dp) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { viewModel.setShowHidden(!showHidden) } + ) } } } diff --git a/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/CodebaseDynamicPlugin.kt b/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/CodebaseDynamicPlugin.kt index eb93501..bfb0615 100644 --- a/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/CodebaseDynamicPlugin.kt +++ b/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/CodebaseDynamicPlugin.kt @@ -27,7 +27,7 @@ import kotlinx.coroutines.launch class CodebaseDynamicPlugin : DynamicPlugin { override val pluginId: String = "ai.rever.boss.plugin.dynamic.codebase" override val displayName: String = "Codebase (Dynamic)" - override val version: String = "1.6.0" + override val version: String = readManifestVersion() override val description: String = "Files, search & replace, and git status/log/graph for the current project" override val author: String = "Risa Labs" override val url: String = "https://github.com/risa-labs-inc/boss-plugin-codebase" @@ -151,8 +151,53 @@ class CodebaseDynamicPlugin : DynamicPlugin { ) } + /** + * Drop every host reference on disable/unload. Without this the plugin + * keeps a strong reference to [PluginContext] - and to every provider + * hanging off it - for the life of the process, which is exactly the leak + * AGENTS.md names as the reason the entry-point contract has a dispose(). + */ + override fun dispose() { + fileSystemDataProvider = null + contextMenuProvider = null + directoryPickerProvider = null + splitViewOperations = null + projectDataProvider = null + gitDataProvider = null + searchProvider = null + pluginScope = null + getWindowId = { null } + getProjectPath = { null } + publishReview = { _ -> } + pluginContext = null + } + companion object { const val EVENT_ATLAS_REVIEW = "atlas.review" + + /** Last-resort value when the bundled manifest cannot be read. */ + private const val FALLBACK_VERSION = "0.0.0" + + /** + * The version from the bundled plugin.json, whose version field the + * build's processResources filter syncs from build.gradle.kts. Reading + * it here removes the hand-maintained third copy of the version - + * which has drifted before (the field said 1.0.9 while gradle said + * 1.5.8). + */ + private fun readManifestVersion(): String { + val stream = + CodebaseDynamicPlugin::class.java.classLoader + ?.getResourceAsStream("META-INF/boss-plugin/plugin.json") + ?: return FALLBACK_VERSION + return stream.use { input -> + Regex(""""version"\s*:\s*"([^"]+)\"""") + .find(input.readBytes().decodeToString()) + ?.groupValues + ?.get(1) + ?: FALLBACK_VERSION + } + } } } diff --git a/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/CodebaseGitMcpTools.kt b/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/CodebaseGitMcpTools.kt index 1c773de..89b1a67 100644 --- a/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/CodebaseGitMcpTools.kt +++ b/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/CodebaseGitMcpTools.kt @@ -7,13 +7,27 @@ import ai.rever.boss.plugin.api.McpToolHandler import ai.rever.boss.plugin.api.McpToolProvider import ai.rever.boss.plugin.api.McpToolResult import ai.rever.boss.plugin.api.ProjectSearchProvider +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withTimeoutOrNull /** * The git / project-search MCP tools absorbed from the retired * git-status, git-log and search-replace plugins (P7). * - * Tool names, schemas and RBAC are carried over verbatim - the P6.2 tool - * tiers pin these names, so the provider move is transparent to Atlas. + * Tool names and schemas are carried over verbatim so the move is transparent + * to agents that already know them. RBAC is deliberate, in three tiers: + * - read-only tools (git_status, git_log, git_diff*, project_search) carry no + * permission and are open to every local agent session; + * - every tool that mutates git state (stage/unstage/discard/checkout/ + * cherry-pick/revert) requires [GIT_WRITE]. An empty requirement list is NOT + * a neutral default here: the host's MCP registry exposes such a tool to + * every session, including one where no user is signed in, so the + * irreversible git_discard must sit behind a grant, exactly like + * project_replace sits behind project.replace; + * - project_replace (writing file contents) requires its own "project.replace". + * Admins bypass all of the above. + * * Registered alongside the existing codebase_* tools in * [CodebaseDynamicPlugin.register]; auto-removed on disable/unload. */ @@ -32,53 +46,59 @@ internal class CodebaseGitMcpToolProvider( readOnly = true, handler = McpToolHandler { status() }, ), - McpToolDefinition( + McpToolDefinition.withRbac( name = "git_stage", description = "Stage a file for commit in the current project.", inputSchema = PATH_SCHEMA, readOnly = false, + requiredPermissions = listOf(GIT_WRITE), handler = McpToolHandler { args -> val path = args.path() ?: return@McpToolHandler missing("path") op("Staged", gitProvider?.stage(path)) }, ), - McpToolDefinition( + McpToolDefinition.withRbac( name = "git_unstage", description = "Unstage a previously staged file in the current project.", inputSchema = PATH_SCHEMA, readOnly = false, + requiredPermissions = listOf(GIT_WRITE), handler = McpToolHandler { args -> val path = args.path() ?: return@McpToolHandler missing("path") op("Unstaged", gitProvider?.unstage(path)) }, ), - McpToolDefinition( + McpToolDefinition.withRbac( name = "git_stage_all", description = "Stage all changed files in the current project.", readOnly = false, + requiredPermissions = listOf(GIT_WRITE), handler = McpToolHandler { op("Staged all", gitProvider?.stageAll()) }, ), - McpToolDefinition( + McpToolDefinition.withRbac( name = "git_unstage_all", description = "Unstage all staged files in the current project.", readOnly = false, + requiredPermissions = listOf(GIT_WRITE), handler = McpToolHandler { op("Unstaged all", gitProvider?.unstageAll()) }, ), - McpToolDefinition( + McpToolDefinition.withRbac( name = "git_discard", description = "Discard working-tree changes to a file (irreversible). Use with care.", inputSchema = PATH_SCHEMA, readOnly = false, + requiredPermissions = listOf(GIT_WRITE), handler = McpToolHandler { args -> val path = args.path() ?: return@McpToolHandler missing("path") op("Discarded", gitProvider?.discardChanges(path)) }, ), - McpToolDefinition( + McpToolDefinition.withRbac( name = "git_checkout", description = "Checkout a commit, branch, or tag in the current project.", inputSchema = REF_SCHEMA, readOnly = false, + requiredPermissions = listOf(GIT_WRITE), handler = McpToolHandler { args -> val ref = args.string("ref") ?: return@McpToolHandler missing("ref") @@ -106,7 +126,14 @@ internal class CodebaseGitMcpToolProvider( handler = McpToolHandler { args -> val names = gitProvider?.diffNames(args.boolean("staged") ?: false) ?: emptyList() if (names.isEmpty()) McpToolResult("No changes.") - else McpToolResult(names.joinToString("\n") { "${(it.indexStatus ?: it.workTreeStatus)?.name ?: "?"} ${it.path}" }) + else + McpToolResult( + capDiff( + names.joinToString("\n") { + "${(it.indexStatus ?: it.workTreeStatus)?.name ?: "?"} ${it.path}" + }, + ), + ) }, ), McpToolDefinition( @@ -142,28 +169,30 @@ internal class CodebaseGitMcpToolProvider( inputSchema = LIMIT_SCHEMA, readOnly = true, handler = McpToolHandler { args -> + val provider = gitProvider ?: return@McpToolHandler unavailableGit() val limit = args.int("limit") ?: 30 - gitProvider?.refreshLog(limit) - val log = gitProvider?.commitLog?.value.orEmpty().take(limit) + val log = awaitFresh(provider.commitLog) { provider.refreshLog(limit) }.take(limit) if (log.isEmpty()) McpToolResult("No commits.") else McpToolResult(log.joinToString("\n") { "${it.shortHash} ${it.subject} (${it.author})" }) }, ), - McpToolDefinition( + McpToolDefinition.withRbac( name = "git_cherry_pick", description = "Cherry-pick a commit onto the current branch of the current project.", inputSchema = HASH_SCHEMA, readOnly = false, + requiredPermissions = listOf(GIT_WRITE), handler = McpToolHandler { args -> val hash = args.hash() if (hash == null) missing("hash") else op("Cherry-picked $hash", gitProvider?.cherryPick(hash)) }, ), - McpToolDefinition( + McpToolDefinition.withRbac( name = "git_revert", description = "Revert a commit in the current project (creates a new revert commit).", inputSchema = HASH_SCHEMA, readOnly = false, + requiredPermissions = listOf(GIT_WRITE), handler = McpToolHandler { args -> val hash = args.hash() if (hash == null) missing("hash") else op("Reverted $hash", gitProvider?.revert(hash)) @@ -222,10 +251,7 @@ internal class CodebaseGitMcpToolProvider( val query = args.string("query") ?: return missing("query") val replacement = args.string("replacement") ?: return missing("replacement") val files = - args.string("files") - ?.split(",", "\n") - ?.map { it.trim() } - ?.filter { it.isNotEmpty() } + args.string("files")?.let(::parseFiles) ?: return missing("files") if (files.isEmpty()) return McpToolResult("Argument 'files' must not be empty.", isError = true) val dryRun = args.boolean("dryRun") ?: true @@ -249,10 +275,88 @@ internal class CodebaseGitMcpToolProvider( return McpToolResult(out) } + /** + * `files` arrives either as a JSON array of path strings (preferred: a path + * may legally contain a comma, which the flat form cannot express) or as the + * flat comma/newline-separated form. The array form is parsed by a small + * strict scanner (the plugin's classpath carries no JSON library); anything + * the scanner does not recognize falls back to flat splitting. + */ + private fun parseFiles(raw: String): List { + val trimmed = raw.trim() + if (trimmed.startsWith("[") && trimmed.endsWith("]")) { + parseJsonStringArray(trimmed)?.let { return it } + } + return trimmed + .split(",", "\n") + .map { it.trim() } + .filter { it.isNotEmpty() } + } + + /** + * Strict scan of `["a", "b, c", ...]`: double-quoted elements, backslash + * escapes, commas only between elements. Returns null for anything else + * (nesting, bare tokens, unbalanced quotes) so the caller falls back to the + * flat form rather than guessing at a partial parse. + */ + private fun parseJsonStringArray(text: String): List? { + val body = text.substring(1, text.length - 1) + val items = mutableListOf() + val current = StringBuilder() + var inString = false + var escaped = false + for (c in body) { + when { + escaped -> { + current.append(c) + escaped = false + } + inString && c == '\\' -> { + current.append(c) + escaped = true + } + c == '"' -> inString = !inString + c == ',' && !inString -> { + items += current.toString().trim().removeSurrounding("\"") + current.clear() + } + !inString && c.isWhitespace() -> Unit + inString -> current.append(c) + else -> return null + } + } + if (inString || escaped) return null + items += current.toString().trim().removeSurrounding("\"") + return if (items.all { it.isNotEmpty() }) items else null + } + + /** + * Runs [refresh] and then returns the first value of [flow] that differs from + * its pre-refresh snapshot, waiting at most [FLOW_SETTLE_MS]. Falls back to the + * latest value if no new emission arrives (a no-op refresh emits nothing). + * + * The provider refreshes asynchronously - and over IPC when this plugin runs + * out-of-process - so sampling `flow.value` right after `refreshStatus()` / + * `refreshLog()` returns the state from BEFORE the refresh. An agent calling + * `git_status` right after `git_stage` would get the pre-stage tree and feed a + * wrong premise into its next step. In-process the host publishes before + * `refresh*()` returns, so the wait resolves immediately. When the refresh is a + * no-op the StateFlow conflates (no emission) and the wait times out, falling + * back to the latest value - which is already correct in that case. + */ + private suspend fun awaitFresh( + flow: StateFlow, + refresh: suspend () -> Unit, + ): T { + val before = flow.value + refresh() + return withTimeoutOrNull(FLOW_SETTLE_MS) { flow.first { it != before } } + ?: flow.value + } + private suspend fun status(): McpToolResult { val provider = gitProvider ?: return unavailableGit() - provider.refreshStatus() - val status = provider.fileStatus.value + val status = awaitFresh(provider.fileStatus) { provider.refreshStatus() } if (status.isEmpty()) return McpToolResult("Working tree clean.") return McpToolResult( status.joinToString("\n") { s -> @@ -347,6 +451,15 @@ internal class CodebaseGitMcpToolProvider( private companion object { const val MAX_RESULT_LINES = 100 + /** Permission required by every git tool that mutates repository state. */ + const val GIT_WRITE = "git.write" + + /** + * How long [awaitFresh] waits, after calling a `refresh*()` method, for + * the provider's flow to move off its pre-refresh value. + */ + const val FLOW_SETTLE_MS = 2_000L + /** Ceiling on a single diff tool result; whole-file-context diffs are otherwise unbounded. */ const val MAX_DIFF_CHARS = 64_000 @@ -371,6 +484,6 @@ internal class CodebaseGitMcpToolProvider( const val SEARCH_SCHEMA = """{"type":"object","properties":{"query":{"type":"string","description":"Literal text, or a regular expression when isRegex=true."},"pathPattern":{"type":"string","description":"Optional glob filter on the project-relative path (e.g. **/*.kt)."},"isRegex":{"type":"boolean","description":"Treat query as a regular expression (default false)."},"caseSensitive":{"type":"boolean","description":"Case-sensitive matching (default false)."},"wholeWord":{"type":"boolean","description":"Whole-word match only (default false)."},"maxResults":{"type":"integer","description":"Hard cap on returned matches (default 100)."}},"required":["query"]}""" const val REPLACE_SCHEMA = - """{"type":"object","properties":{"query":{"type":"string","description":"Literal text, or a regular expression when isRegex=true."},"replacement":{"type":"string","description":"Replacement text; ${'$'}1..${'$'}9 capture references for regex queries."},"files":{"type":"string","description":"Comma-separated file paths to touch (project-relative, or absolute inside the project). Paths outside the project are refused. Never empty."},"isRegex":{"type":"boolean","description":"Treat query as a regular expression (default false)."},"caseSensitive":{"type":"boolean","description":"Case-sensitive matching (default false)."},"wholeWord":{"type":"boolean","description":"Whole-word match only (default false)."},"dryRun":{"type":"boolean","description":"Count without writing (default true)."}},"required":["query","replacement","files"]}""" + """{"type":"object","properties":{"query":{"type":"string","description":"Literal text, or a regular expression when isRegex=true."},"replacement":{"type":"string","description":"Replacement text; ${'$'}1..${'$'}9 capture references for regex queries."},"files":{"type":"string","description":"Comma-separated file paths to touch, or a JSON array of path strings - use the array form when a path contains a comma. Project-relative, or absolute inside the project. Paths outside the project are refused. Never empty."},"isRegex":{"type":"boolean","description":"Treat query as a regular expression (default false)."},"caseSensitive":{"type":"boolean","description":"Case-sensitive matching (default false)."},"wholeWord":{"type":"boolean","description":"Whole-word match only (default false)."},"dryRun":{"type":"boolean","description":"Count without writing (default true)."}},"required":["query","replacement","files"]}""" } -} \ No newline at end of file +} diff --git a/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitGraphLayout.kt b/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitGraphLayout.kt index 7f2e98a..2d82e00 100644 --- a/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitGraphLayout.kt +++ b/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitGraphLayout.kt @@ -74,4 +74,4 @@ object GitGraphLayout { return result.toList() } -} \ No newline at end of file +} diff --git a/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitTab.kt b/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitTab.kt index bc40966..28e979c 100644 --- a/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitTab.kt +++ b/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitTab.kt @@ -235,7 +235,16 @@ class CodebaseGitViewModel( scope.launch { while (isActive) { delay(REFRESH_MS) - git.refreshStatus() + // A throwing provider (IPC drop, repo mid-rebase) must not + // end the polling loop: catch and let the next tick retry. + // The UI symptom - status stops moving - stays visible, and + // any explicit operation surfaces the error via op(). + try { + git.refreshStatus() + } catch (e: Exception) { + // Deliberately silent here: surfacing on every 5 s tick + // would overwrite real operation results. + } } } } @@ -263,7 +272,14 @@ class CodebaseGitViewModel( // delegates to logGraph for a null ref, so on a host that // predates 1.0.90 the checked-out history still draws and only // the picker degrades. - val nodes = git?.logGraphFor(ref, limit) ?: emptyList() + val nodes = + runCatching { git?.logGraphFor(ref, limit) }.getOrElse { e -> + // A throwing provider (IPC drop, repo mid-rebase) leaves a + // message instead of an unhandled exception and a spinner + // that just stops. + _message.value = "Failed to load git history: ${e.message}" + null + } ?: emptyList() _graph.value = nodes // The HEAD decoration only names the checked-out branch while // the graph IS HEAD's; another branch's tip carries no @@ -360,8 +376,6 @@ class CodebaseGitViewModel( fun discard(path: String) = op { git?.discardChanges(path) } - private suspend fun GitDataProvider.discard(path: String) = discardChanges(path) - fun stageAll() = op { git?.stageAll() } /** @@ -374,7 +388,7 @@ class CodebaseGitViewModel( */ fun stagePaths(paths: List) = batch(paths) { git?.stage(it) } - fun discardPaths(paths: List) = batch(paths) { git?.discard(it) } + fun discardPaths(paths: List) = batch(paths) { git?.discardChanges(it) } fun unstagePaths(paths: List) = batch(paths) { git?.unstage(it) } @@ -480,7 +494,13 @@ class CodebaseGitViewModel( _message.value = it return } - val gateway = aiGateway() ?: return + val gateway = + aiGateway() ?: run { + // aiUnavailable() said ready but the gateway is gone now (unloaded + // in between): a silent return would read as a dead button. + _message.value = "AI is unavailable." + return + } scope.launch { _generating.value = true _message.value = null @@ -508,7 +528,6 @@ class CodebaseGitViewModel( } } - /** Unified diff text for [files], truncated to [budget] characters. */ /** * A compact unified-ish diff for a PROMPT: changed lines plus [context] lines * around each change, with runs of unchanged text collapsed to an ellipsis. @@ -551,13 +570,23 @@ class CodebaseGitViewModel( return sb.toString() } + /** + * Per-file compact diffs for [files], concatenated with a `--- path` header + * each, truncated to [budget] characters total. Used to build the prompt + * payload for Agent Review and commit-message generation. + */ private suspend fun collectDiff(files: List, budget: Int): String { val provider = git ?: return "" val sb = StringBuilder() for (f in files.distinctBy { it.path }.take(MAX_INLINE_FILES)) { + // A throwing provider (IPC drop, repo mid-rebase) on ONE file must not + // kill the whole collection: skip that file, keep the rest. The file + // stays in the listing above, and the agent can fetch it via git_diff. val diff = - provider.diffFile(f.path, staged = f.isStaged && !f.isUnstaged).firstOrNull() - ?: provider.diffFile(f.path, staged = false).firstOrNull() + runCatching { provider.diffFile(f.path, staged = f.isStaged && !f.isUnstaged) } + .getOrNull()?.firstOrNull() + ?: runCatching { provider.diffFile(f.path, staged = false) } + .getOrNull()?.firstOrNull() ?: continue // compactDiff, not rawUnified: the host generates diffs with whole-file // context (-U100000) for the viewer, so rawUnified is the entire file. Fed @@ -588,23 +617,13 @@ class CodebaseGitViewModel( var diffText: String? = null if (git != null) { - val sb = StringBuilder() - for (s in (staged + unstaged).distinctBy { it.path }.take(MAX_INLINE_FILES)) { - val diff = - git.diffFile(s.path, staged = s.isStaged && !s.isUnstaged).firstOrNull() - ?: git.diffFile(s.path, staged = false).firstOrNull() - ?: continue - val block = diff.rawUnified.ifBlank { - diff.hunks.joinToString("\n") { h -> - "@@ -${h.oldStart},${h.oldLines} +${h.newStart},${h.newLines} @@\n" + - h.lines.joinToString("\n") { l -> l.text } - } - } - if (sb.length + block.length > AgentReviewPrompt.INLINE_DIFF_BUDGET) break - if (sb.isNotEmpty()) sb.append("\n") - sb.append("diff --git ").append(s.path).append("\n").append(block) - } - diffText = sb.toString().takeIf { it.isNotBlank() } + // Reuse collectDiff: it uses compactDiff (not rawUnified, which with + // the host's -U100000 is the entire file) and slices an oversized + // block instead of breaking, so the first large file does not empty + // the whole prompt. + diffText = + collectDiff(staged + unstaged, AgentReviewPrompt.INLINE_DIFF_BUDGET) + .takeIf { it.isNotBlank() } } onAgentReview( @@ -618,6 +637,10 @@ class CodebaseGitViewModel( baseRef = _reviewBase.value, ), ) + } catch (e: Exception) { + // A throwing provider (IPC drop, repo mid-rebase) must not vanish as + // a dead button: surface it like any other git failure. + _message.value = "Agent review failed: ${e.message ?: e::class.simpleName}" } finally { _busy.value = false } @@ -645,8 +668,15 @@ class CodebaseGitViewModel( _busy.value = true _message.value = null try { - report(block()) { if (refreshGraph) loadGraph(reset = true) } - git.refreshStatus() + try { + report(block()) { if (refreshGraph) loadGraph(reset = true) } + git.refreshStatus() + } catch (e: Exception) { + // A throwing provider (IPC drop, repo mid-rebase) must not vanish: + // report() already exists to surface failures, and a raw throw + // here would leave _message null with only the spinner stopping. + _message.value = "Failed: ${e.message ?: e::class.simpleName}" + } } finally { _busy.value = false } @@ -675,12 +705,6 @@ class CodebaseGitViewModel( /** Immediate children inspected for the no-repository hint. */ private const val CHILD_SCAN_LIMIT = 200 - /** - * The branch name out of a graph's ref decorations. `%D` yields - * entries like `HEAD -> feat/x`, `origin/feat/x`, `tag: v1`; only the - * HEAD arrow names the checked-out branch, and a detached HEAD has - * none - which reads as "detached", not as some remote's name. - */ /** * A sentence for the empty state, from the selected folder alone: * names the folder, and counts the repositories directly inside it so @@ -731,6 +755,12 @@ class CodebaseGitViewModel( ?: branches.firstOrNull { it == "master" } ?: current + /** + * The branch name out of a graph's ref decorations. `%D` yields + * entries like `HEAD -> feat/x`, `origin/feat/x`, `tag: v1`; only the + * HEAD arrow names the checked-out branch, and a detached HEAD has + * none - which reads as "detached", not as some remote's name. + */ internal fun branchOf(nodes: List): String { val refs = nodes.firstOrNull()?.refs ?: return "" refs.firstOrNull { it.startsWith("HEAD ->") } diff --git a/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitTabUi.kt b/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitTabUi.kt index ed23443..4e1349f 100644 --- a/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitTabUi.kt +++ b/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitTabUi.kt @@ -580,9 +580,6 @@ private fun GitAgentReview( busy: Boolean, onFindIssues: () -> Unit, ) { - var approachOpen by remember { mutableStateOf(false) } - var branchOpen by remember { mutableStateOf(false) } - Column( modifier = Modifier .fillMaxWidth() diff --git a/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/SearchTab.kt b/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/SearchTab.kt index 5ba497e..710fa8b 100644 --- a/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/SearchTab.kt +++ b/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/SearchTab.kt @@ -388,7 +388,7 @@ class CodebaseSearchViewModel( scope.cancel() } - private companion object { + companion object { const val MAX_RESULTS = 500 const val DEBOUNCE_MS = 250L @@ -653,6 +653,7 @@ fun CodebaseSearchContent( ConfirmReplaceSheet( summary = summary, busy = busy, + capped = capped, onDismiss = { viewModel.dismissPreview() }, onApply = { viewModel.applyReplacement() }, ) @@ -896,6 +897,11 @@ private const val LEAD_CONTEXT = 12 private fun ConfirmReplaceSheet( summary: ReplaceSummary, busy: Boolean, + /** True when the search result was truncated at MAX_RESULTS. While capped, + * "replace all" would silently cover only the listed files, so the apply + * button is disabled and the truncation is stated plainly. + */ + capped: Boolean, onDismiss: () -> Unit, onApply: () -> Unit, ) { @@ -926,6 +932,16 @@ private fun ConfirmReplaceSheet( fontSize = CodebaseMetrics.SecondaryText, color = CodebasePalette.Secondary, ) + if (capped) { + Spacer(Modifier.height(6.dp)) + Text( + text = "The search was capped at ${CodebaseSearchViewModel.MAX_RESULTS} matches, " + + "so this would replace only the listed files - not every match. " + + "Narrow the search (glob / exclude) to widen the set.", + fontSize = CodebaseMetrics.SecondaryText, + color = CodebasePalette.Warn, + ) + } Spacer(Modifier.height(12.dp)) Row( horizontalArrangement = Arrangement.End, @@ -941,7 +957,7 @@ private fun ConfirmReplaceSheet( CodebasePrimaryButton( label = "Replace", onClick = onApply, - enabled = !busy && summary.totalReplacements > 0, + enabled = !busy && !capped && summary.totalReplacements > 0, modifier = Modifier.width(90.dp), ) } diff --git a/src/main/resources/META-INF/boss-plugin/plugin.json b/src/main/resources/META-INF/boss-plugin/plugin.json index 73c74ed..b752062 100644 --- a/src/main/resources/META-INF/boss-plugin/plugin.json +++ b/src/main/resources/META-INF/boss-plugin/plugin.json @@ -27,6 +27,10 @@ { "name": "project.replace", "description": "Replace occurrences across an explicit file list in the project (project_replace)" + }, + { + "name": "git.write", + "description": "Mutate git state from an agent: stage, unstage, discard, checkout, cherry-pick, revert" } ] } diff --git a/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/AgentReviewPromptTest.kt b/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/AgentReviewPromptTest.kt index 05faf09..8b3c725 100644 --- a/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/AgentReviewPromptTest.kt +++ b/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/AgentReviewPromptTest.kt @@ -53,4 +53,4 @@ class AgentReviewPromptTest { val prompt = AgentReviewPrompt.build("/p", emptyList(), unstaged, null) assertTrue("git_diff" in prompt) } -} \ No newline at end of file +} diff --git a/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/CodebaseGitMcpToolsTest.kt b/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/CodebaseGitMcpToolsTest.kt new file mode 100644 index 0000000..c4720bc --- /dev/null +++ b/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/CodebaseGitMcpToolsTest.kt @@ -0,0 +1,260 @@ +package ai.rever.boss.plugin.dynamic.codebase + +import ai.rever.boss.plugin.api.FileMatch +import ai.rever.boss.plugin.api.GitCommitInfoData +import ai.rever.boss.plugin.api.GitDataProvider +import ai.rever.boss.plugin.api.GitDiffData +import ai.rever.boss.plugin.api.GitFileStatusData +import ai.rever.boss.plugin.api.GitFileStatusTypeData as T +import ai.rever.boss.plugin.api.GitOperationResultData +import ai.rever.boss.plugin.api.McpToolArgs +import ai.rever.boss.plugin.api.McpToolResult +import ai.rever.boss.plugin.api.ProjectSearchProvider +import ai.rever.boss.plugin.api.ReplaceSummary +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.runBlocking + +/** + * The parts of [CodebaseGitMcpToolProvider] that an agent can actually hit, + * pinned against fakes: the RBAC tier on every tool, argument validation, + * diff result truncation, multi-file diff rendering, and the two accepted + * spellings of the `files` argument. + */ +class CodebaseGitMcpToolsTest { + + private val git = FakeGitProvider() + private val search = FakeSearchProvider() + private val tools = CodebaseGitMcpToolProvider("codebase", git, search).tools() + + private fun tool(name: String) = tools.first { it.name == name } + + private fun invoke(name: String, args: Map): McpToolResult = + runBlocking { tool(name).handler.call(McpToolArgs(args)) } + + // ---- RBAC tiers -------------------------------------------------------- + + @Test + fun `mutating git tools sit behind git write, reads stay open`() { + listOf( + "git_stage", "git_unstage", "git_stage_all", "git_unstage_all", + "git_discard", "git_checkout", "git_cherry_pick", "git_revert", + ).forEach { name -> + assertEquals(listOf("git.write"), tool(name).requiredPermissions, name) + assertFalse(tool(name).readOnly, name) + } + listOf( + "git_status", "git_log", "git_diff", "git_diff_all", + "git_diff_ref", "git_diff_between", "project_search", + ).forEach { name -> + assertTrue(tool(name).requiredPermissions.isEmpty(), name) + assertTrue(tool(name).readOnly, name) + } + assertEquals(listOf("project.replace"), tool("project_replace").requiredPermissions) + assertFalse(tool("project_replace").readOnly) + } + + // ---- argument validation ------------------------------------------------ + + @Test + fun `missing required arguments are clean errors, not exceptions`() { + listOf( + "git_diff" to "path", + "git_diff_ref" to "ref", + "git_diff_between" to "from", + "git_discard" to "path", + "project_search" to "query", + "project_replace" to "query", + ).forEach { (name, arg) -> + val result = invoke(name, emptyMap()) + assertTrue(result.isError, name) + assertTrue(result.text.contains(arg), "$name said: ${result.text}") + } + } + + @Test + fun `a blank files list is refused before it reaches the provider`() { + val result = + invoke( + "project_replace", + mapOf("query" to "a", "replacement" to "b", "files" to " , "), + ) + assertTrue(result.isError) + assertTrue(result.text.contains("files"), result.text) + assertTrue(search.replaceCalls.isEmpty()) + } + + // ---- diff rendering ------------------------------------------------------ + + @Test + fun `an oversized diff result is truncated, not returned whole`() { + git.diffs["big"] = listOf(GitDiffData(path = "big", rawUnified = "x".repeat(100_000))) + val result = invoke("git_diff", mapOf("path" to "big")) + assertTrue(result.text.contains("truncated"), result.text.take(200)) + assertTrue(result.text.length < 70_000) + } + + @Test + fun `a multi-file diff names how many files the answer holds`() { + // The one-file-of-N rendering already bit an agent once: the result + // must announce the set it is showing. + git.diffs["abc123"] = + listOf(GitDiffData(path = "a.kt", rawUnified = "+one"), GitDiffData(path = "b.kt", rawUnified = "+two")) + val result = invoke("git_diff_ref", mapOf("ref" to "abc123")) + assertTrue(result.text.startsWith("2 files changed"), result.text.take(40)) + assertTrue(result.text.contains("a.kt")) + assertTrue(result.text.contains("b.kt")) + } + + @Test + fun `git log without a provider reports unavailability, not a crash`() { + val none = CodebaseGitMcpToolProvider("codebase", null, null).tools() + val result = + runBlocking { + none.first { it.name == "git_log" }.handler.call(McpToolArgs(emptyMap())) + } + assertTrue(result.isError) + assertTrue(result.text.contains("unavailable"), result.text) + } + + // ---- files argument spellings --------------------------------------------- + + @Test + fun `the files argument accepts a JSON array so paths may contain commas`() { + val result = + invoke( + "project_replace", + mapOf("query" to "a", "replacement" to "b", "files" to """["a.kt", "b, c.kt"]"""), + ) + assertFalse(result.isError, result.text) + assertEquals(listOf("a.kt", "b, c.kt"), search.replaceCalls.single().files) + } + + @Test + fun `the files argument still accepts the flat comma form`() { + invoke( + "project_replace", + mapOf("query" to "a", "replacement" to "b", "files" to "a.kt, b.kt\n c.kt"), + ) + assertEquals(listOf("a.kt", "b.kt", "c.kt"), search.replaceCalls.single().files) + } + + @Test + fun `a malformed array falls back to the flat form rather than guessing`() { + // `["a.kt` - unbalanced. The scanner must not half-parse it into one + // invented path; flat splitting of the whole string is the fallback. + val malformed = "[\"a.kt" + invoke( + "project_replace", + mapOf("query" to "a", "replacement" to "b", "files" to malformed), + ) + assertEquals(listOf(malformed), search.replaceCalls.single().files) + } + + @Test + fun `git status renders every status type with its porcelain char`() { + // statusChar is an exhaustive when; pinning all eight arms (plus the + // empty index/work-tree cells) keeps a renamed enum value from + // silently rendering as a blank. + git.status.value = listOf( + GitFileStatusData("m.kt", T.MODIFIED, null, isStaged = true, isUnstaged = false), + GitFileStatusData("a.kt", null, T.ADDED, isStaged = false, isUnstaged = true), + GitFileStatusData("d.kt", T.DELETED, null, isStaged = true, isUnstaged = false), + GitFileStatusData("r.kt", T.RENAMED, null, isStaged = true, isUnstaged = false), + GitFileStatusData("c.kt", T.COPIED, null, isStaged = true, isUnstaged = false), + GitFileStatusData("u.kt", null, T.UNTRACKED, isStaged = false, isUnstaged = true), + GitFileStatusData("i.kt", null, T.IGNORED, isStaged = false, isUnstaged = true), + GitFileStatusData("x.kt", T.UNMERGED, T.UNMERGED, isStaged = false, isUnstaged = true), + ) + val result = invoke("git_status", emptyMap()) + assertFalse(result.isError, result.text) + listOf("M m.kt", " A a.kt", "D d.kt", "R r.kt", "C c.kt", "?? u.kt", "!! i.kt", "UU x.kt") + .forEach { line -> assertTrue(result.text.contains(line), "missing $line in:\n${result.text}") } + } + + // ---- fakes --------------------------------------------------------------- + + private class FakeGitProvider : GitDataProvider { + val status = MutableStateFlow(emptyList()) + val diffs = mutableMapOf>() + + override val fileStatus: StateFlow> = status + override val commitLog: StateFlow> = MutableStateFlow(emptyList()) + override val isGitRepository: StateFlow = MutableStateFlow(true) + override val isLoading: StateFlow = MutableStateFlow(false) + + override suspend fun refreshStatus() {} + + override suspend fun refreshLog(limit: Int) {} + + override suspend fun stage(filePath: String) = GitOperationResultData.Success() + + override suspend fun unstage(filePath: String) = GitOperationResultData.Success() + + override suspend fun stageAll() = GitOperationResultData.Success() + + override suspend fun unstageAll() = GitOperationResultData.Success() + + override suspend fun discardChanges(filePath: String) = GitOperationResultData.Success() + + override suspend fun cherryPick(commitHash: String) = GitOperationResultData.Success() + + override suspend fun revert(commitHash: String) = GitOperationResultData.Success() + + override suspend fun checkout(ref: String) = GitOperationResultData.Success() + + override fun getCurrentProjectPath(): String? = "/repo" + + override fun openFile(filePath: String, windowId: String) {} + + override suspend fun diffFile(path: String, staged: Boolean): List = diffs[path].orEmpty() + + override suspend fun diffRef(ref: String, path: String?): List = diffs[ref].orEmpty() + + override suspend fun diffNames(staged: Boolean): List = + listOf( + GitFileStatusData( + path = "a.kt", + indexStatus = T.MODIFIED, + workTreeStatus = null, + isStaged = true, + isUnstaged = false, + ), + ) + } + + private class ReplaceCall( + val files: List, + ) + + private class FakeSearchProvider : ProjectSearchProvider { + val replaceCalls = mutableListOf() + + override suspend fun searchInProject( + query: String, + pathPattern: String?, + excludePattern: String?, + isRegex: Boolean, + caseSensitive: Boolean, + wholeWord: Boolean, + maxResults: Int, + ) = emptyList() + + override suspend fun replaceInProject( + query: String, + replacement: String, + files: List, + isRegex: Boolean, + caseSensitive: Boolean, + wholeWord: Boolean, + dryRun: Boolean, + ): ReplaceSummary { + replaceCalls += ReplaceCall(files) + return ReplaceSummary(filesReplaced = 0, totalReplacements = 0, dryRun = dryRun) + } + } +} diff --git a/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitGraphLayoutTest.kt b/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitGraphLayoutTest.kt index b06a0b8..3d4072c 100644 --- a/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitGraphLayoutTest.kt +++ b/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitGraphLayoutTest.kt @@ -83,4 +83,4 @@ class GitGraphLayoutTest { assertEquals(commits.size, lanes.size) assertEquals(IntArray(commits.size) { 0 }.toList(), lanes) } -} \ No newline at end of file +} diff --git a/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/PanelUiLogicTest.kt b/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/PanelUiLogicTest.kt index da251d7..3bfdad9 100644 --- a/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/PanelUiLogicTest.kt +++ b/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/PanelUiLogicTest.kt @@ -188,7 +188,7 @@ class PanelUiLogicTest { deep = true, baseRef = "main", ) - assertTrue(prompt.contains("against `main`"), prompt.take(200)) + assertTrue(prompt.contains("ahead of landing them on `main`"), prompt.take(200)) assertTrue(prompt.contains("Go deep"), prompt.take(400)) assertTrue(prompt.contains("focus on the parser"), prompt.take(600)) } diff --git a/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/SearchPathTest.kt b/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/SearchPathTest.kt index 29ca610..6422a99 100644 --- a/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/SearchPathTest.kt +++ b/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/SearchPathTest.kt @@ -1,5 +1,6 @@ package ai.rever.boss.plugin.dynamic.codebase +import kotlin.test.AfterTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue @@ -15,8 +16,22 @@ import kotlin.test.assertTrue */ class SearchPathTest { - private fun vm(root: String?) = - CodebaseSearchViewModel(provider = null, splitViewOperations = null, getProjectPath = { root }) + private val viewModels = mutableListOf() + + @AfterTest + fun disposeAll() { + // CodebaseSearchViewModel starts a CoroutineScope in its constructor; + // without this, each vm() below leaks a scope for the JVM's lifetime. + viewModels.forEach { it.dispose() } + viewModels.clear() + } + + private fun vm(root: String?): CodebaseSearchViewModel { + val viewModel = + CodebaseSearchViewModel(provider = null, splitViewOperations = null, getProjectPath = { root }) + viewModels += viewModel + return viewModel + } // ---- relative -> absolute -------------------------------------------- From 4add2d271b6dfe43878ed93203942bb66aac102c Mon Sep 17 00:00:00 2001 From: Shivanshu Date: Mon, 31 Aug 2026 20:46:42 -0700 Subject: [PATCH 3/8] fix: publish checked-out branch with the graph it decorates, and wait on the reloaded graph in the return-to-HEAD test --- .../kotlin/ai/rever/boss/plugin/dynamic/codebase/GitTab.kt | 6 +++++- .../plugin/dynamic/codebase/GitGraphBranchViewModelTest.kt | 4 +++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitTab.kt b/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitTab.kt index 28e979c..682d599 100644 --- a/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitTab.kt +++ b/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitTab.kt @@ -280,13 +280,17 @@ class CodebaseGitViewModel( _message.value = "Failed to load git history: ${e.message}" null } ?: emptyList() - _graph.value = nodes // The HEAD decoration only names the checked-out branch while // the graph IS HEAD's; another branch's tip carries no // `HEAD ->`, and reading it there blanked the toolbar. + // Publish it BEFORE the graph: the name is part of reading + // this graph, and a consumer that observes a non-empty graph + // (the test does, on its own thread) must never see it + // without the branch name - the reverse order raced on CI. if (ref == null) { branchOf(nodes).takeIf { it.isNotEmpty() }?.let { _currentBranch.value = it } } + _graph.value = nodes val found = branchesOf(nodes) _branches.value = found if (_reviewBase.value.isBlank()) { diff --git a/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitGraphBranchViewModelTest.kt b/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitGraphBranchViewModelTest.kt index 4293798..8065a9b 100644 --- a/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitGraphBranchViewModelTest.kt +++ b/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitGraphBranchViewModelTest.kt @@ -187,7 +187,9 @@ class GitGraphBranchViewModelTest { awaitUntil("the side graph") { vm.graphRef.value == "side" } vm.showCurrentBranch() - awaitUntil("the return to HEAD") { vm.graphRef.value == null } + awaitUntil("the return to HEAD") { + vm.graphRef.value == null && vm.graph.value.map { it.hash } == listOf("h1") + } assertEquals(listOf("h1"), vm.graph.value.map { it.hash }) } From 5b2cf47def75b2247c9f85aaa31e23d2ec01956f Mon Sep 17 00:00:00 2001 From: Shivanshu Date: Mon, 31 Aug 2026 21:28:13 -0700 Subject: [PATCH 4/8] review: round 2 - diff budget, modal sheets, project switch, MCP ref safety --- .../dynamic/codebase/AgentReviewPrompt.kt | 21 +++--- .../dynamic/codebase/CodebaseComponent.kt | 33 +++++++-- .../dynamic/codebase/CodebaseGitMcpTools.kt | 66 +++++++++++++----- .../plugin/dynamic/codebase/CodebaseUi.kt | 17 +++-- .../boss/plugin/dynamic/codebase/GitTab.kt | 67 +++++++++++++++++-- .../boss/plugin/dynamic/codebase/GitTabUi.kt | 54 ++++++++++++--- .../boss/plugin/dynamic/codebase/SearchTab.kt | 36 +++++++++- .../META-INF/boss-plugin/plugin.json | 2 +- .../dynamic/codebase/AgentReviewPromptTest.kt | 12 ++-- .../codebase/CodebaseGitMcpToolsTest.kt | 31 +++++++++ .../dynamic/codebase/PanelUiLogicTest.kt | 10 ++- 11 files changed, 289 insertions(+), 60 deletions(-) diff --git a/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/AgentReviewPrompt.kt b/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/AgentReviewPrompt.kt index a8e951c..065bc1f 100644 --- a/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/AgentReviewPrompt.kt +++ b/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/AgentReviewPrompt.kt @@ -11,11 +11,12 @@ import ai.rever.boss.plugin.api.GitFileStatusData */ object AgentReviewPrompt { - /** Inline diffs up to this many characters; above it, list the files and - * point the agent at the git_diff tools instead (keeps the prompt bounded). - */ + /** Character budget the caller passes to the diff collector. */ const val INLINE_DIFF_BUDGET = 15_000 + /** Marker the diff collector appends when a block had to be sliced. */ + const val TRUNCATION_MARKER = "… [truncated]" + fun build( projectPath: String, staged: List, @@ -77,12 +78,16 @@ object AgentReviewPrompt { if (diffText.isNullOrBlank()) { sb.appendLine("The diff could not be fetched inline; use the git_diff / git_diff_all tools to read it.") - } else if (diffText.length >= INLINE_DIFF_BUDGET) { - sb.appendLine( - "The full diff exceeds the inline budget (${diffText.length} chars); " + - "use the git_diff / git_diff_ref tools to read the individual file diffs.", - ) } else { + // Inline whatever the caller budgeted - do NOT re-test against + // INLINE_DIFF_BUDGET here. The collector truncates to fit but then + // appends a per-file header and the marker, so a truncated result + // lands a few characters OVER the budget. Re-testing would replace + // that text with a tool pointer and drop the diff exactly in the + // case the truncation exists to serve: one oversized file. + if (diffText.contains(TRUNCATION_MARKER)) { + sb.appendLine("(diff truncated to the inline budget; use the git_diff / git_diff_ref tools for the rest)") + } sb.appendLine("Full diff:") sb.appendLine("```diff") sb.appendLine(diffText) diff --git a/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/CodebaseComponent.kt b/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/CodebaseComponent.kt index 9139243..7c19236 100644 --- a/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/CodebaseComponent.kt +++ b/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/CodebaseComponent.kt @@ -53,6 +53,7 @@ import com.arkivanov.decompose.ComponentContext import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -140,22 +141,35 @@ class CodebaseComponent( // The reads are blocking I/O - and the tab-switch write below is // already off the UI thread, so keep this path consistent: a // blocking storage implementation must not stall the first frame. - val saved = withContext(Dispatchers.Default) { storage?.getString("codebase.tab") } + val saved = withContext(Dispatchers.IO) { storage?.getString("codebase.tab") } selectedTab = CodebaseTab.fromStorage(saved) // The change-group layout was a `remember` inside the GIT tab, so // it reset on every hop to FILES and back. It is a preference; // persist it beside the selected tab. Seeded BEFORE the collector // below starts, so the load is not immediately overwritten. gitViewModel.setChangeLayout( - withContext(Dispatchers.Default) { + withContext(Dispatchers.IO) { GitChangeLayout.fromStorage(storage?.getString("codebase.gitLayout")) }, ) gitViewModel.changeLayout.collect { layout -> - withContext(Dispatchers.Default) { + withContext(Dispatchers.IO) { storage?.putString("codebase.gitLayout", layout.storageKey) } } + gitViewModel.setSplitFraction( + withContext(Dispatchers.IO) { + storage?.getString("codebase.gitSplit")?.toFloatOrNull()?.coerceIn(0.15f, 0.85f) + ?: 0.55f + }, + ) + // Debounced: a drag emits a value per pointer move, and each + // emission would be a file write without the settle delay. + gitViewModel.splitFraction.debounce(300).collect { fraction -> + withContext(Dispatchers.IO) { + storage?.putString("codebase.gitSplit", fraction.toString()) + } + } } // The selected project is exposed as a plain getter, not a flow, so it // is sampled: the header has to follow a project switch made anywhere @@ -166,7 +180,16 @@ class CodebaseComponent( while (true) { delay(PROJECT_POLL_MS) val current = getProjectPath() - if (current != projectPath) projectPath = current + if (current != projectPath) { + projectPath = current + // GIT and SEARCH keep per-project state: the commit graph, + // branch chip and result tree all describe the project + // that was active when they loaded. Reset both against + // the new project instead of keeping the previous one on + // screen until a manual refresh. + gitViewModel.onProjectChanged() + searchViewModel.clear() + } } } @@ -174,7 +197,7 @@ class CodebaseComponent( CodebaseProjectHeader(projectPath) CodebaseTabStrip(selected = selectedTab) { tab -> selectedTab = tab - scope.launch(Dispatchers.Default) { + scope.launch(Dispatchers.IO) { storage?.putString("codebase.tab", tab.storageKey) } } diff --git a/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/CodebaseGitMcpTools.kt b/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/CodebaseGitMcpTools.kt index 89b1a67..c7756d5 100644 --- a/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/CodebaseGitMcpTools.kt +++ b/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/CodebaseGitMcpTools.kt @@ -102,6 +102,7 @@ internal class CodebaseGitMcpToolProvider( handler = McpToolHandler { args -> val ref = args.string("ref") ?: return@McpToolHandler missing("ref") + if (!GitBranchModel.isSafeRef(ref)) return@McpToolHandler unsafeRef(ref) op("Checked out $ref", gitProvider?.checkout(ref)) }, ), @@ -145,6 +146,7 @@ internal class CodebaseGitMcpToolProvider( handler = McpToolHandler { args -> val ref = args.string("ref") ?: return@McpToolHandler missing("ref") + if (!GitBranchModel.isSafeRef(ref)) return@McpToolHandler unsafeRef(ref) diffTexts(gitProvider?.diffRef(ref, args.string("path")).orEmpty()) }, ), @@ -157,8 +159,10 @@ internal class CodebaseGitMcpToolProvider( handler = McpToolHandler { args -> val from = args.string("from") ?: return@McpToolHandler missing("from") + if (!GitBranchModel.isSafeRef(from)) return@McpToolHandler unsafeRef(from) val to = args.string("to") ?: return@McpToolHandler missing("to") + if (!GitBranchModel.isSafeRef(to)) return@McpToolHandler unsafeRef(to) diffTexts(gitProvider?.diffBetween(from, to, args.string("path")).orEmpty()) }, ), @@ -231,6 +235,7 @@ internal class CodebaseGitMcpToolProvider( provider.searchInProject( query = query, pathPattern = args.string("pathPattern"), + excludePattern = args.string("excludePattern"), isRegex = args.boolean("isRegex") ?: false, caseSensitive = args.boolean("caseSensitive") ?: false, wholeWord = args.boolean("wholeWord") ?: false, @@ -294,39 +299,59 @@ internal class CodebaseGitMcpToolProvider( } /** - * Strict scan of `["a", "b, c", ...]`: double-quoted elements, backslash - * escapes, commas only between elements. Returns null for anything else - * (nesting, bare tokens, unbalanced quotes) so the caller falls back to the - * flat form rather than guessing at a partial parse. + * Strict scan of `["a", "b, c", ...]`: double-quoted elements with + * proper JSON unescaping, commas only between elements. Returns null + * for anything else (nesting, bare tokens, unbalanced quotes, unknown + * escape) so the caller falls back to the flat form rather than + * guessing at a partial parse. + * + * Unescaping matters for the main use case: the array form is the + * recommended carrier for awkward paths, and `["C:\\Users\\x.kt"]` + * must come back as `C:\Users\x.kt`, not with doubled backslashes. */ private fun parseJsonStringArray(text: String): List? { val body = text.substring(1, text.length - 1) val items = mutableListOf() val current = StringBuilder() var inString = false - var escaped = false - for (c in body) { + var i = 0 + while (i < body.length) { + val c = body[i] when { - escaped -> { - current.append(c) - escaped = false - } inString && c == '\\' -> { - current.append(c) - escaped = true + i++ + if (i >= body.length) return null + when (val esc = body[i]) { + '"' -> current.append('"') + '\\' -> current.append('\\') + '/' -> current.append('/') + 'n' -> current.append('\n') + 't' -> current.append('\t') + 'r' -> current.append('\r') + 'b' -> current.append('\b') + 'f' -> current.append(0x0C.toChar()) + 'u' -> { + if (i + 4 >= body.length) return null + val code = body.substring(i + 1, i + 5).toIntOrNull(16) ?: return null + current.append(code.toChar()) + i += 4 + } + else -> return null + } } c == '"' -> inString = !inString c == ',' && !inString -> { - items += current.toString().trim().removeSurrounding("\"") + items += current.toString().trim() current.clear() } !inString && c.isWhitespace() -> Unit inString -> current.append(c) else -> return null } + i++ } - if (inString || escaped) return null - items += current.toString().trim().removeSurrounding("\"") + if (inString) return null + items += current.toString().trim() return if (items.all { it.isNotEmpty() }) items else null } @@ -444,6 +469,15 @@ internal class CodebaseGitMcpToolProvider( private fun missing(name: String): McpToolResult = McpToolResult("Missing required argument: $name", isError = true) + /** + * LLM-supplied refs cross the plugin API, just like the graph picker's + * branch name: the UI path checks [GitBranchModel.isSafeRef] precisely + * because of that, and the MCP path must too. A leading dash would + * become a git flag, and control characters do not survive IPC. + */ + private fun unsafeRef(ref: String): McpToolResult = + McpToolResult("Failed: \"$ref\" is not a usable ref (no blank, leading dash, or control characters).", isError = true) + private fun McpToolArgs.path(): String? = string("path") private fun McpToolArgs.hash(): String? = string("hash") @@ -482,7 +516,7 @@ internal class CodebaseGitMcpToolProvider( const val DIFF_BETWEEN_SCHEMA = """{"type":"object","properties":{"from":{"type":"string","description":"Source ref."},"to":{"type":"string","description":"Target ref."},"path":{"type":"string","description":"Optional single file to restrict to."}},"required":["from","to"]}""" const val SEARCH_SCHEMA = - """{"type":"object","properties":{"query":{"type":"string","description":"Literal text, or a regular expression when isRegex=true."},"pathPattern":{"type":"string","description":"Optional glob filter on the project-relative path (e.g. **/*.kt)."},"isRegex":{"type":"boolean","description":"Treat query as a regular expression (default false)."},"caseSensitive":{"type":"boolean","description":"Case-sensitive matching (default false)."},"wholeWord":{"type":"boolean","description":"Whole-word match only (default false)."},"maxResults":{"type":"integer","description":"Hard cap on returned matches (default 100)."}},"required":["query"]}""" + """{"type":"object","properties":{"query":{"type":"string","description":"Literal text, or a regular expression when isRegex=true."},"pathPattern":{"type":"string","description":"Optional glob filter on the project-relative path (e.g. **/*.kt)."},"excludePattern":{"type":"string","description":"Optional glob filter for paths to EXCLUDE (e.g. **/build/**). Applied inside the engine, so excluded matches do not consume the maxResults cap."},"isRegex":{"type":"boolean","description":"Treat query as a regular expression (default false)."},"caseSensitive":{"type":"boolean","description":"Case-sensitive matching (default false)."},"wholeWord":{"type":"boolean","description":"Whole-word match only (default false)."},"maxResults":{"type":"integer","description":"Hard cap on returned matches (default 100)."}},"required":["query"]}""" const val REPLACE_SCHEMA = """{"type":"object","properties":{"query":{"type":"string","description":"Literal text, or a regular expression when isRegex=true."},"replacement":{"type":"string","description":"Replacement text; ${'$'}1..${'$'}9 capture references for regex queries."},"files":{"type":"string","description":"Comma-separated file paths to touch, or a JSON array of path strings - use the array form when a path contains a comma. Project-relative, or absolute inside the project. Paths outside the project are refused. Never empty."},"isRegex":{"type":"boolean","description":"Treat query as a regular expression (default false)."},"caseSensitive":{"type":"boolean","description":"Case-sensitive matching (default false)."},"wholeWord":{"type":"boolean","description":"Whole-word match only (default false)."},"dryRun":{"type":"boolean","description":"Count without writing (default true)."}},"required":["query","replacement","files"]}""" } diff --git a/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/CodebaseUi.kt b/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/CodebaseUi.kt index 8c59175..0d03dbb 100644 --- a/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/CodebaseUi.kt +++ b/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/CodebaseUi.kt @@ -27,7 +27,6 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.material.Icon -import androidx.compose.material.MaterialTheme import androidx.compose.material.Surface import androidx.compose.material.Text import androidx.compose.material.icons.Icons @@ -59,7 +58,7 @@ import androidx.compose.ui.unit.sp import ai.rever.boss.plugin.ui.BossColors import ai.rever.boss.plugin.ui.BossThemeColors import androidx.compose.foundation.clickable -import androidx.compose.runtime.MutableState +import androidx.compose.runtime.State import androidx.compose.runtime.mutableStateOf import java.awt.Cursor @@ -597,11 +596,13 @@ fun CodebaseToggleChip( /** * A draggable horizontal splitter. [fraction] is the top pane's share of the * available height, clamped to [min]..[max] so neither pane can be dragged - * away entirely. + * away entirely. [onChanged] carries each new fraction to the owner, which + * owns persistence - the splitter itself is stateless. */ @Composable fun CodebaseSplitter( - fraction: MutableState, + fraction: State, + onChanged: (Float) -> Unit, totalHeightPx: Float, min: Float = 0.15f, max: Float = 0.85f, @@ -618,7 +619,7 @@ fun CodebaseSplitter( detectDragGestures { _, dragAmount -> if (totalHeightPx <= 0f) return@detectDragGestures val next = fraction.value + dragAmount.y / totalHeightPx - fraction.value = next.coerceIn(min, max) + onChanged(next.coerceIn(min, max)) } }, contentAlignment = Alignment.Center, @@ -671,6 +672,10 @@ fun CodebaseEmptyState( * name reads at full contrast, its directory trails behind it muted. */ internal fun splitPathForDisplay(path: String): Pair { - val idx = path.lastIndexOf('/') + // The provider's paths are project-relative POSIX today, but the display + // split must not assume that forever: take the LAST separator of either + // family, so a Windows path ever arriving here still renders its file + // name, not the whole path. + val idx = maxOf(path.lastIndexOf('/'), path.lastIndexOf('\\')) return if (idx < 0) path to "" else path.substring(idx + 1) to path.substring(0, idx) } diff --git a/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitTab.kt b/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitTab.kt index 682d599..3c566a1 100644 --- a/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitTab.kt +++ b/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitTab.kt @@ -46,7 +46,9 @@ class CodebaseGitViewModel( /** Why AI is unavailable, or null when it is ready. */ private val aiUnavailable: () -> String? = { null }, ) { - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + // Blocking git/IPC and storage I/O, not CPU-bound work: Default is + // sized to cores and these calls would block it. + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) val providerOrNull: GitDataProvider? get() = git @@ -115,6 +117,21 @@ class CodebaseGitViewModel( _changeLayout.value = _changeLayout.value.toggled() } + /** + * Splitter position: the top pane's share of the graph/changes split. + * + * Same reason as [changeLayout]: a `remember` inside the tab resets on + * every hop to FILES and back, and this is the user's layout, not an + * accident of composition. The splitter clamps to 0.15..0.85; the setter + * re-clamps so a future caller cannot pin a pane at zero. + */ + private val _splitFraction = MutableStateFlow(0.55f) + val splitFraction: StateFlow = _splitFraction.asStateFlow() + + fun setSplitFraction(value: Float) { + _splitFraction.value = value.coerceIn(0.15f, 0.85f) + } + // ---- agent review options ---- private val _reviewInstructions = MutableStateFlow("") @@ -260,6 +277,21 @@ class CodebaseGitViewModel( statusTimer = null } + fun onProjectChanged() { + if (git == null) return + // Everything loaded in init describes the repository that was active + // THEN. Reset the stale parts and re-read the new project now, rather + // than leaving the graph, branch chip and change groups of the old + // project on screen until someone hits refresh. + _currentBranch.value = "" + _branchOptions.value = emptyList() + _graph.value = emptyList() + _graphRef.value = null + _reviewBase.value = "" + refreshStatus() + loadGraph(reset = true) + } + fun loadGraph(reset: Boolean = false) { scope.launch { _graphBusy.value = true @@ -291,7 +323,7 @@ class CodebaseGitViewModel( branchOf(nodes).takeIf { it.isNotEmpty() }?.let { _currentBranch.value = it } } _graph.value = nodes - val found = branchesOf(nodes) + val found = branchesOf(nodes, _remoteNames.value) _branches.value = found if (_reviewBase.value.isBlank()) { _reviewBase.value = defaultBase(found, _currentBranch.value) @@ -600,8 +632,19 @@ class CodebaseGitViewModel( // bounds real content instead of context padding. val block = compactDiff(diff).ifBlank { continue } if (sb.isNotEmpty() && sb.length + block.length > budget) break - val remaining = budget - sb.length - val slice = if (block.length > remaining) block.take(remaining) + "\n… [truncated]\n" else block + // Reserve the per-file header and the truncation marker, so the + // framing never pushes the result past [budget]: the prompt + // builder used to treat an over-budget result as "too large" and + // drop it, which voided the truncation exactly when one happened. + val frame = "--- ${f.path}\n".length + AgentReviewPrompt.TRUNCATION_MARKER.length + 2 + if (sb.length + frame >= budget) break + val remaining = budget - sb.length - frame + val slice = + if (block.length > remaining) { + block.take(remaining) + "\n" + AgentReviewPrompt.TRUNCATION_MARKER + "\n" + } else { + block + } sb.append("--- ").append(f.path).append('\n').append(slice).append('\n') } return sb.toString() @@ -740,15 +783,25 @@ class CodebaseGitViewModel( /** * Every branch named in a graph's ref decorations, de-duplicated and - * with remote copies folded onto their local name. + * with remote copies folded onto their local name. [remoteNames] is + * the repository's real remote set (not a hardcoded "origin"): a + * clone from `upstream/feat/x` folds too, while a local branch that + * merely looks like `someone/something` does not. */ - internal fun branchesOf(nodes: List): List = + internal fun branchesOf(nodes: List, remoteNames: Set): List = nodes .asSequence() .flatMap { it.refs.asSequence() } .map { it.removePrefix("HEAD ->").trim() } .filterNot { it.isBlank() || it == "HEAD" || it.startsWith("tag:") } - .map { ref -> ref.substringAfter('/', ref).takeIf { ref.startsWith("origin/") } ?: ref } + .map { ref -> + val slash = ref.indexOf('/') + if (slash in 1 until ref.length && ref.substring(0, slash) in remoteNames) { + ref.substring(slash + 1) + } else { + ref + } + } .distinct() .sorted() .toList() diff --git a/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitTabUi.kt b/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitTabUi.kt index 4e1349f..8e65aeb 100644 --- a/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitTabUi.kt +++ b/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitTabUi.kt @@ -64,6 +64,7 @@ import androidx.compose.material.icons.rounded.Remove import androidx.compose.material.icons.automirrored.rounded.Undo import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -72,6 +73,9 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.focusTarget import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.key.Key @@ -81,6 +85,8 @@ import androidx.compose.ui.input.key.isMetaPressed import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.type +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontFamily @@ -143,8 +149,14 @@ fun CodebaseGitContent( var untrackedOpen by remember { mutableStateOf(true) } var graphOpen by remember { mutableStateOf(true) } var confirm by remember { mutableStateOf(null) } + // Deliberately shared across the STAGED / CHANGES / UNTRACKED groups: + // the same directory is the same directory on all three sides, so its + // collapsed state is one preference, not three. var collapsedDirs by remember { mutableStateOf(emptySet()) } - val splitFraction = remember { mutableStateOf(0.55f) } + // In the view model, not a `remember`: the tab composable tears down on + // every tab hop, and a layout preference should not reset with it. + val splitFractionState = viewModel.splitFraction.collectAsState() + val splitFraction by splitFractionState val staged = remember(fileStatus) { fileStatus.filter { it.isStaged } } val changed = remember(fileStatus) { fileStatus.filter { it.isUnstaged && !it.isUntracked } } @@ -252,7 +264,7 @@ fun CodebaseGitContent( // ── Changes over graph, split by a draggable divider ─────────── BoxWithConstraints(modifier = Modifier.weight(1f)) { val totalPx = with(LocalDensity.current) { maxHeight.toPx() } - val topWeight = if (graphOpen) splitFraction.value else 1f + val topWeight = if (graphOpen) splitFraction else 1f Column(modifier = Modifier.fillMaxSize()) { LazyColumn( modifier = Modifier.weight(topWeight).fillMaxWidth(), @@ -470,13 +482,13 @@ fun CodebaseGitContent( }, ) if (graphOpen) { - CodebaseSplitter(splitFraction, totalPx) + CodebaseSplitter(splitFractionState, viewModel::setSplitFraction, totalPx) GitGraphList( graph = graph, graphBusy = graphBusy, remoteNames = remoteNames, hasMore = viewModel.hasMoreGraph(), - modifier = Modifier.weight((1f - splitFraction.value).coerceAtLeast(0.05f)), + modifier = Modifier.weight((1f - splitFraction).coerceAtLeast(0.05f)), onOpen = { viewModel.openCommitDiff(it.hash) }, onAction = { confirm = it }, viewModel = viewModel, @@ -1066,10 +1078,16 @@ private fun GitCommitRow( CodebaseListRow(onClick = onClick, height = GRAPH_ROW_HEIGHT) { hovered -> GraphRowCanvas(row = graphRow, laneCount = laneCount) Spacer(Modifier.width(4.dp)) - CodebaseTooltip( - "${node.shortHash} ${node.subject}\n${node.author}", - modifier = Modifier.weight(1f), - ) { + // The row shows the relative age; the tooltip carries the absolute date + // formatCommitDate was written for. + val absoluteDate = formatCommitDate(node.date) + val tooltip = + if (absoluteDate.isNotEmpty()) "${node.shortHash} ${node.subject}\n${node.author} • $absoluteDate" + else "${node.shortHash} ${node.subject}\n${node.author}" + CodebaseTooltip( + tooltip, + modifier = Modifier.weight(1f), + ) { // Pills between the subject and the author, both of which give up // width to them: a branch head is the one thing on this row you // scan a graph FOR, and a long subject must not push it off the @@ -1445,10 +1463,30 @@ private fun GitConfirmDialog( onDismiss: () -> Unit, onConfirm: () -> Unit, ) { + // A modal sheet must EAT input: without a consuming pointer modifier the + // scrim never consumes the click, so the rows underneath stay live - with + // Discard and Push behind this, another row's "Discard changes" would fire + // while the confirmation is on screen. The Box also needs focus for the + // Escape handler to run: onPreviewKeyEvent on an unfocused node never + // fires. + val focusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { focusRequester.requestFocus() } Box( modifier = Modifier .fillMaxSize() .background(CodebaseScrim) + .pointerInput(Unit) { + awaitPointerEventScope { + while (true) { + val event = awaitPointerEvent() + if (event.type == PointerEventType.Press) { + event.changes.forEach { it.consume() } + } + } + } + } + .focusRequester(focusRequester) + .focusTarget() .onPreviewKeyEvent { e -> if (e.type == KeyEventType.KeyDown && e.key == Key.Escape) { onDismiss() diff --git a/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/SearchTab.kt b/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/SearchTab.kt index 710fa8b..72f5100 100644 --- a/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/SearchTab.kt +++ b/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/SearchTab.kt @@ -48,11 +48,14 @@ import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.type +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -82,7 +85,9 @@ class CodebaseSearchViewModel( private val splitViewOperations: SplitViewOperations?, private val getProjectPath: () -> String? = { null }, ) { - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + // Blocking content-scan I/O, not CPU-bound work: Default is sized to + // cores and these calls would block it. + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) val providerOrNull: ProjectSearchProvider? get() = provider @@ -198,7 +203,12 @@ class CodebaseSearchViewModel( while (isActive) { delay(REFRESH_MS) if (_query.value.isNotBlank() && !_busy.value && searchJob?.isActive != true) { - execute() + // Track the refresh in searchJob like every other scan: + // a clear/cancel that lands mid-scan must be able to + // reach it. A direct execute() here outran both, and + // the scan published the old query's results - plus + // _searched - a moment after the user cleared. + searchJob = scope.launch { execute() } } } } @@ -238,6 +248,9 @@ class CodebaseSearchViewModel( wholeWord = _wholeWord.value, maxResults = MAX_RESULTS, ) ?: emptyList() + // A clear or a newer query that landed mid-scan wins: publishing + // this result set would resurrect exactly what the clear removed. + if (_query.value != q) return _results.value = matches _grouped.value = groupMatchesByFile(matches) // Now an honest cap: the engine excluded during the walk, so hitting @@ -245,6 +258,10 @@ class CodebaseSearchViewModel( // that the page was spent on files about to be filtered out. _capped.value = matches.size >= MAX_RESULTS _searched.value = true + } catch (e: CancellationException) { + // Re-throw: a cancelled scan is not a search failure, and + // swallowing the cancel would also leave _busy stuck true. + throw e } catch (e: Exception) { // An in-progress regex ("(", "[a-") throws on every keystroke; // report it in the status line instead of clearing the results. @@ -905,10 +922,23 @@ private fun ConfirmReplaceSheet( onDismiss: () -> Unit, onApply: () -> Unit, ) { + // A modal sheet must EAT input: without a consuming pointer modifier the + // scrim never consumes the click, so the result rows underneath stay live + // while the confirmation is on screen. Box( modifier = Modifier .fillMaxSize() - .background(CodebaseScrim), + .background(CodebaseScrim) + .pointerInput(Unit) { + awaitPointerEventScope { + while (true) { + val event = awaitPointerEvent() + if (event.type == PointerEventType.Press) { + event.changes.forEach { it.consume() } + } + } + } + }, contentAlignment = Alignment.Center, ) { Column( diff --git a/src/main/resources/META-INF/boss-plugin/plugin.json b/src/main/resources/META-INF/boss-plugin/plugin.json index b752062..dfce74c 100644 --- a/src/main/resources/META-INF/boss-plugin/plugin.json +++ b/src/main/resources/META-INF/boss-plugin/plugin.json @@ -2,7 +2,7 @@ "manifestVersion": 1, "pluginId": "ai.rever.boss.plugin.dynamic.codebase", "displayName": "Codebase (Dynamic)", - "version": "1.0.10", + "version": "0.0.0-dev", "apiVersion": "1.0.87", "minApiVersion": "1.0.87", "minBossVersion": "9.5.7", diff --git a/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/AgentReviewPromptTest.kt b/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/AgentReviewPromptTest.kt index 8b3c725..8d3a3d8 100644 --- a/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/AgentReviewPromptTest.kt +++ b/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/AgentReviewPromptTest.kt @@ -3,7 +3,6 @@ package ai.rever.boss.plugin.dynamic.codebase import ai.rever.boss.plugin.api.GitFileStatusData import ai.rever.boss.plugin.api.GitFileStatusTypeData import kotlin.test.Test -import kotlin.test.assertFalse import kotlin.test.assertTrue /** Pins the review brief the Atlas agent receives. */ @@ -38,11 +37,16 @@ class AgentReviewPromptTest { } @Test - fun `diff over the budget is replaced by tool instructions`() { - val diff = "x".repeat(AgentReviewPrompt.INLINE_DIFF_BUDGET + 1) + fun `a truncated diff is inlined with a note, not dropped`() { + // The collector truncates to fit and then appends its own header and + // marker, so a truncated result can land slightly OVER the budget. + // The old length test replaced it with tool instructions - dropping + // the diff exactly in the case the truncation exists to serve. + val diff = "x".repeat(AgentReviewPrompt.INLINE_DIFF_BUDGET + 1) + "\n" + AgentReviewPrompt.TRUNCATION_MARKER + "\n" val unstaged = listOf(status("big.txt", false, GitFileStatusTypeData.MODIFIED)) val prompt = AgentReviewPrompt.build("/p", emptyList(), unstaged, diff) - assertFalse(diff in prompt, "the over-budget diff must not be inlined") + assertTrue(diff in prompt, "the truncated diff must still be inlined") + assertTrue("truncated" in prompt) assertTrue("git_diff" in prompt) assertTrue("[MODIFIED] big.txt" in prompt) } diff --git a/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/CodebaseGitMcpToolsTest.kt b/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/CodebaseGitMcpToolsTest.kt index c4720bc..9c14150 100644 --- a/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/CodebaseGitMcpToolsTest.kt +++ b/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/CodebaseGitMcpToolsTest.kt @@ -134,6 +134,37 @@ class CodebaseGitMcpToolsTest { assertEquals(listOf("a.kt", "b, c.kt"), search.replaceCalls.single().files) } + @Test + fun `a leading-dash ref is refused before it reaches the provider`() { + // git_checkout / git_diff_ref / git_diff_between take an LLM-supplied + // ref across the plugin API; a leading dash would become a git flag. + listOf( + "git_diff_ref" to mapOf("ref" to "-n"), + "git_diff_between" to mapOf("from" to "main", "to" to "--all"), + "git_checkout" to mapOf("ref" to "-b"), + ).forEach { (name, args) -> + val result = invoke(name, args) + assertTrue(result.isError, name) + assertTrue(result.text.contains("not a usable ref"), "$name said: ${result.text}") + } + } + + @Test + fun `a JSON array unescapes backslashes so Windows paths survive`() { + // The array form is the recommended carrier for awkward paths; the + // scanner used to append the backslash AND the escaped character, so + // every escape came back doubled and " stayed ". + invoke( + "project_replace", + mapOf( + "query" to "a", + "replacement" to "b", + "files" to "[\"C:\\\\Users\\\\me\\\\x.kt\", \"a\\\"b.kt\"]", + ), + ) + assertEquals(listOf("C:\\Users\\me\\x.kt", "a\"b.kt"), search.replaceCalls.single().files) + } + @Test fun `the files argument still accepts the flat comma form`() { invoke( diff --git a/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/PanelUiLogicTest.kt b/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/PanelUiLogicTest.kt index 3bfdad9..efe9a87 100644 --- a/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/PanelUiLogicTest.kt +++ b/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/PanelUiLogicTest.kt @@ -155,11 +155,17 @@ class PanelUiLogicTest { @Test fun `the branch list folds remote refs onto their local name`() { + // Folding follows the repository's real remote set, not a hardcoded + // "origin": upstream/x folds when the repo has an upstream remote, + // and a local someone/something never does. val branches = CodebaseGitViewModel.branchesOf( - listOf(node(listOf("HEAD -> feat/x", "origin/feat/x", "tag: v1", "main", "HEAD"))), + listOf( + node(listOf("HEAD -> feat/x", "origin/feat/x", "tag: v1", "main", "HEAD", "upstream/feat/x", "someone/something")), + ), + setOf("origin", "upstream"), ) - assertEquals(listOf("feat/x", "main"), branches) + assertEquals(listOf("feat/x", "main", "someone/something"), branches) } @Test From e5d0eeec0e3f04a77beeefd72c01d8f65de8be9d Mon Sep 17 00:00:00 2001 From: Shivanshu Date: Tue, 1 Sep 2026 10:31:36 +0530 Subject: [PATCH 5/8] review: round 3 - close out the remaining review findings Finishes the second review round and covers what it did not reach. Correctness - The storage LaunchedEffect ran three preferences in sequence, but a StateFlow collect never completes: everything after the first one was unreachable, so the splitter position was neither loaded nor saved. One effect per preference now. - loadGraph no longer latches "history exhausted" off a FAILED load. The error path yields an empty list too, and hiding "Load more" until the next reset is the wrong answer to a dropped IPC call. - Both modal scrims swallow every pointer event, not only Press, so the list behind a confirmation sheet cannot be scrolled either. Dead code the reviewers flagged - GitChangeTree.emit's prefixLabel was null at both call sites; the chain compaction is the local StringBuilder's job. - GitGraphLayout's ancestorCache was written once per key and never read: assignLanes visits each commit exactly once. - GitGraphEdges' comment described a stub leaving the last row that the code does not draw. - Unused items / PointerEventType imports, and a comment duplicated onto collapsedDirs. Tests: 178 -> 196 - GitViewModelOperationsTest pins the three things that only break across a seam, so neither side's own test saw them: the Agent Review diff budget (collector meets prompt builder), "Load more" exhaustion, and batch's first-error-wins rule. The diff-budget case was mutation-checked - reintroducing the length test fails it. - PluginManifestTest pins the version chain end to end (gradle -> the processResources filter -> plugin.json -> the value read back at runtime) and asserts every permission the git tools require is declared. - git_status is now pinned by whole LINE: contains("?? u.kt") passed just as happily on the misaligned " ?? u.kt" it was meant to catch. - git_log limit and project_search maxResults clamping, the empty JSON array, GitBranchModel.reviewBases, and collapseHome on a backslash- joined path (home is a parameter now, so it is testable off Windows). - Two tests leaked a CoroutineScope per run; both dispose now. Docs - README covered only the FILES tree while this release adds SEARCH and GIT, supersedes git-status and git-log, and ships 16 git_*/project_* tools. Adds those, the RBAC rationale, the release-ordering constraint, and corrects the stated minimums (9.2.20/1.0.66 -> 9.5.7/1.0.87). The tool table is cross-checked against the registrations. - AGENTS.md carried API version 1.0.20 and a provider list from before this panel existed. Verified: ./gradlew clean build green, 196 tests, jar manifest stamped 1.6.0. Co-Authored-By: Claude Opus 5 (1M context) --- AGENTS.md | 6 +- README.md | 81 +++++- build.gradle.kts | 12 +- .../dynamic/codebase/AgentReviewPrompt.kt | 15 +- .../dynamic/codebase/CodebaseComponent.kt | 57 +++-- .../dynamic/codebase/CodebaseDynamicPlugin.kt | 47 +++- .../dynamic/codebase/CodebaseGitMcpTools.kt | 80 +++++- .../plugin/dynamic/codebase/GitBranchModel.kt | 30 +++ .../plugin/dynamic/codebase/GitChangeTree.kt | 7 +- .../plugin/dynamic/codebase/GitGraphEdges.kt | 6 +- .../plugin/dynamic/codebase/GitGraphLayout.kt | 22 +- .../boss/plugin/dynamic/codebase/GitTab.kt | 88 +++++-- .../boss/plugin/dynamic/codebase/GitTabUi.kt | 53 ++-- .../boss/plugin/dynamic/codebase/SearchTab.kt | 239 +++++++++++------- .../codebase/CodebaseGitMcpToolsTest.kt | 79 +++++- .../dynamic/codebase/GitBranchModelTest.kt | 42 +++ .../dynamic/codebase/GitTabCompactDiffTest.kt | 8 + .../codebase/GitViewModelOperationsTest.kt | 210 +++++++++++++++ .../dynamic/codebase/PluginManifestTest.kt | 74 ++++++ .../plugin/dynamic/codebase/SearchPathTest.kt | 15 ++ 20 files changed, 961 insertions(+), 210 deletions(-) create mode 100644 src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitViewModelOperationsTest.kt create mode 100644 src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/PluginManifestTest.kt diff --git a/AGENTS.md b/AGENTS.md index 1bd5297..5d0e0bc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,11 +4,11 @@ **Codebase (Dynamic)** (`ai.rever.boss.plugin.dynamic.codebase`) is a dynamic plugin for the BOSS desktop application. -Browse and explore project files +FILES / SEARCH / GIT for the current project. - **Plugin ID**: `ai.rever.boss.plugin.dynamic.codebase` - **Main Class**: `ai.rever.boss.plugin.dynamic.codebase.CodebaseDynamicPlugin` -- **API Version**: 1.0.20 +- **API Version**: 1.0.87 (`minApiVersion`); BOSS >= 9.5.7 ## Essential Commands @@ -36,7 +36,7 @@ build.gradle.kts → Build config + version (single source of truth) - Entry point: `DynamicPlugin` interface with `register(context)` and `dispose()` - UI: `PanelComponentWithUI` with `@Composable Content()` - State: ViewModel pattern with `StateFlow` -- Providers from `PluginContext`: `workspaceDataProvider`, `splitViewOperations`, `contextMenuProvider`, `activeTabsProvider` +- Providers from `PluginContext`: `fileSystemDataProvider`, `splitViewOperations`, `contextMenuProvider`, `directoryPickerProvider`, `projectDataProvider`, `gitDataProvider`, `projectSearchProvider` - Null-safe provider access: providers may be null, UI must handle gracefully ### Dependencies diff --git a/README.md b/README.md index db80e00..4ef1fea 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,13 @@ # BOSS Codebase -The project file tree, in the left sidebar. +FILES, SEARCH and GIT, in the left sidebar. -An IntelliJ-style lazy tree over the host's `FileSystemDataProvider`, with multi-select, a full -context menu, and a background watcher that keeps the tree honest when files change on disk. -It also backs the `codebase_*` MCP tools. +Three tabs over the host's providers: an IntelliJ-style lazy file tree, global search and +replace, and a changes accordion with a true lane commit graph. It backs the `codebase_*`, +`git_*` and `project_*` MCP tools, and as of 1.6.0 it supersedes the standalone git-status and +git-log plugins. -## What it does +## FILES - **Lazy tree** with compacted middle packages, expand and collapse, and a per-node loading state, so a deep project does not stall on open. @@ -22,8 +23,36 @@ It also backs the `codebase_*` MCP tools. - **Project switching** through `ProjectDataProvider` and a directory picker. With no project open, the empty state offers "Open Project". +## SEARCH + +Global content search and replace over the host's `ProjectSearchProvider`. + +- **Include and exclude globs**, both applied *inside* the engine. Exclusion used to be a + filter over the returned list, which meant the result cap was reached before the exclude + ran - so excluding a busy directory quietly returned fewer results than existed. +- **Replace** is dry-run first: the confirmation sheet states what will change before anything + is written, and refuses to run at all while the search is capped, because "replace all" over + a truncated result set is a partial write with no error. +- Open buffers are searched and edited through the editor's undoable path; closed files are + written to disk. + +## GIT + +- **Changes accordion** over `GitDataProvider`: staged, unstaged and untracked groups, as a + list or a compacted tree, with stage / unstage / discard / open-diff row actions. Every + destructive action goes through a confirmation sheet. +- **Commit graph** with a real lane layout, ref pills, a branch picker, and row actions for + checkout, revert, cherry-pick and show-diff. +- **Commit box**, with an optional AI-drafted message built from what is actually staged. A + draft, never an automatic commit. +- **Agent Review** hands the fluck-agent a brief plus the uncommitted diff, bounded to an + inline budget. Commits already on the branch are not attached - the brief points the agent + at `git_diff_between` for those. + ## MCP tools +`codebase_*` - the file tree: + | Tool | Purpose | |---|---| | `codebase_tree` | List the file tree under a path (depth 1-6, default 2) | @@ -33,17 +62,53 @@ It also backs the `codebase_*` MCP tools. | `codebase_projects` | List recent projects | | `codebase_select_project` | Open or select a project by path | +`git_*` / `project_*` - absorbed from the retired git-status, git-log and search-replace +plugins. Names and schemas are carried over verbatim, so an agent that already knows them +needs no change: + +| Tool | Permission | Purpose | +|---|---|---| +| `git_status` | - | Working-tree status in porcelain `XY path` form | +| `git_log` | - | Recent commits (limit clamped to 1..500) | +| `git_diff` | - | One file's working-tree or staged diff | +| `git_diff_all` | - | Every changed file as `STATUS path` | +| `git_diff_ref` | - | The changes a single commit introduced | +| `git_diff_between` | - | The diff between two refs | +| `project_search` | - | Search file contents across the project | +| `git_stage` | `git.write` | Stage a file | +| `git_unstage` | `git.write` | Unstage a file | +| `git_stage_all` | `git.write` | Stage everything changed | +| `git_unstage_all` | `git.write` | Unstage everything | +| `git_discard` | `git.write` | Discard working-tree changes (irreversible) | +| `git_checkout` | `git.write` | Check out a commit, branch or tag | +| `git_cherry_pick` | `git.write` | Cherry-pick a commit | +| `git_revert` | `git.write` | Revert a commit | +| `project_replace` | `project.replace` | Replace across an explicit file list, dry-run by default | + +The RBAC split is deliberate. An empty `requiredPermissions` is not a neutral default: the +host's MCP registry exposes such a tool to every local session, including one where nobody is +signed in. Reads are open on that basis; anything that mutates git state or writes file +contents sits behind a grant. Admins bypass both. Refs arriving from an agent are checked +against the same `isSafeRef` rule the UI uses, so a leading dash cannot become a git flag. + On a host too old for `include_hidden`, `codebase_tree` still answers but adds a `note:` line saying the flag was ignored, rather than silently returning a filtered tree. ## Requirements -- BOSS >= 9.2.20, boss-plugin-api >= **1.0.66** (`minApiVersion`), needed for the - `FileSystemDataProvider.scanDirectory(path, showHidden)` overloads. +- BOSS >= **9.5.7**, boss-plugin-api >= **1.0.87** (`minApiVersion`), for `GitDataProvider`, + `ProjectSearchProvider` and the ref-scoped `logGraphFor`. - Host providers: `fileSystemDataProvider`, `contextMenuProvider`, `directoryPickerProvider`, - `splitViewOperations`, `projectDataProvider`. + `splitViewOperations`, `projectDataProvider`, `gitDataProvider`, `projectSearchProvider`. + Every one is optional at runtime: a missing provider degrades that tab to a hint, never a + crash. - No external binaries. +**Release ordering.** BossConsole retires git-status and git-log at `codebase >= 1.6.0`, so +this version must ship in the same wave as - or before - the host build carrying that floor. +The failure mode if it lands late is silent: those two panels are hidden and nothing replaces +them. + All scanning goes through the host provider. The plugin-local `LocalFileScanner` was deliberately retired, so there is no second code path that can disagree with the host. diff --git a/build.gradle.kts b/build.gradle.kts index 3143971..5a3afc3 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -121,10 +121,18 @@ tasks.processResources { // The filter rewrites the manifest version at execution time; declaring the // version as an input makes a version bump re-run the task. Without this the // processed manifest stays UP-TO-DATE and the new jar carries the old version. - inputs.property("pluginVersion", version) + val pluginVersion = version.toString() + inputs.property("pluginVersion", pluginVersion) filesMatching("**/plugin.json") { filter { line -> - line.replace(Regex(""""version"\s*:\s*"[^"]*""""), """"version": "\$version"""") + // The LAMBDA overload of replace, so the replacement is used + // verbatim. The String overload hands it to java.util.regex.Matcher, + // where a backslash escapes and a dollar is a group reference: the + // previous form emitted a literal backslash and only produced the + // right output because Matcher then swallowed it. It worked for + // every semver by accident, and the manifest version is now + // load-bearing (CodebaseDynamicPlugin reads it back at runtime). + Regex(""""version"\s*:\s*"[^"]*"""").replace(line) { """"version": "$pluginVersion"""" } } } } diff --git a/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/AgentReviewPrompt.kt b/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/AgentReviewPrompt.kt index 065bc1f..b2cac4e 100644 --- a/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/AgentReviewPrompt.kt +++ b/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/AgentReviewPrompt.kt @@ -26,9 +26,14 @@ object AgentReviewPrompt { instructions: String = "", /** Quick keeps it to a short pass; Deep asks for a thorough one. */ deep: Boolean = false, - /** The ref the change is being reviewed toward, e.g. "main". Context for - * the agent only - the attached diff is the uncommitted change, never - * a diff against this ref. + /** + * The ref the change is being reviewed toward, e.g. "main". + * + * The attached diff is the UNCOMMITTED change, never a diff against + * this ref - collecting `baseRef..HEAD` inline would blow the budget + * on any branch more than a few commits old. The brief says so, and + * points the agent at git_diff_between for the committed half, which + * it can fetch itself if the review needs it. */ baseRef: String = "", ): String { @@ -38,6 +43,10 @@ object AgentReviewPrompt { "Review these uncommitted changes (index + working tree) ahead of " + "landing them on `$baseRef`.", ) + sb.appendLine( + "Only the uncommitted change is attached. For the commits already on " + + "this branch, call git_diff_between with from=`$baseRef`, to=`HEAD`.", + ) } else { sb.appendLine("Review the uncommitted changes in this project (index + working tree vs HEAD).") } diff --git a/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/CodebaseComponent.kt b/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/CodebaseComponent.kt index 7c19236..9da8da1 100644 --- a/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/CodebaseComponent.kt +++ b/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/CodebaseComponent.kt @@ -53,7 +53,7 @@ import com.arkivanov.decompose.ComponentContext import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -137,12 +137,18 @@ class CodebaseComponent( } } + // Three separate collectors on purpose. A StateFlow collect never + // completes, so chaining these in one LaunchedEffect body left every + // statement after the first collect unreachable - the splitter + // position was neither loaded nor persisted. LaunchedEffect(Unit) { // The reads are blocking I/O - and the tab-switch write below is // already off the UI thread, so keep this path consistent: a // blocking storage implementation must not stall the first frame. val saved = withContext(Dispatchers.IO) { storage?.getString("codebase.tab") } selectedTab = CodebaseTab.fromStorage(saved) + } + LaunchedEffect(Unit) { // The change-group layout was a `remember` inside the GIT tab, so // it reset on every hop to FILES and back. It is a preference; // persist it beside the selected tab. Seeded BEFORE the collector @@ -157,15 +163,20 @@ class CodebaseComponent( storage?.putString("codebase.gitLayout", layout.storageKey) } } + } + LaunchedEffect(Unit) { gitViewModel.setSplitFraction( withContext(Dispatchers.IO) { - storage?.getString("codebase.gitSplit")?.toFloatOrNull()?.coerceIn(0.15f, 0.85f) - ?: 0.55f + storage?.getString("codebase.gitSplit")?.toFloatOrNull() ?: DEFAULT_SPLIT }, ) - // Debounced: a drag emits a value per pointer move, and each - // emission would be a file write without the settle delay. - gitViewModel.splitFraction.debounce(300).collect { fraction -> + // collectLatest, not collect: a splitter drag emits a value per + // pointer move and each one would be a file write. The pending + // write is cancelled by the next value, so only where the drag + // settled is stored. (collectLatest and not `debounce`, which is + // still a @FlowPreview API.) + gitViewModel.splitFraction.collectLatest { fraction -> + delay(SPLIT_SETTLE_MS) withContext(Dispatchers.IO) { storage?.putString("codebase.gitSplit", fraction.toString()) } @@ -232,13 +243,13 @@ class CodebaseComponent( */ @Composable private fun CodebaseProjectHeader(projectPath: String?) { - val hasProject = !projectPath.isNullOrEmpty() - val name = - if (hasProject) PathUtils.name(projectPath!!).ifEmpty { projectPath } else "No project" - val display = if (hasProject) collapseHome(projectPath!!) else "Open a folder in Files" + val path = projectPath.orEmpty() + val hasProject = path.isNotEmpty() + val name = if (hasProject) PathUtils.name(path).ifEmpty { path } else "No project" + val display = if (hasProject) collapseHome(path) else "Open a folder in Files" CodebaseTooltip( - text = if (hasProject) projectPath!! else "No project selected", + text = if (hasProject) path else "No project selected", modifier = Modifier.fillMaxWidth(), ) { Row( @@ -280,13 +291,23 @@ private fun CodebaseProjectHeader(projectPath: String?) { } } -/** `/Users/me/src/app` reads as `~/src/app` - the home prefix carries nothing. */ -internal fun collapseHome(path: String): String { - val home = System.getProperty("user.home")?.trimEnd('/').orEmpty() +/** + * `/Users/me/src/app` reads as `~/src/app` - the home prefix carries nothing. + * + * Separator taken from [PathUtils] rather than hardcoded to '/': these paths + * come from `File.absolutePath`, so on Windows both the home directory and + * the project path are backslash-joined and a '/'-only rule never matched. + */ +internal fun collapseHome( + path: String, + separator: Char = PathUtils.platformSeparator, + userHome: String? = System.getProperty("user.home"), +): String { + val home = userHome?.trimEnd(separator).orEmpty() if (home.isEmpty()) return path return when { path == home -> "~" - path.startsWith("$home/") -> "~" + path.removePrefix(home) + PathUtils.isNestedUnder(path, home, separator) -> "~" + path.removePrefix(home) else -> path } } @@ -298,6 +319,12 @@ internal fun collapseHome(path: String): String { */ private const val PROJECT_POLL_MS = 5_000L +/** Splitter position when storage holds none. Matches CodebaseSplitter's clamp midpoint. */ +private const val DEFAULT_SPLIT = 0.55f + +/** How long a splitter drag must settle before its position is written. */ +private const val SPLIT_SETTLE_MS = 300L + /** * The panel's tab strip: icon + label while the panel is wide enough, icons * alone once it is narrow. The panel is user-resizable, so a fixed three-up diff --git a/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/CodebaseDynamicPlugin.kt b/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/CodebaseDynamicPlugin.kt index bfb0615..7dcff4e 100644 --- a/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/CodebaseDynamicPlugin.kt +++ b/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/CodebaseDynamicPlugin.kt @@ -86,7 +86,7 @@ class CodebaseDynamicPlugin : DynamicPlugin { ) val window = getWindowId().orEmpty() pluginScope?.launch { - panelEvents?.openPanel(PanelId("atlas", 16), window) + panelEvents?.openPanel(PanelId(ATLAS_PANEL_ID, ATLAS_PANEL_ORDINAL), window) } } @@ -145,8 +145,10 @@ class CodebaseDynamicPlugin : DynamicPlugin { context.registerMcpToolProvider( CodebaseGitMcpToolProvider( providerId = "$pluginId.git", - gitProvider = gitDataProvider, - searchProvider = searchProvider, + // Suppliers, not values: see the constructor's KDoc. The + // gateway above is resolved per call for the same reason. + git = { gitDataProvider }, + search = { searchProvider }, ) ) } @@ -175,6 +177,15 @@ class CodebaseDynamicPlugin : DynamicPlugin { companion object { const val EVENT_ATLAS_REVIEW = "atlas.review" + /** + * The fluck-agent panel Agent Review raises. The ordinal is + * [PanelId]'s registration slot in the host, not a magic number this + * plugin picks - it has to match the value fluck-agent registers with + * or openPanel silently addresses nothing. + */ + private const val ATLAS_PANEL_ID = "atlas" + private const val ATLAS_PANEL_ORDINAL = 16 + /** Last-resort value when the bundled manifest cannot be read. */ private const val FALLBACK_VERSION = "0.0.0" @@ -186,18 +197,32 @@ class CodebaseDynamicPlugin : DynamicPlugin { * 1.5.8). */ private fun readManifestVersion(): String { - val stream = + // Every plugin's manifest lives at the SAME resource path, so + // getResourceAsStream returns whichever one the classloader + // reaches first - another plugin's, under parent-first delegation + // or the shared classloader of the in-process fallback mode this + // manifest declares. Walk them all and take the one whose + // pluginId is ours. + val resources = CodebaseDynamicPlugin::class.java.classLoader - ?.getResourceAsStream("META-INF/boss-plugin/plugin.json") + ?.getResources("META-INF/boss-plugin/plugin.json") ?: return FALLBACK_VERSION - return stream.use { input -> - Regex(""""version"\s*:\s*"([^"]+)\"""") - .find(input.readBytes().decodeToString()) - ?.groupValues - ?.get(1) - ?: FALLBACK_VERSION + val pluginId = "ai.rever.boss.plugin.dynamic.codebase" + for (url in resources) { + val text = runCatching { url.openStream().use { it.readBytes().decodeToString() } } + .getOrNull() ?: continue + if (field(text, "pluginId") != pluginId) continue + field(text, "version")?.let { return it } } + return FALLBACK_VERSION } + + /** One top-level string field out of a manifest, without a JSON library on the classpath. */ + private fun field(json: String, name: String): String? = + Regex("\"" + Regex.escape(name) + "\"\\s*:\\s*\"([^\"]*)\"") + .find(json) + ?.groupValues + ?.get(1) } } diff --git a/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/CodebaseGitMcpTools.kt b/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/CodebaseGitMcpTools.kt index c7756d5..1aa00fd 100644 --- a/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/CodebaseGitMcpTools.kt +++ b/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/CodebaseGitMcpTools.kt @@ -33,10 +33,21 @@ import kotlinx.coroutines.withTimeoutOrNull */ internal class CodebaseGitMcpToolProvider( override val providerId: String, - private val gitProvider: GitDataProvider?, - private val searchProvider: ProjectSearchProvider?, + /** + * Resolved per call, never captured at registration - the same reasoning + * the AI gateway is resolved lazily for. Plugin load order is not + * guaranteed, so a host that registers its GitDataProvider after this + * plugin would otherwise leave all 13 git tools answering "Git is + * unavailable" for the rest of the session. + */ + private val git: () -> GitDataProvider?, + private val search: () -> ProjectSearchProvider?, ) : McpToolProvider { + private val gitProvider: GitDataProvider? get() = git() + + private val searchProvider: ProjectSearchProvider? get() = search() + override fun tools(): List = listOf( // ---- from git-status ---- McpToolDefinition( @@ -174,7 +185,11 @@ internal class CodebaseGitMcpToolProvider( readOnly = true, handler = McpToolHandler { args -> val provider = gitProvider ?: return@McpToolHandler unavailableGit() - val limit = args.int("limit") ?: 30 + // Clamped, not taken raw. List.take(-1) THROWS, so a model + // guessing limit=-1 was the one bad argument here that escaped + // as an exception instead of a clean isError result - and + // limit=100000 is one call away from a very large result. + val limit = (args.int("limit") ?: DEFAULT_LOG_LIMIT).coerceIn(1, MAX_LOG_LIMIT) val log = awaitFresh(provider.commitLog) { provider.refreshLog(limit) }.take(limit) if (log.isEmpty()) McpToolResult("No commits.") else McpToolResult(log.joinToString("\n") { "${it.shortHash} ${it.subject} (${it.author})" }) @@ -239,7 +254,10 @@ internal class CodebaseGitMcpToolProvider( isRegex = args.boolean("isRegex") ?: false, caseSensitive = args.boolean("caseSensitive") ?: false, wholeWord = args.boolean("wholeWord") ?: false, - maxResults = args.int("maxResults") ?: 100, + // Clamped for the same reason as git_log's limit: maxResults = + // 1_000_000 makes the host scan the entire project to render at + // most MAX_RESULT_LINES lines. + maxResults = (args.int("maxResults") ?: DEFAULT_SEARCH_RESULTS).coerceIn(1, MAX_SEARCH_RESULTS), ) if (matches.isEmpty()) return McpToolResult("No matches.") val lines = @@ -290,6 +308,14 @@ internal class CodebaseGitMcpToolProvider( private fun parseFiles(raw: String): List { val trimmed = raw.trim() if (trimmed.startsWith("[") && trimmed.endsWith("]")) { + // An explicit empty array is EMPTY, not unparseable: without this + // the scanner returned null (its one element is empty), the flat + // fallback split "[]" into the single path "[]", and the caller's + // isEmpty() guard never fired - so the provider was handed a file + // literally named "[]". + if (trimmed.length == 2 || trimmed.substring(1, trimmed.length - 1).isBlank()) { + return emptyList() + } parseJsonStringArray(trimmed)?.let { return it } } return trimmed @@ -383,11 +409,29 @@ internal class CodebaseGitMcpToolProvider( val provider = gitProvider ?: return unavailableGit() val status = awaitFresh(provider.fileStatus) { provider.refreshStatus() } if (status.isEmpty()) return McpToolResult("Working tree clean.") - return McpToolResult( - status.joinToString("\n") { s -> - "${statusChar(s.indexStatus)}${statusChar(s.workTreeStatus)} ${s.path}" - }, - ) + return McpToolResult(status.joinToString("\n") { s -> "${statusCell(s)} ${s.path}" }) + } + + /** + * The two-character XY cell the tool description promises. + * + * [statusChar] returns ONE character per side, deliberately: git emits + * `??` and `!!` as the whole cell (index AND worktree), not as a worktree + * token. Returning "??" from the worktree side and concatenating it after + * the index side produced a three-column ` ??` that no agent slicing + * fixed columns can parse. + */ + internal fun statusCell(s: ai.rever.boss.plugin.api.GitFileStatusData): String { + val untrackedOrIgnored = + listOf(s.indexStatus, s.workTreeStatus).firstOrNull { + it == ai.rever.boss.plugin.api.GitFileStatusTypeData.UNTRACKED || + it == ai.rever.boss.plugin.api.GitFileStatusTypeData.IGNORED + } + return when (untrackedOrIgnored) { + ai.rever.boss.plugin.api.GitFileStatusTypeData.UNTRACKED -> "??" + ai.rever.boss.plugin.api.GitFileStatusTypeData.IGNORED -> "!!" + else -> "${statusChar(s.indexStatus)}${statusChar(s.workTreeStatus)}" + } } private fun statusChar(type: ai.rever.boss.plugin.api.GitFileStatusTypeData?): String = @@ -398,8 +442,8 @@ internal class CodebaseGitMcpToolProvider( ai.rever.boss.plugin.api.GitFileStatusTypeData.DELETED -> "D" ai.rever.boss.plugin.api.GitFileStatusTypeData.RENAMED -> "R" ai.rever.boss.plugin.api.GitFileStatusTypeData.COPIED -> "C" - ai.rever.boss.plugin.api.GitFileStatusTypeData.UNTRACKED -> "??" - ai.rever.boss.plugin.api.GitFileStatusTypeData.IGNORED -> "!!" + ai.rever.boss.plugin.api.GitFileStatusTypeData.UNTRACKED -> "?" + ai.rever.boss.plugin.api.GitFileStatusTypeData.IGNORED -> "!" ai.rever.boss.plugin.api.GitFileStatusTypeData.UNMERGED -> "U" } @@ -485,6 +529,16 @@ internal class CodebaseGitMcpToolProvider( private companion object { const val MAX_RESULT_LINES = 100 + const val DEFAULT_LOG_LIMIT = 30 + + /** Ceiling on git_log's `limit`; also stops List.take from seeing a negative. */ + const val MAX_LOG_LIMIT = 500 + + const val DEFAULT_SEARCH_RESULTS = 100 + + /** Ceiling on project_search's `maxResults`, so the host cannot be asked to scan everything. */ + const val MAX_SEARCH_RESULTS = 2_000 + /** Permission required by every git tool that mutates repository state. */ const val GIT_WRITE = "git.write" @@ -506,7 +560,7 @@ internal class CodebaseGitMcpToolProvider( const val HASH_SCHEMA = """{"type":"object","properties":{"hash":{"type":"string","description":"Hash of the commit."}},"required":["hash"]}""" const val LIMIT_SCHEMA = - """{"type":"object","properties":{"limit":{"type":"integer","description":"Max commits to return (default 30)."}}}""" + """{"type":"object","properties":{"limit":{"type":"integer","description":"Max commits to return (default 30, clamped to 1..500)."}}}""" const val DIFF_FILE_SCHEMA = """{"type":"object","properties":{"path":{"type":"string","description":"File path (repo-relative or absolute)."},"staged":{"type":"boolean","description":"Diff the index instead of the working tree (default false)."}},"required":["path"]}""" const val STAGED_SCHEMA = @@ -516,7 +570,7 @@ internal class CodebaseGitMcpToolProvider( const val DIFF_BETWEEN_SCHEMA = """{"type":"object","properties":{"from":{"type":"string","description":"Source ref."},"to":{"type":"string","description":"Target ref."},"path":{"type":"string","description":"Optional single file to restrict to."}},"required":["from","to"]}""" const val SEARCH_SCHEMA = - """{"type":"object","properties":{"query":{"type":"string","description":"Literal text, or a regular expression when isRegex=true."},"pathPattern":{"type":"string","description":"Optional glob filter on the project-relative path (e.g. **/*.kt)."},"excludePattern":{"type":"string","description":"Optional glob filter for paths to EXCLUDE (e.g. **/build/**). Applied inside the engine, so excluded matches do not consume the maxResults cap."},"isRegex":{"type":"boolean","description":"Treat query as a regular expression (default false)."},"caseSensitive":{"type":"boolean","description":"Case-sensitive matching (default false)."},"wholeWord":{"type":"boolean","description":"Whole-word match only (default false)."},"maxResults":{"type":"integer","description":"Hard cap on returned matches (default 100)."}},"required":["query"]}""" + """{"type":"object","properties":{"query":{"type":"string","description":"Literal text, or a regular expression when isRegex=true."},"pathPattern":{"type":"string","description":"Optional glob filter on the project-relative path (e.g. **/*.kt)."},"excludePattern":{"type":"string","description":"Optional glob filter for paths to EXCLUDE (e.g. **/build/**). Applied inside the engine, so excluded matches do not consume the maxResults cap."},"isRegex":{"type":"boolean","description":"Treat query as a regular expression (default false)."},"caseSensitive":{"type":"boolean","description":"Case-sensitive matching (default false)."},"wholeWord":{"type":"boolean","description":"Whole-word match only (default false)."},"maxResults":{"type":"integer","description":"Hard cap on returned matches (default 100, clamped to 1..2000)."}},"required":["query"]}""" const val REPLACE_SCHEMA = """{"type":"object","properties":{"query":{"type":"string","description":"Literal text, or a regular expression when isRegex=true."},"replacement":{"type":"string","description":"Replacement text; ${'$'}1..${'$'}9 capture references for regex queries."},"files":{"type":"string","description":"Comma-separated file paths to touch, or a JSON array of path strings - use the array form when a path contains a comma. Project-relative, or absolute inside the project. Paths outside the project are refused. Never empty."},"isRegex":{"type":"boolean","description":"Treat query as a regular expression (default false)."},"caseSensitive":{"type":"boolean","description":"Case-sensitive matching (default false)."},"wholeWord":{"type":"boolean","description":"Whole-word match only (default false)."},"dryRun":{"type":"boolean","description":"Count without writing (default true)."}},"required":["query","replacement","files"]}""" } diff --git a/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitBranchModel.kt b/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitBranchModel.kt index 3da3b01..1239393 100644 --- a/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitBranchModel.kt +++ b/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitBranchModel.kt @@ -74,6 +74,36 @@ internal object GitBranchModel { return ref.none { it.isWhitespace() || it.code < 0x20 || it.code == 0x7F } } + /** + * The "Diff against…" choices from the repository's real branch list: + * remote copies folded onto their local name, de-duplicated, sorted. + * + * Its own function rather than reusing [options]: the review picker is a + * list of plain refs to review *toward*, so `origin/main` and `main` are + * the same answer, whereas the graph picker deliberately keeps them apart + * because looking at the remote's history is a different thing to do. + */ + fun reviewBases( + branches: List, + remotes: Set = DEFAULT_REMOTES, + ): List = + branches + .asSequence() + .map { it.name.trim() } + .filter { it.isNotEmpty() && it != "HEAD" } + .map { name -> + val slash = name.indexOf('/') + if (slash in 1 until name.length && name.substring(0, slash) in remotes) { + name.substring(slash + 1) + } else { + name + } + } + .filter { it.isNotEmpty() && it != "HEAD" } + .distinct() + .sorted() + .toList() + /** The remote names implied by a branch list, for [refPills]. */ fun remoteNamesOf(branches: List): Set = branches diff --git a/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitChangeTree.kt b/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitChangeTree.kt index 3a35803..8126946 100644 --- a/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitChangeTree.kt +++ b/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitChangeTree.kt @@ -87,7 +87,7 @@ internal object GitChangeTree { } val out = mutableListOf() - emit(root, depth = 0, collapsed = collapsed, out = out, prefixLabel = null) + emit(root, depth = 0, collapsed = collapsed, out = out) return out } @@ -96,13 +96,12 @@ internal object GitChangeTree { depth: Int, collapsed: Set, out: MutableList, - prefixLabel: String?, ) { for (child in node.dirs.values.sortedBy { it.name.lowercase() }) { // Compact a single-child directory chain onto one row, the way VS // Code's explorer does: src > main > kotlin reads as src/main/kotlin. var current = child - val label = StringBuilder(prefixLabel?.let { "$it/${child.name}" } ?: child.name) + val label = StringBuilder(child.name) while (current.files.isEmpty() && current.dirs.size == 1) { val only = current.dirs.values.first() // Stop compacting at a collapsed directory: its own row has to @@ -120,7 +119,7 @@ internal object GitChangeTree { ), ) if (current.path !in collapsed) { - emit(current, depth + 1, collapsed, out, prefixLabel = null) + emit(current, depth + 1, collapsed, out) } } for (file in node.files.sortedBy { it.path.lowercase() }) { diff --git a/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitGraphEdges.kt b/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitGraphEdges.kt index c71436c..65d95e8 100644 --- a/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitGraphEdges.kt +++ b/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitGraphEdges.kt @@ -56,8 +56,10 @@ internal object GitGraphEdges { commits.forEachIndexed { childRow, commit -> val childLane = lanes.getOrElse(childRow) { 0 } for (parentHash in commit.parents) { - // A parent outside the fetched window: the line leaves the - // bottom of the last row rather than stopping mid-air. + // A parent outside the fetched window draws nothing: the + // window's last row is where history visibly stops, and a + // stub leaving the bottom edge would read as a lane that + // continues into the next row rather than off the page. val parentRow = rowOf[parentHash] ?: continue if (parentRow <= childRow) continue val parentLane = lanes.getOrElse(parentRow) { 0 } diff --git a/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitGraphLayout.kt b/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitGraphLayout.kt index 2d82e00..00ac2c5 100644 --- a/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitGraphLayout.kt +++ b/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitGraphLayout.kt @@ -32,19 +32,19 @@ object GitGraphLayout { } } - // Ancestor sets, bounded by the fetched window - fine for a few hundred commits. + // Ancestor sets, bounded by the fetched window - fine for a few hundred + // commits. Not memoized: the loop below visits each commit exactly once, + // so a cache keyed by hash would only ever be written to. val parentsByHash = commits.associateBy({ it.hash }, { it.parents }) - val ancestorCache = HashMap>() - fun ancestorsOfCommit(c: GitCommitNodeData): HashSet = - ancestorCache.getOrPut(c.hash) { - val set = HashSet() - val queue = ArrayDeque(c.parents) - while (queue.isNotEmpty()) { - val p = queue.removeFirst() - if (set.add(p)) parentsByHash[p]?.let { queue.addAll(it) } - } - set + fun ancestorsOfCommit(c: GitCommitNodeData): HashSet { + val set = HashSet() + val queue = ArrayDeque(c.parents) + while (queue.isNotEmpty()) { + val p = queue.removeFirst() + if (set.add(p)) parentsByHash[p]?.let { queue.addAll(it) } } + return set + } val laneOf = HashMap() val laneHolder = HashMap() diff --git a/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitTab.kt b/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitTab.kt index 3c566a1..3820bfd 100644 --- a/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitTab.kt +++ b/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitTab.kt @@ -65,6 +65,9 @@ class CodebaseGitViewModel( private val _graphBusy = MutableStateFlow(false) val graphBusy: StateFlow = _graphBusy.asStateFlow() + /** Latched once a load returns fewer commits than it requested. See [hasMoreGraph]. */ + private val _graphExhausted = MutableStateFlow(false) + /** * The ref the graph is showing, or null for the checked-out branch. * @@ -144,10 +147,14 @@ class CodebaseGitViewModel( val reviewBase: StateFlow = _reviewBase.asStateFlow() /** - * Branches offered in "Diff Against...", read off the loaded graph's ref - * decorations. The provider exposes no branch list, and the refs already - * carry every branch that appears in the history the panel has - which is - * the set worth reviewing against. + * Branches offered in "Diff against…". + * + * The repository's real branch list when the host has + * [GitDataProvider.branches], and the loaded graph's `%D` decorations + * otherwise. The decorations only name branches whose tip happens to sit + * inside the fetched window of history, so on their own they are strictly + * smaller than the set worth reviewing against - a fallback, not the + * source. */ private val _branches = MutableStateFlow>(emptyList()) val branches: StateFlow> = _branches.asStateFlow() @@ -232,7 +239,17 @@ class CodebaseGitViewModel( scope.launch { try { git?.refreshStatus() - _noRepoHint.value = describeMissingRepo(getProjectPath()) + // Only when there is no repository. describeMissingRepo lists the + // project root and stats up to CHILD_SCAN_LIMIT children looking + // for nested .git dirs - blocking work for a hint the normal case + // never renders. + _noRepoHint.value = + if (isGitRepository.value) "" else describeMissingRepo(getProjectPath()) + } catch (e: Exception) { + // The one provider call here used to have no catch, so an IPC + // drop escaped the launch to the thread's uncaught handler and + // left the hint stale. Everything else in this class reports. + _message.value = "Failed to read git status: ${e.message ?: e::class.simpleName}" } finally { _loaded.value = true } @@ -304,12 +321,14 @@ class CodebaseGitViewModel( // delegates to logGraph for a null ref, so on a host that // predates 1.0.90 the checked-out history still draws and only // the picker degrades. + var failed = false val nodes = runCatching { git?.logGraphFor(ref, limit) }.getOrElse { e -> // A throwing provider (IPC drop, repo mid-rebase) leaves a // message instead of an unhandled exception and a spinner // that just stops. _message.value = "Failed to load git history: ${e.message}" + failed = true null } ?: emptyList() // The HEAD decoration only names the checked-out branch while @@ -322,12 +341,14 @@ class CodebaseGitViewModel( if (ref == null) { branchOf(nodes).takeIf { it.isNotEmpty() }?.let { _currentBranch.value = it } } + // Fewer rows than requested means the provider reached the root + // commit; anything else would page forever over the same list. + // Never latched off a FAILED load: that path yields an empty + // list too, and hiding "Load more" until the next reset is the + // wrong answer to a dropped IPC call. + if (!failed) _graphExhausted.value = nodes.size < limit _graph.value = nodes - val found = branchesOf(nodes, _remoteNames.value) - _branches.value = found - if (_reviewBase.value.isBlank()) { - _reviewBase.value = defaultBase(found, _currentBranch.value) - } + refreshReviewBases(nodes) // Only on a reset: paging deeper into the same history cannot // change which branches exist, and this is two extra `git // branch` invocations behind a lock every "Load more" would @@ -362,6 +383,24 @@ class CodebaseGitViewModel( graphRefs = GitBranchModel.branchRefsInGraph(_graph.value, _remoteNames.value), current = _currentBranch.value, ) + // The authoritative list just arrived; rebuild the review picker off + // it rather than leaving the graph-derived fallback in place. + refreshReviewBases(_graph.value) + } + + /** + * Rebuild the "Diff against…" options, preferring the repository's real + * branch list over the graph's ref decorations, and seed the base with + * main/master the first time it lands. + */ + private fun refreshReviewBases(nodes: List) { + val found = + GitBranchModel.reviewBases(_branchRefs.value, _remoteNames.value) + .ifEmpty { branchesOf(nodes, _remoteNames.value) } + _branches.value = found + if (_reviewBase.value.isBlank()) { + _reviewBase.value = defaultBase(found, _currentBranch.value) + } } /** @@ -402,7 +441,16 @@ class CodebaseGitViewModel( */ fun push() = op(refreshGraph = true) { git?.push() } - fun hasMoreGraph(): Boolean = _graph.value.size < GRAPH_MAX + /** + * Whether "Load more" has anything left to load. + * + * Not `size < GRAPH_MAX`: a 12-commit repository is under the ceiling + * forever, so the action showed on every repository smaller than 200 + * commits and each click refetched the same rows. The latch below is set + * when a load comes back with fewer commits than it asked for, which is + * the only signal the provider gives that history ran out. + */ + fun hasMoreGraph(): Boolean = !_graphExhausted.value && _graph.value.size < GRAPH_MAX // ---- row / commit operations ---- @@ -468,9 +516,9 @@ class CodebaseGitViewModel( _message.value = "Git is unavailable on this host." return } + if (!_busy.compareAndSet(expect = false, update = true)) return + _message.value = null scope.launch { - _busy.value = true - _message.value = null try { if (stageFirst) { val staged = git.stageAll() @@ -537,9 +585,9 @@ class CodebaseGitViewModel( _message.value = "AI is unavailable." return } + if (!_generating.compareAndSet(expect = false, update = true)) return + _message.value = null scope.launch { - _generating.value = true - _message.value = null try { val status = _fileStatus.value val subject = status.filter { it.isStaged }.ifEmpty { status.filter { it.isUnstaged } } @@ -655,8 +703,8 @@ class CodebaseGitViewModel( * the inline budget, then hand the composed prompt to [onAgentReview]. */ fun startAgentReview() { + if (!_busy.compareAndSet(expect = false, update = true)) return scope.launch { - _busy.value = true try { val status = _fileStatus.value val staged = status.filter { it.isStaged } @@ -711,9 +759,13 @@ class CodebaseGitViewModel( _message.value = "Git is unavailable on this host." return } + // Set BEFORE launch. Inside the coroutine body it is set only once the + // dispatcher runs it, so two clicks landing in the same frame both saw + // busy = false and both passed the `enabled = !busy` guard - two + // concurrent index writes, which is what batch() exists to avoid. + if (!_busy.compareAndSet(expect = false, update = true)) return + _message.value = null scope.launch { - _busy.value = true - _message.value = null try { try { report(block()) { if (refreshGraph) loadGraph(reset = true) } diff --git a/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitTabUi.kt b/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitTabUi.kt index 8e65aeb..254e177 100644 --- a/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitTabUi.kt +++ b/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitTabUi.kt @@ -58,7 +58,7 @@ import androidx.compose.material.icons.rounded.ContentCopy import androidx.compose.material.icons.rounded.DoneAll import androidx.compose.material.icons.rounded.Edit import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight -import androidx.compose.material.icons.rounded.FormatListBulleted +import androidx.compose.material.icons.automirrored.rounded.FormatListBulleted import androidx.compose.material.icons.rounded.KeyboardArrowDown import androidx.compose.material.icons.rounded.Remove import androidx.compose.material.icons.automirrored.rounded.Undo @@ -85,7 +85,6 @@ import androidx.compose.ui.input.key.isMetaPressed import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.type -import androidx.compose.ui.input.pointer.PointerEventType import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.TextStyle @@ -149,9 +148,9 @@ fun CodebaseGitContent( var untrackedOpen by remember { mutableStateOf(true) } var graphOpen by remember { mutableStateOf(true) } var confirm by remember { mutableStateOf(null) } - // Deliberately shared across the STAGED / CHANGES / UNTRACKED groups: - // the same directory is the same directory on all three sides, so its - // collapsed state is one preference, not three. + // One set across all three change groups on purpose: a directory you + // have folded away is folded away, and the same path appearing in two + // groups is the same directory in the same project. var collapsedDirs by remember { mutableStateOf(emptySet()) } // In the view model, not a `remember`: the tab composable tears down on // every tab hop, and a layout preference should not reset with it. @@ -185,13 +184,10 @@ fun CodebaseGitContent( branch = branch, isRepo = isRepo, busy = busy, - settingsOpen = settingsOpen, onRefresh = { viewModel.refreshStatus() viewModel.loadGraph(reset = true) }, - onReview = { viewModel.startAgentReview() }, - onToggleSettings = { settingsOpen = !settingsOpen }, layout = layout, onToggleLayout = { viewModel.toggleChangeLayout() }, ) @@ -520,10 +516,7 @@ private fun GitToolbar( branch: String, isRepo: Boolean, busy: Boolean, - settingsOpen: Boolean, onRefresh: () -> Unit, - onReview: () -> Unit, - onToggleSettings: () -> Unit, layout: GitChangeLayout, onToggleLayout: () -> Unit, ) { @@ -563,7 +556,9 @@ private fun GitToolbar( Spacer(Modifier.width(6.dp)) } CodebaseIconButton( - icon = if (layout == GitChangeLayout.TREE) Icons.Rounded.AccountTree else Icons.Rounded.FormatListBulleted, + icon = + if (layout == GitChangeLayout.TREE) Icons.Rounded.AccountTree + else Icons.AutoMirrored.Rounded.FormatListBulleted, tooltip = if (layout == GitChangeLayout.TREE) "View as list" else "View as tree", onClick = onToggleLayout, ) @@ -712,7 +707,10 @@ private fun GitAgentReview( } Spacer(Modifier.height(4.dp)) CodebaseTooltip( - text = "Runs the agent over this branch's diff and reports what it finds. " + + // Says what is actually attached. "this branch's diff" read as a + // full baseRef..HEAD review, which is not what is collected - the + // brief asks the agent to fetch that half itself if it needs it. + text = "Runs the agent over the uncommitted changes and reports what it finds. " + "Nothing is edited.", modifier = Modifier.fillMaxWidth(), ) { @@ -722,7 +720,9 @@ private fun GitAgentReview( verticalAlignment = Alignment.CenterVertically, ) { Text( - text = if (base.isBlank()) "Review uncommitted changes." else "Review diffs vs. $base.", + text = + if (base.isBlank()) "Review uncommitted changes." + else "Review uncommitted changes, targeting $base.", fontSize = CodebaseMetrics.MetaText, color = CodebasePalette.Muted, maxLines = 1, @@ -874,6 +874,11 @@ private fun LazyListScope.changeGroup( return } val rows = GitChangeTree.rows(files, collapsedDirs) + // Precomputed once per group, not inside the directory row's composable + // lambda: there it was an O(files) filter re-run on every recomposition + // of every directory row. + val filesByDir = rows.filterIsInstance() + .associate { it.path to GitChangeTree.filesUnder(files, it.path) } items(rows, key = { row -> when (row) { is GitChangeTree.Row.Directory -> "$title-dir-${row.path}" @@ -887,7 +892,7 @@ private fun LazyListScope.changeGroup( collapsed = row.path in collapsedDirs, onToggle = { onToggleDir(row.path) }, actions = { hovered -> - directoryActions(GitChangeTree.filesUnder(files, row.path), hovered) + directoryActions(filesByDir[row.path].orEmpty(), hovered) }, ) @@ -1162,7 +1167,9 @@ private fun GitCommitRow( body = "Check out ${node.shortHash}? This detaches HEAD from the current branch.", confirmLabel = "Checkout", destructive = true, - action = { viewModel.checkout(node.shortHash) }, + // The full hash, like revert and cherry-pick above: + // an abbreviation can collide in a large repository. + action = { viewModel.checkout(node.hash) }, ), ) }, @@ -1477,11 +1484,13 @@ private fun GitConfirmDialog( .background(CodebaseScrim) .pointerInput(Unit) { awaitPointerEventScope { + // Every event, not only Press: consuming the down change + // is what stops a click reaching the rows underneath, and + // consuming scroll stops the list behind the sheet moving + // while it is up. Consumed on the MAIN pass, which runs + // child-first, so this sheet's own buttons still see it. while (true) { - val event = awaitPointerEvent() - if (event.type == PointerEventType.Press) { - event.changes.forEach { it.consume() } - } + awaitPointerEvent().changes.forEach { it.consume() } } } } @@ -1600,6 +1609,10 @@ private fun GraphRowCanvas( row: GitGraphEdges.Row, laneCount: Int, ) { + // Lanes past the ceiling all draw in the last one. That is a deliberate + // width cap on a panel this narrow, not an oversight - but it means a + // repository with more than GRAPH_MAX_LANES concurrent branches shows + // several lines sharing the rightmost column. val drawnLanes = laneCount.coerceIn(1, GRAPH_MAX_LANES) val palette = CodebasePalette.laneColors Canvas( diff --git a/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/SearchTab.kt b/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/SearchTab.kt index 72f5100..dc814af 100644 --- a/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/SearchTab.kt +++ b/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/SearchTab.kt @@ -19,6 +19,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.CircularProgressIndicator import androidx.compose.material.Icon @@ -35,6 +36,7 @@ import androidx.compose.material.icons.rounded.Search import androidx.compose.material.icons.rounded.UnfoldLess import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -43,12 +45,14 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.focusTarget import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.type -import androidx.compose.ui.input.pointer.PointerEventType import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight @@ -271,11 +275,6 @@ class CodebaseSearchViewModel( } } - fun cancelSearch() { - searchJob?.cancel() - _busy.value = false - } - /** * Open one match at its own line and column. * @@ -312,8 +311,11 @@ class CodebaseSearchViewModel( /** Dry-run first, so the UI can show what will change before writing. */ fun previewReplacement() { if (_query.value.isBlank() || _results.value.isEmpty()) return + // Set BEFORE launch: inside the coroutine the flag lands only once the + // dispatcher runs the body, so two clicks in the same frame both saw + // busy = false and both passed the `enabled = … && !busy` guard. + if (!_busy.compareAndSet(expect = false, update = true)) return scope.launch { - _busy.value = true try { _dryRun.value = provider?.replaceInProject( @@ -335,8 +337,9 @@ class CodebaseSearchViewModel( fun applyReplacement() { val summary = _dryRun.value ?: return + // See previewReplacement: a duplicate click here is a duplicate WRITE. + if (!_busy.compareAndSet(expect = false, update = true)) return scope.launch { - _busy.value = true try { val applied = provider?.replaceInProject( @@ -409,8 +412,16 @@ class CodebaseSearchViewModel( const val MAX_RESULTS = 500 const val DEBOUNCE_MS = 250L - /** How soon a change on disk or in a buffer shows up in the results. */ - const val REFRESH_MS = 2_500L + /** + * How soon an edit made outside the panel shows up in the results. + * + * A full project content scan - a directory walk plus a stat per file + * - so the interval is a real cost on a monorepo, and the case it + * exists for (someone edits a matched file in the editor) is not one + * anybody times with a stopwatch. The replace path does not wait for + * it: applyReplacement re-scans explicitly. + */ + const val REFRESH_MS = 15_000L /** `C:\...` / `C:/...` - an absolute path needs no project root. */ val WINDOWS_ABSOLUTE = Regex("""^[A-Za-z]:[\\/].*""") @@ -465,10 +476,14 @@ fun CodebaseSearchContent( var replaceOpen by remember { mutableStateOf(false) } var filtersOpen by remember { mutableStateOf(glob.isNotEmpty() || exclude.isNotEmpty()) } - // Collapse state per path, plus a generation counter so "collapse all" - // can reset every row without tracking each one. - var collapseGeneration by remember { mutableStateOf(0) } - var allCollapsed by remember { mutableStateOf(false) } + // Collapse state by path, hoisted OUT of the lazy items. It used to be a + // `remember` inside each group's item, which a LazyColumn disposes when + // the row scrolls off screen - so a group you folded came back expanded + // the moment you scrolled past it and back. A generation counter forced + // every group to rebuild on "collapse all"; with the state out here the + // set is simply rewritten. + var collapsedFiles by remember { mutableStateOf(emptySet()) } + val allCollapsed = grouped.isNotEmpty() && grouped.all { it.path in collapsedFiles } // Poll only while the tab is on screen. DisposableEffect(viewModel) { @@ -624,12 +639,11 @@ fun CodebaseSearchContent( message = message, allCollapsed = allCollapsed, onCollapseAll = { - allCollapsed = !allCollapsed - collapseGeneration++ + collapsedFiles = if (allCollapsed) emptySet() else grouped.mapTo(mutableSetOf()) { it.path } }, onClear = { viewModel.clear() - allCollapsed = false + collapsedFiles = emptySet() }, ) CodebaseHRule() @@ -656,8 +670,12 @@ fun CodebaseSearchContent( grouped.forEach { entry -> searchFileGroup( entry = entry, - expandedInitially = !allCollapsed, - generation = collapseGeneration, + expanded = entry.path !in collapsedFiles, + onToggle = { + collapsedFiles = + if (entry.path in collapsedFiles) collapsedFiles - entry.path + else collapsedFiles + entry.path + }, onOpenFile = { viewModel.openFile(entry.path) }, onOpenMatch = viewModel::openMatch, ) @@ -738,99 +756,115 @@ private fun SearchSummaryRow( } } -/** One file's group: the file row, then its match rows when expanded. */ +/** + * One file's group: the file row as its own lazy item, then one lazy item per + * match. + * + * Not a single `item { }` holding the whole group. That is what it was, and it + * defeated the point of a LazyColumn: with MAX_RESULTS landing in one or two + * files, every one of up to 500 match rows composed and measured whether or + * not it was on screen. Lazy skipping now works per row. + */ private fun LazyListScope.searchFileGroup( entry: FileMatches, - expandedInitially: Boolean, - generation: Int, + expanded: Boolean, + onToggle: () -> Unit, onOpenFile: () -> Unit, onOpenMatch: (FileMatch) -> Unit, ) { - item(key = "file-${entry.path}-$generation") { - SearchFileGroupBody( + item(key = "file-${entry.path}") { + SearchFileRow( entry = entry, - expandedInitially = expandedInitially, + expanded = expanded, + onToggle = onToggle, onOpenFile = onOpenFile, - onOpenMatch = onOpenMatch, ) } + if (!expanded) return + // Keyed by index, not by line/column: the engine can report two matches on + // the same line and column (a zero-width regex), and a duplicate key is a + // hard crash in LazyColumn rather than a rendering glitch. + itemsIndexed(entry.matches, key = { i, _ -> "match-${entry.path}-$i" }) { _, m -> + SearchMatchRow(match = m, onOpen = { onOpenMatch(m) }) + } } @Composable -private fun SearchFileGroupBody( +private fun SearchFileRow( entry: FileMatches, - expandedInitially: Boolean, + expanded: Boolean, + onToggle: () -> Unit, onOpenFile: () -> Unit, - onOpenMatch: (FileMatch) -> Unit, ) { - var expanded by remember { mutableStateOf(expandedInitially) } val (name, dir) = remember(entry.path) { splitPathForDisplay(entry.path) } - Column { - CodebaseListRow(onClick = { expanded = !expanded }) { hovered -> - Icon( - imageVector = - if (expanded) Icons.Rounded.KeyboardArrowDown - else Icons.AutoMirrored.Rounded.KeyboardArrowRight, - contentDescription = null, - modifier = Modifier.size(16.dp), - tint = CodebasePalette.Secondary, - ) - // One weighted slot for name + directory, so the hover action and - // the count badge keep their place however long the path is. - CodebaseTooltip(entry.path, modifier = Modifier.weight(1f)) { - Row(verticalAlignment = Alignment.CenterVertically) { + CodebaseListRow(onClick = onToggle) { hovered -> + Icon( + imageVector = + if (expanded) Icons.Rounded.KeyboardArrowDown + else Icons.AutoMirrored.Rounded.KeyboardArrowRight, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = CodebasePalette.Secondary, + ) + // One weighted slot for name + directory, so the hover action and + // the count badge keep their place however long the path is. + CodebaseTooltip(entry.path, modifier = Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = name, + fontSize = CodebaseMetrics.PrimaryText, + color = CodebasePalette.Foreground, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false), + ) + if (dir.isNotEmpty()) { + Spacer(Modifier.width(5.dp)) Text( - text = name, - fontSize = CodebaseMetrics.PrimaryText, - color = CodebasePalette.Foreground, + text = dir, + fontSize = CodebaseMetrics.MetaText, + color = CodebasePalette.Muted, maxLines = 1, - overflow = TextOverflow.Ellipsis, + overflow = TextOverflow.MiddleEllipsis, modifier = Modifier.weight(1f, fill = false), ) - if (dir.isNotEmpty()) { - Spacer(Modifier.width(5.dp)) - Text( - text = dir, - fontSize = CodebaseMetrics.MetaText, - color = CodebasePalette.Muted, - maxLines = 1, - overflow = TextOverflow.MiddleEllipsis, - modifier = Modifier.weight(1f, fill = false), - ) - } } } - CodebaseIconButton( - icon = Icons.Rounded.Search, - tooltip = "Open file", - onClick = onOpenFile, - visible = hovered, - ) - CodebaseCountBadge(entry.matches.size) - Spacer(Modifier.width(4.dp)) - } - if (!expanded) return@Column - entry.matches.forEach { m -> - CodebaseListRow(onClick = { onOpenMatch(m) }) { _ -> - Spacer(Modifier.width(6.dp)) - Text( - text = m.line.toString(), - fontSize = CodebaseMetrics.MetaText, - fontFamily = FontFamily.Monospace, - color = CodebasePalette.Muted, - textAlign = TextAlign.End, - maxLines = 1, - modifier = Modifier.width(30.dp), - ) - Spacer(Modifier.width(8.dp)) - HighlightedMatchLine( - line = m.contextLine, - matchStart = m.column - 1, - matchLength = m.matchLength, - modifier = Modifier.weight(1f), - ) - } } + CodebaseIconButton( + icon = Icons.Rounded.Search, + tooltip = "Open file", + onClick = onOpenFile, + visible = hovered, + ) + CodebaseCountBadge(entry.matches.size) + Spacer(Modifier.width(4.dp)) + } +} + +@Composable +private fun SearchMatchRow( + match: FileMatch, + onOpen: () -> Unit, +) { + CodebaseListRow(onClick = onOpen) { _ -> + Spacer(Modifier.width(6.dp)) + Text( + text = match.line.toString(), + fontSize = CodebaseMetrics.MetaText, + fontFamily = FontFamily.Monospace, + color = CodebasePalette.Muted, + textAlign = TextAlign.End, + maxLines = 1, + modifier = Modifier.width(30.dp), + ) + Spacer(Modifier.width(8.dp)) + HighlightedMatchLine( + line = match.contextLine, + matchStart = match.column - 1, + matchLength = match.matchLength, + modifier = Modifier.weight(1f), + ) } } @@ -924,20 +958,37 @@ private fun ConfirmReplaceSheet( ) { // A modal sheet must EAT input: without a consuming pointer modifier the // scrim never consumes the click, so the result rows underneath stay live - // while the confirmation is on screen. + // while the confirmation is on screen - and flipping a search toggle back + // there calls setSearchOption, which nulls _dryRun and yanks this sheet + // away mid-decision. It also has to hold focus, or the Escape handler on + // an unfocused node never fires. + val focusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { focusRequester.requestFocus() } Box( modifier = Modifier .fillMaxSize() .background(CodebaseScrim) .pointerInput(Unit) { awaitPointerEventScope { + // Every event, not only Press: consuming the down change + // is what stops a click reaching the rows underneath, and + // consuming scroll stops the list behind the sheet moving + // while it is up. Consumed on the MAIN pass, which runs + // child-first, so this sheet's own buttons still see it. while (true) { - val event = awaitPointerEvent() - if (event.type == PointerEventType.Press) { - event.changes.forEach { it.consume() } - } + awaitPointerEvent().changes.forEach { it.consume() } } } + } + .focusRequester(focusRequester) + .focusTarget() + .onPreviewKeyEvent { e -> + if (e.type == KeyEventType.KeyDown && e.key == Key.Escape) { + onDismiss() + true + } else { + false + } }, contentAlignment = Alignment.Center, ) { diff --git a/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/CodebaseGitMcpToolsTest.kt b/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/CodebaseGitMcpToolsTest.kt index 9c14150..86aff8c 100644 --- a/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/CodebaseGitMcpToolsTest.kt +++ b/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/CodebaseGitMcpToolsTest.kt @@ -29,7 +29,7 @@ class CodebaseGitMcpToolsTest { private val git = FakeGitProvider() private val search = FakeSearchProvider() - private val tools = CodebaseGitMcpToolProvider("codebase", git, search).tools() + private val tools = CodebaseGitMcpToolProvider("codebase", { git }, { search }).tools() private fun tool(name: String) = tools.first { it.name == name } @@ -112,7 +112,7 @@ class CodebaseGitMcpToolsTest { @Test fun `git log without a provider reports unavailability, not a crash`() { - val none = CodebaseGitMcpToolProvider("codebase", null, null).tools() + val none = CodebaseGitMcpToolProvider("codebase", { null }, { null }).tools() val result = runBlocking { none.first { it.name == "git_log" }.handler.call(McpToolArgs(emptyMap())) @@ -203,8 +203,68 @@ class CodebaseGitMcpToolsTest { ) val result = invoke("git_status", emptyMap()) assertFalse(result.isError, result.text) - listOf("M m.kt", " A a.kt", "D d.kt", "R r.kt", "C c.kt", "?? u.kt", "!! i.kt", "UU x.kt") - .forEach { line -> assertTrue(result.text.contains(line), "missing $line in:\n${result.text}") } + // Whole LINES, not `contains`: the tool advertises porcelain "XY path", + // and contains("?? u.kt") passes just as happily on a misaligned + // " ?? u.kt" - which is what an untracked row rendered as while + // statusChar returned two characters for one cell. + assertEquals( + listOf("M m.kt", " A a.kt", "D d.kt", "R r.kt", "C c.kt", "?? u.kt", "!! i.kt", "UU x.kt"), + result.text.lines(), + ) + assertTrue( + result.text.lines().all { it.length > 3 && it[2] == ' ' }, + "every row must be exactly two status columns then a space:\n${result.text}", + ) + } + + @Test + fun `an untracked file staged alongside an index change keeps two columns`() { + // git reports ?? as the whole cell, never as a work-tree token bolted + // onto an index one. + git.status.value = listOf( + GitFileStatusData("u.kt", T.UNTRACKED, T.UNTRACKED, isStaged = false, isUnstaged = true), + ) + assertEquals(listOf("?? u.kt"), invoke("git_status", emptyMap()).text.lines()) + } + + // ---- argument clamping ----------------------------------------------- + + @Test + fun `a negative git log limit is a clean error path, not an exception`() { + // List.take(-1) THROWS. Every other bad argument here returns an + // isError result, so a model guessing limit=-1 must not be the one + // input that escapes as an exception. + val result = invoke("git_log", mapOf("limit" to -1)) + assertFalse(result.isError, result.text) + assertEquals(1, git.logLimits.last(), "a negative limit must clamp to 1") + } + + @Test + fun `an enormous git log limit is clamped rather than passed through`() { + invoke("git_log", mapOf("limit" to 100_000)) + assertEquals(500, git.logLimits.last(), "the limit must be bounded above") + } + + @Test + fun `an enormous project search maxResults is clamped`() { + invoke("project_search", mapOf("query" to "x", "maxResults" to 1_000_000)) + assertEquals(2_000, search.searchCalls.last(), "maxResults must be bounded above") + } + + @Test + fun `an explicit empty JSON array is refused, not read as a file named brackets`() { + // The scanner returned null for "[]" (its single element is empty), the + // flat fallback split it into the one path "[]", and the caller's + // isEmpty() guard never fired - so the provider was handed a file + // literally called "[]". + val result = + invoke( + "project_replace", + mapOf("query" to "a", "replacement" to "b", "files" to "[]"), + ) + assertTrue(result.isError, result.text) + assertTrue("must not be empty" in result.text, result.text) + assertTrue(search.replaceCalls.isEmpty(), "nothing may reach the provider") } // ---- fakes --------------------------------------------------------------- @@ -212,6 +272,7 @@ class CodebaseGitMcpToolsTest { private class FakeGitProvider : GitDataProvider { val status = MutableStateFlow(emptyList()) val diffs = mutableMapOf>() + val logLimits = mutableListOf() override val fileStatus: StateFlow> = status override val commitLog: StateFlow> = MutableStateFlow(emptyList()) @@ -220,7 +281,9 @@ class CodebaseGitMcpToolsTest { override suspend fun refreshStatus() {} - override suspend fun refreshLog(limit: Int) {} + override suspend fun refreshLog(limit: Int) { + logLimits += limit + } override suspend fun stage(filePath: String) = GitOperationResultData.Success() @@ -264,6 +327,7 @@ class CodebaseGitMcpToolsTest { private class FakeSearchProvider : ProjectSearchProvider { val replaceCalls = mutableListOf() + val searchCalls = mutableListOf() override suspend fun searchInProject( query: String, @@ -273,7 +337,10 @@ class CodebaseGitMcpToolsTest { caseSensitive: Boolean, wholeWord: Boolean, maxResults: Int, - ) = emptyList() + ): List { + searchCalls += maxResults + return emptyList() + } override suspend fun replaceInProject( query: String, diff --git a/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitBranchModelTest.kt b/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitBranchModelTest.kt index e7ff818..fc27b26 100644 --- a/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitBranchModelTest.kt +++ b/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitBranchModelTest.kt @@ -247,4 +247,46 @@ class GitBranchModelTest { assertFalse(GitBranchModel.isSafeRef("has\u0000nul")) assertFalse(GitBranchModel.isSafeRef("a".repeat(256))) } + + // ---- reviewBases ---- + + @Test + fun `review bases fold the remote copy onto the local name`() { + // "Diff against..." lists refs to review TOWARD, so origin/main and + // main are the same answer - unlike the graph picker, where looking at + // the remote's history is a different thing to do. + assertEquals( + listOf("main", "zeta"), + GitBranchModel.reviewBases( + listOf(local("main", current = true), remote("origin/main"), local("zeta")), + setOf("origin"), + ), + ) + } + + @Test + fun `review bases keep a local branch that merely looks remote`() { + // "someone/something" is a local branch, not a remote-tracking ref, + // and the repository's real remote set is the only way to tell. + assertEquals( + listOf("feat/x", "someone/something"), + GitBranchModel.reviewBases( + listOf(local("someone/something"), remote("upstream/feat/x")), + setOf("upstream"), + ), + ) + } + + @Test + fun `review bases drop HEAD and blanks`() { + // origin/HEAD folds to "HEAD", which is not a branch anybody reviews + // toward - it has to be dropped AFTER folding as well as before. + assertEquals( + listOf("main"), + GitBranchModel.reviewBases( + listOf(local("main"), local("HEAD"), remote("origin/HEAD"), local(" ")), + setOf("origin"), + ), + ) + } } diff --git a/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitTabCompactDiffTest.kt b/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitTabCompactDiffTest.kt index e28868e..4032e9c 100644 --- a/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitTabCompactDiffTest.kt +++ b/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitTabCompactDiffTest.kt @@ -4,6 +4,7 @@ import ai.rever.boss.plugin.api.DiffHunk import ai.rever.boss.plugin.api.DiffLine import ai.rever.boss.plugin.api.DiffLineKind import ai.rever.boss.plugin.api.GitDiffData +import kotlin.test.AfterTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue @@ -21,6 +22,13 @@ class GitTabCompactDiffTest { getProjectPath = { null }, ) + @AfterTest + fun dispose() { + // The view model starts a CoroutineScope in its constructor; without + // this the class leaks one per test run. + vm.dispose() + } + private fun ctx(t: String) = DiffLine(t, DiffLineKind.CONTEXT) private fun add(t: String) = DiffLine(t, DiffLineKind.ADDED) diff --git a/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitViewModelOperationsTest.kt b/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitViewModelOperationsTest.kt new file mode 100644 index 0000000..28740cc --- /dev/null +++ b/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitViewModelOperationsTest.kt @@ -0,0 +1,210 @@ +package ai.rever.boss.plugin.dynamic.codebase + +import ai.rever.boss.plugin.api.DiffHunk +import ai.rever.boss.plugin.api.DiffLine +import ai.rever.boss.plugin.api.DiffLineKind +import ai.rever.boss.plugin.api.GitCommitInfoData +import ai.rever.boss.plugin.api.GitCommitNodeData +import ai.rever.boss.plugin.api.GitDataProvider +import ai.rever.boss.plugin.api.GitDiffData +import ai.rever.boss.plugin.api.GitFileStatusData +import ai.rever.boss.plugin.api.GitFileStatusTypeData +import ai.rever.boss.plugin.api.GitOperationResultData +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * View-model behaviour that only shows up across a seam, so neither side's + * own unit test catches it: the Agent Review diff budget (collector meets + * prompt builder), "Load more" exhaustion, and batch's first-error-wins rule. + */ +class GitViewModelOperationsTest { + + private val viewModels = mutableListOf() + + @AfterTest + fun disposeAll() { + viewModels.forEach { it.dispose() } + viewModels.clear() + } + + // ---- fakes ---- + + private fun node(hash: String) = + GitCommitNodeData( + hash = hash, + shortHash = hash.take(7), + subject = hash, + author = "t", + authorEmail = "t@t", + date = 0L, + refs = emptyList(), + parents = emptyList(), + ) + + private open inner class Host( + val graph: List = emptyList(), + val fileDiffs: Map> = emptyMap(), + /** Paths whose stage() fails, in the order the failure should be reported. */ + val stageFailures: Map = emptyMap(), + ) : GitDataProvider { + val status = MutableStateFlow(emptyList()) + val graphLimits = mutableListOf() + val staged = mutableListOf() + + override val fileStatus: StateFlow> = status + override val commitLog: StateFlow> = MutableStateFlow(emptyList()) + override val isGitRepository: StateFlow = MutableStateFlow(true) + override val isLoading: StateFlow = MutableStateFlow(false) + + override suspend fun refreshStatus() {} + + override suspend fun refreshLog(limit: Int) {} + + override suspend fun logGraphFor(ref: String?, limit: Int): List { + graphLimits += limit + return graph.take(limit) + } + + override suspend fun diffFile(path: String, staged: Boolean): List = + fileDiffs[path].orEmpty() + + override suspend fun stage(filePath: String): GitOperationResultData { + staged += filePath + return stageFailures[filePath]?.let { GitOperationResultData.Error(it) } + ?: GitOperationResultData.Success() + } + + override suspend fun unstage(filePath: String) = GitOperationResultData.Success() + + override suspend fun stageAll() = GitOperationResultData.Success() + + override suspend fun unstageAll() = GitOperationResultData.Success() + + override suspend fun discardChanges(filePath: String) = GitOperationResultData.Success() + + override suspend fun cherryPick(commitHash: String) = GitOperationResultData.Success() + + override suspend fun revert(commitHash: String) = GitOperationResultData.Success() + + override suspend fun checkout(ref: String) = GitOperationResultData.Success() + + override fun getCurrentProjectPath(): String? = "/repo" + + override fun openFile(filePath: String, windowId: String) {} + } + + private fun viewModel( + provider: GitDataProvider, + onReview: (String) -> Unit = {}, + ): CodebaseGitViewModel = + CodebaseGitViewModel( + git = provider, + onAgentReview = onReview, + getProjectPath = { "/repo" }, + ).also { viewModels += it } + + private fun awaitUntil(what: String, predicate: () -> Boolean) { + val deadline = System.currentTimeMillis() + 5_000 + while (System.currentTimeMillis() < deadline) { + if (predicate()) return + Thread.sleep(10) + } + throw AssertionError("timed out waiting for: $what") + } + + private fun unstagedFile(path: String) = + GitFileStatusData( + path = path, + indexStatus = null, + workTreeStatus = GitFileStatusTypeData.MODIFIED, + isStaged = false, + isUnstaged = true, + ) + + /** A compact diff comfortably larger than the inline budget. */ + private fun oversizedDiff(path: String): GitDiffData { + val lines = (1..4_000).map { DiffLine("added line number $it", DiffLineKind.ADDED) } + return GitDiffData( + path = path, + additions = lines.size, + deletions = 0, + hunks = listOf(DiffHunk(1, lines.size, 1, lines.size, "", lines)), + ) + } + + // ---- the diff budget seam ---- + + @Test + fun `an oversized first file is truncated into the prompt, never dropped`() { + // The collector fills the budget and then appends its per-file header + // and truncation marker, so the result lands slightly OVER it. The + // prompt builder used to re-test the length and replace the whole diff + // with "use the git_diff tools" - dropping it in exactly the case the + // truncation exists to serve. Neither side's own test sees this: they + // are pinned separately. + val host = Host(fileDiffs = mapOf("big.kt" to listOf(oversizedDiff("big.kt")))) + var prompt: String? = null + val vm = viewModel(host) { prompt = it } + host.status.value = listOf(unstagedFile("big.kt")) + + awaitUntil("the file status to arrive") { vm.fileStatus.value.isNotEmpty() } + vm.startAgentReview() + awaitUntil("the review prompt") { prompt != null } + + val text = prompt!! + assertTrue("Full diff:" in text, "the diff was dropped:\n${text.take(600)}") + assertTrue("added line number 1" in text, "no diff content reached the prompt") + assertTrue(AgentReviewPrompt.TRUNCATION_MARKER in text, "the truncation was not marked") + assertFalse( + "The diff could not be fetched inline" in text, + "a truncated diff must not report as unfetchable", + ) + } + + // ---- Load more ---- + + @Test + fun `a repository smaller than one page has nothing more to load`() { + // hasMoreGraph used to be `size < GRAPH_MAX`, so a 12-commit repo + // offered "Load more" forever and each click refetched the same 12. + val host = Host(graph = (1..12).map { node("c$it") }) + val vm = viewModel(host) + + awaitUntil("the initial graph") { vm.graph.value.size == 12 } + + assertFalse(vm.hasMoreGraph(), "a 12-commit repository has no second page") + } + + @Test + fun `a full page leaves more to load`() { + val host = Host(graph = (1..CodebaseGitViewModel.GRAPH_PAGE + 10).map { node("c$it") }) + val vm = viewModel(host) + + awaitUntil("the initial graph") { vm.graph.value.size == CodebaseGitViewModel.GRAPH_PAGE } + + assertTrue(vm.hasMoreGraph(), "a full first page means history continues") + } + + // ---- batch ---- + + @Test + fun `a batch reports the FIRST failure, not the last result`() { + // A later success would otherwise overwrite the error and the whole + // operation would read as clean. + val host = Host(stageFailures = mapOf("b.kt" to "b is locked")) + val vm = viewModel(host) + awaitUntil("the first load") { vm.loaded.value } + + vm.stagePaths(listOf("a.kt", "b.kt", "c.kt")) + + awaitUntil("the batch result") { vm.message.value != null && !vm.busy.value } + assertEquals(listOf("a.kt", "b.kt", "c.kt"), host.staged, "every path must still be attempted") + assertTrue("b is locked" in vm.message.value!!, "reported: ${vm.message.value}") + } +} diff --git a/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/PluginManifestTest.kt b/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/PluginManifestTest.kt new file mode 100644 index 0000000..77345df --- /dev/null +++ b/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/PluginManifestTest.kt @@ -0,0 +1,74 @@ +package ai.rever.boss.plugin.dynamic.codebase + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * The version chain, end to end: build.gradle.kts -> the processResources + * filter -> the bundled plugin.json -> [CodebaseDynamicPlugin.version]. + * + * Worth a test because every link is silent when it breaks. The filter used a + * replacement string that java.util.regex.Matcher reinterprets - it emitted a + * stray backslash and produced the right answer only because Matcher then + * swallowed it, i.e. by accident, for every semver. And the manifest version + * stopped being decoration the moment the plugin started reading it back + * instead of carrying a fourth hand-maintained copy. + */ +class PluginManifestTest { + + @Test + fun `the plugin reports the version the build stamped into the manifest`() { + val version = CodebaseDynamicPlugin().version + assertTrue( + Regex("""^\d+\.\d+\.\d+$""").matches(version), + "expected a bare semver from the processed manifest, got \"$version\"", + ) + assertTrue( + version != "0.0.0", + "0.0.0 is the FALLBACK - the manifest was not found or had no version field", + ) + assertTrue( + version != "0.0.0-dev", + "0.0.0-dev is the unprocessed source placeholder - processResources did not run", + ) + } + + @Test + fun `the manifest on the classpath is this plugin's own`() { + // readManifestVersion walks every META-INF/boss-plugin/plugin.json on + // the classpath and takes the one whose pluginId matches, because that + // path is not namespaced per plugin and the first hit under parent-first + // delegation may belong to somebody else. + val text = + CodebaseDynamicPlugin::class.java.classLoader + .getResourceAsStream("META-INF/boss-plugin/plugin.json")!! + .use { it.readBytes().decodeToString() } + assertTrue( + """"pluginId"\s*:\s*"ai\.rever\.boss\.plugin\.dynamic\.codebase"""".toRegex().containsMatchIn(text), + ) + } + + @Test + fun `every permission the git tools require is declared in the manifest`() { + // An undeclared permission cannot be granted in the admin UI, so the + // tool behind it would be permanently unreachable for non-admins. + val text = + CodebaseDynamicPlugin::class.java.classLoader + .getResourceAsStream("META-INF/boss-plugin/plugin.json")!! + .use { it.readBytes().decodeToString() } + val declared = + Regex(""""name"\s*:\s*"([^"]+)"""").findAll(text).map { it.groupValues[1] }.toSet() + val required = + CodebaseGitMcpToolProvider("codebase", { null }, { null }) + .tools() + .flatMap { it.requiredPermissions } + .toSet() + assertTrue(required.isNotEmpty(), "the provider declares no permissions at all") + assertEquals( + emptySet(), + required - declared, + "required but not declared in plugin.json definedPermissions", + ) + } +} diff --git a/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/SearchPathTest.kt b/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/SearchPathTest.kt index 6422a99..a3b1bc3 100644 --- a/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/SearchPathTest.kt +++ b/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/SearchPathTest.kt @@ -120,4 +120,19 @@ class SearchPathTest { val home = System.getProperty("user.home").trimEnd('/') assertEquals("${home}2/app", collapseHome("${home}2/app")) } + + @Test + fun `a backslash-joined home collapses on a Windows-style path`() { + // These paths come from File.absolutePath, so on Windows both the home + // directory and the project are backslash-joined and the '/'-only rule + // never matched. The separator is a parameter so the case is testable + // on any OS. + val winHome = "C:\\Users\\me" + assertEquals("~\\src\\app", collapseHome("$winHome\\src\\app", '\\', winHome)) + assertEquals("~", collapseHome(winHome, '\\', winHome)) + assertEquals( + "C:\\Users\\meelsewhere\\app", + collapseHome("C:\\Users\\meelsewhere\\app", '\\', winHome), + ) + } } From 1a97128a9d371e4c5039dad876ad1a4ea8b9c5f6 Mon Sep 17 00:00:00 2001 From: Shivanshu Date: Mon, 31 Aug 2026 22:25:20 -0700 Subject: [PATCH 6/8] review: round 4 - commit() guard, stage-by-path, truncation as a fact, observable hasMoreGraph Per the 05:09 review: 1. commit() now has the try/catch every other operation has; a throwing provider (IPC drop, index.lock) reports "Failed: ..." instead of escaping the SupervisorJob scope to the thread's default handler after the spinner stopped. 2. commit() takes the paths to stage, not a stageAll flag. The provider's stageAll is `git add -A`, which also stages untracked files; under a button labelled "Commit All" that sits above a separate UNTRACKED group it quietly commits files the user never touched. The UI passes `changed` (the tracked edits the label counts), and a stage failure stops short of the commit. 3. collectDiff returns the collected text AND a truncated flag, set on every drop path (sliced file, budget exhaustion, file cap, failed fetch) rather than inferred from a marker only the sliced file carries. The prompt builder takes diffTruncated as a fact and says the diff is INCOMPLETE; a collection that simply stopped early no longer reads as complete. 4. hasMoreGraph is a StateFlow (combine of the graph and the exhaustion latch) collected by the UI, not a function reading both as plain values; the confounded-same-list case that would leave the action on screen is now covered by the flow itself. 5. refreshStatus reads isGitRepository through awaitRepositoryFlag() - the same wait the MCP tools' awaitFresh does - instead of sampling the flag in the same breath as the async refresh. Smaller: buildPluginJar/shadowJar no longer re-add the raw src/main/resources alongside the processed output; onProjectChanged clears the commit message, review instructions and graph latch; project.replace permission uses a constant; api types imported instead of fully qualified; three unused catch parameters renamed; the GitCommitRow tooltip block re-indented; parseJsonStringArray no longer trims element interiors; absolutePathOf trims both path separators and joins with the platform's. Tests: commit stages-by-path and never stageAlls; a stage failure and a throwing commit() both surface; a cut-short multi-file collection and a file-cap overflow both announce themselves as incomplete; the truncation warning is driven by the flag, not the marker. --- build.gradle.kts | 14 +- .../dynamic/codebase/AgentReviewPrompt.kt | 17 +- .../dynamic/codebase/CodebaseGitMcpTools.kt | 57 +++-- .../boss/plugin/dynamic/codebase/GitTab.kt | 196 +++++++++++++----- .../boss/plugin/dynamic/codebase/GitTabUi.kt | 33 +-- .../boss/plugin/dynamic/codebase/SearchTab.kt | 10 +- .../dynamic/codebase/AgentReviewPromptTest.kt | 22 +- .../codebase/GitViewModelOperationsTest.kt | 152 +++++++++++++- 8 files changed, 398 insertions(+), 103 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 5a3afc3..3d91d59 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -109,11 +109,15 @@ tasks.register("buildPluginJar") { ) } - // Include compiled classes + // Compiled classes AND processed resources - sourceSets.main.output already + // carries build/resources/main, so the raw src/main/resources must NOT be + // added as well. With DuplicatesStrategy.EXCLUDE the first copy wins, so + // adding both shipped the correct manifest only because the processed one + // happened to be declared first. That is a live landmine now that + // plugin.json holds the 0.0.0-dev placeholder and the plugin reads its + // version back out of the jar at runtime: a reorder would ship a plugin + // reporting 0.0.0-dev to the host. from(sourceSets.main.get().output) - - // Include plugin manifest - from("src/main/resources") } // Sync version from build.gradle.kts into plugin.json (single source of truth) @@ -151,6 +155,6 @@ tasks.register("shadowJar") { ) } from(configurations.runtimeClasspath.get().map { if (it.isDirectory) it else zipTree(it) }) + // Processed resources only - see buildPluginJar above. from(sourceSets.main.get().output) - from("src/main/resources") } diff --git a/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/AgentReviewPrompt.kt b/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/AgentReviewPrompt.kt index b2cac4e..5ff4733 100644 --- a/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/AgentReviewPrompt.kt +++ b/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/AgentReviewPrompt.kt @@ -22,6 +22,14 @@ object AgentReviewPrompt { staged: List, unstaged: List, diffText: String?, + /** + * Whether the collector left anything out - a sliced file, a file + * dropped for want of budget or past its file cap, or one whose diff + * would not fetch. Passed as a fact rather than sniffed back out of + * [diffText]: the marker only ever appeared on a file the collector + * sliced, so a run that simply stopped early looked complete. + */ + diffTruncated: Boolean = false, /** Free text from the review panel; empty when the user gave none. */ instructions: String = "", /** Quick keeps it to a short pass; Deep asks for a thorough one. */ @@ -94,8 +102,13 @@ object AgentReviewPrompt { // lands a few characters OVER the budget. Re-testing would replace // that text with a tool pointer and drop the diff exactly in the // case the truncation exists to serve: one oversized file. - if (diffText.contains(TRUNCATION_MARKER)) { - sb.appendLine("(diff truncated to the inline budget; use the git_diff / git_diff_ref tools for the rest)") + if (diffTruncated) { + sb.appendLine( + "This diff is INCOMPLETE - it was cut to fit an inline budget. " + + "Files below with no diff here, and any block ending in " + + "\"$TRUNCATION_MARKER\", must be read with git_diff before you " + + "draw conclusions about them.", + ) } sb.appendLine("Full diff:") sb.appendLine("```diff") diff --git a/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/CodebaseGitMcpTools.kt b/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/CodebaseGitMcpTools.kt index 1aa00fd..a0a934f 100644 --- a/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/CodebaseGitMcpTools.kt +++ b/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/CodebaseGitMcpTools.kt @@ -1,6 +1,10 @@ package ai.rever.boss.plugin.dynamic.codebase import ai.rever.boss.plugin.api.GitDataProvider +import ai.rever.boss.plugin.api.GitDiffData +import ai.rever.boss.plugin.api.GitFileStatusData +import ai.rever.boss.plugin.api.GitFileStatusTypeData +import ai.rever.boss.plugin.api.GitOperationResultData import ai.rever.boss.plugin.api.McpToolArgs import ai.rever.boss.plugin.api.McpToolDefinition import ai.rever.boss.plugin.api.McpToolHandler @@ -236,7 +240,7 @@ internal class CodebaseGitMcpToolProvider( "For regex queries the replacement supports $1..$9 capture references.", inputSchema = REPLACE_SCHEMA, readOnly = false, - requiredPermissions = listOf("project.replace"), + requiredPermissions = listOf(PROJECT_REPLACE), handler = McpToolHandler { args -> replace(args) }, ), ) @@ -367,7 +371,7 @@ internal class CodebaseGitMcpToolProvider( } c == '"' -> inString = !inString c == ',' && !inString -> { - items += current.toString().trim() + items += current.toString() current.clear() } !inString && c.isWhitespace() -> Unit @@ -377,7 +381,11 @@ internal class CodebaseGitMcpToolProvider( i++ } if (inString) return null - items += current.toString().trim() + items += current.toString() + // No trim: whitespace BETWEEN elements is skipped by the scanner + // above (`!inString && c.isWhitespace()`), so anything left here was + // inside the quotes and is part of the path. The array form exists to + // carry awkward paths faithfully; trimming here undid that. return if (items.all { it.isNotEmpty() }) items else null } @@ -421,30 +429,30 @@ internal class CodebaseGitMcpToolProvider( * the index side produced a three-column ` ??` that no agent slicing * fixed columns can parse. */ - internal fun statusCell(s: ai.rever.boss.plugin.api.GitFileStatusData): String { + internal fun statusCell(s: GitFileStatusData): String { val untrackedOrIgnored = listOf(s.indexStatus, s.workTreeStatus).firstOrNull { - it == ai.rever.boss.plugin.api.GitFileStatusTypeData.UNTRACKED || - it == ai.rever.boss.plugin.api.GitFileStatusTypeData.IGNORED + it == GitFileStatusTypeData.UNTRACKED || + it == GitFileStatusTypeData.IGNORED } return when (untrackedOrIgnored) { - ai.rever.boss.plugin.api.GitFileStatusTypeData.UNTRACKED -> "??" - ai.rever.boss.plugin.api.GitFileStatusTypeData.IGNORED -> "!!" + GitFileStatusTypeData.UNTRACKED -> "??" + GitFileStatusTypeData.IGNORED -> "!!" else -> "${statusChar(s.indexStatus)}${statusChar(s.workTreeStatus)}" } } - private fun statusChar(type: ai.rever.boss.plugin.api.GitFileStatusTypeData?): String = + private fun statusChar(type: GitFileStatusTypeData?): String = when (type) { null -> " " - ai.rever.boss.plugin.api.GitFileStatusTypeData.MODIFIED -> "M" - ai.rever.boss.plugin.api.GitFileStatusTypeData.ADDED -> "A" - ai.rever.boss.plugin.api.GitFileStatusTypeData.DELETED -> "D" - ai.rever.boss.plugin.api.GitFileStatusTypeData.RENAMED -> "R" - ai.rever.boss.plugin.api.GitFileStatusTypeData.COPIED -> "C" - ai.rever.boss.plugin.api.GitFileStatusTypeData.UNTRACKED -> "?" - ai.rever.boss.plugin.api.GitFileStatusTypeData.IGNORED -> "!" - ai.rever.boss.plugin.api.GitFileStatusTypeData.UNMERGED -> "U" + GitFileStatusTypeData.MODIFIED -> "M" + GitFileStatusTypeData.ADDED -> "A" + GitFileStatusTypeData.DELETED -> "D" + GitFileStatusTypeData.RENAMED -> "R" + GitFileStatusTypeData.COPIED -> "C" + GitFileStatusTypeData.UNTRACKED -> "?" + GitFileStatusTypeData.IGNORED -> "!" + GitFileStatusTypeData.UNMERGED -> "U" } private fun unavailableGit(): McpToolResult = @@ -455,13 +463,13 @@ internal class CodebaseGitMcpToolProvider( private fun op( label: String, - result: ai.rever.boss.plugin.api.GitOperationResultData?, + result: GitOperationResultData?, ): McpToolResult { return when (result) { null -> unavailableGit() - is ai.rever.boss.plugin.api.GitOperationResultData.Success -> + is GitOperationResultData.Success -> McpToolResult("$label${result.message?.let { ": $it" } ?: ""}") - is ai.rever.boss.plugin.api.GitOperationResultData.Error -> + is GitOperationResultData.Error -> McpToolResult("$label failed: ${result.message}", isError = true) } } @@ -473,19 +481,19 @@ internal class CodebaseGitMcpToolProvider( * file of a commit presented as the whole commit - with nothing to tell the * caller the rest existed. An agent reading it drew conclusions from one file. */ - private fun diffTexts(diffs: List): McpToolResult { + private fun diffTexts(diffs: List): McpToolResult { if (diffs.isEmpty()) return McpToolResult("No diff.") if (diffs.size == 1) return diffText(diffs.first()) val body = diffs.joinToString("\n\n") { renderOne(it) } return McpToolResult(capDiff("${diffs.size} files changed\n\n$body")) } - private fun diffText(diff: ai.rever.boss.plugin.api.GitDiffData?): McpToolResult { + private fun diffText(diff: GitDiffData?): McpToolResult { if (diff == null) return McpToolResult("No diff.") return McpToolResult(capDiff(renderOne(diff))) } - private fun renderOne(diff: ai.rever.boss.plugin.api.GitDiffData): String { + private fun renderOne(diff: GitDiffData): String { val header = "diff --git ${diff.path} ${diff.oldPath?.let { "$it " } ?: ""}" + "(+${diff.additions}/-${diff.deletions})" @@ -542,6 +550,9 @@ internal class CodebaseGitMcpToolProvider( /** Permission required by every git tool that mutates repository state. */ const val GIT_WRITE = "git.write" + /** Permission required to write file CONTENTS, as opposed to git state. */ + const val PROJECT_REPLACE = "project.replace" + /** * How long [awaitFresh] waits, after calling a `refresh*()` method, for * the provider's flow to move off its pre-refresh value. diff --git a/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitTab.kt b/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitTab.kt index 3820bfd..a5475b8 100644 --- a/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitTab.kt +++ b/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitTab.kt @@ -1,9 +1,11 @@ package ai.rever.boss.plugin.dynamic.codebase import ai.rever.boss.plugin.api.AiGatewayAPI +import ai.rever.boss.plugin.api.DiffLineKind import ai.rever.boss.plugin.api.GitBranchRefData import ai.rever.boss.plugin.api.GitCommitNodeData import ai.rever.boss.plugin.api.GitDataProvider +import ai.rever.boss.plugin.api.GitDiffData import ai.rever.boss.plugin.api.GitFileStatusData import ai.rever.boss.plugin.api.GitFileStatusTypeData import ai.rever.boss.plugin.api.GitOperationResultData @@ -14,10 +16,15 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull /** * The GIT tab of the codebase panel (P7): the changes accordion plus the @@ -68,6 +75,27 @@ class CodebaseGitViewModel( /** Latched once a load returns fewer commits than it requested. See [hasMoreGraph]. */ private val _graphExhausted = MutableStateFlow(false) + /** + * Whether "Load more" has anything left to load. + * + * A FLOW, not a function. As a function it read [_graphExhausted] and the + * graph size as plain values, and only re-evaluated because a changing + * graph happened to drive recomposition - so the one case it needed to + * cover was the one it missed: a "Load more" that returns the same list is + * conflated away by StateFlow equality, the latch flips, and nothing + * recomposes, leaving the action on screen. + * + * Not `size < GRAPH_MAX` either: a 12-commit repository is under the + * ceiling forever, so the action showed on every repository smaller than + * 200 commits and each click refetched the same rows. The latch is set + * when a load comes back with fewer commits than it asked for, which is + * the only signal the provider gives that history ran out. + */ + val hasMoreGraph: StateFlow = + combine(_graph, _graphExhausted) { graph, exhausted -> + !exhausted && graph.size < GRAPH_MAX + }.stateIn(scope, SharingStarted.Eagerly, false) + /** * The ref the graph is showing, or null for the checked-out branch. * @@ -238,13 +266,12 @@ class CodebaseGitViewModel( fun refreshStatus() { scope.launch { try { - git?.refreshStatus() + val isRepo = awaitRepositoryFlag() // Only when there is no repository. describeMissingRepo lists the // project root and stats up to CHILD_SCAN_LIMIT children looking // for nested .git dirs - blocking work for a hint the normal case // never renders. - _noRepoHint.value = - if (isGitRepository.value) "" else describeMissingRepo(getProjectPath()) + _noRepoHint.value = if (isRepo) "" else describeMissingRepo(getProjectPath()) } catch (e: Exception) { // The one provider call here used to have no catch, so an IPC // drop escaped the launch to the thread's uncaught handler and @@ -256,6 +283,30 @@ class CodebaseGitViewModel( } } + /** + * Refresh, then read [GitDataProvider.isGitRepository] once it has actually + * moved - the same wait the MCP tools' `awaitFresh` does, for the same + * reason: the provider refreshes asynchronously, and over IPC when this + * plugin runs out-of-process, so the value sampled immediately after + * `refreshStatus()` is the one from BEFORE it. + * + * Getting this wrong is visible twice over: the panel flashes "X is not a + * Git repository" on a perfectly good repo (because [_loaded] flips in the + * same breath), and it pays for a directory listing plus up to + * [CHILD_SCAN_LIMIT] stats that a repository never needs. + * + * A no-op refresh emits nothing and the wait simply times out onto the + * current value, which is already correct in that case. + */ + private suspend fun awaitRepositoryFlag(): Boolean { + val provider = git ?: return false + val before = provider.isGitRepository.value + provider.refreshStatus() + return withTimeoutOrNull(REPO_FLAG_SETTLE_MS) { + provider.isGitRepository.first { it != before } + } ?: provider.isGitRepository.value + } + /** * Poll the work tree while the tab is open. The provider does not watch * the filesystem for us, so a bare re-read of its cached value would @@ -275,7 +326,7 @@ class CodebaseGitViewModel( // any explicit operation surfaces the error via op(). try { git.refreshStatus() - } catch (e: Exception) { + } catch (_: Exception) { // Deliberately silent here: surfacing on every 5 s tick // would overwrite real operation results. } @@ -305,6 +356,12 @@ class CodebaseGitViewModel( _graph.value = emptyList() _graphRef.value = null _reviewBase.value = "" + // A commit message and review instructions are written ABOUT a + // project; carrying them into the next one puts someone else's draft + // in your commit box. + _commitMessage.value = "" + _reviewInstructions.value = "" + _graphExhausted.value = false refreshStatus() loadGraph(reset = true) } @@ -370,7 +427,7 @@ class CodebaseGitViewModel( * [GitDataProvider.branches]. */ private suspend fun refreshBranchOptions() { - val refs = try { git?.branches().orEmpty() } catch (e: Exception) { emptyList() } + val refs = try { git?.branches().orEmpty() } catch (_: Exception) { emptyList() } _branchRefs.value = refs _remoteNames.value = GitBranchModel.remoteNamesOf(refs) refs.firstOrNull { it.isCurrent }?.let { _currentBranch.value = it.name } @@ -441,17 +498,6 @@ class CodebaseGitViewModel( */ fun push() = op(refreshGraph = true) { git?.push() } - /** - * Whether "Load more" has anything left to load. - * - * Not `size < GRAPH_MAX`: a 12-commit repository is under the ceiling - * forever, so the action showed on every repository smaller than 200 - * commits and each click refetched the same rows. The latch below is set - * when a load comes back with fewer commits than it asked for, which is - * the only signal the provider gives that history ran out. - */ - fun hasMoreGraph(): Boolean = !_graphExhausted.value && _graph.value.size < GRAPH_MAX - // ---- row / commit operations ---- fun stage(path: String) = op { git?.stage(path) } @@ -502,11 +548,19 @@ class CodebaseGitViewModel( fun checkout(ref: String) = op(refreshGraph = true) { git?.checkout(ref) } /** - * Commit. With nothing staged but tracked changes present this stages - * them first, matching VS Code's "Commit" on an empty index; the button - * label says so, so the extra `git add` is never a surprise. + * Commit. With nothing staged but tracked changes present, [stageFirst] + * carries exactly the paths to stage - matching VS Code's "Commit" on an + * empty index; the button label says so, so the extra `git add` is never + * a surprise. + * + * A path LIST, not a `stageAll` flag. The provider's stageAll is + * `git add -A`, which also stages untracked files: under a button labelled + * "Commit All", sitting above a separate UNTRACKED group the user has not + * touched, that quietly commits files they never staged. The CHANGES + * header button already refuses stageAll for the same reason, and here the + * stakes are higher because this one lands a commit. */ - fun commit(stageFirst: Boolean = false) { + fun commit(stageFirst: List = emptyList()) { val message = _commitMessage.value.trim() if (message.isEmpty()) { _message.value = "Enter a commit message first." @@ -520,10 +574,12 @@ class CodebaseGitViewModel( _message.value = null scope.launch { try { - if (stageFirst) { - val staged = git.stageAll() + for (path in stageFirst) { + val staged = git.stage(path) if (staged is GitOperationResultData.Error) { - _message.value = "Failed: ${staged.message}" + // Stop before committing: a partial index plus a commit + // is worse than no commit at all. + _message.value = "Failed to stage $path: ${staged.message}" return@launch } } @@ -532,6 +588,12 @@ class CodebaseGitViewModel( loadGraph(reset = true) } git.refreshStatus() + } catch (e: Exception) { + // The one operation here that used to have no catch - and the + // most consequential to lose, because after staging, a failure + // between the stage and the commit leaves the index changed + // with nothing on screen saying so. + _message.value = "Failed: ${e.message ?: e::class.simpleName}" } finally { _busy.value = false } @@ -595,7 +657,7 @@ class CodebaseGitViewModel( _message.value = "Nothing to describe - there are no changes." return@launch } - val diff = collectDiff(subject, COMMIT_DIFF_BUDGET) + val diff = collectDiff(subject, COMMIT_DIFF_BUDGET).text val reply = gateway.complete(CommitMessagePrompt.request(subject, diff)).getOrNull() val text = CommitMessagePrompt.clean(reply?.text.orEmpty()) @@ -621,14 +683,14 @@ class CodebaseGitViewModel( * alignment). Pure, so [GitTabCompactDiffTest] can pin it. */ internal fun compactDiff( - diff: ai.rever.boss.plugin.api.GitDiffData, + diff: GitDiffData, context: Int = 3, ): String { val lines = diff.hunks.flatMap { it.lines } - if (lines.none { it.kind != ai.rever.boss.plugin.api.DiffLineKind.CONTEXT }) return "" + if (lines.none { it.kind != DiffLineKind.CONTEXT }) return "" val keep = BooleanArray(lines.size) lines.forEachIndexed { i, l -> - if (l.kind != ai.rever.boss.plugin.api.DiffLineKind.CONTEXT) { + if (l.kind != DiffLineKind.CONTEXT) { for (j in (i - context).coerceAtLeast(0)..(i + context).coerceAtMost(lines.size - 1)) keep[j] = true } } @@ -644,8 +706,8 @@ class CodebaseGitViewModel( gap = false val prefix = when (l.kind) { - ai.rever.boss.plugin.api.DiffLineKind.ADDED -> "+" - ai.rever.boss.plugin.api.DiffLineKind.REMOVED -> "-" + DiffLineKind.ADDED -> "+" + DiffLineKind.REMOVED -> "-" else -> " " } sb.append(prefix).append(l.text).append('\n') @@ -654,15 +716,30 @@ class CodebaseGitViewModel( return sb.toString() } + /** The collected diff text, and whether anything was left out of it. */ + internal data class CollectedDiff(val text: String, val truncated: Boolean) + /** * Per-file compact diffs for [files], concatenated with a `--- path` header - * each, truncated to [budget] characters total. Used to build the prompt + * each, bounded to [budget] characters total. Used to build the prompt * payload for Agent Review and commit-message generation. + * + * [CollectedDiff.truncated] reports whether ANY file was cut short, dropped + * for want of budget, dropped past [MAX_INLINE_FILES], or failed to fetch. + * The caller passes that to the prompt builder as a fact, rather than the + * builder sniffing the marker out of the text - and it used to be missed + * entirely for the common shape: with a dozen medium files the loop broke + * at file 7, appended no marker, and the prompt said "Full diff:" over + * seven of them with nothing saying the rest existed. */ - private suspend fun collectDiff(files: List, budget: Int): String { - val provider = git ?: return "" + private suspend fun collectDiff(files: List, budget: Int): CollectedDiff { + val provider = git ?: return CollectedDiff("", false) + val distinct = files.distinctBy { it.path } + // Files past the cap never get a per-file marker of their own, so the + // overflow itself is the signal. + var truncated = distinct.size > MAX_INLINE_FILES val sb = StringBuilder() - for (f in files.distinctBy { it.path }.take(MAX_INLINE_FILES)) { + for (f in distinct.take(MAX_INLINE_FILES)) { // A throwing provider (IPC drop, repo mid-rebase) on ONE file must not // kill the whole collection: skip that file, keep the rest. The file // stays in the listing above, and the agent can fetch it via git_diff. @@ -671,7 +748,10 @@ class CodebaseGitViewModel( .getOrNull()?.firstOrNull() ?: runCatching { provider.diffFile(f.path, staged = false) } .getOrNull()?.firstOrNull() - ?: continue + if (diff == null) { + truncated = true + continue + } // compactDiff, not rawUnified: the host generates diffs with whole-file // context (-U100000) for the viewer, so rawUnified is the entire file. Fed // to a char-budgeted prompt that broke on the first oversized block, a @@ -679,23 +759,27 @@ class CodebaseGitViewModel( // form is just the changed lines plus a little context, so the budget now // bounds real content instead of context padding. val block = compactDiff(diff).ifBlank { continue } - if (sb.isNotEmpty() && sb.length + block.length > budget) break // Reserve the per-file header and the truncation marker, so the - // framing never pushes the result past [budget]: the prompt - // builder used to treat an over-budget result as "too large" and - // drop it, which voided the truncation exactly when one happened. + // framing never pushes the result past [budget]. val frame = "--- ${f.path}\n".length + AgentReviewPrompt.TRUNCATION_MARKER.length + 2 - if (sb.length + frame >= budget) break val remaining = budget - sb.length - frame + // Every file gets sliced to fit, not just the first: breaking on the + // first block that does not fit whole dropped the rest silently. Stop + // once what is left would be too small to read. + if (remaining < MIN_DIFF_SLICE) { + truncated = true + break + } val slice = if (block.length > remaining) { + truncated = true block.take(remaining) + "\n" + AgentReviewPrompt.TRUNCATION_MARKER + "\n" } else { block } sb.append("--- ").append(f.path).append('\n').append(slice).append('\n') } - return sb.toString() + return CollectedDiff(sb.toString(), truncated) } /** @@ -710,23 +794,21 @@ class CodebaseGitViewModel( val staged = status.filter { it.isStaged } val unstaged = status.filter { it.isUnstaged } - var diffText: String? = null - if (git != null) { - // Reuse collectDiff: it uses compactDiff (not rawUnified, which with - // the host's -U100000 is the entire file) and slices an oversized - // block instead of breaking, so the first large file does not empty - // the whole prompt. - diffText = - collectDiff(staged + unstaged, AgentReviewPrompt.INLINE_DIFF_BUDGET) - .takeIf { it.isNotBlank() } - } + // Reuse collectDiff: it uses compactDiff (not rawUnified, which with + // the host's -U100000 is the entire file) and slices an oversized + // block instead of breaking, so a large file does not empty the + // whole prompt. + val collected = + if (git != null) collectDiff(staged + unstaged, AgentReviewPrompt.INLINE_DIFF_BUDGET) + else CollectedDiff("", false) onAgentReview( AgentReviewPrompt.build( projectPath = getProjectPath().orEmpty(), staged = staged, unstaged = unstaged, - diffText = diffText, + diffText = collected.text.takeIf { it.isNotBlank() }, + diffTruncated = collected.truncated, instructions = _reviewInstructions.value, deep = _reviewDeep.value, baseRef = _reviewBase.value, @@ -801,9 +883,19 @@ class CodebaseGitViewModel( /** Diff characters sent when drafting a commit message. */ private const val COMMIT_DIFF_BUDGET = 12_000 + /** + * Smallest slice of a file's diff worth attaching. Below this the + * remaining budget buys a few unreadable characters, so the collection + * stops and reports itself truncated instead. + */ + private const val MIN_DIFF_SLICE = 200 + /** Immediate children inspected for the no-repository hint. */ private const val CHILD_SCAN_LIMIT = 200 + /** How long [awaitRepositoryFlag] waits for the provider's flag to move. */ + private const val REPO_FLAG_SETTLE_MS = 2_000L + /** * A sentence for the empty state, from the selected folder alone: * names the folder, and counts the repositories directly inside it so @@ -823,7 +915,7 @@ class CodebaseGitViewModel( ?.take(CHILD_SCAN_LIMIT) ?.count { java.io.File(it, ".git").exists() } ?: 0 - } catch (e: Exception) { + } catch (_: Exception) { 0 } return when { diff --git a/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitTabUi.kt b/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitTabUi.kt index 254e177..76cda91 100644 --- a/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitTabUi.kt +++ b/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitTabUi.kt @@ -124,6 +124,7 @@ fun CodebaseGitContent( val fileStatus by viewModel.fileStatus.collectAsState() val graph by viewModel.graph.collectAsState() + val hasMoreGraph by viewModel.hasMoreGraph.collectAsState() val graphBusy by viewModel.graphBusy.collectAsState() val busy by viewModel.busy.collectAsState() val message by viewModel.message.collectAsState() @@ -170,10 +171,16 @@ fun CodebaseGitContent( } // Nothing staged but tracked edits present: commit stages them first, the - // way VS Code's Commit does, and the label says so. + // way VS Code's Commit does, and the label says so. It stages exactly + // `changed` - the tracked edits the label counts - and never the UNTRACKED + // group, which the user has not touched. val commitStagesAll = staged.isEmpty() && changed.isNotEmpty() val canCommit = (staged.isNotEmpty() || commitStagesAll) && commitMessage.isNotBlank() && !busy - val doCommit = { if (canCommit) viewModel.commit(stageFirst = commitStagesAll) } + val doCommit = { + if (canCommit) { + viewModel.commit(stageFirst = if (commitStagesAll) changed.map { it.path } else emptyList()) + } + } // Box, not a bare Column - see the note in CodebaseSearchContent. Emitted // after the Column, GitConfirmDialog was measured at zero height, so @@ -483,7 +490,7 @@ fun CodebaseGitContent( graph = graph, graphBusy = graphBusy, remoteNames = remoteNames, - hasMore = viewModel.hasMoreGraph(), + hasMore = hasMoreGraph, modifier = Modifier.weight((1f - splitFraction).coerceAtLeast(0.05f)), onOpen = { viewModel.openCommitDiff(it.hash) }, onAction = { confirm = it }, @@ -1084,15 +1091,15 @@ private fun GitCommitRow( GraphRowCanvas(row = graphRow, laneCount = laneCount) Spacer(Modifier.width(4.dp)) // The row shows the relative age; the tooltip carries the absolute date - // formatCommitDate was written for. - val absoluteDate = formatCommitDate(node.date) - val tooltip = - if (absoluteDate.isNotEmpty()) "${node.shortHash} ${node.subject}\n${node.author} • $absoluteDate" - else "${node.shortHash} ${node.subject}\n${node.author}" - CodebaseTooltip( - tooltip, - modifier = Modifier.weight(1f), - ) { + // formatCommitDate was written for. + val absoluteDate = formatCommitDate(node.date) + val tooltip = + if (absoluteDate.isNotEmpty()) "${node.shortHash} ${node.subject}\n${node.author} • $absoluteDate" + else "${node.shortHash} ${node.subject}\n${node.author}" + CodebaseTooltip( + tooltip, + modifier = Modifier.weight(1f), + ) { // Pills between the subject and the author, both of which give up // width to them: a branch head is the one thing on this row you // scan a graph FOR, and a long subject must not push it off the @@ -1575,7 +1582,7 @@ internal fun formatCommitDate(epochSeconds: Long): String { if (epochSeconds <= 0) return "" return try { SimpleDateFormat("MMM d, yyyy", Locale.US).format(Date(epochSeconds * 1000L)) - } catch (e: Exception) { + } catch (_: Exception) { "" } } diff --git a/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/SearchTab.kt b/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/SearchTab.kt index dc814af..ef45164 100644 --- a/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/SearchTab.kt +++ b/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/SearchTab.kt @@ -302,10 +302,14 @@ class CodebaseSearchViewModel( * click. [ProjectSearchProvider.replaceInProject] takes either, which is * why replace worked while opening did not. */ - internal fun absolutePathOf(path: String): String { + internal fun absolutePathOf(path: String, separator: Char = PathUtils.platformSeparator): String { if (path.startsWith("/") || path.matches(WINDOWS_ABSOLUTE)) return path - val root = getProjectPath()?.trimEnd('/') ?: return path - return if (root.isEmpty()) path else "$root/$path" + // Trim BOTH separators and join with the platform's: a Windows root + // ("C:\\proj") trimmed only of '/' joined as "C:\\proj/src/x.kt". + // Java tolerates the mix, but the result is handed to the editor and + // compared against host-sourced paths, so it should look like one. + val root = getProjectPath()?.trimEnd('/', '\\') ?: return path + return if (root.isEmpty()) path else root + separator + path } /** Dry-run first, so the UI can show what will change before writing. */ diff --git a/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/AgentReviewPromptTest.kt b/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/AgentReviewPromptTest.kt index 8d3a3d8..deb1783 100644 --- a/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/AgentReviewPromptTest.kt +++ b/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/AgentReviewPromptTest.kt @@ -3,6 +3,7 @@ package ai.rever.boss.plugin.dynamic.codebase import ai.rever.boss.plugin.api.GitFileStatusData import ai.rever.boss.plugin.api.GitFileStatusTypeData import kotlin.test.Test +import kotlin.test.assertFalse import kotlin.test.assertTrue /** Pins the review brief the Atlas agent receives. */ @@ -44,13 +45,30 @@ class AgentReviewPromptTest { // the diff exactly in the case the truncation exists to serve. val diff = "x".repeat(AgentReviewPrompt.INLINE_DIFF_BUDGET + 1) + "\n" + AgentReviewPrompt.TRUNCATION_MARKER + "\n" val unstaged = listOf(status("big.txt", false, GitFileStatusTypeData.MODIFIED)) - val prompt = AgentReviewPrompt.build("/p", emptyList(), unstaged, diff) + val prompt = + AgentReviewPrompt.build("/p", emptyList(), unstaged, diff, diffTruncated = true) assertTrue(diff in prompt, "the truncated diff must still be inlined") - assertTrue("truncated" in prompt) + assertTrue("INCOMPLETE" in prompt) assertTrue("git_diff" in prompt) assertTrue("[MODIFIED] big.txt" in prompt) } + @Test + fun `truncation is announced from the flag, not sniffed out of the text`() { + // A collection that simply STOPPED early - a dozen medium files, budget + // gone at file seven - carries no marker anywhere in the text. Keying + // the warning off the marker made exactly that case read as complete. + val whole = "--- a.kt\n+one\n\n--- b.kt\n+two\n" + val unstaged = listOf(status("a.kt", false, GitFileStatusTypeData.MODIFIED)) + assertFalse(AgentReviewPrompt.TRUNCATION_MARKER in whole) + + val cut = AgentReviewPrompt.build("/p", emptyList(), unstaged, whole, diffTruncated = true) + assertTrue("INCOMPLETE" in cut, "a short-stopped collection must still say so") + + val complete = AgentReviewPrompt.build("/p", emptyList(), unstaged, whole, diffTruncated = false) + assertFalse("INCOMPLETE" in complete, "a complete diff must not be hedged") + } + @Test fun `missing diff degrades to tool instructions`() { val unstaged = listOf(status("a.kt", false, GitFileStatusTypeData.ADDED)) diff --git a/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitViewModelOperationsTest.kt b/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitViewModelOperationsTest.kt index 28740cc..b308285 100644 --- a/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitViewModelOperationsTest.kt +++ b/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitViewModelOperationsTest.kt @@ -56,6 +56,8 @@ class GitViewModelOperationsTest { val status = MutableStateFlow(emptyList()) val graphLimits = mutableListOf() val staged = mutableListOf() + val commits = mutableListOf() + var stageAllCalls = 0 override val fileStatus: StateFlow> = status override val commitLog: StateFlow> = MutableStateFlow(emptyList()) @@ -82,7 +84,15 @@ class GitViewModelOperationsTest { override suspend fun unstage(filePath: String) = GitOperationResultData.Success() - override suspend fun stageAll() = GitOperationResultData.Success() + override suspend fun stageAll(): GitOperationResultData { + stageAllCalls++ + return GitOperationResultData.Success() + } + + override suspend fun commit(message: String): GitOperationResultData { + commits += message + return GitOperationResultData.Success() + } override suspend fun unstageAll() = GitOperationResultData.Success() @@ -127,6 +137,28 @@ class GitViewModelOperationsTest { isUnstaged = true, ) + /** A diff small enough that twenty of them never approach the budget. */ + private fun tinyDiff(path: String): GitDiffData { + val lines = listOf(DiffLine("$path one line", DiffLineKind.ADDED)) + return GitDiffData( + path = path, + additions = 1, + deletions = 0, + hunks = listOf(DiffHunk(1, 1, 1, 1, "", lines)), + ) + } + + /** A diff big enough that a dozen of them overflow the budget together. */ + private fun mediumDiff(path: String): GitDiffData { + val lines = (1..120).map { DiffLine("$path added line number $it", DiffLineKind.ADDED) } + return GitDiffData( + path = path, + additions = lines.size, + deletions = 0, + hunks = listOf(DiffHunk(1, lines.size, 1, lines.size, "", lines)), + ) + } + /** A compact diff comfortably larger than the inline budget. */ private fun oversizedDiff(path: String): GitDiffData { val lines = (1..4_000).map { DiffLine("added line number $it", DiffLineKind.ADDED) } @@ -177,8 +209,11 @@ class GitViewModelOperationsTest { val vm = viewModel(host) awaitUntil("the initial graph") { vm.graph.value.size == 12 } + // hasMoreGraph is a derived flow: it settles on the view model's own + // scope, a beat after the graph it is derived from. + awaitUntil("hasMoreGraph to settle") { !vm.hasMoreGraph.value } - assertFalse(vm.hasMoreGraph(), "a 12-commit repository has no second page") + assertFalse(vm.hasMoreGraph.value, "a 12-commit repository has no second page") } @Test @@ -187,12 +222,123 @@ class GitViewModelOperationsTest { val vm = viewModel(host) awaitUntil("the initial graph") { vm.graph.value.size == CodebaseGitViewModel.GRAPH_PAGE } + awaitUntil("hasMoreGraph to settle") { vm.hasMoreGraph.value } - assertTrue(vm.hasMoreGraph(), "a full first page means history continues") + assertTrue(vm.hasMoreGraph.value, "a full first page means history continues") } // ---- batch ---- + // ---- commit ---- + + @Test + fun `Commit All stages the tracked edits it names, never the untracked group`() { + // The provider's stageAll is `git add -A`, which also stages untracked + // files. Under a button labelled "Commit All", sitting above a separate + // UNTRACKED group the user has not touched, that quietly commits files + // they never staged - and unlike the CHANGES header button, this one + // lands a commit. VS Code's `git commit -a` does not do it either. + val host = Host() + val vm = viewModel(host) + awaitUntil("the first load") { vm.loaded.value } + + vm.setCommitMessage("a message") + vm.commit(stageFirst = listOf("tracked.kt")) + + awaitUntil("the commit") { host.commits.isNotEmpty() } + assertEquals(listOf("tracked.kt"), host.staged) + assertEquals(0, host.stageAllCalls, "stageAll would sweep in the untracked group") + } + + @Test + fun `a failure while staging stops short of the commit`() { + // A partial index plus a commit is worse than no commit at all. + val host = Host(stageFailures = mapOf("b.kt" to "b is locked")) + val vm = viewModel(host) + awaitUntil("the first load") { vm.loaded.value } + + vm.setCommitMessage("a message") + vm.commit(stageFirst = listOf("a.kt", "b.kt")) + + awaitUntil("the failure") { vm.message.value != null && !vm.busy.value } + assertTrue(host.commits.isEmpty(), "nothing may be committed after a staging failure") + assertTrue("b is locked" in vm.message.value!!, "reported: ${vm.message.value}") + } + + @Test + fun `a throwing provider during commit surfaces instead of vanishing`() { + // commit() was the one operation with no catch: the exception escaped + // the SupervisorJob scope to the thread's default handler and the user + // saw the spinner stop and nothing else. + val host = + object : Host() { + override suspend fun commit(message: String): GitOperationResultData = + throw IllegalStateException("index.lock exists") + } + val vm = viewModel(host) + awaitUntil("the first load") { vm.loaded.value } + + vm.setCommitMessage("a message") + vm.commit() + + awaitUntil("the reported failure") { vm.message.value != null && !vm.busy.value } + assertTrue("index.lock exists" in vm.message.value!!, "reported: ${vm.message.value}") + } + + // ---- multi-file diff collection ---- + + @Test + fun `a diff cut short across several files still says it is incomplete`() { + // The marker is only ever appended to a file the collector SLICED. With + // a dozen medium files the loop stopped early instead, appended no + // marker, and the prompt said "Full diff:" over the ones that fit with + // nothing saying the rest existed. + val files = (1..12).map { "f$it.kt" } + val host = + Host(fileDiffs = files.associateWith { listOf(mediumDiff(it)) }) + var prompt: String? = null + val vm = viewModel(host) { prompt = it } + host.status.value = files.map { unstagedFile(it) } + + awaitUntil("the file status") { vm.fileStatus.value.size == 12 } + vm.startAgentReview() + awaitUntil("the review prompt") { prompt != null } + + val text = prompt!! + assertTrue("Full diff:" in text, "some diff must still be attached") + assertTrue("INCOMPLETE" in text, "the dropped files must be announced:\n${text.take(700)}") + assertTrue( + files.any { "--- $it" !in text }, + "this case only means anything if the budget actually dropped a file", + ) + } + + @Test + fun `files dropped past the inline file cap are announced too`() { + // The distinct case: twenty TINY diffs. Nothing is ever sliced and the + // char budget is never reached, so the only thing left out is whatever + // fell past MAX_INLINE_FILES - and that drop has no per-file marker of + // its own. Without the file-count check the prompt reads as complete. + val files = (1..20).map { "t$it.kt" } + val host = Host(fileDiffs = files.associateWith { listOf(tinyDiff(it)) }) + var prompt: String? = null + val vm = viewModel(host) { prompt = it } + host.status.value = files.map { unstagedFile(it) } + + awaitUntil("the file status") { vm.fileStatus.value.size == 20 } + vm.startAgentReview() + awaitUntil("the review prompt") { prompt != null } + + val text = prompt!! + val attached = files.count { "--- $it\n" in text } + assertTrue("Full diff:" in text, "the diffs that fit must still be attached") + // Nothing was sliced and the char budget was never close, so the ONLY + // thing left out is the file-cap overflow. (The marker cannot be used + // as the signal here: the warning text quotes it.) + assertTrue(attached in 1 until files.size, "expected a partial set, got $attached of ${files.size}") + assertTrue("INCOMPLETE" in text, "files past the cap must be announced:\n${text.take(700)}") + } + @Test fun `a batch reports the FIRST failure, not the last result`() { // A later success would otherwise overwrite the error and the whole From b3ff94e3fe530dfb6fdf2aadf59addd536323582 Mon Sep 17 00:00:00 2001 From: Shivanshu Date: Tue, 1 Sep 2026 11:22:49 +0530 Subject: [PATCH 7/8] review: round 5 - one operation per confirmation, and split the busy flags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per the 05:36 review. Items 1 and 2 could produce a wrong write to source files, so both are pinned by tests that fail when the bug is reintroduced. 1. The dry run and the write could describe DIFFERENT operations. The confirmation sheet was rendered from the summary, but applyReplacement re-read the live query, replacement and flags at click time and took the file list from the summary. The search field stays live for the whole dry run, so the sheet could say "Replace 47 across 9 files" and then run a different operation. previewReplacement now snapshots the whole operation synchronously (ReplaceOperation) and the write uses nothing else. A preview that lands after the user has typed on is dropped rather than republished. 2. execute() cleared the mutex the replace path holds. One flag was doing two jobs: the replace path CAS'd it while execute() set and cleared it unconditionally, so a scan finishing mid-write re-enabled the Replace button and a second click ran a duplicate write. Now _scanning and _writing are separate, `busy` is their union for the UI, and the auto-refresh no longer scans during a write. 3. Path arguments do NOT need the isSafeRef treatment refs get, and now say why: the host runs ProcessBuilder("git", *args) - argv, never a shell - puts `--` before every pathspec, and sets GIT_LITERAL_PATHSPECS=1. Verified against DesktopGitService. Checking paths here would only reject the legal ones beginning with a dash. 4. loadGraph had no concurrency guard. A CAS would have fixed the race by dropping the second call, which is right for a repeated "Load more" and wrong for selectGraphBranch and Refresh - dropping those leaves the graph on the previous ref, which the branch tests caught. Loads are serialised on a Mutex instead, with a generation counter so only the newest owns the spinner and a superseded-while-queued request is skipped. 5. The Agent Review pill read "Reviewing…" for any git operation and was unclickable during unrelated ones. It has its own _reviewing flag now, like _generating. Smaller: the graph list keys by index AND hash (a duplicate key throws out of LazyColumn); formatCommitDate is remembered per row instead of building a SimpleDateFormat on every recomposition; the view models are held through a RememberObserver so an ABANDONED composition disposes them too (a DisposableEffect never runs on that path); and the Agent Review KDoc records that the prompt is a broadcast carrying source diff. Concurrency audit while in here: searchJob, refreshTimer and statusTimer are written from the UI thread and read from coroutines, so they are @Volatile; loadGraph's generation counter is atomic for the same reason. Tests: 205 -> 212, and the suite drops from 22.1s to 1.8s. The MCP fakes now PUBLISH on refresh like the real provider, which both pins awaitFresh's happy path - previously untested - and stops every call paying the full 2s settle; the settle is injectable so the fallback is pinned cheaply too. New SearchViewModelTest covers the tab that writes files: the snapshot invariant, the retracted stale sheet, the write mutex surviving a scan, the capped early-return, and clear() during a scan. Verified: ./gradlew clean build green, 212 tests, no source warnings, jar manifest stamped 1.6.0 with a single plugin.json. Co-Authored-By: Claude Opus 5 (1M context) --- .../dynamic/codebase/CodebaseComponent.kt | 81 +++-- .../dynamic/codebase/CodebaseDynamicPlugin.kt | 8 + .../dynamic/codebase/CodebaseGitMcpTools.kt | 18 +- .../boss/plugin/dynamic/codebase/GitTab.kt | 175 +++++++---- .../boss/plugin/dynamic/codebase/GitTabUi.kt | 26 +- .../boss/plugin/dynamic/codebase/SearchTab.kt | 205 ++++++++++--- .../codebase/CodebaseGitMcpToolsTest.kt | 86 +++++- .../codebase/GitGraphBranchViewModelTest.kt | 3 + .../codebase/GitViewModelOperationsTest.kt | 3 + .../dynamic/codebase/SearchViewModelTest.kt | 279 ++++++++++++++++++ 10 files changed, 745 insertions(+), 139 deletions(-) create mode 100644 src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/SearchViewModelTest.kt diff --git a/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/CodebaseComponent.kt b/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/CodebaseComponent.kt index 9da8da1..0bfeedb 100644 --- a/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/CodebaseComponent.kt +++ b/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/CodebaseComponent.kt @@ -34,8 +34,8 @@ import androidx.compose.material.icons.rounded.FolderOpen import androidx.compose.material.icons.rounded.Inventory2 import androidx.compose.material.icons.rounded.Search import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.RememberObserver import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -110,32 +110,38 @@ class CodebaseComponent( var selectedTab by remember { mutableStateOf(CodebaseTab.FILES) } // Both view models own a coroutine scope, so they are created once and - // cancelled when the panel leaves composition. They used to be built - // by assigning to state *during* composition and never disposed, which - // leaked a scope (and a 15s git poll) per panel open. - val searchViewModel = remember { - CodebaseSearchViewModel( - provider = searchProvider, - splitViewOperations = splitViewOperations, - getProjectPath = getProjectPath, - ) - } - val gitViewModel = remember { - CodebaseGitViewModel( - git = gitDataProvider, - getProjectPath = getProjectPath, - getWindowId = getWindowId, - onAgentReview = onAgentReview, - aiGateway = aiGateway, - aiUnavailable = aiUnavailable, - ) - } - DisposableEffect(Unit) { - onDispose { - searchViewModel.dispose() - gitViewModel.dispose() - } - } + // released when the panel leaves composition. They used to be built by + // assigning to state *during* composition and never disposed, which + // leaked a scope (and a git poll) per panel open. + // + // Held through [DisposedWhenGone] rather than paired with a + // DisposableEffect: a composition ABANDONED before it is applied runs + // no effects at all, so onDispose would never fire and the scope would + // outlive a panel that never appeared. RememberObserver.onAbandoned is + // the only callback that covers that path. + val searchViewModel = + remember { + DisposedWhenGone( + CodebaseSearchViewModel( + provider = searchProvider, + splitViewOperations = splitViewOperations, + getProjectPath = getProjectPath, + ), + ) { it.dispose() } + }.value + val gitViewModel = + remember { + DisposedWhenGone( + CodebaseGitViewModel( + git = gitDataProvider, + getProjectPath = getProjectPath, + getWindowId = getWindowId, + onAgentReview = onAgentReview, + aiGateway = aiGateway, + aiUnavailable = aiUnavailable, + ), + ) { it.dispose() } + }.value // Three separate collectors on purpose. A StateFlow collect never // completes, so chaining these in one LaunchedEffect body left every @@ -235,7 +241,6 @@ class CodebaseComponent( } } - /** * The project the panel is showing, above the tab strip - so which directory * you are in is answered without switching to FILES. Name on top, the path @@ -291,6 +296,26 @@ private fun CodebaseProjectHeader(projectPath: String?) { } } +/** + * Owns a value that holds a CoroutineScope, and releases it however the + * composition ends. + * + * [onForgotten] is the ordinary path (the panel closed). [onAbandoned] is the + * one a `DisposableEffect` cannot reach: a composition thrown away before it + * is applied runs no effects, so the scope - and the pollers hanging off it - + * would outlive a panel that never appeared. + */ +private class DisposedWhenGone( + val value: T, + private val dispose: (T) -> Unit, +) : RememberObserver { + override fun onRemembered() = Unit + + override fun onForgotten() = dispose(value) + + override fun onAbandoned() = dispose(value) +} + /** * `/Users/me/src/app` reads as `~/src/app` - the home prefix carries nothing. * diff --git a/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/CodebaseDynamicPlugin.kt b/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/CodebaseDynamicPlugin.kt index 7dcff4e..cdf2ecc 100644 --- a/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/CodebaseDynamicPlugin.kt +++ b/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/CodebaseDynamicPlugin.kt @@ -65,6 +65,14 @@ class CodebaseDynamicPlugin : DynamicPlugin { // Agent Review wiring: publish the event the fluck-agent (Atlas) // listens for, then raise/focus its panel. + // + // NOTE: this is a BROADCAST on the application event bus, not a + // targeted send, and the payload carries up to + // AgentReviewPrompt.INLINE_DIFF_BUDGET characters of source diff. Every + // loaded plugin in this process can observe it. That is acceptable + // because the bus is in-process and the plugin set is host-governed - + // but it is source content leaving this plugin, so a targeted channel + // would be the better shape if the API ever grows one. val eventBus = context.applicationEventBus val panelEvents = context.panelEventProvider // autoStart is always true: the button IS the request to run the diff --git a/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/CodebaseGitMcpTools.kt b/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/CodebaseGitMcpTools.kt index a0a934f..4d233ab 100644 --- a/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/CodebaseGitMcpTools.kt +++ b/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/CodebaseGitMcpTools.kt @@ -32,6 +32,16 @@ import kotlinx.coroutines.withTimeoutOrNull * - project_replace (writing file contents) requires its own "project.replace". * Admins bypass all of the above. * + * Refs and paths are treated differently on purpose. An LLM-supplied REF is + * checked here with [GitBranchModel.isSafeRef], because a ref is passed to git + * ahead of the `--` separator and a leading dash would be read as a flag. An + * LLM-supplied PATH is not, and does not need to be: the host runs every git + * command as `ProcessBuilder("git", *args)` - argv, never a shell - puts `--` + * before every pathspec (`add -- `, `restore --staged -- `, + * `restore -- `, `diff … -- `), and sets `GIT_LITERAL_PATHSPECS=1` + * so pathspec magic like `:/` cannot expand either. Re-checking paths here + * would only reject the legal ones that begin with a dash. + * * Registered alongside the existing codebase_* tools in * [CodebaseDynamicPlugin.register]; auto-removed on disable/unload. */ @@ -46,6 +56,12 @@ internal class CodebaseGitMcpToolProvider( */ private val git: () -> GitDataProvider?, private val search: () -> ProjectSearchProvider?, + /** + * How long [awaitFresh] waits for a provider flow to move after a + * `refresh*()`. Injectable so tests can pin the fallback path without + * paying the real settle time on every call. + */ + private val flowSettleMs: Long = FLOW_SETTLE_MS, ) : McpToolProvider { private val gitProvider: GitDataProvider? get() = git() @@ -409,7 +425,7 @@ internal class CodebaseGitMcpToolProvider( ): T { val before = flow.value refresh() - return withTimeoutOrNull(FLOW_SETTLE_MS) { flow.first { it != before } } + return withTimeoutOrNull(flowSettleMs) { flow.first { it != before } } ?: flow.value } diff --git a/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitTab.kt b/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitTab.kt index a5475b8..cf20486 100644 --- a/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitTab.kt +++ b/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitTab.kt @@ -15,16 +15,18 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch -import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock /** * The GIT tab of the codebase panel (P7): the changes accordion plus the @@ -52,6 +54,12 @@ class CodebaseGitViewModel( private val aiGateway: () -> AiGatewayAPI? = { null }, /** Why AI is unavailable, or null when it is ready. */ private val aiUnavailable: () -> String? = { null }, + /** + * How long [awaitRepositoryFlag] waits for the host's repository flag to + * move after a refresh. Injectable so tests do not pay the real settle + * time on every construction. + */ + private val repoFlagSettleMs: Long = REPO_FLAG_SETTLE_MS, ) { // Blocking git/IPC and storage I/O, not CPU-bound work: Default is // sized to cores and these calls would block it. @@ -126,6 +134,17 @@ class CodebaseGitViewModel( private val _busy = MutableStateFlow(false) val busy: StateFlow = _busy.asStateFlow() + /** + * True only while Agent Review is collecting. + * + * Its own flag for the same reason [_generating] is: [_busy] is set by + * every stage, unstage, discard, commit, fetch, pull and push, so driving + * the review pill from it made staging a file read "Reviewing…", and made + * the review button dead for the duration of any unrelated git operation. + */ + private val _reviewing = MutableStateFlow(false) + val reviewing: StateFlow = _reviewing.asStateFlow() + private val _message = MutableStateFlow(null) val message: StateFlow = _message.asStateFlow() @@ -239,6 +258,25 @@ class CodebaseGitViewModel( private val _loaded = MutableStateFlow(false) val loaded: StateFlow = _loaded.asStateFlow() + /** + * Serialises graph loads. See [loadGraph]. + * + * Declared ABOVE `init`, which calls loadGraph() during construction: + * property initializers run in declaration order, so leaving these below it + * left them null at that first call. + */ + private val graphMutex = Mutex() + + /** + * Which graph load is newest. + * + * Atomic because loadGraph is called from the UI thread (Refresh, Load + * more, the branch picker) AND from inside coroutines on [scope] (after a + * commit, a pull, a project switch). A plain `var` holding the in-flight + * Job would be read and written from both with no happens-before. + */ + private val graphGeneration = java.util.concurrent.atomic.AtomicInteger(0) + init { // Mirror the provider's status flow for the lifetime of the tab. This // is the single source of truth for the three groups; operations only @@ -260,6 +298,8 @@ class CodebaseGitViewModel( _commitMessage.value = value } + /** Volatile: started/stopped from the UI thread, cancelled from dispose(). */ + @Volatile private var statusTimer: Job? = null /** Ask the provider to re-read the work tree; the collector delivers it. */ @@ -302,7 +342,7 @@ class CodebaseGitViewModel( val provider = git ?: return false val before = provider.isGitRepository.value provider.refreshStatus() - return withTimeoutOrNull(REPO_FLAG_SETTLE_MS) { + return withTimeoutOrNull(repoFlagSettleMs) { provider.isGitRepository.first { it != before } } ?: provider.isGitRepository.value } @@ -366,53 +406,84 @@ class CodebaseGitViewModel( loadGraph(reset = true) } + /** + * Load (or page) the commit graph. + * + * Superseding, not de-duplicating. Setting [_graphBusy] inside the + * coroutine let two "Load more" clicks in the same frame both pass the + * `enabled` guard, compute the same limit from the same `_graph.value.size` + * and issue two identical fetches - whichever returned first cleared the + * spinner while the other was still running, and the two then interleaved + * their writes to `_graph` and `_graphExhausted`. + * + * A compare-and-set would fix that by DROPPING the second call, which is + * right for a repeated "Load more" and wrong for everything else: + * `selectGraphBranch` and Refresh are new intent, and dropping them leaves + * the graph on the previous ref. + * + * So loads are serialised on [graphMutex] instead - only one fetch touches + * the graph state at a time - and a request that a newer one has already + * superseded while it queued is skipped rather than re-fetching what the + * newer one is about to fetch anyway. + */ fun loadGraph(reset: Boolean = false) { + // Set here, not in the body: the spinner must be up from the click, + // not from whenever the dispatcher gets to it. + _graphBusy.value = true + val generation = graphGeneration.incrementAndGet() scope.launch { - _graphBusy.value = true - try { - val limit = - if (reset) GRAPH_PAGE - else (_graph.value.size + GRAPH_PAGE).coerceAtMost(GRAPH_MAX) - val ref = _graphRef.value - // Always logGraphFor, never logGraph: its default body - // delegates to logGraph for a null ref, so on a host that - // predates 1.0.90 the checked-out history still draws and only - // the picker degrades. - var failed = false - val nodes = - runCatching { git?.logGraphFor(ref, limit) }.getOrElse { e -> - // A throwing provider (IPC drop, repo mid-rebase) leaves a - // message instead of an unhandled exception and a spinner - // that just stops. - _message.value = "Failed to load git history: ${e.message}" - failed = true - null - } ?: emptyList() - // The HEAD decoration only names the checked-out branch while - // the graph IS HEAD's; another branch's tip carries no - // `HEAD ->`, and reading it there blanked the toolbar. - // Publish it BEFORE the graph: the name is part of reading - // this graph, and a consumer that observes a non-empty graph - // (the test does, on its own thread) must never see it - // without the branch name - the reverse order raced on CI. - if (ref == null) { - branchOf(nodes).takeIf { it.isNotEmpty() }?.let { _currentBranch.value = it } + graphMutex.withLock { + // Superseded while queued: the newer request will do this work + // with a fresher view of the state, and it owns the spinner. + if (generation != graphGeneration.get()) return@withLock + try { + val limit = + if (reset) GRAPH_PAGE + else (_graph.value.size + GRAPH_PAGE).coerceAtMost(GRAPH_MAX) + val ref = _graphRef.value + // Always logGraphFor, never logGraph: its default body + // delegates to logGraph for a null ref, so on a host that + // predates 1.0.90 the checked-out history still draws and only + // the picker degrades. + var failed = false + val nodes = + runCatching { git?.logGraphFor(ref, limit) }.getOrElse { e -> + // A throwing provider (IPC drop, repo mid-rebase) leaves a + // message instead of an unhandled exception and a spinner + // that just stops. + _message.value = "Failed to load git history: ${e.message}" + failed = true + null + } ?: emptyList() + // The HEAD decoration only names the checked-out branch while + // the graph IS HEAD's; another branch's tip carries no + // `HEAD ->`, and reading it there blanked the toolbar. + // Publish it BEFORE the graph: the name is part of reading + // this graph, and a consumer that observes a non-empty graph + // (the test does, on its own thread) must never see it + // without the branch name - the reverse order raced on CI. + if (ref == null) { + branchOf(nodes).takeIf { it.isNotEmpty() }?.let { _currentBranch.value = it } + } + // Fewer rows than requested means the provider reached the root + // commit; anything else would page forever over the same list. + // Never latched off a FAILED load: that path yields an empty + // list too, and hiding "Load more" until the next reset is the + // wrong answer to a dropped IPC call. + if (!failed) _graphExhausted.value = nodes.size < limit + _graph.value = nodes + refreshReviewBases(nodes) + // Only on a reset: paging deeper into the same history cannot + // change which branches exist, and this is two extra `git + // branch` invocations behind a lock every "Load more" would + // otherwise pay for. + if (reset) refreshBranchOptions() + } finally { + // Only the newest load owns the spinner. A superseded one + // clearing it would hide a load that is still running; the + // newest is never superseded, so it always clears. + if (generation == graphGeneration.get()) _graphBusy.value = false } - // Fewer rows than requested means the provider reached the root - // commit; anything else would page forever over the same list. - // Never latched off a FAILED load: that path yields an empty - // list too, and hiding "Load more" until the next reset is the - // wrong answer to a dropped IPC call. - if (!failed) _graphExhausted.value = nodes.size < limit - _graph.value = nodes - refreshReviewBases(nodes) - // Only on a reset: paging deeper into the same history cannot - // change which branches exist, and this is two extra `git - // branch` invocations behind a lock every "Load more" would - // otherwise pay for. - if (reset) refreshBranchOptions() - } finally { - _graphBusy.value = false } } } @@ -787,7 +858,7 @@ class CodebaseGitViewModel( * the inline budget, then hand the composed prompt to [onAgentReview]. */ fun startAgentReview() { - if (!_busy.compareAndSet(expect = false, update = true)) return + if (!_reviewing.compareAndSet(expect = false, update = true)) return scope.launch { try { val status = _fileStatus.value @@ -819,7 +890,7 @@ class CodebaseGitViewModel( // a dead button: surface it like any other git failure. _message.value = "Agent review failed: ${e.message ?: e::class.simpleName}" } finally { - _busy.value = false + _reviewing.value = false } } } diff --git a/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitTabUi.kt b/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitTabUi.kt index 76cda91..7c6fb54 100644 --- a/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitTabUi.kt +++ b/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitTabUi.kt @@ -124,6 +124,7 @@ fun CodebaseGitContent( val fileStatus by viewModel.fileStatus.collectAsState() val graph by viewModel.graph.collectAsState() + val reviewing by viewModel.reviewing.collectAsState() val hasMoreGraph by viewModel.hasMoreGraph.collectAsState() val graphBusy by viewModel.graphBusy.collectAsState() val busy by viewModel.busy.collectAsState() @@ -438,7 +439,10 @@ fun CodebaseGitContent( base = reviewBase, branches = branches, onBaseChange = viewModel::setReviewBase, - busy = busy, + // reviewing, not busy: busy is every git operation, so + // staging a file used to relabel this pill "Reviewing…" + // and an unrelated fetch made it unclickable. + reviewing = reviewing, onFindIssues = { viewModel.startAgentReview() }, ) @@ -591,7 +595,7 @@ private fun GitAgentReview( base: String, branches: List, onBaseChange: (String) -> Unit, - busy: Boolean, + reviewing: Boolean, onFindIssues: () -> Unit, ) { Column( @@ -612,7 +616,7 @@ private fun GitAgentReview( .height(CodebaseMetrics.InputHeight) .clip(RoundedCornerShape(CodebaseMetrics.ButtonRadius)) .background( - if (busy) CodebasePalette.Accent.copy(alpha = 0.25f) + if (reviewing) CodebasePalette.Accent.copy(alpha = 0.25f) else CodebasePalette.Accent.copy(alpha = 0.85f), ), verticalAlignment = Alignment.CenterVertically, @@ -622,7 +626,7 @@ private fun GitAgentReview( modifier = Modifier .weight(1f) .fillMaxHeight() - .then(if (busy) Modifier else Modifier.clickable(onClick = onFindIssues)), + .then(if (reviewing) Modifier else Modifier.clickable(onClick = onFindIssues)), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically, ) { @@ -630,14 +634,14 @@ private fun GitAgentReview( imageVector = Icons.Outlined.AutoAwesome, contentDescription = null, modifier = Modifier.size(CodebaseMetrics.Glyph), - tint = if (busy) onAccent.copy(alpha = 0.45f) else onAccent, + tint = if (reviewing) onAccent.copy(alpha = 0.45f) else onAccent, ) Spacer(Modifier.width(6.dp)) Text( - text = if (busy) "Reviewing…" else "Fluck Agent Review", + text = if (reviewing) "Reviewing…" else "Fluck Agent Review", fontSize = CodebaseMetrics.SecondaryText, fontWeight = FontWeight.Medium, - color = if (busy) onAccent.copy(alpha = 0.45f) else onAccent, + color = if (reviewing) onAccent.copy(alpha = 0.45f) else onAccent, maxLines = 1, // "Fluck Agent Review" is two and a half times the width of // the old "Find Issues", and it shares this pill with a @@ -1038,7 +1042,11 @@ private fun GitGraphList( } LazyColumn(modifier = modifier.fillMaxWidth()) { - itemsIndexed(graph, key = { _, n -> n.hash }) { index, node -> + // Keyed by index AND hash. A duplicate key throws IllegalArgumentException + // out of LazyColumn rather than rendering oddly, and a commit hash is + // only unique if the list is - GitGraphEdges.build already uses + // putIfAbsent for its row lookup, i.e. it does not assume that. + itemsIndexed(graph, key = { i, n -> "$i-${n.hash}" }) { index, node -> GitCommitRow( node = node, graphRow = graphRows.getOrElse(index) { @@ -1092,7 +1100,7 @@ private fun GitCommitRow( Spacer(Modifier.width(4.dp)) // The row shows the relative age; the tooltip carries the absolute date // formatCommitDate was written for. - val absoluteDate = formatCommitDate(node.date) + val absoluteDate = remember(node.date) { formatCommitDate(node.date) } val tooltip = if (absoluteDate.isNotEmpty()) "${node.shortHash} ${node.subject}\n${node.author} • $absoluteDate" else "${node.shortHash} ${node.subject}\n${node.author}" diff --git a/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/SearchTab.kt b/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/SearchTab.kt index ef45164..9aea0ee 100644 --- a/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/SearchTab.kt +++ b/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/SearchTab.kt @@ -65,12 +65,16 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel +import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn /** * The SEARCH tab of the codebase panel (P7) - global search & replace. @@ -124,20 +128,46 @@ class CodebaseSearchViewModel( private val _searched = MutableStateFlow(false) val searched: StateFlow = _searched.asStateFlow() - private val _busy = MutableStateFlow(false) - val busy: StateFlow = _busy.asStateFlow() + /** A content scan is in flight. Set by [execute] only. */ + private val _scanning = MutableStateFlow(false) + + /** + * A dry run or a real replacement is in flight. + * + * Deliberately NOT the same flag as [_scanning]. One flag was doing two + * jobs: the replace path used it as a compare-and-set mutex, while + * [execute] set and cleared it unconditionally - so a scan finishing + * mid-write cleared the mutex, re-enabled the Replace button while the + * write was still running, and let a second click start a duplicate write. + */ + private val _writing = MutableStateFlow(false) + + /** Either kind of work - what the UI disables its controls on. */ + val busy: StateFlow = + combine(_scanning, _writing) { scanning, writing -> scanning || writing } + .stateIn(scope, SharingStarted.Eagerly, false) private val _message = MutableStateFlow(null) val message: StateFlow = _message.asStateFlow() - private val _dryRun = MutableStateFlow(null) - val dryRun: StateFlow = _dryRun.asStateFlow() + private val _dryRun = MutableStateFlow(null) + val dryRun: StateFlow = _dryRun.asStateFlow() /** True when the result set was truncated at [MAX_RESULTS]. */ private val _capped = MutableStateFlow(false) val capped: StateFlow = _capped.asStateFlow() + /** + * The in-flight scan. + * + * Volatile: written from the UI thread (typing, Enter, the search icon) + * and from coroutines on [scope] (the refresh timer, the re-scan after a + * replacement), so a plain var gives the two no happens-before. + */ + @Volatile private var searchJob: Job? = null + + @Volatile private var refreshTimer: Job? = null fun setQuery(value: String) { @@ -174,21 +204,32 @@ class CodebaseSearchViewModel( } /** Debounced re-run, so typing does not queue one scan per keystroke. */ - private fun scheduleSearch() { - searchJob?.cancel() + private fun scheduleSearch() = startScan(DEBOUNCE_MS) + + /** Run now (the Enter key / the search icon) with no debounce. */ + fun runSearch() = startScan() + + /** + * Start a scan, cancelling and WAITING FOR whichever one is already in + * flight. + * + * cancelAndJoin, not a bare cancel: [execute] clears [_scanning] in a + * finally, and cancel() returns before that finally has run. A bare + * cancel therefore let the replacement scan start while the previous one + * was still unwinding, and the old finally then cleared the flag out from + * under it. Joining first also means [execute] never overlaps itself, so + * it needs no mutex of its own. + */ + private fun startScan(delayMs: Long = 0L) { + val previous = searchJob searchJob = scope.launch { - delay(DEBOUNCE_MS) + previous?.cancelAndJoin() + if (delayMs > 0) delay(delayMs) execute() } } - /** Run now (the Enter key / the search icon) with no debounce. */ - fun runSearch() { - searchJob?.cancel() - searchJob = scope.launch { execute() } - } - /** * Keep the results honest while the tab is open. * @@ -206,13 +247,14 @@ class CodebaseSearchViewModel( scope.launch { while (isActive) { delay(REFRESH_MS) - if (_query.value.isNotBlank() && !_busy.value && searchJob?.isActive != true) { - // Track the refresh in searchJob like every other scan: - // a clear/cancel that lands mid-scan must be able to - // reach it. A direct execute() here outran both, and - // the scan published the old query's results - plus - // _searched - a moment after the user cleared. - searchJob = scope.launch { execute() } + // Never while a replacement is in flight: the scan would + // race the write it is meant to observe. Routed through + // startScan like every other scan, so a clear or a newer + // query can reach it - a direct execute() here outran both + // and republished the old query's results, plus _searched, + // a moment after the user cleared. + if (_query.value.isNotBlank() && !_writing.value && searchJob?.isActive != true) { + startScan() } } } @@ -233,7 +275,7 @@ class CodebaseSearchViewModel( _capped.value = false return } - _busy.value = true + _scanning.value = true _message.value = null try { // Exclusion is the engine's, not ours. It used to be a filter over the @@ -271,7 +313,7 @@ class CodebaseSearchViewModel( // report it in the status line instead of clearing the results. _message.value = e.message ?: "Search failed" } finally { - _busy.value = false + _scanning.value = false } } @@ -312,47 +354,94 @@ class CodebaseSearchViewModel( return if (root.isEmpty()) path else root + separator + path } + /** The user-entered half of a replacement, as it stood at click time. */ + private fun currentOperation(): ReplaceOperation = + ReplaceOperation( + query = _query.value, + replacement = _replacement.value, + isRegex = _isRegex.value, + caseSensitive = _caseSensitive.value, + wholeWord = _wholeWord.value, + files = matchedFiles(), + ) + /** Dry-run first, so the UI can show what will change before writing. */ fun previewReplacement() { if (_query.value.isBlank() || _results.value.isEmpty()) return + if (_capped.value) { + // Replace All is refused while capped (the sheet disables the + // button), so measuring first buys nothing but a full-project + // scan. Say why now instead of after it. + _message.value = + "Too many matches to replace safely - narrow the search first." + return + } + // Snapshot the operation SYNCHRONOUSLY, before the launch: the search + // field stays live for the whole dry run, so reading it again later + // measures one operation and writes another. + val operation = currentOperation() // Set BEFORE launch: inside the coroutine the flag lands only once the // dispatcher runs the body, so two clicks in the same frame both saw - // busy = false and both passed the `enabled = … && !busy` guard. - if (!_busy.compareAndSet(expect = false, update = true)) return + // it false and both passed the `enabled = … && !busy` guard. + if (!_writing.compareAndSet(expect = false, update = true)) return scope.launch { try { - _dryRun.value = + val summary = provider?.replaceInProject( - query = _query.value, - replacement = _replacement.value, - files = matchedFiles(), - isRegex = _isRegex.value, - caseSensitive = _caseSensitive.value, - wholeWord = _wholeWord.value, + query = operation.query, + replacement = operation.replacement, + files = operation.files, + isRegex = operation.isRegex, + caseSensitive = operation.caseSensitive, + wholeWord = operation.wholeWord, dryRun = true, ) + // The user may have typed on while this ran. setQuery and + // setSearchOption null _dryRun precisely to retract the sheet; + // republishing here would put it back describing an operation + // they have already moved past. + if (summary != null && sameUserInput(operation)) { + _dryRun.value = PendingReplacement(operation, summary) + } } catch (e: Exception) { _message.value = e.message ?: "Preview failed" } finally { - _busy.value = false + _writing.value = false } } } + /** True while the query, replacement and flags still match [operation]. */ + private fun sameUserInput(operation: ReplaceOperation): Boolean = + operation.query == _query.value && + operation.replacement == _replacement.value && + operation.isRegex == _isRegex.value && + operation.caseSensitive == _caseSensitive.value && + operation.wholeWord == _wholeWord.value + + /** + * Write the replacement the sheet described. + * + * Every argument comes from the snapshot taken for the dry run, never from + * current state: the sheet says "Replace N occurrences across M files", and + * re-reading the live query here would let it write a DIFFERENT operation + * than the one the user was shown and confirmed. + */ fun applyReplacement() { - val summary = _dryRun.value ?: return + val pending = _dryRun.value ?: return // See previewReplacement: a duplicate click here is a duplicate WRITE. - if (!_busy.compareAndSet(expect = false, update = true)) return + if (!_writing.compareAndSet(expect = false, update = true)) return + val operation = pending.operation scope.launch { try { val applied = provider?.replaceInProject( - query = _query.value, - replacement = _replacement.value, - files = summary.files.map { it.path }, - isRegex = _isRegex.value, - caseSensitive = _caseSensitive.value, - wholeWord = _wholeWord.value, + query = operation.query, + replacement = operation.replacement, + files = operation.files, + isRegex = operation.isRegex, + caseSensitive = operation.caseSensitive, + wholeWord = operation.wholeWord, dryRun = false, ) _message.value = describeReplacement(applied) @@ -360,10 +449,10 @@ class CodebaseSearchViewModel( } catch (e: Exception) { _message.value = e.message ?: "Replacement failed" } finally { - _busy.value = false + _writing.value = false } // The files on disk changed; re-scan so the tree matches them. - execute() + startScan() } } @@ -434,6 +523,30 @@ class CodebaseSearchViewModel( enum class SearchToggle { REGEX, CASE, WORD } +/** + * The user-entered half of a replacement, frozen at the moment the user asked + * to preview it. + * + * Its own type so the dry run and the write cannot drift apart: the search + * field stays live while the dry run walks the project, so anything read from + * current state at write time may describe a different operation than the one + * the confirmation sheet measured and the user approved. + */ +data class ReplaceOperation( + val query: String, + val replacement: String, + val isRegex: Boolean, + val caseSensitive: Boolean, + val wholeWord: Boolean, + val files: List, +) + +/** A measured replacement awaiting confirmation, with the operation it measured. */ +data class PendingReplacement( + val operation: ReplaceOperation, + val summary: ReplaceSummary, +) + /** One file's matches; [path] is project-relative, as the provider returns it. */ data class FileMatches(val path: String, val matches: List) @@ -688,9 +801,9 @@ fun CodebaseSearchContent( } } - dryRun?.let { summary -> + dryRun?.let { pending -> ConfirmReplaceSheet( - summary = summary, + summary = pending.summary, busy = busy, capped = capped, onDismiss = { viewModel.dismissPreview() }, diff --git a/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/CodebaseGitMcpToolsTest.kt b/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/CodebaseGitMcpToolsTest.kt index 86aff8c..36b86be 100644 --- a/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/CodebaseGitMcpToolsTest.kt +++ b/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/CodebaseGitMcpToolsTest.kt @@ -29,10 +29,20 @@ class CodebaseGitMcpToolsTest { private val git = FakeGitProvider() private val search = FakeSearchProvider() - private val tools = CodebaseGitMcpToolProvider("codebase", { git }, { search }).tools() + private val tools = + CodebaseGitMcpToolProvider("codebase", { git }, { search }, flowSettleMs = SETTLE_MS).tools() private fun tool(name: String) = tools.first { it.name == name } + private companion object { + /** + * Short on purpose. The real value is 2 s; at that length every call + * whose refresh publishes nothing burned two seconds of wall clock, + * which was most of this class's runtime. + */ + const val SETTLE_MS = 60L + } + private fun invoke(name: String, args: Map): McpToolResult = runBlocking { tool(name).handler.call(McpToolArgs(args)) } @@ -267,22 +277,92 @@ class CodebaseGitMcpToolsTest { assertTrue(search.replaceCalls.isEmpty(), "nothing may reach the provider") } + // ---- awaitFresh -------------------------------------------------------- + + @Test + fun `git status reports the state the refresh published, not the one before it`() { + // The whole point of awaitFresh: the provider refreshes asynchronously + // (over IPC out-of-process), so reading fileStatus.value straight after + // refreshStatus() returns the PRE-refresh tree. An agent calling + // git_status right after git_stage would act on a stale premise. + git.status.value = listOf( + GitFileStatusData("old.kt", null, T.MODIFIED, isStaged = false, isUnstaged = true), + ) + git.pendingStatus = listOf( + GitFileStatusData("new.kt", T.ADDED, null, isStaged = true, isUnstaged = false), + ) + + val result = invoke("git_status", emptyMap()) + + assertEquals(listOf("A new.kt"), result.text.lines()) + } + + @Test + fun `a refresh that publishes nothing falls back to the latest value`() { + // A no-op refresh emits nothing at all (StateFlow conflates an + // identical value), so the wait must time out onto what is already + // there rather than hanging or reporting empty. + git.status.value = listOf( + GitFileStatusData("same.kt", null, T.MODIFIED, isStaged = false, isUnstaged = true), + ) + git.pendingStatus = null + + val result = invoke("git_status", emptyMap()) + + assertEquals(listOf(" M same.kt"), result.text.lines()) + } + + @Test + fun `git log reports the commits the refresh published`() { + git.pendingLog = listOf( + GitCommitInfoData( + hash = "abcdef1234", + shortHash = "abcdef1", + subject = "a subject", + author = "someone", + authorEmail = "s@example.com", + date = 0L, + refs = emptyList(), + ), + ) + + val result = invoke("git_log", emptyMap()) + + assertFalse(result.isError, result.text) + assertEquals(listOf("abcdef1 a subject (someone)"), result.text.lines()) + } + // ---- fakes --------------------------------------------------------------- private class FakeGitProvider : GitDataProvider { val status = MutableStateFlow(emptyList()) val diffs = mutableMapOf>() val logLimits = mutableListOf() + val log = MutableStateFlow(emptyList()) + + /** + * What [refreshStatus] will publish, or null to publish nothing. + * + * The real provider re-reads the work tree and PUBLISHES on its flow; + * a fake whose refresh does nothing never lets awaitFresh resolve, so + * every call paid the full settle timeout and the behaviour awaitFresh + * exists for was never exercised. + */ + var pendingStatus: List? = null + var pendingLog: List? = null override val fileStatus: StateFlow> = status - override val commitLog: StateFlow> = MutableStateFlow(emptyList()) + override val commitLog: StateFlow> = log override val isGitRepository: StateFlow = MutableStateFlow(true) override val isLoading: StateFlow = MutableStateFlow(false) - override suspend fun refreshStatus() {} + override suspend fun refreshStatus() { + pendingStatus?.let { status.value = it } + } override suspend fun refreshLog(limit: Int) { logLimits += limit + pendingLog?.let { log.value = it } } override suspend fun stage(filePath: String) = GitOperationResultData.Success() diff --git a/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitGraphBranchViewModelTest.kt b/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitGraphBranchViewModelTest.kt index 8065a9b..ffdc6da 100644 --- a/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitGraphBranchViewModelTest.kt +++ b/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitGraphBranchViewModelTest.kt @@ -111,6 +111,9 @@ class GitGraphBranchViewModelTest { git = provider, onAgentReview = { _ -> }, getProjectPath = { "/repo" }, + // The real 2 s settle would be paid on every construction; the + // fakes never move isGitRepository, so it always times out. + repoFlagSettleMs = 50L, ).also { viewModels += it } /** The view model owns its own scope, so the assertions wait rather than race. */ diff --git a/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitViewModelOperationsTest.kt b/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitViewModelOperationsTest.kt index b308285..d03e246 100644 --- a/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitViewModelOperationsTest.kt +++ b/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitViewModelOperationsTest.kt @@ -117,6 +117,9 @@ class GitViewModelOperationsTest { git = provider, onAgentReview = onReview, getProjectPath = { "/repo" }, + // The real 2 s settle would be paid on every construction; the + // fakes never move isGitRepository, so it always times out. + repoFlagSettleMs = 50L, ).also { viewModels += it } private fun awaitUntil(what: String, predicate: () -> Boolean) { diff --git a/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/SearchViewModelTest.kt b/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/SearchViewModelTest.kt new file mode 100644 index 0000000..50cf849 --- /dev/null +++ b/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/SearchViewModelTest.kt @@ -0,0 +1,279 @@ +package ai.rever.boss.plugin.dynamic.codebase + +import ai.rever.boss.plugin.api.FileMatch +import ai.rever.boss.plugin.api.ProjectSearchProvider +import ai.rever.boss.plugin.api.ReplaceSummary +import kotlinx.coroutines.CompletableDeferred +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Behaviour of the one tab that writes file contents. + * + * The dry run and the write are two calls separated by however long a + * whole-project scan takes, with the search field live throughout - so the + * thing worth pinning is that they always describe the SAME operation, and + * that a scan finishing in between cannot let a second write start. + */ +class SearchViewModelTest { + + private val viewModels = mutableListOf() + + @AfterTest + fun disposeAll() { + viewModels.forEach { it.dispose() } + viewModels.clear() + } + + // ---- fake ---- + + private class ReplaceCall( + val query: String, + val replacement: String, + val files: List, + val isRegex: Boolean, + val caseSensitive: Boolean, + val wholeWord: Boolean, + val dryRun: Boolean, + ) + + private class FakeSearch( + var matches: List = emptyList(), + ) : ProjectSearchProvider { + val searchQueries = mutableListOf() + val replaceCalls = mutableListOf() + + /** When set, the next replaceInProject blocks until it completes. */ + var gate: CompletableDeferred? = null + + override suspend fun searchInProject( + query: String, + pathPattern: String?, + excludePattern: String?, + isRegex: Boolean, + caseSensitive: Boolean, + wholeWord: Boolean, + maxResults: Int, + ): List { + searchQueries += query + return matches + } + + override suspend fun replaceInProject( + query: String, + replacement: String, + files: List, + isRegex: Boolean, + caseSensitive: Boolean, + wholeWord: Boolean, + dryRun: Boolean, + ): ReplaceSummary { + replaceCalls += + ReplaceCall(query, replacement, files, isRegex, caseSensitive, wholeWord, dryRun) + gate?.await() + return ReplaceSummary(filesReplaced = files.size, totalReplacements = 3, dryRun = dryRun) + } + } + + private fun match(path: String) = FileMatch(path, 1, 1, 3, "a line") + + private fun vm(search: FakeSearch): CodebaseSearchViewModel = + CodebaseSearchViewModel( + provider = search, + splitViewOperations = null, + getProjectPath = { "/repo" }, + ).also { viewModels += it } + + private fun awaitUntil(what: String, predicate: () -> Boolean) { + val deadline = System.currentTimeMillis() + 5_000 + while (System.currentTimeMillis() < deadline) { + if (predicate()) return + Thread.sleep(5) + } + throw AssertionError("timed out waiting for: $what") + } + + /** Search for [query] and wait for the results to land. */ + private fun search(vm: CodebaseSearchViewModel, query: String) { + vm.setQuery(query) + vm.runSearch() + awaitUntil("results for \"$query\"") { vm.searched.value && !vm.busy.value } + } + + // ---- the dry run and the write must describe one operation ---- + + @Test + fun `the write uses exactly the operation the dry run measured`() { + val provider = FakeSearch(listOf(match("a.kt"), match("b.kt"))) + val vm = vm(provider) + search(vm, "needle") + vm.setReplacement("thread") + vm.setSearchOption(SearchToggle.CASE, true) + awaitUntil("the re-scan after the toggle") { !vm.busy.value } + + vm.previewReplacement() + awaitUntil("the dry run") { vm.dryRun.value != null } + vm.applyReplacement() + awaitUntil("the write") { provider.replaceCalls.any { !it.dryRun } } + + val preview = provider.replaceCalls.first { it.dryRun } + val write = provider.replaceCalls.first { !it.dryRun } + assertEquals(preview.query, write.query) + assertEquals(preview.replacement, write.replacement) + assertEquals(preview.files, write.files) + assertEquals(preview.isRegex, write.isRegex) + assertEquals(preview.caseSensitive, write.caseSensitive) + assertEquals(preview.wholeWord, write.wholeWord) + assertEquals("needle", write.query) + assertEquals(listOf("a.kt", "b.kt"), write.files) + } + + @Test + fun `results changing under an open sheet do not widen the write`() { + // A re-scan does not retract the sheet - it has no reason to, the + // query is unchanged - so the results tree can grow while the + // confirmation is on screen. The write must still touch only the files + // the sheet counted, never the ones that arrived afterwards. + val provider = FakeSearch(listOf(match("a.kt"))) + val vm = vm(provider) + search(vm, "needle") + + vm.previewReplacement() + awaitUntil("the dry run") { vm.dryRun.value != null } + + // A new match appears and the auto-refresh picks it up. + provider.matches = listOf(match("a.kt"), match("late.kt")) + vm.runSearch() + awaitUntil("the wider result set") { vm.results.value.size == 2 } + assertNotNull(vm.dryRun.value, "the sheet is still up - this is the case under test") + + vm.applyReplacement() + awaitUntil("the write") { provider.replaceCalls.any { !it.dryRun } } + + assertEquals( + listOf("a.kt"), + provider.replaceCalls.first { !it.dryRun }.files, + "the write must match what the sheet measured, not the newer results", + ) + } + + @Test + fun `editing the query while a dry run is in flight retracts the sheet`() { + // The window the snapshot exists for: the search field stays live for + // the whole dry run. setQuery nulls _dryRun precisely to retract the + // sheet, and the in-flight preview must not put it back describing an + // operation the user has moved past. + val provider = FakeSearch(listOf(match("a.kt"))) + val vm = vm(provider) + search(vm, "needle") + + val gate = CompletableDeferred() + provider.gate = gate + vm.previewReplacement() + awaitUntil("the dry run to start") { provider.replaceCalls.any { it.dryRun } } + + vm.setQuery("something else") + provider.gate = null + gate.complete(Unit) + + awaitUntil("the preview to finish") { !vm.busy.value } + assertNull(vm.dryRun.value, "a stale preview must not republish the sheet") + assertTrue(provider.replaceCalls.none { !it.dryRun }, "and nothing may be written") + } + + // ---- a scan must not release the write mutex ---- + + @Test + fun `a scan finishing mid-write cannot let a second write start`() { + // These used to share one flag: execute() set and cleared it + // unconditionally while the replace path used it as a mutex, so a scan + // completing during a write cleared the mutex and a second click ran a + // duplicate write. + val provider = FakeSearch(listOf(match("a.kt"))) + val vm = vm(provider) + search(vm, "needle") + + vm.previewReplacement() + awaitUntil("the dry run") { vm.dryRun.value != null } + + val gate = CompletableDeferred() + provider.gate = gate + vm.applyReplacement() + awaitUntil("the write to start") { provider.replaceCalls.any { !it.dryRun } } + + // A scan runs and completes while the write is still in flight. + vm.runSearch() + awaitUntil("the scan to finish") { provider.searchQueries.size >= 2 } + + vm.applyReplacement() + Thread.sleep(50) + assertEquals( + 1, + provider.replaceCalls.count { !it.dryRun }, + "the write mutex must survive a scan completing", + ) + + provider.gate = null + gate.complete(Unit) + awaitUntil("the write to finish") { vm.dryRun.value == null && !vm.busy.value } + } + + @Test + fun `busy stays true for the whole write, not just until a scan ends`() { + val provider = FakeSearch(listOf(match("a.kt"))) + val vm = vm(provider) + search(vm, "needle") + vm.previewReplacement() + awaitUntil("the dry run") { vm.dryRun.value != null } + + val gate = CompletableDeferred() + provider.gate = gate + vm.applyReplacement() + awaitUntil("busy") { vm.busy.value } + vm.runSearch() + awaitUntil("the scan to finish") { provider.searchQueries.size >= 2 } + + assertTrue(vm.busy.value, "busy must still report the in-flight write") + provider.gate = null + gate.complete(Unit) + awaitUntil("idle") { !vm.busy.value } + } + + // ---- scan semantics ---- + + @Test + fun `a capped result set refuses the replace before running a dry run`() { + // The sheet disables Replace while capped, so measuring first buys a + // whole-project scan and nothing else. + val provider = FakeSearch((1..CodebaseSearchViewModel.MAX_RESULTS).map { match("f$it.kt") }) + val vm = vm(provider) + search(vm, "needle") + assertTrue(vm.capped.value, "this test needs a capped result set") + + vm.previewReplacement() + Thread.sleep(50) + + assertTrue(provider.replaceCalls.isEmpty(), "no dry run may run while capped") + assertNotNull(vm.message.value) + assertTrue("narrow" in vm.message.value!!, "reported: ${vm.message.value}") + } + + @Test + fun `clearing mid-scan is not undone when the scan lands`() { + val provider = FakeSearch(listOf(match("a.kt"))) + val vm = vm(provider) + search(vm, "needle") + assertTrue(vm.searched.value) + + vm.clear() + + assertEquals("", vm.query.value) + Thread.sleep(80) + assertTrue(vm.results.value.isEmpty(), "a scan landing after clear must not republish") + assertTrue(!vm.searched.value, "nor resurrect the searched state") + } +} From 870e1f965e5b93d74e3abe6b6839512f4fce081e Mon Sep 17 00:00:00 2001 From: Shivanshu Date: Mon, 31 Aug 2026 23:13:15 -0700 Subject: [PATCH 8/8] review: note that replace is not limited by the search caps The round-9 host review (finding 10): a file whose matches were capped in the search view can have more replaced than were displayed, and the count the MCP caller reads must not be mistaken for the displayed one. The project_replace description now says so. The SearchTab confirmation sheet already shows the DRY-RUN count, which is the applied count, so it stays as is. --- .../boss/plugin/dynamic/codebase/CodebaseGitMcpTools.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/CodebaseGitMcpTools.kt b/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/CodebaseGitMcpTools.kt index 4d233ab..84444dd 100644 --- a/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/CodebaseGitMcpTools.kt +++ b/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/CodebaseGitMcpTools.kt @@ -253,7 +253,10 @@ internal class CodebaseGitMcpToolProvider( description = "Replace occurrences in an EXPLICIT list of files - never project-wide. " + "dryRun defaults to true (counts what would change without writing). Open buffers " + "are edited through the editor's undoable path; closed files are written to disk. " + - "For regex queries the replacement supports $1..$9 capture references.", + "For regex queries the replacement supports $1..$9 capture references. " + + "Replacement is NOT limited by the search caps: a file whose matches were " + + "truncated in the search results can have more replaced than were displayed, " + + "so the reported count is the applied count, not the displayed one.", inputSchema = REPLACE_SCHEMA, readOnly = false, requiredPermissions = listOf(PROJECT_REPLACE),