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/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 942edb0..3d91d59 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 { @@ -109,18 +109,34 @@ 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) 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. + 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"""" } } } } @@ -139,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 new file mode 100644 index 0000000..5ff4733 --- /dev/null +++ b/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/AgentReviewPrompt.kt @@ -0,0 +1,120 @@ +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 { + + /** 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, + 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. */ + deep: Boolean = false, + /** + * 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 { + val sb = StringBuilder() + if (baseRef.isNotBlank()) { + sb.appendLine( + "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).") + } + 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 $projectPath.") + 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 { + // 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 (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") + sb.appendLine(diffText) + sb.appendLine("```") + } + return sb.toString().trimEnd() + } +} 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..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 @@ -3,18 +3,88 @@ 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.LaunchedEffect +import androidx.compose.runtime.RememberObserver +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.flow.collectLatest +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) { + 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 +93,352 @@ 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 + // 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 + // 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 + // below starts, so the load is not immediately overwritten. + gitViewModel.setChangeLayout( + withContext(Dispatchers.IO) { + GitChangeLayout.fromStorage(storage?.getString("codebase.gitLayout")) + }, + ) + gitViewModel.changeLayout.collect { layout -> + withContext(Dispatchers.IO) { + storage?.putString("codebase.gitLayout", layout.storageKey) + } + } + } + LaunchedEffect(Unit) { + gitViewModel.setSplitFraction( + withContext(Dispatchers.IO) { + storage?.getString("codebase.gitSplit")?.toFloatOrNull() ?: DEFAULT_SPLIT + }, + ) + // 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()) + } + } + } + // 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 + // 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() + } + } + } + + Column(modifier = Modifier.fillMaxSize().background(CodebasePalette.Panel)) { + CodebaseProjectHeader(projectPath) + CodebaseTabStrip(selected = selectedTab) { tab -> + selectedTab = tab + scope.launch(Dispatchers.IO) { + 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 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) path 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, + ) + } + } + } +} + +/** + * 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. + * + * 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 -> "~" + PathUtils.isNestedUnder(path, home, separator) -> "~" + path.removePrefix(home) + else -> path + } +} + +/** + * 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 + +/** 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 + * 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..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 @@ -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,75 +201,67 @@ 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)) - Text( - text = projectName, - fontSize = 14.sp, - fontWeight = FontWeight.Medium, - color = BossThemeColors.TextPrimary, - 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) } + ) } } } - 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..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 @@ -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 = 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" @@ -29,29 +37,94 @@ 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. + // + // 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 + // 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_PANEL_ID, ATLAS_PANEL_ORDINAL), 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 +146,118 @@ 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", + // Suppliers, not values: see the constructor's KDoc. The + // gateway above is resolved per call for the same reason. + git = { gitDataProvider }, + search = { searchProvider }, + ) + ) + } + + /** + * 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" + + /** + * 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" + + /** + * 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 { + // 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 + ?.getResources("META-INF/boss-plugin/plugin.json") + ?: return 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) + } +} + +/** + * 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..84444dd --- /dev/null +++ b/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/CodebaseGitMcpTools.kt @@ -0,0 +1,607 @@ +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 +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 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. + * + * 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. + */ +internal class CodebaseGitMcpToolProvider( + override val providerId: String, + /** + * 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?, + /** + * 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() + + private val searchProvider: ProjectSearchProvider? get() = search() + + 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.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.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.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.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.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.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") + if (!GitBranchModel.isSafeRef(ref)) return@McpToolHandler unsafeRef(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( + capDiff( + 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") + if (!GitBranchModel.isSafeRef(ref)) return@McpToolHandler unsafeRef(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") + 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()) + }, + ), + // ---- 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 provider = gitProvider ?: return@McpToolHandler unavailableGit() + // 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})" }) + }, + ), + 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.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)) + }, + ), + // ---- 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. " + + "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), + 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"), + excludePattern = args.string("excludePattern"), + isRegex = args.boolean("isRegex") ?: false, + caseSensitive = args.boolean("caseSensitive") ?: false, + wholeWord = args.boolean("wholeWord") ?: false, + // 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 = + 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")?.let(::parseFiles) + ?: 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) + } + + /** + * `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("]")) { + // 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 + .split(",", "\n") + .map { it.trim() } + .filter { it.isNotEmpty() } + } + + /** + * 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 i = 0 + while (i < body.length) { + val c = body[i] + when { + inString && c == '\\' -> { + 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() + current.clear() + } + !inString && c.isWhitespace() -> Unit + inString -> current.append(c) + else -> return null + } + i++ + } + if (inString) return null + 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 + } + + /** + * 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(flowSettleMs) { flow.first { it != before } } + ?: flow.value + } + + private suspend fun status(): McpToolResult { + 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 -> "${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: GitFileStatusData): String { + val untrackedOrIgnored = + listOf(s.indexStatus, s.workTreeStatus).firstOrNull { + it == GitFileStatusTypeData.UNTRACKED || + it == GitFileStatusTypeData.IGNORED + } + return when (untrackedOrIgnored) { + GitFileStatusTypeData.UNTRACKED -> "??" + GitFileStatusTypeData.IGNORED -> "!!" + else -> "${statusChar(s.indexStatus)}${statusChar(s.workTreeStatus)}" + } + } + + private fun statusChar(type: GitFileStatusTypeData?): String = + when (type) { + null -> " " + 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 = + 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: GitOperationResultData?, + ): McpToolResult { + return when (result) { + null -> unavailableGit() + is GitOperationResultData.Success -> + McpToolResult("$label${result.message?.let { ": $it" } ?: ""}") + is 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: GitDiffData?): McpToolResult { + if (diff == null) return McpToolResult("No diff.") + return McpToolResult(capDiff(renderOne(diff))) + } + + private fun renderOne(diff: 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) + + /** + * 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") + + 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" + + /** 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. + */ + 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 + + 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, 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 = + """{"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)."},"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/CodebaseUi.kt b/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/CodebaseUi.kt new file mode 100644 index 0000000..0d03dbb --- /dev/null +++ b/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/CodebaseUi.kt @@ -0,0 +1,681 @@ +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.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.State +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. [onChanged] carries each new fraction to the owner, which + * owns persistence - the splitter itself is stateless. + */ +@Composable +fun CodebaseSplitter( + fraction: State, + onChanged: (Float) -> Unit, + 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 + onChanged(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 { + // 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/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..1239393 --- /dev/null +++ b/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitBranchModel.kt @@ -0,0 +1,246 @@ +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 "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 + .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..8126946 --- /dev/null +++ b/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitChangeTree.kt @@ -0,0 +1,157 @@ +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) + return out + } + + private fun emit( + node: Node, + depth: Int, + collapsed: Set, + out: MutableList, + ) { + 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(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) + } + } + 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..65d95e8 --- /dev/null +++ b/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitGraphEdges.kt @@ -0,0 +1,110 @@ +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 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 } + + 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..00ac2c5 --- /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. 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 }) + 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() + 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() + } +} 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..cf20486 --- /dev/null +++ b/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitTab.kt @@ -0,0 +1,1047 @@ +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 +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.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.sync.Mutex +import kotlinx.coroutines.sync.withLock + +/** + * 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 }, + /** + * 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. + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + 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() + + /** 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. + * + * 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() + + /** + * 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() + + /** + * 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() + } + + /** + * 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("") + 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…". + * + * 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() + + 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() + + /** + * 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 + // 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 + } + + /** 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. */ + fun refreshStatus() { + scope.launch { + try { + 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 (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 + // 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 + } + } + } + + /** + * 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(repoFlagSettleMs) { + 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 + * 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) + // 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 (_: Exception) { + // Deliberately silent here: surfacing on every 5 s tick + // would overwrite real operation results. + } + } + } + } + + /** + * 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 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 = "" + // 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) + } + + /** + * 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 { + 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 + } + } + } + } + + /** + * 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 (_: 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, + ) + // 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) + } + } + + /** + * 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() } + + // ---- 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) } + + 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?.discardChanges(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, [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: List = emptyList()) { + 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 + } + if (!_busy.compareAndSet(expect = false, update = true)) return + _message.value = null + scope.launch { + try { + for (path in stageFirst) { + val staged = git.stage(path) + if (staged is GitOperationResultData.Error) { + // 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 + } + } + report(git.commit(message)) { + _commitMessage.value = "" + 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 + } + } + } + + 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() ?: 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 + } + if (!_generating.compareAndSet(expect = false, update = true)) return + _message.value = null + scope.launch { + 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).text + 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 + } + } + } + + /** + * 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: GitDiffData, + context: Int = 3, + ): String { + val lines = diff.hunks.flatMap { it.lines } + if (lines.none { it.kind != DiffLineKind.CONTEXT }) return "" + val keep = BooleanArray(lines.size) + lines.forEachIndexed { i, l -> + if (l.kind != 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) { + DiffLineKind.ADDED -> "+" + DiffLineKind.REMOVED -> "-" + else -> " " + } + sb.append(prefix).append(l.text).append('\n') + printedAny = true + } + 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, 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): 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 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. + val diff = + runCatching { provider.diffFile(f.path, staged = f.isStaged && !f.isUnstaged) } + .getOrNull()?.firstOrNull() + ?: runCatching { provider.diffFile(f.path, staged = false) } + .getOrNull()?.firstOrNull() + 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 + // 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 } + // Reserve the per-file header and the truncation marker, so the + // framing never pushes the result past [budget]. + val frame = "--- ${f.path}\n".length + AgentReviewPrompt.TRUNCATION_MARKER.length + 2 + 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 CollectedDiff(sb.toString(), truncated) + } + + /** + * 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() { + if (!_reviewing.compareAndSet(expect = false, update = true)) return + scope.launch { + try { + val status = _fileStatus.value + val staged = status.filter { it.isStaged } + val unstaged = status.filter { it.isUnstaged } + + // 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 = collected.text.takeIf { it.isNotBlank() }, + diffTruncated = collected.truncated, + instructions = _reviewInstructions.value, + deep = _reviewDeep.value, + 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 { + _reviewing.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 + } + // 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 { + try { + 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 + } + } + } + + 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 + + /** + * 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 + * 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 (_: 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. [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, remoteNames: Set): List = + nodes + .asSequence() + .flatMap { it.refs.asSequence() } + .map { it.removePrefix("HEAD ->").trim() } + .filterNot { it.isBlank() || it == "HEAD" || it.startsWith("tag:") } + .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() + + /** "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 + + /** + * 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 ->") } + ?.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..7c6fb54 --- /dev/null +++ b/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitTabUi.kt @@ -0,0 +1,1738 @@ +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.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 +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 +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.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 +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.input.pointer.pointerInput +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 reviewing by viewModel.reviewing.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() + 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) } + // 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. + 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 } } + 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. 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 = 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 + // Discard / Revert / Cherry-pick / Checkout silently did nothing. + Box(modifier = modifier.fillMaxSize()) { + Column(modifier = Modifier.fillMaxSize()) { + GitToolbar( + branch = branch, + isRepo = isRepo, + busy = busy, + onRefresh = { + viewModel.refreshStatus() + viewModel.loadGraph(reset = true) + }, + 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 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, + // 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() }, + ) + + 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(splitFractionState, viewModel::setSplitFraction, totalPx) + GitGraphList( + graph = graph, + graphBusy = graphBusy, + remoteNames = remoteNames, + hasMore = hasMoreGraph, + modifier = Modifier.weight((1f - splitFraction).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, + onRefresh: () -> 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.AutoMirrored.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, + reviewing: Boolean, + onFindIssues: () -> Unit, +) { + 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 (reviewing) 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 (reviewing) 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 (reviewing) onAccent.copy(alpha = 0.45f) else onAccent, + ) + Spacer(Modifier.width(6.dp)) + Text( + text = if (reviewing) "Reviewing…" else "Fluck Agent Review", + fontSize = CodebaseMetrics.SecondaryText, + fontWeight = FontWeight.Medium, + 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 + // 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( + // 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(), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = + if (base.isBlank()) "Review uncommitted changes." + else "Review uncommitted changes, targeting $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) + // 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}" + 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(filesByDir[row.path].orEmpty(), 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()) { + // 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) { + 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)) + // The row shows the relative age; the tooltip carries the absolute date + // formatCommitDate was written for. + 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}" + 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 + // 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, + // The full hash, like revert and cherry-pick above: + // an abbreviation can collide in a large repository. + action = { viewModel.checkout(node.hash) }, + ), + ) + }, + ) + } 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, +) { + // 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 { + // 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) { + 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, + ) { + 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 (_: 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, +) { + // 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( + 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..9aea0ee --- /dev/null +++ b/src/main/kotlin/ai/rever/boss/plugin/dynamic/codebase/SearchTab.kt @@ -0,0 +1,1164 @@ +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.lazy.itemsIndexed +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.LaunchedEffect +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.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.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 +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.flow.combine +import kotlinx.coroutines.flow.stateIn + +/** + * 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 }, +) { + // 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 + + 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() + + /** 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() + + /** 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) { + _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() = 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 { + previous?.cancelAndJoin() + if (delayMs > 0) delay(delayMs) + 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) + // 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() + } + } + } + } + + 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 + } + _scanning.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() + // 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 + // 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: 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. + _message.value = e.message ?: "Search failed" + } finally { + _scanning.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, separator: Char = PathUtils.platformSeparator): String { + if (path.startsWith("/") || path.matches(WINDOWS_ABSOLUTE)) return 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 + } + + /** 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 + // it false and both passed the `enabled = … && !busy` guard. + if (!_writing.compareAndSet(expect = false, update = true)) return + scope.launch { + try { + val summary = + provider?.replaceInProject( + 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 { + _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 pending = _dryRun.value ?: return + // See previewReplacement: a duplicate click here is a duplicate WRITE. + if (!_writing.compareAndSet(expect = false, update = true)) return + val operation = pending.operation + scope.launch { + try { + val applied = + provider?.replaceInProject( + query = operation.query, + replacement = operation.replacement, + files = operation.files, + isRegex = operation.isRegex, + caseSensitive = operation.caseSensitive, + wholeWord = operation.wholeWord, + dryRun = false, + ) + _message.value = describeReplacement(applied) + _dryRun.value = null + } catch (e: Exception) { + _message.value = e.message ?: "Replacement failed" + } finally { + _writing.value = false + } + // The files on disk changed; re-scan so the tree matches them. + startScan() + } + } + + 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() + } + + companion object { + const val MAX_RESULTS = 500 + const val DEBOUNCE_MS = 250L + + /** + * 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]:[\\/].*""") + } +} + +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) + +/** 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 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) { + 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 = { + collapsedFiles = if (allCollapsed) emptySet() else grouped.mapTo(mutableSetOf()) { it.path } + }, + onClear = { + viewModel.clear() + collapsedFiles = emptySet() + }, + ) + 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, + 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, + ) + } + } + } + } + + dryRun?.let { pending -> + ConfirmReplaceSheet( + summary = pending.summary, + busy = busy, + capped = capped, + 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 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, + expanded: Boolean, + onToggle: () -> Unit, + onOpenFile: () -> Unit, + onOpenMatch: (FileMatch) -> Unit, +) { + item(key = "file-${entry.path}") { + SearchFileRow( + entry = entry, + expanded = expanded, + onToggle = onToggle, + onOpenFile = onOpenFile, + ) + } + 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 SearchFileRow( + entry: FileMatches, + expanded: Boolean, + onToggle: () -> Unit, + onOpenFile: () -> Unit, +) { + val (name, dir) = remember(entry.path) { splitPathForDisplay(entry.path) } + 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 = 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)) + } +} + +@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), + ) + } +} + +/** + * 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, + /** 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, +) { + // 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 - 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) { + 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, + ) { + 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, + ) + 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, + 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 && !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 7185329..dfce74c 100644 --- a/src/main/resources/META-INF/boss-plugin/plugin.json +++ b/src/main/resources/META-INF/boss-plugin/plugin.json @@ -2,13 +2,13 @@ "manifestVersion": 1, "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", + "version": "0.0.0-dev", + "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,15 @@ }, "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)" + }, + { + "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 new file mode 100644 index 0000000..deb1783 --- /dev/null +++ b/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/AgentReviewPromptTest.kt @@ -0,0 +1,78 @@ +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 `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, diffTruncated = true) + assertTrue(diff in prompt, "the truncated diff must still be inlined") + 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)) + val prompt = AgentReviewPrompt.build("/p", emptyList(), unstaged, null) + assertTrue("git_diff" in prompt) + } +} 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/CodebaseGitMcpToolsTest.kt b/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/CodebaseGitMcpToolsTest.kt new file mode 100644 index 0000000..36b86be --- /dev/null +++ b/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/CodebaseGitMcpToolsTest.kt @@ -0,0 +1,438 @@ +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 }, 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)) } + + // ---- 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 `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( + "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) + // 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") + } + + // ---- 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> = log + override val isGitRepository: StateFlow = MutableStateFlow(true) + override val isLoading: StateFlow = MutableStateFlow(false) + + 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() + + 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() + val searchCalls = mutableListOf() + + override suspend fun searchInProject( + query: String, + pathPattern: String?, + excludePattern: String?, + isRegex: Boolean, + caseSensitive: Boolean, + wholeWord: Boolean, + maxResults: Int, + ): List { + searchCalls += maxResults + return 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/GitBranchModelTest.kt b/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitBranchModelTest.kt new file mode 100644 index 0000000..fc27b26 --- /dev/null +++ b/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitBranchModelTest.kt @@ -0,0 +1,292 @@ +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))) + } + + // ---- 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/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..ffdc6da --- /dev/null +++ b/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitGraphBranchViewModelTest.kt @@ -0,0 +1,297 @@ +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" }, + // 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. */ + 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 && vm.graph.value.map { it.hash } == listOf("h1") + } + 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..3d4072c --- /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) + } +} 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..4032e9c --- /dev/null +++ b/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitTabCompactDiffTest.kt @@ -0,0 +1,68 @@ +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.AfterTest +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 }, + ) + + @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) + + 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/GitViewModelOperationsTest.kt b/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitViewModelOperationsTest.kt new file mode 100644 index 0000000..d03e246 --- /dev/null +++ b/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/GitViewModelOperationsTest.kt @@ -0,0 +1,359 @@ +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() + val commits = mutableListOf() + var stageAllCalls = 0 + + 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 { + stageAllCalls++ + return GitOperationResultData.Success() + } + + override suspend fun commit(message: String): GitOperationResultData { + commits += message + return 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" }, + // 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) { + 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 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) } + 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 } + // 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.value, "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 } + awaitUntil("hasMoreGraph to settle") { vm.hasMoreGraph.value } + + 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 + // 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/PanelUiLogicTest.kt b/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/PanelUiLogicTest.kt new file mode 100644 index 0000000..efe9a87 --- /dev/null +++ b/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/PanelUiLogicTest.kt @@ -0,0 +1,321 @@ +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`() { + // 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", "upstream/feat/x", "someone/something")), + ), + setOf("origin", "upstream"), + ) + assertEquals(listOf("feat/x", "main", "someone/something"), 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("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)) + } + + @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/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 new file mode 100644 index 0000000..a3b1bc3 --- /dev/null +++ b/src/test/kotlin/ai/rever/boss/plugin/dynamic/codebase/SearchPathTest.kt @@ -0,0 +1,138 @@ +package ai.rever.boss.plugin.dynamic.codebase + +import kotlin.test.AfterTest +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 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 -------------------------------------------- + + @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")) + } + + @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), + ) + } +} 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") + } +}