-
Notifications
You must be signed in to change notification settings - Fork 2.5k
feat(jetbrains): add MdView markdown rendering component #9149
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
0ba7b74
feat(jetbrains): add MdView markdown rendering component
kirillk b5aa402
fix(jetbrains): sanitize HTML output and fix link click point
kirillk 07525aa
Merge branch 'main' into glittery-panama
kirillk a385e6a
chore(jetbrains): remove redundant comments from MdView
kirillk da7b600
refactor(jetbrains): use JBHtmlPane with editor-aware styling in MdView
kirillk 644b6f8
Merge branch 'main' into glittery-panama
kirillk fbc20a4
Merge branch 'main' into glittery-panama
kirillk File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
298 changes: 298 additions & 0 deletions
298
packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdView.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,298 @@ | ||
| package ai.kilocode.client.ui.md | ||
|
|
||
| import com.intellij.ui.components.JBHtmlPane | ||
| import com.intellij.ui.components.JBHtmlPaneConfiguration | ||
| import com.intellij.ui.components.JBHtmlPaneStyleConfiguration | ||
| import com.intellij.util.ui.JBUI | ||
| import com.intellij.util.ui.UIUtil | ||
| import org.commonmark.ext.autolink.AutolinkExtension | ||
| import org.commonmark.ext.gfm.strikethrough.StrikethroughExtension | ||
| import org.commonmark.ext.gfm.tables.TablesExtension | ||
| import org.commonmark.parser.Parser | ||
| import org.commonmark.renderer.html.HtmlRenderer | ||
| import java.awt.Color | ||
| import java.awt.Font | ||
| import java.awt.Point | ||
| import javax.swing.JComponent | ||
| import javax.swing.event.HyperlinkEvent | ||
| import javax.swing.text.html.StyleSheet | ||
|
|
||
| /** | ||
| * Markdown rendering component backed by [JBHtmlPane] with editor-aware styling. | ||
| * | ||
| * By default, font and colors are derived from the global editor colour scheme. | ||
| * All style properties are optional overrides on top of those defaults. | ||
| * Call [resetStyles] to revert to editor defaults after overriding. | ||
| * | ||
| * Create instances via [MdView.html]. All public methods must be called on the EDT. | ||
| */ | ||
| @Suppress("UnstableApiUsage") | ||
| abstract class MdView private constructor() { | ||
|
|
||
| abstract val component: JComponent | ||
| abstract fun set(text: String) | ||
| abstract fun append(delta: String) | ||
| abstract fun clear() | ||
| /** Revert all style overrides to editor-derived defaults. */ | ||
| abstract fun resetStyles() | ||
| abstract fun addLinkListener(listener: LinkListener) | ||
| abstract fun removeLinkListener(listener: LinkListener) | ||
|
|
||
| abstract var font: Font | ||
| abstract var foreground: Color | ||
| abstract var background: Color | ||
| abstract var linkColor: Color | ||
| abstract var codeBg: Color | ||
| abstract var preBg: Color | ||
| abstract var preFg: Color | ||
| abstract var codeFont: String | ||
| abstract var quoteBorder: Color | ||
| abstract var quoteFg: Color | ||
| abstract var tableBorder: Color | ||
|
|
||
| /** | ||
| * When `false`, the component is transparent — the parent's background shows through | ||
| * and no background is forced in the CSS body rule. | ||
| */ | ||
| abstract var opaque: Boolean | ||
|
|
||
| data class LinkEvent( | ||
| val href: String, | ||
| val point: Point? = null, | ||
| ) | ||
|
|
||
| fun interface LinkListener { | ||
| fun onLink(event: LinkEvent) | ||
| } | ||
|
|
||
| internal abstract fun markdown(): String | ||
| internal abstract fun html(): String | ||
| /** Returns the current CSS override rules applied on top of JBHtmlPane's default stylesheet. */ | ||
| internal abstract fun overrideSheet(): String | ||
| internal abstract fun simulateLink(href: String) | ||
|
|
||
| companion object { | ||
| fun html(): MdView = HtmlImpl() | ||
| } | ||
|
|
||
| @Suppress("UnstableApiUsage") | ||
| private class HtmlImpl : MdView() { | ||
|
|
||
| private val listeners = mutableListOf<LinkListener>() | ||
| private val source = StringBuilder() | ||
| private var rendered = "" | ||
|
|
||
| private val extensions = listOf( | ||
| AutolinkExtension.create(), | ||
| TablesExtension.create(), | ||
| StrikethroughExtension.create(), | ||
| ) | ||
|
|
||
| private val parser: Parser = Parser.builder().extensions(extensions).build() | ||
|
|
||
| private val renderer: HtmlRenderer = HtmlRenderer.builder() | ||
| .extensions(extensions) | ||
| .escapeHtml(true) | ||
| .sanitizeUrls(true) | ||
| .build() | ||
|
|
||
| // nullable overrides — null means "use JBHtmlPane / editor default" | ||
| private var fontOverride: Font? = null | ||
| private var foregroundOverride: Color? = null | ||
| private var backgroundOverride: Color? = null | ||
| private var linkColorOverride: Color? = null | ||
| private var codeBgOverride: Color? = null | ||
| private var preBgOverride: Color? = null | ||
| private var preFgOverride: Color? = null | ||
| private var codeFontOverride: String? = null | ||
| private var quoteBorderOverride: Color? = null | ||
| private var quoteFgOverride: Color? = null | ||
| private var tableBorderOverride: Color? = null | ||
| private var opaqueState = true | ||
|
|
||
| private val pane: JBHtmlPane = JBHtmlPane( | ||
| JBHtmlPaneStyleConfiguration { | ||
| // colorSchemeProvider defaults to EditorColorsManager.getInstance().globalScheme | ||
| enableInlineCodeBackground = true | ||
| enableCodeBlocksBackground = true | ||
| }, | ||
| JBHtmlPaneConfiguration { | ||
| // fontResolver defaults to EditorCssFontResolver.getGlobalInstance() via JBHtmlPane's ImplService | ||
| customStyleSheetProvider { buildOverrideStyleSheet() } | ||
| } | ||
| ).apply { | ||
| isEditable = false | ||
| isOpaque = true | ||
| background = UIUtil.getPanelBackground() | ||
|
|
||
| addHyperlinkListener { e -> | ||
| if (e.eventType == HyperlinkEvent.EventType.ACTIVATED) { | ||
| val href = e.description ?: return@addHyperlinkListener | ||
| val pt = (e.inputEvent as? java.awt.event.MouseEvent)?.point | ||
| val event = LinkEvent(href, pt) | ||
| for (l in listeners) l.onLink(event) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| override val component: JComponent get() = pane | ||
|
|
||
| // -- style properties (non-null API backed by nullable overrides) ---- | ||
|
|
||
| override var font: Font | ||
| get() = fontOverride ?: JBUI.Fonts.label() | ||
| set(value) { fontOverride = value; markDirty() } | ||
|
|
||
| override var foreground: Color | ||
| get() = foregroundOverride ?: UIUtil.getLabelForeground() | ||
| set(value) { foregroundOverride = value; markDirty() } | ||
|
|
||
| override var background: Color | ||
| get() = backgroundOverride ?: pane.background | ||
| set(value) { | ||
| backgroundOverride = value | ||
| if (opaqueState) pane.background = value | ||
| markDirty() | ||
| } | ||
|
|
||
| override var linkColor: Color | ||
| get() = linkColorOverride ?: Color(0x58, 0x9D, 0xF6) | ||
| set(value) { linkColorOverride = value; markDirty() } | ||
|
|
||
| override var codeBg: Color | ||
| get() = codeBgOverride ?: Color(0x3C, 0x3F, 0x41) | ||
| set(value) { codeBgOverride = value; markDirty() } | ||
|
|
||
| override var preBg: Color | ||
| get() = preBgOverride ?: Color(0x2B, 0x2B, 0x2B) | ||
| set(value) { preBgOverride = value; markDirty() } | ||
|
|
||
| override var preFg: Color | ||
| get() = preFgOverride ?: Color(0xA9, 0xB7, 0xC6) | ||
| set(value) { preFgOverride = value; markDirty() } | ||
|
|
||
| override var codeFont: String | ||
| // _EditorFontNoLigatures_ is resolved by EditorCssFontResolver to the global editor font | ||
| get() = codeFontOverride ?: "_EditorFontNoLigatures_" | ||
| set(value) { codeFontOverride = value; markDirty() } | ||
|
|
||
| override var quoteBorder: Color | ||
| get() = quoteBorderOverride ?: Color(0x55, 0x55, 0x55) | ||
| set(value) { quoteBorderOverride = value; markDirty() } | ||
|
|
||
| override var quoteFg: Color | ||
| get() = quoteFgOverride ?: Color(0x99, 0x99, 0x99) | ||
| set(value) { quoteFgOverride = value; markDirty() } | ||
|
|
||
| override var tableBorder: Color | ||
| get() = tableBorderOverride ?: Color(0x55, 0x55, 0x55) | ||
| set(value) { tableBorderOverride = value; markDirty() } | ||
|
|
||
| override var opaque: Boolean | ||
| get() = opaqueState | ||
| set(value) { | ||
| opaqueState = value | ||
| pane.isOpaque = value | ||
| if (value) pane.background = backgroundOverride ?: UIUtil.getPanelBackground() | ||
| markDirty() | ||
| } | ||
|
|
||
| override fun resetStyles() { | ||
| fontOverride = null | ||
| foregroundOverride = null | ||
| backgroundOverride = null | ||
| linkColorOverride = null | ||
| codeBgOverride = null | ||
| preBgOverride = null | ||
| preFgOverride = null | ||
| codeFontOverride = null | ||
| quoteBorderOverride = null | ||
| quoteFgOverride = null | ||
| tableBorderOverride = null | ||
| opaqueState = true | ||
| pane.isOpaque = true | ||
| pane.background = UIUtil.getPanelBackground() | ||
| markDirty() | ||
| } | ||
|
|
||
| // -- content API --------------------------------------------------- | ||
|
|
||
| override fun set(text: String) { | ||
| source.clear() | ||
| source.append(text) | ||
| render() | ||
| } | ||
|
|
||
| override fun append(delta: String) { | ||
| source.append(delta) | ||
| render() | ||
| } | ||
|
|
||
| override fun clear() { | ||
| source.clear() | ||
| rendered = "" | ||
| pane.text = "" | ||
| } | ||
|
|
||
| override fun addLinkListener(listener: LinkListener) { listeners.add(listener) } | ||
| override fun removeLinkListener(listener: LinkListener) { listeners.remove(listener) } | ||
|
|
||
| override fun markdown(): String = source.toString() | ||
| override fun html(): String = rendered | ||
| override fun overrideSheet(): String = buildOverrideRulesString() | ||
|
|
||
| override fun simulateLink(href: String) { | ||
| val event = LinkEvent(href) | ||
| for (l in listeners) l.onLink(event) | ||
| } | ||
|
|
||
| private fun markDirty() { | ||
| pane.reloadCssStylesheets() | ||
| if (source.isNotEmpty()) render() | ||
| } | ||
|
|
||
| private fun render() { | ||
| val body = renderer.render(parser.parse(source.toString())) | ||
| rendered = body | ||
| pane.text = "<html><body>$body</body></html>" | ||
| pane.caretPosition = 0 | ||
| } | ||
|
|
||
| private fun buildOverrideStyleSheet(): StyleSheet { | ||
| val sheet = StyleSheet() | ||
| val rules = buildOverrideRulesString() | ||
| if (rules.isNotEmpty()) { | ||
| try { sheet.addRule(rules) } catch (_: Exception) {} | ||
| } | ||
| return sheet | ||
| } | ||
|
|
||
| private fun buildOverrideRulesString(): String { | ||
| val rules = StringBuilder() | ||
|
|
||
| val body = mutableListOf<String>() | ||
| foregroundOverride?.let { body.add("color: ${hex(it)}") } | ||
| if (!opaqueState) body.add("background: transparent") | ||
| fontOverride?.let { | ||
| body.add("font-family: '${it.family}', sans-serif") | ||
| body.add("font-size: ${it.size}pt") | ||
| } | ||
| if (body.isNotEmpty()) rules.append("body { ${body.joinToString("; ")} } ") | ||
|
|
||
| linkColorOverride?.let { rules.append("a { color: ${hex(it)} } ") } | ||
| codeFontOverride?.let { rules.append("tt, code, samp, pre { font-family: '${it}', monospace } ") } | ||
| preBgOverride?.let { rules.append("pre { background: ${hex(it)} } ") } | ||
| preFgOverride?.let { rules.append("pre { color: ${hex(it)} } ") } | ||
| codeBgOverride?.let { rules.append("code { background: ${hex(it)} } ") } | ||
| quoteBorderOverride?.let { rules.append("blockquote { border-left-color: ${hex(it)} } ") } | ||
| quoteFgOverride?.let { rules.append("blockquote { color: ${hex(it)} } ") } | ||
| tableBorderOverride?.let { rules.append("th, td { border-color: ${hex(it)} } ") } | ||
|
|
||
| return rules.toString().trim() | ||
| } | ||
|
|
||
| companion object { | ||
| private fun hex(c: Color): String = String.format("#%02x%02x%02x", c.red, c.green, c.blue) | ||
| } | ||
| } | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.