Add squash feature

This commit is contained in:
dizyaa 2023-02-13 00:41:51 +04:00 committed by John Doe
parent 0a7741423d
commit 0685986a13
11 changed files with 300 additions and 9 deletions

View File

@ -5,5 +5,6 @@ import org.eclipse.jgit.revwalk.RevCommit
sealed interface TaskEvent { sealed interface TaskEvent {
data class RebaseInteractive(val revCommit: RevCommit) : TaskEvent data class RebaseInteractive(val revCommit: RevCommit) : TaskEvent
data class SquashCommits(val commits: List<RevCommit>, val upstreamCommit: RevCommit) : TaskEvent
data class ScrollToGraphItem(val selectedItem: SelectedItem) : TaskEvent data class ScrollToGraphItem(val selectedItem: SelectedItem) : TaskEvent
} }

View File

@ -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 }
}
}

View File

@ -127,9 +127,12 @@ fun RepositoryOpenPage(
} }
) { ) {
val rebaseInteractiveViewModel = tabViewModel.rebaseInteractiveViewModel val rebaseInteractiveViewModel = tabViewModel.rebaseInteractiveViewModel
val squashCommitsViewModel = tabViewModel.squashCommitsViewModel
if (repositoryState == RepositoryState.REBASING_INTERACTIVE && rebaseInteractiveViewModel != null) { if (repositoryState == RepositoryState.REBASING_INTERACTIVE && rebaseInteractiveViewModel != null) {
RebaseInteractive(rebaseInteractiveViewModel) RebaseInteractive(rebaseInteractiveViewModel)
} else if (repositoryState == RepositoryState.REBASING_INTERACTIVE && squashCommitsViewModel != null) {
SquashCommits(squashCommitsViewModel)
} else if (repositoryState == RepositoryState.REBASING_INTERACTIVE) { } else if (repositoryState == RepositoryState.REBASING_INTERACTIVE) {
RebaseInteractiveStartedExternally( RebaseInteractiveStartedExternally(
onCancelRebaseInteractive = { tabViewModel.cancelRebaseInteractive() } onCancelRebaseInteractive = { tabViewModel.cancelRebaseInteractive() }

View File

@ -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"
)
}
}
}

View File

@ -140,7 +140,11 @@ fun showPopup(x: Int, y: Int, contextMenuElements: List<ContextMenuElement>, onD
Column(modifier = Modifier.verticalScroll(rememberScrollState())) { Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
for (item in contextMenuElements) { for (item in contextMenuElements) {
when (item) { when (item) {
is ContextMenuElement.ContextTextEntry -> TextEntry(item, onDismissRequest = onDismissRequest) is ContextMenuElement.ContextTextEntry -> {
if (item.isVisible) {
TextEntry(item, onDismissRequest = onDismissRequest)
}
}
ContextMenuElement.ContextSeparator -> Separator() ContextMenuElement.ContextSeparator -> Separator()
} }
@ -203,7 +207,8 @@ sealed interface ContextMenuElement {
data class ContextTextEntry( data class ContextTextEntry(
val label: String, val label: String,
val icon: @Composable (() -> Painter)? = null, val icon: @Composable (() -> Painter)? = null,
val onClick: () -> Unit = {} val onClick: () -> Unit = {},
val isVisible: Boolean = true,
) : ContextMenuElement ) : ContextMenuElement
object ContextSeparator : ContextMenuElement object ContextSeparator : ContextMenuElement

View File

@ -10,12 +10,20 @@ fun logContextMenu(
onCherryPickCommit: () -> Unit, onCherryPickCommit: () -> Unit,
onResetBranch: () -> Unit, onResetBranch: () -> Unit,
onRebaseInteractive: () -> Unit, onRebaseInteractive: () -> Unit,
showSquashCommits: Boolean,
onSquashCommits: () -> Unit,
) = listOf( ) = listOf(
ContextMenuElement.ContextTextEntry( ContextMenuElement.ContextTextEntry(
label = "Checkout commit", label = "Checkout commit",
icon = { painterResource("start.svg") }, icon = { painterResource("start.svg") },
onClick = onCheckoutCommit onClick = onCheckoutCommit
), ),
ContextMenuElement.ContextTextEntry(
label = "Squash commits",
icon = { painterResource("branch.svg") },
isVisible = showSquashCommits,
onClick = onSquashCommits
),
ContextMenuElement.ContextTextEntry( ContextMenuElement.ContextTextEntry(
label = "Create branch", label = "Create branch",
icon = { painterResource("branch.svg") }, icon = { painterResource("branch.svg") },

View File

@ -429,6 +429,8 @@ fun MessagesList(
onRevCommitSelected = { onRevCommitSelected = {
logViewModel.selectLogLine(graphNode, keyScope.isCtrlPressed, keyScope.isShiftPressed) 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, onRebaseBranch: (Ref) -> Unit,
onRevCommitSelected: () -> Unit, onRevCommitSelected: () -> Unit,
onRebaseInteractive: () -> Unit, onRebaseInteractive: () -> Unit,
onSquashCommits: () -> Unit,
showSquashCommits: Boolean,
) { ) {
ContextMenu( ContextMenu(
items = { items = {
@ -775,6 +779,8 @@ fun CommitLine(
onCherryPickCommit = { logViewModel.cherrypickCommit(graphNode) }, onCherryPickCommit = { logViewModel.cherrypickCommit(graphNode) },
onRebaseInteractive = onRebaseInteractive, onRebaseInteractive = onRebaseInteractive,
onResetBranch = { resetBranch() }, onResetBranch = { resetBranch() },
onSquashCommits = { onSquashCommits() },
showSquashCommits = showSquashCommits,
) )
}, },
) { ) {

View File

@ -10,6 +10,7 @@ import com.jetpackduba.gitnuro.git.branches.*
import com.jetpackduba.gitnuro.git.graph.GraphCommitList import com.jetpackduba.gitnuro.git.graph.GraphCommitList
import com.jetpackduba.gitnuro.git.graph.GraphNode import com.jetpackduba.gitnuro.git.graph.GraphNode
import com.jetpackduba.gitnuro.git.log.* 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.rebase.RebaseBranchUseCase
import com.jetpackduba.gitnuro.git.remote_operations.DeleteRemoteBranchUseCase import com.jetpackduba.gitnuro.git.remote_operations.DeleteRemoteBranchUseCase
import com.jetpackduba.gitnuro.git.remote_operations.PullFromSpecificBranchUseCase 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.CoroutineScope
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import org.eclipse.jgit.api.Git import org.eclipse.jgit.api.Git
import org.eclipse.jgit.api.RebaseCommand.InteractiveHandler
import org.eclipse.jgit.api.errors.CheckoutConflictException import org.eclipse.jgit.api.errors.CheckoutConflictException
import org.eclipse.jgit.lib.RebaseTodoLine
import org.eclipse.jgit.lib.Ref import org.eclipse.jgit.lib.Ref
import org.eclipse.jgit.revwalk.RevCommit import org.eclipse.jgit.revwalk.RevCommit
import javax.inject.Inject import javax.inject.Inject
@ -63,6 +67,7 @@ class LogViewModel @Inject constructor(
private val createTagOnCommitUseCase: CreateTagOnCommitUseCase, private val createTagOnCommitUseCase: CreateTagOnCommitUseCase,
private val deleteTagUseCase: DeleteTagUseCase, private val deleteTagUseCase: DeleteTagUseCase,
private val rebaseBranchUseCase: RebaseBranchUseCase, private val rebaseBranchUseCase: RebaseBranchUseCase,
private val getRebaseLinesFullMessageUseCase: GetRebaseLinesFullMessageUseCase,
private val tabState: TabState, private val tabState: TabState,
private val appSettings: AppSettings, private val appSettings: AppSettings,
private val tabScope: CoroutineScope, private val tabScope: CoroutineScope,
@ -305,6 +310,29 @@ class LogViewModel @Inject constructor(
NONE_MATCHING_INDEX 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( fun selectLogLine(
commit: GraphNode, commit: GraphNode,
multiSelect: Boolean, multiSelect: Boolean,

View File

@ -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>(SquashCommitsState.Loading)
val squashState: StateFlow<SquashCommitsState> = _squashState
private var commits: List<RevCommit> = 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<RevCommit>, 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>): 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
}

View File

@ -47,6 +47,7 @@ class TabViewModel @Inject constructor(
private val openRepositoryUseCase: OpenRepositoryUseCase, private val openRepositoryUseCase: OpenRepositoryUseCase,
private val diffViewModelProvider: Provider<DiffViewModel>, private val diffViewModelProvider: Provider<DiffViewModel>,
private val rebaseInteractiveViewModelProvider: Provider<RebaseInteractiveViewModel>, private val rebaseInteractiveViewModelProvider: Provider<RebaseInteractiveViewModel>,
private val squashCommitsViewModelProvider: Provider<SquashCommitsViewModel>,
private val historyViewModelProvider: Provider<HistoryViewModel>, private val historyViewModelProvider: Provider<HistoryViewModel>,
private val authorViewModelProvider: Provider<AuthorViewModel>, private val authorViewModelProvider: Provider<AuthorViewModel>,
private val tabState: TabState, private val tabState: TabState,
@ -67,6 +68,9 @@ class TabViewModel @Inject constructor(
var rebaseInteractiveViewModel: RebaseInteractiveViewModel? = null var rebaseInteractiveViewModel: RebaseInteractiveViewModel? = null
private set private set
var squashCommitsViewModel: SquashCommitsViewModel? = null
private set
private val _repositorySelectionStatus = MutableStateFlow<RepositorySelectionStatus>(RepositorySelectionStatus.None) private val _repositorySelectionStatus = MutableStateFlow<RepositorySelectionStatus>(RepositorySelectionStatus.None)
val repositorySelectionStatus: StateFlow<RepositorySelectionStatus> val repositorySelectionStatus: StateFlow<RepositorySelectionStatus>
get() = _repositorySelectionStatus get() = _repositorySelectionStatus
@ -115,7 +119,7 @@ class TabViewModel @Inject constructor(
when (refreshType) { when (refreshType) {
RefreshType.NONE -> printLog(TAG, "Not refreshing...") RefreshType.NONE -> printLog(TAG, "Not refreshing...")
RefreshType.REPO_STATE -> refreshRepositoryState() RefreshType.REPO_STATE -> refreshRepositoryState()
else -> {} else -> Unit
} }
} }
} }
@ -123,8 +127,8 @@ class TabViewModel @Inject constructor(
tabState.taskEvent.collect { taskEvent -> tabState.taskEvent.collect { taskEvent ->
when (taskEvent) { when (taskEvent) {
is TaskEvent.RebaseInteractive -> onRebaseInteractive(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) rebaseInteractiveViewModel?.startRebaseInteractive(taskEvent.revCommit)
} }
private suspend fun onSquashCommits(taskEvent: TaskEvent.SquashCommits) {
squashCommitsViewModel = squashCommitsViewModelProvider.get()
squashCommitsViewModel?.startSquash(taskEvent.commits, taskEvent.upstreamCommit)
}
fun openRepository(directory: String) { fun openRepository(directory: String) {
openRepository(File(directory)) openRepository(File(directory))
} }
@ -211,10 +220,17 @@ class TabViewModel @Inject constructor(
} }
private fun onRepositoryStateChanged(newRepoState: RepositoryState) { private fun onRepositoryStateChanged(newRepoState: RepositoryState) {
if (newRepoState != RepositoryState.REBASING_INTERACTIVE && rebaseInteractiveViewModel != null) { if (newRepoState != RepositoryState.REBASING_INTERACTIVE) {
if (rebaseInteractiveViewModel != null) {
rebaseInteractiveViewModel?.cancel() rebaseInteractiveViewModel?.cancel()
rebaseInteractiveViewModel = null rebaseInteractiveViewModel = null
} }
if (newRepoState != RepositoryState.SAFE && squashCommitsViewModel != null) {
squashCommitsViewModel?.cancel()
squashCommitsViewModel = null
}
}
} }
private suspend fun watchRepositoryChanges(git: Git) = tabScope.launch(Dispatchers.IO) { private suspend fun watchRepositoryChanges(git: Git) = tabScope.launch(Dispatchers.IO) {
@ -439,7 +455,10 @@ class TabViewModel @Inject constructor(
refreshType = RefreshType.ALL_DATA, refreshType = RefreshType.ALL_DATA,
) { git -> ) { git ->
abortRebaseUseCase(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
} }
} }

View File

@ -21,6 +21,7 @@ class TabViewModelsHolder @Inject constructor(
// Dynamic VM // Dynamic VM
private val diffViewModelProvider: Provider<DiffViewModel>, private val diffViewModelProvider: Provider<DiffViewModel>,
private val rebaseInteractiveViewModelProvider: Provider<RebaseInteractiveViewModel>, private val rebaseInteractiveViewModelProvider: Provider<RebaseInteractiveViewModel>,
private val squashCommitsViewModel: Provider<SquashCommitsViewModel>,
private val historyViewModelProvider: Provider<HistoryViewModel>, private val historyViewModelProvider: Provider<HistoryViewModel>,
private val authorViewModelProvider: Provider<AuthorViewModel>, private val authorViewModelProvider: Provider<AuthorViewModel>,
) { ) {