From bee54755fffd0bcd41f4a6268fb88cb306dd426f Mon Sep 17 00:00:00 2001 From: kirillk Date: Mon, 1 Jun 2026 18:29:22 -0400 Subject: [PATCH] fix(jetbrains): dispose embedded markdown editors --- .../ai/kilocode/client/session/SessionUi.kt | 13 ++++- .../session/ui/SessionMessageListPanel.kt | 24 +++++++- .../client/session/views/MessageView.kt | 21 ++++++- .../client/session/views/PlanExitView.kt | 2 + .../client/session/views/ReasoningView.kt | 2 + .../kilocode/client/session/views/TextView.kt | 2 + .../kilocode/client/session/views/TurnView.kt | 13 ++++- .../client/session/views/base/PartView.kt | 5 +- .../kotlin/ai/kilocode/client/ui/md/MdView.kt | 3 +- .../kilocode/client/ui/md/MdViewHtmlPane.kt | 30 ++++++++++ .../ai/kilocode/client/ui/md/MdViewHybrid.kt | 55 ++++++++++++++++++- .../kilocode/client/ui/md/MdViewHybridTest.kt | 49 +++++++++++++++++ 12 files changed, 209 insertions(+), 10 deletions(-) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt index 194d118a7cd..193d6031864 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 @@ -145,6 +145,7 @@ class SessionUi( private var style = SessionEditorStyle.current() private var editorTheme = style.editorScheme private var colorTheme = UIManager.getLookAndFeel() + private var disposed = false init { buildUi() @@ -158,12 +159,14 @@ class SessionUi( } override fun addNotify() { + if (disposed) return super.addNotify() resumeOpen() } override fun doLayout() { super.doLayout() + if (disposed) return resumeOpen() } @@ -499,6 +502,7 @@ class SessionUi( } private fun onStateChanged(state: SessionState) { + if (disposed) return prompt.setBusy(state.isBusy()) load.setState(state) scroll.setQuestionPending(questionPending(state)) @@ -512,6 +516,7 @@ class SessionUi( } private fun refresh() { + if (disposed) return scroll.refresh() root.revalidate() root.repaint() @@ -549,7 +554,13 @@ class SessionUi( ) } - override fun dispose() {} + override fun dispose() { + disposed = true + modalFocus = null + empty = null + if (this::root.isInitialized) root.setModalContent(null) + removeAll() + } } private fun variantTitle(value: String): String = value.replaceFirstChar { it.titlecase() } 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 f21a2b669bc..60977888672 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 @@ -13,6 +13,7 @@ import ai.kilocode.client.session.views.permission.PermissionView import ai.kilocode.client.session.views.question.QuestionView import ai.kilocode.client.session.views.TurnView import com.intellij.openapi.Disposable +import com.intellij.openapi.util.Disposer import com.intellij.util.ui.JBUI /** @@ -56,7 +57,7 @@ class SessionMessageListPanel( SessionUiStyle.SessionLayout.TRANSCRIPT_PADDING, SessionUiStyle.SessionLayout.TRANSCRIPT_PADDING, ), -), SessionEditorStyleTarget { +), Disposable, SessionEditorStyleTarget { private val turnViews = LinkedHashMap() private val msgToTurn = HashMap() @@ -69,6 +70,7 @@ class SessionMessageListPanel( init { isOpaque = false + Disposer.register(parent, this) model.addListener(parent) { event -> when (event) { @@ -215,11 +217,16 @@ class SessionMessageListPanel( val tv = turnViews.remove(id) ?: return for (msgId in tv.messageIds()) unregister(msgId) remove(tv) + Disposer.dispose(tv) anchorFooter() refresh() } private fun rebuild() { + turnViews.values.forEach { + remove(it) + Disposer.dispose(it) + } turnViews.clear() msgToTurn.clear() msgToView.clear() @@ -242,6 +249,10 @@ class SessionMessageListPanel( } private fun clear() { + turnViews.values.forEach { + remove(it) + Disposer.dispose(it) + } turnViews.clear() msgToTurn.clear() msgToView.clear() @@ -336,4 +347,15 @@ class SessionMessageListPanel( progress.applyStyle(style) refresh() } + + override fun dispose() { + turnViews.values.forEach { + remove(it) + Disposer.dispose(it) + } + turnViews.clear() + msgToTurn.clear() + msgToView.clear() + removeAll() + } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt index efe87c02880..c753a761243 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt @@ -11,6 +11,8 @@ import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget import ai.kilocode.client.session.views.base.PartView import ai.kilocode.client.session.ui.style.SessionUiStyle +import com.intellij.openapi.Disposable +import com.intellij.openapi.util.Disposer import com.intellij.util.ui.JBUI import java.awt.Graphics import java.awt.Graphics2D @@ -34,7 +36,7 @@ class MessageView( private val openUrl: (String) -> Unit = {}, ) : ai.kilocode.client.session.ui.SessionLayoutPanel( JBUI.scale(SessionUiStyle.SessionLayout.GAP), -), SessionEditorStyleTarget, SessionView { +), Disposable, SessionEditorStyleTarget, SessionView { constructor(msg: Message, openFile: (String) -> Unit) : this(msg, openFile, SessionEditorStyle.current()) @@ -84,6 +86,7 @@ class MessageView( val stale = parts.remove(content.id) if (stale != null) { remove(stale) + Disposer.dispose(stale) syncBorder() refresh() } @@ -111,6 +114,7 @@ class MessageView( val at = components.indexOfFirst { it === existing }.takeIf { it >= 0 } ?: componentCount parts.remove(content.id) remove(existing) + Disposer.dispose(existing) val view = view(content) view.applyStyle(style) parts[content.id] = view @@ -123,6 +127,7 @@ class MessageView( fun removePart(contentId: String) { val view = parts.remove(contentId) ?: return remove(view) + Disposer.dispose(view) syncBorder() refresh() } @@ -146,7 +151,10 @@ class MessageView( * Called only when the hidden ref changes to avoid unnecessary rebuilds. */ private fun rebuildParts() { - parts.values.forEach { remove(it) } + parts.values.forEach { + remove(it) + Disposer.dispose(it) + } parts.clear() for ((_, content) in msg.parts) { if (content is StepFinish) continue @@ -194,6 +202,15 @@ class MessageView( refresh() } + override fun dispose() { + parts.values.forEach { + remove(it) + Disposer.dispose(it) + } + parts.clear() + hidden = null + } + override fun paintComponent(g: Graphics) { if (msg.info.role != SessionUiStyle.View.Message.USER_ROLE) { super.paintComponent(g) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PlanExitView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PlanExitView.kt index 3d036f9514c..70afc6fdf5f 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PlanExitView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PlanExitView.kt @@ -7,6 +7,7 @@ import ai.kilocode.client.session.model.ToolExecState import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.views.base.PartView import ai.kilocode.client.ui.md.MdViewFactory +import com.intellij.openapi.util.Disposer import java.awt.BorderLayout class PlanExitView(tool: Tool, openFile: (String) -> Unit) : PartView() { @@ -22,6 +23,7 @@ class PlanExitView(tool: Tool, openFile: (String) -> Unit) : PartView() { init { layout = BorderLayout() isOpaque = false + Disposer.register(this, md) md.addLinkListener { openFile(it.href) } add(md.component, BorderLayout.CENTER) applyStyle(SessionEditorStyle.current()) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ReasoningView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ReasoningView.kt index f32ad1654cd..790fc88b492 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ReasoningView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ReasoningView.kt @@ -12,6 +12,7 @@ import ai.kilocode.client.ui.UiStyle import ai.kilocode.client.ui.md.MdView import ai.kilocode.client.ui.md.MdViewFactory import com.intellij.icons.AllIcons +import com.intellij.openapi.util.Disposer import com.intellij.ui.components.JBLabel import com.intellij.ui.components.JBScrollPane import com.intellij.util.ui.JBUI @@ -39,6 +40,7 @@ class ReasoningView( private var source = reasoning.content.toString() init { + Disposer.register(this, md) bindHeader(parts.title, parts.icon) applyStyle(style) md.opaque = false diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TextView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TextView.kt index facaa4028f5..a002af8415f 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TextView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TextView.kt @@ -6,6 +6,7 @@ import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.views.base.PartView import ai.kilocode.client.ui.md.MdView import ai.kilocode.client.ui.md.MdViewFactory +import com.intellij.openapi.util.Disposer import java.awt.BorderLayout /** @@ -26,6 +27,7 @@ class TextView( init { layout = BorderLayout() isOpaque = false + Disposer.register(this, md) md.opaque = !transparent md.addLinkListener { openUrl(it.href) } applyStyle(SessionEditorStyle.current()) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TurnView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TurnView.kt index 1b1d7283ead..02d8db97b63 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TurnView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TurnView.kt @@ -5,6 +5,8 @@ import ai.kilocode.client.session.ui.SessionLayoutPanel import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget import ai.kilocode.client.session.ui.style.SessionUiStyle +import com.intellij.openapi.Disposable +import com.intellij.openapi.util.Disposer import com.intellij.util.ui.JBUI /** @@ -21,7 +23,7 @@ class TurnView( private val openFile: (String) -> Unit, private var style: SessionEditorStyle = SessionEditorStyle.current(), private val openUrl: (String) -> Unit = {}, -) : SessionLayoutPanel(JBUI.scale(SessionUiStyle.SessionLayout.GAP)), SessionEditorStyleTarget { +) : SessionLayoutPanel(JBUI.scale(SessionUiStyle.SessionLayout.GAP)), Disposable, SessionEditorStyleTarget { constructor(id: String, openFile: (String) -> Unit) : this(id, openFile, SessionEditorStyle.current()) @@ -44,6 +46,7 @@ class TurnView( fun removeMessage(msgId: String) { val view = messages.remove(msgId) ?: return remove(view) + Disposer.dispose(view) revalidate() } @@ -62,4 +65,12 @@ class TurnView( revalidate() repaint() } + + override fun dispose() { + messages.values.forEach { + remove(it) + Disposer.dispose(it) + } + messages.clear() + } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/PartView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/PartView.kt index 9cfacf9b41c..41d4d57853a 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/PartView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/PartView.kt @@ -3,6 +3,7 @@ package ai.kilocode.client.session.views.base import ai.kilocode.client.session.model.Content import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget +import com.intellij.openapi.Disposable import javax.swing.JPanel /** @@ -14,7 +15,7 @@ import javax.swing.JPanel * * All methods must be called on the EDT. */ -abstract class PartView : JPanel(), SessionEditorStyleTarget { +abstract class PartView : JPanel(), Disposable, SessionEditorStyleTarget { /** Stable [Content.id] this renderer was created for. */ abstract val contentId: String @@ -34,6 +35,8 @@ abstract class PartView : JPanel(), SessionEditorStyleTarget { override fun applyStyle(style: SessionEditorStyle) {} + override fun dispose() {} + /** Readable name for test dumps. */ open fun dumpLabel(): String = javaClass.simpleName } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdView.kt index 8f456b01db7..7b49c51d716 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdView.kt @@ -1,13 +1,14 @@ package ai.kilocode.client.ui.md import ai.kilocode.client.session.ui.style.SessionEditorStyle +import com.intellij.openapi.Disposable import java.awt.Color import java.awt.Font import java.awt.Point import javax.swing.JComponent /** Markdown rendering component. All public methods must be called on the EDT. */ -interface MdView { +interface MdView : Disposable { val component: JComponent fun set(text: String) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdViewHtmlPane.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdViewHtmlPane.kt index 22cbe9ce479..7644da58530 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdViewHtmlPane.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdViewHtmlPane.kt @@ -28,6 +28,7 @@ internal class MdViewHtmlPane( private val source = StringBuilder() private var rendered = "" private var style = style + private var disposed = false private val extensions = listOf( AutolinkExtension.create(), @@ -82,6 +83,7 @@ internal class MdViewHtmlPane( override var font: Font get() = fontOverride ?: opts().font set(value) { + if (disposed) return if (fontOverride == value) return fontOverride = value markDirty() @@ -90,6 +92,7 @@ internal class MdViewHtmlPane( override var foreground: Color get() = foregroundOverride ?: opts().foreground set(value) { + if (disposed) return if (foregroundOverride == value) return foregroundOverride = value markDirty() @@ -98,6 +101,7 @@ internal class MdViewHtmlPane( override var background: Color get() = backgroundOverride ?: opts().background set(value) { + if (disposed) return if (backgroundOverride == value) return backgroundOverride = value if (opaqueState) pane.background = value @@ -107,6 +111,7 @@ internal class MdViewHtmlPane( override var linkColor: Color get() = linkColorOverride ?: opts().linkColor set(value) { + if (disposed) return if (linkColorOverride == value) return linkColorOverride = value markDirty() @@ -115,6 +120,7 @@ internal class MdViewHtmlPane( override var codeBg: Color get() = codeBgOverride ?: opts().codeBg set(value) { + if (disposed) return if (codeBgOverride == value) return codeBgOverride = value markDirty() @@ -123,6 +129,7 @@ internal class MdViewHtmlPane( override var preBg: Color get() = preBgOverride ?: opts().preBg set(value) { + if (disposed) return if (preBgOverride == value) return preBgOverride = value markDirty() @@ -131,6 +138,7 @@ internal class MdViewHtmlPane( override var preFg: Color get() = preFgOverride ?: opts().preFg set(value) { + if (disposed) return if (preFgOverride == value) return preFgOverride = value markDirty() @@ -139,6 +147,7 @@ internal class MdViewHtmlPane( override var codeFont: String get() = codeFontOverride ?: opts().codeFont set(value) { + if (disposed) return if (codeFontOverride == value) return codeFontOverride = value markDirty() @@ -147,6 +156,7 @@ internal class MdViewHtmlPane( override var quoteBorder: Color get() = quoteBorderOverride ?: opts().quoteBorder set(value) { + if (disposed) return if (quoteBorderOverride == value) return quoteBorderOverride = value markDirty() @@ -155,6 +165,7 @@ internal class MdViewHtmlPane( override var quoteFg: Color get() = quoteFgOverride ?: opts().quoteFg set(value) { + if (disposed) return if (quoteFgOverride == value) return quoteFgOverride = value markDirty() @@ -163,6 +174,7 @@ internal class MdViewHtmlPane( override var tableBorder: Color get() = tableBorderOverride ?: opts().tableBorder set(value) { + if (disposed) return if (tableBorderOverride == value) return tableBorderOverride = value markDirty() @@ -171,6 +183,7 @@ internal class MdViewHtmlPane( override var opaque: Boolean get() = opaqueState set(value) { + if (disposed) return if (opaqueState == value) return opaqueState = value pane.isOpaque = value @@ -179,6 +192,7 @@ internal class MdViewHtmlPane( } override fun applyStyle(style: SessionEditorStyle) { + if (disposed) return if (this.style == style) return this.style = style if (opaqueState) pane.background = background @@ -186,6 +200,7 @@ internal class MdViewHtmlPane( } override fun resetStyles() { + if (disposed) return fontOverride = null foregroundOverride = null backgroundOverride = null @@ -204,6 +219,7 @@ internal class MdViewHtmlPane( } override fun set(text: String) { + if (disposed) return if (source.toString() == text) return source.clear() source.append(text) @@ -211,12 +227,14 @@ internal class MdViewHtmlPane( } override fun append(delta: String) { + if (disposed) return if (delta.isEmpty()) return source.append(delta) syncHtml() } override fun clear() { + if (disposed) return if (source.isEmpty() && rendered.isEmpty() && pane.text.isEmpty()) return source.clear() rendered = "" @@ -224,6 +242,7 @@ internal class MdViewHtmlPane( } override fun addLinkListener(listener: MdView.LinkListener) { + if (disposed) return listeners.add(listener) } @@ -238,19 +257,30 @@ internal class MdViewHtmlPane( override fun overrideSheet(): String = MdCommon.rules(opts()) override fun simulateLink(href: String) { + if (disposed) return dispatch(MdView.LinkEvent(href)) } + override fun dispose() { + disposed = true + listeners.clear() + source.clear() + rendered = "" + pane.text = "" + } + private fun dispatch(event: MdView.LinkEvent) { for (l in listeners) l.onLink(event) } private fun markDirty() { + if (disposed) return pane.reloadCssStylesheets() if (source.isNotEmpty()) syncHtml() } private fun syncHtml() { + if (disposed) return val body = renderer.render(parser.parse(source.toString())) if (rendered == body && pane.text == "$body") return rendered = body diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdViewHybrid.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdViewHybrid.kt index 1ca17be3614..302ad0a8013 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdViewHybrid.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdViewHybrid.kt @@ -3,12 +3,14 @@ package ai.kilocode.client.ui.md import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.log.KiloLog +import com.intellij.openapi.Disposable import com.intellij.openapi.editor.EditorFactory import com.intellij.openapi.fileTypes.FileType import com.intellij.openapi.fileTypes.FileTypeRegistry import com.intellij.openapi.fileTypes.PlainTextFileType import com.intellij.openapi.fileTypes.UnknownFileType import com.intellij.openapi.project.ProjectManager +import com.intellij.openapi.util.Disposer import com.intellij.ui.components.JBTextArea import com.intellij.ui.components.JBHtmlPane import com.intellij.ui.components.JBHtmlPaneConfiguration @@ -122,6 +124,8 @@ internal class MdViewHybrid( private val source = StringBuilder() private var style = style private var rendered = "" + private var block: Disposable? = null + private var disposed = false private val extensions = listOf( AutolinkExtension.create(), @@ -161,6 +165,7 @@ internal class MdViewHybrid( override var font: Font get() = fontOverride ?: opts().font set(value) { + if (disposed) return if (fontOverride == value) return fontOverride = value syncStyle() @@ -169,6 +174,7 @@ internal class MdViewHybrid( override var foreground: Color get() = foregroundOverride ?: opts().foreground set(value) { + if (disposed) return if (foregroundOverride == value) return foregroundOverride = value syncStyle() @@ -177,6 +183,7 @@ internal class MdViewHybrid( override var background: Color get() = backgroundOverride ?: opts().background set(value) { + if (disposed) return if (backgroundOverride == value) return backgroundOverride = value syncStyle() @@ -185,6 +192,7 @@ internal class MdViewHybrid( override var linkColor: Color get() = linkColorOverride ?: opts().linkColor set(value) { + if (disposed) return if (linkColorOverride == value) return linkColorOverride = value syncStyle() @@ -193,6 +201,7 @@ internal class MdViewHybrid( override var codeBg: Color get() = codeBgOverride ?: opts().codeBg set(value) { + if (disposed) return if (codeBgOverride == value) return codeBgOverride = value syncStyle() @@ -201,6 +210,7 @@ internal class MdViewHybrid( override var preBg: Color get() = preBgOverride ?: opts().preBg set(value) { + if (disposed) return if (preBgOverride == value) return preBgOverride = value syncStyle() @@ -209,6 +219,7 @@ internal class MdViewHybrid( override var preFg: Color get() = preFgOverride ?: opts().preFg set(value) { + if (disposed) return if (preFgOverride == value) return preFgOverride = value syncStyle() @@ -217,6 +228,7 @@ internal class MdViewHybrid( override var codeFont: String get() = codeFontOverride ?: opts().codeFont set(value) { + if (disposed) return if (codeFontOverride == value) return codeFontOverride = value syncStyle() @@ -225,6 +237,7 @@ internal class MdViewHybrid( override var quoteBorder: Color get() = quoteBorderOverride ?: opts().quoteBorder set(value) { + if (disposed) return if (quoteBorderOverride == value) return quoteBorderOverride = value syncStyle() @@ -233,6 +246,7 @@ internal class MdViewHybrid( override var quoteFg: Color get() = quoteFgOverride ?: opts().quoteFg set(value) { + if (disposed) return if (quoteFgOverride == value) return quoteFgOverride = value syncStyle() @@ -241,6 +255,7 @@ internal class MdViewHybrid( override var tableBorder: Color get() = tableBorderOverride ?: opts().tableBorder set(value) { + if (disposed) return if (tableBorderOverride == value) return tableBorderOverride = value syncStyle() @@ -249,18 +264,21 @@ internal class MdViewHybrid( override var opaque: Boolean get() = opaqueState set(value) { + if (disposed) return if (opaqueState == value) return opaqueState = value syncStyle() } override fun applyStyle(style: SessionEditorStyle) { + if (disposed) return if (this.style == style) return this.style = style syncStyle() } override fun resetStyles() { + if (disposed) return fontOverride = null foregroundOverride = null backgroundOverride = null @@ -277,6 +295,7 @@ internal class MdViewHybrid( } override fun set(text: String) { + if (disposed) return if (source.toString() == text) return source.clear() source.append(text) @@ -284,21 +303,24 @@ internal class MdViewHybrid( } override fun append(delta: String) { + if (disposed) return if (delta.isEmpty()) return source.append(delta) syncBlocks() } override fun clear() { + if (disposed) return if (source.isEmpty() && rendered.isEmpty() && root.componentCount == 0) return source.clear() rendered = "" - root.removeAll() + clearBlocks() root.revalidate() root.repaint() } override fun addLinkListener(listener: MdView.LinkListener) { + if (disposed) return listeners.add(listener) } @@ -313,10 +335,20 @@ internal class MdViewHybrid( override fun overrideSheet(): String = MdCommon.rules(opts()) override fun simulateLink(href: String) { + if (disposed) return dispatch(MdView.LinkEvent(href)) } + override fun dispose() { + disposed = true + listeners.clear() + source.clear() + rendered = "" + clearBlocks() + } + private fun syncStyle() { + if (disposed) return val opts = opts() root.isOpaque = opts.opaque if (opts.opaque) root.background = opts.background @@ -324,11 +356,12 @@ internal class MdViewHybrid( } private fun syncBlocks() { + if (disposed) return val text = source.toString() val doc = parser.parse(text) val body = renderer.render(doc) rendered = body - root.removeAll() + resetBlocks() if (text.isEmpty()) { root.revalidate() root.repaint() @@ -340,6 +373,18 @@ internal class MdViewHybrid( root.repaint() } + private fun resetBlocks() { + block?.let { Disposer.dispose(it) } + block = Disposer.newDisposable("Markdown blocks") + root.removeAll() + } + + private fun clearBlocks() { + block?.let { Disposer.dispose(it) } + block = null + root.removeAll() + } + private fun addGap() { if (root.componentCount == 0) return root.add(Box.createVerticalStrut(JBUI.scale(SessionUiStyle.View.Code.BLOCK_GAP))) @@ -379,7 +424,11 @@ internal class MdViewHybrid( private fun codeBlock(text: String, lang: String?): JComponent { val opts = opts() val value = text.trimEnd('\n') - val field = runCatching { CodeField(file(lang), opts, text) }.getOrElse { err -> + val field = runCatching { + CodeField(file(lang), opts, text).also { ed -> + block?.let { ed.setDisposedWith(it) } + } + }.getOrElse { err -> LOG.warn("kind=markdown codeEditor=true failed message=${err.message}", err) textArea(text, opts) } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewHybridTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewHybridTest.kt index 1c821b16ea4..47f4e4368c6 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewHybridTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewHybridTest.kt @@ -6,9 +6,11 @@ import com.intellij.openapi.fileTypes.FileType import com.intellij.openapi.fileTypes.FileTypeRegistry import com.intellij.openapi.fileTypes.PlainTextFileType import com.intellij.openapi.fileTypes.UnknownFileType +import com.intellij.openapi.util.Disposer import com.intellij.testFramework.fixtures.BasePlatformTestCase import com.intellij.ui.EditorTextField import com.intellij.ui.components.JBScrollPane +import com.intellij.util.ui.UIUtil import java.awt.BorderLayout import javax.swing.JPanel import javax.swing.ScrollPaneConstants @@ -16,10 +18,20 @@ import javax.swing.ScrollPaneConstants @Suppress("UnstableApiUsage") class MdViewHybridTest : BasePlatformTestCase() { private lateinit var view: MdView + private var disposed = false override fun setUp() { super.setUp() view = MdViewFactory.hybrid() + disposed = false + } + + override fun tearDown() { + try { + if (this::view.isInitialized && !disposed) Disposer.dispose(view) + } finally { + super.tearDown() + } } fun `test set stores source`() { @@ -199,6 +211,39 @@ class MdViewHybridTest : BasePlatformTestCase() { assertTrue(scrolls().isEmpty()) } + fun `test rerender disposes previous code block editor`() { + view.set("```kotlin\nval value = 1\n```") + val editor = editors().single().getEditor(true)!! + + view.set("plain text") + drainEdt() + + assertTrue(editor.isDisposed) + assertTrue(scrolls().isEmpty()) + } + + fun `test clear disposes code block editor`() { + view.set("```kotlin\nval value = 1\n```") + val editor = editors().single().getEditor(true)!! + + view.clear() + drainEdt() + + assertTrue(editor.isDisposed) + } + + fun `test dispose disposes code block editor`() { + view.set("```kotlin\nval value = 1\n```") + val editor = editors().single().getEditor(true)!! + + Disposer.dispose(view) + disposed = true + drainEdt() + + assertTrue(editor.isDisposed) + assertTrue(scrolls().isEmpty()) + } + fun `test applyStyle updates current and future blocks`() { val style = SessionEditorStyle.create(family = "Courier New", size = 21) @@ -248,4 +293,8 @@ class MdViewHybridTest : BasePlatformTestCase() { view.component.doLayout() scrolls().forEach { it.doLayout() } } + + private fun drainEdt() { + UIUtil.dispatchAllInvocationEvents() + } }