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 {
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 {
83 raw.startsWith("--- ") -> raw.substring(4)
84 raw.startsWith("+++ ") -> raw.substring(4)
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()) {
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))
118/** Heuristic: does this string look like a unified diff? */
119fun looksLikeDiff(text: String): Boolean {
120 val lines = text.lineSequence()
124 for (line in lines) {
126 line.startsWith("@@") -> hunkCount++
127 line.startsWith("+") && !line.startsWith("+++") -> addCount++
128 line.startsWith("-") && !line.startsWith("---") -> removeCount++
130 if (hunkCount >= 1 && (addCount + removeCount) >= 1) return true
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`.
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)
145 headerText.startsWith("+++ ") -> headerText.substring(4).substringAfter("b/", headerText.substring(4)).trimEnd()
146 headerText.startsWith("--- ") -> headerText.substring(4).substringAfter("a/", headerText.substring(4)).trimEnd()
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
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].
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
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.
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(
207 collapsedHunks = collapsedHunks.value,
209 removeColor = removeColor,
211 removeText = removeText,
212 tertiary = scheme.tertiary,
213 onSurface = scheme.onSurface,
214 onSurfaceVariant = scheme.onSurfaceVariant,
220 .clip(MaterialTheme.shapes.small)
221 .background(MaterialTheme.colorScheme.surfaceVariant),
224 lineCount = rows.lineCount,
226 onToggle = { expanded = !expanded },
227 onCopy = { copyToClipboard(context, context.getString(R.string.clip_label_diff), diff) },
229 Column(modifier = Modifier.horizontalScroll(hScrollState)) {
230 rows.rows.forEach { row -> RenderDiffRow(row, palette, collapsedHunks) }
232 DiffExpandFooter(lineCount = rows.lineCount, expanded = expanded, onToggle = { expanded = !expanded })
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.
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(
261 collapsedHunks = collapsedHunks.value,
263 removeColor = removeColor,
265 removeText = removeText,
266 tertiary = scheme.tertiary,
267 onSurface = scheme.onSurface,
268 onSurfaceVariant = scheme.onSurfaceVariant,
274 .clip(MaterialTheme.shapes.small)
275 .background(MaterialTheme.colorScheme.surfaceVariant),
278 lineCount = rows.lineCount,
280 onToggle = { expanded = !expanded },
281 onCopy = { copyToClipboard(context, context.getString(R.string.clip_label_diff), diff) },
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.
287 state = lazyListState,
288 modifier = Modifier.horizontalScroll(hScrollState),
292 key = { i, row -> "${row.kind::class.simpleName}:$i:${row.text.hashCode()}" },
294 RenderDiffRow(row, palette, collapsedHunks)
297 DiffExpandFooter(lineCount = rows.lineCount, expanded = expanded, onToggle = { expanded = !expanded })
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).
306private data class DiffRows(val rows: List<DiffRowItem>, val lineCount: Int)
309 * Parse [diff] and build the pre-resolved [DiffRowItem] list off the main thread.
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.
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).
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
328private fun rememberDiffRows(
331 collapsedHunks: Set<Int>,
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)
349 val hunkIndices = hunkStartIndices(lines)
350 val visibleLines = if (collapsedHunks.isEmpty()) baseLines
351 else filterCollapsedHunks(baseLines, hunkIndices, collapsedHunks)
353 rows = buildDiffRows(
355 addColor, removeColor, addText, removeText,
356 tertiary, onSurface, onSurfaceVariant,
358 lineCount = lines.size,
360 }.getOrDefault(DiffRows(emptyList(), 0))
365/** Header row with the expand/collapse toggle (when applicable) and a copy action. */
367private fun DiffHeader(lineCount: Int, expanded: Boolean, onToggle: () -> Unit, onCopy: () -> Unit) {
368 val context = LocalContext.current
370 modifier = Modifier.fillMaxWidth().padding(top = 2.dp, start = 4.dp, end = 4.dp),
371 verticalAlignment = Alignment.CenterVertically,
373 if (lineCount > NetworkConfig.collapsedDiffLineThreshold) {
374 val moreLines = lineCount - NetworkConfig.collapsedDiffLineThreshold
375 IconButton(onClick = onToggle) {
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,
382 tint = MaterialTheme.colorScheme.onSurfaceVariant,
386 Spacer(Modifier.weight(1f))
387 IconButton(onClick = onCopy) {
388 Icon(Icons.Filled.ContentCopy, contentDescription = stringResource(R.string.copy), tint = MaterialTheme.colorScheme.onSurfaceVariant)
393/** Footer "show more/less" button when the diff was collapsed. */
395private fun DiffExpandFooter(lineCount: Int, expanded: Boolean, onToggle: () -> Unit) {
396 if (lineCount > NetworkConfig.collapsedDiffLineThreshold) {
397 val hidden = lineCount - NetworkConfig.collapsedDiffLineThreshold
400 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 10.dp),
401 modifier = Modifier.defaultMinSize(minHeight = 48.dp),
404 if (expanded) stringResource(R.string.show_less)
405 else pluralStringResource(R.plurals.show_more_lines, hidden, hidden),
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>()
417 lines.forEachIndexed { i, line ->
418 if (line is DiffLine.Hunk) {
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>,
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
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(
462 val textColor: Color,
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>,
485 onSurfaceVariant: Color,
486): List<DiffRowItem> {
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 ->
496 is DiffLine.Hunk -> {
498 val m = hunkRegex.find(line.text)
500 oldLine = m.groupValues[1].toInt()
501 newLine = m.groupValues[2].toInt()
503 DiffRowItem(line, line.text, Color.Transparent, tertiary, null, null, hunkIndex, currentSyntax)
505 is DiffLine.FileHeader -> {
506 currentSyntax = syntaxForHeader(line.text)
507 DiffRowItem(line, line.text, Color.Transparent, onSurface, null, null, null, currentSyntax)
509 is DiffLine.Meta -> DiffRowItem(line, line.text, Color.Transparent, onSurfaceVariant, null, null, null, currentSyntax)
512 DiffRowItem(line, line.text, addColor, addText, null, n, hunkIndex, currentSyntax)
514 is DiffLine.Remove -> {
516 DiffRowItem(line, line.text, removeColor, removeText, o, null, hunkIndex, currentSyntax)
518 is DiffLine.Context -> {
519 val o = oldLine++; val n = newLine++
520 DiffRowItem(line, line.text, Color.Transparent, onSurface, o, n, hunkIndex, currentSyntax)
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]. */
530private fun RenderDiffRow(
532 palette: HighlightPalette,
533 collapsedHunks: androidx.compose.runtime.MutableState<Set<Int>>,
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)
543 .defaultMinSize(minHeight = 32.dp)
544 .clickable(enabled = idx != null) {
546 collapsedHunks.value = if (idx in collapsedHunks.value) {
547 collapsedHunks.value - idx
549 collapsedHunks.value + idx
553 .padding(horizontal = 10.dp, vertical = 2.dp)
555 contentDescription = collapseLabel
556 stateDescription = if (isCollapsed) stateCollapsedLabel else stateExpandedLabel
558 verticalAlignment = Alignment.CenterVertically,
561 if (isCollapsed) Icons.Filled.ExpandMore else Icons.Filled.ExpandLess,
562 contentDescription = null,
563 modifier = Modifier.size(16.dp),
564 tint = MaterialTheme.colorScheme.onSurfaceVariant,
568 style = MaterialTheme.typography.bodySmall,
569 fontFamily = FontFamily.Monospace,
570 color = MaterialTheme.colorScheme.tertiary,
571 modifier = Modifier.padding(start = 4.dp),
575 is DiffLine.FileHeader -> {
576 val displayPath = extractDisplayPath(row.text)
577 val isHeader = displayPath != row.text
580 style = MaterialTheme.typography.labelMedium,
581 fontFamily = FontFamily.Monospace,
582 fontWeight = if (isHeader) FontWeight.SemiBold else FontWeight.Normal,
583 color = MaterialTheme.colorScheme.onSurface,
586 .padding(horizontal = 10.dp, vertical = 3.dp)
587 .semantics { heading() },
590 is DiffLine.Meta -> Text(
592 style = MaterialTheme.typography.bodySmall,
593 fontFamily = FontFamily.Monospace,
594 color = MaterialTheme.colorScheme.onSurfaceVariant,
595 modifier = Modifier.padding(vertical = 1.dp),
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)
608 textColor: androidx.compose.ui.graphics.Color,
609 oldLine: Int? = null,
610 newLine: Int? = null,
611 syntax: FileSyntax? = null,
612 palette: HighlightPalette,
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)
629 .semantics(mergeDescendants = true) { contentDescription = a11yLabel },
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
635 oldLine?.toString() ?: "",
636 style = MaterialTheme.typography.bodySmall,
637 fontFamily = FontFamily.Monospace,
639 modifier = Modifier.width(NetworkConfig.diffGutterWidthDp.dp),
642 newLine?.toString() ?: "",
643 style = MaterialTheme.typography.bodySmall,
644 fontFamily = FontFamily.Monospace,
646 modifier = Modifier.width(NetworkConfig.diffGutterWidthDp.dp),
650 style = MaterialTheme.typography.bodySmall,
651 fontFamily = FontFamily.Monospace,
653 modifier = Modifier.width(NetworkConfig.diffPrefixWidthDp.dp),
657 style = MaterialTheme.typography.bodySmall,
658 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