Fetch is a button, and moving the checkout builds nothing
Nothing here ever wrote a remote-tracking ref except the fetch inside a
pull, and a pull is reachable only when the branch you are already on is
behind -- so a branch pushed from another machine reached the picker as a
side effect of pulling something else, and a project already up to date
could never be moved onto a new branch at all.
POST /apps/{key}/fetch answers with the whole ref list, so the pickers
repopulate from after the fetch in one round trip. It is the only call
here that asks for --prune: making the picker say what the remote says is
its job, where a pull should change as little as it can. Still not on a
timer and not on opening the sheet, because a fetch mutates the checkout.
Picking a remote-only branch then had to work: `git checkout origin/topic`
detaches HEAD, since git's DWIM fires on the bare name, so the control
that says it is picking a branch produced the state picking a commit is
meant to produce -- and printed its detached-HEAD advice and succeeded.
Moving the checkout no longer builds. A pull is somebody taking new work;
a move is somebody looking, and charging a full build for a look starts a
minute of work an accidental tap cannot call back. What it leaves behind
is a component whose build no longer matches the checkout, so the build
button's word now follows the state: Rebuild only where every component
it covers is known current, Build otherwise.
The sheet loses its Checkout heading and its paragraph, both pickers sit
in a weighted row so a long branch name cannot wrap the label one letter
per line, and the failure text moved below them -- above, it shoved the
pickers down the screen as somebody reached for one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f983db154d
commit
25c069909b
8 files changed
+507
-117
No files matched your search
@@ -1,5 +1,6 @@
|
||||
package com.example.devupdater
|
||||
|
||||
import java.net.HttpURLConnection
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
@@ -200,34 +201,62 @@ data class CheckoutRefs(
|
||||
* the checkout has not fetched is Pull's business, and the card already says when there is any.
|
||||
*/
|
||||
fun checkoutRefs(key: String): CheckoutRefs =
|
||||
requestFromServer("/apps/$key/refs") { connection ->
|
||||
val body = JSONObject(connection.inputStream.bufferedReader().readText())
|
||||
val branches = body.getJSONArray("branches")
|
||||
val commits = body.getJSONArray("commits")
|
||||
CheckoutRefs(
|
||||
branches =
|
||||
(0 until branches.length()).map { i ->
|
||||
val branch = branches.getJSONObject(i)
|
||||
GitBranch(
|
||||
name = branch.getString("name"),
|
||||
current = branch.optBoolean("current", false),
|
||||
remoteOnly = branch.optBoolean("remoteOnly", false),
|
||||
)
|
||||
},
|
||||
commits =
|
||||
(0 until commits.length()).map { i ->
|
||||
val commit = commits.getJSONObject(i)
|
||||
GitCommit(
|
||||
sha = commit.getString("sha"),
|
||||
short = commit.getString("short"),
|
||||
subject = commit.optString("subject"),
|
||||
at = commit.optLong("at", 0),
|
||||
current = commit.optBoolean("current", false),
|
||||
)
|
||||
},
|
||||
head = body.optString("head").takeIf { it.isNotEmpty() },
|
||||
)
|
||||
}
|
||||
requestFromServer("/apps/$key/refs", readBody = ::refs)
|
||||
|
||||
/**
|
||||
* Brings the build machine's checkout up to date with its remote, and answers with the refs that
|
||||
* leaves.
|
||||
*
|
||||
* The one call in this sheet that reaches the network, and the only way a branch pushed from
|
||||
* another machine ever appears in the picker: nothing on the build machine writes a new
|
||||
* remote-tracking ref except the fetch inside a pull, and a pull is only offered when the branch
|
||||
* you are already on is behind. Answering with the refs rather than an acknowledgement is what
|
||||
* makes one round trip enough -- reading them again afterwards would leave a moment where the
|
||||
* button had finished and the list was still the old one.
|
||||
*/
|
||||
fun fetchRefs(key: String): CheckoutRefs =
|
||||
requestFromServer(
|
||||
"/apps/$key/fetch",
|
||||
method = "POST",
|
||||
// Longer than the build machine's own hard stop for a remote
|
||||
// command, so a slow remote is reported in git's words rather
|
||||
// than as this side giving up on a request that is still running.
|
||||
readTimeoutMs = FETCH_READ_TIMEOUT_MS,
|
||||
readBody = ::refs,
|
||||
)
|
||||
|
||||
/** Beyond the 30s the build machine allows a remote command, so its answer arrives first. */
|
||||
private const val FETCH_READ_TIMEOUT_MS = 35000
|
||||
|
||||
/** The one reader for [checkoutRefs] and [fetchRefs], so the two cannot read a checkout apart. */
|
||||
private fun refs(connection: HttpURLConnection): CheckoutRefs {
|
||||
val body = JSONObject(connection.inputStream.bufferedReader().readText())
|
||||
val branches = body.getJSONArray("branches")
|
||||
val commits = body.getJSONArray("commits")
|
||||
return CheckoutRefs(
|
||||
branches =
|
||||
(0 until branches.length()).map { i ->
|
||||
val branch = branches.getJSONObject(i)
|
||||
GitBranch(
|
||||
name = branch.getString("name"),
|
||||
current = branch.optBoolean("current", false),
|
||||
remoteOnly = branch.optBoolean("remoteOnly", false),
|
||||
)
|
||||
},
|
||||
commits =
|
||||
(0 until commits.length()).map { i ->
|
||||
val commit = commits.getJSONObject(i)
|
||||
GitCommit(
|
||||
sha = commit.getString("sha"),
|
||||
short = commit.getString("short"),
|
||||
subject = commit.optString("subject"),
|
||||
at = commit.optLong("at", 0),
|
||||
current = commit.optBoolean("current", false),
|
||||
)
|
||||
},
|
||||
head = body.optString("head").takeIf { it.isNotEmpty() },
|
||||
)
|
||||
}
|
||||
|
||||
/** One component's log, as the modal shows it. */
|
||||
/**
|
||||
|
||||
@@ -84,6 +84,21 @@ data class GitStatus(
|
||||
val root: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* What a build button says for the components it covers.
|
||||
*
|
||||
* **Rebuild** only where every one of them is known to match the checkout, because that is the case
|
||||
* where pressing it does the same work over again. Anything else -- behind the checkout, or never
|
||||
* built, or parked on a commit nobody measured the output against -- has a build to make rather
|
||||
* than one to repeat, and saying "Rebuild" there reads as though the work had already been done.
|
||||
*
|
||||
* One rule at both scales, so a project's button and its components' cannot come to disagree about
|
||||
* what pressing them means. Iris asked for the word to follow the state on 2026-09-02, when moving
|
||||
* a checkout stopped building what it left behind.
|
||||
*/
|
||||
fun buildWord(components: List<ProjectComponent>): String =
|
||||
if (components.all { it.freshness == "current" }) "Rebuild" else "Build"
|
||||
|
||||
// One thing a project produces. [kind] is "apk" (installed on this phone)
|
||||
// or "server" (installed and run on the build machine).
|
||||
//
|
||||
|
||||
@@ -38,6 +38,7 @@ import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedCard
|
||||
@@ -895,11 +896,13 @@ private fun AppListScreen(
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves this project's checkout onto a branch or a commit on the build machine, and builds what
|
||||
* that leaves behind.
|
||||
* Moves this project's checkout onto a branch or a commit on the build machine, and builds
|
||||
* nothing.
|
||||
*
|
||||
* Followed exactly as a pull is, because it is the same act on the same single checkout: one
|
||||
* progress path, reported in the project's own row, rather than a second one to keep in step.
|
||||
* It is over as soon as git is, though, since there is no build behind it -- what the move
|
||||
* leaves behind is a component the card reports as out of date, with its own Build beside it.
|
||||
*/
|
||||
fun startCheckout(entry: ManifestEntry, target: String) {
|
||||
scope.launch {
|
||||
@@ -2035,7 +2038,12 @@ private fun AppCard(
|
||||
// behind is not obvious from here.
|
||||
colors = ActionTone.Caution.colors(),
|
||||
) {
|
||||
Text("Rebuild")
|
||||
// Asked of the components this actually builds:
|
||||
// one with no command of its own has no output to
|
||||
// be current with anything, and counting it would
|
||||
// leave every such project's button saying Build
|
||||
// for ever.
|
||||
Text(buildWord(entry.components.filter { it.hasBuild }))
|
||||
}
|
||||
}
|
||||
if (awaitingApproval) {
|
||||
@@ -2226,6 +2234,12 @@ private fun ProjectSettingsDialog(
|
||||
* that does nothing: not a checkout at all, something already running on it, uncommitted work in
|
||||
* the way, still reading, or the read failed. Those are five different things to do next, and a
|
||||
* disabled dropdown with no sentence beside it is the same picture for all of them.
|
||||
*
|
||||
* Fetch is the third control and the only one that touches the network. It is what puts a branch
|
||||
* pushed from another machine into the picker above it -- without it the list is whatever the last
|
||||
* pull happened to bring in, so a project already up to date on its own branch could never be moved
|
||||
* onto a new one. It stays a press rather than something the sheet does on opening, because a fetch
|
||||
* writes into the checkout and pulls down objects: opening a sheet should not do either.
|
||||
*/
|
||||
@Composable
|
||||
private fun CheckoutSection(
|
||||
@@ -2235,6 +2249,8 @@ private fun CheckoutSection(
|
||||
) {
|
||||
var refs by remember(entry.key) { mutableStateOf<CheckoutRefs?>(null) }
|
||||
var failure by remember(entry.key) { mutableStateOf<String?>(null) }
|
||||
var fetching by remember(entry.key) { mutableStateOf(false) }
|
||||
val scope = rememberCoroutineScope()
|
||||
val git = entry.git
|
||||
|
||||
// Only for a project actually in a checkout; asking git about a
|
||||
@@ -2250,7 +2266,6 @@ private fun CheckoutSection(
|
||||
}
|
||||
}
|
||||
|
||||
SettingsHeading("Checkout")
|
||||
when {
|
||||
git == null -> {
|
||||
SettingsNote("This project is not in a git repository, so there is nothing to move.")
|
||||
@@ -2264,25 +2279,14 @@ private fun CheckoutSection(
|
||||
"or stash on the build machine first."
|
||||
)
|
||||
busy -> SettingsNote("Something is already running on this checkout.")
|
||||
else ->
|
||||
SettingsNote(
|
||||
"Moves the checkout on the build machine and builds what that leaves behind, the " +
|
||||
"same as Pull does. Picking a commit parks it there, on no branch; HEAD is " +
|
||||
"the way back to following the branch."
|
||||
)
|
||||
}
|
||||
|
||||
val enabled = !busy && !git.dirty && refs != null
|
||||
// A fetch replaces the very lists these two are showing, so they are
|
||||
// disabled while it runs rather than left offering the old answer.
|
||||
val enabled = !busy && !git.dirty && refs != null && !fetching
|
||||
val loaded = refs
|
||||
if (loaded == null && failure == null) {
|
||||
SettingsNote("Reading this checkout's branches...")
|
||||
}
|
||||
// The server's own words, so selectable like every other machine
|
||||
// output here -- the fix is on the other machine.
|
||||
failure?.let { OutputText(it, style = MaterialTheme.typography.bodySmall) }
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
||||
Text("Branch", style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f))
|
||||
SettingRow("Branch") {
|
||||
BranchPicker(
|
||||
branches = loaded?.branches.orEmpty(),
|
||||
// The branch line on the card is the same answer, and it is
|
||||
@@ -2293,8 +2297,7 @@ private fun CheckoutSection(
|
||||
onSelect = onCheckout,
|
||||
)
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
||||
Text("Commit", style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f))
|
||||
SettingRow("Commit") {
|
||||
CommitPicker(
|
||||
commits = loaded?.commits.orEmpty(),
|
||||
head = loaded?.head,
|
||||
@@ -2306,6 +2309,81 @@ private fun CheckoutSection(
|
||||
onSelect = onCheckout,
|
||||
)
|
||||
}
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.End,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
TextButton(
|
||||
onClick = {
|
||||
fetching = true
|
||||
failure = null
|
||||
scope.launch {
|
||||
try {
|
||||
refs = withContext(Dispatchers.IO) { fetchRefs(entry.key) }
|
||||
} catch (e: DownloadServerException) {
|
||||
failure = e.message ?: "the fetch failed"
|
||||
} finally {
|
||||
fetching = false
|
||||
}
|
||||
}
|
||||
},
|
||||
// Not while the checkout is being worked on: a pull is
|
||||
// writing the same refs, and git would refuse one of the two
|
||||
// for a reason nobody pressed anything to hear. Uncommitted
|
||||
// work is deliberately *not* a reason -- a fetch touches no
|
||||
// file in the working tree, so this is the one control in
|
||||
// here that still does its job on a dirty checkout.
|
||||
enabled = !busy && !fetching,
|
||||
colors = ActionTone.Primary.colors(),
|
||||
) {
|
||||
if (fetching) {
|
||||
// The button's own content colour, which while it is
|
||||
// running is the disabled one -- a full-strength mark
|
||||
// beside a dimmed label reads as two things happening
|
||||
// rather than as one button that is busy.
|
||||
Working(color = LocalContentColor.current)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
}
|
||||
Text("Fetch")
|
||||
}
|
||||
}
|
||||
// Both of these belong *below* the controls rather than above them.
|
||||
// Above, each one appears and disappears in the middle of the sheet
|
||||
// and shoves the two pickers down the screen as it does -- so the row
|
||||
// somebody is reaching for moves while they reach, and the failure
|
||||
// ends up nowhere near the button that produced it.
|
||||
if (loaded == null && failure == null) {
|
||||
SettingsNote("Reading this checkout's branches...")
|
||||
}
|
||||
// The server's own words, so selectable like every other machine
|
||||
// output here -- the fix is on the other machine.
|
||||
failure?.let { OutputText(it, style = MaterialTheme.typography.bodySmall) }
|
||||
}
|
||||
|
||||
/**
|
||||
* One setting: what it is on the left, the control for it on the right.
|
||||
*
|
||||
* The two get [Modifier.weight] rather than the control being left to take whatever width it likes,
|
||||
* because a picker's label is a branch name and those run long: unweighted, the pill grows to fit
|
||||
* `second-branch-from-elsewhere` and squeezes "Branch" down to a column three characters wide,
|
||||
* which then wraps one letter per line. Both sides truncate instead -- the control at its own end,
|
||||
* since a pill that has to be cut is still recognisably a control, and the name it is showing is
|
||||
* the thing the reader can open it to see in full.
|
||||
*/
|
||||
@Composable
|
||||
private fun SettingRow(label: String, control: @Composable () -> Unit) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
label,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Box(Modifier.weight(2f), contentAlignment = Alignment.CenterEnd) { control() }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -2610,7 +2688,7 @@ private fun ComponentBuildButton(
|
||||
enabled = !state.busy && !projectState.busy,
|
||||
colors = ActionTone.Caution.colors(),
|
||||
) {
|
||||
Text("Rebuild")
|
||||
Text(buildWord(listOf(component)))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,8 +2,10 @@ package com.example.devupdater
|
||||
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ProgressIndicatorDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
@@ -15,11 +17,15 @@ import androidx.compose.ui.unit.dp
|
||||
*
|
||||
* One composable rather than a size and a stroke width repeated per site, so every one of them
|
||||
* reads as the same mark.
|
||||
*
|
||||
* [color] is for the one inside a button, where the mark belongs to that button's label and taking
|
||||
* the scheme's own accent instead would read as a second thing going on beside it.
|
||||
*/
|
||||
@Composable
|
||||
fun Working(modifier: Modifier = Modifier) {
|
||||
fun Working(modifier: Modifier = Modifier, color: Color = ProgressIndicatorDefaults.circularColor) {
|
||||
CircularProgressIndicator(
|
||||
modifier = modifier.size(12.dp),
|
||||
color = color,
|
||||
strokeWidth = 1.5.dp,
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user