git.iko.soy/opencode-android
on d643c13 help sign in
171 lines 7.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.runtime.Composable
22import androidx.compose.runtime.remember
23import androidx.compose.ui.Alignment
24import androidx.compose.ui.Modifier
25import androidx.compose.ui.draw.clip
26import androidx.compose.ui.graphics.Color
27import androidx.compose.ui.platform.LocalContext
28import androidx.compose.ui.text.font.FontFamily
29import androidx.compose.ui.unit.dp
30import androidx.compose.ui.res.stringResource
31import soy.iko.opencode.R
33/** A single line of a parsed unified diff. */
34sealed interface DiffLine {
35 val text: String
37 data class Hunk(override val text: String) : DiffLine // @@ ... @@
38 data class FileHeader(override val text: String) : DiffLine // --- / +++ / diff / index
39 data class Context(override val text: String) : DiffLine
40 data class Add(override val text: String) : DiffLine
41 data class Remove(override val text: String) : DiffLine
42 data class Meta(override val text: String) : DiffLine // git metadata (new file mode, etc.)
45/** Git metadata line prefixes that aren't part of the diff content. */
46private val gitMetaPrefixes = listOf(
47 "new file mode ", "deleted file mode ", "old mode ", "new mode ",
48 "similarity index ", "dissimilarity index ", "rename from ", "rename to ",
49 "copy from ", "copy to ", "Binary files ", "\\ No newline",
52/** Parse a unified diff string into typed [DiffLine]s. */
53fun parseDiff(diff: String): List<DiffLine> {
54 val result = mutableListOf<DiffLine>()
55 for (raw in diff.split("\n")) {
56 when {
57 raw.startsWith("@@") -> result.add(DiffLine.Hunk(raw))
58 raw.startsWith("---") || raw.startsWith("+++") -> result.add(DiffLine.FileHeader(raw))
59 raw.startsWith("+") -> result.add(DiffLine.Add(raw.removePrefix("+")))
60 raw.startsWith("-") -> result.add(DiffLine.Remove(raw.removePrefix("-")))
61 raw.startsWith(" ") -> result.add(DiffLine.Context(raw.removePrefix(" ")))
62 raw.startsWith("diff ") || raw.startsWith("index ") -> result.add(DiffLine.FileHeader(raw))
63 gitMetaPrefixes.any { raw.startsWith(it) } -> result.add(DiffLine.Meta(raw))
64 raw.isNotBlank() -> result.add(DiffLine.Context(raw))
65 }
66 }
67 return result
70/** Heuristic: does this string look like a unified diff? */
71fun looksLikeDiff(text: String): Boolean {
72 val lines = text.lineSequence()
73 var hunkCount = 0
74 var addCount = 0
75 var removeCount = 0
76 for (line in lines) {
77 when {
78 line.startsWith("@@") -> hunkCount++
79 line.startsWith("+") && !line.startsWith("+++") -> addCount++
80 line.startsWith("-") && !line.startsWith("---") -> removeCount++
81 }
82 if (hunkCount >= 1 && (addCount + removeCount) >= 1) return true
83 }
84 return false
87/**
88 * Renders a parsed unified diff. Implemented as a plain (non-lazy) [Column] so it is
89 * safe to embed inside another vertically scrolling container (e.g. the chat message
90 * list) — a nested `LazyColumn` would crash with an unbounded-height constraint.
91 * Callers that want the view to scroll on its own (e.g. the file viewer) should pass a
92 * [Modifier.verticalScroll] in [modifier].
93 */
94@Composable
95fun DiffView(diff: String, modifier: Modifier = Modifier) {
96 val lines = remember(diff) { parseDiff(diff) }
97 val addColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.15f)
98 val removeColor = MaterialTheme.colorScheme.error.copy(alpha = 0.15f)
99 val addText = MaterialTheme.colorScheme.primary
100 val removeText = MaterialTheme.colorScheme.error
101 val context = LocalContext.current
102 val hScrollState = rememberScrollState()
104 Column(
105 modifier = modifier
106 .fillMaxWidth()
107 .clip(RoundedCornerShape(8.dp))
108 .background(MaterialTheme.colorScheme.surfaceVariant),
109 ) {
110 Row(
111 modifier = Modifier.fillMaxWidth().padding(top = 2.dp, end = 4.dp),
112 horizontalArrangement = Arrangement.End,
113 ) {
114 IconButton(onClick = { copyToClipboard(context, "diff", diff) }) {
115 Icon(Icons.Filled.ContentCopy, contentDescription = stringResource(R.string.copy), tint = MaterialTheme.colorScheme.onSurfaceVariant)
116 }
117 }
118 lines.forEach { line ->
119 Row(modifier = Modifier.horizontalScroll(hScrollState).padding(horizontal = 10.dp)) {
120 when (line) {
121 is DiffLine.Hunk -> Text(
122 line.text,
123 style = MaterialTheme.typography.bodySmall,
124 fontFamily = FontFamily.Monospace,
125 color = MaterialTheme.colorScheme.tertiary,
126 modifier = Modifier.padding(vertical = 2.dp),
127 )
128 is DiffLine.FileHeader -> Text(
129 line.text,
130 style = MaterialTheme.typography.bodySmall,
131 fontFamily = FontFamily.Monospace,
132 color = MaterialTheme.colorScheme.onSurfaceVariant,
133 modifier = Modifier.padding(vertical = 1.dp),
134 )
135 is DiffLine.Meta -> Text(
136 line.text,
137 style = MaterialTheme.typography.bodySmall,
138 fontFamily = FontFamily.Monospace,
139 color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
140 modifier = Modifier.padding(vertical = 1.dp),
141 )
142 is DiffLine.Add -> DiffRow(line.text, "+", addColor, addText)
143 is DiffLine.Remove -> DiffRow(line.text, "-", removeColor, removeText)
144 is DiffLine.Context -> DiffRow(line.text, " ", Color.Transparent, MaterialTheme.colorScheme.onSurface)
145 }
146 }
147 }
148 Spacer(Modifier.height(10.dp))
149 }
152@Composable
153private fun DiffRow(text: String, prefix: String, bg: Color, textColor: androidx.compose.ui.graphics.Color) {
154 Row(
155 modifier = Modifier.background(bg),
156 ) {
157 Text(
158 prefix,
159 style = MaterialTheme.typography.bodySmall,
160 fontFamily = FontFamily.Monospace,
161 color = textColor,
162 modifier = Modifier.width(16.dp),
163 )
164 Text(
165 text,
166 style = MaterialTheme.typography.bodySmall,
167 fontFamily = FontFamily.Monospace,
168 color = textColor,
169 )
170 }