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 {
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")) {
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))
60/** Heuristic: does this string look like a unified diff? */
61fun looksLikeDiff(text: String): Boolean {
62 val lines = text.lineSequence()
68 line.startsWith("@@") -> hunkCount++
69 line.startsWith("+") && !line.startsWith("+++") -> addCount++
70 line.startsWith("-") && !line.startsWith("---") -> removeCount++
72 if (hunkCount >= 1 && (addCount + removeCount) >= 1) return true
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].
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()
97 .clip(RoundedCornerShape(8.dp))
98 .background(MaterialTheme.colorScheme.surfaceVariant),
101 modifier = Modifier.fillMaxWidth().padding(top = 2.dp, end = 4.dp),
102 horizontalArrangement = Arrangement.End,
104 IconButton(onClick = { copyToClipboard(context, "diff", diff) }) {
105 Icon(Icons.Filled.ContentCopy, contentDescription = stringResource(R.string.copy), tint = MaterialTheme.colorScheme.onSurfaceVariant)
108 lines.forEach { line ->
109 Row(modifier = Modifier.horizontalScroll(hScrollState).padding(horizontal = 10.dp)) {
111 is DiffLine.Hunk -> Text(
113 style = MaterialTheme.typography.bodySmall,
114 fontFamily = FontFamily.Monospace,
115 color = MaterialTheme.colorScheme.tertiary,
116 modifier = Modifier.padding(vertical = 2.dp),
118 is DiffLine.FileHeader -> Text(
120 style = MaterialTheme.typography.bodySmall,
121 fontFamily = FontFamily.Monospace,
122 color = MaterialTheme.colorScheme.onSurfaceVariant,
123 modifier = Modifier.padding(vertical = 1.dp),
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)
131 Spacer(Modifier.padding(bottom = 10.dp))
136private fun DiffRow(text: String, prefix: String, bg: Color, textColor: androidx.compose.ui.graphics.Color) {
138 modifier = Modifier.background(bg),
142 style = MaterialTheme.typography.bodySmall,
143 fontFamily = FontFamily.Monospace,
145 modifier = Modifier.width(16.dp),
149 style = MaterialTheme.typography.bodySmall,
150 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