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