@@ -13,6 +13,9 @@ import androidx.compose.foundation.layout.height
1313import androidx.compose.foundation.layout.defaultMinSize
1414import androidx.compose.foundation.layout.padding
1515import 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
1619import androidx.compose.foundation.rememberScrollState
1720import androidx.compose.material.icons.Icons
1821import androidx.compose.material.icons.filled.ContentCopy
@@ -45,6 +48,7 @@ import androidx.compose.ui.res.pluralStringResource
4548import androidx.compose.ui.res.stringResource
4649import soy.iko.opencode.R
4750import soy.iko.opencode.data.network.NetworkConfig
51+import soy.iko.opencode.ui.theme.diffColors
4852
4953/** A single line of a parsed unified diff. */
5054sealed interface DiffLine {
@@ -162,14 +166,15 @@ private fun syntaxForHeader(headerText: String): FileSyntax? {
162166@Composable
163167fun DiffView(diff: String, modifier: Modifier = Modifier, saveKey: String? = null) {
164168 val lines = remember(diff) { parseDiff(diff) }
165− // Derive the four row colors once per color scheme instead of allocating fresh Color
166− // objects (the .copy() calls) on every recomposition — a diff re-parses/recomposes
167− // per token while its output streams under the collapse limit.
169+ // Use dedicated diff add/remove colors (a fixed green/red pair tuned to the theme) so the
170+ // diff semantics stay consistent under Material You — where the primary role may not be
171+ // green-tinted, an "added" line would otherwise read as a primary-tinted highlight.
168172 val scheme = MaterialTheme.colorScheme
169− val addColor = remember(scheme) { scheme.primary.copy(alpha = 0.15f) }
170− val removeColor = remember(scheme) { scheme.error.copy(alpha = 0.15f) }
171− val addText = scheme.primary
172− val removeText = scheme.error
173+ val diffColors = diffColors()
174+ val addColor = remember(scheme) { diffColors.addBg }
175+ val removeColor = remember(scheme) { diffColors.removeBg }
176+ val addText = diffColors.addText
177+ val removeText = diffColors.removeText
173178 val context = LocalContext.current
174179 val hScrollState = rememberScrollState()
175180 // 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
189194 .clip(MaterialTheme.shapes.small)
190195 .background(MaterialTheme.colorScheme.surfaceVariant),
191196 ) {
192− Row(
193− modifier = Modifier.fillMaxWidth().padding(top = 2.dp, start = 4.dp, end = 4.dp),
194− verticalAlignment = Alignment.CenterVertically,
195− ) {
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 }) {
201− Icon(
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,
206− ),
207− tint = MaterialTheme.colorScheme.onSurfaceVariant,
208− )
209− }
210− }
211− Spacer(Modifier.weight(1f))
212− IconButton(onClick = { copyToClipboard(context, context.getString(R.string.clip_label_diff), diff) }) {
213− Icon(Icons.Filled.ContentCopy, contentDescription = stringResource(R.string.copy), tint = MaterialTheme.colorScheme.onSurfaceVariant)
214− }
215− }
197+ DiffHeader(
198+ lineCount = lines.size,
199+ expanded = expanded,
200+ onToggle = { expanded = !expanded },
201+ onCopy = { copyToClipboard(context, context.getString(R.string.clip_label_diff), diff) },
202+ )
216203 val visibleLines = if (lines.size <= NetworkConfig.collapsedDiffLineThreshold || expanded) lines
217204 else lines.subList(0, NetworkConfig.collapsedDiffLineThreshold)
218205 // One horizontalScroll on the container instead of one per row: each modifier
219206 // adds a layout node + clip + offset pass, so a 200-line collapsed diff was
220207 // 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
222− // so the gutter can show absolute line numbers (a big readability win for review).
223− var oldLine = 0
224− var newLine = 0
225− val hunkRegex = Regex("""@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@""")
208+ val rows = remember(visibleLines, addColor, removeColor, addText, removeText, scheme) {
209+ buildDiffRows(
210+ visibleLines,
211+ addColor, removeColor, addText, removeText,
212+ scheme.tertiary, scheme.onSurface, scheme.onSurfaceVariant,
213+ )
214+ }
226215 Column(modifier = Modifier.horizontalScroll(hScrollState)) {
227− visibleLines.forEach { line ->
228− when (line) {
229− is DiffLine.Hunk -> {
230− val m = hunkRegex.find(line.text)
231− if (m != null) {
232− oldLine = m.groupValues[1].toInt()
233− newLine = m.groupValues[2].toInt()
234− }
235− Text(
236− line.text,
237− style = MaterialTheme.typography.bodySmall,
238− fontFamily = FontFamily.Monospace,
239− color = MaterialTheme.colorScheme.tertiary,
240− modifier = Modifier.padding(horizontal = 10.dp, vertical = 2.dp),
241− )
242− }
243− is DiffLine.FileHeader -> {
244− // Render the display path (e.g. `src/Foo.kt`) as a bold header rather
245− // than the raw `+++ b/src/Foo.kt`, so file boundaries are scannable in
246− // a long multi-file diff. The raw line is still copied with the diff.
247− val displayPath = extractDisplayPath(line.text)
248− val isHeader = displayPath != line.text
249− Text(
250− displayPath,
251− style = MaterialTheme.typography.labelMedium,
252− fontFamily = FontFamily.Monospace,
253− fontWeight = if (isHeader) FontWeight.SemiBold else FontWeight.Normal,
254− color = MaterialTheme.colorScheme.onSurface,
255− modifier = Modifier
256− .fillMaxWidth()
257− .padding(horizontal = 10.dp, vertical = 3.dp)
258− .semantics { heading() },
259− )
260− }
261− is DiffLine.Meta -> Text(
262− line.text,
263− style = MaterialTheme.typography.bodySmall,
264− fontFamily = FontFamily.Monospace,
265− color = MaterialTheme.colorScheme.onSurfaceVariant,
266− modifier = Modifier.padding(vertical = 1.dp),
267− )
268− is DiffLine.Add -> {
269− val n = newLine++
270− DiffRow(line.text, "+", addColor, addText, oldLine = null, newLine = n, syntax = syntax, palette = palette)
271− }
272− is DiffLine.Remove -> {
273− val o = oldLine++
274− DiffRow(line.text, "-", removeColor, removeText, oldLine = o, newLine = null, syntax = syntax, palette = palette)
275− }
276− is DiffLine.Context -> {
277− val o = oldLine++; val n = newLine++
278− DiffRow(line.text, " ", Color.Transparent, MaterialTheme.colorScheme.onSurface, oldLine = o, newLine = n, syntax = syntax, palette = palette)
279− }
280− }
216+ rows.forEach { row -> RenderDiffRow(row, syntax, palette) }
217+ }
218+ DiffExpandFooter(lines = lines, expanded = expanded, onToggle = { expanded = !expanded })
219+ }
220+}
221+
222+/**
223+ * Lazy variant of [DiffView] for callers that need the diff to scroll on its own (e.g. the
224+ * file viewer) WITHOUT an enclosing verticalScroll. A 5000-line diff in [DiffView] would
225+ * compose every row eagerly; this variant uses a [LazyColumn] so only the visible rows
226+ * compose. The collapse threshold still applies for the *initial* view, but expanding
227+ * switches to lazy rendering so a huge expanded diff doesn't OOM.
228+ */
229+@Composable
230+fun LazyDiffView(diff: String, modifier: Modifier = Modifier, saveKey: String? = null) {
231+ val lines = remember(diff) { parseDiff(diff) }
232+ val scheme = MaterialTheme.colorScheme
233+ val diffColors = diffColors()
234+ val addColor = remember(scheme) { diffColors.addBg }
235+ val removeColor = remember(scheme) { diffColors.removeBg }
236+ val addText = diffColors.addText
237+ val removeText = diffColors.removeText
238+ val context = LocalContext.current
239+ val hScrollState = rememberScrollState()
240+ var expanded by rememberSaveable(saveKey ?: diff) { mutableStateOf(false) }
241+ val syntax = remember(lines) {
242+ lines.firstOrNull { it is DiffLine.FileHeader }?.let { syntaxForHeader(it.text) }
243+ }
244+ val palette = rememberHighlightPalette()
245+ val lazyListState = androidx.compose.foundation.lazy.rememberLazyListState()
246+
247+ Column(
248+ modifier = modifier
249+ .fillMaxWidth()
250+ .clip(MaterialTheme.shapes.small)
251+ .background(MaterialTheme.colorScheme.surfaceVariant),
252+ ) {
253+ DiffHeader(
254+ lineCount = lines.size,
255+ expanded = expanded,
256+ onToggle = { expanded = !expanded },
257+ onCopy = { copyToClipboard(context, context.getString(R.string.clip_label_diff), diff) },
258+ )
259+ val visibleLines = if (lines.size <= NetworkConfig.collapsedDiffLineThreshold || expanded) lines
260+ else lines.subList(0, NetworkConfig.collapsedDiffLineThreshold)
261+ val rows = remember(visibleLines, addColor, removeColor, addText, removeText, scheme) {
262+ buildDiffRows(
263+ visibleLines,
264+ addColor, removeColor, addText, removeText,
265+ scheme.tertiary, scheme.onSurface, scheme.onSurfaceVariant,
266+ )
267+ }
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.
271+ LazyColumn(
272+ state = lazyListState,
273+ modifier = Modifier.horizontalScroll(hScrollState),
274+ ) {
275+ itemsIndexed(
276+ rows,
277+ key = { i, row -> "${row.kind::class.simpleName}:$i:${row.text.hashCode()}" },
278+ ) { _, row ->
279+ RenderDiffRow(row, syntax, palette)
281280 }
282281 }
283− if (lines.size > NetworkConfig.collapsedDiffLineThreshold) {
284− val hidden = lines.size - NetworkConfig.collapsedDiffLineThreshold
285− TextButton(
286− onClick = { expanded = !expanded },
287− contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 10.dp),
288− modifier = Modifier.defaultMinSize(minHeight = 48.dp),
289− ) {
290− Text(
291− if (expanded) stringResource(R.string.show_less)
292− else pluralStringResource(R.plurals.show_more_lines, hidden, hidden),
282+ DiffExpandFooter(lines = lines, expanded = expanded, onToggle = { expanded = !expanded })
283+ }
284+}
285+
286+/** Header row with the expand/collapse toggle (when applicable) and a copy action. */
287+@Composable
288+private fun DiffHeader(lineCount: Int, expanded: Boolean, onToggle: () -> Unit, onCopy: () -> Unit) {
289+ val context = LocalContext.current
290+ Row(
291+ modifier = Modifier.fillMaxWidth().padding(top = 2.dp, start = 4.dp, end = 4.dp),
292+ verticalAlignment = Alignment.CenterVertically,
293+ ) {
294+ if (lineCount > NetworkConfig.collapsedDiffLineThreshold) {
295+ val moreLines = lineCount - NetworkConfig.collapsedDiffLineThreshold
296+ IconButton(onClick = onToggle) {
297+ Icon(
298+ if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
299+ contentDescription = if (expanded) stringResource(R.string.show_less)
300+ else context.resources.getQuantityString(
301+ R.plurals.show_more_lines, moreLines, moreLines,
302+ ),
303+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
293304 )
294305 }
295306 }
307+ Spacer(Modifier.weight(1f))
308+ IconButton(onClick = onCopy) {
309+ Icon(Icons.Filled.ContentCopy, contentDescription = stringResource(R.string.copy), tint = MaterialTheme.colorScheme.onSurfaceVariant)
310+ }
311+ }
312+}
313+
314+/** Footer "show more/less" button when the diff was collapsed. */
315+@Composable
316+private fun DiffExpandFooter(lines: List<DiffLine>, expanded: Boolean, onToggle: () -> Unit) {
317+ if (lines.size > NetworkConfig.collapsedDiffLineThreshold) {
318+ val hidden = lines.size - NetworkConfig.collapsedDiffLineThreshold
319+ TextButton(
320+ onClick = onToggle,
321+ contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 10.dp),
322+ modifier = Modifier.defaultMinSize(minHeight = 48.dp),
323+ ) {
324+ Text(
325+ if (expanded) stringResource(R.string.show_less)
326+ else pluralStringResource(R.plurals.show_more_lines, hidden, hidden),
327+ )
328+ }
329+ }
330+}
331+
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(
336+ val kind: DiffLine,
337+ val text: String,
338+ val bg: Color,
339+ val textColor: Color,
340+ val oldLine: Int?,
341+ val newLine: Int?,
342+)
343+
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>,
351+ addColor: Color,
352+ removeColor: Color,
353+ addText: Color,
354+ removeText: Color,
355+ tertiary: Color,
356+ onSurface: Color,
357+ onSurfaceVariant: Color,
358+): List<DiffRowItem> {
359+ var oldLine = 0
360+ var newLine = 0
361+ val hunkRegex = Regex("""@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@""")
362+ return visibleLines.map { line ->
363+ when (line) {
364+ is DiffLine.Hunk -> {
365+ val m = hunkRegex.find(line.text)
366+ if (m != null) {
367+ oldLine = m.groupValues[1].toInt()
368+ newLine = m.groupValues[2].toInt()
369+ }
370+ DiffRowItem(line, line.text, Color.Transparent, tertiary, null, null)
371+ }
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 -> {
375+ val n = newLine++
376+ DiffRowItem(line, line.text, addColor, addText, null, n)
377+ }
378+ is DiffLine.Remove -> {
379+ val o = oldLine++
380+ DiffRowItem(line, line.text, removeColor, removeText, o, null)
381+ }
382+ is DiffLine.Context -> {
383+ val o = oldLine++; val n = newLine++
384+ DiffRowItem(line, line.text, Color.Transparent, onSurface, o, n)
385+ }
386+ }
387+ }
388+}
389+
390+/** Render a single [DiffRowItem]. Hunk and FileHeader rows get their own styling; Add/Remove/
391+ * Context delegate to [DiffRow]. */
392+@Composable
393+private fun RenderDiffRow(row: DiffRowItem, syntax: FileSyntax?, palette: HighlightPalette) {
394+ when (row.kind) {
395+ is DiffLine.Hunk -> Text(
396+ row.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),
401+ )
402+ is DiffLine.FileHeader -> {
403+ val displayPath = extractDisplayPath(row.text)
404+ val isHeader = displayPath != row.text
405+ Text(
406+ displayPath,
407+ style = MaterialTheme.typography.labelMedium,
408+ fontFamily = FontFamily.Monospace,
409+ fontWeight = if (isHeader) FontWeight.SemiBold else FontWeight.Normal,
410+ color = MaterialTheme.colorScheme.onSurface,
411+ modifier = Modifier
412+ .fillMaxWidth()
413+ .padding(horizontal = 10.dp, vertical = 3.dp)
414+ .semantics { heading() },
415+ )
416+ }
417+ is DiffLine.Meta -> Text(
418+ row.text,
419+ style = MaterialTheme.typography.bodySmall,
420+ fontFamily = FontFamily.Monospace,
421+ color = MaterialTheme.colorScheme.onSurfaceVariant,
422+ modifier = Modifier.padding(vertical = 1.dp),
423+ )
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)
296427 }
297428}
298429