git.iko.soy/opencode-android
on d518ef7 help sign in
662 lines 31 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.size
16import androidx.compose.foundation.layout.width
17import androidx.compose.foundation.lazy.LazyColumn
18import androidx.compose.foundation.lazy.itemsIndexed
19import androidx.compose.foundation.lazy.rememberLazyListState
20import androidx.compose.foundation.rememberScrollState
21import androidx.compose.material.icons.Icons
22import androidx.compose.material.icons.filled.ContentCopy
23import androidx.compose.material.icons.filled.ExpandLess
24import androidx.compose.material.icons.filled.ExpandMore
25import androidx.compose.material3.Icon
26import androidx.compose.material3.IconButton
27import androidx.compose.material3.MaterialTheme
28import androidx.compose.material3.Text
29import androidx.compose.material3.TextButton
30import androidx.compose.runtime.Composable
31import androidx.compose.runtime.LaunchedEffect
32import androidx.compose.runtime.getValue
33import androidx.compose.runtime.mutableStateOf
34import androidx.compose.runtime.produceState
35import androidx.compose.runtime.remember
36import androidx.compose.runtime.saveable.rememberSaveable
37import androidx.compose.runtime.setValue
38import androidx.compose.ui.Alignment
39import androidx.compose.ui.Modifier
40import androidx.compose.ui.draw.clip
41import androidx.compose.ui.graphics.Color
42import androidx.compose.ui.platform.LocalContext
43import androidx.compose.ui.semantics.contentDescription
44import androidx.compose.ui.semantics.heading
45import androidx.compose.ui.semantics.semantics
46import androidx.compose.ui.semantics.stateDescription
47import androidx.compose.ui.text.AnnotatedString
48import androidx.compose.ui.text.font.FontFamily
49import androidx.compose.ui.text.font.FontWeight
50import androidx.compose.ui.unit.dp
51import androidx.compose.ui.res.pluralStringResource
52import androidx.compose.ui.res.stringResource
53import kotlinx.coroutines.Dispatchers
54import kotlinx.coroutines.withContext
55import soy.iko.opencode.R
56import soy.iko.opencode.data.network.NetworkConfig
57import soy.iko.opencode.ui.theme.diffColors
58import soy.iko.opencode.util.runCatchingCancellable
60/** A single line of a parsed unified diff. */
61sealed interface DiffLine {
62 val text: String
64 data class Hunk(override val text: String) : DiffLine // @@ ... @@
65 data class FileHeader(override val text: String) : DiffLine // --- / +++ / diff / index
66 data class Context(override val text: String) : DiffLine
67 data class Add(override val text: String) : DiffLine
68 data class Remove(override val text: String) : DiffLine
69 data class Meta(override val text: String) : DiffLine // git metadata (new file mode, etc.)
72/** Git metadata line prefixes that aren't part of the diff content. */
73private val gitMetaPrefixes = listOf(
74 "new file mode ", "deleted file mode ", "old mode ", "new mode ",
75 "similarity index ", "dissimilarity index ", "rename from ", "rename to ",
76 "copy from ", "copy to ", "Binary files ", "\\ No newline",
79/** Does [raw] look like a real unified-diff file header ("--- a/…", "+++ b/…", "--- /dev/null")
80 * rather than a removed/added content line that merely starts with "--- "/"+++ "? */
81private fun isDiffFileHeader(raw: String): Boolean {
82 val rest = when {
83 raw.startsWith("--- ") -> raw.substring(4)
84 raw.startsWith("+++ ") -> raw.substring(4)
85 else -> return false
86 }
87 // Header paths are "a/…", "b/…", or the literal "/dev/null" (optionally followed by a
88 // tab/space-separated timestamp). Restricting the absolute-path case to /dev/null avoids
89 // misclassifying a removed content line like "-- /usr/local/foo" (on the wire
90 // "--- /usr/local/foo"), whose rest starts with "/", as a file header.
91 return rest.startsWith("a/") || rest.startsWith("b/") ||
92 rest == "/dev/null" || rest.startsWith("/dev/null\t") || rest.startsWith("/dev/null ")
95/** Parse a unified diff string into typed [DiffLine]s. */
96fun parseDiff(diff: String): List<DiffLine> {
97 val result = mutableListOf<DiffLine>()
98 for (raw in diff.lineSequence()) {
99 when {
100 raw.startsWith("@@") -> result.add(DiffLine.Hunk(raw))
101 // Unified-diff file headers are "--- <path>" / "+++ <path>" where the path is
102 // "a/…", "b/…", or an absolute path (e.g. "/dev/null"). Requiring that the marker
103 // be followed by "a/", "b/", or "/" avoids misclassifying removed/added *content*
104 // lines like "-- TODO"/"++ x" — which appear on the wire as "--- TODO"/"+++ x" —
105 // as headers; those fall through to the +/- branches below.
106 isDiffFileHeader(raw) -> result.add(DiffLine.FileHeader(raw))
107 raw.startsWith("+") -> result.add(DiffLine.Add(raw.removePrefix("+")))
108 raw.startsWith("-") -> result.add(DiffLine.Remove(raw.removePrefix("-")))
109 raw.startsWith(" ") -> result.add(DiffLine.Context(raw.removePrefix(" ")))
110 raw.startsWith("diff ") || raw.startsWith("index ") -> result.add(DiffLine.FileHeader(raw))
111 gitMetaPrefixes.any { raw.startsWith(it) } -> result.add(DiffLine.Meta(raw))
112 raw.isNotBlank() -> result.add(DiffLine.Context(raw))
113 }
114 }
115 return result
118/** Heuristic: does this string look like a unified diff? */
119fun looksLikeDiff(text: String): Boolean {
120 val lines = text.lineSequence()
121 var hunkCount = 0
122 var addCount = 0
123 var removeCount = 0
124 for (line in lines) {
125 when {
126 line.startsWith("@@") -> hunkCount++
127 line.startsWith("+") && !line.startsWith("+++") -> addCount++
128 line.startsWith("-") && !line.startsWith("---") -> removeCount++
129 }
130 if (hunkCount >= 1 && (addCount + removeCount) >= 1) return true
131 }
132 return false
135/** Extract a display path from a `+++ b/path` / `--- a/path` / `diff --git a/x b/y` header.
136 * Returns the raw line when no path can be extracted, so the header still shows something. */
137private fun extractDisplayPath(headerText: String): String {
138 // `diff --git a/foo b/bar` → `bar` (the new path). `+++ b/foo` → `foo`. `--- a/foo` → `foo`.
139 return when {
140 headerText.startsWith("diff --git ") -> {
141 // Grab the second path after " b/"; fall back to the first after " a/".
142 val bIdx = headerText.indexOf(" b/")
143 if (bIdx >= 0) headerText.substring(bIdx + 3).trim() else headerText.substringAfter(" a/", headerText)
144 }
145 headerText.startsWith("+++ ") -> headerText.substring(4).substringAfter("b/", headerText.substring(4)).trimEnd()
146 headerText.startsWith("--- ") -> headerText.substring(4).substringAfter("a/", headerText.substring(4)).trimEnd()
147 else -> headerText
148 }
151/** Resolve a [FileSyntax] from a diff file header's path, for per-line highlighting. Returns
152 * null when the path has no recognizable extension (so the diff renders plain, as before). */
153private fun syntaxForHeader(headerText: String): FileSyntax? {
154 val path = extractDisplayPath(headerText)
155 if (path == headerText) return null
156 val filename = path.substringAfterLast('/')
157 val syntax = syntaxFor(filename)
158 return if (syntax.lang == Language.NONE) null else syntax
161/**
162 * Renders a parsed unified diff. Implemented as a plain (non-lazy) [Column] so it is
163 * safe to embed inside another vertically scrolling container (e.g. the chat message
164 * list) — a nested `LazyColumn` would crash with an unbounded-height constraint.
165 * Callers that want the view to scroll on its own (e.g. the file viewer) should pass a
166 * [Modifier.verticalScroll] in [modifier].
167 *
168 * To avoid composing thousands of [Text] nodes at once (which can ANR/OOM on large
169 * diffs), only the first [NetworkConfig.collapsedDiffLineThreshold] lines are rendered
170 * unless the user expands the view. Each hunk can also be collapsed individually via
171 * its `@@` header.
172 *
173 * The [parseDiff] + [buildDiffRows] work runs off the main thread (via [rememberDiffRows])
174 * so a streaming diff that grows on every throttle commit doesn't tokenize + walk the
175 * whole string on the main thread ~20x/sec. The first frame uses an empty (or cached)
176 * row list so the header renders immediately; rows swap in once the off-main pass lands.
177 */
178@Composable
179fun DiffView(diff: String, modifier: Modifier = Modifier, saveKey: String? = null) {
180 val scheme = MaterialTheme.colorScheme
181 val diffColors = diffColors()
182 val addColor = remember(scheme) { diffColors.addBg }
183 val removeColor = remember(scheme) { diffColors.removeBg }
184 val addText = diffColors.addText
185 val removeText = diffColors.removeText
186 val context = LocalContext.current
187 val hScrollState = rememberScrollState()
188 // Key on a stable id (the part id) when available so streaming updates that grow `diff`
189 // don't reset expand state on every token. Fall back to `diff` for non-streaming callers
190 // (e.g. FileViewScreen) where the content is static.
191 var expanded by rememberSaveable(saveKey ?: diff) { mutableStateOf(false) }
192 // Per-hunk collapse state: a set of hunk indices the user has collapsed via its `@@`
193 // header. Keyed on the hunk's ordinal position in `lines` so two hunks with the same
194 // `@@` range text (rare but possible) are still distinct toggles. The KDoc promised this
195 // affordance; it's now implemented.
196 val collapsedHunks = remember { mutableStateOf(emptySet<Int>()) }
197 val palette = rememberHighlightPalette()
199 // Parse + build rows off-main. The row list is recomputed when `diff` grows (streaming)
200 // or expand/collapse changes, but the heavy walk runs on Dispatchers.Default so the
201 // main thread stays free for layout/draw. First frame: empty rows (header renders, body
202 // fills in a frame later). For a static diff (file viewer, revert banner) the off-main
203 // pass lands within a frame or two and is then stable.
204 val rows = rememberDiffRows(
205 diff = diff,
206 expanded = expanded,
207 collapsedHunks = collapsedHunks.value,
208 addColor = addColor,
209 removeColor = removeColor,
210 addText = addText,
211 removeText = removeText,
212 tertiary = scheme.tertiary,
213 onSurface = scheme.onSurface,
214 onSurfaceVariant = scheme.onSurfaceVariant,
215 )
217 Column(
218 modifier = modifier
219 .fillMaxWidth()
220 .clip(MaterialTheme.shapes.small)
221 .background(MaterialTheme.colorScheme.surfaceVariant),
222 ) {
223 DiffHeader(
224 lineCount = rows.lineCount,
225 expanded = expanded,
226 onToggle = { expanded = !expanded },
227 onCopy = { copyToClipboard(context, context.getString(R.string.clip_label_diff), diff) },
228 )
229 Column(modifier = Modifier.horizontalScroll(hScrollState)) {
230 rows.rows.forEach { row -> RenderDiffRow(row, palette, collapsedHunks) }
231 }
232 DiffExpandFooter(lineCount = rows.lineCount, expanded = expanded, onToggle = { expanded = !expanded })
233 }
236/**
237 * Lazy variant of [DiffView] for callers that need the diff to scroll on its own (e.g. the
238 * file viewer) WITHOUT an enclosing verticalScroll. A 5000-line diff in [DiffView] would
239 * compose every row eagerly; this variant uses a [LazyColumn] so only the visible rows
240 * compose. The collapse threshold still applies for the *initial* view, but expanding
241 * switches to lazy rendering so a huge expanded diff doesn't OOM.
242 */
243@Composable
244fun LazyDiffView(diff: String, modifier: Modifier = Modifier, saveKey: String? = null) {
245 val scheme = MaterialTheme.colorScheme
246 val diffColors = diffColors()
247 val addColor = remember(scheme) { diffColors.addBg }
248 val removeColor = remember(scheme) { diffColors.removeBg }
249 val addText = diffColors.addText
250 val removeText = diffColors.removeText
251 val context = LocalContext.current
252 val hScrollState = rememberScrollState()
253 var expanded by rememberSaveable(saveKey ?: diff) { mutableStateOf(false) }
254 val collapsedHunks = remember { mutableStateOf(emptySet<Int>()) }
255 val palette = rememberHighlightPalette()
256 val lazyListState = androidx.compose.foundation.lazy.rememberLazyListState()
258 val rows = rememberDiffRows(
259 diff = diff,
260 expanded = expanded,
261 collapsedHunks = collapsedHunks.value,
262 addColor = addColor,
263 removeColor = removeColor,
264 addText = addText,
265 removeText = removeText,
266 tertiary = scheme.tertiary,
267 onSurface = scheme.onSurface,
268 onSurfaceVariant = scheme.onSurfaceVariant,
269 )
271 Column(
272 modifier = modifier
273 .fillMaxWidth()
274 .clip(MaterialTheme.shapes.small)
275 .background(MaterialTheme.colorScheme.surfaceVariant),
276 ) {
277 DiffHeader(
278 lineCount = rows.lineCount,
279 expanded = expanded,
280 onToggle = { expanded = !expanded },
281 onCopy = { copyToClipboard(context, context.getString(R.string.clip_label_diff), diff) },
282 )
283 // One horizontalScroll wrapping the LazyColumn synchronizes horizontal panning across
284 // visible rows (the same trick DiffView uses on its Column). LazyColumn handles the
285 // vertical scrolling.
286 LazyColumn(
287 state = lazyListState,
288 modifier = Modifier.horizontalScroll(hScrollState),
289 ) {
290 itemsIndexed(
291 rows.rows,
292 key = { i, row -> "${row.kind::class.simpleName}:$i:${row.text.hashCode()}" },
293 ) { _, row ->
294 RenderDiffRow(row, palette, collapsedHunks)
295 }
296 }
297 DiffExpandFooter(lineCount = rows.lineCount, expanded = expanded, onToggle = { expanded = !expanded })
298 }
301/**
302 * The parsed + pre-resolved rows for a diff, plus the total line count (for the
303 * expand/collapse footer, which reports "N more lines" against the full parsed size,
304 * not just the visible row count).
305 */
306private data class DiffRows(val rows: List<DiffRowItem>, val lineCount: Int)
308/**
309 * Parse [diff] and build the pre-resolved [DiffRowItem] list off the main thread.
310 *
311 * The parse ([parseDiff]) walks the whole diff string, and [buildDiffRows] walks every
312 * visible line advancing line counters + resolving per-file syntax — together they're
313 * O(lines). During a streaming diff, `diff` grows on every ~50ms throttle commit, so
314 * running these on the main thread re-walks the whole growing string ~20x/sec. Moving
315 * them to `Dispatchers.Default` keeps the main thread free for layout/draw.
316 *
317 * First frame: an empty row list + a line count of 0 (the header renders, the body fills
318 * in a frame or two later once the off-main pass lands). For a static diff the off-main
319 * pass is fast and the result is then stable across recompositions (the underlying
320 * `produceState` is keyed on the inputs, so a recompute only happens when `diff`/expand/
321 * collapse/colors actually change).
322 *
323 * The per-row syntax highlighting ([DiffRow]'s `rememberHighlightedCode`) is separately
324 * off-main and cross-message cached, so the per-row cost on the main thread is a cache
325 * lookup.
326 */
327@Composable
328private fun rememberDiffRows(
329 diff: String,
330 expanded: Boolean,
331 collapsedHunks: Set<Int>,
332 addColor: Color,
333 removeColor: Color,
334 addText: Color,
335 removeText: Color,
336 tertiary: Color,
337 onSurface: Color,
338 onSurfaceVariant: Color,
339): DiffRows = produceState<DiffRows>(DiffRows(emptyList(), 0), diff, expanded, collapsedHunks) {
340 val result = withContext(Dispatchers.Default) {
341 runCatchingCancellable {
342 val lines = parseDiff(diff)
343 val thresholdCollapsed = lines.size > NetworkConfig.collapsedDiffLineThreshold && !expanded
344 val baseLines = if (thresholdCollapsed) {
345 lines.subList(0, NetworkConfig.collapsedDiffLineThreshold)
346 } else {
347 lines
348 }
349 val hunkIndices = hunkStartIndices(lines)
350 val visibleLines = if (collapsedHunks.isEmpty()) baseLines
351 else filterCollapsedHunks(baseLines, hunkIndices, collapsedHunks)
352 DiffRows(
353 rows = buildDiffRows(
354 visibleLines,
355 addColor, removeColor, addText, removeText,
356 tertiary, onSurface, onSurfaceVariant,
357 ),
358 lineCount = lines.size,
359 )
360 }.getOrDefault(DiffRows(emptyList(), 0))
361 }
362 value = result
363}.value
365/** Header row with the expand/collapse toggle (when applicable) and a copy action. */
366@Composable
367private fun DiffHeader(lineCount: Int, expanded: Boolean, onToggle: () -> Unit, onCopy: () -> Unit) {
368 val context = LocalContext.current
369 Row(
370 modifier = Modifier.fillMaxWidth().padding(top = 2.dp, start = 4.dp, end = 4.dp),
371 verticalAlignment = Alignment.CenterVertically,
372 ) {
373 if (lineCount > NetworkConfig.collapsedDiffLineThreshold) {
374 val moreLines = lineCount - NetworkConfig.collapsedDiffLineThreshold
375 IconButton(onClick = onToggle) {
376 Icon(
377 if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
378 contentDescription = if (expanded) stringResource(R.string.show_less)
379 else context.resources.getQuantityString(
380 R.plurals.show_more_lines, moreLines, moreLines,
381 ),
382 tint = MaterialTheme.colorScheme.onSurfaceVariant,
383 )
384 }
385 }
386 Spacer(Modifier.weight(1f))
387 IconButton(onClick = onCopy) {
388 Icon(Icons.Filled.ContentCopy, contentDescription = stringResource(R.string.copy), tint = MaterialTheme.colorScheme.onSurfaceVariant)
389 }
390 }
393/** Footer "show more/less" button when the diff was collapsed. */
394@Composable
395private fun DiffExpandFooter(lineCount: Int, expanded: Boolean, onToggle: () -> Unit) {
396 if (lineCount > NetworkConfig.collapsedDiffLineThreshold) {
397 val hidden = lineCount - NetworkConfig.collapsedDiffLineThreshold
398 TextButton(
399 onClick = onToggle,
400 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 10.dp),
401 modifier = Modifier.defaultMinSize(minHeight = 48.dp),
402 ) {
403 Text(
404 if (expanded) stringResource(R.string.show_less)
405 else pluralStringResource(R.plurals.show_more_lines, hidden, hidden),
406 )
407 }
408 }
411/** Map each hunk ordinal (0, 1, 2, …) to its index in [lines]. Used to find the boundaries
412 * of a collapsed hunk: a hunk spans from its start index up to the next hunk's start (or the
413 * end of the list). File headers and meta lines before the first hunk are not hunk members. */
414private fun hunkStartIndices(lines: List<DiffLine>): Map<Int, Int> {
415 val map = LinkedHashMap<Int, Int>()
416 var hunkOrdinal = 0
417 lines.forEachIndexed { i, line ->
418 if (line is DiffLine.Hunk) {
419 map[hunkOrdinal] = i
420 hunkOrdinal++
421 }
422 }
423 return map
426/** Drop the content lines (Context/Add/Remove/Meta-after-hunk) of each collapsed hunk while
427 * keeping the hunk's own `@@` header row so the user has a handle to re-expand. A line is
428 * dropped when its [DiffRowItem.hunkIndex] is in [collapsed] AND it's not the Hunk header
429 * itself (the header carries the same hunkIndex but its kind is Hunk, which we keep). */
430private fun filterCollapsedHunks(
431 baseLines: List<DiffLine>,
432 hunkIndices: Map<Int, Int>,
433 collapsed: Set<Int>,
434): List<DiffLine> {
435 if (collapsed.isEmpty()) return baseLines
436 // For each collapsed hunk, compute [start, end) where end is the next hunk's start or the
437 // list size. We keep the header (at start) and drop the rest of the range.
438 val dropRanges = collapsed.mapNotNull { ordinal ->
439 val start = hunkIndices[ordinal] ?: return@mapNotNull null
440 val end = hunkIndices.entries
441 .firstOrNull { it.value > start }?.value ?: baseLines.size
442 (start + 1) until end
443 }
444 val dropSet = dropRanges.flatMap { it }.toHashSet()
445 return baseLines.filterIndexed { i, _ -> i !in dropSet }
448/** Cached labels for the per-hunk collapse `stateDescription` (resolved once per composition
449 * rather than per row to avoid a per-row string lookup on a potentially huge diff). */
450private val stateCollapsedLabel: String get() = "Collapsed"
451private val stateExpandedLabel: String get() = "Expanded"
453/** A pre-resolved diff row carrying its kind, text, line numbers, and resolved colors. This
454 * lets both [DiffView] and [LazyDiffView] share the rendering path without re-deriving the
455 * per-row state (line numbers advance as a side effect of walking the list). [hunkIndex] is
456 * the ordinal of the hunk this row belongs to (null for pre-hunk file headers/meta), used
457 * by [RenderDiffRow] to toggle per-hunk collapse. */
458private data class DiffRowItem(
459 val kind: DiffLine,
460 val text: String,
461 val bg: Color,
462 val textColor: Color,
463 val oldLine: Int?,
464 val newLine: Int?,
465 val hunkIndex: Int?,
466 // The syntax resolved from the file header this row belongs to. A multi-file diff switches
467 // language at each FileHeader; carrying syntax per row (instead of one for the whole diff)
468 // ensures a later file's hunk is highlighted with its own language, not the first file's.
469 val syntax: FileSyntax?,
472/** Walk [visibleLines] advancing the old/new line counters per hunk, producing a list of
473 * [DiffRowItem]s that the renderers can iterate without per-row state. Theme colors for the
474 * non-add/remove rows (tertiary for hunk headers, onSurface/onSurfaceVariant for context
475 * and meta) are passed in by the @Composable caller so the row-builder stays a plain
476 * function that doesn't read MaterialTheme directly. */
477private fun buildDiffRows(
478 visibleLines: List<DiffLine>,
479 addColor: Color,
480 removeColor: Color,
481 addText: Color,
482 removeText: Color,
483 tertiary: Color,
484 onSurface: Color,
485 onSurfaceVariant: Color,
486): List<DiffRowItem> {
487 var oldLine = 0
488 var newLine = 0
489 var hunkIndex = -1
490 // Track the current file's syntax as we walk the list, re-resolving at each FileHeader so
491 // a multi-file diff highlights each hunk with its own language instead of the first file's.
492 var currentSyntax: FileSyntax? = null
493 val hunkRegex = Regex("""@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@""")
494 return visibleLines.map { line ->
495 when (line) {
496 is DiffLine.Hunk -> {
497 hunkIndex++
498 val m = hunkRegex.find(line.text)
499 if (m != null) {
500 oldLine = m.groupValues[1].toInt()
501 newLine = m.groupValues[2].toInt()
502 }
503 DiffRowItem(line, line.text, Color.Transparent, tertiary, null, null, hunkIndex, currentSyntax)
504 }
505 is DiffLine.FileHeader -> {
506 currentSyntax = syntaxForHeader(line.text)
507 DiffRowItem(line, line.text, Color.Transparent, onSurface, null, null, null, currentSyntax)
508 }
509 is DiffLine.Meta -> DiffRowItem(line, line.text, Color.Transparent, onSurfaceVariant, null, null, null, currentSyntax)
510 is DiffLine.Add -> {
511 val n = newLine++
512 DiffRowItem(line, line.text, addColor, addText, null, n, hunkIndex, currentSyntax)
513 }
514 is DiffLine.Remove -> {
515 val o = oldLine++
516 DiffRowItem(line, line.text, removeColor, removeText, o, null, hunkIndex, currentSyntax)
517 }
518 is DiffLine.Context -> {
519 val o = oldLine++; val n = newLine++
520 DiffRowItem(line, line.text, Color.Transparent, onSurface, o, n, hunkIndex, currentSyntax)
521 }
522 }
523 }
526/** Render a single [DiffRowItem]. Hunk and FileHeader rows get their own styling; Add/Remove/
527 * Context delegate to [DiffRow]. The Hunk row is clickable to toggle per-hunk collapse via
528 * [collapsedHunks]. */
529@Composable
530private fun RenderDiffRow(
531 row: DiffRowItem,
532 palette: HighlightPalette,
533 collapsedHunks: androidx.compose.runtime.MutableState<Set<Int>>,
534) {
535 when (row.kind) {
536 is DiffLine.Hunk -> {
537 val idx = row.hunkIndex
538 val isCollapsed = idx != null && idx in collapsedHunks.value
539 val collapseLabel = stringResource(if (isCollapsed) R.string.show_more else R.string.show_less)
540 Row(
541 modifier = Modifier
542 .fillMaxWidth()
543 .defaultMinSize(minHeight = 32.dp)
544 .clickable(enabled = idx != null) {
545 if (idx != null) {
546 collapsedHunks.value = if (idx in collapsedHunks.value) {
547 collapsedHunks.value - idx
548 } else {
549 collapsedHunks.value + idx
550 }
551 }
552 }
553 .padding(horizontal = 10.dp, vertical = 2.dp)
554 .semantics {
555 contentDescription = collapseLabel
556 stateDescription = if (isCollapsed) stateCollapsedLabel else stateExpandedLabel
557 },
558 verticalAlignment = Alignment.CenterVertically,
559 ) {
560 Icon(
561 if (isCollapsed) Icons.Filled.ExpandMore else Icons.Filled.ExpandLess,
562 contentDescription = null,
563 modifier = Modifier.size(16.dp),
564 tint = MaterialTheme.colorScheme.onSurfaceVariant,
565 )
566 Text(
567 row.text,
568 style = MaterialTheme.typography.bodySmall,
569 fontFamily = FontFamily.Monospace,
570 color = MaterialTheme.colorScheme.tertiary,
571 modifier = Modifier.padding(start = 4.dp),
572 )
573 }
574 }
575 is DiffLine.FileHeader -> {
576 val displayPath = extractDisplayPath(row.text)
577 val isHeader = displayPath != row.text
578 Text(
579 displayPath,
580 style = MaterialTheme.typography.labelMedium,
581 fontFamily = FontFamily.Monospace,
582 fontWeight = if (isHeader) FontWeight.SemiBold else FontWeight.Normal,
583 color = MaterialTheme.colorScheme.onSurface,
584 modifier = Modifier
585 .fillMaxWidth()
586 .padding(horizontal = 10.dp, vertical = 3.dp)
587 .semantics { heading() },
588 )
589 }
590 is DiffLine.Meta -> Text(
591 row.text,
592 style = MaterialTheme.typography.bodySmall,
593 fontFamily = FontFamily.Monospace,
594 color = MaterialTheme.colorScheme.onSurfaceVariant,
595 modifier = Modifier.padding(vertical = 1.dp),
596 )
597 is DiffLine.Add -> DiffRow(row.text, "+", row.bg, row.textColor, oldLine = row.oldLine, newLine = row.newLine, syntax = row.syntax, palette = palette)
598 is DiffLine.Remove -> DiffRow(row.text, "-", row.bg, row.textColor, oldLine = row.oldLine, newLine = row.newLine, syntax = row.syntax, palette = palette)
599 is DiffLine.Context -> DiffRow(row.text, " ", row.bg, row.textColor, oldLine = row.oldLine, newLine = row.newLine, syntax = row.syntax, palette = palette)
600 }
603@Composable
604private fun DiffRow(
605 text: String,
606 prefix: String,
607 bg: Color,
608 textColor: androidx.compose.ui.graphics.Color,
609 oldLine: Int? = null,
610 newLine: Int? = null,
611 syntax: FileSyntax? = null,
612 palette: HighlightPalette,
613) {
614 val gutterColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
615 // Highlight the line's content (minus the leading +/-/space prefix, which DiffView
616 // already stripped) when a syntax was resolved from a file header. Falls back to plain
617 // monospace text when no syntax is known (e.g. an untagged patch), preserving the prior
618 // rendering for diffs that can't be classified. Off-main + cross-message cached so a
619 // re-displayed row (scroll recycle, theme switch) is a hash hit instead of a re-tokenize.
620 val highlighted: AnnotatedString = rememberHighlightedCode(text, syntax, palette)
621 val a11yLabel = when (prefix) {
622 "+" -> stringResource(R.string.diff_added_line, newLine ?: 0, text)
623 "-" -> stringResource(R.string.diff_removed_line, oldLine ?: 0, text)
624 else -> stringResource(R.string.diff_context_line, oldLine ?: 0, text)
625 }
626 Row(
627 modifier = Modifier
628 .background(bg)
629 .semantics(mergeDescendants = true) { contentDescription = a11yLabel },
630 ) {
631 // Old/new line-number gutters. Blank (not 0) for the side that doesn't have a number
632 // (an added line has no old number; a removed line has no new number) so the columns
633 // stay aligned.
634 Text(
635 oldLine?.toString() ?: "",
636 style = MaterialTheme.typography.bodySmall,
637 fontFamily = FontFamily.Monospace,
638 color = gutterColor,
639 modifier = Modifier.width(NetworkConfig.diffGutterWidthDp.dp),
640 )
641 Text(
642 newLine?.toString() ?: "",
643 style = MaterialTheme.typography.bodySmall,
644 fontFamily = FontFamily.Monospace,
645 color = gutterColor,
646 modifier = Modifier.width(NetworkConfig.diffGutterWidthDp.dp),
647 )
648 Text(
649 prefix,
650 style = MaterialTheme.typography.bodySmall,
651 fontFamily = FontFamily.Monospace,
652 color = textColor,
653 modifier = Modifier.width(NetworkConfig.diffPrefixWidthDp.dp),
654 )
655 Text(
656 highlighted,
657 style = MaterialTheme.typography.bodySmall,
658 fontFamily = FontFamily.Monospace,
659 color = textColor,
660 )
661 }