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.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.launch
import java.util.concurrent.atomic.AtomicBoolean
@@ -42,38 +43,40 @@ 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()
private val mutableLatestByTarget = MutableStateFlow<Map<HexKey, GitStatusEvent>?>(null)
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)
}
// 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>()
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
}.collect { bundle -> processBundle(bundle) }
}
}
private fun processBundle(bundle: Set<Note>) {
val snapshot = mutableLatestByTarget.value ?: emptyMap()
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 map = modified ?: snapshot
val current = map[target]
if (current == null || event.createdAt > current.createdAt) {
if (modified == null) modified = HashMap(mutableLatestByTarget.value)
if (modified == null) modified = HashMap(snapshot)
modified[target] = event
}
}
@@ -30,9 +30,7 @@ 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.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -70,23 +68,16 @@ private fun GitStatusEvent.statusKind(): StatusKind =
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
fun GitStatusPill(
targetIdHex: String,
modifier: Modifier = Modifier,
defaultIfMissing: StatusKind? = null,
) {
val latest = rememberLatestStatus(targetIdHex)
val kind =
latest?.statusKind() ?: defaultIfMissing ?: return
LaunchedEffect(Unit) { GitStatusIndex.startIfNeeded() }
val index by GitStatusIndex.latestByTarget.collectAsStateWithLifecycle()
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)
}