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 {
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")) {
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))
75/** Heuristic: does this string look like a unified diff? */
76fun looksLikeDiff(text: String): Boolean {
77 val lines = text.lineSequence()
83 line.startsWith("@@") -> hunkCount++
84 line.startsWith("+") && !line.startsWith("+++") -> addCount++
85 line.startsWith("-") && !line.startsWith("---") -> removeCount++
87 if (hunkCount >= 1 && (addCount + removeCount) >= 1) return true
92private const val COLLAPSED_DIFF_LINES = 500
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].
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
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) }
119 .clip(RoundedCornerShape(8.dp))
120 .background(MaterialTheme.colorScheme.surfaceVariant),
123 modifier = Modifier.fillMaxWidth().padding(top = 2.dp, end = 4.dp),
124 horizontalArrangement = Arrangement.End,
126 IconButton(onClick = { copyToClipboard(context, "diff", diff) }) {
127 Icon(Icons.Filled.ContentCopy, contentDescription = stringResource(R.string.copy), tint = MaterialTheme.colorScheme.onSurfaceVariant)
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)) {
135 is DiffLine.Hunk -> Text(
137 style = MaterialTheme.typography.bodySmall,
138 fontFamily = FontFamily.Monospace,
139 color = MaterialTheme.colorScheme.tertiary,
140 modifier = Modifier.padding(vertical = 2.dp),
142 is DiffLine.FileHeader -> Text(
144 style = MaterialTheme.typography.bodySmall,
145 fontFamily = FontFamily.Monospace,
146 color = MaterialTheme.colorScheme.onSurfaceVariant,
147 modifier = Modifier.padding(vertical = 1.dp),
149 is DiffLine.Meta -> 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),
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)
162 if (lines.size > COLLAPSED_DIFF_LINES && !expanded) {
164 onClick = { expanded = true },
165 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 10.dp),
167 Text(stringResource(R.string.show_more))
170 Spacer(Modifier.height(10.dp))
175private fun DiffRow(text: String, prefix: String, bg: Color, textColor: androidx.compose.ui.graphics.Color) {
177 modifier = Modifier.background(bg),
181 style = MaterialTheme.typography.bodySmall,
182 fontFamily = FontFamily.Monospace,
184 modifier = Modifier.width(16.dp),
188 style = MaterialTheme.typography.bodySmall,
189 fontFamily = FontFamily.Monospace,
keysesc to close
- j k
- move down, up
- enter
- open
- esc
- back, or up a level
- 1 2 3 4
- readme, files, commits, refs
- /
- filter
- y
- copy the clone command
- c
- compare the chosen commit with another (or shift-click both)
- ?
- show or hide this list