From 2ad4af2479c1441694840886b2a339db0e990183 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 29 Jun 2026 15:32:47 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01DpNmN8CvP6HnEsdTGAjVUr --- .../screen/loggedIn/gitRepo/GitItemListRow.kt | 2 +- .../loggedIn/gitRepo/GitRepositoryHomeUi.kt | 420 ++++++++++++++++++ .../loggedIn/gitRepo/GitRepositoryScreen.kt | 87 +++- amethyst/src/main/res/values/strings.xml | 5 + .../quartz/nip34Git/git/GitHttpClient.kt | 30 ++ 5 files changed, 539 insertions(+), 5 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryHomeUi.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitItemListRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitItemListRow.kt index cd4a789797..cdb48b8fd4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitItemListRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitItemListRow.kt @@ -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() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryHomeUi.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryHomeUi.kt new file mode 100644 index 0000000000..f400d32122 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryHomeUi.kt @@ -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) { + 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 = + 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): List { + if (files.isEmpty()) return emptyList() + val counts = HashMap() + 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, + 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" +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryScreen.kt index a97f916623..f6d2ec0bbd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryScreen.kt @@ -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, diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 609cb66f59..16473b8b30 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2646,6 +2646,11 @@ Closed & Resolved All Bookmark repository + Branches + Tags + Files + Updated + Recent activity Untitled About Links diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitHttpClient.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitHttpClient.kt index e0369dd187..8c296f5ea7 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitHttpClient.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitHttpClient.kt @@ -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, 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(blobs) private val blobMutex = Mutex() @@ -360,6 +365,31 @@ class GitRepoSnapshot( /** Entries at the repository root, folders first. */ fun rootEntries(): List = 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 { + val out = ArrayList() + val stack = ArrayDeque>() + stack.addLast(rootTreeOid to "") + val seen = HashSet() + 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.