perf: index git status events and observe repo event reactively

Audit follow-up on the Git Repository screen.

- Add GitStatusIndex, a process-level Map<targetId, latest GitStatusEvent>
  fed by an initial scan on Dispatchers.IO and incrementally maintained
  from LocalCache.live.newEventBundles. GitStatusPill now does an O(1)
  map lookup instead of scanning the entire LocalCache on every issue/
  patch row at composition time on the main thread.

- Observe the GitRepositoryEvent reactively in the screen via
  observeNoteEvent so the title and Overview tab refresh when the event
  arrives after composition or when the maintainer publishes a new
  version.
This commit is contained in:
Claude
2026-05-15 21:03:50 +00:00
parent e69c166de2
commit db564d9367
3 changed files with 100 additions and 43 deletions
@@ -0,0 +1,82 @@
/*
* 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.model
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip34Git.status.GitStatusEvent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import java.util.concurrent.atomic.AtomicBoolean
/**
* Cross-screen index of the most recent NIP-34 status event (kinds
* 1630-1633) per target id, kept up to date from
* [LocalCache.live.newEventBundles]. Status events are not tracked in
* `Note.replies` (see `LocalCache.computeReplyTo`), so the only way to
* find them otherwise would be a full cache scan per row.
*/
object GitStatusIndex {
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val started = AtomicBoolean(false)
private val mutableLatestByTarget = MutableStateFlow<Map<HexKey, GitStatusEvent>>(emptyMap())
val latestByTarget: StateFlow<Map<HexKey, GitStatusEvent>> = mutableLatestByTarget.asStateFlow()
fun startIfNeeded() {
if (!started.compareAndSet(false, true)) return
scope.launch {
val initial = HashMap<HexKey, GitStatusEvent>()
LocalCache.notes.forEach { _, note ->
val event = note.event as? GitStatusEvent ?: return@forEach
val target = event.rootEventId() ?: return@forEach
val current = initial[target]
if (current == null || event.createdAt > current.createdAt) {
initial[target] = event
}
}
mutableLatestByTarget.value = initial
LocalCache.live.newEventBundles.collect { bundle ->
processBundle(bundle)
}
}
}
private fun processBundle(bundle: Set<Note>) {
var modified: HashMap<HexKey, GitStatusEvent>? = null
for (note in bundle) {
val event = note.event as? GitStatusEvent ?: continue
val target = event.rootEventId() ?: continue
val map = modified ?: mutableLatestByTarget.value
val current = map[target]
if (current == null || event.createdAt > current.createdAt) {
if (modified == null) modified = HashMap(mutableLatestByTarget.value)
modified[target] = event
}
}
modified?.let { mutableLatestByTarget.value = it }
}
}
@@ -30,21 +30,21 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
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.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
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.LocalCache
import com.vitorpamplona.amethyst.model.GitStatusIndex
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip34Git.status.GitStatusAppliedEvent
import com.vitorpamplona.quartz.nip34Git.status.GitStatusClosedEvent
@@ -72,38 +72,9 @@ private fun GitStatusEvent.statusKind(): StatusKind =
@Composable
fun rememberLatestStatus(targetIdHex: String): GitStatusEvent? {
var status by remember(targetIdHex) {
mutableStateOf(scanForLatestStatus(targetIdHex))
}
LaunchedEffect(targetIdHex) {
LocalCache.live.newEventBundles.collect { bundle ->
for (note in bundle) {
val event = note.event as? GitStatusEvent ?: continue
if (event.rootEventId() == targetIdHex) {
val current = status
if (current == null || event.createdAt > current.createdAt) {
status = event
}
}
}
}
}
return status
}
private fun scanForLatestStatus(targetIdHex: String): GitStatusEvent? {
var latest: GitStatusEvent? = null
LocalCache.notes.forEach { _, note ->
val event = note.event
if (event is GitStatusEvent && event.rootEventId() == targetIdHex) {
val current = latest
if (current == null || event.createdAt > current.createdAt) {
latest = event
}
}
}
LaunchedEffect(Unit) { GitStatusIndex.startIfNeeded() }
val index by GitStatusIndex.latestByTarget.collectAsStateWithLifecycle()
val latest by remember(targetIdHex) { derivedStateOf { index[targetIdHex] } }
return latest
}
@@ -35,6 +35,7 @@ import androidx.compose.material3.SecondaryTabRow
import androidx.compose.material3.Tab
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -44,6 +45,7 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent
import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel
import com.vitorpamplona.amethyst.ui.feeds.rememberForeverPagerState
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
@@ -120,6 +122,7 @@ private fun GitRepositoryScreen(
WatchLifecycleAndUpdateModel(patchesViewModel)
RepositoryFilterAssemblerSubscription(note, accountViewModel.dataSources().gitRepository)
val event by observeNoteEvent<GitRepositoryEvent>(note, accountViewModel)
val pagerState = rememberForeverPagerState(note.idHex + "GitRepoScreenPagerState") { 3 }
DisappearingScaffold(
@@ -128,7 +131,7 @@ private fun GitRepositoryScreen(
Column {
ShorterTopAppBar(
title = {
TopBarTitle(note)
TopBarTitle(event = event, fallback = note.dTag())
},
navigationIcon = {
Row(TitleIconModifier, verticalAlignment = Alignment.CenterVertically) {
@@ -170,9 +173,9 @@ private fun GitRepositoryScreen(
) { page ->
when (page) {
0 -> {
val event = note.event as? GitRepositoryEvent
if (event != null) {
GitRepositoryOverview(event, accountViewModel, nav)
val currentEvent = event
if (currentEvent != null) {
GitRepositoryOverview(currentEvent, accountViewModel, nav)
} else {
EmptyMessage(stringRes(R.string.loading_feed))
}
@@ -201,11 +204,12 @@ private fun GitRepositoryScreen(
}
@Composable
private fun TopBarTitle(note: AddressableNote) {
val event = note.event as? GitRepositoryEvent
val title = event?.name() ?: note.dTag()
private fun TopBarTitle(
event: GitRepositoryEvent?,
fallback: String,
) {
Text(
text = title,
text = event?.name() ?: fallback,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
maxLines = 1,