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.material.icons.Icons
15import androidx.compose.material.icons.filled.ContentCopy
16import androidx.compose.material3.Icon
17import androidx.compose.material3.IconButton
18import androidx.compose.material3.MaterialTheme
19import androidx.compose.material3.Text
20import androidx.compose.material3.TextButton
21import androidx.compose.runtime.Composable
22import androidx.compose.runtime.getValue
23import androidx.compose.runtime.mutableStateOf
24import androidx.compose.runtime.remember
25import androidx.compose.runtime.saveable.rememberSaveable
26import androidx.compose.runtime.setValue
27import androidx.compose.ui.Alignment
28import androidx.compose.ui.Modifier
29import androidx.compose.ui.draw.clip
30import androidx.compose.ui.graphics.Color
31import androidx.compose.ui.platform.LocalContext
32import androidx.compose.ui.text.font.FontFamily
33import androidx.compose.ui.unit.dp
34import androidx.compose.ui.res.stringResource
35import soy.iko.opencode.R
37/** A single line of a parsed unified diff. */
38sealed interface DiffLine {
41 data class Hunk(override val text: String) : DiffLine // @@ ... @@
42 data class FileHeader(override val text: String) : DiffLine // --- / +++ / diff / index
43 data class Context(override val text: String) : DiffLine
44 data class Add(override val text: String) : DiffLine
45 data class Remove(override val text: String) : DiffLine
46 data class Meta(override val text: String) : DiffLine // git metadata (new file mode, etc.)
49/** Git metadata line prefixes that aren't part of the diff content. */
50private val gitMetaPrefixes = listOf(
51 "new file mode ", "deleted file mode ", "old mode ", "new mode ",
52 "similarity index ", "dissimilarity index ", "rename from ", "rename to ",
53 "copy from ", "copy to ", "Binary files ", "\\ No newline",
56/** Does [raw] look like a real unified-diff file header ("--- a/…", "+++ b/…", "--- /dev/null")
57 * rather than a removed/added content line that merely starts with "--- "/"+++ "? */
58private fun isDiffFileHeader(raw: String): Boolean {
60 raw.startsWith("--- ") -> raw.substring(4)
61 raw.startsWith("+++ ") -> raw.substring(4)
64 // Header paths are "a/…", "b/…", or the literal "/dev/null" (optionally followed by a
65 // tab/space-separated timestamp). Restricting the absolute-path case to /dev/null avoids
66 // misclassifying a removed content line like "-- /usr/local/foo" (on the wire
67 // "--- /usr/local/foo"), whose rest starts with "/", as a file header.
68 return rest.startsWith("a/") || rest.startsWith("b/") ||
69 rest == "/dev/null" || rest.startsWith("/dev/null\t") || rest.startsWith("/dev/null ")
72/** Parse a unified diff string into typed [DiffLine]s. */
73fun parseDiff(diff: String): List<DiffLine> {
74 val result = mutableListOf<DiffLine>()
75 for (raw in diff.lineSequence()) {
77 raw.startsWith("@@") -> result.add(DiffLine.Hunk(raw))
78 // Unified-diff file headers are "--- <path>" / "+++ <path>" where the path is
79 // "a/…", "b/…", or an absolute path (e.g. "/dev/null"). Requiring that the marker
80 // be followed by "a/", "b/", or "/" avoids misclassifying removed/added *content*
81 // lines like "-- TODO"/"++ x" — which appear on the wire as "--- TODO"/"+++ x" —
82 // as headers; those fall through to the +/- branches below.
83 isDiffFileHeader(raw) -> result.add(DiffLine.FileHeader(raw))
84 raw.startsWith("+") -> result.add(DiffLine.Add(raw.removePrefix("+")))
85 raw.startsWith("-") -> result.add(DiffLine.Remove(raw.removePrefix("-")))
86 raw.startsWith(" ") -> result.add(DiffLine.Context(raw.removePrefix(" ")))
87 raw.startsWith("diff ") || raw.startsWith("index ") -> result.add(DiffLine.FileHeader(raw))
88 gitMetaPrefixes.any { raw.startsWith(it) } -> result.add(DiffLine.Meta(raw))
89 raw.isNotBlank() -> result.add(DiffLine.Context(raw))
95/** Heuristic: does this string look like a unified diff? */
96fun looksLikeDiff(text: String): Boolean {
97 val lines = text.lineSequence()
101 for (line in lines) {
103 line.startsWith("@@") -> hunkCount++
104 line.startsWith("+") && !line.startsWith("+++") -> addCount++
105 line.startsWith("-") && !line.startsWith("---") -> removeCount++
107 if (hunkCount >= 1 && (addCount + removeCount) >= 1) return true
112private const val COLLAPSED_DIFF_LINES = 200
115 * Renders a parsed unified diff. Implemented as a plain (non-lazy) [Column] so it is
116 * safe to embed inside another vertically scrolling container (e.g. the chat message
117 * list) — a nested `LazyColumn` would crash with an unbounded-height constraint.
118 * Callers that want the view to scroll on its own (e.g. the file viewer) should pass a
119 * [Modifier.verticalScroll] in [modifier].
121 * To avoid composing thousands of [Text] nodes at once (which can ANR/OOM on large
122 * diffs), only the first [COLLAPSED_DIFF_LINES] lines are rendered unless the user
126fun DiffView(diff: String, modifier: Modifier = Modifier, saveKey: String? = null) {
127 val lines = remember(diff) { parseDiff(diff) }
128 // Derive the four row colors once per color scheme instead of allocating fresh Color
129 // objects (the .copy() calls) on every recomposition — a diff re-parses/recomposes
130 // per token while its output streams under the collapse limit.
131 val scheme = MaterialTheme.colorScheme
132 val addColor = remember(scheme) { scheme.primary.copy(alpha = 0.15f) }
133 val removeColor = remember(scheme) { scheme.error.copy(alpha = 0.15f) }
134 val addText = scheme.primary
135 val removeText = scheme.error
136 val context = LocalContext.current
137 val hScrollState = rememberScrollState()
138 // Key on a stable id (the part id) when available so streaming updates that grow `diff`
139 // don't reset expand state on every token. Fall back to `diff` for non-streaming callers
140 // (e.g. FileViewScreen) where the content is static.
141 var expanded by rememberSaveable(saveKey ?: diff) { mutableStateOf(false) }
146 .clip(MaterialTheme.shapes.small)
147 .background(MaterialTheme.colorScheme.surfaceVariant),
150 modifier = Modifier.fillMaxWidth().padding(top = 2.dp, end = 4.dp),
151 horizontalArrangement = Arrangement.End,
153 IconButton(onClick = { copyToClipboard(context, "diff", diff) }) {
154 Icon(Icons.Filled.ContentCopy, contentDescription = stringResource(R.string.copy), tint = MaterialTheme.colorScheme.onSurfaceVariant)
157 val visibleLines = if (lines.size <= COLLAPSED_DIFF_LINES || expanded) lines
158 else lines.subList(0, COLLAPSED_DIFF_LINES)
159 // One horizontalScroll on the container instead of one per row: each modifier
160 // adds a layout node + clip + offset pass, so a 200-line collapsed diff was
161 // paying for 200 of them. The shared scroll state still synchronizes all rows.
162 Column(modifier = Modifier.horizontalScroll(hScrollState)) {
163 visibleLines.forEach { line ->
164 Row(modifier = Modifier.padding(horizontal = 10.dp)) {
166 is DiffLine.Hunk -> Text(
168 style = MaterialTheme.typography.bodySmall,
169 fontFamily = FontFamily.Monospace,
170 color = MaterialTheme.colorScheme.tertiary,
171 modifier = Modifier.padding(vertical = 2.dp),
173 is DiffLine.FileHeader -> Text(
175 style = MaterialTheme.typography.bodySmall,
176 fontFamily = FontFamily.Monospace,
177 color = MaterialTheme.colorScheme.onSurfaceVariant,
178 modifier = Modifier.padding(vertical = 1.dp),
180 is DiffLine.Meta -> Text(
182 style = MaterialTheme.typography.bodySmall,
183 fontFamily = FontFamily.Monospace,
184 color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
185 modifier = Modifier.padding(vertical = 1.dp),
187 is DiffLine.Add -> DiffRow(line.text, "+", addColor, addText)
188 is DiffLine.Remove -> DiffRow(line.text, "-", removeColor, removeText)
189 is DiffLine.Context -> DiffRow(line.text, " ", Color.Transparent, MaterialTheme.colorScheme.onSurface)
194 if (lines.size > COLLAPSED_DIFF_LINES && !expanded) {
196 onClick = { expanded = true },
197 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 10.dp),
199 Text(stringResource(R.string.show_more_lines, lines.size - COLLAPSED_DIFF_LINES))
202 Spacer(Modifier.height(10.dp))
207private fun DiffRow(text: String, prefix: String, bg: Color, textColor: androidx.compose.ui.graphics.Color) {
209 modifier = Modifier.background(bg),
213 style = MaterialTheme.typography.bodySmall,
214 fontFamily = FontFamily.Monospace,
216 modifier = Modifier.width(16.dp),
220 style = MaterialTheme.typography.bodySmall,
221 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