From 0685986a13fbc5d6758e9a9c383cb2b8ec4f6d85 Mon Sep 17 00:00:00 2001 From: dizyaa Date: Mon, 13 Feb 2023 00:41:51 +0400 Subject: [PATCH] Add squash feature --- .../com/jetpackduba/gitnuro/git/TaskEvent.kt | 1 + .../git/branches/GetBranchByCommitUseCase.kt | 25 +++++ .../jetpackduba/gitnuro/ui/RepositoryOpen.kt | 3 + .../jetpackduba/gitnuro/ui/SquashCommits.kt | 101 ++++++++++++++++++ .../gitnuro/ui/context_menu/ContextMenu.kt | 9 +- .../gitnuro/ui/context_menu/LogContextMenu.kt | 8 ++ .../com/jetpackduba/gitnuro/ui/log/Log.kt | 6 ++ .../gitnuro/viewmodels/LogViewModel.kt | 28 +++++ .../viewmodels/SquashCommitsViewModel.kt | 94 ++++++++++++++++ .../gitnuro/viewmodels/TabViewModel.kt | 33 ++++-- .../gitnuro/viewmodels/TabViewModelsHolder.kt | 1 + 11 files changed, 300 insertions(+), 9 deletions(-) create mode 100644 src/main/kotlin/com/jetpackduba/gitnuro/git/branches/GetBranchByCommitUseCase.kt create mode 100644 src/main/kotlin/com/jetpackduba/gitnuro/ui/SquashCommits.kt create mode 100644 src/main/kotlin/com/jetpackduba/gitnuro/viewmodels/SquashCommitsViewModel.kt diff --git a/src/main/kotlin/com/jetpackduba/gitnuro/git/TaskEvent.kt b/src/main/kotlin/com/jetpackduba/gitnuro/git/TaskEvent.kt index 53687f6..fccb13f 100644 --- a/src/main/kotlin/com/jetpackduba/gitnuro/git/TaskEvent.kt +++ b/src/main/kotlin/com/jetpackduba/gitnuro/git/TaskEvent.kt @@ -5,5 +5,6 @@ import org.eclipse.jgit.revwalk.RevCommit sealed interface TaskEvent { data class RebaseInteractive(val revCommit: RevCommit) : TaskEvent + data class SquashCommits(val commits: List, val upstreamCommit: RevCommit) : TaskEvent data class ScrollToGraphItem(val selectedItem: SelectedItem) : TaskEvent } \ No newline at end of file diff --git a/src/main/kotlin/com/jetpackduba/gitnuro/git/branches/GetBranchByCommitUseCase.kt b/src/main/kotlin/com/jetpackduba/gitnuro/git/branches/GetBranchByCommitUseCase.kt new file mode 100644 index 0000000..f1e12e1 --- /dev/null +++ b/src/main/kotlin/com/jetpackduba/gitnuro/git/branches/GetBranchByCommitUseCase.kt @@ -0,0 +1,25 @@ +package com.jetpackduba.gitnuro.git.branches + +import com.jetpackduba.gitnuro.extensions.isBranch +import com.jetpackduba.gitnuro.extensions.isHead +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.eclipse.jgit.api.Git +import org.eclipse.jgit.api.ListBranchCommand +import org.eclipse.jgit.lib.ObjectId +import org.eclipse.jgit.lib.Ref +import org.eclipse.jgit.revwalk.RevCommit +import javax.inject.Inject + +class GetBranchByCommitUseCase @Inject constructor() { + + suspend operator fun invoke(git: Git, commit: RevCommit): Ref = withContext(Dispatchers.IO) { + git + .branchList() + .setContains(commit.id.name) + .setListMode(ListBranchCommand.ListMode.ALL) + .call() + .first { it.isBranch } + } + +} \ No newline at end of file diff --git a/src/main/kotlin/com/jetpackduba/gitnuro/ui/RepositoryOpen.kt b/src/main/kotlin/com/jetpackduba/gitnuro/ui/RepositoryOpen.kt index 3668f2c..85d8093 100644 --- a/src/main/kotlin/com/jetpackduba/gitnuro/ui/RepositoryOpen.kt +++ b/src/main/kotlin/com/jetpackduba/gitnuro/ui/RepositoryOpen.kt @@ -127,9 +127,12 @@ fun RepositoryOpenPage( } ) { val rebaseInteractiveViewModel = tabViewModel.rebaseInteractiveViewModel + val squashCommitsViewModel = tabViewModel.squashCommitsViewModel if (repositoryState == RepositoryState.REBASING_INTERACTIVE && rebaseInteractiveViewModel != null) { RebaseInteractive(rebaseInteractiveViewModel) + } else if (repositoryState == RepositoryState.REBASING_INTERACTIVE && squashCommitsViewModel != null) { + SquashCommits(squashCommitsViewModel) } else if (repositoryState == RepositoryState.REBASING_INTERACTIVE) { RebaseInteractiveStartedExternally( onCancelRebaseInteractive = { tabViewModel.cancelRebaseInteractive() } diff --git a/src/main/kotlin/com/jetpackduba/gitnuro/ui/SquashCommits.kt b/src/main/kotlin/com/jetpackduba/gitnuro/ui/SquashCommits.kt new file mode 100644 index 0000000..b265e35 --- /dev/null +++ b/src/main/kotlin/com/jetpackduba/gitnuro/ui/SquashCommits.kt @@ -0,0 +1,101 @@ +package com.jetpackduba.gitnuro.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.jetpackduba.gitnuro.ui.components.AdjustableOutlinedTextField +import com.jetpackduba.gitnuro.ui.components.PrimaryButton +import com.jetpackduba.gitnuro.viewmodels.SquashCommitsState +import com.jetpackduba.gitnuro.viewmodels.SquashCommitsViewModel + +@Composable +fun SquashCommits( + squashCommitsViewModel: SquashCommitsViewModel +) { + val state = squashCommitsViewModel.squashState.collectAsState() + val stateValue = state.value + + Box( + modifier = Modifier + .background(MaterialTheme.colors.surface) + .fillMaxSize(), + ) { + when (stateValue) { + is SquashCommitsState.Failed -> {} + is SquashCommitsState.Loaded -> { + SquashCommitsLoaded( + squashCommitsViewModel, + stateValue, + ) + } + + SquashCommitsState.Loading -> { + CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) + } + } + } +} + +@Composable +private fun SquashCommitsLoaded( + viewModel: SquashCommitsViewModel, + stateValue: SquashCommitsState.Loaded, +) { + Column { + Text( + text = "Edit message for squashed commits", + color = MaterialTheme.colors.onBackground, + modifier = Modifier.padding(start = 16.dp, top = 16.dp), + fontSize = 20.sp, + ) + + Column( + modifier = Modifier + .weight(1f) + ) { + AdjustableOutlinedTextField( + modifier = Modifier + .weight(1f) + .heightIn(min = 40.dp), + value = stateValue.message, + onValueChange = { + viewModel.editMessage(it) + }, + textStyle = MaterialTheme.typography.body2, + backgroundColor = MaterialTheme.colors.background + ) + } + + Row( + modifier = Modifier.padding(bottom = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Spacer(modifier = Modifier.weight(1f)) + PrimaryButton( + text = "Cancel", + modifier = Modifier.padding(end = 8.dp), + onClick = { + viewModel.cancel() + }, + backgroundColor = Color.Transparent, + textColor = MaterialTheme.colors.onBackground, + ) + PrimaryButton( + modifier = Modifier.padding(end = 16.dp), + onClick = { + viewModel.continueSquash() + }, + text = "OK" + ) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/jetpackduba/gitnuro/ui/context_menu/ContextMenu.kt b/src/main/kotlin/com/jetpackduba/gitnuro/ui/context_menu/ContextMenu.kt index 4513b73..a1fffa1 100644 --- a/src/main/kotlin/com/jetpackduba/gitnuro/ui/context_menu/ContextMenu.kt +++ b/src/main/kotlin/com/jetpackduba/gitnuro/ui/context_menu/ContextMenu.kt @@ -140,7 +140,11 @@ fun showPopup(x: Int, y: Int, contextMenuElements: List, onD Column(modifier = Modifier.verticalScroll(rememberScrollState())) { for (item in contextMenuElements) { when (item) { - is ContextMenuElement.ContextTextEntry -> TextEntry(item, onDismissRequest = onDismissRequest) + is ContextMenuElement.ContextTextEntry -> { + if (item.isVisible) { + TextEntry(item, onDismissRequest = onDismissRequest) + } + } ContextMenuElement.ContextSeparator -> Separator() } @@ -203,7 +207,8 @@ sealed interface ContextMenuElement { data class ContextTextEntry( val label: String, val icon: @Composable (() -> Painter)? = null, - val onClick: () -> Unit = {} + val onClick: () -> Unit = {}, + val isVisible: Boolean = true, ) : ContextMenuElement object ContextSeparator : ContextMenuElement diff --git a/src/main/kotlin/com/jetpackduba/gitnuro/ui/context_menu/LogContextMenu.kt b/src/main/kotlin/com/jetpackduba/gitnuro/ui/context_menu/LogContextMenu.kt index 07a2a79..6df5750 100644 --- a/src/main/kotlin/com/jetpackduba/gitnuro/ui/context_menu/LogContextMenu.kt +++ b/src/main/kotlin/com/jetpackduba/gitnuro/ui/context_menu/LogContextMenu.kt @@ -10,12 +10,20 @@ fun logContextMenu( onCherryPickCommit: () -> Unit, onResetBranch: () -> Unit, onRebaseInteractive: () -> Unit, + showSquashCommits: Boolean, + onSquashCommits: () -> Unit, ) = listOf( ContextMenuElement.ContextTextEntry( label = "Checkout commit", icon = { painterResource("start.svg") }, onClick = onCheckoutCommit ), + ContextMenuElement.ContextTextEntry( + label = "Squash commits", + icon = { painterResource("branch.svg") }, + isVisible = showSquashCommits, + onClick = onSquashCommits + ), ContextMenuElement.ContextTextEntry( label = "Create branch", icon = { painterResource("branch.svg") }, diff --git a/src/main/kotlin/com/jetpackduba/gitnuro/ui/log/Log.kt b/src/main/kotlin/com/jetpackduba/gitnuro/ui/log/Log.kt index 196d5de..4b7589b 100644 --- a/src/main/kotlin/com/jetpackduba/gitnuro/ui/log/Log.kt +++ b/src/main/kotlin/com/jetpackduba/gitnuro/ui/log/Log.kt @@ -429,6 +429,8 @@ fun MessagesList( onRevCommitSelected = { logViewModel.selectLogLine(graphNode, keyScope.isCtrlPressed, keyScope.isShiftPressed) }, + onSquashCommits = { logViewModel.squashCommits() }, + showSquashCommits = selectedItem is SelectedItem.MultiCommitBasedItem && selectedItem.itemList.size > 1 ) } @@ -764,6 +766,8 @@ fun CommitLine( onRebaseBranch: (Ref) -> Unit, onRevCommitSelected: () -> Unit, onRebaseInteractive: () -> Unit, + onSquashCommits: () -> Unit, + showSquashCommits: Boolean, ) { ContextMenu( items = { @@ -775,6 +779,8 @@ fun CommitLine( onCherryPickCommit = { logViewModel.cherrypickCommit(graphNode) }, onRebaseInteractive = onRebaseInteractive, onResetBranch = { resetBranch() }, + onSquashCommits = { onSquashCommits() }, + showSquashCommits = showSquashCommits, ) }, ) { diff --git a/src/main/kotlin/com/jetpackduba/gitnuro/viewmodels/LogViewModel.kt b/src/main/kotlin/com/jetpackduba/gitnuro/viewmodels/LogViewModel.kt index abb341d..8519985 100644 --- a/src/main/kotlin/com/jetpackduba/gitnuro/viewmodels/LogViewModel.kt +++ b/src/main/kotlin/com/jetpackduba/gitnuro/viewmodels/LogViewModel.kt @@ -10,6 +10,7 @@ import com.jetpackduba.gitnuro.git.branches.* import com.jetpackduba.gitnuro.git.graph.GraphCommitList import com.jetpackduba.gitnuro.git.graph.GraphNode import com.jetpackduba.gitnuro.git.log.* +import com.jetpackduba.gitnuro.git.rebase.GetRebaseLinesFullMessageUseCase import com.jetpackduba.gitnuro.git.rebase.RebaseBranchUseCase import com.jetpackduba.gitnuro.git.remote_operations.DeleteRemoteBranchUseCase import com.jetpackduba.gitnuro.git.remote_operations.PullFromSpecificBranchUseCase @@ -25,8 +26,11 @@ import com.jetpackduba.gitnuro.ui.log.LogDialog import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import org.eclipse.jgit.api.Git +import org.eclipse.jgit.api.RebaseCommand.InteractiveHandler import org.eclipse.jgit.api.errors.CheckoutConflictException +import org.eclipse.jgit.lib.RebaseTodoLine import org.eclipse.jgit.lib.Ref import org.eclipse.jgit.revwalk.RevCommit import javax.inject.Inject @@ -63,6 +67,7 @@ class LogViewModel @Inject constructor( private val createTagOnCommitUseCase: CreateTagOnCommitUseCase, private val deleteTagUseCase: DeleteTagUseCase, private val rebaseBranchUseCase: RebaseBranchUseCase, + private val getRebaseLinesFullMessageUseCase: GetRebaseLinesFullMessageUseCase, private val tabState: TabState, private val appSettings: AppSettings, private val tabScope: CoroutineScope, @@ -305,6 +310,29 @@ class LogViewModel @Inject constructor( NONE_MATCHING_INDEX } + fun squashCommits() = tabState.runOperation( + refreshType = RefreshType.NONE, + ) { + val selectedItem = tabState.selectedItem.value + val log = logStatus.value + + if (selectedItem is SelectedItem.MultiCommitBasedItem && log is LogStatus.Loaded) { + val firstCommit = selectedItem.itemList + .sortedBy { it.commitTime } + .minBy { it.commitTime } + + val firstCommitIndex = log.plotCommitList.indexOf(firstCommit) + val upstreamCommit = log.plotCommitList[firstCommitIndex + 1] + + tabState.emitNewTaskEvent( + TaskEvent.SquashCommits( + commits = selectedItem.itemList, + upstreamCommit = upstreamCommit + ) + ) + } + } + fun selectLogLine( commit: GraphNode, multiSelect: Boolean, diff --git a/src/main/kotlin/com/jetpackduba/gitnuro/viewmodels/SquashCommitsViewModel.kt b/src/main/kotlin/com/jetpackduba/gitnuro/viewmodels/SquashCommitsViewModel.kt new file mode 100644 index 0000000..e924a4f --- /dev/null +++ b/src/main/kotlin/com/jetpackduba/gitnuro/viewmodels/SquashCommitsViewModel.kt @@ -0,0 +1,94 @@ +package com.jetpackduba.gitnuro.viewmodels + +import com.jetpackduba.gitnuro.git.RefreshType +import com.jetpackduba.gitnuro.git.TabState +import com.jetpackduba.gitnuro.git.branches.GetBranchByCommitUseCase +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import org.eclipse.jgit.lib.RebaseTodoLine +import org.eclipse.jgit.revwalk.RevCommit +import javax.inject.Inject + +class SquashCommitsViewModel @Inject constructor( + private val tabState: TabState, + private val rebaseInteractiveViewModel: RebaseInteractiveViewModel, + private val getBranchByCommitUseCase: GetBranchByCommitUseCase, +) { + private val _squashState = MutableStateFlow(SquashCommitsState.Loading) + val squashState: StateFlow = _squashState + + private var commits: List = emptyList() + private var squashMessage: String? = null + + init { + tabState.runOperation( + refreshType = RefreshType.NONE + ) { + rebaseInteractiveViewModel.rebaseState.collect { state -> + _squashState.value = when (state) { + is RebaseInteractiveState.Failed -> SquashCommitsState.Failed(state.error) + is RebaseInteractiveState.Loading -> SquashCommitsState.Loading + is RebaseInteractiveState.Loaded -> { + SquashCommitsState.Loaded( + message = getDefaultSquashMessageFromCommits( + messages = commits.mapNotNull { state.messages[it.abbreviate(7).name()] } + ) + ) + } + } + } + } + } + + suspend fun startSquash(commits: List, upstreamCommit: RevCommit) = tabState.runOperation( + refreshType = RefreshType.ALL_DATA, + showError = true, + ) { git -> + commits + .groupBy { getBranchByCommitUseCase(git, it) } + .let { + if (it.size > 1) throw Exception("Squash is impossible") + } + + this.commits = commits + + rebaseInteractiveViewModel.startRebaseInteractive(upstreamCommit) + } + + fun cancel() { + rebaseInteractiveViewModel.cancel() + } + + fun editMessage(message: String) { + val state = _squashState.value + if (state !is SquashCommitsState.Loaded) return + _squashState.value = state.copy(message = message) + squashMessage = message + } + + fun continueSquash() { + commits.forEachIndexed { index, commit -> + val abbreviate = commit.abbreviate(7) + val action = if (index == commits.size - 1) RebaseTodoLine.Action.PICK else RebaseTodoLine.Action.SQUASH + rebaseInteractiveViewModel.onCommitActionChanged(abbreviate, action) + } + + rebaseInteractiveViewModel.continueRebaseInteractive() + } + + private fun getDefaultSquashMessageFromCommits(messages: List): String = buildString { + messages.forEachIndexed { index, value -> + append(value) + if (messages.size != index + 1) { + append("\n") + } + } + } + +} + +sealed interface SquashCommitsState { + object Loading : SquashCommitsState + data class Loaded(val message: String) : SquashCommitsState + data class Failed(val error: String) : SquashCommitsState +} \ No newline at end of file diff --git a/src/main/kotlin/com/jetpackduba/gitnuro/viewmodels/TabViewModel.kt b/src/main/kotlin/com/jetpackduba/gitnuro/viewmodels/TabViewModel.kt index 7470ceb..8aace38 100644 --- a/src/main/kotlin/com/jetpackduba/gitnuro/viewmodels/TabViewModel.kt +++ b/src/main/kotlin/com/jetpackduba/gitnuro/viewmodels/TabViewModel.kt @@ -47,6 +47,7 @@ class TabViewModel @Inject constructor( private val openRepositoryUseCase: OpenRepositoryUseCase, private val diffViewModelProvider: Provider, private val rebaseInteractiveViewModelProvider: Provider, + private val squashCommitsViewModelProvider: Provider, private val historyViewModelProvider: Provider, private val authorViewModelProvider: Provider, private val tabState: TabState, @@ -67,6 +68,9 @@ class TabViewModel @Inject constructor( var rebaseInteractiveViewModel: RebaseInteractiveViewModel? = null private set + var squashCommitsViewModel: SquashCommitsViewModel? = null + private set + private val _repositorySelectionStatus = MutableStateFlow(RepositorySelectionStatus.None) val repositorySelectionStatus: StateFlow get() = _repositorySelectionStatus @@ -115,7 +119,7 @@ class TabViewModel @Inject constructor( when (refreshType) { RefreshType.NONE -> printLog(TAG, "Not refreshing...") RefreshType.REPO_STATE -> refreshRepositoryState() - else -> {} + else -> Unit } } } @@ -123,8 +127,8 @@ class TabViewModel @Inject constructor( tabState.taskEvent.collect { taskEvent -> when (taskEvent) { is TaskEvent.RebaseInteractive -> onRebaseInteractive(taskEvent) - else -> { /*Nothing to do here*/ - } + is TaskEvent.SquashCommits -> onSquashCommits(taskEvent) + else -> Unit } } } @@ -149,6 +153,11 @@ class TabViewModel @Inject constructor( rebaseInteractiveViewModel?.startRebaseInteractive(taskEvent.revCommit) } + private suspend fun onSquashCommits(taskEvent: TaskEvent.SquashCommits) { + squashCommitsViewModel = squashCommitsViewModelProvider.get() + squashCommitsViewModel?.startSquash(taskEvent.commits, taskEvent.upstreamCommit) + } + fun openRepository(directory: String) { openRepository(File(directory)) } @@ -211,9 +220,16 @@ class TabViewModel @Inject constructor( } private fun onRepositoryStateChanged(newRepoState: RepositoryState) { - if (newRepoState != RepositoryState.REBASING_INTERACTIVE && rebaseInteractiveViewModel != null) { - rebaseInteractiveViewModel?.cancel() - rebaseInteractiveViewModel = null + if (newRepoState != RepositoryState.REBASING_INTERACTIVE) { + if (rebaseInteractiveViewModel != null) { + rebaseInteractiveViewModel?.cancel() + rebaseInteractiveViewModel = null + } + + if (newRepoState != RepositoryState.SAFE && squashCommitsViewModel != null) { + squashCommitsViewModel?.cancel() + squashCommitsViewModel = null + } } } @@ -439,7 +455,10 @@ class TabViewModel @Inject constructor( refreshType = RefreshType.ALL_DATA, ) { git -> abortRebaseUseCase(git) - rebaseInteractiveViewModel = null // shouldn't be necessary but just to make sure + + // shouldn't be necessary but just to make sure + rebaseInteractiveViewModel = null + squashCommitsViewModel = null } } diff --git a/src/main/kotlin/com/jetpackduba/gitnuro/viewmodels/TabViewModelsHolder.kt b/src/main/kotlin/com/jetpackduba/gitnuro/viewmodels/TabViewModelsHolder.kt index 89e59f1..ad713d1 100644 --- a/src/main/kotlin/com/jetpackduba/gitnuro/viewmodels/TabViewModelsHolder.kt +++ b/src/main/kotlin/com/jetpackduba/gitnuro/viewmodels/TabViewModelsHolder.kt @@ -21,6 +21,7 @@ class TabViewModelsHolder @Inject constructor( // Dynamic VM private val diffViewModelProvider: Provider, private val rebaseInteractiveViewModelProvider: Provider, + private val squashCommitsViewModel: Provider, private val historyViewModelProvider: Provider, private val authorViewModelProvider: Provider, ) {