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