Bind Run now, both evictions, and the library build on both modalities
Build Debug APK / build (push) Successful in 5m21s
Build Debug APK / build (push) Successful in 5m21s
The last controls anywhere that showed the click rather than the job. Each already started one with a nameable (kind, subject); each kept a boolean or a Pending variant beside it that only its own screen could see. Settings' "Run now" reads BackgroundSync/None; "Delete Locally" reads Eviction/Document and "Reclaim…" Eviction/DocumentType — distinct subjects on purpose, so a subtree eviction and a type-scoped reclaim neither dedup against nor mask each other. Music's "Build library" reads LibraryBuild/DocumentType. Both keep Evaluating, which is genuinely the screen's: gathering the evidence for the confirmation dialog is not work another node can see. FFI: JobSubjectRef gains None, since "no per-thing widget speaks for whole-node work" stopped being true the moment Run now bound to it, and its DocumentType variant now carries the enum rather than a snake_case name — a frontend that had to produce that name would be a mirror of DocumentType::type_name, which is what job_match_key exists to avoid. Both are covered by the drift guard in free_fns' tests. Also fixes rust/tui-app's EnsembleStatus test fixture, which had not been updated for the three epoch fields and was failing the workspace build; the values are the zeros every other field in that fixture already uses.
This commit is contained in:
@@ -456,16 +456,12 @@ Model and dedup design: `docs/adr/0011-jobs-first-class-model.md`; product desig
|
||||
one `ByteArray`.
|
||||
- The Jobs screen should be aware of when a long-running sync was initiated by another node, or syncs
|
||||
that were safely/temporarily interrupted because a device went off wifi etc.
|
||||
- **Half the job-starting controls are still screen-local flags, and the same half on both
|
||||
modalities.** The document detail and Ensemble screens' pull/push/sync buttons and the
|
||||
Documents screen's imports are bound — see `docs/sync-management.md`'s "Widgets reflect job
|
||||
state in place". Everything else still shows the *click*: Settings' "Run now"
|
||||
(`BackgroundSync`/`None` on both `settings/sync.rs`'s `RunNowState` and
|
||||
`SyncSettingsScreen.kt`'s `runNowPending`), "Delete Locally" and "Reclaim…"
|
||||
(`Eviction`/`Document` and `Eviction`/`DocumentType`), and the music library build
|
||||
(`LibraryBuild`/`DocumentType`). Each already starts a job with a nameable `(kind, subject)`,
|
||||
so each is a small change; what makes them worth doing is that all four are exactly the work
|
||||
the scheduler or another node can start behind the user's back.
|
||||
- **Purge is the one long-running action that is not a Job at all.** Every other control on the
|
||||
document detail screen starts one; "Purge from ensemble…" writes tombstones and broadcasts a
|
||||
delete intent behind a plain `PurgeState::Pending` boolean, on both modalities. `JobKind::Purge`
|
||||
exists and nothing constructs it. Until it does there is nothing for the button to bind to, no
|
||||
Jobs-screen row for a purge that is still propagating, and no dedup against a second click on
|
||||
another screen — so this is a `run_as_job` wrap first, and a widget binding after.
|
||||
- **Android's media library screens still show nothing while an import runs**, where the
|
||||
desktop's now carry `components::imports_in_progress`. Comics/PDF/Pictures/Music import one
|
||||
file at a time and do report progress into a job row, so there is something to bind to, but
|
||||
|
||||
+24
-13
@@ -308,7 +308,9 @@ fun DocumentDetailScreen(documentId: String, onBack: () -> Unit, modifier: Modif
|
||||
// Execute the eviction plan currently shown in the confirmation dialog. The buried
|
||||
// Settings override is re-checked at execute time, not trusted from the click.
|
||||
fun confirmDeleteLocally(allowUnverified: Boolean) {
|
||||
deleteLocallyState = EvictionFlowState.Pending
|
||||
// Nothing here says "running" — the `Eviction` job does, and the Danger Zone reads it.
|
||||
// This only takes the dialog down.
|
||||
deleteLocallyState = EvictionFlowState.Idle
|
||||
scope.launch {
|
||||
val result =
|
||||
withContext(Dispatchers.IO) {
|
||||
@@ -409,6 +411,7 @@ fun DocumentDetailScreen(documentId: String, onBack: () -> Unit, modifier: Modif
|
||||
}
|
||||
}
|
||||
},
|
||||
documentId = documentId,
|
||||
deleteLocallyState = deleteLocallyState,
|
||||
onDeleteLocallyClick = { openDeleteLocallyConfirm() },
|
||||
)
|
||||
@@ -881,12 +884,19 @@ private fun RemoteOverrideControl(
|
||||
|
||||
@Composable
|
||||
private fun DangerZoneCard(
|
||||
documentId: String,
|
||||
purging: Boolean,
|
||||
error: String?,
|
||||
onPurgeClick: () -> Unit,
|
||||
deleteLocallyState: EvictionFlowState?,
|
||||
onDeleteLocallyClick: () -> Unit,
|
||||
) {
|
||||
// The eviction itself is a job, so it reports whoever started it — this screen before it was
|
||||
// backgrounded, or the Sync policy screen's type-scoped "Reclaim…" — and survives leaving and
|
||||
// re-entering. `Evaluating` stays this screen's: gathering the evidence for the dialog is not
|
||||
// work anyone else can see. `JobSubjectRef.Document`, not `DocumentType`: this is one subtree.
|
||||
val evicting =
|
||||
rememberJobControl(JobKinds.EVICTION, JobSubjectRef.Document(documentId = documentId))
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
@@ -919,24 +929,23 @@ private fun DangerZoneCard(
|
||||
Text("Purge")
|
||||
}
|
||||
}
|
||||
val deleteLocallyBusy =
|
||||
deleteLocallyState is EvictionFlowState.Evaluating ||
|
||||
deleteLocallyState is EvictionFlowState.Pending
|
||||
OutlinedButton(onClick = onDeleteLocallyClick, enabled = !deleteLocallyBusy) {
|
||||
if (deleteLocallyBusy) {
|
||||
val evaluating = deleteLocallyState is EvictionFlowState.Evaluating
|
||||
OutlinedButton(
|
||||
onClick = onDeleteLocallyClick,
|
||||
enabled = !evaluating && !evicting.busy,
|
||||
) {
|
||||
if (evaluating) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(16.dp),
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
if (deleteLocallyState is EvictionFlowState.Evaluating) "Checking…"
|
||||
else "Deleting…"
|
||||
)
|
||||
Text("Checking…")
|
||||
} else {
|
||||
Text("Delete Locally")
|
||||
}
|
||||
}
|
||||
JobActivity(control = evicting, fallback = "Deleting…")
|
||||
}
|
||||
if (!error.isNullOrEmpty()) {
|
||||
Text(
|
||||
@@ -945,16 +954,18 @@ private fun DangerZoneCard(
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
when (deleteLocallyState) {
|
||||
// Hidden while the job runs, so a finished deletion's summary can't be read as this
|
||||
// one's.
|
||||
when (val outcome = if (evicting.busy) null else deleteLocallyState) {
|
||||
is EvictionFlowState.Done ->
|
||||
Text(
|
||||
text = deleteLocallyState.message,
|
||||
text = outcome.message,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
is EvictionFlowState.Failed ->
|
||||
Text(
|
||||
text = "Delete locally failed: ${deleteLocallyState.message}",
|
||||
text = "Delete locally failed: ${outcome.message}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
|
||||
@@ -38,9 +38,12 @@ private const val PERCENT_SCALE = 100f
|
||||
* comment on `job_match_key`.
|
||||
*/
|
||||
object JobKinds {
|
||||
const val BACKGROUND_SYNC = "background_sync"
|
||||
const val CATALOGUE_SYNC = "catalogue_sync"
|
||||
const val CONTENT_FETCH = "content_fetch"
|
||||
const val EVICTION = "eviction"
|
||||
const val IMPORT = "import"
|
||||
const val LIBRARY_BUILD = "library_build"
|
||||
const val PUSH = "push"
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,8 @@ import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.gregshuflin.synchronicity.NativeLib
|
||||
import com.gregshuflin.synchronicity.OnStoreChange
|
||||
import com.gregshuflin.synchronicity.jobs.JobKinds
|
||||
import com.gregshuflin.synchronicity.jobs.rememberJobControl
|
||||
import com.gregshuflin.synchronicity.podcast.AudioPlayerProvider
|
||||
import com.gregshuflin.synchronicity.podcast.PlaybackSource
|
||||
import com.gregshuflin.synchronicity.ui.Adjacency
|
||||
@@ -48,6 +50,8 @@ import com.gregshuflin.synchronicity.ui.rememberLibraryEmptyCopy
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import uniffi.synchronicity.ChangeChannel
|
||||
import uniffi.synchronicity.DocumentType
|
||||
import uniffi.synchronicity.JobSubjectRef
|
||||
import uniffi.synchronicity.LibraryEntry
|
||||
import uniffi.synchronicity.LibraryEntryKind
|
||||
import uniffi.synchronicity.MusicTreeItem
|
||||
@@ -134,7 +138,9 @@ fun MusicScreen(onBack: () -> Unit, modifier: Modifier = Modifier) {
|
||||
// song's Next means.
|
||||
var playbackContext by remember { mutableStateOf<List<MusicTrack>>(emptyList()) }
|
||||
var isLoadingAudio by remember { mutableStateOf(false) }
|
||||
var building by remember { mutableStateOf(false) }
|
||||
// Only the milliseconds between the tap and the `LibraryBuild` job row existing; after that
|
||||
// the job is the whole truth. See `jobs/JobBinding.kt`.
|
||||
var buildStarting by remember { mutableStateOf(false) }
|
||||
var reloadKey by remember { mutableIntStateOf(0) }
|
||||
// A document arriving from another node, or an import finishing here, belongs in the
|
||||
// list on screen — not in the one the user will see next time they open this. The
|
||||
@@ -215,15 +221,17 @@ fun MusicScreen(onBack: () -> Unit, modifier: Modifier = Modifier) {
|
||||
// pointing at what used to be there. Mirrors the desktop's own post-build reset.
|
||||
fun rebuildLibrary() {
|
||||
scope.launchReportingFailure(snackbar, TAG, "build the music library") {
|
||||
building = true
|
||||
buildStarting = true
|
||||
// In the `finally` so a failure — including "a build is already running" — puts the
|
||||
// action back rather than leaving a spinner that never resolves. The flag is set
|
||||
// inside the block, not around it, because a retry re-enters this same block.
|
||||
// inside the block, not around it, because a retry re-enters this same block. It
|
||||
// covers only the gap before the `LibraryBuild` job row exists; after that the job is
|
||||
// what the action reads.
|
||||
val summary =
|
||||
try {
|
||||
repository.buildLibrary().bind()
|
||||
} finally {
|
||||
building = false
|
||||
buildStarting = false
|
||||
}
|
||||
libraryPath = emptyList()
|
||||
art.clear()
|
||||
@@ -245,7 +253,9 @@ fun MusicScreen(onBack: () -> Unit, modifier: Modifier = Modifier) {
|
||||
title = "Music",
|
||||
onBack = onBack,
|
||||
modifier = modifier,
|
||||
actions = { RebuildLibraryAction(building = building, onRebuild = { rebuildLibrary() }) },
|
||||
actions = {
|
||||
RebuildLibraryAction(starting = buildStarting, onRebuild = { rebuildLibrary() })
|
||||
},
|
||||
search = {
|
||||
SynSearchBar(
|
||||
query = searchQuery,
|
||||
@@ -366,12 +376,22 @@ internal fun adjacencyOf(track: MusicTrack?, context: List<MusicTrack>): Adjacen
|
||||
/**
|
||||
* The header's rebuild action: the Library tab is derived from the tracks' tags, and nothing
|
||||
* derives it on its own, so this is where a phone-only node turns imported files into an
|
||||
* Artist/Album view. The work is a tracked Job on the Rust side and the screen stays live while it
|
||||
* runs; the spinner here only says that this screen started one.
|
||||
* Artist/Album view.
|
||||
*
|
||||
* Bound to the `LibraryBuild` job for this agent's type rather than to a "did this screen tap it"
|
||||
* flag, so a build started from the desktop's copy of this button — or by this screen before the
|
||||
* app was last backgrounded — reads here as what it is. [starting] covers only the milliseconds
|
||||
* before the job row exists.
|
||||
*/
|
||||
@Composable
|
||||
private fun RebuildLibraryAction(building: Boolean, onRebuild: () -> Unit) {
|
||||
if (building) {
|
||||
private fun RebuildLibraryAction(starting: Boolean, onRebuild: () -> Unit) {
|
||||
val control =
|
||||
rememberJobControl(
|
||||
JobKinds.LIBRARY_BUILD,
|
||||
JobSubjectRef.DocumentType(DocumentType.MUSIC_TRACK),
|
||||
starting = starting,
|
||||
)
|
||||
if (control.busy) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(REBUILD_SPINNER_SIZE))
|
||||
} else {
|
||||
IconButton(onClick = onRebuild) {
|
||||
|
||||
+18
-10
@@ -14,10 +14,14 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.gregshuflin.synchronicity.jobs.JobActivity
|
||||
import com.gregshuflin.synchronicity.jobs.JobKinds
|
||||
import com.gregshuflin.synchronicity.jobs.rememberJobControl
|
||||
import com.gregshuflin.synchronicity.ui.EvictionFlowState
|
||||
import uniffi.synchronicity.ContentRow
|
||||
import uniffi.synchronicity.DocumentSyncPolicy
|
||||
import uniffi.synchronicity.DocumentType
|
||||
import uniffi.synchronicity.JobSubjectRef
|
||||
import uniffi.synchronicity.RemoteDocumentSyncPolicy
|
||||
import uniffi.synchronicity.SyncPolicy
|
||||
import uniffi.synchronicity.SyncPolicyOption
|
||||
@@ -56,6 +60,13 @@ fun SyncPolicyControl(
|
||||
onReclaim: (() -> Unit)? = null,
|
||||
reclaimStatus: EvictionFlowState? = null,
|
||||
) {
|
||||
// A running eviction for this type outranks whatever this screen's own flow last said, and is
|
||||
// reported even when there is no flow at all: it may have been started from the document
|
||||
// detail screen's "Delete Locally", or here before the screen was last backgrounded.
|
||||
// `JobSubjectRef.DocumentType` rather than `Document`, so a type-scoped reclaim dedups against
|
||||
// itself rather than colliding with a per-document eviction of something in that type.
|
||||
val reclaiming =
|
||||
rememberJobControl(JobKinds.EVICTION, JobSubjectRef.DocumentType(entry.docType))
|
||||
Column(modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(
|
||||
text = entry.displayName,
|
||||
@@ -96,37 +107,34 @@ fun SyncPolicyControl(
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
if (onReclaim != null && reclaimStatus == null) {
|
||||
if (onReclaim != null && reclaimStatus == null && !reclaiming.busy) {
|
||||
TextButton(onClick = onReclaim) { Text("Reclaim…") }
|
||||
}
|
||||
JobActivity(control = reclaiming, fallback = "Reclaiming…")
|
||||
}
|
||||
}
|
||||
when (reclaimStatus) {
|
||||
// Hidden while the job runs, so a finished reclaim's summary can't be read as this one's.
|
||||
when (val outcome = if (reclaiming.busy) null else reclaimStatus) {
|
||||
is EvictionFlowState.Evaluating ->
|
||||
Text(
|
||||
"Checking…",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
is EvictionFlowState.Pending ->
|
||||
Text(
|
||||
"Reclaiming…",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
is EvictionFlowState.Done ->
|
||||
Text(
|
||||
reclaimStatus.message,
|
||||
outcome.message,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
is EvictionFlowState.Failed ->
|
||||
Text(
|
||||
"Reclaim failed: ${reclaimStatus.message}",
|
||||
"Reclaim failed: ${outcome.message}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
is EvictionFlowState.Confirming,
|
||||
is EvictionFlowState.Idle,
|
||||
null -> {}
|
||||
}
|
||||
}
|
||||
|
||||
+34
-15
@@ -32,6 +32,9 @@ import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.gregshuflin.synchronicity.NativeLib
|
||||
import com.gregshuflin.synchronicity.OnStoreChange
|
||||
import com.gregshuflin.synchronicity.jobs.JobActivity
|
||||
import com.gregshuflin.synchronicity.jobs.JobKinds
|
||||
import com.gregshuflin.synchronicity.jobs.rememberJobControl
|
||||
import com.gregshuflin.synchronicity.scheduler.SchedulerWorker
|
||||
import com.gregshuflin.synchronicity.ui.EvictionConfirmDialog
|
||||
import com.gregshuflin.synchronicity.ui.EvictionFlowState
|
||||
@@ -45,6 +48,7 @@ import uniffi.synchronicity.ChangeChannel
|
||||
import uniffi.synchronicity.DocumentSyncPolicy
|
||||
import uniffi.synchronicity.DocumentType
|
||||
import uniffi.synchronicity.EnsembleMemberInfo
|
||||
import uniffi.synchronicity.JobSubjectRef
|
||||
import uniffi.synchronicity.RemoteDocumentSyncPolicy
|
||||
import uniffi.synchronicity.SchedulerSettings
|
||||
import uniffi.synchronicity.remoteConfigUnreachable
|
||||
@@ -100,7 +104,10 @@ fun SyncSettingsScreen(
|
||||
)
|
||||
)
|
||||
}
|
||||
var runNowPending by remember { mutableStateOf(false) }
|
||||
// Only the milliseconds between the tap and the `BackgroundSync` job row existing; after that
|
||||
// the job is the whole truth, including for a pass the periodic tick or a peer's `TriggerSync`
|
||||
// started. See `jobs/JobBinding.kt`.
|
||||
var runNowStarting by remember { mutableStateOf(false) }
|
||||
var runNowStatus by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
var otherNodes by remember { mutableStateOf<List<EnsembleMemberInfo>>(emptyList()) }
|
||||
@@ -191,7 +198,9 @@ fun SyncSettingsScreen(
|
||||
|
||||
fun confirmReclaim(allowUnverified: Boolean) {
|
||||
val docType = reclaimFlow?.docType ?: return
|
||||
reclaimFlow = ReclaimFlow(docType, EvictionFlowState.Pending)
|
||||
// Nothing here says "running" — the `Eviction` job does, and `SyncPolicyControl` reads it.
|
||||
// This only takes the dialog down.
|
||||
reclaimFlow = ReclaimFlow(docType, EvictionFlowState.Idle)
|
||||
scope.launch {
|
||||
val result =
|
||||
withContext(Dispatchers.IO) {
|
||||
@@ -222,11 +231,13 @@ fun SyncSettingsScreen(
|
||||
}
|
||||
|
||||
fun runSchedulerNow() {
|
||||
runNowPending = true
|
||||
runNowStarting = true
|
||||
// The previous run's summary, cleared now that a new one is starting. Nothing here says
|
||||
// "running" — `runSchedulerPasses` opens the job, and the button reads that.
|
||||
runNowStatus = null
|
||||
scope.launch {
|
||||
val result = withContext(Dispatchers.IO) { nativeLib.settings.runSchedulerPasses() }
|
||||
runNowPending = false
|
||||
runNowStarting = false
|
||||
runNowStatus =
|
||||
result.fold(
|
||||
onSuccess = { summary ->
|
||||
@@ -283,7 +294,7 @@ fun SyncSettingsScreen(
|
||||
// same as desktop hides it while editing a remote node.
|
||||
BackgroundSyncSection(
|
||||
settings = schedulerSettings,
|
||||
runNowPending = runNowPending,
|
||||
runNowStarting = runNowStarting,
|
||||
runNowStatus = runNowStatus,
|
||||
onSettingsChange = { updateSchedulerSettings(it) },
|
||||
onRunNow = { runSchedulerNow() },
|
||||
@@ -405,11 +416,17 @@ private val INTERVAL_PRESETS =
|
||||
@Composable
|
||||
private fun BackgroundSyncSection(
|
||||
settings: SchedulerSettings,
|
||||
runNowPending: Boolean,
|
||||
runNowStarting: Boolean,
|
||||
runNowStatus: String?,
|
||||
onSettingsChange: (SchedulerSettings) -> Unit,
|
||||
onRunNow: () -> Unit,
|
||||
) {
|
||||
// The job decides, not this screen: a pass may already be under way because the periodic tick
|
||||
// fired or a peer sent `TriggerSync`. `JobSubjectRef.None` because a full pass is about the
|
||||
// node, not one peer or document — which is also what makes those three dedup against each
|
||||
// other.
|
||||
val control =
|
||||
rememberJobControl(JobKinds.BACKGROUND_SYNC, JobSubjectRef.None, starting = runNowStarting)
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
SectionHeading("Background sync")
|
||||
Text(
|
||||
@@ -473,15 +490,17 @@ private fun BackgroundSyncSection(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
TextButton(onClick = onRunNow, enabled = !runNowPending) {
|
||||
Text(if (runNowPending) "Running…" else "Run now")
|
||||
}
|
||||
runNowStatus?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
TextButton(onClick = onRunNow, enabled = !control.busy) { Text("Run now") }
|
||||
JobActivity(control = control, fallback = "Running…")
|
||||
// Hidden while a pass runs, so a finished run's numbers can't be read as this one's.
|
||||
if (!control.busy) {
|
||||
runNowStatus?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,8 +13,15 @@ sealed class EvictionFlowState {
|
||||
/** Evidence gathered; showing [EvictionConfirmDialog] for the user to confirm or cancel. */
|
||||
data class Confirming(val plan: EvictionPlanItem) : EvictionFlowState()
|
||||
|
||||
/** Deleting/reclaiming what the confirmed plan said was safe. */
|
||||
object Pending : EvictionFlowState()
|
||||
/**
|
||||
* Nothing of the screen's own to say: the eviction is under way and its `Eviction` job is what
|
||||
* reports it, or the flow was opened and dismissed.
|
||||
*
|
||||
* There is deliberately no `Pending` here. Whether the work is running is a fact about the job
|
||||
* — which a screen this one never started can also be showing — and a boolean beside it would
|
||||
* be a second, private answer to the same question. See `jobs/JobBinding.kt`.
|
||||
*/
|
||||
object Idle : EvictionFlowState()
|
||||
|
||||
data class Done(val message: String) : EvictionFlowState()
|
||||
|
||||
|
||||
+24
-9
@@ -347,15 +347,25 @@ now", the Ensemble screen's per-peer "Sync", and import triggers. The
|
||||
existing per-widget `AsyncActionState` pattern gets replaced by "subscribe to
|
||||
job id."
|
||||
|
||||
**Status**: built on Android and on the gpui desktop, over the same five
|
||||
controls — the document detail screen's "Sync to this node" (desktop:
|
||||
"Download"), its per-artifact "Sync", and its per-peer "Push now"; the
|
||||
Ensemble screen's per-peer "Sync"; and the Documents screen's imports. Two
|
||||
consequences beyond the progress readout, and they are the point: a job
|
||||
started *anywhere else* — the background scheduler, a remote `TriggerSync`,
|
||||
another screen, this screen before it was navigated away from — shows up in
|
||||
these controls, and the state survives leaving and re-entering the screen,
|
||||
because it was never the screen's to remember.
|
||||
**Status**: built on Android and on the gpui desktop, over the same set of
|
||||
controls — every one that starts a job. The document detail screen's "Sync to
|
||||
this node" (desktop: "Download"), its per-artifact "Sync", its per-peer "Push
|
||||
now", and its "Delete Locally"; the Ensemble screen's per-peer "Sync"; the
|
||||
Sync policy screen's "Run now" and each type's "Reclaim…"; the Music screen's
|
||||
"Build library"; and the Documents screen's imports. Two consequences beyond
|
||||
the progress readout, and they are the point: a job started *anywhere else* —
|
||||
the background scheduler, a remote `TriggerSync`, another screen, this screen
|
||||
before it was navigated away from — shows up in these controls, and the state
|
||||
survives leaving and re-entering the screen, because it was never the screen's
|
||||
to remember.
|
||||
|
||||
The two evictions are worth naming separately because they share a `JobKind`
|
||||
and must not share an identity: "Delete Locally" is `Eviction`/`Document` and
|
||||
"Reclaim…" is `Eviction`/`DocumentType`. A subtree eviction and a type-scoped
|
||||
reclaim are different work, so they neither dedup against each other nor mask
|
||||
each other's control. Both keep an `Evaluating` state of their own, which is
|
||||
not a job: gathering the evidence for the confirmation dialog is work no other
|
||||
node can see.
|
||||
|
||||
The mechanism is per-modality, the identity is not. A widget names the work
|
||||
it starts as a `(kind, subject)` pair, and the pair is spelled in Rust
|
||||
@@ -376,6 +386,11 @@ either frontend.
|
||||
one that starts it inside a spawned task (the Ensemble screen's "Sync")
|
||||
passes `starting: true` for the gap.
|
||||
|
||||
`JobSubjectRef` is the FFI spelling of the subject, and it carries
|
||||
`DocumentType` as the enum rather than its snake_case name for the reason
|
||||
`job_match_key` exists at all: a frontend that had to produce that name would
|
||||
be a mirror of `DocumentType::type_name`, and mirrors go stale silently.
|
||||
|
||||
What stays a per-screen flag on both is the *result* of the last action —
|
||||
"Pushed 3 documents", "Sync failed: …". A job carries a state and a progress
|
||||
message, not the sentence a particular screen wants to show afterwards. It is
|
||||
|
||||
@@ -416,9 +416,8 @@ pub fn job_kind_label(kind: String) -> String {
|
||||
/// A job's subject, in the shapes a frontend already has in hand — the FFI
|
||||
/// spelling of [`JobSubject`], and the argument to [`job_match_key`].
|
||||
///
|
||||
/// Only the variants a widget can bind to are here. `JobSubject::None` is
|
||||
/// deliberately absent: it is the subject of whole-node work
|
||||
/// (`BackgroundSync`), which no per-thing widget speaks for.
|
||||
/// Only the variants a widget can bind to are here — `JobSubject::Path` is a
|
||||
/// folder import's tree URI, and nothing else in [`JobSubject`] is missing.
|
||||
#[derive(uniffi::Enum)]
|
||||
pub enum JobSubjectRef {
|
||||
/// One peer node, by its hex node id — a `CatalogueSync`.
|
||||
@@ -430,10 +429,21 @@ pub enum JobSubjectRef {
|
||||
DocumentToPeer { document_id: String, node_id_hex: String },
|
||||
/// One artifact of one document, pulled on its own.
|
||||
DocumentAsset { document_id: String, asset: DocumentAssetRef },
|
||||
/// Every document of one type, by its snake_case type name.
|
||||
DocumentType { type_name: String },
|
||||
/// Every document of one type — a type-scoped `Eviction` (the Sync policy
|
||||
/// tab's "Reclaim…") or a `LibraryBuild`.
|
||||
///
|
||||
/// The enum itself rather than its snake_case name: the name is Rust's
|
||||
/// spelling, and a frontend that had to produce one would be a mirror of
|
||||
/// `DocumentType::type_name` — exactly what `job_match_key` exists to
|
||||
/// avoid. Kotlin already holds `DocumentType` values.
|
||||
DocumentType { doc_type: DocumentType },
|
||||
/// A filesystem path or content URI — a folder import's tree URI.
|
||||
Path { path: String },
|
||||
/// Work about the node as a whole rather than any one thing in it: a
|
||||
/// `BackgroundSync` pass. Its widget is the Settings screen's "Run now",
|
||||
/// which speaks for whichever pass is running — the periodic tick's, a
|
||||
/// peer's `TriggerSync`, or its own.
|
||||
None,
|
||||
}
|
||||
|
||||
impl TryFrom<JobSubjectRef> for JobSubject {
|
||||
@@ -457,12 +467,9 @@ impl TryFrom<JobSubjectRef> for JobSubject {
|
||||
JobSubjectRef::DocumentAsset { document_id, asset } => {
|
||||
JobSubject::DocumentAsset(document(document_id)?, asset.try_into()?)
|
||||
}
|
||||
JobSubjectRef::DocumentType { type_name } => {
|
||||
JobSubject::DocumentType(DocumentType::from_str(&type_name).map_err(|e| {
|
||||
SyncError::InvalidInput { msg: format!("invalid document type: {e}") }
|
||||
})?)
|
||||
}
|
||||
JobSubjectRef::DocumentType { doc_type } => JobSubject::DocumentType(doc_type),
|
||||
JobSubjectRef::Path { path } => JobSubject::Path(path),
|
||||
JobSubjectRef::None => JobSubject::None,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1304,6 +1311,13 @@ mod tests {
|
||||
JobKind::Import,
|
||||
JobSubject::Path("content://tree/Music".to_string()),
|
||||
)]
|
||||
#[case(
|
||||
"eviction",
|
||||
JobSubjectRef::DocumentType { doc_type: DocumentType::MusicTrack },
|
||||
JobKind::Eviction,
|
||||
JobSubject::DocumentType(DocumentType::MusicTrack),
|
||||
)]
|
||||
#[case("background_sync", JobSubjectRef::None, JobKind::BackgroundSync, JobSubject::None)]
|
||||
fn a_widgets_key_is_the_key_on_its_job_row(
|
||||
#[case] kind_name: &str,
|
||||
#[case] widget_subject: JobSubjectRef,
|
||||
|
||||
@@ -1012,6 +1012,9 @@ mod tests {
|
||||
members,
|
||||
total_documents: 0,
|
||||
this_node_is_revoked: false,
|
||||
current_epoch: 0,
|
||||
this_node_epoch: 0,
|
||||
this_node_epoch_is_stale: false,
|
||||
},
|
||||
created_at_display: String::new(),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user