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.width 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.getValue import androidx.compose.runtime.mutableStateOf 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.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 soy.iko.opencode.R import soy.iko.opencode.data.network.NetworkConfig /** 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. */ @Composable fun DiffView(diff: String, modifier: Modifier = Modifier, saveKey: String? = null) { val lines = remember(diff) { parseDiff(diff) } // Derive the four row colors once per color scheme instead of allocating fresh Color // objects (the .copy() calls) on every recomposition — a diff re-parses/recomposes // per token while its output streams under the collapse limit. val scheme = MaterialTheme.colorScheme val addColor = remember(scheme) { scheme.primary.copy(alpha = 0.15f) } val removeColor = remember(scheme) { scheme.error.copy(alpha = 0.15f) } val addText = scheme.primary val removeText = scheme.error 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) } // Resolve a syntax for highlighting from the first file header (if any). Memoized so a // streaming re-parse doesn't re-resolve per token. val syntax = remember(lines) { lines.firstOrNull { it is DiffLine.FileHeader }?.let { syntaxForHeader(it.text) } } val palette = rememberHighlightPalette() Column( modifier = modifier .fillMaxWidth() .clip(MaterialTheme.shapes.small) .background(MaterialTheme.colorScheme.surfaceVariant), ) { Row( modifier = Modifier.fillMaxWidth().padding(top = 2.dp, start = 4.dp, end = 4.dp), verticalAlignment = Alignment.CenterVertically, ) { // Expand/collapse toggle in the header (matching CodeWithCopy's pattern) so the // user doesn't have to scroll to the bottom of a collapsed diff to expand it. if (lines.size > NetworkConfig.collapsedDiffLineThreshold) { val moreLines = lines.size - NetworkConfig.collapsedDiffLineThreshold IconButton(onClick = { expanded = !expanded }) { 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 = { copyToClipboard(context, context.getString(R.string.clip_label_diff), diff) }) { Icon(Icons.Filled.ContentCopy, contentDescription = stringResource(R.string.copy), tint = MaterialTheme.colorScheme.onSurfaceVariant) } } val visibleLines = if (lines.size <= NetworkConfig.collapsedDiffLineThreshold || expanded) lines else lines.subList(0, NetworkConfig.collapsedDiffLineThreshold) // One horizontalScroll on the container instead of one per row: each modifier // adds a layout node + clip + offset pass, so a 200-line collapsed diff was // paying for 200 of them. The shared scroll state still synchronizes all rows. // oldLine/newLine track the current line numbers derived from each @@ hunk header // so the gutter can show absolute line numbers (a big readability win for review). var oldLine = 0 var newLine = 0 val hunkRegex = Regex("""@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@""") Column(modifier = Modifier.horizontalScroll(hScrollState)) { visibleLines.forEach { line -> when (line) { is DiffLine.Hunk -> { val m = hunkRegex.find(line.text) if (m != null) { oldLine = m.groupValues[1].toInt() newLine = m.groupValues[2].toInt() } Text( line.text, style = MaterialTheme.typography.bodySmall, fontFamily = FontFamily.Monospace, color = MaterialTheme.colorScheme.tertiary, modifier = Modifier.padding(horizontal = 10.dp, vertical = 2.dp), ) } is DiffLine.FileHeader -> { // Render the display path (e.g. `src/Foo.kt`) as a bold header rather // than the raw `+++ b/src/Foo.kt`, so file boundaries are scannable in // a long multi-file diff. The raw line is still copied with the diff. val displayPath = extractDisplayPath(line.text) val isHeader = displayPath != line.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( line.text, style = MaterialTheme.typography.bodySmall, fontFamily = FontFamily.Monospace, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(vertical = 1.dp), ) is DiffLine.Add -> { val n = newLine++ DiffRow(line.text, "+", addColor, addText, oldLine = null, newLine = n, syntax = syntax, palette = palette) } is DiffLine.Remove -> { val o = oldLine++ DiffRow(line.text, "-", removeColor, removeText, oldLine = o, newLine = null, syntax = syntax, palette = palette) } is DiffLine.Context -> { val o = oldLine++; val n = newLine++ DiffRow(line.text, " ", Color.Transparent, MaterialTheme.colorScheme.onSurface, oldLine = o, newLine = n, syntax = syntax, palette = palette) } } } } if (lines.size > NetworkConfig.collapsedDiffLineThreshold) { val hidden = lines.size - NetworkConfig.collapsedDiffLineThreshold TextButton( onClick = { expanded = !expanded }, 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), ) } } } } @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. val highlighted: AnnotatedString = remember(text, syntax, palette) { if (syntax != null) highlightLine(text, syntax, palette) else AnnotatedString(text) } 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, ) } }