1package soy.iko.opencode.ui.components
3import android.content.ClipData
4import android.content.ClipboardManager
5import android.content.Context
7import android.widget.Toast
8import androidx.compose.animation.core.RepeatMode
9import androidx.compose.animation.core.animateFloat
10import androidx.compose.animation.core.infiniteRepeatable
11import androidx.compose.animation.core.rememberInfiniteTransition
12import androidx.compose.animation.core.tween
13import androidx.compose.foundation.background
14import androidx.compose.foundation.horizontalScroll
15import androidx.compose.foundation.layout.Arrangement
16import androidx.compose.foundation.layout.Box
17import androidx.compose.foundation.layout.Column
18import androidx.compose.foundation.layout.Row
19import androidx.compose.foundation.layout.defaultMinSize
20import androidx.compose.foundation.layout.fillMaxWidth
21import androidx.compose.foundation.layout.padding
22import androidx.compose.foundation.layout.size
23import androidx.compose.foundation.rememberScrollState
24import androidx.compose.foundation.text.selection.SelectionContainer
25import androidx.compose.material.icons.Icons
26import androidx.compose.material.icons.automirrored.filled.WrapText
27import androidx.compose.material.icons.filled.Check
28import androidx.compose.material.icons.filled.ContentCopy
29import androidx.compose.material.icons.filled.ExpandLess
30import androidx.compose.material.icons.filled.ExpandMore
31import androidx.compose.material3.AlertDialog
32import androidx.compose.material3.Icon
33import androidx.compose.material3.IconButton
34import androidx.compose.material3.MaterialTheme
35import androidx.compose.material3.Surface
36import androidx.compose.material3.Text
37import androidx.compose.material3.TextButton
38import androidx.compose.material3.Typography
39import androidx.compose.runtime.Composable
40import androidx.compose.runtime.CompositionLocalProvider
41import androidx.compose.runtime.LaunchedEffect
42import androidx.compose.runtime.getValue
43import androidx.compose.runtime.mutableStateOf
44import androidx.compose.runtime.remember
45import androidx.compose.runtime.rememberUpdatedState
46import androidx.compose.runtime.saveable.rememberSaveable
47import androidx.compose.runtime.setValue
48import androidx.compose.runtime.snapshotFlow
49import androidx.compose.ui.Alignment
50import androidx.compose.ui.Modifier
51import androidx.compose.ui.draw.clip
52import androidx.compose.ui.graphics.Color
53import androidx.compose.ui.platform.LocalContext
54import androidx.compose.ui.platform.LocalHapticFeedback
55import androidx.compose.ui.platform.LocalUriHandler
56import androidx.compose.ui.platform.UriHandler
57import androidx.compose.ui.res.pluralStringResource
58import androidx.compose.ui.res.stringResource
59import androidx.compose.ui.semantics.LiveRegionMode
60import androidx.compose.ui.semantics.invisibleToUser
61import androidx.compose.ui.semantics.liveRegion
62import androidx.compose.ui.semantics.semantics
63import androidx.compose.ui.text.AnnotatedString
64import androidx.compose.ui.text.SpanStyle
65import androidx.compose.ui.text.TextStyle
66import androidx.compose.ui.text.font.FontFamily
67import androidx.compose.ui.unit.dp
68import kotlinx.coroutines.flow.conflate
69import kotlinx.coroutines.delay
70import com.mikepenz.markdown.compose.LocalMarkdownAnnotator
71import com.mikepenz.markdown.compose.components.MarkdownComponentModel
72import com.mikepenz.markdown.compose.components.markdownComponents
73import com.mikepenz.markdown.m3.Markdown
74import com.mikepenz.markdown.model.DefaultMarkdownAnnotator
75import org.intellij.markdown.MarkdownElementTypes
76import org.intellij.markdown.ast.ASTNode
77import org.intellij.markdown.ast.getTextInNode
78import soy.iko.opencode.R
79import soy.iko.opencode.data.model.FilePart
80import soy.iko.opencode.data.network.NetworkConfig
83 * Renders markdown using [multiplatform-markdown-renderer](https://github.com/mikepenz/multiplatform-markdown-renderer)
84 * (commonmark-java under the hood). Keeps a local API so callers don't import the library directly.
85 * Code blocks/fences get an inline copy button.
87 * The rendered text is wrapped in a [SelectionContainer] so the user can select and
88 * copy a portion of the response (e.g. a single code snippet or paragraph) instead of
89 * the all-or-nothing long-press copy. The per-message copy button in [MessageBubble]
90 * copies all TextParts; this complements it with partial selection.
92 * During streaming, the full markdown is re-parsed on every token (the library re-parses
93 * whenever the content string changes). To avoid O(n²) work during long responses, the
94 * rendered content is throttled — the latest [markdown] is committed to the renderer at
95 * most once every ~50ms, so a burst of tokens coalesces into a single re-parse.
97 * The throttle uses a single long-lived `LaunchedEffect(Unit)` that observes the markdown
98 * parameter via `rememberUpdatedState` + `snapshotFlow` + `conflate` + `collect`. A keyed
99 * effect (`LaunchedEffect(markdown)`) cancels and restarts on every token; if tokens arrive
100 * faster than the throttle delay, the in-flight `delay` is cancelled before it completes,
101 * starving the render and showing stale content until the stream pauses. The conflated
102 * `collect` lets the delay run to completion, then picks up the most recent buffered value,
103 * so rendering always progresses even under continuous fast streaming.
105 * When [streaming] is false (the common case: every non-active message in the chat list),
106 * the throttle pipeline is skipped entirely — no `LaunchedEffect`, no `snapshotFlow`, no
107 * coroutine. This eliminates per-item coroutine churn as messages scroll in and out of view.
112 modifier: Modifier = Modifier,
113 @Suppress("UNUSED_PARAMETER") style: TextStyle = MaterialTheme.typography.bodyLarge,
114 streaming: Boolean = false,
115 imageContext: ImageLoadContext? = null,
117 val scale = LocalChatTextScale.current
119 MarkdownBody(markdown, modifier, streaming, imageContext)
121 // Scale the entire markdown subtree (headings, lists, code, body) from the user's
122 // chat text-size preference by nesting a MaterialTheme with a scaled typography:
123 // the renderer derives its typography from MaterialTheme, so this reaches every
124 // element without plumbing a scale through the library. colorScheme/shapes default
125 // to the current theme's, so only sizes change. Memoized so a recomposition that
126 // doesn't change the scale doesn't rebuild the 15-role Typography.
127 val base = MaterialTheme.typography
128 val scaled = remember(scale, base) { base.scaledBy(scale) }
129 MaterialTheme(typography = scaled) {
130 MarkdownBody(markdown, modifier, streaming, imageContext)
136private fun MarkdownBody(
138 modifier: Modifier = Modifier,
139 streaming: Boolean = false,
140 imageContext: ImageLoadContext? = null,
142 // Provide a custom image component when an ImageLoadContext is available so markdown
143 // image tags () route through RemoteImage (Basic auth + same-origin guard).
144 // Without this the library's default image loader fetches with no auth and fails on
145 // server-relative URLs. When no context is available, fall back to the library default
146 // by not overriding the image component.
147 val components = if (imageContext != null) {
149 codeFence = { CodeWithCopy(it) },
150 codeBlock = { CodeWithCopy(it) },
151 image = { MarkdownImage(it, imageContext) },
156 codeFence = { CodeWithCopy(it) },
157 codeBlock = { CodeWithCopy(it) },
161 val context = LocalContext.current
162 // Intercept link taps so the scheme can be validated before the library fires an
163 // implicit ACTION_VIEW. A hallucinated javascript:/file:/intent: URL must not launch
164 // an unexpected intent chooser. Taps are routed to a dialog offering Open (only for
165 // http/https/mailto) and Copy link. The default handler is captured here and invoked
166 // from the dialog's Open action.
167 val defaultUriHandler = LocalUriHandler.current
168 var pendingLink by remember { mutableStateOf<String?>(null) }
169 val safeUriHandler = remember {
170 object : UriHandler {
171 override fun openUri(uri: String) {
176 // In-conversation search highlighting: when LocalSearchHighlight carries a query,
177 // wrap the default MarkdownAnnotator to add a translucent background span over each
178 // case-insensitive match in the rendered text. Skipped while streaming so the
179 // throttled re-render isn't invalidated on every token. Offsets are derived from the
180 // builder's actual rendered output (not the raw markdown), so highlights land on the
181 // right characters even inside formatted spans.
182 val searchQuery = LocalSearchHighlight.current?.takeIf { it.isNotBlank() }
183 val highlightColor = MaterialTheme.colorScheme.secondaryContainer
184 val baseAnnotator = LocalMarkdownAnnotator.current
185 val highlightAnnotator = if (searchQuery != null && !streaming) {
186 remember(baseAnnotator, searchQuery, highlightColor) {
187 DefaultMarkdownAnnotator(
188 annotate = { text, node ->
189 // `this` is the AnnotatedString.Builder being filled for this node.
190 val startLen = this.length
191 val handled = baseAnnotator.annotate?.invoke(this, text, node) ?: false
192 val endLen = this.length
193 if (endLen > startLen) {
196 this.toString().substring(startLen, endLen),
210 // Bridge the markdown parameter into snapshot state so snapshotFlow can observe it.
211 val markdownState = rememberUpdatedState(markdown)
212 var renderedContent by remember { mutableStateOf(markdown) }
213 // A SINGLE long-lived effect (keyed on Unit), per this file's design. Keying on the
214 // content — even a prefix like markdown.take(32) — cancels and restarts the effect as the
215 // first characters stream in (the prefix changes on nearly every early token), so the
216 // in-flight delay never completes and the throttle is defeated at the start of every
217 // response. A streaming MarkdownBody instance only ever grows its content (a different
218 // message is a fresh composition with its own remember), so no reset key is needed.
219 // conflate() coalesces a burst of token updates into a single emission so the collector
220 // sees only the latest value after the previous delay completes. Plain collect (not
221 // collectLatest) is critical: collectLatest would cancel the delay on every new value,
222 // reintroducing the same starvation a keyed effect has.
223 LaunchedEffect(Unit) {
224 snapshotFlow { markdownState.value }
227 // Only throttle when the content is growing incrementally (streaming): a
228 // shorter switch or initial render proceeds immediately. Checking length is
229 // O(1) vs. startsWith which is O(n) in the rendered content length — during a
230 // long streaming response this runs every throttle cycle. Within one streaming
231 // composition, growing length means appending (streaming), not a rewrite.
232 if (renderedContent.isNotEmpty() &&
233 md.length > renderedContent.length
235 delay(NetworkConfig.streamingThrottleMs)
240 // SelectionContainer is intentionally omitted during streaming: the content is
241 // changing every ~50ms, and an active selection would be invalidated (and the
242 // selection handles would flicker) on each throttle commit. Partial selection
243 // becomes available once the stream finishes; the per-message Copy button in
244 // MessageBubble copies all TextParts at any time.
245 // A polite live region announces streaming content to TalkBack users. The throttle
246 // (50ms) plus TalkBack's own polite-queue coalescing keeps announcements from
247 // interrupting on every token; the reader hears the reply grow in measured chunks.
248 CompositionLocalProvider(LocalUriHandler provides safeUriHandler) {
250 content = renderedContent,
251 modifier = modifier.semantics { liveRegion = LiveRegionMode.Polite },
252 components = components,
255 // A blinking caret at the end of a streaming reply gives a "live" typewriter feel.
256 // Skipped under reduced motion (where blinking is discouraged). It's a tiny separate
257 // element so the markdown AST isn't re-parsed to toggle it.
258 if (!LocalReducedMotion.current) StreamingCaret()
260 val content: @Composable () -> Unit = {
264 modifier = modifier.semantics { liveRegion = LiveRegionMode.Polite },
265 components = components,
269 CompositionLocalProvider(LocalUriHandler provides safeUriHandler) {
270 if (highlightAnnotator != null) {
271 CompositionLocalProvider(LocalMarkdownAnnotator provides highlightAnnotator) {
279 // Open/copy dialog for the most recently tapped markdown link. Offer Open only for
280 // schemes the app is willing to hand to the system (http/https/mailto); for anything
281 // else, copy is the only escape so a model hallucination can't fire an unexpected intent.
282 pendingLink?.let { url ->
285 canOpen = isSafeLinkScheme(url),
286 onDismiss = { pendingLink = null },
289 runCatching { defaultUriHandler.openUri(url) }
293 copyToClipboard(context, context.getString(R.string.copy_link), url)
300 * Adds a translucent background span over each case-insensitive occurrence of [query] found
301 * in [chunk], mapping the chunk-relative offsets into the builder via [baseOffset]. Called
302 * from the search-highlight annotator wrapper; [chunk] is the text the default annotator
303 * appended to the builder for a single markdown node, so the offsets line up with what the
304 * user actually sees (markdown syntax already stripped).
306private fun addSearchHighlights(
307 builder: AnnotatedString.Builder,
314 if (q.isEmpty()) return
315 var idx = chunk.indexOf(q, ignoreCase = true)
318 SpanStyle(background = color),
320 baseOffset + idx + q.length,
322 idx = chunk.indexOf(q, idx + q.length, ignoreCase = true)
326/** Schemes the app is willing to forward to the system's URI handler from a markdown link. */
327private fun isSafeLinkScheme(uri: String): Boolean {
328 val scheme = uri.substringBefore(':', "").lowercase()
329 return scheme == "http" || scheme == "https" || scheme == "mailto"
333 * Dialog shown when the user taps a markdown link. For http/https/mailto URIs, offers Open
334 * (via the platform default handler) and Copy link. For any other scheme, only Copy is
335 * offered (with an "unsupported link type" note) so a hallucinated javascript:/file: URL
336 * can't launch an unexpected intent.
339private fun LinkActionDialog(
342 onDismiss: () -> Unit,
347 onDismissRequest = onDismiss,
351 style = MaterialTheme.typography.bodySmall,
352 color = MaterialTheme.colorScheme.onSurfaceVariant,
358 stringResource(R.string.link_unsupported),
359 style = MaterialTheme.typography.bodyMedium,
360 color = MaterialTheme.colorScheme.error,
365 androidx.compose.foundation.layout.Row {
366 TextButton(onClick = onCopy) { Text(stringResource(R.string.copy_link)) }
368 TextButton(onClick = onOpen) { Text(stringResource(R.string.open_link)) }
373 TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) }
379 * Renders a fenced or indented code block with a header toolbar: the fence's language
380 * label (when present), a wrap toggle (soft-wrap long lines vs. horizontal scroll), and a
381 * copy button. Self-contained so it doesn't depend on the library's internal code-block
382 * composables. The wrap toggle starts from the user's [LocalCodeWrap] preference but can
383 * be flipped per-block.
385 * To avoid composing thousands of [Text] nodes at once (which can ANR/OOM on a long
386 * response), code blocks over [COLLAPSED_CODE_LINES] lines render only the head until the
387 * user expands — mirroring the collapse already applied to tool output and diffs.
390private fun CodeWithCopy(model: MarkdownComponentModel) {
391 val context = LocalContext.current
392 // Key on the content string + the AST node's offset range (stable ints) instead of
393 // the model itself. MarkdownComponentModel is not @Immutable/@Stable (it holds an
394 // ASTNode), so remembering on it re-executes extract* on every recomposition.
395 val node = model.node
396 val isFenced = node.type == MarkdownElementTypes.CODE_FENCE
397 val raw = remember(model.content, node.startOffset, node.endOffset) {
398 node.getTextInNode(model.content).toString()
400 val code = remember(raw, isFenced) { extractCodeText(raw, isFenced) }
401 val language = remember(raw, isFenced) { extractFenceLanguage(raw, isFenced) }
402 val codeStyle = model.typography.code.copy(fontFamily = FontFamily.Monospace)
403 // A long code fence can carry hundreds of lines; computing lineCount walks the string
404 // once, memoized so a recomposition that doesn't change `code` (e.g. a wrap/expand flip)
405 // doesn't re-walk it.
406 val lineCount = remember(code) { code.count { it == '\n' } + 1 }
407 val canCollapse = lineCount > NetworkConfig.collapsedCodeLineThreshold
408 // Key expand state on the content so a growing (streaming) block re-seeds it. Collapsing
409 // primarily benefits completed messages; a streaming block that crosses the cap may snap
410 // to collapsed on the token that crosses it, which is acceptable (the head stays visible).
411 var expanded by rememberSaveable(code) { mutableStateOf(false) }
412 val displayCode = if (canCollapse && !expanded) {
413 remember(code, expanded) {
414 code.lineSequence().take(NetworkConfig.collapsedCodeLineThreshold).joinToString("\n")
419 // Heuristic syntax highlighting, reusing the same highlighter the file viewer uses.
420 // Resolved from the fence's info string (e.g. "kotlin"), not a filename. Memoized so a
421 // scroll-induced recomposition doesn't re-tokenize; during streaming the MarkdownBody
422 // throttle coalesces re-highlights. Falls back to plain text for unknown/missing tags
423 // (NONE) so un-tagged fences render unchanged. Re-derived when displayCode changes so a
424 // collapsed head and the full expanded block are both highlighted correctly.
425 val palette = rememberHighlightPalette()
426 val syntax = remember(language) { language?.let { syntaxForLanguageTag(it) } }
427 val highlighted = remember(displayCode, syntax, palette) {
428 if (syntax != null) highlightCode(displayCode, syntax, palette) else AnnotatedString(displayCode)
430 // Default per-block wrap from the global preference; re-seed if the preference changes
431 // while this block is composed (rememberSaveable would over-persist across blocks).
432 val defaultWrap = LocalCodeWrap.current
433 var wrap by remember(defaultWrap) { mutableStateOf(defaultWrap) }
434 // Briefly flip the copy button to a checkmark after a successful copy so the user gets
435 // visible confirmation even on Android 13+, where the copy toast is suppressed in favor of
436 // the platform's tiny (and easy to miss) confirmation chip.
437 var copied by remember { mutableStateOf(false) }
438 val haptics = LocalHapticFeedback.current
439 LaunchedEffect(copied) {
441 delay(NetworkConfig.copyFeedbackMs.toLong())
448 .clip(MaterialTheme.shapes.small)
449 .background(MaterialTheme.colorScheme.surfaceVariant),
454 .padding(start = 12.dp, end = 2.dp),
455 verticalAlignment = Alignment.CenterVertically,
458 language ?: context.getString(R.string.code),
459 style = MaterialTheme.typography.labelSmall,
460 color = MaterialTheme.colorScheme.onSurfaceVariant,
461 modifier = Modifier.weight(1f),
464 val moreLines = lineCount - NetworkConfig.collapsedCodeLineThreshold
465 IconButton(onClick = { expanded = !expanded }) {
467 if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
468 contentDescription = if (expanded) context.getString(R.string.show_less)
469 else context.resources.getQuantityString(R.plurals.show_more_lines, moreLines, moreLines),
470 modifier = Modifier.size(18.dp),
471 tint = MaterialTheme.colorScheme.onSurfaceVariant,
475 val wrapLabel = context.getString(if (wrap) R.string.code_no_wrap else R.string.code_wrap)
476 IconButton(onClick = { wrap = !wrap }) {
478 Icons.AutoMirrored.Filled.WrapText,
479 contentDescription = wrapLabel,
480 modifier = Modifier.size(18.dp),
481 tint = if (wrap) MaterialTheme.colorScheme.primary
482 else MaterialTheme.colorScheme.onSurfaceVariant,
487 copyToClipboard(context, context.getString(R.string.clip_label_code), code)
489 // Haptic to match every other copy affordance in the app (the code-fence
490 // copy is the most-used copy action in a coding assistant).
491 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
495 if (copied) Icons.Filled.Check else Icons.Filled.ContentCopy,
496 contentDescription = if (copied) context.getString(R.string.copied)
497 else context.getString(R.string.copy),
498 modifier = Modifier.size(18.dp),
499 tint = if (copied) MaterialTheme.colorScheme.primary
500 else MaterialTheme.colorScheme.onSurfaceVariant,
504 // Wrap → let the Text wrap naturally (no horizontal scroll). No-wrap → horizontal
505 // scroll so long lines keep their formatting. Selectable so a portion can be copied.
510 modifier = if (wrap) {
511 Modifier.fillMaxWidth().padding(start = 12.dp, end = 12.dp, bottom = 12.dp)
514 .horizontalScroll(rememberScrollState())
516 .padding(start = 12.dp, end = 12.dp, bottom = 12.dp)
520 if (canCollapse && !expanded) {
521 val hidden = lineCount - NetworkConfig.collapsedCodeLineThreshold
523 onClick = { expanded = true },
524 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 12.dp),
526 .defaultMinSize(minHeight = 48.dp)
527 .padding(bottom = 4.dp),
529 Text(pluralStringResource(R.plurals.show_more_lines, hidden, hidden))
536 * Custom markdown image component that routes `` through [RemoteImage] so
537 * server-relative image URLs get the same Basic auth + same-origin handling as image
538 * attachments. The library's default image loader has no notion of the opencode server's
539 * auth, so without this a `` in an assistant reply would fail
540 * to load. When the URL can't be extracted or doesn't resolve to a loadable model, the
541 * alt text is shown as a muted placeholder so the image isn't silently invisible.
544private fun MarkdownImage(model: MarkdownComponentModel, ctx: ImageLoadContext) {
545 val node = model.node
546 val content = model.content
547 // Extract the URL from the IMAGE node's LINK_DESTINATION child, and the alt text from
548 // the LINK_TEXT child. The AST structure for `` is:
549 // IMAGE -> [LINK_LABEL, LPAREN, LINK_DESTINATION, RPAREN] (and LINK_TEXT nested)
550 val (url, alt) = remember(node, content) { extractImageUrlAndAlt(node, content) }
551 if (url.isNullOrBlank()) {
552 // No URL — show the alt text (or a placeholder) so the image isn't invisible. The
553 // earlier `"[$alt]".ifBlank { "[image]" }` was a bug: when alt is blank, "[$alt]" is
554 // "[]" — a non-blank string — so the [image] fallback never fired and the user saw "[]".
555 val placeholder = if (alt.isNotBlank()) alt else stringResource(R.string.markdown_image_placeholder)
558 style = MaterialTheme.typography.bodyMedium,
559 color = MaterialTheme.colorScheme.onSurfaceVariant,
560 modifier = Modifier.padding(vertical = 4.dp),
564 // Build a FilePart so RemoteImage's existing resolution (data URI, same-origin, auth)
565 // handles the URL. The part carries the URL and alt text as the filename.
566 val part = FilePart(url = url, filename = alt.ifBlank { null })
567 RemoteImage(part = part, ctx = ctx, modifier = Modifier.fillMaxWidth())
570/** Extract the URL and alt text from a markdown IMAGE AST node. Returns (url, alt) where
571 * url may be null when no LINK_DESTINATION child is present. */
572private fun extractImageUrlAndAlt(node: ASTNode, content: String): Pair<String?, String> {
573 var url: String? = null
575 for (child in node.children) {
577 MarkdownElementTypes.LINK_DESTINATION -> {
578 url = child.getTextInNode(content).toString().trim().ifBlank { null }
580 MarkdownElementTypes.LINK_TEXT -> {
581 alt = child.getTextInNode(content).toString()
582 .removePrefix("[").removeSuffix("]").trim()
590 * A small blinking caret shown at the tail of a streaming assistant reply for a "live"
591 * typewriter feel. Respects reduced motion (callers gate it). The blink is a gentle 1s alpha
592 * pulse; the caret sits inline at the leading edge so it reads as the next character arriving.
595private fun StreamingCaret() {
596 val transition = rememberInfiniteTransition(label = "caret")
597 val alpha by transition.animateFloat(
600 animationSpec = infiniteRepeatable(tween(NetworkConfig.streamingCaretPeriodMs), RepeatMode.Reverse),
601 label = "caretAlpha",
605 style = MaterialTheme.typography.bodyLarge,
606 color = MaterialTheme.colorScheme.primary.copy(alpha = alpha),
607 modifier = Modifier.semantics { invisibleToUser() },
612 * Extract the fence language tag (e.g. "kotlin" from ```kotlin) so the code block can
613 * label itself. Returns null for indented blocks or fences with no info string. Extracted
614 * as a pure function for testability.
616internal fun extractFenceLanguage(raw: String, isFenced: Boolean): String? {
617 if (!isFenced) return null
618 val first = raw.lineSequence().firstOrNull()?.trim() ?: return null
619 // Strip the whole opening fence run (CommonMark allows 3+ backticks or tildes, e.g.
620 // ````kotlin), not a fixed 3 chars, before reading the info string.
621 val info = first.trimStart('`', '~').trim()
622 // A fence info string can carry more than the language (e.g. "```ts title=foo"); the
623 // first whitespace-delimited token is the language. Split on ANY whitespace (CommonMark
624 // ends the language at the first whitespace char), not just a space, so a tab-delimited
625 // info string ("```ts\ttitle=foo") still yields "ts" rather than the whole run.
626 return info.takeWhile { !it.isWhitespace() }.takeIf { it.isNotBlank() }
630 * Strip fence markers (```/~~~ and the language tag) from a raw code node text so
631 * only the code content is returned. Extracted as a pure function for testability.
633 * @param raw the raw text of the code node (including fence markers for fenced blocks)
634 * @param isFenced true for CODE_FENCE nodes, false for indented CODE_BLOCK nodes
636internal fun extractCodeText(raw: String, isFenced: Boolean): String {
637 if (!isFenced) return raw.trimIndent()
638 val body = raw.lines().drop(1).toMutableList()
639 // Strip the closing fence. getTextInNode can include a trailing newline, making the true last
640 // line "" — so locate the last NON-blank line and drop from there, rather than only checking
641 // body.last() (which would miss the fence in that case and leak the closing ```/~~~ into the
642 // displayed/copied code).
643 val lastNonBlank = body.indexOfLast { it.isNotBlank() }
644 // CommonMark allows the closing fence to be indented up to 3 spaces, and a fence nested inside
645 // a list item keeps that indentation in the node's raw text — so trim leading whitespace before
646 // the marker check. Without it, an indented closing ```/~~~ fails startsWith and leaks into the
647 // displayed/copied code (the same leak a prior fix closed for the trailing-newline case).
648 val closer = if (lastNonBlank >= 0) body[lastNonBlank].trimStart() else ""
649 if (lastNonBlank >= 0 && (closer.startsWith("```") || closer.startsWith("~~~"))) {
650 while (body.size > lastNonBlank) body.removeAt(body.lastIndex)
652 return body.joinToString("\n").trimEnd()
655/** Copy [text] to the system clipboard and show a confirmation toast (except on Android 13+,
656 * which renders its own system copy confirmation — a toast there would be a redundant double). */
657internal fun copyToClipboard(context: Context, label: String, text: String = label) {
658 val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager ?: return
659 clipboard.setPrimaryClip(ClipData.newPlainText(label.take(40), text))
660 showCopyToast(context, context.getString(R.string.copied))
663/** Toast that self-suppresses on Android 13+ (Build.VERSION_CODES.TIRAMISU), where the platform
664 * already shows a copy confirmation. Use for clipboard-copy feedback so it isn't doubled. */
665internal fun showCopyToast(context: Context, message: String) {
666 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) return
667 showToast(context, message)
670private var lastToast: Toast? = null
672internal fun showToast(context: Context, message: String) {
674 lastToast = Toast.makeText(context.applicationContext, message, Toast.LENGTH_SHORT).also { it.show() }
keysesc to close
- j k
- move down, up
- enter
- open
- esc
- back, or up a level
- 1 2 3 4
- readme, files, commits, refs
- /
- filter
- y
- copy the clone command
- c
- compare the chosen commit with another (or shift-click both)
- ?
- show or hide this list