diff --git a/app/src/main/java/com/bniladridas/diff/ui/MarkdownPreview.kt b/app/src/main/java/com/bniladridas/diff/ui/MarkdownPreview.kt index 5c71823..d04d507 100644 --- a/app/src/main/java/com/bniladridas/diff/ui/MarkdownPreview.kt +++ b/app/src/main/java/com/bniladridas/diff/ui/MarkdownPreview.kt @@ -5,6 +5,7 @@ private val markdownLinkPattern = Regex("""\[([^\]]+)]\(https?://[^)]+\)""") private val htmlImagePattern = Regex("""]*>""", RegexOption.IGNORE_CASE) private val htmlAltPattern = Regex("""\balt=["']([^"']*)["']""", RegexOption.IGNORE_CASE) private val bareUrlPattern = Regex("""https?://[^\s)>\]]+""") +private val inlineCodePattern = Regex("""`([^`\n]+)`""") internal fun markdownBodyPreview( rawBody: String, @@ -13,6 +14,10 @@ internal fun markdownBodyPreview( if (rawBody.isBlank()) return emptyText return rawBody + .withoutCodeFences() + .replace(inlineCodePattern) { match -> + match.groups[1]?.value.orEmpty() + } .replace(htmlImagePattern) { match -> htmlAltPattern.find(match.value)?.groups?.get(1)?.value?.cleanBadgeLabel()?.let { "badge: $it" }.orEmpty() } @@ -32,6 +37,11 @@ internal fun markdownBodyPreview( .ifBlank { emptyText } } +private fun String.withoutCodeFences(): String = + lines() + .filterNot { it.trim().startsWith("```") } + .joinToString("\n") + private fun String.cleanBadgeLabel(): String? { val cleaned = trim() .removeSuffix(" badge") diff --git a/app/src/main/java/com/bniladridas/diff/ui/components/DiffComponents.kt b/app/src/main/java/com/bniladridas/diff/ui/components/DiffComponents.kt index f270bfd..d373ce1 100644 --- a/app/src/main/java/com/bniladridas/diff/ui/components/DiffComponents.kt +++ b/app/src/main/java/com/bniladridas/diff/ui/components/DiffComponents.kt @@ -28,9 +28,12 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString 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.text.withStyle import androidx.compose.ui.unit.dp import com.bniladridas.diff.R import com.bniladridas.diff.model.ChangedFile @@ -64,6 +67,8 @@ import com.bniladridas.diff.ui.theme.DiffRedSoft import com.bniladridas.diff.ui.theme.PanelRaised import com.bniladridas.diff.ui.theme.TextMuted +private val inlineCodePattern = Regex("""`([^`\n]+)`""") + @Composable fun PanelCard( modifier: Modifier = Modifier, @@ -110,6 +115,111 @@ private fun NeutralTag(text: String) { Tag(text, mutedText(0.72f), MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.28f)) } +@Composable +private fun MarkdownBody( + rawBody: String, + emptyText: String, + modifier: Modifier = Modifier, +) { + val blocks = markdownBlocks(rawBody, emptyText) + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(7.dp), + ) { + blocks.forEach { block -> + if (block.isCode) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(7.dp), + color = codeSurface(), + border = BorderStroke(1.dp, outlineText(0.45f)), + ) { + Text( + block.text, + modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp), + color = codeText(), + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + ) + } + } else { + Text( + text = inlineCodeText(block.text), + color = bodyText(), + style = MaterialTheme.typography.bodyMedium, + ) + } + } + } +} + +@Composable +private fun inlineCodeText(text: String) = buildAnnotatedString { + var cursor = 0 + inlineCodePattern.findAll(text).forEach { match -> + append(text.substring(cursor, match.range.first)) + withStyle( + SpanStyle( + color = codeText(), + fontFamily = FontFamily.Monospace, + background = codeSurface(), + ), + ) { + append(match.groups[1]?.value.orEmpty()) + } + cursor = match.range.last + 1 + } + append(text.substring(cursor)) +} + +private data class MarkdownBlock( + val text: String, + val isCode: Boolean, +) + +private fun markdownBlocks( + rawBody: String, + emptyText: String, +): List { + if (rawBody.isBlank()) return listOf(MarkdownBlock(emptyText, isCode = false)) + + val blocks = mutableListOf() + val textLines = mutableListOf() + val codeLines = mutableListOf() + var inCode = false + + fun flushText() { + val text = markdownBodyPreview(textLines.joinToString("\n"), "") + if (text.isNotBlank()) blocks += MarkdownBlock(text, isCode = false) + textLines.clear() + } + + fun flushCode() { + val text = codeLines.joinToString("\n").trim() + if (text.isNotBlank()) blocks += MarkdownBlock(text, isCode = true) + codeLines.clear() + } + + rawBody.lines().forEach { line -> + if (line.trim().startsWith("```")) { + if (inCode) { + flushCode() + } else { + flushText() + } + inCode = !inCode + } else if (inCode) { + codeLines += line + } else { + textLines += line + } + } + + if (inCode) flushCode() else flushText() + + return blocks.ifEmpty { listOf(MarkdownBlock(emptyText, isCode = false)) } +} + @Composable fun BrandMark() { Row(verticalAlignment = Alignment.CenterVertically) { @@ -456,13 +566,7 @@ fun CommentCard( overflow = TextOverflow.Ellipsis, ) } - Text( - markdownBodyPreview(comment.body, "No comment body."), - color = bodyText(), - style = MaterialTheme.typography.bodyMedium, - maxLines = 10, - overflow = TextOverflow.Ellipsis, - ) + MarkdownBody(comment.body, "No comment body.") if (comment.path != null && (onJumpToDiff != null || onDraftFix != null)) { Row(horizontalArrangement = Arrangement.spacedBy(7.dp)) { onJumpToDiff?.let { diff --git a/app/src/test/java/com/bniladridas/diff/ui/MarkdownPreviewTest.kt b/app/src/test/java/com/bniladridas/diff/ui/MarkdownPreviewTest.kt index ba5c2b4..32a2e5c 100644 --- a/app/src/test/java/com/bniladridas/diff/ui/MarkdownPreviewTest.kt +++ b/app/src/test/java/com/bniladridas/diff/ui/MarkdownPreviewTest.kt @@ -37,6 +37,31 @@ class MarkdownPreviewTest { ) } + @Test + fun `preserves inline code without backtick markers`() { + val body = "Run `./gradlew test` before merging." + + assertEquals( + "Run ./gradlew test before merging.", + markdownBodyPreview(body, "empty"), + ) + } + + @Test + fun `preserves fenced code content without fence markers`() { + val body = """ + Output: + ```text + BUILD SUCCESSFUL + ``` + """.trimIndent() + + assertEquals( + "Output:\nBUILD SUCCESSFUL", + markdownBodyPreview(body, "empty"), + ) + } + @Test fun `keeps blank previews calm`() { assertEquals("No comment body.", markdownBodyPreview(" ", "No comment body."))