1package soy.iko.opencode.ui.components
3import androidx.compose.foundation.background
4import androidx.compose.foundation.horizontalScroll
5import androidx.compose.foundation.layout.Arrangement
6import androidx.compose.foundation.layout.Column
7import androidx.compose.foundation.layout.Row
8import androidx.compose.foundation.layout.Spacer
9import androidx.compose.foundation.layout.fillMaxWidth
10import androidx.compose.foundation.layout.height
11import androidx.compose.foundation.layout.padding
12import androidx.compose.foundation.layout.width
13import androidx.compose.foundation.rememberScrollState
14import androidx.compose.foundation.shape.RoundedCornerShape
15import androidx.compose.material.icons.Icons
16import androidx.compose.material.icons.filled.ContentCopy
17import androidx.compose.material3.Icon
18import androidx.compose.material3.IconButton
19import androidx.compose.material3.MaterialTheme
20import androidx.compose.material3.Text
21import androidx.compose.material3.TextButton
22import androidx.compose.runtime.Composable
23import androidx.compose.runtime.getValue
24import androidx.compose.runtime.mutableStateOf
25import androidx.compose.runtime.remember
26import androidx.compose.runtime.saveable.rememberSaveable
27import androidx.compose.runtime.setValue
28import androidx.compose.ui.Alignment
29import androidx.compose.ui.Modifier
30import androidx.compose.ui.draw.clip
31import androidx.compose.ui.graphics.Color
32import androidx.compose.ui.platform.LocalContext
33import androidx.compose.ui.text.font.FontFamily
34import androidx.compose.ui.unit.dp
35import androidx.compose.ui.res.stringResource
36import soy.iko.opencode.R
38/** A single line of a parsed unified diff. */
39sealed interface DiffLine {
42 data class Hunk(override val text: String) : DiffLine // @@ ... @@
43 data class FileHeader(override val text: String) : DiffLine // --- / +++ / diff / index
44 data class Context(override val text: String) : DiffLine
45 data class Add(override val text: String) : DiffLine
46 data class Remove(override val text: String) : DiffLine
47 data class Meta(override val text: String) : DiffLine // git metadata (new file mode, etc.)
50/** Git metadata line prefixes that aren't part of the diff content. */
51private val gitMetaPrefixes = listOf(
52 "new file mode ", "deleted file mode ", "old mode ", "new mode ",
53 "similarity index ", "dissimilarity index ", "rename from ", "rename to ",
54 "copy from ", "copy to ", "Binary files ", "\\ No newline",
57/** Parse a unified diff string into typed [DiffLine]s. */
58fun parseDiff(diff: String): List<DiffLine> {
59 val result = mutableListOf<DiffLine>()
60 for (raw in diff.lineSequence()) {
62 raw.startsWith("@@") -> result.add(DiffLine.Hunk(raw))
63 raw.startsWith("---") || raw.startsWith("+++") -> result.add(DiffLine.FileHeader(raw))
64 raw.startsWith("+") -> result.add(DiffLine.Add(raw.removePrefix("+")))
65 raw.startsWith("-") -> result.add(DiffLine.Remove(raw.removePrefix("-")))
66 raw.startsWith(" ") -> result.add(DiffLine.Context(raw.removePrefix(" ")))
67 raw.startsWith("diff ") || raw.startsWith("index ") -> result.add(DiffLine.FileHeader(raw))
68 gitMetaPrefixes.any { raw.startsWith(it) } -> result.add(DiffLine.Meta(raw))
69 raw.isNotBlank() -> result.add(DiffLine.Context(raw))
75/** Heuristic: does this string look like a unified diff? */
76fun looksLikeDiff(text: String): Boolean {
77 val lines = text.lineSequence()
83 line.startsWith("@@") -> hunkCount++
84 line.startsWith("+") && !line.startsWith("+++") -> addCount++
85 line.startsWith("-") && !line.startsWith("---") -> removeCount++
87 if (hunkCount >= 1 && (addCount + removeCount) >= 1) return true
92private const val COLLAPSED_DIFF_LINES = 200
95 * Renders a parsed unified diff. Implemented as a plain (non-lazy) [Column] so it is
96 * safe to embed inside another vertically scrolling container (e.g. the chat message
97 * list) — a nested `LazyColumn` would crash with an unbounded-height constraint.
98 * Callers that want the view to scroll on its own (e.g. the file viewer) should pass a
99 * [Modifier.verticalScroll] in [modifier].
101 * To avoid composing thousands of [Text] nodes at once (which can ANR/OOM on large
102 * diffs), only the first [COLLAPSED_DIFF_LINES] lines are rendered unless the user
106fun DiffView(diff: String, modifier: Modifier = Modifier) {
107 val lines = remember(diff) { parseDiff(diff) }
108 val addColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.15f)
109 val removeColor = MaterialTheme.colorScheme.error.copy(alpha = 0.15f)
110 val addText = MaterialTheme.colorScheme.primary
111 val removeText = MaterialTheme.colorScheme.error
112 val context = LocalContext.current
113 val hScrollState = rememberScrollState()
114 var expanded by rememberSaveable(diff) { mutableStateOf(false) }
119 .clip(RoundedCornerShape(8.dp))
120 .background(MaterialTheme.colorScheme.surfaceVariant),
123 modifier = Modifier.fillMaxWidth().padding(top = 2.dp, end = 4.dp),
124 horizontalArrangement = Arrangement.End,
126 IconButton(onClick = { copyToClipboard(context, "diff", diff) }) {
127 Icon(Icons.Filled.ContentCopy, contentDescription = stringResource(R.string.copy), tint = MaterialTheme.colorScheme.onSurfaceVariant)
130 val visibleLines = if (lines.size <= COLLAPSED_DIFF_LINES || expanded) lines
131 else lines.subList(0, COLLAPSED_DIFF_LINES)
132 // One horizontalScroll on the container instead of one per row: each modifier
133 // adds a layout node + clip + offset pass, so a 200-line collapsed diff was
134 // paying for 200 of them. The shared scroll state still synchronizes all rows.
135 Column(modifier = Modifier.horizontalScroll(hScrollState)) {
136 visibleLines.forEach { line ->
137 Row(modifier = Modifier.padding(horizontal = 10.dp)) {
139 is DiffLine.Hunk -> Text(
141 style = MaterialTheme.typography.bodySmall,
142 fontFamily = FontFamily.Monospace,
143 color = MaterialTheme.colorScheme.tertiary,
144 modifier = Modifier.padding(vertical = 2.dp),
146 is DiffLine.FileHeader -> Text(
148 style = MaterialTheme.typography.bodySmall,
149 fontFamily = FontFamily.Monospace,
150 color = MaterialTheme.colorScheme.onSurfaceVariant,
151 modifier = Modifier.padding(vertical = 1.dp),
153 is DiffLine.Meta -> Text(
155 style = MaterialTheme.typography.bodySmall,
156 fontFamily = FontFamily.Monospace,
157 color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
158 modifier = Modifier.padding(vertical = 1.dp),
160 is DiffLine.Add -> DiffRow(line.text, "+", addColor, addText)
161 is DiffLine.Remove -> DiffRow(line.text, "-", removeColor, removeText)
162 is DiffLine.Context -> DiffRow(line.text, " ", Color.Transparent, MaterialTheme.colorScheme.onSurface)
167 if (lines.size > COLLAPSED_DIFF_LINES && !expanded) {
169 onClick = { expanded = true },
170 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 10.dp),
172 Text(stringResource(R.string.show_more))
175 Spacer(Modifier.height(10.dp))
180private fun DiffRow(text: String, prefix: String, bg: Color, textColor: androidx.compose.ui.graphics.Color) {
182 modifier = Modifier.background(bg),
186 style = MaterialTheme.typography.bodySmall,
187 fontFamily = FontFamily.Monospace,
189 modifier = Modifier.width(16.dp),
193 style = MaterialTheme.typography.bodySmall,
194 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