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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,68 +1,183 @@
package app.ui.dialogs package app.ui.dialogs
import androidx.compose.foundation.layout.Column import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.Row import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.background
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.* import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Search
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
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.text.TextStyle
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import app.git.CloneStatus import app.git.CloneStatus
import app.theme.primaryTextColor import app.theme.primaryTextColor
import app.viewmodels.TabViewModel import app.viewmodels.CloneViewModel
import openDirectoryDialog
import java.io.File import java.io.File
@Composable @Composable
fun CloneDialog( fun CloneDialog(
gitManager: TabViewModel, cloneViewModel: CloneViewModel,
onClose: () -> Unit onClose: () -> Unit,
onOpenRepository: (File) -> Unit,
) { ) {
val cloneStatus = gitManager.cloneStatus.collectAsState() val cloneStatus = cloneViewModel.cloneStatus.collectAsState()
val cloneStatusValue = cloneStatus.value 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)
onClose()
}
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( Text(
"Clone a repository", "Clone a new repository",
color = MaterialTheme.colors.primaryTextColor, color = MaterialTheme.colors.primaryTextColor,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp, horizontal = 8.dp)
) )
OutlinedTextField( OutlinedTextField(
modifier = Modifier modifier = Modifier
.width(400.dp) .fillMaxWidth()
.padding(vertical = 4.dp, horizontal = 8.dp), .padding(vertical = 4.dp, horizontal = 8.dp)
.focusOrder(urlFocusRequester) {
previous = cancelButtonFocusRequester
next = directoryFocusRequester
},
label = { Text("URL") }, label = { Text("URL") },
textStyle = TextStyle(fontSize = 14.sp, color = MaterialTheme.colors.primaryTextColor), textStyle = TextStyle(fontSize = 14.sp, color = MaterialTheme.colors.primaryTextColor),
maxLines = 1, maxLines = 1,
value = url, value = url,
onValueChange = { onValueChange = {
errorHasBeenNoticed = true
url = it url = it
} }
) )
OutlinedTextField( Row(
modifier = Modifier modifier = Modifier
.width(400.dp) .fillMaxWidth()
.padding(vertical = 4.dp, horizontal = 8.dp), .padding(vertical = 4.dp, horizontal = 8.dp),
label = { Text("Directory") }, verticalAlignment = Alignment.CenterVertically,
textStyle = TextStyle(fontSize = 14.sp, color = MaterialTheme.colors.primaryTextColor), ) {
maxLines = 1, OutlinedTextField(
value = directory, modifier = Modifier
onValueChange = { .weight(1f)
directory = it .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( Row(
modifier = Modifier modifier = Modifier
@ -70,7 +185,12 @@ fun CloneDialog(
.align(Alignment.End) .align(Alignment.End)
) { ) {
TextButton( TextButton(
modifier = Modifier.padding(end = 8.dp), modifier = Modifier
.padding(end = 8.dp)
.focusOrder(cancelButtonFocusRequester) {
previous = cloneButtonFocusRequester
next = urlFocusRequester
},
onClick = { onClick = {
onClose() onClose()
} }
@ -79,11 +199,61 @@ fun CloneDialog(
} }
Button( Button(
onClick = { onClick = {
gitManager.clone(File(directory), url) cloneViewModel.clone(directory, url)
} },
modifier = Modifier
.focusOrder(cloneButtonFocusRequester) {
previous = directoryButtonFocusRequester
next = cancelButtonFocusRequester
}
) { ) {
Text("Clone") Text("Clone")
} }
} }
} }
} }
@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 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( class TabViewModel @Inject constructor(
val logViewModel: LogViewModel, val logViewModel: LogViewModel,
val branchesViewModel: BranchesViewModel, val branchesViewModel: BranchesViewModel,
@ -29,8 +34,8 @@ class TabViewModel @Inject constructor(
val menuViewModel: MenuViewModel, val menuViewModel: MenuViewModel,
val stashesViewModel: StashesViewModel, val stashesViewModel: StashesViewModel,
val commitChangesViewModel: CommitChangesViewModel, val commitChangesViewModel: CommitChangesViewModel,
val cloneViewModel: CloneViewModel,
private val repositoryManager: RepositoryManager, private val repositoryManager: RepositoryManager,
private val remoteOperationsManager: RemoteOperationsManager,
private val tabState: TabState, private val tabState: TabState,
val appStateManager: AppStateManager, val appStateManager: AppStateManager,
private val fileChangesWatcher: FileChangesWatcher, private val fileChangesWatcher: FileChangesWatcher,
@ -47,7 +52,6 @@ class TabViewModel @Inject constructor(
val processing: StateFlow<Boolean> = tabState.processing val processing: StateFlow<Boolean> = tabState.processing
val credentialsState: StateFlow<CredentialsState> = credentialsStateManager.credentialsState val credentialsState: StateFlow<CredentialsState> = credentialsStateManager.credentialsState
val cloneStatus: StateFlow<CloneStatus> = remoteOperationsManager.cloneStatus
private val _diffSelected = MutableStateFlow<DiffEntryType?>(null) private val _diffSelected = MutableStateFlow<DiffEntryType?>(null)
val diffSelected: StateFlow<DiffEntryType?> = _diffSelected val diffSelected: StateFlow<DiffEntryType?> = _diffSelected
@ -243,10 +247,6 @@ class TabViewModel @Inject constructor(
tabState.managerScope.cancel() tabState.managerScope.cancel()
} }
fun clone(directory: File, url: String) = tabState.safeProcessingWihoutGit {
remoteOperationsManager.clone(directory, url)
}
private fun updateDiffEntry() { private fun updateDiffEntry() {
val diffSelected = diffSelected.value val diffSelected = diffSelected.value