@@ -13,6 +13,9 @@ import androidx.compose.foundation.layout.height
13import androidx.compose.foundation.layout.defaultMinSize
13import androidx.compose.foundation.layout.defaultMinSize
14import androidx.compose.foundation.layout.padding
14import androidx.compose.foundation.layout.padding
15import androidx.compose.foundation.layout.width
15import androidx.compose.foundation.layout.width
16+import androidx.compose.foundation.lazy.LazyColumn
17+import androidx.compose.foundation.lazy.itemsIndexed
18+import androidx.compose.foundation.lazy.rememberLazyListState
16import androidx.compose.foundation.rememberScrollState
19import androidx.compose.foundation.rememberScrollState
17import androidx.compose.material.icons.Icons
20import androidx.compose.material.icons.Icons
18import androidx.compose.material.icons.filled.ContentCopy
21import androidx.compose.material.icons.filled.ContentCopy
@@ -45,6 +48,7 @@ import androidx.compose.ui.res.pluralStringResource
45import androidx.compose.ui.res.stringResource
48import androidx.compose.ui.res.stringResource
46import soy.iko.opencode.R
49import soy.iko.opencode.R
47import soy.iko.opencode.data.network.NetworkConfig
50import soy.iko.opencode.data.network.NetworkConfig
51+import soy.iko.opencode.ui.theme.diffColors
49/** A single line of a parsed unified diff. */
53/** A single line of a parsed unified diff. */
50sealed interface DiffLine {
54sealed interface DiffLine {
@@ -162,14 +166,15 @@ private fun syntaxForHeader(headerText: String): FileSyntax? {
162@Composable
166@Composable
163fun DiffView(diff: String, modifier: Modifier = Modifier, saveKey: String? = null) {
167fun DiffView(diff: String, modifier: Modifier = Modifier, saveKey: String? = null) {
164 val lines = remember(diff) { parseDiff(diff) }
168 val lines = remember(diff) { parseDiff(diff) }
165− // Derive the four row colors once per color scheme instead of allocating fresh Color
169+ // Use dedicated diff add/remove colors (a fixed green/red pair tuned to the theme) so the
166− // objects (the .copy() calls) on every recomposition — a diff re-parses/recomposes
170+ // diff semantics stay consistent under Material You — where the primary role may not be
167− // per token while its output streams under the collapse limit.
171+ // green-tinted, an "added" line would otherwise read as a primary-tinted highlight.
168 val scheme = MaterialTheme.colorScheme
172 val scheme = MaterialTheme.colorScheme
173+ val diffColors = diffColors()
169− val addColor = remember(scheme) { scheme.primary.copy(alpha = 0.15f) }
174+ val addColor = remember(scheme) { diffColors.addBg }
170− val removeColor = remember(scheme) { scheme.error.copy(alpha = 0.15f) }
175+ val removeColor = remember(scheme) { diffColors.removeBg }
171− val addText = scheme.primary
176+ val addText = diffColors.addText
172− val removeText = scheme.error
177+ val removeText = diffColors.removeText
173 val context = LocalContext.current
178 val context = LocalContext.current
174 val hScrollState = rememberScrollState()
179 val hScrollState = rememberScrollState()
175 // Key on a stable id (the part id) when available so streaming updates that grow `diff`
180 // Key on a stable id (the part id) when available so streaming updates that grow `diff`
@@ -189,110 +194,236 @@ fun DiffView(diff: String, modifier: Modifier = Modifier, saveKey: String? = nul
189 .clip(MaterialTheme.shapes.small)
194 .clip(MaterialTheme.shapes.small)
190 .background(MaterialTheme.colorScheme.surfaceVariant),
195 .background(MaterialTheme.colorScheme.surfaceVariant),
192− Row(
197+ DiffHeader(
193− modifier = Modifier.fillMaxWidth().padding(top = 2.dp, start = 4.dp, end = 4.dp),
198+ lineCount = lines.size,
194− verticalAlignment = Alignment.CenterVertically,
199+ expanded = expanded,
196− // Expand/collapse toggle in the header (matching CodeWithCopy's pattern) so the
197− // user doesn't have to scroll to the bottom of a collapsed diff to expand it.
198− if (lines.size > NetworkConfig.collapsedDiffLineThreshold) {
199− val moreLines = lines.size - NetworkConfig.collapsedDiffLineThreshold
200− IconButton(onClick = { expanded = !expanded }) {
200+ onToggle = { expanded = !expanded },
202− if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
203− contentDescription = if (expanded) stringResource(R.string.show_less)
204− else context.resources.getQuantityString(
205− R.plurals.show_more_lines, moreLines, moreLines,
207− tint = MaterialTheme.colorScheme.onSurfaceVariant,
211− Spacer(Modifier.weight(1f))
212− IconButton(onClick = { copyToClipboard(context, context.getString(R.string.clip_label_diff), diff) }) {
201+ onCopy = { copyToClipboard(context, context.getString(R.string.clip_label_diff), diff) },
213− Icon(Icons.Filled.ContentCopy, contentDescription = stringResource(R.string.copy), tint = MaterialTheme.colorScheme.onSurfaceVariant)
202+ )
216 val visibleLines = if (lines.size <= NetworkConfig.collapsedDiffLineThreshold || expanded) lines
203 val visibleLines = if (lines.size <= NetworkConfig.collapsedDiffLineThreshold || expanded) lines
217 else lines.subList(0, NetworkConfig.collapsedDiffLineThreshold)
204 else lines.subList(0, NetworkConfig.collapsedDiffLineThreshold)
218 // One horizontalScroll on the container instead of one per row: each modifier
205 // One horizontalScroll on the container instead of one per row: each modifier
219 // adds a layout node + clip + offset pass, so a 200-line collapsed diff was
206 // adds a layout node + clip + offset pass, so a 200-line collapsed diff was
220 // paying for 200 of them. The shared scroll state still synchronizes all rows.
207 // paying for 200 of them. The shared scroll state still synchronizes all rows.
221− // oldLine/newLine track the current line numbers derived from each @@ hunk header
208+ val rows = remember(visibleLines, addColor, removeColor, addText, removeText, scheme) {
222− // so the gutter can show absolute line numbers (a big readability win for review).
209+ buildDiffRows(
223− var oldLine = 0
210+ visibleLines,
224− var newLine = 0
211+ addColor, removeColor, addText, removeText,
225− val hunkRegex = Regex("""@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@""")
212+ scheme.tertiary, scheme.onSurface, scheme.onSurfaceVariant,
226 Column(modifier = Modifier.horizontalScroll(hScrollState)) {
215 Column(modifier = Modifier.horizontalScroll(hScrollState)) {
227− visibleLines.forEach { line ->
216+ rows.forEach { row -> RenderDiffRow(row, syntax, palette) }
229− is DiffLine.Hunk -> {
218+ DiffExpandFooter(lines = lines, expanded = expanded, onToggle = { expanded = !expanded })
230− val m = hunkRegex.find(line.text)
232− oldLine = m.groupValues[1].toInt()
233− newLine = m.groupValues[2].toInt()
237− style = MaterialTheme.typography.bodySmall,
222+/**
238− fontFamily = FontFamily.Monospace,
223+ * Lazy variant of [DiffView] for callers that need the diff to scroll on its own (e.g. the
239− color = MaterialTheme.colorScheme.tertiary,
224+ * file viewer) WITHOUT an enclosing verticalScroll. A 5000-line diff in [DiffView] would
240− modifier = Modifier.padding(horizontal = 10.dp, vertical = 2.dp),
225+ * compose every row eagerly; this variant uses a [LazyColumn] so only the visible rows
241− )
226+ * compose. The collapse threshold still applies for the *initial* view, but expanding
242− }
227+ * switches to lazy rendering so a huge expanded diff doesn't OOM.
243− is DiffLine.FileHeader -> {
228+ */
244− // Render the display path (e.g. `src/Foo.kt`) as a bold header rather
229+@Composable
245− // than the raw `+++ b/src/Foo.kt`, so file boundaries are scannable in
230+fun LazyDiffView(diff: String, modifier: Modifier = Modifier, saveKey: String? = null) {
246− // a long multi-file diff. The raw line is still copied with the diff.
231+ val lines = remember(diff) { parseDiff(diff) }
247− val displayPath = extractDisplayPath(line.text)
232+ val scheme = MaterialTheme.colorScheme
248− val isHeader = displayPath != line.text
233+ val diffColors = diffColors()
249− Text(
234+ val addColor = remember(scheme) { diffColors.addBg }
250− displayPath,
235+ val removeColor = remember(scheme) { diffColors.removeBg }
251− style = MaterialTheme.typography.labelMedium,
236+ val addText = diffColors.addText
252− fontFamily = FontFamily.Monospace,
237+ val removeText = diffColors.removeText
253− fontWeight = if (isHeader) FontWeight.SemiBold else FontWeight.Normal,
238+ val context = LocalContext.current
254− color = MaterialTheme.colorScheme.onSurface,
239+ val hScrollState = rememberScrollState()
255− modifier = Modifier
240+ var expanded by rememberSaveable(saveKey ?: diff) { mutableStateOf(false) }
256− .fillMaxWidth()
241+ val syntax = remember(lines) {
257− .padding(horizontal = 10.dp, vertical = 3.dp)
242+ lines.firstOrNull { it is DiffLine.FileHeader }?.let { syntaxForHeader(it.text) }
258− .semantics { heading() },
243+ }
259− )
244+ val palette = rememberHighlightPalette()
260− }
245+ val lazyListState = androidx.compose.foundation.lazy.rememberLazyListState()
261− is DiffLine.Meta -> Text(
246+
262− line.text,
247+ Column(
263− style = MaterialTheme.typography.bodySmall,
248+ modifier = modifier
264− fontFamily = FontFamily.Monospace,
249+ .fillMaxWidth()
265− color = MaterialTheme.colorScheme.onSurfaceVariant,
250+ .clip(MaterialTheme.shapes.small)
266− modifier = Modifier.padding(vertical = 1.dp),
251+ .background(MaterialTheme.colorScheme.surfaceVariant),
268− is DiffLine.Add -> {
253+ DiffHeader(
269− val n = newLine++
254+ lineCount = lines.size,
270− DiffRow(line.text, "+", addColor, addText, oldLine = null, newLine = n, syntax = syntax, palette = palette)
255+ expanded = expanded,
271− }
256+ onToggle = { expanded = !expanded },
272− is DiffLine.Remove -> {
257+ onCopy = { copyToClipboard(context, context.getString(R.string.clip_label_diff), diff) },
273− val o = oldLine++
258+ )
274− DiffRow(line.text, "-", removeColor, removeText, oldLine = o, newLine = null, syntax = syntax, palette = palette)
259+ val visibleLines = if (lines.size <= NetworkConfig.collapsedDiffLineThreshold || expanded) lines
275− }
260+ else lines.subList(0, NetworkConfig.collapsedDiffLineThreshold)
276− is DiffLine.Context -> {
261+ val rows = remember(visibleLines, addColor, removeColor, addText, removeText, scheme) {
277− val o = oldLine++; val n = newLine++
262+ buildDiffRows(
278− DiffRow(line.text, " ", Color.Transparent, MaterialTheme.colorScheme.onSurface, oldLine = o, newLine = n, syntax = syntax, palette = palette)
263+ visibleLines,
279− }
264+ addColor, removeColor, addText, removeText,
280− }
265+ scheme.tertiary, scheme.onSurface, scheme.onSurfaceVariant,
268+ // One horizontalScroll wrapping the LazyColumn synchronizes horizontal panning across
269+ // visible rows (the same trick DiffView uses on its Column). LazyColumn handles the
270+ // vertical scrolling.
272+ state = lazyListState,
273+ modifier = Modifier.horizontalScroll(hScrollState),
277+ key = { i, row -> "${row.kind::class.simpleName}:$i:${row.text.hashCode()}" },
279+ RenderDiffRow(row, syntax, palette)
282+ DiffExpandFooter(lines = lines, expanded = expanded, onToggle = { expanded = !expanded })
286+/** Header row with the expand/collapse toggle (when applicable) and a copy action. */
288+private fun DiffHeader(lineCount: Int, expanded: Boolean, onToggle: () -> Unit, onCopy: () -> Unit) {
289+ val context = LocalContext.current
291+ modifier = Modifier.fillMaxWidth().padding(top = 2.dp, start = 4.dp, end = 4.dp),
292+ verticalAlignment = Alignment.CenterVertically,
283− if (lines.size > NetworkConfig.collapsedDiffLineThreshold) {
294+ if (lineCount > NetworkConfig.collapsedDiffLineThreshold) {
284− val hidden = lines.size - NetworkConfig.collapsedDiffLineThreshold
295+ val moreLines = lineCount - NetworkConfig.collapsedDiffLineThreshold
285− TextButton(
296+ IconButton(onClick = onToggle) {
286− onClick = { expanded = !expanded },
297+ Icon(
287− contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 10.dp),
298+ if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
288− modifier = Modifier.defaultMinSize(minHeight = 48.dp),
291− if (expanded) stringResource(R.string.show_less)
299+ contentDescription = if (expanded) stringResource(R.string.show_less)
300+ else context.resources.getQuantityString(
292− else pluralStringResource(R.plurals.show_more_lines, hidden, hidden),
301+ R.plurals.show_more_lines, moreLines, moreLines,
303+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
307+ Spacer(Modifier.weight(1f))
308+ IconButton(onClick = onCopy) {
309+ Icon(Icons.Filled.ContentCopy, contentDescription = stringResource(R.string.copy), tint = MaterialTheme.colorScheme.onSurfaceVariant)
314+/** Footer "show more/less" button when the diff was collapsed. */
316+private fun DiffExpandFooter(lines: List<DiffLine>, expanded: Boolean, onToggle: () -> Unit) {
317+ if (lines.size > NetworkConfig.collapsedDiffLineThreshold) {
318+ val hidden = lines.size - NetworkConfig.collapsedDiffLineThreshold
321+ contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 10.dp),
322+ modifier = Modifier.defaultMinSize(minHeight = 48.dp),
325+ if (expanded) stringResource(R.string.show_less)
326+ else pluralStringResource(R.plurals.show_more_lines, hidden, hidden),
332+/** A pre-resolved diff row carrying its kind, text, line numbers, and resolved colors. This
333+ * lets both [DiffView] and [LazyDiffView] share the rendering path without re-deriving the
334+ * per-row state (line numbers advance as a side effect of walking the list). */
335+private data class DiffRowItem(
339+ val textColor: Color,
344+/** Walk [visibleLines] advancing the old/new line counters per hunk, producing a list of
345+ * [DiffRowItem]s that the renderers can iterate without per-row state. Theme colors for the
346+ * non-add/remove rows (tertiary for hunk headers, onSurface/onSurfaceVariant for context
347+ * and meta) are passed in by the @Composable caller so the row-builder stays a plain
348+ * function that doesn't read MaterialTheme directly. */
349+private fun buildDiffRows(
350+ visibleLines: List<DiffLine>,
357+ onSurfaceVariant: Color,
358+): List<DiffRowItem> {
361+ val hunkRegex = Regex("""@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@""")
362+ return visibleLines.map { line ->
364+ is DiffLine.Hunk -> {
365+ val m = hunkRegex.find(line.text)
367+ oldLine = m.groupValues[1].toInt()
368+ newLine = m.groupValues[2].toInt()
370+ DiffRowItem(line, line.text, Color.Transparent, tertiary, null, null)
372+ is DiffLine.FileHeader -> DiffRowItem(line, line.text, Color.Transparent, onSurface, null, null)
373+ is DiffLine.Meta -> DiffRowItem(line, line.text, Color.Transparent, onSurfaceVariant, null, null)
374+ is DiffLine.Add -> {
376+ DiffRowItem(line, line.text, addColor, addText, null, n)
378+ is DiffLine.Remove -> {
380+ DiffRowItem(line, line.text, removeColor, removeText, o, null)
382+ is DiffLine.Context -> {
383+ val o = oldLine++; val n = newLine++
384+ DiffRowItem(line, line.text, Color.Transparent, onSurface, o, n)
390+/** Render a single [DiffRowItem]. Hunk and FileHeader rows get their own styling; Add/Remove/
391+ * Context delegate to [DiffRow]. */
393+private fun RenderDiffRow(row: DiffRowItem, syntax: FileSyntax?, palette: HighlightPalette) {
395+ is DiffLine.Hunk -> Text(
397+ style = MaterialTheme.typography.bodySmall,
398+ fontFamily = FontFamily.Monospace,
399+ color = MaterialTheme.colorScheme.tertiary,
400+ modifier = Modifier.padding(horizontal = 10.dp, vertical = 2.dp),
402+ is DiffLine.FileHeader -> {
403+ val displayPath = extractDisplayPath(row.text)
404+ val isHeader = displayPath != row.text
407+ style = MaterialTheme.typography.labelMedium,
408+ fontFamily = FontFamily.Monospace,
409+ fontWeight = if (isHeader) FontWeight.SemiBold else FontWeight.Normal,
410+ color = MaterialTheme.colorScheme.onSurface,
413+ .padding(horizontal = 10.dp, vertical = 3.dp)
414+ .semantics { heading() },
417+ is DiffLine.Meta -> Text(
419+ style = MaterialTheme.typography.bodySmall,
420+ fontFamily = FontFamily.Monospace,
421+ color = MaterialTheme.colorScheme.onSurfaceVariant,
422+ modifier = Modifier.padding(vertical = 1.dp),
424+ is DiffLine.Add -> DiffRow(row.text, "+", row.bg, row.textColor, oldLine = row.oldLine, newLine = row.newLine, syntax = syntax, palette = palette)
425+ is DiffLine.Remove -> DiffRow(row.text, "-", row.bg, row.textColor, oldLine = row.oldLine, newLine = row.newLine, syntax = syntax, palette = palette)
426+ is DiffLine.Context -> DiffRow(row.text, " ", row.bg, row.textColor, oldLine = row.oldLine, newLine = row.newLine, syntax = syntax, palette = palette)