1package soy.iko.opencode.ui.components
3import androidx.compose.animation.animateContentSize
4import androidx.compose.foundation.background
5import androidx.compose.foundation.clickable
6import androidx.compose.foundation.horizontalScroll
7import androidx.compose.foundation.layout.Arrangement
8import androidx.compose.foundation.layout.Column
9import androidx.compose.foundation.layout.Row
10import androidx.compose.foundation.layout.Spacer
11import androidx.compose.foundation.layout.fillMaxWidth
12import androidx.compose.foundation.layout.height
13import androidx.compose.foundation.layout.defaultMinSize
14import androidx.compose.foundation.layout.padding
15import androidx.compose.foundation.layout.width
16import androidx.compose.foundation.rememberScrollState
17import androidx.compose.material.icons.Icons
18import androidx.compose.material.icons.filled.ContentCopy
19import androidx.compose.material.icons.filled.ExpandLess
20import androidx.compose.material.icons.filled.ExpandMore
21import androidx.compose.material3.Icon
22import androidx.compose.material3.IconButton
23import androidx.compose.material3.MaterialTheme
24import androidx.compose.material3.Text
25import androidx.compose.material3.TextButton
26import androidx.compose.runtime.Composable
27import androidx.compose.runtime.getValue
28import androidx.compose.runtime.mutableStateOf
29import androidx.compose.runtime.remember
30import androidx.compose.runtime.saveable.rememberSaveable
31import androidx.compose.runtime.setValue
32import androidx.compose.ui.Alignment
33import androidx.compose.ui.Modifier
34import androidx.compose.ui.draw.clip
35import androidx.compose.ui.graphics.Color
36import androidx.compose.ui.platform.LocalContext
37import androidx.compose.ui.semantics.contentDescription
38import androidx.compose.ui.semantics.heading
39import androidx.compose.ui.semantics.semantics
40import androidx.compose.ui.text.AnnotatedString
41import androidx.compose.ui.text.font.FontFamily
42import androidx.compose.ui.text.font.FontWeight
43import androidx.compose.ui.unit.dp
44import androidx.compose.ui.res.pluralStringResource
45import androidx.compose.ui.res.stringResource
46import soy.iko.opencode.R
47import soy.iko.opencode.data.network.NetworkConfig
49/** A single line of a parsed unified diff. */
50sealed interface DiffLine {
53 data class Hunk(override val text: String) : DiffLine // @@ ... @@
54 data class FileHeader(override val text: String) : DiffLine // --- / +++ / diff / index
55 data class Context(override val text: String) : DiffLine
56 data class Add(override val text: String) : DiffLine
57 data class Remove(override val text: String) : DiffLine
58 data class Meta(override val text: String) : DiffLine // git metadata (new file mode, etc.)
61/** Git metadata line prefixes that aren't part of the diff content. */
62private val gitMetaPrefixes = listOf(
63 "new file mode ", "deleted file mode ", "old mode ", "new mode ",
64 "similarity index ", "dissimilarity index ", "rename from ", "rename to ",
65 "copy from ", "copy to ", "Binary files ", "\\ No newline",
68/** Does [raw] look like a real unified-diff file header ("--- a/…", "+++ b/…", "--- /dev/null")
69 * rather than a removed/added content line that merely starts with "--- "/"+++ "? */
70private fun isDiffFileHeader(raw: String): Boolean {
72 raw.startsWith("--- ") -> raw.substring(4)
73 raw.startsWith("+++ ") -> raw.substring(4)
76 // Header paths are "a/…", "b/…", or the literal "/dev/null" (optionally followed by a
77 // tab/space-separated timestamp). Restricting the absolute-path case to /dev/null avoids
78 // misclassifying a removed content line like "-- /usr/local/foo" (on the wire
79 // "--- /usr/local/foo"), whose rest starts with "/", as a file header.
80 return rest.startsWith("a/") || rest.startsWith("b/") ||
81 rest == "/dev/null" || rest.startsWith("/dev/null\t") || rest.startsWith("/dev/null ")
84/** Parse a unified diff string into typed [DiffLine]s. */
85fun parseDiff(diff: String): List<DiffLine> {
86 val result = mutableListOf<DiffLine>()
87 for (raw in diff.lineSequence()) {
89 raw.startsWith("@@") -> result.add(DiffLine.Hunk(raw))
90 // Unified-diff file headers are "--- <path>" / "+++ <path>" where the path is
91 // "a/…", "b/…", or an absolute path (e.g. "/dev/null"). Requiring that the marker
92 // be followed by "a/", "b/", or "/" avoids misclassifying removed/added *content*
93 // lines like "-- TODO"/"++ x" — which appear on the wire as "--- TODO"/"+++ x" —
94 // as headers; those fall through to the +/- branches below.
95 isDiffFileHeader(raw) -> result.add(DiffLine.FileHeader(raw))
96 raw.startsWith("+") -> result.add(DiffLine.Add(raw.removePrefix("+")))
97 raw.startsWith("-") -> result.add(DiffLine.Remove(raw.removePrefix("-")))
98 raw.startsWith(" ") -> result.add(DiffLine.Context(raw.removePrefix(" ")))
99 raw.startsWith("diff ") || raw.startsWith("index ") -> result.add(DiffLine.FileHeader(raw))
100 gitMetaPrefixes.any { raw.startsWith(it) } -> result.add(DiffLine.Meta(raw))
101 raw.isNotBlank() -> result.add(DiffLine.Context(raw))
107/** Heuristic: does this string look like a unified diff? */
108fun looksLikeDiff(text: String): Boolean {
109 val lines = text.lineSequence()
113 for (line in lines) {
115 line.startsWith("@@") -> hunkCount++
116 line.startsWith("+") && !line.startsWith("+++") -> addCount++
117 line.startsWith("-") && !line.startsWith("---") -> removeCount++
119 if (hunkCount >= 1 && (addCount + removeCount) >= 1) return true
124/** Extract a display path from a `+++ b/path` / `--- a/path` / `diff --git a/x b/y` header.
125 * Returns the raw line when no path can be extracted, so the header still shows something. */
126private fun extractDisplayPath(headerText: String): String {
127 // `diff --git a/foo b/bar` → `bar` (the new path). `+++ b/foo` → `foo`. `--- a/foo` → `foo`.
129 headerText.startsWith("diff --git ") -> {
130 // Grab the second path after " b/"; fall back to the first after " a/".
131 val bIdx = headerText.indexOf(" b/")
132 if (bIdx >= 0) headerText.substring(bIdx + 3).trim() else headerText.substringAfter(" a/", headerText)
134 headerText.startsWith("+++ ") -> headerText.substring(4).substringAfter("b/", headerText.substring(4)).trimEnd()
135 headerText.startsWith("--- ") -> headerText.substring(4).substringAfter("a/", headerText.substring(4)).trimEnd()
140/** Resolve a [FileSyntax] from a diff file header's path, for per-line highlighting. Returns
141 * null when the path has no recognizable extension (so the diff renders plain, as before). */
142private fun syntaxForHeader(headerText: String): FileSyntax? {
143 val path = extractDisplayPath(headerText)
144 if (path == headerText) return null
145 val filename = path.substringAfterLast('/')
146 val syntax = syntaxFor(filename)
147 return if (syntax.lang == Language.NONE) null else syntax
151 * Renders a parsed unified diff. Implemented as a plain (non-lazy) [Column] so it is
152 * safe to embed inside another vertically scrolling container (e.g. the chat message
153 * list) — a nested `LazyColumn` would crash with an unbounded-height constraint.
154 * Callers that want the view to scroll on its own (e.g. the file viewer) should pass a
155 * [Modifier.verticalScroll] in [modifier].
157 * To avoid composing thousands of [Text] nodes at once (which can ANR/OOM on large
158 * diffs), only the first [NetworkConfig.collapsedDiffLineThreshold] lines are rendered
159 * unless the user expands the view. Each hunk can also be collapsed individually via
163fun DiffView(diff: String, modifier: Modifier = Modifier, saveKey: String? = null) {
164 val lines = remember(diff) { parseDiff(diff) }
165 // Derive the four row colors once per color scheme instead of allocating fresh Color
166 // objects (the .copy() calls) on every recomposition — a diff re-parses/recomposes
167 // per token while its output streams under the collapse limit.
168 val scheme = MaterialTheme.colorScheme
169 val addColor = remember(scheme) { scheme.primary.copy(alpha = 0.15f) }
170 val removeColor = remember(scheme) { scheme.error.copy(alpha = 0.15f) }
171 val addText = scheme.primary
172 val removeText = scheme.error
173 val context = LocalContext.current
174 val hScrollState = rememberScrollState()
175 // Key on a stable id (the part id) when available so streaming updates that grow `diff`
176 // don't reset expand state on every token. Fall back to `diff` for non-streaming callers
177 // (e.g. FileViewScreen) where the content is static.
178 var expanded by rememberSaveable(saveKey ?: diff) { mutableStateOf(false) }
179 // Resolve a syntax for highlighting from the first file header (if any). Memoized so a
180 // streaming re-parse doesn't re-resolve per token.
181 val syntax = remember(lines) {
182 lines.firstOrNull { it is DiffLine.FileHeader }?.let { syntaxForHeader(it.text) }
184 val palette = rememberHighlightPalette()
189 .clip(MaterialTheme.shapes.small)
190 .background(MaterialTheme.colorScheme.surfaceVariant),
193 modifier = Modifier.fillMaxWidth().padding(top = 2.dp, start = 4.dp, end = 4.dp),
194 verticalAlignment = Alignment.CenterVertically,
196 // Expand/collapse toggle in the header (matching CodeWithCopy's pattern) so the
197 // user doesn't have to scroll to the bottom of a collapsed diff to expand it.
198 if (lines.size > NetworkConfig.collapsedDiffLineThreshold) {
199 val moreLines = lines.size - NetworkConfig.collapsedDiffLineThreshold
200 IconButton(onClick = { expanded = !expanded }) {
202 if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
203 contentDescription = if (expanded) stringResource(R.string.show_less)
204 else context.resources.getQuantityString(
205 R.plurals.show_more_lines, moreLines, moreLines,
207 tint = MaterialTheme.colorScheme.onSurfaceVariant,
211 Spacer(Modifier.weight(1f))
212 IconButton(onClick = { copyToClipboard(context, context.getString(R.string.clip_label_diff), diff) }) {
213 Icon(Icons.Filled.ContentCopy, contentDescription = stringResource(R.string.copy), tint = MaterialTheme.colorScheme.onSurfaceVariant)
216 val visibleLines = if (lines.size <= NetworkConfig.collapsedDiffLineThreshold || expanded) lines
217 else lines.subList(0, NetworkConfig.collapsedDiffLineThreshold)
218 // One horizontalScroll on the container instead of one per row: each modifier
219 // adds a layout node + clip + offset pass, so a 200-line collapsed diff was
220 // paying for 200 of them. The shared scroll state still synchronizes all rows.
221 // oldLine/newLine track the current line numbers derived from each @@ hunk header
222 // so the gutter can show absolute line numbers (a big readability win for review).
225 val hunkRegex = Regex("""@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@""")
226 Column(modifier = Modifier.horizontalScroll(hScrollState)) {
227 visibleLines.forEach { line ->
229 is DiffLine.Hunk -> {
230 val m = hunkRegex.find(line.text)
232 oldLine = m.groupValues[1].toInt()
233 newLine = m.groupValues[2].toInt()
237 style = MaterialTheme.typography.bodySmall,
238 fontFamily = FontFamily.Monospace,
239 color = MaterialTheme.colorScheme.tertiary,
240 modifier = Modifier.padding(horizontal = 10.dp, vertical = 2.dp),
243 is DiffLine.FileHeader -> {
244 // Render the display path (e.g. `src/Foo.kt`) as a bold header rather
245 // than the raw `+++ b/src/Foo.kt`, so file boundaries are scannable in
246 // a long multi-file diff. The raw line is still copied with the diff.
247 val displayPath = extractDisplayPath(line.text)
248 val isHeader = displayPath != line.text
251 style = MaterialTheme.typography.labelMedium,
252 fontFamily = FontFamily.Monospace,
253 fontWeight = if (isHeader) FontWeight.SemiBold else FontWeight.Normal,
254 color = MaterialTheme.colorScheme.onSurface,
257 .padding(horizontal = 10.dp, vertical = 3.dp)
258 .semantics { heading() },
261 is DiffLine.Meta -> Text(
263 style = MaterialTheme.typography.bodySmall,
264 fontFamily = FontFamily.Monospace,
265 color = MaterialTheme.colorScheme.onSurfaceVariant,
266 modifier = Modifier.padding(vertical = 1.dp),
270 DiffRow(line.text, "+", addColor, addText, oldLine = null, newLine = n, syntax = syntax, palette = palette)
272 is DiffLine.Remove -> {
274 DiffRow(line.text, "-", removeColor, removeText, oldLine = o, newLine = null, syntax = syntax, palette = palette)
276 is DiffLine.Context -> {
277 val o = oldLine++; val n = newLine++
278 DiffRow(line.text, " ", Color.Transparent, MaterialTheme.colorScheme.onSurface, oldLine = o, newLine = n, syntax = syntax, palette = palette)
283 if (lines.size > NetworkConfig.collapsedDiffLineThreshold) {
284 val hidden = lines.size - NetworkConfig.collapsedDiffLineThreshold
286 onClick = { expanded = !expanded },
287 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 10.dp),
288 modifier = Modifier.defaultMinSize(minHeight = 48.dp),
291 if (expanded) stringResource(R.string.show_less)
292 else pluralStringResource(R.plurals.show_more_lines, hidden, hidden),
304 textColor: androidx.compose.ui.graphics.Color,
305 oldLine: Int? = null,
306 newLine: Int? = null,
307 syntax: FileSyntax? = null,
308 palette: HighlightPalette,
310 val gutterColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
311 // Highlight the line's content (minus the leading +/-/space prefix, which DiffView
312 // already stripped) when a syntax was resolved from a file header. Falls back to plain
313 // monospace text when no syntax is known (e.g. an untagged patch), preserving the prior
314 // rendering for diffs that can't be classified.
315 val highlighted: AnnotatedString = remember(text, syntax, palette) {
316 if (syntax != null) highlightLine(text, syntax, palette) else AnnotatedString(text)
318 val a11yLabel = when (prefix) {
319 "+" -> stringResource(R.string.diff_added_line, newLine ?: 0, text)
320 "-" -> stringResource(R.string.diff_removed_line, oldLine ?: 0, text)
321 else -> stringResource(R.string.diff_context_line, oldLine ?: 0, text)
326 .semantics(mergeDescendants = true) { contentDescription = a11yLabel },
328 // Old/new line-number gutters. Blank (not 0) for the side that doesn't have a number
329 // (an added line has no old number; a removed line has no new number) so the columns
332 oldLine?.toString() ?: "",
333 style = MaterialTheme.typography.bodySmall,
334 fontFamily = FontFamily.Monospace,
336 modifier = Modifier.width(NetworkConfig.diffGutterWidthDp.dp),
339 newLine?.toString() ?: "",
340 style = MaterialTheme.typography.bodySmall,
341 fontFamily = FontFamily.Monospace,
343 modifier = Modifier.width(NetworkConfig.diffGutterWidthDp.dp),
347 style = MaterialTheme.typography.bodySmall,
348 fontFamily = FontFamily.Monospace,
350 modifier = Modifier.width(NetworkConfig.diffPrefixWidthDp.dp),
354 style = MaterialTheme.typography.bodySmall,
355 fontFamily = FontFamily.Monospace,
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