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.defaultMinSize
12import androidx.compose.foundation.layout.padding
13import androidx.compose.foundation.layout.width
14import androidx.compose.foundation.rememberScrollState
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.pluralStringResource
36import androidx.compose.ui.res.stringResource
37import soy.iko.opencode.R
39/** A single line of a parsed unified diff. */
40sealed interface DiffLine {
43 data class Hunk(override val text: String) : DiffLine // @@ ... @@
44 data class FileHeader(override val text: String) : DiffLine // --- / +++ / diff / index
45 data class Context(override val text: String) : DiffLine
46 data class Add(override val text: String) : DiffLine
47 data class Remove(override val text: String) : DiffLine
48 data class Meta(override val text: String) : DiffLine // git metadata (new file mode, etc.)
51/** Git metadata line prefixes that aren't part of the diff content. */
52private val gitMetaPrefixes = listOf(
53 "new file mode ", "deleted file mode ", "old mode ", "new mode ",
54 "similarity index ", "dissimilarity index ", "rename from ", "rename to ",
55 "copy from ", "copy to ", "Binary files ", "\\ No newline",
58/** Does [raw] look like a real unified-diff file header ("--- a/…", "+++ b/…", "--- /dev/null")
59 * rather than a removed/added content line that merely starts with "--- "/"+++ "? */
60private fun isDiffFileHeader(raw: String): Boolean {
62 raw.startsWith("--- ") -> raw.substring(4)
63 raw.startsWith("+++ ") -> raw.substring(4)
66 // Header paths are "a/…", "b/…", or the literal "/dev/null" (optionally followed by a
67 // tab/space-separated timestamp). Restricting the absolute-path case to /dev/null avoids
68 // misclassifying a removed content line like "-- /usr/local/foo" (on the wire
69 // "--- /usr/local/foo"), whose rest starts with "/", as a file header.
70 return rest.startsWith("a/") || rest.startsWith("b/") ||
71 rest == "/dev/null" || rest.startsWith("/dev/null\t") || rest.startsWith("/dev/null ")
74/** Parse a unified diff string into typed [DiffLine]s. */
75fun parseDiff(diff: String): List<DiffLine> {
76 val result = mutableListOf<DiffLine>()
77 for (raw in diff.lineSequence()) {
79 raw.startsWith("@@") -> result.add(DiffLine.Hunk(raw))
80 // Unified-diff file headers are "--- <path>" / "+++ <path>" where the path is
81 // "a/…", "b/…", or an absolute path (e.g. "/dev/null"). Requiring that the marker
82 // be followed by "a/", "b/", or "/" avoids misclassifying removed/added *content*
83 // lines like "-- TODO"/"++ x" — which appear on the wire as "--- TODO"/"+++ x" —
84 // as headers; those fall through to the +/- branches below.
85 isDiffFileHeader(raw) -> result.add(DiffLine.FileHeader(raw))
86 raw.startsWith("+") -> result.add(DiffLine.Add(raw.removePrefix("+")))
87 raw.startsWith("-") -> result.add(DiffLine.Remove(raw.removePrefix("-")))
88 raw.startsWith(" ") -> result.add(DiffLine.Context(raw.removePrefix(" ")))
89 raw.startsWith("diff ") || raw.startsWith("index ") -> result.add(DiffLine.FileHeader(raw))
90 gitMetaPrefixes.any { raw.startsWith(it) } -> result.add(DiffLine.Meta(raw))
91 raw.isNotBlank() -> result.add(DiffLine.Context(raw))
97/** Heuristic: does this string look like a unified diff? */
98fun looksLikeDiff(text: String): Boolean {
99 val lines = text.lineSequence()
103 for (line in lines) {
105 line.startsWith("@@") -> hunkCount++
106 line.startsWith("+") && !line.startsWith("+++") -> addCount++
107 line.startsWith("-") && !line.startsWith("---") -> removeCount++
109 if (hunkCount >= 1 && (addCount + removeCount) >= 1) return true
114private const val COLLAPSED_DIFF_LINES = 200
117 * Renders a parsed unified diff. Implemented as a plain (non-lazy) [Column] so it is
118 * safe to embed inside another vertically scrolling container (e.g. the chat message
119 * list) — a nested `LazyColumn` would crash with an unbounded-height constraint.
120 * Callers that want the view to scroll on its own (e.g. the file viewer) should pass a
121 * [Modifier.verticalScroll] in [modifier].
123 * To avoid composing thousands of [Text] nodes at once (which can ANR/OOM on large
124 * diffs), only the first [COLLAPSED_DIFF_LINES] lines are rendered unless the user
128fun DiffView(diff: String, modifier: Modifier = Modifier, saveKey: String? = null) {
129 val lines = remember(diff) { parseDiff(diff) }
130 // Derive the four row colors once per color scheme instead of allocating fresh Color
131 // objects (the .copy() calls) on every recomposition — a diff re-parses/recomposes
132 // per token while its output streams under the collapse limit.
133 val scheme = MaterialTheme.colorScheme
134 val addColor = remember(scheme) { scheme.primary.copy(alpha = 0.15f) }
135 val removeColor = remember(scheme) { scheme.error.copy(alpha = 0.15f) }
136 val addText = scheme.primary
137 val removeText = scheme.error
138 val context = LocalContext.current
139 val hScrollState = rememberScrollState()
140 // Key on a stable id (the part id) when available so streaming updates that grow `diff`
141 // don't reset expand state on every token. Fall back to `diff` for non-streaming callers
142 // (e.g. FileViewScreen) where the content is static.
143 var expanded by rememberSaveable(saveKey ?: diff) { mutableStateOf(false) }
148 .clip(MaterialTheme.shapes.small)
149 .background(MaterialTheme.colorScheme.surfaceVariant),
152 modifier = Modifier.fillMaxWidth().padding(top = 2.dp, end = 4.dp),
153 horizontalArrangement = Arrangement.End,
155 IconButton(onClick = { copyToClipboard(context, context.getString(R.string.clip_label_diff), diff) }) {
156 Icon(Icons.Filled.ContentCopy, contentDescription = stringResource(R.string.copy), tint = MaterialTheme.colorScheme.onSurfaceVariant)
159 val visibleLines = if (lines.size <= COLLAPSED_DIFF_LINES || expanded) lines
160 else lines.subList(0, COLLAPSED_DIFF_LINES)
161 // One horizontalScroll on the container instead of one per row: each modifier
162 // adds a layout node + clip + offset pass, so a 200-line collapsed diff was
163 // paying for 200 of them. The shared scroll state still synchronizes all rows.
164 // oldLine/newLine track the current line numbers derived from each @@ hunk header
165 // so the gutter can show absolute line numbers (a big readability win for review).
168 val hunkRegex = Regex("""@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@""")
169 Column(modifier = Modifier.horizontalScroll(hScrollState)) {
170 visibleLines.forEach { line ->
171 Row(modifier = Modifier.padding(horizontal = 10.dp)) {
173 is DiffLine.Hunk -> {
174 val m = hunkRegex.find(line.text)
176 oldLine = m.groupValues[1].toInt()
177 newLine = m.groupValues[2].toInt()
181 style = MaterialTheme.typography.bodySmall,
182 fontFamily = FontFamily.Monospace,
183 color = MaterialTheme.colorScheme.tertiary,
184 modifier = Modifier.padding(vertical = 2.dp),
187 is DiffLine.FileHeader -> Text(
189 style = MaterialTheme.typography.bodySmall,
190 fontFamily = FontFamily.Monospace,
191 color = MaterialTheme.colorScheme.onSurfaceVariant,
192 modifier = Modifier.padding(vertical = 1.dp),
194 is DiffLine.Meta -> Text(
196 style = MaterialTheme.typography.bodySmall,
197 fontFamily = FontFamily.Monospace,
198 color = MaterialTheme.colorScheme.onSurfaceVariant,
199 modifier = Modifier.padding(vertical = 1.dp),
202 val n = newLine++; DiffRow(line.text, "+", addColor, addText, oldLine = null, newLine = n)
204 is DiffLine.Remove -> {
205 val o = oldLine++; DiffRow(line.text, "-", removeColor, removeText, oldLine = o, newLine = null)
207 is DiffLine.Context -> {
208 val o = oldLine++; val n = newLine++
209 DiffRow(line.text, " ", Color.Transparent, MaterialTheme.colorScheme.onSurface, oldLine = o, newLine = n)
215 if (lines.size > COLLAPSED_DIFF_LINES) {
216 val hidden = lines.size - COLLAPSED_DIFF_LINES
218 onClick = { expanded = !expanded },
219 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 10.dp),
220 modifier = Modifier.defaultMinSize(minHeight = 48.dp),
223 if (expanded) stringResource(R.string.show_less)
224 else pluralStringResource(R.plurals.show_more_lines, hidden, hidden),
228 Spacer(Modifier.height(10.dp))
237 textColor: androidx.compose.ui.graphics.Color,
238 oldLine: Int? = null,
239 newLine: Int? = null,
241 val gutterColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
243 modifier = Modifier.background(bg),
245 // Old/new line-number gutters. Blank (not 0) for the side that doesn't have a number
246 // (an added line has no old number; a removed line has no new number) so the columns
249 oldLine?.toString() ?: "",
250 style = MaterialTheme.typography.bodySmall,
251 fontFamily = FontFamily.Monospace,
253 modifier = Modifier.width(36.dp),
256 newLine?.toString() ?: "",
257 style = MaterialTheme.typography.bodySmall,
258 fontFamily = FontFamily.Monospace,
260 modifier = Modifier.width(36.dp),
264 style = MaterialTheme.typography.bodySmall,
265 fontFamily = FontFamily.Monospace,
267 modifier = Modifier.width(16.dp),
271 style = MaterialTheme.typography.bodySmall,
272 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