Add squash feature
This commit is contained in:
parent
0a7741423d
commit
0685986a13
@ -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<RevCommit>, val upstreamCommit: RevCommit) : TaskEvent
|
||||
data class ScrollToGraphItem(val selectedItem: SelectedItem) : TaskEvent
|
||||
}
|
@ -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 }
|
||||
}
|
||||
|
||||
}
|
@ -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() }
|
||||
|
101
src/main/kotlin/com/jetpackduba/gitnuro/ui/SquashCommits.kt
Normal file
101
src/main/kotlin/com/jetpackduba/gitnuro/ui/SquashCommits.kt
Normal 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"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
@ -140,7 +140,11 @@ fun showPopup(x: Int, y: Int, contextMenuElements: List<ContextMenuElement>, 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
|
||||
|
@ -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") },
|
||||
|
@ -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,
|
||||
)
|
||||
},
|
||||
) {
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
}
|
@ -47,6 +47,7 @@ class TabViewModel @Inject constructor(
|
||||
private val openRepositoryUseCase: OpenRepositoryUseCase,
|
||||
private val diffViewModelProvider: Provider<DiffViewModel>,
|
||||
private val rebaseInteractiveViewModelProvider: Provider<RebaseInteractiveViewModel>,
|
||||
private val squashCommitsViewModelProvider: Provider<SquashCommitsViewModel>,
|
||||
private val historyViewModelProvider: Provider<HistoryViewModel>,
|
||||
private val authorViewModelProvider: Provider<AuthorViewModel>,
|
||||
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>(RepositorySelectionStatus.None)
|
||||
val repositorySelectionStatus: StateFlow<RepositorySelectionStatus>
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -21,6 +21,7 @@ class TabViewModelsHolder @Inject constructor(
|
||||
// Dynamic VM
|
||||
private val diffViewModelProvider: Provider<DiffViewModel>,
|
||||
private val rebaseInteractiveViewModelProvider: Provider<RebaseInteractiveViewModel>,
|
||||
private val squashCommitsViewModel: Provider<SquashCommitsViewModel>,
|
||||
private val historyViewModelProvider: Provider<HistoryViewModel>,
|
||||
private val authorViewModelProvider: Provider<AuthorViewModel>,
|
||||
) {
|
||||
|
Loading…
Reference in New Issue
Block a user