Skip to content

Commit 1b8ed1e

Browse files
authored
Merge pull request #9149 from Kilo-Org/glittery-panama
feat(jetbrains): add MdView markdown rendering component
2 parents 1da343f + fbc20a4 commit 1b8ed1e

4 files changed

Lines changed: 709 additions & 0 deletions

File tree

packages/kilo-jetbrains/frontend/build.gradle.kts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,11 @@ dependencies {
1919

2020
implementation(project(":shared"))
2121

22+
implementation(libs.commonmark)
23+
implementation(libs.commonmark.autolink)
24+
implementation(libs.commonmark.tables)
25+
implementation(libs.commonmark.strikethrough)
26+
2227
testImplementation(kotlin("test"))
2328
testImplementation("junit:junit:4.13.2")
2429
testRuntimeOnly("org.junit.vintage:junit-vintage-engine:5.11.4")
Lines changed: 298 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,298 @@
1+
package ai.kilocode.client.ui.md
2+
3+
import com.intellij.ui.components.JBHtmlPane
4+
import com.intellij.ui.components.JBHtmlPaneConfiguration
5+
import com.intellij.ui.components.JBHtmlPaneStyleConfiguration
6+
import com.intellij.util.ui.JBUI
7+
import com.intellij.util.ui.UIUtil
8+
import org.commonmark.ext.autolink.AutolinkExtension
9+
import org.commonmark.ext.gfm.strikethrough.StrikethroughExtension
10+
import org.commonmark.ext.gfm.tables.TablesExtension
11+
import org.commonmark.parser.Parser
12+
import org.commonmark.renderer.html.HtmlRenderer
13+
import java.awt.Color
14+
import java.awt.Font
15+
import java.awt.Point
16+
import javax.swing.JComponent
17+
import javax.swing.event.HyperlinkEvent
18+
import javax.swing.text.html.StyleSheet
19+
20+
/**
21+
* Markdown rendering component backed by [JBHtmlPane] with editor-aware styling.
22+
*
23+
* By default, font and colors are derived from the global editor colour scheme.
24+
* All style properties are optional overrides on top of those defaults.
25+
* Call [resetStyles] to revert to editor defaults after overriding.
26+
*
27+
* Create instances via [MdView.html]. All public methods must be called on the EDT.
28+
*/
29+
@Suppress("UnstableApiUsage")
30+
abstract class MdView private constructor() {
31+
32+
abstract val component: JComponent
33+
abstract fun set(text: String)
34+
abstract fun append(delta: String)
35+
abstract fun clear()
36+
/** Revert all style overrides to editor-derived defaults. */
37+
abstract fun resetStyles()
38+
abstract fun addLinkListener(listener: LinkListener)
39+
abstract fun removeLinkListener(listener: LinkListener)
40+
41+
abstract var font: Font
42+
abstract var foreground: Color
43+
abstract var background: Color
44+
abstract var linkColor: Color
45+
abstract var codeBg: Color
46+
abstract var preBg: Color
47+
abstract var preFg: Color
48+
abstract var codeFont: String
49+
abstract var quoteBorder: Color
50+
abstract var quoteFg: Color
51+
abstract var tableBorder: Color
52+
53+
/**
54+
* When `false`, the component is transparent — the parent's background shows through
55+
* and no background is forced in the CSS body rule.
56+
*/
57+
abstract var opaque: Boolean
58+
59+
data class LinkEvent(
60+
val href: String,
61+
val point: Point? = null,
62+
)
63+
64+
fun interface LinkListener {
65+
fun onLink(event: LinkEvent)
66+
}
67+
68+
internal abstract fun markdown(): String
69+
internal abstract fun html(): String
70+
/** Returns the current CSS override rules applied on top of JBHtmlPane's default stylesheet. */
71+
internal abstract fun overrideSheet(): String
72+
internal abstract fun simulateLink(href: String)
73+
74+
companion object {
75+
fun html(): MdView = HtmlImpl()
76+
}
77+
78+
@Suppress("UnstableApiUsage")
79+
private class HtmlImpl : MdView() {
80+
81+
private val listeners = mutableListOf<LinkListener>()
82+
private val source = StringBuilder()
83+
private var rendered = ""
84+
85+
private val extensions = listOf(
86+
AutolinkExtension.create(),
87+
TablesExtension.create(),
88+
StrikethroughExtension.create(),
89+
)
90+
91+
private val parser: Parser = Parser.builder().extensions(extensions).build()
92+
93+
private val renderer: HtmlRenderer = HtmlRenderer.builder()
94+
.extensions(extensions)
95+
.escapeHtml(true)
96+
.sanitizeUrls(true)
97+
.build()
98+
99+
// nullable overrides — null means "use JBHtmlPane / editor default"
100+
private var fontOverride: Font? = null
101+
private var foregroundOverride: Color? = null
102+
private var backgroundOverride: Color? = null
103+
private var linkColorOverride: Color? = null
104+
private var codeBgOverride: Color? = null
105+
private var preBgOverride: Color? = null
106+
private var preFgOverride: Color? = null
107+
private var codeFontOverride: String? = null
108+
private var quoteBorderOverride: Color? = null
109+
private var quoteFgOverride: Color? = null
110+
private var tableBorderOverride: Color? = null
111+
private var opaqueState = true
112+
113+
private val pane: JBHtmlPane = JBHtmlPane(
114+
JBHtmlPaneStyleConfiguration {
115+
// colorSchemeProvider defaults to EditorColorsManager.getInstance().globalScheme
116+
enableInlineCodeBackground = true
117+
enableCodeBlocksBackground = true
118+
},
119+
JBHtmlPaneConfiguration {
120+
// fontResolver defaults to EditorCssFontResolver.getGlobalInstance() via JBHtmlPane's ImplService
121+
customStyleSheetProvider { buildOverrideStyleSheet() }
122+
}
123+
).apply {
124+
isEditable = false
125+
isOpaque = true
126+
background = UIUtil.getPanelBackground()
127+
128+
addHyperlinkListener { e ->
129+
if (e.eventType == HyperlinkEvent.EventType.ACTIVATED) {
130+
val href = e.description ?: return@addHyperlinkListener
131+
val pt = (e.inputEvent as? java.awt.event.MouseEvent)?.point
132+
val event = LinkEvent(href, pt)
133+
for (l in listeners) l.onLink(event)
134+
}
135+
}
136+
}
137+
138+
override val component: JComponent get() = pane
139+
140+
// -- style properties (non-null API backed by nullable overrides) ----
141+
142+
override var font: Font
143+
get() = fontOverride ?: JBUI.Fonts.label()
144+
set(value) { fontOverride = value; markDirty() }
145+
146+
override var foreground: Color
147+
get() = foregroundOverride ?: UIUtil.getLabelForeground()
148+
set(value) { foregroundOverride = value; markDirty() }
149+
150+
override var background: Color
151+
get() = backgroundOverride ?: pane.background
152+
set(value) {
153+
backgroundOverride = value
154+
if (opaqueState) pane.background = value
155+
markDirty()
156+
}
157+
158+
override var linkColor: Color
159+
get() = linkColorOverride ?: Color(0x58, 0x9D, 0xF6)
160+
set(value) { linkColorOverride = value; markDirty() }
161+
162+
override var codeBg: Color
163+
get() = codeBgOverride ?: Color(0x3C, 0x3F, 0x41)
164+
set(value) { codeBgOverride = value; markDirty() }
165+
166+
override var preBg: Color
167+
get() = preBgOverride ?: Color(0x2B, 0x2B, 0x2B)
168+
set(value) { preBgOverride = value; markDirty() }
169+
170+
override var preFg: Color
171+
get() = preFgOverride ?: Color(0xA9, 0xB7, 0xC6)
172+
set(value) { preFgOverride = value; markDirty() }
173+
174+
override var codeFont: String
175+
// _EditorFontNoLigatures_ is resolved by EditorCssFontResolver to the global editor font
176+
get() = codeFontOverride ?: "_EditorFontNoLigatures_"
177+
set(value) { codeFontOverride = value; markDirty() }
178+
179+
override var quoteBorder: Color
180+
get() = quoteBorderOverride ?: Color(0x55, 0x55, 0x55)
181+
set(value) { quoteBorderOverride = value; markDirty() }
182+
183+
override var quoteFg: Color
184+
get() = quoteFgOverride ?: Color(0x99, 0x99, 0x99)
185+
set(value) { quoteFgOverride = value; markDirty() }
186+
187+
override var tableBorder: Color
188+
get() = tableBorderOverride ?: Color(0x55, 0x55, 0x55)
189+
set(value) { tableBorderOverride = value; markDirty() }
190+
191+
override var opaque: Boolean
192+
get() = opaqueState
193+
set(value) {
194+
opaqueState = value
195+
pane.isOpaque = value
196+
if (value) pane.background = backgroundOverride ?: UIUtil.getPanelBackground()
197+
markDirty()
198+
}
199+
200+
override fun resetStyles() {
201+
fontOverride = null
202+
foregroundOverride = null
203+
backgroundOverride = null
204+
linkColorOverride = null
205+
codeBgOverride = null
206+
preBgOverride = null
207+
preFgOverride = null
208+
codeFontOverride = null
209+
quoteBorderOverride = null
210+
quoteFgOverride = null
211+
tableBorderOverride = null
212+
opaqueState = true
213+
pane.isOpaque = true
214+
pane.background = UIUtil.getPanelBackground()
215+
markDirty()
216+
}
217+
218+
// -- content API ---------------------------------------------------
219+
220+
override fun set(text: String) {
221+
source.clear()
222+
source.append(text)
223+
render()
224+
}
225+
226+
override fun append(delta: String) {
227+
source.append(delta)
228+
render()
229+
}
230+
231+
override fun clear() {
232+
source.clear()
233+
rendered = ""
234+
pane.text = ""
235+
}
236+
237+
override fun addLinkListener(listener: LinkListener) { listeners.add(listener) }
238+
override fun removeLinkListener(listener: LinkListener) { listeners.remove(listener) }
239+
240+
override fun markdown(): String = source.toString()
241+
override fun html(): String = rendered
242+
override fun overrideSheet(): String = buildOverrideRulesString()
243+
244+
override fun simulateLink(href: String) {
245+
val event = LinkEvent(href)
246+
for (l in listeners) l.onLink(event)
247+
}
248+
249+
private fun markDirty() {
250+
pane.reloadCssStylesheets()
251+
if (source.isNotEmpty()) render()
252+
}
253+
254+
private fun render() {
255+
val body = renderer.render(parser.parse(source.toString()))
256+
rendered = body
257+
pane.text = "<html><body>$body</body></html>"
258+
pane.caretPosition = 0
259+
}
260+
261+
private fun buildOverrideStyleSheet(): StyleSheet {
262+
val sheet = StyleSheet()
263+
val rules = buildOverrideRulesString()
264+
if (rules.isNotEmpty()) {
265+
try { sheet.addRule(rules) } catch (_: Exception) {}
266+
}
267+
return sheet
268+
}
269+
270+
private fun buildOverrideRulesString(): String {
271+
val rules = StringBuilder()
272+
273+
val body = mutableListOf<String>()
274+
foregroundOverride?.let { body.add("color: ${hex(it)}") }
275+
if (!opaqueState) body.add("background: transparent")
276+
fontOverride?.let {
277+
body.add("font-family: '${it.family}', sans-serif")
278+
body.add("font-size: ${it.size}pt")
279+
}
280+
if (body.isNotEmpty()) rules.append("body { ${body.joinToString("; ")} } ")
281+
282+
linkColorOverride?.let { rules.append("a { color: ${hex(it)} } ") }
283+
codeFontOverride?.let { rules.append("tt, code, samp, pre { font-family: '${it}', monospace } ") }
284+
preBgOverride?.let { rules.append("pre { background: ${hex(it)} } ") }
285+
preFgOverride?.let { rules.append("pre { color: ${hex(it)} } ") }
286+
codeBgOverride?.let { rules.append("code { background: ${hex(it)} } ") }
287+
quoteBorderOverride?.let { rules.append("blockquote { border-left-color: ${hex(it)} } ") }
288+
quoteFgOverride?.let { rules.append("blockquote { color: ${hex(it)} } ") }
289+
tableBorderOverride?.let { rules.append("th, td { border-color: ${hex(it)} } ") }
290+
291+
return rules.toString().trim()
292+
}
293+
294+
companion object {
295+
private fun hex(c: Color): String = String.format("#%02x%02x%02x", c.red, c.green, c.blue)
296+
}
297+
}
298+
}

0 commit comments

Comments
 (0)