diff --git a/.changeset/jetbrains-attachment-button-sync.md b/.changeset/jetbrains-attachment-button-sync.md new file mode 100644 index 00000000000..dc86abba24e --- /dev/null +++ b/.changeset/jetbrains-attachment-button-sync.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Keep the JetBrains prompt send/stop button in sync when attachments are added or removed while a session is busy. diff --git a/.changeset/jetbrains-diff-view-fixes.md b/.changeset/jetbrains-diff-view-fixes.md new file mode 100644 index 00000000000..9fc6246c69b --- /dev/null +++ b/.changeset/jetbrains-diff-view-fixes.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Fix JetBrains diff views to show compact workspace-relative file paths and keep added-file content visible in large branch diffs. diff --git a/.changeset/jetbrains-session-load-crop.md b/.changeset/jetbrains-session-load-crop.md new file mode 100644 index 00000000000..4e515cb3041 --- /dev/null +++ b/.changeset/jetbrains-session-load-crop.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Fix JetBrains chat transcripts rendering cropped when opening existing sessions. 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 65c20e5cf79..8140b86bffd 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 @@ -81,6 +81,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 BRANCH_DIFF_CAP = 8 * 1024 * 1024 private const val LARGE_FILE = 2 * 1024 * 1024L private val JSON = Json { ignoreUnknownKeys = true } private val CONFIG = """{ @@ -271,9 +272,9 @@ class KiloWorkspaceRpcApiImpl internal constructor( 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 + // Fetch patches lazily and stop once the running total reaches BRANCH_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 -> + capDiff(files, BRANCH_DIFF_CAP) { file -> if (file.status == "untracked") untracked(base, file.file, withPatch = true).patch.orEmpty() else fileDiff(base, anc, file.file) } 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 bf370342a5b..6521c61ba44 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 @@ -75,6 +75,16 @@ class BranchDiffTest { assertEquals("untracked", diff[1].status) } + @Test + fun `capDiff keeps all patches under a large budget`() { + val files = (1..110).map { i -> stat("src/File$i.kt") } + + val diff = capDiff(files, cap = 8 * 1024 * 1024) { file -> "patch-${file.file}\n".repeat(30) } + + assertEquals(files.map { it.file }, diff.map { it.file }) + assertEquals(files.map { file -> "patch-${file.file}\n".repeat(30) }, diff.map { it.patch }) + } + @Test fun `parses git numstat output`() { val stats = parseNumstat("1\t2\tsrc/A.kt\n0\t3\tsrc/B.kt\n-\t-\tbin.png\n") 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 f710fdeabe1..538a1686ace 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 @@ -8,6 +8,7 @@ import com.intellij.diff.requests.SimpleDiffRequest import com.intellij.diff.util.DiffUserDataKeys import com.intellij.openapi.fileTypes.FileTypeManager import com.intellij.openapi.project.Project +import com.intellij.openapi.vcs.FileStatus internal fun diffRequest( project: Project, @@ -18,15 +19,20 @@ internal fun diffRequest( val sides = DiffPatchReconstruct.sides(dto) val type = FileTypeManager.getInstance().getFileTypeByFileName(dto.file) val factory = DiffContentFactory.getInstance() + val status = fileStatus(dto) + val patch = dto.patch?.takeIf { it.isNotBlank() } + val fallback = patch ?: KiloBundle.message("diff.editor.patch.unavailable") val left = when { DiffPatchReconstruct.added(dto.patch) -> factory.createEmpty() sides.renderable -> factory.create(project, sides.before, type) + status == FileStatus.DELETED -> factory.create(project, fallback, 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 ?: KiloBundle.message("diff.editor.patch.unavailable"), type) + status == FileStatus.DELETED -> factory.createEmpty() + else -> factory.create(project, fallback, 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/KiloDiffEditorContent.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloDiffEditorContent.kt index b66adb3b00a..e364b2f019f 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 @@ -49,6 +49,7 @@ 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 com.intellij.util.ui.tree.TreeUtil import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -67,6 +68,8 @@ import javax.swing.JPanel import javax.swing.ScrollPaneConstants import javax.swing.JViewport import javax.swing.JTree +import javax.swing.event.TreeExpansionEvent +import javax.swing.event.TreeExpansionListener import javax.swing.tree.DefaultMutableTreeNode import javax.swing.tree.DefaultTreeModel import javax.swing.tree.TreeCellRenderer @@ -98,10 +101,11 @@ internal class DiffEditorView( private val load: ((DiffEditorData) -> Unit) -> Job, private val replace: (DiffEditorData) -> Unit, ) : Disposable { + private val start = normalize(initial) private val disposed = AtomicBoolean(false) private val outdated = AtomicBoolean(false) private val refreshing = AtomicBoolean(false) - private val tree = buildFileTree(initial) + private val tree = buildFileTree(start) 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) } @@ -114,12 +118,12 @@ internal class DiffEditorView( add(banner, BorderLayout.NORTH) add(splitter, BorderLayout.CENTER) } - private var files = initial + private var files = start private var branch = branch private var syncing = false - private var requested: String? = initial.firstOrNull()?.file + private var requested: String? = start.firstOrNull()?.file private var refreshJob: Job? = null - private var processor = processor(initial, selected(initial.firstOrNull()?.file)) + private var processor = processor(start, selected(start.firstOrNull()?.file)) private val openFileAction = object : DumbAwareAction( KiloBundle.message("diff.editor.openFile"), KiloBundle.message("diff.editor.openFile"), @@ -155,11 +159,11 @@ internal class DiffEditorView( // 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.firstComponent = buildTreePanel(tree, start, badge, processor.component, ::refresh) splitter.secondComponent = processor.component processor.updateRequest() - applyBadge(initial) - select(initial.firstOrNull()?.file) + applyBadge(start) + select(start.firstOrNull()?.file) listen() } @@ -174,24 +178,25 @@ internal class DiffEditorView( @RequiresEdt fun applyFiles(next: List, nextBranch: String? = branch) { - if (same(files, next) && branch == nextBranch) return + val items = normalize(next) + if (same(files, items) && branch == nextBranch) return val path = selectedFile()?.file ?: activePath() ?: files.firstOrNull()?.file - val index = selected(path, next) + val index = selected(path, items) val old = processor - files = next + files = items branch = nextBranch - requested = next.getOrNull(index)?.file - tree.model = buildFileModel(next) + requested = items.getOrNull(index)?.file + tree.model = buildFileModel(items) expandAll(tree) - processor = processor(next, index) + processor = processor(items, index) Disposer.register(parent, processor) processor.addListener(DiffRequestProcessorListener { syncTree() }, processor) - splitter.firstComponent = buildTreePanel(tree, next, badge, processor.component, ::refresh) + splitter.firstComponent = buildTreePanel(tree, items, badge, processor.component, ::refresh) splitter.secondComponent = processor.component processor.updateRequest() Disposer.dispose(old) - applyBadge(next) - select(next.getOrNull(index)?.file) + applyBadge(items) + select(items.getOrNull(index)?.file) root.revalidate() root.repaint() } @@ -360,6 +365,22 @@ internal class DiffEditorView( private fun same(a: List, b: List): Boolean = a == b + private fun normalize(files: List): List = files.map { file -> file.copy(file = display(file.file)) } + + private fun display(file: String): String { + val dir = params["directory"] ?: return file + val root = clean(dir) ?: return file + return try { + val raw = Path.of(file) + if (!raw.isAbsolute) return file + val path = raw.normalize() + if (!path.startsWith(root)) return file + root.relativize(path).toString().replace('\\', '/') + } catch (_: InvalidPathException) { + file + } + } + private fun clean(dir: String): Path? = try { Path.of(dir).normalize() } catch (_: InvalidPathException) { @@ -421,17 +442,51 @@ private fun buildFileTree(files: List): Tree { val node = path.lastPathComponent as? DefaultMutableTreeNode (node?.userObject as? Node)?.name.orEmpty() } + // A folder row hides its rolled-up badge while expanded, so its preferred width depends on + // expansion state. JTree only invalidates cached path bounds on model changes, not on + // expand/collapse, so a collapsed folder would keep its narrower expanded-state bounds and the + // re-shown badge would squeeze the name until an unrelated re-measure. Invalidate the layout + // cache on a user toggle so the row re-measures. invalidateCacheAndRepaint is UI-scoped (whole + // tree), so [bulkToggle] suppresses this during expand/collapse-all and invalidates once at the + // end — otherwise a bulk op would re-measure the whole tree per row. Registered after the initial + // expandAll (already fully expanded, nothing to re-measure). expandAll(tree) + tree.addTreeExpansionListener(object : TreeExpansionListener { + override fun treeExpanded(event: TreeExpansionEvent) = onToggle() + override fun treeCollapsed(event: TreeExpansionEvent) = onToggle() + private fun onToggle() { if (!tree.bulk) TreeUtil.invalidateCacheAndRepaint(tree.ui) } + }) return tree } private fun buildFileModel(files: List): DefaultTreeModel { val root = DefaultMutableTreeNode(Node("", "", true, null)) for (file in files) addFile(root, file) + compact(root) updateStats(root) return DefaultTreeModel(root) } +// Collapse chains of single-child directories into one node (e.g. "pkg/ui/list") so the tree +// doesn't nest through directories that never branch, mirroring the IDE's compact directories. +private fun compact(node: DefaultMutableTreeNode) { + val item = node.userObject as? Node + if (item != null && item.file == null && item.path.isNotEmpty()) { + while (node.childCount == 1) { + val parent = node.userObject as? Node ?: break + val child = node.getChildAt(0) as? DefaultMutableTreeNode ?: break + val kid = child.userObject as? Node ?: break + if (kid.file != null) break + node.userObject = Node("${parent.name}/${kid.name}", kid.path, true, null) + node.removeAllChildren() + while (child.childCount > 0) node.add(child.getChildAt(0) as DefaultMutableTreeNode) + } + } + for (i in 0 until node.childCount) { + compact(node.getChildAt(i) as? DefaultMutableTreeNode ?: continue) + } +} + private fun buildTreePanel(tree: Tree, files: List, badge: DiffStatBadge, target: JComponent, refresh: () -> Unit): JComponent { val toolbar = ActionManager.getInstance().createActionToolbar( ActionPlaces.TOOLBAR, @@ -464,7 +519,7 @@ private fun buildTreePanel(tree: Tree, files: List, badge: DiffStat JBScrollPane(tree).apply { border = JBUI.Borders.empty() viewportBorder = JBUI.Borders.empty() - horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER + horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED }, BorderLayout.CENTER, ) @@ -483,7 +538,7 @@ private fun fileCount(count: Int): String = KiloBundle.message( count, ) -private fun expandAll(tree: Tree) { +private fun expandAll(tree: Tree) = bulkToggle(tree) { var i = 0 while (i < tree.rowCount) { tree.expandRow(i) @@ -491,10 +546,26 @@ private fun expandAll(tree: Tree) { } } -private fun collapseAll(tree: Tree) { +private fun collapseAll(tree: Tree) = bulkToggle(tree) { for (i in tree.rowCount - 1 downTo 0) tree.collapseRow(i) } +/** + * Run a bulk expand/collapse without firing the per-row layout-cache invalidation. Each toggle would + * otherwise invalidate the whole tree (invalidateCacheAndRepaint is UI-scoped), making a bulk op + * O(rows^2) to re-measure. Suppress the toggle listener for the loop and invalidate once at the end. + */ +private fun bulkToggle(tree: Tree, action: () -> Unit) { + val diff = tree as? DiffTree + diff?.bulk = true + try { + action() + } finally { + diff?.bulk = false + } + TreeUtil.invalidateCacheAndRepaint(tree.ui) +} + private fun addFile(root: DefaultMutableTreeNode, file: DiffFileDto) { var node = root val parts = file.file.split('/').filter { it.isNotBlank() } @@ -549,6 +620,9 @@ private class Node(val name: String, val path: String, val dir: Boolean, val fil } private class DiffTree(model: TreeModel) : Tree(model) { + /** Set while a bulk expand/collapse runs so the toggle listener skips its per-row invalidation. */ + var bulk = false + override fun getBackground(): Color = JBUI.CurrentTheme.ToolWindow.background() override fun getScrollableTracksViewportHeight(): Boolean { @@ -584,9 +658,12 @@ private class Renderer : JPanel(BorderLayout()), TreeCellRenderer { 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) + // A folder's badge rolls up its descendants' stats, which is only meaningful while the + // folder is collapsed. Once expanded the child rows carry their own badges, so hide the + // folder aggregate to avoid duplicating the numbers. Leaf files always show their badge. + val show = item != null && (item.additions != 0 || item.deletions != 0) && !(item.dir && expanded) + badge.isVisible = show + if (show) badge.update(item.additions, item.deletions) return this } } 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 f95799e88db..9b635e1bf4b 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 @@ -389,6 +389,7 @@ class SessionUi( header = SessionHeaderPanel(controller, this) { openBranchChanges() } scroll = SessionScroll(root, sessionContent, messageBody, blankBody) + messageBody.onReflow = { on -> if (on && !opening) scroll.followTail() } scroll.onScroll = { overlay.clear() popup.hideAll() @@ -673,6 +674,7 @@ class SessionUi( if (width <= 0 || height <= 0) return if (body(controller.model.state) !== messageBody) return pending = false + messageBody.reflow() scroll.openBottom { opening = false } 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 5c09926ac62..4d01bfa9010 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 @@ -53,6 +53,7 @@ class ModifiedFilesView private constructor( init { body.parent = this + body.overflow = ::openDiffViewer parts.diff.addActionListener { openDiffViewer() } isVisible = false bindHeader(parts.glyph, parts.title, parts.count, parts.panel.left, parts.bars, parts.anchor) @@ -149,7 +150,10 @@ class ModifiedFilesView private constructor( @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 popup = PatchBody(selection, openFile, POPUP_OPTS).also { + it.parent = owner + it.overflow = ::openDiffViewer + } val panel = popup.mountFiles(files) popup.applyStyle(style) return HeaderPopupBody(panel, owner, style.editorBackground, SessionUiStyle.View.Popup.WIDE_MAX_WIDTH) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionLayout.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionLayout.kt index 3b29734149d..94d57811264 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionLayout.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionLayout.kt @@ -91,6 +91,11 @@ class SessionLayout( cache.remove(comp) } + /** Drop every cached measurement so the next layout pass re-measures all children. */ + fun forgetAll() { + cache.clear() + } + private fun measure(comp: Component, width: Int): Int { val hit = cache[comp] if (comp.isValid && hit?.width == width) return hit.height 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 9898119dff2..5f60ef388b3 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 @@ -17,8 +17,10 @@ import ai.kilocode.client.session.views.permission.PermissionView import ai.kilocode.client.session.views.question.QuestionView import ai.kilocode.client.session.views.TurnView import ai.kilocode.client.session.views.base.PartView +import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.Disposable import com.intellij.openapi.util.Disposer +import com.intellij.util.concurrency.annotations.RequiresEdt import java.awt.Insets import javax.swing.JComponent @@ -82,8 +84,13 @@ class SessionMessageListPanel( private var revertingMessage: String? = null private var openDiff: SessionDiffOpener = { _, _, _ -> } private var sessionId: String? = null + private var seq = 0 + private var stable = -1 + private var pendingReflow = false + private var dead = false var onHover: ((PartView, Boolean) -> Unit)? = null + var onReflow: ((Boolean) -> Unit)? = null /** Progress footer — always the last child inside the scroll. */ val progress = ProgressPanel(model, parent) @@ -170,7 +177,9 @@ class SessionMessageListPanel( // 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) { + val view = turnViews[event.info.info.id] + if (view?.setDiffs(event.info.info.summary?.diffs.orEmpty()) == true) { + (layout as? SessionLayout)?.forget(view) refresh() } } @@ -186,6 +195,22 @@ class SessionMessageListPanel( rebuild() } + override fun addNotify() { + super.addNotify() + scheduleReflow() + } + + override fun doLayout() { + super.doLayout() + // A reflow scheduled before the panel had a width parks itself in [pendingReflow]. The first + // layout that gives us a real width re-arms it, so the transcript is always measured on-screen + // instead of against the zero-width state a resize used to be the only escape from. Cheap and + // inert on the streaming path: pendingReflow is only set by a rebuild/clear that ran too early. + if (!pendingReflow || dead || width <= 0 || turnViews.isEmpty()) return + pendingReflow = false + scheduleReflow() + } + fun setDiffOpener(openDiff: SessionDiffOpener, sessionId: String?) { this.openDiff = openDiff this.sessionId = sessionId @@ -237,6 +262,24 @@ class SessionMessageListPanel( } }.trimEnd() + @RequiresEdt + internal fun reflow(): Boolean { + // Measuring at zero width reflows every HTML pane to a 1-char column and yields a bogus + // height. Defer until the panel has a real width (see doLayout) so a pass can never + // "stabilize" the transcript against a zero-width measurement. + if (width <= 0) { + pendingReflow = turnViews.isNotEmpty() + return false + } + val before = preferredSize.height + (layout as? SessionLayout)?.forgetAll() + revalidate() + doLayout() + val after = preferredSize.height + repaint() + return after != before + } + // ------ private event handlers ------ private fun onTurnAdded(turn: ai.kilocode.client.session.model.Turn) { @@ -331,6 +374,7 @@ class SessionMessageListPanel( syncReverting(model.state) banner?.update() anchorFooter() + scheduleReflow() refresh() } @@ -344,6 +388,8 @@ class SessionMessageListPanel( } private fun clear() { + seq++ + stable = -1 clearHover() turnViews.values.forEach { remove(it) @@ -360,6 +406,7 @@ class SessionMessageListPanel( syncReverting(model.state) banner?.update() anchorFooter() + scheduleReflow() refresh() } @@ -473,6 +520,53 @@ class SessionMessageListPanel( repaint() } + private fun scheduleReflow() { + if (dead) return + if (turnViews.isEmpty()) { + pendingReflow = false + return + } + stable = -1 + val id = ++seq + ApplicationManager.getApplication().invokeLater { + reflowPass(id, REFLOW_PASSES, REFLOW_BUDGET) + } + } + + @RequiresEdt + private fun reflowPass(id: Int, remaining: Int, budget: Int) { + if (dead || id != seq) return + if (turnViews.isEmpty()) return + if (width <= 0) { + // Not laid out yet. Stop polling and let doLayout re-arm once a real width arrives, + // rather than draining the pass budget against a zero-width height. + pendingReflow = true + return + } + val changed = reflow() + if (changed) onReflow?.invoke(true) + // [remaining] restarts while the height is still settling so the chain keeps re-measuring + // until it holds steady for REFLOW_PASSES consecutive passes. [budget] never resets and is + // the hard backstop that guarantees termination. See below for why both are needed. + if (remaining <= 0 || budget <= 0) { + stable = -1 + return + } + val height = preferredSize.height + // A moving height only means the layout is still settling when nothing is streaming in. While + // [SessionState.Busy] deltas land every EDT cycle, so restarting the settle window on each one + // was the runaway that pinned the panel in a perpetual forgetAll()/re-measure loop — count the + // pass down instead so streaming settles in REFLOW_PASSES and hands off to the per-turn + // forgetTurn path. Every other state (idle, awaiting-permission/question, retry, offline — + // which recoverPending() can seed right after load) has no deltas arriving, so a moving height + // is genuine convergence and must keep restarting; [budget] caps that if a pane never settles. + val left = if (height == stable || model.state is SessionState.Busy) remaining - 1 else REFLOW_PASSES + stable = height + ApplicationManager.getApplication().invokeLater { + reflowPass(id, left, budget - 1) + } + } + /** * Handle a content mutation that changed an already-rendered message: sync the turn's copy * toolbars, forget its cached height, then relayout. [forgetTurn] is essential when the update @@ -522,10 +616,14 @@ class SessionMessageListPanel( login?.applyStyle(style) banner?.applyStyle(style) progress.applyStyle(style) + reflow() refresh() } override fun dispose() { + dead = true + seq++ + pendingReflow = false clearHover() question?.hideView() permission?.hideView() @@ -539,6 +637,16 @@ class SessionMessageListPanel( msgToView.clear() revertingMessage = null onHover = null + onReflow = null removeAll() } + + private companion object { + const val REFLOW_PASSES = 6 + + // Hard ceiling on total reflow passes per schedule, independent of height stability. Lets the + // layout settle across several height changes (HTML panes reflow asynchronously) while capping + // the work a streaming session can trigger, since its height never stabilizes. + const val REFLOW_BUDGET = REFLOW_PASSES * 4 + } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt index 9a4fd04d14c..ba7c2a03530 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt @@ -509,6 +509,8 @@ class PromptPanel( strip.clear() syncEditorHeight() syncHighlights() + syncButton() + syncTooltip() } @RequiresEdt @@ -737,6 +739,8 @@ class PromptPanel( strip.add(item) LOG.debug { "kind=prompt-attachment add name=${item.name} mime=${item.mime} count=${attachments.size}" } syncEditorHeight() + syncButton() + syncTooltip() onChange() } @@ -745,6 +749,8 @@ class PromptPanel( if (!attachments.removeIf { it.id == item.id }) return strip.remove(item) syncEditorHeight() + syncButton() + syncTooltip() onChange() } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionEditorStyle.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionEditorStyle.kt index d426ef01f0e..1a71b24c65a 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionEditorStyle.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionEditorStyle.kt @@ -5,6 +5,7 @@ import com.intellij.ide.ui.UISettingsUtils import com.intellij.openapi.editor.colors.EditorColorsManager import com.intellij.openapi.editor.colors.EditorColorsScheme import com.intellij.openapi.editor.ex.EditorEx +import com.intellij.openapi.util.Key import com.intellij.ui.EditorTextField import com.intellij.util.ui.JBFont import com.intellij.util.ui.JBUI @@ -46,8 +47,14 @@ data class SessionEditorStyle( fun applyToEditor(editor: EditorEx) { try { if (editor.isDisposed) return + // setColorsScheme always runs a full reinitSettings (gutter annotation sizing walks every + // document line), so skip it when this exact style snapshot was already applied to this + // editor. Snapshots are shared per session and recreated only on a theme change, so an + // identity check is enough and avoids repeated O(lines) reinit on redundant applyStyle. + if (editor.getUserData(APPLIED) === this) return editor.setColorsScheme(editorScheme) editor.setFontSize(editorSize) + editor.putUserData(APPLIED, this) } catch (err: RuntimeException) { if (err.javaClass.name != "com.intellij.openapi.util.TraceableDisposable\$DisposalException") throw err } @@ -96,6 +103,9 @@ data class SessionEditorStyle( } companion object { + /** Marks the last style snapshot applied to an editor so [applyToEditor] can skip redundant reinit. */ + private val APPLIED = Key.create("kilo.session.editor.style") + /** Builds a style snapshot from the current global editor color scheme. */ fun current(): SessionEditorStyle { val scheme = EditorColorsManager.getInstance().globalScheme 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 057fecfd7d8..82cdcd40321 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 @@ -199,6 +199,15 @@ object SessionUiStyle { const val DIFF_LINES = 20 const val PREVIEW_LIMIT = 20_000 + /** + * Total unified-diff line count above which the hover popup and inline body stop building + * embedded editors and show an "open in a diff tab" placeholder instead. Each embedded + * editor holds the whole diff document, and reinitializing it walks every line on the EDT, + * so an uncapped large diff freezes the UI. Above this the platform diff viewer (which + * streams file diffs on background threads) handles it. + */ + const val DIFF_MAX_LINES = 2_000 + fun pending(): Color = UiStyle.Colors.weak() fun running(): Color = UiStyle.Colors.fg() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/DiffOverflow.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/DiffOverflow.kt new file mode 100644 index 00000000000..273b36d59a9 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/DiffOverflow.kt @@ -0,0 +1,76 @@ +package ai.kilocode.client.session.views.tool + +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.session.model.Tool +import ai.kilocode.client.session.ui.style.SessionEditorStyle +import ai.kilocode.client.ui.UiStyle +import ai.kilocode.client.ui.layout.Stack +import com.intellij.openapi.Disposable +import com.intellij.ui.EditorTextField +import com.intellij.ui.HyperlinkLabel +import com.intellij.ui.components.JBLabel +import com.intellij.util.concurrency.annotations.RequiresEdt +import com.intellij.util.ui.JBUI +import java.awt.Component +import javax.swing.JComponent + +/** + * Placeholder shown in place of an embedded diff editor when a diff exceeds + * [ai.kilocode.client.session.ui.style.SessionUiStyle.View.Tool.DIFF_MAX_LINES]. Building an editor + * for such a diff walks every line on the EDT (gutter reinit) and freezes the UI, so the popup and + * inline body defer to the platform diff tab, which streams file diffs on background threads. + */ +@RequiresEdt +internal fun diffOverflowPanel(open: () -> Unit): JComponent { + val message = JBLabel(KiloBundle.message("diff.overflow.message")).apply { + foreground = UiStyle.Colors.weak() + } + val link = HyperlinkLabel(KiloBundle.message("diff.overflow.open")).apply { + addHyperlinkListener { open() } + } + val body = Stack.vertical(gap = UiStyle.Gap.sm()) + .next(message) + .next(link) + return JBUI.Panels.simplePanel(body).apply { + isOpaque = false + border = JBUI.Borders.empty(UiStyle.Gap.pad()) + } +} + +/** + * [EditBody] that renders the large-diff placeholder for a single-file edit whose diff is too large + * to preview inline or in a hover popup. Multi-file diffs are capped inside [PatchBody] directly, so + * this only covers the single-file edit/write case that [PatchBody] cannot render. + */ +internal class OverflowBody : EditBody { + override var parent: Disposable? = null + override var overflow: (() -> Unit)? = null + private var root: JComponent? = null + + @RequiresEdt + override fun mount(tool: Tool): JComponent { + root?.let { return it } + val open = overflow ?: {} + return diffOverflowPanel(open).also { root = it } + } + + @RequiresEdt override fun created(): Boolean = root != null + + @RequiresEdt override fun panel(): JComponent? = root + + @RequiresEdt override fun attached(host: Component): Boolean = root?.parent === host + + // The placeholder text is fixed once shown; a diff that crosses back under the cap swaps this body + // out for a real one via EditToolView.swapBody, so no in-place update is needed here. + @RequiresEdt override fun update(tool: Tool): Boolean = false + + @RequiresEdt override fun applyStyle(style: SessionEditorStyle): Boolean = false + + @RequiresEdt override fun markdown(): String? = null + + @RequiresEdt override fun codeEditors(): List = emptyList() + + @RequiresEdt override fun disposeBody() { + root = null + } +} 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 7ef7ec47491..77fb712763a 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 @@ -53,7 +53,7 @@ class EditToolView( private var item = tool private var style = SessionEditorStyle.current() - private var multi = editFiles(tool).size > 1 + private var kind = bodyKind(tool) private var opener: SessionDiffOpener = { _, _, _ -> } private var sessionId: String? = null private var canDiff = false @@ -70,6 +70,7 @@ class EditToolView( init { body.parent = this + body.overflow = ::openDiffViewer // 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) @@ -142,16 +143,19 @@ class EditToolView( if (changed) refresh() } - /** Rebuild the body delegate when a streaming tool crosses the single/multi-file boundary. */ + /** Rebuild the body delegate when a streaming tool crosses a single/multi/overflow boundary. */ @RequiresEdt private fun swapBody(): Boolean { - val next = editFiles(item).size > 1 - if (next == multi) return false - multi = next + val next = bodyKind(item) + if (next == kind) return false + kind = next val expanded = isExpanded() discardBody() body.disposeBody() - body = editBody(item, selection, openFile).also { it.parent = this } + body = editBody(item, selection, openFile).also { + it.parent = this + it.overflow = ::openDiffViewer + } if (expanded) expand() return true } @@ -273,7 +277,10 @@ class EditToolView( @RequiresEdt private fun buildPopupBody(): HeaderPopupBody { val owner = Disposer.newDisposable("Edit popup body") - val popup = popupBody(item, selection, openFile).also { it.parent = owner } + val popup = popupBody(item, selection, openFile).also { + it.parent = owner + it.overflow = ::openDiffViewer + } // mount() already renders the current item (ToolMarkdownBody.mount calls update; PatchBody.mount // calls rebuild and sets its signature), so a follow-up update() here would be a no-op. val panel = popup.mount(item) @@ -302,12 +309,34 @@ private fun diffTitle(tool: Tool): String = // (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. */ +/** + * Which body to build for the current diff. [PatchBody] renders (and self-caps) multi-file patches; + * [OverflowBody] shows the "open in a diff tab" placeholder for a single-file diff too large to + * preview; [ToolMarkdownBody] renders a normal single-file diff. Multi-file overflow stays [PATCH] + * because [PatchBody] caps itself internally. + */ +private enum class BodyKind { SINGLE, PATCH, OVERFLOW } + +private fun bodyKind(tool: Tool): BodyKind { + if (editFiles(tool).size > 1) return BodyKind.PATCH + if (patchLineCount(editDiff(tool)) > SessionUiStyle.View.Tool.DIFF_MAX_LINES) return BodyKind.OVERFLOW + return BodyKind.SINGLE +} + +/** Picks the multi-file patch body, the large-diff placeholder, or the single-file diff. */ private fun editBody(tool: Tool, selection: SessionSelection?, openFile: SessionFileOpener): EditBody = - if (editFiles(tool).size > 1) PatchBody(selection, openFile) else diffBody(selection) + when (bodyKind(tool)) { + BodyKind.PATCH -> PatchBody(selection, openFile) + BodyKind.OVERFLOW -> OverflowBody() + BodyKind.SINGLE -> diffBody(selection) + } private fun popupBody(tool: Tool, selection: SessionSelection?, openFile: SessionFileOpener): EditBody = - if (editFiles(tool).size > 1) PatchBody(selection, openFile, POPUP_OPTS) else popupDiffBody(selection) + when (bodyKind(tool)) { + BodyKind.PATCH -> PatchBody(selection, openFile, POPUP_OPTS) + BodyKind.OVERFLOW -> OverflowBody() + BodyKind.SINGLE -> popupDiffBody(selection) + } private fun diffBody(selection: SessionSelection?) = ToolMarkdownBody( 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 fffdcc879de..98c0fd67bbc 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 @@ -35,6 +35,13 @@ import javax.swing.ScrollPaneConstants interface EditBody { var parent: Disposable? + /** + * When set and the diff exceeds [SessionUiStyle.View.Tool.DIFF_MAX_LINES], the body renders an + * "open in a diff tab" placeholder instead of building embedded editors, and invokes this to open + * the full diff in a background-backed tab. Null leaves the body uncapped (non-diff bodies). + */ + var overflow: (() -> Unit)? + @RequiresEdt fun mount(tool: Tool): JComponent @RequiresEdt fun created(): Boolean @RequiresEdt fun panel(): JComponent? @@ -58,6 +65,7 @@ class PatchBody( private val opts: MdCodeBlockOptions = DIFF_OPTS, ) : EditBody { override var parent: Disposable? = null + override var overflow: (() -> Unit)? = null private var root: Stack? = null private var owner: Disposable? = null @@ -144,19 +152,26 @@ class PatchBody( val disposable = Disposer.newDisposable("Patch body") Disposer.register(parent, disposable) owner = disposable - 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()) - val md = MdViewFactory.create(style, selection, MdCodeBlockFactory.default(opts)) - 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) + val open = overflow + if (open != null && patchLineCount(files) > SessionUiStyle.View.Tool.DIFF_MAX_LINES) { + // Building one editor per file for a very large aggregate diff walks every line on the EDT + // (gutter reinit) and freezes; defer to the diff tab, which streams diffs off the EDT. + panel.next(diffOverflowPanel(open)) + } else { + 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()) + val md = MdViewFactory.create(style, selection, MdCodeBlockFactory.default(opts)) + 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) + } } signature = signatureOf(files) panel.revalidate() 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 328bd37fc80..24ec73db2ad 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 @@ -38,6 +38,10 @@ class ToolMarkdownBody( private val chrome: (MdView) -> Unit = {}, ) : EditBody { override var parent: Disposable? = null + + // Single-file diffs over the cap are routed to OverflowBody by EditToolView, and non-diff bodies + // (shell/read) are never capped, so this body itself never renders the overflow placeholder. + override var overflow: (() -> Unit)? = null private var view: MdView? = null private var item: Tool? = null 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 b9a2d2b23ac..a17b164e31d 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 @@ -777,6 +777,16 @@ internal data class EditFileChange( val patch: String, ) +/** + * Cheap upper-bound line count of a unified patch, used to gate large-diff rendering before any + * editor is built. Counts raw patch lines (including hunk/file headers) so it slightly over-counts + * the rendered body — a conservative gate is fine, and it avoids parsing the diff twice. + */ +internal fun patchLineCount(patch: String): Int = if (patch.isEmpty()) 0 else patch.count { it == '\n' } + 1 + +/** Total diff line count across the files touched by a multi-file apply_patch. */ +internal fun patchLineCount(files: List): Int = files.sumOf { patchLineCount(it.patch) } + /** Per-file changes from an apply_patch tool; empty for single-file edit/write tools (`filediff`). */ internal fun editFiles(tool: Tool): List = parseJsonArray(tool.metadata["files"])?.mapNotNull { element -> diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/LayeredOverlayPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/LayeredOverlayPanel.kt index 1ff400346b1..d2cdf91b37c 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/LayeredOverlayPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/LayeredOverlayPanel.kt @@ -4,7 +4,6 @@ import ai.kilocode.client.ui.layout.HAlign import ai.kilocode.client.ui.layout.VAlign import ai.kilocode.client.ui.layout.align import com.intellij.util.concurrency.annotations.RequiresEdt -import com.intellij.util.ui.JBDimension import com.intellij.util.ui.components.BorderLayoutPanel import java.awt.BorderLayout import java.awt.Container @@ -89,7 +88,7 @@ open class LayeredOverlayPanel( override fun getPreferredSize(): Dimension { val w = listOf(content, overlay).maxOfOrNull { it.preferredSize.width } ?: 0 val h = listOf(content, overlay).maxOfOrNull { it.preferredSize.height } ?: 0 - return JBDimension(w, h) + return Dimension(w, h) } open class Overlay : BorderLayoutPanel() { @@ -124,7 +123,7 @@ open class LayeredOverlayPanel( val pref = super.getPreferredSize() val w = maxOf(pref.width, components.maxOfOrNull { it.preferredSize.width } ?: 0) val h = maxOf(pref.height, components.maxOfOrNull { it.preferredSize.height } ?: 0) - return JBDimension(w, h) + return Dimension(w, h) } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdViewHybrid.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdViewHybrid.kt index 3453cad9153..82310c6cd7e 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdViewHybrid.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdViewHybrid.kt @@ -123,7 +123,11 @@ internal open class MdViewHybrid( override fun applyStyle(style: SessionEditorStyle) { if (disposed) return this.style = style - selection?.applyStyle(style) + // Selection colors are a session-wide concern applied once by SessionUi.applyStyle via the + // shared SessionSelection. Re-applying them here would re-run setColorsScheme on every editor + // registered across the whole transcript each time any single block is styled (a popup build, + // an inline expand, a streaming delta), which triggers a full gutter reinit per editor and can + // freeze the EDT. This view's own editors are styled by syncStyle() below. syncStyle() } 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 a6b91eccd0d..56e2bbb3ef6 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -146,6 +146,8 @@ diff.editor.openFile=Open File diff.editor.refresh=Refresh diff.editor.tree.expandAll=Expand All diff.editor.tree.collapseAll=Collapse All +diff.overflow.message=This diff is too large to preview here. +diff.overflow.open=Open in a diff tab session.part.tool.copy=Copy session.part.tool.openDiff=Open in Diff Viewer session.part.tool.error=Error 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 823c2ece242..7bf4d3c12bd 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,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.diff.contents.DiffContent +import com.intellij.diff.contents.DocumentContent +import com.intellij.diff.requests.SimpleDiffRequest import com.intellij.openapi.actionSystem.ActionToolbar import com.intellij.openapi.actionSystem.Separator import com.intellij.openapi.Disposable @@ -124,6 +127,59 @@ class KiloDiffEditorContentTest : BasePlatformTestCase() { } } + fun `test folder badge shows only while collapsed`() { + val parent = Disposer.newDisposable() + try { + val view = view(files(), parent) + val tree = components(view).filterIsInstance().single() + val folder = folder(tree) + + tree.expandPath(TreePath(folder.path)) + assertFalse("expanded folder hides its rolled-up badge", rowBadge(renderer(tree, folder)).isVisible) + + tree.collapsePath(TreePath(folder.path)) + assertTrue("collapsed folder shows its rolled-up badge", rowBadge(renderer(tree, folder)).isVisible) + } finally { + Disposer.dispose(parent) + } + } + + fun `test folder row width tracks its badge visibility`() { + val parent = Disposer.newDisposable() + try { + val view = view(files(), parent) + val tree = components(view).filterIsInstance().single() + val path = TreePath(folder(tree).path) + + tree.expandPath(path) + val expanded = tree.getPathBounds(path)!!.width + + // Collapsing re-shows the rolled-up badge, so the folder row's measured width must grow. + // This expansion-dependent width is why buildFileTree invalidates JTree's layout cache on + // toggle: a displayed tree caches path bounds across expand/collapse and would otherwise + // paint the row at its stale narrower width, squeezing the name. + tree.collapsePath(path) + val collapsed = tree.getPathBounds(path)!!.width + + assertTrue("collapsed folder row must be wider to fit its badge", collapsed > expanded) + } finally { + Disposer.dispose(parent) + } + } + + fun `test leaf badge stays visible while its folder is expanded`() { + val parent = Disposer.newDisposable() + try { + val view = view(files(), parent) + val tree = components(view).filterIsInstance().single() + tree.expandPath(TreePath(folder(tree).path)) + + assertTrue(rowBadge(renderer(tree, leaf(tree))).isVisible) + } finally { + Disposer.dispose(parent) + } + } + fun `test row badge hidden when node has no changes`() { val parent = Disposer.newDisposable() try { @@ -218,6 +274,74 @@ class KiloDiffEditorContentTest : BasePlatformTestCase() { assertEquals("src/App.kt (feature/test)", request.title) } + fun `test diff request shows placeholder for blank added patch`() { + val request = diffRequest(project, file("src/New.kt", 1, 0, patch = "", status = "added")) as SimpleDiffRequest + val contents = request.contents.map(::content) + + assertEquals("", contents[0]) + assertEquals(KiloBundle.message("diff.editor.patch.unavailable"), contents[1]) + } + + fun `test diff request reconstructs added patch content`() { + val patch = "--- src/New.kt\n+++ src/New.kt\n@@ -0,0 +1,2 @@\n+hello\n+world" + val request = diffRequest(project, file("src/New.kt", 2, 0, patch = patch, status = "added")) as SimpleDiffRequest + val contents = request.contents.map(::content) + + assertEquals("", contents[0]) + assertEquals("hello\nworld", contents[1]) + } + + fun `test tree displays absolute files relative to workspace`() { + val parent = Disposer.newDisposable() + try { + val dir = project.basePath.orEmpty() + val view = view(listOf(file("$dir/pkg/ui/list/ActiveListRenderer.kt", 4, 1)), parent, dir) + val tree = components(view).filterIsInstance().single() + val root = tree.model.root as DefaultMutableTreeNode + val top = root.getChildAt(0) as DefaultMutableTreeNode + val leaf = top.getChildAt(0) as DefaultMutableTreeNode + + assertEquals("pkg/ui/list", text(tree, top)) + assertEquals("ActiveListRenderer.kt", text(tree, leaf)) + assertEquals(2, tree.rowCount) + } finally { + Disposer.dispose(parent) + } + } + + fun `test tree compacts single-child directory chains`() { + val parent = Disposer.newDisposable() + try { + val view = view(listOf(file("a/b/c/One.kt", 1, 0), file("a/b/c/Two.kt", 2, 0)), parent) + val tree = components(view).filterIsInstance().single() + val root = tree.model.root as DefaultMutableTreeNode + val top = root.getChildAt(0) as DefaultMutableTreeNode + + assertEquals("a/b/c", text(tree, top)) + assertEquals("One.kt", text(tree, top.getChildAt(0) as DefaultMutableTreeNode)) + assertEquals("Two.kt", text(tree, top.getChildAt(1) as DefaultMutableTreeNode)) + assertEquals(3, tree.rowCount) + } finally { + Disposer.dispose(parent) + } + } + + fun `test tree stops compacting at branch`() { + val parent = Disposer.newDisposable() + try { + val view = view(listOf(file("a/b/c/One.kt", 1, 0), file("a/x/Two.kt", 2, 0)), parent) + val tree = components(view).filterIsInstance().single() + val root = tree.model.root as DefaultMutableTreeNode + val a = root.getChildAt(0) as DefaultMutableTreeNode + + assertEquals("a", text(tree, a)) + assertEquals("b/c", text(tree, a.getChildAt(0) as DefaultMutableTreeNode)) + assertEquals("x", text(tree, a.getChildAt(1) as DefaultMutableTreeNode)) + } finally { + Disposer.dispose(parent) + } + } + fun `test diff params includes inline token`() { val params = diffParams("inline", "/repo", "ses_1", "Session Changes", token = "tool:ses_1:p1") @@ -371,19 +495,32 @@ class KiloDiffEditorContentTest : BasePlatformTestCase() { false, ) - private fun leaf(tree: Tree): DefaultMutableTreeNode { + private fun folder(tree: Tree): DefaultMutableTreeNode { val root = tree.model.root as DefaultMutableTreeNode - val src = root.getChildAt(0) as DefaultMutableTreeNode - return src.getChildAt(0) as DefaultMutableTreeNode + return root.getChildAt(0) as DefaultMutableTreeNode } + private fun leaf(tree: Tree): DefaultMutableTreeNode = + folder(tree).getChildAt(0) as DefaultMutableTreeNode + 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 text(tree: Tree, node: DefaultMutableTreeNode): String { + val row = renderer(tree, node) + val text = components(row).filterIsInstance().single() + val iter = text.iterator() + if (!iter.hasNext()) return "" + iter.next() + return iter.fragment + } + + private fun content(content: DiffContent): String = (content as? DocumentContent)?.document?.text.orEmpty() + + private fun view(files: List, parent: Disposable, dir: String = project.basePath.orEmpty()): Component = editor(files, parent, dir).component private fun editor( files: List, 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 0eca7aa10ef..045b139dc61 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 @@ -6,6 +6,8 @@ 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.EditorTextField +import com.intellij.ui.HyperlinkLabel import com.intellij.ui.components.JBLabel import com.intellij.util.ui.UIUtil import java.awt.Component @@ -86,6 +88,34 @@ class ModifiedFilesViewTest : BasePlatformTestCase() { assertEquals("Changed files", titles.single()) } + fun `test large changes set shows overflow placeholder instead of editors`() { + val fired = mutableListOf>() + view.setDiffOpener({ files, _, _ -> fired.add(files) }, "ses", "turn") + view.setDiffs(listOf(file("src/A.kt", 2100, 0, bigPatch(2100)))) + + view.toggle() + + assertTrue(view.isExpanded()) + assertTrue(components(view).filterIsInstance().isEmpty()) + components(view).filterIsInstance().single().doClick() + assertEquals(1, fired.single().size) + } + + fun `test large changes popup defers to the diff tab`() { + val fired = mutableListOf>() + view.setDiffOpener({ files, _, _ -> fired.add(files) }, "ses", "turn") + view.setDiffs(listOf(file("src/A.kt", 2100, 0, bigPatch(2100)))) + val body = view.headerPopup()!!.build() + + try { + assertTrue(components(body.component).filterIsInstance().isEmpty()) + components(body.component).filterIsInstance().single().doClick() + assertEquals(1, fired.single().size) + } finally { + Disposer.dispose(body.disposable) + } + } + fun `test dispose releases created editors`() { val base = EditorFactory.getInstance().allEditors.size view.setDiffs(listOf(file("src/A.kt", 2, 1, PATCH))) @@ -121,6 +151,14 @@ class ModifiedFilesViewTest : BasePlatformTestCase() { patch = patch, ) + // A patch whose line count clears SessionUiStyle.View.Tool.DIFF_MAX_LINES so the body overflows. + private fun bigPatch(lines: Int): String = buildString { + append("--- a/src/A.kt\n") + append("+++ b/src/A.kt\n") + append("@@ -0,0 +1,").append(lines).append(" @@\n") + repeat(lines) { append("+line").append(it).append('\n') } + } + private companion object { val PATCH = """ diff --git a/src/A.kt b/src/A.kt diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt index 745062e15f5..5d9ecab3331 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt @@ -1070,6 +1070,35 @@ class PromptPanelTest : BasePlatformTestCase() { assertNotSame(AllIcons.Actions.Suspend, panel.buttonForTest().icon) } + fun `test busy attachment changes sync send and stop button state`() { + val item = PromptAttachment("a", "a.png", "image/png", "file:///tmp/a.png") + val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }) + panel.setReady(true) + panel.setBusy(true) + + assertSame(AllIcons.Actions.Suspend, panel.buttonForTest().icon) + + panel.addAttachmentForTest(item) + + assertTrue(panel.isSendEnabled) + assertTrue(panel.isStopEnabled) + assertNotSame(AllIcons.Actions.Suspend, panel.buttonForTest().icon) + + attachmentRemoveButton(panel, item).doClick() + + assertFalse(panel.isSendEnabled) + assertSame(AllIcons.Actions.Suspend, panel.buttonForTest().icon) + + panel.addAttachmentForTest(item) + assertNotSame(AllIcons.Actions.Suspend, panel.buttonForTest().icon) + + panel.clear() + + assertFalse(panel.isSendEnabled) + assertTrue(panel.isStopEnabled) + assertSame(AllIcons.Actions.Suspend, panel.buttonForTest().icon) + } + fun `test auto approve button toggles and updates tooltip`() { val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }) val button = autoApproveButton(panel) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionEditorStyleTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionEditorStyleTest.kt index 6cb46c9a74b..d6b8082ca21 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionEditorStyleTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionEditorStyleTest.kt @@ -136,4 +136,24 @@ class SessionEditorStyleTest : BasePlatformTestCase() { SessionEditorStyle.current().applyTranscriptToEditor(editor) } + + fun `test applyToEditor skips redundant scheme reinit for the same snapshot`() { + val factory = EditorFactory.getInstance() + val editor = factory.createEditor(factory.createDocument("a\nb\nc\n"), project) as EditorEx + try { + val style = SessionEditorStyle.current() + style.applyToEditor(editor) + // setColorsScheme wraps the scheme in a fresh delegate on every call, so an unchanged + // colorsScheme identity proves the redundant second apply was skipped (no reinit). + val applied = editor.colorsScheme + style.applyToEditor(editor) + assertSame(applied, editor.colorsScheme) + + // A different snapshot instance must still re-apply and swap the delegate. + SessionEditorStyle.current().applyToEditor(editor) + assertNotSame(applied, editor.colorsScheme) + } finally { + factory.releaseEditor(editor) + } + } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionLayoutTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionLayoutTest.kt index ed069137883..6e39e42c605 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionLayoutTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionLayoutTest.kt @@ -342,6 +342,25 @@ class SessionLayoutTest : BasePlatformTestCase() { assertEquals(count + 1, child.count) } + fun `test forget all re-measures all valid children`() { + val p = panel(width = 300) + val first = probe(height = 20) + val second = probe(height = 30) + p.add(first) + p.add(second) + p.doLayout() + first.markValid() + second.markValid() + val fCount = first.count + val sCount = second.count + + (p.layout as SessionLayout).forgetAll() + p.doLayout() + + assertEquals(fCount + 1, first.count) + assertEquals(sCount + 1, second.count) + } + // ---- helpers ------ /** A fixed-height JLabel. The width is reported as 0 until layout sets it. */ 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 bec15f93d8f..ab7f800934b 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 @@ -144,6 +144,136 @@ class SessionMessageListPanelTest : BasePlatformTestCase() { assertEquals(right, left) } + fun `test reflow drops cached panel measurements`() { + val child = Growing(20) + panel.add(child, 0) + panel.setSize(600, 400) + layout(panel) + child.markValid() + child.size = 80 + + panel.reflow() + + assertEquals(80, child.height) + } + + fun `test reflow is skipped until the panel has a width`() { + val child = Growing(20) + panel.add(child, 0) + panel.setSize(0, 400) + layout(panel) + child.markValid() + child.size = 80 + + // At zero width an HTML pane collapses to a one-char column, so reflow must report no + // change instead of locking the transcript height in against that bogus measurement. + assertFalse(panel.reflow()) + + // Once the panel is laid out with a real width the child measures to its true height. + panel.setSize(600, 400) + layout(panel) + assertEquals(80, child.height) + } + + fun `test deferred reflow re-arms on first real width layout`() { + val child = Growing(20) + panel.add(child, 0) + // A real turn makes turnViews non-empty, so a zero-width reflow latches pendingReflow instead + // of no-opping the way the turnless zero-width test above does. + model.upsertMessage(msg("u1", "user")) + panel.setSize(0, 400) + layout(panel) + assertFalse(panel.reflow()) + + // The first layout at a real width consumes the parked reflow and schedules a pass. + panel.setSize(600, 400) + panel.doLayout() + + // Simulate an HTML pane that only reports its settled height after the first layout: the child + // stays valid at the same width, so a plain layout keeps the cached height and only the + // re-armed forgetAll() reflow re-measures it. + child.markValid() + child.size = 80 + + // Draining the EDT runs the scheduled reflow. Without the doLayout re-arm nothing is queued + // and the child would stay at its stale cached height. + UIUtil.dispatchAllInvocationEvents() + + assertEquals(80, child.height) + } + + fun `test reflow budget terminates when height never settles`() { + var reflows = 0 + panel.onReflow = { reflows++ } + // loadHistory rebuilds the transcript (and schedules the reflow chain) after wiping existing + // children, so add the ever-growing child afterwards — it grows on every measurement, so the + // idle chain restarts its settle window on every pass. Without the hard budget the invokeLater + // chain would repost forever and this drain would spin; the budget bounds it. + model.loadHistory(listOf(MessageWithPartsDto(msg("u1", "user"), emptyList()))) + panel.add(EverGrowing(), 0) + panel.setSize(600, 400) + + UIUtil.dispatchAllInvocationEvents() + + // Reaching this line proves the chain terminated. The pass count is bounded by the hard + // budget (REFLOW_PASSES * 4), so a regression that reset it alongside `remaining` would + // either hang here or blow past this bound. + assertTrue("reflow passes must be bounded by the budget, was $reflows", reflows in 1..30) + } + + fun `test streaming session settles reflow within the pass window`() { + var reflows = 0 + panel.onReflow = { reflows++ } + // Same ever-growing child, but a streaming (Busy) session: a moving height is incoming + // content, not the layout still settling, so the chain must count its passes down and stop + // after REFLOW_PASSES instead of restarting the settle window and draining the full budget + // the idle case relies on. recoverPending()'s non-Busy states (awaiting-permission, retry, + // offline) intentionally keep the idle settle behavior and are not gated here. + model.loadHistory(listOf(MessageWithPartsDto(msg("u1", "user"), emptyList()))) + model.setState(SessionState.Busy("thinking")) + panel.add(EverGrowing(), 0) + panel.setSize(600, 400) + + UIUtil.dispatchAllInvocationEvents() + + // A handful of passes (~REFLOW_PASSES), well short of the idle budget (~25). Dropping or + // inverting the Busy term restarts the window every pass and blows past this bound. + assertTrue("streaming reflow must settle in the pass window, was $reflows", reflows in 1..10) + } + + fun `test non-streaming active state keeps the reflow settle window`() { + var reflows = 0 + panel.onReflow = { reflows++ } + // Retry is isBusy() == true but not SessionState.Busy: no deltas arrive, so a moving height + // means the panes are still settling and the window must keep restarting toward the idle + // budget rather than collapsing to REFLOW_PASSES. recoverPending() can seed this right after + // load, and it is the only case that tells `is SessionState.Busy` apart from `isBusy()`. + model.loadHistory(listOf(MessageWithPartsDto(msg("u1", "user"), emptyList()))) + model.setState(SessionState.Retry("retrying", 1, 0L)) + panel.add(EverGrowing(), 0) + panel.setSize(600, 400) + + UIUtil.dispatchAllInvocationEvents() + + // Reaches the idle budget region (~25), not the streaming window (~7). Reverting the gate to + // isBusy() would collapse this to REFLOW_PASSES and fail here. + assertTrue("non-streaming state must keep re-measuring, was $reflows", reflows in 11..30) + } + + fun `test apply style drops cached panel measurements`() { + val child = Growing(20) + panel.add(child, 0) + panel.setSize(600, 400) + layout(panel) + child.markValid() + child.size = 80 + + panel.applyStyle(SessionEditorStyle.current()) + layout(panel) + + assertEquals(80, child.height) + } + fun `test top level user turns use prompt gap after previous turn`() { model.upsertMessage(msg("u1", "user")) model.updateContent("u1", part("p1", "u1", "text", text = "first")) @@ -1415,4 +1545,31 @@ class SessionMessageListPanelTest : BasePlatformTestCase() { super.addInvalidComponent(invalidComponent) } } + + private class Growing(var size: Int) : JPanel() { + private var valid = false + + override fun isValid() = valid + + override fun invalidate() { + valid = false + super.invalidate() + } + + fun markValid() { + valid = true + } + + override fun getPreferredSize() = java.awt.Dimension(0, size) + } + + /** Reports a taller preferred height on every measurement, so a reflow chain never stabilizes. */ + private class EverGrowing : JPanel() { + private var size = 10 + + override fun getPreferredSize(): java.awt.Dimension { + size += 10 + return java.awt.Dimension(0, size) + } + } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionRootPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionRootPanelTest.kt index 6f8be31660e..74af7d69600 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionRootPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionRootPanelTest.kt @@ -4,6 +4,7 @@ import ai.kilocode.client.ui.UiStyle import com.intellij.icons.AllIcons import com.intellij.testFramework.fixtures.BasePlatformTestCase import com.intellij.ui.components.JBLabel +import com.intellij.ui.scale.JBUIScale import com.intellij.util.ui.JBFont import com.intellij.util.ui.components.BorderLayoutPanel import java.awt.Dimension @@ -58,6 +59,21 @@ class SessionRootPanelTest : BasePlatformTestCase() { assertEquals(Dimension(300, 220), root.preferredSize) } + fun `test root preferred size is not double scaled by user scale factor`() { + val original = JBUIScale.scale(1f) + try { + JBUIScale.setUserScaleFactorForTest(2f) + val root = SessionRootPanel().apply { + content.preferredSize = Dimension(300, 120) + overlay.preferredSize = Dimension(180, 220) + } + + assertEquals(Dimension(300, 220), root.preferredSize) + } finally { + JBUIScale.setUserScaleFactorForTest(original) + } + } + fun `test addOverlay applies callback bounds and delegates child layout`() { val root = SessionRootPanel().apply { setSize(400, 260) 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 c8ac1fe9a2f..45adacf4395 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 @@ -14,6 +14,8 @@ import com.intellij.openapi.diff.DiffColors import com.intellij.openapi.editor.EditorFactory import com.intellij.openapi.util.Disposer import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.ui.EditorTextField +import com.intellij.ui.HyperlinkLabel import com.intellij.ui.components.JBLabel import com.intellij.util.ui.JBUI import com.intellij.util.ui.UIUtil @@ -372,6 +374,59 @@ class EditToolViewTest : BasePlatformTestCase() { assertNull(view.headerPopup()) } + fun `test large single-file edit shows overflow placeholder instead of editors`() { + val fired = mutableListOf>() + val view = track(EditToolView(tool().also { + it.metadata = mapOf("filediff" to fileDiff(2100, 0, bigPatch(2100))) + })) + view.setDiffOpener({ files, _, _ -> fired.add(files) }, "ses") + + view.toggle() + + assertTrue(view.isExpanded()) + // The large diff is not rendered as an embedded editor; a placeholder link opens the diff tab. + assertTrue(view.codeEditors().isEmpty()) + hyperlinks(view).single().doClick() + assertEquals(1, fired.single().size) + // Copy still yields the full diff even though it is not previewed inline. + assertTrue(view.markdown().contains("+line0")) + } + + fun `test large single-file edit popup defers to the diff tab`() { + val fired = mutableListOf>() + val view = track(EditToolView(tool().also { + it.metadata = mapOf("filediff" to fileDiff(2100, 0, bigPatch(2100))) + }, { _, _ -> }, null, { files, _, _ -> fired.add(files) }, "ses")) + val body = view.headerPopup()!!.build() + + try { + assertTrue(editors(body.component).isEmpty()) + hyperlinks(body.component).single().doClick() + assertEquals(1, fired.single().size) + } finally { + Disposer.dispose(body.disposable) + } + } + + fun `test large multi-file patch shows overflow placeholder instead of editors`() { + val fired = mutableListOf>() + val view = track(EditToolView(tool().also { + it.input = emptyMap() + it.metadata = mapOf("files" to filesMeta( + FileChange("src/A.kt", 1100, 0, bigHunk(1100)), + FileChange("src/B.kt", 1100, 0, bigHunk(1100)), + )) + })) + view.setDiffOpener({ files, _, _ -> fired.add(files) }, "ses") + + view.toggle() + + assertTrue(view.isExpanded()) + assertTrue(view.codeEditors().isEmpty()) + hyperlinks(view).single().doClick() + assertEquals(2, fired.single().size) + } + fun `test view factory routes write tools to edit tool view`() { assertTrue(ViewFactory.create(tool(), openFile = { _, _ -> }) is EditToolView) assertTrue(ViewFactory.create(write("write"), openFile = { _, _ -> }) is EditToolView) @@ -450,6 +505,29 @@ class EditToolViewTest : BasePlatformTestCase() { if (child is DiffStatBadge) nested + child else nested } + private fun hyperlinks(root: Container): List = root.components.flatMap { child -> + val nested = if (child is Container) hyperlinks(child) else emptyList() + if (child is HyperlinkLabel) nested + child else nested + } + + private fun editors(root: Container): List = root.components.flatMap { child -> + val nested = if (child is Container) editors(child) else emptyList() + if (child is EditorTextField) nested + child else nested + } + + // Patches whose line count clears SessionUiStyle.View.Tool.DIFF_MAX_LINES so the body overflows. + private fun bigPatch(lines: Int): String = buildString { + append("--- src/App.kt\n") + append("+++ src/App.kt\n") + append("@@ -0,0 +1,").append(lines).append(" @@\n") + repeat(lines) { append("+line").append(it).append('\n') } + } + + private fun bigHunk(lines: Int): String = buildString { + append("@@ -0,0 +1,").append(lines).append(" @@\n") + repeat(lines) { append("+x").append(it).append('\n') } + } + private fun openDiffButton(view: EditToolView): AbstractButton = view.copyToolbar as AbstractButton