Add a warning state for conflicted project syncs

This commit is contained in:
Adam Brown 2026-06-10 20:50:40 -07:00
parent db29c001c2
commit 00a9bcafd1
No known key found for this signature in database
7 changed files with 214 additions and 56 deletions

View file

@ -36,6 +36,7 @@
<string name="account_sync_log_filter_all">All</string>
<string name="account_sync_dialog_status_pending">Pending</string>
<string name="account_sync_dialog_status_error">Error</string>
<string name="account_sync_dialog_status_conflict">Conflict</string>
<string name="account_sync_dialog_status_complete">Sync Complete</string>
<string name="account_sync_dialog_status_canceled">Canceled</string>
<string name="delete_project_title">Delete Project</string>

View file

@ -63,6 +63,7 @@ interface ProjectsList : HammerComponent, ComponentToaster {
Pending,
Syncing,
Failed,
NeedsResolution,
Complete,
Canceled
}
@ -79,8 +80,8 @@ interface ProjectsList : HammerComponent, ComponentToaster {
/**
* True only while a sync is still working. Closing the dialog mid-sync should confirm
* cancellation; once every project is in a terminal state (Complete/Failed/Canceled) the
* sync is no longer active, so closing should just close even if [syncComplete] never flipped.
* cancellation; once every project is in a terminal state (Complete/Failed/NeedsResolution/Canceled)
* the sync is no longer active, so closing should just close even if [syncComplete] never flipped.
*/
val ProjectsList.SyncState.hasActiveSync: Boolean
get() = !syncComplete && projectsStatus.values.any { status ->

View file

@ -3,7 +3,7 @@ package com.darkrockstudios.apps.hammer.common.components.projectselection.proje
import com.arkivanov.decompose.ComponentContext
import com.arkivanov.decompose.value.Value
import com.arkivanov.decompose.value.getAndUpdate
import com.darkrockstudios.apps.hammer.*
import com.darkrockstudios.apps.hammer.Res
import com.darkrockstudios.apps.hammer.base.http.readTomlOrNull
import com.darkrockstudios.apps.hammer.common.components.ComponentToaster
import com.darkrockstudios.apps.hammer.common.components.ComponentToasterImpl
@ -22,7 +22,15 @@ import com.darkrockstudios.apps.hammer.common.data.projectmetadata.ProjectMetada
import com.darkrockstudios.apps.hammer.common.data.projectsrepository.ProjectsRepository
import com.darkrockstudios.apps.hammer.common.data.projectstatistics.ProjectStatisticsCacheReader
import com.darkrockstudios.apps.hammer.common.data.sync.accountsync.ClientAccountSynchronizer
import com.darkrockstudios.apps.hammer.common.data.sync.projectsync.*
import com.darkrockstudios.apps.hammer.common.data.sync.projectsync.ClientProjectSynchronizer
import com.darkrockstudios.apps.hammer.common.data.sync.projectsync.OnSyncLog
import com.darkrockstudios.apps.hammer.common.data.sync.projectsync.SyncLogMessage
import com.darkrockstudios.apps.hammer.common.data.sync.projectsync.syncAccLogE
import com.darkrockstudios.apps.hammer.common.data.sync.projectsync.syncAccLogI
import com.darkrockstudios.apps.hammer.common.data.sync.projectsync.syncAccLogW
import com.darkrockstudios.apps.hammer.common.data.sync.projectsync.syncLogE
import com.darkrockstudios.apps.hammer.common.data.sync.projectsync.syncLogI
import com.darkrockstudios.apps.hammer.common.data.sync.projectsync.syncLogW
import com.darkrockstudios.apps.hammer.common.data.temporaryProjectTask
import com.darkrockstudios.apps.hammer.common.dependencyinjection.injectMainDispatcher
import com.darkrockstudios.apps.hammer.common.fileio.HPath
@ -31,10 +39,26 @@ import com.darkrockstudios.apps.hammer.common.fileio.okio.toOkioPath
import com.darkrockstudios.apps.hammer.common.util.NetworkConnectivity
import com.darkrockstudios.apps.hammer.common.util.StrRes
import com.darkrockstudios.apps.hammer.common.util.lifecycleCoroutineScope
import com.darkrockstudios.apps.hammer.projects_list_toast_sync_complete
import com.darkrockstudios.apps.hammer.projects_list_toast_sync_failed
import com.darkrockstudios.apps.hammer.sync_log_begin_account
import com.darkrockstudios.apps.hammer.sync_log_begin_project
import com.darkrockstudios.apps.hammer.sync_log_begin_projects
import com.darkrockstudios.apps.hammer.sync_log_end_projects
import com.darkrockstudios.apps.hammer.sync_log_project_conflict
import io.github.aakira.napier.Napier
import korlibs.datastructure.iterators.parallelMap
import kotlinx.coroutines.*
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.cancel
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.joinAll
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.yield
import net.peanuuutz.tomlkt.Toml
import okio.FileSystem
import okio.IOException
@ -289,55 +313,80 @@ class ProjectsListComponent(
projectDef: ProjectDef,
onLog: OnSyncLog,
onProgress: suspend (Float, SyncLogMessage?) -> Unit
): Boolean {
): ProjectSyncOutcome {
onLog(syncLogI(strRes.get(Res.string.sync_log_begin_project, projectDef.name), projectDef))
var success = false
temporaryProjectTask(projectDef) { projScope ->
val synchronizer: ClientProjectSynchronizer = projScope.get { parametersOf(projectDef) }
val conflictBroker: ProjectDataConflictBroker = projScope.get { parametersOf(projectDef) }
var conflicted = false
coroutineScope {
// Bulk account sync has no interactive resolver. A project-data conflict reports
// to the broker and waits on resolutions forever, leaving the project stuck
// "Syncing". Watch for it and abort so the project fails instead of hanging.
val conflictWatcher = launch {
for (conflict in conflictBroker.conflicts) {
onLog(
syncLogW(
strRes.get(Res.string.sync_log_project_conflict, projectDef.name),
projectDef
)
)
conflictBroker.abort()
}
}
try {
temporaryProjectTask(projectDef) { projScope ->
val synchronizer: ClientProjectSynchronizer =
projScope.get { parametersOf(projectDef) }
val conflictBroker: ProjectDataConflictBroker =
projScope.get { parametersOf(projectDef) }
try {
success = synchronizer.sync(
onProgress = onProgress,
onLog = { message -> onLog(message) },
onConflict = {
coroutineScope {
// Bulk account sync has no interactive resolver. A project-data conflict reports
// to the broker and waits on resolutions forever, leaving the project stuck
// "Syncing". Watch for it and abort so the project stops instead of hanging.
val conflictWatcher = launch {
for (conflict in conflictBroker.conflicts) {
onLog(
syncLogW(
strRes.get(Res.string.sync_log_project_conflict, projectDef.name),
strRes.get(
Res.string.sync_log_project_conflict,
projectDef.name
),
projectDef
)
)
throw IllegalStateException("Entity conflict must be handled by Project sync")
},
onComplete = {},
onUnauthorized = ::showReauth
)
} finally {
conflictWatcher.cancel()
conflicted = true
conflictBroker.abort()
}
}
try {
success = synchronizer.sync(
onProgress = onProgress,
onLog = { message -> onLog(message) },
onConflict = {
onLog(
syncLogW(
strRes.get(
Res.string.sync_log_project_conflict,
projectDef.name
),
projectDef
)
)
conflicted = true
throw IllegalStateException("Entity conflict must be handled by Project sync")
},
onComplete = {},
onUnauthorized = ::showReauth
)
} finally {
conflictWatcher.cancel()
}
}
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
// A conflict aborts the entity sync by throwing; that's a resolvable state, not a failure.
if (!conflicted) throw e
}
return success
return when {
success -> ProjectSyncOutcome.Success
conflicted -> ProjectSyncOutcome.NeedsResolution
else -> ProjectSyncOutcome.Failed
}
}
private enum class ProjectSyncOutcome { Success, NeedsResolution, Failed }
private suspend fun syncNewProjectStatus(projects: List<ProjectDef>) {
val newStatuses = mutableMapOf<String, ProjectsList.ProjectSyncStatus>()
projects.forEach { projDef ->
@ -429,11 +478,14 @@ class ProjectsListComponent(
if (message != null) onSyncLog(message)
}
val projectSuccess = syncProject(projectDef, ::onSyncLog, ::onProgress)
allSuccess = allSuccess && projectSuccess
val outcome = syncProject(projectDef, ::onSyncLog, ::onProgress)
allSuccess = allSuccess && (outcome == ProjectSyncOutcome.Success)
val newStatus =
if (projectSuccess) ProjectsList.Status.Complete else ProjectsList.Status.Failed
val newStatus = when (outcome) {
ProjectSyncOutcome.Success -> ProjectsList.Status.Complete
ProjectSyncOutcome.NeedsResolution -> ProjectsList.Status.NeedsResolution
ProjectSyncOutcome.Failed -> ProjectsList.Status.Failed
}
syncProgressStatus(projectDef.name, newStatus)
} catch (e: CancellationException) {
throw e

View file

@ -59,6 +59,15 @@ class SyncStateActiveTest {
assertFalse(state.hasActiveSync)
}
@Test
fun `Project needing conflict resolution is not active`() {
// A conflict stops the project's sync in a terminal state; closing the dialog
// should not prompt the cancel confirmation.
val state = syncState(syncComplete = false, Status.Complete, Status.NeedsResolution)
assertFalse(state.hasActiveSync)
}
@Test
fun `Canceled projects are not active`() {
val state = syncState(syncComplete = true, Status.Canceled, Status.Canceled)

View file

@ -30,6 +30,7 @@ enum class HdStatus {
Syncing,
Complete,
Failed,
Warning,
Canceled,
}
@ -43,6 +44,7 @@ fun HdStatus.accentColor(): Color {
HdStatus.Syncing -> MaterialTheme.colorScheme.primary
HdStatus.Complete -> hammer.success
HdStatus.Failed -> hammer.danger
HdStatus.Warning -> hammer.warning
HdStatus.Canceled -> MaterialTheme.colorScheme.onSurfaceVariant
}
}
@ -69,6 +71,12 @@ fun HdStatusGlyph(
}
HdStatus.Complete -> Box(modifier = base.background(accent).drawCheck(ink))
HdStatus.Failed -> Box(modifier = base.background(accent).drawCross(ink))
HdStatus.Warning -> Box(
modifier = base.background(accent),
contentAlignment = Alignment.Center,
) {
BangMark(ink, size.value)
}
HdStatus.Canceled -> Box(
modifier = base
.border(width = Dp.Hairline, color = MaterialTheme.colorScheme.outlineVariant, shape = RectangleShape)
@ -89,18 +97,24 @@ fun HdWarnGlyph(
modifier = modifier.size(size).background(amber),
contentAlignment = Alignment.Center,
) {
Text(
text = "!",
color = ink,
style = TextStyle(
fontFamily = MaterialTheme.typography.labelSmall.fontFamily,
fontWeight = FontWeight.SemiBold,
fontSize = (size.value * 0.6f).sp,
),
)
BangMark(ink, size.value)
}
}
/** Mono `!` sized relative to its containing glyph. */
@Composable
private fun BangMark(ink: Color, sizeValue: Float) {
Text(
text = "!",
color = ink,
style = TextStyle(
fontFamily = MaterialTheme.typography.labelSmall.fontFamily,
fontWeight = FontWeight.SemiBold,
fontSize = (sizeValue * 0.6f).sp,
),
)
}
private fun Modifier.drawCross(color: Color): Modifier = this
.fillMaxSize()
.drawBehind {

View file

@ -2,7 +2,18 @@ package com.darkrockstudios.apps.hammer.common.projectselection
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState
@ -10,8 +21,15 @@ import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.RectangleShape
@ -19,14 +37,36 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogProperties
import com.arkivanov.decompose.extensions.compose.subscribeAsState
import com.darkrockstudios.apps.hammer.*
import com.darkrockstudios.apps.hammer.Res
import com.darkrockstudios.apps.hammer.account_sync_confirm_cancel_message
import com.darkrockstudios.apps.hammer.account_sync_confirm_cancel_title
import com.darkrockstudios.apps.hammer.account_sync_dialog_header
import com.darkrockstudios.apps.hammer.account_sync_dialog_status_canceled
import com.darkrockstudios.apps.hammer.account_sync_dialog_status_complete
import com.darkrockstudios.apps.hammer.account_sync_dialog_status_conflict
import com.darkrockstudios.apps.hammer.account_sync_dialog_status_error
import com.darkrockstudios.apps.hammer.account_sync_dialog_status_pending
import com.darkrockstudios.apps.hammer.account_sync_log_filter_all
import com.darkrockstudios.apps.hammer.account_sync_log_filter_label
import com.darkrockstudios.apps.hammer.account_sync_log_title
import com.darkrockstudios.apps.hammer.account_sync_toast_canceled
import com.darkrockstudios.apps.hammer.common.components.projectselection.projectslist.ProjectsList
import com.darkrockstudios.apps.hammer.common.components.projectselection.projectslist.hasActiveSync
import com.darkrockstudios.apps.hammer.common.compose.AnimatedDialogContainer
import com.darkrockstudios.apps.hammer.common.compose.RootSnackbarHostState
import com.darkrockstudios.apps.hammer.common.compose.SimpleConfirm
import com.darkrockstudios.apps.hammer.common.compose.Ui
import com.darkrockstudios.apps.hammer.common.compose.designsystem.*
import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdFilterMenu
import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdFolioDivider
import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdHairlineProgressBar
import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdLogGlyph
import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdMasthead
import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdMastheadAction
import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdMonoLabel
import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdStatus
import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdStatusGlyph
import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdToolButton
import com.darkrockstudios.apps.hammer.common.compose.designsystem.accentColor
import com.darkrockstudios.apps.hammer.common.compose.resources.get
import com.darkrockstudios.apps.hammer.common.data.sync.projectsync.SyncLogMessage
import kotlinx.coroutines.launch
@ -293,7 +333,7 @@ private fun ProgressList(projects: List<ProjectsList.ProjectSyncStatus>) {
}
@Composable
private fun SyncStatusRow(
internal fun SyncStatusRow(
projectStatus: ProjectsList.ProjectSyncStatus,
isLast: Boolean,
) {
@ -301,6 +341,7 @@ private fun SyncStatusRow(
ProjectsList.Status.Pending -> HdStatus.Pending
ProjectsList.Status.Syncing -> HdStatus.Syncing
ProjectsList.Status.Failed -> HdStatus.Failed
ProjectsList.Status.NeedsResolution -> HdStatus.Warning
ProjectsList.Status.Complete -> HdStatus.Complete
ProjectsList.Status.Canceled -> HdStatus.Canceled
}
@ -308,6 +349,7 @@ private fun SyncStatusRow(
ProjectsList.Status.Pending -> Res.string.account_sync_dialog_status_pending.get()
ProjectsList.Status.Syncing -> "${(projectStatus.progress * 100).toInt()}%"
ProjectsList.Status.Failed -> Res.string.account_sync_dialog_status_error.get()
ProjectsList.Status.NeedsResolution -> Res.string.account_sync_dialog_status_conflict.get()
ProjectsList.Status.Complete -> Res.string.account_sync_dialog_status_complete.get()
ProjectsList.Status.Canceled -> Res.string.account_sync_dialog_status_canceled.get()
}

View file

@ -9,6 +9,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.darkrockstudios.apps.hammer.common.Padded
import com.darkrockstudios.apps.hammer.common.components.projectselection.projectslist.ProjectsList
import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdHairlineProgressBar
import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdStatus
import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdStatusGlyph
@ -18,6 +19,7 @@ import com.darkrockstudios.apps.hammer.common.data.sync.projectsync.syncLogE
import com.darkrockstudios.apps.hammer.common.data.sync.projectsync.syncLogI
import com.darkrockstudios.apps.hammer.common.data.sync.projectsync.syncLogW
import com.darkrockstudios.apps.hammer.common.projectselection.SyncLogMessageUi
import com.darkrockstudios.apps.hammer.common.projectselection.SyncStatusRow
@Preview
@Composable
@ -34,6 +36,43 @@ private fun SyncLogRowsPreview() = Padded {
}
}
@Preview
@Composable
internal fun SyncStatusRowsPreview() = Padded {
Column(modifier = Modifier.fillMaxWidth()) {
val rows = listOf(
ProjectsList.ProjectSyncStatus(
"Pending Project",
progress = 0f,
status = ProjectsList.Status.Pending
),
ProjectsList.ProjectSyncStatus(
"Syncing Project",
progress = 0.4f,
status = ProjectsList.Status.Syncing
),
ProjectsList.ProjectSyncStatus(
"Conflicted Project",
progress = 0.6f,
status = ProjectsList.Status.NeedsResolution
),
ProjectsList.ProjectSyncStatus(
"Failed Project",
progress = 0.6f,
status = ProjectsList.Status.Failed
),
ProjectsList.ProjectSyncStatus(
"Synced Project",
progress = 1f,
status = ProjectsList.Status.Complete
),
)
rows.forEachIndexed { index, row ->
SyncStatusRow(projectStatus = row, isLast = index == rows.lastIndex)
}
}
}
@Preview
@Composable
private fun StatusGlyphPreview() = Padded {