mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
feat(git): rich project-home dashboard (stats, languages, social pulse)
Turns the repository home into a data-rich dashboard combining git facts with the Nostr social layer: - Social row: live zap / reaction / comment counts on the repo announcement (via observeNoteZaps/Reactions/ReplyCount). - Stat tiles: branches, tags, file count, and last-updated relative time. - Language breakdown bar: proportional colored segments computed from the snapshot's file tree by extension (new GitRepoSnapshot.walkFileNames()). - Last-commit strip: tip commit summary, author, time and short SHA, exposed up-front via the new GitRepoSnapshot.tipCommit (no extra fetch). - Nav cards now show open issue / PR counts. - Recent-activity pulse: newest issues/patches merged from the feeds. quartz: GitRepoSnapshot gains tipCommit (parsed in open()) and walkFileNames() to enumerate blob paths for the language bar. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DpNmN8CvP6HnEsdTGAjVUr
This commit is contained in:
+1
-1
@@ -272,7 +272,7 @@ private fun GitRevisedChip(prIdHex: String) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun gitSubjectOf(event: Event?): String? =
|
||||
internal fun gitSubjectOf(event: Event?): String? =
|
||||
when (event) {
|
||||
is GitIssueEvent -> event.subject()?.takeIf { it.isNotBlank() }
|
||||
is GitPullRequestEvent -> event.subject()?.takeIf { it.isNotBlank() }
|
||||
|
||||
+420
@@ -0,0 +1,420 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
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.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteReactionCount
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteReplyCount
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteZaps
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.note.elements.TimeAgo
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.grayText
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip34Git.git.GitCommit
|
||||
import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestEvent
|
||||
|
||||
private val CardShape = RoundedCornerShape(15.dp)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Social pulse — zaps, reactions and comments on the repository announcement.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@Composable
|
||||
fun RepoSocialRow(
|
||||
note: AddressableNote,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
val reactionCount by observeNoteReactionCount(note, accountViewModel)
|
||||
val replyCount by observeNoteReplyCount(note, accountViewModel)
|
||||
val zapState by observeNoteZaps(note, accountViewModel)
|
||||
val zapCount = zapState?.note?.zaps?.size ?: note.zaps.size
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
SocialStat(MaterialSymbols.Bolt, zapCount, MaterialTheme.colorScheme.primary)
|
||||
SocialStat(MaterialSymbols.Favorite, reactionCount, MaterialTheme.colorScheme.error)
|
||||
SocialStat(MaterialSymbols.Forum, replyCount, MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SocialStat(
|
||||
symbol: MaterialSymbol,
|
||||
count: Int,
|
||||
tint: Color,
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Icon(symbol = symbol, contentDescription = null, modifier = Modifier.size(16.dp), tint = tint)
|
||||
Text(
|
||||
text = compactCount(count),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stat tiles — git facts (branches, tags, files, last updated).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@Composable
|
||||
fun RepoStatTiles(
|
||||
branches: Int,
|
||||
tags: Int,
|
||||
files: Int,
|
||||
updatedEpochSec: Long?,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
StatTile(MaterialSymbols.Commit, branches.toString(), stringRes(R.string.git_repo_stat_branches), Modifier.weight(1f))
|
||||
StatTile(MaterialSymbols.Tag, tags.toString(), stringRes(R.string.git_repo_stat_tags), Modifier.weight(1f))
|
||||
StatTile(MaterialSymbols.Description, compactCount(files), stringRes(R.string.git_repo_stat_files), Modifier.weight(1f))
|
||||
StatTile(MaterialSymbols.Schedule, updatedEpochSec?.let { relativeShort(it) } ?: "—", stringRes(R.string.git_repo_stat_updated), Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatTile(
|
||||
symbol: MaterialSymbol,
|
||||
value: String,
|
||||
label: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier =
|
||||
modifier
|
||||
.clip(CardShape)
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.padding(vertical = 12.dp, horizontal = 8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(3.dp),
|
||||
) {
|
||||
Icon(symbol = symbol, contentDescription = null, modifier = Modifier.size(18.dp), tint = MaterialTheme.colorScheme.primary)
|
||||
Text(
|
||||
text = value,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.grayText,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Language breakdown bar — proportional colours by file count.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class LanguageSlice(
|
||||
val name: String,
|
||||
val fraction: Float,
|
||||
val color: Color,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun RepoLanguageBar(slices: List<LanguageSlice>) {
|
||||
if (slices.isEmpty()) return
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(10.dp)
|
||||
.clip(CircleShape),
|
||||
) {
|
||||
slices.forEach { slice ->
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.weight(slice.fraction.coerceAtLeast(0.0001f))
|
||||
.height(10.dp)
|
||||
.background(slice.color),
|
||||
)
|
||||
}
|
||||
}
|
||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
slices.forEach { slice ->
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(5.dp)) {
|
||||
Box(Modifier.size(9.dp).clip(CircleShape).background(slice.color))
|
||||
Text(
|
||||
text = slice.name + " " + (slice.fraction * 100).toInt() + "%",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.grayText,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class LangDef(
|
||||
val name: String,
|
||||
val color: Long,
|
||||
) {
|
||||
override fun equals(other: Any?) = other is LangDef && other.name == name
|
||||
|
||||
override fun hashCode() = name.hashCode()
|
||||
}
|
||||
|
||||
private val EXT_TO_LANG: Map<String, LangDef> =
|
||||
buildMap {
|
||||
fun add(
|
||||
def: LangDef,
|
||||
vararg exts: String,
|
||||
) = exts.forEach { put(it, def) }
|
||||
add(LangDef("Kotlin", 0xFFA97BFF), "kt", "kts")
|
||||
add(LangDef("Java", 0xFFB07219), "java")
|
||||
add(LangDef("JavaScript", 0xFFF1E05A), "js", "mjs", "cjs", "jsx")
|
||||
add(LangDef("TypeScript", 0xFF3178C6), "ts", "tsx")
|
||||
add(LangDef("Python", 0xFF3572A5), "py")
|
||||
add(LangDef("Ruby", 0xFF701516), "rb")
|
||||
add(LangDef("Rust", 0xFFDEA584), "rs")
|
||||
add(LangDef("Go", 0xFF00ADD8), "go")
|
||||
add(LangDef("C", 0xFF555555), "c", "h")
|
||||
add(LangDef("C++", 0xFFF34B7D), "cpp", "cc", "cxx", "hpp", "hh", "hxx")
|
||||
add(LangDef("C#", 0xFF178600), "cs")
|
||||
add(LangDef("Swift", 0xFFF05138), "swift")
|
||||
add(LangDef("PHP", 0xFF4F5D95), "php")
|
||||
add(LangDef("Shell", 0xFF89E051), "sh", "bash", "zsh", "ksh")
|
||||
add(LangDef("HTML", 0xFFE34C26), "html", "htm")
|
||||
add(LangDef("CSS", 0xFF563D7C), "css")
|
||||
add(LangDef("SCSS", 0xFFC6538C), "scss")
|
||||
add(LangDef("Markdown", 0xFF083FA1), "md", "markdown")
|
||||
add(LangDef("JSON", 0xFF40803F), "json")
|
||||
add(LangDef("YAML", 0xFFCB171E), "yml", "yaml")
|
||||
add(LangDef("XML", 0xFF0060AC), "xml")
|
||||
add(LangDef("Gradle", 0xFF02303A), "gradle")
|
||||
add(LangDef("Dart", 0xFF00B4AB), "dart")
|
||||
add(LangDef("TOML", 0xFF9C4221), "toml")
|
||||
}
|
||||
|
||||
private const val OTHER_COLOR = 0xFFBBBBBB
|
||||
|
||||
/** Buckets files by recognised language (by extension), returning the top slices + an "Other" remainder. */
|
||||
fun computeLanguageBreakdown(files: List<String>): List<LanguageSlice> {
|
||||
if (files.isEmpty()) return emptyList()
|
||||
val counts = HashMap<LangDef, Int>()
|
||||
var other = 0
|
||||
for (f in files) {
|
||||
val ext = f.substringAfterLast('.', "").lowercase()
|
||||
val def = EXT_TO_LANG[ext]
|
||||
if (def == null) other++ else counts[def] = (counts[def] ?: 0) + 1
|
||||
}
|
||||
val total = files.size.toFloat()
|
||||
val ranked = counts.entries.sortedByDescending { it.value }
|
||||
val top = ranked.take(6)
|
||||
val remainder = ranked.drop(6).sumOf { it.value } + other
|
||||
|
||||
val slices =
|
||||
top.map { LanguageSlice(it.key.name, it.value / total, Color(it.key.color)) }.toMutableList()
|
||||
if (remainder > 0) {
|
||||
slices.add(LanguageSlice("Other", remainder / total, Color(OTHER_COLOR)))
|
||||
}
|
||||
return slices
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Last-commit strip.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@Composable
|
||||
fun RepoLastCommit(commit: GitCommit) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(CardShape)
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Icon(MaterialSymbols.Commit, contentDescription = null, modifier = Modifier.size(20.dp), tint = MaterialTheme.colorScheme.primary)
|
||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(
|
||||
text = commit.summary,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = commit.authorName + " · " + relativeShort(commit.authorTimeSec),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.grayText,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = commit.shortOid,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.grayText,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Recent activity pulse — newest issues / patches / status events.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@Composable
|
||||
fun RepoActivityPulse(
|
||||
items: List<Note>,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
if (items.isEmpty()) return
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(CardShape)
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.padding(vertical = 6.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringRes(R.string.git_repo_recent_activity),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.grayText,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.padding(horizontal = 14.dp, vertical = 6.dp),
|
||||
)
|
||||
items.forEach { ActivityRow(it, accountViewModel, nav) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ActivityRow(
|
||||
note: Note,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val event = note.event ?: return
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Icon(
|
||||
symbol = activityIcon(event),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
text = gitSubjectOf(event) ?: stringRes(R.string.git_untitled),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
TimeAgo(note)
|
||||
}
|
||||
}
|
||||
|
||||
private fun activityIcon(event: Event): MaterialSymbol =
|
||||
when (event) {
|
||||
is GitIssueEvent -> MaterialSymbols.ErrorOutline
|
||||
is GitPullRequestEvent -> MaterialSymbols.CallMerge
|
||||
is GitPatchEvent -> MaterialSymbols.CallMerge
|
||||
else -> MaterialSymbols.Commit
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Compacts large counts: 1234 -> "1.2k", 2_500_000 -> "2.5M". */
|
||||
private fun compactCount(n: Int): String =
|
||||
when {
|
||||
n < 1_000 -> n.toString()
|
||||
n < 1_000_000 -> trimDecimal(n / 1_000.0) + "k"
|
||||
else -> trimDecimal(n / 1_000_000.0) + "M"
|
||||
}
|
||||
|
||||
private fun trimDecimal(v: Double): String {
|
||||
val rounded = (v * 10).toInt() / 10.0
|
||||
return if (rounded % 1.0 == 0.0) rounded.toInt().toString() else rounded.toString()
|
||||
}
|
||||
|
||||
/** A coarse "time since" label for epoch seconds: "5m", "3h", "2d", "4w", "1y". */
|
||||
private fun relativeShort(epochSec: Long): String {
|
||||
val deltaSec = (System.currentTimeMillis() / 1000) - epochSec
|
||||
if (deltaSec < 60) return "now"
|
||||
val mins = deltaSec / 60
|
||||
if (mins < 60) return mins.toString() + "m"
|
||||
val hours = mins / 60
|
||||
if (hours < 24) return hours.toString() + "h"
|
||||
val days = hours / 24
|
||||
if (days < 7) return days.toString() + "d"
|
||||
val weeks = days / 7
|
||||
if (weeks < 52) return weeks.toString() + "w"
|
||||
return (days / 365).toString() + "y"
|
||||
}
|
||||
+83
-4
@@ -67,6 +67,7 @@ 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.commons.nip34Git.GitBrowseState
|
||||
import com.vitorpamplona.amethyst.commons.nip34Git.GitRepositoryBrowserViewModel
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
|
||||
import com.vitorpamplona.amethyst.commons.ui.layouts.LocalDisappearingScaffoldPadding
|
||||
@@ -216,6 +217,47 @@ private fun GitRepositoryHome(
|
||||
|
||||
RepoContentSubscription(note, event, accountViewModel)
|
||||
|
||||
// Feed view models power the issue/PR counts on the nav cards and the recent-activity pulse.
|
||||
val openIssues: RepositoryIssuesFeedViewModel =
|
||||
viewModel(
|
||||
key = note.idHex + "GitRepoIssuesOpen",
|
||||
factory = RepositoryIssuesFeedViewModel.Factory(note, accountViewModel.account, showClosed = false),
|
||||
)
|
||||
val closedIssues: RepositoryIssuesFeedViewModel =
|
||||
viewModel(
|
||||
key = note.idHex + "GitRepoIssuesClosed",
|
||||
factory = RepositoryIssuesFeedViewModel.Factory(note, accountViewModel.account, showClosed = true),
|
||||
)
|
||||
val openPatches: RepositoryPatchesFeedViewModel =
|
||||
viewModel(
|
||||
key = note.idHex + "GitRepoPatchesOpen",
|
||||
factory = RepositoryPatchesFeedViewModel.Factory(note, accountViewModel.account, showClosed = false),
|
||||
)
|
||||
val closedPatches: RepositoryPatchesFeedViewModel =
|
||||
viewModel(
|
||||
key = note.idHex + "GitRepoPatchesClosed",
|
||||
factory = RepositoryPatchesFeedViewModel.Factory(note, accountViewModel.account, showClosed = true),
|
||||
)
|
||||
WatchLifecycleAndUpdateModel(openIssues)
|
||||
WatchLifecycleAndUpdateModel(closedIssues)
|
||||
WatchLifecycleAndUpdateModel(openPatches)
|
||||
WatchLifecycleAndUpdateModel(closedPatches)
|
||||
|
||||
val openIssueItems = rememberGitFeedItems(openIssues)
|
||||
val closedIssueItems = rememberGitFeedItems(closedIssues)
|
||||
val openPatchItems = rememberGitFeedItems(openPatches)
|
||||
val closedPatchItems = rememberGitFeedItems(closedPatches)
|
||||
|
||||
val snapshot = (browserState as? GitBrowseState.Loaded)?.snapshot
|
||||
val fileNames = remember(snapshot) { snapshot?.walkFileNames() ?: emptyList() }
|
||||
val languageSlices = remember(fileNames) { computeLanguageBreakdown(fileNames) }
|
||||
val activity =
|
||||
remember(openIssueItems, closedIssueItems, openPatchItems, closedPatchItems) {
|
||||
(openIssueItems + closedIssueItems + openPatchItems + closedPatchItems)
|
||||
.sortedByDescending { it.createdAt() ?: 0L }
|
||||
.take(6)
|
||||
}
|
||||
|
||||
var showSettings by rememberSaveable(note.idHex) { mutableStateOf(false) }
|
||||
val currentEventForSettings = event
|
||||
if (showSettings && currentEventForSettings != null) {
|
||||
@@ -266,9 +308,25 @@ private fun GitRepositoryHome(
|
||||
val currentEvent = event
|
||||
if (currentEvent != null) {
|
||||
GitRepositoryOverviewSections(currentEvent, accountViewModel, nav)
|
||||
RepoSocialRow(note, accountViewModel)
|
||||
}
|
||||
|
||||
RepoNavCards(note, nav)
|
||||
if (snapshot != null) {
|
||||
RepoStatTiles(
|
||||
branches = snapshot.branches.size,
|
||||
tags = snapshot.tags.size,
|
||||
files = fileNames.size,
|
||||
updatedEpochSec = snapshot.tipCommit?.authorTimeSec,
|
||||
)
|
||||
if (languageSlices.isNotEmpty()) {
|
||||
RepoLanguageBar(languageSlices)
|
||||
}
|
||||
snapshot.tipCommit?.let { RepoLastCommit(it) }
|
||||
}
|
||||
|
||||
RepoNavCards(note, openIssueItems.size, openPatchItems.size, nav)
|
||||
|
||||
RepoActivityPulse(activity, accountViewModel, nav)
|
||||
|
||||
if (currentEvent != null) {
|
||||
GitReadmeSection(browserState, browserViewModel, currentEvent, accountViewModel, nav)
|
||||
@@ -397,19 +455,21 @@ private fun GitRepoSubScreenScaffold(
|
||||
@Composable
|
||||
private fun RepoNavCards(
|
||||
note: AddressableNote,
|
||||
openIssues: Int,
|
||||
openPulls: Int,
|
||||
nav: INav,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
RepoNavCard(MaterialSymbols.Code, stringRes(R.string.git_repo_tab_code)) {
|
||||
RepoNavCard(MaterialSymbols.Code, stringRes(R.string.git_repo_tab_code), null) {
|
||||
nav.nav(Route.GitRepositoryCode(note.address))
|
||||
}
|
||||
RepoNavCard(MaterialSymbols.ErrorOutline, stringRes(R.string.git_repo_tab_issues)) {
|
||||
RepoNavCard(MaterialSymbols.ErrorOutline, stringRes(R.string.git_repo_tab_issues), openIssues) {
|
||||
nav.nav(Route.GitRepositoryIssues(note.address))
|
||||
}
|
||||
RepoNavCard(MaterialSymbols.CallMerge, stringRes(R.string.git_repo_tab_patches)) {
|
||||
RepoNavCard(MaterialSymbols.CallMerge, stringRes(R.string.git_repo_tab_patches), openPulls) {
|
||||
nav.nav(Route.GitRepositoryPulls(note.address))
|
||||
}
|
||||
}
|
||||
@@ -419,6 +479,7 @@ private fun RepoNavCards(
|
||||
private fun RepoNavCard(
|
||||
symbol: MaterialSymbol,
|
||||
title: String,
|
||||
count: Int?,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
@@ -444,6 +505,24 @@ private fun RepoNavCard(
|
||||
fontWeight = FontWeight.Medium,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
if (count != null && count > 0) {
|
||||
Text(
|
||||
text =
|
||||
if (count > 999) {
|
||||
"999+"
|
||||
} else {
|
||||
count.toString()
|
||||
},
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier =
|
||||
Modifier
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f))
|
||||
.padding(horizontal = 9.dp, vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
Icon(
|
||||
symbol = MaterialSymbols.ChevronRight,
|
||||
contentDescription = null,
|
||||
|
||||
@@ -2646,6 +2646,11 @@
|
||||
<string name="git_repo_filter_closed">Closed & Resolved</string>
|
||||
<string name="git_repo_label_all">All</string>
|
||||
<string name="git_repo_bookmark">Bookmark repository</string>
|
||||
<string name="git_repo_stat_branches">Branches</string>
|
||||
<string name="git_repo_stat_tags">Tags</string>
|
||||
<string name="git_repo_stat_files">Files</string>
|
||||
<string name="git_repo_stat_updated">Updated</string>
|
||||
<string name="git_repo_recent_activity">Recent activity</string>
|
||||
<string name="git_untitled">Untitled</string>
|
||||
<string name="git_repo_section_about">About</string>
|
||||
<string name="git_repo_section_links">Links</string>
|
||||
|
||||
@@ -93,6 +93,8 @@ class GitHttpClient(
|
||||
}
|
||||
}
|
||||
|
||||
val tipCommit = runCatching { GitObjectParser.parseCommit(head.oid, commit.data) }.getOrNull()
|
||||
|
||||
return GitRepoSnapshot(
|
||||
cloneUrl = cloneUrl,
|
||||
headCommit = head.oid,
|
||||
@@ -104,6 +106,7 @@ class GitHttpClient(
|
||||
blobs = blobs,
|
||||
transport = transport,
|
||||
caps = caps,
|
||||
tipCommit = tipCommit,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -353,6 +356,8 @@ class GitRepoSnapshot(
|
||||
blobs: Map<String, ByteArray>,
|
||||
private val transport: GitSmartHttpTransport,
|
||||
private val caps: GitCapabilities,
|
||||
/** The tip commit object, parsed up-front so the UI can show it without another fetch. */
|
||||
val tipCommit: GitCommit? = null,
|
||||
) {
|
||||
private val blobs = HashMap<String, ByteArray>(blobs)
|
||||
private val blobMutex = Mutex()
|
||||
@@ -360,6 +365,31 @@ class GitRepoSnapshot(
|
||||
/** Entries at the repository root, folders first. */
|
||||
fun rootEntries(): List<GitTreeEntry> = sortForDisplay(trees[rootTreeOid].orEmpty())
|
||||
|
||||
/**
|
||||
* Every blob (file) path reachable from the tip tree, depth-first. Folders and submodules
|
||||
* are not included. Drives the home screen's language breakdown; the whole tree is already
|
||||
* in memory (the snapshot is fetched with `blob:none`, so all trees came down).
|
||||
*/
|
||||
fun walkFileNames(): List<String> {
|
||||
val out = ArrayList<String>()
|
||||
val stack = ArrayDeque<Pair<String, String>>()
|
||||
stack.addLast(rootTreeOid to "")
|
||||
val seen = HashSet<String>()
|
||||
while (stack.isNotEmpty()) {
|
||||
val (treeOid, prefix) = stack.removeLast()
|
||||
if (!seen.add(treeOid + "@" + prefix)) continue
|
||||
val entries = trees[treeOid] ?: continue
|
||||
for (e in entries) {
|
||||
if (e.isFolder) {
|
||||
stack.addLast(e.oid to "$prefix${e.name}/")
|
||||
} else if (!e.isSubmodule) {
|
||||
out.add("$prefix${e.name}")
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Entries inside the directory at [path] (a list of path segments). Returns
|
||||
* null if the path doesn't resolve to a directory.
|
||||
|
||||
Reference in New Issue
Block a user