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:
parent
05a894a1cb
commit
0d91ec747a
@ -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 {
|
||||
|
||||
}
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
@ -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() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
) {
|
||||
|
@ -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
|
||||
|
@ -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()
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user