git.iko.soy/opencode-android
on 066cded help sign in
230 lines 11 kB history raw
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.pluralStringResource
35import androidx.compose.ui.res.stringResource
36import soy.iko.opencode.R
38/** A single line of a parsed unified diff. */
39sealed interface DiffLine {
40 val text: String
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/** Does [raw] look like a real unified-diff file header ("--- a/…", "+++ b/…", "--- /dev/null")
58 * rather than a removed/added content line that merely starts with "--- "/"+++ "? */
59private fun isDiffFileHeader(raw: String): Boolean {
60 val rest = when {
61 raw.startsWith("--- ") -> raw.substring(4)
62 raw.startsWith("+++ ") -> raw.substring(4)
63 else -> return false
64 }
65 // Header paths are "a/…", "b/…", or the literal "/dev/null" (optionally followed by a
66 // tab/space-separated timestamp). Restricting the absolute-path case to /dev/null avoids
67 // misclassifying a removed content line like "-- /usr/local/foo" (on the wire
68 // "--- /usr/local/foo"), whose rest starts with "/", as a file header.
69 return rest.startsWith("a/") || rest.startsWith("b/") ||
70 rest == "/dev/null" || rest.startsWith("/dev/null\t") || rest.startsWith("/dev/null ")
73/** Parse a unified diff string into typed [DiffLine]s. */
74fun parseDiff(diff: String): List<DiffLine> {
75 val result = mutableListOf<DiffLine>()
76 for (raw in diff.lineSequence()) {
77 when {
78 raw.startsWith("@@") -> result.add(DiffLine.Hunk(raw))
79 // Unified-diff file headers are "--- <path>" / "+++ <path>" where the path is
80 // "a/…", "b/…", or an absolute path (e.g. "/dev/null"). Requiring that the marker
81 // be followed by "a/", "b/", or "/" avoids misclassifying removed/added *content*
82 // lines like "-- TODO"/"++ x" — which appear on the wire as "--- TODO"/"+++ x" —
83 // as headers; those fall through to the +/- branches below.
84 isDiffFileHeader(raw) -> result.add(DiffLine.FileHeader(raw))
85 raw.startsWith("+") -> result.add(DiffLine.Add(raw.removePrefix("+")))
86 raw.startsWith("-") -> result.add(DiffLine.Remove(raw.removePrefix("-")))
87 raw.startsWith(" ") -> result.add(DiffLine.Context(raw.removePrefix(" ")))
88 raw.startsWith("diff ") || raw.startsWith("index ") -> result.add(DiffLine.FileHeader(raw))
89 gitMetaPrefixes.any { raw.startsWith(it) } -> result.add(DiffLine.Meta(raw))
90 raw.isNotBlank() -> result.add(DiffLine.Context(raw))
91 }
92 }
93 return result
96/** Heuristic: does this string look like a unified diff? */
97fun looksLikeDiff(text: String): Boolean {
98 val lines = text.lineSequence()
99 var hunkCount = 0
100 var addCount = 0
101 var removeCount = 0
102 for (line in lines) {
103 when {
104 line.startsWith("@@") -> hunkCount++
105 line.startsWith("+") && !line.startsWith("+++") -> addCount++
106 line.startsWith("-") && !line.startsWith("---") -> removeCount++
107 }
108 if (hunkCount >= 1 && (addCount + removeCount) >= 1) return true
109 }
110 return false
113private const val COLLAPSED_DIFF_LINES = 200
115/**
116 * Renders a parsed unified diff. Implemented as a plain (non-lazy) [Column] so it is
117 * safe to embed inside another vertically scrolling container (e.g. the chat message
118 * list) — a nested `LazyColumn` would crash with an unbounded-height constraint.
119 * Callers that want the view to scroll on its own (e.g. the file viewer) should pass a
120 * [Modifier.verticalScroll] in [modifier].
121 *
122 * To avoid composing thousands of [Text] nodes at once (which can ANR/OOM on large
123 * diffs), only the first [COLLAPSED_DIFF_LINES] lines are rendered unless the user
124 * expands the view.
125 */
126@Composable
127fun DiffView(diff: String, modifier: Modifier = Modifier, saveKey: String? = null) {
128 val lines = remember(diff) { parseDiff(diff) }
129 // Derive the four row colors once per color scheme instead of allocating fresh Color
130 // objects (the .copy() calls) on every recomposition — a diff re-parses/recomposes
131 // per token while its output streams under the collapse limit.
132 val scheme = MaterialTheme.colorScheme
133 val addColor = remember(scheme) { scheme.primary.copy(alpha = 0.15f) }
134 val removeColor = remember(scheme) { scheme.error.copy(alpha = 0.15f) }
135 val addText = scheme.primary
136 val removeText = scheme.error
137 val context = LocalContext.current
138 val hScrollState = rememberScrollState()
139 // Key on a stable id (the part id) when available so streaming updates that grow `diff`
140 // don't reset expand state on every token. Fall back to `diff` for non-streaming callers
141 // (e.g. FileViewScreen) where the content is static.
142 var expanded by rememberSaveable(saveKey ?: diff) { mutableStateOf(false) }
144 Column(
145 modifier = modifier
146 .fillMaxWidth()
147 .clip(MaterialTheme.shapes.small)
148 .background(MaterialTheme.colorScheme.surfaceVariant),
149 ) {
150 Row(
151 modifier = Modifier.fillMaxWidth().padding(top = 2.dp, end = 4.dp),
152 horizontalArrangement = Arrangement.End,
153 ) {
154 IconButton(onClick = { copyToClipboard(context, context.getString(R.string.clip_label_diff), diff) }) {
155 Icon(Icons.Filled.ContentCopy, contentDescription = stringResource(R.string.copy), tint = MaterialTheme.colorScheme.onSurfaceVariant)
156 }
157 }
158 val visibleLines = if (lines.size <= COLLAPSED_DIFF_LINES || expanded) lines
159 else lines.subList(0, COLLAPSED_DIFF_LINES)
160 // One horizontalScroll on the container instead of one per row: each modifier
161 // adds a layout node + clip + offset pass, so a 200-line collapsed diff was
162 // paying for 200 of them. The shared scroll state still synchronizes all rows.
163 Column(modifier = Modifier.horizontalScroll(hScrollState)) {
164 visibleLines.forEach { line ->
165 Row(modifier = Modifier.padding(horizontal = 10.dp)) {
166 when (line) {
167 is DiffLine.Hunk -> Text(
168 line.text,
169 style = MaterialTheme.typography.bodySmall,
170 fontFamily = FontFamily.Monospace,
171 color = MaterialTheme.colorScheme.tertiary,
172 modifier = Modifier.padding(vertical = 2.dp),
173 )
174 is DiffLine.FileHeader -> Text(
175 line.text,
176 style = MaterialTheme.typography.bodySmall,
177 fontFamily = FontFamily.Monospace,
178 color = MaterialTheme.colorScheme.onSurfaceVariant,
179 modifier = Modifier.padding(vertical = 1.dp),
180 )
181 is DiffLine.Meta -> Text(
182 line.text,
183 style = MaterialTheme.typography.bodySmall,
184 fontFamily = FontFamily.Monospace,
185 color = MaterialTheme.colorScheme.onSurfaceVariant,
186 modifier = Modifier.padding(vertical = 1.dp),
187 )
188 is DiffLine.Add -> DiffRow(line.text, "+", addColor, addText)
189 is DiffLine.Remove -> DiffRow(line.text, "-", removeColor, removeText)
190 is DiffLine.Context -> DiffRow(line.text, " ", Color.Transparent, MaterialTheme.colorScheme.onSurface)
191 }
192 }
193 }
194 }
195 if (lines.size > COLLAPSED_DIFF_LINES) {
196 val hidden = lines.size - COLLAPSED_DIFF_LINES
197 TextButton(
198 onClick = { expanded = !expanded },
199 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 10.dp),
200 ) {
201 Text(
202 if (expanded) stringResource(R.string.show_less)
203 else pluralStringResource(R.plurals.show_more_lines, hidden, hidden),
204 )
205 }
206 }
207 Spacer(Modifier.height(10.dp))
208 }
211@Composable
212private fun DiffRow(text: String, prefix: String, bg: Color, textColor: androidx.compose.ui.graphics.Color) {
213 Row(
214 modifier = Modifier.background(bg),
215 ) {
216 Text(
217 prefix,
218 style = MaterialTheme.typography.bodySmall,
219 fontFamily = FontFamily.Monospace,
220 color = textColor,
221 modifier = Modifier.width(16.dp),
222 )
223 Text(
224 text,
225 style = MaterialTheme.typography.bodySmall,
226 fontFamily = FontFamily.Monospace,
227 color = textColor,
228 )
229 }