* feat(chat): interactive diff view for patch and file edit tool output * fix(chat): address agy review feedback for diff viewer UX and layout * fix(chat): suppress raw text/json when diff view renders and fix sync message ordering race (#741) * fix(tests): resolve nullable smart-cast errors in ToolBubbleParsingTest --------- Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
1 parent
e39544ebd5
commit
8ba92f2567
5 files changed
+569
-49
No files matched your search
@@ -98,3 +98,11 @@ val CodeTerminalBg = Color(0xFF1E1E1E)
|
||||
val CodeTerminalBorder = Color(0xFF333333)
|
||||
val CodeTerminalText = Color(0xFFD4D4D4)
|
||||
val CodeTerminalMuted = Color(0xFF808080)
|
||||
|
||||
// ── Diff viewer tokens (issue #738) ──────────────────────────────────
|
||||
val CodeDiffAddBg = Color(0x263DDC84)
|
||||
val CodeDiffAddText = Color(0xFF3DDC84)
|
||||
val CodeDiffDeleteBg = Color(0x26FF5C5C)
|
||||
val CodeDiffDeleteText = Color(0xFFFF5C5C)
|
||||
val CodeDiffHunkBg = Color(0x264DA8FF)
|
||||
val CodeDiffHunkText = Color(0xFF4DA8FF)
|
||||
@@ -85,6 +85,7 @@ import com.m57.hermescontrol.theme.HermesStatusColors
|
||||
import com.m57.hermescontrol.theme.LightOnSurface
|
||||
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
||||
import com.m57.hermescontrol.theme.onColorFor
|
||||
import com.m57.hermescontrol.ui.chat.components.DiffViewCard
|
||||
import com.m57.hermescontrol.ui.chat.components.ReasoningCard
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -560,6 +561,8 @@ data class ParsedToolData(
|
||||
val mainOutput: String? = null,
|
||||
val extraFields: Map<String, String> = emptyMap(),
|
||||
val isRunning: Boolean = false,
|
||||
val diffOutput: String? = null,
|
||||
val diffPath: String? = null,
|
||||
)
|
||||
|
||||
private fun formatTodoToolOutput(
|
||||
@@ -1688,7 +1691,19 @@ fun parseToolOutput(
|
||||
val hasStderr = dataSource.has("stderr")
|
||||
val hasExitCode = dataSource.has("exit_code") || dataSource.has("exitCode")
|
||||
|
||||
if (hasOutput || hasStdout || hasStderr || hasExitCode) {
|
||||
val isTerminalTool =
|
||||
resolvedToolName == "terminal" ||
|
||||
resolvedToolName == "bash" ||
|
||||
resolvedToolName == "sh" ||
|
||||
resolvedToolName == "cmd" ||
|
||||
resolvedToolName == "exec"
|
||||
|
||||
val isDiffTool =
|
||||
resolvedToolName == "patch" ||
|
||||
resolvedToolName == "write_file" ||
|
||||
resolvedToolName == "edit"
|
||||
|
||||
if (!isDiffTool && (isTerminalTool || hasOutput || hasStdout || hasStderr || hasExitCode)) {
|
||||
val terminalOutput =
|
||||
(
|
||||
dataSource.get("output")?.takeIf { !it.isJsonNull }?.asString
|
||||
@@ -1760,6 +1775,50 @@ fun parseToolOutput(
|
||||
}
|
||||
}
|
||||
|
||||
val diffCandidate =
|
||||
dataSource.get("diff")?.takeIf { !it.isJsonNull }?.let {
|
||||
if (it.isJsonPrimitive) it.asString else it.toString()
|
||||
}
|
||||
?: dataSource.get("patch")?.takeIf { !it.isJsonNull }?.let {
|
||||
if (it.isJsonPrimitive) it.asString else it.toString()
|
||||
}
|
||||
?: mainOutput
|
||||
|
||||
val filePathArg =
|
||||
args["path"]?.toString()
|
||||
?: argsObj?.get("path")?.takeIf { !it.isJsonNull }?.asString
|
||||
|
||||
val oldStrArg = args["old_string"]?.toString()
|
||||
val newStrArg = args["new_string"]?.toString()
|
||||
|
||||
val isDiffTool =
|
||||
resolvedToolName == "patch" ||
|
||||
resolvedToolName == "edit" ||
|
||||
resolvedToolName == "write_file"
|
||||
|
||||
val extractedDiffOutput =
|
||||
when {
|
||||
diffCandidate != null && (
|
||||
diffCandidate.contains("\n-") ||
|
||||
diffCandidate.contains("\n+") ||
|
||||
diffCandidate.startsWith("--- ") ||
|
||||
diffCandidate.startsWith("+++ ") ||
|
||||
diffCandidate.startsWith("*** ") ||
|
||||
diffCandidate.startsWith("@@ ")
|
||||
) -> diffCandidate
|
||||
|
||||
isDiffTool && oldStrArg != null && newStrArg != null -> {
|
||||
val pathHeader = filePathArg ?: "file"
|
||||
"--- $pathHeader\n+++ $pathHeader\n@@ -1,1 +1,1 @@\n-$oldStrArg\n+$newStrArg"
|
||||
}
|
||||
|
||||
isDiffTool && args["patch"] != null -> {
|
||||
args["patch"].toString()
|
||||
}
|
||||
|
||||
else -> null
|
||||
}
|
||||
|
||||
val duration = obj.get("duration_s")?.takeIf { !it.isJsonNull }?.asDouble
|
||||
|
||||
ParsedToolData(
|
||||
@@ -1771,6 +1830,8 @@ fun parseToolOutput(
|
||||
extraFields = extraFields,
|
||||
durationSec = duration,
|
||||
isRunning = isRunning,
|
||||
diffOutput = extractedDiffOutput,
|
||||
diffPath = filePathArg,
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
@@ -1848,51 +1909,57 @@ private fun ExpandedToolContent(
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// ── Generic tool output ──
|
||||
parsed.mainOutput?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style =
|
||||
MaterialTheme.typography.bodySmall.copy(
|
||||
color = contentColor.copy(alpha = 0.9f),
|
||||
fontSize = 12.sp,
|
||||
),
|
||||
// ── Generic tool output / Diff view ──
|
||||
if (parsed.diffOutput != null) {
|
||||
DiffViewCard(
|
||||
diffText = parsed.diffOutput,
|
||||
filePath = parsed.diffPath,
|
||||
)
|
||||
}
|
||||
parsed.extraFields.forEach { (key, value) ->
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
} else {
|
||||
parsed.mainOutput?.let {
|
||||
Text(
|
||||
text = "$key:",
|
||||
style =
|
||||
MaterialTheme.typography.labelSmall.copy(
|
||||
color = contentColor.copy(alpha = 0.6f),
|
||||
fontWeight = FontWeight.Bold,
|
||||
),
|
||||
)
|
||||
Text(
|
||||
text = value,
|
||||
text = it,
|
||||
style =
|
||||
MaterialTheme.typography.bodySmall.copy(
|
||||
color = contentColor.copy(alpha = 0.9f),
|
||||
fontSize = 11.sp,
|
||||
fontSize = 12.sp,
|
||||
),
|
||||
)
|
||||
}
|
||||
parsed.extraFields.forEach { (key, value) ->
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "$key:",
|
||||
style =
|
||||
MaterialTheme.typography.labelSmall.copy(
|
||||
color = contentColor.copy(alpha = 0.6f),
|
||||
fontWeight = FontWeight.Bold,
|
||||
),
|
||||
)
|
||||
Text(
|
||||
text = value,
|
||||
style =
|
||||
MaterialTheme.typography.bodySmall.copy(
|
||||
color = contentColor.copy(alpha = 0.9f),
|
||||
fontSize = 11.sp,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Duration footer
|
||||
parsed.durationSec?.let { dur ->
|
||||
Text(
|
||||
text = "Duration: ${"%.1f".format(dur)}s",
|
||||
style =
|
||||
MaterialTheme.typography.labelSmall.copy(
|
||||
color = contentColor.copy(alpha = 0.5f),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
// Duration footer
|
||||
parsed.durationSec?.let { dur ->
|
||||
Text(
|
||||
text = "Duration: ${"%.1f".format(dur)}s",
|
||||
style =
|
||||
MaterialTheme.typography.labelSmall.copy(
|
||||
color = contentColor.copy(alpha = 0.5f),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1571,21 +1571,39 @@ class ChatViewModel(
|
||||
withContext(Dispatchers.IO) { repo.persistMessages(incoming, sessionId) }
|
||||
_uiState.update { current ->
|
||||
if (current.currentSessionId != sessionId) return@update current
|
||||
val unmatched = incoming.map { it.role to it.content }.toMutableList()
|
||||
val retained =
|
||||
current.messages.filter { existing ->
|
||||
if (serverMessageIndex(existing.id, sessionId) != null) {
|
||||
true
|
||||
val unmatchedIncoming = incoming.toMutableList()
|
||||
val mergedList = mutableListOf<ChatMessage>()
|
||||
|
||||
for (existing in current.messages) {
|
||||
val existingServerIndex = serverMessageIndex(existing.id, sessionId)
|
||||
if (existingServerIndex != null) {
|
||||
val matchIdx = unmatchedIncoming.indexOfFirst { it.id == existing.id }
|
||||
if (matchIdx >= 0) {
|
||||
mergedList.add(unmatchedIncoming.removeAt(matchIdx))
|
||||
} else {
|
||||
val duplicate =
|
||||
unmatched.indexOfFirst { (role, content) ->
|
||||
role == existing.role && content == existing.content
|
||||
}
|
||||
if (duplicate >= 0) unmatched.removeAt(duplicate)
|
||||
duplicate < 0
|
||||
mergedList.add(existing)
|
||||
}
|
||||
} else {
|
||||
val matchIdx =
|
||||
unmatchedIncoming.indexOfFirst { inc ->
|
||||
inc.role == existing.role && (
|
||||
inc.content == existing.content ||
|
||||
(
|
||||
existing.role == MessageRole.TOOL &&
|
||||
inc.toolName != null &&
|
||||
inc.toolName == existing.toolName
|
||||
)
|
||||
)
|
||||
}
|
||||
if (matchIdx >= 0) {
|
||||
mergedList.add(unmatchedIncoming.removeAt(matchIdx))
|
||||
} else {
|
||||
mergedList.add(existing)
|
||||
}
|
||||
}
|
||||
val merged = (retained + incoming).distinctBy { it.id }
|
||||
}
|
||||
mergedList.addAll(unmatchedIncoming)
|
||||
val merged = mergedList.distinctBy { it.id }
|
||||
if (sameMessages(current.messages, merged)) current else current.copy(messages = merged)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
package com.m57.hermescontrol.ui.chat.components
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.InsertDriveFile
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material.icons.filled.ExpandLess
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.m57.hermescontrol.theme.CodeDiffAddBg
|
||||
import com.m57.hermescontrol.theme.CodeDiffAddText
|
||||
import com.m57.hermescontrol.theme.CodeDiffDeleteBg
|
||||
import com.m57.hermescontrol.theme.CodeDiffDeleteText
|
||||
import com.m57.hermescontrol.theme.CodeDiffHunkBg
|
||||
import com.m57.hermescontrol.theme.CodeDiffHunkText
|
||||
import com.m57.hermescontrol.theme.CodeTerminalBg
|
||||
import com.m57.hermescontrol.theme.CodeTerminalBorder
|
||||
import com.m57.hermescontrol.theme.CodeTerminalMuted
|
||||
import com.m57.hermescontrol.theme.CodeTerminalText
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
enum class DiffLineType {
|
||||
FILE_HEADER,
|
||||
HUNK_HEADER,
|
||||
ADDED,
|
||||
DELETED,
|
||||
CONTEXT,
|
||||
}
|
||||
|
||||
data class ParsedDiffLine(
|
||||
val type: DiffLineType,
|
||||
val text: String,
|
||||
)
|
||||
|
||||
data class ParsedDiffResult(
|
||||
val filePath: String?,
|
||||
val lines: List<ParsedDiffLine>,
|
||||
val additionsCount: Int,
|
||||
val deletionsCount: Int,
|
||||
)
|
||||
|
||||
/**
|
||||
* Parses unified diff or patch text into structured lines and line counts.
|
||||
*/
|
||||
fun parseDiffText(
|
||||
diffText: String,
|
||||
defaultPath: String? = null,
|
||||
): ParsedDiffResult {
|
||||
if (diffText.isBlank()) {
|
||||
return ParsedDiffResult(
|
||||
filePath = defaultPath,
|
||||
lines = emptyList(),
|
||||
additionsCount = 0,
|
||||
deletionsCount = 0,
|
||||
)
|
||||
}
|
||||
|
||||
val rawLines = diffText.lines()
|
||||
val parsedLines = mutableListOf<ParsedDiffLine>()
|
||||
var extractedPath: String? = defaultPath
|
||||
var additions = 0
|
||||
var deletions = 0
|
||||
|
||||
for (line in rawLines) {
|
||||
when {
|
||||
line.startsWith("--- ") || line.startsWith("+++ ") -> {
|
||||
if (extractedPath == null || extractedPath == defaultPath) {
|
||||
val candidate =
|
||||
line.drop(4)
|
||||
.trim()
|
||||
.removePrefix("a/")
|
||||
.removePrefix("b/")
|
||||
.split("\t")
|
||||
.firstOrNull()
|
||||
if (!candidate.isNullOrBlank() && candidate != "/dev/null") {
|
||||
extractedPath = candidate
|
||||
}
|
||||
}
|
||||
parsedLines.add(ParsedDiffLine(DiffLineType.FILE_HEADER, line))
|
||||
}
|
||||
|
||||
line.startsWith("*** Update File:") ||
|
||||
line.startsWith("*** Add File:") ||
|
||||
line.startsWith("*** Delete File:") -> {
|
||||
val candidate = line.substringAfter(":").trim()
|
||||
if (candidate.isNotBlank()) {
|
||||
extractedPath = candidate
|
||||
}
|
||||
parsedLines.add(ParsedDiffLine(DiffLineType.FILE_HEADER, line))
|
||||
}
|
||||
|
||||
line.startsWith("@@ ") || line.startsWith("*** ") -> {
|
||||
parsedLines.add(ParsedDiffLine(DiffLineType.HUNK_HEADER, line))
|
||||
}
|
||||
|
||||
line.startsWith("+") -> {
|
||||
additions++
|
||||
parsedLines.add(ParsedDiffLine(DiffLineType.ADDED, line))
|
||||
}
|
||||
|
||||
line.startsWith("-") -> {
|
||||
deletions++
|
||||
parsedLines.add(ParsedDiffLine(DiffLineType.DELETED, line))
|
||||
}
|
||||
|
||||
else -> {
|
||||
parsedLines.add(ParsedDiffLine(DiffLineType.CONTEXT, line))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ParsedDiffResult(
|
||||
filePath = extractedPath ?: defaultPath,
|
||||
lines = parsedLines,
|
||||
additionsCount = additions,
|
||||
deletionsCount = deletions,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Interactive diff view card for file edit & patch tool outputs.
|
||||
*/
|
||||
@Composable
|
||||
fun DiffViewCard(
|
||||
diffText: String,
|
||||
filePath: String? = null,
|
||||
onCopy: (String) -> Unit = {},
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val parsed = remember(diffText, filePath) { parseDiffText(diffText, filePath) }
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
var copied by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(copied) {
|
||||
if (copied) {
|
||||
delay(2000)
|
||||
copied = false
|
||||
}
|
||||
}
|
||||
|
||||
val displayLines =
|
||||
if (expanded || parsed.lines.size <= 16) {
|
||||
parsed.lines
|
||||
} else {
|
||||
parsed.lines.take(16)
|
||||
}
|
||||
|
||||
Surface(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.testTag("diff_view_card"),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = CodeTerminalBg,
|
||||
border = BorderStroke(1.dp, CodeTerminalBorder),
|
||||
) {
|
||||
Column(modifier = Modifier.animateContentSize()) {
|
||||
// Header bar
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.InsertDriveFile,
|
||||
contentDescription = null,
|
||||
tint = CodeTerminalMuted,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(
|
||||
text = parsed.filePath ?: "diff",
|
||||
style =
|
||||
MaterialTheme.typography.labelMedium.copy(
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = CodeTerminalText,
|
||||
),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
|
||||
// Addition / Deletion badges
|
||||
if (parsed.additionsCount > 0) {
|
||||
Text(
|
||||
text = "+${parsed.additionsCount}",
|
||||
style =
|
||||
MaterialTheme.typography.labelSmall.copy(
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = CodeDiffAddText,
|
||||
),
|
||||
)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
}
|
||||
if (parsed.deletionsCount > 0) {
|
||||
Text(
|
||||
text = "-${parsed.deletionsCount}",
|
||||
style =
|
||||
MaterialTheme.typography.labelSmall.copy(
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = CodeDiffDeleteText,
|
||||
),
|
||||
)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
}
|
||||
}
|
||||
|
||||
IconButton(
|
||||
onClick = {
|
||||
val clipboard =
|
||||
context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager
|
||||
clipboard?.setPrimaryClip(ClipData.newPlainText("diff", diffText))
|
||||
copied = true
|
||||
onCopy(diffText)
|
||||
},
|
||||
modifier = Modifier.size(28.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (copied) Icons.Filled.Check else Icons.Filled.ContentCopy,
|
||||
contentDescription = if (copied) "Copied" else "Copy diff",
|
||||
tint = CodeTerminalMuted,
|
||||
modifier = Modifier.size(14.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Diff lines body
|
||||
val verticalScrollModifier =
|
||||
if (expanded) {
|
||||
Modifier.heightIn(max = 400.dp).verticalScroll(rememberScrollState())
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.then(verticalScrollModifier)
|
||||
.horizontalScroll(rememberScrollState())
|
||||
.width(IntrinsicSize.Max),
|
||||
) {
|
||||
displayLines.forEach { line ->
|
||||
val (bgColor, textColor) =
|
||||
when (line.type) {
|
||||
DiffLineType.ADDED -> CodeDiffAddBg to CodeDiffAddText
|
||||
DiffLineType.DELETED -> CodeDiffDeleteBg to CodeDiffDeleteText
|
||||
DiffLineType.HUNK_HEADER -> CodeDiffHunkBg to CodeDiffHunkText
|
||||
DiffLineType.FILE_HEADER -> Color.Transparent to CodeTerminalMuted
|
||||
DiffLineType.CONTEXT -> Color.Transparent to CodeTerminalText
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.background(bgColor)
|
||||
.padding(horizontal = 10.dp, vertical = 2.dp),
|
||||
) {
|
||||
Text(
|
||||
text = line.text,
|
||||
style =
|
||||
MaterialTheme.typography.bodySmall.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 11.sp,
|
||||
color = textColor,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Expand / Collapse footer button for diffs > 16 lines
|
||||
if (parsed.lines.size > 16) {
|
||||
TextButton(
|
||||
onClick = { expanded = !expanded },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text =
|
||||
if (expanded) {
|
||||
"Collapse diff"
|
||||
} else {
|
||||
"Show full diff (${parsed.lines.size} lines)"
|
||||
},
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = CodeTerminalMuted,
|
||||
)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Icon(
|
||||
imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
|
||||
contentDescription = null,
|
||||
tint = CodeTerminalMuted,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user