Follow a card that moved, and wait for the answer a move invalidated
Switching a branch makes the build out of date, which moves the card out of "Up to date" and somewhere else entirely on a list of ten. The list now scrolls to it -- only when it is actually off screen, since moving something the reader can already see is a jump nobody asked for. The two frames before reading the position are load-bearing: the entry has just been applied, so the first frame carries the composition and the second reports the layout it produced, and reading sooner gives where the card was. The other half is why a switched branch showed no commits and no Pull. The build machine drops what it knew about that checkout's remote and asks again, but the answer lands after the response -- and startCheckout read the entry exactly once, so the card kept a pending check for ever. It waits on awaitCheck now, the same poll the card's own Refresh uses. Reproduced against a clone of ai-app before changing anything: immediately after the move newCommits false with checkPending true, three seconds later newCommits true, and nothing asking again in between. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
af605211e2
commit
07639ed14d
2 files changed
+184
-78
No files matched your search
@@ -51,15 +51,19 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.withFrameNanos
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.layout.positionInParent
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
@@ -77,6 +81,7 @@ import com.journeyapps.barcodescanner.ScanContract
|
||||
import com.journeyapps.barcodescanner.ScanIntentResult
|
||||
import com.journeyapps.barcodescanner.ScanOptions
|
||||
import java.io.File
|
||||
import kotlin.math.roundToInt
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -530,6 +535,10 @@ private fun AppListScreen(
|
||||
// away. One slot rather than one per card: it is a modal, so only one
|
||||
// can be open, and the entry in it says which card asked.
|
||||
var forcePull by remember { mutableStateOf<ManifestEntry?>(null) }
|
||||
val listScroll = rememberScrollState()
|
||||
// Where each card is in that scrolling column, filled in as they are
|
||||
// laid out. Only [followCard] reads it.
|
||||
val cardPlaces = remember { mutableStateMapOf<String, CardPlace>() }
|
||||
// Which component of which project has a service action in flight.
|
||||
// Per project, because that is the granularity of a card, and the row
|
||||
// that is busy is the one that shows it.
|
||||
@@ -727,6 +736,33 @@ private fun AppListScreen(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Puts a card back on screen after something moved it.
|
||||
*
|
||||
* Switching a project's branch makes its build out of date, which moves the card out of "Up to
|
||||
* date" and into the group above -- somewhere else entirely on a list of ten. The reader
|
||||
* pressed something on that card and is owed the answer, so the list follows it. Iris asked for
|
||||
* this on 2026-09-02, having watched a card she had just acted on disappear upwards.
|
||||
*
|
||||
* Only when it is actually off screen: a card that moved a little, or not at all, is left where
|
||||
* it is, because scrolling something the reader can already see is a jump they did not ask for.
|
||||
*
|
||||
* The two frames are the load-bearing part. The entry has just been applied, so the card is
|
||||
* about to be composed in its new place; the first frame carries that composition and the
|
||||
* second reports the layout it produced. Reading the position before both have passed gives
|
||||
* where the card *was*.
|
||||
*/
|
||||
suspend fun followCard(key: String) {
|
||||
withFrameNanos {}
|
||||
withFrameNanos {}
|
||||
val place = cardPlaces[key] ?: return
|
||||
val viewTop = listScroll.value
|
||||
if (place.top >= viewTop && place.top + place.height <= viewTop + listScroll.viewportSize) {
|
||||
return
|
||||
}
|
||||
listScroll.animateScrollTo(place.top.coerceIn(0, listScroll.maxValue))
|
||||
}
|
||||
|
||||
/**
|
||||
* Take the server's view of *one* card and leave every other card exactly as it was, returning
|
||||
* the entry that landed.
|
||||
@@ -1077,7 +1113,27 @@ private fun AppListScreen(
|
||||
entry,
|
||||
start = { checkoutTarget(entry.key, target) },
|
||||
progress = { ProjectState.Working("Moving the checkout", it) },
|
||||
)
|
||||
) ?: return@launch
|
||||
// The card has just moved, most likely into the group above:
|
||||
// its build is now behind the checkout. Take the reader with
|
||||
// it, before waiting on the remote below, so the list follows
|
||||
// the press rather than a round trip.
|
||||
followCard(entry.key)
|
||||
// And then the answer that the move invalidated. The build
|
||||
// machine drops what it knew about this checkout's remote and
|
||||
// asks again -- the last answer was about the branch being
|
||||
// left -- but that lands *after* the response, so without
|
||||
// this the card keeps a pending check for ever: no commit
|
||||
// count, and Pull disabled, on a branch with commits waiting.
|
||||
// Reading it back can move the card again, hence the second
|
||||
// follow.
|
||||
try {
|
||||
awaitCheck(entry.key)
|
||||
} catch (e: DownloadServerException) {
|
||||
setProject(entry.key, failure(e)?.let(ProjectState::Error))
|
||||
return@launch
|
||||
}
|
||||
followCard(entry.key)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1495,7 +1551,7 @@ private fun AppListScreen(
|
||||
onRefresh = ::refreshByPull,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState())) {
|
||||
Column(Modifier.fillMaxSize().verticalScroll(listScroll)) {
|
||||
val entries = state.manifest.entries
|
||||
if (entries.isEmpty()) {
|
||||
Text(
|
||||
@@ -1551,86 +1607,110 @@ private fun AppListScreen(
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
group.forEach { entry ->
|
||||
AppCard(
|
||||
entry = entry,
|
||||
installedTimes = installedTimes[entry.key] ?: emptyMap(),
|
||||
chosenVariants = chosenVariants[entry.key] ?: emptyMap(),
|
||||
installedSizes = installedSizes[entry.key] ?: emptyMap(),
|
||||
projectState = projectStates[entry.key],
|
||||
componentStates = componentStates[entry.key] ?: emptyMap(),
|
||||
onUpdate = { updated, component ->
|
||||
startUpdate(updated, component)
|
||||
},
|
||||
onComponentBuild = { built, component ->
|
||||
startComponentBuild(built, component)
|
||||
},
|
||||
onPull = { startPull(entry) },
|
||||
onProjectUpdate = { startProjectUpdate(entry) },
|
||||
onRefresh = { refreshOne(entry) },
|
||||
onSettings = { gitIpv4 ->
|
||||
manage(entry) { setAppSettings(entry.key, gitIpv4) }
|
||||
},
|
||||
onCheckout = { target -> startCheckout(entry, target) },
|
||||
// Only this component's slot is
|
||||
// cleared: the download it was
|
||||
// holding is dropped, and every
|
||||
// other card is left alone.
|
||||
onCancelInstall = { component ->
|
||||
setComponent(entry.key, component, null)
|
||||
},
|
||||
onApprove = {
|
||||
manage(entry) { approveDeclaration(entry.key) }
|
||||
},
|
||||
onRemove = {
|
||||
forgetVariants(context, entry.key)
|
||||
manage(entry, removes = true) { removeApp(entry.key) }
|
||||
},
|
||||
// Written here rather than sent to the server:
|
||||
// it is this device's preference. Bumping the
|
||||
// reload token is what redraws the card with
|
||||
// the new choice and the mtime that goes with
|
||||
// it.
|
||||
onSelectVariant = { component, variant ->
|
||||
chooseVariant(
|
||||
context,
|
||||
entry.key,
|
||||
component,
|
||||
variant?.path,
|
||||
)
|
||||
val forProject = chosenVariants[entry.key] ?: emptyMap()
|
||||
Box(
|
||||
// Where this card sits in the scrolling
|
||||
// column, so an action that moves it
|
||||
// between groups can take the reader
|
||||
// with it. Recorded per card rather
|
||||
// than worked out from the order,
|
||||
// because the cards are different
|
||||
// heights and the headings count too.
|
||||
Modifier.onGloballyPositioned { placed ->
|
||||
cardPlaces[entry.key] =
|
||||
CardPlace(
|
||||
placed.positionInParent().y.roundToInt(),
|
||||
placed.size.height,
|
||||
)
|
||||
}
|
||||
) {
|
||||
AppCard(
|
||||
entry = entry,
|
||||
installedTimes =
|
||||
installedTimes[entry.key] ?: emptyMap(),
|
||||
chosenVariants =
|
||||
chosenVariants +
|
||||
(entry.key to
|
||||
when (variant) {
|
||||
null -> forProject - component
|
||||
else ->
|
||||
forProject +
|
||||
(component to variant.path)
|
||||
})
|
||||
},
|
||||
// The build machine's, not this
|
||||
// device's: a mode decides what
|
||||
// gets built there, so it is a
|
||||
// round trip and a refetch rather
|
||||
// than a preference written here.
|
||||
onComponentSettings = { component, mode, strip ->
|
||||
manageComponent(entry, component) {
|
||||
setComponentSettings(
|
||||
chosenVariants[entry.key] ?: emptyMap(),
|
||||
installedSizes =
|
||||
installedSizes[entry.key] ?: emptyMap(),
|
||||
projectState = projectStates[entry.key],
|
||||
componentStates =
|
||||
componentStates[entry.key] ?: emptyMap(),
|
||||
onUpdate = { updated, component ->
|
||||
startUpdate(updated, component)
|
||||
},
|
||||
onComponentBuild = { built, component ->
|
||||
startComponentBuild(built, component)
|
||||
},
|
||||
onPull = { startPull(entry) },
|
||||
onProjectUpdate = { startProjectUpdate(entry) },
|
||||
onRefresh = { refreshOne(entry) },
|
||||
onSettings = { gitIpv4 ->
|
||||
manage(entry) { setAppSettings(entry.key, gitIpv4) }
|
||||
},
|
||||
onCheckout = { target -> startCheckout(entry, target) },
|
||||
// Only this component's slot is
|
||||
// cleared: the download it was
|
||||
// holding is dropped, and every
|
||||
// other card is left alone.
|
||||
onCancelInstall = { component ->
|
||||
setComponent(entry.key, component, null)
|
||||
},
|
||||
onApprove = {
|
||||
manage(entry) { approveDeclaration(entry.key) }
|
||||
},
|
||||
onRemove = {
|
||||
forgetVariants(context, entry.key)
|
||||
manage(entry, removes = true) {
|
||||
removeApp(entry.key)
|
||||
}
|
||||
},
|
||||
// Written here rather than sent to the server:
|
||||
// it is this device's preference. Bumping the
|
||||
// reload token is what redraws the card with
|
||||
// the new choice and the mtime that goes with
|
||||
// it.
|
||||
onSelectVariant = { component, variant ->
|
||||
chooseVariant(
|
||||
context,
|
||||
entry.key,
|
||||
component,
|
||||
mode,
|
||||
strip,
|
||||
variant?.path,
|
||||
)
|
||||
}
|
||||
},
|
||||
onEnroll = { component ->
|
||||
openEnrollmentLink(entry, component)
|
||||
},
|
||||
serviceBusy = serviceBusy[entry.key],
|
||||
onServiceAction = { component, action, purge ->
|
||||
runServiceAction(entry, component, action, purge)
|
||||
},
|
||||
)
|
||||
val forProject =
|
||||
chosenVariants[entry.key] ?: emptyMap()
|
||||
chosenVariants =
|
||||
chosenVariants +
|
||||
(entry.key to
|
||||
when (variant) {
|
||||
null -> forProject - component
|
||||
else ->
|
||||
forProject +
|
||||
(component to variant.path)
|
||||
})
|
||||
},
|
||||
// The build machine's, not this
|
||||
// device's: a mode decides what
|
||||
// gets built there, so it is a
|
||||
// round trip and a refetch rather
|
||||
// than a preference written here.
|
||||
onComponentSettings = { component, mode, strip ->
|
||||
manageComponent(entry, component) {
|
||||
setComponentSettings(
|
||||
entry.key,
|
||||
component,
|
||||
mode,
|
||||
strip,
|
||||
)
|
||||
}
|
||||
},
|
||||
onEnroll = { component ->
|
||||
openEnrollmentLink(entry, component)
|
||||
},
|
||||
serviceBusy = serviceBusy[entry.key],
|
||||
onServiceAction = { component, action, purge ->
|
||||
runServiceAction(entry, component, action, purge)
|
||||
},
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
}
|
||||
}
|
||||
@@ -2638,6 +2718,9 @@ private fun CommitPicker(
|
||||
}
|
||||
}
|
||||
|
||||
/** Where one card sits in the scrolling list, in the column's own coordinates. */
|
||||
private data class CardPlace(val top: Int, val height: Int)
|
||||
|
||||
/** What git reports as the branch when no branch is checked out. */
|
||||
private const val DETACHED_HEAD = "HEAD"
|
||||
|
||||
|
||||
Reference in new issue
Block a user