git.iko.soy/opencode-android
on fb38d12 help sign in
193 lines 8.5 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.split("\n")) {
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 = 500
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 visibleLines.forEach { line ->
133 Row(modifier = Modifier.horizontalScroll(hScrollState).padding(horizontal = 10.dp)) {
134 when (line) {
135 is DiffLine.Hunk -> Text(
136 line.text,
137 style = MaterialTheme.typography.bodySmall,
138 fontFamily = FontFamily.Monospace,
139 color = MaterialTheme.colorScheme.tertiary,
140 modifier = Modifier.padding(vertical = 2.dp),
141 )
142 is DiffLine.FileHeader -> Text(
143 line.text,
144 style = MaterialTheme.typography.bodySmall,
145 fontFamily = FontFamily.Monospace,
146 color = MaterialTheme.colorScheme.onSurfaceVariant,
147 modifier = Modifier.padding(vertical = 1.dp),
148 )
149 is DiffLine.Meta -> Text(
150 line.text,
151 style = MaterialTheme.typography.bodySmall,
152 fontFamily = FontFamily.Monospace,
153 color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
154 modifier = Modifier.padding(vertical = 1.dp),
155 )
156 is DiffLine.Add -> DiffRow(line.text, "+", addColor, addText)
157 is DiffLine.Remove -> DiffRow(line.text, "-", removeColor, removeText)
158 is DiffLine.Context -> DiffRow(line.text, " ", Color.Transparent, MaterialTheme.colorScheme.onSurface)
159 }
160 }
161 }
162 if (lines.size > COLLAPSED_DIFF_LINES && !expanded) {
163 TextButton(
164 onClick = { expanded = true },
165 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 10.dp),
166 ) {
167 Text(stringResource(R.string.show_more))
168 }
169 }
170 Spacer(Modifier.height(10.dp))
171 }
174@Composable
175private fun DiffRow(text: String, prefix: String, bg: Color, textColor: androidx.compose.ui.graphics.Color) {
176 Row(
177 modifier = Modifier.background(bg),
178 ) {
179 Text(
180 prefix,
181 style = MaterialTheme.typography.bodySmall,
182 fontFamily = FontFamily.Monospace,
183 color = textColor,
184 modifier = Modifier.width(16.dp),
185 )
186 Text(
187 text,
188 style = MaterialTheme.typography.bodySmall,
189 fontFamily = FontFamily.Monospace,
190 color = textColor,
191 )
192 }