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.lib.ObjectId
import org.eclipse.jgit.revwalk.RevCommit import org.eclipse.jgit.revwalk.RevCommit
import javax.inject.Inject import javax.inject.Inject
import kotlin.coroutines.cancellation.CancellationException
private const val TAG = "TabState" 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 @TabScope
class TabState @Inject constructor( class TabState @Inject constructor(
val errorsManager: ErrorsManager, val errorsManager: ErrorsManager,
@ -44,8 +57,10 @@ class TabState @Inject constructor(
@set:Synchronized @set:Synchronized
var operationRunning = false var operationRunning = false
private val _processing = MutableStateFlow(false) var currentJob: Job? = null
val processing: StateFlow<Boolean> = _processing
private val _processing = MutableStateFlow<ProcessingState>(ProcessingState.None)
val processing: StateFlow<ProcessingState> = _processing
fun initGit(git: Git) { fun initGit(git: Git) {
this.unsafeGit = git this.unsafeGit = git
@ -54,23 +69,64 @@ class TabState @Inject constructor(
fun safeProcessing( fun safeProcessing(
showError: Boolean = true, showError: Boolean = true,
refreshType: RefreshType, 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, refreshEvenIfCrashes: Boolean = false,
refreshEvenIfCrashesInteractive: ((Exception) -> Boolean)? = null, refreshEvenIfCrashesInteractive: ((Exception) -> Boolean)? = null,
callback: suspend (git: Git) -> Unit callback: suspend ProcessingInfo.(git: Git) -> Unit
) = ): Job {
scope.launch(Dispatchers.IO) { val job = scope.launch(Dispatchers.IO) {
var hasProcessFailed = false var hasProcessFailed = false
var refreshEvenIfCrashesInteractiveResult = false var refreshEvenIfCrashesInteractiveResult = false
operationRunning = true 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 { try {
delayedStateChange( delayedStateChange(
delayMs = 300, delayMs = 300,
onDelayTriggered = { 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) { } catch (ex: Exception) {
hasProcessFailed = true hasProcessFailed = true
@ -83,16 +139,20 @@ class TabState @Inject constructor(
if (showError && !containsCancellation) if (showError && !containsCancellation)
errorsManager.addError(newErrorNow(ex, ex.message.orEmpty())) errorsManager.addError(newErrorNow(ex, ex.message.orEmpty()))
} finally { } finally {
_processing.value = false _processing.value = ProcessingState.None
operationRunning = false operationRunning = false
if (refreshType != RefreshType.NONE && (!hasProcessFailed || refreshEvenIfCrashes || refreshEvenIfCrashesInteractiveResult)) { if (refreshType != RefreshType.NONE && (!hasProcessFailed || refreshEvenIfCrashes || refreshEvenIfCrashesInteractiveResult)) {
_refreshData.emit(refreshType) _refreshData.emit(refreshType)
} }
} }
} }
this.currentJob = job
return job
}
private fun exceptionContainsCancellation(ex: Throwable?): Boolean { private fun exceptionContainsCancellation(ex: Throwable?): Boolean {
return when (ex) { return when (ex) {
null -> false null -> false
@ -102,9 +162,17 @@ class TabState @Inject constructor(
} }
} }
fun safeProcessingWithoutGit(showError: Boolean = true, callback: suspend CoroutineScope.() -> Unit) = fun safeProcessingWithoutGit(
scope.launch(Dispatchers.IO) { showError: Boolean = true,
_processing.value = 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 operationRunning = true
try { try {
@ -112,14 +180,21 @@ class TabState @Inject constructor(
} catch (ex: Exception) { } catch (ex: Exception) {
ex.printStackTrace() ex.printStackTrace()
if (showError) val containsCancellation = exceptionContainsCancellation(ex)
if (showError && !containsCancellation)
errorsManager.addError(newErrorNow(ex, ex.localizedMessage)) errorsManager.addError(newErrorNow(ex, ex.localizedMessage))
} finally { } finally {
_processing.value = false _processing.value = ProcessingState.None
operationRunning = false operationRunning = false
} }
} }
this.currentJob = job
return job
}
fun runOperation( fun runOperation(
showError: Boolean = false, showError: Boolean = false,
refreshType: RefreshType, refreshType: RefreshType,
@ -201,6 +276,10 @@ class TabState @Inject constructor(
} }
} }
} }
fun cancelCurrentTask() {
currentJob?.cancel()
}
} }
enum class RefreshType { enum class RefreshType {
@ -214,7 +293,3 @@ enum class RefreshType {
UNCOMMITED_CHANGES_AND_LOG, UNCOMMITED_CHANGES_AND_LOG,
REMOTES, REMOTES,
} }
enum class Processing {
}

View File

@ -1,8 +1,11 @@
package com.jetpackduba.gitnuro.git.remote_operations package com.jetpackduba.gitnuro.git.remote_operations
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.eclipse.jgit.api.Git import org.eclipse.jgit.api.Git
import org.eclipse.jgit.lib.ProgressMonitor
import org.eclipse.jgit.transport.CredentialsProvider import org.eclipse.jgit.transport.CredentialsProvider
import org.eclipse.jgit.transport.RefSpec import org.eclipse.jgit.transport.RefSpec
import javax.inject.Inject import javax.inject.Inject
@ -13,6 +16,8 @@ class FetchAllBranchesUseCase @Inject constructor(
suspend operator fun invoke(git: Git) = withContext(Dispatchers.IO) { suspend operator fun invoke(git: Git) = withContext(Dispatchers.IO) {
val remotes = git.remoteList().call() val remotes = git.remoteList().call()
delay(4000)
for (remote in remotes) { for (remote in remotes) {
val refSpecs = remote.fetchRefSpecs.ifEmpty { val refSpecs = remote.fetchRefSpecs.ifEmpty {
listOf(RefSpec("refs/heads/*:refs/remotes/${remote.name}/*")) listOf(RefSpec("refs/heads/*:refs/remotes/${remote.name}/*"))
@ -24,6 +29,17 @@ class FetchAllBranchesUseCase @Inject constructor(
.setRemoveDeletedRefs(true) .setRemoveDeletedRefs(true)
.setTransportConfigCallback { handleTransportUseCase(it, git) } .setTransportConfigCallback { handleTransportUseCase(it, git) }
.setCredentialsProvider(CredentialsProvider.getDefault()) .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() .call()
} }
} }

View File

@ -2,12 +2,10 @@ package com.jetpackduba.gitnuro.ui
import androidx.compose.animation.Crossfade import androidx.compose.animation.Crossfade
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.width
import androidx.compose.material.LinearProgressIndicator import androidx.compose.material.LinearProgressIndicator
import androidx.compose.material.MaterialTheme import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.CredentialsAccepted
import com.jetpackduba.gitnuro.credentials.CredentialsRequested import com.jetpackduba.gitnuro.credentials.CredentialsRequested
import com.jetpackduba.gitnuro.credentials.CredentialsState 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.*
import com.jetpackduba.gitnuro.ui.dialogs.settings.SettingsDialog import com.jetpackduba.gitnuro.ui.dialogs.settings.SettingsDialog
import com.jetpackduba.gitnuro.viewmodels.RepositorySelectionStatus import com.jetpackduba.gitnuro.viewmodels.RepositorySelectionStatus
@ -32,7 +32,7 @@ fun AppTab(
val repositorySelectionStatus = tabViewModel.repositorySelectionStatus.collectAsState() val repositorySelectionStatus = tabViewModel.repositorySelectionStatus.collectAsState()
val repositorySelectionStatusValue = repositorySelectionStatus.value val repositorySelectionStatusValue = repositorySelectionStatus.value
val isProcessing by tabViewModel.processing.collectAsState() val processingState = tabViewModel.processing.collectAsState().value
Box { Box {
Column( Column(
@ -90,7 +90,7 @@ fun AppTab(
} }
} }
if (isProcessing) { if (processingState is ProcessingState.Processing) {
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@ -98,11 +98,33 @@ fun AppTab(
.onPreviewKeyEvent { true }, // Disable all keyboard events .onPreviewKeyEvent { true }, // Disable all keyboard events
contentAlignment = Alignment.Center, 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( LinearProgressIndicator(
modifier = Modifier.width(340.dp), modifier = Modifier.width(280.dp)
.padding(bottom = 32.dp),
color = MaterialTheme.colors.secondary, color = MaterialTheme.colors.secondary,
) )
if (processingState.isCancellable) {
PrimaryButton(
text = "Cancel",
onClick = { tabViewModel.cancelOngoingTask() }
)
}
} }
} }
} }

View File

@ -203,12 +203,12 @@ fun ExtendedMenuButton(
.ignoreKeyEvents() .ignoreKeyEvents()
.clip(RoundedCornerShape(4.dp)) .clip(RoundedCornerShape(4.dp))
.background(MaterialTheme.colors.surface) .background(MaterialTheme.colors.surface)
.handMouseClickable { if (enabled) onClick() }
) { ) {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxHeight() .fillMaxHeight()
.weight(1f) .weight(1f),
.handMouseClickable { if (enabled) onClick() },
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally, 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.Popup
import androidx.compose.ui.window.PopupPositionProvider import androidx.compose.ui.window.PopupPositionProvider
import com.jetpackduba.gitnuro.extensions.awaitFirstDownEvent 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.KeybindingOption
import com.jetpackduba.gitnuro.keybindings.matchesBinding import com.jetpackduba.gitnuro.keybindings.matchesBinding
import com.jetpackduba.gitnuro.theme.onBackgroundSecondary import com.jetpackduba.gitnuro.theme.onBackgroundSecondary
@ -85,7 +87,7 @@ private fun Modifier.contextMenu(items: () -> List<ContextMenuElement>): Modifie
@OptIn(ExperimentalComposeUiApi::class) @OptIn(ExperimentalComposeUiApi::class)
@Composable @Composable
private fun Modifier.dropdownMenu(items: () -> List<ContextMenuElement>): Modifier { 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 (offset, setOffset) = remember { mutableStateOf<Offset?>(null) }
val mod = this val mod = this
.onGloballyPositioned { layoutCoordinates -> .onGloballyPositioned { layoutCoordinates ->
@ -95,32 +97,16 @@ private fun Modifier.dropdownMenu(items: () -> List<ContextMenuElement>): Modifi
val offsetToBottomOfComponent = offsetToRoot.copy(y = offsetToRoot.y + layoutCoordinates.size.height) val offsetToBottomOfComponent = offsetToRoot.copy(y = offsetToRoot.y + layoutCoordinates.size.height)
setOffset(offsetToBottomOfComponent) setOffset(offsetToBottomOfComponent)
} }
.pointerInput(Unit) { .handMouseClickable {
while (true) { setIsClicked(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)
}
}
}
}
} }
if (offset != null && lastMouseEventState != null) { if (offset != null && isClicked) {
showPopup( showPopup(
offset.x.toInt(), offset.x.toInt(),
offset.y.toInt(), offset.y.toInt(),
items(), items(),
onDismissRequest = { setLastMouseEventState(null) }) onDismissRequest = { setIsClicked(false) })
} }
return mod return mod

View File

@ -77,7 +77,7 @@ class HistoryViewModel @Inject constructor(
fun fileHistory(filePath: String) = tabState.safeProcessing( fun fileHistory(filePath: String) = tabState.safeProcessing(
refreshType = RefreshType.NONE, refreshType = RefreshType.NONE,
) { git -> ) { git ->
this.filePath = filePath this@HistoryViewModel.filePath = filePath
_historyState.value = HistoryState.Loading(filePath) _historyState.value = HistoryState.Loading(filePath)
val log = git.log() val log = git.log()

View File

@ -36,6 +36,9 @@ class MenuViewModel @Inject constructor(
fun fetchAll() = tabState.safeProcessing( fun fetchAll() = tabState.safeProcessing(
refreshType = RefreshType.ALL_DATA, refreshType = RefreshType.ALL_DATA,
refreshEvenIfCrashes = true, refreshEvenIfCrashes = true,
title = "Fetching",
subtitle = "Updating references from the remote repositories...",
isCancellable = true
) { git -> ) { git ->
fetchAllBranchesUseCase(git) fetchAllBranchesUseCase(git)
} }

View File

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