Improved clone dialog features by adding more error management & directory picker

This commit is contained in:
Abdelilah El Aissaoui 2022-04-03 01:16:55 +02:00
parent 8a9c2d5fc3
commit d662edba9d
10 changed files with 367 additions and 104 deletions

View File

@ -0,0 +1,3 @@
package app.exceptions
class InvalidDirectoryException(msg: String) : GitnuroException(msg)

View File

@ -4,11 +4,10 @@ import app.credentials.GSessionManager
import app.credentials.HttpCredentialsProvider
import app.extensions.remoteName
import app.extensions.simpleName
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.withContext
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import org.eclipse.jgit.api.Git
import org.eclipse.jgit.api.RebaseResult
import org.eclipse.jgit.lib.ProgressMonitor
@ -16,15 +15,12 @@ import org.eclipse.jgit.lib.Ref
import org.eclipse.jgit.transport.*
import java.io.File
import javax.inject.Inject
import kotlin.coroutines.cancellation.CancellationException
class RemoteOperationsManager @Inject constructor(
private val sessionManager: GSessionManager
) {
private val _cloneStatus = MutableStateFlow<CloneStatus>(CloneStatus.None)
val cloneStatus: StateFlow<CloneStatus>
get() = _cloneStatus
suspend fun pull(git: Git, rebase: Boolean) = withContext(Dispatchers.IO) {
val pullResult = git
.pull()
@ -226,59 +222,69 @@ class RemoteOperationsManager @Inject constructor(
}
suspend fun clone(directory: File, url: String) = withContext(Dispatchers.IO) {
@OptIn(ExperimentalCoroutinesApi::class)
fun clone(directory: File, url: String): Flow<CloneStatus> = callbackFlow {
try {
_cloneStatus.value = CloneStatus.Cloning(0)
ensureActive()
this.trySend(CloneStatus.Cloning(0))
Git.cloneRepository()
.setDirectory(directory)
.setURI(url)
.setProgressMonitor(object : ProgressMonitor {
.setProgressMonitor(
object : ProgressMonitor {
override fun start(totalTasks: Int) {
println("ProgressMonitor Start")
println("ProgressMonitor Start with total tasks of: $totalTasks")
}
override fun beginTask(title: String?, totalWork: Int) {
println("ProgressMonitor Begin task")
println("ProgressMonitor Begin task with title: $title")
}
override fun update(completed: Int) {
println("ProgressMonitor Update $completed")
_cloneStatus.value = CloneStatus.Cloning(completed)
ensureActive()
trySend(CloneStatus.Cloning(completed))
}
override fun endTask() {
println("ProgressMonitor End task")
_cloneStatus.value = CloneStatus.CheckingOut
ensureActive()
trySend(CloneStatus.CheckingOut)
}
override fun isCancelled(): Boolean {
return !isActive
}
})
}
)
.setTransportConfigCallback {
handleTransportCredentials(it)
}
.call()
_cloneStatus.value = CloneStatus.Completed
ensureActive()
trySend(CloneStatus.Completed(directory))
channel.close()
} catch (ex: Exception) {
_cloneStatus.value = CloneStatus.Fail(ex.localizedMessage)
}
if(ex.cause?.cause is CancellationException) {
println("Clone cancelled")
} else {
trySend(CloneStatus.Fail(ex.localizedMessage))
}
fun resetCloneStatus() {
_cloneStatus.value = CloneStatus.None
channel.close()
}
awaitClose()
}
}
sealed class CloneStatus {
object None : CloneStatus()
data class Cloning(val progress: Int) : CloneStatus()
object Cancelling : CloneStatus()
object CheckingOut : CloneStatus()
data class Fail(val reason: String) : CloneStatus()
object Completed : CloneStatus()
data class Completed(val repoDir: File) : CloneStatus()
}

View File

@ -85,14 +85,14 @@ class TabState @Inject constructor(
}
}
fun safeProcessingWihoutGit(showError: Boolean = true, callback: suspend () -> Unit) =
fun safeProcessingWihoutGit(showError: Boolean = true, callback: suspend CoroutineScope.() -> Unit) =
managerScope.launch(Dispatchers.IO) {
mutex.withLock {
_processing.value = true
operationRunning = true
try {
callback()
this.callback()
} catch (ex: Exception) {
ex.printStackTrace()

View File

@ -13,6 +13,7 @@ val secondaryTextDark = Color(0xFFCCCBCB)
val borderColorLight = Color(0xFF989898)
val borderColorDark = Color(0xFF989898)
val errorColor = Color(0xFFc93838)
val onErrorColor = Color(0xFFFFFFFF)
val backgroundColorLight = Color(0xFFFFFFFF)
val backgroundColorDark = Color(0xFF0E1621)

View File

@ -14,7 +14,8 @@ private val DarkColorPalette = darkColors(
secondary = secondary,
surface = surfaceColorDark,
background = backgroundColorDark,
error = errorColor
error = errorColor,
onError = onErrorColor,
)
private val LightColorPalette = lightColors(
@ -23,10 +24,8 @@ private val LightColorPalette = lightColors(
secondary = secondary,
background = backgroundColorLight,
surface = surfaceColorLight,
error = errorColor
/* Other default colors to override
*/
error = errorColor,
onError = onErrorColor,
)
@Composable

View File

@ -15,7 +15,6 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.DefaultAlpha
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
@ -121,12 +120,12 @@ fun AppTab(
fontWeight = FontWeight.Medium,
modifier = Modifier
.padding(top = 16.dp),
color = Color.White,
color = MaterialTheme.colors.onError,
) // TODO Add more descriptive title
Text(
text = lastError?.message ?: "",
color = Color.White,
color = MaterialTheme.colors.onError,
modifier = Modifier
.padding(top = 8.dp, bottom = 16.dp)
.widthIn(max = 600.dp)

View File

@ -23,7 +23,6 @@ import app.extensions.dirPath
import app.theme.primaryTextColor
import app.theme.secondaryTextColor
import app.ui.dialogs.CloneDialog
import app.ui.dialogs.MaterialDialog
import app.viewmodels.TabViewModel
import openDirectoryDialog
import openRepositoryDialog
@ -90,7 +89,7 @@ fun WelcomePage(
painter = painterResource("open.svg"),
onClick = {
val dir = openDirectoryDialog()
if(dir != null)
if (dir != null)
tabViewModel.initLocalRepository(dir)
}
)
@ -163,23 +162,23 @@ fun WelcomePage(
}
}
LaunchedEffect(showCloneView) {
if (showCloneView)
tabViewModel.cloneViewModel.reset() // Reset dialog before showing it
}
if (showCloneView)
MaterialDialog {
CloneDialog(tabViewModel, onClose = { showCloneView = false })
}
// Popup(focusable = true, onDismissRequest = { showCloneView = false }, alignment = Alignment.Center) {
//
// }
// PopupAlertDialogProvider.AlertDialog(onDismissRequest = {}) {
//
// CloneDialog(gitManager, onClose = { showCloneView = false })
// }
// }
// }
CloneDialog(
tabViewModel.cloneViewModel,
onClose = {
showCloneView = false
tabViewModel.cloneViewModel.reset()
},
onOpenRepository = { dir ->
tabViewModel.openRepository(dir)
},
)
}
@Composable

View File

@ -1,76 +1,196 @@
package app.ui.dialogs
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Search
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusOrder
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import app.git.CloneStatus
import app.theme.primaryTextColor
import app.viewmodels.TabViewModel
import app.viewmodels.CloneViewModel
import openDirectoryDialog
import java.io.File
@Composable
fun CloneDialog(
gitManager: TabViewModel,
onClose: () -> Unit
cloneViewModel: CloneViewModel,
onClose: () -> Unit,
onOpenRepository: (File) -> Unit,
) {
val cloneStatus = gitManager.cloneStatus.collectAsState()
val cloneStatus = cloneViewModel.cloneStatus.collectAsState()
val cloneStatusValue = cloneStatus.value
var directory by remember { mutableStateOf("") }
var url by remember { mutableStateOf("") }
Column {
if (cloneStatusValue is CloneStatus.Cloning || cloneStatusValue == CloneStatus.CheckingOut)
LinearProgressIndicator(modifier = Modifier.width(500.dp))
else if (cloneStatusValue == CloneStatus.Completed) {
gitManager.openRepository(directory)
MaterialDialog {
Box(
modifier = Modifier
.width(400.dp)
.animateContentSize()
) {
when (cloneStatusValue) {
CloneStatus.CheckingOut -> {
Cloning(cloneViewModel)
}
is CloneStatus.Cloning -> {
Cloning(cloneViewModel)
}
is CloneStatus.Cancelling -> {
onClose()
}
is CloneStatus.Completed -> {
onOpenRepository(cloneStatusValue.repoDir)
onClose()
}
is CloneStatus.Fail -> CloneInput(
cloneViewModel = cloneViewModel,
onClose = onClose,
errorMessage = cloneStatusValue.reason
)
CloneStatus.None -> CloneInput(
cloneViewModel = cloneViewModel,
onClose = onClose,
)
}
}
}
}
@Composable
private fun CloneInput(
cloneViewModel: CloneViewModel,
onClose: () -> Unit,
errorMessage: String? = null,
) {
var url by remember { mutableStateOf(cloneViewModel.url) }
var directory by remember { mutableStateOf(cloneViewModel.directory) }
var errorHasBeenNoticed by remember { mutableStateOf(false) }
val urlFocusRequester = remember { FocusRequester() }
val directoryFocusRequester = remember { FocusRequester() }
val directoryButtonFocusRequester = remember { FocusRequester() }
val cloneButtonFocusRequester = remember { FocusRequester() }
val cancelButtonFocusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) {
urlFocusRequester.requestFocus()
}
Column(
modifier = Modifier.fillMaxWidth()
) {
Text(
"Clone a repository",
"Clone a new repository",
color = MaterialTheme.colors.primaryTextColor,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp, horizontal = 8.dp)
)
OutlinedTextField(
modifier = Modifier
.width(400.dp)
.padding(vertical = 4.dp, horizontal = 8.dp),
.fillMaxWidth()
.padding(vertical = 4.dp, horizontal = 8.dp)
.focusOrder(urlFocusRequester) {
previous = cancelButtonFocusRequester
next = directoryFocusRequester
},
label = { Text("URL") },
textStyle = TextStyle(fontSize = 14.sp, color = MaterialTheme.colors.primaryTextColor),
maxLines = 1,
value = url,
onValueChange = {
errorHasBeenNoticed = true
url = it
}
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp, horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedTextField(
modifier = Modifier
.width(400.dp)
.padding(vertical = 4.dp, horizontal = 8.dp),
label = { Text("Directory") },
.weight(1f)
.padding(end = 4.dp)
.focusOrder(directoryFocusRequester) {
previous = urlFocusRequester
next = directoryButtonFocusRequester
},
textStyle = TextStyle(fontSize = 14.sp, color = MaterialTheme.colors.primaryTextColor),
maxLines = 1,
label = { Text("Directory") },
value = directory,
onValueChange = {
errorHasBeenNoticed = true
directory = it
}
)
IconButton(
onClick = {
errorHasBeenNoticed = true
val newDirectory = openDirectoryDialog()
if (newDirectory != null)
directory = newDirectory
},
modifier = Modifier
.focusOrder(directoryButtonFocusRequester) {
previous = directoryFocusRequester
next = cloneButtonFocusRequester
}
) {
Icon(
Icons.Default.Search,
contentDescription = null,
tint = MaterialTheme.colors.primaryTextColor,
)
}
}
AnimatedVisibility(errorMessage != null && !errorHasBeenNoticed) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp, horizontal = 8.dp)
.clip(RoundedCornerShape(4.dp))
.background(MaterialTheme.colors.error)
) {
Text(
errorMessage.orEmpty(),
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp, horizontal = 8.dp),
color = MaterialTheme.colors.onError,
)
}
}
Row(
modifier = Modifier
.padding(top = 16.dp)
.align(Alignment.End)
) {
TextButton(
modifier = Modifier.padding(end = 8.dp),
modifier = Modifier
.padding(end = 8.dp)
.focusOrder(cancelButtonFocusRequester) {
previous = cloneButtonFocusRequester
next = urlFocusRequester
},
onClick = {
onClose()
}
@ -79,7 +199,12 @@ fun CloneDialog(
}
Button(
onClick = {
gitManager.clone(File(directory), url)
cloneViewModel.clone(directory, url)
},
modifier = Modifier
.focusOrder(cloneButtonFocusRequester) {
previous = directoryButtonFocusRequester
next = cancelButtonFocusRequester
}
) {
Text("Clone")
@ -87,3 +212,48 @@ fun CloneDialog(
}
}
}
@Composable
private fun Cloning(cloneViewModel: CloneViewModel) {
Column (
modifier = Modifier
.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
CircularProgressIndicator(modifier = Modifier.padding(horizontal = 16.dp))
TextButton(
modifier = Modifier
.padding(
top = 36.dp,
end = 8.dp
)
.align(Alignment.End),
onClick = {
cloneViewModel.cancelClone()
}
) {
Text("Cancel")
}
}
}
@Composable
private fun Cancelling() {
Column (
modifier = Modifier
.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
CircularProgressIndicator(
modifier = Modifier.padding(horizontal = 16.dp)
)
Text(
text = "Cancelling clone operation...",
color = MaterialTheme.colors.primaryTextColor,
modifier = Modifier.padding(vertical = 16.dp),
)
}
}

View File

@ -0,0 +1,86 @@
package app.viewmodels
import app.git.CloneStatus
import app.git.RemoteOperationsManager
import app.git.TabState
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.flowOn
import java.io.File
import javax.inject.Inject
class CloneViewModel @Inject constructor(
private val tabState: TabState,
private val remoteOperationsManager: RemoteOperationsManager,
) {
private val _cloneStatus = MutableStateFlow<CloneStatus>(CloneStatus.None)
val cloneStatus: StateFlow<CloneStatus>
get() = _cloneStatus
var url: String = ""
var directory: String = ""
private var cloneJob: Job? = null
fun clone(directoryPath: String, url: String) {
cloneJob = tabState.safeProcessingWihoutGit {
if (directoryPath.isBlank()) {
_cloneStatus.value = CloneStatus.Fail("Check your URL and try again")
return@safeProcessingWihoutGit
}
if (url.isBlank()) {
_cloneStatus.value = CloneStatus.Fail("Check your URL and try again")
return@safeProcessingWihoutGit
}
val urlSplit = url.split("/", "\\").toMutableList()
// Removes the last element for URLs that end with "/" or "\" instead of the repo name like https://github.com/JetpackDuba/Gitnuro/
if(urlSplit.isNotEmpty() && urlSplit.last().isBlank()) {
urlSplit.removeLast()
}
// Take the last element of the path/URL to generate obtain the repo name
val repoName = urlSplit.lastOrNull()?.replace(".git", "")
if (repoName.isNullOrBlank()) {
_cloneStatus.value = CloneStatus.Fail("Check your URL and try again")
return@safeProcessingWihoutGit
}
val directory = File(directoryPath)
if (!directory.exists()) {
directory.mkdirs()
}
val repoDir = File(directory, repoName)
if (!repoDir.exists()) {
repoDir.mkdir()
}
remoteOperationsManager.clone(repoDir, url)
.flowOn(Dispatchers.IO)
.collect { newCloneStatus ->
_cloneStatus.value = newCloneStatus
}
}
}
fun reset() {
_cloneStatus.value = CloneStatus.None
url = ""
directory = ""
}
fun cancelClone() {
cloneJob?.cancel()
_cloneStatus.value = CloneStatus.Cancelling
}
}

View File

@ -19,6 +19,11 @@ import javax.inject.Inject
private const val MIN_TIME_IN_MS_BETWEEN_REFRESHES = 500L
/**
* Contains all the information related to a tab and its subcomponents (smaller composables like the log, branches,
* commit changes, etc.). It holds a reference to every view model because this class lives as long as the tab is open (survives
* across full app recompositions), therefore, tab's content can be recreated with these view models.
*/
class TabViewModel @Inject constructor(
val logViewModel: LogViewModel,
val branchesViewModel: BranchesViewModel,
@ -29,8 +34,8 @@ class TabViewModel @Inject constructor(
val menuViewModel: MenuViewModel,
val stashesViewModel: StashesViewModel,
val commitChangesViewModel: CommitChangesViewModel,
val cloneViewModel: CloneViewModel,
private val repositoryManager: RepositoryManager,
private val remoteOperationsManager: RemoteOperationsManager,
private val tabState: TabState,
val appStateManager: AppStateManager,
private val fileChangesWatcher: FileChangesWatcher,
@ -47,7 +52,6 @@ class TabViewModel @Inject constructor(
val processing: StateFlow<Boolean> = tabState.processing
val credentialsState: StateFlow<CredentialsState> = credentialsStateManager.credentialsState
val cloneStatus: StateFlow<CloneStatus> = remoteOperationsManager.cloneStatus
private val _diffSelected = MutableStateFlow<DiffEntryType?>(null)
val diffSelected: StateFlow<DiffEntryType?> = _diffSelected
@ -243,10 +247,6 @@ class TabViewModel @Inject constructor(
tabState.managerScope.cancel()
}
fun clone(directory: File, url: String) = tabState.safeProcessingWihoutGit {
remoteOperationsManager.clone(directory, url)
}
private fun updateDiffEntry() {
val diffSelected = diffSelected.value