git.iko.soy/opencode-android
on 9f0326a help sign in
490 lines 23 kB history raw
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.lazy.LazyColumn
17import androidx.compose.foundation.lazy.itemsIndexed
18import androidx.compose.foundation.lazy.rememberLazyListState
19import androidx.compose.foundation.rememberScrollState
20import androidx.compose.material.icons.Icons
21import androidx.compose.material.icons.filled.ContentCopy
22import androidx.compose.material.icons.filled.ExpandLess
23import androidx.compose.material.icons.filled.ExpandMore
24import androidx.compose.material3.Icon
25import androidx.compose.material3.IconButton
26import androidx.compose.material3.MaterialTheme
27import androidx.compose.material3.Text
28import androidx.compose.material3.TextButton
29import androidx.compose.runtime.Composable
30import androidx.compose.runtime.getValue
31import androidx.compose.runtime.mutableStateOf
32import androidx.compose.runtime.remember
33import androidx.compose.runtime.saveable.rememberSaveable
34import androidx.compose.runtime.setValue
35import androidx.compose.ui.Alignment
36import androidx.compose.ui.Modifier
37import androidx.compose.ui.draw.clip
38import androidx.compose.ui.graphics.Color
39import androidx.compose.ui.platform.LocalContext
40import androidx.compose.ui.semantics.contentDescription
41import androidx.compose.ui.semantics.heading
42import androidx.compose.ui.semantics.semantics
43import androidx.compose.ui.text.AnnotatedString
44import androidx.compose.ui.text.font.FontFamily
45import androidx.compose.ui.text.font.FontWeight
46import androidx.compose.ui.unit.dp
47import androidx.compose.ui.res.pluralStringResource
48import androidx.compose.ui.res.stringResource
49import soy.iko.opencode.R
50import soy.iko.opencode.data.network.NetworkConfig
51import soy.iko.opencode.ui.theme.diffColors
53/** A single line of a parsed unified diff. */
54sealed interface DiffLine {
55 val text: String
57 data class Hunk(override val text: String) : DiffLine // @@ ... @@
58 data class FileHeader(override val text: String) : DiffLine // --- / +++ / diff / index
59 data class Context(override val text: String) : DiffLine
60 data class Add(override val text: String) : DiffLine
61 data class Remove(override val text: String) : DiffLine
62 data class Meta(override val text: String) : DiffLine // git metadata (new file mode, etc.)
65/** Git metadata line prefixes that aren't part of the diff content. */
66private val gitMetaPrefixes = listOf(
67 "new file mode ", "deleted file mode ", "old mode ", "new mode ",
68 "similarity index ", "dissimilarity index ", "rename from ", "rename to ",
69 "copy from ", "copy to ", "Binary files ", "\\ No newline",
72/** Does [raw] look like a real unified-diff file header ("--- a/…", "+++ b/…", "--- /dev/null")
73 * rather than a removed/added content line that merely starts with "--- "/"+++ "? */
74private fun isDiffFileHeader(raw: String): Boolean {
75 val rest = when {
76 raw.startsWith("--- ") -> raw.substring(4)
77 raw.startsWith("+++ ") -> raw.substring(4)
78 else -> return false
79 }
80 // Header paths are "a/…", "b/…", or the literal "/dev/null" (optionally followed by a
81 // tab/space-separated timestamp). Restricting the absolute-path case to /dev/null avoids
82 // misclassifying a removed content line like "-- /usr/local/foo" (on the wire
83 // "--- /usr/local/foo"), whose rest starts with "/", as a file header.
84 return rest.startsWith("a/") || rest.startsWith("b/") ||
85 rest == "/dev/null" || rest.startsWith("/dev/null\t") || rest.startsWith("/dev/null ")
88/** Parse a unified diff string into typed [DiffLine]s. */
89fun parseDiff(diff: String): List<DiffLine> {
90 val result = mutableListOf<DiffLine>()
91 for (raw in diff.lineSequence()) {
92 when {
93 raw.startsWith("@@") -> result.add(DiffLine.Hunk(raw))
94 // Unified-diff file headers are "--- <path>" / "+++ <path>" where the path is
95 // "a/…", "b/…", or an absolute path (e.g. "/dev/null"). Requiring that the marker
96 // be followed by "a/", "b/", or "/" avoids misclassifying removed/added *content*
97 // lines like "-- TODO"/"++ x" — which appear on the wire as "--- TODO"/"+++ x" —
98 // as headers; those fall through to the +/- branches below.
99 isDiffFileHeader(raw) -> result.add(DiffLine.FileHeader(raw))
100 raw.startsWith("+") -> result.add(DiffLine.Add(raw.removePrefix("+")))
101 raw.startsWith("-") -> result.add(DiffLine.Remove(raw.removePrefix("-")))
102 raw.startsWith(" ") -> result.add(DiffLine.Context(raw.removePrefix(" ")))
103 raw.startsWith("diff ") || raw.startsWith("index ") -> result.add(DiffLine.FileHeader(raw))
104 gitMetaPrefixes.any { raw.startsWith(it) } -> result.add(DiffLine.Meta(raw))
105 raw.isNotBlank() -> result.add(DiffLine.Context(raw))
106 }
107 }
108 return result
111/** Heuristic: does this string look like a unified diff? */
112fun looksLikeDiff(text: String): Boolean {
113 val lines = text.lineSequence()
114 var hunkCount = 0
115 var addCount = 0
116 var removeCount = 0
117 for (line in lines) {
118 when {
119 line.startsWith("@@") -> hunkCount++
120 line.startsWith("+") && !line.startsWith("+++") -> addCount++
121 line.startsWith("-") && !line.startsWith("---") -> removeCount++
122 }
123 if (hunkCount >= 1 && (addCount + removeCount) >= 1) return true
124 }
125 return false
128/** Extract a display path from a `+++ b/path` / `--- a/path` / `diff --git a/x b/y` header.
129 * Returns the raw line when no path can be extracted, so the header still shows something. */
130private fun extractDisplayPath(headerText: String): String {
131 // `diff --git a/foo b/bar` → `bar` (the new path). `+++ b/foo` → `foo`. `--- a/foo` → `foo`.
132 return when {
133 headerText.startsWith("diff --git ") -> {
134 // Grab the second path after " b/"; fall back to the first after " a/".
135 val bIdx = headerText.indexOf(" b/")
136 if (bIdx >= 0) headerText.substring(bIdx + 3).trim() else headerText.substringAfter(" a/", headerText)
137 }
138 headerText.startsWith("+++ ") -> headerText.substring(4).substringAfter("b/", headerText.substring(4)).trimEnd()
139 headerText.startsWith("--- ") -> headerText.substring(4).substringAfter("a/", headerText.substring(4)).trimEnd()
140 else -> headerText
141 }
144/** Resolve a [FileSyntax] from a diff file header's path, for per-line highlighting. Returns
145 * null when the path has no recognizable extension (so the diff renders plain, as before). */
146private fun syntaxForHeader(headerText: String): FileSyntax? {
147 val path = extractDisplayPath(headerText)
148 if (path == headerText) return null
149 val filename = path.substringAfterLast('/')
150 val syntax = syntaxFor(filename)
151 return if (syntax.lang == Language.NONE) null else syntax
154/**
155 * Renders a parsed unified diff. Implemented as a plain (non-lazy) [Column] so it is
156 * safe to embed inside another vertically scrolling container (e.g. the chat message
157 * list) — a nested `LazyColumn` would crash with an unbounded-height constraint.
158 * Callers that want the view to scroll on its own (e.g. the file viewer) should pass a
159 * [Modifier.verticalScroll] in [modifier].
160 *
161 * To avoid composing thousands of [Text] nodes at once (which can ANR/OOM on large
162 * diffs), only the first [NetworkConfig.collapsedDiffLineThreshold] lines are rendered
163 * unless the user expands the view. Each hunk can also be collapsed individually via
164 * its `@@` header.
165 */
166@Composable
167fun DiffView(diff: String, modifier: Modifier = Modifier, saveKey: String? = null) {
168 val lines = remember(diff) { parseDiff(diff) }
169 // Use dedicated diff add/remove colors (a fixed green/red pair tuned to the theme) so the
170 // diff semantics stay consistent under Material You — where the primary role may not be
171 // green-tinted, an "added" line would otherwise read as a primary-tinted highlight.
172 val scheme = MaterialTheme.colorScheme
173 val diffColors = diffColors()
174 val addColor = remember(scheme) { diffColors.addBg }
175 val removeColor = remember(scheme) { diffColors.removeBg }
176 val addText = diffColors.addText
177 val removeText = diffColors.removeText
178 val context = LocalContext.current
179 val hScrollState = rememberScrollState()
180 // Key on a stable id (the part id) when available so streaming updates that grow `diff`
181 // don't reset expand state on every token. Fall back to `diff` for non-streaming callers
182 // (e.g. FileViewScreen) where the content is static.
183 var expanded by rememberSaveable(saveKey ?: diff) { mutableStateOf(false) }
184 // Resolve a syntax for highlighting from the first file header (if any). Memoized so a
185 // streaming re-parse doesn't re-resolve per token.
186 val syntax = remember(lines) {
187 lines.firstOrNull { it is DiffLine.FileHeader }?.let { syntaxForHeader(it.text) }
188 }
189 val palette = rememberHighlightPalette()
191 Column(
192 modifier = modifier
193 .fillMaxWidth()
194 .clip(MaterialTheme.shapes.small)
195 .background(MaterialTheme.colorScheme.surfaceVariant),
196 ) {
197 DiffHeader(
198 lineCount = lines.size,
199 expanded = expanded,
200 onToggle = { expanded = !expanded },
201 onCopy = { copyToClipboard(context, context.getString(R.string.clip_label_diff), diff) },
202 )
203 val visibleLines = if (lines.size <= NetworkConfig.collapsedDiffLineThreshold || expanded) lines
204 else lines.subList(0, NetworkConfig.collapsedDiffLineThreshold)
205 // One horizontalScroll on the container instead of one per row: each modifier
206 // adds a layout node + clip + offset pass, so a 200-line collapsed diff was
207 // paying for 200 of them. The shared scroll state still synchronizes all rows.
208 val rows = remember(visibleLines, addColor, removeColor, addText, removeText, scheme) {
209 buildDiffRows(
210 visibleLines,
211 addColor, removeColor, addText, removeText,
212 scheme.tertiary, scheme.onSurface, scheme.onSurfaceVariant,
213 )
214 }
215 Column(modifier = Modifier.horizontalScroll(hScrollState)) {
216 rows.forEach { row -> RenderDiffRow(row, syntax, palette) }
217 }
218 DiffExpandFooter(lines = lines, expanded = expanded, onToggle = { expanded = !expanded })
219 }
222/**
223 * Lazy variant of [DiffView] for callers that need the diff to scroll on its own (e.g. the
224 * file viewer) WITHOUT an enclosing verticalScroll. A 5000-line diff in [DiffView] would
225 * compose every row eagerly; this variant uses a [LazyColumn] so only the visible rows
226 * compose. The collapse threshold still applies for the *initial* view, but expanding
227 * switches to lazy rendering so a huge expanded diff doesn't OOM.
228 */
229@Composable
230fun LazyDiffView(diff: String, modifier: Modifier = Modifier, saveKey: String? = null) {
231 val lines = remember(diff) { parseDiff(diff) }
232 val scheme = MaterialTheme.colorScheme
233 val diffColors = diffColors()
234 val addColor = remember(scheme) { diffColors.addBg }
235 val removeColor = remember(scheme) { diffColors.removeBg }
236 val addText = diffColors.addText
237 val removeText = diffColors.removeText
238 val context = LocalContext.current
239 val hScrollState = rememberScrollState()
240 var expanded by rememberSaveable(saveKey ?: diff) { mutableStateOf(false) }
241 val syntax = remember(lines) {
242 lines.firstOrNull { it is DiffLine.FileHeader }?.let { syntaxForHeader(it.text) }
243 }
244 val palette = rememberHighlightPalette()
245 val lazyListState = androidx.compose.foundation.lazy.rememberLazyListState()
247 Column(
248 modifier = modifier
249 .fillMaxWidth()
250 .clip(MaterialTheme.shapes.small)
251 .background(MaterialTheme.colorScheme.surfaceVariant),
252 ) {
253 DiffHeader(
254 lineCount = lines.size,
255 expanded = expanded,
256 onToggle = { expanded = !expanded },
257 onCopy = { copyToClipboard(context, context.getString(R.string.clip_label_diff), diff) },
258 )
259 val visibleLines = if (lines.size <= NetworkConfig.collapsedDiffLineThreshold || expanded) lines
260 else lines.subList(0, NetworkConfig.collapsedDiffLineThreshold)
261 val rows = remember(visibleLines, addColor, removeColor, addText, removeText, scheme) {
262 buildDiffRows(
263 visibleLines,
264 addColor, removeColor, addText, removeText,
265 scheme.tertiary, scheme.onSurface, scheme.onSurfaceVariant,
266 )
267 }
268 // One horizontalScroll wrapping the LazyColumn synchronizes horizontal panning across
269 // visible rows (the same trick DiffView uses on its Column). LazyColumn handles the
270 // vertical scrolling.
271 LazyColumn(
272 state = lazyListState,
273 modifier = Modifier.horizontalScroll(hScrollState),
274 ) {
275 itemsIndexed(
276 rows,
277 key = { i, row -> "${row.kind::class.simpleName}:$i:${row.text.hashCode()}" },
278 ) { _, row ->
279 RenderDiffRow(row, syntax, palette)
280 }
281 }
282 DiffExpandFooter(lines = lines, expanded = expanded, onToggle = { expanded = !expanded })
283 }
286/** Header row with the expand/collapse toggle (when applicable) and a copy action. */
287@Composable
288private fun DiffHeader(lineCount: Int, expanded: Boolean, onToggle: () -> Unit, onCopy: () -> Unit) {
289 val context = LocalContext.current
290 Row(
291 modifier = Modifier.fillMaxWidth().padding(top = 2.dp, start = 4.dp, end = 4.dp),
292 verticalAlignment = Alignment.CenterVertically,
293 ) {
294 if (lineCount > NetworkConfig.collapsedDiffLineThreshold) {
295 val moreLines = lineCount - NetworkConfig.collapsedDiffLineThreshold
296 IconButton(onClick = onToggle) {
297 Icon(
298 if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
299 contentDescription = if (expanded) stringResource(R.string.show_less)
300 else context.resources.getQuantityString(
301 R.plurals.show_more_lines, moreLines, moreLines,
302 ),
303 tint = MaterialTheme.colorScheme.onSurfaceVariant,
304 )
305 }
306 }
307 Spacer(Modifier.weight(1f))
308 IconButton(onClick = onCopy) {
309 Icon(Icons.Filled.ContentCopy, contentDescription = stringResource(R.string.copy), tint = MaterialTheme.colorScheme.onSurfaceVariant)
310 }
311 }
314/** Footer "show more/less" button when the diff was collapsed. */
315@Composable
316private fun DiffExpandFooter(lines: List<DiffLine>, expanded: Boolean, onToggle: () -> Unit) {
317 if (lines.size > NetworkConfig.collapsedDiffLineThreshold) {
318 val hidden = lines.size - NetworkConfig.collapsedDiffLineThreshold
319 TextButton(
320 onClick = onToggle,
321 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 10.dp),
322 modifier = Modifier.defaultMinSize(minHeight = 48.dp),
323 ) {
324 Text(
325 if (expanded) stringResource(R.string.show_less)
326 else pluralStringResource(R.plurals.show_more_lines, hidden, hidden),
327 )
328 }
329 }
332/** A pre-resolved diff row carrying its kind, text, line numbers, and resolved colors. This
333 * lets both [DiffView] and [LazyDiffView] share the rendering path without re-deriving the
334 * per-row state (line numbers advance as a side effect of walking the list). */
335private data class DiffRowItem(
336 val kind: DiffLine,
337 val text: String,
338 val bg: Color,
339 val textColor: Color,
340 val oldLine: Int?,
341 val newLine: Int?,
344/** Walk [visibleLines] advancing the old/new line counters per hunk, producing a list of
345 * [DiffRowItem]s that the renderers can iterate without per-row state. Theme colors for the
346 * non-add/remove rows (tertiary for hunk headers, onSurface/onSurfaceVariant for context
347 * and meta) are passed in by the @Composable caller so the row-builder stays a plain
348 * function that doesn't read MaterialTheme directly. */
349private fun buildDiffRows(
350 visibleLines: List<DiffLine>,
351 addColor: Color,
352 removeColor: Color,
353 addText: Color,
354 removeText: Color,
355 tertiary: Color,
356 onSurface: Color,
357 onSurfaceVariant: Color,
358): List<DiffRowItem> {
359 var oldLine = 0
360 var newLine = 0
361 val hunkRegex = Regex("""@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@""")
362 return visibleLines.map { line ->
363 when (line) {
364 is DiffLine.Hunk -> {
365 val m = hunkRegex.find(line.text)
366 if (m != null) {
367 oldLine = m.groupValues[1].toInt()
368 newLine = m.groupValues[2].toInt()
369 }
370 DiffRowItem(line, line.text, Color.Transparent, tertiary, null, null)
371 }
372 is DiffLine.FileHeader -> DiffRowItem(line, line.text, Color.Transparent, onSurface, null, null)
373 is DiffLine.Meta -> DiffRowItem(line, line.text, Color.Transparent, onSurfaceVariant, null, null)
374 is DiffLine.Add -> {
375 val n = newLine++
376 DiffRowItem(line, line.text, addColor, addText, null, n)
377 }
378 is DiffLine.Remove -> {
379 val o = oldLine++
380 DiffRowItem(line, line.text, removeColor, removeText, o, null)
381 }
382 is DiffLine.Context -> {
383 val o = oldLine++; val n = newLine++
384 DiffRowItem(line, line.text, Color.Transparent, onSurface, o, n)
385 }
386 }
387 }
390/** Render a single [DiffRowItem]. Hunk and FileHeader rows get their own styling; Add/Remove/
391 * Context delegate to [DiffRow]. */
392@Composable
393private fun RenderDiffRow(row: DiffRowItem, syntax: FileSyntax?, palette: HighlightPalette) {
394 when (row.kind) {
395 is DiffLine.Hunk -> Text(
396 row.text,
397 style = MaterialTheme.typography.bodySmall,
398 fontFamily = FontFamily.Monospace,
399 color = MaterialTheme.colorScheme.tertiary,
400 modifier = Modifier.padding(horizontal = 10.dp, vertical = 2.dp),
401 )
402 is DiffLine.FileHeader -> {
403 val displayPath = extractDisplayPath(row.text)
404 val isHeader = displayPath != row.text
405 Text(
406 displayPath,
407 style = MaterialTheme.typography.labelMedium,
408 fontFamily = FontFamily.Monospace,
409 fontWeight = if (isHeader) FontWeight.SemiBold else FontWeight.Normal,
410 color = MaterialTheme.colorScheme.onSurface,
411 modifier = Modifier
412 .fillMaxWidth()
413 .padding(horizontal = 10.dp, vertical = 3.dp)
414 .semantics { heading() },
415 )
416 }
417 is DiffLine.Meta -> Text(
418 row.text,
419 style = MaterialTheme.typography.bodySmall,
420 fontFamily = FontFamily.Monospace,
421 color = MaterialTheme.colorScheme.onSurfaceVariant,
422 modifier = Modifier.padding(vertical = 1.dp),
423 )
424 is DiffLine.Add -> DiffRow(row.text, "+", row.bg, row.textColor, oldLine = row.oldLine, newLine = row.newLine, syntax = syntax, palette = palette)
425 is DiffLine.Remove -> DiffRow(row.text, "-", row.bg, row.textColor, oldLine = row.oldLine, newLine = row.newLine, syntax = syntax, palette = palette)
426 is DiffLine.Context -> DiffRow(row.text, " ", row.bg, row.textColor, oldLine = row.oldLine, newLine = row.newLine, syntax = syntax, palette = palette)
427 }
430@Composable
431private fun DiffRow(
432 text: String,
433 prefix: String,
434 bg: Color,
435 textColor: androidx.compose.ui.graphics.Color,
436 oldLine: Int? = null,
437 newLine: Int? = null,
438 syntax: FileSyntax? = null,
439 palette: HighlightPalette,
440) {
441 val gutterColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
442 // Highlight the line's content (minus the leading +/-/space prefix, which DiffView
443 // already stripped) when a syntax was resolved from a file header. Falls back to plain
444 // monospace text when no syntax is known (e.g. an untagged patch), preserving the prior
445 // rendering for diffs that can't be classified.
446 val highlighted: AnnotatedString = remember(text, syntax, palette) {
447 if (syntax != null) highlightLine(text, syntax, palette) else AnnotatedString(text)
448 }
449 val a11yLabel = when (prefix) {
450 "+" -> stringResource(R.string.diff_added_line, newLine ?: 0, text)
451 "-" -> stringResource(R.string.diff_removed_line, oldLine ?: 0, text)
452 else -> stringResource(R.string.diff_context_line, oldLine ?: 0, text)
453 }
454 Row(
455 modifier = Modifier
456 .background(bg)
457 .semantics(mergeDescendants = true) { contentDescription = a11yLabel },
458 ) {
459 // Old/new line-number gutters. Blank (not 0) for the side that doesn't have a number
460 // (an added line has no old number; a removed line has no new number) so the columns
461 // stay aligned.
462 Text(
463 oldLine?.toString() ?: "",
464 style = MaterialTheme.typography.bodySmall,
465 fontFamily = FontFamily.Monospace,
466 color = gutterColor,
467 modifier = Modifier.width(NetworkConfig.diffGutterWidthDp.dp),
468 )
469 Text(
470 newLine?.toString() ?: "",
471 style = MaterialTheme.typography.bodySmall,
472 fontFamily = FontFamily.Monospace,
473 color = gutterColor,
474 modifier = Modifier.width(NetworkConfig.diffGutterWidthDp.dp),
475 )
476 Text(
477 prefix,
478 style = MaterialTheme.typography.bodySmall,
479 fontFamily = FontFamily.Monospace,
480 color = textColor,
481 modifier = Modifier.width(NetworkConfig.diffPrefixWidthDp.dp),
482 )
483 Text(
484 highlighted,
485 style = MaterialTheme.typography.bodySmall,
486 fontFamily = FontFamily.Monospace,
487 color = textColor,
488 )
489 }