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.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols 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.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip34Git.status.GitStatusAppliedEvent import com.vitorpamplona.quartz.nip34Git.status.GitStatusAppliedEvent
import com.vitorpamplona.quartz.nip34Git.status.GitStatusClosedEvent import com.vitorpamplona.quartz.nip34Git.status.GitStatusClosedEvent
@@ -72,38 +72,9 @@ private fun GitStatusEvent.statusKind(): StatusKind =
@Composable @Composable
fun rememberLatestStatus(targetIdHex: String): GitStatusEvent? { fun rememberLatestStatus(targetIdHex: String): GitStatusEvent? {
var status by remember(targetIdHex) { LaunchedEffect(Unit) { GitStatusIndex.startIfNeeded() }
mutableStateOf(scanForLatestStatus(targetIdHex)) val index by GitStatusIndex.latestByTarget.collectAsStateWithLifecycle()
} val latest by remember(targetIdHex) { derivedStateOf { index[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
}
}
}
return latest return latest
} }
@@ -35,6 +35,7 @@ import androidx.compose.material3.SecondaryTabRow
import androidx.compose.material3.Tab import androidx.compose.material3.Tab
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -44,6 +45,7 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.AddressableNote 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.WatchLifecycleAndUpdateModel
import com.vitorpamplona.amethyst.ui.feeds.rememberForeverPagerState import com.vitorpamplona.amethyst.ui.feeds.rememberForeverPagerState
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
@@ -120,6 +122,7 @@ private fun GitRepositoryScreen(
WatchLifecycleAndUpdateModel(patchesViewModel) WatchLifecycleAndUpdateModel(patchesViewModel)
RepositoryFilterAssemblerSubscription(note, accountViewModel.dataSources().gitRepository) RepositoryFilterAssemblerSubscription(note, accountViewModel.dataSources().gitRepository)
val event by observeNoteEvent<GitRepositoryEvent>(note, accountViewModel)
val pagerState = rememberForeverPagerState(note.idHex + "GitRepoScreenPagerState") { 3 } val pagerState = rememberForeverPagerState(note.idHex + "GitRepoScreenPagerState") { 3 }
DisappearingScaffold( DisappearingScaffold(
@@ -128,7 +131,7 @@ private fun GitRepositoryScreen(
Column { Column {
ShorterTopAppBar( ShorterTopAppBar(
title = { title = {
TopBarTitle(note) TopBarTitle(event = event, fallback = note.dTag())
}, },
navigationIcon = { navigationIcon = {
Row(TitleIconModifier, verticalAlignment = Alignment.CenterVertically) { Row(TitleIconModifier, verticalAlignment = Alignment.CenterVertically) {
@@ -170,9 +173,9 @@ private fun GitRepositoryScreen(
) { page -> ) { page ->
when (page) { when (page) {
0 -> { 0 -> {
val event = note.event as? GitRepositoryEvent val currentEvent = event
if (event != null) { if (currentEvent != null) {
GitRepositoryOverview(event, accountViewModel, nav) GitRepositoryOverview(currentEvent, accountViewModel, nav)
} else { } else {
EmptyMessage(stringRes(R.string.loading_feed)) EmptyMessage(stringRes(R.string.loading_feed))
} }
@@ -201,11 +204,12 @@ private fun GitRepositoryScreen(
} }
@Composable @Composable
private fun TopBarTitle(note: AddressableNote) { private fun TopBarTitle(
val event = note.event as? GitRepositoryEvent event: GitRepositoryEvent?,
val title = event?.name() ?: note.dTag() fallback: String,
) {
Text( Text(
text = title, text = event?.name() ?: fallback,
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
maxLines = 1, maxLines = 1,