From ed877f5b31e73b903d5615f408e288b3177cc224 Mon Sep 17 00:00:00 2001 From: kirillk Date: Mon, 27 Jul 2026 17:04:44 -0400 Subject: [PATCH 01/28] feat(jetbrains): show modified files per turn --- .changeset/jetbrains-modified-files-view.md | 5 + ...179166798-jetbrains-modified-files-view.md | 125 ++++++++++++++ ...60000-jetbrains-per-turn-modified-files.md | 124 ++++++++++++++ .../kilocode/backend/cli/KiloCliDataParser.kt | 29 ++-- .../backend/cli/ChatDtoSerializationTest.kt | 30 ++++ .../backend/cli/KiloCliDataParserTest.kt | 19 ++- .../client/session/ui/ModifiedFilesView.kt | 158 ++++++++++++++++++ .../session/ui/SessionMessageListPanel.kt | 12 +- .../client/session/ui/popup/HeaderPopup.kt | 21 ++- .../client/session/views/ReasoningView.kt | 12 +- .../kilocode/client/session/views/TurnView.kt | 24 ++- .../client/session/views/tool/EditToolView.kt | 2 +- .../client/session/views/tool/PatchBody.kt | 24 ++- .../kotlin/ai/kilocode/client/ui/DiffBars.kt | 75 +++++++++ .../resources/messages/KiloBundle.properties | 3 + .../session/controller/HistoryLoadingTest.kt | 7 +- .../session/ui/ModifiedFilesViewTest.kt | 133 +++++++++++++++ .../session/ui/SessionMessageListPanelTest.kt | 37 ++++ .../session/ui/popup/HeaderPopupBodyTest.kt | 49 ++++++ .../client/session/views/TurnViewTest.kt | 42 +++++ .../kotlin/ai/kilocode/rpc/dto/ChatDto.kt | 6 + 21 files changed, 901 insertions(+), 36 deletions(-) create mode 100644 .changeset/jetbrains-modified-files-view.md create mode 100644 .kilo/plans/1785179166798-jetbrains-modified-files-view.md create mode 100644 .kilo/plans/1785185060000-jetbrains-per-turn-modified-files.md create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ModifiedFilesView.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/DiffBars.kt create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ModifiedFilesViewTest.kt create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/popup/HeaderPopupBodyTest.kt diff --git a/.changeset/jetbrains-modified-files-view.md b/.changeset/jetbrains-modified-files-view.md new file mode 100644 index 0000000000..20f7342409 --- /dev/null +++ b/.changeset/jetbrains-modified-files-view.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": minor +--- + +Show a session-level modified files card with expandable per-file diffs in JetBrains. diff --git a/.kilo/plans/1785179166798-jetbrains-modified-files-view.md b/.kilo/plans/1785179166798-jetbrains-modified-files-view.md new file mode 100644 index 0000000000..754c68511d --- /dev/null +++ b/.kilo/plans/1785179166798-jetbrains-modified-files-view.md @@ -0,0 +1,125 @@ +# JetBrains: Session "Modified files" view + +Add a VS Code–style **"Modified N files"** card to the JetBrains chat transcript. It behaves like +the existing `apply_patch` / write tool card (expand shows in-place per-file diffs; collapsed shows a +hover popup) but is fed by the **whole-session cumulative diff** and styled like VS Code. + +## Decisions (resolved) + +- **Scope**: Whole-session cumulative diff. Source = existing `SessionModel.diff: List` + (fed by the `session.diff` SSE event, already parsed **with per-file `patch`** in + `KiloCliDataParser.kt:256-268`). **No backend / shared DTO / CLI changes.** +- **Behavior**: Reuse `SecondarySessionPartView` (expand/collapse + hover-popup) and `PatchBody` + (per-file sections: filename link + `DiffStatBadge` + unified diff), exactly like `EditToolView`. +- **Look**: Header reads like VS Code — a "Modified" label, an "N file(s)" count, and a compact + 5-block add/delete **bars** meter (mirrors `packages/ui/src/components/diff-changes.tsx` `variant="bars"`). +- **Placement**: One session-level card in the transcript footer, wired like `RevertBanner`. +- **Visibility**: Shown when `model.diff` is non-empty **and** no revert is pending. When a revert is + pending, `RevertBanner` (which already lists the same files) takes over, so hide this card to avoid + duplication. +- **No "open full changes" action** — patch-style expand/popup only (matches the requested behavior). + +## Key reuse points (do NOT duplicate) + +- `SecondarySessionPartView` (`session/views/base/`) — arrow, expand/collapse, header hover bg, popup hook. +- `PatchBody` (`session/views/tool/PatchBody.kt`) — per-file diff sections. Currently `Tool`-coupled; + decouple it to render from `List` (see Task 2). +- `EditFileChange` + `diffStat`/patch helpers (`session/views/tool/ToolSupport.kt`) — `internal`, reuse from frontend. +- `DiffStatBadge` (`ui/DiffStatBadge.kt`) — per-file +/- pill (already used inside `PatchBody`). +- `HeaderPopupRequest` / `HeaderPopupBody` + `POPUP_OPTS` (`EditToolView.kt`) — collapsed popup body. +- Footer wiring: `SessionMessageListPanel` `banner`/`anchorFooter`/`onHover` path and `SessionUi:356-373`. + +## Data flow + +`session.diff` SSE → `KiloCliDataParser` → `ChatEventDto.SessionDiffChanged` → +`SessionController.handle` → `model.setDiff` → `SessionModelEvent.DiffUpdated` → +`SessionMessageListPanel` (already listens at line 160) → `ModifiedFilesView.update()`. + +## Tasks + +1. **`DiffBars` widget** — new `frontend/.../ui/DiffBars.kt`. + - Small `JPanel` painting 5 rounded blocks; color each block add vs delete vs neutral by ratio of + `additions`/`deletions` (port the block-count logic from `diff-changes.tsx`: `TOTAL_BLOCKS = 5`). + - Colors: `UiStyle.Colors.addedForeground()`, `removedForeground()`, `weak()` (neutral). Sizes via + `JBUI.scale`. `fun update(additions, deletions)`. Antialiased `paintComponent` like `DiffStatBadge`. + +2. **Decouple `PatchBody` from `Tool`** — `session/views/tool/PatchBody.kt`. + - Extract the render core to operate on `List`: add `mountFiles(files)`, + `updateFiles(files): Boolean`, and make `rebuild`/`signatureOf` take the list. + - Keep `EditBody` conformance for `EditToolView`: `mount(tool) = mountFiles(editFiles(tool))`, + `update(tool) = updateFiles(editFiles(tool))`. No behavior change for `EditToolView`. + - Verify `EditToolViewTest` still passes unchanged. + +3. **`DiffFileDto` → `EditFileChange` mapping** — small `internal` helper (in `ToolSupport.kt` or the + new view file): `path = file`, `additions`, `deletions`, `patch = patch ?: ""`, `type = ""`. + Filter out entries with blank patch (matches `PatchBody.rebuild` filter). + +4. **`ModifiedFilesView`** — new `frontend/.../session/ui/ModifiedFilesView.kt`, sits beside + `RevertBanner`, extends `SecondarySessionPartView`. + - Ctor: `(model: SessionModel, openFile: SessionFileOpener, selection: SessionSelection?)`. + - Header (custom `JPanel`, VS Code look): "Modified" label + count label ("{0} file(s)") + `DiffBars`. + `SecondarySessionPartView` adds the expand arrow to the header row automatically. + - Body (lazy, in `content = { ... }`): a `PatchBody` mounted via `mountFiles(files())` where + `files()` maps `model.diff`. + - `update()` (call on `DiffUpdated`/`HistoryLoaded`/`Cleared`/state change): compute `files()`; + set `isVisible = files.isNotEmpty() && model.revert() == null`; update count label + `DiffBars` + (sum additions/deletions); if expanded, `body.updateFiles(files)`; else leave lazy body untouched. + Compare-before-assign; `revalidate()/repaint()` only when something changed (retained-Swing rule). + - `override expand()`: call `super.expand()`, then `body.updateFiles(files())` + `body.applyStyle`. + - `override headerPopup()`: return `null` when expanded or `files()` empty; else a + `HeaderPopupRequest(row) { ... }` building a **second** `PatchBody(POPUP_OPTS)` mounted from + `files()` in `HeaderPopupBody(..., WIDE_MAX_WIDTH)`. Send `Telemetry.send("Header Popup Shown", + mapOf("surface" to "session", "tool" to "changes"))` in `shown`. + - Implement `SessionEditorStyleTarget.applyStyle` → forward to `PatchBody` + header fonts. + - `contentId` = a stable constant (e.g. `"session-modified-files"`). + +5. **Wire into the transcript** — `session/ui/SessionMessageListPanel.kt` + `session/SessionUi.kt`. + - Add ctor param `modified: ModifiedFilesView? = null` (mirror `banner`). + - In `init`, set `modified?.hover = ::hover` so collapsed-popup uses the existing `onHover`→ + `HeaderPopupController` path (like tool part views). + - Call `modified?.update()` from the `DiffUpdated`, `RevertChanged`, `StateChanged` branches and in + `rebuild()`/`clear()` (alongside the existing `banner?.update()` calls at lines 147/161/297/325). + - Add `modified` to `anchorFooter()` (place before `banner`) and to `applyStyle()`. + - In `SessionUi.kt:356` construct + `modified = ModifiedFilesView(controller.model, fileLinks::open, selection)` and pass it in. + +6. **i18n** — add to `frontend/src/main/resources/messages/KiloBundle.properties` (base only; other + locales fall back): + - `session.changes.modified=Modified` + - `session.changes.count.one={0} file` + - `session.changes.count.other={0} files` + +7. **Tests** — `frontend/src/test/.../session/` (extend `SessionControllerTestBase` / `BasePlatformTestCase`). + - `ModifiedFilesViewTest`: hidden when `model.diff` empty; visible + correct count/bars after + `setDiff`; hidden while a revert is pending; collapsed start (body not created); first `expand()` + creates `PatchBody` sections once (filename link + `DiffStatBadge` + diff per file); collapse + detaches, re-expand reuses same instance; `headerPopup()` returns a request only when collapsed & + non-empty; `update()` on new diff mutates existing labels without rebuilding when collapsed. + - Editor **leak/stress** test (code-editor-bearing view, per plugin rules): drive many `setDiff` + churn + expand/collapse cycles; assert `EditorFactory.getInstance().allEditors.size` returns to a + baseline captured before the loop, and retained component identity holds (`assertSame`). + - Extend `SessionMessageListPanelTest` to assert the footer contains `ModifiedFilesView` and that a + `DiffUpdated` event drives its visibility/count. + - Confirm `EditToolViewTest` and `PatchBody` behavior unchanged after the Task 2 refactor. + +## Risks / notes + +- **`patch` availability**: `session.diff` parsing already includes `patch` (`KiloCliDataParser.kt:265`), + so in-place diffs render without extra fetches. If a future CLI omits patches, `PatchBody` filters + blank-patch files — the header still shows count/bars but the body may be empty; acceptable. +- **Duplication with `RevertBanner`**: mitigated by the "hide while revert pending" rule (Task 4). +- **New visual element**: `DiffBars` is genuinely new (no JetBrains equivalent), so it is not + duplication; keep it minimal and theme-derived. Do not touch `DiffStatBadge` (reused as-is inside `PatchBody`). +- **EDT / retained Swing**: all methods `@RequiresEdt`; mutate existing components in `update()`, lazy + body creation, compare-before-assign — follow the plugin's retained-Swing conventions. +- **Shared-code guard**: everything is under `packages/kilo-jetbrains/` (Kilo-owned) — no `kilocode_change` + markers and no opencode annotations required. + +## Validation + +From `packages/kilo-jetbrains/`: +- `./gradlew typecheck` +- `./gradlew test` (or targeted `--tests "*ModifiedFilesViewTest"`, `"*EditToolViewTest"`, + `"*SessionMessageListPanelTest"`) +- Manual: `./gradlew runIde`, run a session that edits files; confirm the collapsed "Modified N files" + card with bars, hover popup, expand showing per-file diffs, and that it disappears when a revert is pending. diff --git a/.kilo/plans/1785185060000-jetbrains-per-turn-modified-files.md b/.kilo/plans/1785185060000-jetbrains-per-turn-modified-files.md new file mode 100644 index 0000000000..a880bb7fbe --- /dev/null +++ b/.kilo/plans/1785185060000-jetbrains-per-turn-modified-files.md @@ -0,0 +1,124 @@ +# JetBrains: per-turn "Modified files" view (VS Code parity) + +Refactor the session-level "Modified files" card into a **per-turn** card rendered at the end of each +turn, matching VS Code. It keeps the same behavior we already built (collapsed header with count + +bars, hover popup, expand → in-place per-file diffs) and **persists across reopen** because the data +rides on the message, not on a live-only event. + +## Data source (no CLI changes) + +Per-turn diffs already exist on the wire as `message.info.summary.diffs` (a `SnapshotFileDiff[]`), +set by the CLI on the **user anchor message** of each turn (`summary.ts:142-144`). It is delivered by: + +- **History / reopen**: `GET /session/{id}/message` returns each user message with `summary.diffs`. +- **Live**: the `message.updated` event carries the updated user-message info with `summary.diffs`. + +Both paths funnel through one parser: `KiloCliDataParser.parseMessage(obj)` (used at line 147 for +`message.updated` and line 397 inside `parseMessages`). JetBrains currently drops `summary` because +`MessageDto` has no such field. So the whole feature is JetBrains-side only. + +`SnapshotFileDiff` maps 1:1 to the existing `DiffFileDto` (`file?`, `patch?`, `additions`, +`deletions`), so no new diff type is needed. + +## Turn model already fits + +`SessionModel` maintains a `Turn` grouping (turn id == user anchor message id) and fires +`TurnAdded` / `TurnUpdated` / `TurnRemoved`. `TurnView` renders one turn (user anchor + following +assistant messages). The per-turn card is a trailing child of `TurnView`, fed by +`model.message(turn.id)?.info?.summary?.diffs`. + +## Tasks + +1. **Shared DTO — `ChatDto.kt`** + - Add: + ```kotlin + @Serializable + data class MessageSummaryDto(val diffs: List = emptyList()) + ``` + - Add `val summary: MessageSummaryDto? = null` to `MessageDto`. + +2. **Backend parse — `KiloCliDataParser.kt`** + - Extract the inline per-file diff mapping (currently `session.diff` branch, lines 258-267) into a + reusable `parseDiffs(elem: JsonElement?): List`; call it from that branch (no dup). + - In `parseMessage(obj)`, read `obj["summary"]?.jsonObject?.get("diffs")` via `parseDiffs` and set + `summary = MessageSummaryDto(diffs)` when the array is present (otherwise `null`). This covers + both history (`parseMessages`) and `message.updated` automatically. + +3. **Refactor `ModifiedFilesView` to be turn-scoped (`session/ui/ModifiedFilesView.kt`)** + - Drop the `model` / `model.diff` / `model.revert()` dependency and the "hide during revert" rule + (turns are removed on revert anyway). + - New API: constructor `(openFile: SessionFileOpener, selection: SessionSelection? = null)` plus + `@RequiresEdt fun setDiffs(diffs: List)`. + - `setDiffs` maps `DiffFileDto` → `EditFileChange` (existing helper), sets + `isVisible = files.isNotEmpty()`, updates count + `DiffBars`, and, when expanded, + `body.updateFiles(files)`. Keep lazy body creation, `expand()`, and `headerPopup()` exactly as + now (reuse `PatchBody` + `POPUP_OPTS` + `DiffBars`). + - Keep `contentId = "session-modified-files"` (or rename to `"turn-modified-files"`). + +4. **Host the card in `TurnView.kt`** + - Add a lazily-created `ModifiedFilesView` kept as the **last** child of the turn. + - `addMessage` must insert message views **before** the card: add at index + `modified?.let { components.indexOf(it) } ?: componentCount`. + - Add `@RequiresEdt fun setDiffs(diffs: List)`: create+append the card on first + non-empty diff, forward to `card.setDiffs(...)`; wire `card.hover = hover` so the popup uses the + existing hover path. Forward `applyStyle` and dispose to the card. + +5. **Drive it from `SessionMessageListPanel.kt`** + - Helper `diffsOf(turn) = model.message(turn.id)?.info?.summary?.diffs.orEmpty()`. + - Call `tv.setDiffs(diffsOf(turn))` at the end of `onTurnAdded`, `onTurnUpdated`, and in `rebuild()` + for each turn (this is what makes it **persist on reopen**). + - Handle `MessageUpdated` (currently a no-op at lines 156-162): when + `turnViews[event.info.info.id]` exists (message is a turn anchor), call + `tv.setDiffs(event.info.info.summary?.diffs.orEmpty())`. This is how a completing turn's diff + appears live. + +6. **Remove the session-level footer card** + - `SessionUi.kt`: drop the `modified = ModifiedFilesView(...)` argument. + - `SessionMessageListPanel.kt`: remove the `modified` ctor param, its `anchorFooter`/`applyStyle`/ + `clear` handling, hover wiring, and the `modified?.update()` calls in `StateChanged`, + `RevertChanged`, `DiffUpdated`, `rebuild`, `clear`. Leave `RevertBanner` untouched (it still uses + `model.diff`; the `session.diff` event / `model.diff` stay for the revert banner). + +## Persistence verification + +On reopen, `SessionController.loadSession()` → `model.loadHistory(items)` stores messages **with** +`summary` (task 2) → `rebuild()` builds turns → `setDiffs(diffsOf(turn))` renders each turn's card. +No extra RPC/fetch and no `session.diff` dependency — unlike the old session-level card, this survives +reopen natively. + +## Tests + +- **Backend** (`KiloCliDataParserTest`): `parseMessage` populates `summary.diffs` (file/patch/additions/ + deletions); a `message.updated` payload with summary yields `MessageUpdated` carrying it; a message + without summary yields `summary == null`. +- **Shared** (serialization test alongside `ChatDtoSerializationTest`): `MessageDto` with/without + `summary` round-trips. +- **`ModifiedFilesViewTest`**: rewrite to the `setDiffs` API — hidden when empty; visible + correct + count after `setDiffs`; collapsed start (body not created); first expand builds one link + badge per + file; popup only when collapsed; editor leak/churn test retained. +- **`TurnViewTest`** (new or extend): card is the last child and appears only when the anchor has + diffs; `addMessage` keeps the card last; `setDiffs([])` hides it. +- **`SessionControllerTestBase` existing-session flow**: seed `rpc.history` with a user message whose + `summary.diffs` is non-empty; assert the reopened turn renders the card (**persistence**). Also emit + a `message.updated` with summary and assert the live card updates. +- Remove the old session-level footer assertions in `SessionMessageListPanelTest` and the + session-scoped bits of the current `ModifiedFilesViewTest`. + +## Notes / risks + +- Scope matches VS Code exactly: only **user** anchor messages carry `summary.diffs`; leading + assistant-only turns show nothing. +- Same snapshot dependency as VS Code: if `snapshot: false`, the CLI emits no diffs, so nothing shows. +- All changes live under `packages/kilo-jetbrains/` (Kilo-owned) — no `kilocode_change` markers, no + opencode annotations. Reuse `PatchBody`, `DiffStatBadge`, `DiffBars`, `SecondarySessionPartView`, + `HeaderPopup*`; introduce no duplicate diff/scroll/rendering code. +- Keep the existing `session.changes.*` i18n keys and the changeset. + +## Validation + +From `packages/kilo-jetbrains/`: +- `./gradlew :frontend:test` and `./gradlew :backend:test` (or targeted `--tests` for the classes above) +- `./gradlew typecheck` +- Manual `./gradlew runIde`: run a multi-turn session that edits files, confirm a card at the end of + each turn (count + bars, hover popup, expand per-file diffs), then reopen the session and confirm the + cards are still there. diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt index a87fd32c6a..b175ce7c97 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt @@ -27,6 +27,7 @@ import ai.kilocode.rpc.dto.CustomProviderSaveDto import ai.kilocode.rpc.dto.DiffFileDto import ai.kilocode.rpc.dto.MessageDto import ai.kilocode.rpc.dto.MessageErrorDto +import ai.kilocode.rpc.dto.MessageSummaryDto import ai.kilocode.rpc.dto.MessageTimeDto import ai.kilocode.rpc.dto.MessageWithPartsDto import ai.kilocode.rpc.dto.McpConfigDto @@ -255,16 +256,7 @@ object KiloCliDataParser { "session.diff" -> { val sid = props.str("sessionID") ?: return null - val diffs = props["diff"]?.jsonArray?.mapNotNull { elem -> - val d = elem.jsonObject - val file = d.str("file") ?: return@mapNotNull null - DiffFileDto( - file = file, - additions = d.long("additions")?.safeInt() ?: 0, - deletions = d.long("deletions")?.safeInt() ?: 0, - patch = d.str("patch"), - ) - } ?: emptyList() + val diffs = parseDiffs(props["diff"]) ChatEventDto.SessionDiffChanged(sid, diffs) } @@ -1041,6 +1033,8 @@ object KiloCliDataParser { val time = obj["time"]?.jsonObject val tokens = obj["tokens"]?.jsonObject val error = obj["error"]?.jsonObject + val raw = obj["summary"]?.jsonObject?.get("diffs") + val summary = if (raw == null) null else MessageSummaryDto(parseDiffs(raw)) return MessageDto( id = obj.str("id") ?: "", @@ -1057,9 +1051,24 @@ object KiloCliDataParser { cost = obj.num("cost"), tokens = tokens?.let(::parseTokens), error = error?.let { parseError(it) }, + summary = summary, ) } + private fun parseDiffs(raw: JsonElement?): List { + val arr = raw.arr() ?: return emptyList() + return arr.mapNotNull { elem -> + val item = elem.obj() ?: return@mapNotNull null + val file = item.str("file") ?: return@mapNotNull null + DiffFileDto( + file = file, + additions = item.long("additions")?.safeInt() ?: 0, + deletions = item.long("deletions")?.safeInt() ?: 0, + patch = item.str("patch"), + ) + } + } + internal fun parsePart(obj: JsonObject): PartDto { val state = obj["state"]?.jsonObject val tokens = obj["tokens"]?.jsonObject diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/ChatDtoSerializationTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/ChatDtoSerializationTest.kt index 09df6ad590..551597e33e 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/ChatDtoSerializationTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/ChatDtoSerializationTest.kt @@ -1,8 +1,10 @@ package ai.kilocode.backend.cli import ai.kilocode.rpc.dto.ChatEventDto +import ai.kilocode.rpc.dto.DiffFileDto import ai.kilocode.rpc.dto.MessageDto import ai.kilocode.rpc.dto.MessageErrorDto +import ai.kilocode.rpc.dto.MessageSummaryDto import ai.kilocode.rpc.dto.MessageTimeDto import ai.kilocode.rpc.dto.PartDto import ai.kilocode.rpc.dto.PartTimeDto @@ -15,6 +17,7 @@ import ai.kilocode.rpc.dto.SessionStatusDto import kotlinx.serialization.json.Json import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertNull import kotlin.test.assertTrue /** @@ -233,6 +236,33 @@ class ChatDtoSerializationTest { assertEquals("a.png", decoded.filename) } + @Test + fun `MessageDto summary diffs are preserved in round-trip`() { + val msg = msg("msg_1").copy( + summary = MessageSummaryDto( + diffs = listOf(DiffFileDto("src/A.kt", 2, 1, "@@ patch")), + ), + ) + + val encoded = json.encodeToString(MessageDto.serializer(), msg) + assertTrue(encoded.contains(""""summary"""")) + assertTrue(encoded.contains(""""diffs"""")) + + val decoded = json.decodeFromString(MessageDto.serializer(), encoded) + val diff = decoded.summary?.diffs?.single() + assertEquals("src/A.kt", diff?.file) + assertEquals("@@ patch", diff?.patch) + } + + @Test + fun `MessageDto summary defaults to null`() { + val encoded = json.encodeToString(MessageDto.serializer(), msg("msg_1")) + + val decoded = json.decodeFromString(MessageDto.serializer(), encoded) + + assertNull(decoded.summary) + } + @Test fun `PromptDto variant is preserved in round-trip`() { val prompt = PromptDto( diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt index 8e71d6ce37..1b69dd5b0e 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt @@ -86,7 +86,10 @@ class KiloCliDataParserTest { "id": "msg_1", "sessionID": "ses_123", "role": "assistant", - "time": { "created": 1000.0 } + "time": { "created": 1000.0 }, + "summary": { + "diffs": [{"file": "src/A.kt", "additions": 3, "deletions": 1, "patch": "@@ ..."}] + } } } } @@ -98,6 +101,11 @@ class KiloCliDataParserTest { assertEquals("ses_123", result.sessionID) assertEquals("msg_1", result.info.id) assertEquals("assistant", result.info.role) + val diff = result.info.summary?.diffs?.single() + assertEquals("src/A.kt", diff?.file) + assertEquals(3, diff?.additions) + assertEquals(1, diff?.deletions) + assertEquals("@@ ...", diff?.patch) } @Test @@ -120,6 +128,7 @@ class KiloCliDataParserTest { assertTrue(result is ChatEventDto.MessageUpdated) assertEquals("ses_456", result.sessionID) assertEquals("user", result.info.role) + assertNull(result.info.summary) } // ---- parseChatEvent — specific event types ---- @@ -1345,7 +1354,10 @@ class KiloCliDataParserTest { fun `parseMessages - user and assistant messages`() { val raw = """[ { - "info": { "id": "m1", "sessionID": "s1", "role": "user", "time": { "created": 1.0 } }, + "info": { + "id": "m1", "sessionID": "s1", "role": "user", "time": { "created": 1.0 }, + "summary": { "diffs": [{"file": "src/A.kt", "additions": 2, "deletions": 1, "patch": "@@ patch"}] } + }, "parts": [{ "id": "p1", "sessionID": "s1", "messageID": "m1", "type": "text", "text": "Hello" }] }, { @@ -1357,8 +1369,11 @@ class KiloCliDataParserTest { val result = KiloCliDataParser.parseMessages(raw) assertEquals(2, result.size) assertEquals("user", result[0].info.role) + assertEquals("src/A.kt", result[0].info.summary?.diffs?.single()?.file) + assertEquals("@@ patch", result[0].info.summary?.diffs?.single()?.patch) assertEquals("Hello", result[0].parts[0].text) assertEquals("assistant", result[1].info.role) + assertNull(result[1].info.summary) assertEquals("Hi there", result[1].parts[0].text) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ModifiedFilesView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ModifiedFilesView.kt new file mode 100644 index 0000000000..8c23806a71 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ModifiedFilesView.kt @@ -0,0 +1,158 @@ +package ai.kilocode.client.session.ui + +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.session.SessionFileOpener +import ai.kilocode.client.session.model.Content +import ai.kilocode.client.session.ui.popup.HeaderPopupBody +import ai.kilocode.client.session.ui.popup.HeaderPopupRequest +import ai.kilocode.client.session.ui.selection.SessionSelection +import ai.kilocode.client.session.ui.style.SessionEditorStyle +import ai.kilocode.client.session.ui.style.SessionUiStyle +import ai.kilocode.client.session.views.base.SecondarySessionPartView +import ai.kilocode.client.session.views.tool.EditFileChange +import ai.kilocode.client.session.views.tool.POPUP_OPTS +import ai.kilocode.client.session.views.tool.PatchBody +import ai.kilocode.client.telemetry.Telemetry +import ai.kilocode.client.ui.DiffBars +import ai.kilocode.client.ui.UiStyle +import ai.kilocode.client.ui.layout.Stack +import ai.kilocode.rpc.dto.DiffFileDto +import com.intellij.openapi.util.Disposer +import com.intellij.ui.components.JBLabel +import com.intellij.util.concurrency.annotations.RequiresEdt +import javax.swing.JComponent + +class ModifiedFilesView private constructor( + private val openFile: SessionFileOpener, + private val selection: SessionSelection? = null, + private val parts: Header = Header(), + private val body: PatchBody = PatchBody(selection, openFile), +) : SecondarySessionPartView(parts.panel, { body.mountFiles(emptyList()) }) { + override val contentId = CONTENT_ID + + private var style = SessionEditorStyle.current() + private var files = emptyList() + + constructor( + openFile: SessionFileOpener, + selection: SessionSelection? = null, + ) : this(openFile, selection, Header(), PatchBody(selection, openFile)) + + init { + body.parent = this + isVisible = false + applyStyle(style) + } + + @RequiresEdt + fun setDiffs(diffs: List) { + val next = diffs.map(::file) + if (files == next) { + val visible = next.isNotEmpty() + if (isVisible == visible) return + isVisible = visible + revalidate() + repaint() + return + } + files = next + val visible = files.isNotEmpty() + val additions = files.sumOf { it.additions } + val deletions = files.sumOf { it.deletions } + if (isVisible != visible) isVisible = visible + if (!visible) collapse() + parts.update(files.size, additions, deletions) + if (isExpanded()) body.updateFiles(files) + revalidate() + repaint() + } + + @RequiresEdt + override fun expand(): Boolean { + val changed = super.expand() + if (!changed) return false + body.updateFiles(files) + body.applyStyle(style) + return true + } + + @RequiresEdt + override fun update(content: Content) = Unit + + @RequiresEdt + override fun headerPopup(): HeaderPopupRequest? { + if (isExpanded() || files.isEmpty()) return null + return HeaderPopupRequest(row, build = { buildPopup(files) }) { + Telemetry.send("Header Popup Shown", mapOf("surface" to "session", "tool" to "changes")) + } + } + + @RequiresEdt + override fun applyStyle(style: SessionEditorStyle) { + this.style = style + parts.applyStyle(style) + body.applyStyle(style) + refresh() + } + + override fun dispose() { + body.disposeBody() + super.dispose() + } + + @RequiresEdt + internal fun bodyCreated() = body.created() + + @RequiresEdt + internal fun bodyVisible() = body.attached(this) + + @RequiresEdt + internal fun countText() = parts.count.text + + @RequiresEdt + private fun buildPopup(files: List): HeaderPopupBody { + val owner = Disposer.newDisposable("Modified files popup body") + val popup = PatchBody(selection, openFile, POPUP_OPTS).also { it.parent = owner } + val panel = popup.mountFiles(files) + popup.applyStyle(style) + return HeaderPopupBody(panel, owner, style.editorBackground, SessionUiStyle.View.Popup.WIDE_MAX_WIDTH) + } + + private class Header { + val title = JBLabel(KiloBundle.message("session.changes.modified")) + val count = JBLabel() + private val bars = DiffBars(0, 0) + // Match the patch header: title and target sit a standard md gap apart, so the bars + // indicator is separated from the "Modified N files" label by the same gap. + val panel: JComponent = Stack.horizontal(UiStyle.Gap.md()) + .next(Stack.horizontal(UiStyle.Gap.sm()).next(title).next(count)) + .next(bars) + + @RequiresEdt + fun update(total: Int, additions: Int, deletions: Int) { + val text = KiloBundle.message(if (total == 1) "session.changes.count.one" else "session.changes.count.other", total) + if (count.text != text) count.text = text + bars.update(additions, deletions) + } + + @RequiresEdt + fun applyStyle(style: SessionEditorStyle) { + title.font = style.boldEditorFont + count.font = style.transcriptFont + title.foreground = UiStyle.Colors.fg() + count.foreground = UiStyle.Colors.weak() + } + } + + private companion object { + const val CONTENT_ID = "session-modified-files" + } +} + +private fun file(dto: DiffFileDto) = EditFileChange( + path = dto.file, + type = "", + additions = dto.additions, + deletions = dto.deletions, + patch = dto.patch.orEmpty(), +) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt index a0dc4d7dfd..3b67a944df 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt @@ -150,13 +150,17 @@ class SessionMessageListPanel( // Message events: structural changes are handled via turn events above. is SessionModelEvent.MessageAdded, - is SessionModelEvent.MessageUpdated, is SessionModelEvent.MessageRemoved, is SessionModelEvent.TodosUpdated, is SessionModelEvent.SessionUpdated, is SessionModelEvent.HeaderUpdated, is SessionModelEvent.Compacted -> Unit + is SessionModelEvent.MessageUpdated -> { + turnViews[event.info.info.id]?.setDiffs(event.info.info.summary?.diffs.orEmpty()) + refresh() + } + is SessionModelEvent.DiffUpdated -> { banner?.update() refresh() @@ -223,6 +227,7 @@ class SessionMessageListPanel( val mv = tv.addMessage(msg) register(msgId, tv, mv) } + tv.setDiffs(diffsOf(turn)) tv.syncCopyToolbars() syncReverted() add(tv) @@ -250,6 +255,7 @@ class SessionMessageListPanel( val mv = tv.addMessage(msg) register(id, tv, mv) } + tv.setDiffs(diffsOf(turn)) tv.syncCopyToolbars() syncReverted() syncSettled() @@ -286,6 +292,7 @@ class SessionMessageListPanel( val mv = tv.addMessage(msg) register(msgId, tv, mv) } + tv.setDiffs(diffsOf(turn)) tv.syncCopyToolbars() add(tv) } @@ -409,6 +416,9 @@ class SessionMessageListPanel( add(progress) } + private fun diffsOf(turn: ai.kilocode.client.session.model.Turn) = + model.message(turn.id)?.info?.summary?.diffs.orEmpty() + private fun register(msgId: String, tv: TurnView, mv: MessageView) { msgToTurn[msgId] = tv msgToView[msgId] = mv diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/popup/HeaderPopup.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/popup/HeaderPopup.kt index 39e8d60de2..82702ded3c 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/popup/HeaderPopup.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/popup/HeaderPopup.kt @@ -3,6 +3,7 @@ package ai.kilocode.client.session.ui.popup import ai.kilocode.client.session.ui.style.SessionUiStyle import com.intellij.openapi.Disposable import com.intellij.ui.EditorTextField +import com.intellij.ui.components.JBScrollPane import com.intellij.ui.components.JBTextArea import com.intellij.util.ui.JBUI import java.awt.BorderLayout @@ -15,6 +16,7 @@ import javax.swing.JComponent import javax.swing.JEditorPane import javax.swing.JPanel import javax.swing.JScrollPane +import javax.swing.ScrollPaneConstants class HeaderPopupRequest( val anchor: JComponent, @@ -35,16 +37,29 @@ private class HeaderPopupPanel( private val child: JComponent, private val maxWidth: Int, ) : JPanel(BorderLayout()) { - init { + // One scroll pane wraps every popup body (single-file edit, multi-file patch, session changes), + // so bodies taller than the max height scroll instead of clipping. Bodies that carry their own + // inner scroll pane render at full height inside the viewport, so only this outer pane scrolls. + private val scroll = JBScrollPane( + child, + ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED, + ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER, + ).apply { // Transparent so the balloon fill shows uniformly behind nested popup content. isOpaque = false - add(child, BorderLayout.CENTER) + viewport.isOpaque = false + border = JBUI.Borders.empty() + } + + init { + isOpaque = false + add(scroll, BorderLayout.CENTER) } override fun getPreferredSize(): Dimension { val width = contentWidth(child).takeIf { it > 0 }?.coerceAtMost(maxWidth) ?: maxWidth fit(child, width) - val height = super.getPreferredSize().height.coerceAtMost(JBUI.scale(SessionUiStyle.View.Popup.MAX_HEIGHT)) + val height = child.preferredSize.height.coerceAtMost(JBUI.scale(SessionUiStyle.View.Popup.MAX_HEIGHT)) return Dimension(width, height) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ReasoningView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ReasoningView.kt index 01c2e9fa9c..e5a9269ad6 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ReasoningView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ReasoningView.kt @@ -281,6 +281,8 @@ class ReasoningView( md.background = style.editorBackground md.component.border = JBUI.Borders.empty() md.set(text) + // The shared popup wrapper (HeaderPopupBody) provides the scroll pane, so pass the content + // panel directly instead of nesting a second scroll pane here. val panel = TrackPanel().apply { isOpaque = true background = style.editorBackground @@ -290,15 +292,7 @@ class ReasoningView( ) add(md.component, BorderLayout.CENTER) } - val scroll = JBScrollPane(panel).apply { - border = JBUI.Borders.empty() - isOpaque = true - background = style.editorBackground - viewport.background = style.editorBackground - horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER - verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED - } - return HeaderPopupBody(scroll, md, style.editorBackground) + return HeaderPopupBody(panel, md, style.editorBackground) } private fun bodyMaxHeight(): Int { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TurnView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TurnView.kt index b4d8684004..dc151fc15c 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TurnView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TurnView.kt @@ -3,6 +3,7 @@ package ai.kilocode.client.session.views import ai.kilocode.client.session.SessionFileOpener import ai.kilocode.client.session.model.FileAttachment import ai.kilocode.client.session.model.Message +import ai.kilocode.client.session.ui.ModifiedFilesView import ai.kilocode.client.session.ui.SessionLayoutPanel import ai.kilocode.client.session.ui.SessionView import ai.kilocode.client.session.ui.style.SessionEditorStyle @@ -10,6 +11,7 @@ import ai.kilocode.client.session.ui.selection.SessionSelection import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.session.views.base.PartView +import ai.kilocode.rpc.dto.DiffFileDto import com.intellij.openapi.Disposable import com.intellij.openapi.util.Disposer import com.intellij.openapi.util.registry.Registry @@ -39,6 +41,7 @@ class TurnView( ) : SessionLayoutPanel(SessionUiStyle.SessionLayout.GAP), Disposable, SessionEditorStyleTarget, SessionView { private val messages = LinkedHashMap() + private var modified: ModifiedFilesView? = null private var settled = true override val sessionViewKind = SessionView.Kind.Default @@ -65,12 +68,25 @@ class TurnView( fun addMessage(msg: Message): MessageView { val view = MessageView(msg, openFile, style, openUrl, selection, openAttachment, resize, repo, hover, revert) messages[msg.info.id] = view - add(view) + val idx = modified?.let { components.indexOf(it) } ?: componentCount + add(view, idx) syncCopyToolbars() revalidate() return view } + @RequiresEdt + fun setDiffs(diffs: List) { + val card = modified ?: if (diffs.isEmpty()) null else ModifiedFilesView(openFile, selection).also { + it.hover = hover + it.applyStyle(style) + modified = it + add(it) + } + card?.setDiffs(diffs) + if (card != null) revalidate() + } + /** Remove the [MessageView] for [msgId] if present. */ fun removeMessage(msgId: String) { removeMessageChanged(msgId) @@ -104,6 +120,7 @@ class TurnView( override fun applyStyle(style: SessionEditorStyle) { this.style = style for (view in messages.values) view.applyStyle(style) + modified?.applyStyle(style) syncCopyToolbars() revalidate() repaint() @@ -114,6 +131,11 @@ class TurnView( remove(it) Disposer.dispose(it) } + modified?.let { + remove(it) + Disposer.dispose(it) + } + modified = null messages.clear() } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/EditToolView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/EditToolView.kt index 865e2d42f4..48c0dcb4f1 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/EditToolView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/EditToolView.kt @@ -248,7 +248,7 @@ private fun popupDiffBody(selection: SessionSelection?) = ToolMarkdownBody( render = ::diffMarkdown, ) -private val POPUP_OPTS = MdCodeBlockOptions( +internal val POPUP_OPTS = MdCodeBlockOptions( border = MdCodeBlockBorder.None, verticalPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED, editorOnly = true, diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/PatchBody.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/PatchBody.kt index 85cd7f2cc5..97cfb20a4e 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/PatchBody.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/PatchBody.kt @@ -64,11 +64,14 @@ class PatchBody( private var signature = "" @RequiresEdt - override fun mount(tool: Tool): JComponent { + override fun mount(tool: Tool): JComponent = mountFiles(editFiles(tool)) + + @RequiresEdt + internal fun mountFiles(files: List): JComponent { root?.let { return it } val panel = Stack.vertical() root = panel - rebuild(tool) + rebuild(files) return panel } @@ -83,9 +86,14 @@ class PatchBody( @RequiresEdt override fun update(tool: Tool): Boolean { + return updateFiles(editFiles(tool)) + } + + @RequiresEdt + internal fun updateFiles(files: List): Boolean { if (root == null) return false - if (signatureOf(tool) == signature) return false - rebuild(tool) + if (signatureOf(files) == signature) return false + rebuild(files) return true } @@ -124,14 +132,14 @@ class PatchBody( } @RequiresEdt - private fun rebuild(tool: Tool) { + private fun rebuild(files: List) { val panel = root ?: return val parent = parent ?: error("Patch body has no parent") disposeBody() val disposable = Disposer.newDisposable("Patch body") Disposer.register(parent, disposable) owner = disposable - editFiles(tool).filter { it.patch.isNotBlank() }.forEachIndexed { index, file -> + files.filter { it.patch.isNotBlank() }.forEachIndexed { index, file -> if (index > 0) panel.gap(JBUI.scale(SessionUiStyle.View.Code.BLOCK_GAP)) panel.next(header(file)) panel.gap(UiStyle.Gap.sm()) @@ -142,12 +150,12 @@ class PatchBody( views.add(md) panel.next(md.component) } - signature = signatureOf(tool) + signature = signatureOf(files) panel.revalidate() panel.repaint() } - private fun signatureOf(tool: Tool): String = editFiles(tool) + private fun signatureOf(files: List): String = files .joinToString("\u0000") { "${it.path}\u0001${it.additions}\u0001${it.deletions}\u0001${it.patch}" } @RequiresEdt diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/DiffBars.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/DiffBars.kt new file mode 100644 index 0000000000..2861aaa348 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/DiffBars.kt @@ -0,0 +1,75 @@ +package ai.kilocode.client.ui + +import com.intellij.util.ui.JBUI +import java.awt.Color +import java.awt.Dimension +import java.awt.Graphics +import java.awt.Graphics2D +import java.awt.RenderingHints +import javax.swing.JPanel + +internal class DiffBars( + additions: Int, + deletions: Int, +) : JPanel() { + private var additions = additions + private var deletions = deletions + + init { + isOpaque = false + } + + fun update(additions: Int, deletions: Int) { + if (this.additions == additions && this.deletions == deletions) return + this.additions = additions + this.deletions = deletions + repaint() + } + + override fun getPreferredSize(): Dimension = JBUI.size(WIDTH, HEIGHT) + + override fun getMinimumSize(): Dimension = preferredSize + + override fun getMaximumSize(): Dimension = preferredSize + + override fun paintComponent(g: Graphics) { + val g2 = g.create() as Graphics2D + try { + g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON) + blocks().forEachIndexed { index, color -> + g2.color = color + g2.fillRoundRect( + JBUI.scale(index * STEP), + 0, + JBUI.scale(BAR_WIDTH), + JBUI.scale(HEIGHT), + JBUI.scale(ARC), + JBUI.scale(ARC), + ) + } + } finally { + g2.dispose() + } + super.paintComponent(g) + } + + private fun blocks(): List { + val total = additions + deletions + if (total <= 0) return List(COUNT) { UiStyle.Colors.weak() } + val added = ((additions.toDouble() / total) * COUNT).toInt().coerceIn(0, COUNT) + val removed = ((deletions.toDouble() / total) * COUNT).toInt().coerceIn(0, COUNT - added) + val neutral = COUNT - added - removed + return List(added) { UiStyle.Colors.addedForeground() } + + List(removed) { UiStyle.Colors.removedForeground() } + + List(neutral) { UiStyle.Colors.weak() } + } + + private companion object { + const val COUNT = 5 + const val BAR_WIDTH = 2 + const val STEP = 4 + const val HEIGHT = 14 + const val WIDTH = 18 + const val ARC = 2 + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties index 2557da487f..92d69453b5 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -121,6 +121,9 @@ session.status.offline=Connection offline session.part.reasoning=Reasoning session.part.compaction=context compacted +session.changes.modified=Modified +session.changes.count.one={0} file +session.changes.count.other={0} files session.part.tool.copy=Copy session.part.tool.error=Error session.part.tool.agent={0} Agent diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/HistoryLoadingTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/HistoryLoadingTest.kt index da883cdbda..8eeea174c8 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/HistoryLoadingTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/HistoryLoadingTest.kt @@ -3,8 +3,10 @@ package ai.kilocode.client.session.controller import ai.kilocode.client.session.model.SessionModelEvent import ai.kilocode.rpc.dto.AgentDto import ai.kilocode.rpc.dto.ConfigDto +import ai.kilocode.rpc.dto.DiffFileDto import ai.kilocode.rpc.dto.KiloAppStateDto import ai.kilocode.rpc.dto.KiloAppStatusDto +import ai.kilocode.rpc.dto.MessageSummaryDto import ai.kilocode.rpc.dto.MessageTimeDto import ai.kilocode.rpc.dto.MessageWithPartsDto import ai.kilocode.rpc.dto.ModelDto @@ -13,7 +15,9 @@ import ai.kilocode.rpc.dto.ProviderDto class HistoryLoadingTest : SessionControllerTestBase() { fun `test existing session loads history on init`() { - val m = msg("msg1", "ses_test", "user") + val m = msg("msg1", "ses_test", "user").copy( + summary = MessageSummaryDto(listOf(DiffFileDto("src/A.kt", 2, 1, "@@ patch"))), + ) val part = part("prt1", "ses_test", "msg1", "text", text = "hello") rpc.history.add(MessageWithPartsDto(m, listOf(part))) @@ -30,6 +34,7 @@ class HistoryLoadingTest : SessionControllerTestBase() { """, c, ) + assertEquals("src/A.kt", c.model.message("msg1")?.info?.summary?.diffs?.single()?.file) } fun `test non-empty history shows messages view`() { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ModifiedFilesViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ModifiedFilesViewTest.kt new file mode 100644 index 0000000000..5204ddb2c3 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ModifiedFilesViewTest.kt @@ -0,0 +1,133 @@ +package ai.kilocode.client.session.ui + +import ai.kilocode.client.ui.DiffStatBadge +import ai.kilocode.rpc.dto.DiffFileDto +import com.intellij.openapi.editor.EditorFactory +import com.intellij.openapi.util.Disposer +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.ui.components.JBLabel +import com.intellij.util.ui.UIUtil +import java.awt.Component +import java.awt.Container + +class ModifiedFilesViewTest : BasePlatformTestCase() { + private lateinit var view: ModifiedFilesView + + override fun setUp() { + super.setUp() + view = ModifiedFilesView({ _, _ -> }) + } + + override fun tearDown() { + try { + Disposer.dispose(view) + } finally { + super.tearDown() + } + } + + fun `test view is hidden without changes and shows count after diff`() { + assertFalse(view.isVisible) + + view.setDiffs(listOf(file("src/A.kt", 2, 1, PATCH))) + + assertTrue(view.isVisible) + assertEquals("1 file", view.countText()) + } + + fun `test expand renders one link and badge per file`() { + val opened = mutableListOf() + Disposer.dispose(view) + view = ModifiedFilesView({ href, _ -> opened.add(href) }) + view.setDiffs(listOf( + file("src/A.kt", 2, 0, ADD), + file("pkg/B.kt", 1, 1, UPDATE), + )) + + assertFalse(view.bodyCreated()) + + view.toggle() + + assertTrue(view.isExpanded()) + assertTrue(view.bodyVisible()) + assertTrue(view.bodyCreated()) + assertEquals(2, components(view).filterIsInstance().size) + + val links = components(view).filterIsInstance().filter { it.text?.contains("") == true } + assertTrue(links.any { it.text!!.contains("A.kt") && it.toolTipText == "src/A.kt" }) + assertTrue(links.any { it.text!!.contains("B.kt") && it.toolTipText == "pkg/B.kt" }) + } + + fun `test popup is available only when collapsed`() { + view.setDiffs(listOf(file("src/A.kt", 2, 1, PATCH))) + + assertNotNull(view.headerPopup()) + + view.toggle() + + assertNull(view.headerPopup()) + } + + fun `test dispose releases created editors`() { + val base = EditorFactory.getInstance().allEditors.size + view.setDiffs(listOf(file("src/A.kt", 2, 1, PATCH))) + + repeat(20) { + view.expand() + view.collapse() + view.setDiffs(listOf(file("src/A.kt", it + 1, 1, PATCH))) + } + + Disposer.dispose(view) + UIUtil.dispatchAllInvocationEvents() + + assertEquals(base, EditorFactory.getInstance().allEditors.size) + } + + private fun components(root: Component): List { + val out = mutableListOf() + fun visit(node: Component) { + out.add(node) + if (node is Container) node.components.forEach(::visit) + } + visit(root) + return out + } + + private fun file(path: String, additions: Int, deletions: Int, patch: String) = DiffFileDto( + file = path, + additions = additions, + deletions = deletions, + patch = patch, + ) + + private companion object { + val PATCH = """ + diff --git a/src/A.kt b/src/A.kt + --- a/src/A.kt + +++ b/src/A.kt + @@ -1,1 +1,2 @@ + -old + +new + +more + """.trimIndent() + + val ADD = """ + diff --git a/src/A.kt b/src/A.kt + --- /dev/null + +++ b/src/A.kt + @@ -0,0 +1,2 @@ + +one + +two + """.trimIndent() + + val UPDATE = """ + diff --git a/pkg/B.kt b/pkg/B.kt + --- a/pkg/B.kt + +++ b/pkg/B.kt + @@ -1,1 +1,1 @@ + -before + +after + """.trimIndent() + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt index d5e72cfc08..5915c7169b 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt @@ -31,6 +31,7 @@ import ai.kilocode.client.ui.DiffStatBadge import ai.kilocode.client.ui.layout.Stack import ai.kilocode.rpc.dto.DiffFileDto import ai.kilocode.rpc.dto.MessageDto +import ai.kilocode.rpc.dto.MessageSummaryDto import ai.kilocode.rpc.dto.MessageTimeDto import ai.kilocode.rpc.dto.MessageWithPartsDto import ai.kilocode.rpc.dto.PartDto @@ -61,6 +62,16 @@ import javax.swing.RepaintManager import javax.swing.SwingUtilities import javax.swing.border.Border +private val PATCH = """ + diff --git a/src/A.kt b/src/A.kt + --- a/src/A.kt + +++ b/src/A.kt + @@ -1,1 +1,2 @@ + -old + +new + +more +""".trimIndent() + /** * Tests for [SessionMessageListPanel] — structural and index integrity. * @@ -98,6 +109,28 @@ class SessionMessageListPanelTest : BasePlatformTestCase() { assertEquals("", panel.dump()) } + fun `test modified files card follows turn anchor summary`() { + model.upsertMessage(msg("u1", "user").copy(summary = summary("src/A.kt"))) + + val turn = panel.findTurn("u1")!! + val card = components(turn).filterIsInstance().single() + + assertSame(card, turn.components.last()) + assertTrue(card.isVisible) + assertEquals("1 file", card.countText()) + } + + fun `test message updated summary updates modified files card`() { + model.upsertMessage(msg("u1", "user")) + assertTrue(components(panel.findTurn("u1")!!).filterIsInstance().isEmpty()) + + model.upsertMessage(msg("u1", "user").copy(summary = summary("src/A.kt"))) + + val card = components(panel.findTurn("u1")!!).filterIsInstance().single() + assertTrue(card.isVisible) + assertEquals("1 file", card.countText()) + } + fun `test transcript content has symmetric side padding`() { model.upsertMessage(msg("a1", "assistant")) @@ -1233,6 +1266,10 @@ class SessionMessageListPanelTest : BasePlatformTestCase() { id = id, sessionID = "ses", role = role, time = MessageTimeDto(0.0), ) + private fun summary(path: String) = MessageSummaryDto( + diffs = listOf(DiffFileDto(path, 2, 1, PATCH)), + ) + private fun part(id: String, mid: String, type: String, text: String? = null) = PartDto( id = id, sessionID = "ses", messageID = mid, type = type, text = text, ) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/popup/HeaderPopupBodyTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/popup/HeaderPopupBodyTest.kt new file mode 100644 index 0000000000..2d866e53e7 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/popup/HeaderPopupBodyTest.kt @@ -0,0 +1,49 @@ +package ai.kilocode.client.session.ui.popup + +import ai.kilocode.client.session.ui.style.SessionUiStyle +import com.intellij.openapi.util.Disposer +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.ui.components.JBScrollPane +import com.intellij.util.ui.JBUI +import com.intellij.util.ui.UIUtil +import java.awt.Component +import java.awt.Container +import java.awt.Dimension +import javax.swing.JPanel + +class HeaderPopupBodyTest : BasePlatformTestCase() { + + fun `test tall popup content scrolls and caps height`() { + val tall = JPanel().apply { + preferredSize = Dimension(JBUI.scale(200), JBUI.scale(SessionUiStyle.View.Popup.MAX_HEIGHT * 3)) + } + val owner = Disposer.newDisposable("popup body") + Disposer.register(testRootDisposable, owner) + val body = HeaderPopupBody(tall, owner, UIUtil.getPanelBackground()) + + val scroll = descendants(body.component).filterIsInstance().single() + assertSame(tall, scroll.viewport.view) + assertEquals(JBUI.scale(SessionUiStyle.View.Popup.MAX_HEIGHT), body.component.preferredSize.height) + } + + fun `test short popup content is not capped`() { + val short = JPanel().apply { + preferredSize = Dimension(JBUI.scale(200), JBUI.scale(40)) + } + val owner = Disposer.newDisposable("popup body") + Disposer.register(testRootDisposable, owner) + val body = HeaderPopupBody(short, owner, UIUtil.getPanelBackground()) + + assertEquals(JBUI.scale(40), body.component.preferredSize.height) + } + + private fun descendants(root: Component): List { + val out = mutableListOf() + fun visit(node: Component) { + out.add(node) + if (node is Container) node.components.forEach(::visit) + } + visit(root) + return out + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TurnViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TurnViewTest.kt index e672437320..301b78d761 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TurnViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TurnViewTest.kt @@ -7,7 +7,9 @@ import ai.kilocode.client.session.model.Text import ai.kilocode.client.session.model.Tool import ai.kilocode.client.session.model.ToolExecState import ai.kilocode.client.session.model.toolKind +import ai.kilocode.client.session.ui.ModifiedFilesView import ai.kilocode.client.session.ui.style.SessionUiStyle +import ai.kilocode.rpc.dto.DiffFileDto import ai.kilocode.rpc.dto.MessageDto import ai.kilocode.rpc.dto.MessageTimeDto import com.intellij.testFramework.fixtures.BasePlatformTestCase @@ -84,6 +86,32 @@ class TurnViewTest : BasePlatformTestCase() { assertEquals("user#u1, assistant#a1", tv.dump()) } + fun `test modified files card stays last in turn`() { + val tv = TurnView("u1", openFile) + tv.addMessage(msg("u1", "user")) + + tv.setDiffs(listOf(diff("src/A.kt"))) + + val card = tv.components.last() as ModifiedFilesView + assertTrue(card.isVisible) + + tv.addMessage(msg("a1", "assistant")) + + assertSame(card, tv.components.last()) + assertEquals(listOf("u1", "a1"), tv.messageIds()) + } + + fun `test modified files card hides for empty diffs`() { + val tv = TurnView("u1", openFile) + + tv.setDiffs(listOf(diff("src/A.kt"))) + val card = tv.components.last() as ModifiedFilesView + + tv.setDiffs(emptyList()) + + assertFalse(card.isVisible) + } + // ------ MessageView ------ fun `test new MessageView is empty`() { @@ -344,6 +372,8 @@ class TurnViewTest : BasePlatformTestCase() { private fun msg(id: String, role: String): Message = Message(MessageDto(id = id, sessionID = "ses", role = role, time = MessageTimeDto(0.0))) + private fun diff(path: String) = DiffFileDto(path, additions = 2, deletions = 1, patch = PATCH) + private fun reasoning(id: String, content: String) = Reasoning(id).also { it.done = false it.content.append(content) @@ -375,4 +405,16 @@ class TurnViewTest : BasePlatformTestCase() { super.addInvalidComponent(invalidComponent) } } + + private companion object { + val PATCH = """ + diff --git a/src/A.kt b/src/A.kt + --- a/src/A.kt + +++ b/src/A.kt + @@ -1,1 +1,2 @@ + -old + +new + +more + """.trimIndent() + } } diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt index dbb053f8fd..1ecf38daf5 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt @@ -18,6 +18,12 @@ data class MessageDto( val cost: Double? = null, val tokens: TokensDto? = null, val error: MessageErrorDto? = null, + val summary: MessageSummaryDto? = null, +) + +@Serializable +data class MessageSummaryDto( + val diffs: List = emptyList(), ) @Serializable From 5f94226bb64eebf26e4742b21db779fbe8c6f8fc Mon Sep 17 00:00:00 2001 From: kirillk Date: Mon, 27 Jul 2026 17:20:38 -0400 Subject: [PATCH 02/28] fix(jetbrains): style modified files header --- .../client/session/ui/ModifiedFilesView.kt | 38 ++++++++++++++----- .../client/session/views/SessionViewIcons.kt | 1 + .../client/session/views/tool/ToolSupport.kt | 2 +- .../session/ui/ModifiedFilesViewTest.kt | 7 ++++ 4 files changed, 38 insertions(+), 10 deletions(-) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ModifiedFilesView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ModifiedFilesView.kt index 8c23806a71..be357d3065 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ModifiedFilesView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ModifiedFilesView.kt @@ -8,10 +8,14 @@ import ai.kilocode.client.session.ui.popup.HeaderPopupRequest import ai.kilocode.client.session.ui.selection.SessionSelection import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionUiStyle +import ai.kilocode.client.session.views.SessionViewIcons import ai.kilocode.client.session.views.base.SecondarySessionPartView import ai.kilocode.client.session.views.tool.EditFileChange import ai.kilocode.client.session.views.tool.POPUP_OPTS import ai.kilocode.client.session.views.tool.PatchBody +import ai.kilocode.client.session.views.tool.setFont +import ai.kilocode.client.session.views.tool.setForeground +import ai.kilocode.client.session.views.tool.setIcon import ai.kilocode.client.telemetry.Telemetry import ai.kilocode.client.ui.DiffBars import ai.kilocode.client.ui.UiStyle @@ -20,7 +24,11 @@ import ai.kilocode.rpc.dto.DiffFileDto import com.intellij.openapi.util.Disposer import com.intellij.ui.components.JBLabel import com.intellij.util.concurrency.annotations.RequiresEdt +import com.intellij.util.ui.JBUI +import java.awt.BorderLayout +import java.awt.Dimension import javax.swing.JComponent +import javax.swing.JPanel class ModifiedFilesView private constructor( private val openFile: SessionFileOpener, @@ -41,6 +49,7 @@ class ModifiedFilesView private constructor( init { body.parent = this isVisible = false + bindHeader(parts.glyph, parts.title, parts.count, parts.center, parts.controls) applyStyle(style) } @@ -119,14 +128,23 @@ class ModifiedFilesView private constructor( } private class Header { + val glyph = JBLabel() val title = JBLabel(KiloBundle.message("session.changes.modified")) val count = JBLabel() private val bars = DiffBars(0, 0) - // Match the patch header: title and target sit a standard md gap apart, so the bars - // indicator is separated from the "Modified N files" label by the same gap. - val panel: JComponent = Stack.horizontal(UiStyle.Gap.md()) - .next(Stack.horizontal(UiStyle.Gap.sm()).next(title).next(count)) - .next(bars) + val center = JPanel(BorderLayout(UiStyle.Gap.md(), 0)).apply { + isOpaque = false + minimumSize = Dimension(0, minimumSize.height) + add(Stack.horizontal(UiStyle.Gap.sm()).next(title).next(count), BorderLayout.WEST) + } + val controls: JComponent = Stack.horizontal().next(bars) + // Match edit/patch cards: glyph on the left, text in the center, and stats in the control slot. + val panel: JComponent = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.Layout.GAP), 0)).apply { + isOpaque = false + add(glyph, BorderLayout.WEST) + add(center, BorderLayout.CENTER) + add(controls, BorderLayout.EAST) + } @RequiresEdt fun update(total: Int, additions: Int, deletions: Int) { @@ -137,10 +155,12 @@ class ModifiedFilesView private constructor( @RequiresEdt fun applyStyle(style: SessionEditorStyle) { - title.font = style.boldEditorFont - count.font = style.transcriptFont - title.foreground = UiStyle.Colors.fg() - count.foreground = UiStyle.Colors.weak() + setIcon(glyph, SessionViewIcons.edit) + setForeground(glyph, SessionUiStyle.View.Tool.completed()) + setFont(title, style.boldEditorFont) + setFont(count, style.transcriptFont) + setForeground(title, UiStyle.Colors.fg()) + setForeground(count, UiStyle.Colors.weak()) } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/SessionViewIcons.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/SessionViewIcons.kt index 6eb68f643b..bb382b2242 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/SessionViewIcons.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/SessionViewIcons.kt @@ -15,6 +15,7 @@ object SessionViewIcons { val chevronExpanded: Icon = chevronDown val code = icon("code") val codeLines = icon("code-lines") + val edit = codeLines val console = icon("console") val eye = icon("eye") val glasses = icon("glasses") diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolSupport.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolSupport.kt index cbc8bf0975..4ba944fc88 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolSupport.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolSupport.kt @@ -476,7 +476,7 @@ internal fun icon(tool: Tool) = when (tool.name) { "codesearch" -> SessionViewIcons.code "task" -> SessionViewIcons.task "bash" -> SessionViewIcons.console - "edit", "write", "apply_patch" -> SessionViewIcons.codeLines + "edit", "write", "apply_patch" -> SessionViewIcons.edit "todowrite", "todoread" -> SessionViewIcons.checklist "question" -> SessionViewIcons.bubble "skill" -> SessionViewIcons.brain diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ModifiedFilesViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ModifiedFilesViewTest.kt index 5204ddb2c3..544eeb328f 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ModifiedFilesViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ModifiedFilesViewTest.kt @@ -1,5 +1,6 @@ package ai.kilocode.client.session.ui +import ai.kilocode.client.session.views.SessionViewIcons import ai.kilocode.client.ui.DiffStatBadge import ai.kilocode.rpc.dto.DiffFileDto import com.intellij.openapi.editor.EditorFactory @@ -35,6 +36,12 @@ class ModifiedFilesViewTest : BasePlatformTestCase() { assertEquals("1 file", view.countText()) } + fun `test header uses edit icon`() { + val labels = components(view).filterIsInstance() + + assertTrue(labels.any { it.icon === SessionViewIcons.edit }) + } + fun `test expand renders one link and badge per file`() { val opened = mutableListOf() Disposer.dispose(view) From 3a400993b11a19c1477765a3a22c3147fe2ec2de Mon Sep 17 00:00:00 2001 From: kirillk Date: Mon, 27 Jul 2026 19:43:58 -0400 Subject: [PATCH 03/28] fix(jetbrains): correct transcript scroll follow --- .../client/session/scroll/SessionScroll.kt | 2 +- .../kilocode/client/session/views/TurnView.kt | 1 + .../client/session/SessionScrollTest.kt | 87 ++++++++++++++++++- 3 files changed, 85 insertions(+), 5 deletions(-) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/scroll/SessionScroll.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/scroll/SessionScroll.kt index d9e1cd8450..cf7d6d4360 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/scroll/SessionScroll.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/scroll/SessionScroll.kt @@ -183,7 +183,7 @@ internal class SessionScroll( } finally { auto = false } - tail = atBottom() + tail = near() syncValue() updateJump() if (tail) { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TurnView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TurnView.kt index dc151fc15c..0f06df9173 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TurnView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TurnView.kt @@ -78,6 +78,7 @@ class TurnView( @RequiresEdt fun setDiffs(diffs: List) { val card = modified ?: if (diffs.isEmpty()) null else ModifiedFilesView(openFile, selection).also { + it.resize = resize it.hover = hover it.applyStyle(style) modified = it diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionScrollTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionScrollTest.kt index 7d0a6ebe4f..70d9ffeec8 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionScrollTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionScrollTest.kt @@ -1,10 +1,17 @@ package ai.kilocode.client.session +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.session.ui.ModifiedFilesView import ai.kilocode.client.session.ui.SessionMessageListPanel +import ai.kilocode.client.session.ui.prompt.PromptPanel import ai.kilocode.client.session.ui.selection.SessionCopyTarget import ai.kilocode.client.session.ui.style.SessionUiStyle +import ai.kilocode.client.session.views.tool.ShellToolView +import ai.kilocode.client.session.views.tool.ToolView import ai.kilocode.rpc.dto.ChatEventDto +import ai.kilocode.rpc.dto.DiffFileDto import ai.kilocode.rpc.dto.MessageErrorDto +import ai.kilocode.rpc.dto.MessageSummaryDto import ai.kilocode.rpc.dto.MessageWithPartsDto import ai.kilocode.rpc.dto.PermissionRequestDto import ai.kilocode.rpc.dto.PartDto @@ -14,10 +21,6 @@ import ai.kilocode.rpc.dto.QuestionRequestDto import ai.kilocode.rpc.dto.SessionRevertDto import ai.kilocode.rpc.dto.SessionStatusDto import ai.kilocode.rpc.dto.ToolRefDto -import ai.kilocode.client.session.ui.prompt.PromptPanel -import ai.kilocode.client.session.views.tool.ShellToolView -import ai.kilocode.client.session.views.tool.ToolView -import ai.kilocode.client.plugin.KiloBundle import com.intellij.ui.EditorTextField import com.intellij.ui.components.JBScrollPane import com.intellij.ui.components.JBRadioButton @@ -345,6 +348,57 @@ class SessionScrollTest : SessionUiTestBase() { assertEquals(value, bar.value) } + fun `test expanding modified files at bottom preserves clicked header position`() { + val mid = "modified_expand_bottom" + val pid = "modified_expand_bottom_part" + rpc.history.addAll(history(23) + modifiedHistory(mid, pid) + historyRange(1, start = 23)) + ui = newUi(id = "ses_test") + settle() + drainScroll() + val bar = scrollBar() + setBottom(bar) + drainScroll() + val view = modifiedView() + assertFalse(view.bodyVisible()) + val y = visibleY(view) + val value = bar.value + + view.toggle() + drainScroll() + + assertTrue(view.bodyVisible()) + assertEquals(y, visibleY(view)) + assertEquals(value, bar.value) + } + + fun `test preserve re-enables tail when viewport is near bottom`() { + showMessages() + fillTranscript(24) + val bar = scrollBar() + val messages = find(ui) + setBottom(bar) + drainScroll() + val anchor = messages.components.filterIsInstance().first() + + val value = bottom(bar) - JBUI.scale(16) + setValue(bar, value) + drainScroll() + assertEquals(value, bar.value) + assertFalse(ui.scroll.following()) + assertTrue(jumpButton().isVisible) + + ui.scroll.preserve(anchor) {} + drainScroll() + + assertFalse(jumpButton().isVisible) + assertTrue(ui.scroll.following()) + + emit(ChatEventDto.MessageUpdated("ses_test", message("preserve_shrink_tail"))) + drainScroll() + + assertBottom(bar) + } + fun `test expanding tool in middle preserves clicked header position`() { val mid = "tool_expand_middle" val pid = "tool_expand_middle_part" @@ -1149,6 +1203,12 @@ class SessionScrollTest : SessionUiTestBase() { ?: error("missing tool $mid/$pid\n${messages.dumpDetailed()}") } + private fun modifiedView(): ModifiedFilesView { + val messages = find(ui) + return findAll(messages).singleOrNull() + ?: error("missing modified files card\n${messages.dumpDetailed()}") + } + private fun bodyVisible(view: JComponent): Boolean = when (view) { is ShellToolView -> view.bodyVisible() is ToolView -> view.bodyVisible() @@ -1278,6 +1338,25 @@ class SessionScrollTest : SessionUiTestBase() { listOf(toolPart(pid, mid)), ) + private fun modifiedHistory(mid: String, pid: String) = MessageWithPartsDto( + message(mid).copy(summary = MessageSummaryDto(listOf(modifiedFile()))), + listOf(part(pid, mid, "text", text(0))), + ) + + private fun modifiedFile() = DiffFileDto( + file = "src/Changed.kt", + additions = 80, + deletions = 80, + patch = buildString { + appendLine("diff --git a/src/Changed.kt b/src/Changed.kt") + appendLine("--- a/src/Changed.kt") + appendLine("+++ b/src/Changed.kt") + appendLine("@@ -1,80 +1,80 @@") + repeat(80) { i -> appendLine("-old line $i") } + repeat(80) { i -> appendLine("+new line $i") } + }, + ) + private fun historyRange(count: Int, start: Int) = List(count) { offset -> val i = start + offset val id = "hist_range_$i" From 6d74bcf68ea437717c7f02c94e80e91a313bccd9 Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 28 Jul 2026 13:32:57 -0400 Subject: [PATCH 04/28] fix(jetbrains): queue multiple pending permissions The session UI tracked only one permission at a time, so when the CLI asked for several permissions in one turn the last PermissionAsked overwrote the previous one and replying it dropped the session to Busy while the earlier permission stayed pending forever. Maintain a controller-owned FIFO queue keyed by permission id. The model still holds one active permission; advancing is driven by the authoritative PermissionReplied event so it behaves the same whether the reply came from this client, another client, or auto-approve. Recovery and child-session permissions seed the same queue in FIFO order, and the queue is cleared on abort, auto-approve enable, and subscription reset. --- .changeset/jetbrains-permission-queue.md | 5 + .../session/controller/SessionController.kt | 59 +++++++-- .../session/controller/PermissionQueueTest.kt | 119 ++++++++++++++++++ 3 files changed, 173 insertions(+), 10 deletions(-) create mode 100644 .changeset/jetbrains-permission-queue.md create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PermissionQueueTest.kt diff --git a/.changeset/jetbrains-permission-queue.md b/.changeset/jetbrains-permission-queue.md new file mode 100644 index 0000000000..a9fab8d8c5 --- /dev/null +++ b/.changeset/jetbrains-permission-queue.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Handle multiple pending permissions in JetBrains sessions without getting stuck. diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt index 0570d4bd36..8dc3e5ac1b 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt @@ -162,6 +162,7 @@ class SessionController( // then reconciled when the operation releases so an underlying server turn is not lost. private var revertDeferred: SessionState? = null private var creating: CompletableDeferred? = null + private val pending = LinkedHashMap() private val childJobs: MutableMap = mutableMapOf() private val childIds: MutableSet = mutableSetOf() private val childParts: MutableMap = mutableMapOf() @@ -363,6 +364,7 @@ class SessionController( return } val id = sid ?: return + pending.clear() capture("Session Stop Clicked", sessionProps(id)) cs.launch { try { @@ -392,6 +394,7 @@ class SessionController( } else { emptySet() } + pending.clear() drainAutoApprove(skip) } @@ -765,6 +768,12 @@ class SessionController( private fun updatePermission(id: String, state: PermissionRequestState, message: String? = null) { assertEdt() + pending[id]?.let { perm -> + pending[id] = perm.copy( + state = state, + message = message ?: perm.message, + ) + } val current = model.state if (current !is SessionState.AwaitingPermission) return if (current.permission.id != id) return @@ -1128,6 +1137,7 @@ class SessionController( childJobs.clear() childIds.clear() childParts.clear() + pending.clear() } private suspend fun recoverChildPermissions(child: String) { @@ -1139,13 +1149,14 @@ class SessionController( replyAll(permissions) return } - val last = toPermission(permissions.last()) + val items = permissions.map(::toPermission) runEdt { if (disposed) return@runEdt if (child !in childIds) return@runEdt - // Do not overwrite an existing root or other child AwaitingPermission state - if (model.state is SessionState.AwaitingPermission) return@runEdt - updateModel { model.setState(SessionState.AwaitingPermission(last)) } + items.forEach(::enqueue) + if (model.state !is SessionState.AwaitingPermission && model.state !is SessionState.AwaitingQuestion) { + updateModel { promote() } + } } } catch (e: Exception) { LOG.warn("${ChatLogSummary.sid(sid ?: "pending")} kind=child-recovery child=$child dir=${ChatLogSummary.dir(directory)} failed message=${e.message}", e) @@ -1204,8 +1215,10 @@ class SessionController( if (disposed) return@runEdt if (sid != id) return@runEdt updateModel { + pending.entries.removeIf { it.value.sessionId == id } if (permissions.isNotEmpty()) { - model.setState(SessionState.AwaitingPermission(toPermission(permissions.last()))) + permissions.map(::toPermission).forEach(::enqueue) + promote() } else if (questions.isNotEmpty()) { model.setState(SessionState.AwaitingQuestion(toQuestion(questions.last()))) } else if (status != null) { @@ -1315,6 +1328,7 @@ class SessionController( // Keep pending questions visible for follow-up flows that arrive just before close. val current = model.state if (current is SessionState.AwaitingQuestion) return + if (current is SessionState.AwaitingPermission) return val clobberOk = event.reason == "completed" || current is SessionState.Busy || current is SessionState.Retry @@ -1455,14 +1469,25 @@ class SessionController( return } val perm = toPermission(event.request) - model.setState(SessionState.AwaitingPermission(perm)) + enqueue(perm) + if (model.state !is SessionState.AwaitingPermission && model.state !is SessionState.AwaitingQuestion) { + promote() + } } private fun replied(event: ChatEventDto.PermissionReplied) { val current = model.state - if (current is SessionState.AwaitingPermission && current.permission.id == event.requestID) { - model.setState(SessionState.Busy(KiloBundle.message("session.status.considering"))) + val front = current is SessionState.AwaitingPermission && current.permission.id == event.requestID + pending.remove(event.requestID) + // Front card resolved: advance to the next queued permission, else resume Busy. + if (front) { + model.setState(afterResolve()) + return } + // A queued (non-front) permission or an unrelated prompt is active: leave it in place. + if (current is SessionState.AwaitingPermission || current is SessionState.AwaitingQuestion) return + // Otherwise (busy/idle/etc.) only surface a still-queued permission; never force Busy. + promote() } private fun asked(event: ChatEventDto.QuestionAsked) { @@ -1472,17 +1497,31 @@ class SessionController( private fun replied(event: ChatEventDto.QuestionReplied) { val current = model.state if (current is SessionState.AwaitingQuestion && current.question.id == event.requestID) { - model.setState(SessionState.Busy(KiloBundle.message("session.status.considering"))) + model.setState(afterResolve()) } } private fun rejected(event: ChatEventDto.QuestionRejected) { val current = model.state if (current is SessionState.AwaitingQuestion && current.question.id == event.requestID) { - model.setState(SessionState.Idle) + model.setState(afterResolve(idle = true)) } } + private fun afterResolve(idle: Boolean = false): SessionState { + return pending.values.firstOrNull()?.let { SessionState.AwaitingPermission(it) } + ?: if (idle) SessionState.Idle else SessionState.Busy(KiloBundle.message("session.status.considering")) + } + + private fun enqueue(perm: Permission) { + pending[perm.id] = perm + } + + private fun promote() { + val perm = pending.values.firstOrNull() ?: return + model.setState(SessionState.AwaitingPermission(perm)) + } + private fun status(dto: SessionStatusDto) { if (revertOp != null) { revertDeferred = when (dto.type) { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PermissionQueueTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PermissionQueueTest.kt new file mode 100644 index 0000000000..dfa6eb0594 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PermissionQueueTest.kt @@ -0,0 +1,119 @@ +package ai.kilocode.client.session.controller + +import ai.kilocode.client.plugin.KiloPluginSettings +import ai.kilocode.client.session.model.SessionState +import ai.kilocode.rpc.dto.ChatEventDto +import ai.kilocode.rpc.dto.ConfigDto +import ai.kilocode.rpc.dto.KiloAppStateDto +import ai.kilocode.rpc.dto.KiloAppStatusDto +import ai.kilocode.rpc.dto.PermissionRequestDto +import ai.kilocode.rpc.dto.QuestionInfoDto +import ai.kilocode.rpc.dto.QuestionReplyDto +import ai.kilocode.rpc.dto.QuestionRequestDto + +class PermissionQueueTest : SessionControllerTestBase() { + + override fun setUp() { + super.setUp() + edt { KiloPluginSettings.unsetAutoApprove() } + } + + fun `test two permissions advance in FIFO order`() { + val (m, _, _) = prompted() + + emit(ChatEventDto.PermissionAsked("ses_test", permission("perm1"))) + emit(ChatEventDto.PermissionAsked("ses_test", permission("perm2"))) + + assertPermission(m, "perm1") + + emit(ChatEventDto.PermissionReplied("ses_test", "perm1")) + assertPermission(m, "perm2") + + emit(ChatEventDto.PermissionReplied("ses_test", "perm2")) + assertTrue(m.model.state is SessionState.Busy) + } + + fun `test duplicate permission ask does not reset active card`() { + val (m, _, events) = prompted() + + emit(ChatEventDto.PermissionAsked("ses_test", permission("perm1", "edit"))) + events.clear() + emit(ChatEventDto.PermissionAsked("ses_test", permission("perm1", "read"))) + + assertPermission(m, "perm1", "edit") + assertModelEvents("", events) + } + + fun `test non-front resolution leaves active permission shown`() { + val (m, _, _) = prompted() + + emit(ChatEventDto.PermissionAsked("ses_test", permission("perm1"))) + emit(ChatEventDto.PermissionAsked("ses_test", permission("perm2"))) + emit(ChatEventDto.PermissionReplied("ses_test", "perm2")) + + assertPermission(m, "perm1") + + emit(ChatEventDto.PermissionReplied("ses_test", "perm1")) + assertTrue(m.model.state is SessionState.Busy) + } + + fun `test recovered permissions advance in FIFO order`() { + rpc.pendingPermissionList.add(permission("perm1")) + rpc.pendingPermissionList.add(permission("perm2")) + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY, config = ConfigDto(model = "kilo/gpt-5")) + projectRpc.state.value = workspaceReady() + + val m = controller("ses_test") + flush() + + assertPermission(m, "perm1") + + emit(ChatEventDto.PermissionReplied("ses_test", "perm1")) + assertPermission(m, "perm2") + + emit(ChatEventDto.PermissionReplied("ses_test", "perm2")) + assertTrue(m.model.state is SessionState.Busy) + } + + fun `test late permission reply while idle does not force busy`() { + val (m, _, _) = prompted() + + emit(ChatEventDto.PermissionReplied("ses_test", "perm_gone")) + + assertTrue(m.model.state is SessionState.Idle) + } + + fun `test replying active question shows queued permission`() { + val (m, _, _) = prompted() + + emit(ChatEventDto.QuestionAsked("ses_test", question("q1"))) + emit(ChatEventDto.PermissionAsked("ses_test", permission("perm1"))) + + assertTrue(m.model.state is SessionState.AwaitingQuestion) + + edt { m.replyQuestion("q1", QuestionReplyDto(listOf(listOf("A")))) } + emit(ChatEventDto.QuestionReplied("ses_test", "q1")) + + assertPermission(m, "perm1") + } + + private fun assertPermission(c: SessionController, id: String, name: String = "edit") { + val state = c.model.state as? SessionState.AwaitingPermission ?: error("Expected AwaitingPermission") + assertEquals(id, state.permission.id) + assertEquals(name, state.permission.name) + } + + private fun permission(id: String, name: String = "edit") = PermissionRequestDto( + id = id, + sessionID = "ses_test", + permission = name, + patterns = listOf("*.kt"), + always = emptyList(), + ) + + private fun question(id: String) = QuestionRequestDto( + id = id, + sessionID = "ses_test", + questions = listOf(QuestionInfoDto("Pick one", "Choice")), + ) +} From 3e1a55c3e4278a3eef06eabdb072416c380e737d Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 28 Jul 2026 13:46:24 -0400 Subject: [PATCH 05/28] fix: resolve post-merge typecheck errors --- bun.lock | 24 ++++++++++++------------ packages/opencode/src/mcp/catalog.ts | 11 +++++++++-- packages/ui/src/components/file.tsx | 1 - packages/ui/src/pierre/virtualizer.ts | 1 - 4 files changed, 21 insertions(+), 16 deletions(-) diff --git a/bun.lock b/bun.lock index fd599605c4..45f3304ca3 100644 --- a/bun.lock +++ b/bun.lock @@ -305,7 +305,7 @@ }, "packages/kilo-jetbrains": { "name": "@kilocode/kilo-jetbrains", - "version": "7.4.15", + "version": "7.4.16", }, "packages/kilo-memory": { "name": "@kilocode/kilo-memory", @@ -845,22 +845,22 @@ }, }, "trustedDependencies": [ - "web-tree-sitter", "esbuild", - "tree-sitter-bash", "protobufjs", + "web-tree-sitter", + "tree-sitter-bash", ], "patchedDependencies": { - "virtua@0.49.1": "patches/virtua@0.49.1.patch", - "mammoth@1.12.0": "patches/mammoth@1.12.0.patch", - "@ff-labs/fff-bun@0.9.4": "patches/@ff-labs%2Ffff-bun@0.9.4.patch", - "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", - "@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch", - "@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch", - "pacote@21.5.1": "patches/pacote@21.5.1.patch", - "@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch", - "@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch", "@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch", + "@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch", + "@ff-labs/fff-bun@0.9.4": "patches/@ff-labs%2Ffff-bun@0.9.4.patch", + "pacote@21.5.1": "patches/pacote@21.5.1.patch", + "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", + "@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch", + "@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch", + "virtua@0.49.1": "patches/virtua@0.49.1.patch", + "@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch", + "mammoth@1.12.0": "patches/mammoth@1.12.0.patch", }, "overrides": { "@effect/platform-node-shared": "4.0.0-beta.74", diff --git a/packages/opencode/src/mcp/catalog.ts b/packages/opencode/src/mcp/catalog.ts index 3a1d9755d2..ebf07ba5e1 100644 --- a/packages/opencode/src/mcp/catalog.ts +++ b/packages/opencode/src/mcp/catalog.ts @@ -65,13 +65,20 @@ export function convertTool(mcpTool: MCPToolDef, client: Client, timeout?: numbe onprogress: () => {}, }, ) + // kilocode_change start - tolerate unknown MCP error content shape + const content = Array.isArray(result.content) ? result.content : [] if (result.isError) throw new Error( - result.content - .flatMap((item) => (item.type === "text" ? [item.text] : [])) + content + .flatMap((item): string[] => { + if (typeof item !== "object" || item === null) return [] + const part = item as { type?: unknown; text?: unknown } + return part.type === "text" && typeof part.text === "string" ? [part.text] : [] + }) .filter((text) => text.trim()) .join("\n\n") || "MCP tool returned an error", ) + // kilocode_change end if (result.structuredContent === undefined || result.structuredContent === null) return result return { ...result, diff --git a/packages/ui/src/components/file.tsx b/packages/ui/src/components/file.tsx index 8c8096375a..fb9a5b91cf 100644 --- a/packages/ui/src/components/file.tsx +++ b/packages/ui/src/components/file.tsx @@ -52,7 +52,6 @@ const VIRTUALIZE_BYTES = 500_000 const codeMetrics = { ...DEFAULT_VIRTUAL_FILE_METRICS, lineHeight: 24, - spacing: 0, } satisfies Partial type SharedProps = { diff --git a/packages/ui/src/pierre/virtualizer.ts b/packages/ui/src/pierre/virtualizer.ts index 235a3fd677..369d36eab5 100644 --- a/packages/ui/src/pierre/virtualizer.ts +++ b/packages/ui/src/pierre/virtualizer.ts @@ -16,7 +16,6 @@ const cache = new WeakMap() export const virtualMetrics: Partial = { lineHeight: 24, hunkSeparatorHeight: 24, - spacing: 0, } function scrollable(value: string) { From 64036252cdce90452c1bc4109f1aafc0dee07718 Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 28 Jul 2026 16:57:39 -0400 Subject: [PATCH 06/28] feat(jetbrains): add branch diff editor Show JetBrains session changes against the base branch after restart by wiring the diff editor entry point and matching VS Code diff semantics for uncommitted and untracked files. --- .changeset/jetbrains-branch-diff-editor.md | 5 + .../backend/rpc/KiloSessionRpcApiImpl.kt | 4 + .../backend/rpc/KiloWorkspaceRpcApiImpl.kt | 131 ++++++++++++++ .../backend/rpc/BranchDiffBuildTest.kt | 93 ++++++++++ .../kilocode/client/app/KiloSessionService.kt | 10 ++ .../client/app/KiloWorkspaceService.kt | 12 ++ .../ai/kilocode/client/diff/DiffBlocks.kt | 28 +++ .../client/diff/DiffPatchReconstruct.kt | 49 ++++++ .../client/diff/KiloDiffEditorContent.kt | 133 ++++++++++++++ .../client/diff/KiloDiffEditorKind.kt | 164 ++++++++++++++++++ .../ai/kilocode/client/session/SessionUi.kt | 12 +- .../session/ui/header/SessionHeaderPanel.kt | 13 ++ .../client/vfs/KiloFileEditorProvider.kt | 3 + .../resources/messages/KiloBundle.properties | 5 + .../client/diff/DiffPatchReconstructTest.kt | 111 ++++++++++++ .../ui/header/SessionHeaderPanelTest.kt | 23 +++ .../client/testing/FakeSessionRpcApi.kt | 7 + .../client/testing/FakeWorkspaceRpcApi.kt | 7 + .../ai/kilocode/rpc/KiloSessionRpcApi.kt | 4 + .../ai/kilocode/rpc/KiloWorkspaceRpcApi.kt | 4 + 20 files changed, 817 insertions(+), 1 deletion(-) create mode 100644 .changeset/jetbrains-branch-diff-editor.md create mode 100644 packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/BranchDiffBuildTest.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/DiffBlocks.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/DiffPatchReconstruct.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloDiffEditorContent.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloDiffEditorKind.kt create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/DiffPatchReconstructTest.kt diff --git a/.changeset/jetbrains-branch-diff-editor.md b/.changeset/jetbrains-branch-diff-editor.md new file mode 100644 index 0000000000..c0e725c0ad --- /dev/null +++ b/.changeset/jetbrains-branch-diff-editor.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": minor +--- + +Support viewing JetBrains session changes against the base branch, including uncommitted and untracked files. diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt index 597404c665..31c5da49d9 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt @@ -11,6 +11,7 @@ import ai.kilocode.rpc.KiloSessionRpcApi import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.CloudSessionListDto import ai.kilocode.rpc.dto.ConfigUpdateDto +import ai.kilocode.rpc.dto.DiffFileDto import ai.kilocode.rpc.dto.MessageWithPartsDto import ai.kilocode.rpc.dto.ModelSelectionDto import ai.kilocode.rpc.dto.PermissionAlwaysRulesDto @@ -141,6 +142,9 @@ class KiloSessionRpcApiImpl internal constructor( override suspend fun messages(id: String, directory: String): List = ready { chat.messages(id, directory) } + override suspend fun diff(id: String, directory: String): List = + ready { chat.messages(id, directory).flatMap { it.info.summary?.diffs.orEmpty() } } + override suspend fun attachmentPart(id: String, directory: String, messageId: String, partId: String, attachmentKey: String?): PartDto? = ready { chat.attachmentPart(id, directory, messageId, partId, attachmentKey) } diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt index af986d0769..4e75d23119 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt @@ -15,6 +15,7 @@ import ai.kilocode.jetbrains.api.model.Agent import ai.kilocode.rpc.KiloWorkspaceRpcApi import ai.kilocode.rpc.isManagedWorktreeStorage import ai.kilocode.rpc.dto.ConfigTargetDto +import ai.kilocode.rpc.dto.DiffFileDto import ai.kilocode.rpc.dto.FileSearchResultDto import ai.kilocode.rpc.dto.KiloWorkspaceStateDto import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto @@ -55,6 +56,9 @@ import java.nio.charset.StandardCharsets import java.nio.file.Files import java.nio.file.InvalidPathException import java.nio.file.Path +import kotlin.io.path.fileSize +import kotlin.io.path.isRegularFile +import kotlin.io.path.readBytes import java.util.concurrent.ConcurrentHashMap import kotlin.coroutines.resume @@ -76,6 +80,7 @@ class KiloWorkspaceRpcApiImpl internal constructor( private val GLOBAL = MODERN + LEGACY + "config.json" private val LOCAL_DIRS = listOf(".kilo", ".kilocode", ".opencode") private const val DIFF_CAP = 200_000 + private const val LARGE_FILE = 2 * 1024 * 1024L private val JSON = Json { ignoreUnknownKeys = true } private val CONFIG = """{ "${'$'}schema": "$SCHEMA" @@ -248,6 +253,21 @@ class KiloWorkspaceRpcApiImpl internal constructor( text.takeIf { it.isNotBlank() }?.take(DIFF_CAP) } + override suspend fun branchDiff(directory: String): List = withContext(Dispatchers.IO) { + val base = file(clean(directory) ?: directory) ?: return@withContext emptyList() + if (!gitAvailable(base)) return@withContext emptyList() + val ref = defaultBranch(base) + val anc = ref?.let { git(base, "merge-base", it, "HEAD").trim().ifBlank { null } } ?: "HEAD" + val numstat = git(base, "-c", "core.quotepath=false", "diff", "--numstat", "--no-color", "--no-renames", anc) + val patch = git(base, "-c", "core.quotepath=false", "diff", "--no-color", "--no-ext-diff", "--no-renames", "--unified=2147483647", anc) + val untracked = git(base, "-c", "core.quotepath=false", "ls-files", "--others", "--exclude-standard") + .lineSequence() + .filter { it.isNotBlank() } + .map { untracked(base, it) } + .toList() + buildBranchDiff(numstat, patch, untracked, DIFF_CAP) + } + override suspend fun openFile(path: String, line: Int?, column: Int?): Boolean { val item = clean(path) ?: return false val target = file(item)?.takeIf { it.isAbsolute } ?: return false @@ -368,6 +388,25 @@ class KiloWorkspaceRpcApiImpl internal constructor( return runWorkspaceGit(base, *args) } + private fun defaultBranch(base: Path): String? = listOf("main", "master").firstOrNull { ref -> + git(base, "rev-parse", "--verify", ref).isNotBlank() + } + + private fun untracked(base: Path, rel: String): DiffFileDto { + return runCatching { + val path = base.resolve(rel).normalize() + if (!path.startsWith(base) || !path.isRegularFile() || path.fileSize() > LARGE_FILE) return@runCatching DiffFileDto(rel, 0, 0, "") + val bytes = path.readBytes() + if (bytes.any { it == 0.toByte() }) return@runCatching DiffFileDto(rel, 0, 0, "") + val text = bytes.toString(StandardCharsets.UTF_8) + val additions = lines(text).size + DiffFileDto(rel, additions, 0, untrackedPatch(rel, text, additions)) + }.getOrElse { err -> + LOG.debug { "Failed to read untracked file for branch diff: $rel (${err.message})" } + DiffFileDto(rel, 0, 0, "") + } + } + private fun agent(a: Agent) = AgentInfo( name = a.name, displayName = a.displayName, @@ -427,6 +466,98 @@ internal fun resolveProjectDirectoryHint(hint: String, bases: List): Str return bases.firstOrNull() ?: hint } +internal fun buildBranchDiff( + numstat: String, + patch: String, + untracked: List = emptyList(), + cap: Int = 200_000, +): List { + val stats = parseNumstat(numstat) + if (stats.isEmpty() && untracked.isEmpty()) return emptyList() + val patches = splitGitPatch(patch, stats.map { it.path }) + var used = 0 + val tracked = stats.map { stat -> + val text = patches[stat.path].orEmpty() + val next = if (text.isNotBlank() && used + text.length <= cap) { + used += text.length + text + } else { + "" + } + DiffFileDto( + file = stat.path, + additions = stat.additions, + deletions = stat.deletions, + patch = next, + ) + } + return tracked + untracked.map { file -> + val text = file.patch.orEmpty() + val next = if (text.isNotBlank() && used + text.length <= cap) { + used += text.length + text + } else { + "" + } + file.copy(patch = next) + } +} + +private fun untrackedPatch(path: String, text: String, additions: Int): String = buildString { + appendLine("diff --git a/$path b/$path") + appendLine("new file mode 100644") + appendLine("--- /dev/null") + appendLine("+++ b/$path") + appendLine("@@ -0,0 +1,$additions @@") + lines(text).forEach { line -> appendLine("+$line") } + if (text.isNotEmpty() && !text.endsWith("\n")) appendLine("\\ No newline at end of file") +}.removeSuffix("\n") + +private fun lines(text: String): List { + if (text.isEmpty()) return emptyList() + return text.removeSuffix("\n").split('\n') +} + +private data class DiffStat(val path: String, val additions: Int, val deletions: Int) + +private fun parseNumstat(text: String): List = text.lineSequence() + .mapNotNull { line -> + val parts = line.split('\t') + if (parts.size < 3) return@mapNotNull null + val path = parts.drop(2).joinToString("\t").takeIf { it.isNotBlank() } ?: return@mapNotNull null + DiffStat(path, parts[0].toIntOrNull() ?: 0, parts[1].toIntOrNull() ?: 0) + } + .toList() + +private fun splitGitPatch(text: String, paths: List): Map { + val ordered = paths.sortedByDescending { it.length } + val map = linkedMapOf() + var current: String? = null + val lines = mutableListOf() + fun flush() { + val path = current + if (path != null && lines.isNotEmpty()) map[path] = lines.joinToString("\n") + current = null + lines.clear() + } + fun match(header: String): String? { + for (path in ordered) { + if (header.endsWith(" b/$path") && header.contains(" a/$path ")) return path + if (header.endsWith(" \"b/$path\"") && header.contains(" \"a/$path\" ")) return path + } + return null + } + for (line in text.split('\n')) { + if (line.startsWith("diff --git ")) { + flush() + current = match(line) + } + if (current != null) lines.add(line) + } + flush() + return map +} + internal fun workspaceGitAvailable(base: Path, cache: ConcurrentHashMap = ConcurrentHashMap()): Boolean { if (Files.exists(base.resolve(".git"))) return true return cache.getOrPut(base.toString()) { diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/BranchDiffBuildTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/BranchDiffBuildTest.kt new file mode 100644 index 0000000000..bfcab184cf --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/BranchDiffBuildTest.kt @@ -0,0 +1,93 @@ +package ai.kilocode.backend.rpc + +import kotlin.test.Test +import kotlin.test.assertEquals +import ai.kilocode.rpc.dto.DiffFileDto + +class BranchDiffBuildTest { + @Test + fun `builds ordered branch diff from git outputs`() { + val numstat = "1\t1\tsrc/A.kt\n2\t0\tsrc/B.kt\n" + val patch = """ + diff --git a/src/A.kt b/src/A.kt + index 111..222 100644 + --- a/src/A.kt + +++ b/src/A.kt + @@ -1 +1 @@ + -old + +new + diff --git a/src/B.kt b/src/B.kt + new file mode 100644 + --- /dev/null + +++ b/src/B.kt + @@ -0,0 +1,2 @@ + +one + +two + """.trimIndent() + + val diff = buildBranchDiff(numstat, patch) + + assertEquals(listOf("src/A.kt", "src/B.kt"), diff.map { it.file }) + assertEquals(1, diff[0].additions) + assertEquals(1, diff[0].deletions) + assertEquals(2, diff[1].additions) + assertEquals(0, diff[1].deletions) + assertEquals(true, diff[0].patch?.startsWith("diff --git a/src/A.kt") == true) + assertEquals(true, diff[1].patch?.startsWith("diff --git a/src/B.kt") == true) + } + + @Test + fun `blanks patches after cap`() { + val diff = buildBranchDiff( + numstat = "1\t0\ta.txt\n1\t0\tb.txt\n", + patch = """ + diff --git a/a.txt b/a.txt + --- /dev/null + +++ b/a.txt + @@ -0,0 +1 @@ + +a + diff --git a/b.txt b/b.txt + --- /dev/null + +++ b/b.txt + @@ -0,0 +1 @@ + +b + """.trimIndent(), + cap = 20, + ) + + assertEquals("", diff[0].patch) + assertEquals("", diff[1].patch) + } + + @Test + fun `appends untracked files after tracked files`() { + val diff = buildBranchDiff( + numstat = "1\t1\tsrc/A.kt\n", + patch = """ + diff --git a/src/A.kt b/src/A.kt + --- a/src/A.kt + +++ b/src/A.kt + @@ -1 +1 @@ + -old + +new + """.trimIndent(), + untracked = listOf(DiffFileDto("src/New.kt", 2, 0, "patch")), + ) + + assertEquals(listOf("src/A.kt", "src/New.kt"), diff.map { it.file }) + assertEquals(2, diff[1].additions) + assertEquals("patch", diff[1].patch) + } + + @Test + fun `untracked patches count toward cap`() { + val diff = buildBranchDiff( + numstat = "", + patch = "", + untracked = listOf(DiffFileDto("src/New.kt", 1, 0, "diff --git a/src/New.kt b/src/New.kt")), + cap = 5, + ) + + assertEquals("", diff.single().patch) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt index 1aef23518f..c6b74f5e40 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt @@ -8,6 +8,7 @@ import ai.kilocode.client.session.SessionActivityKind import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.CloudSessionListDto import ai.kilocode.rpc.dto.ConfigUpdateDto +import ai.kilocode.rpc.dto.DiffFileDto import ai.kilocode.rpc.dto.MessageWithPartsDto import ai.kilocode.rpc.dto.ModelSelectionDto import ai.kilocode.rpc.dto.PermissionAlwaysRulesDto @@ -214,6 +215,15 @@ class KiloSessionService internal constructor( call { messages(id, dir) } .also { log.debug { "${ChatLogSummary.sid(id)} ${ChatLogSummary.history(it)} ${ChatLogSummary.dir(dir)}" } } + suspend fun diff(id: String, dir: String): List = try { + call { diff(id, dir) } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + log.warn("${ChatLogSummary.sid(id)} kind=session-diff ${ChatLogSummary.dir(dir)} failed message=${e.message}", e) + emptyList() + } + suspend fun attachmentPart(id: String, dir: String, message: String, part: String, key: String?): PartDto? = call { attachmentPart(id, dir, message, part, key) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloWorkspaceService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloWorkspaceService.kt index cd7dae807d..133a28aa91 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloWorkspaceService.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloWorkspaceService.kt @@ -4,6 +4,7 @@ package ai.kilocode.client.app import ai.kilocode.rpc.KiloWorkspaceRpcApi import ai.kilocode.rpc.dto.ConfigTargetDto +import ai.kilocode.rpc.dto.DiffFileDto import ai.kilocode.rpc.dto.FileSearchResultDto import ai.kilocode.rpc.dto.KiloWorkspaceStateDto import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto @@ -159,6 +160,17 @@ class KiloWorkspaceService internal constructor( } } + suspend fun branchDiff(directory: String): List { + return try { + call { branchDiff(directory) } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + LOG.warn("branch diff lookup failed for directory=$directory", e) + emptyList() + } + } + suspend fun openPath(directory: String, path: String, line: Int? = null, column: Int? = null): Boolean { val match = files(directory, path).firstOrNull() ?: return false return try { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/DiffBlocks.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/DiffBlocks.kt new file mode 100644 index 0000000000..2d5c84351e --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/DiffBlocks.kt @@ -0,0 +1,28 @@ +package ai.kilocode.client.diff + +import ai.kilocode.rpc.dto.DiffFileDto +import com.intellij.diff.DiffContentFactory +import com.intellij.diff.requests.DiffRequest +import com.intellij.diff.requests.SimpleDiffRequest +import com.intellij.diff.util.DiffUserDataKeys +import com.intellij.openapi.fileTypes.FileTypeManager +import com.intellij.openapi.project.Project + +internal fun diffRequest(project: Project, dto: DiffFileDto): DiffRequest { + val sides = DiffPatchReconstruct.sides(dto) + val type = FileTypeManager.getInstance().getFileTypeByFileName(dto.file) + val factory = DiffContentFactory.getInstance() + val left = when { + DiffPatchReconstruct.added(dto.patch) -> factory.createEmpty() + sides.renderable -> factory.create(project, sides.before, type) + else -> factory.createEmpty() + } + val right = when { + DiffPatchReconstruct.deleted(dto.patch) -> factory.createEmpty() + sides.renderable -> factory.create(project, sides.after, type) + else -> factory.create(project, dto.patch ?: "diff unavailable", type) + } + return SimpleDiffRequest(dto.file, left, right, "Base", "Current").also { + it.putUserData(DiffUserDataKeys.FORCE_READ_ONLY, true) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/DiffPatchReconstruct.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/DiffPatchReconstruct.kt new file mode 100644 index 0000000000..ddbe088599 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/DiffPatchReconstruct.kt @@ -0,0 +1,49 @@ +package ai.kilocode.client.diff + +import ai.kilocode.rpc.dto.DiffFileDto + +internal data class DiffSides( + val before: String, + val after: String, + val renderable: Boolean, +) + +internal object DiffPatchReconstruct { + fun sides(dto: DiffFileDto): DiffSides { + val patch = dto.patch + if (patch.isNullOrBlank() || binary(patch)) return DiffSides("", "", false) + val before = StringBuilder() + val after = StringBuilder() + var hunk = false + for (line in patch.split('\n')) { + if (line.startsWith("@@")) { + hunk = true + continue + } + if (!hunk) continue + if (line.startsWith("\\")) continue + when (line.firstOrNull()) { + ' ' -> { + before.appendLine(line.substring(1)) + after.appendLine(line.substring(1)) + } + '-' -> before.appendLine(line.substring(1)) + '+' -> after.appendLine(line.substring(1)) + else -> { + before.appendLine("") + after.appendLine("") + } + } + } + if (!hunk) return DiffSides("", "", false) + val left = if (added(patch)) "" else before.toString().removeSuffix("\n") + val right = if (deleted(patch)) "" else after.toString().removeSuffix("\n") + return DiffSides(left, right, true) + } + + fun added(patch: String?): Boolean = patch?.lineSequence()?.any { it == "--- /dev/null" } == true + + fun deleted(patch: String?): Boolean = patch?.lineSequence()?.any { it == "+++ /dev/null" } == true + + private fun binary(patch: String): Boolean = patch.lineSequence().any { it.startsWith("Binary files ") } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloDiffEditorContent.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloDiffEditorContent.kt new file mode 100644 index 0000000000..d50785d233 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloDiffEditorContent.kt @@ -0,0 +1,133 @@ +package ai.kilocode.client.diff + +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.rpc.dto.DiffFileDto +import com.intellij.diff.DiffManager +import com.intellij.icons.AllIcons +import com.intellij.openapi.Disposable +import com.intellij.openapi.project.Project +import com.intellij.ui.ColoredTreeCellRenderer +import com.intellij.ui.OnePixelSplitter +import com.intellij.ui.SimpleTextAttributes +import com.intellij.ui.TreeSpeedSearch +import com.intellij.ui.components.JBScrollPane +import com.intellij.ui.treeStructure.Tree +import com.intellij.util.concurrency.annotations.RequiresEdt +import java.awt.BorderLayout +import javax.swing.JComponent +import javax.swing.JPanel +import javax.swing.JTree +import javax.swing.tree.DefaultMutableTreeNode +import javax.swing.tree.DefaultTreeModel +import javax.swing.tree.TreeNode +import javax.swing.tree.TreePath + +@RequiresEdt +internal fun buildDiffEditor(project: Project, files: List, parent: Disposable): JComponent { + val panel = DiffManager.getInstance().createRequestPanel(project, parent, null) + val tree = buildFileTree(files) + tree.addTreeSelectionListener { + val node = tree.lastSelectedPathComponent as? DefaultMutableTreeNode ?: return@addTreeSelectionListener + val file = (node.userObject as? Node)?.file ?: return@addTreeSelectionListener + panel.setRequest(diffRequest(project, file), file.file) + } + files.firstOrNull()?.let { + panel.setRequest(diffRequest(project, it), it.file) + selectTreeNode(tree, it.file) + } + + return OnePixelSplitter(false, 0.25f).apply { + firstComponent = JBScrollPane(tree) + secondComponent = panel.component + } +} + +@RequiresEdt +internal fun emptyChangesComponent(): JComponent = JPanel(BorderLayout()).apply { + add(com.intellij.ui.components.JBLabel(KiloBundle.message("diff.editor.empty")), BorderLayout.CENTER) +} + +private fun buildFileTree(files: List): Tree { + val root = DefaultMutableTreeNode(Node("", "", true, null)) + for (file in files) addFile(root, file) + val tree = Tree(DefaultTreeModel(root)).apply { + isRootVisible = false + showsRootHandles = true + cellRenderer = Renderer() + } + TreeSpeedSearch(tree) { path -> + val node = path.lastPathComponent as? DefaultMutableTreeNode + (node?.userObject as? Node)?.name.orEmpty() + } + for (i in 0 until tree.rowCount) tree.expandRow(i) + return tree +} + +private fun addFile(root: DefaultMutableTreeNode, file: DiffFileDto) { + var node = root + val parts = file.file.split('/').filter { it.isNotBlank() } + for ((index, part) in parts.withIndex()) { + val path = parts.take(index + 1).joinToString("/") + val leaf = index == parts.lastIndex + val child = child(node, path) ?: DefaultMutableTreeNode(Node(part, path, !leaf, if (leaf) file else null)).also(node::add) + node = child + } +} + +private fun child(node: DefaultMutableTreeNode, path: String): DefaultMutableTreeNode? { + for (i in 0 until node.childCount) { + val child = node.getChildAt(i) as? DefaultMutableTreeNode ?: continue + if ((child.userObject as? Node)?.path == path) return child + } + return null +} + +private fun selectTreeNode(tree: Tree, path: String) { + val node = find(tree.model.root as? DefaultMutableTreeNode ?: return, path) ?: return + val selection = TreePath(node.path) + tree.selectionPath = selection + tree.scrollPathToVisible(selection) +} + +private fun find(node: DefaultMutableTreeNode, path: String): DefaultMutableTreeNode? { + if ((node.userObject as? Node)?.path == path) return node + for (i in 0 until node.childCount) { + val found = find(node.getChildAt(i) as? DefaultMutableTreeNode ?: continue, path) + if (found != null) return found + } + return null +} + +private data class Node(val name: String, val path: String, val dir: Boolean, val file: DiffFileDto?) + +private class Renderer : ColoredTreeCellRenderer() { + override fun customizeCellRenderer( + tree: JTree, + value: Any?, + selected: Boolean, + expanded: Boolean, + leaf: Boolean, + row: Int, + hasFocus: Boolean, + ) { + val node = value as? DefaultMutableTreeNode ?: return + val item = node.userObject as? Node ?: return + icon = if (item.dir) AllIcons.Nodes.Folder else AllIcons.FileTypes.Text + val file = item.file + append(item.name.ifBlank { item.path }) + if (file != null) { + append(" -${file.deletions} +${file.additions}", SimpleTextAttributes.GRAYED_ATTRIBUTES) + } + } +} + +private val TreeNode.path: Array + get() { + val list = mutableListOf() + var node: TreeNode? = this + while (node != null) { + list += node + node = node.parent + } + return list.asReversed().toTypedArray() + } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloDiffEditorKind.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloDiffEditorKind.kt new file mode 100644 index 0000000000..b5d51d2894 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloDiffEditorKind.kt @@ -0,0 +1,164 @@ +package ai.kilocode.client.diff + +import ai.kilocode.client.app.KiloAppService +import ai.kilocode.client.app.KiloSessionService +import ai.kilocode.client.app.KiloWorkspaceService +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.ui.UiStyle +import ai.kilocode.client.ui.layout.Stack +import ai.kilocode.client.vfs.KiloEditorKind +import ai.kilocode.client.vfs.KiloEditorKindRegistry +import ai.kilocode.client.vfs.KiloVirtualFile +import ai.kilocode.log.KiloLog +import ai.kilocode.rpc.dto.DiffFileDto +import ai.kilocode.rpc.dto.KiloAppStatusDto +import com.intellij.openapi.Disposable +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.service +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.Disposer +import com.intellij.ui.AnimatedIcon +import com.intellij.ui.components.ActionLink +import com.intellij.ui.components.JBLabel +import com.intellij.util.concurrency.annotations.RequiresEdt +import com.intellij.util.ui.Centerizer +import com.intellij.util.ui.JBUI +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.awt.BorderLayout +import java.util.concurrent.atomic.AtomicBoolean +import javax.swing.JComponent +import javax.swing.JPanel + +internal object KiloDiffEditorKind : KiloEditorKind { + const val ID = "kilo-diff" + + override val id: String = ID + + override fun title(params: Map): String { + return params["title"].takeIfPresent() + ?: KiloBundle.message(if (params["source"] == "branch") "diff.editor.branch.title" else "diff.editor.session.title") + } + + override fun presentablePath(params: Map): String = title(params) + + override fun isValid(params: Map): Boolean { + val dir = params["directory"].takeIfPresent() ?: return false + if (dir.isBlank()) return false + if (params["source"] == "branch") return true + return params["sessionId"].takeIfPresent() != null + } + + @RequiresEdt + override fun createContent(project: Project, file: KiloVirtualFile, parent: Disposable): JComponent { + val panel = JPanel(BorderLayout()) + panel.add(connecting(), BorderLayout.CENTER) + project.service().load(file.path.params, parent) { data -> + panel.removeAll() + panel.add( + when (data) { + DiffEditorData.Connecting -> connecting() + DiffEditorData.Empty -> emptyChangesComponent() + is DiffEditorData.Error -> failed(data.message) + is DiffEditorData.Files -> buildDiffEditor(project, data.files, parent) + }, + BorderLayout.CENTER, + ) + panel.revalidate() + panel.repaint() + } + return panel + } +} + +@Service(Service.Level.PROJECT) +internal class KiloDiffEditorService( + private val project: Project, + private val cs: CoroutineScope, +) { + fun load(params: Map, parent: Disposable, done: (DiffEditorData) -> Unit) { + val disposed = AtomicBoolean(false) + val job = cs.launch { + val app = service() + app.connect() + withContext(Dispatchers.Main) { + if (alive(disposed)) done(DiffEditorData.Connecting) + } + val state = app.state.first { it.status == KiloAppStatusDto.READY || it.status == KiloAppStatusDto.ERROR } + if (state.status == KiloAppStatusDto.ERROR) { + withContext(Dispatchers.Main) { + if (alive(disposed)) done(DiffEditorData.Error(KiloBundle.message("session.connection.error.app"))) + } + return@launch + } + val data = runCatching { fetch(params) } + .getOrElse { + LOG.warn("diff editor load failed source=${params["source"]} dir=${params["directory"]}", it) + DiffEditorData.Error(it.message ?: it::class.java.simpleName) + } + withContext(Dispatchers.Main) { + if (alive(disposed)) done(data) + } + } + Disposer.register(parent) { + disposed.set(true) + job.cancel() + } + } + + private fun alive(disposed: AtomicBoolean): Boolean = !project.isDisposed && !disposed.get() + + private suspend fun fetch(params: Map): DiffEditorData { + val dir = params["directory"].takeIfPresent() ?: return DiffEditorData.Empty + val files = when (params["source"]) { + "branch" -> service().branchDiff(dir) + else -> project.service().diff(params["sessionId"].orEmpty(), dir) + } + if (files.isEmpty()) return DiffEditorData.Empty + return DiffEditorData.Files(files) + } + + private companion object { + private val LOG = KiloLog.create(KiloDiffEditorService::class.java) + } +} + +internal sealed interface DiffEditorData { + data object Connecting : DiffEditorData + data object Empty : DiffEditorData + data class Error(val message: String) : DiffEditorData + data class Files(val files: List) : DiffEditorData +} + +internal fun diffParams(source: String, directory: String, sessionId: String?, title: String): Map = + linkedMapOf( + "source" to source, + "directory" to directory, + "title" to title, + ).apply { + if (!sessionId.isNullOrBlank()) put("sessionId", sessionId) + } + +fun ensureDiffEditorKind() { + service().register(KiloDiffEditorKind) +} + +private fun connecting(): JComponent = Stack.horizontal(gap = UiStyle.Gap.sm()).apply { + border = JBUI.Borders.empty(UiStyle.Gap.pad()) + next(JBLabel(AnimatedIcon.Default())) + next(JBLabel(KiloBundle.message("session.connection.connecting"))) +}.let { Centerizer(it, Centerizer.TYPE.BOTH) } + +private fun failed(message: String): JComponent = Stack.horizontal(gap = UiStyle.Gap.sm()).apply { + border = JBUI.Borders.empty(UiStyle.Gap.pad()) + next(JBLabel(message)) + next(ActionLink(KiloBundle.message("session.connection.retry")) { + service().retryAsync() + }) +}.let { Centerizer(it, Centerizer.TYPE.BOTH) } + +private fun String?.takeIfPresent(): String? = takeIf { !it.isNullOrBlank() } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt index c59014686d..2b15c77faf 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt @@ -4,6 +4,9 @@ import ai.kilocode.client.app.KiloAppService import ai.kilocode.client.app.KiloSessionService import ai.kilocode.client.app.KiloWorkspaceService import ai.kilocode.client.app.Workspace +import ai.kilocode.client.diff.KiloDiffEditorKind +import ai.kilocode.client.diff.diffParams +import ai.kilocode.client.diff.ensureDiffEditorKind import ai.kilocode.client.migration.KiloMigrationService import ai.kilocode.client.migration.MigrationUiController import ai.kilocode.client.migration.MigrationUiState @@ -372,7 +375,14 @@ class SessionUi( ).also { it.onHover = { view, on -> if (on) popup.show(view) else popup.notifyExit(view) } } - header = SessionHeaderPanel(controller, this) + header = SessionHeaderPanel(controller, this) { + ensureDiffEditorKind() + project.service().open( + KiloDiffEditorKind.ID, + diffParams("branch", workspace.directory, null, KiloBundle.message("diff.editor.branch.title")), + ) + Telemetry.send("Diff Editor Opened", mapOf("source" to "branch")) + } scroll = SessionScroll(root, sessionContent, messageBody, blankBody) scroll.onScroll = { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanel.kt index 96d2a8e768..2dcbed8934 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanel.kt @@ -40,6 +40,7 @@ import javax.swing.SwingUtilities class SessionHeaderPanel( private val controller: SessionController, parent: Disposable, + onOpenBranchDiff: (() -> Unit)? = null, ) : BorderLayoutPanel(), SessionEditorStyleTarget { companion object { @@ -65,6 +66,14 @@ class SessionHeaderPanel( accessibleContext.accessibleName = KiloBundle.message("session.header.compact") addActionListener { controller.compact() } } + private val branch = HoverIcon().apply { + icon = AllIcons.Actions.Diff + cursor = java.awt.Cursor.getPredefinedCursor(java.awt.Cursor.HAND_CURSOR) + toolTipText = KiloBundle.message("diff.editor.branch.tooltip") + accessibleContext.accessibleName = KiloBundle.message("diff.editor.branch.tooltip") + isVisible = onOpenBranchDiff != null + addActionListener { onOpenBranchDiff?.invoke() } + } private val expand = JBLabel().apply { cursor = java.awt.Cursor.getPredefinedCursor(java.awt.Cursor.HAND_CURSOR) toolTipText = KiloBundle.message("session.header.expand") @@ -110,6 +119,8 @@ class SessionHeaderPanel( .gap(UiStyle.Gap.xl()) .next(context) .gap(UiStyle.Gap.sm()) + .next(branch) + .gap(UiStyle.Gap.sm()) .next(compact) private val tokens = JPanel(FlowLayout(FlowLayout.LEFT, 0, 0)).apply { isOpaque = false @@ -340,6 +351,8 @@ class SessionHeaderPanel( internal fun compactButton() = compact + internal fun branchDiffButton() = branch + internal fun expandButton() = expand internal fun isExpanded() = body.parent === this diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/vfs/KiloFileEditorProvider.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/vfs/KiloFileEditorProvider.kt index 1bcbbbcb27..5541a690b9 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/vfs/KiloFileEditorProvider.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/vfs/KiloFileEditorProvider.kt @@ -1,5 +1,6 @@ package ai.kilocode.client.vfs +import ai.kilocode.client.diff.ensureDiffEditorKind import ai.kilocode.client.session.ui.attachment.ensureAttachmentEditorKind import com.intellij.openapi.components.service import com.intellij.openapi.fileEditor.FileEditor @@ -13,6 +14,7 @@ import com.intellij.openapi.vfs.VirtualFile class KiloFileEditorProvider : FileEditorProvider, DumbAware { override fun accept(project: Project, file: VirtualFile): Boolean { ensureAttachmentEditorKind() + ensureDiffEditorKind() val path = path(file) ?: return false return service().get(path.kind) != null } @@ -21,6 +23,7 @@ class KiloFileEditorProvider : FileEditorProvider, DumbAware { override fun createEditor(project: Project, file: VirtualFile): FileEditor { ensureAttachmentEditorKind() + ensureDiffEditorKind() val path = path(file) ?: error("Invalid Kilo virtual file: ${file.path}") val kilo = file as? KiloVirtualFile ?: KiloVirtualFile(path) val kind = service().get(kilo.path.kind) ?: error("Unknown Kilo editor kind: ${kilo.path.kind}") diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties index 25735b1845..92a68936d1 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -126,6 +126,11 @@ session.part.compaction=context compacted session.changes.modified=Modified session.changes.count.one={0} file session.changes.count.other={0} files +diff.editor.session.title=Session Changes +diff.editor.branch.title=Changes vs base branch +diff.editor.branch.tooltip=Compare with base branch +diff.editor.session.tooltip=Open changes in editor +diff.editor.empty=No changes session.part.tool.copy=Copy session.part.tool.error=Error session.part.tool.agent={0} Agent diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/DiffPatchReconstructTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/DiffPatchReconstructTest.kt new file mode 100644 index 0000000000..2731f36d9d --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/DiffPatchReconstructTest.kt @@ -0,0 +1,111 @@ +package ai.kilocode.client.diff + +import ai.kilocode.rpc.dto.DiffFileDto +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class DiffPatchReconstructTest { + @Test + fun `reconstructs modified full context patch`() { + val dto = DiffFileDto( + file = "src/A.kt", + additions = 1, + deletions = 1, + patch = """ + diff --git a/src/A.kt b/src/A.kt + index 111..222 100644 + --- a/src/A.kt + +++ b/src/A.kt + @@ -1,3 +1,3 @@ + one + -two + +TWO + three + """.trimIndent(), + ) + + val sides = DiffPatchReconstruct.sides(dto) + + assertTrue(sides.renderable) + assertEquals("one\ntwo\nthree", sides.before) + assertEquals("one\nTWO\nthree", sides.after) + } + + @Test + fun `added file has empty before side`() { + val dto = DiffFileDto( + file = "src/A.kt", + additions = 2, + deletions = 0, + patch = """ + diff --git a/src/A.kt b/src/A.kt + --- /dev/null + +++ b/src/A.kt + @@ -0,0 +1,2 @@ + +one + +two + """.trimIndent(), + ) + + val sides = DiffPatchReconstruct.sides(dto) + + assertEquals("", sides.before) + assertEquals("one\ntwo", sides.after) + } + + @Test + fun `synthesized untracked patch renders as added file`() { + val dto = DiffFileDto( + file = "src/New.kt", + additions = 2, + deletions = 0, + patch = """ + diff --git a/src/New.kt b/src/New.kt + new file mode 100644 + --- /dev/null + +++ b/src/New.kt + @@ -0,0 +1,2 @@ + +one + +two + \ No newline at end of file + """.trimIndent(), + ) + + val sides = DiffPatchReconstruct.sides(dto) + + assertTrue(DiffPatchReconstruct.added(dto.patch)) + assertEquals("", sides.before) + assertEquals("one\ntwo", sides.after) + assertTrue(sides.renderable) + } + + @Test + fun `deleted file has empty after side`() { + val dto = DiffFileDto( + file = "src/A.kt", + additions = 0, + deletions = 2, + patch = """ + diff --git a/src/A.kt b/src/A.kt + --- a/src/A.kt + +++ /dev/null + @@ -1,2 +0,0 @@ + -one + -two + """.trimIndent(), + ) + + val sides = DiffPatchReconstruct.sides(dto) + + assertEquals("one\ntwo", sides.before) + assertEquals("", sides.after) + } + + @Test + fun `binary and blank patches are not renderable`() { + assertFalse(DiffPatchReconstruct.sides(DiffFileDto("a.bin", 0, 0, "Binary files a/a.bin and b/a.bin differ")).renderable) + assertFalse(DiffPatchReconstruct.sides(DiffFileDto("a.kt", 0, 0, "")).renderable) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanelTest.kt index a0402fb353..bbdc9c7237 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanelTest.kt @@ -1,5 +1,6 @@ package ai.kilocode.client.session.ui.header +import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.session.model.Reasoning import ai.kilocode.client.session.model.StepFinish import ai.kilocode.client.session.model.Tool @@ -122,6 +123,28 @@ class SessionHeaderPanelTest : SessionControllerTestBase() { assertEquals(1, rpc.compacts.size) } + fun `test branch diff button invokes callback when configured`() { + val c = promptedHeader() + var opened = 0 + val panel = SessionHeaderPanel(c, parent) { opened++ } + val button = panel.branchDiffButton() + + assertTrue(button.isVisible) + assertEquals(KiloBundle.message("diff.editor.branch.tooltip"), button.toolTipText) + assertEquals(KiloBundle.message("diff.editor.branch.tooltip"), button.accessibleContext.accessibleName) + + button.doClick() + + assertEquals(1, opened) + } + + fun `test branch diff button is hidden without callback`() { + val c = promptedHeader() + val panel = SessionHeaderPanel(c, parent) + + assertFalse(panel.branchDiffButton().isVisible) + } + fun `test todo list starts collapsed and toggles independently`() { val c = promptedHeader() val panel = SessionHeaderPanel(c, parent) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt index 65f282db4e..18ccd3d093 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt @@ -5,6 +5,7 @@ import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.CloudSessionDto import ai.kilocode.rpc.dto.CloudSessionListDto import ai.kilocode.rpc.dto.ConfigUpdateDto +import ai.kilocode.rpc.dto.DiffFileDto import ai.kilocode.rpc.dto.MessageWithPartsDto import ai.kilocode.rpc.dto.ModelSelectionDto import ai.kilocode.rpc.dto.PermissionAlwaysRulesDto @@ -47,6 +48,7 @@ class FakeSessionRpcApi : KiloSessionRpcApi { /** Message history returned by [messages]. */ val history = mutableListOf() val histories = mutableMapOf>() + val diffs = mutableMapOf>() var historyGate: CompletableDeferred? = null var historyCalls = 0 private set @@ -252,6 +254,11 @@ class FakeSessionRpcApi : KiloSessionRpcApi { return histories[id]?.toList() ?: history.toList() } + override suspend fun diff(id: String, directory: String): List { + assertNotEdt("diff") + return diffs[id]?.toList().orEmpty() + } + override suspend fun attachmentPart(id: String, directory: String, messageId: String, partId: String, attachmentKey: String?): PartDto? { assertNotEdt("attachmentPart") attachmentParts.add(AttachmentCall(id, directory, messageId, partId, attachmentKey)) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorkspaceRpcApi.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorkspaceRpcApi.kt index 817fc677e9..d4e51fbffa 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorkspaceRpcApi.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorkspaceRpcApi.kt @@ -2,6 +2,7 @@ package ai.kilocode.client.testing import ai.kilocode.rpc.KiloWorkspaceRpcApi import ai.kilocode.rpc.dto.ConfigTargetDto +import ai.kilocode.rpc.dto.DiffFileDto import ai.kilocode.rpc.dto.FileSearchResultDto import ai.kilocode.rpc.dto.KiloWorkspaceStateDto import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto @@ -34,6 +35,7 @@ class FakeWorkspaceRpcApi : KiloWorkspaceRpcApi { var searchResult = FileSearchResultDto() var search: ((String) -> FileSearchResultDto)? = null var gitChanges: String? = null + val branchDiffs = mutableListOf() var openResult = true var localConfigPath = "/test/.kilo/kilo.jsonc" var globalConfigPath = "/config/kilo.jsonc" @@ -94,6 +96,11 @@ class FakeWorkspaceRpcApi : KiloWorkspaceRpcApi { return gitChanges } + override suspend fun branchDiff(directory: String): List { + assertNotEdt("branchDiff") + return branchDiffs.toList() + } + override suspend fun openFile(path: String, line: Int?, column: Int?): Boolean { assertNotEdt("openFile") opened.add(path) diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloSessionRpcApi.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloSessionRpcApi.kt index 0fab2e8696..0eaa3c5c01 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloSessionRpcApi.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloSessionRpcApi.kt @@ -3,6 +3,7 @@ package ai.kilocode.rpc import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.CloudSessionListDto import ai.kilocode.rpc.dto.ConfigUpdateDto +import ai.kilocode.rpc.dto.DiffFileDto import ai.kilocode.rpc.dto.MessageWithPartsDto import ai.kilocode.rpc.dto.ModelSelectionDto import ai.kilocode.rpc.dto.PermissionAlwaysRulesDto @@ -99,6 +100,9 @@ interface KiloSessionRpcApi : RemoteApi { /** Load message history for a session. */ suspend fun messages(id: String, directory: String): List + /** Load cumulative file changes for a session. */ + suspend fun diff(id: String, directory: String): List + /** Load one attachment part from a session without returning full history to the frontend. */ suspend fun attachmentPart(id: String, directory: String, messageId: String, partId: String, attachmentKey: String?): PartDto? diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloWorkspaceRpcApi.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloWorkspaceRpcApi.kt index e85e5b2766..baacf0e61b 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloWorkspaceRpcApi.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloWorkspaceRpcApi.kt @@ -1,6 +1,7 @@ package ai.kilocode.rpc import ai.kilocode.rpc.dto.ConfigTargetDto +import ai.kilocode.rpc.dto.DiffFileDto import ai.kilocode.rpc.dto.FileSearchResultDto import ai.kilocode.rpc.dto.KiloWorkspaceStateDto import ai.kilocode.rpc.dto.ModelsWorkspaceDto @@ -54,6 +55,9 @@ interface KiloWorkspaceRpcApi : RemoteApi { /** Current uncommitted git changes as a unified diff for @git-changes mentions. */ suspend fun gitChanges(directory: String): String? + /** Committed branch changes compared with the default branch merge-base. */ + suspend fun branchDiff(directory: String): List + /** Open an absolute backend file path in the IDE. */ suspend fun openFile(path: String, line: Int? = null, column: Int? = null): Boolean From 98b54232c385af7ea85a58a02ab50f9d0af3ca9a Mon Sep 17 00:00:00 2001 From: kirillk Date: Wed, 29 Jul 2026 12:14:31 -0400 Subject: [PATCH 07/28] fix(jetbrains): align changes tree badges --- .../client/diff/KiloDiffEditorContent.kt | 128 +++++++++++--- .../client/diff/KiloDiffEditorContentTest.kt | 159 ++++++++++++++++++ 2 files changed, 268 insertions(+), 19 deletions(-) create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/KiloDiffEditorContentTest.kt diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloDiffEditorContent.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloDiffEditorContent.kt index d50785d233..7efdb8741f 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloDiffEditorContent.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloDiffEditorContent.kt @@ -1,43 +1,55 @@ package ai.kilocode.client.diff import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.ui.DiffStatBadge +import ai.kilocode.client.ui.UiStyle import ai.kilocode.rpc.dto.DiffFileDto import com.intellij.diff.DiffManager import com.intellij.icons.AllIcons import com.intellij.openapi.Disposable +import com.intellij.openapi.actionSystem.ActionManager +import com.intellij.openapi.actionSystem.ActionPlaces +import com.intellij.openapi.actionSystem.ActionUpdateThread +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.DefaultActionGroup +import com.intellij.openapi.project.DumbAwareAction import com.intellij.openapi.project.Project -import com.intellij.ui.ColoredTreeCellRenderer import com.intellij.ui.OnePixelSplitter -import com.intellij.ui.SimpleTextAttributes +import com.intellij.ui.SimpleColoredComponent import com.intellij.ui.TreeSpeedSearch import com.intellij.ui.components.JBScrollPane import com.intellij.ui.treeStructure.Tree import com.intellij.util.concurrency.annotations.RequiresEdt +import com.intellij.util.ui.JBUI import java.awt.BorderLayout +import java.awt.Component +import javax.swing.Icon import javax.swing.JComponent import javax.swing.JPanel +import javax.swing.ScrollPaneConstants import javax.swing.JTree import javax.swing.tree.DefaultMutableTreeNode import javax.swing.tree.DefaultTreeModel +import javax.swing.tree.TreeCellRenderer import javax.swing.tree.TreeNode import javax.swing.tree.TreePath @RequiresEdt -internal fun buildDiffEditor(project: Project, files: List, parent: Disposable): JComponent { +internal fun buildDiffEditor(project: Project, files: List, parent: Disposable, branch: String? = null): JComponent { val panel = DiffManager.getInstance().createRequestPanel(project, parent, null) val tree = buildFileTree(files) tree.addTreeSelectionListener { val node = tree.lastSelectedPathComponent as? DefaultMutableTreeNode ?: return@addTreeSelectionListener val file = (node.userObject as? Node)?.file ?: return@addTreeSelectionListener - panel.setRequest(diffRequest(project, file), file.file) + panel.setRequest(diffRequest(project, file, branch), diffTitle(file.file, branch)) } files.firstOrNull()?.let { - panel.setRequest(diffRequest(project, it), it.file) + panel.setRequest(diffRequest(project, it, branch), diffTitle(it.file, branch)) selectTreeNode(tree, it.file) } return OnePixelSplitter(false, 0.25f).apply { - firstComponent = JBScrollPane(tree) + firstComponent = buildTreePanel(tree, files) secondComponent = panel.component } } @@ -50,19 +62,60 @@ internal fun emptyChangesComponent(): JComponent = JPanel(BorderLayout()).apply private fun buildFileTree(files: List): Tree { val root = DefaultMutableTreeNode(Node("", "", true, null)) for (file in files) addFile(root, file) + updateStats(root) val tree = Tree(DefaultTreeModel(root)).apply { isRootVisible = false showsRootHandles = true + isOpaque = true cellRenderer = Renderer() } TreeSpeedSearch(tree) { path -> val node = path.lastPathComponent as? DefaultMutableTreeNode (node?.userObject as? Node)?.name.orEmpty() } - for (i in 0 until tree.rowCount) tree.expandRow(i) + expandAll(tree) return tree } +private fun buildTreePanel(tree: Tree, files: List): JComponent { + val stats = Stats(files.sumOf { it.additions }, files.sumOf { it.deletions }) + val toolbar = ActionManager.getInstance().createActionToolbar( + ActionPlaces.TOOLBAR, + DefaultActionGroup( + TreeAction(KiloBundle.message("diff.editor.tree.expandAll"), AllIcons.Actions.Expandall) { expandAll(tree) }, + TreeAction(KiloBundle.message("diff.editor.tree.collapseAll"), AllIcons.Actions.Collapseall) { collapseAll(tree) }, + ), + true, + ) + toolbar.targetComponent = tree + toolbar.updateActionsImmediately() + val row = JPanel(BorderLayout()).apply { + add(toolbar.component, BorderLayout.WEST) + add(DiffStatBadge(stats.additions, stats.deletions, inset = UiStyle.Gap.pad()), BorderLayout.EAST) + } + return JPanel(BorderLayout()).apply { + add(row, BorderLayout.NORTH) + add( + JBScrollPane(tree).apply { + horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER + }, + BorderLayout.CENTER, + ) + } +} + +private fun expandAll(tree: Tree) { + var i = 0 + while (i < tree.rowCount) { + tree.expandRow(i) + i += 1 + } +} + +private fun collapseAll(tree: Tree) { + for (i in tree.rowCount - 1 downTo 0) tree.collapseRow(i) +} + private fun addFile(root: DefaultMutableTreeNode, file: DiffFileDto) { var node = root val parts = file.file.split('/').filter { it.isNotBlank() } @@ -74,6 +127,17 @@ private fun addFile(root: DefaultMutableTreeNode, file: DiffFileDto) { } } +private fun updateStats(node: DefaultMutableTreeNode): Stats { + val item = node.userObject as? Node ?: return Stats(0, 0) + if (item.file != null) return Stats(item.additions, item.deletions) + val stats = (0 until node.childCount) + .map { updateStats(node.getChildAt(it) as? DefaultMutableTreeNode ?: return@map Stats(0, 0)) } + .fold(Stats(0, 0)) { acc, child -> Stats(acc.additions + child.additions, acc.deletions + child.deletions) } + item.additions = stats.additions + item.deletions = stats.deletions + return stats +} + private fun child(node: DefaultMutableTreeNode, path: String): DefaultMutableTreeNode? { for (i in 0 until node.childCount) { val child = node.getChildAt(i) as? DefaultMutableTreeNode ?: continue @@ -98,10 +162,25 @@ private fun find(node: DefaultMutableTreeNode, path: String): DefaultMutableTree return null } -private data class Node(val name: String, val path: String, val dir: Boolean, val file: DiffFileDto?) +private data class Stats(val additions: Int, val deletions: Int) -private class Renderer : ColoredTreeCellRenderer() { - override fun customizeCellRenderer( +private class Node(val name: String, val path: String, val dir: Boolean, val file: DiffFileDto?) { + var additions: Int = file?.additions ?: 0 + var deletions: Int = file?.deletions ?: 0 +} + +private class Renderer : JPanel(BorderLayout()), TreeCellRenderer { + private val text = SimpleColoredComponent() + private val badge = DiffStatBadge(0, 0, DiffStatBadge.Variant.COMPACT) + + init { + UiStyle.Components.transparent(this, text) + border = JBUI.Borders.empty(0, UiStyle.Gap.sm(), 0, UiStyle.Gap.xl()) + add(text, BorderLayout.CENTER) + add(badge, BorderLayout.EAST) + } + + override fun getTreeCellRendererComponent( tree: JTree, value: Any?, selected: Boolean, @@ -109,18 +188,29 @@ private class Renderer : ColoredTreeCellRenderer() { leaf: Boolean, row: Int, hasFocus: Boolean, - ) { - val node = value as? DefaultMutableTreeNode ?: return - val item = node.userObject as? Node ?: return - icon = if (item.dir) AllIcons.Nodes.Folder else AllIcons.FileTypes.Text - val file = item.file - append(item.name.ifBlank { item.path }) - if (file != null) { - append(" -${file.deletions} +${file.additions}", SimpleTextAttributes.GRAYED_ATTRIBUTES) - } + ): Component { + val node = value as? DefaultMutableTreeNode + val item = node?.userObject as? Node + text.clear() + text.icon = if (item?.dir == true) AllIcons.Nodes.Folder else AllIcons.FileTypes.Text + text.append(item?.name?.ifBlank { item.path }.orEmpty()) + val changed = item != null && (item.additions != 0 || item.deletions != 0) + badge.isVisible = changed + if (changed) badge.update(item.additions, item.deletions) + return this } } +private class TreeAction( + text: String, + icon: Icon, + private val action: () -> Unit, +) : DumbAwareAction(text, text, icon) { + override fun getActionUpdateThread() = ActionUpdateThread.EDT + + override fun actionPerformed(e: AnActionEvent) = action() +} + private val TreeNode.path: Array get() { val list = mutableListOf() diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/KiloDiffEditorContentTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/KiloDiffEditorContentTest.kt new file mode 100644 index 0000000000..95ad3028c6 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/KiloDiffEditorContentTest.kt @@ -0,0 +1,159 @@ +package ai.kilocode.client.diff + +import ai.kilocode.client.ui.DiffStatBadge +import ai.kilocode.rpc.dto.DiffFileDto +import com.intellij.openapi.util.Disposer +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.ui.treeStructure.Tree +import com.intellij.util.ui.UIUtil +import java.awt.BorderLayout +import java.awt.Component +import java.awt.Container +import javax.swing.tree.DefaultMutableTreeNode +import javax.swing.tree.TreePath + +class KiloDiffEditorContentTest : BasePlatformTestCase() { + fun `test tree toolbar shows aggregate badge`() { + val parent = Disposer.newDisposable() + try { + val view = buildDiffEditor(project, files(), parent, "feature/test") + val badges = components(view).filterIsInstance() + + assertTrue(badges.any { it.addedLabelForTest().text == "+5" && it.removedLabelForTest().text == "-4" }) + } finally { + Disposer.dispose(parent) + } + } + + fun `test tree renderer shows compact row change badge`() { + val parent = Disposer.newDisposable() + try { + val view = buildDiffEditor(project, files(), parent, "feature/test") + val tree = components(view).filterIsInstance().single() + val badge = rowBadge(renderer(tree, leaf(tree))) + + assertTrue(badge.isVisible) + assertTrue(badge.preferredSize.height < DiffStatBadge(1, 1).preferredSize.height) + } finally { + Disposer.dispose(parent) + } + } + + fun `test row renderer places badge east of filename`() { + val parent = Disposer.newDisposable() + try { + val view = buildDiffEditor(project, files(), parent, "feature/test") + val tree = components(view).filterIsInstance().single() + val row = renderer(tree, leaf(tree)) as Container + val layout = row.layout as BorderLayout + val east = layout.getLayoutComponent(BorderLayout.EAST) + val center = layout.getLayoutComponent(BorderLayout.CENTER) + + assertTrue(east is DiffStatBadge) + assertNotNull(center) + } finally { + Disposer.dispose(parent) + } + } + + fun `test row badge hidden when node has no changes`() { + val parent = Disposer.newDisposable() + try { + val view = buildDiffEditor(project, listOf(file("src/Empty.kt", 0, 0)), parent, "feature/test") + val tree = components(view).filterIsInstance().single() + val badge = rowBadge(renderer(tree, leaf(tree))) + + assertFalse(badge.isVisible) + } finally { + Disposer.dispose(parent) + } + } + + fun `test tree expands all rows on show`() { + val parent = Disposer.newDisposable() + try { + val view = buildDiffEditor(project, files(), parent, "feature/test") + val tree = components(view).filterIsInstance().single() + + assertEquals(4, tree.rowCount) + } finally { + Disposer.dispose(parent) + } + } + + fun `test tree paints standard background`() { + val parent = Disposer.newDisposable() + try { + val view = buildDiffEditor(project, files(), parent, "feature/test") + val tree = components(view).filterIsInstance().single() + + assertTrue(tree.isOpaque) + assertEquals(UIUtil.getTreeBackground(), tree.background) + } finally { + Disposer.dispose(parent) + } + } + + fun `test row renderer reuses badge instance`() { + val parent = Disposer.newDisposable() + try { + val view = buildDiffEditor(project, files(), parent, "feature/test") + val tree = components(view).filterIsInstance().single() + val leaf = leaf(tree) + val first = renderer(tree, leaf) + val second = renderer(tree, leaf) + + assertSame(first, second) + assertSame(rowBadge(first), rowBadge(second)) + } finally { + Disposer.dispose(parent) + } + } + + fun `test branch is included in diff title`() { + val request = diffRequest(project, file("src/App.kt", 1, 1), "feature/test") + + assertEquals("src/App.kt (feature/test)", request.title) + } + + private fun renderer(tree: Tree, node: DefaultMutableTreeNode): Component = + tree.cellRenderer.getTreeCellRendererComponent( + tree, + node, + false, + tree.isExpanded(TreePath(node.path)), + node.isLeaf, + 0, + false, + ) + + private fun leaf(tree: Tree): DefaultMutableTreeNode { + val root = tree.model.root as DefaultMutableTreeNode + val src = root.getChildAt(0) as DefaultMutableTreeNode + return src.getChildAt(0) as DefaultMutableTreeNode + } + + private fun rowBadge(row: Component): DiffStatBadge = components(row).filterIsInstance().single() + + private fun components(root: Component): List { + val out = mutableListOf() + fun visit(node: Component) { + out.add(node) + if (node is Container) node.components.forEach(::visit) + } + visit(root) + return out + } + + private fun files() = listOf( + file("src/App.kt", 2, 1), + file("test/AppTest.kt", 3, 3), + ) + + private fun file(path: String, additions: Int, deletions: Int) = DiffFileDto( + file = path, + additions = additions, + deletions = deletions, + patch = "@@ -1 +1 @@\n-old\n+new", + ) +} From 72591f7f5e13e54584a61d83d471d0abed311876 Mon Sep 17 00:00:00 2001 From: kirillk Date: Wed, 29 Jul 2026 14:23:12 -0400 Subject: [PATCH 08/28] fix(jetbrains): polish branch diff tree --- .changeset/quiet-diff-tree.md | 5 +++ .../backend/rpc/KiloWorkspaceRpcApiImpl.kt | 8 ++++ .../client/app/KiloWorkspaceService.kt | 11 ++++++ .../ai/kilocode/client/diff/DiffBlocks.kt | 10 ++++- .../client/diff/KiloDiffEditorContent.kt | 28 +++++++++++-- .../client/diff/KiloDiffEditorKind.kt | 15 ++++--- .../ai/kilocode/client/session/SessionUi.kt | 17 +++++--- .../ai/kilocode/client/ui/DiffStatBadge.kt | 39 +++++++++++++++++-- .../resources/messages/KiloBundle.properties | 4 ++ .../client/diff/KiloDiffEditorContentTest.kt | 21 ++++++++-- .../client/testing/FakeWorkspaceRpcApi.kt | 6 +++ .../ai/kilocode/rpc/KiloWorkspaceRpcApi.kt | 3 ++ 12 files changed, 146 insertions(+), 21 deletions(-) create mode 100644 .changeset/quiet-diff-tree.md diff --git a/.changeset/quiet-diff-tree.md b/.changeset/quiet-diff-tree.md new file mode 100644 index 0000000000..36d9d1a119 --- /dev/null +++ b/.changeset/quiet-diff-tree.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Improve the branch diff editor tree with expand/collapse controls, change badges, and branch-aware diff labels. diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt index 4e75d23119..4177d12d86 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt @@ -268,6 +268,14 @@ class KiloWorkspaceRpcApiImpl internal constructor( buildBranchDiff(numstat, patch, untracked, DIFF_CAP) } + override suspend fun branchName(directory: String): String? = withContext(Dispatchers.IO) { + val base = file(clean(directory) ?: directory) ?: return@withContext null + if (!gitAvailable(base)) return@withContext null + git(base, "branch", "--show-current").trim().ifBlank { + git(base, "rev-parse", "--short", "HEAD").trim() + }.ifBlank { null } + } + override suspend fun openFile(path: String, line: Int?, column: Int?): Boolean { val item = clean(path) ?: return false val target = file(item)?.takeIf { it.isAbsolute } ?: return false diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloWorkspaceService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloWorkspaceService.kt index 133a28aa91..3d900faa5a 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloWorkspaceService.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloWorkspaceService.kt @@ -171,6 +171,17 @@ class KiloWorkspaceService internal constructor( } } + suspend fun branchName(directory: String): String? { + return try { + call { branchName(directory) } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + LOG.warn("branch name lookup failed for directory=$directory", e) + null + } + } + suspend fun openPath(directory: String, path: String, line: Int? = null, column: Int? = null): Boolean { val match = files(directory, path).firstOrNull() ?: return false return try { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/DiffBlocks.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/DiffBlocks.kt index 2d5c84351e..ab73369016 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/DiffBlocks.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/DiffBlocks.kt @@ -1,5 +1,6 @@ package ai.kilocode.client.diff +import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.rpc.dto.DiffFileDto import com.intellij.diff.DiffContentFactory import com.intellij.diff.requests.DiffRequest @@ -8,7 +9,7 @@ import com.intellij.diff.util.DiffUserDataKeys import com.intellij.openapi.fileTypes.FileTypeManager import com.intellij.openapi.project.Project -internal fun diffRequest(project: Project, dto: DiffFileDto): DiffRequest { +internal fun diffRequest(project: Project, dto: DiffFileDto, branch: String? = null): DiffRequest { val sides = DiffPatchReconstruct.sides(dto) val type = FileTypeManager.getInstance().getFileTypeByFileName(dto.file) val factory = DiffContentFactory.getInstance() @@ -22,7 +23,12 @@ internal fun diffRequest(project: Project, dto: DiffFileDto): DiffRequest { sides.renderable -> factory.create(project, sides.after, type) else -> factory.create(project, dto.patch ?: "diff unavailable", type) } - return SimpleDiffRequest(dto.file, left, right, "Base", "Current").also { + return SimpleDiffRequest(diffTitle(dto.file, branch), left, right, "Base", "Current").also { it.putUserData(DiffUserDataKeys.FORCE_READ_ONLY, true) } } + +internal fun diffTitle(file: String, branch: String?): String { + val name = branch.takeIf { !it.isNullOrBlank() } ?: return file + return KiloBundle.message("diff.editor.file.title", file, name) +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloDiffEditorContent.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloDiffEditorContent.kt index 7efdb8741f..83479b7d06 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloDiffEditorContent.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloDiffEditorContent.kt @@ -14,7 +14,9 @@ import com.intellij.openapi.actionSystem.AnActionEvent import com.intellij.openapi.actionSystem.DefaultActionGroup import com.intellij.openapi.project.DumbAwareAction import com.intellij.openapi.project.Project +import com.intellij.ui.IdeBorderFactory import com.intellij.ui.OnePixelSplitter +import com.intellij.ui.SideBorder import com.intellij.ui.SimpleColoredComponent import com.intellij.ui.TreeSpeedSearch import com.intellij.ui.components.JBScrollPane @@ -22,15 +24,18 @@ import com.intellij.ui.treeStructure.Tree import com.intellij.util.concurrency.annotations.RequiresEdt import com.intellij.util.ui.JBUI import java.awt.BorderLayout +import java.awt.Color import java.awt.Component import javax.swing.Icon import javax.swing.JComponent import javax.swing.JPanel import javax.swing.ScrollPaneConstants +import javax.swing.JViewport import javax.swing.JTree import javax.swing.tree.DefaultMutableTreeNode import javax.swing.tree.DefaultTreeModel import javax.swing.tree.TreeCellRenderer +import javax.swing.tree.TreeModel import javax.swing.tree.TreeNode import javax.swing.tree.TreePath @@ -63,7 +68,7 @@ private fun buildFileTree(files: List): Tree { val root = DefaultMutableTreeNode(Node("", "", true, null)) for (file in files) addFile(root, file) updateStats(root) - val tree = Tree(DefaultTreeModel(root)).apply { + val tree = DiffTree(DefaultTreeModel(root)).apply { isRootVisible = false showsRootHandles = true isOpaque = true @@ -88,15 +93,23 @@ private fun buildTreePanel(tree: Tree, files: List): JComponent { true, ) toolbar.targetComponent = tree + toolbar.component.background = JBUI.CurrentTheme.ToolWindow.background() toolbar.updateActionsImmediately() - val row = JPanel(BorderLayout()).apply { + val row = object : JPanel(BorderLayout()) { + override fun getBackground(): Color = JBUI.CurrentTheme.ToolWindow.background() + }.apply { + border = IdeBorderFactory.createBorder(SideBorder.BOTTOM) add(toolbar.component, BorderLayout.WEST) add(DiffStatBadge(stats.additions, stats.deletions, inset = UiStyle.Gap.pad()), BorderLayout.EAST) } - return JPanel(BorderLayout()).apply { + return object : JPanel(BorderLayout()) { + override fun getBackground(): Color = JBUI.CurrentTheme.ToolWindow.background() + }.apply { add(row, BorderLayout.NORTH) add( JBScrollPane(tree).apply { + border = JBUI.Borders.empty() + viewportBorder = JBUI.Borders.empty() horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER }, BorderLayout.CENTER, @@ -169,6 +182,15 @@ private class Node(val name: String, val path: String, val dir: Boolean, val fil var deletions: Int = file?.deletions ?: 0 } +private class DiffTree(model: TreeModel) : Tree(model) { + override fun getBackground(): Color = JBUI.CurrentTheme.ToolWindow.background() + + override fun getScrollableTracksViewportHeight(): Boolean { + val view = parent as? JViewport ?: return super.getScrollableTracksViewportHeight() + return preferredSize.height < view.height || super.getScrollableTracksViewportHeight() + } +} + private class Renderer : JPanel(BorderLayout()), TreeCellRenderer { private val text = SimpleColoredComponent() private val badge = DiffStatBadge(0, 0, DiffStatBadge.Variant.COMPACT) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloDiffEditorKind.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloDiffEditorKind.kt index b5d51d2894..c9a79238cc 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloDiffEditorKind.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloDiffEditorKind.kt @@ -41,6 +41,7 @@ internal object KiloDiffEditorKind : KiloEditorKind { override fun title(params: Map): String { return params["title"].takeIfPresent() + ?: params["branch"].takeIfPresent()?.let { KiloBundle.message("diff.editor.branch.title.named", it) } ?: KiloBundle.message(if (params["source"] == "branch") "diff.editor.branch.title" else "diff.editor.session.title") } @@ -64,7 +65,7 @@ internal object KiloDiffEditorKind : KiloEditorKind { DiffEditorData.Connecting -> connecting() DiffEditorData.Empty -> emptyChangesComponent() is DiffEditorData.Error -> failed(data.message) - is DiffEditorData.Files -> buildDiffEditor(project, data.files, parent) + is DiffEditorData.Files -> buildDiffEditor(project, data.files, parent, data.branch) }, BorderLayout.CENTER, ) @@ -114,12 +115,15 @@ internal class KiloDiffEditorService( private suspend fun fetch(params: Map): DiffEditorData { val dir = params["directory"].takeIfPresent() ?: return DiffEditorData.Empty + val workspace = service() val files = when (params["source"]) { - "branch" -> service().branchDiff(dir) + "branch" -> workspace.branchDiff(dir) else -> project.service().diff(params["sessionId"].orEmpty(), dir) } if (files.isEmpty()) return DiffEditorData.Empty - return DiffEditorData.Files(files) + val branch = params["branch"].takeIfPresent() + ?: if (params["source"] == "branch") workspace.branchName(dir) else null + return DiffEditorData.Files(files, branch) } private companion object { @@ -131,16 +135,17 @@ internal sealed interface DiffEditorData { data object Connecting : DiffEditorData data object Empty : DiffEditorData data class Error(val message: String) : DiffEditorData - data class Files(val files: List) : DiffEditorData + data class Files(val files: List, val branch: String? = null) : DiffEditorData } -internal fun diffParams(source: String, directory: String, sessionId: String?, title: String): Map = +internal fun diffParams(source: String, directory: String, sessionId: String?, title: String, branch: String? = null): Map = linkedMapOf( "source" to source, "directory" to directory, "title" to title, ).apply { if (!sessionId.isNullOrBlank()) put("sessionId", sessionId) + if (!branch.isNullOrBlank()) put("branch", branch) } fun ensureDiffEditorKind() { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt index 2b15c77faf..a388534ec5 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt @@ -377,11 +377,18 @@ class SessionUi( } header = SessionHeaderPanel(controller, this) { ensureDiffEditorKind() - project.service().open( - KiloDiffEditorKind.ID, - diffParams("branch", workspace.directory, null, KiloBundle.message("diff.editor.branch.title")), - ) - Telemetry.send("Diff Editor Opened", mapOf("source" to "branch")) + cs.launch { + val branch = workspaces.branchName(workspace.directory) + val title = branch?.let { KiloBundle.message("diff.editor.branch.title.named", it) } + ?: KiloBundle.message("diff.editor.branch.title") + withContext(Dispatchers.Main) { + project.service().open( + KiloDiffEditorKind.ID, + diffParams("branch", workspace.directory, null, title, branch), + ) + Telemetry.send("Diff Editor Opened", mapOf("source" to "branch")) + } + } } scroll = SessionScroll(root, sessionContent, messageBody, blankBody) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/DiffStatBadge.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/DiffStatBadge.kt index 8f47080a59..3fba5f3962 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/DiffStatBadge.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/DiffStatBadge.kt @@ -6,6 +6,7 @@ import com.intellij.ui.components.JBLabel import com.intellij.util.ui.JBFont import com.intellij.util.ui.JBUI import java.awt.Color +import java.awt.Dimension import java.awt.Graphics import java.awt.Graphics2D import java.awt.GridBagLayout @@ -15,7 +16,31 @@ import javax.swing.JPanel internal class DiffStatBadge( additions: Int, deletions: Int, + private val variant: Variant = Variant.REGULAR, + private val inset: Int = 0, ) : JPanel(GridBagLayout()) { + constructor(additions: Int, deletions: Int) : this(additions, deletions, Variant.REGULAR, 0) + + internal enum class Variant { + REGULAR, + COMPACT; + + fun height() = when (this) { + REGULAR -> JBUI.scale(16) + COMPACT -> JBUI.scale(14) + } + + fun gap() = when (this) { + REGULAR -> UiStyle.Gap.sm() + COMPACT -> UiStyle.Gap.xs() + } + + fun pad() = when (this) { + REGULAR -> UiStyle.Gap.sm() + COMPACT -> UiStyle.Gap.sm() + } + } + private val removed = JBLabel().apply { foreground = UiStyle.Colors.removedForeground() font = JBFont.small() @@ -27,15 +52,20 @@ internal class DiffStatBadge( init { isOpaque = false - border = JBUI.Borders.empty(0, UiStyle.Gap.sm(), 0, UiStyle.Gap.sm()) + border = JBUI.Borders.empty(0, variant.pad(), 0, variant.pad() + inset) add( - Stack.horizontal(UiStyle.Gap.sm()) + Stack.horizontal(variant.gap()) .next(removed) .next(added), ) update(additions, deletions) } + override fun getPreferredSize(): Dimension { + val dim = super.getPreferredSize() + return Dimension(dim.width, variant.height()) + } + fun update(additions: Int, deletions: Int) { removed.text = "-$deletions" added.text = "+$additions" @@ -44,9 +74,12 @@ internal class DiffStatBadge( override fun paintComponent(g: Graphics) { val g2 = g.create() as Graphics2D try { + val w = maxOf(0, width - inset) + val h = minOf(height, variant.height()) + val y = (height - h) / 2 g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON) g2.color = backgroundColor() - g2.fillRoundRect(0, 0, width, height, height, height) + g2.fillRoundRect(0, y, w, h, h, h) } finally { g2.dispose() } diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties index 92a68936d1..b57fdb1aee 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -128,9 +128,13 @@ session.changes.count.one={0} file session.changes.count.other={0} files diff.editor.session.title=Session Changes diff.editor.branch.title=Changes vs base branch +diff.editor.branch.title.named=Changes vs base branch ({0}) +diff.editor.file.title={0} ({1}) diff.editor.branch.tooltip=Compare with base branch diff.editor.session.tooltip=Open changes in editor diff.editor.empty=No changes +diff.editor.tree.expandAll=Expand All +diff.editor.tree.collapseAll=Collapse All session.part.tool.copy=Copy session.part.tool.error=Error session.part.tool.agent={0} Agent diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/KiloDiffEditorContentTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/KiloDiffEditorContentTest.kt index 95ad3028c6..827200e13a 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/KiloDiffEditorContentTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/KiloDiffEditorContentTest.kt @@ -4,11 +4,13 @@ import ai.kilocode.client.ui.DiffStatBadge import ai.kilocode.rpc.dto.DiffFileDto import com.intellij.openapi.util.Disposer import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.ui.components.JBScrollPane import com.intellij.ui.treeStructure.Tree -import com.intellij.util.ui.UIUtil +import com.intellij.util.ui.JBUI import java.awt.BorderLayout import java.awt.Component import java.awt.Container +import javax.swing.SwingUtilities import javax.swing.tree.DefaultMutableTreeNode import javax.swing.tree.TreePath @@ -81,14 +83,27 @@ class KiloDiffEditorContentTest : BasePlatformTestCase() { } } - fun `test tree paints standard background`() { + fun `test tree paints tool window background`() { val parent = Disposer.newDisposable() try { val view = buildDiffEditor(project, files(), parent, "feature/test") val tree = components(view).filterIsInstance().single() + val scroll = SwingUtilities.getAncestorOfClass(JBScrollPane::class.java, tree) as JBScrollPane + val row = (scroll.parent.layout as BorderLayout).getLayoutComponent(BorderLayout.NORTH) as Container + val toolbar = (row.layout as BorderLayout).getLayoutComponent(BorderLayout.WEST) assertTrue(tree.isOpaque) - assertEquals(UIUtil.getTreeBackground(), tree.background) + assertEquals(JBUI.CurrentTheme.ToolWindow.background(), tree.background) + assertEquals(JBUI.CurrentTheme.ToolWindow.background(), row.background) + assertEquals(JBUI.CurrentTheme.ToolWindow.background(), toolbar.background) + assertEquals(0, scroll.border.getBorderInsets(scroll).top) + assertEquals(0, scroll.border.getBorderInsets(scroll).left) + assertEquals(0, scroll.border.getBorderInsets(scroll).bottom) + assertEquals(0, scroll.border.getBorderInsets(scroll).right) + assertEquals(0, scroll.viewportBorder.getBorderInsets(scroll).top) + assertEquals(0, scroll.viewportBorder.getBorderInsets(scroll).left) + assertEquals(0, scroll.viewportBorder.getBorderInsets(scroll).bottom) + assertEquals(0, scroll.viewportBorder.getBorderInsets(scroll).right) } finally { Disposer.dispose(parent) } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorkspaceRpcApi.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorkspaceRpcApi.kt index d4e51fbffa..8ef01f7291 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorkspaceRpcApi.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorkspaceRpcApi.kt @@ -36,6 +36,7 @@ class FakeWorkspaceRpcApi : KiloWorkspaceRpcApi { var search: ((String) -> FileSearchResultDto)? = null var gitChanges: String? = null val branchDiffs = mutableListOf() + var branchName: String? = null var openResult = true var localConfigPath = "/test/.kilo/kilo.jsonc" var globalConfigPath = "/config/kilo.jsonc" @@ -101,6 +102,11 @@ class FakeWorkspaceRpcApi : KiloWorkspaceRpcApi { return branchDiffs.toList() } + override suspend fun branchName(directory: String): String? { + assertNotEdt("branchName") + return branchName + } + override suspend fun openFile(path: String, line: Int?, column: Int?): Boolean { assertNotEdt("openFile") opened.add(path) diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloWorkspaceRpcApi.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloWorkspaceRpcApi.kt index baacf0e61b..d7341c1739 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloWorkspaceRpcApi.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloWorkspaceRpcApi.kt @@ -58,6 +58,9 @@ interface KiloWorkspaceRpcApi : RemoteApi { /** Committed branch changes compared with the default branch merge-base. */ suspend fun branchDiff(directory: String): List + /** Current git branch name for branch-scoped UI labels. */ + suspend fun branchName(directory: String): String? + /** Open an absolute backend file path in the IDE. */ suspend fun openFile(path: String, line: Int? = null, column: Int? = null): Boolean From b9770dfab75e5af23a6877d2d557d77b01d7fab6 Mon Sep 17 00:00:00 2001 From: kirillk Date: Wed, 29 Jul 2026 15:44:28 -0400 Subject: [PATCH 09/28] fix(jetbrains): improve diff navigation reload --- .../jetbrains-diff-navigation-reload.md | 5 + .../client/diff/KiloDiffEditorContent.kt | 250 ++++++++++++++++-- .../client/diff/KiloDiffEditorKind.kt | 33 ++- .../client/diff/KiloDiffEditorContentTest.kt | 68 ++++- 4 files changed, 324 insertions(+), 32 deletions(-) create mode 100644 .changeset/jetbrains-diff-navigation-reload.md diff --git a/.changeset/jetbrains-diff-navigation-reload.md b/.changeset/jetbrains-diff-navigation-reload.md new file mode 100644 index 0000000000..e115aecf24 --- /dev/null +++ b/.changeset/jetbrains-diff-navigation-reload.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Improve JetBrains diff navigation, tree selection responsiveness, and disk change reloads. diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloDiffEditorContent.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloDiffEditorContent.kt index 83479b7d06..2ff2c63f18 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloDiffEditorContent.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloDiffEditorContent.kt @@ -4,7 +4,10 @@ import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.ui.DiffStatBadge import ai.kilocode.client.ui.UiStyle import ai.kilocode.rpc.dto.DiffFileDto -import com.intellij.diff.DiffManager +import com.intellij.diff.chains.DiffRequestProducer +import com.intellij.diff.chains.SimpleDiffRequestChain +import com.intellij.diff.impl.CacheDiffRequestChainProcessor +import com.intellij.diff.impl.DiffRequestProcessorListener import com.intellij.icons.AllIcons import com.intellij.openapi.Disposable import com.intellij.openapi.actionSystem.ActionManager @@ -12,8 +15,17 @@ import com.intellij.openapi.actionSystem.ActionPlaces import com.intellij.openapi.actionSystem.ActionUpdateThread import com.intellij.openapi.actionSystem.AnActionEvent import com.intellij.openapi.actionSystem.DefaultActionGroup +import com.intellij.openapi.actionSystem.IdeActions +import com.intellij.openapi.actionSystem.Separator import com.intellij.openapi.project.DumbAwareAction import com.intellij.openapi.project.Project +import com.intellij.openapi.progress.ProgressIndicator +import com.intellij.openapi.util.Disposer +import com.intellij.openapi.util.Key +import com.intellij.openapi.util.UserDataHolder +import com.intellij.openapi.vfs.AsyncFileListener +import com.intellij.openapi.vfs.VirtualFileManager +import com.intellij.openapi.vfs.newvfs.events.VFileEvent import com.intellij.ui.IdeBorderFactory import com.intellij.ui.OnePixelSplitter import com.intellij.ui.SideBorder @@ -23,9 +35,18 @@ import com.intellij.ui.components.JBScrollPane import com.intellij.ui.treeStructure.Tree import com.intellij.util.concurrency.annotations.RequiresEdt import com.intellij.util.ui.JBUI +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import java.awt.BorderLayout import java.awt.Color import java.awt.Component +import java.nio.file.InvalidPathException +import java.nio.file.Path +import java.util.concurrent.atomic.AtomicBoolean import javax.swing.Icon import javax.swing.JComponent import javax.swing.JPanel @@ -40,22 +61,198 @@ import javax.swing.tree.TreeNode import javax.swing.tree.TreePath @RequiresEdt -internal fun buildDiffEditor(project: Project, files: List, parent: Disposable, branch: String? = null): JComponent { - val panel = DiffManager.getInstance().createRequestPanel(project, parent, null) - val tree = buildFileTree(files) - tree.addTreeSelectionListener { - val node = tree.lastSelectedPathComponent as? DefaultMutableTreeNode ?: return@addTreeSelectionListener - val file = (node.userObject as? Node)?.file ?: return@addTreeSelectionListener - panel.setRequest(diffRequest(project, file, branch), diffTitle(file.file, branch)) - } - files.firstOrNull()?.let { - panel.setRequest(diffRequest(project, it, branch), diffTitle(it.file, branch)) - selectTreeNode(tree, it.file) +internal fun buildDiffEditor( + project: Project, + params: Map, + files: List, + parent: Disposable, + branch: String? = null, + scope: CoroutineScope, + refresh: ((DiffEditorData) -> Unit) -> Job, + replace: (DiffEditorData) -> Unit, +): JComponent = DiffEditorView(project, params, files, parent, branch, scope, refresh, replace).component + +internal val DIFF_FILE_KEY: Key = Key.create("kilo.diff.file") + +internal class DiffEditorView( + private val project: Project, + private val params: Map, + initial: List, + private val parent: Disposable, + branch: String?, + scope: CoroutineScope, + private val refresh: ((DiffEditorData) -> Unit) -> Job, + private val replace: (DiffEditorData) -> Unit, +) : Disposable { + private val disposed = AtomicBoolean(false) + private val tree = buildFileTree(initial) + private val badge = DiffStatBadge(0, 0, inset = UiStyle.Gap.pad()) + private val splitter = OnePixelSplitter(false, 0.25f) + private val select = Debouncer(scope, parent) { show(it) } + private val reload = Debouncer(scope, parent) { reload() } + private var files = initial + private var branch = branch + private var syncing = false + private var processor = processor(initial, selected(initial.firstOrNull()?.file)) + val component: JComponent = splitter + + init { + Disposer.register(parent, this) + Disposer.register(parent, processor) + tree.addTreeSelectionListener { + if (syncing) return@addTreeSelectionListener + val file = selectedFile() ?: return@addTreeSelectionListener + val index = files.indexOfFirst { it.file == file.file } + if (index >= 0) select.request(index) + } + processor.addListener(DiffRequestProcessorListener { syncTree() }, parent) + splitter.firstComponent = buildTreePanel(tree, initial, badge, processor.component) + splitter.secondComponent = processor.component + processor.updateRequest() + applyBadge(initial) + select(initial.firstOrNull()?.file) + listen() } - return OnePixelSplitter(false, 0.25f).apply { - firstComponent = buildTreePanel(tree, files) - secondComponent = panel.component + override fun dispose() { + disposed.set(true) + } + + @RequiresEdt + fun applyFiles(next: List, nextBranch: String? = branch) { + if (same(files, next) && branch == nextBranch) return + val path = selectedFile()?.file ?: activePath() ?: files.firstOrNull()?.file + val index = selected(path, next) + val old = processor + files = next + branch = nextBranch + tree.model = buildFileModel(next) + expandAll(tree) + processor = processor(next, index) + Disposer.register(parent, processor) + processor.addListener(DiffRequestProcessorListener { syncTree() }, parent) + splitter.firstComponent = buildTreePanel(tree, next, badge, processor.component) + splitter.secondComponent = processor.component + processor.updateRequest() + Disposer.dispose(old) + applyBadge(next) + select(next.getOrNull(index)?.file) + splitter.revalidate() + splitter.repaint() + } + + private fun show(index: Int) { + if (disposed.get() || project.isDisposed || index !in files.indices) return + processor.setCurrentRequest(index) + } + + private fun reload() { + if (disposed.get() || project.isDisposed) return + refresh { data -> + if (disposed.get() || project.isDisposed) return@refresh + if (data is DiffEditorData.Files) applyFiles(data.files, data.branch) + if (data !is DiffEditorData.Files) replace(data) + } + } + + private fun listen() { + val dir = params["directory"] ?: return + val root = clean(dir) ?: return + VirtualFileManager.getInstance().addAsyncFileListenerBackgroundable( + object : AsyncFileListener { + override fun prepareChange(events: List): AsyncFileListener.ChangeApplier? { + if (events.none { inside(root, it.path) }) return null + return object : AsyncFileListener.ChangeApplier { + override fun afterVfsChange() { + reload.request(Unit) + } + } + } + }, + parent, + ) + } + + private fun processor(next: List, index: Int): CacheDiffRequestChainProcessor { + val producers = next.map { file -> producer(file) } + val chain = SimpleDiffRequestChain.fromProducers(producers, index.coerceIn(0, (next.size - 1).coerceAtLeast(0))) + return CacheDiffRequestChainProcessor(project, chain) + } + + private fun producer(file: DiffFileDto): DiffRequestProducer = object : DiffRequestProducer { + override fun getName(): String = file.file + + override fun process(context: UserDataHolder, indicator: ProgressIndicator) = diffRequest(project, file, branch).also { + it.putUserData(DIFF_FILE_KEY, file.file) + } + } + + private fun syncTree() { + if (disposed.get()) return + val path = activePath() ?: return + if (selectedFile()?.file == path) return + select(path) + } + + private fun select(path: String?) { + if (path == null) return + syncing = true + selectTreeNode(tree, path) + syncing = false + } + + private fun activePath(): String? = processor.activeRequest?.getUserData(DIFF_FILE_KEY) + + private fun selectedFile(): DiffFileDto? { + val node = tree.lastSelectedPathComponent as? DefaultMutableTreeNode ?: return null + return (node.userObject as? Node)?.file + } + + private fun applyBadge(next: List) { + badge.update(next.sumOf { it.additions }, next.sumOf { it.deletions }) + } + + private fun selected(path: String?, next: List = files): Int { + val index = next.indexOfFirst { it.file == path } + if (index >= 0) return index + return 0 + } + + private fun same(a: List, b: List): Boolean = a == b + + private fun clean(dir: String): Path? = try { + Path.of(dir).normalize() + } catch (_: InvalidPathException) { + null + } + + private fun inside(root: Path, raw: String): Boolean = try { + val path = Path.of(raw).normalize() + val text = path.toString().replace('\\', '/') + path.startsWith(root) && !text.contains("/.git/") && !text.endsWith("/.git") + } catch (_: InvalidPathException) { + false + } +} + +private class Debouncer( + private val scope: CoroutineScope, + parent: Disposable, + private val delay: Long = 300, + private val action: suspend (T) -> Unit, +) { + private var job: Job? = null + + init { + Disposer.register(parent) { job?.cancel() } + } + + fun request(value: T) { + job?.cancel() + job = scope.launch { + delay(delay) + withContext(Dispatchers.Main) { action(value) } + } } } @@ -65,10 +262,7 @@ internal fun emptyChangesComponent(): JComponent = JPanel(BorderLayout()).apply } private fun buildFileTree(files: List): Tree { - val root = DefaultMutableTreeNode(Node("", "", true, null)) - for (file in files) addFile(root, file) - updateStats(root) - val tree = DiffTree(DefaultTreeModel(root)).apply { + val tree = DiffTree(buildFileModel(files)).apply { isRootVisible = false showsRootHandles = true isOpaque = true @@ -82,17 +276,26 @@ private fun buildFileTree(files: List): Tree { return tree } -private fun buildTreePanel(tree: Tree, files: List): JComponent { - val stats = Stats(files.sumOf { it.additions }, files.sumOf { it.deletions }) +private fun buildFileModel(files: List): DefaultTreeModel { + val root = DefaultMutableTreeNode(Node("", "", true, null)) + for (file in files) addFile(root, file) + updateStats(root) + return DefaultTreeModel(root) +} + +private fun buildTreePanel(tree: Tree, files: List, badge: DiffStatBadge, target: JComponent): JComponent { val toolbar = ActionManager.getInstance().createActionToolbar( ActionPlaces.TOOLBAR, DefaultActionGroup( + ActionManager.getInstance().getAction(IdeActions.ACTION_PREVIOUS_DIFF), + ActionManager.getInstance().getAction(IdeActions.ACTION_NEXT_DIFF), + Separator.getInstance(), TreeAction(KiloBundle.message("diff.editor.tree.expandAll"), AllIcons.Actions.Expandall) { expandAll(tree) }, TreeAction(KiloBundle.message("diff.editor.tree.collapseAll"), AllIcons.Actions.Collapseall) { collapseAll(tree) }, ), true, ) - toolbar.targetComponent = tree + toolbar.targetComponent = target toolbar.component.background = JBUI.CurrentTheme.ToolWindow.background() toolbar.updateActionsImmediately() val row = object : JPanel(BorderLayout()) { @@ -100,7 +303,8 @@ private fun buildTreePanel(tree: Tree, files: List): JComponent { }.apply { border = IdeBorderFactory.createBorder(SideBorder.BOTTOM) add(toolbar.component, BorderLayout.WEST) - add(DiffStatBadge(stats.additions, stats.deletions, inset = UiStyle.Gap.pad()), BorderLayout.EAST) + badge.update(files.sumOf { it.additions }, files.sumOf { it.deletions }) + add(badge, BorderLayout.EAST) } return object : JPanel(BorderLayout()) { override fun getBackground(): Color = JBUI.CurrentTheme.ToolWindow.background() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloDiffEditorKind.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloDiffEditorKind.kt index c9a79238cc..385f3a7c9c 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloDiffEditorKind.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloDiffEditorKind.kt @@ -58,20 +58,35 @@ internal object KiloDiffEditorKind : KiloEditorKind { override fun createContent(project: Project, file: KiloVirtualFile, parent: Disposable): JComponent { val panel = JPanel(BorderLayout()) panel.add(connecting(), BorderLayout.CENTER) - project.service().load(file.path.params, parent) { data -> + val service = project.service() + var current: Disposable? = null + fun render(data: DiffEditorData) { + current?.let { Disposer.dispose(it) } + val child = Disposer.newDisposable(parent, "Kilo diff editor content") + current = child panel.removeAll() panel.add( when (data) { DiffEditorData.Connecting -> connecting() DiffEditorData.Empty -> emptyChangesComponent() is DiffEditorData.Error -> failed(data.message) - is DiffEditorData.Files -> buildDiffEditor(project, data.files, parent, data.branch) + is DiffEditorData.Files -> buildDiffEditor( + project, + file.path.params, + data.files, + child, + data.branch, + service.scope, + { done -> service.refresh(file.path.params, done) }, + ::render, + ) }, BorderLayout.CENTER, ) panel.revalidate() panel.repaint() } + service.load(file.path.params, parent, ::render) return panel } } @@ -81,6 +96,9 @@ internal class KiloDiffEditorService( private val project: Project, private val cs: CoroutineScope, ) { + internal val scope: CoroutineScope + get() = cs + fun load(params: Map, parent: Disposable, done: (DiffEditorData) -> Unit) { val disposed = AtomicBoolean(false) val job = cs.launch { @@ -111,6 +129,17 @@ internal class KiloDiffEditorService( } } + fun refresh(params: Map, done: (DiffEditorData) -> Unit) = cs.launch { + val data = runCatching { fetch(params) } + .getOrElse { + LOG.warn("diff editor refresh failed source=${params["source"]} dir=${params["directory"]}", it) + DiffEditorData.Error(it.message ?: it::class.java.simpleName) + } + withContext(Dispatchers.Main) { + if (!project.isDisposed) done(data) + } + } + private fun alive(disposed: AtomicBoolean): Boolean = !project.isDisposed && !disposed.get() private suspend fun fetch(params: Map): DiffEditorData { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/KiloDiffEditorContentTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/KiloDiffEditorContentTest.kt index 827200e13a..504d8d9097 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/KiloDiffEditorContentTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/KiloDiffEditorContentTest.kt @@ -2,11 +2,17 @@ package ai.kilocode.client.diff import ai.kilocode.client.ui.DiffStatBadge import ai.kilocode.rpc.dto.DiffFileDto +import com.intellij.openapi.Disposable import com.intellij.openapi.util.Disposer import com.intellij.testFramework.fixtures.BasePlatformTestCase import com.intellij.ui.components.JBScrollPane import com.intellij.ui.treeStructure.Tree import com.intellij.util.ui.JBUI +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel import java.awt.BorderLayout import java.awt.Component import java.awt.Container @@ -18,7 +24,7 @@ class KiloDiffEditorContentTest : BasePlatformTestCase() { fun `test tree toolbar shows aggregate badge`() { val parent = Disposer.newDisposable() try { - val view = buildDiffEditor(project, files(), parent, "feature/test") + val view = view(files(), parent) val badges = components(view).filterIsInstance() assertTrue(badges.any { it.addedLabelForTest().text == "+5" && it.removedLabelForTest().text == "-4" }) @@ -30,7 +36,7 @@ class KiloDiffEditorContentTest : BasePlatformTestCase() { fun `test tree renderer shows compact row change badge`() { val parent = Disposer.newDisposable() try { - val view = buildDiffEditor(project, files(), parent, "feature/test") + val view = view(files(), parent) val tree = components(view).filterIsInstance().single() val badge = rowBadge(renderer(tree, leaf(tree))) @@ -44,7 +50,7 @@ class KiloDiffEditorContentTest : BasePlatformTestCase() { fun `test row renderer places badge east of filename`() { val parent = Disposer.newDisposable() try { - val view = buildDiffEditor(project, files(), parent, "feature/test") + val view = view(files(), parent) val tree = components(view).filterIsInstance().single() val row = renderer(tree, leaf(tree)) as Container val layout = row.layout as BorderLayout @@ -61,7 +67,7 @@ class KiloDiffEditorContentTest : BasePlatformTestCase() { fun `test row badge hidden when node has no changes`() { val parent = Disposer.newDisposable() try { - val view = buildDiffEditor(project, listOf(file("src/Empty.kt", 0, 0)), parent, "feature/test") + val view = view(listOf(file("src/Empty.kt", 0, 0)), parent) val tree = components(view).filterIsInstance().single() val badge = rowBadge(renderer(tree, leaf(tree))) @@ -74,7 +80,7 @@ class KiloDiffEditorContentTest : BasePlatformTestCase() { fun `test tree expands all rows on show`() { val parent = Disposer.newDisposable() try { - val view = buildDiffEditor(project, files(), parent, "feature/test") + val view = view(files(), parent) val tree = components(view).filterIsInstance().single() assertEquals(4, tree.rowCount) @@ -86,7 +92,7 @@ class KiloDiffEditorContentTest : BasePlatformTestCase() { fun `test tree paints tool window background`() { val parent = Disposer.newDisposable() try { - val view = buildDiffEditor(project, files(), parent, "feature/test") + val view = view(files(), parent) val tree = components(view).filterIsInstance().single() val scroll = SwingUtilities.getAncestorOfClass(JBScrollPane::class.java, tree) as JBScrollPane val row = (scroll.parent.layout as BorderLayout).getLayoutComponent(BorderLayout.NORTH) as Container @@ -112,7 +118,7 @@ class KiloDiffEditorContentTest : BasePlatformTestCase() { fun `test row renderer reuses badge instance`() { val parent = Disposer.newDisposable() try { - val view = buildDiffEditor(project, files(), parent, "feature/test") + val view = view(files(), parent) val tree = components(view).filterIsInstance().single() val leaf = leaf(tree) val first = renderer(tree, leaf) @@ -131,6 +137,38 @@ class KiloDiffEditorContentTest : BasePlatformTestCase() { assertEquals("src/App.kt (feature/test)", request.title) } + fun `test reload updates aggregate badge`() { + val parent = Disposer.newDisposable() + try { + val editor = editor(files(), parent) + + editor.applyFiles(listOf(file("src/App.kt", 7, 6)), "feature/test") + val badges = components(editor.component).filterIsInstance() + + assertTrue(badges.any { it.addedLabelForTest().text == "+7" && it.removedLabelForTest().text == "-6" }) + } finally { + Disposer.dispose(parent) + } + } + + fun `test reload preserves selected file`() { + val parent = Disposer.newDisposable() + try { + val editor = editor(files(), parent) + val tree = components(editor.component).filterIsInstance().single() + tree.selectionPath = TreePath(leaf(tree).path) + + editor.applyFiles( + listOf(file("src/App.kt", 4, 2), file("test/AppTest.kt", 1, 1)), + "feature/test", + ) + + assertSame(leaf(tree), tree.lastSelectedPathComponent) + } finally { + Disposer.dispose(parent) + } + } + private fun renderer(tree: Tree, node: DefaultMutableTreeNode): Component = tree.cellRenderer.getTreeCellRendererComponent( tree, @@ -150,6 +188,22 @@ class KiloDiffEditorContentTest : BasePlatformTestCase() { private fun rowBadge(row: Component): DiffStatBadge = components(row).filterIsInstance().single() + private fun view(files: List, parent: Disposable): Component = editor(files, parent).component + + private fun editor(files: List, parent: Disposable): DiffEditorView { + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + Disposer.register(parent) { scope.cancel() } + return DiffEditorView( + project, + mapOf("directory" to project.basePath.orEmpty(), "source" to "branch"), + files, + parent, + "feature/test", + scope, + { Job().also { it.complete() } }, + ) {} + } + private fun components(root: Component): List { val out = mutableListOf() fun visit(node: Component) { From f4e2b5adda8273bcbddd2c8b918f10c45d096d3c Mon Sep 17 00:00:00 2001 From: kirillk Date: Wed, 29 Jul 2026 16:47:32 -0400 Subject: [PATCH 10/28] fix(jetbrains): show stale diff refresh banner --- .../jetbrains-diff-navigation-reload.md | 2 +- .../client/diff/KiloDiffEditorContent.kt | 99 +++++++++++++++---- .../resources/messages/KiloBundle.properties | 2 + .../client/diff/KiloDiffEditorContentTest.kt | 97 +++++++++++++++++- 4 files changed, 179 insertions(+), 21 deletions(-) diff --git a/.changeset/jetbrains-diff-navigation-reload.md b/.changeset/jetbrains-diff-navigation-reload.md index e115aecf24..f2d5ceb46d 100644 --- a/.changeset/jetbrains-diff-navigation-reload.md +++ b/.changeset/jetbrains-diff-navigation-reload.md @@ -2,4 +2,4 @@ "@kilocode/kilo-jetbrains": patch --- -Improve JetBrains diff navigation, tree selection responsiveness, and disk change reloads. +Improve JetBrains diff navigation and show stale diff views with a manual refresh action when files change on disk. diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloDiffEditorContent.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloDiffEditorContent.kt index 2ff2c63f18..18eb334bca 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloDiffEditorContent.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloDiffEditorContent.kt @@ -17,6 +17,12 @@ import com.intellij.openapi.actionSystem.AnActionEvent import com.intellij.openapi.actionSystem.DefaultActionGroup import com.intellij.openapi.actionSystem.IdeActions import com.intellij.openapi.actionSystem.Separator +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.application.ModalityState +import com.intellij.openapi.editor.EditorFactory +import com.intellij.openapi.editor.event.DocumentEvent +import com.intellij.openapi.editor.event.DocumentListener +import com.intellij.openapi.fileEditor.FileDocumentManager import com.intellij.openapi.project.DumbAwareAction import com.intellij.openapi.project.Project import com.intellij.openapi.progress.ProgressIndicator @@ -27,6 +33,7 @@ import com.intellij.openapi.vfs.AsyncFileListener import com.intellij.openapi.vfs.VirtualFileManager import com.intellij.openapi.vfs.newvfs.events.VFileEvent import com.intellij.ui.IdeBorderFactory +import com.intellij.ui.EditorNotificationPanel import com.intellij.ui.OnePixelSplitter import com.intellij.ui.SideBorder import com.intellij.ui.SimpleColoredComponent @@ -81,20 +88,30 @@ internal class DiffEditorView( private val parent: Disposable, branch: String?, scope: CoroutineScope, - private val refresh: ((DiffEditorData) -> Unit) -> Job, + private val load: ((DiffEditorData) -> Unit) -> Job, private val replace: (DiffEditorData) -> Unit, ) : Disposable { private val disposed = AtomicBoolean(false) + private val outdated = AtomicBoolean(false) + private val refreshing = AtomicBoolean(false) private val tree = buildFileTree(initial) private val badge = DiffStatBadge(0, 0, inset = UiStyle.Gap.pad()) private val splitter = OnePixelSplitter(false, 0.25f) private val select = Debouncer(scope, parent) { show(it) } - private val reload = Debouncer(scope, parent) { reload() } + private val banner = EditorNotificationPanel(EditorNotificationPanel.Status.Warning).apply { + text(KiloBundle.message("diff.editor.outdated")) + createActionLabel(KiloBundle.message("diff.editor.refresh")) { refresh() } + isVisible = false + } + private val root = JPanel(BorderLayout()).apply { + add(banner, BorderLayout.NORTH) + add(splitter, BorderLayout.CENTER) + } private var files = initial private var branch = branch private var syncing = false private var processor = processor(initial, selected(initial.firstOrNull()?.file)) - val component: JComponent = splitter + val component: JComponent = root init { Disposer.register(parent, this) @@ -106,7 +123,7 @@ internal class DiffEditorView( if (index >= 0) select.request(index) } processor.addListener(DiffRequestProcessorListener { syncTree() }, parent) - splitter.firstComponent = buildTreePanel(tree, initial, badge, processor.component) + splitter.firstComponent = buildTreePanel(tree, initial, badge, processor.component, ::refresh) splitter.secondComponent = processor.component processor.updateRequest() applyBadge(initial) @@ -131,14 +148,39 @@ internal class DiffEditorView( processor = processor(next, index) Disposer.register(parent, processor) processor.addListener(DiffRequestProcessorListener { syncTree() }, parent) - splitter.firstComponent = buildTreePanel(tree, next, badge, processor.component) + splitter.firstComponent = buildTreePanel(tree, next, badge, processor.component, ::refresh) splitter.secondComponent = processor.component processor.updateRequest() Disposer.dispose(old) applyBadge(next) select(next.getOrNull(index)?.file) - splitter.revalidate() - splitter.repaint() + root.revalidate() + root.repaint() + } + + @RequiresEdt + internal fun refresh() { + if (disposed.get() || project.isDisposed) return + if (!refreshing.compareAndSet(false, true)) return + saveDocuments() + outdated.set(false) + banner.isVisible = false + root.revalidate() + root.repaint() + load { data -> + refreshing.set(false) + if (!disposed.get() && !project.isDisposed) { + if (data is DiffEditorData.Files) applyFiles(data.files, data.branch) + if (data !is DiffEditorData.Files) replace(data) + } + } + } + + internal fun markOutdated() { + if (!outdated.compareAndSet(false, true)) return + ApplicationManager.getApplication().invokeLater({ showOutdated() }, ModalityState.any()) { + disposed.get() || project.isDisposed + } } private fun show(index: Int) { @@ -146,25 +188,34 @@ internal class DiffEditorView( processor.setCurrentRequest(index) } - private fun reload() { + @RequiresEdt + private fun showOutdated() { if (disposed.get() || project.isDisposed) return - refresh { data -> - if (disposed.get() || project.isDisposed) return@refresh - if (data is DiffEditorData.Files) applyFiles(data.files, data.branch) - if (data !is DiffEditorData.Files) replace(data) - } + banner.isVisible = true + root.revalidate() + root.repaint() } private fun listen() { val dir = params["directory"] ?: return val root = clean(dir) ?: return + EditorFactory.getInstance().eventMulticaster.addDocumentListener( + object : DocumentListener { + override fun documentChanged(event: DocumentEvent) { + val file = FileDocumentManager.getInstance().getFile(event.document) ?: return + if (inside(root, file.path)) markOutdated() + } + }, + parent, + ) VirtualFileManager.getInstance().addAsyncFileListenerBackgroundable( object : AsyncFileListener { override fun prepareChange(events: List): AsyncFileListener.ChangeApplier? { + if (outdated.get()) return null if (events.none { inside(root, it.path) }) return null return object : AsyncFileListener.ChangeApplier { override fun afterVfsChange() { - reload.request(Unit) + markOutdated() } } } @@ -173,6 +224,16 @@ internal class DiffEditorView( ) } + private fun saveDocuments() { + val dir = params["directory"] ?: return + val root = clean(dir) ?: return + val manager = FileDocumentManager.getInstance() + manager.saveDocuments { doc -> + val file = manager.getFile(doc) ?: return@saveDocuments false + inside(root, file.path) + } + } + private fun processor(next: List, index: Int): CacheDiffRequestChainProcessor { val producers = next.map { file -> producer(file) } val chain = SimpleDiffRequestChain.fromProducers(producers, index.coerceIn(0, (next.size - 1).coerceAtLeast(0))) @@ -228,8 +289,10 @@ internal class DiffEditorView( private fun inside(root: Path, raw: String): Boolean = try { val path = Path.of(raw).normalize() - val text = path.toString().replace('\\', '/') - path.startsWith(root) && !text.contains("/.git/") && !text.endsWith("/.git") + if (!path.startsWith(root)) return false + val rel = root.relativize(path).toString().replace('\\', '/') + if (rel == ".git/HEAD" || rel.startsWith(".git/refs/")) return true + rel != ".git" && !rel.startsWith(".git/") } catch (_: InvalidPathException) { false } @@ -283,7 +346,7 @@ private fun buildFileModel(files: List): DefaultTreeModel { return DefaultTreeModel(root) } -private fun buildTreePanel(tree: Tree, files: List, badge: DiffStatBadge, target: JComponent): JComponent { +private fun buildTreePanel(tree: Tree, files: List, badge: DiffStatBadge, target: JComponent, refresh: () -> Unit): JComponent { val toolbar = ActionManager.getInstance().createActionToolbar( ActionPlaces.TOOLBAR, DefaultActionGroup( @@ -292,6 +355,8 @@ private fun buildTreePanel(tree: Tree, files: List, badge: DiffStat Separator.getInstance(), TreeAction(KiloBundle.message("diff.editor.tree.expandAll"), AllIcons.Actions.Expandall) { expandAll(tree) }, TreeAction(KiloBundle.message("diff.editor.tree.collapseAll"), AllIcons.Actions.Collapseall) { collapseAll(tree) }, + Separator.getInstance(), + TreeAction(KiloBundle.message("diff.editor.refresh"), AllIcons.Actions.Refresh, refresh), ), true, ) diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties index b57fdb1aee..7a45f15cdc 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -133,6 +133,8 @@ diff.editor.file.title={0} ({1}) diff.editor.branch.tooltip=Compare with base branch diff.editor.session.tooltip=Open changes in editor diff.editor.empty=No changes +diff.editor.outdated=Changes on disk are not shown +diff.editor.refresh=Refresh diff.editor.tree.expandAll=Expand All diff.editor.tree.collapseAll=Collapse All session.part.tool.copy=Copy diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/KiloDiffEditorContentTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/KiloDiffEditorContentTest.kt index 504d8d9097..f4c526d95e 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/KiloDiffEditorContentTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/KiloDiffEditorContentTest.kt @@ -3,11 +3,15 @@ package ai.kilocode.client.diff import ai.kilocode.client.ui.DiffStatBadge import ai.kilocode.rpc.dto.DiffFileDto import com.intellij.openapi.Disposable +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.fileEditor.FileDocumentManager import com.intellij.openapi.util.Disposer import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.ui.EditorNotificationPanel import com.intellij.ui.components.JBScrollPane import com.intellij.ui.treeStructure.Tree import com.intellij.util.ui.JBUI +import com.intellij.util.ui.UIUtil import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -169,6 +173,84 @@ class KiloDiffEditorContentTest : BasePlatformTestCase() { } } + fun `test outdated banner is hidden initially`() { + val parent = Disposer.newDisposable() + try { + val editor = editor(files(), parent) + + assertFalse(banner(editor).isVisible) + } finally { + Disposer.dispose(parent) + } + } + + fun `test outdated banner appears when files change`() { + val parent = Disposer.newDisposable() + try { + val editor = editor(files(), parent) + + editor.markOutdated() + UIUtil.dispatchAllInvocationEvents() + + assertTrue(banner(editor).isVisible) + } finally { + Disposer.dispose(parent) + } + } + + fun `test outdated banner appears for unsaved ide document changes`() { + val parent = Disposer.newDisposable() + try { + val psi = myFixture.addFileToProject("src/App.kt", "old") + val doc = FileDocumentManager.getInstance().getDocument(psi.virtualFile)!! + val dir = psi.virtualFile.parent.parent.path + val editor = editor(files(), parent, dir = dir) + + ApplicationManager.getApplication().runWriteAction { doc.setText("new") } + UIUtil.dispatchAllInvocationEvents() + + assertTrue(banner(editor).isVisible) + } finally { + Disposer.dispose(parent) + } + } + + fun `test manual refresh clears banner and updates files`() { + val parent = Disposer.newDisposable() + try { + val next = listOf(file("src/App.kt", 9, 8)) + val editor = editor(files(), parent) { done -> + done(DiffEditorData.Files(next, "feature/test")) + Job().also { it.complete() } + } + editor.markOutdated() + UIUtil.dispatchAllInvocationEvents() + + editor.refresh() + val badges = components(editor.component).filterIsInstance() + + assertFalse(banner(editor).isVisible) + assertTrue(badges.any { it.addedLabelForTest().text == "+9" && it.removedLabelForTest().text == "-8" }) + } finally { + Disposer.dispose(parent) + } + } + + fun `test editor construction does not refresh`() { + val parent = Disposer.newDisposable() + var calls = 0 + try { + editor(files(), parent) { + calls += 1 + Job().also { it.complete() } + } + + assertEquals(0, calls) + } finally { + Disposer.dispose(parent) + } + } + private fun renderer(tree: Tree, node: DefaultMutableTreeNode): Component = tree.cellRenderer.getTreeCellRendererComponent( tree, @@ -188,19 +270,28 @@ class KiloDiffEditorContentTest : BasePlatformTestCase() { private fun rowBadge(row: Component): DiffStatBadge = components(row).filterIsInstance().single() + private fun banner(editor: DiffEditorView): EditorNotificationPanel = components(editor.component) + .filterIsInstance() + .single() + private fun view(files: List, parent: Disposable): Component = editor(files, parent).component - private fun editor(files: List, parent: Disposable): DiffEditorView { + private fun editor( + files: List, + parent: Disposable, + dir: String = project.basePath.orEmpty(), + load: ((DiffEditorData) -> Unit) -> Job = { Job().also { it.complete() } }, + ): DiffEditorView { val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) Disposer.register(parent) { scope.cancel() } return DiffEditorView( project, - mapOf("directory" to project.basePath.orEmpty(), "source" to "branch"), + mapOf("directory" to dir, "source" to "branch"), files, parent, "feature/test", scope, - { Job().also { it.complete() } }, + load, ) {} } From f0f29d0e0e33411f68f9ac9644bb16d29ff1b3d8 Mon Sep 17 00:00:00 2001 From: kirillk Date: Wed, 29 Jul 2026 20:35:05 -0400 Subject: [PATCH 11/28] feat(jetbrains): enhance branch diff tree view - Color file names with IntelliJ VCS status colors (added/modified/deleted/untracked) - Add tree context menu with Open File (F4) and Refresh actions - Show changed-file count in the toolbar before the diff stat badge - Fix keyboard selection snap-back while preserving debounced buffering --- .../kilocode/backend/cli/KiloCliDataParser.kt | 1 + .../backend/rpc/KiloWorkspaceRpcApiImpl.kt | 28 ++++-- .../backend/cli/ChatDtoSerializationTest.kt | 3 +- .../backend/rpc/BranchDiffBuildTest.kt | 24 ++++- .../ai/kilocode/client/diff/DiffFileStatus.kt | 16 ++++ .../client/diff/KiloDiffEditorContent.kt | 90 +++++++++++++++++-- .../resources/messages/KiloBundle.properties | 1 + .../client/diff/KiloDiffEditorContentTest.kt | 76 +++++++++++++++- .../kotlin/ai/kilocode/rpc/dto/ChatDto.kt | 1 + 9 files changed, 224 insertions(+), 16 deletions(-) create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/DiffFileStatus.kt diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt index 5215b3aaf1..6a40107465 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt @@ -1071,6 +1071,7 @@ object KiloCliDataParser { additions = item.long("additions")?.safeInt() ?: 0, deletions = item.long("deletions")?.safeInt() ?: 0, patch = item.str("patch"), + status = item.str("status"), ) } } diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt index 4177d12d86..13bc944e06 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt @@ -259,13 +259,14 @@ class KiloWorkspaceRpcApiImpl internal constructor( val ref = defaultBranch(base) val anc = ref?.let { git(base, "merge-base", it, "HEAD").trim().ifBlank { null } } ?: "HEAD" val numstat = git(base, "-c", "core.quotepath=false", "diff", "--numstat", "--no-color", "--no-renames", anc) + val names = git(base, "-c", "core.quotepath=false", "diff", "--name-status", "--no-color", "--no-renames", anc) val patch = git(base, "-c", "core.quotepath=false", "diff", "--no-color", "--no-ext-diff", "--no-renames", "--unified=2147483647", anc) val untracked = git(base, "-c", "core.quotepath=false", "ls-files", "--others", "--exclude-standard") .lineSequence() .filter { it.isNotBlank() } .map { untracked(base, it) } .toList() - buildBranchDiff(numstat, patch, untracked, DIFF_CAP) + buildBranchDiff(numstat, patch, untracked, parseNameStatus(names), DIFF_CAP) } override suspend fun branchName(directory: String): String? = withContext(Dispatchers.IO) { @@ -403,15 +404,15 @@ class KiloWorkspaceRpcApiImpl internal constructor( private fun untracked(base: Path, rel: String): DiffFileDto { return runCatching { val path = base.resolve(rel).normalize() - if (!path.startsWith(base) || !path.isRegularFile() || path.fileSize() > LARGE_FILE) return@runCatching DiffFileDto(rel, 0, 0, "") + if (!path.startsWith(base) || !path.isRegularFile() || path.fileSize() > LARGE_FILE) return@runCatching DiffFileDto(rel, 0, 0, "", "untracked") val bytes = path.readBytes() - if (bytes.any { it == 0.toByte() }) return@runCatching DiffFileDto(rel, 0, 0, "") + if (bytes.any { it == 0.toByte() }) return@runCatching DiffFileDto(rel, 0, 0, "", "untracked") val text = bytes.toString(StandardCharsets.UTF_8) val additions = lines(text).size - DiffFileDto(rel, additions, 0, untrackedPatch(rel, text, additions)) + DiffFileDto(rel, additions, 0, untrackedPatch(rel, text, additions), "untracked") }.getOrElse { err -> LOG.debug { "Failed to read untracked file for branch diff: $rel (${err.message})" } - DiffFileDto(rel, 0, 0, "") + DiffFileDto(rel, 0, 0, "", "untracked") } } @@ -478,6 +479,7 @@ internal fun buildBranchDiff( numstat: String, patch: String, untracked: List = emptyList(), + status: Map = emptyMap(), cap: Int = 200_000, ): List { val stats = parseNumstat(numstat) @@ -497,6 +499,7 @@ internal fun buildBranchDiff( additions = stat.additions, deletions = stat.deletions, patch = next, + status = status[stat.path] ?: "modified", ) } return tracked + untracked.map { file -> @@ -528,6 +531,21 @@ private fun lines(text: String): List { private data class DiffStat(val path: String, val additions: Int, val deletions: Int) +internal fun parseNameStatus(text: String): Map = text.lineSequence() + .mapNotNull { line -> + val parts = line.split('\t') + if (parts.size < 2) return@mapNotNull null + val path = parts.drop(1).joinToString("\t").takeIf { it.isNotBlank() } ?: return@mapNotNull null + val status = when (parts[0].firstOrNull()) { + 'A' -> "added" + 'D' -> "deleted" + 'M' -> "modified" + else -> null + } ?: return@mapNotNull null + path to status + } + .toMap() + private fun parseNumstat(text: String): List = text.lineSequence() .mapNotNull { line -> val parts = line.split('\t') diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/ChatDtoSerializationTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/ChatDtoSerializationTest.kt index 551597e33e..dcad88c000 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/ChatDtoSerializationTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/ChatDtoSerializationTest.kt @@ -240,7 +240,7 @@ class ChatDtoSerializationTest { fun `MessageDto summary diffs are preserved in round-trip`() { val msg = msg("msg_1").copy( summary = MessageSummaryDto( - diffs = listOf(DiffFileDto("src/A.kt", 2, 1, "@@ patch")), + diffs = listOf(DiffFileDto("src/A.kt", 2, 1, "@@ patch", "modified")), ), ) @@ -252,6 +252,7 @@ class ChatDtoSerializationTest { val diff = decoded.summary?.diffs?.single() assertEquals("src/A.kt", diff?.file) assertEquals("@@ patch", diff?.patch) + assertEquals("modified", diff?.status) } @Test diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/BranchDiffBuildTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/BranchDiffBuildTest.kt index bfcab184cf..9565a15f9a 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/BranchDiffBuildTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/BranchDiffBuildTest.kt @@ -25,7 +25,7 @@ class BranchDiffBuildTest { +two """.trimIndent() - val diff = buildBranchDiff(numstat, patch) + val diff = buildBranchDiff(numstat, patch, status = mapOf("src/A.kt" to "modified", "src/B.kt" to "added")) assertEquals(listOf("src/A.kt", "src/B.kt"), diff.map { it.file }) assertEquals(1, diff[0].additions) @@ -34,6 +34,22 @@ class BranchDiffBuildTest { assertEquals(0, diff[1].deletions) assertEquals(true, diff[0].patch?.startsWith("diff --git a/src/A.kt") == true) assertEquals(true, diff[1].patch?.startsWith("diff --git a/src/B.kt") == true) + assertEquals("modified", diff[0].status) + assertEquals("added", diff[1].status) + } + + @Test + fun `parses git name status output`() { + val status = parseNameStatus("M\tsrc/A.kt\nA\tsrc/B.kt\nD\tsrc/Old.kt\n??\tsrc/Skip.kt\n") + + assertEquals( + mapOf( + "src/A.kt" to "modified", + "src/B.kt" to "added", + "src/Old.kt" to "deleted", + ), + status, + ) } @Test @@ -71,12 +87,13 @@ class BranchDiffBuildTest { -old +new """.trimIndent(), - untracked = listOf(DiffFileDto("src/New.kt", 2, 0, "patch")), + untracked = listOf(DiffFileDto("src/New.kt", 2, 0, "patch", "untracked")), ) assertEquals(listOf("src/A.kt", "src/New.kt"), diff.map { it.file }) assertEquals(2, diff[1].additions) assertEquals("patch", diff[1].patch) + assertEquals("untracked", diff[1].status) } @Test @@ -84,10 +101,11 @@ class BranchDiffBuildTest { val diff = buildBranchDiff( numstat = "", patch = "", - untracked = listOf(DiffFileDto("src/New.kt", 1, 0, "diff --git a/src/New.kt b/src/New.kt")), + untracked = listOf(DiffFileDto("src/New.kt", 1, 0, "diff --git a/src/New.kt b/src/New.kt", "untracked")), cap = 5, ) assertEquals("", diff.single().patch) + assertEquals("untracked", diff.single().status) } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/DiffFileStatus.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/DiffFileStatus.kt new file mode 100644 index 0000000000..ff316baa96 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/DiffFileStatus.kt @@ -0,0 +1,16 @@ +package ai.kilocode.client.diff + +import ai.kilocode.rpc.dto.DiffFileDto +import com.intellij.openapi.vcs.FileStatus + +internal fun fileStatus(dto: DiffFileDto): FileStatus = when (dto.status) { + "added" -> FileStatus.ADDED + "deleted" -> FileStatus.DELETED + "untracked" -> FileStatus.UNKNOWN + "modified" -> FileStatus.MODIFIED + else -> when { + DiffPatchReconstruct.added(dto.patch) -> FileStatus.ADDED + DiffPatchReconstruct.deleted(dto.patch) -> FileStatus.DELETED + else -> FileStatus.MODIFIED + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloDiffEditorContent.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloDiffEditorContent.kt index 18eb334bca..4384035734 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloDiffEditorContent.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloDiffEditorContent.kt @@ -1,8 +1,10 @@ package ai.kilocode.client.diff +import ai.kilocode.client.app.KiloWorkspaceService import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.ui.DiffStatBadge import ai.kilocode.client.ui.UiStyle +import ai.kilocode.client.ui.layout.Stack import ai.kilocode.rpc.dto.DiffFileDto import com.intellij.diff.chains.DiffRequestProducer import com.intellij.diff.chains.SimpleDiffRequestChain @@ -14,11 +16,13 @@ import com.intellij.openapi.actionSystem.ActionManager import com.intellij.openapi.actionSystem.ActionPlaces import com.intellij.openapi.actionSystem.ActionUpdateThread import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.CommonShortcuts import com.intellij.openapi.actionSystem.DefaultActionGroup import com.intellij.openapi.actionSystem.IdeActions import com.intellij.openapi.actionSystem.Separator import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.application.ModalityState +import com.intellij.openapi.components.service import com.intellij.openapi.editor.EditorFactory import com.intellij.openapi.editor.event.DocumentEvent import com.intellij.openapi.editor.event.DocumentListener @@ -29,15 +33,19 @@ import com.intellij.openapi.progress.ProgressIndicator import com.intellij.openapi.util.Disposer import com.intellij.openapi.util.Key import com.intellij.openapi.util.UserDataHolder +import com.intellij.openapi.vcs.FileStatus import com.intellij.openapi.vfs.AsyncFileListener import com.intellij.openapi.vfs.VirtualFileManager import com.intellij.openapi.vfs.newvfs.events.VFileEvent import com.intellij.ui.IdeBorderFactory import com.intellij.ui.EditorNotificationPanel import com.intellij.ui.OnePixelSplitter +import com.intellij.ui.PopupHandler import com.intellij.ui.SideBorder import com.intellij.ui.SimpleColoredComponent +import com.intellij.ui.SimpleTextAttributes import com.intellij.ui.TreeSpeedSearch +import com.intellij.ui.components.JBLabel import com.intellij.ui.components.JBScrollPane import com.intellij.ui.treeStructure.Tree import com.intellij.util.concurrency.annotations.RequiresEdt @@ -87,7 +95,7 @@ internal class DiffEditorView( initial: List, private val parent: Disposable, branch: String?, - scope: CoroutineScope, + private val scope: CoroutineScope, private val load: ((DiffEditorData) -> Unit) -> Job, private val replace: (DiffEditorData) -> Unit, ) : Disposable { @@ -110,7 +118,26 @@ internal class DiffEditorView( private var files = initial private var branch = branch private var syncing = false + private var requested: String? = initial.firstOrNull()?.file private var processor = processor(initial, selected(initial.firstOrNull()?.file)) + private val openFileAction = object : DumbAwareAction( + KiloBundle.message("diff.editor.openFile"), + KiloBundle.message("diff.editor.openFile"), + AllIcons.Actions.EditSource, + ) { + override fun getActionUpdateThread() = ActionUpdateThread.EDT + + override fun update(e: AnActionEvent) { + val file = selectedFile() + e.presentation.isEnabled = file != null && fileStatus(file) != FileStatus.DELETED && path(file) != null + } + + override fun actionPerformed(e: AnActionEvent) { + val file = selectedFile() ?: return + val path = path(file) ?: return + scope.launch { service().openFile(path) } + } + } val component: JComponent = root init { @@ -122,6 +149,8 @@ internal class DiffEditorView( val index = files.indexOfFirst { it.file == file.file } if (index >= 0) select.request(index) } + openFileAction.registerCustomShortcutSet(CommonShortcuts.getEditSource(), tree) + installMenu() processor.addListener(DiffRequestProcessorListener { syncTree() }, parent) splitter.firstComponent = buildTreePanel(tree, initial, badge, processor.component, ::refresh) splitter.secondComponent = processor.component @@ -143,6 +172,7 @@ internal class DiffEditorView( val old = processor files = next branch = nextBranch + requested = next.getOrNull(index)?.file tree.model = buildFileModel(next) expandAll(tree) processor = processor(next, index) @@ -185,9 +215,24 @@ internal class DiffEditorView( private fun show(index: Int) { if (disposed.get() || project.isDisposed || index !in files.indices) return + val path = files[index].file + if (activePath() == path) { + requested = null + return + } + requested = path processor.setCurrentRequest(index) } + private fun installMenu() { + val group = DefaultActionGroup( + openFileAction, + Separator.getInstance(), + TreeAction(KiloBundle.message("diff.editor.refresh"), AllIcons.Actions.Refresh, ::refresh), + ) + PopupHandler.installPopupMenu(tree, group, ActionPlaces.POPUP) + } + @RequiresEdt private fun showOutdated() { if (disposed.get() || project.isDisposed) return @@ -251,8 +296,22 @@ internal class DiffEditorView( private fun syncTree() { if (disposed.get()) return val path = activePath() ?: return - if (selectedFile()?.file == path) return - select(path) + val target = reverseSyncTarget(path, requested, selectedFile()?.file) + if (path == requested) requested = null + if (target == null) return + select(target) + } + + private fun path(file: DiffFileDto): String? { + if (fileStatus(file) == FileStatus.DELETED) return null + val dir = params["directory"] ?: return null + return try { + val raw = Path.of(file.file) + val path = if (raw.isAbsolute) raw else Path.of(dir).resolve(raw) + path.normalize().toString() + } catch (_: InvalidPathException) { + null + } } private fun select(path: String?) { @@ -298,6 +357,13 @@ internal class DiffEditorView( } } +internal fun reverseSyncTarget(active: String?, requested: String?, selected: String?): String? { + if (active == null) return null + if (requested != null) return null + if (active == selected) return null + return active +} + private class Debouncer( private val scope: CoroutineScope, parent: Disposable, @@ -369,7 +435,14 @@ private fun buildTreePanel(tree: Tree, files: List, badge: DiffStat border = IdeBorderFactory.createBorder(SideBorder.BOTTOM) add(toolbar.component, BorderLayout.WEST) badge.update(files.sumOf { it.additions }, files.sumOf { it.deletions }) - add(badge, BorderLayout.EAST) + add( + Stack.horizontal(gap = UiStyle.Gap.sm()).apply { + border = JBUI.Borders.empty(0, 0, 0, UiStyle.Gap.pad()) + next(JBLabel(fileCount(files.size)).apply { foreground = UiStyle.Colors.weak() }) + next(badge) + }, + BorderLayout.EAST, + ) } return object : JPanel(BorderLayout()) { override fun getBackground(): Color = JBUI.CurrentTheme.ToolWindow.background() @@ -386,6 +459,11 @@ private fun buildTreePanel(tree: Tree, files: List, badge: DiffStat } } +private fun fileCount(count: Int): String = KiloBundle.message( + if (count == 1) "session.changes.count.one" else "session.changes.count.other", + count, +) + private fun expandAll(tree: Tree) { var i = 0 while (i < tree.rowCount) { @@ -484,7 +562,9 @@ private class Renderer : JPanel(BorderLayout()), TreeCellRenderer { val item = node?.userObject as? Node text.clear() text.icon = if (item?.dir == true) AllIcons.Nodes.Folder else AllIcons.FileTypes.Text - text.append(item?.name?.ifBlank { item.path }.orEmpty()) + val name = item?.name?.ifBlank { item.path }.orEmpty() + val color = item?.file?.let(::fileStatus)?.color + if (color == null) text.append(name) else text.append(name, SimpleTextAttributes(SimpleTextAttributes.STYLE_PLAIN, color)) val changed = item != null && (item.additions != 0 || item.deletions != 0) badge.isVisible = changed if (changed) badge.update(item.additions, item.deletions) diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties index 7a45f15cdc..f0f19e2b7c 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -134,6 +134,7 @@ diff.editor.branch.tooltip=Compare with base branch diff.editor.session.tooltip=Open changes in editor diff.editor.empty=No changes diff.editor.outdated=Changes on disk are not shown +diff.editor.openFile=Open File diff.editor.refresh=Refresh diff.editor.tree.expandAll=Expand All diff.editor.tree.collapseAll=Collapse All diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/KiloDiffEditorContentTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/KiloDiffEditorContentTest.kt index f4c526d95e..057eef0b99 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/KiloDiffEditorContentTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/KiloDiffEditorContentTest.kt @@ -6,8 +6,11 @@ import com.intellij.openapi.Disposable import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.fileEditor.FileDocumentManager import com.intellij.openapi.util.Disposer +import com.intellij.openapi.vcs.FileStatus +import com.intellij.ui.SimpleColoredComponent import com.intellij.testFramework.fixtures.BasePlatformTestCase import com.intellij.ui.EditorNotificationPanel +import com.intellij.ui.components.JBLabel import com.intellij.ui.components.JBScrollPane import com.intellij.ui.treeStructure.Tree import com.intellij.util.ui.JBUI @@ -37,6 +40,28 @@ class KiloDiffEditorContentTest : BasePlatformTestCase() { } } + fun `test tree toolbar shows changed file count`() { + val parent = Disposer.newDisposable() + try { + val view = view(files(), parent) + + assertTrue(components(view).filterIsInstance().any { it.text == "2 files" }) + } finally { + Disposer.dispose(parent) + } + } + + fun `test tree toolbar shows singular changed file count`() { + val parent = Disposer.newDisposable() + try { + val view = view(listOf(file("src/App.kt", 2, 1)), parent) + + assertTrue(components(view).filterIsInstance().any { it.text == "1 file" }) + } finally { + Disposer.dispose(parent) + } + } + fun `test tree renderer shows compact row change badge`() { val parent = Disposer.newDisposable() try { @@ -51,6 +76,34 @@ class KiloDiffEditorContentTest : BasePlatformTestCase() { } } + fun `test tree renderer uses file status color`() { + val parent = Disposer.newDisposable() + try { + val color = FileStatus.ADDED.color ?: return + val view = view(listOf(file("src/App.kt", 2, 0, status = "added")), parent) + val tree = components(view).filterIsInstance().single() + val row = renderer(tree, leaf(tree)) + val text = components(row).filterIsInstance().single() + val iter = text.iterator() + + assertTrue(iter.hasNext()) + iter.next() + assertEquals(color, iter.textAttributes.fgColor) + } finally { + Disposer.dispose(parent) + } + } + + fun `test explicit and patch-derived file statuses`() { + assertEquals(FileStatus.ADDED, fileStatus(file("src/New.kt", 1, 0, status = "added"))) + assertEquals(FileStatus.MODIFIED, fileStatus(file("src/App.kt", 1, 1, status = "modified"))) + assertEquals(FileStatus.DELETED, fileStatus(file("src/Old.kt", 0, 1, status = "deleted"))) + assertEquals(FileStatus.UNKNOWN, fileStatus(file("src/Unknown.kt", 1, 0, status = "untracked"))) + assertEquals(FileStatus.ADDED, fileStatus(file("src/New.kt", 1, 0, patch = "--- /dev/null\n+++ b/src/New.kt"))) + assertEquals(FileStatus.DELETED, fileStatus(file("src/Old.kt", 0, 1, patch = "--- a/src/Old.kt\n+++ /dev/null"))) + assertEquals(FileStatus.MODIFIED, fileStatus(file("src/App.kt", 1, 1, patch = "@@ -1 +1 @@\n-old\n+new"))) + } + fun `test row renderer places badge east of filename`() { val parent = Disposer.newDisposable() try { @@ -251,6 +304,18 @@ class KiloDiffEditorContentTest : BasePlatformTestCase() { } } + fun `test reverse sync skips active requested path`() { + assertNull(reverseSyncTarget("src/App.kt", "src/App.kt", "test/AppTest.kt")) + } + + fun `test reverse sync waits while requested path is pending`() { + assertNull(reverseSyncTarget("src/App.kt", "test/AppTest.kt", "src/App.kt")) + } + + fun `test reverse sync returns active path for diff-driven navigation`() { + assertEquals("test/AppTest.kt", reverseSyncTarget("test/AppTest.kt", null, "src/App.kt")) + } + private fun renderer(tree: Tree, node: DefaultMutableTreeNode): Component = tree.cellRenderer.getTreeCellRendererComponent( tree, @@ -310,10 +375,17 @@ class KiloDiffEditorContentTest : BasePlatformTestCase() { file("test/AppTest.kt", 3, 3), ) - private fun file(path: String, additions: Int, deletions: Int) = DiffFileDto( + private fun file( + path: String, + additions: Int, + deletions: Int, + patch: String? = "@@ -1 +1 @@\n-old\n+new", + status: String? = null, + ) = DiffFileDto( file = path, additions = additions, deletions = deletions, - patch = "@@ -1 +1 @@\n-old\n+new", + patch = patch, + status = status, ) } diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt index a38c9b902c..1bcbbcfd0c 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt @@ -405,6 +405,7 @@ data class DiffFileDto( val additions: Int, val deletions: Int, val patch: String? = null, + val status: String? = null, ) // --- Config Update --- From 6bfb59c502b9587053b8156231f16a3292589176 Mon Sep 17 00:00:00 2001 From: kirillk Date: Thu, 30 Jul 2026 11:44:24 -0400 Subject: [PATCH 12/28] fix(jetbrains): improve inline diff controls --- .../jetbrains-inline-diff-improvements.md | 5 + .../ai/kilocode/client/diff/DiffBlocks.kt | 9 +- .../kilocode/client/diff/DiffLineNumbers.kt | 89 +++++++++++++++++ .../client/diff/KiloDiffEditorContent.kt | 9 +- .../client/diff/KiloDiffEditorKind.kt | 5 +- .../client/diff/KiloInlineDiffStore.kt | 16 +++ .../client/session/SessionFileLinks.kt | 2 + .../ai/kilocode/client/session/SessionUi.kt | 13 +++ .../client/session/ui/ModifiedFilesView.kt | 33 ++++++- .../session/ui/SessionMessageListPanel.kt | 17 +++- .../client/session/views/MessageView.kt | 12 ++- .../client/session/views/SessionViewIcons.kt | 1 + .../kilocode/client/session/views/TurnView.kt | 15 ++- .../client/session/views/ViewFactory.kt | 21 +++- .../views/base/AbstractSessionPartView.kt | 11 +++ .../client/session/views/tool/EditToolView.kt | 49 +++++++++- .../client/session/views/tool/PatchBody.kt | 22 ++++- .../session/views/tool/ToolMarkdownBody.kt | 11 +++ .../client/session/views/tool/ToolSupport.kt | 2 +- .../ai/kilocode/client/ui/DiffStatBadge.kt | 6 +- .../main/resources/icons/views/open-diff.svg | 5 + .../resources/icons/views/open-diff_dark.svg | 5 + .../resources/messages/KiloBundle.properties | 6 ++ .../client/diff/DiffLineNumbersTest.kt | 97 +++++++++++++++++++ .../client/diff/KiloDiffEditorContentTest.kt | 20 ++++ .../kilocode/client/ui/DiffStatBadgeTest.kt | 44 +++++++++ 26 files changed, 506 insertions(+), 19 deletions(-) create mode 100644 .changeset/jetbrains-inline-diff-improvements.md create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/DiffLineNumbers.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloInlineDiffStore.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/resources/icons/views/open-diff.svg create mode 100644 packages/kilo-jetbrains/frontend/src/main/resources/icons/views/open-diff_dark.svg create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/DiffLineNumbersTest.kt create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/DiffStatBadgeTest.kt diff --git a/.changeset/jetbrains-inline-diff-improvements.md b/.changeset/jetbrains-inline-diff-improvements.md new file mode 100644 index 0000000000..cb5546e40f --- /dev/null +++ b/.changeset/jetbrains-inline-diff-improvements.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Improve JetBrains inline diff cards with diff-viewer actions, cleaner change badges, and real old/new line numbers. diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/DiffBlocks.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/DiffBlocks.kt index ab73369016..1f509171fa 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/DiffBlocks.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/DiffBlocks.kt @@ -9,7 +9,12 @@ import com.intellij.diff.util.DiffUserDataKeys import com.intellij.openapi.fileTypes.FileTypeManager import com.intellij.openapi.project.Project -internal fun diffRequest(project: Project, dto: DiffFileDto, branch: String? = null): DiffRequest { +internal fun diffRequest( + project: Project, + dto: DiffFileDto, + branch: String? = null, + labels: Pair = KiloBundle.message("diff.editor.side.base") to KiloBundle.message("diff.editor.side.current"), +): DiffRequest { val sides = DiffPatchReconstruct.sides(dto) val type = FileTypeManager.getInstance().getFileTypeByFileName(dto.file) val factory = DiffContentFactory.getInstance() @@ -23,7 +28,7 @@ internal fun diffRequest(project: Project, dto: DiffFileDto, branch: String? = n sides.renderable -> factory.create(project, sides.after, type) else -> factory.create(project, dto.patch ?: "diff unavailable", type) } - return SimpleDiffRequest(diffTitle(dto.file, branch), left, right, "Base", "Current").also { + return SimpleDiffRequest(diffTitle(dto.file, branch), left, right, labels.first, labels.second).also { it.putUserData(DiffUserDataKeys.FORCE_READ_ONLY, true) } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/DiffLineNumbers.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/DiffLineNumbers.kt new file mode 100644 index 0000000000..af6a6ddeb5 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/DiffLineNumbers.kt @@ -0,0 +1,89 @@ +package ai.kilocode.client.diff + +import ai.kilocode.client.session.views.tool.diffMeta +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.editor.Editor +import com.intellij.openapi.editor.TextAnnotationGutterProvider +import com.intellij.openapi.editor.colors.ColorKey +import com.intellij.openapi.editor.colors.EditorFontType +import com.intellij.ui.EditorTextField +import java.awt.Color + +object DiffLineNumbers { + data class Row(val old: Int?, val new: Int?) + + private val HUNK = Regex("^@@ -(\\d+)(?:,\\d+)? \\+(\\d+)(?:,\\d+)? @@") + + fun rows(patch: String): List { + val rows = mutableListOf>() + var old = 0 + var new = 0 + var hunk = false + patch.lineSequence().forEach { line -> + val match = HUNK.find(line) + if (match != null) { + old = match.groupValues[1].toInt() + new = match.groupValues[2].toInt() + hunk = true + return@forEach + } + if (diffMeta(line)) return@forEach + if (!hunk) return@forEach + when { + line.startsWith("+") -> rows.add(line to Row(null, new++)) + line.startsWith("-") -> rows.add(line to Row(old++, null)) + line.startsWith("\\") -> rows.add(line to Row(null, null)) + else -> rows.add(line to Row(old++, new++)) + } + } + return rows.trimBlankEdges().map { it.second } + } + + private fun List>.trimBlankEdges(): List> { + val start = indexOfFirst { it.first.isNotBlank() } + if (start < 0) return emptyList() + val end = indexOfLast { it.first.isNotBlank() } + return subList(start, end + 1) + } +} + +fun installDiffGutter(field: EditorTextField, rows: List) { + val ed = field.getEditor(true) ?: return + ed.settings.isLineNumbersShown = false + ed.gutter.closeAllAnnotations() + ed.gutter.registerTextAnnotation(DiffGutter(rows)) +} + +private const val FIGURE = '\u2007' + +private class DiffGutter(private val rows: List) : TextAnnotationGutterProvider { + private val oldWidth = width { it.old } + private val newWidth = width { it.new } + + override fun getLineText(line: Int, editor: Editor): String? { + val row = rows.getOrNull(line) ?: return null + // The gutter paints with a proportional font, so pad with the figure space (digit-width) + // to right-align both columns. Each column keeps a fixed width even when a side is blank, + // and trailing figure spaces add a right inset before the code text. + return "${col(row.old, oldWidth)}$FIGURE${col(row.new, newWidth)}$FIGURE$FIGURE" + } + + override fun getToolTip(line: Int, editor: Editor): String? = null + + override fun getStyle(line: Int, editor: Editor): EditorFontType = EditorFontType.PLAIN + + override fun getColor(line: Int, editor: Editor): ColorKey? = null + + override fun getBgColor(line: Int, editor: Editor): Color? = null + + override fun gutterClosed() = Unit + + override fun getPopupActions(line: Int, editor: Editor): List? = null + + override fun useMargin(): Boolean = false + + private fun width(pick: (DiffLineNumbers.Row) -> Int?): Int = + rows.mapNotNull(pick).maxOrNull()?.toString()?.length ?: 1 + + private fun col(value: Int?, width: Int): String = value?.toString().orEmpty().padStart(width, FIGURE) +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloDiffEditorContent.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloDiffEditorContent.kt index 4384035734..b2074b4bcd 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloDiffEditorContent.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloDiffEditorContent.kt @@ -288,11 +288,18 @@ internal class DiffEditorView( private fun producer(file: DiffFileDto): DiffRequestProducer = object : DiffRequestProducer { override fun getName(): String = file.file - override fun process(context: UserDataHolder, indicator: ProgressIndicator) = diffRequest(project, file, branch).also { + override fun process(context: UserDataHolder, indicator: ProgressIndicator) = diffRequest(project, file, branch, labels()).also { it.putUserData(DIFF_FILE_KEY, file.file) } } + private fun labels(): Pair { + if (params["source"] == "branch") { + return KiloBundle.message("diff.editor.side.base") to KiloBundle.message("diff.editor.side.current") + } + return KiloBundle.message("diff.editor.side.original") to KiloBundle.message("diff.editor.side.modified") + } + private fun syncTree() { if (disposed.get()) return val path = activePath() ?: return diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloDiffEditorKind.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloDiffEditorKind.kt index 385f3a7c9c..c1c6bf15c0 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloDiffEditorKind.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloDiffEditorKind.kt @@ -51,6 +51,7 @@ internal object KiloDiffEditorKind : KiloEditorKind { val dir = params["directory"].takeIfPresent() ?: return false if (dir.isBlank()) return false if (params["source"] == "branch") return true + if (params["source"] == "inline") return params["token"].takeIfPresent() != null return params["sessionId"].takeIfPresent() != null } @@ -147,6 +148,7 @@ internal class KiloDiffEditorService( val workspace = service() val files = when (params["source"]) { "branch" -> workspace.branchDiff(dir) + "inline" -> project.service().get(params["token"].orEmpty()).orEmpty() else -> project.service().diff(params["sessionId"].orEmpty(), dir) } if (files.isEmpty()) return DiffEditorData.Empty @@ -167,7 +169,7 @@ internal sealed interface DiffEditorData { data class Files(val files: List, val branch: String? = null) : DiffEditorData } -internal fun diffParams(source: String, directory: String, sessionId: String?, title: String, branch: String? = null): Map = +internal fun diffParams(source: String, directory: String, sessionId: String?, title: String, branch: String? = null, token: String? = null): Map = linkedMapOf( "source" to source, "directory" to directory, @@ -175,6 +177,7 @@ internal fun diffParams(source: String, directory: String, sessionId: String?, t ).apply { if (!sessionId.isNullOrBlank()) put("sessionId", sessionId) if (!branch.isNullOrBlank()) put("branch", branch) + if (!token.isNullOrBlank()) put("token", token) } fun ensureDiffEditorKind() { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloInlineDiffStore.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloInlineDiffStore.kt new file mode 100644 index 0000000000..8cf7260cee --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloInlineDiffStore.kt @@ -0,0 +1,16 @@ +package ai.kilocode.client.diff + +import ai.kilocode.rpc.dto.DiffFileDto +import com.intellij.openapi.components.Service +import java.util.concurrent.ConcurrentHashMap + +@Service(Service.Level.PROJECT) +class KiloInlineDiffStore { + private val items = ConcurrentHashMap>() + + fun put(token: String, files: List) { + items[token] = files + } + + fun get(token: String): List? = items[token] +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionFileLinks.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionFileLinks.kt index 0ba88641c5..daee52fea8 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionFileLinks.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionFileLinks.kt @@ -5,6 +5,7 @@ import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.telemetry.Telemetry import ai.kilocode.client.ui.md.MdView import ai.kilocode.rpc.isManagedWorktreeStorage +import ai.kilocode.rpc.dto.DiffFileDto import ai.kilocode.rpc.dto.WorkspaceFileDto import com.intellij.icons.AllIcons import com.intellij.openapi.fileTypes.FileTypeManager @@ -27,6 +28,7 @@ import javax.swing.JComponent import javax.swing.JList typealias SessionFileOpener = (href: String, anchor: RelativePoint?) -> Unit +typealias SessionDiffOpener = (files: List, title: String, key: String) -> Unit fun MdView.LinkEvent.anchor(): RelativePoint? { val component = component ?: return null diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt index a388534ec5..9ebd381df4 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt @@ -5,6 +5,7 @@ import ai.kilocode.client.app.KiloSessionService import ai.kilocode.client.app.KiloWorkspaceService import ai.kilocode.client.app.Workspace import ai.kilocode.client.diff.KiloDiffEditorKind +import ai.kilocode.client.diff.KiloInlineDiffStore import ai.kilocode.client.diff.diffParams import ai.kilocode.client.diff.ensureDiffEditorKind import ai.kilocode.client.migration.KiloMigrationService @@ -58,6 +59,7 @@ import ai.kilocode.client.util.UiTimers import ai.kilocode.client.vfs.KiloVfsManager import ai.kilocode.log.ChatLogSummary import ai.kilocode.rpc.dto.ModelLimitDto +import ai.kilocode.rpc.dto.DiffFileDto import ai.kilocode.rpc.dto.PromptDto import ai.kilocode.rpc.dto.PromptPartDto import ai.kilocode.rpc.dto.SessionRevertDto @@ -373,6 +375,7 @@ class SessionUi( deleteQueued = { id -> controller.deleteQueuedMessage(id) }, banner = RevertBanner(controller.model, ::redo, controller::redoAll, ::cancelRevert, focus), ).also { + it.setDiffOpener(::openInlineDiff, controller.id) it.onHover = { view, on -> if (on) popup.show(view) else popup.notifyExit(view) } } header = SessionHeaderPanel(controller, this) { @@ -821,6 +824,16 @@ class SessionUi( BrowserUtil.browse(url) } + private fun openInlineDiff(files: List, title: String, key: String) { + ensureDiffEditorKind() + project.service().put(key, files) + project.service().open( + KiloDiffEditorKind.ID, + diffParams("inline", workspace.directory, controller.id, title, token = key), + ) + Telemetry.send("Diff Editor Opened", mapOf("source" to "inline")) + } + private fun openAttachment(messageId: String, item: FileAttachment) { val url = item.url.takeIf { it.isNotBlank() } ?: run { LOG.info("kind=attachment-open skipped=true reason=blank-url message=$messageId part=${item.id} name=${attachmentName(item)} mime=${item.mime}") diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ModifiedFilesView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ModifiedFilesView.kt index be357d3065..38fe7dffb9 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ModifiedFilesView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ModifiedFilesView.kt @@ -1,6 +1,7 @@ package ai.kilocode.client.session.ui import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.session.SessionDiffOpener import ai.kilocode.client.session.SessionFileOpener import ai.kilocode.client.session.model.Content import ai.kilocode.client.session.ui.popup.HeaderPopupBody @@ -18,8 +19,10 @@ import ai.kilocode.client.session.views.tool.setForeground import ai.kilocode.client.session.views.tool.setIcon import ai.kilocode.client.telemetry.Telemetry import ai.kilocode.client.ui.DiffBars +import ai.kilocode.client.ui.ToolbarButtonAction import ai.kilocode.client.ui.UiStyle import ai.kilocode.client.ui.layout.Stack +import ai.kilocode.client.ui.toolbarButton import ai.kilocode.rpc.dto.DiffFileDto import com.intellij.openapi.util.Disposer import com.intellij.ui.components.JBLabel @@ -40,6 +43,10 @@ class ModifiedFilesView private constructor( private var style = SessionEditorStyle.current() private var files = emptyList() + private var diffs = emptyList() + private var openDiff: SessionDiffOpener = { _, _, _ -> } + private var sessionId: String? = null + private var turnId: String = CONTENT_ID constructor( openFile: SessionFileOpener, @@ -48,16 +55,26 @@ class ModifiedFilesView private constructor( init { body.parent = this + parts.diff.addActionListener { openDiffViewer() } isVisible = false - bindHeader(parts.glyph, parts.title, parts.count, parts.center, parts.controls) + bindHeader(parts.glyph, parts.title, parts.count, parts.center, parts.controls, parts.bars) + unbindHeader(parts.diff) applyStyle(style) } + fun setDiffOpener(openDiff: SessionDiffOpener, sessionId: String?, turnId: String) { + this.openDiff = openDiff + this.sessionId = sessionId + this.turnId = turnId + } + @RequiresEdt fun setDiffs(diffs: List) { val next = diffs.map(::file) + this.diffs = diffs if (files == next) { val visible = next.isNotEmpty() + parts.diff.isVisible = visible if (isVisible == visible) return isVisible = visible revalidate() @@ -71,6 +88,7 @@ class ModifiedFilesView private constructor( if (isVisible != visible) isVisible = visible if (!visible) collapse() parts.update(files.size, additions, deletions) + parts.diff.isVisible = visible if (isExpanded()) body.updateFiles(files) revalidate() repaint() @@ -118,6 +136,11 @@ class ModifiedFilesView private constructor( @RequiresEdt internal fun countText() = parts.count.text + private fun openDiffViewer() { + if (diffs.isEmpty()) return + openDiff(diffs, KiloBundle.message("diff.editor.inline.title"), "turn:${sessionId ?: "pending"}:$turnId") + } + @RequiresEdt private fun buildPopup(files: List): HeaderPopupBody { val owner = Disposer.newDisposable("Modified files popup body") @@ -131,11 +154,15 @@ class ModifiedFilesView private constructor( val glyph = JBLabel() val title = JBLabel(KiloBundle.message("session.changes.modified")) val count = JBLabel() - private val bars = DiffBars(0, 0) + val diff = toolbarButton( + ToolbarButtonAction(SessionViewIcons.openDiff, KiloBundle.message("session.part.tool.openDiff")) {}, + ).apply { isVisible = false } + val bars = DiffBars(0, 0) + private val titleRow = Stack.horizontal(UiStyle.Gap.sm()).next(title).next(count).next(diff) val center = JPanel(BorderLayout(UiStyle.Gap.md(), 0)).apply { isOpaque = false minimumSize = Dimension(0, minimumSize.height) - add(Stack.horizontal(UiStyle.Gap.sm()).next(title).next(count), BorderLayout.WEST) + add(titleRow, BorderLayout.WEST) } val controls: JComponent = Stack.horizontal().next(bars) // Match edit/patch cards: glyph on the left, text in the center, and stats in the control slot. diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt index a6561f65b4..3f185c6645 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt @@ -1,5 +1,6 @@ package ai.kilocode.client.session.ui +import ai.kilocode.client.session.SessionDiffOpener import ai.kilocode.client.session.SessionFileOpener import ai.kilocode.client.session.model.SessionModel import ai.kilocode.client.session.model.SessionModelEvent @@ -79,6 +80,8 @@ class SessionMessageListPanel( private var hiddenTool: ToolCallRef? = null private var hovered: PartView? = null private var revertingMessage: String? = null + private var openDiff: SessionDiffOpener = { _, _, _ -> } + private var sessionId: String? = null var onHover: ((PartView, Boolean) -> Unit)? = null @@ -179,6 +182,12 @@ class SessionMessageListPanel( rebuild() } + fun setDiffOpener(openDiff: SessionDiffOpener, sessionId: String?) { + this.openDiff = openDiff + this.sessionId = sessionId + turnViews.values.forEach { it.setDiffOpener(openDiff, sessionId) } + } + // ------ public lookup API ------ /** Find the [MessageView] for a message by id, or null if not present. */ @@ -227,7 +236,9 @@ class SessionMessageListPanel( // ------ private event handlers ------ private fun onTurnAdded(turn: ai.kilocode.client.session.model.Turn) { - val tv = TurnView(turn.id, openFile, style, openUrl, selection, openAttachment, resize, repo, ::hover, revert, deleteQueued) + val tv = TurnView(turn.id, openFile, style, openUrl, selection, openAttachment, resize, repo, ::hover, revert, deleteQueued).also { + it.setDiffOpener(openDiff, sessionId) + } turnViews[turn.id] = tv for (msgId in turn.messageIds) { val msg = model.message(msgId) ?: continue @@ -294,7 +305,9 @@ class SessionMessageListPanel( removeAll() for (turn in model.turns()) { - val tv = TurnView(turn.id, openFile, style, openUrl, selection, openAttachment, resize, repo, ::hover, revert, deleteQueued) + val tv = TurnView(turn.id, openFile, style, openUrl, selection, openAttachment, resize, repo, ::hover, revert, deleteQueued).also { + it.setDiffOpener(openDiff, sessionId) + } turnViews[turn.id] = tv for (msgId in turn.messageIds) { val msg = model.message(msgId) ?: continue diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt index 5ccc77850f..9bc37ce8a2 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt @@ -1,5 +1,6 @@ package ai.kilocode.client.session.views +import ai.kilocode.client.session.SessionDiffOpener import ai.kilocode.client.session.SessionFileOpener import ai.kilocode.client.session.model.Compaction import ai.kilocode.client.session.model.Content @@ -92,6 +93,8 @@ class MessageView( private var prompt: PromptView? = null private var promptBox: JPanel? = null private var wrap: PromptWrap? = null + private var openDiff: SessionDiffOpener = { _, _, _ -> } + private var sessionId: String? = null init { isOpaque = false @@ -106,6 +109,11 @@ class MessageView( } } + fun setDiffOpener(openDiff: SessionDiffOpener, sessionId: String?) { + this.openDiff = openDiff + this.sessionId = sessionId + } + /** * Suppress the running/pending question tool part that matches [ref] while * the linked question request is active. Pass null to stop suppressing. @@ -340,9 +348,9 @@ class MessageView( } private fun view(content: Content) = if (msg.info.role == SessionUiStyle.View.Message.USER_ROLE) { - ViewFactory.createUser(content, openFile, openUrl, selection, repo, promptMentions(msg)) { openAttachment(msg.info.id, it) } + ViewFactory.createUser(content, openFile, openUrl, selection, repo, promptMentions(msg), { openAttachment(msg.info.id, it) }, openDiff, sessionId) } else { - ViewFactory.create(content, openFile, openUrl, selection, repo) { openAttachment(msg.info.id, it) } + ViewFactory.create(content, openFile, openUrl, selection, repo, { openAttachment(msg.info.id, it) }, openDiff, sessionId) } private fun syncPromptMentions() { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/SessionViewIcons.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/SessionViewIcons.kt index bb382b2242..96ff1086ae 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/SessionViewIcons.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/SessionViewIcons.kt @@ -20,6 +20,7 @@ object SessionViewIcons { val eye = icon("eye") val glasses = icon("glasses") val mcp = icon("mcp") + val openDiff = icon("open-diff") val ruleApprove = icon("check-small") val ruleApproveActive = icon("check-small-active") val ruleDeny = icon("close-small") diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TurnView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TurnView.kt index bef3741b14..40e58411fa 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TurnView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TurnView.kt @@ -1,5 +1,6 @@ package ai.kilocode.client.session.views +import ai.kilocode.client.session.SessionDiffOpener import ai.kilocode.client.session.SessionFileOpener import ai.kilocode.client.session.model.FileAttachment import ai.kilocode.client.session.model.Message @@ -44,6 +45,8 @@ class TurnView( private val messages = LinkedHashMap() private var modified: ModifiedFilesView? = null private var settled = true + private var openDiff: SessionDiffOpener = { _, _, _ -> } + private var sessionId: String? = null override val sessionViewKind = SessionView.Kind.Default @@ -54,6 +57,13 @@ class TurnView( isOpaque = false } + fun setDiffOpener(openDiff: SessionDiffOpener, sessionId: String?) { + this.openDiff = openDiff + this.sessionId = sessionId + modified?.setDiffOpener(openDiff, sessionId, id) + messages.values.forEach { it.setDiffOpener(openDiff, sessionId) } + } + @RequiresEdt fun setSettled(value: Boolean) { if (settled == value) return @@ -67,7 +77,9 @@ class TurnView( /** Add a new [MessageView] for [msg] at the end of this turn. */ fun addMessage(msg: Message): MessageView { - val view = MessageView(msg, openFile, style, openUrl, selection, openAttachment, resize, repo, hover, revert) + val view = MessageView(msg, openFile, style, openUrl, selection, openAttachment, resize, repo, hover, revert).also { + it.setDiffOpener(openDiff, sessionId) + } messages[msg.info.id] = view val idx = modified?.let { components.indexOf(it) } ?: componentCount add(view, idx) @@ -79,6 +91,7 @@ class TurnView( @RequiresEdt fun setDiffs(diffs: List) { val card = modified ?: if (diffs.isEmpty()) null else ModifiedFilesView(openFile, selection).also { + it.setDiffOpener(openDiff, sessionId, id) it.resize = resize it.hover = hover it.applyStyle(style) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ViewFactory.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ViewFactory.kt index bc6cf2b314..1d24afe0b0 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ViewFactory.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ViewFactory.kt @@ -1,5 +1,6 @@ package ai.kilocode.client.session.views +import ai.kilocode.client.session.SessionDiffOpener import ai.kilocode.client.session.SessionFileOpener import ai.kilocode.client.session.views.base.GenericView import ai.kilocode.client.session.views.base.PartView @@ -36,6 +37,12 @@ object ViewFactory { openFile: SessionFileOpener, ): PartView = create(content, openFile, openUrl = {}, selection = null, repo = null) + fun create( + content: Content, + openFile: SessionFileOpener, + openUrl: (String) -> Unit, + ): PartView = create(content, openFile, openUrl = openUrl, selection = null, repo = null) + fun create( content: Content, openFile: SessionFileOpener, @@ -43,6 +50,8 @@ object ViewFactory { selection: SessionSelection? = null, repo: String? = null, openAttachment: (FileAttachment) -> Unit = { AttachmentView.openDefault(it, openFile, openUrl) }, + openDiff: SessionDiffOpener = { _, _, _ -> }, + sessionId: String? = null, ): PartView = when (content) { is Text -> TextView(content, openFile = openFile, openUrl = openUrl, selection = selection) is Reasoning -> ReasoningView(content, openFile = openFile, openUrl = openUrl, selection = selection) @@ -55,7 +64,7 @@ object ViewFactory { GlobToolView.canRender(content) -> GlobToolView(content, selection = selection, repo = repo) SearchToolView.canRender(content) -> SearchToolView(content, selection = selection, repo = repo) ReadToolView.canRender(content) -> ReadToolView(content, openFile, selection = selection) - EditToolView.canRender(content) -> EditToolView(content, openFile, selection = selection) + EditToolView.canRender(content) -> EditToolView(content, openFile, selection, openDiff, sessionId) TaskToolView.canRender(content) -> TaskToolView(content, selection = selection) else -> ToolView(content, selection = selection) } @@ -69,6 +78,12 @@ object ViewFactory { openFile: SessionFileOpener, ): PartView = createUser(content, openFile, openUrl = {}, selection = null, repo = null) + fun createUser( + content: Content, + openFile: SessionFileOpener, + openUrl: (String) -> Unit, + ): PartView = createUser(content, openFile, openUrl = openUrl, selection = null, repo = null) + fun createUser( content: Content, openFile: SessionFileOpener, @@ -77,9 +92,11 @@ object ViewFactory { repo: String? = null, mentions: List = emptyList(), openAttachment: (FileAttachment) -> Unit = { AttachmentView.openDefault(it, openFile, openUrl) }, + openDiff: SessionDiffOpener = { _, _, _ -> }, + sessionId: String? = null, ): PartView = when (content) { is Text -> PromptView(content, openFile = openFile, openAttachment = openAttachment, openUrl = openUrl, selection = selection, mentions = mentions) - else -> create(content, openFile, openUrl, selection, repo, openAttachment) + else -> create(content, openFile, openUrl, selection, repo, openAttachment, openDiff, sessionId) } /** diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/AbstractSessionPartView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/AbstractSessionPartView.kt index 6e252b4364..0cb5919666 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/AbstractSessionPartView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/AbstractSessionPartView.kt @@ -124,6 +124,10 @@ abstract class AbstractSessionPartView( items.forEach { bind(it) } } + protected fun unbindHeader(vararg items: Component) { + items.forEach { unbind(it) } + } + protected fun refresh() { revalidate() repaint() @@ -151,6 +155,13 @@ abstract class AbstractSessionPartView( component.addMouseListener(mouse) } + private fun unbind(component: Component) { + if (!bound.remove(component)) return + component.removeMouseListener(click) + component.removeMouseListener(mouse) + component.cursor = Cursor.getDefaultCursor() + } + private fun body(): JComponent { val item = body if (item != null) return item diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/EditToolView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/EditToolView.kt index 48c0dcb4f1..492a2108b3 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/EditToolView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/EditToolView.kt @@ -1,6 +1,8 @@ package ai.kilocode.client.session.views.tool +import ai.kilocode.client.diff.DiffLineNumbers import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.session.SessionDiffOpener import ai.kilocode.client.session.SessionFileOpener import ai.kilocode.client.session.model.Content import ai.kilocode.client.session.model.Tool @@ -10,12 +12,16 @@ import ai.kilocode.client.session.ui.popup.HeaderPopupRequest import ai.kilocode.client.session.ui.selection.SessionSelection import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionUiStyle +import ai.kilocode.client.session.views.SessionViewIcons import ai.kilocode.client.session.views.base.SecondarySessionPartView import ai.kilocode.client.telemetry.Telemetry import ai.kilocode.client.ui.DiffStatBadge +import ai.kilocode.client.ui.ToolbarButtonAction import ai.kilocode.client.ui.UiStyle import ai.kilocode.client.ui.md.MdCodeBlockBorder import ai.kilocode.client.ui.md.MdCodeBlockOptions +import ai.kilocode.client.ui.toolbarButton +import ai.kilocode.rpc.dto.DiffFileDto import com.intellij.openapi.actionSystem.DataSink import com.intellij.openapi.actionSystem.UiDataProvider import com.intellij.openapi.util.Disposer @@ -45,7 +51,12 @@ class EditToolView( private var item = tool private var style = SessionEditorStyle.current() private var multi = editFiles(tool).size > 1 + private var opener: SessionDiffOpener = { _, _, _ -> } + private var sessionId: String? = null private val badge = DiffStatBadge(0, 0) + private val diff = toolbarButton( + ToolbarButtonAction(SessionViewIcons.openDiff, KiloBundle.message("session.part.tool.openDiff"), ::openDiffViewer), + ).apply { isVisible = false } private val filesTag = JBLabel().apply { foreground = UiStyle.Colors.weak() font = JBFont.small() @@ -55,13 +66,26 @@ class EditToolView( init { body.parent = this + parts.slot.add(diff) parts.controls.add(filesTag) parts.controls.add(badge) - bindHeader(parts.glyph, parts.title, parts.sub, parts.state, parts.center, parts.controls, parts.slot, filesTag, badge) + bindHeader(parts.glyph, parts.title, parts.sub, parts.link, parts.state, parts.center, parts.controls, parts.slot, filesTag, badge) + unbindHeader(diff) applyStyle(style) sync() } + constructor( + tool: Tool, + openFile: SessionFileOpener, + selection: SessionSelection?, + openDiff: SessionDiffOpener, + sessionId: String?, + ) : this(tool, openFile, selection) { + opener = openDiff + this.sessionId = sessionId + } + override fun uiDataSnapshot(sink: DataSink) { selection?.provideCopy(sink) { body.markdown() ?: diffMarkdown(item) } } @@ -184,11 +208,18 @@ class EditToolView( changed = setForeground(parts.link, UiStyle.Colors.fg()) || changed changed = setText(parts.state, stateText(item)) || changed changed = setForeground(parts.state, color(item)) || changed + changed = setVisible(diff, editDiff(item).isNotBlank()) || changed changed = syncFilesTag(count) || changed changed = syncBadge() || changed return changed } + private fun openDiffViewer() { + val files = toDiffFiles(item) + if (files.isEmpty()) return + opener(files, diffTitle(files), "tool:${sessionId ?: "pending"}:${item.id}") + } + private fun syncFilesTag(count: Int): Boolean { val show = count > 1 var changed = setVisible(filesTag, show) @@ -224,6 +255,20 @@ class EditToolView( } } +private fun toDiffFiles(tool: Tool): List { + val files = editFiles(tool).map { DiffFileDto(it.path, it.additions, it.deletions, it.patch, it.type.ifBlank { null }) } + if (files.isNotEmpty()) return files + val patch = editDiff(tool) + if (patch.isBlank()) return emptyList() + val stat = diffStat(tool) + return listOf(DiffFileDto(editPath(tool), stat.first, stat.second, patch)) +} + +private fun diffTitle(files: List): String { + if (files.size == 1) return tail(files.single().file) + return KiloBundle.message("diff.editor.inline.title") +} + /** Picks the multi-file patch body for apply_patch spanning several files, else the single diff. */ private fun editBody(tool: Tool, selection: SessionSelection?, openFile: SessionFileOpener): EditBody = if (editFiles(tool).size > 1) PatchBody(selection, openFile) else diffBody(selection) @@ -240,12 +285,14 @@ private fun diffBody(selection: SessionSelection?) = ToolMarkdownBody( ), selection, render = ::diffMarkdown, + gutter = { editDiff(it).takeIf { patch -> patch.isNotBlank() }?.let(DiffLineNumbers::rows) }, ) private fun popupDiffBody(selection: SessionSelection?) = ToolMarkdownBody( POPUP_OPTS, selection, render = ::diffMarkdown, + gutter = { editDiff(it).takeIf { patch -> patch.isNotBlank() }?.let(DiffLineNumbers::rows) }, ) internal val POPUP_OPTS = MdCodeBlockOptions( diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/PatchBody.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/PatchBody.kt index 97cfb20a4e..fffdcc879d 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/PatchBody.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/PatchBody.kt @@ -1,5 +1,7 @@ package ai.kilocode.client.session.views.tool +import ai.kilocode.client.diff.DiffLineNumbers +import ai.kilocode.client.diff.installDiffGutter import ai.kilocode.client.session.SessionFileOpener import ai.kilocode.client.session.model.Tool import ai.kilocode.client.session.ui.selection.SessionSelection @@ -17,6 +19,7 @@ import com.intellij.openapi.Disposable import com.intellij.openapi.util.Disposer import com.intellij.ui.EditorTextField import com.intellij.ui.components.JBScrollPane +import com.intellij.util.ui.NamedColorUtil import com.intellij.util.concurrency.annotations.RequiresEdt import com.intellij.util.ui.JBUI import java.awt.Component @@ -62,6 +65,7 @@ class PatchBody( private val links = mutableListOf() private var style = SessionEditorStyle.current() private var signature = "" + private val rows = mutableListOf>() @RequiresEdt override fun mount(tool: Tool): JComponent = mountFiles(editFiles(tool)) @@ -127,6 +131,7 @@ class PatchBody( owner = null views.clear() links.clear() + rows.clear() panel?.removeAll() signature = "" } @@ -147,6 +152,9 @@ class PatchBody( Disposer.register(disposable, md) applyMd(md) md.set(patchMarkdown(file.patch)) + val nums = DiffLineNumbers.rows(file.patch) + rows.add(nums) + installGutter(md, nums) views.add(md) panel.next(md.component) } @@ -172,7 +180,10 @@ class PatchBody( .next(DiffStatBadge(file.additions, file.deletions)) return JBUI.Panels.simplePanel(row).apply { isOpaque = false - border = JBUI.Borders.emptyLeft(SessionUiStyle.View.Code.VIEWPORT_HORIZONTAL_PADDING) + border = JBUI.Borders.compound( + JBUI.Borders.customLineBottom(NamedColorUtil.getBoundsColor()), + JBUI.Borders.emptyLeft(SessionUiStyle.View.Code.VIEWPORT_HORIZONTAL_PADDING), + ) } } @@ -185,9 +196,18 @@ class PatchBody( md.preBg = style.editorBackground md.codeFont = style.editorFamily md.component.border = JBUI.Borders.empty() + rows.getOrNull(views.indexOf(md))?.let { installGutter(md, it) } return before != md.font } + @RequiresEdt + private fun installGutter(md: MdView, rows: List) { + ((md.component as? JPanel)?.components + ?.filterIsInstance() + ?.mapNotNull { it.viewport.view as? EditorTextField } + ?: emptyList()).forEach { installDiffGutter(it, rows) } + } + private companion object { val DIFF_OPTS = MdCodeBlockOptions( border = MdCodeBlockBorder.Bottom, diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolMarkdownBody.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolMarkdownBody.kt index 3b5e1830e9..412bd15e14 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolMarkdownBody.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolMarkdownBody.kt @@ -1,5 +1,7 @@ package ai.kilocode.client.session.views.tool +import ai.kilocode.client.diff.DiffLineNumbers +import ai.kilocode.client.diff.installDiffGutter import ai.kilocode.client.session.model.Tool import ai.kilocode.client.session.ui.selection.SessionSelection import ai.kilocode.client.session.ui.style.SessionEditorStyle @@ -31,6 +33,7 @@ class ToolMarkdownBody( private val opts: MdCodeBlockOptions, private val selection: SessionSelection?, private val render: (Tool) -> String, + private val gutter: ((Tool) -> List?)? = null, private val font: (SessionEditorStyle) -> Font = SessionEditorStyle::editorFont, private val chrome: (MdView) -> Unit = {}, ) : EditBody { @@ -47,6 +50,7 @@ class ToolMarkdownBody( view = md applyStyle(SessionEditorStyle.current()) update(tool) + syncGutter(tool) return md.component } @@ -66,6 +70,7 @@ class ToolMarkdownBody( if (md.markdown() == value) return false md.set(value) chrome(md) + syncGutter(tool) return true } @@ -84,6 +89,12 @@ class ToolMarkdownBody( return before != md.font } + @RequiresEdt + private fun syncGutter(tool: Tool) { + val rows = gutter?.invoke(tool) ?: return + codeEditors().forEach { installDiffGutter(it, rows) } + } + @RequiresEdt override fun markdown(): String? = view?.markdown() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolSupport.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolSupport.kt index 4ba944fc88..8fac817f52 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolSupport.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolSupport.kt @@ -861,7 +861,7 @@ internal fun pureDiff(diff: String): String = diff.lineSequence() .joinToString("\n") .trim('\n') -private fun diffMeta(line: String): Boolean = line.startsWith("Index:") || +internal fun diffMeta(line: String): Boolean = line.startsWith("Index:") || line.startsWith("====") || line.startsWith("diff --git ") || line.startsWith("@@") || diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/DiffStatBadge.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/DiffStatBadge.kt index 3fba5f3962..5e866f468b 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/DiffStatBadge.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/DiffStatBadge.kt @@ -67,8 +67,10 @@ internal class DiffStatBadge( } fun update(additions: Int, deletions: Int) { - removed.text = "-$deletions" - added.text = "+$additions" + removed.isVisible = deletions > 0 + added.isVisible = additions > 0 + if (removed.isVisible) removed.text = "-$deletions" + if (added.isVisible) added.text = "+$additions" } override fun paintComponent(g: Graphics) { diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/open-diff.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/open-diff.svg new file mode 100644 index 0000000000..ec9ece4281 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/open-diff.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/open-diff_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/open-diff_dark.svg new file mode 100644 index 0000000000..a7599e54e7 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/open-diff_dark.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties index f0f19e2b7c..4d45cf9619 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -127,9 +127,14 @@ session.changes.modified=Modified session.changes.count.one={0} file session.changes.count.other={0} files diff.editor.session.title=Session Changes +diff.editor.inline.title=Kilo changes diff.editor.branch.title=Changes vs base branch diff.editor.branch.title.named=Changes vs base branch ({0}) diff.editor.file.title={0} ({1}) +diff.editor.side.base=Base +diff.editor.side.current=Current +diff.editor.side.original=Original +diff.editor.side.modified=Modified diff.editor.branch.tooltip=Compare with base branch diff.editor.session.tooltip=Open changes in editor diff.editor.empty=No changes @@ -139,6 +144,7 @@ diff.editor.refresh=Refresh diff.editor.tree.expandAll=Expand All diff.editor.tree.collapseAll=Collapse All session.part.tool.copy=Copy +session.part.tool.openDiff=Open in Diff Viewer session.part.tool.error=Error session.part.tool.agent={0} Agent session.part.tool.pending=Pending diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/DiffLineNumbersTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/DiffLineNumbersTest.kt new file mode 100644 index 0000000000..e9957aa6b0 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/DiffLineNumbersTest.kt @@ -0,0 +1,97 @@ +package ai.kilocode.client.diff + +import ai.kilocode.client.session.views.tool.pureDiff +import com.intellij.testFramework.fixtures.BasePlatformTestCase + +class DiffLineNumbersTest : BasePlatformTestCase() { + fun `test rows align with pure diff display lines`() { + fixtures().forEach { patch -> + assertEquals(pureDiff(patch).trim('\n').lines().size, DiffLineNumbers.rows(patch).size) + } + } + + fun `test modified hunk emits old and new counters`() { + val patch = """ + @@ -1,3 +1,3 @@ + keep + -old + +new + done + """.trimIndent() + + assertEquals( + listOf( + DiffLineNumbers.Row(1, 1), + DiffLineNumbers.Row(2, null), + DiffLineNumbers.Row(null, 2), + DiffLineNumbers.Row(3, 3), + ), + DiffLineNumbers.rows(patch), + ) + } + + fun `test multi hunk resets counters`() { + val patch = """ + @@ -1,1 +1,1 @@ + -old + +new + @@ -10,1 +20,1 @@ + keep + """.trimIndent() + + assertEquals( + listOf( + DiffLineNumbers.Row(1, null), + DiffLineNumbers.Row(null, 1), + DiffLineNumbers.Row(10, 20), + ), + DiffLineNumbers.rows(patch), + ) + } + + fun `test no newline marker emits empty row`() { + val patch = """ + @@ -1 +1 @@ + -old + \ No newline at end of file + +new + """.trimIndent() + + assertEquals( + listOf( + DiffLineNumbers.Row(1, null), + DiffLineNumbers.Row(null, null), + DiffLineNumbers.Row(null, 1), + ), + DiffLineNumbers.rows(patch), + ) + } + + private fun fixtures() = listOf( + """ + diff --git a/src/App.kt b/src/App.kt + index 111..222 100644 + --- a/src/App.kt + +++ b/src/App.kt + @@ -1,2 +1,2 @@ + keep + -old + +new + """.trimIndent(), + """ + --- /dev/null + +++ b/src/New.kt + @@ -0,0 +1,2 @@ + +one + +two + """.trimIndent(), + """ + --- a/src/Old.kt + +++ /dev/null + @@ -1,2 +0,0 @@ + -one + -two + """.trimIndent(), + "@@ -1 +1 @@\r\n-old\r\n+new\r\n", + ) +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/KiloDiffEditorContentTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/KiloDiffEditorContentTest.kt index 057eef0b99..689855f213 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/KiloDiffEditorContentTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/KiloDiffEditorContentTest.kt @@ -194,6 +194,26 @@ class KiloDiffEditorContentTest : BasePlatformTestCase() { assertEquals("src/App.kt (feature/test)", request.title) } + fun `test diff params includes inline token`() { + val params = diffParams("inline", "/repo", "ses_1", "Kilo changes", token = "tool:ses_1:p1") + + assertEquals("inline", params["source"]) + assertEquals("/repo", params["directory"]) + assertEquals("ses_1", params["sessionId"]) + assertEquals("Kilo changes", params["title"]) + assertEquals("tool:ses_1:p1", params["token"]) + } + + fun `test inline params require directory and token`() { + assertTrue(KiloDiffEditorKind.isValid(diffParams("inline", "/repo", null, "Kilo changes", token = "turn:ses_1:u1"))) + assertFalse(KiloDiffEditorKind.isValid(mapOf("source" to "inline", "directory" to "/repo", "title" to "Kilo changes"))) + assertFalse(KiloDiffEditorKind.isValid(mapOf("source" to "inline", "token" to "turn:ses_1:u1", "title" to "Kilo changes"))) + } + + fun `test inline editor title uses params title`() { + assertEquals("Kilo changes", KiloDiffEditorKind.title(diffParams("inline", "/repo", null, "Kilo changes", token = "token"))) + } + fun `test reload updates aggregate badge`() { val parent = Disposer.newDisposable() try { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/DiffStatBadgeTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/DiffStatBadgeTest.kt new file mode 100644 index 0000000000..176cdd48d9 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/DiffStatBadgeTest.kt @@ -0,0 +1,44 @@ +package ai.kilocode.client.ui + +import com.intellij.testFramework.fixtures.BasePlatformTestCase + +class DiffStatBadgeTest : BasePlatformTestCase() { + fun `test hides deletion label when deletions are zero`() { + val badge = DiffStatBadge(3, 0) + + assertTrue(badge.addedLabelForTest().isVisible) + assertEquals("+3", badge.addedLabelForTest().text) + assertFalse(badge.removedLabelForTest().isVisible) + } + + fun `test hides addition label when additions are zero`() { + val badge = DiffStatBadge(0, 2) + + assertTrue(badge.removedLabelForTest().isVisible) + assertEquals("-2", badge.removedLabelForTest().text) + assertFalse(badge.addedLabelForTest().isVisible) + } + + fun `test both zero leaves badge empty`() { + val badge = DiffStatBadge(0, 0) + + assertFalse(badge.removedLabelForTest().isVisible) + assertFalse(badge.addedLabelForTest().isVisible) + } + + fun `test update toggles zero side visibility`() { + val badge = DiffStatBadge(1, 1) + + badge.update(0, 4) + assertTrue(badge.removedLabelForTest().isVisible) + assertFalse(badge.addedLabelForTest().isVisible) + + badge.update(5, 0) + assertFalse(badge.removedLabelForTest().isVisible) + assertTrue(badge.addedLabelForTest().isVisible) + + badge.update(0, 0) + assertFalse(badge.removedLabelForTest().isVisible) + assertFalse(badge.addedLabelForTest().isVisible) + } +} From ab9a80b1aa3d59173fbb1522973e2db322930fbd Mon Sep 17 00:00:00 2001 From: kirillk Date: Thu, 30 Jul 2026 13:12:06 -0400 Subject: [PATCH 13/28] fix(jetbrains): align session part headers --- .../client/session/ui/ModifiedFilesView.kt | 26 ++----- .../client/session/views/ReasoningView.kt | 9 +-- .../client/session/views/base/PartHeader.kt | 58 +++++++++++++++ .../session/views/todo/TodoWriteView.kt | 28 ++----- .../session/views/tool/BaseSearchToolView.kt | 4 +- .../client/session/views/tool/EditToolView.kt | 9 ++- .../client/session/views/tool/ReadToolView.kt | 2 +- .../session/views/tool/ShellToolView.kt | 2 +- .../client/session/views/tool/TaskToolView.kt | 2 +- .../client/session/views/tool/ToolSupport.kt | 55 +++++--------- .../client/session/views/tool/ToolView.kt | 2 +- .../kotlin/ai/kilocode/client/ui/DiffBars.kt | 6 +- .../session/views/SearchToolViewTest.kt | 10 ++- .../client/session/views/ToolViewTest.kt | 11 ++- .../session/views/base/PartHeaderTest.kt | 73 +++++++++++++++++++ .../session/views/todo/TodoWriteViewTest.kt | 9 +-- 16 files changed, 196 insertions(+), 110 deletions(-) create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/PartHeader.kt create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/PartHeaderTest.kt diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ModifiedFilesView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ModifiedFilesView.kt index 38fe7dffb9..2518c7f3ec 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ModifiedFilesView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ModifiedFilesView.kt @@ -10,6 +10,7 @@ import ai.kilocode.client.session.ui.selection.SessionSelection import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.session.views.SessionViewIcons +import ai.kilocode.client.session.views.base.PartHeader import ai.kilocode.client.session.views.base.SecondarySessionPartView import ai.kilocode.client.session.views.tool.EditFileChange import ai.kilocode.client.session.views.tool.POPUP_OPTS @@ -21,17 +22,11 @@ import ai.kilocode.client.telemetry.Telemetry import ai.kilocode.client.ui.DiffBars import ai.kilocode.client.ui.ToolbarButtonAction import ai.kilocode.client.ui.UiStyle -import ai.kilocode.client.ui.layout.Stack import ai.kilocode.client.ui.toolbarButton import ai.kilocode.rpc.dto.DiffFileDto import com.intellij.openapi.util.Disposer import com.intellij.ui.components.JBLabel import com.intellij.util.concurrency.annotations.RequiresEdt -import com.intellij.util.ui.JBUI -import java.awt.BorderLayout -import java.awt.Dimension -import javax.swing.JComponent -import javax.swing.JPanel class ModifiedFilesView private constructor( private val openFile: SessionFileOpener, @@ -57,7 +52,7 @@ class ModifiedFilesView private constructor( body.parent = this parts.diff.addActionListener { openDiffViewer() } isVisible = false - bindHeader(parts.glyph, parts.title, parts.count, parts.center, parts.controls, parts.bars) + bindHeader(parts.glyph, parts.title, parts.count, parts.panel.left, parts.panel.right, parts.bars) unbindHeader(parts.diff) applyStyle(style) } @@ -158,19 +153,10 @@ class ModifiedFilesView private constructor( ToolbarButtonAction(SessionViewIcons.openDiff, KiloBundle.message("session.part.tool.openDiff")) {}, ).apply { isVisible = false } val bars = DiffBars(0, 0) - private val titleRow = Stack.horizontal(UiStyle.Gap.sm()).next(title).next(count).next(diff) - val center = JPanel(BorderLayout(UiStyle.Gap.md(), 0)).apply { - isOpaque = false - minimumSize = Dimension(0, minimumSize.height) - add(titleRow, BorderLayout.WEST) - } - val controls: JComponent = Stack.horizontal().next(bars) - // Match edit/patch cards: glyph on the left, text in the center, and stats in the control slot. - val panel: JComponent = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.Layout.GAP), 0)).apply { - isOpaque = false - add(glyph, BorderLayout.WEST) - add(center, BorderLayout.CENTER) - add(controls, BorderLayout.EAST) + // Glyph, title, count, and the open-diff action on the left; diff bars hug the right edge. + val panel = PartHeader().apply { + left(glyph, title, count, PartHeader.centered(diff)) + right(PartHeader.centered(bars)) } @RequiresEdt diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ReasoningView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ReasoningView.kt index e5a9269ad6..965ab5ac97 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ReasoningView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ReasoningView.kt @@ -12,6 +12,7 @@ import ai.kilocode.client.session.ui.popup.HeaderPopupRequest import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.selection.SessionSelection import ai.kilocode.client.session.ui.style.SessionUiStyle +import ai.kilocode.client.session.views.base.PartHeader import ai.kilocode.client.session.views.base.SecondarySessionPartView import ai.kilocode.client.telemetry.Telemetry import ai.kilocode.client.ui.UiStyle @@ -330,7 +331,7 @@ class ReasoningView( } class ReasoningParts( - val header: JPanel, + val header: PartHeader, val title: JBLabel, val icon: JBLabel, private val selection: SessionSelection?, @@ -385,11 +386,7 @@ class ReasoningBody( private fun reasoningParts(selection: SessionSelection? = null): ReasoningParts { val title = JBLabel(KiloBundle.message("session.part.reasoning")).apply { foreground = UiStyle.Colors.weak() } val icon = JBLabel(SessionViewIcons.brain).apply { foreground = UiStyle.Colors.weak() } - val header = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.Layout.GAP), 0)).apply { - isOpaque = false - add(icon, BorderLayout.WEST) - add(title, BorderLayout.CENTER) - } + val header = PartHeader().apply { left(icon, title) } return ReasoningParts(header, title, icon, selection) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/PartHeader.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/PartHeader.kt new file mode 100644 index 0000000000..9469d56e98 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/PartHeader.kt @@ -0,0 +1,58 @@ +package ai.kilocode.client.session.views.base + +import ai.kilocode.client.session.ui.style.SessionUiStyle +import ai.kilocode.client.ui.layout.HAlign +import ai.kilocode.client.ui.layout.Stack +import ai.kilocode.client.ui.layout.VAlign +import ai.kilocode.client.ui.layout.align +import com.intellij.util.ui.JBUI +import java.awt.BorderLayout +import java.awt.Component +import javax.swing.JComponent +import javax.swing.JPanel + +/** + * Shared session-card header. A [BorderLayout] row with a left group, an optional + * flexible middle that absorbs remaining width and clips (e.g. a file path), and a + * right group that hugs the trailing edge. + * + * The collapse/expand arrow is owned by [AbstractSessionPartView] and sits to the + * right of this header, so together they realise the west (left) / center (right + * group) / east (arrow) layout. + * + * Every child is stretched to the full header height by the [left]/[right] [Stack]s. + * Text labels center vertically by default, so add them directly. Fixed-size controls + * (icons, badges, diff bars) must be added via [centered] so they keep their preferred + * size and stay centered instead of stretching to the full height. + */ +class PartHeader : JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.Layout.GAP), 0)) { + val left = Stack.horizontal(JBUI.scale(SessionUiStyle.View.Layout.GAP)) + val right = Stack.horizontal(JBUI.scale(SessionUiStyle.View.Layout.GAP)) + + init { + isOpaque = false + add(left, BorderLayout.WEST) + add(right, BorderLayout.EAST) + } + + fun left(vararg items: Component): PartHeader { + items.forEach { left.next(it) } + return this + } + + fun right(vararg items: Component): PartHeader { + items.forEach { right.next(it) } + return this + } + + /** Flexible middle that absorbs remaining width and clips its content. */ + fun fill(component: JComponent): PartHeader { + add(component, BorderLayout.CENTER) + return this + } + + companion object { + /** Wraps a fixed-size control so it keeps its preferred size and stays centered. */ + fun centered(component: Component): JComponent = component.align(HAlign.CENTER, VAlign.CENTER) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/todo/TodoWriteView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/todo/TodoWriteView.kt index ef7483eeef..3ff13a3d37 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/todo/TodoWriteView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/todo/TodoWriteView.kt @@ -7,15 +7,14 @@ import ai.kilocode.client.session.model.ToolExecState import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.session.views.SessionViewIcons +import ai.kilocode.client.session.views.base.PartHeader import ai.kilocode.client.session.views.base.PrimarySessionPartView import ai.kilocode.client.ui.UiStyle +import ai.kilocode.client.ui.layout.Stack import com.intellij.ui.components.JBLabel import com.intellij.util.ui.JBUI -import java.awt.BorderLayout import java.awt.Font -import javax.swing.Box import javax.swing.JComponent -import javax.swing.JPanel class TodoWriteView(tool: Tool, private val parts: TodoParts = todoParts()) : PrimarySessionPartView(parts.header, parts.list, expanded = true) { @@ -26,7 +25,7 @@ class TodoWriteView(tool: Tool, private val parts: TodoParts = todoParts()) : private var style = SessionEditorStyle.current() init { - bindHeader(parts.glyph, parts.title, parts.sub, parts.center, parts.controls) + bindHeader(parts.glyph, parts.title, parts.sub, parts.left, parts.right) parts.list.border = JBUI.Borders.compound( JBUI.Borders.customLine( SessionUiStyle.View.Outline.color(), @@ -92,12 +91,12 @@ class TodoWriteView(tool: Tool, private val parts: TodoParts = todoParts()) : } class TodoParts( - val header: JPanel, + val header: PartHeader, val glyph: JBLabel, val title: JBLabel, val sub: JBLabel, - val center: JPanel, - val controls: JComponent, + val left: Stack, + val right: Stack, val list: TodoListPanel, ) @@ -105,19 +104,8 @@ private fun todoParts(): TodoParts { val glyph = JBLabel(SessionViewIcons.checklist) val title = JBLabel(KiloBundle.message("session.part.todo.title")) val sub = JBLabel().apply { foreground = UiStyle.Colors.weak() } - val center = JPanel(BorderLayout(UiStyle.Gap.md(), 0)).apply { - isOpaque = false - add(title, BorderLayout.WEST) - add(sub, BorderLayout.CENTER) - } - val controls = Box.createHorizontalBox() - val header = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.Layout.GAP), 0)).apply { - isOpaque = false - add(glyph, BorderLayout.WEST) - add(center, BorderLayout.CENTER) - add(controls, BorderLayout.EAST) - } - return TodoParts(header, glyph, title, sub, center, controls, TodoListPanel()) + val header = PartHeader().apply { left(glyph, title, sub) } + return TodoParts(header, glyph, title, sub, header.left, header.right, TodoListPanel()) } private fun subtitle(tool: Tool): String { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/BaseSearchToolView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/BaseSearchToolView.kt index 24d36cff64..e4dcb2d915 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/BaseSearchToolView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/BaseSearchToolView.kt @@ -34,7 +34,7 @@ abstract class BaseSearchToolView( protected abstract fun viewName(): String init { - bindHeader(parts.glyph, parts.title, parts.sub, parts.state, parts.center, parts.controls, parts.slot) + bindHeader(parts.glyph, parts.title, parts.sub, parts.state, parts.left, parts.right, parts.slot) parts.targets.forEach { bindHeader(it) } applyStyle(style) sync() @@ -104,7 +104,7 @@ abstract class BaseSearchToolView( @RequiresEdt internal fun headerComponent() = parts.header @RequiresEdt - internal fun centerComponent() = parts.center + internal fun centerComponent() = parts.fill @RequiresEdt internal fun targetComponents() = parts.targets diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/EditToolView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/EditToolView.kt index 492a2108b3..c189563b6d 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/EditToolView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/EditToolView.kt @@ -13,6 +13,7 @@ import ai.kilocode.client.session.ui.selection.SessionSelection import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.session.views.SessionViewIcons +import ai.kilocode.client.session.views.base.PartHeader import ai.kilocode.client.session.views.base.SecondarySessionPartView import ai.kilocode.client.telemetry.Telemetry import ai.kilocode.client.ui.DiffStatBadge @@ -66,10 +67,10 @@ class EditToolView( init { body.parent = this - parts.slot.add(diff) - parts.controls.add(filesTag) - parts.controls.add(badge) - bindHeader(parts.glyph, parts.title, parts.sub, parts.link, parts.state, parts.center, parts.controls, parts.slot, filesTag, badge) + parts.slot.add(PartHeader.centered(diff)) + parts.right.next(filesTag) + parts.right.next(PartHeader.centered(badge)) + bindHeader(parts.glyph, parts.title, parts.sub, parts.link, parts.state, parts.left, parts.right, parts.slot, filesTag, badge) unbindHeader(diff) applyStyle(style) sync() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ReadToolView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ReadToolView.kt index c9b80039d1..9ac9d158be 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ReadToolView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ReadToolView.kt @@ -34,7 +34,7 @@ class ReadToolView( init { parts.text?.let { selection?.register(it, this) } - bindHeader(parts.glyph, parts.title, parts.sub, parts.state, parts.center, parts.controls, parts.slot) + bindHeader(parts.glyph, parts.title, parts.sub, parts.state, parts.left, parts.right, parts.slot) parts.text?.text = preview(item) applyStyle(style) sync() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ShellToolView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ShellToolView.kt index a14bd9f526..2bfca46931 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ShellToolView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ShellToolView.kt @@ -43,7 +43,7 @@ class ShellToolView( init { body.parent = this - bindHeader(parts.glyph, parts.title, parts.sub, parts.state, parts.center, parts.controls, parts.slot) + bindHeader(parts.glyph, parts.title, parts.sub, parts.state, parts.left, parts.right, parts.slot) applyStyle(style) sync() } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/TaskToolView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/TaskToolView.kt index 0feb6c454a..89b76161bb 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/TaskToolView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/TaskToolView.kt @@ -42,7 +42,7 @@ class TaskToolView( private var collapsed = false init { - bindHeader(parts.glyph, parts.title, parts.sub, parts.state, parts.center, parts.controls, parts.slot) + bindHeader(parts.glyph, parts.title, parts.sub, parts.state, parts.left, parts.right, parts.slot) applyStyle(style) sync() if (item.childTools.isNotEmpty()) expand() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolSupport.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolSupport.kt index 8fac817f52..01b81bbc94 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolSupport.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolSupport.kt @@ -12,12 +12,10 @@ import ai.kilocode.client.session.ui.selection.SessionCopyTarget import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.session.views.SessionViewIcons +import ai.kilocode.client.session.views.base.PartHeader import ai.kilocode.client.ui.UiStyle import ai.kilocode.client.ui.editor.BashCommandHighlighter -import ai.kilocode.client.ui.layout.HAlign import ai.kilocode.client.ui.layout.Stack -import ai.kilocode.client.ui.layout.VAlign -import ai.kilocode.client.ui.layout.align import ai.kilocode.cli.KiloCliParser import ai.kilocode.log.KiloLog import com.intellij.openapi.actionSystem.DataSink @@ -44,7 +42,6 @@ import kotlinx.serialization.json.contentOrNull import kotlinx.serialization.json.intOrNull import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive -import java.awt.BorderLayout import java.awt.Color import java.awt.Cursor import java.awt.Dimension @@ -62,15 +59,16 @@ private val LOG = KiloLog.create(ToolParts::class.java) enum class ToolBodyMode { EDITOR, TEXT } class ToolParts( - val header: JPanel, + val header: PartHeader, val glyph: JBLabel, val title: JBLabel, val sub: JBLabel, val link: FileLinkLabel, val slot: JPanel, val state: JBLabel, - val center: JPanel, - val controls: JComponent, + val left: Stack, + val right: Stack, + val fill: JComponent, val extra: JBLabel? = null, val targets: List = emptyList(), private val mode: ToolBodyMode = ToolBodyMode.EDITOR, @@ -412,23 +410,12 @@ internal fun toolParts( next(link) } val state = clip(JBLabel()).apply { foreground = UiStyle.Colors.weak() } - val center = JPanel(BorderLayout(UiStyle.Gap.md(), 0)).apply { - isOpaque = false - minimumSize = Dimension(0, minimumSize.height) - } - val controls = Stack.horizontal() - val header = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.Layout.GAP), 0)).apply { - isOpaque = false - center.add(title, BorderLayout.WEST) - center.add(slot, BorderLayout.CENTER) - add(glyph, BorderLayout.WEST) - add(center, BorderLayout.CENTER) - add(controls, BorderLayout.EAST) - } - val parts = ToolParts(header, glyph, title, sub, link, slot, state, center, controls, mode = mode) - return parts.also { - controls.add(it.state) + val header = PartHeader().apply { + left(glyph, title) + fill(slot) + right(state) } + return ToolParts(header, glyph, title, sub, link, slot, state, header.left, header.right, fill = slot, mode = mode) } @RequiresEdt @@ -448,24 +435,16 @@ internal fun searchParts(count: Int): ToolParts { next(link) } val state = clip(JBLabel()).apply { foreground = UiStyle.Colors.weak() } - val stack = Stack.fitHorizontal(UiStyle.Gap.md()).apply { targets.forEach { next(it) } } - val target = stack.align(HAlign.TRACK, VAlign.CENTER) - val center = JPanel(BorderLayout(UiStyle.Gap.md(), 0)).apply { - isOpaque = false + val target = Stack.fitHorizontal(UiStyle.Gap.md()).apply { minimumSize = Dimension(0, minimumSize.height) - add(title, BorderLayout.WEST) - add(target, BorderLayout.CENTER) + targets.forEach { next(it) } } - val controls = Stack.horizontal() - val header = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.Layout.GAP), 0)).apply { - isOpaque = false - add(glyph, BorderLayout.WEST) - add(center, BorderLayout.CENTER) - add(controls, BorderLayout.EAST) - } - return ToolParts(header, glyph, title, sub, link, slot, state, center, controls, targets = targets, mode = ToolBodyMode.EDITOR).also { - controls.add(it.state) + val header = PartHeader().apply { + left(glyph, title) + fill(target) + right(state) } + return ToolParts(header, glyph, title, sub, link, slot, state, header.left, header.right, fill = target, targets = targets, mode = ToolBodyMode.EDITOR) } internal fun icon(tool: Tool) = when (tool.name) { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolView.kt index 1f04865526..15ba583933 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolView.kt @@ -31,7 +31,7 @@ class ToolView( private var disposed = false init { - bindHeader(parts.glyph, parts.title, parts.sub, parts.state, parts.center, parts.controls, parts.slot) + bindHeader(parts.glyph, parts.title, parts.sub, parts.state, parts.left, parts.right, parts.slot) applyStyle(style) sync() } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/DiffBars.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/DiffBars.kt index 2861aaa348..49c65943b1 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/DiffBars.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/DiffBars.kt @@ -36,13 +36,15 @@ internal class DiffBars( val g2 = g.create() as Graphics2D try { g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON) + val barHeight = JBUI.scale(HEIGHT) + val y = maxOf(0, (height - barHeight) / 2) blocks().forEachIndexed { index, color -> g2.color = color g2.fillRoundRect( JBUI.scale(index * STEP), - 0, + y, JBUI.scale(BAR_WIDTH), - JBUI.scale(HEIGHT), + barHeight, JBUI.scale(ARC), JBUI.scale(ARC), ) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/SearchToolViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/SearchToolViewTest.kt index 54428263a1..c6009b0747 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/SearchToolViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/SearchToolViewTest.kt @@ -9,9 +9,10 @@ import ai.kilocode.client.session.views.tool.GlobToolView import ai.kilocode.client.session.views.tool.ReadToolView import ai.kilocode.client.session.views.tool.SearchToolView import ai.kilocode.client.session.views.tool.ToolView -import ai.kilocode.client.ui.UiStyle +import ai.kilocode.client.session.ui.style.SessionUiStyle import com.intellij.openapi.util.Disposer import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.util.ui.JBUI import java.awt.BorderLayout import java.awt.Container import java.awt.Dimension @@ -111,12 +112,15 @@ class SearchToolViewTest : BasePlatformTestCase() { assertEquals(style.regularFont, view.targetFont(1)) } - fun `test search header title target gap uses standard medium gap`() { + fun `test search header uses standard layout gap between regions`() { val view = SearchToolView(tool().also { it.input = mapOf("pattern" to "TODO", "include" to "*.kt") }) - assertEquals(UiStyle.Gap.md(), (view.centerComponent().layout as BorderLayout).hgap) + assertEquals( + JBUI.scale(SessionUiStyle.View.Layout.GAP), + (view.headerComponent().layout as BorderLayout).hgap, + ) } fun `test completed search starts collapsed and expands output`() { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ToolViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ToolViewTest.kt index 777cf1157b..d8f4d3e9fb 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ToolViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ToolViewTest.kt @@ -8,11 +8,11 @@ import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.session.views.base.SecondarySessionPartView import ai.kilocode.client.session.views.tool.ToolView -import ai.kilocode.client.ui.UiStyle import com.intellij.openapi.editor.DefaultLanguageHighlighterColors import com.intellij.openapi.util.Disposer import com.intellij.testFramework.fixtures.BasePlatformTestCase import com.intellij.ui.scale.JBUIScale +import com.intellij.util.ui.JBUI import java.awt.BorderLayout import java.awt.Color import java.awt.image.BufferedImage @@ -279,10 +279,10 @@ class ToolViewTest : BasePlatformTestCase() { assertSmallEditorFont(view.stateFont(), style) } - fun `test tool header title subtitle gap uses standard medium gap`() { + fun `test tool header uses standard layout gap`() { val view = track(ToolView(tool("p1", "bash", ToolExecState.COMPLETED).also { it.output = "done" })) - assertEquals(UiStyle.Gap.md(), centerGap(view)) + assertEquals(JBUI.scale(SessionUiStyle.View.Layout.GAP), headerGap(view)) } fun `test applyStyle updates tool fonts in place`() { @@ -434,11 +434,10 @@ class ToolViewTest : BasePlatformTestCase() { assertTrue(font.size < style.editorSize) } - private fun centerGap(view: ToolView): Int { + private fun headerGap(view: ToolView): Int { val row = view.components.filterIsInstance().single() val header = (row.layout as BorderLayout).getLayoutComponent(BorderLayout.CENTER) as JPanel - val center = (header.layout as BorderLayout).getLayoutComponent(BorderLayout.CENTER) as JPanel - return (center.layout as BorderLayout).hgap + return (header.layout as BorderLayout).hgap } private fun paint(border: Border): Color { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/PartHeaderTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/PartHeaderTest.kt new file mode 100644 index 0000000000..e30bfd0936 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/PartHeaderTest.kt @@ -0,0 +1,73 @@ +package ai.kilocode.client.session.views.base + +import ai.kilocode.client.ui.DiffBars +import ai.kilocode.client.ui.HoverIcon +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.ui.components.JBLabel +import java.awt.Component +import java.awt.Container +import java.awt.Dimension +import javax.swing.SwingUtilities +import kotlin.math.abs + +class PartHeaderTest : BasePlatformTestCase() { + fun `test labels and fixed controls are vertically centered`() { + val title = JBLabel("Edit") + val icon = HoverIcon() + val bars = DiffBars(3, 1) + val header = PartHeader().apply { + left(title, PartHeader.centered(icon)) + right(PartHeader.centered(bars)) + } + + sized(header, 400) + + val mid = header.height / 2 + assertNear(mid, centerY(header, title)) + assertNear(mid, centerY(header, icon)) + assertNear(mid, centerY(header, bars)) + } + + fun `test right group hugs the trailing edge`() { + val bars = DiffBars(1, 1) + val header = PartHeader().apply { + left(JBLabel("Modified")) + right(PartHeader.centered(bars)) + } + + sized(header, 400) + + val edge = SwingUtilities.convertPoint(bars.parent, bars.x + bars.width, 0, header).x + assertTrue("right group should reach the trailing edge, was $edge of ${header.width}", edge >= header.width - 2) + } + + fun `test fill middle absorbs width and clips long content`() { + val path = JBLabel("a".repeat(400)) + val header = PartHeader().apply { + left(JBLabel("Edit")) + fill(path) + right(JBLabel("done")) + } + + sized(header, 240) + + assertTrue("fill child should not exceed header width", path.width <= header.width) + } + + private fun sized(header: PartHeader, width: Int) { + header.size = Dimension(width, header.preferredSize.height) + layout(header) + } + + private fun layout(root: Container) { + root.doLayout() + root.components.filterIsInstance().forEach { layout(it) } + } + + private fun centerY(header: PartHeader, comp: Component): Int = + SwingUtilities.convertPoint(comp.parent, comp.x + comp.width / 2, comp.y + comp.height / 2, header).y + + private fun assertNear(expected: Int, actual: Int) { + assertTrue("expected ~$expected but was $actual", abs(expected - actual) <= 1) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/todo/TodoWriteViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/todo/TodoWriteViewTest.kt index 28be9b3e76..efad300f3e 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/todo/TodoWriteViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/todo/TodoWriteViewTest.kt @@ -80,12 +80,12 @@ class TodoWriteViewTest : BasePlatformTestCase() { assertEquals(style.regularFont, view.rowFont(1)) } - fun `test todo header title subtitle gap uses standard medium gap`() { + fun `test todo header uses standard layout gap`() { val view = TodoWriteView(tool("todowrite", ToolExecState.COMPLETED).also { it.todos = listOf(TodoDto("Next", "pending", "medium")) }) - assertEquals(UiStyle.Gap.md(), centerGap(view)) + assertEquals(JBUI.scale(SessionUiStyle.View.Layout.GAP), headerGap(view)) } fun `test todo body uses next standard inner padding`() { @@ -157,11 +157,10 @@ class TodoWriteViewTest : BasePlatformTestCase() { assertTrue(view.rowText(0).contains("New")) } - private fun centerGap(view: TodoWriteView): Int { + private fun headerGap(view: TodoWriteView): Int { val row = view.components.filterIsInstance().first() val header = (row.layout as BorderLayout).getLayoutComponent(BorderLayout.CENTER) as JPanel - val center = (header.layout as BorderLayout).getLayoutComponent(BorderLayout.CENTER) as JPanel - return (center.layout as BorderLayout).hgap + return (header.layout as BorderLayout).hgap } private fun tool(name: String, state: ToolExecState) = Tool("p1", name, toolKind(name)).also { it.state = state } From 04c8e646f6617935ff3b9d7c6f2e23d0ca945b02 Mon Sep 17 00:00:00 2001 From: kirillk Date: Thu, 30 Jul 2026 13:42:17 -0400 Subject: [PATCH 14/28] fix(jetbrains): standardize diff header spacing --- .../ai/kilocode/client/session/SessionUi.kt | 20 ++++++++++------ .../client/session/ui/ModifiedFilesView.kt | 3 ++- .../client/session/ui/style/SessionUiStyle.kt | 18 +++++++++++++++ .../client/session/views/ReasoningView.kt | 9 ++++++-- .../views/base/AbstractSessionPartView.kt | 3 +-- .../client/session/views/base/PartHeader.kt | 23 +++++++++++-------- .../views/base/PrimarySessionPartView.kt | 4 +++- .../views/base/SecondarySessionPartView.kt | 4 +++- .../session/views/todo/TodoWriteView.kt | 5 +++- .../client/session/views/tool/ToolSupport.kt | 12 ++++++---- .../main/resources/icons/views/open-diff.svg | 7 +++--- .../resources/icons/views/open-diff_dark.svg | 7 +++--- .../resources/messages/KiloBundle.properties | 3 ++- .../client/diff/KiloDiffEditorContentTest.kt | 12 +++++----- .../session/views/base/PartHeaderTest.kt | 16 +++++++++++++ 15 files changed, 104 insertions(+), 42 deletions(-) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt index 9ebd381df4..fa4e297c1c 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt @@ -825,13 +825,19 @@ class SessionUi( } private fun openInlineDiff(files: List, title: String, key: String) { - ensureDiffEditorKind() - project.service().put(key, files) - project.service().open( - KiloDiffEditorKind.ID, - diffParams("inline", workspace.directory, controller.id, title, token = key), - ) - Telemetry.send("Diff Editor Opened", mapOf("source" to "inline")) + cs.launch { + val branch = workspaces.branchName(workspace.directory) + val label = branch?.let { KiloBundle.message("diff.editor.session.title.named", it) } ?: title + withContext(Dispatchers.Main) { + ensureDiffEditorKind() + project.service().put(key, files) + project.service().open( + KiloDiffEditorKind.ID, + diffParams("inline", workspace.directory, controller.id, label, token = key), + ) + Telemetry.send("Diff Editor Opened", mapOf("source" to "inline")) + } + } } private fun openAttachment(messageId: String, item: FileAttachment) { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ModifiedFilesView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ModifiedFilesView.kt index 2518c7f3ec..f67491966d 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ModifiedFilesView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ModifiedFilesView.kt @@ -155,7 +155,8 @@ class ModifiedFilesView private constructor( val bars = DiffBars(0, 0) // Glyph, title, count, and the open-diff action on the left; diff bars hug the right edge. val panel = PartHeader().apply { - left(glyph, title, count, PartHeader.centered(diff)) + leading(glyph) + left(title, count, PartHeader.centered(diff)) right(PartHeader.centered(bars)) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionUiStyle.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionUiStyle.kt index cb4bc8cb6c..7b0fbce2fb 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionUiStyle.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionUiStyle.kt @@ -37,6 +37,24 @@ object SessionUiStyle { const val BODY_EXTRA_HEIGHT = 16 } + /** + * Single source of truth for the spacing of every session-card header (see `PartHeader`). + * Keep header gaps here so all cards stay aligned; do not hardcode header spacing elsewhere. + */ + object Header { + /** Leading inset from the card edge to the first header element. */ + fun left() = JBUI.scale(Layout.HORIZONTAL_PADDING) + + /** Trailing inset from the collapse/expand arrow to the card edge. */ + fun right() = JBUI.scale(Layout.HORIZONTAL_PADDING) + + /** Gap between the leading glyph icon and the title. */ + fun icon() = UiStyle.Gap.sm() + + /** Universal gap: title to every trailing element, and between all elements. */ + fun gap() = JBUI.scale(Layout.GAP) + } + object Popup { const val MAX_WIDTH = 350 const val WIDE_MAX_WIDTH = MAX_WIDTH * 2 diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ReasoningView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ReasoningView.kt index 965ab5ac97..c0d8715839 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ReasoningView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ReasoningView.kt @@ -72,7 +72,9 @@ class ReasoningView( init { row.border = JBUI.Borders.empty( JBUI.scale(SessionUiStyle.View.Reasoning.HEADER_VERTICAL_PADDING), - JBUI.scale(SessionUiStyle.View.Layout.HORIZONTAL_PADDING), + SessionUiStyle.View.Header.left(), + JBUI.scale(SessionUiStyle.View.Reasoning.HEADER_VERTICAL_PADDING), + SessionUiStyle.View.Header.right(), ) bindHeader(parts.title, parts.icon) applyStyle(style) @@ -386,7 +388,10 @@ class ReasoningBody( private fun reasoningParts(selection: SessionSelection? = null): ReasoningParts { val title = JBLabel(KiloBundle.message("session.part.reasoning")).apply { foreground = UiStyle.Colors.weak() } val icon = JBLabel(SessionViewIcons.brain).apply { foreground = UiStyle.Colors.weak() } - val header = PartHeader().apply { left(icon, title) } + val header = PartHeader().apply { + leading(icon) + left(title) + } return ReasoningParts(header, title, icon, selection) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/AbstractSessionPartView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/AbstractSessionPartView.kt index 0cb5919666..23ba2b1119 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/AbstractSessionPartView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/AbstractSessionPartView.kt @@ -3,7 +3,6 @@ package ai.kilocode.client.session.views.base import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.session.views.SessionViewIcons import com.intellij.ui.components.JBLabel -import com.intellij.util.ui.JBUI import java.awt.BorderLayout import java.awt.Color import java.awt.Component @@ -29,7 +28,7 @@ abstract class AbstractSessionPartView( ) : this(header, { body }, expanded, expandable) protected val arrow = JBLabel() - protected val row = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.Layout.GAP), 0)) + protected val row = JPanel(BorderLayout(SessionUiStyle.View.Header.gap(), 0)) private val bound = linkedSetOf() private var body: JComponent? = null diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/PartHeader.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/PartHeader.kt index 9469d56e98..a1a8285f2e 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/PartHeader.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/PartHeader.kt @@ -1,11 +1,10 @@ package ai.kilocode.client.session.views.base -import ai.kilocode.client.session.ui.style.SessionUiStyle +import ai.kilocode.client.session.ui.style.SessionUiStyle.View.Header import ai.kilocode.client.ui.layout.HAlign import ai.kilocode.client.ui.layout.Stack import ai.kilocode.client.ui.layout.VAlign import ai.kilocode.client.ui.layout.align -import com.intellij.util.ui.JBUI import java.awt.BorderLayout import java.awt.Component import javax.swing.JComponent @@ -20,14 +19,14 @@ import javax.swing.JPanel * right of this header, so together they realise the west (left) / center (right * group) / east (arrow) layout. * - * Every child is stretched to the full header height by the [left]/[right] [Stack]s. - * Text labels center vertically by default, so add them directly. Fixed-size controls - * (icons, badges, diff bars) must be added via [centered] so they keep their preferred - * size and stay centered instead of stretching to the full height. + * All spacing comes from [Header]: [leading] applies the icon-to-title gap, while every + * other element is separated by the universal [Header.gap]. Text labels center vertically + * by default, so add them directly. Fixed-size controls (icons, badges, diff bars) must be + * added via [centered] so they keep their preferred size and stay centered. */ -class PartHeader : JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.Layout.GAP), 0)) { - val left = Stack.horizontal(JBUI.scale(SessionUiStyle.View.Layout.GAP)) - val right = Stack.horizontal(JBUI.scale(SessionUiStyle.View.Layout.GAP)) +class PartHeader : JPanel(BorderLayout(Header.gap(), 0)) { + val left = Stack.horizontal(Header.gap()) + val right = Stack.horizontal(Header.gap()) init { isOpaque = false @@ -35,6 +34,12 @@ class PartHeader : JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.Layout.GAP add(right, BorderLayout.EAST) } + /** Adds the leading glyph and reserves the tighter icon-to-title gap before the title. */ + fun leading(icon: Component): PartHeader { + left.next(icon).gap(Header.icon()) + return this + } + fun left(vararg items: Component): PartHeader { items.forEach { left.next(it) } return this diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/PrimarySessionPartView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/PrimarySessionPartView.kt index d82a3e1162..b5ad67822b 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/PrimarySessionPartView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/PrimarySessionPartView.kt @@ -17,7 +17,9 @@ abstract class PrimarySessionPartView( row.background = SessionUiStyle.View.Surface.headerBgColor() row.border = JBUI.Borders.empty( JBUI.scale(SessionUiStyle.View.Layout.VERTICAL_PADDING), - JBUI.scale(SessionUiStyle.View.Layout.HORIZONTAL_PADDING), + SessionUiStyle.View.Header.left(), + JBUI.scale(SessionUiStyle.View.Layout.VERTICAL_PADDING), + SessionUiStyle.View.Header.right(), ) syncBorder() } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/SecondarySessionPartView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/SecondarySessionPartView.kt index d0a0d3808e..a494b2d446 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/SecondarySessionPartView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/SecondarySessionPartView.kt @@ -22,7 +22,9 @@ abstract class SecondarySessionPartView( row.background = SessionUiStyle.View.Surface.headerBgColor() row.border = JBUI.Borders.empty( JBUI.scale(SessionUiStyle.View.Layout.VERTICAL_PADDING), - JBUI.scale(SessionUiStyle.View.Layout.HORIZONTAL_PADDING), + SessionUiStyle.View.Header.left(), + JBUI.scale(SessionUiStyle.View.Layout.VERTICAL_PADDING), + SessionUiStyle.View.Header.right(), ) syncBorder() } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/todo/TodoWriteView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/todo/TodoWriteView.kt index 3ff13a3d37..645f85814a 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/todo/TodoWriteView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/todo/TodoWriteView.kt @@ -104,7 +104,10 @@ private fun todoParts(): TodoParts { val glyph = JBLabel(SessionViewIcons.checklist) val title = JBLabel(KiloBundle.message("session.part.todo.title")) val sub = JBLabel().apply { foreground = UiStyle.Colors.weak() } - val header = PartHeader().apply { left(glyph, title, sub) } + val header = PartHeader().apply { + leading(glyph) + left(title, sub) + } return TodoParts(header, glyph, title, sub, header.left, header.right, TodoListPanel()) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolSupport.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolSupport.kt index 01b81bbc94..1a7f540790 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolSupport.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolSupport.kt @@ -404,14 +404,15 @@ internal fun toolParts( val title = clip(JBLabel()) val sub = clip(JBLabel()).apply { foreground = UiStyle.Colors.weak() } val link = clip(FileLinkLabel(openFile)) - val slot = Stack.fitHorizontal().apply { + val slot = Stack.fitHorizontal(SessionUiStyle.View.Header.gap()).apply { minimumSize = Dimension(0, minimumSize.height) next(sub) next(link) } val state = clip(JBLabel()).apply { foreground = UiStyle.Colors.weak() } val header = PartHeader().apply { - left(glyph, title) + leading(glyph) + left(title) fill(slot) right(state) } @@ -429,18 +430,19 @@ internal fun searchParts(count: Int): ToolParts { } } val link = clip(FileLinkLabel()) - val slot = Stack.fitHorizontal().apply { + val slot = Stack.fitHorizontal(SessionUiStyle.View.Header.gap()).apply { minimumSize = Dimension(0, minimumSize.height) next(sub) next(link) } val state = clip(JBLabel()).apply { foreground = UiStyle.Colors.weak() } - val target = Stack.fitHorizontal(UiStyle.Gap.md()).apply { + val target = Stack.fitHorizontal(SessionUiStyle.View.Header.gap()).apply { minimumSize = Dimension(0, minimumSize.height) targets.forEach { next(it) } } val header = PartHeader().apply { - left(glyph, title) + leading(glyph) + left(title) fill(target) right(state) } diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/open-diff.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/open-diff.svg index ec9ece4281..1ca8ae31fc 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/open-diff.svg +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/open-diff.svg @@ -1,5 +1,6 @@ + - - - + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/open-diff_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/open-diff_dark.svg index a7599e54e7..9029eb2f2d 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/open-diff_dark.svg +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/open-diff_dark.svg @@ -1,5 +1,6 @@ + - - - + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties index 4d45cf9619..ac4270e7b6 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -127,7 +127,8 @@ session.changes.modified=Modified session.changes.count.one={0} file session.changes.count.other={0} files diff.editor.session.title=Session Changes -diff.editor.inline.title=Kilo changes +diff.editor.session.title.named=Session Changes ({0}) +diff.editor.inline.title=Session Changes diff.editor.branch.title=Changes vs base branch diff.editor.branch.title.named=Changes vs base branch ({0}) diff.editor.file.title={0} ({1}) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/KiloDiffEditorContentTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/KiloDiffEditorContentTest.kt index 689855f213..efa66f877c 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/KiloDiffEditorContentTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/KiloDiffEditorContentTest.kt @@ -195,23 +195,23 @@ class KiloDiffEditorContentTest : BasePlatformTestCase() { } fun `test diff params includes inline token`() { - val params = diffParams("inline", "/repo", "ses_1", "Kilo changes", token = "tool:ses_1:p1") + val params = diffParams("inline", "/repo", "ses_1", "Session Changes", token = "tool:ses_1:p1") assertEquals("inline", params["source"]) assertEquals("/repo", params["directory"]) assertEquals("ses_1", params["sessionId"]) - assertEquals("Kilo changes", params["title"]) + assertEquals("Session Changes", params["title"]) assertEquals("tool:ses_1:p1", params["token"]) } fun `test inline params require directory and token`() { - assertTrue(KiloDiffEditorKind.isValid(diffParams("inline", "/repo", null, "Kilo changes", token = "turn:ses_1:u1"))) - assertFalse(KiloDiffEditorKind.isValid(mapOf("source" to "inline", "directory" to "/repo", "title" to "Kilo changes"))) - assertFalse(KiloDiffEditorKind.isValid(mapOf("source" to "inline", "token" to "turn:ses_1:u1", "title" to "Kilo changes"))) + assertTrue(KiloDiffEditorKind.isValid(diffParams("inline", "/repo", null, "Session Changes", token = "turn:ses_1:u1"))) + assertFalse(KiloDiffEditorKind.isValid(mapOf("source" to "inline", "directory" to "/repo", "title" to "Session Changes"))) + assertFalse(KiloDiffEditorKind.isValid(mapOf("source" to "inline", "token" to "turn:ses_1:u1", "title" to "Session Changes"))) } fun `test inline editor title uses params title`() { - assertEquals("Kilo changes", KiloDiffEditorKind.title(diffParams("inline", "/repo", null, "Kilo changes", token = "token"))) + assertEquals("Session Changes", KiloDiffEditorKind.title(diffParams("inline", "/repo", null, "Session Changes", token = "token"))) } fun `test reload updates aggregate badge`() { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/PartHeaderTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/PartHeaderTest.kt index e30bfd0936..1198c8c534 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/PartHeaderTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/PartHeaderTest.kt @@ -1,5 +1,6 @@ package ai.kilocode.client.session.views.base +import ai.kilocode.client.session.ui.style.SessionUiStyle.View.Header import ai.kilocode.client.ui.DiffBars import ai.kilocode.client.ui.HoverIcon import com.intellij.testFramework.fixtures.BasePlatformTestCase @@ -28,6 +29,21 @@ class PartHeaderTest : BasePlatformTestCase() { assertNear(mid, centerY(header, bars)) } + fun `test leading uses icon gap and universal gap between elements`() { + val glyph = JBLabel("g") + val title = JBLabel("Edit") + val extra = JBLabel("x") + val header = PartHeader().apply { + leading(glyph) + left(title, extra) + } + + sized(header, 400) + + assertEquals(Header.icon(), title.x - (glyph.x + glyph.width)) + assertEquals(Header.gap(), extra.x - (title.x + title.width)) + } + fun `test right group hugs the trailing edge`() { val bars = DiffBars(1, 1) val header = PartHeader().apply { From c68a84b890fbcc960381cad62f3bc97ab4c86444 Mon Sep 17 00:00:00 2001 From: kirillk Date: Thu, 30 Jul 2026 14:12:52 -0400 Subject: [PATCH 15/28] fix(jetbrains): align file change headers --- .../client/session/ui/ModifiedFilesView.kt | 9 ++--- .../client/session/ui/style/SessionUiStyle.kt | 5 ++- .../client/session/views/base/PartHeader.kt | 6 ++++ .../session/views/todo/TodoWriteView.kt | 4 ++- .../client/session/views/tool/EditToolView.kt | 12 +++---- .../client/session/views/tool/ToolSupport.kt | 2 ++ .../client/session/views/EditToolViewTest.kt | 33 +++++++++++++++++++ .../session/views/base/PartHeaderTest.kt | 19 +++++++++++ 8 files changed, 78 insertions(+), 12 deletions(-) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ModifiedFilesView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ModifiedFilesView.kt index f67491966d..39792dacab 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ModifiedFilesView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ModifiedFilesView.kt @@ -52,7 +52,7 @@ class ModifiedFilesView private constructor( body.parent = this parts.diff.addActionListener { openDiffViewer() } isVisible = false - bindHeader(parts.glyph, parts.title, parts.count, parts.panel.left, parts.panel.right, parts.bars) + bindHeader(parts.glyph, parts.title, parts.count, parts.panel.left, parts.bars) unbindHeader(parts.diff) applyStyle(style) } @@ -153,11 +153,12 @@ class ModifiedFilesView private constructor( ToolbarButtonAction(SessionViewIcons.openDiff, KiloBundle.message("session.part.tool.openDiff")) {}, ).apply { isVisible = false } val bars = DiffBars(0, 0) - // Glyph, title, count, and the open-diff action on the left; diff bars hug the right edge. + // Left-aligned header: icon, title, file count, sticks change badge, open-in-diff. val panel = PartHeader().apply { leading(glyph) - left(title, count, PartHeader.centered(diff)) - right(PartHeader.centered(bars)) + left(title) + titleGap() + left(count, PartHeader.centered(bars), PartHeader.centered(diff)) } @RequiresEdt diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionUiStyle.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionUiStyle.kt index 7b0fbce2fb..057fecfd7d 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionUiStyle.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionUiStyle.kt @@ -51,8 +51,11 @@ object SessionUiStyle { /** Gap between the leading glyph icon and the title. */ fun icon() = UiStyle.Gap.sm() - /** Universal gap: title to every trailing element, and between all elements. */ + /** Universal gap between every element after the title. */ fun gap() = JBUI.scale(Layout.GAP) + + /** Larger gap separating the title from the elements that follow it (one standard step above [gap]). */ + fun title() = UiStyle.Gap.lg() } object Popup { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/PartHeader.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/PartHeader.kt index a1a8285f2e..12b5510d9a 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/PartHeader.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/PartHeader.kt @@ -40,6 +40,12 @@ class PartHeader : JPanel(BorderLayout(Header.gap(), 0)) { return this } + /** Reserves the larger title-to-elements gap before the next left element. */ + fun titleGap(): PartHeader { + left.gap(Header.title()) + return this + } + fun left(vararg items: Component): PartHeader { items.forEach { left.next(it) } return this diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/todo/TodoWriteView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/todo/TodoWriteView.kt index 645f85814a..4db6e24d2c 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/todo/TodoWriteView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/todo/TodoWriteView.kt @@ -106,7 +106,9 @@ private fun todoParts(): TodoParts { val sub = JBLabel().apply { foreground = UiStyle.Colors.weak() } val header = PartHeader().apply { leading(glyph) - left(title, sub) + left(title) + titleGap() + left(sub) } return TodoParts(header, glyph, title, sub, header.left, header.right, TodoListPanel()) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/EditToolView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/EditToolView.kt index c189563b6d..eb9fbae82f 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/EditToolView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/EditToolView.kt @@ -30,7 +30,6 @@ import com.intellij.ui.EditorTextField import com.intellij.ui.components.JBLabel import com.intellij.util.concurrency.annotations.RequiresEdt import com.intellij.util.ui.JBFont -import com.intellij.util.ui.JBUI import java.awt.Dimension import javax.swing.ScrollPaneConstants @@ -61,15 +60,16 @@ class EditToolView( private val filesTag = JBLabel().apply { foreground = UiStyle.Colors.weak() font = JBFont.small() - border = JBUI.Borders.emptyRight(SessionUiStyle.View.Layout.HORIZONTAL_PADDING) isVisible = false } init { body.parent = this - parts.slot.add(PartHeader.centered(diff)) - parts.right.next(filesTag) - parts.right.next(PartHeader.centered(badge)) + // Left-aligned header: icon, title, file name (single) or file count (multi), change badge, open-in-diff. + parts.left.next(parts.link) + parts.left.next(filesTag) + parts.left.next(PartHeader.centered(badge)) + parts.left.next(PartHeader.centered(diff)) bindHeader(parts.glyph, parts.title, parts.sub, parts.link, parts.state, parts.left, parts.right, parts.slot, filesTag, badge) unbindHeader(diff) applyStyle(style) @@ -209,7 +209,7 @@ class EditToolView( changed = setForeground(parts.link, UiStyle.Colors.fg()) || changed changed = setText(parts.state, stateText(item)) || changed changed = setForeground(parts.state, color(item)) || changed - changed = setVisible(diff, editDiff(item).isNotBlank()) || changed + changed = setVisible(diff, toDiffFiles(item).isNotEmpty()) || changed changed = syncFilesTag(count) || changed changed = syncBadge() || changed return changed diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolSupport.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolSupport.kt index 1a7f540790..a506acce25 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolSupport.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolSupport.kt @@ -413,6 +413,7 @@ internal fun toolParts( val header = PartHeader().apply { leading(glyph) left(title) + titleGap() fill(slot) right(state) } @@ -443,6 +444,7 @@ internal fun searchParts(count: Int): ToolParts { val header = PartHeader().apply { leading(glyph) left(title) + titleGap() fill(target) right(state) } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/EditToolViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/EditToolViewTest.kt index 4cfaf8d2c6..894bb37ce3 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/EditToolViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/EditToolViewTest.kt @@ -1,5 +1,6 @@ package ai.kilocode.client.session.views +import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.session.model.Tool import ai.kilocode.client.session.model.ToolExecState import ai.kilocode.client.session.model.toolKind @@ -9,6 +10,7 @@ import ai.kilocode.client.session.views.tool.EditToolView import ai.kilocode.client.session.views.tool.ReadToolView import ai.kilocode.client.session.views.tool.ToolView import ai.kilocode.client.ui.DiffStatBadge +import ai.kilocode.rpc.dto.DiffFileDto import com.intellij.openapi.diff.DiffColors import com.intellij.openapi.editor.EditorFactory import com.intellij.openapi.util.Disposer @@ -23,6 +25,7 @@ import kotlinx.serialization.json.put import java.awt.Component import java.awt.Container import java.awt.event.MouseEvent +import javax.swing.AbstractButton @Suppress("UnstableApiUsage") class EditToolViewTest : BasePlatformTestCase() { @@ -130,6 +133,28 @@ class EditToolViewTest : BasePlatformTestCase() { assertEquals(listOf("src/A.kt"), opened) } + fun `test open in diff action fires for edit and patch`() { + val edit = mutableListOf>() + val editView = track(EditToolView(tool(), { _, _ -> }, null, { files, _, _ -> edit.add(files) }, "ses")) + val editButton = openDiffButton(editView) + assertTrue(editButton.isVisible) + editButton.doClick() + assertEquals(1, edit.single().size) + + val patch = mutableListOf>() + val patchView = track(EditToolView(tool().also { + it.input = emptyMap() + it.metadata = mapOf("files" to filesMeta( + FileChange("src/A.kt", 2, 0, ADD_HUNK), + FileChange("src/B.kt", 1, 1, UPDATE_HUNK), + )) + }, { _, _ -> }, null, { files, _, _ -> patch.add(files) }, "ses")) + val patchButton = openDiffButton(patchView) + assertTrue(patchButton.isVisible) + patchButton.doClick() + assertEquals(2, patch.single().size) + } + fun `test single file apply_patch keeps link and hides count tag`() { val view = track(EditToolView(tool().also { it.input = emptyMap() @@ -397,6 +422,14 @@ class EditToolViewTest : BasePlatformTestCase() { if (child is DiffStatBadge) nested + child else nested } + private fun openDiffButton(view: EditToolView): AbstractButton = + buttons(view).first { it.toolTipText == KiloBundle.message("session.part.tool.openDiff") } + + private fun buttons(root: Container): List = root.components.flatMap { child -> + val nested = if (child is Container) buttons(child) else emptyList() + if (child is AbstractButton) nested + child else nested + } + private fun tool() = Tool("p1", "edit", toolKind("edit")).also { it.state = ToolExecState.COMPLETED it.title = "src/App.kt" diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/PartHeaderTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/PartHeaderTest.kt index 1198c8c534..cc0321d29e 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/PartHeaderTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/PartHeaderTest.kt @@ -44,6 +44,25 @@ class PartHeaderTest : BasePlatformTestCase() { assertEquals(Header.gap(), extra.x - (title.x + title.width)) } + fun `test title gap separates the title from following elements`() { + val glyph = JBLabel("g") + val title = JBLabel("Edit") + val name = JBLabel("main.tf") + val extra = JBLabel("x") + val header = PartHeader().apply { + leading(glyph) + left(title) + titleGap() + left(name, extra) + } + + sized(header, 400) + + assertEquals(Header.title(), name.x - (title.x + title.width)) + assertEquals(Header.gap(), extra.x - (name.x + name.width)) + assertTrue(Header.title() > Header.gap()) + } + fun `test right group hugs the trailing edge`() { val bars = DiffBars(1, 1) val header = PartHeader().apply { From 1b78d347d814ecf5d6ab0656f502b36beb1d3ac4 Mon Sep 17 00:00:00 2001 From: kirillk Date: Thu, 30 Jul 2026 14:28:20 -0400 Subject: [PATCH 16/28] fix(jetbrains): open edit diffs from transcript --- .../client/session/views/MessageView.kt | 4 ++ .../client/session/views/tool/EditToolView.kt | 10 +++++ .../client/session/views/EditToolViewTest.kt | 17 ++++++++ .../client/session/views/TurnViewTest.kt | 39 +++++++++++++++++++ 4 files changed, 70 insertions(+) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt index 9bc37ce8a2..c4ae7dc75b 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt @@ -19,6 +19,7 @@ import ai.kilocode.client.session.ui.selection.SessionCopyTarget import ai.kilocode.client.session.ui.selection.SessionSelection import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget import ai.kilocode.client.session.views.base.PartView +import ai.kilocode.client.session.views.tool.EditToolView import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.ui.ToolbarButtonAction @@ -112,6 +113,9 @@ class MessageView( fun setDiffOpener(openDiff: SessionDiffOpener, sessionId: String?) { this.openDiff = openDiff this.sessionId = sessionId + // Rebind parts created before the opener was wired (e.g. history load), matching the + // late-binding TurnView already does for its ModifiedFilesView card. + for (view in parts.values) if (view is EditToolView) view.setDiffOpener(openDiff, sessionId) } /** diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/EditToolView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/EditToolView.kt index eb9fbae82f..b2ec37ea7e 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/EditToolView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/EditToolView.kt @@ -87,6 +87,16 @@ class EditToolView( this.sessionId = sessionId } + /** + * Late-bind the diff opener. The transcript builds this view before the session-level opener is + * known, so [ai.kilocode.client.session.views.MessageView] rebinds it once the opener is wired. + */ + @RequiresEdt + fun setDiffOpener(openDiff: SessionDiffOpener, sessionId: String?) { + opener = openDiff + this.sessionId = sessionId + } + override fun uiDataSnapshot(sink: DataSink) { selection?.provideCopy(sink) { body.markdown() ?: diffMarkdown(item) } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/EditToolViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/EditToolViewTest.kt index 894bb37ce3..cdea341837 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/EditToolViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/EditToolViewTest.kt @@ -155,6 +155,23 @@ class EditToolViewTest : BasePlatformTestCase() { assertEquals(2, patch.single().size) } + fun `test open in diff uses a late-bound opener`() { + // Mirrors the real wiring: the view is built before the session-level opener is known, then + // MessageView rebinds it. Without late binding the button click is a no-op. + val fired = mutableListOf>() + val view = track(EditToolView(tool())) + val button = openDiffButton(view) + assertTrue(button.isVisible) + + button.doClick() + assertTrue(fired.isEmpty()) + + view.setDiffOpener({ files, _, _ -> fired.add(files) }, "ses") + button.doClick() + + assertEquals(1, fired.single().size) + } + fun `test single file apply_patch keeps link and hides count tag`() { val view = track(EditToolView(tool().also { it.input = emptyMap() diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TurnViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TurnViewTest.kt index 301b78d761..525d8f466f 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TurnViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TurnViewTest.kt @@ -1,5 +1,6 @@ package ai.kilocode.client.session.views +import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.session.SessionFileOpener import ai.kilocode.client.session.model.Message import ai.kilocode.client.session.model.Reasoning @@ -14,7 +15,11 @@ import ai.kilocode.rpc.dto.MessageDto import ai.kilocode.rpc.dto.MessageTimeDto import com.intellij.testFramework.fixtures.BasePlatformTestCase import com.intellij.util.ui.JBUI +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import java.awt.Container import java.awt.image.BufferedImage +import javax.swing.AbstractButton import javax.swing.JComponent import javax.swing.JPanel import javax.swing.RepaintManager @@ -322,6 +327,20 @@ class TurnViewTest : BasePlatformTestCase() { } } + fun `test setDiffOpener rebinds edit tool parts built before wiring`() { + // The transcript builds the MessageView (and its EditToolView) before the session-level + // opener is known, exactly like history load. Rebinding must reach the existing part. + val message = msg("a1", "assistant").also { it.parts["t1"] = editTool() } + val mv = MessageView(message, openFile) + + val fired = mutableListOf>() + mv.setDiffOpener({ files, _, _ -> fired.add(files) }, "ses") + + openDiffButton(mv).doClick() + + assertEquals(1, fired.single().size) + } + fun `test MessageView pre-populates parts from Message on creation`() { val message = msg("a1", "assistant") val text = ai.kilocode.client.session.model.Text("p1").also { it.content.append("preloaded") } @@ -374,6 +393,26 @@ class TurnViewTest : BasePlatformTestCase() { private fun diff(path: String) = DiffFileDto(path, additions = 2, deletions = 1, patch = PATCH) + private fun editTool() = Tool("t1", "edit", toolKind("edit")).also { + it.state = ToolExecState.COMPLETED + it.title = "src/App.kt" + it.input = mapOf("filePath" to "/repo/src/App.kt") + it.metadata = mapOf("filediff" to buildJsonObject { + put("file", "src/App.kt") + put("additions", 2) + put("deletions", 1) + put("patch", PATCH) + }.toString()) + } + + private fun openDiffButton(root: Container): AbstractButton = + buttons(root).first { it.toolTipText == KiloBundle.message("session.part.tool.openDiff") } + + private fun buttons(root: Container): List = root.components.flatMap { child -> + val nested = if (child is Container) buttons(child) else emptyList() + if (child is AbstractButton) nested + child else nested + } + private fun reasoning(id: String, content: String) = Reasoning(id).also { it.done = false it.content.append(content) From 05b520e68909b1ffe7234a17a79d7a60ee98f18b Mon Sep 17 00:00:00 2001 From: kirillk Date: Thu, 30 Jul 2026 14:43:01 -0400 Subject: [PATCH 17/28] fix(jetbrains): show diff actions on hover --- .../ai/kilocode/client/session/SessionUi.kt | 2 +- .../client/session/ui/ModifiedFilesView.kt | 25 ++++++++---- .../session/ui/selection/SessionCopyTarget.kt | 14 +++++++ .../client/session/views/tool/EditToolView.kt | 38 +++++++++++++------ .../resources/messages/KiloBundle.properties | 2 + .../session/ui/ModifiedFilesViewTest.kt | 13 +++++++ .../client/session/views/EditToolViewTest.kt | 27 +++++++------ .../client/session/views/TurnViewTest.kt | 12 ++---- 8 files changed, 92 insertions(+), 41 deletions(-) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt index fa4e297c1c..14a37eec7c 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt @@ -827,7 +827,7 @@ class SessionUi( private fun openInlineDiff(files: List, title: String, key: String) { cs.launch { val branch = workspaces.branchName(workspace.directory) - val label = branch?.let { KiloBundle.message("diff.editor.session.title.named", it) } ?: title + val label = branch?.let { KiloBundle.message("diff.editor.inline.title.named", title, it) } ?: title withContext(Dispatchers.Main) { ensureDiffEditorKind() project.service().put(key, files) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ModifiedFilesView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ModifiedFilesView.kt index 39792dacab..ed67f18f24 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ModifiedFilesView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ModifiedFilesView.kt @@ -6,7 +6,9 @@ import ai.kilocode.client.session.SessionFileOpener import ai.kilocode.client.session.model.Content import ai.kilocode.client.session.ui.popup.HeaderPopupBody import ai.kilocode.client.session.ui.popup.HeaderPopupRequest +import ai.kilocode.client.session.ui.selection.SessionCopyTarget import ai.kilocode.client.session.ui.selection.SessionSelection +import ai.kilocode.client.session.ui.selection.hoverPlaceholder import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.session.views.SessionViewIcons @@ -27,13 +29,14 @@ import ai.kilocode.rpc.dto.DiffFileDto import com.intellij.openapi.util.Disposer import com.intellij.ui.components.JBLabel import com.intellij.util.concurrency.annotations.RequiresEdt +import javax.swing.JComponent class ModifiedFilesView private constructor( private val openFile: SessionFileOpener, private val selection: SessionSelection? = null, private val parts: Header = Header(), private val body: PatchBody = PatchBody(selection, openFile), -) : SecondarySessionPartView(parts.panel, { body.mountFiles(emptyList()) }) { +) : SecondarySessionPartView(parts.panel, { body.mountFiles(emptyList()) }), SessionCopyTarget { override val contentId = CONTENT_ID private var style = SessionEditorStyle.current() @@ -52,11 +55,14 @@ class ModifiedFilesView private constructor( body.parent = this parts.diff.addActionListener { openDiffViewer() } isVisible = false - bindHeader(parts.glyph, parts.title, parts.count, parts.panel.left, parts.bars) - unbindHeader(parts.diff) + bindHeader(parts.glyph, parts.title, parts.count, parts.panel.left, parts.bars, parts.anchor) applyStyle(style) } + override val copyEligible: Boolean get() = diffs.isNotEmpty() + override val copyAnchor: JComponent get() = parts.anchor + override val copyToolbar: JComponent get() = parts.diff + fun setDiffOpener(openDiff: SessionDiffOpener, sessionId: String?, turnId: String) { this.openDiff = openDiff this.sessionId = sessionId @@ -69,7 +75,7 @@ class ModifiedFilesView private constructor( this.diffs = diffs if (files == next) { val visible = next.isNotEmpty() - parts.diff.isVisible = visible + parts.diff.isEnabled = visible if (isVisible == visible) return isVisible = visible revalidate() @@ -83,7 +89,7 @@ class ModifiedFilesView private constructor( if (isVisible != visible) isVisible = visible if (!visible) collapse() parts.update(files.size, additions, deletions) - parts.diff.isVisible = visible + parts.diff.isEnabled = visible if (isExpanded()) body.updateFiles(files) revalidate() repaint() @@ -101,6 +107,8 @@ class ModifiedFilesView private constructor( @RequiresEdt override fun update(content: Content) = Unit + override fun copyText(): String? = null + @RequiresEdt override fun headerPopup(): HeaderPopupRequest? { if (isExpanded() || files.isEmpty()) return null @@ -133,7 +141,7 @@ class ModifiedFilesView private constructor( private fun openDiffViewer() { if (diffs.isEmpty()) return - openDiff(diffs, KiloBundle.message("diff.editor.inline.title"), "turn:${sessionId ?: "pending"}:$turnId") + openDiff(diffs, KiloBundle.message("diff.editor.changedFiles.title"), "turn:${sessionId ?: "pending"}:$turnId") } @RequiresEdt @@ -151,14 +159,15 @@ class ModifiedFilesView private constructor( val count = JBLabel() val diff = toolbarButton( ToolbarButtonAction(SessionViewIcons.openDiff, KiloBundle.message("session.part.tool.openDiff")) {}, - ).apply { isVisible = false } + ).apply { isEnabled = false } + val anchor = hoverPlaceholder(diff) val bars = DiffBars(0, 0) // Left-aligned header: icon, title, file count, sticks change badge, open-in-diff. val panel = PartHeader().apply { leading(glyph) left(title) titleGap() - left(count, PartHeader.centered(bars), PartHeader.centered(diff)) + left(count, PartHeader.centered(bars), PartHeader.centered(anchor)) } @RequiresEdt diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/selection/SessionCopyTarget.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/selection/SessionCopyTarget.kt index 8f3abe9ae5..e83897797f 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/selection/SessionCopyTarget.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/selection/SessionCopyTarget.kt @@ -1,7 +1,9 @@ package ai.kilocode.client.session.ui.selection import com.intellij.util.concurrency.annotations.RequiresEdt +import java.awt.Dimension import javax.swing.JComponent +import javax.swing.JPanel internal interface SessionCopyTarget { val copyEligible: Boolean get() = true @@ -13,3 +15,15 @@ internal interface SessionCopyTarget { @RequiresEdt fun copyText(): String? } + +internal fun hoverPlaceholder(toolbar: JComponent): JComponent = object : JPanel() { + init { + isOpaque = false + } + + override fun getPreferredSize(): Dimension = Dimension(toolbar.preferredSize) + + override fun getMinimumSize(): Dimension = Dimension(toolbar.minimumSize) + + override fun getMaximumSize(): Dimension = Dimension(toolbar.maximumSize) +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/EditToolView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/EditToolView.kt index b2ec37ea7e..367df2c37c 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/EditToolView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/EditToolView.kt @@ -9,7 +9,9 @@ import ai.kilocode.client.session.model.Tool import ai.kilocode.client.session.model.ToolKind import ai.kilocode.client.session.ui.popup.HeaderPopupBody import ai.kilocode.client.session.ui.popup.HeaderPopupRequest +import ai.kilocode.client.session.ui.selection.SessionCopyTarget import ai.kilocode.client.session.ui.selection.SessionSelection +import ai.kilocode.client.session.ui.selection.hoverPlaceholder import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.session.views.SessionViewIcons @@ -31,6 +33,7 @@ import com.intellij.ui.components.JBLabel import com.intellij.util.concurrency.annotations.RequiresEdt import com.intellij.util.ui.JBFont import java.awt.Dimension +import javax.swing.JComponent import javax.swing.ScrollPaneConstants /** @@ -44,7 +47,7 @@ class EditToolView( private val selection: SessionSelection? = null, private val parts: ToolParts = toolParts(tool, openFile), private var body: EditBody = editBody(tool, selection, openFile), -) : SecondarySessionPartView(parts.header, { body.mount(tool) }), UiDataProvider { +) : SecondarySessionPartView(parts.header, { body.mount(tool) }), UiDataProvider, SessionCopyTarget { override val contentId: String = tool.id @@ -53,10 +56,12 @@ class EditToolView( private var multi = editFiles(tool).size > 1 private var opener: SessionDiffOpener = { _, _, _ -> } private var sessionId: String? = null + private var canDiff = false private val badge = DiffStatBadge(0, 0) private val diff = toolbarButton( ToolbarButtonAction(SessionViewIcons.openDiff, KiloBundle.message("session.part.tool.openDiff"), ::openDiffViewer), - ).apply { isVisible = false } + ) + private val diffAnchor = hoverPlaceholder(diff) private val filesTag = JBLabel().apply { foreground = UiStyle.Colors.weak() font = JBFont.small() @@ -69,13 +74,16 @@ class EditToolView( parts.left.next(parts.link) parts.left.next(filesTag) parts.left.next(PartHeader.centered(badge)) - parts.left.next(PartHeader.centered(diff)) - bindHeader(parts.glyph, parts.title, parts.sub, parts.link, parts.state, parts.left, parts.right, parts.slot, filesTag, badge) - unbindHeader(diff) + parts.left.next(PartHeader.centered(diffAnchor)) + bindHeader(parts.glyph, parts.title, parts.sub, parts.link, parts.state, parts.left, parts.right, parts.slot, filesTag, badge, diffAnchor) applyStyle(style) sync() } + override val copyEligible: Boolean get() = canDiff + override val copyAnchor: JComponent get() = diffAnchor + override val copyToolbar: JComponent get() = diff + constructor( tool: Tool, openFile: SessionFileOpener, @@ -101,6 +109,8 @@ class EditToolView( selection?.provideCopy(sink) { body.markdown() ?: diffMarkdown(item) } } + override fun copyText(): String? = null + @RequiresEdt override fun expand(): Boolean { val changed = super.expand() @@ -219,16 +229,23 @@ class EditToolView( changed = setForeground(parts.link, UiStyle.Colors.fg()) || changed changed = setText(parts.state, stateText(item)) || changed changed = setForeground(parts.state, color(item)) || changed - changed = setVisible(diff, toDiffFiles(item).isNotEmpty()) || changed + syncDiffAction() changed = syncFilesTag(count) || changed changed = syncBadge() || changed return changed } + private fun syncDiffAction() { + val show = toDiffFiles(item).isNotEmpty() + if (canDiff == show && diff.isEnabled == show) return + canDiff = show + diff.isEnabled = show + } + private fun openDiffViewer() { val files = toDiffFiles(item) if (files.isEmpty()) return - opener(files, diffTitle(files), "tool:${sessionId ?: "pending"}:${item.id}") + opener(files, diffTitle(item), "tool:${sessionId ?: "pending"}:${item.id}") } private fun syncFilesTag(count: Int): Boolean { @@ -275,10 +292,9 @@ private fun toDiffFiles(tool: Tool): List { return listOf(DiffFileDto(editPath(tool), stat.first, stat.second, patch)) } -private fun diffTitle(files: List): String { - if (files.size == 1) return tail(files.single().file) - return KiloBundle.message("diff.editor.inline.title") -} +private fun diffTitle(tool: Tool): String = KiloBundle.message( + if (editFiles(tool).size > 1) "session.part.tool.patch" else "session.part.tool.edit", +) /** Picks the multi-file patch body for apply_patch spanning several files, else the single diff. */ private fun editBody(tool: Tool, selection: SessionSelection?, openFile: SessionFileOpener): EditBody = diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties index ac4270e7b6..1d614074f2 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -129,6 +129,8 @@ session.changes.count.other={0} files diff.editor.session.title=Session Changes diff.editor.session.title.named=Session Changes ({0}) diff.editor.inline.title=Session Changes +diff.editor.inline.title.named={0} ({1}) +diff.editor.changedFiles.title=Changed files diff.editor.branch.title=Changes vs base branch diff.editor.branch.title.named=Changes vs base branch ({0}) diff.editor.file.title={0} ({1}) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ModifiedFilesViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ModifiedFilesViewTest.kt index 544eeb328f..0eca7aa10e 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ModifiedFilesViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ModifiedFilesViewTest.kt @@ -10,6 +10,7 @@ import com.intellij.ui.components.JBLabel import com.intellij.util.ui.UIUtil import java.awt.Component import java.awt.Container +import javax.swing.AbstractButton class ModifiedFilesViewTest : BasePlatformTestCase() { private lateinit var view: ModifiedFilesView @@ -75,6 +76,16 @@ class ModifiedFilesViewTest : BasePlatformTestCase() { assertNull(view.headerPopup()) } + fun `test open in diff uses changed files title`() { + val titles = mutableListOf() + view.setDiffOpener({ _, title, _ -> titles.add(title) }, "ses", "turn") + view.setDiffs(listOf(file("src/A.kt", 2, 1, PATCH))) + + openDiffButton().doClick() + + assertEquals("Changed files", titles.single()) + } + fun `test dispose releases created editors`() { val base = EditorFactory.getInstance().allEditors.size view.setDiffs(listOf(file("src/A.kt", 2, 1, PATCH))) @@ -101,6 +112,8 @@ class ModifiedFilesViewTest : BasePlatformTestCase() { return out } + private fun openDiffButton(): AbstractButton = view.copyToolbar as AbstractButton + private fun file(path: String, additions: Int, deletions: Int, patch: String) = DiffFileDto( file = path, additions = additions, diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/EditToolViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/EditToolViewTest.kt index cdea341837..2ad955aecb 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/EditToolViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/EditToolViewTest.kt @@ -1,6 +1,5 @@ package ai.kilocode.client.session.views -import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.session.model.Tool import ai.kilocode.client.session.model.ToolExecState import ai.kilocode.client.session.model.toolKind @@ -135,11 +134,16 @@ class EditToolViewTest : BasePlatformTestCase() { fun `test open in diff action fires for edit and patch`() { val edit = mutableListOf>() - val editView = track(EditToolView(tool(), { _, _ -> }, null, { files, _, _ -> edit.add(files) }, "ses")) + val titles = mutableListOf() + val editView = track(EditToolView(tool(), { _, _ -> }, null, { files, title, _ -> + edit.add(files) + titles.add(title) + }, "ses")) val editButton = openDiffButton(editView) - assertTrue(editButton.isVisible) + assertTrue(editButton.isEnabled) editButton.doClick() assertEquals(1, edit.single().size) + assertEquals("Edit", titles.single()) val patch = mutableListOf>() val patchView = track(EditToolView(tool().also { @@ -148,11 +152,15 @@ class EditToolViewTest : BasePlatformTestCase() { FileChange("src/A.kt", 2, 0, ADD_HUNK), FileChange("src/B.kt", 1, 1, UPDATE_HUNK), )) - }, { _, _ -> }, null, { files, _, _ -> patch.add(files) }, "ses")) + }, { _, _ -> }, null, { files, title, _ -> + patch.add(files) + titles.add(title) + }, "ses")) val patchButton = openDiffButton(patchView) - assertTrue(patchButton.isVisible) + assertTrue(patchButton.isEnabled) patchButton.doClick() assertEquals(2, patch.single().size) + assertEquals("Patch", titles.last()) } fun `test open in diff uses a late-bound opener`() { @@ -161,7 +169,7 @@ class EditToolViewTest : BasePlatformTestCase() { val fired = mutableListOf>() val view = track(EditToolView(tool())) val button = openDiffButton(view) - assertTrue(button.isVisible) + assertTrue(button.isEnabled) button.doClick() assertTrue(fired.isEmpty()) @@ -440,12 +448,7 @@ class EditToolViewTest : BasePlatformTestCase() { } private fun openDiffButton(view: EditToolView): AbstractButton = - buttons(view).first { it.toolTipText == KiloBundle.message("session.part.tool.openDiff") } - - private fun buttons(root: Container): List = root.components.flatMap { child -> - val nested = if (child is Container) buttons(child) else emptyList() - if (child is AbstractButton) nested + child else nested - } + view.copyToolbar as AbstractButton private fun tool() = Tool("p1", "edit", toolKind("edit")).also { it.state = ToolExecState.COMPLETED diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TurnViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TurnViewTest.kt index 525d8f466f..4b2e38e1fc 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TurnViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TurnViewTest.kt @@ -1,6 +1,5 @@ package ai.kilocode.client.session.views -import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.session.SessionFileOpener import ai.kilocode.client.session.model.Message import ai.kilocode.client.session.model.Reasoning @@ -10,6 +9,7 @@ import ai.kilocode.client.session.model.ToolExecState import ai.kilocode.client.session.model.toolKind import ai.kilocode.client.session.ui.ModifiedFilesView import ai.kilocode.client.session.ui.style.SessionUiStyle +import ai.kilocode.client.session.views.tool.EditToolView import ai.kilocode.rpc.dto.DiffFileDto import ai.kilocode.rpc.dto.MessageDto import ai.kilocode.rpc.dto.MessageTimeDto @@ -17,7 +17,6 @@ import com.intellij.testFramework.fixtures.BasePlatformTestCase import com.intellij.util.ui.JBUI import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.put -import java.awt.Container import java.awt.image.BufferedImage import javax.swing.AbstractButton import javax.swing.JComponent @@ -405,13 +404,8 @@ class TurnViewTest : BasePlatformTestCase() { }.toString()) } - private fun openDiffButton(root: Container): AbstractButton = - buttons(root).first { it.toolTipText == KiloBundle.message("session.part.tool.openDiff") } - - private fun buttons(root: Container): List = root.components.flatMap { child -> - val nested = if (child is Container) buttons(child) else emptyList() - if (child is AbstractButton) nested + child else nested - } + private fun openDiffButton(view: MessageView): AbstractButton = + (view.part("t1") as EditToolView).copyToolbar as AbstractButton private fun reasoning(id: String, content: String) = Reasoning(id).also { it.done = false From b1bc59fd8cfb7279d714aca508a2132c21931b8e Mon Sep 17 00:00:00 2001 From: kirillk Date: Thu, 30 Jul 2026 15:59:47 -0400 Subject: [PATCH 18/28] feat(jetbrains): show branch changes in session header --- .../jetbrains-session-header-changes-badge.md | 5 + .../client/diff/KiloDiffEditorKind.kt | 7 +- .../client/diff/KiloInlineDiffStore.kt | 2 + .../ai/kilocode/client/session/SessionUi.kt | 69 +++++++++--- .../session/ui/header/BranchChangesBadge.kt | 93 ++++++++++++++++ .../session/ui/header/SessionHeaderPanel.kt | 92 ++++++++++++---- .../client/diff/KiloInlineDiffStoreTest.kt | 69 ++++++++++++ .../client/session/SessionUiLayoutTest.kt | 28 +++++ .../client/session/SessionUiTestBase.kt | 3 +- .../ui/header/SessionHeaderPanelTest.kt | 102 ++++++++++++++++-- .../client/testing/FakeWorkspaceRpcApi.kt | 2 + 11 files changed, 424 insertions(+), 48 deletions(-) create mode 100644 .changeset/jetbrains-session-header-changes-badge.md create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/BranchChangesBadge.kt create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/KiloInlineDiffStoreTest.kt diff --git a/.changeset/jetbrains-session-header-changes-badge.md b/.changeset/jetbrains-session-header-changes-badge.md new file mode 100644 index 0000000000..816ac9daab --- /dev/null +++ b/.changeset/jetbrains-session-header-changes-badge.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Show branch changes in the session header and open the branch diff from the badge. diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloDiffEditorKind.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloDiffEditorKind.kt index c1c6bf15c0..b06e54b24c 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloDiffEditorKind.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloDiffEditorKind.kt @@ -143,12 +143,13 @@ internal class KiloDiffEditorService( private fun alive(disposed: AtomicBoolean): Boolean = !project.isDisposed && !disposed.get() - private suspend fun fetch(params: Map): DiffEditorData { + internal suspend fun fetch(params: Map): DiffEditorData { val dir = params["directory"].takeIfPresent() ?: return DiffEditorData.Empty val workspace = service() + val store = project.service() val files = when (params["source"]) { - "branch" -> workspace.branchDiff(dir) - "inline" -> project.service().get(params["token"].orEmpty()).orEmpty() + "branch" -> store.pop(params["token"].orEmpty()).orEmpty().ifEmpty { workspace.branchDiff(dir) } + "inline" -> store.get(params["token"].orEmpty()).orEmpty() else -> project.service().diff(params["sessionId"].orEmpty(), dir) } if (files.isEmpty()) return DiffEditorData.Empty diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloInlineDiffStore.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloInlineDiffStore.kt index 8cf7260cee..51129b654b 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloInlineDiffStore.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloInlineDiffStore.kt @@ -13,4 +13,6 @@ class KiloInlineDiffStore { } fun get(token: String): List? = items[token] + + fun pop(token: String): List? = items.remove(token) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt index 14a37eec7c..fb3aee1a7c 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt @@ -89,6 +89,8 @@ import com.intellij.util.concurrency.annotations.RequiresEdt import java.util.function.Predicate import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.awt.BorderLayout @@ -207,6 +209,9 @@ class SessionUi( } private var editorTheme = style.editorScheme private var colorTheme = UIManager.getLookAndFeel() + private var wasBusy = false + private var branchStarted = false + private var branchJob: Job? = null private var disposed = false init { @@ -219,6 +224,7 @@ class SessionUi( bindStyle() bindMigration() onStateChanged(controller.model.state) + computeInitialBranchChanges() loaded?.let(::finishOpen) } @@ -378,21 +384,7 @@ class SessionUi( it.setDiffOpener(::openInlineDiff, controller.id) it.onHover = { view, on -> if (on) popup.show(view) else popup.notifyExit(view) } } - header = SessionHeaderPanel(controller, this) { - ensureDiffEditorKind() - cs.launch { - val branch = workspaces.branchName(workspace.directory) - val title = branch?.let { KiloBundle.message("diff.editor.branch.title.named", it) } - ?: KiloBundle.message("diff.editor.branch.title") - withContext(Dispatchers.Main) { - project.service().open( - KiloDiffEditorKind.ID, - diffParams("branch", workspace.directory, null, title, branch), - ) - Telemetry.send("Diff Editor Opened", mapOf("source" to "branch")) - } - } - } + header = SessionHeaderPanel(controller, this) { computeBranchChanges(open = true) } scroll = SessionScroll(root, sessionContent, messageBody, blankBody) scroll.onScroll = { @@ -570,7 +562,8 @@ class SessionUi( is SessionModelEvent.TurnUpdated, is SessionModelEvent.ContentAdded, is SessionModelEvent.ContentDelta, - is SessionModelEvent.HistoryLoaded, + is SessionModelEvent.HistoryLoaded -> computeInitialBranchChanges() + is SessionModelEvent.TurnRemoved, is SessionModelEvent.MessageAdded, is SessionModelEvent.MessageUpdated, @@ -736,6 +729,7 @@ class SessionUi( @RequiresEdt private fun onRevertChanged(revert: SessionRevertDto?) { + computeBranchChanges(open = false) syncPromptRevert() val rollback = pendingRollback if (rollback != null) { @@ -840,6 +834,43 @@ class SessionUi( } } + private fun computeBranchChanges(open: Boolean) { + val prev = branchJob + prev?.cancel() + branchJob = cs.launch { + if (open) prev?.cancelAndJoin() + val dir = workspace.directory + val files = workspaces.branchDiff(dir) + val branch = if (open) workspaces.branchName(dir) else null + withContext(Dispatchers.Main) { + if (disposed || project.isDisposed) return@withContext + header.setBranchChanges(files) + if (open) openBranchDiff(files, branch) + } + } + } + + private fun computeInitialBranchChanges() { + if (branchStarted) return + branchStarted = true + computeBranchChanges(open = false) + } + + @RequiresEdt + private fun openBranchDiff(files: List, branch: String?) { + ensureDiffEditorKind() + val dir = workspace.directory + val token = "branch:$dir" + val title = branch?.let { KiloBundle.message("diff.editor.branch.title.named", it) } + ?: KiloBundle.message("diff.editor.branch.title") + project.service().put(token, files) + project.service().open( + KiloDiffEditorKind.ID, + diffParams("branch", dir, null, title, branch, token = token), + ) + Telemetry.send("Diff Editor Opened", mapOf("source" to "branch")) + } + private fun openAttachment(messageId: String, item: FileAttachment) { val url = item.url.takeIf { it.isNotBlank() } ?: run { LOG.info("kind=attachment-open skipped=true reason=blank-url message=$messageId part=${item.id} name=${attachmentName(item)} mime=${item.mime}") @@ -890,12 +921,15 @@ class SessionUi( private fun onStateChanged(state: SessionState) { if (disposed) return + val busy = state.isBusy() + if (wasBusy && state is SessionState.Idle) computeBranchChanges(open = false) + wasBusy = busy if (state is SessionState.Reverting) overlay.clear() if (state is SessionState.Error) { pendingRollback = null pendingRedo = null } - prompt.setBusy(state.isBusy()) + prompt.setBusy(busy) load.setState(state) scroll.setQuestionPending(questionPending(state)) scroll.show(body(state)) @@ -963,6 +997,7 @@ class SessionUi( override fun dispose() { disposed = true + branchJob?.cancel() hide.stop() popup.hideAll() modalFocus = null diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/BranchChangesBadge.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/BranchChangesBadge.kt new file mode 100644 index 0000000000..08cc0a02d0 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/BranchChangesBadge.kt @@ -0,0 +1,93 @@ +package ai.kilocode.client.session.ui.header + +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.session.ui.style.SessionEditorStyle +import ai.kilocode.client.ui.DiffStatBadge +import ai.kilocode.client.ui.UiStyle +import ai.kilocode.client.ui.layout.Stack +import ai.kilocode.rpc.dto.DiffFileDto +import com.intellij.ui.components.JBLabel +import com.intellij.util.ui.JBUI +import java.awt.Cursor +import java.awt.FlowLayout +import java.awt.Graphics +import java.awt.Graphics2D +import java.awt.RenderingHints +import java.awt.event.MouseAdapter +import java.awt.event.MouseEvent +import javax.swing.JPanel + +internal class BranchChangesBadge( + private val open: () -> Unit, +) : JPanel(FlowLayout(FlowLayout.LEFT, 0, 0)) { + private val count = JBLabel() + private val stat = DiffStatBadge(0, 0, DiffStatBadge.Variant.COMPACT) + private var files = emptyList() + private var additions = 0 + private var deletions = 0 + private var over = false + + init { + isOpaque = false + isVisible = false + cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) + toolTipText = KiloBundle.message("diff.editor.branch.tooltip") + getAccessibleContext().accessibleName = KiloBundle.message("diff.editor.branch.tooltip") + border = JBUI.Borders.empty(0, UiStyle.Gap.sm()) + add(Stack.horizontal(gap = UiStyle.Gap.sm()).next(count).next(stat)) + addMouseListener(object : MouseAdapter() { + override fun mouseEntered(event: MouseEvent) = hover(true) + override fun mouseExited(event: MouseEvent) = hover(false) + override fun mouseClicked(event: MouseEvent) = open() + }) + } + + fun applyStyle(style: SessionEditorStyle) { + count.font = style.smallFont + count.foreground = UiStyle.Colors.weak() + } + + fun update(next: List): Boolean { + if (files == next) return false + files = next + additions = files.sumOf { it.additions } + deletions = files.sumOf { it.deletions } + val text = KiloBundle.message( + if (files.size == 1) "session.changes.count.one" else "session.changes.count.other", + files.size, + ) + count.text = text + stat.update(additions, deletions) + isVisible = files.isNotEmpty() + revalidate() + repaint() + return true + } + + override fun paintComponent(g: Graphics) { + if (over && isEnabled) paintHover(g) + super.paintComponent(g) + } + + internal fun countText() = count.text + + internal fun stats() = additions to deletions + + private fun hover(value: Boolean) { + if (over == value) return + over = value + repaint() + } + + private fun paintHover(g: Graphics) { + val g2 = g.create() as Graphics2D + try { + g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON) + g2.color = UiStyle.Colors.actionHoverBackground() + val arc = JBUI.scale(JBUI.getInt("Button.arc", 6)) + g2.fillRoundRect(0, 0, width, height, arc, arc) + } finally { + g2.dispose() + } + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanel.kt index 2dcbed8934..0666140b15 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanel.kt @@ -11,6 +11,7 @@ import ai.kilocode.client.session.views.todo.TodoListPanel import ai.kilocode.client.ui.HoverIcon import ai.kilocode.client.ui.UiStyle import ai.kilocode.client.ui.layout.Stack +import ai.kilocode.rpc.dto.DiffFileDto import ai.kilocode.rpc.dto.TodoDto import ai.kilocode.rpc.dto.TokensDto import com.intellij.icons.AllIcons @@ -19,6 +20,7 @@ import com.intellij.openapi.Disposable import com.intellij.openapi.util.IconLoader import com.intellij.ui.JBColor import com.intellij.ui.components.JBLabel +import com.intellij.util.ui.SwingTextTrimmer import com.intellij.util.ui.JBUI import com.intellij.util.ui.components.BorderLayoutPanel import java.awt.BorderLayout @@ -53,7 +55,15 @@ class SessionHeaderPanel( internal const val EXPANDED_KEY = "kilo.session.header.expanded" } - private val title = JBLabel() + private val title = JBLabel().apply { + putClientProperty(SwingTextTrimmer.KEY, SwingTextTrimmer.ELLIPSIS_AT_RIGHT) + cursor = java.awt.Cursor.getPredefinedCursor(java.awt.Cursor.HAND_CURSOR) + addMouseListener(object : MouseAdapter() { + override fun mouseClicked(event: MouseEvent) { + toggle() + } + }) + } private val cost = JBLabel() private val context = JBLabel() private val todos = JBLabel() @@ -66,15 +76,9 @@ class SessionHeaderPanel( accessibleContext.accessibleName = KiloBundle.message("session.header.compact") addActionListener { controller.compact() } } - private val branch = HoverIcon().apply { - icon = AllIcons.Actions.Diff - cursor = java.awt.Cursor.getPredefinedCursor(java.awt.Cursor.HAND_CURSOR) - toolTipText = KiloBundle.message("diff.editor.branch.tooltip") - accessibleContext.accessibleName = KiloBundle.message("diff.editor.branch.tooltip") - isVisible = onOpenBranchDiff != null - addActionListener { onOpenBranchDiff?.invoke() } - } + private val changes = BranchChangesBadge { onOpenBranchDiff?.invoke() } private val expand = JBLabel().apply { + border = JBUI.Borders.empty(0, UiStyle.Gap.sm()) cursor = java.awt.Cursor.getPredefinedCursor(java.awt.Cursor.HAND_CURSOR) toolTipText = KiloBundle.message("session.header.expand") accessibleContext.accessibleName = KiloBundle.message("session.header.expand") @@ -111,16 +115,46 @@ class SessionHeaderPanel( iconTextGap = UiStyle.Gap.xs() } private val top = BorderLayoutPanel() - private val center = BorderLayoutPanel().apply { - border = JBUI.Borders.empty(0, UiStyle.Gap.md(), 0, 0) + // Lays the title out first with the branch-changes badge hugging its trailing edge, + // both vertically centered. The title ellipsizes so the badge stays visible on long titles. + private val centerGroup = object : JPanel(null) { + override fun getPreferredSize(): Dimension { + val ins = insets + val t = title.preferredSize + var w = t.width + var h = t.height + if (changes.isVisible) { + val b = changes.preferredSize + w += UiStyle.Gap.sm() + b.width + h = maxOf(h, b.height) + } + return Dimension(w + ins.left + ins.right, h + ins.top + ins.bottom) + } + + override fun doLayout() { + val ins = insets + val availW = maxOf(0, width - ins.left - ins.right) + val availH = maxOf(0, height - ins.top - ins.bottom) + val t = title.preferredSize + val gap = if (changes.isVisible) UiStyle.Gap.sm() else 0 + val b = if (changes.isVisible) changes.preferredSize else Dimension(0, 0) + val badgeW = minOf(b.width, availW) + val titleW = minOf(t.width, maxOf(0, availW - badgeW - gap)) + val titleH = minOf(t.height, availH) + title.setBounds(ins.left, ins.top + (availH - titleH) / 2, titleW, titleH) + if (changes.isVisible) { + val badgeH = minOf(b.height, availH) + changes.setBounds(ins.left + titleW + gap, ins.top + (availH - badgeH) / 2, badgeW, badgeH) + } + } + }.apply { + border = JBUI.Borders.empty(0, UiStyle.Gap.sm(), 0, 0) } private val right = Stack.horizontal() .next(cost) .gap(UiStyle.Gap.xl()) .next(context) .gap(UiStyle.Gap.sm()) - .next(branch) - .gap(UiStyle.Gap.sm()) .next(compact) private val tokens = JPanel(FlowLayout(FlowLayout.LEFT, 0, 0)).apply { isOpaque = false @@ -170,10 +204,11 @@ class SessionHeaderPanel( isOpaque = true updateUI() - center.add(title, BorderLayout.CENTER) - center.add(right, BorderLayout.EAST) + centerGroup.add(title) + centerGroup.add(changes) top.add(expand, BorderLayout.WEST) - top.add(center, BorderLayout.CENTER) + top.add(centerGroup, BorderLayout.CENTER) + top.add(right, BorderLayout.EAST) add(top, BorderLayout.NORTH) timeline.addMouseListener(object : MouseAdapter() { override fun mousePressed(event: MouseEvent) { @@ -272,6 +307,11 @@ class SessionHeaderPanel( refresh() } + fun setBranchChanges(files: List) { + if (!changes.update(files)) return + refresh() + } + override fun applyStyle(style: SessionEditorStyle) { this.style = style background = style.editorBackground @@ -279,9 +319,10 @@ class SessionHeaderPanel( top.background = style.editorBackground top.isOpaque = true top.border = JBUI.Borders.empty(UiStyle.Gap.md(), UiStyle.Gap.sm(), UiStyle.Gap.md(), UiStyle.Gap.sm()) - center.background = style.editorBackground - center.isOpaque = true + centerGroup.background = style.editorBackground + centerGroup.isOpaque = true right.background = style.editorBackground + changes.background = style.editorBackground tokens.background = style.editorBackground todoRow.background = style.editorBackground todoBox.background = style.editorBackground @@ -289,6 +330,7 @@ class SessionHeaderPanel( viewport.background = style.editorBackground title.font = style.boldFont title.foreground = style.editorForeground + changes.applyStyle(style) cost.font = style.regularFont cost.foreground = style.editorForeground cost.icon = null @@ -314,6 +356,8 @@ class SessionHeaderPanel( internal fun titleText(): String = title.text + internal fun titleLabel() = title + internal fun costText(): String = costValue internal fun costTip() = cost.toolTipText @@ -351,7 +395,17 @@ class SessionHeaderPanel( internal fun compactButton() = compact - internal fun branchDiffButton() = branch + internal fun changesBadge() = changes + + internal fun changesVisible() = changes.isVisible + + internal fun changesText() = changes.countText() + + internal fun changesStat() = changes.stats() + + internal fun centerGroupPanel() = centerGroup + + internal fun rightPanel() = right internal fun expandButton() = expand diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/KiloInlineDiffStoreTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/KiloInlineDiffStoreTest.kt new file mode 100644 index 0000000000..cf437afb64 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/KiloInlineDiffStoreTest.kt @@ -0,0 +1,69 @@ +package ai.kilocode.client.diff + +import ai.kilocode.client.app.KiloWorkspaceService +import ai.kilocode.client.testing.FakeWorkspaceRpcApi +import ai.kilocode.client.testing.TestCoroutines +import ai.kilocode.rpc.dto.DiffFileDto +import com.intellij.openapi.components.service +import com.intellij.openapi.application.ApplicationManager +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.testFramework.replaceService +import com.intellij.util.ui.UIUtil +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext + +class KiloInlineDiffStoreTest : BasePlatformTestCase() { + private lateinit var coroutines: TestCoroutines + private lateinit var workspace: FakeWorkspaceRpcApi + private lateinit var service: KiloDiffEditorService + + override fun setUp() { + super.setUp() + coroutines = TestCoroutines() + workspace = FakeWorkspaceRpcApi() + service = KiloDiffEditorService(project, coroutines.scope) + project.replaceService(KiloInlineDiffStore::class.java, KiloInlineDiffStore(), testRootDisposable) + ApplicationManager.getApplication() + .replaceService(KiloWorkspaceService::class.java, KiloWorkspaceService(coroutines.scope, workspace), testRootDisposable) + } + + override fun tearDown() { + try { + coroutines.close { UIUtil.dispatchAllInvocationEvents() } + } finally { + super.tearDown() + } + } + + fun `test pop returns then clears while get remains persistent`() { + val store = project.service() + val files = listOf(file("src/A.kt", 2, 1)) + + store.put("inline", files) + assertEquals(files, store.get("inline")) + assertEquals(files, store.get("inline")) + + store.put("branch:/test", files) + assertEquals(files, store.pop("branch:/test")) + assertNull(store.pop("branch:/test")) + } + + fun `test branch fetch consumes seeded snapshot before recomputing`() = runBlocking { + val store = project.service() + val seed = listOf(file("src/Seed.kt", 3, 1)) + val fresh = file("src/Fresh.kt", 1, 0) + workspace.branchDiffs.add(fresh) + workspace.branchName = "main" + store.put("branch:/test", seed) + val params = diffParams("branch", "/test", null, "Branch", "main", token = "branch:/test") + + val first = withContext(coroutines.dispatcher) { service.fetch(params) } as DiffEditorData.Files + val second = withContext(coroutines.dispatcher) { service.fetch(params) } as DiffEditorData.Files + + assertEquals(seed, first.files) + assertEquals(listOf(fresh), second.files) + assertEquals(listOf("/test"), workspace.branchDiffCalls) + } + + private fun file(path: String, additions: Int, deletions: Int) = DiffFileDto(path, additions, deletions) +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt index 3cb8526fb8..9a3a19bc4d 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt @@ -28,6 +28,7 @@ import ai.kilocode.rpc.dto.KiloAppStateDto import ai.kilocode.rpc.dto.KiloAppStatusDto import ai.kilocode.rpc.dto.ProfileDto import ai.kilocode.rpc.dto.SessionRevertDto +import ai.kilocode.rpc.dto.DiffFileDto import com.intellij.util.ui.JBUI import ai.kilocode.client.session.views.permission.PermissionView import ai.kilocode.client.session.views.question.QuestionView @@ -114,6 +115,33 @@ class SessionUiLayoutTest : SessionUiTestBase() { assertNull(drop.dropTarget) } + fun `test branch changes badge refreshes on finish and revert`() { + workspaceRpc.branchDiffs.clear() + workspaceRpc.branchDiffs.add(DiffFileDto("src/A.kt", 2, 1)) + val header = find(ui) + + controller().model.setState(SessionState.Busy("running")) + controller().model.setState(SessionState.Idle) + settle() + + assertEquals(2 to 1, header.changesStat()) + + workspaceRpc.branchDiffs.clear() + workspaceRpc.branchDiffs.add(DiffFileDto("src/B.kt", 4, 3)) + controller().model.setState(SessionState.Busy("running")) + controller().model.setState(SessionState.Idle) + settle() + + assertEquals(4 to 3, header.changesStat()) + + workspaceRpc.branchDiffs.clear() + workspaceRpc.branchDiffs.add(DiffFileDto("src/C.kt", 1, 0)) + controller().model.setRevert(SessionRevertDto("msg1", "part1", diff = "patch")) + settle() + + assertEquals(1 to 0, header.changesStat()) + } + fun `test prompt file drag leave does not immediately hide drop overlay`() { val prompt = find(ui) val drop = find(ui) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiTestBase.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiTestBase.kt index de7051b922..8f1ca79143 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiTestBase.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiTestBase.kt @@ -46,6 +46,7 @@ abstract class SessionUiTestBase : BasePlatformTestCase() { protected lateinit var sessions: KiloSessionService protected lateinit var app: KiloAppService protected lateinit var workspaces: KiloWorkspaceService + protected lateinit var workspaceRpc: FakeWorkspaceRpcApi protected lateinit var rpc: FakeSessionRpcApi protected lateinit var appRpc: FakeAppRpcApi protected lateinit var workspace: Workspace @@ -60,7 +61,7 @@ abstract class SessionUiTestBase : BasePlatformTestCase() { appRpc = FakeAppRpcApi().also { it.state.value = KiloAppStateDto(KiloAppStatusDto.READY) } - val workspaceRpc = FakeWorkspaceRpcApi().also { + workspaceRpc = FakeWorkspaceRpcApi().also { it.state.value = KiloWorkspaceStateDto(status = KiloWorkspaceStatusDto.READY) } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanelTest.kt index bbdc9c7237..f0f4819600 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanelTest.kt @@ -20,12 +20,15 @@ import ai.kilocode.rpc.dto.TodoDto import ai.kilocode.rpc.dto.TokensDto import com.intellij.icons.AllIcons import com.intellij.ide.util.PropertiesComponent +import ai.kilocode.rpc.dto.DiffFileDto import java.awt.Cursor import java.awt.Color import java.awt.Point import java.awt.event.MouseEvent import java.awt.event.MouseWheelEvent import java.awt.image.BufferedImage +import javax.swing.JComponent +import javax.swing.RepaintManager import javax.swing.UIManager class SessionHeaderPanelTest : SessionControllerTestBase() { @@ -123,26 +126,94 @@ class SessionHeaderPanelTest : SessionControllerTestBase() { assertEquals(1, rpc.compacts.size) } - fun `test branch diff button invokes callback when configured`() { + fun `test branch changes badge shows count stats and hides when empty`() { + val c = promptedHeader() + val panel = SessionHeaderPanel(c, parent) + + assertFalse(panel.changesVisible()) + + panel.setBranchChanges(listOf( + DiffFileDto("src/A.kt", 2, 1), + DiffFileDto("src/B.kt", 0, 3), + DiffFileDto("src/C.kt", 5, 0), + )) + + assertTrue(panel.changesVisible()) + assertEquals("3 files", panel.changesText()) + assertEquals(7 to 4, panel.changesStat()) + + panel.setBranchChanges(emptyList()) + + assertFalse(panel.changesVisible()) + } + + fun `test branch changes badge invokes callback when clicked`() { val c = promptedHeader() var opened = 0 val panel = SessionHeaderPanel(c, parent) { opened++ } - val button = panel.branchDiffButton() + val badge = panel.changesBadge() - assertTrue(button.isVisible) - assertEquals(KiloBundle.message("diff.editor.branch.tooltip"), button.toolTipText) - assertEquals(KiloBundle.message("diff.editor.branch.tooltip"), button.accessibleContext.accessibleName) + panel.setBranchChanges(listOf(DiffFileDto("src/A.kt", 2, 1))) - button.doClick() + assertTrue(badge.isVisible) + assertEquals(KiloBundle.message("diff.editor.branch.tooltip"), badge.toolTipText) + assertEquals(KiloBundle.message("diff.editor.branch.tooltip"), badge.accessibleContext.accessibleName) + + click(badge) assertEquals(1, opened) } - fun `test branch diff button is hidden without callback`() { + fun `test branch changes badge is hidden without files even with callback`() { + val c = promptedHeader() + val panel = SessionHeaderPanel(c, parent) {} + + assertFalse(panel.changesVisible()) + } + + fun `test branch changes badge no-op update does not repaint`() { + val c = promptedHeader() + val panel = SessionHeaderPanel(c, parent) + val files = listOf(DiffFileDto("src/A.kt", 2, 1)) + panel.setBranchChanges(files) + val prev = RepaintManager.currentManager(panel) + val tracker = TrackingRepaintManager(panel.changesBadge()) + + try { + RepaintManager.setCurrentManager(tracker) + panel.setBranchChanges(files) + + assertTrue(tracker.dirty.isEmpty()) + assertTrue(tracker.invalid.isEmpty()) + } finally { + RepaintManager.setCurrentManager(prev) + } + } + + fun `test clicking session title toggles expansion`() { val c = promptedHeader() val panel = SessionHeaderPanel(c, parent) - assertFalse(panel.branchDiffButton().isVisible) + assertFalse(panel.isExpanded()) + + click(panel.titleLabel()) + assertTrue(panel.isExpanded()) + + click(panel.titleLabel()) + assertFalse(panel.isExpanded()) + } + + fun `test top row places expand center group and right controls`() { + val c = promptedHeader() + val panel = SessionHeaderPanel(c, parent) + val top = panel.expandButton().parent + val layout = top.layout as java.awt.BorderLayout + + assertSame(panel.expandButton(), layout.getLayoutComponent(java.awt.BorderLayout.WEST)) + assertSame(panel.centerGroupPanel(), layout.getLayoutComponent(java.awt.BorderLayout.CENTER)) + assertSame(panel.rightPanel(), layout.getLayoutComponent(java.awt.BorderLayout.EAST)) + assertSame(panel.centerGroupPanel(), panel.changesBadge().parent) + assertSame(panel.rightPanel(), panel.compactButton().parent) } fun `test todo list starts collapsed and toggles independently`() { @@ -700,4 +771,19 @@ class SessionHeaderPanelTest : SessionControllerTestBase() { private fun reset() { PropertiesComponent.getInstance().unsetValue(SessionHeaderPanel.EXPANDED_KEY) } + + private class TrackingRepaintManager(private val watched: JComponent) : RepaintManager() { + val dirty = mutableListOf() + val invalid = mutableListOf() + + override fun addDirtyRegion(c: JComponent, x: Int, y: Int, w: Int, h: Int) { + if (c === watched) dirty.add(c) + super.addDirtyRegion(c, x, y, w, h) + } + + override fun addInvalidComponent(invalidComponent: JComponent) { + if (invalidComponent === watched) invalid.add(invalidComponent) + super.addInvalidComponent(invalidComponent) + } + } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorkspaceRpcApi.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorkspaceRpcApi.kt index 8ef01f7291..3e526c7430 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorkspaceRpcApi.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorkspaceRpcApi.kt @@ -36,6 +36,7 @@ class FakeWorkspaceRpcApi : KiloWorkspaceRpcApi { var search: ((String) -> FileSearchResultDto)? = null var gitChanges: String? = null val branchDiffs = mutableListOf() + val branchDiffCalls = CopyOnWriteArrayList() var branchName: String? = null var openResult = true var localConfigPath = "/test/.kilo/kilo.jsonc" @@ -99,6 +100,7 @@ class FakeWorkspaceRpcApi : KiloWorkspaceRpcApi { override suspend fun branchDiff(directory: String): List { assertNotEdt("branchDiff") + branchDiffCalls.add(directory) return branchDiffs.toList() } From 8a15b0029cacb350bcf471a3c491f1cdd9576b12 Mon Sep 17 00:00:00 2001 From: kirillk Date: Thu, 30 Jul 2026 16:24:24 -0400 Subject: [PATCH 19/28] fix(jetbrains): center branch changes badge content --- .../session/ui/header/BranchChangesBadge.kt | 27 ++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/BranchChangesBadge.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/BranchChangesBadge.kt index 08cc0a02d0..05c42daaac 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/BranchChangesBadge.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/BranchChangesBadge.kt @@ -9,7 +9,7 @@ import ai.kilocode.rpc.dto.DiffFileDto import com.intellij.ui.components.JBLabel import com.intellij.util.ui.JBUI import java.awt.Cursor -import java.awt.FlowLayout +import java.awt.Dimension import java.awt.Graphics import java.awt.Graphics2D import java.awt.RenderingHints @@ -19,9 +19,10 @@ import javax.swing.JPanel internal class BranchChangesBadge( private val open: () -> Unit, -) : JPanel(FlowLayout(FlowLayout.LEFT, 0, 0)) { +) : JPanel(null) { private val count = JBLabel() private val stat = DiffStatBadge(0, 0, DiffStatBadge.Variant.COMPACT) + private val row = Stack.horizontal(gap = UiStyle.Gap.sm()).next(count).next(stat) private var files = emptyList() private var additions = 0 private var deletions = 0 @@ -34,7 +35,7 @@ internal class BranchChangesBadge( toolTipText = KiloBundle.message("diff.editor.branch.tooltip") getAccessibleContext().accessibleName = KiloBundle.message("diff.editor.branch.tooltip") border = JBUI.Borders.empty(0, UiStyle.Gap.sm()) - add(Stack.horizontal(gap = UiStyle.Gap.sm()).next(count).next(stat)) + add(row) addMouseListener(object : MouseAdapter() { override fun mouseEntered(event: MouseEvent) = hover(true) override fun mouseExited(event: MouseEvent) = hover(false) @@ -47,6 +48,26 @@ internal class BranchChangesBadge( count.foreground = UiStyle.Colors.weak() } + override fun getPreferredSize(): Dimension { + val ins = insets + val size = row.preferredSize + return Dimension(size.width + ins.left + ins.right, JBUI.scale(24)) + } + + override fun getMinimumSize(): Dimension = preferredSize + + override fun getMaximumSize(): Dimension = Dimension(Int.MAX_VALUE, preferredSize.height) + + override fun doLayout() { + val ins = insets + val w = maxOf(0, width - ins.left - ins.right) + val h = maxOf(0, height - ins.top - ins.bottom) + val size = row.preferredSize + val rowW = minOf(size.width, w) + val rowH = minOf(size.height, h) + row.setBounds(ins.left, ins.top + (h - rowH) / 2, rowW, rowH) + } + fun update(next: List): Boolean { if (files == next) return false files = next From 17bda33c5f7328241c46a91a8966506c9382a4ba Mon Sep 17 00:00:00 2001 From: kirillk Date: Thu, 30 Jul 2026 16:29:16 -0400 Subject: [PATCH 20/28] fix(jetbrains): tighten session header title gap --- .../ai/kilocode/client/session/ui/header/SessionHeaderPanel.kt | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanel.kt index 0666140b15..88dc42bfee 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanel.kt @@ -147,8 +147,6 @@ class SessionHeaderPanel( changes.setBounds(ins.left + titleW + gap, ins.top + (availH - badgeH) / 2, badgeW, badgeH) } } - }.apply { - border = JBUI.Borders.empty(0, UiStyle.Gap.sm(), 0, 0) } private val right = Stack.horizontal() .next(cost) From a103f4abf91c2d3192c11f18d4a56f54b0dafe25 Mon Sep 17 00:00:00 2001 From: kirillk Date: Thu, 30 Jul 2026 16:44:30 -0400 Subject: [PATCH 21/28] fix(jetbrains): restore diff tree toolbar actions --- .changeset/jetbrains-branch-diff-editor.md | 5 - .../jetbrains-diff-navigation-reload.md | 5 - .../jetbrains-inline-diff-improvements.md | 5 - .changeset/jetbrains-modified-files-view.md | 5 - .changeset/jetbrains-permission-queue.md | 5 - .../jetbrains-session-diff-improvements.md | 5 + .../jetbrains-session-header-changes-badge.md | 5 - .changeset/quiet-diff-tree.md | 5 - ...179166798-jetbrains-modified-files-view.md | 125 ------------------ ...60000-jetbrains-per-turn-modified-files.md | 124 ----------------- .../client/diff/KiloDiffEditorContent.kt | 17 ++- .../client/diff/KiloDiffEditorContentTest.kt | 18 +++ 12 files changed, 31 insertions(+), 293 deletions(-) delete mode 100644 .changeset/jetbrains-branch-diff-editor.md delete mode 100644 .changeset/jetbrains-diff-navigation-reload.md delete mode 100644 .changeset/jetbrains-inline-diff-improvements.md delete mode 100644 .changeset/jetbrains-modified-files-view.md delete mode 100644 .changeset/jetbrains-permission-queue.md create mode 100644 .changeset/jetbrains-session-diff-improvements.md delete mode 100644 .changeset/jetbrains-session-header-changes-badge.md delete mode 100644 .changeset/quiet-diff-tree.md delete mode 100644 .kilo/plans/1785179166798-jetbrains-modified-files-view.md delete mode 100644 .kilo/plans/1785185060000-jetbrains-per-turn-modified-files.md diff --git a/.changeset/jetbrains-branch-diff-editor.md b/.changeset/jetbrains-branch-diff-editor.md deleted file mode 100644 index c0e725c0ad..0000000000 --- a/.changeset/jetbrains-branch-diff-editor.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/kilo-jetbrains": minor ---- - -Support viewing JetBrains session changes against the base branch, including uncommitted and untracked files. diff --git a/.changeset/jetbrains-diff-navigation-reload.md b/.changeset/jetbrains-diff-navigation-reload.md deleted file mode 100644 index f2d5ceb46d..0000000000 --- a/.changeset/jetbrains-diff-navigation-reload.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/kilo-jetbrains": patch ---- - -Improve JetBrains diff navigation and show stale diff views with a manual refresh action when files change on disk. diff --git a/.changeset/jetbrains-inline-diff-improvements.md b/.changeset/jetbrains-inline-diff-improvements.md deleted file mode 100644 index cb5546e40f..0000000000 --- a/.changeset/jetbrains-inline-diff-improvements.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/kilo-jetbrains": patch ---- - -Improve JetBrains inline diff cards with diff-viewer actions, cleaner change badges, and real old/new line numbers. diff --git a/.changeset/jetbrains-modified-files-view.md b/.changeset/jetbrains-modified-files-view.md deleted file mode 100644 index 20f7342409..0000000000 --- a/.changeset/jetbrains-modified-files-view.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/kilo-jetbrains": minor ---- - -Show a session-level modified files card with expandable per-file diffs in JetBrains. diff --git a/.changeset/jetbrains-permission-queue.md b/.changeset/jetbrains-permission-queue.md deleted file mode 100644 index a9fab8d8c5..0000000000 --- a/.changeset/jetbrains-permission-queue.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/kilo-jetbrains": patch ---- - -Handle multiple pending permissions in JetBrains sessions without getting stuck. diff --git a/.changeset/jetbrains-session-diff-improvements.md b/.changeset/jetbrains-session-diff-improvements.md new file mode 100644 index 0000000000..c325fd9ed2 --- /dev/null +++ b/.changeset/jetbrains-session-diff-improvements.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": minor +--- + +Improve JetBrains session change tracking with modified-files summaries, inline and branch diff views, refreshable diff navigation, and clearer session header change badges. diff --git a/.changeset/jetbrains-session-header-changes-badge.md b/.changeset/jetbrains-session-header-changes-badge.md deleted file mode 100644 index 816ac9daab..0000000000 --- a/.changeset/jetbrains-session-header-changes-badge.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/kilo-jetbrains": patch ---- - -Show branch changes in the session header and open the branch diff from the badge. diff --git a/.changeset/quiet-diff-tree.md b/.changeset/quiet-diff-tree.md deleted file mode 100644 index 36d9d1a119..0000000000 --- a/.changeset/quiet-diff-tree.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/kilo-jetbrains": patch ---- - -Improve the branch diff editor tree with expand/collapse controls, change badges, and branch-aware diff labels. diff --git a/.kilo/plans/1785179166798-jetbrains-modified-files-view.md b/.kilo/plans/1785179166798-jetbrains-modified-files-view.md deleted file mode 100644 index 754c68511d..0000000000 --- a/.kilo/plans/1785179166798-jetbrains-modified-files-view.md +++ /dev/null @@ -1,125 +0,0 @@ -# JetBrains: Session "Modified files" view - -Add a VS Code–style **"Modified N files"** card to the JetBrains chat transcript. It behaves like -the existing `apply_patch` / write tool card (expand shows in-place per-file diffs; collapsed shows a -hover popup) but is fed by the **whole-session cumulative diff** and styled like VS Code. - -## Decisions (resolved) - -- **Scope**: Whole-session cumulative diff. Source = existing `SessionModel.diff: List` - (fed by the `session.diff` SSE event, already parsed **with per-file `patch`** in - `KiloCliDataParser.kt:256-268`). **No backend / shared DTO / CLI changes.** -- **Behavior**: Reuse `SecondarySessionPartView` (expand/collapse + hover-popup) and `PatchBody` - (per-file sections: filename link + `DiffStatBadge` + unified diff), exactly like `EditToolView`. -- **Look**: Header reads like VS Code — a "Modified" label, an "N file(s)" count, and a compact - 5-block add/delete **bars** meter (mirrors `packages/ui/src/components/diff-changes.tsx` `variant="bars"`). -- **Placement**: One session-level card in the transcript footer, wired like `RevertBanner`. -- **Visibility**: Shown when `model.diff` is non-empty **and** no revert is pending. When a revert is - pending, `RevertBanner` (which already lists the same files) takes over, so hide this card to avoid - duplication. -- **No "open full changes" action** — patch-style expand/popup only (matches the requested behavior). - -## Key reuse points (do NOT duplicate) - -- `SecondarySessionPartView` (`session/views/base/`) — arrow, expand/collapse, header hover bg, popup hook. -- `PatchBody` (`session/views/tool/PatchBody.kt`) — per-file diff sections. Currently `Tool`-coupled; - decouple it to render from `List` (see Task 2). -- `EditFileChange` + `diffStat`/patch helpers (`session/views/tool/ToolSupport.kt`) — `internal`, reuse from frontend. -- `DiffStatBadge` (`ui/DiffStatBadge.kt`) — per-file +/- pill (already used inside `PatchBody`). -- `HeaderPopupRequest` / `HeaderPopupBody` + `POPUP_OPTS` (`EditToolView.kt`) — collapsed popup body. -- Footer wiring: `SessionMessageListPanel` `banner`/`anchorFooter`/`onHover` path and `SessionUi:356-373`. - -## Data flow - -`session.diff` SSE → `KiloCliDataParser` → `ChatEventDto.SessionDiffChanged` → -`SessionController.handle` → `model.setDiff` → `SessionModelEvent.DiffUpdated` → -`SessionMessageListPanel` (already listens at line 160) → `ModifiedFilesView.update()`. - -## Tasks - -1. **`DiffBars` widget** — new `frontend/.../ui/DiffBars.kt`. - - Small `JPanel` painting 5 rounded blocks; color each block add vs delete vs neutral by ratio of - `additions`/`deletions` (port the block-count logic from `diff-changes.tsx`: `TOTAL_BLOCKS = 5`). - - Colors: `UiStyle.Colors.addedForeground()`, `removedForeground()`, `weak()` (neutral). Sizes via - `JBUI.scale`. `fun update(additions, deletions)`. Antialiased `paintComponent` like `DiffStatBadge`. - -2. **Decouple `PatchBody` from `Tool`** — `session/views/tool/PatchBody.kt`. - - Extract the render core to operate on `List`: add `mountFiles(files)`, - `updateFiles(files): Boolean`, and make `rebuild`/`signatureOf` take the list. - - Keep `EditBody` conformance for `EditToolView`: `mount(tool) = mountFiles(editFiles(tool))`, - `update(tool) = updateFiles(editFiles(tool))`. No behavior change for `EditToolView`. - - Verify `EditToolViewTest` still passes unchanged. - -3. **`DiffFileDto` → `EditFileChange` mapping** — small `internal` helper (in `ToolSupport.kt` or the - new view file): `path = file`, `additions`, `deletions`, `patch = patch ?: ""`, `type = ""`. - Filter out entries with blank patch (matches `PatchBody.rebuild` filter). - -4. **`ModifiedFilesView`** — new `frontend/.../session/ui/ModifiedFilesView.kt`, sits beside - `RevertBanner`, extends `SecondarySessionPartView`. - - Ctor: `(model: SessionModel, openFile: SessionFileOpener, selection: SessionSelection?)`. - - Header (custom `JPanel`, VS Code look): "Modified" label + count label ("{0} file(s)") + `DiffBars`. - `SecondarySessionPartView` adds the expand arrow to the header row automatically. - - Body (lazy, in `content = { ... }`): a `PatchBody` mounted via `mountFiles(files())` where - `files()` maps `model.diff`. - - `update()` (call on `DiffUpdated`/`HistoryLoaded`/`Cleared`/state change): compute `files()`; - set `isVisible = files.isNotEmpty() && model.revert() == null`; update count label + `DiffBars` - (sum additions/deletions); if expanded, `body.updateFiles(files)`; else leave lazy body untouched. - Compare-before-assign; `revalidate()/repaint()` only when something changed (retained-Swing rule). - - `override expand()`: call `super.expand()`, then `body.updateFiles(files())` + `body.applyStyle`. - - `override headerPopup()`: return `null` when expanded or `files()` empty; else a - `HeaderPopupRequest(row) { ... }` building a **second** `PatchBody(POPUP_OPTS)` mounted from - `files()` in `HeaderPopupBody(..., WIDE_MAX_WIDTH)`. Send `Telemetry.send("Header Popup Shown", - mapOf("surface" to "session", "tool" to "changes"))` in `shown`. - - Implement `SessionEditorStyleTarget.applyStyle` → forward to `PatchBody` + header fonts. - - `contentId` = a stable constant (e.g. `"session-modified-files"`). - -5. **Wire into the transcript** — `session/ui/SessionMessageListPanel.kt` + `session/SessionUi.kt`. - - Add ctor param `modified: ModifiedFilesView? = null` (mirror `banner`). - - In `init`, set `modified?.hover = ::hover` so collapsed-popup uses the existing `onHover`→ - `HeaderPopupController` path (like tool part views). - - Call `modified?.update()` from the `DiffUpdated`, `RevertChanged`, `StateChanged` branches and in - `rebuild()`/`clear()` (alongside the existing `banner?.update()` calls at lines 147/161/297/325). - - Add `modified` to `anchorFooter()` (place before `banner`) and to `applyStyle()`. - - In `SessionUi.kt:356` construct - `modified = ModifiedFilesView(controller.model, fileLinks::open, selection)` and pass it in. - -6. **i18n** — add to `frontend/src/main/resources/messages/KiloBundle.properties` (base only; other - locales fall back): - - `session.changes.modified=Modified` - - `session.changes.count.one={0} file` - - `session.changes.count.other={0} files` - -7. **Tests** — `frontend/src/test/.../session/` (extend `SessionControllerTestBase` / `BasePlatformTestCase`). - - `ModifiedFilesViewTest`: hidden when `model.diff` empty; visible + correct count/bars after - `setDiff`; hidden while a revert is pending; collapsed start (body not created); first `expand()` - creates `PatchBody` sections once (filename link + `DiffStatBadge` + diff per file); collapse - detaches, re-expand reuses same instance; `headerPopup()` returns a request only when collapsed & - non-empty; `update()` on new diff mutates existing labels without rebuilding when collapsed. - - Editor **leak/stress** test (code-editor-bearing view, per plugin rules): drive many `setDiff` - churn + expand/collapse cycles; assert `EditorFactory.getInstance().allEditors.size` returns to a - baseline captured before the loop, and retained component identity holds (`assertSame`). - - Extend `SessionMessageListPanelTest` to assert the footer contains `ModifiedFilesView` and that a - `DiffUpdated` event drives its visibility/count. - - Confirm `EditToolViewTest` and `PatchBody` behavior unchanged after the Task 2 refactor. - -## Risks / notes - -- **`patch` availability**: `session.diff` parsing already includes `patch` (`KiloCliDataParser.kt:265`), - so in-place diffs render without extra fetches. If a future CLI omits patches, `PatchBody` filters - blank-patch files — the header still shows count/bars but the body may be empty; acceptable. -- **Duplication with `RevertBanner`**: mitigated by the "hide while revert pending" rule (Task 4). -- **New visual element**: `DiffBars` is genuinely new (no JetBrains equivalent), so it is not - duplication; keep it minimal and theme-derived. Do not touch `DiffStatBadge` (reused as-is inside `PatchBody`). -- **EDT / retained Swing**: all methods `@RequiresEdt`; mutate existing components in `update()`, lazy - body creation, compare-before-assign — follow the plugin's retained-Swing conventions. -- **Shared-code guard**: everything is under `packages/kilo-jetbrains/` (Kilo-owned) — no `kilocode_change` - markers and no opencode annotations required. - -## Validation - -From `packages/kilo-jetbrains/`: -- `./gradlew typecheck` -- `./gradlew test` (or targeted `--tests "*ModifiedFilesViewTest"`, `"*EditToolViewTest"`, - `"*SessionMessageListPanelTest"`) -- Manual: `./gradlew runIde`, run a session that edits files; confirm the collapsed "Modified N files" - card with bars, hover popup, expand showing per-file diffs, and that it disappears when a revert is pending. diff --git a/.kilo/plans/1785185060000-jetbrains-per-turn-modified-files.md b/.kilo/plans/1785185060000-jetbrains-per-turn-modified-files.md deleted file mode 100644 index a880bb7fbe..0000000000 --- a/.kilo/plans/1785185060000-jetbrains-per-turn-modified-files.md +++ /dev/null @@ -1,124 +0,0 @@ -# JetBrains: per-turn "Modified files" view (VS Code parity) - -Refactor the session-level "Modified files" card into a **per-turn** card rendered at the end of each -turn, matching VS Code. It keeps the same behavior we already built (collapsed header with count + -bars, hover popup, expand → in-place per-file diffs) and **persists across reopen** because the data -rides on the message, not on a live-only event. - -## Data source (no CLI changes) - -Per-turn diffs already exist on the wire as `message.info.summary.diffs` (a `SnapshotFileDiff[]`), -set by the CLI on the **user anchor message** of each turn (`summary.ts:142-144`). It is delivered by: - -- **History / reopen**: `GET /session/{id}/message` returns each user message with `summary.diffs`. -- **Live**: the `message.updated` event carries the updated user-message info with `summary.diffs`. - -Both paths funnel through one parser: `KiloCliDataParser.parseMessage(obj)` (used at line 147 for -`message.updated` and line 397 inside `parseMessages`). JetBrains currently drops `summary` because -`MessageDto` has no such field. So the whole feature is JetBrains-side only. - -`SnapshotFileDiff` maps 1:1 to the existing `DiffFileDto` (`file?`, `patch?`, `additions`, -`deletions`), so no new diff type is needed. - -## Turn model already fits - -`SessionModel` maintains a `Turn` grouping (turn id == user anchor message id) and fires -`TurnAdded` / `TurnUpdated` / `TurnRemoved`. `TurnView` renders one turn (user anchor + following -assistant messages). The per-turn card is a trailing child of `TurnView`, fed by -`model.message(turn.id)?.info?.summary?.diffs`. - -## Tasks - -1. **Shared DTO — `ChatDto.kt`** - - Add: - ```kotlin - @Serializable - data class MessageSummaryDto(val diffs: List = emptyList()) - ``` - - Add `val summary: MessageSummaryDto? = null` to `MessageDto`. - -2. **Backend parse — `KiloCliDataParser.kt`** - - Extract the inline per-file diff mapping (currently `session.diff` branch, lines 258-267) into a - reusable `parseDiffs(elem: JsonElement?): List`; call it from that branch (no dup). - - In `parseMessage(obj)`, read `obj["summary"]?.jsonObject?.get("diffs")` via `parseDiffs` and set - `summary = MessageSummaryDto(diffs)` when the array is present (otherwise `null`). This covers - both history (`parseMessages`) and `message.updated` automatically. - -3. **Refactor `ModifiedFilesView` to be turn-scoped (`session/ui/ModifiedFilesView.kt`)** - - Drop the `model` / `model.diff` / `model.revert()` dependency and the "hide during revert" rule - (turns are removed on revert anyway). - - New API: constructor `(openFile: SessionFileOpener, selection: SessionSelection? = null)` plus - `@RequiresEdt fun setDiffs(diffs: List)`. - - `setDiffs` maps `DiffFileDto` → `EditFileChange` (existing helper), sets - `isVisible = files.isNotEmpty()`, updates count + `DiffBars`, and, when expanded, - `body.updateFiles(files)`. Keep lazy body creation, `expand()`, and `headerPopup()` exactly as - now (reuse `PatchBody` + `POPUP_OPTS` + `DiffBars`). - - Keep `contentId = "session-modified-files"` (or rename to `"turn-modified-files"`). - -4. **Host the card in `TurnView.kt`** - - Add a lazily-created `ModifiedFilesView` kept as the **last** child of the turn. - - `addMessage` must insert message views **before** the card: add at index - `modified?.let { components.indexOf(it) } ?: componentCount`. - - Add `@RequiresEdt fun setDiffs(diffs: List)`: create+append the card on first - non-empty diff, forward to `card.setDiffs(...)`; wire `card.hover = hover` so the popup uses the - existing hover path. Forward `applyStyle` and dispose to the card. - -5. **Drive it from `SessionMessageListPanel.kt`** - - Helper `diffsOf(turn) = model.message(turn.id)?.info?.summary?.diffs.orEmpty()`. - - Call `tv.setDiffs(diffsOf(turn))` at the end of `onTurnAdded`, `onTurnUpdated`, and in `rebuild()` - for each turn (this is what makes it **persist on reopen**). - - Handle `MessageUpdated` (currently a no-op at lines 156-162): when - `turnViews[event.info.info.id]` exists (message is a turn anchor), call - `tv.setDiffs(event.info.info.summary?.diffs.orEmpty())`. This is how a completing turn's diff - appears live. - -6. **Remove the session-level footer card** - - `SessionUi.kt`: drop the `modified = ModifiedFilesView(...)` argument. - - `SessionMessageListPanel.kt`: remove the `modified` ctor param, its `anchorFooter`/`applyStyle`/ - `clear` handling, hover wiring, and the `modified?.update()` calls in `StateChanged`, - `RevertChanged`, `DiffUpdated`, `rebuild`, `clear`. Leave `RevertBanner` untouched (it still uses - `model.diff`; the `session.diff` event / `model.diff` stay for the revert banner). - -## Persistence verification - -On reopen, `SessionController.loadSession()` → `model.loadHistory(items)` stores messages **with** -`summary` (task 2) → `rebuild()` builds turns → `setDiffs(diffsOf(turn))` renders each turn's card. -No extra RPC/fetch and no `session.diff` dependency — unlike the old session-level card, this survives -reopen natively. - -## Tests - -- **Backend** (`KiloCliDataParserTest`): `parseMessage` populates `summary.diffs` (file/patch/additions/ - deletions); a `message.updated` payload with summary yields `MessageUpdated` carrying it; a message - without summary yields `summary == null`. -- **Shared** (serialization test alongside `ChatDtoSerializationTest`): `MessageDto` with/without - `summary` round-trips. -- **`ModifiedFilesViewTest`**: rewrite to the `setDiffs` API — hidden when empty; visible + correct - count after `setDiffs`; collapsed start (body not created); first expand builds one link + badge per - file; popup only when collapsed; editor leak/churn test retained. -- **`TurnViewTest`** (new or extend): card is the last child and appears only when the anchor has - diffs; `addMessage` keeps the card last; `setDiffs([])` hides it. -- **`SessionControllerTestBase` existing-session flow**: seed `rpc.history` with a user message whose - `summary.diffs` is non-empty; assert the reopened turn renders the card (**persistence**). Also emit - a `message.updated` with summary and assert the live card updates. -- Remove the old session-level footer assertions in `SessionMessageListPanelTest` and the - session-scoped bits of the current `ModifiedFilesViewTest`. - -## Notes / risks - -- Scope matches VS Code exactly: only **user** anchor messages carry `summary.diffs`; leading - assistant-only turns show nothing. -- Same snapshot dependency as VS Code: if `snapshot: false`, the CLI emits no diffs, so nothing shows. -- All changes live under `packages/kilo-jetbrains/` (Kilo-owned) — no `kilocode_change` markers, no - opencode annotations. Reuse `PatchBody`, `DiffStatBadge`, `DiffBars`, `SecondarySessionPartView`, - `HeaderPopup*`; introduce no duplicate diff/scroll/rendering code. -- Keep the existing `session.changes.*` i18n keys and the changeset. - -## Validation - -From `packages/kilo-jetbrains/`: -- `./gradlew :frontend:test` and `./gradlew :backend:test` (or targeted `--tests` for the classes above) -- `./gradlew typecheck` -- Manual `./gradlew runIde`: run a multi-turn session that edits files, confirm a card at the end of - each turn (count + bars, hover popup, expand per-file diffs), then reopen the session and confirm the - cards are still there. diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloDiffEditorContent.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloDiffEditorContent.kt index b2074b4bcd..2a3365a11d 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloDiffEditorContent.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloDiffEditorContent.kt @@ -422,15 +422,7 @@ private fun buildFileModel(files: List): DefaultTreeModel { private fun buildTreePanel(tree: Tree, files: List, badge: DiffStatBadge, target: JComponent, refresh: () -> Unit): JComponent { val toolbar = ActionManager.getInstance().createActionToolbar( ActionPlaces.TOOLBAR, - DefaultActionGroup( - ActionManager.getInstance().getAction(IdeActions.ACTION_PREVIOUS_DIFF), - ActionManager.getInstance().getAction(IdeActions.ACTION_NEXT_DIFF), - Separator.getInstance(), - TreeAction(KiloBundle.message("diff.editor.tree.expandAll"), AllIcons.Actions.Expandall) { expandAll(tree) }, - TreeAction(KiloBundle.message("diff.editor.tree.collapseAll"), AllIcons.Actions.Collapseall) { collapseAll(tree) }, - Separator.getInstance(), - TreeAction(KiloBundle.message("diff.editor.refresh"), AllIcons.Actions.Refresh, refresh), - ), + treeToolbarGroup(tree, refresh), true, ) toolbar.targetComponent = target @@ -466,6 +458,13 @@ private fun buildTreePanel(tree: Tree, files: List, badge: DiffStat } } +internal fun treeToolbarGroup(tree: Tree, refresh: () -> Unit) = DefaultActionGroup( + TreeAction(KiloBundle.message("diff.editor.refresh"), AllIcons.Actions.Refresh, refresh), + Separator.getInstance(), + TreeAction(KiloBundle.message("diff.editor.tree.expandAll"), AllIcons.Actions.Expandall) { expandAll(tree) }, + TreeAction(KiloBundle.message("diff.editor.tree.collapseAll"), AllIcons.Actions.Collapseall) { collapseAll(tree) }, +) + private fun fileCount(count: Int): String = KiloBundle.message( if (count == 1) "session.changes.count.one" else "session.changes.count.other", count, diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/KiloDiffEditorContentTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/KiloDiffEditorContentTest.kt index efa66f877c..fb3397453d 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/KiloDiffEditorContentTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/KiloDiffEditorContentTest.kt @@ -1,7 +1,9 @@ package ai.kilocode.client.diff +import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.ui.DiffStatBadge import ai.kilocode.rpc.dto.DiffFileDto +import com.intellij.openapi.actionSystem.Separator import com.intellij.openapi.Disposable import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.fileEditor.FileDocumentManager @@ -172,6 +174,22 @@ class KiloDiffEditorContentTest : BasePlatformTestCase() { } } + fun `test tree toolbar action order`() { + val parent = Disposer.newDisposable() + try { + val view = view(files(), parent) + val tree = components(view).filterIsInstance().single() + val actions = treeToolbarGroup(tree) {}.getChildren(null).toList() + assertEquals(KiloBundle.message("diff.editor.refresh"), actions[0].templatePresentation.text) + assertTrue(actions[1] is Separator) + assertEquals(KiloBundle.message("diff.editor.tree.expandAll"), actions[2].templatePresentation.text) + assertEquals(KiloBundle.message("diff.editor.tree.collapseAll"), actions[3].templatePresentation.text) + assertEquals(4, actions.size) + } finally { + Disposer.dispose(parent) + } + } + fun `test row renderer reuses badge instance`() { val parent = Disposer.newDisposable() try { From e3a2454711ea995dc3ea16f30cc3b2bac4f72fe8 Mon Sep 17 00:00:00 2001 From: kirillk Date: Thu, 30 Jul 2026 17:35:29 -0400 Subject: [PATCH 22/28] fix(jetbrains): address session diff review feedback Roll back unrelated shared changes and address the PR review on the JetBrains session-diff work. Shared reverts (keep the PR scoped to JetBrains): - Restore packages/opencode/src/mcp/catalog.ts to upstream (the defensive MCP error-shape guard was unnecessary and widened the fork diff). - Restore the ui `spacing: 0` diff metrics in file.tsx / virtualizer.ts. Backend: - branchDiff: add --relative so tracked paths are project-relative and scoped to the opened directory (matches the untracked list in monorepos). - branchDiff: fetch patches per file and stop at DIFF_CAP instead of materializing the whole repo's full-context diff up front; add a patches=false stat-only path for the header badge. - defaultBranch: resolve via origin/HEAD then origin/local main|master using fully-qualified refs (no tag confusion, non-main/master repos). - session diff() now uses GET /session/:id/diff (cumulative, deduped, unquoted) instead of concatenating per-turn summaries. Frontend: - Permission queue: purge ghost permissions the CLI abandons on turn interruption (TurnClose / idle) and on child untrack. - Diff editor: bind processor listeners to the processor, constrain "open file" to the diff directory, bind the refresh coroutine to the view, stop swallowing CancellationException, and make the branch source fetch authoritative (drop the store seeding side channel). - KiloInlineDiffStore: bound with a small LRU. - DiffLineNumbers / pureDiff: keep in-hunk header-shaped lines and mirror trim('\n') so the gutter stays aligned; DiffPatchReconstruct treats multi-hunk / length-mismatched patches as non-renderable. - ToolMarkdownBody re-installs the diff gutter on applyStyle so it survives collapse/re-expand. - EditToolView: don't bind the file link for toggling, avoid re-parsing diff metadata per delta, keep the file name in single-file diff titles. - Only relayout the transcript when a turn's modified-files card changed. - Propagate branch/session diff errors so the editor shows a retry. - Badge: separate open vs refresh jobs, keyboard-accessible, gate on isEnabled; remove dead history-load refresh branches. - DiffBlocks / KiloBundle: bundle key for the diff-unavailable string, drop unused bundle keys. --- .../jetbrains-session-diff-improvements.md | 2 +- .../backend/rpc/KiloSessionRpcApiImpl.kt | 14 +++- .../backend/rpc/KiloWorkspaceRpcApiImpl.kt | 67 +++++++++++++---- .../kilocode/client/app/KiloSessionService.kt | 9 +-- .../client/app/KiloWorkspaceService.kt | 17 ++--- .../ai/kilocode/client/diff/DiffBlocks.kt | 2 +- .../kilocode/client/diff/DiffLineNumbers.kt | 20 +++--- .../client/diff/DiffPatchReconstruct.kt | 30 ++++++-- .../client/diff/KiloDiffEditorContent.kt | 24 +++++-- .../client/diff/KiloDiffEditorKind.kt | 7 +- .../client/diff/KiloInlineDiffStore.kt | 19 ++++- .../ai/kilocode/client/session/SessionUi.kt | 71 ++++++++++++------- .../session/controller/SessionController.kt | 28 ++++++++ .../client/session/ui/ModifiedFilesView.kt | 8 ++- .../session/ui/SessionMessageListPanel.kt | 8 ++- .../session/ui/header/BranchChangesBadge.kt | 26 ++++++- .../kilocode/client/session/views/TurnView.kt | 12 ++-- .../client/session/views/tool/EditToolView.kt | 20 ++++-- .../session/views/tool/ToolMarkdownBody.kt | 7 ++ .../client/session/views/tool/ToolSupport.kt | 24 +++++-- .../resources/messages/KiloBundle.properties | 3 +- .../client/diff/DiffLineNumbersTest.kt | 30 ++++++++ .../client/diff/DiffPatchReconstructTest.kt | 47 ++++++++++++ .../client/diff/KiloInlineDiffStoreTest.kt | 15 ++-- .../session/controller/PermissionQueueTest.kt | 29 ++++++++ .../client/session/views/EditToolViewTest.kt | 5 +- .../client/testing/FakeWorkspaceRpcApi.kt | 4 +- .../ai/kilocode/rpc/KiloWorkspaceRpcApi.kt | 9 ++- packages/opencode/src/mcp/catalog.ts | 11 +-- packages/ui/src/components/file.tsx | 1 + packages/ui/src/pierre/virtualizer.ts | 1 + 31 files changed, 444 insertions(+), 126 deletions(-) diff --git a/.changeset/jetbrains-session-diff-improvements.md b/.changeset/jetbrains-session-diff-improvements.md index c325fd9ed2..e75be291a8 100644 --- a/.changeset/jetbrains-session-diff-improvements.md +++ b/.changeset/jetbrains-session-diff-improvements.md @@ -2,4 +2,4 @@ "@kilocode/kilo-jetbrains": minor --- -Improve JetBrains session change tracking with modified-files summaries, inline and branch diff views, refreshable diff navigation, and clearer session header change badges. +Improve JetBrains session change tracking: show the files each assistant turn modified with expandable per-file diffs, open inline and branch diffs in a refreshable diff viewer, and surface branch changes in the session header. diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt index 31c5da49d9..82442564a8 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt @@ -27,6 +27,8 @@ import ai.kilocode.rpc.dto.SessionStatusDto import com.intellij.openapi.components.service import ai.kilocode.log.KiloLog import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.onCompletion @@ -142,8 +144,16 @@ class KiloSessionRpcApiImpl internal constructor( override suspend fun messages(id: String, directory: String): List = ready { chat.messages(id, directory) } - override suspend fun diff(id: String, directory: String): List = - ready { chat.messages(id, directory).flatMap { it.info.summary?.diffs.orEmpty() } } + override suspend fun diff(id: String, directory: String): List = ready { + // GET /session/:id/diff returns the cumulative, deduplicated, unquoted snapshot diff. Prefer it + // over concatenating per-message summaries (which duplicate files per turn and skip unquoting). + val api = app.api ?: throw IllegalStateException("Kilo API is unavailable") + withContext(Dispatchers.IO) { api.sessionDiff(sessionID = id, directory = directory) } + .mapNotNull { file -> + val path = file.file ?: return@mapNotNull null + DiffFileDto(path, file.additions.toInt(), file.deletions.toInt(), file.patch, file.status?.value) + } + } override suspend fun attachmentPart(id: String, directory: String, messageId: String, partId: String, attachmentKey: String?): PartDto? = ready { chat.attachmentPart(id, directory, messageId, partId, attachmentKey) } diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt index 13bc944e06..9d7f9a0c7a 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt @@ -253,20 +253,36 @@ class KiloWorkspaceRpcApiImpl internal constructor( text.takeIf { it.isNotBlank() }?.take(DIFF_CAP) } - override suspend fun branchDiff(directory: String): List = withContext(Dispatchers.IO) { + override suspend fun branchDiff(directory: String, patches: Boolean): List = withContext(Dispatchers.IO) { val base = file(clean(directory) ?: directory) ?: return@withContext emptyList() if (!gitAvailable(base)) return@withContext emptyList() - val ref = defaultBranch(base) - val anc = ref?.let { git(base, "merge-base", it, "HEAD").trim().ifBlank { null } } ?: "HEAD" - val numstat = git(base, "-c", "core.quotepath=false", "diff", "--numstat", "--no-color", "--no-renames", anc) - val names = git(base, "-c", "core.quotepath=false", "diff", "--name-status", "--no-color", "--no-renames", anc) - val patch = git(base, "-c", "core.quotepath=false", "diff", "--no-color", "--no-ext-diff", "--no-renames", "--unified=2147483647", anc) - val untracked = git(base, "-c", "core.quotepath=false", "ls-files", "--others", "--exclude-standard") + val anc = mergeBase(base) + // --relative scopes diff output to the opened directory and emits project-relative paths, so + // tracked entries match the untracked list (ls-files is already cwd-relative) in monorepos. + val stats = parseNumstat(git(base, "-c", "core.quotepath=false", "diff", "--numstat", "--relative", "--no-color", "--no-renames", anc)) + val status = parseNameStatus(git(base, "-c", "core.quotepath=false", "diff", "--name-status", "--relative", "--no-color", "--no-renames", anc)) + val untrackedPaths = git(base, "-c", "core.quotepath=false", "ls-files", "--others", "--exclude-standard") .lineSequence() .filter { it.isNotBlank() } - .map { untracked(base, it) } .toList() - buildBranchDiff(numstat, patch, untracked, parseNameStatus(names), DIFF_CAP) + if (!patches) { + val tracked = stats.map { DiffFileDto(it.path, it.additions, it.deletions, "", status[it.path] ?: "modified") } + return@withContext tracked + untrackedPaths.map { untracked(base, it, withPatch = false) } + } + // Fetch patches per file and stop once the running total reaches DIFF_CAP, rather than + // materializing the whole repository's full-context diff into one string up front. + var used = 0 + val tracked = stats.map { stat -> + val text = if (used < DIFF_CAP) fileDiff(base, anc, stat.path) else "" + val next = if (text.isNotBlank() && used + text.length <= DIFF_CAP) { used += text.length; text } else "" + DiffFileDto(stat.path, stat.additions, stat.deletions, next, status[stat.path] ?: "modified") + } + val untracked = untrackedPaths.map { rel -> + val dto = untracked(base, rel, withPatch = used < DIFF_CAP) + val text = dto.patch.orEmpty() + if (text.isNotBlank() && used + text.length <= DIFF_CAP) { used += text.length; dto } else dto.copy(patch = "") + } + tracked + untracked } override suspend fun branchName(directory: String): String? = withContext(Dispatchers.IO) { @@ -397,11 +413,35 @@ class KiloWorkspaceRpcApiImpl internal constructor( return runWorkspaceGit(base, *args) } - private fun defaultBranch(base: Path): String? = listOf("main", "master").firstOrNull { ref -> - git(base, "rev-parse", "--verify", ref).isNotBlank() + /** Merge-base of the resolved default branch and HEAD, or HEAD when no base can be determined. */ + private fun mergeBase(base: Path): String { + val ref = defaultBranch(base) ?: return "HEAD" + return git(base, "merge-base", ref, "HEAD").trim().ifBlank { "HEAD" } } - private fun untracked(base: Path, rel: String): DiffFileDto { + /** + * Resolve the base branch ref, preferring the remote's declared default (origin HEAD), then the + * common origin and local main or master branches, so repos whose default is develop or trunk — + * or worktrees where only the remote branch exists locally — still resolve. Fully-qualified refs + * are used so a tag named "main" can't be mistaken for the branch. + */ + private fun defaultBranch(base: Path): String? { + git(base, "symbolic-ref", "--quiet", "refs/remotes/origin/HEAD").trim() + .removePrefix("refs/remotes/") + .takeIf { it.isNotBlank() } + ?.let { return it } + return listOf( + "refs/remotes/origin/main" to "origin/main", + "refs/remotes/origin/master" to "origin/master", + "refs/heads/main" to "main", + "refs/heads/master" to "master", + ).firstOrNull { git(base, "rev-parse", "--verify", "--quiet", it.first).isNotBlank() }?.second + } + + private fun fileDiff(base: Path, anc: String, path: String): String = + git(base, "-c", "core.quotepath=false", "diff", "--relative", "--no-color", "--no-ext-diff", "--no-renames", "--unified=2147483647", anc, "--", path) + + private fun untracked(base: Path, rel: String, withPatch: Boolean): DiffFileDto { return runCatching { val path = base.resolve(rel).normalize() if (!path.startsWith(base) || !path.isRegularFile() || path.fileSize() > LARGE_FILE) return@runCatching DiffFileDto(rel, 0, 0, "", "untracked") @@ -409,7 +449,8 @@ class KiloWorkspaceRpcApiImpl internal constructor( if (bytes.any { it == 0.toByte() }) return@runCatching DiffFileDto(rel, 0, 0, "", "untracked") val text = bytes.toString(StandardCharsets.UTF_8) val additions = lines(text).size - DiffFileDto(rel, additions, 0, untrackedPatch(rel, text, additions), "untracked") + val patch = if (withPatch) untrackedPatch(rel, text, additions) else "" + DiffFileDto(rel, additions, 0, patch, "untracked") }.getOrElse { err -> LOG.debug { "Failed to read untracked file for branch diff: $rel (${err.message})" } DiffFileDto(rel, 0, 0, "", "untracked") diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt index c6b74f5e40..5a36f3f190 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt @@ -215,14 +215,9 @@ class KiloSessionService internal constructor( call { messages(id, dir) } .also { log.debug { "${ChatLogSummary.sid(id)} ${ChatLogSummary.history(it)} ${ChatLogSummary.dir(dir)}" } } - suspend fun diff(id: String, dir: String): List = try { + // Errors propagate so the diff editor can distinguish a real failure (retry link) from "no changes". + suspend fun diff(id: String, dir: String): List = call { diff(id, dir) } - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - log.warn("${ChatLogSummary.sid(id)} kind=session-diff ${ChatLogSummary.dir(dir)} failed message=${e.message}", e) - emptyList() - } suspend fun attachmentPart(id: String, dir: String, message: String, part: String, key: String?): PartDto? = call { attachmentPart(id, dir, message, part, key) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloWorkspaceService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloWorkspaceService.kt index 3d900faa5a..66c3625bd6 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloWorkspaceService.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloWorkspaceService.kt @@ -160,16 +160,13 @@ class KiloWorkspaceService internal constructor( } } - suspend fun branchDiff(directory: String): List { - return try { - call { branchDiff(directory) } - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - LOG.warn("branch diff lookup failed for directory=$directory", e) - emptyList() - } - } + /** + * Committed branch changes vs the default-branch merge-base. Errors propagate so the diff editor + * can surface a retry (a swallowed failure is indistinguishable from "no changes"); pass + * [patches] = false on the badge path to fetch stats only and skip materializing patch text. + */ + suspend fun branchDiff(directory: String, patches: Boolean = true): List = + call { branchDiff(directory, patches) } suspend fun branchName(directory: String): String? { return try { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/DiffBlocks.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/DiffBlocks.kt index 1f509171fa..f710fdeabe 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/DiffBlocks.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/DiffBlocks.kt @@ -26,7 +26,7 @@ internal fun diffRequest( val right = when { DiffPatchReconstruct.deleted(dto.patch) -> factory.createEmpty() sides.renderable -> factory.create(project, sides.after, type) - else -> factory.create(project, dto.patch ?: "diff unavailable", type) + else -> factory.create(project, dto.patch ?: KiloBundle.message("diff.editor.patch.unavailable"), type) } return SimpleDiffRequest(diffTitle(dto.file, branch), left, right, labels.first, labels.second).also { it.putUserData(DiffUserDataKeys.FORCE_READ_ONLY, true) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/DiffLineNumbers.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/DiffLineNumbers.kt index af6a6ddeb5..1f7242d4ea 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/DiffLineNumbers.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/DiffLineNumbers.kt @@ -1,6 +1,5 @@ package ai.kilocode.client.diff -import ai.kilocode.client.session.views.tool.diffMeta import com.intellij.openapi.actionSystem.AnAction import com.intellij.openapi.editor.Editor import com.intellij.openapi.editor.TextAnnotationGutterProvider @@ -20,14 +19,17 @@ object DiffLineNumbers { var new = 0 var hunk = false patch.lineSequence().forEach { line -> - val match = HUNK.find(line) - if (match != null) { - old = match.groupValues[1].toInt() - new = match.groupValues[2].toInt() + // Hunk headers (and any pre-hunk file/VCS headers) are the only lines stripped, matching + // pureDiff's hunk-aware body. In-hunk lines are kept verbatim even when they look like a + // header (e.g. a deleted "-- " comment renders as "--- ..."), so the counters stay aligned. + if (line.startsWith("@@")) { + HUNK.find(line)?.let { match -> + old = match.groupValues[1].toInt() + new = match.groupValues[2].toInt() + } hunk = true return@forEach } - if (diffMeta(line)) return@forEach if (!hunk) return@forEach when { line.startsWith("+") -> rows.add(line to Row(null, new++)) @@ -40,9 +42,11 @@ object DiffLineNumbers { } private fun List>.trimBlankEdges(): List> { - val start = indexOfFirst { it.first.isNotBlank() } + // isNotEmpty (not isNotBlank) mirrors pureDiff's trim('\n'): a blank context line renders as + // a single space that survives the body trim, so an empty-string edge is the only one dropped. + val start = indexOfFirst { it.first.isNotEmpty() } if (start < 0) return emptyList() - val end = indexOfLast { it.first.isNotBlank() } + val end = indexOfLast { it.first.isNotEmpty() } return subList(start, end + 1) } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/DiffPatchReconstruct.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/DiffPatchReconstruct.kt index ddbe088599..3466cd0322 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/DiffPatchReconstruct.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/DiffPatchReconstruct.kt @@ -9,33 +9,51 @@ internal data class DiffSides( ) internal object DiffPatchReconstruct { + private val HUNK = Regex("^@@ -\\d+(?:,(\\d+))? \\+\\d+(?:,(\\d+))? @@") + fun sides(dto: DiffFileDto): DiffSides { val patch = dto.patch if (patch.isNullOrBlank() || binary(patch)) return DiffSides("", "", false) val before = StringBuilder() val after = StringBuilder() - var hunk = false + var hunks = 0 + var oldLen = 0 + var newLen = 0 + var oldSeen = 0 + var newSeen = 0 for (line in patch.split('\n')) { if (line.startsWith("@@")) { - hunk = true + hunks += 1 + HUNK.find(line)?.let { match -> + oldLen += match.groupValues[1].ifEmpty { "1" }.toInt() + newLen += match.groupValues[2].ifEmpty { "1" }.toInt() + } continue } - if (!hunk) continue + if (hunks == 0) continue if (line.startsWith("\\")) continue when (line.firstOrNull()) { ' ' -> { before.appendLine(line.substring(1)) after.appendLine(line.substring(1)) + oldSeen += 1 + newSeen += 1 } - '-' -> before.appendLine(line.substring(1)) - '+' -> after.appendLine(line.substring(1)) + '-' -> { before.appendLine(line.substring(1)); oldSeen += 1 } + '+' -> { after.appendLine(line.substring(1)); newSeen += 1 } else -> { before.appendLine("") after.appendLine("") + oldSeen += 1 + newSeen += 1 } } } - if (!hunk) return DiffSides("", "", false) + // Both producers (CLI snapshot and branchDiff) emit a single full-context hunk. A patch with + // several hunks, or one whose header lengths don't match the reconstructed body, has elided + // context: reconstructing would place every line at the wrong number, so fall back to the + // raw-patch view (renderable = false) instead of showing a misaligned side-by-side diff. + if (hunks != 1 || oldSeen != oldLen || newSeen != newLen) return DiffSides("", "", false) val left = if (added(patch)) "" else before.toString().removeSuffix("\n") val right = if (deleted(patch)) "" else after.toString().removeSuffix("\n") return DiffSides(left, right, true) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloDiffEditorContent.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloDiffEditorContent.kt index 2a3365a11d..bed025dc4e 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloDiffEditorContent.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloDiffEditorContent.kt @@ -119,6 +119,7 @@ internal class DiffEditorView( private var branch = branch private var syncing = false private var requested: String? = initial.firstOrNull()?.file + private var refreshJob: Job? = null private var processor = processor(initial, selected(initial.firstOrNull()?.file)) private val openFileAction = object : DumbAwareAction( KiloBundle.message("diff.editor.openFile"), @@ -151,7 +152,10 @@ internal class DiffEditorView( } openFileAction.registerCustomShortcutSet(CommonShortcuts.getEditSource(), tree) installMenu() - processor.addListener(DiffRequestProcessorListener { syncTree() }, parent) + // Tie the listener to the processor it observes, not to the long-lived parent: applyFiles + // disposes the old processor on each refresh, and registering under parent would leak a + // removal hook (holding the dead processor) for every refresh across the editor's lifetime. + processor.addListener(DiffRequestProcessorListener { syncTree() }, processor) splitter.firstComponent = buildTreePanel(tree, initial, badge, processor.component, ::refresh) splitter.secondComponent = processor.component processor.updateRequest() @@ -162,6 +166,11 @@ internal class DiffEditorView( override fun dispose() { disposed.set(true) + // Bind the refresh coroutine to this view's lifecycle (load already does so via `parent`): an + // in-flight refresh started just before the editor closes would otherwise keep running on the + // project scope, holding the `done` closure and through it this view, its tree, and processor. + refreshJob?.cancel() + refreshJob = null } @RequiresEdt @@ -177,7 +186,7 @@ internal class DiffEditorView( expandAll(tree) processor = processor(next, index) Disposer.register(parent, processor) - processor.addListener(DiffRequestProcessorListener { syncTree() }, parent) + processor.addListener(DiffRequestProcessorListener { syncTree() }, processor) splitter.firstComponent = buildTreePanel(tree, next, badge, processor.component, ::refresh) splitter.secondComponent = processor.component processor.updateRequest() @@ -197,7 +206,8 @@ internal class DiffEditorView( banner.isVisible = false root.revalidate() root.repaint() - load { data -> + refreshJob?.cancel() + refreshJob = load { data -> refreshing.set(false) if (!disposed.get() && !project.isDisposed) { if (data is DiffEditorData.Files) applyFiles(data.files, data.branch) @@ -312,10 +322,14 @@ internal class DiffEditorView( private fun path(file: DiffFileDto): String? { if (fileStatus(file) == FileStatus.DELETED) return null val dir = params["directory"] ?: return null + val root = clean(dir) ?: return null return try { val raw = Path.of(file.file) - val path = if (raw.isAbsolute) raw else Path.of(dir).resolve(raw) - path.normalize().toString() + val path = (if (raw.isAbsolute) raw else root.resolve(raw)).normalize() + // Constrain "open file" to the diff's directory: reject a server-supplied entry that + // escapes via `..` or an absolute path outside the base rather than opening it blindly. + if (!path.startsWith(root)) return null + path.toString() } catch (_: InvalidPathException) { null } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloDiffEditorKind.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloDiffEditorKind.kt index b06e54b24c..f8d61745f8 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloDiffEditorKind.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloDiffEditorKind.kt @@ -23,6 +23,7 @@ import com.intellij.ui.components.JBLabel import com.intellij.util.concurrency.annotations.RequiresEdt import com.intellij.util.ui.Centerizer import com.intellij.util.ui.JBUI +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.cancel @@ -117,6 +118,7 @@ internal class KiloDiffEditorService( } val data = runCatching { fetch(params) } .getOrElse { + if (it is CancellationException) throw it LOG.warn("diff editor load failed source=${params["source"]} dir=${params["directory"]}", it) DiffEditorData.Error(it.message ?: it::class.java.simpleName) } @@ -133,6 +135,7 @@ internal class KiloDiffEditorService( fun refresh(params: Map, done: (DiffEditorData) -> Unit) = cs.launch { val data = runCatching { fetch(params) } .getOrElse { + if (it is CancellationException) throw it LOG.warn("diff editor refresh failed source=${params["source"]} dir=${params["directory"]}", it) DiffEditorData.Error(it.message ?: it::class.java.simpleName) } @@ -148,7 +151,9 @@ internal class KiloDiffEditorService( val workspace = service() val store = project.service() val files = when (params["source"]) { - "branch" -> store.pop(params["token"].orEmpty()).orEmpty().ifEmpty { workspace.branchDiff(dir) } + // branch is authoritative here (no store seeding): recompute on every load/refresh so a + // re-open or Refresh always reflects the current worktree instead of a stale click seed. + "branch" -> workspace.branchDiff(dir) "inline" -> store.get(params["token"].orEmpty()).orEmpty() else -> project.service().diff(params["sessionId"].orEmpty(), dir) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloInlineDiffStore.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloInlineDiffStore.kt index 51129b654b..b8ac079927 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloInlineDiffStore.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloInlineDiffStore.kt @@ -2,11 +2,22 @@ package ai.kilocode.client.diff import ai.kilocode.rpc.dto.DiffFileDto import com.intellij.openapi.components.Service -import java.util.concurrent.ConcurrentHashMap +import java.util.Collections +/** + * Hands off the diff payload for a "Open in Diff Viewer" click to the editor that opens for it. + * + * Bounded by a small access-ordered LRU: this is a project-level service, so without eviction every + * click would retain the full patch text of its turn for the IDE session's lifetime. [MAX] entries is + * ample for the handful of diff editors a user keeps open, and the eldest entry is dropped after that. + */ @Service(Service.Level.PROJECT) class KiloInlineDiffStore { - private val items = ConcurrentHashMap>() + private val items = Collections.synchronizedMap( + object : LinkedHashMap>(16, 0.75f, true) { + override fun removeEldestEntry(eldest: Map.Entry>): Boolean = size > MAX + }, + ) fun put(token: String, files: List) { items[token] = files @@ -15,4 +26,8 @@ class KiloInlineDiffStore { fun get(token: String): List? = items[token] fun pop(token: String): List? = items.remove(token) + + private companion object { + const val MAX = 32 + } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt index fb3aee1a7c..f95799e88d 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt @@ -87,10 +87,10 @@ import com.intellij.openapi.util.registry.Registry import com.intellij.openapi.wm.IdeFocusManager import com.intellij.util.concurrency.annotations.RequiresEdt import java.util.function.Predicate +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job -import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.awt.BorderLayout @@ -210,8 +210,10 @@ class SessionUi( private var editorTheme = style.editorScheme private var colorTheme = UIManager.getLookAndFeel() private var wasBusy = false - private var branchStarted = false - private var branchJob: Job? = null + // Kept separate so a background stat refresh (turn end / revert) can supersede another refresh + // but never cancel an in-flight user-initiated open. + private var refreshJob: Job? = null + private var openJob: Job? = null private var disposed = false init { @@ -224,7 +226,7 @@ class SessionUi( bindStyle() bindMigration() onStateChanged(controller.model.state) - computeInitialBranchChanges() + refreshBranchChanges() loaded?.let(::finishOpen) } @@ -384,7 +386,7 @@ class SessionUi( it.setDiffOpener(::openInlineDiff, controller.id) it.onHover = { view, on -> if (on) popup.show(view) else popup.notifyExit(view) } } - header = SessionHeaderPanel(controller, this) { computeBranchChanges(open = true) } + header = SessionHeaderPanel(controller, this) { openBranchChanges() } scroll = SessionScroll(root, sessionContent, messageBody, blankBody) scroll.onScroll = { @@ -562,8 +564,7 @@ class SessionUi( is SessionModelEvent.TurnUpdated, is SessionModelEvent.ContentAdded, is SessionModelEvent.ContentDelta, - is SessionModelEvent.HistoryLoaded -> computeInitialBranchChanges() - + is SessionModelEvent.HistoryLoaded, is SessionModelEvent.TurnRemoved, is SessionModelEvent.MessageAdded, is SessionModelEvent.MessageUpdated, @@ -729,7 +730,7 @@ class SessionUi( @RequiresEdt private fun onRevertChanged(revert: SessionRevertDto?) { - computeBranchChanges(open = false) + refreshBranchChanges() syncPromptRevert() val rollback = pendingRollback if (rollback != null) { @@ -834,39 +835,54 @@ class SessionUi( } } - private fun computeBranchChanges(open: Boolean) { - val prev = branchJob - prev?.cancel() - branchJob = cs.launch { - if (open) prev?.cancelAndJoin() - val dir = workspace.directory - val files = workspaces.branchDiff(dir) - val branch = if (open) workspaces.branchName(dir) else null + /** Badge-only refresh: fetches stats (no patch text) and updates the header count. */ + private fun refreshBranchChanges() { + refreshJob?.cancel() + refreshJob = cs.launch { + val files = runCatching { workspaces.branchDiff(workspace.directory, patches = false) } + .getOrElse { + if (it is CancellationException) throw it + LOG.warn("branch changes badge refresh failed dir=${workspace.directory}", it) + return@launch + } withContext(Dispatchers.Main) { if (disposed || project.isDisposed) return@withContext header.setBranchChanges(files) - if (open) openBranchDiff(files, branch) } } } - private fun computeInitialBranchChanges() { - if (branchStarted) return - branchStarted = true - computeBranchChanges(open = false) + /** User clicked the badge: opens the branch diff editor. Never cancelled by a background refresh. */ + private fun openBranchChanges() { + openJob?.cancel() + openJob = cs.launch { + val dir = workspace.directory + val branch = workspaces.branchName(dir) + val files = runCatching { workspaces.branchDiff(dir, patches = false) } + .getOrElse { + if (it is CancellationException) throw it + LOG.warn("branch changes open failed dir=$dir", it) + emptyList() + } + withContext(Dispatchers.Main) { + if (disposed || project.isDisposed) return@withContext + header.setBranchChanges(files) + openBranchDiff(branch) + } + } } @RequiresEdt - private fun openBranchDiff(files: List, branch: String?) { + private fun openBranchDiff(branch: String?) { + // No store seeding: the diff editor's fetch recomputes branchDiff authoritatively, so a + // re-open or Refresh always reflects the current worktree (and nothing is retained for its life). ensureDiffEditorKind() val dir = workspace.directory - val token = "branch:$dir" val title = branch?.let { KiloBundle.message("diff.editor.branch.title.named", it) } ?: KiloBundle.message("diff.editor.branch.title") - project.service().put(token, files) project.service().open( KiloDiffEditorKind.ID, - diffParams("branch", dir, null, title, branch, token = token), + diffParams("branch", dir, null, title, branch), ) Telemetry.send("Diff Editor Opened", mapOf("source" to "branch")) } @@ -922,7 +938,7 @@ class SessionUi( private fun onStateChanged(state: SessionState) { if (disposed) return val busy = state.isBusy() - if (wasBusy && state is SessionState.Idle) computeBranchChanges(open = false) + if (wasBusy && state is SessionState.Idle) refreshBranchChanges() wasBusy = busy if (state is SessionState.Reverting) overlay.clear() if (state is SessionState.Error) { @@ -997,7 +1013,8 @@ class SessionUi( override fun dispose() { disposed = true - branchJob?.cancel() + refreshJob?.cancel() + openJob?.cancel() hide.stop() popup.hideAll() modalFocus = null diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt index 2db70430fc..4f29226f0f 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt @@ -1141,6 +1141,9 @@ class SessionController( if (child in childParts.values) return childIds.remove(child) childJobs.remove(child)?.cancel() + // A sub-agent that finished/was cancelled with an unanswered permission would otherwise leave + // a queue entry that a later promote() surfaces as a live card for a session that no longer exists. + purgePending(child) } @RequiresEdt @@ -1346,6 +1349,9 @@ class SessionController( revertDeferred = SessionState.Idle return } + // The turn is done, so any still-queued permission for it is a ghost the CLI abandoned + // server-side without a reply event — drop it before deciding whether to keep a card. + purgePending(event.sessionID) // Keep pending questions visible for follow-up flows that arrive just before close. val current = model.state if (current is SessionState.AwaitingQuestion) return @@ -1547,6 +1553,24 @@ class SessionController( model.setState(SessionState.AwaitingPermission(perm)) } + /** + * Drop queued permissions for [session] and clear/re-promote the visible card when it belonged to + * one of them. The CLI deletes an outstanding permission server-side on turn interruption without + * emitting permission.replied (`Permission.ask` cleans up in `Effect.ensuring`), so on TurnClose / + * idle / child untrack a still-queued entry is a ghost that would otherwise resurface on the next + * promote() and fail to reply with NotFoundError. + */ + @RequiresEdt + private fun purgePending(session: String?) { + if (session == null) return + val removed = pending.entries.removeIf { it.value.sessionId == session } + if (!removed) return + val current = model.state + if (current is SessionState.AwaitingPermission && current.permission.sessionId == session) { + model.setState(afterResolve(idle = true)) + } + } + private fun status(dto: SessionStatusDto) { if (revertOp != null) { revertDeferred = when (dto.type) { @@ -1562,6 +1586,7 @@ class SessionController( "idle" -> { val current = model.state if (current is SessionState.LoginRequired || current is SessionState.Reverting) return + purgePending(sid) SessionState.Idle } "busy" -> { @@ -1663,6 +1688,9 @@ class SessionController( revertDeferred = SessionState.Idle return } + // An idle session cannot have a live permission outstanding — purge any ghost left by an + // abort/error that originated on the server or another client (local abort() already clears). + purgePending(sid) // Treat session.idle as an explicit signal to return to Idle. // Only apply if we're not in a more specific non-terminal state. val current = model.state diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ModifiedFilesView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ModifiedFilesView.kt index ed67f18f24..5c09926ac6 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ModifiedFilesView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ModifiedFilesView.kt @@ -69,18 +69,19 @@ class ModifiedFilesView private constructor( this.turnId = turnId } + /** Returns true when anything visible changed, so the parent only relayouts on a real change. */ @RequiresEdt - fun setDiffs(diffs: List) { + fun setDiffs(diffs: List): Boolean { val next = diffs.map(::file) this.diffs = diffs if (files == next) { val visible = next.isNotEmpty() parts.diff.isEnabled = visible - if (isVisible == visible) return + if (isVisible == visible) return false isVisible = visible revalidate() repaint() - return + return true } files = next val visible = files.isNotEmpty() @@ -93,6 +94,7 @@ class ModifiedFilesView private constructor( if (isExpanded()) body.updateFiles(files) revalidate() repaint() + return true } @RequiresEdt diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt index 3f185c6645..9898119dff 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt @@ -167,8 +167,12 @@ class SessionMessageListPanel( is SessionModelEvent.Compacted -> Unit is SessionModelEvent.MessageUpdated -> { - turnViews[event.info.info.id]?.setDiffs(event.info.info.summary?.diffs.orEmpty()) - refresh() + // message.updated fires on every streamed metadata delta (time/tokens/cost). Only + // relayout the transcript when the turn's modified-files card actually changed, + // not on each delta or when this message isn't a turn anchor. + if (turnViews[event.info.info.id]?.setDiffs(event.info.info.summary?.diffs.orEmpty()) == true) { + refresh() + } } is SessionModelEvent.DiffUpdated -> { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/BranchChangesBadge.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/BranchChangesBadge.kt index 05c42daaac..12764745e3 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/BranchChangesBadge.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/BranchChangesBadge.kt @@ -13,9 +13,14 @@ import java.awt.Dimension import java.awt.Graphics import java.awt.Graphics2D import java.awt.RenderingHints +import java.awt.event.ActionEvent +import java.awt.event.KeyEvent import java.awt.event.MouseAdapter import java.awt.event.MouseEvent +import javax.swing.AbstractAction +import javax.swing.JComponent import javax.swing.JPanel +import javax.swing.KeyStroke internal class BranchChangesBadge( private val open: () -> Unit, @@ -31,6 +36,7 @@ internal class BranchChangesBadge( init { isOpaque = false isVisible = false + isFocusable = true cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) toolTipText = KiloBundle.message("diff.editor.branch.tooltip") getAccessibleContext().accessibleName = KiloBundle.message("diff.editor.branch.tooltip") @@ -39,8 +45,22 @@ internal class BranchChangesBadge( addMouseListener(object : MouseAdapter() { override fun mouseEntered(event: MouseEvent) = hover(true) override fun mouseExited(event: MouseEvent) = hover(false) - override fun mouseClicked(event: MouseEvent) = open() + override fun mouseClicked(event: MouseEvent) = activate() }) + // Keep the action reachable without a mouse (the HoverIcon this replaced was an + // AbstractButton). Enter/Space fire the same guarded action as a click. + val action = object : AbstractAction() { + override fun actionPerformed(e: ActionEvent) = activate() + } + getInputMap(JComponent.WHEN_FOCUSED).apply { + put(KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, 0), ACTIVATE) + put(KeyStroke.getKeyStroke(KeyEvent.VK_SPACE, 0), ACTIVATE) + } + actionMap.put(ACTIVATE, action) + } + + private fun activate() { + if (isEnabled) open() } fun applyStyle(style: SessionEditorStyle) { @@ -111,4 +131,8 @@ internal class BranchChangesBadge( g2.dispose() } } + + private companion object { + const val ACTIVATE = "kilo.branch.changes.activate" + } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TurnView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TurnView.kt index 40e58411fa..ccc2ba53ad 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TurnView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TurnView.kt @@ -88,9 +88,11 @@ class TurnView( return view } + /** Returns true when the modified-files card was created or its content changed. */ @RequiresEdt - fun setDiffs(diffs: List) { - val card = modified ?: if (diffs.isEmpty()) null else ModifiedFilesView(openFile, selection).also { + fun setDiffs(diffs: List): Boolean { + val existing = modified + val card = existing ?: if (diffs.isEmpty()) null else ModifiedFilesView(openFile, selection).also { it.setDiffOpener(openDiff, sessionId, id) it.resize = resize it.hover = hover @@ -98,8 +100,10 @@ class TurnView( modified = it add(it) } - card?.setDiffs(diffs) - if (card != null) revalidate() + val created = existing == null && card != null + val changed = card?.setDiffs(diffs) ?: false + if (created || changed) revalidate() + return created || changed } @RequiresEdt diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/EditToolView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/EditToolView.kt index 367df2c37c..7ef7ec4749 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/EditToolView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/EditToolView.kt @@ -75,7 +75,10 @@ class EditToolView( parts.left.next(filesTag) parts.left.next(PartHeader.centered(badge)) parts.left.next(PartHeader.centered(diffAnchor)) - bindHeader(parts.glyph, parts.title, parts.sub, parts.link, parts.state, parts.left, parts.right, parts.slot, filesTag, badge, diffAnchor) + // parts.link is intentionally omitted: FileLinkLabel installs its own click handler that opens + // the file, and binding it here would also toggle the card on the same click (see ReadToolView, + // which likewise omits it). Header toggling still works via parts.left/row. + bindHeader(parts.glyph, parts.title, parts.sub, parts.state, parts.left, parts.right, parts.slot, filesTag, badge, diffAnchor) applyStyle(style) sync() } @@ -229,14 +232,16 @@ class EditToolView( changed = setForeground(parts.link, UiStyle.Colors.fg()) || changed changed = setText(parts.state, stateText(item)) || changed changed = setForeground(parts.state, color(item)) || changed - syncDiffAction() + syncDiffAction(count) changed = syncFilesTag(count) || changed changed = syncBadge() || changed return changed } - private fun syncDiffAction() { - val show = toDiffFiles(item).isNotEmpty() + private fun syncDiffAction(count: Int) { + // Mirrors toDiffFiles(item).isNotEmpty() without re-parsing the metadata JSON or allocating a + // DiffFileDto per file on every streaming delta: files present, else a single-file patch. + val show = count > 0 || editDiff(item).isNotBlank() if (canDiff == show && diff.isEnabled == show) return canDiff = show diff.isEnabled = show @@ -292,9 +297,10 @@ private fun toDiffFiles(tool: Tool): List { return listOf(DiffFileDto(editPath(tool), stat.first, stat.second, patch)) } -private fun diffTitle(tool: Tool): String = KiloBundle.message( - if (editFiles(tool).size > 1) "session.part.tool.patch" else "session.part.tool.edit", -) +private fun diffTitle(tool: Tool): String = + // Keep the file name for a single-file edit so each per-tool diff tab is identifiable + // (SessionUi decorates it into " (branch)"); reserve the generic label for multi-file patches. + if (editFiles(tool).size > 1) KiloBundle.message("session.part.tool.patch") else tail(editPath(tool)) /** Picks the multi-file patch body for apply_patch spanning several files, else the single diff. */ private fun editBody(tool: Tool, selection: SessionSelection?, openFile: SessionFileOpener): EditBody = diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolMarkdownBody.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolMarkdownBody.kt index 412bd15e14..328bd37fc8 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolMarkdownBody.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolMarkdownBody.kt @@ -39,11 +39,13 @@ class ToolMarkdownBody( ) : EditBody { override var parent: Disposable? = null private var view: MdView? = null + private var item: Tool? = null /** Builds the body on first call, wiring it into [parent]'s disposable tree, then returns it. */ @RequiresEdt override fun mount(tool: Tool): JComponent { view?.let { return it.component } + item = tool val owner = parent ?: error("Tool markdown body has no parent") val md = MdViewFactory.create(SessionEditorStyle.current(), selection, MdCodeBlockFactory.default(opts)) Disposer.register(owner, md) @@ -65,6 +67,7 @@ class ToolMarkdownBody( @RequiresEdt override fun update(tool: Tool): Boolean { + item = tool val md = view ?: return false val value = render(tool) if (md.markdown() == value) return false @@ -86,6 +89,10 @@ class ToolMarkdownBody( md.codeFont = style.editorFamily md.component.border = JBUI.Borders.empty() chrome(md) + // EditorTextField drops its editor in removeNotify, so collapse/re-expand yields a fresh + // editor with no annotation provider. Re-install the gutter here (as PatchBody.applyMd does) + // so the old/new line-number gutter survives a re-expansion, not just the first mount. + item?.let(::syncGutter) return before != md.font } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolSupport.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolSupport.kt index a506acce25..b9a2d2b23a 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolSupport.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolSupport.kt @@ -838,11 +838,25 @@ internal fun diffStat(tool: Tool): Pair { return added to removed } -/** Display-only diff body without VCS/file metadata headers (Index, diff --git, ---, +++, etc.). */ -internal fun pureDiff(diff: String): String = diff.lineSequence() - .filterNot(::diffMeta) - .joinToString("\n") - .trim('\n') +/** + * Display-only diff body. Strips the pre-hunk file/VCS headers (Index, diff --git, ---, +++, etc.) + * and the `@@` hunk markers, but keeps every in-hunk line verbatim — a deleted `-- ` comment that + * renders as `--- ...` is diff content, not a header, so it must survive here and in + * [ai.kilocode.client.diff.DiffLineNumbers.rows] for the gutter line numbers to stay aligned. + */ +internal fun pureDiff(diff: String): String { + val out = StringBuilder() + var hunk = false + diff.lineSequence().forEach { line -> + if (line.startsWith("@@")) { + hunk = true + return@forEach + } + if (!hunk && diffMeta(line)) return@forEach + out.append(line).append('\n') + } + return out.toString().trim('\n') +} internal fun diffMeta(line: String): Boolean = line.startsWith("Index:") || line.startsWith("====") || diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties index 1d614074f2..1600fa9739 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -127,8 +127,6 @@ session.changes.modified=Modified session.changes.count.one={0} file session.changes.count.other={0} files diff.editor.session.title=Session Changes -diff.editor.session.title.named=Session Changes ({0}) -diff.editor.inline.title=Session Changes diff.editor.inline.title.named={0} ({1}) diff.editor.changedFiles.title=Changed files diff.editor.branch.title=Changes vs base branch @@ -141,6 +139,7 @@ diff.editor.side.modified=Modified diff.editor.branch.tooltip=Compare with base branch diff.editor.session.tooltip=Open changes in editor diff.editor.empty=No changes +diff.editor.patch.unavailable=Diff unavailable diff.editor.outdated=Changes on disk are not shown diff.editor.openFile=Open File diff.editor.refresh=Refresh diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/DiffLineNumbersTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/DiffLineNumbersTest.kt index e9957aa6b0..892d9d4553 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/DiffLineNumbersTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/DiffLineNumbersTest.kt @@ -49,6 +49,28 @@ class DiffLineNumbersTest : BasePlatformTestCase() { ) } + fun `test in-hunk header-shaped lines stay content`() { + // A deleted "-- foo" comment renders as "--- foo" and an added "++ bar" as "+++ bar"; + // both are hunk content, so they keep incrementing the counters instead of being dropped. + val patch = """ + --- a/q.sql + +++ b/q.sql + @@ -1,2 +1,2 @@ + --- old comment + +++ new comment + keep + """.trimIndent() + + assertEquals( + listOf( + DiffLineNumbers.Row(1, null), + DiffLineNumbers.Row(null, 1), + DiffLineNumbers.Row(2, 2), + ), + DiffLineNumbers.rows(patch), + ) + } + fun `test no newline marker emits empty row`() { val patch = """ @@ -1 +1 @@ @@ -93,5 +115,13 @@ class DiffLineNumbersTest : BasePlatformTestCase() { -two """.trimIndent(), "@@ -1 +1 @@\r\n-old\r\n+new\r\n", + """ + --- a/q.sql + +++ b/q.sql + @@ -1,2 +1,2 @@ + --- old comment + +++ new comment + keep + """.trimIndent(), ) } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/DiffPatchReconstructTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/DiffPatchReconstructTest.kt index 2731f36d9d..3baca9795a 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/DiffPatchReconstructTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/DiffPatchReconstructTest.kt @@ -103,6 +103,53 @@ class DiffPatchReconstructTest { assertEquals("", sides.after) } + @Test + fun `multi hunk partial context patch is not renderable`() { + val dto = DiffFileDto( + file = "src/A.kt", + additions = 2, + deletions = 2, + patch = """ + diff --git a/src/A.kt b/src/A.kt + --- a/src/A.kt + +++ b/src/A.kt + @@ -1,3 +1,3 @@ + one + -two + +TWO + @@ -20,3 +20,3 @@ + twenty + -x + +X + """.trimIndent(), + ) + + val sides = DiffPatchReconstruct.sides(dto) + + assertFalse(sides.renderable) + assertEquals("", sides.before) + assertEquals("", sides.after) + } + + @Test + fun `single hunk with mismatched header length is not renderable`() { + // header claims 3 old / 3 new lines but the body only carries 2 of each (context elided). + val dto = DiffFileDto( + file = "src/A.kt", + additions = 1, + deletions = 1, + patch = """ + --- a/src/A.kt + +++ b/src/A.kt + @@ -1,3 +1,3 @@ + -two + +TWO + """.trimIndent(), + ) + + assertFalse(DiffPatchReconstruct.sides(dto).renderable) + } + @Test fun `binary and blank patches are not renderable`() { assertFalse(DiffPatchReconstruct.sides(DiffFileDto("a.bin", 0, 0, "Binary files a/a.bin and b/a.bin differ")).renderable) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/KiloInlineDiffStoreTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/KiloInlineDiffStoreTest.kt index cf437afb64..dae1b36342 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/KiloInlineDiffStoreTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/KiloInlineDiffStoreTest.kt @@ -48,21 +48,24 @@ class KiloInlineDiffStoreTest : BasePlatformTestCase() { assertNull(store.pop("branch:/test")) } - fun `test branch fetch consumes seeded snapshot before recomputing`() = runBlocking { + fun `test branch fetch recomputes authoritatively and ignores any store seed`() = runBlocking { val store = project.service() - val seed = listOf(file("src/Seed.kt", 3, 1)) + val stale = listOf(file("src/Stale.kt", 3, 1)) val fresh = file("src/Fresh.kt", 1, 0) workspace.branchDiffs.add(fresh) workspace.branchName = "main" - store.put("branch:/test", seed) - val params = diffParams("branch", "/test", null, "Branch", "main", token = "branch:/test") + // A leftover seed under the branch token must never be consumed as a side channel: it would + // otherwise poison a re-open or Refresh with content from an earlier click. + store.put("branch:/test", stale) + val params = diffParams("branch", "/test", null, "Branch", "main") val first = withContext(coroutines.dispatcher) { service.fetch(params) } as DiffEditorData.Files val second = withContext(coroutines.dispatcher) { service.fetch(params) } as DiffEditorData.Files - assertEquals(seed, first.files) + assertEquals(listOf(fresh), first.files) assertEquals(listOf(fresh), second.files) - assertEquals(listOf("/test"), workspace.branchDiffCalls) + assertEquals(listOf("/test", "/test"), workspace.branchDiffCalls) + assertEquals(stale, store.get("branch:/test")) } private fun file(path: String, additions: Int, deletions: Int) = DiffFileDto(path, additions, deletions) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PermissionQueueTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PermissionQueueTest.kt index dfa6eb0594..e242aa23b2 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PermissionQueueTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PermissionQueueTest.kt @@ -83,6 +83,35 @@ class PermissionQueueTest : SessionControllerTestBase() { assertTrue(m.model.state is SessionState.Idle) } + fun `test turn close purges outstanding permission ghost`() { + val (m, _, _) = prompted() + + emit(ChatEventDto.PermissionAsked("ses_test", permission("perm1"))) + assertPermission(m, "perm1") + + // The CLI abandons an outstanding permission server-side when a turn is interrupted, without + // emitting permission.replied, so TurnClose must drop the ghost instead of leaving it shown. + emit(ChatEventDto.TurnClose("ses_test", "aborted")) + assertTrue(m.model.state is SessionState.Idle) + + // The next request surfaces itself rather than the purged ghost (which would fail to reply). + emit(ChatEventDto.PermissionAsked("ses_test", permission("perm2"))) + assertPermission(m, "perm2") + } + + fun `test session idle purges outstanding permission ghost`() { + val (m, _, _) = prompted() + + emit(ChatEventDto.PermissionAsked("ses_test", permission("perm1"))) + assertPermission(m, "perm1") + + emit(ChatEventDto.SessionIdle("ses_test")) + assertTrue(m.model.state is SessionState.Idle) + + emit(ChatEventDto.PermissionAsked("ses_test", permission("perm2"))) + assertPermission(m, "perm2") + } + fun `test replying active question shows queued permission`() { val (m, _, _) = prompted() diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/EditToolViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/EditToolViewTest.kt index 2ad955aecb..c8ac1fe9a2 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/EditToolViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/EditToolViewTest.kt @@ -143,7 +143,8 @@ class EditToolViewTest : BasePlatformTestCase() { assertTrue(editButton.isEnabled) editButton.doClick() assertEquals(1, edit.single().size) - assertEquals("Edit", titles.single()) + // Single-file edit keeps the file name so its diff tab is identifiable (not a generic "Edit"). + assertEquals("App.kt", titles.single()) val patch = mutableListOf>() val patchView = track(EditToolView(tool().also { @@ -272,6 +273,8 @@ class EditToolViewTest : BasePlatformTestCase() { click(link, 0) assertEquals(listOf("/repo/src/App.kt"), opened) + // The link is not bound for toggling, so opening the file must not also collapse the card. + assertTrue(view.isExpanded()) } fun `test metadata only patch falls back to raw text`() { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorkspaceRpcApi.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorkspaceRpcApi.kt index 3e526c7430..327566bf9c 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorkspaceRpcApi.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorkspaceRpcApi.kt @@ -37,6 +37,7 @@ class FakeWorkspaceRpcApi : KiloWorkspaceRpcApi { var gitChanges: String? = null val branchDiffs = mutableListOf() val branchDiffCalls = CopyOnWriteArrayList() + val branchDiffPatchCalls = CopyOnWriteArrayList() var branchName: String? = null var openResult = true var localConfigPath = "/test/.kilo/kilo.jsonc" @@ -98,9 +99,10 @@ class FakeWorkspaceRpcApi : KiloWorkspaceRpcApi { return gitChanges } - override suspend fun branchDiff(directory: String): List { + override suspend fun branchDiff(directory: String, patches: Boolean): List { assertNotEdt("branchDiff") branchDiffCalls.add(directory) + branchDiffPatchCalls.add(patches) return branchDiffs.toList() } diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloWorkspaceRpcApi.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloWorkspaceRpcApi.kt index d7341c1739..f609a796da 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloWorkspaceRpcApi.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloWorkspaceRpcApi.kt @@ -55,8 +55,13 @@ interface KiloWorkspaceRpcApi : RemoteApi { /** Current uncommitted git changes as a unified diff for @git-changes mentions. */ suspend fun gitChanges(directory: String): String? - /** Committed branch changes compared with the default branch merge-base. */ - suspend fun branchDiff(directory: String): List + /** + * Committed branch changes compared with the default branch merge-base. + * + * [patches] = false returns file stats only (additions/deletions/status) and skips materializing + * the full patch text — used by the header badge, which only needs counts. + */ + suspend fun branchDiff(directory: String, patches: Boolean = true): List /** Current git branch name for branch-scoped UI labels. */ suspend fun branchName(directory: String): String? diff --git a/packages/opencode/src/mcp/catalog.ts b/packages/opencode/src/mcp/catalog.ts index ebf07ba5e1..3a1d9755d2 100644 --- a/packages/opencode/src/mcp/catalog.ts +++ b/packages/opencode/src/mcp/catalog.ts @@ -65,20 +65,13 @@ export function convertTool(mcpTool: MCPToolDef, client: Client, timeout?: numbe onprogress: () => {}, }, ) - // kilocode_change start - tolerate unknown MCP error content shape - const content = Array.isArray(result.content) ? result.content : [] if (result.isError) throw new Error( - content - .flatMap((item): string[] => { - if (typeof item !== "object" || item === null) return [] - const part = item as { type?: unknown; text?: unknown } - return part.type === "text" && typeof part.text === "string" ? [part.text] : [] - }) + result.content + .flatMap((item) => (item.type === "text" ? [item.text] : [])) .filter((text) => text.trim()) .join("\n\n") || "MCP tool returned an error", ) - // kilocode_change end if (result.structuredContent === undefined || result.structuredContent === null) return result return { ...result, diff --git a/packages/ui/src/components/file.tsx b/packages/ui/src/components/file.tsx index fb9a5b91cf..8c8096375a 100644 --- a/packages/ui/src/components/file.tsx +++ b/packages/ui/src/components/file.tsx @@ -52,6 +52,7 @@ const VIRTUALIZE_BYTES = 500_000 const codeMetrics = { ...DEFAULT_VIRTUAL_FILE_METRICS, lineHeight: 24, + spacing: 0, } satisfies Partial type SharedProps = { diff --git a/packages/ui/src/pierre/virtualizer.ts b/packages/ui/src/pierre/virtualizer.ts index 369d36eab5..235a3fd677 100644 --- a/packages/ui/src/pierre/virtualizer.ts +++ b/packages/ui/src/pierre/virtualizer.ts @@ -16,6 +16,7 @@ const cache = new WeakMap() export const virtualMetrics: Partial = { lineHeight: 24, hunkSeparatorHeight: 24, + spacing: 0, } function scrollable(value: string) { From 568b21cd1cb95fb8a761386d0c726acc77461ad6 Mon Sep 17 00:00:00 2001 From: kirillk Date: Thu, 30 Jul 2026 17:44:27 -0400 Subject: [PATCH 23/28] fix(jetbrains): drop dead IdeActions import, assert real diff toolbar Address the follow-up review on the diff tree toolbar: - Remove the now-unused IdeActions import from KiloDiffEditorContent (its only users were the removed prev/next-diff lookups). - KiloDiffEditorContentTest now asserts on the ActionToolbar the view actually installs (via its actionGroup) instead of a freshly built detached group, so it guards the real "toolbar lost its actions" regression. --- .../ai/kilocode/client/diff/KiloDiffEditorContent.kt | 1 - .../kilocode/client/diff/KiloDiffEditorContentTest.kt | 10 ++++++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloDiffEditorContent.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloDiffEditorContent.kt index bed025dc4e..b66adb3b00 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloDiffEditorContent.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloDiffEditorContent.kt @@ -18,7 +18,6 @@ import com.intellij.openapi.actionSystem.ActionUpdateThread import com.intellij.openapi.actionSystem.AnActionEvent import com.intellij.openapi.actionSystem.CommonShortcuts import com.intellij.openapi.actionSystem.DefaultActionGroup -import com.intellij.openapi.actionSystem.IdeActions import com.intellij.openapi.actionSystem.Separator import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.application.ModalityState diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/KiloDiffEditorContentTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/KiloDiffEditorContentTest.kt index fb3397453d..823c2ece24 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/KiloDiffEditorContentTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/KiloDiffEditorContentTest.kt @@ -3,6 +3,7 @@ package ai.kilocode.client.diff import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.ui.DiffStatBadge import ai.kilocode.rpc.dto.DiffFileDto +import com.intellij.openapi.actionSystem.ActionToolbar import com.intellij.openapi.actionSystem.Separator import com.intellij.openapi.Disposable import com.intellij.openapi.application.ApplicationManager @@ -174,12 +175,17 @@ class KiloDiffEditorContentTest : BasePlatformTestCase() { } } - fun `test tree toolbar action order`() { + fun `test tree toolbar installs actions in order`() { val parent = Disposer.newDisposable() try { + // Assert against the toolbar the view actually installs (not a freshly built group), so + // this guards the real regression: the tree toolbar losing or rewiring its actions. val view = view(files(), parent) val tree = components(view).filterIsInstance().single() - val actions = treeToolbarGroup(tree) {}.getChildren(null).toList() + val scroll = SwingUtilities.getAncestorOfClass(JBScrollPane::class.java, tree) as JBScrollPane + val row = (scroll.parent.layout as BorderLayout).getLayoutComponent(BorderLayout.NORTH) as Container + val toolbar = (row.layout as BorderLayout).getLayoutComponent(BorderLayout.WEST) as ActionToolbar + val actions = toolbar.actionGroup.getChildren(null).toList() assertEquals(KiloBundle.message("diff.editor.refresh"), actions[0].templatePresentation.text) assertTrue(actions[1] is Separator) assertEquals(KiloBundle.message("diff.editor.tree.expandAll"), actions[2].templatePresentation.text) From a0e6273cf5cd3a57622eb64ea3b2652174291349 Mon Sep 17 00:00:00 2001 From: kirillk Date: Thu, 30 Jul 2026 17:57:10 -0400 Subject: [PATCH 24/28] fix(jetbrains): render full-context diffs instead of green raw patch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DiffPatchReconstruct counted the trailing empty element that split('\n') produces for a newline-terminated patch (every real git patch) as a hunk body line. That inflated oldSeen/newSeen past the header lengths, so the renderable check failed and every modified-file diff fell back to the empty-vs-patch view — rendering the whole file (and its line-number gutter) as all-added green. Drop the trailing empty split artifact before counting, mirroring the edge trim in DiffLineNumbers. Add a newline-terminated regression test; the existing trimIndent fixtures masked the bug by omitting the final newline. --- .../client/diff/DiffPatchReconstruct.kt | 6 +++- .../client/diff/DiffPatchReconstructTest.kt | 29 +++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/DiffPatchReconstruct.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/DiffPatchReconstruct.kt index 3466cd0322..654ef86da0 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/DiffPatchReconstruct.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/DiffPatchReconstruct.kt @@ -21,7 +21,11 @@ internal object DiffPatchReconstruct { var newLen = 0 var oldSeen = 0 var newSeen = 0 - for (line in patch.split('\n')) { + // Drop the trailing empty element that split('\n') yields for a newline-terminated patch (the + // usual case for git output). Counting it as a body line would inflate oldSeen/newSeen past the + // header lengths and wrongly reject every full-context diff. Mirrors DiffLineNumbers' edge trim; + // real blank context lines are " " (space-prefixed), never "", so no content is lost. + for (line in patch.split('\n').dropLastWhile { it.isEmpty() }) { if (line.startsWith("@@")) { hunks += 1 HUNK.find(line)?.let { match -> diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/DiffPatchReconstructTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/DiffPatchReconstructTest.kt index 3baca9795a..24ab5b67c5 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/DiffPatchReconstructTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/DiffPatchReconstructTest.kt @@ -33,6 +33,35 @@ class DiffPatchReconstructTest { assertEquals("one\nTWO\nthree", sides.after) } + @Test + fun `newline terminated full context patch stays renderable`() { + // Real git patches end with a newline, so split('\n') yields a trailing "" that must not be + // counted as a hunk body line. Without the edge trim this reconstructs as non-renderable and + // falls back to the all-green raw-patch view. + val dto = DiffFileDto( + file = "src/A.kt", + additions = 1, + deletions = 1, + patch = """ + diff --git a/src/A.kt b/src/A.kt + index 111..222 100644 + --- a/src/A.kt + +++ b/src/A.kt + @@ -1,3 +1,3 @@ + one + -two + +TWO + three + """.trimIndent() + "\n", + ) + + val sides = DiffPatchReconstruct.sides(dto) + + assertTrue(sides.renderable) + assertEquals("one\ntwo\nthree", sides.before) + assertEquals("one\nTWO\nthree", sides.after) + } + @Test fun `added file has empty before side`() { val dto = DiffFileDto( From 79ac066d287ca652bdb57ed6421b6978323565e2 Mon Sep 17 00:00:00 2001 From: kirillk Date: Thu, 30 Jul 2026 18:30:26 -0400 Subject: [PATCH 25/28] fix(jetbrains): bound branch-diff patch fetching and preserve promoted permissions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review feedback on the session/branch diff work: - branchDiff no longer keeps calling git per file after the DIFF_CAP budget is exhausted. A single oversized full-context patch previously left the budget open, so every remaining file still spawned a `git diff --unified=inf` that was fetched and discarded — hundreds of subprocesses per open/refresh on large branches. The new pure `capDiff` accumulator stops fetching once a patch overflows the cap. - The stats-only (badge) untracked path now streams a newline count instead of reading each file into a String on every turn end/revert. - Delete the now-dead `buildBranchDiff`/`splitGitPatch` helpers and re-target the suite onto the live `capDiff` path plus `parseNumstat`/ `parseNameStatus` (renamed BranchDiffBuildTest -> BranchDiffTest). - SessionController.status("idle") no longer clobbers a permission that purgePending just promoted from an unpurged child session; it mirrors idle() and leaves the promoted card in place. --- .../backend/rpc/KiloWorkspaceRpcApiImpl.kt | 147 ++++++++---------- .../backend/rpc/BranchDiffBuildTest.kt | 111 ------------- .../ai/kilocode/backend/rpc/BranchDiffTest.kt | 93 +++++++++++ .../session/controller/SessionController.kt | 3 + .../session/controller/PermissionQueueTest.kt | 39 +++++ 5 files changed, 198 insertions(+), 195 deletions(-) delete mode 100644 packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/BranchDiffBuildTest.kt create mode 100644 packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/BranchDiffTest.kt diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt index 9d7f9a0c7a..4b550fd026 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt @@ -57,6 +57,7 @@ import java.nio.file.Files import java.nio.file.InvalidPathException import java.nio.file.Path import kotlin.io.path.fileSize +import kotlin.io.path.inputStream import kotlin.io.path.isRegularFile import kotlin.io.path.readBytes import java.util.concurrent.ConcurrentHashMap @@ -265,24 +266,17 @@ class KiloWorkspaceRpcApiImpl internal constructor( .lineSequence() .filter { it.isNotBlank() } .toList() - if (!patches) { - val tracked = stats.map { DiffFileDto(it.path, it.additions, it.deletions, "", status[it.path] ?: "modified") } - return@withContext tracked + untrackedPaths.map { untracked(base, it, withPatch = false) } + // Stats-only DTOs (empty patch); the badge path stops here. untracked() streams a line + // count on this path instead of materializing each file as a String. + val files = stats.map { DiffFileDto(it.path, it.additions, it.deletions, "", status[it.path] ?: "modified") } + + untrackedPaths.map { untracked(base, it, withPatch = false) } + if (!patches) return@withContext files + // Fetch patches lazily and stop once the running total reaches DIFF_CAP, so a branch with + // hundreds of changed files doesn't spawn a git subprocess (or read a file) per entry. + capDiff(files, DIFF_CAP) { file -> + if (file.status == "untracked") untracked(base, file.file, withPatch = true).patch.orEmpty() + else fileDiff(base, anc, file.file) } - // Fetch patches per file and stop once the running total reaches DIFF_CAP, rather than - // materializing the whole repository's full-context diff into one string up front. - var used = 0 - val tracked = stats.map { stat -> - val text = if (used < DIFF_CAP) fileDiff(base, anc, stat.path) else "" - val next = if (text.isNotBlank() && used + text.length <= DIFF_CAP) { used += text.length; text } else "" - DiffFileDto(stat.path, stat.additions, stat.deletions, next, status[stat.path] ?: "modified") - } - val untracked = untrackedPaths.map { rel -> - val dto = untracked(base, rel, withPatch = used < DIFF_CAP) - val text = dto.patch.orEmpty() - if (text.isNotBlank() && used + text.length <= DIFF_CAP) { used += text.length; dto } else dto.copy(patch = "") - } - tracked + untracked } override suspend fun branchName(directory: String): String? = withContext(Dispatchers.IO) { @@ -445,12 +439,17 @@ class KiloWorkspaceRpcApiImpl internal constructor( return runCatching { val path = base.resolve(rel).normalize() if (!path.startsWith(base) || !path.isRegularFile() || path.fileSize() > LARGE_FILE) return@runCatching DiffFileDto(rel, 0, 0, "", "untracked") + if (!withPatch) { + // Badge path (runs on every turn end / revert): count lines by streaming bytes rather + // than allocating the whole file. null = binary (NUL byte), reported as 0/0. + val count = countLines(path) ?: return@runCatching DiffFileDto(rel, 0, 0, "", "untracked") + return@runCatching DiffFileDto(rel, count, 0, "", "untracked") + } val bytes = path.readBytes() if (bytes.any { it == 0.toByte() }) return@runCatching DiffFileDto(rel, 0, 0, "", "untracked") val text = bytes.toString(StandardCharsets.UTF_8) val additions = lines(text).size - val patch = if (withPatch) untrackedPatch(rel, text, additions) else "" - DiffFileDto(rel, additions, 0, patch, "untracked") + DiffFileDto(rel, additions, 0, untrackedPatch(rel, text, additions), "untracked") }.getOrElse { err -> LOG.debug { "Failed to read untracked file for branch diff: $rel (${err.message})" } DiffFileDto(rel, 0, 0, "", "untracked") @@ -516,42 +515,24 @@ internal fun resolveProjectDirectoryHint(hint: String, bases: List): Str return bases.firstOrNull() ?: hint } -internal fun buildBranchDiff( - numstat: String, - patch: String, - untracked: List = emptyList(), - status: Map = emptyMap(), - cap: Int = 200_000, -): List { - val stats = parseNumstat(numstat) - if (stats.isEmpty() && untracked.isEmpty()) return emptyList() - val patches = splitGitPatch(patch, stats.map { it.path }) +/** + * Assemble capped diff DTOs. [fetch] lazily produces each file's full-context patch and is skipped + * once the running patch total would exceed [cap] (or an earlier patch already overflowed), so a + * branch with hundreds of changed files doesn't run a git subprocess (or read a file) per entry + * only to discard the output. Files past the cap keep their stats but carry an empty patch, which + * the client renders from stats alone. + */ +internal fun capDiff(files: List, cap: Int, fetch: (DiffFileDto) -> String): List { var used = 0 - val tracked = stats.map { stat -> - val text = patches[stat.path].orEmpty() - val next = if (text.isNotBlank() && used + text.length <= cap) { - used += text.length - text - } else { - "" + var full = false + return files.map { file -> + if (full) return@map file.copy(patch = "") + val text = fetch(file) + when { + text.isBlank() -> file.copy(patch = "") + used + text.length <= cap -> { used += text.length; file.copy(patch = text) } + else -> { full = true; file.copy(patch = "") } } - DiffFileDto( - file = stat.path, - additions = stat.additions, - deletions = stat.deletions, - patch = next, - status = status[stat.path] ?: "modified", - ) - } - return tracked + untracked.map { file -> - val text = file.patch.orEmpty() - val next = if (text.isNotBlank() && used + text.length <= cap) { - used += text.length - text - } else { - "" - } - file.copy(patch = next) } } @@ -570,7 +551,34 @@ private fun lines(text: String): List { return text.removeSuffix("\n").split('\n') } -private data class DiffStat(val path: String, val additions: Int, val deletions: Int) +/** + * Count lines the way [lines] does (trailing newline ignored, empty file = 0) by streaming bytes, + * so the stats-only untracked path doesn't allocate the whole file. Returns null for binary content + * (a NUL byte), matching the with-patch path's binary guard. + */ +private fun countLines(path: Path): Int? { + var newlines = 0 + var last = 0 + var any = false + path.inputStream().buffered().use { input -> + val buf = ByteArray(8192) + while (true) { + val n = input.read(buf) + if (n <= 0) break + any = true + for (i in 0 until n) { + val b = buf[i].toInt() + if (b == 0) return null + if (b == '\n'.code) newlines++ + } + last = buf[n - 1].toInt() + } + } + if (!any) return 0 + return if (last == '\n'.code) newlines else newlines + 1 +} + +internal data class DiffStat(val path: String, val additions: Int, val deletions: Int) internal fun parseNameStatus(text: String): Map = text.lineSequence() .mapNotNull { line -> @@ -587,7 +595,7 @@ internal fun parseNameStatus(text: String): Map = text.lineSeque } .toMap() -private fun parseNumstat(text: String): List = text.lineSequence() +internal fun parseNumstat(text: String): List = text.lineSequence() .mapNotNull { line -> val parts = line.split('\t') if (parts.size < 3) return@mapNotNull null @@ -596,35 +604,6 @@ private fun parseNumstat(text: String): List = text.lineSequence() } .toList() -private fun splitGitPatch(text: String, paths: List): Map { - val ordered = paths.sortedByDescending { it.length } - val map = linkedMapOf() - var current: String? = null - val lines = mutableListOf() - fun flush() { - val path = current - if (path != null && lines.isNotEmpty()) map[path] = lines.joinToString("\n") - current = null - lines.clear() - } - fun match(header: String): String? { - for (path in ordered) { - if (header.endsWith(" b/$path") && header.contains(" a/$path ")) return path - if (header.endsWith(" \"b/$path\"") && header.contains(" \"a/$path\" ")) return path - } - return null - } - for (line in text.split('\n')) { - if (line.startsWith("diff --git ")) { - flush() - current = match(line) - } - if (current != null) lines.add(line) - } - flush() - return map -} - internal fun workspaceGitAvailable(base: Path, cache: ConcurrentHashMap = ConcurrentHashMap()): Boolean { if (Files.exists(base.resolve(".git"))) return true return cache.getOrPut(base.toString()) { diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/BranchDiffBuildTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/BranchDiffBuildTest.kt deleted file mode 100644 index 9565a15f9a..0000000000 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/BranchDiffBuildTest.kt +++ /dev/null @@ -1,111 +0,0 @@ -package ai.kilocode.backend.rpc - -import kotlin.test.Test -import kotlin.test.assertEquals -import ai.kilocode.rpc.dto.DiffFileDto - -class BranchDiffBuildTest { - @Test - fun `builds ordered branch diff from git outputs`() { - val numstat = "1\t1\tsrc/A.kt\n2\t0\tsrc/B.kt\n" - val patch = """ - diff --git a/src/A.kt b/src/A.kt - index 111..222 100644 - --- a/src/A.kt - +++ b/src/A.kt - @@ -1 +1 @@ - -old - +new - diff --git a/src/B.kt b/src/B.kt - new file mode 100644 - --- /dev/null - +++ b/src/B.kt - @@ -0,0 +1,2 @@ - +one - +two - """.trimIndent() - - val diff = buildBranchDiff(numstat, patch, status = mapOf("src/A.kt" to "modified", "src/B.kt" to "added")) - - assertEquals(listOf("src/A.kt", "src/B.kt"), diff.map { it.file }) - assertEquals(1, diff[0].additions) - assertEquals(1, diff[0].deletions) - assertEquals(2, diff[1].additions) - assertEquals(0, diff[1].deletions) - assertEquals(true, diff[0].patch?.startsWith("diff --git a/src/A.kt") == true) - assertEquals(true, diff[1].patch?.startsWith("diff --git a/src/B.kt") == true) - assertEquals("modified", diff[0].status) - assertEquals("added", diff[1].status) - } - - @Test - fun `parses git name status output`() { - val status = parseNameStatus("M\tsrc/A.kt\nA\tsrc/B.kt\nD\tsrc/Old.kt\n??\tsrc/Skip.kt\n") - - assertEquals( - mapOf( - "src/A.kt" to "modified", - "src/B.kt" to "added", - "src/Old.kt" to "deleted", - ), - status, - ) - } - - @Test - fun `blanks patches after cap`() { - val diff = buildBranchDiff( - numstat = "1\t0\ta.txt\n1\t0\tb.txt\n", - patch = """ - diff --git a/a.txt b/a.txt - --- /dev/null - +++ b/a.txt - @@ -0,0 +1 @@ - +a - diff --git a/b.txt b/b.txt - --- /dev/null - +++ b/b.txt - @@ -0,0 +1 @@ - +b - """.trimIndent(), - cap = 20, - ) - - assertEquals("", diff[0].patch) - assertEquals("", diff[1].patch) - } - - @Test - fun `appends untracked files after tracked files`() { - val diff = buildBranchDiff( - numstat = "1\t1\tsrc/A.kt\n", - patch = """ - diff --git a/src/A.kt b/src/A.kt - --- a/src/A.kt - +++ b/src/A.kt - @@ -1 +1 @@ - -old - +new - """.trimIndent(), - untracked = listOf(DiffFileDto("src/New.kt", 2, 0, "patch", "untracked")), - ) - - assertEquals(listOf("src/A.kt", "src/New.kt"), diff.map { it.file }) - assertEquals(2, diff[1].additions) - assertEquals("patch", diff[1].patch) - assertEquals("untracked", diff[1].status) - } - - @Test - fun `untracked patches count toward cap`() { - val diff = buildBranchDiff( - numstat = "", - patch = "", - untracked = listOf(DiffFileDto("src/New.kt", 1, 0, "diff --git a/src/New.kt b/src/New.kt", "untracked")), - cap = 5, - ) - - assertEquals("", diff.single().patch) - assertEquals("untracked", diff.single().status) - } -} diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/BranchDiffTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/BranchDiffTest.kt new file mode 100644 index 0000000000..0e06297d9a --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/BranchDiffTest.kt @@ -0,0 +1,93 @@ +package ai.kilocode.backend.rpc + +import ai.kilocode.rpc.dto.DiffFileDto +import kotlin.test.Test +import kotlin.test.assertEquals + +class BranchDiffTest { + private fun stat(path: String, status: String = "modified") = DiffFileDto(path, 1, 0, "", status) + + @Test + fun `capDiff fills patches in order until the cap is reached`() { + val files = listOf(stat("a.txt"), stat("b.txt"), stat("c.txt")) + + val diff = capDiff(files, cap = 5) { "12345" } + + assertEquals(listOf("a.txt", "b.txt", "c.txt"), diff.map { it.file }) + assertEquals("12345", diff[0].patch) + assertEquals("", diff[1].patch) + assertEquals("", diff[2].patch) + } + + @Test + fun `capDiff stops fetching once a patch overflows the cap`() { + // Regression guard: a single oversized patch must not leave the budget open so that every + // remaining file still triggers a fetch (a git subprocess per file in production). + val fetched = mutableListOf() + val files = listOf(stat("big.txt"), stat("small.txt"), stat("tiny.txt")) + + val diff = capDiff(files, cap = 4) { file -> + fetched += file.file + if (file.file == "big.txt") "0123456789" else "x" + } + + assertEquals(listOf("big.txt"), fetched) + assertEquals(listOf("", "", ""), diff.map { it.patch }) + } + + @Test + fun `capDiff keeps stats and skips blank patches without exhausting the budget`() { + val fetched = mutableListOf() + val files = listOf(stat("empty.txt"), stat("kept.txt")) + + val diff = capDiff(files, cap = 10) { file -> + fetched += file.file + if (file.file == "empty.txt") "" else "patch" + } + + assertEquals(listOf("empty.txt", "kept.txt"), fetched) + assertEquals("", diff[0].patch) + assertEquals("patch", diff[1].patch) + } + + @Test + fun `capDiff dispatches fetch per file so tracked and untracked share the budget`() { + val files = listOf(stat("A.kt", "modified"), stat("New.kt", "untracked")) + + val diff = capDiff(files, cap = 100) { file -> + if (file.status == "untracked") "untracked-patch" else "tracked-patch" + } + + assertEquals("tracked-patch", diff[0].patch) + assertEquals("untracked-patch", diff[1].patch) + assertEquals("untracked", diff[1].status) + } + + @Test + fun `parses git numstat output`() { + val stats = parseNumstat("1\t2\tsrc/A.kt\n0\t3\tsrc/B.kt\n-\t-\tbin.png\n") + + assertEquals( + listOf( + DiffStat("src/A.kt", 1, 2), + DiffStat("src/B.kt", 0, 3), + DiffStat("bin.png", 0, 0), + ), + stats, + ) + } + + @Test + fun `parses git name status output`() { + val status = parseNameStatus("M\tsrc/A.kt\nA\tsrc/B.kt\nD\tsrc/Old.kt\n??\tsrc/Skip.kt\n") + + assertEquals( + mapOf( + "src/A.kt" to "modified", + "src/B.kt" to "added", + "src/Old.kt" to "deleted", + ), + status, + ) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt index ef2650815c..f7dd52d3fe 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt @@ -1615,6 +1615,9 @@ class SessionController( val current = model.state if (current is SessionState.LoginRequired || current is SessionState.Reverting) return purgePending(sid) + // purgePending may promote a still-queued permission from another (unpurged) child + // session; mirror idle() and leave that card in place rather than clobbering it with Idle. + if (model.state is SessionState.AwaitingPermission) return SessionState.Idle } "busy" -> { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PermissionQueueTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PermissionQueueTest.kt index e242aa23b2..ea551c6281 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PermissionQueueTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PermissionQueueTest.kt @@ -6,10 +6,12 @@ import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.ConfigDto import ai.kilocode.rpc.dto.KiloAppStateDto import ai.kilocode.rpc.dto.KiloAppStatusDto +import ai.kilocode.rpc.dto.PartDto import ai.kilocode.rpc.dto.PermissionRequestDto import ai.kilocode.rpc.dto.QuestionInfoDto import ai.kilocode.rpc.dto.QuestionReplyDto import ai.kilocode.rpc.dto.QuestionRequestDto +import ai.kilocode.rpc.dto.SessionStatusDto class PermissionQueueTest : SessionControllerTestBase() { @@ -126,6 +128,43 @@ class PermissionQueueTest : SessionControllerTestBase() { assertPermission(m, "perm1") } + fun `test status idle keeps a promoted child permission instead of clobbering to idle`() { + // A root card in front of a queued child permission: when the root session reports idle, + // purgePending clears the root card and promotes the child's still-live permission. The + // status handler must leave that promotion in place rather than overwriting it with Idle. + val (m, _, _) = prompted() + + emit(ChatEventDto.PermissionAsked("ses_test", permission("perm1"))) + emit(taskPart("ses_child"), flush = false) + emit(ChatEventDto.PermissionAsked("ses_child", childPermission("child_perm1"))) + assertPermission(m, "perm1") + + emit(ChatEventDto.SessionStatusChanged("ses_test", SessionStatusDto("idle"))) + + assertPermission(m, "child_perm1") + } + + private fun taskPart(child: String) = ChatEventDto.PartUpdated( + sessionID = "ses_test", + part = PartDto( + id = "part_task", + sessionID = "ses_test", + messageID = "msg1", + type = "tool", + tool = "task", + metadata = mapOf("sessionId" to child), + input = mapOf("subagent_type" to "explore", "description" to "Find files"), + ), + ) + + private fun childPermission(id: String) = PermissionRequestDto( + id = id, + sessionID = "ses_child", + permission = "edit", + patterns = listOf("*.kt"), + always = emptyList(), + ) + private fun assertPermission(c: SessionController, id: String, name: String = "edit") { val state = c.model.state as? SessionState.AwaitingPermission ?: error("Expected AwaitingPermission") assertEquals(id, state.permission.id) From 56e07859c3e0123c7ab8f0f922f64447626768a1 Mon Sep 17 00:00:00 2001 From: kirillk Date: Thu, 30 Jul 2026 21:09:16 -0400 Subject: [PATCH 26/28] fix(jetbrains): handle compacted summaries and queued permission ghosts Address follow-up review on the JetBrains diff/session work: - Parse message `summary` with the safe JSON-object accessor so assistant compaction summaries (`summary: true`) do not crash history/event parsing. - Route auto-approve skill-shell permission cards through the same queue as ordinary permissions, preserving FIFO order and letting purge/resolve logic handle them consistently. - Make Stop purge current root/child permission ghosts instead of clearing only the backing queue and leaving a dead card on screen. - Let capDiff skip an oversized patch and still fetch later small patches, while bounding repeated oversized misses to keep subprocess count finite. Add regression coverage for compacted assistant messages, oversized diff cap behavior, Stop ghost purge, and multi skill-shell queueing. --- .../kilocode/backend/cli/KiloCliDataParser.kt | 2 +- .../backend/rpc/KiloWorkspaceRpcApiImpl.kt | 25 ++++++++--- .../backend/cli/KiloCliDataParserTest.kt | 2 +- .../ai/kilocode/backend/rpc/BranchDiffTest.kt | 22 +++++++--- .../session/controller/SessionController.kt | 24 +++++++---- .../session/controller/PermissionQueueTest.kt | 43 +++++++++++++++++++ 6 files changed, 96 insertions(+), 22 deletions(-) diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt index eeaaf27e91..9b84af8525 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt @@ -1039,7 +1039,7 @@ object KiloCliDataParser { val time = obj["time"]?.jsonObject val tokens = obj["tokens"]?.jsonObject val error = obj["error"]?.jsonObject - val raw = obj["summary"]?.jsonObject?.get("diffs") + val raw = obj["summary"].obj()?.get("diffs") val summary = if (raw == null) null else MessageSummaryDto(parseDiffs(raw)) return MessageDto( diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt index 4b550fd026..65c20e5cf7 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt @@ -516,26 +516,37 @@ internal fun resolveProjectDirectoryHint(hint: String, bases: List): Str } /** - * Assemble capped diff DTOs. [fetch] lazily produces each file's full-context patch and is skipped - * once the running patch total would exceed [cap] (or an earlier patch already overflowed), so a - * branch with hundreds of changed files doesn't run a git subprocess (or read a file) per entry - * only to discard the output. Files past the cap keep their stats but carry an empty patch, which - * the client renders from stats alone. + * Assemble capped diff DTOs. [fetch] lazily produces each file's full-context patch. Oversized + * patches are skipped, but a single generated/large file should not blank every later small file; + * after a bounded number of misses, stop fetching so large branches still don't run a git subprocess + * (or read a file) per entry only to discard the output. Files past the cap keep their stats but + * carry an empty patch, which the client renders from stats alone. */ internal fun capDiff(files: List, cap: Int, fetch: (DiffFileDto) -> String): List { var used = 0 + var misses = 0 var full = false return files.map { file -> if (full) return@map file.copy(patch = "") val text = fetch(file) when { text.isBlank() -> file.copy(patch = "") - used + text.length <= cap -> { used += text.length; file.copy(patch = text) } - else -> { full = true; file.copy(patch = "") } + used + text.length <= cap -> { + used += text.length + if (used >= cap) full = true + file.copy(patch = text) + } + else -> { + misses++ + full = misses >= MAX_OVERSIZED_PATCHES || used >= cap + file.copy(patch = "") + } } } } +private const val MAX_OVERSIZED_PATCHES = 3 + private fun untrackedPatch(path: String, text: String, additions: Int): String = buildString { appendLine("diff --git a/$path b/$path") appendLine("new file mode 100644") diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt index da7cc43723..aebd8a40cf 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt @@ -1374,7 +1374,7 @@ class KiloCliDataParserTest { "parts": [{ "id": "p1", "sessionID": "s1", "messageID": "m1", "type": "text", "text": "Hello" }] }, { - "info": { "id": "m2", "sessionID": "s1", "role": "assistant", "time": { "created": 2.0 } }, + "info": { "id": "m2", "sessionID": "s1", "role": "assistant", "time": { "created": 2.0 }, "summary": true }, "parts": [{ "id": "p2", "sessionID": "s1", "messageID": "m2", "type": "text", "text": "Hi there" }] } ]""" diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/BranchDiffTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/BranchDiffTest.kt index 0e06297d9a..bf370342a5 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/BranchDiffTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/BranchDiffTest.kt @@ -20,9 +20,7 @@ class BranchDiffTest { } @Test - fun `capDiff stops fetching once a patch overflows the cap`() { - // Regression guard: a single oversized patch must not leave the budget open so that every - // remaining file still triggers a fetch (a git subprocess per file in production). + fun `capDiff skips one oversized patch and keeps later small patches`() { val fetched = mutableListOf() val files = listOf(stat("big.txt"), stat("small.txt"), stat("tiny.txt")) @@ -31,8 +29,22 @@ class BranchDiffTest { if (file.file == "big.txt") "0123456789" else "x" } - assertEquals(listOf("big.txt"), fetched) - assertEquals(listOf("", "", ""), diff.map { it.patch }) + assertEquals(listOf("big.txt", "small.txt", "tiny.txt"), fetched) + assertEquals(listOf("", "x", "x"), diff.map { it.patch }) + } + + @Test + fun `capDiff stops fetching after bounded oversized misses`() { + val fetched = mutableListOf() + val files = listOf(stat("big1.txt"), stat("big2.txt"), stat("big3.txt"), stat("later.txt")) + + val diff = capDiff(files, cap = 4) { file -> + fetched += file.file + "0123456789" + } + + assertEquals(listOf("big1.txt", "big2.txt", "big3.txt"), fetched) + assertEquals(listOf("", "", "", ""), diff.map { it.patch }) } @Test diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt index f7dd52d3fe..f9efd9eb1f 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt @@ -364,7 +364,7 @@ class SessionController( return } val id = sid ?: return - pending.clear() + (childIds + id).forEach(::purgePending) capture("Session Stop Clicked", sessionProps(id)) cs.launch { try { @@ -731,7 +731,10 @@ class SessionController( if (!autoApprove || restore().meta.raw["skillShell"] == "true") { edt { if (disposed) return@edt - model.setState(SessionState.AwaitingPermission(restore())) + enqueue(restore()) + if (model.state !is SessionState.AwaitingPermission && model.state !is SessionState.AwaitingQuestion) { + promote() + } } return@launch } @@ -765,14 +768,19 @@ class SessionController( try { val permissions = sessions.pendingPermissions(directory).filter { it.sessionID in ids && it.id !in skip } val count = replyAll(permissions) - // Skill-shell requests are skipped by replyAll; surface one as a card so it - // isn't stranded (never machine-approved, never shown). - val card = skillShellCard(permissions)?.let { toPermission(it) } - if (count == 0 && card == null) return@launch + // Skill-shell requests are skipped by replyAll; queue all of them so they aren't + // stranded (never machine-approved, never shown) or overwritten by later cards. + val cards = permissions.filter { it.metadata["skillShell"] == "true" }.map(::toPermission) + if (count == 0 && cards.isEmpty()) return@launch runEdt { if (disposed) return@runEdt - if (card != null) { - updateModel { model.setState(SessionState.AwaitingPermission(card)) } + if (cards.isNotEmpty()) { + updateModel { + cards.forEach(::enqueue) + if (model.state !is SessionState.AwaitingPermission && model.state !is SessionState.AwaitingQuestion) { + promote() + } + } return@runEdt } val current = model.state diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PermissionQueueTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PermissionQueueTest.kt index ea551c6281..2487f4ed3c 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PermissionQueueTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PermissionQueueTest.kt @@ -114,6 +114,47 @@ class PermissionQueueTest : SessionControllerTestBase() { assertPermission(m, "perm2") } + fun `test stop purges outstanding permission ghost`() { + val (m, _, _) = prompted() + + emit(ChatEventDto.PermissionAsked("ses_test", permission("perm1"))) + assertPermission(m, "perm1") + + edt { m.abort() } + flush() + assertTrue(m.model.state is SessionState.Idle) + + emit(ChatEventDto.PermissionAsked("ses_test", permission("perm2"))) + assertPermission(m, "perm2") + } + + fun `test auto approve skill shell permissions stay queued in FIFO order`() { + edt { KiloPluginSettings.setAutoApprove(true) } + val (m, _, _) = prompted() + + emit(ChatEventDto.PermissionAsked("ses_test", skillPermission("perm1"))) + emit(ChatEventDto.PermissionAsked("ses_test", skillPermission("perm2"))) + + assertPermission(m, "perm1") + + emit(ChatEventDto.PermissionReplied("ses_test", "perm1")) + assertPermission(m, "perm2") + } + + fun `test auto approve drain queues multiple skill shell permissions`() { + rpc.pendingPermissionList.add(skillPermission("perm1")) + rpc.pendingPermissionList.add(skillPermission("perm2")) + val (m, _, _) = prompted() + + edt { m.setAutoApprove(true) } + flush() + + assertPermission(m, "perm1") + + emit(ChatEventDto.PermissionReplied("ses_test", "perm1")) + assertPermission(m, "perm2") + } + fun `test replying active question shows queued permission`() { val (m, _, _) = prompted() @@ -179,6 +220,8 @@ class PermissionQueueTest : SessionControllerTestBase() { always = emptyList(), ) + private fun skillPermission(id: String) = permission(id).copy(metadata = mapOf("skillShell" to "true")) + private fun question(id: String) = QuestionRequestDto( id = id, sessionID = "ses_test", From 36fbfbc5c24029407d66f8b1d4fe81b048f9d751 Mon Sep 17 00:00:00 2001 From: kirillk Date: Fri, 31 Jul 2026 09:03:00 -0400 Subject: [PATCH 27/28] fix(jetbrains): keep permission queue deterministic and authoritative Follow-up review on the permission queue: - Decide the skill-shell "needs a human" case synchronously on the EDT in approve() and enqueue there, so back-to-back auto-approve asks keep arrival (FIFO) order instead of racing two independent coroutines. Only the replyPermission RPC stays in a coroutine. - Route permission enqueue/promote through a show() helper wrapped in updateModel, so cards added from approve()/abort() preserve the transcript's bottom-follow like the drain and child-recovery paths. - Queue the auto-approve error card too, so pending stays the single source of truth and Stop / TurnClose / idle purge can clear it rather than stranding a card that can only fail with NotFoundError. Add coverage for the purged auto-approve error card. --- .../session/controller/SessionController.kt | 54 ++++++++++--------- .../session/controller/PermissionQueueTest.kt | 19 +++++++ .../client/testing/FakeSessionRpcApi.kt | 3 ++ 3 files changed, 50 insertions(+), 26 deletions(-) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt index f9efd9eb1f..cd09ba532f 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt @@ -364,7 +364,7 @@ class SessionController( return } val id = sid ?: return - (childIds + id).forEach(::purgePending) + updateModel { (childIds + id).forEach(::purgePending) } capture("Session Stop Clicked", sessionProps(id)) cs.launch { try { @@ -723,36 +723,29 @@ class SessionController( private fun approve(id: String, restore: () -> Permission) { assertEdt() LOG.debug { "${ChatLogSummary.sid(sid ?: ref?.key ?: "pending")} kind=permission-auto rid=$id" } + // Skill-shell batches must be answered by a human: the server refuses non-interactive + // approvals, so show the card (its manual reply sets interactive=true) rather than send a + // machine reply. Decide and enqueue synchronously on the EDT so back-to-back asks keep + // arrival (FIFO) order, matching asked()'s non-auto path; only the RPC needs a coroutine. + if (!autoApprove || restore().meta.raw["skillShell"] == "true") { + show(restore()) + return + } + updateModel { model.setState(SessionState.Busy(KiloBundle.message("session.status.considering"))) } cs.launch { try { - // Skill-shell batches must be answered by a human: the server refuses - // non-interactive approvals, so auto-approve must show the card (whose - // manual reply sets interactive=true) rather than send a machine reply. - if (!autoApprove || restore().meta.raw["skillShell"] == "true") { - edt { - if (disposed) return@edt - enqueue(restore()) - if (model.state !is SessionState.AwaitingPermission && model.state !is SessionState.AwaitingQuestion) { - promote() - } - } - return@launch - } - edt { - if (disposed) return@edt - model.setState(SessionState.Busy(KiloBundle.message("session.status.considering"))) - } sessions.replyPermission(id, directory, PermissionReplyDto("once")) capture("Permission Auto Approved", sessionProps() + mapOf("tool" to restore().name, "source" to "single")) LOG.debug { "${ChatLogSummary.sid(sid ?: ref?.key ?: "pending")} kind=permission-auto rid=$id ok=true" } } catch (e: Exception) { LOG.warn("${ChatLogSummary.sid(sid ?: ref?.key ?: "pending")} kind=permission-auto rid=$id dir=${ChatLogSummary.dir(directory)} failed message=${e.message}", e) edt { - if (disposed) return@edt - model.setState(SessionState.AwaitingPermission(restore().copy( + // Queue the error card too, so pending stays the single source of truth and a + // later Stop / TurnClose / idle purge can clear it instead of stranding it. + show(restore().copy( state = PermissionRequestState.ERROR, message = e.message ?: KiloBundle.message("session.permission.error"), - ))) + )) } } } @@ -1535,11 +1528,7 @@ class SessionController( approve(event.request) return } - val perm = toPermission(event.request) - enqueue(perm) - if (model.state !is SessionState.AwaitingPermission && model.state !is SessionState.AwaitingQuestion) { - promote() - } + show(toPermission(event.request)) } private fun replied(event: ChatEventDto.PermissionReplied) { @@ -1589,6 +1578,19 @@ class SessionController( model.setState(SessionState.AwaitingPermission(perm)) } + /** + * Queue [perm] and surface it if no card/question is already up. Wrapped in updateModel so the + * transcript's bottom-follow is preserved (permission cards live inside the scroll pane), and + * kept synchronous so callers on the EDT enqueue in arrival (FIFO) order. + */ + @RequiresEdt + private fun show(perm: Permission) = updateModel { + enqueue(perm) + if (model.state !is SessionState.AwaitingPermission && model.state !is SessionState.AwaitingQuestion) { + promote() + } + } + /** * Drop queued permissions for [session] and clear/re-promote the visible card when it belonged to * one of them. The CLI deletes an outstanding permission server-side on turn interruption without diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PermissionQueueTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PermissionQueueTest.kt index 2487f4ed3c..2e10f152e8 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PermissionQueueTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PermissionQueueTest.kt @@ -1,6 +1,7 @@ package ai.kilocode.client.session.controller import ai.kilocode.client.plugin.KiloPluginSettings +import ai.kilocode.client.session.model.PermissionRequestState import ai.kilocode.client.session.model.SessionState import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.ConfigDto @@ -141,6 +142,24 @@ class PermissionQueueTest : SessionControllerTestBase() { assertPermission(m, "perm2") } + fun `test auto approve failure card is queued and purged by stop`() { + edt { KiloPluginSettings.setAutoApprove(true) } + rpc.replyPermissionThrows = RuntimeException("boom") + val (m, _, _) = prompted() + + emit(ChatEventDto.PermissionAsked("ses_test", permission("perm1"))) + flush() + + // The failed auto-approval surfaces as an error card; it must be in the queue so purge sees it. + val state = m.model.state as? SessionState.AwaitingPermission ?: error("Expected error card") + assertEquals("perm1", state.permission.id) + assertEquals(PermissionRequestState.ERROR, state.permission.state) + + edt { m.abort() } + flush() + assertTrue(m.model.state is SessionState.Idle) + } + fun `test auto approve drain queues multiple skill shell permissions`() { rpc.pendingPermissionList.add(skillPermission("perm1")) rpc.pendingPermissionList.add(skillPermission("perm2")) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt index 18ccd3d093..ce540a25d4 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt @@ -283,8 +283,11 @@ class FakeSessionRpcApi : KiloSessionRpcApi { configs.add(directory to config) } + var replyPermissionThrows: Exception? = null + override suspend fun replyPermission(requestId: String, directory: String, reply: PermissionReplyDto) { assertNotEdt("replyPermission") + replyPermissionThrows?.let { throw it } permissionReplies.add(Triple(requestId, directory, reply)) } From e20f5ec510af6890304c97fa1c74743891ba3401 Mon Sep 17 00:00:00 2001 From: kirillk Date: Fri, 31 Jul 2026 09:40:29 -0400 Subject: [PATCH 28/28] fix(jetbrains): keep skill-shell card queued when enabling auto-approve Toggling auto-approve on while a skill-shell permission card is visible stranded the card: approve() synchronously re-enqueues it via show(), but the trailing pending.clear() dropped that entry, leaving a ghost card not in pending that Stop/idle purge could not clear (answering it later failed with NotFoundError). Clear the queue before re-surfacing the card so show() is the last writer. Also guard drainAutoApprove so it never flips a card that was handled synchronously (in skip) to Busy, which otherwise hid a preserved skill-shell card with no reply path left. --- .../session/controller/SessionController.kt | 15 ++++++- .../session/controller/PermissionQueueTest.kt | 42 +++++++++++++++++++ 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt index cd09ba532f..315a5adb0a 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt @@ -388,13 +388,17 @@ class SessionController( return } val current = model.state + // Clear the local queue before re-surfacing the visible card. approve() may synchronously + // re-enqueue a skill-shell card via show() (skill-shell asks always need a human), so that + // enqueue must be the last writer — otherwise a trailing clear() would drop it and leave a + // ghost card that is not in pending, which a later Stop/idle purge could not clear. + pending.clear() val skip = if (current is SessionState.AwaitingPermission) { approve(current.permission) setOf(current.permission.id) } else { emptySet() } - pending.clear() drainAutoApprove(skip) } @@ -777,7 +781,14 @@ class SessionController( return@runEdt } val current = model.state - if (current is SessionState.AwaitingPermission && current.permission.sessionId in ids) { + // A card in `skip` was handled synchronously by the caller (approve() either + // replied to it — already Busy — or re-showed a skill-shell card we must keep). + // Never transition it to Busy here or the preserved skill-shell card vanishes + // with no reply path left. + if (current is SessionState.AwaitingPermission && + current.permission.sessionId in ids && + current.permission.id !in skip + ) { model.setState(SessionState.Busy(KiloBundle.message("session.status.considering"))) } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PermissionQueueTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PermissionQueueTest.kt index 2e10f152e8..df0f4c97df 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PermissionQueueTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PermissionQueueTest.kt @@ -174,6 +174,48 @@ class PermissionQueueTest : SessionControllerTestBase() { assertPermission(m, "perm2") } + fun `test toggling auto approve on keeps a visible skill shell card queued for purge`() { + // A skill-shell card is up while auto-approve is off; enabling auto-approve must not strand it. + // Skill-shell asks always need a human, so setAutoApprove re-shows the card via show(); that + // enqueue has to survive pending.clear() (be the last writer) or the card becomes a ghost that + // is no longer in pending, which a later Stop could not purge and answering would NotFoundError. + val (m, _, _) = prompted() + + emit(ChatEventDto.PermissionAsked("ses_test", skillPermission("perm1"))) + assertPermission(m, "perm1") + + edt { m.setAutoApprove(true) } + flush() + + // Still shown, and still tracked in pending — so Stop can clear it. + assertPermission(m, "perm1") + + edt { m.abort() } + flush() + assertTrue(m.model.state is SessionState.Idle) + } + + fun `test toggling auto approve on keeps skill shell card while draining other permissions`() { + // Visible skill-shell card plus another auto-approvable permission on the server. Enabling + // auto-approve drains/replies the other one, but the drain must not flip the preserved + // skill-shell card to Busy — that would hide it with no reply path left. + rpc.pendingPermissionList.add(permission("perm2")) + val (m, _, _) = prompted() + + emit(ChatEventDto.PermissionAsked("ses_test", skillPermission("perm1"))) + assertPermission(m, "perm1") + + edt { m.setAutoApprove(true) } + flush() + + assertTrue(rpc.permissionReplies.any { it.first == "perm2" }) + assertPermission(m, "perm1") + + edt { m.abort() } + flush() + assertTrue(m.model.state is SessionState.Idle) + } + fun `test replying active question shows queued permission`() { val (m, _, _) = prompted()