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 {
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")) {
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))
70/** Heuristic: does this string look like a unified diff? */
71fun looksLikeDiff(text: String): Boolean {
72 val lines = text.lineSequence()
78 line.startsWith("@@") -> hunkCount++
79 line.startsWith("+") && !line.startsWith("+++") -> addCount++
80 line.startsWith("-") && !line.startsWith("---") -> removeCount++
82 if (hunkCount >= 1 && (addCount + removeCount) >= 1) return true
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].
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()
107 .clip(RoundedCornerShape(8.dp))
108 .background(MaterialTheme.colorScheme.surfaceVariant),
111 modifier = Modifier.fillMaxWidth().padding(top = 2.dp, end = 4.dp),
112 horizontalArrangement = Arrangement.End,
114 IconButton(onClick = { copyToClipboard(context, "diff", diff) }) {
115 Icon(Icons.Filled.ContentCopy, contentDescription = stringResource(R.string.copy), tint = MaterialTheme.colorScheme.onSurfaceVariant)
118 lines.forEach { line ->
119 Row(modifier = Modifier.horizontalScroll(hScrollState).padding(horizontal = 10.dp)) {
121 is DiffLine.Hunk -> Text(
123 style = MaterialTheme.typography.bodySmall,
124 fontFamily = FontFamily.Monospace,
125 color = MaterialTheme.colorScheme.tertiary,
126 modifier = Modifier.padding(vertical = 2.dp),
128 is DiffLine.FileHeader -> Text(
130 style = MaterialTheme.typography.bodySmall,
131 fontFamily = FontFamily.Monospace,
132 color = MaterialTheme.colorScheme.onSurfaceVariant,
133 modifier = Modifier.padding(vertical = 1.dp),
135 is DiffLine.Meta -> 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),
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)
148 Spacer(Modifier.height(10.dp))
153private fun DiffRow(text: String, prefix: String, bg: Color, textColor: androidx.compose.ui.graphics.Color) {
155 modifier = Modifier.background(bg),
159 style = MaterialTheme.typography.bodySmall,
160 fontFamily = FontFamily.Monospace,
162 modifier = Modifier.width(16.dp),
166 style = MaterialTheme.typography.bodySmall,
167 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