package soy.iko.opencode.ui.components import androidx.compose.animation.animateContentSize import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.ExpandLess import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.heading import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import soy.iko.opencode.R import soy.iko.opencode.data.network.NetworkConfig import soy.iko.opencode.ui.theme.diffColors import soy.iko.opencode.util.runCatchingCancellable /** A single line of a parsed unified diff. */ sealed interface DiffLine { val text: String data class Hunk(override val text: String) : DiffLine // @@ ... @@ data class FileHeader(override val text: String) : DiffLine // --- / +++ / diff / index data class Context(override val text: String) : DiffLine data class Add(override val text: String) : DiffLine data class Remove(override val text: String) : DiffLine data class Meta(override val text: String) : DiffLine // git metadata (new file mode, etc.) } /** Git metadata line prefixes that aren't part of the diff content. */ private val gitMetaPrefixes = listOf( "new file mode ", "deleted file mode ", "old mode ", "new mode ", "similarity index ", "dissimilarity index ", "rename from ", "rename to ", "copy from ", "copy to ", "Binary files ", "\\ No newline", ) /** Does [raw] look like a real unified-diff file header ("--- a/…", "+++ b/…", "--- /dev/null") * rather than a removed/added content line that merely starts with "--- "/"+++ "? */ private fun isDiffFileHeader(raw: String): Boolean { val rest = when { raw.startsWith("--- ") -> raw.substring(4) raw.startsWith("+++ ") -> raw.substring(4) else -> return false } // Header paths are "a/…", "b/…", or the literal "/dev/null" (optionally followed by a // tab/space-separated timestamp). Restricting the absolute-path case to /dev/null avoids // misclassifying a removed content line like "-- /usr/local/foo" (on the wire // "--- /usr/local/foo"), whose rest starts with "/", as a file header. return rest.startsWith("a/") || rest.startsWith("b/") || rest == "/dev/null" || rest.startsWith("/dev/null\t") || rest.startsWith("/dev/null ") } /** Parse a unified diff string into typed [DiffLine]s. */ fun parseDiff(diff: String): List { val result = mutableListOf() for (raw in diff.lineSequence()) { when { raw.startsWith("@@") -> result.add(DiffLine.Hunk(raw)) // Unified-diff file headers are "--- " / "+++ " where the path is // "a/…", "b/…", or an absolute path (e.g. "/dev/null"). Requiring that the marker // be followed by "a/", "b/", or "/" avoids misclassifying removed/added *content* // lines like "-- TODO"/"++ x" — which appear on the wire as "--- TODO"/"+++ x" — // as headers; those fall through to the +/- branches below. isDiffFileHeader(raw) -> result.add(DiffLine.FileHeader(raw)) raw.startsWith("+") -> result.add(DiffLine.Add(raw.removePrefix("+"))) raw.startsWith("-") -> result.add(DiffLine.Remove(raw.removePrefix("-"))) raw.startsWith(" ") -> result.add(DiffLine.Context(raw.removePrefix(" "))) raw.startsWith("diff ") || raw.startsWith("index ") -> result.add(DiffLine.FileHeader(raw)) gitMetaPrefixes.any { raw.startsWith(it) } -> result.add(DiffLine.Meta(raw)) raw.isNotBlank() -> result.add(DiffLine.Context(raw)) } } return result } /** Heuristic: does this string look like a unified diff? */ fun looksLikeDiff(text: String): Boolean { val lines = text.lineSequence() var hunkCount = 0 var addCount = 0 var removeCount = 0 for (line in lines) { when { line.startsWith("@@") -> hunkCount++ line.startsWith("+") && !line.startsWith("+++") -> addCount++ line.startsWith("-") && !line.startsWith("---") -> removeCount++ } if (hunkCount >= 1 && (addCount + removeCount) >= 1) return true } return false } /** Extract a display path from a `+++ b/path` / `--- a/path` / `diff --git a/x b/y` header. * Returns the raw line when no path can be extracted, so the header still shows something. */ private fun extractDisplayPath(headerText: String): String { // `diff --git a/foo b/bar` → `bar` (the new path). `+++ b/foo` → `foo`. `--- a/foo` → `foo`. return when { headerText.startsWith("diff --git ") -> { // Grab the second path after " b/"; fall back to the first after " a/". val bIdx = headerText.indexOf(" b/") if (bIdx >= 0) headerText.substring(bIdx + 3).trim() else headerText.substringAfter(" a/", headerText) } headerText.startsWith("+++ ") -> headerText.substring(4).substringAfter("b/", headerText.substring(4)).trimEnd() headerText.startsWith("--- ") -> headerText.substring(4).substringAfter("a/", headerText.substring(4)).trimEnd() else -> headerText } } /** Resolve a [FileSyntax] from a diff file header's path, for per-line highlighting. Returns * null when the path has no recognizable extension (so the diff renders plain, as before). */ private fun syntaxForHeader(headerText: String): FileSyntax? { val path = extractDisplayPath(headerText) if (path == headerText) return null val filename = path.substringAfterLast('/') val syntax = syntaxFor(filename) return if (syntax.lang == Language.NONE) null else syntax } /** * Renders a parsed unified diff. Implemented as a plain (non-lazy) [Column] so it is * safe to embed inside another vertically scrolling container (e.g. the chat message * list) — a nested `LazyColumn` would crash with an unbounded-height constraint. * Callers that want the view to scroll on its own (e.g. the file viewer) should pass a * [Modifier.verticalScroll] in [modifier]. * * To avoid composing thousands of [Text] nodes at once (which can ANR/OOM on large * diffs), only the first [NetworkConfig.collapsedDiffLineThreshold] lines are rendered * unless the user expands the view. Each hunk can also be collapsed individually via * its `@@` header. * * The [parseDiff] + [buildDiffRows] work runs off the main thread (via [rememberDiffRows]) * so a streaming diff that grows on every throttle commit doesn't tokenize + walk the * whole string on the main thread ~20x/sec. The first frame uses an empty (or cached) * row list so the header renders immediately; rows swap in once the off-main pass lands. */ @Composable fun DiffView(diff: String, modifier: Modifier = Modifier, saveKey: String? = null) { val scheme = MaterialTheme.colorScheme val diffColors = diffColors() val addColor = remember(scheme) { diffColors.addBg } val removeColor = remember(scheme) { diffColors.removeBg } val addText = diffColors.addText val removeText = diffColors.removeText val context = LocalContext.current val hScrollState = rememberScrollState() // Key on a stable id (the part id) when available so streaming updates that grow `diff` // don't reset expand state on every token. Fall back to `diff` for non-streaming callers // (e.g. FileViewScreen) where the content is static. var expanded by rememberSaveable(saveKey ?: diff) { mutableStateOf(false) } // Per-hunk collapse state: a set of hunk indices the user has collapsed via its `@@` // header. Keyed on the hunk's ordinal position in `lines` so two hunks with the same // `@@` range text (rare but possible) are still distinct toggles. The KDoc promised this // affordance; it's now implemented. val collapsedHunks = remember { mutableStateOf(emptySet()) } val palette = rememberHighlightPalette() // Parse + build rows off-main. The row list is recomputed when `diff` grows (streaming) // or expand/collapse changes, but the heavy walk runs on Dispatchers.Default so the // main thread stays free for layout/draw. First frame: empty rows (header renders, body // fills in a frame later). For a static diff (file viewer, revert banner) the off-main // pass lands within a frame or two and is then stable. val rows = rememberDiffRows( diff = diff, expanded = expanded, collapsedHunks = collapsedHunks.value, addColor = addColor, removeColor = removeColor, addText = addText, removeText = removeText, tertiary = scheme.tertiary, onSurface = scheme.onSurface, onSurfaceVariant = scheme.onSurfaceVariant, ) Column( modifier = modifier .fillMaxWidth() .clip(MaterialTheme.shapes.small) .background(MaterialTheme.colorScheme.surfaceVariant), ) { DiffHeader( lineCount = rows.lineCount, expanded = expanded, onToggle = { expanded = !expanded }, onCopy = { copyToClipboard(context, context.getString(R.string.clip_label_diff), diff) }, ) Column(modifier = Modifier.horizontalScroll(hScrollState)) { rows.rows.forEach { row -> RenderDiffRow(row, palette, collapsedHunks) } } DiffExpandFooter(lineCount = rows.lineCount, expanded = expanded, onToggle = { expanded = !expanded }) } } /** * Lazy variant of [DiffView] for callers that need the diff to scroll on its own (e.g. the * file viewer) WITHOUT an enclosing verticalScroll. A 5000-line diff in [DiffView] would * compose every row eagerly; this variant uses a [LazyColumn] so only the visible rows * compose. The collapse threshold still applies for the *initial* view, but expanding * switches to lazy rendering so a huge expanded diff doesn't OOM. */ @Composable fun LazyDiffView(diff: String, modifier: Modifier = Modifier, saveKey: String? = null) { val scheme = MaterialTheme.colorScheme val diffColors = diffColors() val addColor = remember(scheme) { diffColors.addBg } val removeColor = remember(scheme) { diffColors.removeBg } val addText = diffColors.addText val removeText = diffColors.removeText val context = LocalContext.current val hScrollState = rememberScrollState() var expanded by rememberSaveable(saveKey ?: diff) { mutableStateOf(false) } val collapsedHunks = remember { mutableStateOf(emptySet()) } val palette = rememberHighlightPalette() val lazyListState = androidx.compose.foundation.lazy.rememberLazyListState() val rows = rememberDiffRows( diff = diff, expanded = expanded, collapsedHunks = collapsedHunks.value, addColor = addColor, removeColor = removeColor, addText = addText, removeText = removeText, tertiary = scheme.tertiary, onSurface = scheme.onSurface, onSurfaceVariant = scheme.onSurfaceVariant, ) Column( modifier = modifier .fillMaxWidth() .clip(MaterialTheme.shapes.small) .background(MaterialTheme.colorScheme.surfaceVariant), ) { DiffHeader( lineCount = rows.lineCount, expanded = expanded, onToggle = { expanded = !expanded }, onCopy = { copyToClipboard(context, context.getString(R.string.clip_label_diff), diff) }, ) // One horizontalScroll wrapping the LazyColumn synchronizes horizontal panning across // visible rows (the same trick DiffView uses on its Column). LazyColumn handles the // vertical scrolling. LazyColumn( state = lazyListState, modifier = Modifier.horizontalScroll(hScrollState), ) { itemsIndexed( rows.rows, key = { i, row -> "${row.kind::class.simpleName}:$i:${row.text.hashCode()}" }, ) { _, row -> RenderDiffRow(row, palette, collapsedHunks) } } DiffExpandFooter(lineCount = rows.lineCount, expanded = expanded, onToggle = { expanded = !expanded }) } } /** * The parsed + pre-resolved rows for a diff, plus the total line count (for the * expand/collapse footer, which reports "N more lines" against the full parsed size, * not just the visible row count). */ private data class DiffRows(val rows: List, val lineCount: Int) /** * Parse [diff] and build the pre-resolved [DiffRowItem] list off the main thread. * * The parse ([parseDiff]) walks the whole diff string, and [buildDiffRows] walks every * visible line advancing line counters + resolving per-file syntax — together they're * O(lines). During a streaming diff, `diff` grows on every ~50ms throttle commit, so * running these on the main thread re-walks the whole growing string ~20x/sec. Moving * them to `Dispatchers.Default` keeps the main thread free for layout/draw. * * First frame: an empty row list + a line count of 0 (the header renders, the body fills * in a frame or two later once the off-main pass lands). For a static diff the off-main * pass is fast and the result is then stable across recompositions (the underlying * `produceState` is keyed on the inputs, so a recompute only happens when `diff`/expand/ * collapse/colors actually change). * * The per-row syntax highlighting ([DiffRow]'s `rememberHighlightedCode`) is separately * off-main and cross-message cached, so the per-row cost on the main thread is a cache * lookup. */ @Composable private fun rememberDiffRows( diff: String, expanded: Boolean, collapsedHunks: Set, addColor: Color, removeColor: Color, addText: Color, removeText: Color, tertiary: Color, onSurface: Color, onSurfaceVariant: Color, ): DiffRows = produceState(DiffRows(emptyList(), 0), diff, expanded, collapsedHunks) { val result = withContext(Dispatchers.Default) { runCatchingCancellable { val lines = parseDiff(diff) val thresholdCollapsed = lines.size > NetworkConfig.collapsedDiffLineThreshold && !expanded val baseLines = if (thresholdCollapsed) { lines.subList(0, NetworkConfig.collapsedDiffLineThreshold) } else { lines } val hunkIndices = hunkStartIndices(lines) val visibleLines = if (collapsedHunks.isEmpty()) baseLines else filterCollapsedHunks(baseLines, hunkIndices, collapsedHunks) DiffRows( rows = buildDiffRows( visibleLines, addColor, removeColor, addText, removeText, tertiary, onSurface, onSurfaceVariant, ), lineCount = lines.size, ) }.getOrDefault(DiffRows(emptyList(), 0)) } value = result }.value /** Header row with the expand/collapse toggle (when applicable) and a copy action. */ @Composable private fun DiffHeader(lineCount: Int, expanded: Boolean, onToggle: () -> Unit, onCopy: () -> Unit) { val context = LocalContext.current Row( modifier = Modifier.fillMaxWidth().padding(top = 2.dp, start = 4.dp, end = 4.dp), verticalAlignment = Alignment.CenterVertically, ) { if (lineCount > NetworkConfig.collapsedDiffLineThreshold) { val moreLines = lineCount - NetworkConfig.collapsedDiffLineThreshold IconButton(onClick = onToggle) { Icon( if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore, contentDescription = if (expanded) stringResource(R.string.show_less) else context.resources.getQuantityString( R.plurals.show_more_lines, moreLines, moreLines, ), tint = MaterialTheme.colorScheme.onSurfaceVariant, ) } } Spacer(Modifier.weight(1f)) IconButton(onClick = onCopy) { Icon(Icons.Filled.ContentCopy, contentDescription = stringResource(R.string.copy), tint = MaterialTheme.colorScheme.onSurfaceVariant) } } } /** Footer "show more/less" button when the diff was collapsed. */ @Composable private fun DiffExpandFooter(lineCount: Int, expanded: Boolean, onToggle: () -> Unit) { if (lineCount > NetworkConfig.collapsedDiffLineThreshold) { val hidden = lineCount - NetworkConfig.collapsedDiffLineThreshold TextButton( onClick = onToggle, contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 10.dp), modifier = Modifier.defaultMinSize(minHeight = 48.dp), ) { Text( if (expanded) stringResource(R.string.show_less) else pluralStringResource(R.plurals.show_more_lines, hidden, hidden), ) } } } /** Map each hunk ordinal (0, 1, 2, …) to its index in [lines]. Used to find the boundaries * of a collapsed hunk: a hunk spans from its start index up to the next hunk's start (or the * end of the list). File headers and meta lines before the first hunk are not hunk members. */ private fun hunkStartIndices(lines: List): Map { val map = LinkedHashMap() var hunkOrdinal = 0 lines.forEachIndexed { i, line -> if (line is DiffLine.Hunk) { map[hunkOrdinal] = i hunkOrdinal++ } } return map } /** Drop the content lines (Context/Add/Remove/Meta-after-hunk) of each collapsed hunk while * keeping the hunk's own `@@` header row so the user has a handle to re-expand. A line is * dropped when its [DiffRowItem.hunkIndex] is in [collapsed] AND it's not the Hunk header * itself (the header carries the same hunkIndex but its kind is Hunk, which we keep). */ private fun filterCollapsedHunks( baseLines: List, hunkIndices: Map, collapsed: Set, ): List { if (collapsed.isEmpty()) return baseLines // For each collapsed hunk, compute [start, end) where end is the next hunk's start or the // list size. We keep the header (at start) and drop the rest of the range. val dropRanges = collapsed.mapNotNull { ordinal -> val start = hunkIndices[ordinal] ?: return@mapNotNull null val end = hunkIndices.entries .firstOrNull { it.value > start }?.value ?: baseLines.size (start + 1) until end } val dropSet = dropRanges.flatMap { it }.toHashSet() return baseLines.filterIndexed { i, _ -> i !in dropSet } } /** Cached labels for the per-hunk collapse `stateDescription` (resolved once per composition * rather than per row to avoid a per-row string lookup on a potentially huge diff). */ private val stateCollapsedLabel: String get() = "Collapsed" private val stateExpandedLabel: String get() = "Expanded" /** A pre-resolved diff row carrying its kind, text, line numbers, and resolved colors. This * lets both [DiffView] and [LazyDiffView] share the rendering path without re-deriving the * per-row state (line numbers advance as a side effect of walking the list). [hunkIndex] is * the ordinal of the hunk this row belongs to (null for pre-hunk file headers/meta), used * by [RenderDiffRow] to toggle per-hunk collapse. */ private data class DiffRowItem( val kind: DiffLine, val text: String, val bg: Color, val textColor: Color, val oldLine: Int?, val newLine: Int?, val hunkIndex: Int?, // The syntax resolved from the file header this row belongs to. A multi-file diff switches // language at each FileHeader; carrying syntax per row (instead of one for the whole diff) // ensures a later file's hunk is highlighted with its own language, not the first file's. val syntax: FileSyntax?, ) /** Walk [visibleLines] advancing the old/new line counters per hunk, producing a list of * [DiffRowItem]s that the renderers can iterate without per-row state. Theme colors for the * non-add/remove rows (tertiary for hunk headers, onSurface/onSurfaceVariant for context * and meta) are passed in by the @Composable caller so the row-builder stays a plain * function that doesn't read MaterialTheme directly. */ private fun buildDiffRows( visibleLines: List, addColor: Color, removeColor: Color, addText: Color, removeText: Color, tertiary: Color, onSurface: Color, onSurfaceVariant: Color, ): List { var oldLine = 0 var newLine = 0 var hunkIndex = -1 // Track the current file's syntax as we walk the list, re-resolving at each FileHeader so // a multi-file diff highlights each hunk with its own language instead of the first file's. var currentSyntax: FileSyntax? = null val hunkRegex = Regex("""@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@""") return visibleLines.map { line -> when (line) { is DiffLine.Hunk -> { hunkIndex++ val m = hunkRegex.find(line.text) if (m != null) { oldLine = m.groupValues[1].toInt() newLine = m.groupValues[2].toInt() } DiffRowItem(line, line.text, Color.Transparent, tertiary, null, null, hunkIndex, currentSyntax) } is DiffLine.FileHeader -> { currentSyntax = syntaxForHeader(line.text) DiffRowItem(line, line.text, Color.Transparent, onSurface, null, null, null, currentSyntax) } is DiffLine.Meta -> DiffRowItem(line, line.text, Color.Transparent, onSurfaceVariant, null, null, null, currentSyntax) is DiffLine.Add -> { val n = newLine++ DiffRowItem(line, line.text, addColor, addText, null, n, hunkIndex, currentSyntax) } is DiffLine.Remove -> { val o = oldLine++ DiffRowItem(line, line.text, removeColor, removeText, o, null, hunkIndex, currentSyntax) } is DiffLine.Context -> { val o = oldLine++; val n = newLine++ DiffRowItem(line, line.text, Color.Transparent, onSurface, o, n, hunkIndex, currentSyntax) } } } } /** Render a single [DiffRowItem]. Hunk and FileHeader rows get their own styling; Add/Remove/ * Context delegate to [DiffRow]. The Hunk row is clickable to toggle per-hunk collapse via * [collapsedHunks]. */ @Composable private fun RenderDiffRow( row: DiffRowItem, palette: HighlightPalette, collapsedHunks: androidx.compose.runtime.MutableState>, ) { when (row.kind) { is DiffLine.Hunk -> { val idx = row.hunkIndex val isCollapsed = idx != null && idx in collapsedHunks.value val collapseLabel = stringResource(if (isCollapsed) R.string.show_more else R.string.show_less) Row( modifier = Modifier .fillMaxWidth() .defaultMinSize(minHeight = 32.dp) .clickable(enabled = idx != null) { if (idx != null) { collapsedHunks.value = if (idx in collapsedHunks.value) { collapsedHunks.value - idx } else { collapsedHunks.value + idx } } } .padding(horizontal = 10.dp, vertical = 2.dp) .semantics { contentDescription = collapseLabel stateDescription = if (isCollapsed) stateCollapsedLabel else stateExpandedLabel }, verticalAlignment = Alignment.CenterVertically, ) { Icon( if (isCollapsed) Icons.Filled.ExpandMore else Icons.Filled.ExpandLess, contentDescription = null, modifier = Modifier.size(16.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant, ) Text( row.text, style = MaterialTheme.typography.bodySmall, fontFamily = FontFamily.Monospace, color = MaterialTheme.colorScheme.tertiary, modifier = Modifier.padding(start = 4.dp), ) } } is DiffLine.FileHeader -> { val displayPath = extractDisplayPath(row.text) val isHeader = displayPath != row.text Text( displayPath, style = MaterialTheme.typography.labelMedium, fontFamily = FontFamily.Monospace, fontWeight = if (isHeader) FontWeight.SemiBold else FontWeight.Normal, color = MaterialTheme.colorScheme.onSurface, modifier = Modifier .fillMaxWidth() .padding(horizontal = 10.dp, vertical = 3.dp) .semantics { heading() }, ) } is DiffLine.Meta -> Text( row.text, style = MaterialTheme.typography.bodySmall, fontFamily = FontFamily.Monospace, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(vertical = 1.dp), ) is DiffLine.Add -> DiffRow(row.text, "+", row.bg, row.textColor, oldLine = row.oldLine, newLine = row.newLine, syntax = row.syntax, palette = palette) is DiffLine.Remove -> DiffRow(row.text, "-", row.bg, row.textColor, oldLine = row.oldLine, newLine = row.newLine, syntax = row.syntax, palette = palette) is DiffLine.Context -> DiffRow(row.text, " ", row.bg, row.textColor, oldLine = row.oldLine, newLine = row.newLine, syntax = row.syntax, palette = palette) } } @Composable private fun DiffRow( text: String, prefix: String, bg: Color, textColor: androidx.compose.ui.graphics.Color, oldLine: Int? = null, newLine: Int? = null, syntax: FileSyntax? = null, palette: HighlightPalette, ) { val gutterColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) // Highlight the line's content (minus the leading +/-/space prefix, which DiffView // already stripped) when a syntax was resolved from a file header. Falls back to plain // monospace text when no syntax is known (e.g. an untagged patch), preserving the prior // rendering for diffs that can't be classified. Off-main + cross-message cached so a // re-displayed row (scroll recycle, theme switch) is a hash hit instead of a re-tokenize. val highlighted: AnnotatedString = rememberHighlightedCode(text, syntax, palette) val a11yLabel = when (prefix) { "+" -> stringResource(R.string.diff_added_line, newLine ?: 0, text) "-" -> stringResource(R.string.diff_removed_line, oldLine ?: 0, text) else -> stringResource(R.string.diff_context_line, oldLine ?: 0, text) } Row( modifier = Modifier .background(bg) .semantics(mergeDescendants = true) { contentDescription = a11yLabel }, ) { // Old/new line-number gutters. Blank (not 0) for the side that doesn't have a number // (an added line has no old number; a removed line has no new number) so the columns // stay aligned. Text( oldLine?.toString() ?: "", style = MaterialTheme.typography.bodySmall, fontFamily = FontFamily.Monospace, color = gutterColor, modifier = Modifier.width(NetworkConfig.diffGutterWidthDp.dp), ) Text( newLine?.toString() ?: "", style = MaterialTheme.typography.bodySmall, fontFamily = FontFamily.Monospace, color = gutterColor, modifier = Modifier.width(NetworkConfig.diffGutterWidthDp.dp), ) Text( prefix, style = MaterialTheme.typography.bodySmall, fontFamily = FontFamily.Monospace, color = textColor, modifier = Modifier.width(NetworkConfig.diffPrefixWidthDp.dp), ) Text( highlighted, style = MaterialTheme.typography.bodySmall, fontFamily = FontFamily.Monospace, color = textColor, ) } }