From 059f373ef56543affa43792cd43d346b3b19e002 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 28 Jun 2026 15:25:22 +0000 Subject: [PATCH] feat: polish git repository README + Code browser UI Make the repository browser flagship-grade rather than utilitarian: - Shared status components: spinner-backed loading box and an icon-led message box with a styled retry action, replacing plain centered text. - Code tab: a repo info bar (branch + short-commit chips, item count), a scrollable chip breadcrumb, and polished entry rows with tinted icon tiles, monospace filenames, folder/file color distinction, a trailing chevron on folders, and hairline dividers. - File viewer: a line-number gutter with horizontally scrollable, syntax-highlighted code, a language/path bar with copy-to-clipboard, and themed surfaces; richer binary/error states. - README tab: spinner loading + icon-led empty/error states. Adds a git_repo_item_count plural and copy/text strings. No new icons. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DpNmN8CvP6HnEsdTGAjVUr --- .../loggedIn/gitRepo/code/GitBrowseUi.kt | 186 ++++++++++++++++++ .../loggedIn/gitRepo/code/GitCodeTab.kt | 135 +++++++------ .../loggedIn/gitRepo/code/GitFileViewer.kt | 182 ++++++++++++++--- .../loggedIn/gitRepo/code/GitReadmeTab.kt | 30 +-- amethyst/src/main/res/values/strings.xml | 6 + 5 files changed, 427 insertions(+), 112 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitBrowseUi.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitBrowseUi.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitBrowseUi.kt new file mode 100644 index 0000000000..b631e53f39 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitBrowseUi.kt @@ -0,0 +1,186 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.code + +import androidx.compose.foundation.background +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.pluralStringResource +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 com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.ui.stringRes + +/** Centered spinner + caption while the repository or a file is loading. */ +@Composable +fun GitLoadingBox( + text: String, + modifier: Modifier = Modifier, +) { + Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.padding(24.dp), + ) { + CircularProgressIndicator(strokeWidth = 2.5.dp, modifier = Modifier.size(34.dp)) + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.6f), + ) + } + } +} + +/** Centered icon + message, with an optional primary retry action. */ +@Composable +fun GitMessageBox( + symbol: MaterialSymbol, + text: String, + modifier: Modifier = Modifier, + onRetry: (() -> Unit)? = null, +) { + Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(14.dp), + modifier = Modifier.padding(32.dp), + ) { + Box( + modifier = + Modifier + .size(64.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.08f)), + contentAlignment = Alignment.Center, + ) { + Icon( + symbol = symbol, + contentDescription = null, + modifier = Modifier.size(32.dp), + tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f), + ) + } + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f), + textAlign = androidx.compose.ui.text.style.TextAlign.Center, + ) + if (onRetry != null) { + FilledTonalButton(onClick = onRetry) { + Icon(MaterialSymbols.Refresh, contentDescription = null, modifier = Modifier.size(18.dp)) + Text( + text = stringRes(R.string.git_repo_retry), + modifier = Modifier.padding(start = 6.dp), + ) + } + } + } + } +} + +/** + * Compact bar identifying the snapshot: the branch and short commit the file + * tree was loaded from, plus the entry count of the current directory. + */ +@Composable +fun RepoInfoBar( + branch: String?, + headCommit: String, + entryCount: Int, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (branch != null) { + InfoChip(symbol = MaterialSymbols.AltRoute, label = branch) + } + InfoChip(symbol = MaterialSymbols.Commit, label = headCommit.take(7), monospace = true) + Text( + text = pluralStringResource(R.plurals.git_repo_item_count, entryCount, entryCount), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.5f), + ) + } +} + +@Composable +private fun InfoChip( + symbol: MaterialSymbol, + label: String, + monospace: Boolean = false, +) { + Row( + modifier = + Modifier + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) + .padding(horizontal = 8.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Icon( + symbol = symbol, + contentDescription = null, + modifier = Modifier.size(15.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Text( + text = label, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Medium, + fontFamily = if (monospace) FontFamily.Monospace else null, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitCodeTab.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitCodeTab.kt index 096b1ec504..ddf2f7b5d6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitCodeTab.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitCodeTab.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.code import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement @@ -35,7 +36,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState -import androidx.compose.material3.Button +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -48,6 +49,8 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +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 @@ -72,19 +75,17 @@ fun GitCodeTab( val scaffoldPadding = LocalDisappearingScaffoldPadding.current when (state) { is GitBrowseState.Loading -> - CenteredStatus(stringRes(R.string.git_repo_code_loading), Modifier.padding(scaffoldPadding)) + GitLoadingBox(stringRes(R.string.git_repo_code_loading), Modifier.padding(scaffoldPadding)) - is GitBrowseState.Error -> - CenteredStatus( - text = - if (state.message == GitRepositoryBrowserViewModel.NO_CLONE_URL) { - stringRes(R.string.git_repo_no_clone_url) - } else { - stringRes(R.string.git_repo_code_error) - }, + is GitBrowseState.Error -> { + val noClone = state.message == GitRepositoryBrowserViewModel.NO_CLONE_URL + GitMessageBox( + symbol = if (noClone) MaterialSymbols.Code else MaterialSymbols.ErrorOutline, + text = if (noClone) stringRes(R.string.git_repo_no_clone_url) else stringRes(R.string.git_repo_code_error), modifier = Modifier.padding(scaffoldPadding), - onRetry = if (state.message == GitRepositoryBrowserViewModel.NO_CLONE_URL) null else viewModel::reload, + onRetry = if (noClone) null else viewModel::reload, ) + } is GitBrowseState.Loaded -> CodeBrowser(state.snapshot, viewModel, accountViewModel, nav, scaffoldPaddingTop = scaffoldPadding) @@ -112,7 +113,7 @@ private fun CodeBrowser( FileHeader(name = openPath.lastOrNull() ?: "", onBack = { openFilePath = null }) HorizontalDivider(thickness = 0.5.dp) if (entry == null) { - CenteredStatus(stringRes(R.string.git_repo_file_load_error), Modifier) + GitMessageBox(MaterialSymbols.ErrorOutline, stringRes(R.string.git_repo_file_load_error)) } else { GitFileViewer( snapshot = snapshot, @@ -132,13 +133,14 @@ private fun CodeBrowser( val entries = remember(snapshot, pathString) { snapshot.entriesAt(path).orEmpty() } Column(Modifier.fillMaxSize().padding(scaffoldPaddingTop)) { + RepoInfoBar(branch = snapshot.branch, headCommit = snapshot.headCommit, entryCount = entries.size) Breadcrumb( path = path, onNavigate = { depth -> pathString = path.take(depth).joinToString("/") }, ) HorizontalDivider(thickness = 0.5.dp) if (entries.isEmpty()) { - CenteredStatus(stringRes(R.string.git_repo_empty_folder), Modifier) + GitMessageBox(MaterialSymbols.Folder, stringRes(R.string.git_repo_empty_folder)) } else { LazyColumn(Modifier.fillMaxSize()) { items(entries, key = { it.name }) { entry -> @@ -149,6 +151,11 @@ private fun CodeBrowser( if (entry.isFolder) pathString = child else openFilePath = child }, ) + HorizontalDivider( + modifier = Modifier.padding(start = 56.dp), + thickness = 0.5.dp, + color = MaterialTheme.colorScheme.outline.copy(alpha = 0.15f), + ) } } } @@ -165,18 +172,19 @@ private fun Breadcrumb( Modifier .fillMaxWidth() .horizontalScroll(rememberScrollState()) - .padding(horizontal = 12.dp, vertical = 8.dp), + .padding(horizontal = 10.dp, vertical = 6.dp), verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(2.dp), ) { - Crumb(label = stringRes(R.string.git_repo_root), enabled = path.isNotEmpty()) { onNavigate(0) } + Crumb(label = stringRes(R.string.git_repo_root), current = path.isEmpty()) { onNavigate(0) } path.forEachIndexed { index, segment -> Icon( symbol = MaterialSymbols.ChevronRight, contentDescription = null, - modifier = Modifier.size(16.dp).padding(horizontal = 2.dp), - tint = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.4f), + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.35f), ) - Crumb(label = segment, enabled = index < path.lastIndex) { onNavigate(index + 1) } + Crumb(label = segment, current = index == path.lastIndex) { onNavigate(index + 1) } } } } @@ -184,21 +192,29 @@ private fun Breadcrumb( @Composable private fun Crumb( label: String, - enabled: Boolean, + current: Boolean, onClick: () -> Unit, ) { + val background = + if (current) { + MaterialTheme.colorScheme.primary.copy(alpha = 0.12f) + } else { + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f) + } + val textColor = if (current) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant + Text( text = label, style = MaterialTheme.typography.labelLarge, - fontWeight = if (enabled) FontWeight.Normal else FontWeight.SemiBold, - color = - if (enabled) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onBackground - }, + fontWeight = if (current) FontWeight.SemiBold else FontWeight.Normal, + color = textColor, maxLines = 1, - modifier = if (enabled) Modifier.clickable(onClick = onClick) else Modifier, + modifier = + Modifier + .clip(RoundedCornerShape(8.dp)) + .let { if (!current) it.clickable(onClick = onClick) else it } + .background(background) + .padding(horizontal = 10.dp, vertical = 4.dp), ) } @@ -212,7 +228,7 @@ private fun EntryRow( Modifier .fillMaxWidth() .clickable(onClick = onClick) - .padding(horizontal = 14.dp, vertical = 12.dp), + .padding(horizontal = 12.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), ) { @@ -222,23 +238,39 @@ private fun EntryRow( entry.isSubmodule -> MaterialSymbols.Code else -> MaterialSymbols.Description } - Icon( - symbol = symbol, - contentDescription = null, - modifier = Modifier.size(20.dp), - tint = - if (entry.isFolder) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f) - }, - ) + val tint = if (entry.isFolder) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant + Box( + modifier = + Modifier + .size(32.dp) + .clip(RoundedCornerShape(8.dp)) + .background(tint.copy(alpha = 0.1f)), + contentAlignment = Alignment.Center, + ) { + Icon( + symbol = symbol, + contentDescription = null, + modifier = Modifier.size(19.dp), + tint = tint, + ) + } Text( text = entry.name, style = MaterialTheme.typography.bodyMedium, + fontFamily = if (entry.isFolder) null else FontFamily.Monospace, + fontWeight = if (entry.isFolder) FontWeight.Medium else FontWeight.Normal, maxLines = 1, overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), ) + if (entry.isFolder) { + Icon( + symbol = MaterialSymbols.ChevronRight, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.3f), + ) + } } } @@ -256,33 +288,10 @@ private fun FileHeader( Text( text = name, style = MaterialTheme.typography.titleSmall, + fontFamily = FontFamily.Monospace, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis, ) } } - -@Composable -private fun CenteredStatus( - text: String, - modifier: Modifier, - onRetry: (() -> Unit)? = null, -) { - Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(12.dp), - modifier = Modifier.padding(24.dp), - ) { - Text( - text = text, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f), - ) - if (onRetry != null) { - Button(onClick = onRetry) { Text(stringRes(R.string.git_repo_retry)) } - } - } - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitFileViewer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitFileViewer.kt index bd5436c26b..5ec06412de 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitFileViewer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitFileViewer.kt @@ -20,14 +20,21 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.code +import android.widget.Toast +import androidx.compose.foundation.background import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth 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.verticalScroll +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -35,26 +42,40 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.produceState import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.EmptyTagList import com.vitorpamplona.amethyst.ui.components.RichTextViewer +import com.vitorpamplona.amethyst.ui.components.util.setText import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.nip34Git.git.GitRepoSnapshot import com.vitorpamplona.quartz.nip34Git.git.GitTreeEntry import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlin.coroutines.cancellation.CancellationException +private val CodeFontSize = 13.sp +private val CodeLineHeight = 20.sp + /** * Renders a single file from the repository. Markdown files render as rich text, - * text files render with syntax highlighting, and binary files show a notice. + * text files render with a line-number gutter and syntax highlighting, and binary + * files show a notice. */ @Composable fun GitFileViewer( @@ -79,13 +100,17 @@ fun GitFileViewer( val bytes = result when { - bytes == null -> CenteredMessage(stringRes(R.string.git_repo_code_loading), modifier) - bytes.isFailure -> CenteredMessage(stringRes(R.string.git_repo_file_load_error), modifier) + bytes == null -> GitLoadingBox(stringRes(R.string.git_repo_code_loading), modifier) + bytes.isFailure -> GitMessageBox(MaterialSymbols.ErrorOutline, stringRes(R.string.git_repo_file_load_error), modifier) else -> { val data = bytes.getOrThrow() when { isProbablyBinary(data) -> - CenteredMessage(stringRes(R.string.git_repo_binary_file, humanSize(data.size)), modifier) + GitMessageBox( + symbol = MaterialSymbols.Description, + text = stringRes(R.string.git_repo_binary_file, humanSize(data.size)), + modifier = modifier, + ) isMarkdownFile(entry.name) -> MarkdownFile(data.decodeToString(), accountViewModel, nav, modifier) else -> @@ -131,47 +156,150 @@ private fun HighlightedCode( modifier: Modifier, ) { val darkMode = isSystemInDarkTheme() + val language = remember(fileName) { CodeHighlighter.languageForFile(fileName) } + val annotated by produceState(AnnotatedString(code), code, fileName, darkMode) { value = withContext(Dispatchers.Default) { - val language = CodeHighlighter.languageForFile(fileName) if (language == null) AnnotatedString(code) else CodeHighlighter.highlight(code, language, darkMode) } } - Text( - text = annotated, - fontFamily = FontFamily.Monospace, - fontSize = 13.sp, - lineHeight = 18.sp, - softWrap = false, - color = MaterialTheme.colorScheme.onBackground, - modifier = - modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .horizontalScroll(rememberScrollState()) - .padding(horizontal = 12.dp, vertical = 8.dp), - ) + Column(modifier.fillMaxSize()) { + CodeBar(languageLabel = language?.name?.let(::prettyLanguage) ?: stringRes(R.string.git_repo_plain_text), code = code) + HorizontalDivider(thickness = 0.5.dp) + CodeWithLineNumbers(annotated, Modifier.fillMaxSize()) + } } @Composable -private fun CenteredMessage( - text: String, - modifier: Modifier, +private fun CodeBar( + languageLabel: String, + code: String, ) { - Column( - modifier = modifier.fillMaxSize().padding(24.dp), + val clipboard = LocalClipboard.current + val context = LocalContext.current + val scope = rememberCoroutineScope() + + Row( + modifier = + Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f)) + .padding(start = 14.dp, end = 4.dp, top = 2.dp, bottom = 2.dp), + verticalAlignment = Alignment.CenterVertically, ) { Text( - text = text, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f), + text = languageLabel, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f), ) + IconButton( + onClick = { + scope.launch { + clipboard.setText(code) + Toast.makeText(context, stringRes(context, R.string.copied_to_clipboard), Toast.LENGTH_SHORT).show() + } + }, + ) { + Icon( + symbol = MaterialSymbols.ContentCopy, + contentDescription = stringRes(R.string.git_repo_copy_file), + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } } +@Composable +private fun CodeWithLineNumbers( + annotated: AnnotatedString, + modifier: Modifier, +) { + val lineRanges = remember(annotated) { computeLineRanges(annotated.text) } + val gutterDigits = lineRanges.size.toString().length + val gutterWidth = (gutterDigits * 9 + 20).dp + + val verticalScroll = rememberScrollState() + val horizontalScroll = rememberScrollState() + val gutterColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.35f) + + Row(modifier.verticalScroll(verticalScroll)) { + // Sticky line-number gutter (only the code area scrolls horizontally). + Column( + modifier = + Modifier + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)) + .width(gutterWidth) + .padding(vertical = 8.dp, horizontal = 4.dp), + horizontalAlignment = Alignment.End, + ) { + for (i in lineRanges.indices) { + Text( + text = (i + 1).toString(), + fontFamily = FontFamily.Monospace, + fontSize = CodeFontSize, + lineHeight = CodeLineHeight, + color = gutterColor, + textAlign = TextAlign.End, + ) + } + } + Column( + modifier = + Modifier + .horizontalScroll(horizontalScroll) + .padding(start = 12.dp, end = 16.dp, top = 8.dp, bottom = 8.dp), + ) { + for ((start, end) in lineRanges) { + Text( + text = annotated.subSequence(start, end), + fontFamily = FontFamily.Monospace, + fontSize = CodeFontSize, + lineHeight = CodeLineHeight, + softWrap = false, + maxLines = 1, + color = MaterialTheme.colorScheme.onBackground, + ) + } + } + } +} + +/** Splits text into per-line character ranges, dropping a single trailing newline's empty line. */ +private fun computeLineRanges(text: String): List> { + val ranges = ArrayList>() + var lineStart = 0 + var i = 0 + while (i < text.length) { + if (text[i] == '\n') { + ranges.add(lineStart to i) + lineStart = i + 1 + } + i++ + } + ranges.add(lineStart to text.length) + if (ranges.size > 1 && ranges.last().let { it.first == it.second }) { + ranges.removeAt(ranges.size - 1) + } + return ranges +} + +private fun prettyLanguage(enumName: String): String = + when (enumName) { + "CPP" -> "C++" + "CSHARP" -> "C#" + "JAVASCRIPT" -> "JavaScript" + "TYPESCRIPT" -> "TypeScript" + "COFFEESCRIPT" -> "CoffeeScript" + "PHP" -> "PHP" + else -> enumName.lowercase().replaceFirstChar { it.uppercase() } + } + private fun isMarkdownFile(name: String): Boolean { val ext = name.substringAfterLast('.', "").lowercase() return ext == "md" || ext == "markdown" || ext == "mdown" || ext == "mkd" diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitReadmeTab.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitReadmeTab.kt index 1f9e4d5fd7..4bc30479db 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitReadmeTab.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitReadmeTab.kt @@ -37,6 +37,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.EmptyTagList import com.vitorpamplona.amethyst.commons.ui.layouts.LocalDisappearingScaffoldPadding import com.vitorpamplona.amethyst.ui.components.RichTextViewer @@ -101,8 +102,8 @@ private fun ReadmeContent( } when (val text = content) { - null -> StatusText(stringRes(R.string.git_repo_code_loading), scaffoldPaddingTop) - "" -> StatusText(stringRes(R.string.git_repo_file_load_error), scaffoldPaddingTop) + null -> GitLoadingBox(stringRes(R.string.git_repo_code_loading), Modifier.padding(scaffoldPaddingTop)) + "" -> GitMessageBox(MaterialSymbols.ErrorOutline, stringRes(R.string.git_repo_file_load_error), Modifier.padding(scaffoldPaddingTop)) else -> { val background = MaterialTheme.colorScheme.background val backgroundColor = remember { mutableStateOf(background) } @@ -139,10 +140,11 @@ private fun ReadmeFallback( ) { val description = event.description()?.takeIf { it.isNotBlank() } if (description == null) { - StatusText( - text = if (loading) stringRes(R.string.git_repo_code_loading) else stringRes(R.string.git_repo_readme_missing), - scaffoldPaddingTop = scaffoldPaddingTop, - ) + if (loading) { + GitLoadingBox(stringRes(R.string.git_repo_code_loading), Modifier.padding(scaffoldPaddingTop)) + } else { + GitMessageBox(MaterialSymbols.Description, stringRes(R.string.git_repo_readme_missing), Modifier.padding(scaffoldPaddingTop)) + } return } @@ -181,22 +183,6 @@ private fun ReadmeFallback( } } -@Composable -private fun StatusText( - text: String, - scaffoldPaddingTop: PaddingValues, -) { - Column( - modifier = Modifier.fillMaxSize().padding(scaffoldPaddingTop).padding(24.dp), - ) { - Text( - text = text, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f), - ) - } -} - /** Picks the most README-like file in the root, preferring markdown. */ private fun findReadme(entries: List): GitTreeEntry? { val files = entries.filter { !it.isFolder } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 07fbdda10c..08df8e1484 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2589,6 +2589,12 @@ This folder is empty. root Retry + Copy file contents + Text + + %1$d item + %1$d items + Git Repositories nSite: %1$s nApplet: %1$s