diff --git a/src/main/kotlin/app/exceptions/InvalidDirectoryException.kt b/src/main/kotlin/app/exceptions/InvalidDirectoryException.kt new file mode 100644 index 0000000..d09210d --- /dev/null +++ b/src/main/kotlin/app/exceptions/InvalidDirectoryException.kt @@ -0,0 +1,3 @@ +package app.exceptions + +class InvalidDirectoryException(msg: String) : GitnuroException(msg) \ No newline at end of file diff --git a/src/main/kotlin/app/git/RemoteOperationsManager.kt b/src/main/kotlin/app/git/RemoteOperationsManager.kt index 96c972f..f533f70 100644 --- a/src/main/kotlin/app/git/RemoteOperationsManager.kt +++ b/src/main/kotlin/app/git/RemoteOperationsManager.kt @@ -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.None) - val cloneStatus: StateFlow - 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 = callbackFlow { try { - _cloneStatus.value = CloneStatus.Cloning(0) + ensureActive() + this.trySend(CloneStatus.Cloning(0)) Git.cloneRepository() .setDirectory(directory) .setURI(url) - .setProgressMonitor(object : ProgressMonitor { - override fun start(totalTasks: Int) { - println("ProgressMonitor Start") - } + .setProgressMonitor( + object : ProgressMonitor { + override fun start(totalTasks: Int) { + println("ProgressMonitor Start with total tasks of: $totalTasks") + } - override fun beginTask(title: String?, totalWork: Int) { - println("ProgressMonitor Begin task") - } + override fun beginTask(title: String?, totalWork: Int) { + println("ProgressMonitor Begin task with title: $title") + } - override fun update(completed: Int) { - println("ProgressMonitor Update $completed") - _cloneStatus.value = CloneStatus.Cloning(completed) - } + override fun update(completed: Int) { + println("ProgressMonitor Update $completed") + ensureActive() + trySend(CloneStatus.Cloning(completed)) + } - override fun endTask() { - println("ProgressMonitor End task") - _cloneStatus.value = CloneStatus.CheckingOut - } + override fun endTask() { + println("ProgressMonitor End task") + ensureActive() + trySend(CloneStatus.CheckingOut) + } - override fun isCancelled(): Boolean { - return !isActive + 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)) + } + + channel.close() } + + awaitClose() } - - fun resetCloneStatus() { - _cloneStatus.value = CloneStatus.None - } - - } 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() } \ No newline at end of file diff --git a/src/main/kotlin/app/git/TabState.kt b/src/main/kotlin/app/git/TabState.kt index 621bdaf..49054a9 100644 --- a/src/main/kotlin/app/git/TabState.kt +++ b/src/main/kotlin/app/git/TabState.kt @@ -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() diff --git a/src/main/kotlin/app/theme/Color.kt b/src/main/kotlin/app/theme/Color.kt index 889d9b2..63ca46e 100644 --- a/src/main/kotlin/app/theme/Color.kt +++ b/src/main/kotlin/app/theme/Color.kt @@ -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) diff --git a/src/main/kotlin/app/theme/Theme.kt b/src/main/kotlin/app/theme/Theme.kt index e1e802e..5138a8d 100644 --- a/src/main/kotlin/app/theme/Theme.kt +++ b/src/main/kotlin/app/theme/Theme.kt @@ -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 diff --git a/src/main/kotlin/app/ui/AppTab.kt b/src/main/kotlin/app/ui/AppTab.kt index 71be35b..042641b 100644 --- a/src/main/kotlin/app/ui/AppTab.kt +++ b/src/main/kotlin/app/ui/AppTab.kt @@ -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) diff --git a/src/main/kotlin/app/ui/WelcomePage.kt b/src/main/kotlin/app/ui/WelcomePage.kt index 1384293..3113097 100644 --- a/src/main/kotlin/app/ui/WelcomePage.kt +++ b/src/main/kotlin/app/ui/WelcomePage.kt @@ -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 diff --git a/src/main/kotlin/app/ui/dialogs/CloneDialog.kt b/src/main/kotlin/app/ui/dialogs/CloneDialog.kt index d39b2f6..f3ecfca 100644 --- a/src/main/kotlin/app/ui/dialogs/CloneDialog.kt +++ b/src/main/kotlin/app/ui/dialogs/CloneDialog.kt @@ -1,68 +1,183 @@ 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) - 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( - "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 } ) - OutlinedTextField( + Row( modifier = Modifier - .width(400.dp) + .fillMaxWidth() .padding(vertical = 4.dp, horizontal = 8.dp), - label = { Text("Directory") }, - textStyle = TextStyle(fontSize = 14.sp, color = MaterialTheme.colors.primaryTextColor), - maxLines = 1, - value = directory, - onValueChange = { - directory = it + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedTextField( + modifier = Modifier + .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 @@ -70,7 +185,12 @@ fun CloneDialog( .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,11 +199,61 @@ fun CloneDialog( } Button( onClick = { - gitManager.clone(File(directory), url) - } + cloneViewModel.clone(directory, url) + }, + modifier = Modifier + .focusOrder(cloneButtonFocusRequester) { + previous = directoryButtonFocusRequester + next = cancelButtonFocusRequester + } ) { 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), + ) + } } \ No newline at end of file diff --git a/src/main/kotlin/app/viewmodels/CloneViewModel.kt b/src/main/kotlin/app/viewmodels/CloneViewModel.kt new file mode 100644 index 0000000..ac62449 --- /dev/null +++ b/src/main/kotlin/app/viewmodels/CloneViewModel.kt @@ -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.None) + val cloneStatus: StateFlow + 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 + } +} \ No newline at end of file diff --git a/src/main/kotlin/app/viewmodels/TabViewModel.kt b/src/main/kotlin/app/viewmodels/TabViewModel.kt index 03b7a29..98961f9 100644 --- a/src/main/kotlin/app/viewmodels/TabViewModel.kt +++ b/src/main/kotlin/app/viewmodels/TabViewModel.kt @@ -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 = tabState.processing val credentialsState: StateFlow = credentialsStateManager.credentialsState - val cloneStatus: StateFlow = remoteOperationsManager.cloneStatus private val _diffSelected = MutableStateFlow(null) val diffSelected: StateFlow = _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