feat(git): home polish + repo-card dashboard in the feed

Home screen:
- Nav cards: tighter vertical spacing; badges now count only OPEN issues/PRs,
  derived from the live GitStatusIndex (started on the home so the split is
  correct without visiting the Issues screen first).
- Moved the reaction row to after the recent-activity pulse.
- Added the standard 3-dot note menu (MoreOptionsButton) to the top bar.

Feed card (RenderGitRepositoryEvent):
- Replaced the web/clone links with the same stat tiles + language bar +
  last-commit strip used on the home, loaded from a lazily-fetched shallow
  snapshot. (Factory made internal so the card can build the browser VM.)

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 17:45:18 +00:00
parent 45f36a8159
commit 09633abae5
2 changed files with 73 additions and 26 deletions
@@ -52,12 +52,15 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
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.model.EmptyTagList
import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists
import com.vitorpamplona.amethyst.commons.nip34Git.GitBrowseState
import com.vitorpamplona.amethyst.commons.nip34Git.GitRepositoryBrowserViewModel
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.GitPullRequestUpdateIndex
import com.vitorpamplona.amethyst.model.Note
@@ -71,6 +74,11 @@ import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
import com.vitorpamplona.amethyst.ui.note.LoadDecryptedContent
import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.GitRepositoryBrowserViewModelFactory
import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.RepoLanguageBar
import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.RepoLastCommit
import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.RepoStatTiles
import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.computeLanguageBreakdown
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Font12SP
import com.vitorpamplona.amethyst.ui.theme.HalfDoubleVertSpacer
@@ -785,8 +793,6 @@ private fun RenderGitRepositoryEvent(
) {
val title = noteEvent.name() ?: noteEvent.dTag()
val summary = noteEvent.description()
val web = noteEvent.web()
val clone = noteEvent.clone()
val topics = remember(noteEvent) { noteEvent.hashtags().filter { it.isNotBlank() } }
val isPersonalFork = remember(noteEvent) { noteEvent.isPersonalFork() }
@@ -840,25 +846,7 @@ private fun RenderGitRepositoryEvent(
)
}
if (web != null || clone != null) {
Spacer(modifier = HalfDoubleVertSpacer)
Column(verticalArrangement = Arrangement.spacedBy(Size5dp)) {
web?.let {
LinkRow(
symbol = MaterialSymbols.Public,
contentDescription = stringRes(id = R.string.git_web_address),
url = it,
)
}
clone?.let {
LinkRow(
symbol = MaterialSymbols.AutoMirrored.OpenInNew,
contentDescription = stringRes(id = R.string.git_clone_address),
url = it,
)
}
}
}
RepoSnapshotDashboard(noteEvent, note, accountViewModel)
if (topics.isNotEmpty()) {
Spacer(modifier = HalfDoubleVertSpacer)
@@ -877,3 +865,43 @@ private fun RenderGitRepositoryEvent(
}
}
}
/**
* Loads a shallow snapshot of the repository over smart-HTTP and renders the same
* stat tiles / language bar / last-commit strip used on the project home, in place of
* the old web/clone links. Fetches lazily (once) when the card is composed.
*/
@Composable
private fun RepoSnapshotDashboard(
noteEvent: GitRepositoryEvent,
note: Note,
accountViewModel: AccountViewModel,
) {
val browser: GitRepositoryBrowserViewModel =
viewModel(
key = note.idHex + "GitRepoCardBrowser",
factory = GitRepositoryBrowserViewModelFactory(accountViewModel.httpClientBuilder::okHttpClientForPreview),
)
LaunchedEffect(noteEvent) { browser.loadOnce(noteEvent.clones()) }
val browserState by browser.state.collectAsStateWithLifecycle()
val snapshot = (browserState as? GitBrowseState.Loaded)?.snapshot ?: return
val fileNames = remember(snapshot) { snapshot.walkFileNames() }
val slices = remember(fileNames) { computeLanguageBreakdown(fileNames) }
Spacer(modifier = HalfDoubleVertSpacer)
RepoStatTiles(
branches = snapshot.branches.size,
tags = snapshot.tags.size,
files = fileNames.size,
updatedEpochSec = snapshot.tipCommit?.authorTimeSec,
)
if (slices.isNotEmpty()) {
Spacer(modifier = HalfDoubleVertSpacer)
RepoLanguageBar(slices)
}
snapshot.tipCommit?.let {
Spacer(modifier = HalfDoubleVertSpacer)
RepoLastCommit(it)
}
}
@@ -72,6 +72,7 @@ import com.vitorpamplona.amethyst.commons.nip34Git.GitRepositoryBrowserViewModel
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
import com.vitorpamplona.amethyst.commons.ui.layouts.LocalDisappearingScaffoldPadding
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.GitStatusIndex
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent
import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel
@@ -82,6 +83,7 @@ import com.vitorpamplona.amethyst.ui.navigation.topbars.ShorterTopAppBar
import com.vitorpamplona.amethyst.ui.navigation.topbars.TitleIconModifier
import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
import com.vitorpamplona.amethyst.ui.note.elements.MoreOptionsButton
import com.vitorpamplona.amethyst.ui.screen.FeedViewModel
import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -163,7 +165,7 @@ fun GitRepositoryPullsScreen(
* Builds the [GitRepositoryBrowserViewModel]. The factory lives app-side because the KMP
* lifecycle artifact used in commons doesn't expose the `create(Class<T>)` override.
*/
private class GitRepositoryBrowserViewModelFactory(
internal class GitRepositoryBrowserViewModelFactory(
private val okHttpClient: (String) -> OkHttpClient,
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T = GitRepositoryBrowserViewModel(okHttpClient) as T
@@ -258,6 +260,20 @@ private fun GitRepositoryHome(
.take(6)
}
// Nav-card badges count only the OPEN issues/PRs. The open/closed split needs the status
// index (kinds 1630-1633), which is started here so the home reflects it without visiting
// the Issues screen first; the count is then derived directly from the live index.
LaunchedEffect(Unit) { GitStatusIndex.startIfNeeded() }
val statusMap by GitStatusIndex.latestByTarget.collectAsStateWithLifecycle()
val openIssueCount =
remember(openIssueItems, closedIssueItems, statusMap) {
(openIssueItems + closedIssueItems).distinctBy { it.idHex }.count { !GitStatusIndex.isClosedOrResolved(it.idHex, statusMap) }
}
val openPullCount =
remember(openPatchItems, closedPatchItems, statusMap) {
(openPatchItems + closedPatchItems).distinctBy { it.idHex }.count { !GitStatusIndex.isClosedOrResolved(it.idHex, statusMap) }
}
var showSettings by rememberSaveable(note.idHex) { mutableStateOf(false) }
val currentEventForSettings = event
if (showSettings && currentEventForSettings != null) {
@@ -290,6 +306,9 @@ private fun GitRepositoryHome(
Icon(MaterialSymbols.Edit, contentDescription = stringRes(R.string.git_repo_settings_title))
}
}
Row(Modifier.padding(end = 6.dp), verticalAlignment = Alignment.CenterVertically) {
MoreOptionsButton(note, accountViewModel = accountViewModel, nav = nav)
}
},
)
},
@@ -309,7 +328,6 @@ private fun GitRepositoryHome(
if (currentEvent != null) {
RepoHero(currentEvent)
RepoMaintainersRow(currentEvent, accountViewModel, nav)
RepoSocialRow(note, accountViewModel, nav)
}
if (snapshot != null) {
@@ -325,11 +343,12 @@ private fun GitRepositoryHome(
snapshot.tipCommit?.let { RepoLastCommit(it) }
}
RepoNavCards(note, openIssueItems.size, openPatchItems.size, nav)
RepoNavCards(note, openIssueCount, openPullCount, nav)
RepoActivityPulse(activity, accountViewModel, nav)
if (currentEvent != null) {
RepoSocialRow(note, accountViewModel, nav)
GitReadmeSection(browserState, browserViewModel, currentEvent, accountViewModel, nav)
} else {
EmptyMessage(stringRes(R.string.loading_feed))
@@ -462,7 +481,7 @@ private fun RepoNavCards(
) {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
RepoNavCard(MaterialSymbols.Code, stringRes(R.string.git_repo_tab_code), null) {
nav.nav(Route.GitRepositoryCode(note.address))
@@ -490,7 +509,7 @@ private fun RepoNavCard(
.clip(RoundedCornerShape(15.dp))
.background(MaterialTheme.colorScheme.surface)
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 16.dp),
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp),
) {