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.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,38 +43,40 @@ 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 {
|
||||||
val initial = HashMap<HexKey, GitStatusEvent>()
|
// Subscribe to bundle updates BEFORE the initial scan via onStart, so any
|
||||||
LocalCache.notes.forEach { _, note ->
|
// events that arrive between scan start and collector attach are picked up.
|
||||||
val event = note.event as? GitStatusEvent ?: return@forEach
|
LocalCache.live.newEventBundles
|
||||||
val target = event.rootEventId() ?: return@forEach
|
.onStart {
|
||||||
val current = initial[target]
|
val initial = HashMap<HexKey, GitStatusEvent>()
|
||||||
if (current == null || event.createdAt > current.createdAt) {
|
LocalCache.notes.forEach { _, note ->
|
||||||
initial[target] = event
|
val event = note.event as? GitStatusEvent ?: return@forEach
|
||||||
}
|
val target = event.rootEventId() ?: return@forEach
|
||||||
}
|
val current = initial[target]
|
||||||
mutableLatestByTarget.value = initial
|
if (current == null || event.createdAt > current.createdAt) {
|
||||||
|
initial[target] = event
|
||||||
LocalCache.live.newEventBundles.collect { bundle ->
|
}
|
||||||
processBundle(bundle)
|
}
|
||||||
}
|
mutableLatestByTarget.value = initial
|
||||||
|
}.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)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user