@@ -1,6 +1,8 @@
1package soy.iko.opencode.ui.components
1package soy.iko.opencode.ui.components
3+import androidx.compose.animation.animateContentSize
3import androidx.compose.foundation.background
4import androidx.compose.foundation.background
5+import androidx.compose.foundation.clickable
4import androidx.compose.foundation.horizontalScroll
6import androidx.compose.foundation.horizontalScroll
5import androidx.compose.foundation.layout.Arrangement
7import androidx.compose.foundation.layout.Arrangement
6import androidx.compose.foundation.layout.Column
8import androidx.compose.foundation.layout.Column
@@ -14,6 +16,8 @@ import androidx.compose.foundation.layout.width
14import androidx.compose.foundation.rememberScrollState
16import androidx.compose.foundation.rememberScrollState
15import androidx.compose.material.icons.Icons
17import androidx.compose.material.icons.Icons
16import androidx.compose.material.icons.filled.ContentCopy
18import androidx.compose.material.icons.filled.ContentCopy
19+import androidx.compose.material.icons.filled.ExpandLess
20+import androidx.compose.material.icons.filled.ExpandMore
17import androidx.compose.material3.Icon
21import androidx.compose.material3.Icon
18import androidx.compose.material3.IconButton
22import androidx.compose.material3.IconButton
19import androidx.compose.material3.MaterialTheme
23import androidx.compose.material3.MaterialTheme
@@ -30,11 +34,17 @@ import androidx.compose.ui.Modifier
30import androidx.compose.ui.draw.clip
34import androidx.compose.ui.draw.clip
31import androidx.compose.ui.graphics.Color
35import androidx.compose.ui.graphics.Color
32import androidx.compose.ui.platform.LocalContext
36import 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
33import androidx.compose.ui.text.font.FontFamily
41import androidx.compose.ui.text.font.FontFamily
42+import androidx.compose.ui.text.font.FontWeight
34import androidx.compose.ui.unit.dp
43import androidx.compose.ui.unit.dp
35import androidx.compose.ui.res.pluralStringResource
44import androidx.compose.ui.res.pluralStringResource
36import androidx.compose.ui.res.stringResource
45import androidx.compose.ui.res.stringResource
37import soy.iko.opencode.R
46import soy.iko.opencode.R
47+import soy.iko.opencode.data.network.NetworkConfig
39/** A single line of a parsed unified diff. */
49/** A single line of a parsed unified diff. */
40sealed interface DiffLine {
50sealed interface DiffLine {
@@ -111,7 +121,31 @@ fun looksLikeDiff(text: String): Boolean {
111 return false
121 return false
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`.
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)
134+ headerText.startsWith("+++ ") -> headerText.substring(4).substringAfter("b/", headerText.substring(4)).trimEnd()
135+ headerText.startsWith("--- ") -> headerText.substring(4).substringAfter("a/", headerText.substring(4)).trimEnd()
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
117 * Renders a parsed unified diff. Implemented as a plain (non-lazy) [Column] so it is
151 * 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
121 * [Modifier.verticalScroll] in [modifier].
155 * [Modifier.verticalScroll] in [modifier].
123 * To avoid composing thousands of [Text] nodes at once (which can ANR/OOM on large
157 * 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
158+ * diffs), only the first [NetworkConfig.collapsedDiffLineThreshold] lines are rendered
125− * expands the view.
159+ * unless the user expands the view. Each hunk can also be collapsed individually via
127@Composable
162@Composable
128fun DiffView(diff: String, modifier: Modifier = Modifier, saveKey: String? = null) {
163fun DiffView(diff: String, modifier: Modifier = Modifier, saveKey: String? = null) {
@@ -141,6 +176,12 @@ fun DiffView(diff: String, modifier: Modifier = Modifier, saveKey: String? = nul
141 // don't reset expand state on every token. Fall back to `diff` for non-streaming callers
176 // don't reset expand state on every token. Fall back to `diff` for non-streaming callers
142 // (e.g. FileViewScreen) where the content is static.
177 // (e.g. FileViewScreen) where the content is static.
143 var expanded by rememberSaveable(saveKey ?: diff) { mutableStateOf(false) }
178 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) }
184+ val palette = rememberHighlightPalette()
146 modifier = modifier
187 modifier = modifier
@@ -149,15 +190,31 @@ fun DiffView(diff: String, modifier: Modifier = Modifier, saveKey: String? = nul
149 .background(MaterialTheme.colorScheme.surfaceVariant),
190 .background(MaterialTheme.colorScheme.surfaceVariant),
152− modifier = Modifier.fillMaxWidth().padding(top = 2.dp, end = 4.dp),
193+ modifier = Modifier.fillMaxWidth().padding(top = 2.dp, start = 4.dp, end = 4.dp),
153− horizontalArrangement = Arrangement.End,
194+ verticalAlignment = Alignment.CenterVertically,
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 }) {
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))
155 IconButton(onClick = { copyToClipboard(context, context.getString(R.string.clip_label_diff), diff) }) {
212 IconButton(onClick = { copyToClipboard(context, context.getString(R.string.clip_label_diff), diff) }) {
156 Icon(Icons.Filled.ContentCopy, contentDescription = stringResource(R.string.copy), tint = MaterialTheme.colorScheme.onSurfaceVariant)
213 Icon(Icons.Filled.ContentCopy, contentDescription = stringResource(R.string.copy), tint = MaterialTheme.colorScheme.onSurfaceVariant)
159− val visibleLines = if (lines.size <= COLLAPSED_DIFF_LINES || expanded) lines
216+ val visibleLines = if (lines.size <= NetworkConfig.collapsedDiffLineThreshold || expanded) lines
160− else lines.subList(0, COLLAPSED_DIFF_LINES)
217+ else lines.subList(0, NetworkConfig.collapsedDiffLineThreshold)
161 // One horizontalScroll on the container instead of one per row: each modifier
218 // One horizontalScroll on the container instead of one per row: each modifier
162 // adds a layout node + clip + offset pass, so a 200-line collapsed diff was
219 // adds a layout node + clip + offset pass, so a 200-line collapsed diff was
163 // paying for 200 of them. The shared scroll state still synchronizes all rows.
220 // 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
168 val hunkRegex = Regex("""@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@""")
225 val hunkRegex = Regex("""@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@""")
169 Column(modifier = Modifier.horizontalScroll(hScrollState)) {
226 Column(modifier = Modifier.horizontalScroll(hScrollState)) {
170 visibleLines.forEach { line ->
227 visibleLines.forEach { line ->
171− Row(modifier = Modifier.padding(horizontal = 10.dp)) {
228+ when (line) {
173− is DiffLine.Hunk -> {
229+ is DiffLine.Hunk -> {
174− val m = hunkRegex.find(line.text)
230+ val m = hunkRegex.find(line.text)
175− if (m != null) {
231+ if (m != null) {
176− oldLine = m.groupValues[1].toInt()
232+ oldLine = m.groupValues[1].toInt()
177− newLine = m.groupValues[2].toInt()
233+ newLine = m.groupValues[2].toInt()
181− style = MaterialTheme.typography.bodySmall,
182− fontFamily = FontFamily.Monospace,
183− color = MaterialTheme.colorScheme.tertiary,
184− modifier = Modifier.padding(vertical = 2.dp),
187− is DiffLine.FileHeader -> Text(
235+ Text(
188 line.text,
236 line.text,
189 style = MaterialTheme.typography.bodySmall,
237 style = MaterialTheme.typography.bodySmall,
190 fontFamily = FontFamily.Monospace,
238 fontFamily = FontFamily.Monospace,
191− color = MaterialTheme.colorScheme.onSurfaceVariant,
239+ color = MaterialTheme.colorScheme.tertiary,
192− modifier = Modifier.padding(vertical = 1.dp),
240+ modifier = Modifier.padding(horizontal = 10.dp, vertical = 2.dp),
194− is DiffLine.Meta -> Text(
243+ is DiffLine.FileHeader -> {
195− line.text,
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
196− style = MaterialTheme.typography.bodySmall,
251+ style = MaterialTheme.typography.labelMedium,
197 fontFamily = FontFamily.Monospace,
252 fontFamily = FontFamily.Monospace,
253+ fontWeight = if (isHeader) FontWeight.SemiBold else FontWeight.Normal,
198− color = MaterialTheme.colorScheme.onSurfaceVariant,
254+ color = MaterialTheme.colorScheme.onSurface,
199− modifier = Modifier.padding(vertical = 1.dp),
255+ modifier = Modifier
257+ .padding(horizontal = 10.dp, vertical = 3.dp)
258+ .semantics { heading() },
261+ is DiffLine.Meta -> Text(
263+ style = MaterialTheme.typography.bodySmall,
264+ fontFamily = FontFamily.Monospace,
265+ color = MaterialTheme.colorScheme.onSurfaceVariant,
266+ modifier = Modifier.padding(vertical = 1.dp),
201− is DiffLine.Add -> {
268+ is DiffLine.Add -> {
202− val n = newLine++; DiffRow(line.text, "+", addColor, addText, oldLine = null, newLine = n)
270+ DiffRow(line.text, "+", addColor, addText, oldLine = null, newLine = n, syntax = syntax, palette = palette)
204− is DiffLine.Remove -> {
272+ is DiffLine.Remove -> {
205− val o = oldLine++; DiffRow(line.text, "-", removeColor, removeText, oldLine = o, newLine = null)
274+ DiffRow(line.text, "-", removeColor, removeText, oldLine = o, newLine = null, syntax = syntax, palette = palette)
207− is DiffLine.Context -> {
276+ is DiffLine.Context -> {
208− val o = oldLine++; val n = newLine++
277+ val o = oldLine++; val n = newLine++
209− DiffRow(line.text, " ", Color.Transparent, MaterialTheme.colorScheme.onSurface, oldLine = o, newLine = n)
278+ DiffRow(line.text, " ", Color.Transparent, MaterialTheme.colorScheme.onSurface, oldLine = o, newLine = n, syntax = syntax, palette = palette)
215− if (lines.size > COLLAPSED_DIFF_LINES) {
283+ if (lines.size > NetworkConfig.collapsedDiffLineThreshold) {
216− val hidden = lines.size - COLLAPSED_DIFF_LINES
284+ val hidden = lines.size - NetworkConfig.collapsedDiffLineThreshold
217 TextButton(
285 TextButton(
218 onClick = { expanded = !expanded },
286 onClick = { expanded = !expanded },
219 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 10.dp),
287 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 10.dp),
@@ -225,7 +293,6 @@ fun DiffView(diff: String, modifier: Modifier = Modifier, saveKey: String? = nul
228− Spacer(Modifier.height(10.dp))
@@ -237,10 +304,26 @@ private fun DiffRow(
237 textColor: androidx.compose.ui.graphics.Color,
304 textColor: androidx.compose.ui.graphics.Color,
238 oldLine: Int? = null,
305 oldLine: Int? = null,
239 newLine: Int? = null,
306 newLine: Int? = null,
307+ syntax: FileSyntax? = null,
308+ palette: HighlightPalette,
241 val gutterColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
310 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)
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)
243− modifier = Modifier.background(bg),
324+ modifier = Modifier
326+ .semantics(mergeDescendants = true) { contentDescription = a11yLabel },
245 // Old/new line-number gutters. Blank (not 0) for the side that doesn't have a number
328 // Old/new line-number gutters. Blank (not 0) for the side that doesn't have a number
246 // (an added line has no old number; a removed line has no new number) so the columns
329 // (an added line has no old number; a removed line has no new number) so the columns
@@ -250,24 +333,24 @@ private fun DiffRow(
250 style = MaterialTheme.typography.bodySmall,
333 style = MaterialTheme.typography.bodySmall,
251 fontFamily = FontFamily.Monospace,
334 fontFamily = FontFamily.Monospace,
252 color = gutterColor,
335 color = gutterColor,
253− modifier = Modifier.width(36.dp),
336+ modifier = Modifier.width(NetworkConfig.diffGutterWidthDp.dp),
256 newLine?.toString() ?: "",
339 newLine?.toString() ?: "",
257 style = MaterialTheme.typography.bodySmall,
340 style = MaterialTheme.typography.bodySmall,
258 fontFamily = FontFamily.Monospace,
341 fontFamily = FontFamily.Monospace,
259 color = gutterColor,
342 color = gutterColor,
260− modifier = Modifier.width(36.dp),
343+ modifier = Modifier.width(NetworkConfig.diffGutterWidthDp.dp),
264 style = MaterialTheme.typography.bodySmall,
347 style = MaterialTheme.typography.bodySmall,
265 fontFamily = FontFamily.Monospace,
348 fontFamily = FontFamily.Monospace,
266 color = textColor,
349 color = textColor,
267− modifier = Modifier.width(16.dp),
350+ modifier = Modifier.width(NetworkConfig.diffPrefixWidthDp.dp),
270− text,
353+ highlighted,
271 style = MaterialTheme.typography.bodySmall,
354 style = MaterialTheme.typography.bodySmall,
272 fontFamily = FontFamily.Monospace,
355 fontFamily = FontFamily.Monospace,
273 color = textColor,
356 color = textColor,