git.iko.soy/opencode-android
on c0621ff help sign in
222 lines 10 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.stringResource
35import soy.iko.opencode.R
37/** A single line of a parsed unified diff. */
38sealed interface DiffLine {
39 val text: String
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 {
59 val rest = when {
60 raw.startsWith("--- ") -> raw.substring(4)
61 raw.startsWith("+++ ") -> raw.substring(4)
62 else -> return false
63 }
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()) {
76 when {
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))
90 }
91 }
92 return result
95/** Heuristic: does this string look like a unified diff? */
96fun looksLikeDiff(text: String): Boolean {
97 val lines = text.lineSequence()
98 var hunkCount = 0
99 var addCount = 0
100 var removeCount = 0
101 for (line in lines) {
102 when {
103 line.startsWith("@@") -> hunkCount++
104 line.startsWith("+") && !line.startsWith("+++") -> addCount++
105 line.startsWith("-") && !line.startsWith("---") -> removeCount++
106 }
107 if (hunkCount >= 1 && (addCount + removeCount) >= 1) return true
108 }
109 return false
112private const val COLLAPSED_DIFF_LINES = 200
114/**
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].
120 *
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
123 * expands the view.
124 */
125@Composable
126fun DiffView(diff: String, modifier: Modifier = Modifier) {
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 var expanded by rememberSaveable(diff) { mutableStateOf(false) }
140 Column(
141 modifier = modifier
142 .fillMaxWidth()
143 .clip(MaterialTheme.shapes.small)
144 .background(MaterialTheme.colorScheme.surfaceVariant),
145 ) {
146 Row(
147 modifier = Modifier.fillMaxWidth().padding(top = 2.dp, end = 4.dp),
148 horizontalArrangement = Arrangement.End,
149 ) {
150 IconButton(onClick = { copyToClipboard(context, "diff", diff) }) {
151 Icon(Icons.Filled.ContentCopy, contentDescription = stringResource(R.string.copy), tint = MaterialTheme.colorScheme.onSurfaceVariant)
152 }
153 }
154 val visibleLines = if (lines.size <= COLLAPSED_DIFF_LINES || expanded) lines
155 else lines.subList(0, COLLAPSED_DIFF_LINES)
156 // One horizontalScroll on the container instead of one per row: each modifier
157 // adds a layout node + clip + offset pass, so a 200-line collapsed diff was
158 // paying for 200 of them. The shared scroll state still synchronizes all rows.
159 Column(modifier = Modifier.horizontalScroll(hScrollState)) {
160 visibleLines.forEach { line ->
161 Row(modifier = Modifier.padding(horizontal = 10.dp)) {
162 when (line) {
163 is DiffLine.Hunk -> Text(
164 line.text,
165 style = MaterialTheme.typography.bodySmall,
166 fontFamily = FontFamily.Monospace,
167 color = MaterialTheme.colorScheme.tertiary,
168 modifier = Modifier.padding(vertical = 2.dp),
169 )
170 is DiffLine.FileHeader -> Text(
171 line.text,
172 style = MaterialTheme.typography.bodySmall,
173 fontFamily = FontFamily.Monospace,
174 color = MaterialTheme.colorScheme.onSurfaceVariant,
175 modifier = Modifier.padding(vertical = 1.dp),
176 )
177 is DiffLine.Meta -> Text(
178 line.text,
179 style = MaterialTheme.typography.bodySmall,
180 fontFamily = FontFamily.Monospace,
181 color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
182 modifier = Modifier.padding(vertical = 1.dp),
183 )
184 is DiffLine.Add -> DiffRow(line.text, "+", addColor, addText)
185 is DiffLine.Remove -> DiffRow(line.text, "-", removeColor, removeText)
186 is DiffLine.Context -> DiffRow(line.text, " ", Color.Transparent, MaterialTheme.colorScheme.onSurface)
187 }
188 }
189 }
190 }
191 if (lines.size > COLLAPSED_DIFF_LINES && !expanded) {
192 TextButton(
193 onClick = { expanded = true },
194 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 10.dp),
195 ) {
196 Text(stringResource(R.string.show_more_lines, lines.size - COLLAPSED_DIFF_LINES))
197 }
198 }
199 Spacer(Modifier.height(10.dp))
200 }
203@Composable
204private fun DiffRow(text: String, prefix: String, bg: Color, textColor: androidx.compose.ui.graphics.Color) {
205 Row(
206 modifier = Modifier.background(bg),
207 ) {
208 Text(
209 prefix,
210 style = MaterialTheme.typography.bodySmall,
211 fontFamily = FontFamily.Monospace,
212 color = textColor,
213 modifier = Modifier.width(16.dp),
214 )
215 Text(
216 text,
217 style = MaterialTheme.typography.bodySmall,
218 fontFamily = FontFamily.Monospace,
219 color = textColor,
220 )
221 }