fix: tighten GitStatusIndex initial-scan and pill warming UX

Two issues from a follow-up audit on the Git Repository screen.

- Move the initial cache scan into flow `onStart` so the
  newEventBundles collector is attached before scanning. Closes a small
  race window where status events emitted between scan completion and
  collect attachment would have been dropped.

- Distinguish "index warming up" from "no status exists" by typing the
  StateFlow as `Map?` (null until first scan completes). The pill now
  hides itself entirely while warming, instead of briefly rendering
  the `defaultIfMissing` fallback and then flipping to the real status.

Also drops the dead public `rememberLatestStatus` helper and the
unnecessary derivedStateOf scaffolding — at the scale this screen
operates (tens of pills), the straightforward map read is fine.
This commit is contained in:
Claude
2026-05-15 21:14:16 +00:00
parent db564d9367
commit bfea285a6a
2 changed files with 25 additions and 31 deletions
@@ -28,6 +28,7 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
@@ -42,12 +43,16 @@ object GitStatusIndex {
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val started = AtomicBoolean(false) private val started = AtomicBoolean(false)
private val mutableLatestByTarget = MutableStateFlow<Map<HexKey, GitStatusEvent>>(emptyMap()) private val mutableLatestByTarget = MutableStateFlow<Map<HexKey, GitStatusEvent>?>(null)
val latestByTarget: StateFlow<Map<HexKey, GitStatusEvent>> = mutableLatestByTarget.asStateFlow() val latestByTarget: StateFlow<Map<HexKey, GitStatusEvent>?> = mutableLatestByTarget.asStateFlow()
fun startIfNeeded() { fun startIfNeeded() {
if (!started.compareAndSet(false, true)) return if (!started.compareAndSet(false, true)) return
scope.launch { scope.launch {
// Subscribe to bundle updates BEFORE the initial scan via onStart, so any
// events that arrive between scan start and collector attach are picked up.
LocalCache.live.newEventBundles
.onStart {
val initial = HashMap<HexKey, GitStatusEvent>() val initial = HashMap<HexKey, GitStatusEvent>()
LocalCache.notes.forEach { _, note -> LocalCache.notes.forEach { _, note ->
val event = note.event as? GitStatusEvent ?: return@forEach val event = note.event as? GitStatusEvent ?: return@forEach
@@ -58,22 +63,20 @@ object GitStatusIndex {
} }
} }
mutableLatestByTarget.value = initial mutableLatestByTarget.value = initial
}.collect { bundle -> processBundle(bundle) }
LocalCache.live.newEventBundles.collect { bundle ->
processBundle(bundle)
}
} }
} }
private fun processBundle(bundle: Set<Note>) { private fun processBundle(bundle: Set<Note>) {
val snapshot = mutableLatestByTarget.value ?: emptyMap()
var modified: HashMap<HexKey, GitStatusEvent>? = null var modified: HashMap<HexKey, GitStatusEvent>? = null
for (note in bundle) { for (note in bundle) {
val event = note.event as? GitStatusEvent ?: continue val event = note.event as? GitStatusEvent ?: continue
val target = event.rootEventId() ?: continue val target = event.rootEventId() ?: continue
val map = modified ?: mutableLatestByTarget.value val map = modified ?: snapshot
val current = map[target] val current = map[target]
if (current == null || event.createdAt > current.createdAt) { if (current == null || event.createdAt > current.createdAt) {
if (modified == null) modified = HashMap(mutableLatestByTarget.value) if (modified == null) modified = HashMap(snapshot)
modified[target] = event modified[target] = event
} }
} }
@@ -30,9 +30,7 @@ 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.remember
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
@@ -70,23 +68,16 @@ private fun GitStatusEvent.statusKind(): StatusKind =
else -> StatusKind.OPEN else -> StatusKind.OPEN
} }
@Composable
fun rememberLatestStatus(targetIdHex: String): GitStatusEvent? {
LaunchedEffect(Unit) { GitStatusIndex.startIfNeeded() }
val index by GitStatusIndex.latestByTarget.collectAsStateWithLifecycle()
val latest by remember(targetIdHex) { derivedStateOf { index[targetIdHex] } }
return latest
}
@Composable @Composable
fun GitStatusPill( fun GitStatusPill(
targetIdHex: String, targetIdHex: String,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
defaultIfMissing: StatusKind? = null, defaultIfMissing: StatusKind? = null,
) { ) {
val latest = rememberLatestStatus(targetIdHex) LaunchedEffect(Unit) { GitStatusIndex.startIfNeeded() }
val kind = val index by GitStatusIndex.latestByTarget.collectAsStateWithLifecycle()
latest?.statusKind() ?: defaultIfMissing ?: return val map = index ?: return // hide pill until the initial scan completes — avoids a default-then-real flicker
val kind = map[targetIdHex]?.statusKind() ?: defaultIfMissing ?: return
GitStatusPill(kind, modifier) GitStatusPill(kind, modifier)
} }