diff --git a/src/main/kotlin/app/exceptions/GitnuroException.kt b/src/main/kotlin/app/exceptions/GitnuroException.kt new file mode 100644 index 0000000..6562632 --- /dev/null +++ b/src/main/kotlin/app/exceptions/GitnuroException.kt @@ -0,0 +1,3 @@ +package app.exceptions + +abstract class GitnuroException(msg: String) : RuntimeException(msg) \ No newline at end of file diff --git a/src/main/kotlin/app/exceptions/InvalidRemoteUrlException.kt b/src/main/kotlin/app/exceptions/InvalidRemoteUrlException.kt new file mode 100644 index 0000000..b77b5a2 --- /dev/null +++ b/src/main/kotlin/app/exceptions/InvalidRemoteUrlException.kt @@ -0,0 +1,3 @@ +package app.exceptions + +class InvalidRemoteUrlException(msg: String) : GitnuroException(msg) \ No newline at end of file diff --git a/src/main/kotlin/app/git/BranchesManager.kt b/src/main/kotlin/app/git/BranchesManager.kt index 22187d4..2c47994 100644 --- a/src/main/kotlin/app/git/BranchesManager.kt +++ b/src/main/kotlin/app/git/BranchesManager.kt @@ -55,6 +55,14 @@ class BranchesManager @Inject constructor() { .call() } + suspend fun deleteLocallyRemoteBranches(git: Git, branches: List) = withContext(Dispatchers.IO) { + git + .branchDelete() + .setBranchNames(*branches.toTypedArray()) + .setForce(true) + .call() + } + suspend fun remoteBranches(git: Git) = withContext(Dispatchers.IO) { git .branchList() diff --git a/src/main/kotlin/app/git/RemotesManager.kt b/src/main/kotlin/app/git/RemotesManager.kt index a892f9e..b1a72ee 100644 --- a/src/main/kotlin/app/git/RemotesManager.kt +++ b/src/main/kotlin/app/git/RemotesManager.kt @@ -3,13 +3,13 @@ package app.git import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.eclipse.jgit.api.Git +import org.eclipse.jgit.api.RemoteSetUrlCommand import org.eclipse.jgit.lib.Ref import org.eclipse.jgit.transport.RemoteConfig +import org.eclipse.jgit.transport.URIish import javax.inject.Inject class RemotesManager @Inject constructor() { - - suspend fun loadRemotes(git: Git, allRemoteBranches: List) = withContext(Dispatchers.IO) { val remotes = git.remoteList() .call() @@ -21,6 +21,28 @@ class RemotesManager @Inject constructor() { RemoteInfo(remoteConfig, remoteBranches) } } + + suspend fun deleteRemote(git: Git, remoteName: String) = withContext(Dispatchers.IO) { + git.remoteRemove() + .setRemoteName(remoteName) + .call() + } + + suspend fun addRemote(git: Git, remoteName: String, fetchUri: String) = withContext(Dispatchers.IO) { + git.remoteAdd() + .setName(remoteName) + .setUri(URIish(fetchUri)) + .call() + } + + suspend fun updateRemote(git: Git, remoteName: String, uri: String, uriType: RemoteSetUrlCommand.UriType) = + withContext(Dispatchers.IO) { + git.remoteSetUrl() + .setRemoteName(remoteName) + .setRemoteUri(URIish(uri)) + .setUriType(uriType) + .call() + } } data class RemoteInfo(val remoteConfig: RemoteConfig, val branchesList: List) \ 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 f74cc8f..621bdaf 100644 --- a/src/main/kotlin/app/git/TabState.kt +++ b/src/main/kotlin/app/git/TabState.kt @@ -159,10 +159,6 @@ class TabState @Inject constructor( fun newSelectedItem(selectedItem: SelectedItem) { _selectedItem.value = selectedItem - println(selectedItem) -// if (selectedItem is SelectedItem.CommitBasedItem) { -// commitChangesViewModel.loadChanges(selectedItem.revCommit) -// } } } @@ -173,4 +169,5 @@ enum class RefreshType { STASHES, UNCOMMITED_CHANGES, UNCOMMITED_CHANGES_AND_LOG, + REMOTES, } \ No newline at end of file diff --git a/src/main/kotlin/app/theme/Theme.kt b/src/main/kotlin/app/theme/Theme.kt index ae47024..d3f127a 100644 --- a/src/main/kotlin/app/theme/Theme.kt +++ b/src/main/kotlin/app/theme/Theme.kt @@ -53,6 +53,13 @@ val Colors.inversePrimaryTextColor: Color val Colors.secondaryTextColor: Color get() = if (isLight) secondaryText else secondaryTextDark +@get:Composable +val Colors.semiSecondaryTextColor: Color + get() = if (isLight) + secondaryText.copy(alpha = 0.3f) + else + secondaryTextDark.copy(alpha = 0.3f) + @get:Composable val Colors.headerBackground: Color get() { diff --git a/src/main/kotlin/app/ui/AppTab.kt b/src/main/kotlin/app/ui/AppTab.kt index f64de3f..1265366 100644 --- a/src/main/kotlin/app/ui/AppTab.kt +++ b/src/main/kotlin/app/ui/AppTab.kt @@ -98,7 +98,7 @@ fun AppTab( .padding(end = 32.dp, bottom = 32.dp) ) { val interactionSource = remember { MutableInteractionSource() } - + // TODO: Rework popup to appear on top of every other UI component, even dialogs Card( modifier = Modifier .defaultMinSize(minWidth = 200.dp, minHeight = 100.dp) diff --git a/src/main/kotlin/app/ui/Branches.kt b/src/main/kotlin/app/ui/Branches.kt index 38230d8..3c64137 100644 --- a/src/main/kotlin/app/ui/Branches.kt +++ b/src/main/kotlin/app/ui/Branches.kt @@ -14,11 +14,13 @@ import app.extensions.simpleName import app.ui.components.SideMenuPanel import app.ui.components.SideMenuSubentry import app.ui.context_menu.branchContextMenuItems +import app.ui.context_menu.remoteContextMenu import app.ui.dialogs.MergeDialog import app.ui.dialogs.RebaseDialog import app.viewmodels.BranchesViewModel import org.eclipse.jgit.lib.Ref +@OptIn(ExperimentalFoundationApi::class) @Composable fun Branches( branchesViewModel: BranchesViewModel, @@ -31,18 +33,19 @@ fun Branches( SideMenuPanel( title = "Local branches", icon = painterResource("branch.svg"), - items = branches - ) { branch -> - BranchLineEntry( - branch = branch, - isCurrentBranch = currentBranch == branch.name, - onBranchClicked = { branchesViewModel.selectBranch(branch) }, - onCheckoutBranch = { branchesViewModel.checkoutRef(branch) }, - onMergeBranch = { setMergeBranch(branch) }, - onDeleteBranch = { branchesViewModel.deleteBranch(branch) }, - onRebaseBranch = { setRebaseBranch(branch) }, - ) - } + items = branches, + itemContent = { branch -> + BranchLineEntry( + branch = branch, + isCurrentBranch = currentBranch == branch.name, + onBranchClicked = { branchesViewModel.selectBranch(branch) }, + onCheckoutBranch = { branchesViewModel.checkoutRef(branch) }, + onMergeBranch = { setMergeBranch(branch) }, + onDeleteBranch = { branchesViewModel.deleteBranch(branch) }, + onRebaseBranch = { setRebaseBranch(branch) }, + ) + } + ) if (mergeBranch != null) { MergeDialog( diff --git a/src/main/kotlin/app/ui/Remotes.kt b/src/main/kotlin/app/ui/Remotes.kt index e9a937c..98a1fa7 100644 --- a/src/main/kotlin/app/ui/Remotes.kt +++ b/src/main/kotlin/app/ui/Remotes.kt @@ -1,19 +1,30 @@ +@file:OptIn(ExperimentalFoundationApi::class) + package app.ui import androidx.compose.foundation.ContextMenuArea import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.Column -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.MaterialTheme +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Settings +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp import app.extensions.simpleName +import app.theme.primaryTextColor import app.ui.components.SideMenuPanel import app.ui.components.SideMenuSubentry import app.ui.components.VerticalExpandable import app.ui.context_menu.remoteBranchesContextMenu +import app.ui.context_menu.remoteContextMenu +import app.ui.dialogs.EditRemotesDialog import app.viewmodels.RemoteView import app.viewmodels.RemotesViewModel import org.eclipse.jgit.lib.Ref @@ -23,6 +34,7 @@ fun Remotes( remotesViewModel: RemotesViewModel, ) { val remotes by remotesViewModel.remotes.collectAsState() + var showEditRemotesDialog by remember { mutableStateOf(false) } val itemsCount = remember(remotes) { val allBranches = remotes.filter { remoteView -> @@ -34,11 +46,39 @@ fun Remotes( allBranches.count() + remotes.count() } + if (showEditRemotesDialog) { + EditRemotesDialog( + remotesViewModel = remotesViewModel, + onDismiss = { + showEditRemotesDialog = false + }, + ) + } + SideMenuPanel( title = "Remotes", icon = painterResource("cloud.svg"), items = remotes, itemsCountForMaxHeight = itemsCount, + contextItems = { + remoteContextMenu { showEditRemotesDialog = true } + }, + headerHoverIcon = { + IconButton( + onClick = { showEditRemotesDialog = true }, + modifier = Modifier + .padding(end = 8.dp) + .size(16.dp), + ) { + Icon( + painter = painterResource("settings.svg"), + contentDescription = null, + modifier = Modifier + .fillMaxSize(), + tint = MaterialTheme.colors.primaryTextColor, + ) + } + }, itemContent = { remoteInfo -> RemoteRow( remote = remoteInfo, @@ -50,6 +90,7 @@ fun Remotes( ) } + @OptIn(ExperimentalFoundationApi::class) @Composable private fun RemoteRow( diff --git a/src/main/kotlin/app/ui/Tags.kt b/src/main/kotlin/app/ui/Tags.kt index 6dd28e5..0c128bf 100644 --- a/src/main/kotlin/app/ui/Tags.kt +++ b/src/main/kotlin/app/ui/Tags.kt @@ -12,6 +12,7 @@ import app.ui.context_menu.tagContextMenuItems import app.viewmodels.TagsViewModel import org.eclipse.jgit.lib.Ref +@OptIn(ExperimentalFoundationApi::class) @Composable fun Tags( tagsViewModel: TagsViewModel, diff --git a/src/main/kotlin/app/ui/components/SideMenuEntry.kt b/src/main/kotlin/app/ui/components/SideMenuEntry.kt index db20cb7..8ae30c2 100644 --- a/src/main/kotlin/app/ui/components/SideMenuEntry.kt +++ b/src/main/kotlin/app/ui/components/SideMenuEntry.kt @@ -1,11 +1,15 @@ package app.ui.components import androidx.compose.foundation.background +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.HoverInteraction +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState import androidx.compose.foundation.layout.* import androidx.compose.material.Icon import androidx.compose.material.MaterialTheme import androidx.compose.material.Text -import androidx.compose.runtime.Composable +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.painter.Painter @@ -21,11 +25,16 @@ fun SideMenuEntry( text: String, icon: Painter? = null, itemsCount: Int, + hoverIcon: @Composable (() -> Unit)? = null, ) { + val hoverInteraction = remember { MutableInteractionSource() } + val isHovered by hoverInteraction.collectIsHoveredAsState() + Row( modifier = Modifier .height(32.dp) .fillMaxWidth() + .hoverable(hoverInteraction) .background(color = MaterialTheme.colors.headerBackground), verticalAlignment = Alignment.CenterVertically, ) { @@ -51,12 +60,14 @@ fun SideMenuEntry( overflow = TextOverflow.Ellipsis, ) - - Text( - text = itemsCount.toString(), - fontSize = 14.sp, - color = MaterialTheme.colors.secondaryTextColor, - modifier = Modifier.padding(end = 8.dp), - ) + if(hoverIcon != null && isHovered) { + hoverIcon() + } else + Text( + text = itemsCount.toString(), + fontSize = 14.sp, + color = MaterialTheme.colors.secondaryTextColor, + modifier = Modifier.padding(end = 8.dp), + ) } } \ No newline at end of file diff --git a/src/main/kotlin/app/ui/components/SideMenuPanel.kt b/src/main/kotlin/app/ui/components/SideMenuPanel.kt index e74a74d..060bc65 100644 --- a/src/main/kotlin/app/ui/components/SideMenuPanel.kt +++ b/src/main/kotlin/app/ui/components/SideMenuPanel.kt @@ -1,5 +1,7 @@ package app.ui.components +import androidx.compose.foundation.ContextMenuArea +import androidx.compose.foundation.ContextMenuItem import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.layout.fillMaxWidth @@ -21,16 +23,23 @@ fun SideMenuPanel( items: List, itemsCountForMaxHeight: Int = items.count(), itemContent: @Composable (T) -> Unit, + headerHoverIcon: @Composable (() -> Unit)? = null, + contextItems: () -> List = { emptyList() }, ) { val maxHeight = remember(items) { maxSidePanelHeight(itemsCountForMaxHeight) } VerticalExpandable( header = { - SideMenuEntry( - text = title, - icon = icon, - itemsCount = items.count() - ) + ContextMenuArea( + items = contextItems + ) { + SideMenuEntry( + text = title, + icon = icon, + itemsCount = items.count(), + hoverIcon = headerHoverIcon + ) + } }, ) { ScrollableLazyColumn( diff --git a/src/main/kotlin/app/ui/context_menu/RemoteContextMenu.kt b/src/main/kotlin/app/ui/context_menu/RemoteContextMenu.kt new file mode 100644 index 0000000..eed717c --- /dev/null +++ b/src/main/kotlin/app/ui/context_menu/RemoteContextMenu.kt @@ -0,0 +1,14 @@ +package app.ui.context_menu + +import androidx.compose.foundation.ContextMenuItem +import androidx.compose.foundation.ExperimentalFoundationApi + +@OptIn(ExperimentalFoundationApi::class) +fun remoteContextMenu( + onEditRemotes: () -> Unit, +) = listOf( + ContextMenuItem( + label = "Edit remotes", + onClick = onEditRemotes + ), +) \ No newline at end of file diff --git a/src/main/kotlin/app/ui/dialogs/EditRemotesDialog.kt b/src/main/kotlin/app/ui/dialogs/EditRemotesDialog.kt new file mode 100644 index 0000000..ff8643f --- /dev/null +++ b/src/main/kotlin/app/ui/dialogs/EditRemotesDialog.kt @@ -0,0 +1,358 @@ +package app.ui.dialogs + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Clear +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.dp +import app.theme.primaryTextColor +import app.theme.secondaryTextColor +import app.theme.semiSecondaryTextColor +import app.viewmodels.RemotesViewModel +import org.eclipse.jgit.transport.RemoteConfig + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun EditRemotesDialog( + remotesViewModel: RemotesViewModel, + onDismiss: () -> Unit, +) { + var remotesEditorData by remember { + mutableStateOf( + RemotesEditorData( + emptyList(), + null, + ) + ) + } + + val remotes by remotesViewModel.remotes.collectAsState() + var remoteChanged by remember { mutableStateOf(false) } + val selectedRemote = remotesEditorData.selectedRemote + + + LaunchedEffect(remotes) { + val newRemotesWrappers = remotes.map { + val remoteConfig = it.remoteInfo.remoteConfig + remoteConfig.toRemoteWrapper() + } + + val safeSelectedRemote = remotesEditorData.selectedRemote + var newSelectedRemote: RemoteWrapper? = null + + if (safeSelectedRemote != null) { + newSelectedRemote = newRemotesWrappers.firstOrNull { it.remoteName == safeSelectedRemote.remoteName } + } + + remoteChanged = newSelectedRemote?.haveUrisChanged ?: false + + remotesEditorData = remotesEditorData.copy( + listRemotes = newRemotesWrappers, + selectedRemote = newSelectedRemote, + ) + } + + MaterialDialog( + paddingVertical = 8.dp, + paddingHorizontal = 16.dp, + ) { + Column( + modifier = Modifier + .size(width = 900.dp, height = 600.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Row( + modifier = Modifier, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "Remotes", + color = MaterialTheme.colors.primaryTextColor, + modifier = Modifier.padding(vertical = 8.dp), + ) + + Spacer(modifier = Modifier.weight(1f)) + + IconButton( + onClick = onDismiss, + ) { + Icon( + imageVector = Icons.Default.Clear, + contentDescription = null, + tint = MaterialTheme.colors.primaryTextColor, + ) + } + } + Row( + modifier = Modifier + .padding(bottom = 8.dp) + .border( + width = 1.dp, + shape = RoundedCornerShape(5.dp), + color = MaterialTheme.colors.semiSecondaryTextColor, + ) + .background(MaterialTheme.colors.surface) + ) { + Column( + modifier = Modifier + .width(200.dp) + .fillMaxHeight(), + ) { + LazyColumn( + modifier = Modifier.weight(1f) + ) { + items(remotesEditorData.listRemotes) { remote -> + val background = if(remote == selectedRemote) { + MaterialTheme.colors.background + } else + MaterialTheme.colors.surface + + Text( + text = remote.remoteName, + color = MaterialTheme.colors.primaryTextColor, + modifier = Modifier + .fillMaxWidth() + .clickable { + remotesEditorData = remotesEditorData.copy(selectedRemote = remote) + } + .background(background) + .padding(horizontal = 16.dp, vertical = 8.dp) + ) + } + } + + Row( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colors.background) + ) { + IconButton( + modifier = Modifier.size(36.dp), + onClick = { + val remotesWithNew = remotesEditorData.listRemotes.toMutableList() + val newRemote = RemoteWrapper( + remoteName = "new_remote", + fetchUri = "", + originalFetchUri = "", + pushUri = "", + originalPushUri = "", + isNew = true + ) + + remotesWithNew.add(newRemote) + + remotesEditorData = remotesEditorData.copy( + listRemotes = remotesWithNew, + selectedRemote = newRemote + ) + } + ) { + Icon( + painter = painterResource("add.svg"), + contentDescription = null, + tint = MaterialTheme.colors.primaryTextColor, + ) + } + IconButton( + modifier = Modifier.size(36.dp), + enabled = selectedRemote != null, + onClick = { + if (selectedRemote != null) + remotesViewModel.deleteRemote(selectedRemote.remoteName, selectedRemote.isNew) + } + ) { + Icon( + painter = painterResource("remove.svg"), + contentDescription = null, + tint = if (selectedRemote != null) + MaterialTheme.colors.primaryTextColor + else + MaterialTheme.colors.secondaryTextColor, + ) + } + } + } + + Box( + modifier = Modifier + .fillMaxHeight() + .width(1.dp) + .background(MaterialTheme.colors.semiSecondaryTextColor) + ) + + Column( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colors.surface) + .padding(horizontal = 8.dp) + ) { + if (selectedRemote != null) { + Column { + if (selectedRemote.isNew) { + Text( + text = "New remote name", + color = MaterialTheme.colors.primaryTextColor, + modifier = Modifier.padding(vertical = 8.dp), + ) + + OutlinedTextField( + value = selectedRemote.remoteName, + onValueChange = { newValue -> + val newSelectedRemoteConfig = selectedRemote.copy(remoteName = newValue) + val listRemotes = remotesEditorData.listRemotes.toMutableList() + val newRemoteToBeReplacedIndex = listRemotes.indexOfFirst { it.isNew } + listRemotes[newRemoteToBeReplacedIndex] = newSelectedRemoteConfig + + remotesEditorData = remotesEditorData.copy( + listRemotes = listRemotes, + selectedRemote = newSelectedRemoteConfig + ) + }, + textStyle = TextStyle.Default.copy(color = MaterialTheme.colors.primaryTextColor), + maxLines = 1, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp) + ) + } + + Text( + text = "Fetch URL", + color = MaterialTheme.colors.primaryTextColor, + modifier = Modifier.padding(vertical = 8.dp), + ) + + OutlinedTextField( + value = selectedRemote.fetchUri, + onValueChange = { newValue -> + val newSelectedRemoteConfig = selectedRemote.copy(fetchUri = newValue) + remotesEditorData = remotesEditorData.copy(selectedRemote = newSelectedRemoteConfig) + remoteChanged = newSelectedRemoteConfig.haveUrisChanged + }, + textStyle = TextStyle.Default.copy(color = MaterialTheme.colors.primaryTextColor), + maxLines = 1, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp) + ) + + Text( + text = "Push URL", + color = MaterialTheme.colors.primaryTextColor, + modifier = Modifier.padding(vertical = 8.dp), + ) + + OutlinedTextField( + value = selectedRemote.pushUri, + onValueChange = { newValue -> + val newSelectedRemoteConfig = selectedRemote.copy(pushUri = newValue) + remotesEditorData = remotesEditorData.copy(selectedRemote = newSelectedRemoteConfig) + remoteChanged = newSelectedRemoteConfig.haveUrisChanged + }, + textStyle = TextStyle.Default.copy(color = MaterialTheme.colors.primaryTextColor), + maxLines = 1, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp) + ) + + Spacer(modifier = Modifier.weight(1f)) + + Row( + modifier = Modifier + .padding(top = 16.dp) + .align(Alignment.End) + ) { + Spacer(modifier = Modifier.weight(1f)) + + if (!selectedRemote.isNew) { + TextButton( + modifier = Modifier.padding(end = 8.dp), + enabled = remoteChanged, + onClick = { + remotesEditorData = remotesEditorData.copy( + selectedRemote = selectedRemote.copy( + fetchUri = selectedRemote.originalFetchUri, + pushUri = selectedRemote.originalPushUri, + ) + ) + + remoteChanged = false + } + ) { + Text("Discard changes") + } + } + + Button( + modifier = Modifier, + enabled = remoteChanged, + onClick = { + if (selectedRemote.isNew) + remotesViewModel.addRemote(selectedRemote) + else + remotesViewModel.updateRemote(selectedRemote) + }, + ) { + val text = if (selectedRemote.isNew) + "Add new remote" + else + "Save ${selectedRemote.remoteName} changes" + + Text(text) + } + } + } + } + } + } + + } + } +} + +data class RemoteWrapper( + val remoteName: String, + val fetchUri: String, + val originalFetchUri: String, + val pushUri: String, + val originalPushUri: String, + val isNew: Boolean = false, +) { + val haveUrisChanged: Boolean = isNew || + fetchUri != originalFetchUri || + pushUri.toString() != originalPushUri +} + + +data class RemotesEditorData( + val listRemotes: List, + val selectedRemote: RemoteWrapper?, +) + +fun RemoteConfig.toRemoteWrapper(): RemoteWrapper { + val fetchUri = this.urIs.firstOrNull() + val pushUri = this.pushURIs.firstOrNull() + ?: this.urIs.firstOrNull() // If push URI == null, take fetch URI + + return RemoteWrapper( + remoteName = this.name, + fetchUri = fetchUri?.toString().orEmpty(), + originalFetchUri = fetchUri?.toString().orEmpty(), + pushUri = pushUri?.toString().orEmpty(), + originalPushUri = pushUri?.toString().orEmpty(), + ) +} \ No newline at end of file diff --git a/src/main/kotlin/app/ui/dialogs/MaterialDialog.kt b/src/main/kotlin/app/ui/dialogs/MaterialDialog.kt index a21f34b..0f0c7fa 100644 --- a/src/main/kotlin/app/ui/dialogs/MaterialDialog.kt +++ b/src/main/kotlin/app/ui/dialogs/MaterialDialog.kt @@ -18,6 +18,8 @@ import app.theme.dialogBackgroundColor @Composable fun MaterialDialog( alignment: Alignment = Alignment.Center, + paddingHorizontal: Dp = 16.dp, + paddingVertical: Dp = 16.dp, content: @Composable () -> Unit ) { Popup( @@ -41,7 +43,10 @@ fun MaterialDialog( modifier = Modifier .clip(RoundedCornerShape(15.dp)) .background(MaterialTheme.colors.background) - .padding(all = 16.dp) + .padding( + horizontal = paddingHorizontal, + vertical = paddingVertical, + ) ) { content() } diff --git a/src/main/kotlin/app/viewmodels/RemotesViewModel.kt b/src/main/kotlin/app/viewmodels/RemotesViewModel.kt index 800da98..fd94b38 100644 --- a/src/main/kotlin/app/viewmodels/RemotesViewModel.kt +++ b/src/main/kotlin/app/viewmodels/RemotesViewModel.kt @@ -1,12 +1,16 @@ package app.viewmodels +import app.exceptions.InvalidRemoteUrlException import app.git.* +import app.ui.dialogs.RemoteWrapper import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.withContext import org.eclipse.jgit.api.Git +import org.eclipse.jgit.api.RemoteSetUrlCommand import org.eclipse.jgit.lib.Ref +import org.eclipse.jgit.transport.URIish import javax.inject.Inject class RemotesViewModel @Inject constructor( @@ -62,6 +66,68 @@ class RemotesViewModel @Inject constructor( fun selectBranch(ref: Ref) { tabState.newSelectedRef(ref.objectId) } + + fun deleteRemote(remoteName: String, isNew: Boolean) = tabState.safeProcessing( + refreshType = if(isNew) RefreshType.REMOTES else RefreshType.ALL_DATA, + showError = true, + ) { git -> + remotesManager.deleteRemote(git, remoteName) + + val remoteBranches = branchesManager.remoteBranches(git) + val remoteToDeleteBranchesNames = remoteBranches.filter { + it.name.startsWith("refs/remotes/$remoteName/") + }.map { + it.name + } + + branchesManager.deleteLocallyRemoteBranches(git, remoteToDeleteBranchesNames) + } + + + fun addRemote(selectedRemoteConfig: RemoteWrapper) = tabState.runOperation( + refreshType = RefreshType.REMOTES, + showError = true, + ) { git -> + if(selectedRemoteConfig.fetchUri.isBlank()) { + throw InvalidRemoteUrlException("Invalid empty fetch URI") + } + + if(selectedRemoteConfig.pushUri.isBlank()) { + throw InvalidRemoteUrlException("Invalid empty push URI") + } + + remotesManager.addRemote(git, selectedRemoteConfig.remoteName, selectedRemoteConfig.fetchUri) + + updateRemote(selectedRemoteConfig) // Sets both, fetch and push uri + } + + fun updateRemote(selectedRemoteConfig: RemoteWrapper) = tabState.runOperation( + refreshType = RefreshType.REMOTES, + showError = true, + ) { git -> + + if(selectedRemoteConfig.fetchUri.isBlank()) { + throw InvalidRemoteUrlException("Invalid empty fetch URI") + } + + if(selectedRemoteConfig.pushUri.isBlank()) { + throw InvalidRemoteUrlException("Invalid empty push URI") + } + + remotesManager.updateRemote( + git = git, + remoteName = selectedRemoteConfig.remoteName, + uri = selectedRemoteConfig.fetchUri, + uriType = RemoteSetUrlCommand.UriType.FETCH + ) + + remotesManager.updateRemote( + git = git, + remoteName = selectedRemoteConfig.remoteName, + uri = selectedRemoteConfig.pushUri, + uriType = RemoteSetUrlCommand.UriType.PUSH + ) + } } data class RemoteView(val remoteInfo: RemoteInfo, val isExpanded: Boolean) \ No newline at end of file diff --git a/src/main/kotlin/app/viewmodels/StashesViewModel.kt b/src/main/kotlin/app/viewmodels/StashesViewModel.kt index 223cc4d..ab1a4d7 100644 --- a/src/main/kotlin/app/viewmodels/StashesViewModel.kt +++ b/src/main/kotlin/app/viewmodels/StashesViewModel.kt @@ -21,7 +21,7 @@ class StashesViewModel @Inject constructor( suspend fun loadStashes(git: Git) { _stashStatus.value = StashStatus.Loading val stashList = stashManager.getStashList(git) - _stashStatus.value = StashStatus.Loaded(stashList.toList()) // TODO: Is the list cast necessary? + _stashStatus.value = StashStatus.Loaded(stashList.toList()) } suspend fun refresh(git: Git) { diff --git a/src/main/kotlin/app/viewmodels/TabViewModel.kt b/src/main/kotlin/app/viewmodels/TabViewModel.kt index 5fdb5fc..c5eaa45 100644 --- a/src/main/kotlin/app/viewmodels/TabViewModel.kt +++ b/src/main/kotlin/app/viewmodels/TabViewModel.kt @@ -77,11 +77,18 @@ class TabViewModel @Inject constructor( RefreshType.STASHES -> refreshStashes() RefreshType.UNCOMMITED_CHANGES -> checkUncommitedChanges() RefreshType.UNCOMMITED_CHANGES_AND_LOG -> checkUncommitedChanges(true) + RefreshType.REMOTES -> refreshRemotes() } } } } + private fun refreshRemotes() = tabState.runOperation( + refreshType = RefreshType.NONE + ) { git -> + remotesViewModel.refresh(git) + } + private fun refreshStashes() = tabState.runOperation( refreshType = RefreshType.NONE ) { git -> diff --git a/src/main/resources/add.svg b/src/main/resources/add.svg new file mode 100644 index 0000000..bead882 --- /dev/null +++ b/src/main/resources/add.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/main/resources/remove.svg b/src/main/resources/remove.svg new file mode 100644 index 0000000..d84e9a6 --- /dev/null +++ b/src/main/resources/remove.svg @@ -0,0 +1 @@ + \ No newline at end of file