fix(jetbrains): align reverted diff action

This commit is contained in:
kirillk
2026-08-03 14:53:48 -04:00
parent d5e19d5b36
commit c47cfeceeb
28 changed files with 332 additions and 27 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Show reverted-card diff actions inline with the session header and open rolled-back changes in the diff viewer.
@@ -1516,14 +1516,71 @@ object KiloCliDataParser {
private fun parseRevert(obj: JsonObject?): SessionRevertDto? {
if (obj == null) return null
val message = obj.str("messageID") ?: return null
val diff = obj.str("diff")
return SessionRevertDto(
messageID = message,
partID = obj.str("partID"),
snapshot = obj.str("snapshot"),
diff = obj.str("diff"),
diff = diff,
diffs = parseUnifiedDiff(diff),
)
}
private fun parseUnifiedDiff(diff: String?): List<DiffFileDto> {
if (diff.isNullOrBlank()) return emptyList()
val lines = diff.lines()
val starts = lines.mapIndexedNotNull { index, line -> if (line.startsWith("diff --git ")) index else null }
if (starts.isEmpty()) return emptyList()
return starts.mapIndexedNotNull { index, start ->
val end = starts.getOrNull(index + 1) ?: lines.size
parseUnifiedBlock(lines.subList(start, end).joinToString("\n"))
}
}
private fun parseUnifiedBlock(block: String): DiffFileDto? {
val lines = block.lines()
val file = unifiedFile(lines) ?: return null
return DiffFileDto(
file = file,
additions = lines.count { it.startsWith("+") && !it.startsWith("+++") },
deletions = lines.count { it.startsWith("-") && !it.startsWith("---") },
patch = block,
status = unifiedStatus(lines),
)
}
private fun unifiedFile(lines: List<String>): String? {
val next = lines.firstOrNull { it.startsWith("+++ ") }?.removePrefix("+++ ")
val prev = lines.firstOrNull { it.startsWith("--- ") }?.removePrefix("--- ")
val path = sequenceOf(next, prev)
.filterNotNull()
.firstOrNull { it != "/dev/null" }
?: lines.firstOrNull()?.let(::gitDiffTarget)
return path?.let(::cleanDiffPath)
}
private fun unifiedStatus(lines: List<String>): String = when {
lines.any { it == "new file mode" || it.startsWith("new file mode ") } -> "added"
lines.any { it == "deleted file mode" || it.startsWith("deleted file mode ") } -> "deleted"
lines.any { it.startsWith("--- /dev/null") } -> "added"
lines.any { it.startsWith("+++ /dev/null") } -> "deleted"
else -> "modified"
}
private fun gitDiffTarget(line: String): String? {
val match = Regex("^diff --git a/(.*) b/(.*)$").find(line) ?: return null
return match.groupValues.getOrNull(2)
}
private fun cleanDiffPath(path: String): String {
val text = path.trim().trim('"')
return when {
text.startsWith("a/") -> text.removePrefix("a/")
text.startsWith("b/") -> text.removePrefix("b/")
else -> text
}
}
// ================================================================
// Internal — status parsing
// ================================================================
@@ -691,7 +691,7 @@ class KiloCliDataParserTest {
"messageID": "msg_rollback",
"partID": "prt_rollback",
"snapshot": "snap_rollback",
"diff": "diff --git a/file b/file"
"diff": "diff --git a/src/A.kt b/src/A.kt\n--- a/src/A.kt\n+++ b/src/A.kt\n@@ -1 +1,2 @@\n-old\n+new\n+more\ndiff --git a/src/Old.kt b/src/Old.kt\ndeleted file mode 100644\n--- a/src/Old.kt\n+++ /dev/null\n@@ -1 +0,0 @@\n-gone"
}
}
}
@@ -705,6 +705,13 @@ class KiloCliDataParserTest {
assertEquals(2, result.session.summary?.files)
assertEquals("msg_rollback", result.session.revert?.messageID)
assertEquals("prt_rollback", result.session.revert?.partID)
assertEquals(2, result.session.revert?.diffs?.size)
assertEquals("src/A.kt", result.session.revert?.diffs?.get(0)?.file)
assertEquals(2, result.session.revert?.diffs?.get(0)?.additions)
assertEquals(1, result.session.revert?.diffs?.get(0)?.deletions)
assertEquals("modified", result.session.revert?.diffs?.get(0)?.status)
assertEquals("src/Old.kt", result.session.revert?.diffs?.get(1)?.file)
assertEquals("deleted", result.session.revert?.diffs?.get(1)?.status)
}
@Test
@@ -1,22 +1,32 @@
package ai.kilocode.client.session.ui
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.session.SessionDiffOpener
import ai.kilocode.client.session.model.SessionModel
import ai.kilocode.client.session.model.SessionState
import ai.kilocode.client.session.ui.style.SessionEditorStyle
import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget
import ai.kilocode.client.session.views.SessionViewIcons
import ai.kilocode.client.session.views.base.BaseQuestionView
import ai.kilocode.client.session.views.base.PartHeader
import ai.kilocode.client.ui.DiffStatBadge
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.icons.AllIcons
import com.intellij.ui.components.JBLabel
import com.intellij.ui.components.JBScrollPane
import com.intellij.util.concurrency.annotations.RequiresEdt
import com.intellij.util.ui.JBFont
import com.intellij.util.ui.JBUI
import com.intellij.util.ui.UIUtil
import com.intellij.util.ui.components.BorderLayoutPanel
import java.awt.BorderLayout
import java.awt.Dimension
import javax.swing.JPanel
import javax.swing.ScrollPaneConstants
class RevertBanner(
private val model: SessionModel,
@@ -24,15 +34,54 @@ class RevertBanner(
private val redoAllAction: () -> Unit,
private val cancelAction: () -> Unit,
focus: (() -> Unit)? = null,
private var openDiff: SessionDiffOpener = { _, _, _ -> },
private var sessionId: String? = null,
) : BorderLayoutPanel(), SessionView, SessionEditorStyleTarget {
override val sessionViewKind = SessionView.Kind.Default
companion object {
/** Cap the reverted-file list to this many rows before scrolling. */
const val MAX_FILE_ROWS = 10
}
private val card = BaseQuestionView(focus = focus)
private val title = JBLabel()
private val diff = toolbarButton(
ToolbarButtonAction(SessionViewIcons.openDiff, KiloBundle.message("session.part.tool.openDiff"), ::openDiffViewer),
).apply {
isEnabled = false
isVisible = false
}
private val header = PartHeader().apply {
leading(JBLabel(AllIcons.Actions.Back))
left(title)
left(PartHeader.centered(diff))
}
private val body = Stack.vertical(UiStyle.Gap.lg())
private val files = Stack.vertical(UiStyle.Gap.xs())
private val scroll = object : JBScrollPane(
files,
ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED,
ScrollPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED,
) {
override fun getPreferredSize(): Dimension {
val size = super.getPreferredSize()
val cap = rowCap()
return Dimension(size.width, if (cap > 0) minOf(size.height, cap) else size.height)
}
}.apply {
border = JBUI.Borders.empty()
viewportBorder = JBUI.Borders.empty()
isOpaque = false
viewport.isOpaque = false
}
private val rows = LinkedHashMap<String, Row>()
private var progress: RevertProgress? = null
@@ -46,8 +95,8 @@ class RevertBanner(
init {
isOpaque = false
card.setHeaderIcon(AllIcons.Actions.Back, KiloBundle.message("revert.message.rollback"))
body.next(files).next(hint).next(notice)
card.setTopPanel(header)
body.next(scroll).next(hint).next(notice)
card.setContent(body)
card.setActions(listOf(
BaseQuestionView.Action("redo", KiloBundle.message("revert.banner.redo"), primary = false) { redoAction() },
@@ -58,22 +107,33 @@ class RevertBanner(
update()
}
@RequiresEdt
fun setDiffOpener(openDiff: SessionDiffOpener, sessionId: String?) {
this.openDiff = openDiff
this.sessionId = sessionId
}
@RequiresEdt
fun update() {
val revert = model.revert()
isVisible = revert != null
if (revert == null) return
val total = model.revertedCount()
card.setHeader(KiloBundle.message(if (total == 1) "revert.banner.count.one" else "revert.banner.count.other", total))
title.text = KiloBundle.message(if (total == 1) "revert.banner.count.one" else "revert.banner.count.other", total)
card.setActionVisible("all", total > 1)
notice.isVisible = revert.snapshot == null
val keep = model.diff.mapTo(LinkedHashSet()) { it.file }
val diffs = resolveDiffs(revert)
val names = disambiguate(diffs.map { it.file })
diff.isVisible = diffs.isNotEmpty()
diff.isEnabled = diffs.isNotEmpty()
val keep = diffs.mapTo(LinkedHashSet()) { it.file }
rows.entries.removeIf { it.key !in keep }
val order = model.diff.map { item ->
scroll.isVisible = diffs.isNotEmpty()
val order = diffs.map { item ->
val row = rows.getOrPut(item.file) {
Row(item.file)
Row(item)
}
row.update(item.file, item.additions, item.deletions)
row.update(item, names[item.file] ?: item.file)
row.panel
}
if (files.components.toList() != order) {
@@ -105,15 +165,42 @@ class RevertBanner(
override fun applyStyle(style: SessionEditorStyle) {
card.applyStyle(style)
title.font = style.headerFont
title.foreground = UiStyle.Colors.fg()
progress?.applyStyle(style)
hint.foreground = UIUtil.getLabelForeground()
notice.foreground = UIUtil.getContextHelpForeground()
rows.values.forEach { it.applyStyle() }
}
private class Row(file: String) {
private val label = JBLabel(file)
private val badge = DiffStatBadge(0, 0)
/**
* The rolled-back diff to render. Prefer the snapshot diff the CLI attaches to the revert; fall
* back to the session's current file diff when the pinned CLI doesn't provide it. Empty when no
* snapshot exists (files were not restored, so there is nothing to diff).
*/
private fun resolveDiffs(revert: ai.kilocode.rpc.dto.SessionRevertDto): List<DiffFileDto> =
if (revert.snapshot == null) emptyList() else revert.diffs.ifEmpty { model.diff }
private fun openDiffViewer() {
val revert = model.revert() ?: return
val diffs = resolveDiffs(revert)
if (diffs.isEmpty()) return
openDiff(diffs, KiloBundle.message("revert.banner.openDiff.title"), "revert:${sessionId ?: "pending"}:${revert.messageID}")
}
/** Height that fits at most [MAX_FILE_ROWS] rows, or 0 when the list is short enough to show in full. */
private fun rowCap(): Int {
val comps = files.components
if (comps.size <= MAX_FILE_ROWS) return 0
val gap = UiStyle.Gap.xs()
return (0 until MAX_FILE_ROWS).sumOf { comps[it].preferredSize.height } + gap * (MAX_FILE_ROWS - 1)
}
private class Row(item: DiffFileDto) {
private val label = JBLabel(item.file).apply {
toolTipText = item.file
}
private val badge = DiffStatBadge(item.additions, item.deletions)
val panel: JPanel = Stack.horizontal(UiStyle.Gap.sm())
.next(label)
.next(badge)
@@ -122,9 +209,10 @@ class RevertBanner(
applyStyle()
}
fun update(file: String, additions: Int, deletions: Int) {
if (label.text != file) label.text = file
badge.update(additions, deletions)
fun update(item: DiffFileDto, text: String) {
if (label.text != text) label.text = text
if (label.toolTipText != item.file) label.toolTipText = item.file
badge.update(item.additions, item.deletions)
}
fun applyStyle() {
@@ -132,3 +220,18 @@ class RevertBanner(
}
}
}
internal fun disambiguate(paths: List<String>): Map<String, String> {
val parts = paths.associateWith { split(it) }
return paths.groupBy { parts[it]?.lastOrNull().orEmpty() }.values.flatMap { group ->
if (group.size == 1) return@flatMap listOf(group.first() to (parts[group.first()]?.lastOrNull() ?: group.first()))
val depth = (1..(group.maxOf { parts[it]?.size ?: 1 })).firstOrNull { count ->
group.map { suffix(parts[it].orEmpty(), count) }.toSet().size == group.size
} ?: group.maxOf { parts[it]?.size ?: 1 }
group.map { it to suffix(parts[it].orEmpty(), depth) }
}.toMap()
}
private fun split(path: String): List<String> = path.split('/', '\\').filter { it.isNotEmpty() }
private fun suffix(parts: List<String>, depth: Int): String = parts.takeLast(depth).joinToString("/")
@@ -214,6 +214,7 @@ class SessionMessageListPanel(
fun setDiffOpener(openDiff: SessionDiffOpener, sessionId: String?) {
this.openDiff = openDiff
this.sessionId = sessionId
banner?.setDiffOpener(openDiff, sessionId)
turnViews.values.forEach { it.setDiffOpener(openDiff, sessionId) }
}
@@ -93,7 +93,9 @@ class BaseQuestionView(
}
private val headerText: JBTextArea = makeText("", UiStyle.Colors.fg(), bold = true)
private val descriptionText: JBTextArea = makeText("", UiStyle.Colors.weak(), bold = false)
private val descriptionText: JBTextArea = makeText("", UiStyle.Colors.weak(), bold = false).apply {
isVisible = false
}
private var top: JComponent? = null
private var content: JComponent? = null
@@ -131,6 +133,7 @@ class BaseQuestionView(
fun setHeader(text: String, description: String? = null) {
headerText.text = text
setDescription(description)
syncNorth()
}
/**
@@ -141,6 +144,7 @@ class BaseQuestionView(
fun setDescription(text: String?) {
descriptionText.text = text ?: ""
descriptionText.isVisible = !text.isNullOrBlank()
syncNorth()
}
// ---- public slot API ----
@@ -170,8 +174,7 @@ class BaseQuestionView(
if (icon == null && attached) header.remove(this.icon)
this.icon.revalidate()
this.icon.repaint()
header.revalidate()
header.repaint()
syncNorth()
}
/**
@@ -297,12 +300,14 @@ class BaseQuestionView(
private fun syncNorth() {
north.removeAll()
top?.let { north.next(it) }
north.next(header)
if (hasHeader()) north.next(header)
if (content != null) north.fill(gap)
north.revalidate()
north.repaint()
}
private fun hasHeader() = icon.icon != null || headerText.text.isNotBlank() || descriptionText.isVisible
private fun syncFooter() {
val layout = footer.layout as BorderLayout
val west = layout.getLayoutComponent(BorderLayout.WEST)
@@ -331,7 +336,8 @@ class BaseQuestionView(
private fun makeText(value: String, color: Color, bold: Boolean): JBTextArea {
val area = object : JBTextArea(value) {
override fun getPreferredSize() = withWidth(super.getPreferredSize().height)
override fun getPreferredSize() =
withWidth(super.getPreferredSize().height)
override fun getMaximumSize(): Dimension {
val size = preferredSize
@@ -44,6 +44,7 @@ revert.banner.redo=Redo
revert.banner.redo.all=Redo All
revert.banner.hint=You can redo these changes until you send a new message
revert.banner.filesNotRestored=Snapshots are off - only the conversation was reverted; your files were not changed.
revert.banner.openDiff.title=Rolled back changes
revert.message.rollback=Rollback to this message
session.status.rollingback=Rolling back\u2026
session.status.redoing=Redoing\u2026
@@ -427,6 +427,7 @@ settings.agentBehavior.skills.sources.editPath.title=تعديل مسار الم
settings.agentBehavior.skills.sources.editUrl.title=تعديل URL المهارات
session.file.missing=Couldn''t find ''{0}'' in this repository.
revert.banner.filesNotRestored=Snapshots are off - only the conversation was reverted; your files were not changed.
revert.banner.openDiff.title=Rolled back changes
revert.banner.count.one=تم التراجع عن رسالة واحدة
revert.banner.count.other=تم التراجع عن {0} رسائل
revert.banner.redo=إعادة
@@ -427,6 +427,7 @@ settings.agentBehavior.skills.sources.editPath.title=Uredi putanju vještina
settings.agentBehavior.skills.sources.editUrl.title=Uredi URL vještina
session.file.missing=Couldn''t find ''{0}'' in this repository.
revert.banner.filesNotRestored=Snapshots are off - only the conversation was reverted; your files were not changed.
revert.banner.openDiff.title=Rolled back changes
revert.banner.count.one={0} poruka vraćena
revert.banner.count.other={0} poruka vraćeno
revert.banner.redo=Ponovi
@@ -427,6 +427,7 @@ settings.agentBehavior.skills.sources.editPath.title=Rediger færdighedssti
settings.agentBehavior.skills.sources.editUrl.title=Rediger færdigheds-URL
session.file.missing=Couldn''t find ''{0}'' in this repository.
revert.banner.filesNotRestored=Snapshots are off - only the conversation was reverted; your files were not changed.
revert.banner.openDiff.title=Rolled back changes
revert.banner.count.one={0} besked rullet tilbage
revert.banner.count.other={0} beskeder rullet tilbage
revert.banner.redo=Gentag
@@ -427,6 +427,7 @@ settings.agentBehavior.skills.sources.editPath.title=Skill-Pfad bearbeiten
settings.agentBehavior.skills.sources.editUrl.title=Skill-URL bearbeiten
session.file.missing=Couldn''t find ''{0}'' in this repository.
revert.banner.filesNotRestored=Snapshots are off - only the conversation was reverted; your files were not changed.
revert.banner.openDiff.title=Rolled back changes
revert.banner.count.one={0} Nachricht zurückgesetzt
revert.banner.count.other={0} Nachrichten zurückgesetzt
revert.banner.redo=Wiederholen
@@ -427,6 +427,7 @@ settings.agentBehavior.skills.sources.editPath.title=Editar ruta de habilidades
settings.agentBehavior.skills.sources.editUrl.title=Editar URL de habilidades
session.file.missing=Couldn''t find ''{0}'' in this repository.
revert.banner.filesNotRestored=Snapshots are off - only the conversation was reverted; your files were not changed.
revert.banner.openDiff.title=Rolled back changes
revert.banner.count.one={0} mensaje revertido
revert.banner.count.other={0} mensajes revertidos
revert.banner.redo=Rehacer
@@ -427,6 +427,7 @@ settings.agentBehavior.skills.sources.editPath.title=Modifier le chemin des comp
settings.agentBehavior.skills.sources.editUrl.title=Modifier l’URL des compétences
session.file.missing=Couldn''t find ''{0}'' in this repository.
revert.banner.filesNotRestored=Snapshots are off - only the conversation was reverted; your files were not changed.
revert.banner.openDiff.title=Rolled back changes
revert.banner.count.one={0} message annulé
revert.banner.count.other={0} messages annulés
revert.banner.redo=Rétablir
@@ -427,6 +427,7 @@ settings.agentBehavior.skills.sources.editPath.title=スキルパスを編集
settings.agentBehavior.skills.sources.editUrl.title=スキル URL を編集
session.file.missing=Couldn''t find ''{0}'' in this repository.
revert.banner.filesNotRestored=Snapshots are off - only the conversation was reverted; your files were not changed.
revert.banner.openDiff.title=Rolled back changes
revert.banner.count.one={0} 件のメッセージをロールバックしました
revert.banner.count.other={0} 件のメッセージをロールバックしました
revert.banner.redo=やり直し
@@ -427,6 +427,7 @@ settings.agentBehavior.skills.sources.editPath.title=스킬 경로 편집
settings.agentBehavior.skills.sources.editUrl.title=스킬 URL 편집
session.file.missing=Couldn''t find ''{0}'' in this repository.
revert.banner.filesNotRestored=Snapshots are off - only the conversation was reverted; your files were not changed.
revert.banner.openDiff.title=Rolled back changes
revert.banner.count.one={0}개 메시지가 롤백됨
revert.banner.count.other={0}개 메시지가 롤백됨
revert.banner.redo=다시 실행
@@ -427,6 +427,7 @@ settings.agentBehavior.skills.sources.editPath.title=Vaardigheidspad bewerken
settings.agentBehavior.skills.sources.editUrl.title=Vaardigheids-URL bewerken
session.file.missing=Couldn''t find ''{0}'' in this repository.
revert.banner.filesNotRestored=Snapshots are off - only the conversation was reverted; your files were not changed.
revert.banner.openDiff.title=Rolled back changes
revert.banner.count.one={0} bericht teruggedraaid
revert.banner.count.other={0} berichten teruggedraaid
revert.banner.redo=Opnieuw uitvoeren
@@ -427,6 +427,7 @@ settings.agentBehavior.skills.sources.editPath.title=Rediger ferdighetssti
settings.agentBehavior.skills.sources.editUrl.title=Rediger ferdighets-URL
session.file.missing=Couldn''t find ''{0}'' in this repository.
revert.banner.filesNotRestored=Snapshots are off - only the conversation was reverted; your files were not changed.
revert.banner.openDiff.title=Rolled back changes
revert.banner.count.one={0} melding rullet tilbake
revert.banner.count.other={0} meldinger rullet tilbake
revert.banner.redo=Gjør om
@@ -427,6 +427,7 @@ settings.agentBehavior.skills.sources.editPath.title=Edytuj ścieżkę umiejętn
settings.agentBehavior.skills.sources.editUrl.title=Edytuj URL umiejętności
session.file.missing=Couldn''t find ''{0}'' in this repository.
revert.banner.filesNotRestored=Snapshots are off - only the conversation was reverted; your files were not changed.
revert.banner.openDiff.title=Rolled back changes
revert.banner.count.one=Cofnięto {0} wiadomość
revert.banner.count.other=Cofnięto {0} wiadomości
revert.banner.redo=Ponów
@@ -427,6 +427,7 @@ settings.agentBehavior.skills.sources.editPath.title=Editar caminho de habilidad
settings.agentBehavior.skills.sources.editUrl.title=Editar URL de habilidades
session.file.missing=Couldn''t find ''{0}'' in this repository.
revert.banner.filesNotRestored=Snapshots are off - only the conversation was reverted; your files were not changed.
revert.banner.openDiff.title=Rolled back changes
revert.banner.count.one={0} mensagem revertida
revert.banner.count.other={0} mensagens revertidas
revert.banner.redo=Refazer
@@ -427,6 +427,7 @@ settings.agentBehavior.skills.sources.editPath.title=Изменить путь
settings.agentBehavior.skills.sources.editUrl.title=Изменить URL навыков
session.file.missing=Couldn''t find ''{0}'' in this repository.
revert.banner.filesNotRestored=Snapshots are off - only the conversation was reverted; your files were not changed.
revert.banner.openDiff.title=Rolled back changes
revert.banner.count.one=Отменено сообщений: {0}
revert.banner.count.other=Отменено сообщений: {0}
revert.banner.redo=Повторить
@@ -427,6 +427,7 @@ settings.agentBehavior.skills.sources.editPath.title=แก้ไขเส้น
settings.agentBehavior.skills.sources.editUrl.title=แก้ไข URL ทักษะ
session.file.missing=Couldn''t find ''{0}'' in this repository.
revert.banner.filesNotRestored=Snapshots are off - only the conversation was reverted; your files were not changed.
revert.banner.openDiff.title=Rolled back changes
revert.banner.count.one=ย้อนกลับข้อความ {0} รายการแล้ว
revert.banner.count.other=ย้อนกลับข้อความ {0} รายการแล้ว
revert.banner.redo=ทำซ้ำ
@@ -427,6 +427,7 @@ settings.agentBehavior.skills.sources.editPath.title=Beceri Yolunu Düzenle
settings.agentBehavior.skills.sources.editUrl.title=Beceri URL'sini Düzenle
session.file.missing=Couldn''t find ''{0}'' in this repository.
revert.banner.filesNotRestored=Snapshots are off - only the conversation was reverted; your files were not changed.
revert.banner.openDiff.title=Rolled back changes
revert.banner.count.one={0} mesaj geri alındı
revert.banner.count.other={0} mesaj geri alındı
revert.banner.redo=Yinele
@@ -427,6 +427,7 @@ settings.agentBehavior.skills.sources.editPath.title=Редагувати шля
settings.agentBehavior.skills.sources.editUrl.title=Редагувати URL навичок
session.file.missing=Couldn''t find ''{0}'' in this repository.
revert.banner.filesNotRestored=Snapshots are off - only the conversation was reverted; your files were not changed.
revert.banner.openDiff.title=Rolled back changes
revert.banner.count.one=Відкочено повідомлень: {0}
revert.banner.count.other=Відкочено повідомлень: {0}
revert.banner.redo=Повторити
@@ -427,6 +427,7 @@ settings.agentBehavior.skills.sources.editPath.title=编辑技能路径
settings.agentBehavior.skills.sources.editUrl.title=编辑技能 URL
session.file.missing=Couldn''t find ''{0}'' in this repository.
revert.banner.filesNotRestored=Snapshots are off - only the conversation was reverted; your files were not changed.
revert.banner.openDiff.title=Rolled back changes
revert.banner.count.one=已回滚 {0} 条消息
revert.banner.count.other=已回滚 {0} 条消息
revert.banner.redo=重做
@@ -427,6 +427,7 @@ settings.agentBehavior.skills.sources.editPath.title=編輯技能路徑
settings.agentBehavior.skills.sources.editUrl.title=編輯技能 URL
session.file.missing=Couldn''t find ''{0}'' in this repository.
revert.banner.filesNotRestored=Snapshots are off - only the conversation was reverted; your files were not changed.
revert.banner.openDiff.title=Rolled back changes
revert.banner.count.one=已回復 {0} 則訊息
revert.banner.count.other=已回復 {0} 則訊息
revert.banner.redo=重做
@@ -15,6 +15,7 @@ import ai.kilocode.client.session.ui.style.SessionUiStyle
import ai.kilocode.client.session.views.LoginRequiredView
import ai.kilocode.client.session.views.PlanExitView
import ai.kilocode.client.session.views.base.BaseQuestionView
import ai.kilocode.client.session.views.base.PartHeader
import ai.kilocode.client.session.views.permission.PermissionView
import ai.kilocode.client.session.views.question.QuestionResultView
import ai.kilocode.client.session.views.question.QuestionView
@@ -29,6 +30,7 @@ import ai.kilocode.client.session.views.tool.ToolView
import ai.kilocode.client.session.views.todo.TodoWriteView
import ai.kilocode.client.ui.DiffStatBadge
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.MessageDto
@@ -60,6 +62,7 @@ import javax.swing.JButton
import javax.swing.JComponent
import javax.swing.JPanel
import javax.swing.RepaintManager
import javax.swing.ScrollPaneConstants
import javax.swing.SwingUtilities
import javax.swing.border.Border
@@ -1080,8 +1083,9 @@ class SessionMessageListPanelTest : BasePlatformTestCase() {
banner.update()
assertNotNull(find<BaseQuestionView>(banner))
assertNotNull(components(banner).filterIsInstance<PartHeader>().singleOrNull())
val buttons = components(banner).filterIsInstance<JButton>()
val buttons = components(banner).filterIsInstance<JButton>().filter { it.text.isNotEmpty() }
assertEquals(
listOf(KiloBundle.message("revert.banner.redo"), KiloBundle.message("revert.banner.redo.all")),
buttons.map { it.text },
@@ -1098,7 +1102,7 @@ class SessionMessageListPanelTest : BasePlatformTestCase() {
fun `test rollback banner reuses file rows across updates`() {
val banner = RevertBanner(model, {}, {}, {})
model.upsertMessage(msg("u1", "user"))
model.setRevert(SessionRevertDto("u1"))
model.setRevert(SessionRevertDto("u1", snapshot = "snap1"))
model.setDiff(listOf(DiffFileDto("src/A.kt", 1, 0), DiffFileDto("src/B.kt", 2, 1)))
banner.update()
val rows = components(banner).filterIsInstance<Stack>().filter { stack ->
@@ -1122,6 +1126,102 @@ class SessionMessageListPanelTest : BasePlatformTestCase() {
assertEquals("-2", badges[1].removedLabelForTest().text)
}
fun `test rollback banner caps file list with scroll pane`() {
val banner = RevertBanner(model, {}, {}, {})
model.upsertMessage(msg("u1", "user"))
model.setRevert(SessionRevertDto("u1", snapshot = "snap1"))
model.setDiff((1..80).map { DiffFileDto("src/file-$it.kt", it, 0) })
banner.update()
val scroll = components(banner).filterIsInstance<JBScrollPane>().single()
assertTrue(scroll.verticalScrollBarPolicy == ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED)
assertTrue(scroll.horizontalScrollBarPolicy == ScrollPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED)
val rows = rowLabels(banner).mapNotNull { it.parent }
val rowHeight = rows.first().preferredSize.height
val cap = rowHeight * RevertBanner.MAX_FILE_ROWS + UiStyle.Gap.xs() * (RevertBanner.MAX_FILE_ROWS - 1)
assertEquals(cap, scroll.preferredSize.height)
}
fun `test rollback banner shortens duplicate file names with parents`() {
val banner = RevertBanner(model, {}, {}, {})
model.upsertMessage(msg("u1", "user"))
model.setRevert(SessionRevertDto("u1", snapshot = "snap1"))
model.setDiff(listOf(
DiffFileDto("apps/main/src/App.kt", 1, 0),
DiffFileDto("packages/ui/src/App.kt", 2, 1),
DiffFileDto("packages/ui/src/Button.kt", 3, 0),
))
banner.update()
val labels = rowLabels(banner).map { it.text to it.toolTipText }
assertTrue(labels.contains("main/src/App.kt" to "apps/main/src/App.kt"))
assertTrue(labels.contains("ui/src/App.kt" to "packages/ui/src/App.kt"))
assertTrue(labels.contains("Button.kt" to "packages/ui/src/Button.kt"))
}
fun `test rollback banner opens rolled back diff`() {
val diff = DiffFileDto("src/A.kt", 1, 0, PATCH, "modified")
val opened = mutableListOf<List<DiffFileDto>>()
val titles = mutableListOf<String>()
val keys = mutableListOf<String>()
val banner = RevertBanner(model, {}, {}, {})
banner.setDiffOpener({ files, title, key ->
opened.add(files)
titles.add(title)
keys.add(key)
}, "ses_1")
model.upsertMessage(msg("u1", "user"))
model.setRevert(SessionRevertDto("u1", snapshot = "snap1", diffs = listOf(diff)))
banner.update()
val button = components(banner).filterIsInstance<HoverIcon>()
.first { it.toolTipText == KiloBundle.message("session.part.tool.openDiff") }
assertTrue(button.isVisible)
assertTrue(button.isEnabled)
button.doClick()
assertEquals(listOf(diff), opened.single())
assertEquals(KiloBundle.message("revert.banner.openDiff.title"), titles.single())
assertEquals("revert:ses_1:u1", keys.single())
}
fun `test rollback banner hides open diff without a snapshot`() {
val banner = RevertBanner(model, {}, {}, {})
model.upsertMessage(msg("u1", "user"))
model.setRevert(SessionRevertDto("u1", snapshot = null))
model.setDiff(listOf(DiffFileDto("src/A.kt", 1, 0, PATCH)))
banner.update()
val button = components(banner).filterIsInstance<HoverIcon>()
.first { it.toolTipText == KiloBundle.message("session.part.tool.openDiff") }
assertFalse(button.isVisible)
assertFalse(button.isEnabled)
}
fun `test rollback banner opens session diff when revert diff is absent`() {
val diff = DiffFileDto("src/A.kt", 1, 0, PATCH, "modified")
val opened = mutableListOf<List<DiffFileDto>>()
val banner = RevertBanner(model, {}, {}, {})
banner.setDiffOpener({ files, _, _ -> opened.add(files) }, "ses_1")
model.upsertMessage(msg("u1", "user"))
model.setRevert(SessionRevertDto("u1", snapshot = "snap1"))
model.setDiff(listOf(diff))
banner.update()
val button = components(banner).filterIsInstance<HoverIcon>()
.first { it.toolTipText == KiloBundle.message("session.part.tool.openDiff") }
assertTrue(button.isVisible)
assertTrue(button.isEnabled)
button.doClick()
assertEquals(listOf(diff), opened.single())
}
fun `test rollback banner shows redo all only for multiple reverted messages`() {
val banner = RevertBanner(model, {}, {}, {})
model.upsertMessage(msg("u1", "user"))
@@ -1168,7 +1268,7 @@ class SessionMessageListPanelTest : BasePlatformTestCase() {
banner.setReverting(SessionState.Reverting("Rolling back...", SessionState.Reverting.Kind.ROLLBACK, "u1"))
val buttons = components(banner).filterIsInstance<JButton>()
val buttons = components(banner).filterIsInstance<JButton>().filter { it.text.isNotEmpty() }
assertTrue(buttons.filter { it.text == KiloBundle.message("revert.banner.redo") }.all { !it.isEnabled })
assertTrue(buttons.filter { it.text == KiloBundle.message("revert.banner.redo.all") }.all { !it.isEnabled })
val progress = components(banner).filterIsInstance<RevertProgress>().single()
@@ -1521,6 +1621,11 @@ class SessionMessageListPanelTest : BasePlatformTestCase() {
return out
}
private fun rowLabels(root: Component): List<JBLabel> = components(root)
.filterIsInstance<Stack>()
.filter { stack -> stack.components.any { it is DiffStatBadge } }
.mapNotNull { stack -> components(stack).filterIsInstance<JBLabel>().firstOrNull() }
private fun taskText(view: TaskToolView): List<String> {
val scroll = components(view).filterIsInstance<JBScrollPane>().single()
val stack = components(scroll.viewport.view).filterIsInstance<Stack>().single()
@@ -21,12 +21,11 @@ class BaseQuestionViewTest : BasePlatformTestCase() {
// ------ initial state ------
fun `test header and description text areas are in the component tree by default`() {
fun `test empty card does not render a header row by default`() {
edt {
val panel = BaseQuestionView()
assertTrue("Root layout should be BorderLayout", panel.layout is BorderLayout)
val areas = findAll<JBTextArea>(panel)
assertTrue("Should have at least 2 text areas (header + description)", areas.size >= 2)
assertNull("Header row should be omitted until header content exists", headerRow(panel))
}
}
@@ -87,6 +86,7 @@ class BaseQuestionViewTest : BasePlatformTestCase() {
fun `test setTopPanel adds component before header`() {
edt {
val panel = BaseQuestionView()
panel.setHeader("Title")
val top = JLabel("top")
panel.setTopPanel(top)
@@ -280,9 +280,10 @@ class BaseQuestionViewTest : BasePlatformTestCase() {
}
}
fun `test header row has no west icon gap by default`() {
fun `test header row has no west icon gap without icon`() {
edt {
val panel = BaseQuestionView()
panel.setHeader("Title")
val header = headerRow(panel)!!
val west = (header.layout as BorderLayout).getLayoutComponent(BorderLayout.WEST)
assertNull("header should not reserve icon space when icon is absent", west)
@@ -21,6 +21,7 @@ data class SessionRevertDto(
val partID: String? = null,
val snapshot: String? = null,
val diff: String? = null,
val diffs: List<DiffFileDto> = emptyList(),
)
@Serializable