git.iko.soy/opencode-android
on a40d3a2 help sign in
198 lines 8.9 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.foundation.shape.RoundedCornerShape
15import androidx.compose.material.icons.Icons
16import androidx.compose.material.icons.filled.ContentCopy
17import androidx.compose.material3.Icon
18import androidx.compose.material3.IconButton
19import androidx.compose.material3.MaterialTheme
20import androidx.compose.material3.Text
21import androidx.compose.material3.TextButton
22import androidx.compose.runtime.Composable
23import androidx.compose.runtime.getValue
24import androidx.compose.runtime.mutableStateOf
25import androidx.compose.runtime.remember
26import androidx.compose.runtime.saveable.rememberSaveable
27import androidx.compose.runtime.setValue
28import androidx.compose.ui.Alignment
29import androidx.compose.ui.Modifier
30import androidx.compose.ui.draw.clip
31import androidx.compose.ui.graphics.Color
32import androidx.compose.ui.platform.LocalContext
33import androidx.compose.ui.text.font.FontFamily
34import androidx.compose.ui.unit.dp
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/** Parse a unified diff string into typed [DiffLine]s. */
58fun parseDiff(diff: String): List<DiffLine> {
59 val result = mutableListOf<DiffLine>()
60 for (raw in diff.lineSequence()) {
61 when {
62 raw.startsWith("@@") -> result.add(DiffLine.Hunk(raw))
63 raw.startsWith("---") || raw.startsWith("+++") -> result.add(DiffLine.FileHeader(raw))
64 raw.startsWith("+") -> result.add(DiffLine.Add(raw.removePrefix("+")))
65 raw.startsWith("-") -> result.add(DiffLine.Remove(raw.removePrefix("-")))
66 raw.startsWith(" ") -> result.add(DiffLine.Context(raw.removePrefix(" ")))
67 raw.startsWith("diff ") || raw.startsWith("index ") -> result.add(DiffLine.FileHeader(raw))
68 gitMetaPrefixes.any { raw.startsWith(it) } -> result.add(DiffLine.Meta(raw))
69 raw.isNotBlank() -> result.add(DiffLine.Context(raw))
70 }
71 }
72 return result
75/** Heuristic: does this string look like a unified diff? */
76fun looksLikeDiff(text: String): Boolean {
77 val lines = text.lineSequence()
78 var hunkCount = 0
79 var addCount = 0
80 var removeCount = 0
81 for (line in lines) {
82 when {
83 line.startsWith("@@") -> hunkCount++
84 line.startsWith("+") && !line.startsWith("+++") -> addCount++
85 line.startsWith("-") && !line.startsWith("---") -> removeCount++
86 }
87 if (hunkCount >= 1 && (addCount + removeCount) >= 1) return true
88 }
89 return false
92private const val COLLAPSED_DIFF_LINES = 200
94/**
95 * Renders a parsed unified diff. Implemented as a plain (non-lazy) [Column] so it is
96 * safe to embed inside another vertically scrolling container (e.g. the chat message
97 * list) — a nested `LazyColumn` would crash with an unbounded-height constraint.
98 * Callers that want the view to scroll on its own (e.g. the file viewer) should pass a
99 * [Modifier.verticalScroll] in [modifier].
100 *
101 * To avoid composing thousands of [Text] nodes at once (which can ANR/OOM on large
102 * diffs), only the first [COLLAPSED_DIFF_LINES] lines are rendered unless the user
103 * expands the view.
104 */
105@Composable
106fun DiffView(diff: String, modifier: Modifier = Modifier) {
107 val lines = remember(diff) { parseDiff(diff) }
108 val addColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.15f)
109 val removeColor = MaterialTheme.colorScheme.error.copy(alpha = 0.15f)
110 val addText = MaterialTheme.colorScheme.primary
111 val removeText = MaterialTheme.colorScheme.error
112 val context = LocalContext.current
113 val hScrollState = rememberScrollState()
114 var expanded by rememberSaveable(diff) { mutableStateOf(false) }
116 Column(
117 modifier = modifier
118 .fillMaxWidth()
119 .clip(RoundedCornerShape(8.dp))
120 .background(MaterialTheme.colorScheme.surfaceVariant),
121 ) {
122 Row(
123 modifier = Modifier.fillMaxWidth().padding(top = 2.dp, end = 4.dp),
124 horizontalArrangement = Arrangement.End,
125 ) {
126 IconButton(onClick = { copyToClipboard(context, "diff", diff) }) {
127 Icon(Icons.Filled.ContentCopy, contentDescription = stringResource(R.string.copy), tint = MaterialTheme.colorScheme.onSurfaceVariant)
128 }
129 }
130 val visibleLines = if (lines.size <= COLLAPSED_DIFF_LINES || expanded) lines
131 else lines.subList(0, COLLAPSED_DIFF_LINES)
132 // One horizontalScroll on the container instead of one per row: each modifier
133 // adds a layout node + clip + offset pass, so a 200-line collapsed diff was
134 // paying for 200 of them. The shared scroll state still synchronizes all rows.
135 Column(modifier = Modifier.horizontalScroll(hScrollState)) {
136 visibleLines.forEach { line ->
137 Row(modifier = Modifier.padding(horizontal = 10.dp)) {
138 when (line) {
139 is DiffLine.Hunk -> Text(
140 line.text,
141 style = MaterialTheme.typography.bodySmall,
142 fontFamily = FontFamily.Monospace,
143 color = MaterialTheme.colorScheme.tertiary,
144 modifier = Modifier.padding(vertical = 2.dp),
145 )
146 is DiffLine.FileHeader -> Text(
147 line.text,
148 style = MaterialTheme.typography.bodySmall,
149 fontFamily = FontFamily.Monospace,
150 color = MaterialTheme.colorScheme.onSurfaceVariant,
151 modifier = Modifier.padding(vertical = 1.dp),
152 )
153 is DiffLine.Meta -> Text(
154 line.text,
155 style = MaterialTheme.typography.bodySmall,
156 fontFamily = FontFamily.Monospace,
157 color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
158 modifier = Modifier.padding(vertical = 1.dp),
159 )
160 is DiffLine.Add -> DiffRow(line.text, "+", addColor, addText)
161 is DiffLine.Remove -> DiffRow(line.text, "-", removeColor, removeText)
162 is DiffLine.Context -> DiffRow(line.text, " ", Color.Transparent, MaterialTheme.colorScheme.onSurface)
163 }
164 }
165 }
166 }
167 if (lines.size > COLLAPSED_DIFF_LINES && !expanded) {
168 TextButton(
169 onClick = { expanded = true },
170 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 10.dp),
171 ) {
172 Text(stringResource(R.string.show_more))
173 }
174 }
175 Spacer(Modifier.height(10.dp))
176 }
179@Composable
180private fun DiffRow(text: String, prefix: String, bg: Color, textColor: androidx.compose.ui.graphics.Color) {
181 Row(
182 modifier = Modifier.background(bg),
183 ) {
184 Text(
185 prefix,
186 style = MaterialTheme.typography.bodySmall,
187 fontFamily = FontFamily.Monospace,
188 color = textColor,
189 modifier = Modifier.width(16.dp),
190 )
191 Text(
192 text,
193 style = MaterialTheme.typography.bodySmall,
194 fontFamily = FontFamily.Monospace,
195 color = textColor,
196 )
197 }