git.iko.soy/opencode-android
on bc6b45e help sign in
218 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 val addColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.15f)
129 val removeColor = MaterialTheme.colorScheme.error.copy(alpha = 0.15f)
130 val addText = MaterialTheme.colorScheme.primary
131 val removeText = MaterialTheme.colorScheme.error
132 val context = LocalContext.current
133 val hScrollState = rememberScrollState()
134 var expanded by rememberSaveable(diff) { mutableStateOf(false) }
136 Column(
137 modifier = modifier
138 .fillMaxWidth()
139 .clip(MaterialTheme.shapes.small)
140 .background(MaterialTheme.colorScheme.surfaceVariant),
141 ) {
142 Row(
143 modifier = Modifier.fillMaxWidth().padding(top = 2.dp, end = 4.dp),
144 horizontalArrangement = Arrangement.End,
145 ) {
146 IconButton(onClick = { copyToClipboard(context, "diff", diff) }) {
147 Icon(Icons.Filled.ContentCopy, contentDescription = stringResource(R.string.copy), tint = MaterialTheme.colorScheme.onSurfaceVariant)
148 }
149 }
150 val visibleLines = if (lines.size <= COLLAPSED_DIFF_LINES || expanded) lines
151 else lines.subList(0, COLLAPSED_DIFF_LINES)
152 // One horizontalScroll on the container instead of one per row: each modifier
153 // adds a layout node + clip + offset pass, so a 200-line collapsed diff was
154 // paying for 200 of them. The shared scroll state still synchronizes all rows.
155 Column(modifier = Modifier.horizontalScroll(hScrollState)) {
156 visibleLines.forEach { line ->
157 Row(modifier = Modifier.padding(horizontal = 10.dp)) {
158 when (line) {
159 is DiffLine.Hunk -> Text(
160 line.text,
161 style = MaterialTheme.typography.bodySmall,
162 fontFamily = FontFamily.Monospace,
163 color = MaterialTheme.colorScheme.tertiary,
164 modifier = Modifier.padding(vertical = 2.dp),
165 )
166 is DiffLine.FileHeader -> Text(
167 line.text,
168 style = MaterialTheme.typography.bodySmall,
169 fontFamily = FontFamily.Monospace,
170 color = MaterialTheme.colorScheme.onSurfaceVariant,
171 modifier = Modifier.padding(vertical = 1.dp),
172 )
173 is DiffLine.Meta -> Text(
174 line.text,
175 style = MaterialTheme.typography.bodySmall,
176 fontFamily = FontFamily.Monospace,
177 color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
178 modifier = Modifier.padding(vertical = 1.dp),
179 )
180 is DiffLine.Add -> DiffRow(line.text, "+", addColor, addText)
181 is DiffLine.Remove -> DiffRow(line.text, "-", removeColor, removeText)
182 is DiffLine.Context -> DiffRow(line.text, " ", Color.Transparent, MaterialTheme.colorScheme.onSurface)
183 }
184 }
185 }
186 }
187 if (lines.size > COLLAPSED_DIFF_LINES && !expanded) {
188 TextButton(
189 onClick = { expanded = true },
190 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 10.dp),
191 ) {
192 Text(stringResource(R.string.show_more))
193 }
194 }
195 Spacer(Modifier.height(10.dp))
196 }
199@Composable
200private fun DiffRow(text: String, prefix: String, bg: Color, textColor: androidx.compose.ui.graphics.Color) {
201 Row(
202 modifier = Modifier.background(bg),
203 ) {
204 Text(
205 prefix,
206 style = MaterialTheme.typography.bodySmall,
207 fontFamily = FontFamily.Monospace,
208 color = textColor,
209 modifier = Modifier.width(16.dp),
210 )
211 Text(
212 text,
213 style = MaterialTheme.typography.bodySmall,
214 fontFamily = FontFamily.Monospace,
215 color = textColor,
216 )
217 }