@@ -1,6 +1,8 @@
11package soy.iko.opencode.ui.components
22
3+import androidx.compose.animation.animateContentSize
34import androidx.compose.foundation.background
5+import androidx.compose.foundation.clickable
46import androidx.compose.foundation.horizontalScroll
57import androidx.compose.foundation.layout.Arrangement
68import androidx.compose.foundation.layout.Column
@@ -14,6 +16,8 @@ import androidx.compose.foundation.layout.width
1416import androidx.compose.foundation.rememberScrollState
1517import androidx.compose.material.icons.Icons
1618import androidx.compose.material.icons.filled.ContentCopy
19+import androidx.compose.material.icons.filled.ExpandLess
20+import androidx.compose.material.icons.filled.ExpandMore
1721import androidx.compose.material3.Icon
1822import androidx.compose.material3.IconButton
1923import androidx.compose.material3.MaterialTheme
@@ -30,11 +34,17 @@ import androidx.compose.ui.Modifier
3034import androidx.compose.ui.draw.clip
3135import androidx.compose.ui.graphics.Color
3236import androidx.compose.ui.platform.LocalContext
37+import androidx.compose.ui.semantics.contentDescription
38+import androidx.compose.ui.semantics.heading
39+import androidx.compose.ui.semantics.semantics
40+import androidx.compose.ui.text.AnnotatedString
3341import androidx.compose.ui.text.font.FontFamily
42+import androidx.compose.ui.text.font.FontWeight
3443import androidx.compose.ui.unit.dp
3544import androidx.compose.ui.res.pluralStringResource
3645import androidx.compose.ui.res.stringResource
3746import soy.iko.opencode.R
47+import soy.iko.opencode.data.network.NetworkConfig
3848
3949/** A single line of a parsed unified diff. */
4050sealed interface DiffLine {
@@ -111,7 +121,31 @@ fun looksLikeDiff(text: String): Boolean {
111121 return false
112122}
113123
114−private const val COLLAPSED_DIFF_LINES = 200
124+/** Extract a display path from a `+++ b/path` / `--- a/path` / `diff --git a/x b/y` header.
125+ * Returns the raw line when no path can be extracted, so the header still shows something. */
126+private fun extractDisplayPath(headerText: String): String {
127+ // `diff --git a/foo b/bar` → `bar` (the new path). `+++ b/foo` → `foo`. `--- a/foo` → `foo`.
128+ return when {
129+ headerText.startsWith("diff --git ") -> {
130+ // Grab the second path after " b/"; fall back to the first after " a/".
131+ val bIdx = headerText.indexOf(" b/")
132+ if (bIdx >= 0) headerText.substring(bIdx + 3).trim() else headerText.substringAfter(" a/", headerText)
133+ }
134+ headerText.startsWith("+++ ") -> headerText.substring(4).substringAfter("b/", headerText.substring(4)).trimEnd()
135+ headerText.startsWith("--- ") -> headerText.substring(4).substringAfter("a/", headerText.substring(4)).trimEnd()
136+ else -> headerText
137+ }
138+}
139+
140+/** Resolve a [FileSyntax] from a diff file header's path, for per-line highlighting. Returns
141+ * null when the path has no recognizable extension (so the diff renders plain, as before). */
142+private fun syntaxForHeader(headerText: String): FileSyntax? {
143+ val path = extractDisplayPath(headerText)
144+ if (path == headerText) return null
145+ val filename = path.substringAfterLast('/')
146+ val syntax = syntaxFor(filename)
147+ return if (syntax.lang == Language.NONE) null else syntax
148+}
115149
116150/**
117151 * Renders a parsed unified diff. Implemented as a plain (non-lazy) [Column] so it is
@@ -121,8 +155,9 @@ private const val COLLAPSED_DIFF_LINES = 200
121155 * [Modifier.verticalScroll] in [modifier].
122156 *
123157 * To avoid composing thousands of [Text] nodes at once (which can ANR/OOM on large
124− * diffs), only the first [COLLAPSED_DIFF_LINES] lines are rendered unless the user
125− * expands the view.
158+ * diffs), only the first [NetworkConfig.collapsedDiffLineThreshold] lines are rendered
159+ * unless the user expands the view. Each hunk can also be collapsed individually via
160+ * its `@@` header.
126161 */
127162@Composable
128163fun DiffView(diff: String, modifier: Modifier = Modifier, saveKey: String? = null) {
@@ -141,6 +176,12 @@ fun DiffView(diff: String, modifier: Modifier = Modifier, saveKey: String? = nul
141176 // don't reset expand state on every token. Fall back to `diff` for non-streaming callers
142177 // (e.g. FileViewScreen) where the content is static.
143178 var expanded by rememberSaveable(saveKey ?: diff) { mutableStateOf(false) }
179+ // Resolve a syntax for highlighting from the first file header (if any). Memoized so a
180+ // streaming re-parse doesn't re-resolve per token.
181+ val syntax = remember(lines) {
182+ lines.firstOrNull { it is DiffLine.FileHeader }?.let { syntaxForHeader(it.text) }
183+ }
184+ val palette = rememberHighlightPalette()
144185
145186 Column(
146187 modifier = modifier
@@ -149,15 +190,31 @@ fun DiffView(diff: String, modifier: Modifier = Modifier, saveKey: String? = nul
149190 .background(MaterialTheme.colorScheme.surfaceVariant),
150191 ) {
151192 Row(
152− modifier = Modifier.fillMaxWidth().padding(top = 2.dp, end = 4.dp),
153− horizontalArrangement = Arrangement.End,
193+ modifier = Modifier.fillMaxWidth().padding(top = 2.dp, start = 4.dp, end = 4.dp),
194+ verticalAlignment = Alignment.CenterVertically,
154195 ) {
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))
155212 IconButton(onClick = { copyToClipboard(context, context.getString(R.string.clip_label_diff), diff) }) {
156213 Icon(Icons.Filled.ContentCopy, contentDescription = stringResource(R.string.copy), tint = MaterialTheme.colorScheme.onSurfaceVariant)
157214 }
158215 }
159− val visibleLines = if (lines.size <= COLLAPSED_DIFF_LINES || expanded) lines
160− else lines.subList(0, COLLAPSED_DIFF_LINES)
216+ val visibleLines = if (lines.size <= NetworkConfig.collapsedDiffLineThreshold || expanded) lines
217+ else lines.subList(0, NetworkConfig.collapsedDiffLineThreshold)
161218 // One horizontalScroll on the container instead of one per row: each modifier
162219 // adds a layout node + clip + offset pass, so a 200-line collapsed diff was
163220 // paying for 200 of them. The shared scroll state still synchronizes all rows.
@@ -168,52 +225,63 @@ fun DiffView(diff: String, modifier: Modifier = Modifier, saveKey: String? = nul
168225 val hunkRegex = Regex("""@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@""")
169226 Column(modifier = Modifier.horizontalScroll(hScrollState)) {
170227 visibleLines.forEach { line ->
171− Row(modifier = Modifier.padding(horizontal = 10.dp)) {
172− when (line) {
173− is DiffLine.Hunk -> {
174− val m = hunkRegex.find(line.text)
175− if (m != null) {
176− oldLine = m.groupValues[1].toInt()
177− newLine = m.groupValues[2].toInt()
178− }
179− Text(
180− line.text,
181− style = MaterialTheme.typography.bodySmall,
182− fontFamily = FontFamily.Monospace,
183− color = MaterialTheme.colorScheme.tertiary,
184− modifier = Modifier.padding(vertical = 2.dp),
185− )
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()
186234 }
187− is DiffLine.FileHeader -> Text(
235+ Text(
188236 line.text,
189237 style = MaterialTheme.typography.bodySmall,
190238 fontFamily = FontFamily.Monospace,
191− color = MaterialTheme.colorScheme.onSurfaceVariant,
192− modifier = Modifier.padding(vertical = 1.dp),
239+ color = MaterialTheme.colorScheme.tertiary,
240+ modifier = Modifier.padding(horizontal = 10.dp, vertical = 2.dp),
193241 )
194− is DiffLine.Meta -> Text(
195− line.text,
196− style = MaterialTheme.typography.bodySmall,
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,
197252 fontFamily = FontFamily.Monospace,
198− color = MaterialTheme.colorScheme.onSurfaceVariant,
199− modifier = Modifier.padding(vertical = 1.dp),
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() },
200259 )
201− is DiffLine.Add -> {
202− val n = newLine++; DiffRow(line.text, "+", addColor, addText, oldLine = null, newLine = n)
203− }
204− is DiffLine.Remove -> {
205− val o = oldLine++; DiffRow(line.text, "-", removeColor, removeText, oldLine = o, newLine = null)
206− }
207− is DiffLine.Context -> {
208− val o = oldLine++; val n = newLine++
209− DiffRow(line.text, " ", Color.Transparent, MaterialTheme.colorScheme.onSurface, oldLine = o, newLine = n)
210− }
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)
211279 }
212280 }
213281 }
214282 }
215− if (lines.size > COLLAPSED_DIFF_LINES) {
216− val hidden = lines.size - COLLAPSED_DIFF_LINES
283+ if (lines.size > NetworkConfig.collapsedDiffLineThreshold) {
284+ val hidden = lines.size - NetworkConfig.collapsedDiffLineThreshold
217285 TextButton(
218286 onClick = { expanded = !expanded },
219287 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 10.dp),
@@ -225,7 +293,6 @@ fun DiffView(diff: String, modifier: Modifier = Modifier, saveKey: String? = nul
225293 )
226294 }
227295 }
228− Spacer(Modifier.height(10.dp))
229296 }
230297}
231298
@@ -237,10 +304,26 @@ private fun DiffRow(
237304 textColor: androidx.compose.ui.graphics.Color,
238305 oldLine: Int? = null,
239306 newLine: Int? = null,
307+ syntax: FileSyntax? = null,
308+ palette: HighlightPalette,
240309) {
241310 val gutterColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
311+ // Highlight the line's content (minus the leading +/-/space prefix, which DiffView
312+ // already stripped) when a syntax was resolved from a file header. Falls back to plain
313+ // monospace text when no syntax is known (e.g. an untagged patch), preserving the prior
314+ // rendering for diffs that can't be classified.
315+ val highlighted: AnnotatedString = remember(text, syntax, palette) {
316+ if (syntax != null) highlightLine(text, syntax, palette) else AnnotatedString(text)
317+ }
318+ val a11yLabel = when (prefix) {
319+ "+" -> stringResource(R.string.diff_added_line, newLine ?: 0, text)
320+ "-" -> stringResource(R.string.diff_removed_line, oldLine ?: 0, text)
321+ else -> stringResource(R.string.diff_context_line, oldLine ?: 0, text)
322+ }
242323 Row(
243− modifier = Modifier.background(bg),
324+ modifier = Modifier
325+ .background(bg)
326+ .semantics(mergeDescendants = true) { contentDescription = a11yLabel },
244327 ) {
245328 // Old/new line-number gutters. Blank (not 0) for the side that doesn't have a number
246329 // (an added line has no old number; a removed line has no new number) so the columns
@@ -250,24 +333,24 @@ private fun DiffRow(
250333 style = MaterialTheme.typography.bodySmall,
251334 fontFamily = FontFamily.Monospace,
252335 color = gutterColor,
253− modifier = Modifier.width(36.dp),
336+ modifier = Modifier.width(NetworkConfig.diffGutterWidthDp.dp),
254337 )
255338 Text(
256339 newLine?.toString() ?: "",
257340 style = MaterialTheme.typography.bodySmall,
258341 fontFamily = FontFamily.Monospace,
259342 color = gutterColor,
260− modifier = Modifier.width(36.dp),
343+ modifier = Modifier.width(NetworkConfig.diffGutterWidthDp.dp),
261344 )
262345 Text(
263346 prefix,
264347 style = MaterialTheme.typography.bodySmall,
265348 fontFamily = FontFamily.Monospace,
266349 color = textColor,
267− modifier = Modifier.width(16.dp),
350+ modifier = Modifier.width(NetworkConfig.diffPrefixWidthDp.dp),
268351 )
269352 Text(
270− text,
353+ highlighted,
271354 style = MaterialTheme.typography.bodySmall,
272355 fontFamily = FontFamily.Monospace,
273356 color = textColor,