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.
This commit is contained in:
Abdelilah El Aissaoui 2023-04-04 22:00:19 +02:00
parent 05a894a1cb
commit 0d91ec747a
No known key found for this signature in database
GPG Key ID: 7587FC860F594869
8 changed files with 158 additions and 52 deletions

View File

@ -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<Boolean> = _processing
var currentJob: Job? = null
private val _processing = MutableStateFlow<ProcessingState>(ProcessingState.None)
val processing: StateFlow<ProcessingState> = _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 {
}

View File

@ -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()
}
}

View File

@ -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() }
)
}
}
}
}

View File

@ -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,
) {

View File

@ -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<ContextMenuElement>): Modifie
@OptIn(ExperimentalComposeUiApi::class)
@Composable
private fun Modifier.dropdownMenu(items: () -> List<ContextMenuElement>): Modifier {
val (lastMouseEventState, setLastMouseEventState) = remember { mutableStateOf<MouseEvent?>(null) }
val (isClicked, setIsClicked) = remember { mutableStateOf(false) }
val (offset, setOffset) = remember { mutableStateOf<Offset?>(null) }
val mod = this
.onGloballyPositioned { layoutCoordinates ->
@ -95,32 +97,16 @@ private fun Modifier.dropdownMenu(items: () -> List<ContextMenuElement>): 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

View File

@ -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()

View File

@ -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)
}

View File

@ -74,7 +74,7 @@ class TabViewModel @Inject constructor(
val repositorySelectionStatus: StateFlow<RepositorySelectionStatus>
get() = _repositorySelectionStatus
val processing: StateFlow<Boolean> = tabState.processing
val processing: StateFlow<ProcessingState> = tabState.processing
val credentialsState: StateFlow<CredentialsState> = credentialsStateManager.credentialsState
@ -464,6 +464,10 @@ class TabViewModel @Inject constructor(
fun gpgCredentialsAccepted(password: String) {
credentialsStateManager.updateState(CredentialsAccepted.GpgCredentialsAccepted(password))
}
fun cancelOngoingTask() {
tabState.cancelCurrentTask()
}
}