From 0d91ec747af75ba399f2a85e45fe116c73c538bf Mon Sep 17 00:00:00 2001 From: Abdelilah El Aissaoui Date: Tue, 4 Apr 2023 22:00:19 +0200 Subject: [PATCH] Processing tasks now can show a title, subtitle and an option to cancel it. Right now only fetch implements such features but every feature will be migrated gradually. --- .../com/jetpackduba/gitnuro/git/TabState.kt | 113 +++++++++++++++--- .../FetchAllBranchesUseCase.kt | 16 +++ .../com/jetpackduba/gitnuro/ui/AppTab.kt | 38 ++++-- .../kotlin/com/jetpackduba/gitnuro/ui/Menu.kt | 4 +- .../gitnuro/ui/context_menu/ContextMenu.kt | 28 ++--- .../gitnuro/viewmodels/HistoryViewModel.kt | 2 +- .../gitnuro/viewmodels/MenuViewModel.kt | 3 + .../gitnuro/viewmodels/TabViewModel.kt | 6 +- 8 files changed, 158 insertions(+), 52 deletions(-) diff --git a/src/main/kotlin/com/jetpackduba/gitnuro/git/TabState.kt b/src/main/kotlin/com/jetpackduba/gitnuro/git/TabState.kt index 08eb25a..11741ca 100644 --- a/src/main/kotlin/com/jetpackduba/gitnuro/git/TabState.kt +++ b/src/main/kotlin/com/jetpackduba/gitnuro/git/TabState.kt @@ -11,10 +11,23 @@ import org.eclipse.jgit.api.Git import org.eclipse.jgit.lib.ObjectId import org.eclipse.jgit.revwalk.RevCommit import javax.inject.Inject -import kotlin.coroutines.cancellation.CancellationException private const val TAG = "TabState" +interface ProcessingInfo { + fun changeSubtitle(newSubtitle: String) + fun changeIsCancellable(newIsCancellable: Boolean) +} + +sealed interface ProcessingState { + object None : ProcessingState + data class Processing( + val title: String, + val subtitle: String, + val isCancellable: Boolean, + ) : ProcessingState +} + @TabScope class TabState @Inject constructor( val errorsManager: ErrorsManager, @@ -44,8 +57,10 @@ class TabState @Inject constructor( @set:Synchronized var operationRunning = false - private val _processing = MutableStateFlow(false) - val processing: StateFlow = _processing + var currentJob: Job? = null + + private val _processing = MutableStateFlow(ProcessingState.None) + val processing: StateFlow = _processing fun initGit(git: Git) { this.unsafeGit = git @@ -54,23 +69,64 @@ class TabState @Inject constructor( fun safeProcessing( showError: Boolean = true, refreshType: RefreshType, + // TODO Eventually the title and subtitles should be mandatory but for now the default it's empty to slowly + // migrate the code that uses this function + title: String = "", + subtitle: String = "", + isCancellable: Boolean = false, refreshEvenIfCrashes: Boolean = false, refreshEvenIfCrashesInteractive: ((Exception) -> Boolean)? = null, - callback: suspend (git: Git) -> Unit - ) = - scope.launch(Dispatchers.IO) { + callback: suspend ProcessingInfo.(git: Git) -> Unit + ): Job { + val job = scope.launch(Dispatchers.IO) { var hasProcessFailed = false var refreshEvenIfCrashesInteractiveResult = false operationRunning = true + val processingInfo: ProcessingInfo = object : ProcessingInfo { + override fun changeSubtitle(newSubtitle: String) { + _processing.update { processingState -> + if (processingState is ProcessingState.Processing) { + processingState.copy(subtitle = newSubtitle) + } else { + ProcessingState.Processing( + title = title, + isCancellable = isCancellable, + subtitle = newSubtitle + ) + } + } + } + + override fun changeIsCancellable(newIsCancellable: Boolean) { + _processing.update { processingState -> + if (processingState is ProcessingState.Processing) { + processingState.copy(isCancellable = newIsCancellable) + } else { + ProcessingState.Processing( + title = title, + isCancellable = newIsCancellable, + subtitle = subtitle + ) + } + } + } + } + try { delayedStateChange( delayMs = 300, onDelayTriggered = { - _processing.value = true + _processing.update { processingState -> + if(processingState is ProcessingState.None) { + ProcessingState.Processing(title, subtitle, isCancellable) + } else { + processingState + } + } } ) { - callback(git) + processingInfo.callback(git) } } catch (ex: Exception) { hasProcessFailed = true @@ -83,16 +139,20 @@ class TabState @Inject constructor( if (showError && !containsCancellation) errorsManager.addError(newErrorNow(ex, ex.message.orEmpty())) } finally { - _processing.value = false + _processing.value = ProcessingState.None operationRunning = false if (refreshType != RefreshType.NONE && (!hasProcessFailed || refreshEvenIfCrashes || refreshEvenIfCrashesInteractiveResult)) { _refreshData.emit(refreshType) } } - } + this.currentJob = job + + return job + } + private fun exceptionContainsCancellation(ex: Throwable?): Boolean { return when (ex) { null -> false @@ -102,9 +162,17 @@ class TabState @Inject constructor( } } - fun safeProcessingWithoutGit(showError: Boolean = true, callback: suspend CoroutineScope.() -> Unit) = - scope.launch(Dispatchers.IO) { - _processing.value = true + fun safeProcessingWithoutGit( + showError: Boolean = true, + // TODO Eventually the title and subtitles should be mandatory but for now the default it's empty to slowly + // migrate the code that uses this function + title: String = "", + subtitle: String = "", + isCancellable: Boolean = false, + callback: suspend CoroutineScope.() -> Unit + ): Job { + val job = scope.launch(Dispatchers.IO) { + _processing.value = ProcessingState.Processing(title, subtitle, isCancellable) operationRunning = true try { @@ -112,14 +180,21 @@ class TabState @Inject constructor( } catch (ex: Exception) { ex.printStackTrace() - if (showError) + val containsCancellation = exceptionContainsCancellation(ex) + + if (showError && !containsCancellation) errorsManager.addError(newErrorNow(ex, ex.localizedMessage)) } finally { - _processing.value = false + _processing.value = ProcessingState.None operationRunning = false } } + this.currentJob = job + + return job + } + fun runOperation( showError: Boolean = false, refreshType: RefreshType, @@ -201,6 +276,10 @@ class TabState @Inject constructor( } } } + + fun cancelCurrentTask() { + currentJob?.cancel() + } } enum class RefreshType { @@ -214,7 +293,3 @@ enum class RefreshType { UNCOMMITED_CHANGES_AND_LOG, REMOTES, } - -enum class Processing { - -} \ No newline at end of file diff --git a/src/main/kotlin/com/jetpackduba/gitnuro/git/remote_operations/FetchAllBranchesUseCase.kt b/src/main/kotlin/com/jetpackduba/gitnuro/git/remote_operations/FetchAllBranchesUseCase.kt index 0e14c78..d70830a 100644 --- a/src/main/kotlin/com/jetpackduba/gitnuro/git/remote_operations/FetchAllBranchesUseCase.kt +++ b/src/main/kotlin/com/jetpackduba/gitnuro/git/remote_operations/FetchAllBranchesUseCase.kt @@ -1,8 +1,11 @@ package com.jetpackduba.gitnuro.git.remote_operations import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive import kotlinx.coroutines.withContext import org.eclipse.jgit.api.Git +import org.eclipse.jgit.lib.ProgressMonitor import org.eclipse.jgit.transport.CredentialsProvider import org.eclipse.jgit.transport.RefSpec import javax.inject.Inject @@ -13,6 +16,8 @@ class FetchAllBranchesUseCase @Inject constructor( suspend operator fun invoke(git: Git) = withContext(Dispatchers.IO) { val remotes = git.remoteList().call() + delay(4000) + for (remote in remotes) { val refSpecs = remote.fetchRefSpecs.ifEmpty { listOf(RefSpec("refs/heads/*:refs/remotes/${remote.name}/*")) @@ -24,6 +29,17 @@ class FetchAllBranchesUseCase @Inject constructor( .setRemoveDeletedRefs(true) .setTransportConfigCallback { handleTransportUseCase(it, git) } .setCredentialsProvider(CredentialsProvider.getDefault()) + .setProgressMonitor(object: ProgressMonitor { + override fun start(totalTasks: Int) {} + + override fun beginTask(title: String?, totalWork: Int) {} + + override fun update(completed: Int) {} + + override fun endTask() {} + + override fun isCancelled(): Boolean = isActive + }) .call() } } diff --git a/src/main/kotlin/com/jetpackduba/gitnuro/ui/AppTab.kt b/src/main/kotlin/com/jetpackduba/gitnuro/ui/AppTab.kt index 9f01ed4..8d0c9a0 100644 --- a/src/main/kotlin/com/jetpackduba/gitnuro/ui/AppTab.kt +++ b/src/main/kotlin/com/jetpackduba/gitnuro/ui/AppTab.kt @@ -2,12 +2,10 @@ package com.jetpackduba.gitnuro.ui import androidx.compose.animation.Crossfade import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.* import androidx.compose.material.LinearProgressIndicator import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -17,6 +15,8 @@ import com.jetpackduba.gitnuro.LoadingRepository import com.jetpackduba.gitnuro.credentials.CredentialsAccepted import com.jetpackduba.gitnuro.credentials.CredentialsRequested import com.jetpackduba.gitnuro.credentials.CredentialsState +import com.jetpackduba.gitnuro.git.ProcessingState +import com.jetpackduba.gitnuro.ui.components.PrimaryButton import com.jetpackduba.gitnuro.ui.dialogs.* import com.jetpackduba.gitnuro.ui.dialogs.settings.SettingsDialog import com.jetpackduba.gitnuro.viewmodels.RepositorySelectionStatus @@ -32,7 +32,7 @@ fun AppTab( val repositorySelectionStatus = tabViewModel.repositorySelectionStatus.collectAsState() val repositorySelectionStatusValue = repositorySelectionStatus.value - val isProcessing by tabViewModel.processing.collectAsState() + val processingState = tabViewModel.processing.collectAsState().value Box { Column( @@ -90,7 +90,7 @@ fun AppTab( } } - if (isProcessing) { + if (processingState is ProcessingState.Processing) { Box( modifier = Modifier .fillMaxSize() @@ -98,11 +98,33 @@ fun AppTab( .onPreviewKeyEvent { true }, // Disable all keyboard events contentAlignment = Alignment.Center, ) { - Column { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + processingState.title, + style = MaterialTheme.typography.h3, + color = MaterialTheme.colors.onBackground, + modifier = Modifier.padding(bottom = 8.dp), + ) + Text( + processingState.subtitle, + style = MaterialTheme.typography.body1, + color = MaterialTheme.colors.onBackground, + modifier = Modifier.padding(bottom = 32.dp), + ) LinearProgressIndicator( - modifier = Modifier.width(340.dp), + modifier = Modifier.width(280.dp) + .padding(bottom = 32.dp), color = MaterialTheme.colors.secondary, ) + + if (processingState.isCancellable) { + PrimaryButton( + text = "Cancel", + onClick = { tabViewModel.cancelOngoingTask() } + ) + } } } } diff --git a/src/main/kotlin/com/jetpackduba/gitnuro/ui/Menu.kt b/src/main/kotlin/com/jetpackduba/gitnuro/ui/Menu.kt index 790da70..951d062 100644 --- a/src/main/kotlin/com/jetpackduba/gitnuro/ui/Menu.kt +++ b/src/main/kotlin/com/jetpackduba/gitnuro/ui/Menu.kt @@ -203,12 +203,12 @@ fun ExtendedMenuButton( .ignoreKeyEvents() .clip(RoundedCornerShape(4.dp)) .background(MaterialTheme.colors.surface) + .handMouseClickable { if (enabled) onClick() } ) { Column( modifier = Modifier .fillMaxHeight() - .weight(1f) - .handMouseClickable { if (enabled) onClick() }, + .weight(1f), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally, ) { diff --git a/src/main/kotlin/com/jetpackduba/gitnuro/ui/context_menu/ContextMenu.kt b/src/main/kotlin/com/jetpackduba/gitnuro/ui/context_menu/ContextMenu.kt index 4323421..d6b97f2 100644 --- a/src/main/kotlin/com/jetpackduba/gitnuro/ui/context_menu/ContextMenu.kt +++ b/src/main/kotlin/com/jetpackduba/gitnuro/ui/context_menu/ContextMenu.kt @@ -22,6 +22,8 @@ import androidx.compose.ui.unit.* import androidx.compose.ui.window.Popup import androidx.compose.ui.window.PopupPositionProvider import com.jetpackduba.gitnuro.extensions.awaitFirstDownEvent +import com.jetpackduba.gitnuro.extensions.handMouseClickable +import com.jetpackduba.gitnuro.extensions.handOnHover import com.jetpackduba.gitnuro.keybindings.KeybindingOption import com.jetpackduba.gitnuro.keybindings.matchesBinding import com.jetpackduba.gitnuro.theme.onBackgroundSecondary @@ -85,7 +87,7 @@ private fun Modifier.contextMenu(items: () -> List): Modifie @OptIn(ExperimentalComposeUiApi::class) @Composable private fun Modifier.dropdownMenu(items: () -> List): Modifier { - val (lastMouseEventState, setLastMouseEventState) = remember { mutableStateOf(null) } + val (isClicked, setIsClicked) = remember { mutableStateOf(false) } val (offset, setOffset) = remember { mutableStateOf(null) } val mod = this .onGloballyPositioned { layoutCoordinates -> @@ -95,32 +97,16 @@ private fun Modifier.dropdownMenu(items: () -> List): Modifi val offsetToBottomOfComponent = offsetToRoot.copy(y = offsetToRoot.y + layoutCoordinates.size.height) setOffset(offsetToBottomOfComponent) } - .pointerInput(Unit) { - while (true) { - val lastMouseEvent = awaitPointerEventScope { awaitFirstDownEvent() } - val mouseEvent = lastMouseEvent.awtEventOrNull - - if (mouseEvent != null) { - if (lastMouseEvent.button.isPrimary) { - val currentCheck = System.currentTimeMillis() - if (lastCheck != 0L && currentCheck - lastCheck < MIN_TIME_BETWEEN_POPUPS) { - println("IGNORE POPUP TRIGGERED!") - } else { - lastCheck = currentCheck - - setLastMouseEventState(mouseEvent) - } - } - } - } + .handMouseClickable { + setIsClicked(true) } - if (offset != null && lastMouseEventState != null) { + if (offset != null && isClicked) { showPopup( offset.x.toInt(), offset.y.toInt(), items(), - onDismissRequest = { setLastMouseEventState(null) }) + onDismissRequest = { setIsClicked(false) }) } return mod diff --git a/src/main/kotlin/com/jetpackduba/gitnuro/viewmodels/HistoryViewModel.kt b/src/main/kotlin/com/jetpackduba/gitnuro/viewmodels/HistoryViewModel.kt index 3b2ee2e..5a6c24a 100644 --- a/src/main/kotlin/com/jetpackduba/gitnuro/viewmodels/HistoryViewModel.kt +++ b/src/main/kotlin/com/jetpackduba/gitnuro/viewmodels/HistoryViewModel.kt @@ -77,7 +77,7 @@ class HistoryViewModel @Inject constructor( fun fileHistory(filePath: String) = tabState.safeProcessing( refreshType = RefreshType.NONE, ) { git -> - this.filePath = filePath + this@HistoryViewModel.filePath = filePath _historyState.value = HistoryState.Loading(filePath) val log = git.log() diff --git a/src/main/kotlin/com/jetpackduba/gitnuro/viewmodels/MenuViewModel.kt b/src/main/kotlin/com/jetpackduba/gitnuro/viewmodels/MenuViewModel.kt index ad500d2..1e8e40d 100644 --- a/src/main/kotlin/com/jetpackduba/gitnuro/viewmodels/MenuViewModel.kt +++ b/src/main/kotlin/com/jetpackduba/gitnuro/viewmodels/MenuViewModel.kt @@ -36,6 +36,9 @@ class MenuViewModel @Inject constructor( fun fetchAll() = tabState.safeProcessing( refreshType = RefreshType.ALL_DATA, refreshEvenIfCrashes = true, + title = "Fetching", + subtitle = "Updating references from the remote repositories...", + isCancellable = true ) { git -> fetchAllBranchesUseCase(git) } diff --git a/src/main/kotlin/com/jetpackduba/gitnuro/viewmodels/TabViewModel.kt b/src/main/kotlin/com/jetpackduba/gitnuro/viewmodels/TabViewModel.kt index ed9f6ca..e95815a 100644 --- a/src/main/kotlin/com/jetpackduba/gitnuro/viewmodels/TabViewModel.kt +++ b/src/main/kotlin/com/jetpackduba/gitnuro/viewmodels/TabViewModel.kt @@ -74,7 +74,7 @@ class TabViewModel @Inject constructor( val repositorySelectionStatus: StateFlow get() = _repositorySelectionStatus - val processing: StateFlow = tabState.processing + val processing: StateFlow = tabState.processing val credentialsState: StateFlow = credentialsStateManager.credentialsState @@ -464,6 +464,10 @@ class TabViewModel @Inject constructor( fun gpgCredentialsAccepted(password: String) { credentialsStateManager.updateState(CredentialsAccepted.GpgCredentialsAccepted(password)) } + + fun cancelOngoingTask() { + tabState.cancelCurrentTask() + } }