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:
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user