mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-08 23:54:39 +00:00
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:
+186
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+72
-63
@@ -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)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+155
-27
@@ -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"
|
||||
|
||||
+8
-22
@@ -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 }
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user