git.iko.soy/opencode-android
on 366f92e help sign in
359 lines 18 kB history raw
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.width
16import androidx.compose.foundation.rememberScrollState
17import androidx.compose.material.icons.Icons
18import androidx.compose.material.icons.filled.ContentCopy
19import androidx.compose.material.icons.filled.ExpandLess
20import androidx.compose.material.icons.filled.ExpandMore
21import androidx.compose.material3.Icon
22import androidx.compose.material3.IconButton
23import androidx.compose.material3.MaterialTheme
24import androidx.compose.material3.Text
25import androidx.compose.material3.TextButton
26import androidx.compose.runtime.Composable
27import androidx.compose.runtime.getValue
28import androidx.compose.runtime.mutableStateOf
29import androidx.compose.runtime.remember
30import androidx.compose.runtime.saveable.rememberSaveable
31import androidx.compose.runtime.setValue
32import androidx.compose.ui.Alignment
33import androidx.compose.ui.Modifier
34import androidx.compose.ui.draw.clip
35import androidx.compose.ui.graphics.Color
36import androidx.compose.ui.platform.LocalContext
37import androidx.compose.ui.semantics.contentDescription
38import androidx.compose.ui.semantics.heading
39import androidx.compose.ui.semantics.semantics
40import androidx.compose.ui.text.AnnotatedString
41import androidx.compose.ui.text.font.FontFamily
42import androidx.compose.ui.text.font.FontWeight
43import androidx.compose.ui.unit.dp
44import androidx.compose.ui.res.pluralStringResource
45import androidx.compose.ui.res.stringResource
46import soy.iko.opencode.R
47import soy.iko.opencode.data.network.NetworkConfig
49/** A single line of a parsed unified diff. */
50sealed interface DiffLine {
51 val text: String
53 data class Hunk(override val text: String) : DiffLine // @@ ... @@
54 data class FileHeader(override val text: String) : DiffLine // --- / +++ / diff / index
55 data class Context(override val text: String) : DiffLine
56 data class Add(override val text: String) : DiffLine
57 data class Remove(override val text: String) : DiffLine
58 data class Meta(override val text: String) : DiffLine // git metadata (new file mode, etc.)
61/** Git metadata line prefixes that aren't part of the diff content. */
62private val gitMetaPrefixes = listOf(
63 "new file mode ", "deleted file mode ", "old mode ", "new mode ",
64 "similarity index ", "dissimilarity index ", "rename from ", "rename to ",
65 "copy from ", "copy to ", "Binary files ", "\\ No newline",
68/** Does [raw] look like a real unified-diff file header ("--- a/…", "+++ b/…", "--- /dev/null")
69 * rather than a removed/added content line that merely starts with "--- "/"+++ "? */
70private fun isDiffFileHeader(raw: String): Boolean {
71 val rest = when {
72 raw.startsWith("--- ") -> raw.substring(4)
73 raw.startsWith("+++ ") -> raw.substring(4)
74 else -> return false
75 }
76 // Header paths are "a/…", "b/…", or the literal "/dev/null" (optionally followed by a
77 // tab/space-separated timestamp). Restricting the absolute-path case to /dev/null avoids
78 // misclassifying a removed content line like "-- /usr/local/foo" (on the wire
79 // "--- /usr/local/foo"), whose rest starts with "/", as a file header.
80 return rest.startsWith("a/") || rest.startsWith("b/") ||
81 rest == "/dev/null" || rest.startsWith("/dev/null\t") || rest.startsWith("/dev/null ")
84/** Parse a unified diff string into typed [DiffLine]s. */
85fun parseDiff(diff: String): List<DiffLine> {
86 val result = mutableListOf<DiffLine>()
87 for (raw in diff.lineSequence()) {
88 when {
89 raw.startsWith("@@") -> result.add(DiffLine.Hunk(raw))
90 // Unified-diff file headers are "--- <path>" / "+++ <path>" where the path is
91 // "a/…", "b/…", or an absolute path (e.g. "/dev/null"). Requiring that the marker
92 // be followed by "a/", "b/", or "/" avoids misclassifying removed/added *content*
93 // lines like "-- TODO"/"++ x" — which appear on the wire as "--- TODO"/"+++ x" —
94 // as headers; those fall through to the +/- branches below.
95 isDiffFileHeader(raw) -> result.add(DiffLine.FileHeader(raw))
96 raw.startsWith("+") -> result.add(DiffLine.Add(raw.removePrefix("+")))
97 raw.startsWith("-") -> result.add(DiffLine.Remove(raw.removePrefix("-")))
98 raw.startsWith(" ") -> result.add(DiffLine.Context(raw.removePrefix(" ")))
99 raw.startsWith("diff ") || raw.startsWith("index ") -> result.add(DiffLine.FileHeader(raw))
100 gitMetaPrefixes.any { raw.startsWith(it) } -> result.add(DiffLine.Meta(raw))
101 raw.isNotBlank() -> result.add(DiffLine.Context(raw))
102 }
103 }
104 return result
107/** Heuristic: does this string look like a unified diff? */
108fun looksLikeDiff(text: String): Boolean {
109 val lines = text.lineSequence()
110 var hunkCount = 0
111 var addCount = 0
112 var removeCount = 0
113 for (line in lines) {
114 when {
115 line.startsWith("@@") -> hunkCount++
116 line.startsWith("+") && !line.startsWith("+++") -> addCount++
117 line.startsWith("-") && !line.startsWith("---") -> removeCount++
118 }
119 if (hunkCount >= 1 && (addCount + removeCount) >= 1) return true
120 }
121 return false
124/** Extract a display path from a `+++ b/path` / `--- a/path` / `diff --git a/x b/y` header.
125 * Returns the raw line when no path can be extracted, so the header still shows something. */
126private fun extractDisplayPath(headerText: String): String {
127 // `diff --git a/foo b/bar` → `bar` (the new path). `+++ b/foo` → `foo`. `--- a/foo` → `foo`.
128 return when {
129 headerText.startsWith("diff --git ") -> {
130 // Grab the second path after " b/"; fall back to the first after " a/".
131 val bIdx = headerText.indexOf(" b/")
132 if (bIdx >= 0) headerText.substring(bIdx + 3).trim() else headerText.substringAfter(" a/", headerText)
133 }
134 headerText.startsWith("+++ ") -> headerText.substring(4).substringAfter("b/", headerText.substring(4)).trimEnd()
135 headerText.startsWith("--- ") -> headerText.substring(4).substringAfter("a/", headerText.substring(4)).trimEnd()
136 else -> headerText
137 }
140/** Resolve a [FileSyntax] from a diff file header's path, for per-line highlighting. Returns
141 * null when the path has no recognizable extension (so the diff renders plain, as before). */
142private fun syntaxForHeader(headerText: String): FileSyntax? {
143 val path = extractDisplayPath(headerText)
144 if (path == headerText) return null
145 val filename = path.substringAfterLast('/')
146 val syntax = syntaxFor(filename)
147 return if (syntax.lang == Language.NONE) null else syntax
150/**
151 * Renders a parsed unified diff. Implemented as a plain (non-lazy) [Column] so it is
152 * safe to embed inside another vertically scrolling container (e.g. the chat message
153 * list) — a nested `LazyColumn` would crash with an unbounded-height constraint.
154 * Callers that want the view to scroll on its own (e.g. the file viewer) should pass a
155 * [Modifier.verticalScroll] in [modifier].
156 *
157 * To avoid composing thousands of [Text] nodes at once (which can ANR/OOM on large
158 * diffs), only the first [NetworkConfig.collapsedDiffLineThreshold] lines are rendered
159 * unless the user expands the view. Each hunk can also be collapsed individually via
160 * its `@@` header.
161 */
162@Composable
163fun DiffView(diff: String, modifier: Modifier = Modifier, saveKey: String? = null) {
164 val lines = remember(diff) { parseDiff(diff) }
165 // Derive the four row colors once per color scheme instead of allocating fresh Color
166 // objects (the .copy() calls) on every recomposition — a diff re-parses/recomposes
167 // per token while its output streams under the collapse limit.
168 val scheme = MaterialTheme.colorScheme
169 val addColor = remember(scheme) { scheme.primary.copy(alpha = 0.15f) }
170 val removeColor = remember(scheme) { scheme.error.copy(alpha = 0.15f) }
171 val addText = scheme.primary
172 val removeText = scheme.error
173 val context = LocalContext.current
174 val hScrollState = rememberScrollState()
175 // Key on a stable id (the part id) when available so streaming updates that grow `diff`
176 // don't reset expand state on every token. Fall back to `diff` for non-streaming callers
177 // (e.g. FileViewScreen) where the content is static.
178 var expanded by rememberSaveable(saveKey ?: diff) { mutableStateOf(false) }
179 // Resolve a syntax for highlighting from the first file header (if any). Memoized so a
180 // streaming re-parse doesn't re-resolve per token.
181 val syntax = remember(lines) {
182 lines.firstOrNull { it is DiffLine.FileHeader }?.let { syntaxForHeader(it.text) }
183 }
184 val palette = rememberHighlightPalette()
186 Column(
187 modifier = modifier
188 .fillMaxWidth()
189 .clip(MaterialTheme.shapes.small)
190 .background(MaterialTheme.colorScheme.surfaceVariant),
191 ) {
192 Row(
193 modifier = Modifier.fillMaxWidth().padding(top = 2.dp, start = 4.dp, end = 4.dp),
194 verticalAlignment = Alignment.CenterVertically,
195 ) {
196 // Expand/collapse toggle in the header (matching CodeWithCopy's pattern) so the
197 // user doesn't have to scroll to the bottom of a collapsed diff to expand it.
198 if (lines.size > NetworkConfig.collapsedDiffLineThreshold) {
199 val moreLines = lines.size - NetworkConfig.collapsedDiffLineThreshold
200 IconButton(onClick = { expanded = !expanded }) {
201 Icon(
202 if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
203 contentDescription = if (expanded) stringResource(R.string.show_less)
204 else context.resources.getQuantityString(
205 R.plurals.show_more_lines, moreLines, moreLines,
206 ),
207 tint = MaterialTheme.colorScheme.onSurfaceVariant,
208 )
209 }
210 }
211 Spacer(Modifier.weight(1f))
212 IconButton(onClick = { copyToClipboard(context, context.getString(R.string.clip_label_diff), diff) }) {
213 Icon(Icons.Filled.ContentCopy, contentDescription = stringResource(R.string.copy), tint = MaterialTheme.colorScheme.onSurfaceVariant)
214 }
215 }
216 val visibleLines = if (lines.size <= NetworkConfig.collapsedDiffLineThreshold || expanded) lines
217 else lines.subList(0, NetworkConfig.collapsedDiffLineThreshold)
218 // One horizontalScroll on the container instead of one per row: each modifier
219 // adds a layout node + clip + offset pass, so a 200-line collapsed diff was
220 // paying for 200 of them. The shared scroll state still synchronizes all rows.
221 // oldLine/newLine track the current line numbers derived from each @@ hunk header
222 // so the gutter can show absolute line numbers (a big readability win for review).
223 var oldLine = 0
224 var newLine = 0
225 val hunkRegex = Regex("""@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@""")
226 Column(modifier = Modifier.horizontalScroll(hScrollState)) {
227 visibleLines.forEach { line ->
228 when (line) {
229 is DiffLine.Hunk -> {
230 val m = hunkRegex.find(line.text)
231 if (m != null) {
232 oldLine = m.groupValues[1].toInt()
233 newLine = m.groupValues[2].toInt()
234 }
235 Text(
236 line.text,
237 style = MaterialTheme.typography.bodySmall,
238 fontFamily = FontFamily.Monospace,
239 color = MaterialTheme.colorScheme.tertiary,
240 modifier = Modifier.padding(horizontal = 10.dp, vertical = 2.dp),
241 )
242 }
243 is DiffLine.FileHeader -> {
244 // Render the display path (e.g. `src/Foo.kt`) as a bold header rather
245 // than the raw `+++ b/src/Foo.kt`, so file boundaries are scannable in
246 // a long multi-file diff. The raw line is still copied with the diff.
247 val displayPath = extractDisplayPath(line.text)
248 val isHeader = displayPath != line.text
249 Text(
250 displayPath,
251 style = MaterialTheme.typography.labelMedium,
252 fontFamily = FontFamily.Monospace,
253 fontWeight = if (isHeader) FontWeight.SemiBold else FontWeight.Normal,
254 color = MaterialTheme.colorScheme.onSurface,
255 modifier = Modifier
256 .fillMaxWidth()
257 .padding(horizontal = 10.dp, vertical = 3.dp)
258 .semantics { heading() },
259 )
260 }
261 is DiffLine.Meta -> Text(
262 line.text,
263 style = MaterialTheme.typography.bodySmall,
264 fontFamily = FontFamily.Monospace,
265 color = MaterialTheme.colorScheme.onSurfaceVariant,
266 modifier = Modifier.padding(vertical = 1.dp),
267 )
268 is DiffLine.Add -> {
269 val n = newLine++
270 DiffRow(line.text, "+", addColor, addText, oldLine = null, newLine = n, syntax = syntax, palette = palette)
271 }
272 is DiffLine.Remove -> {
273 val o = oldLine++
274 DiffRow(line.text, "-", removeColor, removeText, oldLine = o, newLine = null, syntax = syntax, palette = palette)
275 }
276 is DiffLine.Context -> {
277 val o = oldLine++; val n = newLine++
278 DiffRow(line.text, " ", Color.Transparent, MaterialTheme.colorScheme.onSurface, oldLine = o, newLine = n, syntax = syntax, palette = palette)
279 }
280 }
281 }
282 }
283 if (lines.size > NetworkConfig.collapsedDiffLineThreshold) {
284 val hidden = lines.size - NetworkConfig.collapsedDiffLineThreshold
285 TextButton(
286 onClick = { expanded = !expanded },
287 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 10.dp),
288 modifier = Modifier.defaultMinSize(minHeight = 48.dp),
289 ) {
290 Text(
291 if (expanded) stringResource(R.string.show_less)
292 else pluralStringResource(R.plurals.show_more_lines, hidden, hidden),
293 )
294 }
295 }
296 }
299@Composable
300private fun DiffRow(
301 text: String,
302 prefix: String,
303 bg: Color,
304 textColor: androidx.compose.ui.graphics.Color,
305 oldLine: Int? = null,
306 newLine: Int? = null,
307 syntax: FileSyntax? = null,
308 palette: HighlightPalette,
309) {
310 val gutterColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
311 // Highlight the line's content (minus the leading +/-/space prefix, which DiffView
312 // already stripped) when a syntax was resolved from a file header. Falls back to plain
313 // monospace text when no syntax is known (e.g. an untagged patch), preserving the prior
314 // rendering for diffs that can't be classified.
315 val highlighted: AnnotatedString = remember(text, syntax, palette) {
316 if (syntax != null) highlightLine(text, syntax, palette) else AnnotatedString(text)
317 }
318 val a11yLabel = when (prefix) {
319 "+" -> stringResource(R.string.diff_added_line, newLine ?: 0, text)
320 "-" -> stringResource(R.string.diff_removed_line, oldLine ?: 0, text)
321 else -> stringResource(R.string.diff_context_line, oldLine ?: 0, text)
322 }
323 Row(
324 modifier = Modifier
325 .background(bg)
326 .semantics(mergeDescendants = true) { contentDescription = a11yLabel },
327 ) {
328 // Old/new line-number gutters. Blank (not 0) for the side that doesn't have a number
329 // (an added line has no old number; a removed line has no new number) so the columns
330 // stay aligned.
331 Text(
332 oldLine?.toString() ?: "",
333 style = MaterialTheme.typography.bodySmall,
334 fontFamily = FontFamily.Monospace,
335 color = gutterColor,
336 modifier = Modifier.width(NetworkConfig.diffGutterWidthDp.dp),
337 )
338 Text(
339 newLine?.toString() ?: "",
340 style = MaterialTheme.typography.bodySmall,
341 fontFamily = FontFamily.Monospace,
342 color = gutterColor,
343 modifier = Modifier.width(NetworkConfig.diffGutterWidthDp.dp),
344 )
345 Text(
346 prefix,
347 style = MaterialTheme.typography.bodySmall,
348 fontFamily = FontFamily.Monospace,
349 color = textColor,
350 modifier = Modifier.width(NetworkConfig.diffPrefixWidthDp.dp),
351 )
352 Text(
353 highlighted,
354 style = MaterialTheme.typography.bodySmall,
355 fontFamily = FontFamily.Monospace,
356 color = textColor,
357 )
358 }