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:
Claude
2026-06-29 15:32:47 +00:00
parent d2225c73cc
commit 2ad4af2479
5 changed files with 539 additions and 5 deletions
@@ -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() }
@@ -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"
}
@@ -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,
+5
View File
@@ -2646,6 +2646,11 @@
<string name="git_repo_filter_closed">Closed &amp; 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.