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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DpNmN8CvP6HnEsdTGAjVUr
This commit is contained in:
Claude
2026-06-28 15:25:22 +00:00
parent 291fda5728
commit 059f373ef5
5 changed files with 427 additions and 112 deletions
@@ -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,
)
}
}
@@ -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)) }
}
}
}
}
@@ -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<Pair<Int, Int>> {
val ranges = ArrayList<Pair<Int, Int>>()
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"
@@ -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>): GitTreeEntry? {
val files = entries.filter { !it.isFolder }
+6
View File
@@ -2589,6 +2589,12 @@
<string name="git_repo_empty_folder">This folder is empty.</string>
<string name="git_repo_root">root</string>
<string name="git_repo_retry">Retry</string>
<string name="git_repo_copy_file">Copy file contents</string>
<string name="git_repo_plain_text">Text</string>
<plurals name="git_repo_item_count">
<item quantity="one">%1$d item</item>
<item quantity="other">%1$d items</item>
</plurals>
<string name="git_repositories">Git Repositories</string>
<string name="nsite_title">nSite: %1$s</string>
<string name="napplet_card_title">nApplet: %1$s</string>