Added first version of interactive rebase

This commit is contained in:
Abdelilah El Aissaoui 2022-04-09 23:10:08 +02:00
parent 08d0323e48
commit 14eb5f8c9c
9 changed files with 336 additions and 10 deletions

View File

@ -5,8 +5,10 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.eclipse.jgit.api.Git import org.eclipse.jgit.api.Git
import org.eclipse.jgit.api.RebaseCommand import org.eclipse.jgit.api.RebaseCommand
import org.eclipse.jgit.api.RebaseCommand.InteractiveHandler
import org.eclipse.jgit.api.RebaseResult import org.eclipse.jgit.api.RebaseResult
import org.eclipse.jgit.lib.Ref import org.eclipse.jgit.lib.Ref
import org.eclipse.jgit.revwalk.RevCommit
import javax.inject.Inject import javax.inject.Inject
class RebaseManager @Inject constructor() { class RebaseManager @Inject constructor() {
@ -39,4 +41,13 @@ class RebaseManager @Inject constructor() {
.setOperation(RebaseCommand.Operation.SKIP) .setOperation(RebaseCommand.Operation.SKIP)
.call() .call()
} }
suspend fun rebaseInteractive(git: Git, interactiveHandler: InteractiveHandler, commit: RevCommit) {
//TODO Check possible rebase errors by checking the result
git.rebase()
.runInteractively(interactiveHandler)
.setOperation(RebaseCommand.Operation.BEGIN)
.setUpstream(commit)
.call()
}
} }

View File

@ -4,14 +4,11 @@ import app.ErrorsManager
import app.di.TabScope import app.di.TabScope
import app.newErrorNow import app.newErrorNow
import app.ui.SelectedItem import app.ui.SelectedItem
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import org.eclipse.jgit.api.Git import org.eclipse.jgit.api.Git
@ -127,7 +124,15 @@ class TabState @Inject constructor(
if (showError) if (showError)
errorsManager.addError(newErrorNow(ex, ex.localizedMessage)) errorsManager.addError(newErrorNow(ex, ex.localizedMessage))
} finally { } finally {
operationRunning = false launch {
// Add a slight delay because sometimes the file watcher takes a few moments to notify a change in the
// filesystem, therefore notifying late and being operationRunning already false (which leads to a full
// refresh because there have been changes in the git dir). This can be easily triggered by interactive
// rebase.
delay(500)
operationRunning = false
}
if (refreshType != RefreshType.NONE && (!hasProcessFailed || refreshEvenIfCrashes)) if (refreshType != RefreshType.NONE && (!hasProcessFailed || refreshEvenIfCrashes))
_refreshData.emit(refreshType) _refreshData.emit(refreshType)

View File

@ -11,6 +11,7 @@ fun logContextMenu(
onRevertCommit: () -> Unit, onRevertCommit: () -> Unit,
onCherryPickCommit: () -> Unit, onCherryPickCommit: () -> Unit,
onResetBranch: () -> Unit, onResetBranch: () -> Unit,
onRebaseInteractive: () -> Unit,
) = listOf( ) = listOf(
ContextMenuItem( ContextMenuItem(
label = "Checkout commit", label = "Checkout commit",
@ -24,6 +25,10 @@ fun logContextMenu(
label = "Create tag", label = "Create tag",
onClick = onCreateNewTag onClick = onCreateNewTag
), ),
ContextMenuItem(
label = "Rebase interactive",
onClick = onRebaseInteractive
),
ContextMenuItem( ContextMenuItem(
label = "Revert commit", label = "Revert commit",
onClick = onRevertCommit onClick = onRevertCommit

View File

@ -0,0 +1,176 @@
package app.ui.dialogs
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import app.theme.primaryTextColor
import app.ui.components.PrimaryButton
import app.viewmodels.RebaseInteractiveState
import app.viewmodels.RebaseInteractiveViewModel
import org.eclipse.jgit.lib.RebaseTodoLine
import org.eclipse.jgit.lib.RebaseTodoLine.Action
import org.eclipse.jgit.revwalk.RevCommit
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun RebaseInteractiveDialog(
rebaseInteractiveViewModel: RebaseInteractiveViewModel,
revCommit: RevCommit,
onClose: () -> Unit,
) {
val rebaseState = rebaseInteractiveViewModel.rebaseState.collectAsState()
val rebaseStateValue = rebaseState.value
LaunchedEffect(Unit) {
rebaseInteractiveViewModel.revCommit = revCommit
rebaseInteractiveViewModel.startRebaseInteractive()
}
MaterialDialog {
Box(
modifier = Modifier
.background(MaterialTheme.colors.background)
.fillMaxSize(0.8f),
) {
when (rebaseStateValue) {
is RebaseInteractiveState.Failed -> {}
RebaseInteractiveState.Finished -> onClose()
is RebaseInteractiveState.Loaded -> {
RebaseStateLoaded(
rebaseInteractiveViewModel,
rebaseStateValue,
onCancel = onClose,
)
}
RebaseInteractiveState.Loading -> {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
}
}
}
}
}
@Composable
fun RebaseStateLoaded(
rebaseInteractiveViewModel: RebaseInteractiveViewModel,
rebaseState: RebaseInteractiveState.Loaded,
onCancel: () -> Unit,
) {
Column(
modifier = Modifier.fillMaxSize()
) {
Text(
text = "Rebase interactive",
color = MaterialTheme.colors.primaryTextColor,
modifier = Modifier.padding(all = 16.dp)
)
LazyColumn(modifier = Modifier.weight(1f)) {
items(rebaseState.stepsList) { rebaseTodoLine ->
RebaseCommit(
rebaseLine = rebaseTodoLine,
onActionChanged = { newAction ->
rebaseInteractiveViewModel.onCommitActionChanged(rebaseTodoLine.commit, newAction)
}
)
}
}
Row {
Spacer(modifier = Modifier.weight(1f))
TextButton(
modifier = Modifier.padding(end = 8.dp),
onClick = {
onCancel()
}
) {
Text("Cancel")
}
PrimaryButton(
onClick = {
rebaseInteractiveViewModel.continueRebaseInteractive()
},
text = "Complete rebase"
)
}
}
}
@Composable
fun RebaseCommit(rebaseLine: RebaseTodoLine, onActionChanged: (Action) -> Unit) {
Row (
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
) {
ActionDropdown(
rebaseLine.action,
onActionChanged = onActionChanged,
)
OutlinedTextField(
modifier = Modifier
.weight(1f)
.height(48.dp),
value = rebaseLine.shortMessage,
onValueChange = {},
colors = TextFieldDefaults.textFieldColors(backgroundColor = MaterialTheme.colors.background),
textStyle = TextStyle.Default.copy(fontSize = 14.sp, color = MaterialTheme.colors.primaryTextColor),
)
}
}
@Composable
fun ActionDropdown(
action: Action,
onActionChanged: (Action) -> Unit,
) {
var showDropDownMenu by remember { mutableStateOf(false) }
Box {
PrimaryButton(
onClick = { showDropDownMenu = true },
modifier = Modifier
.width(120.dp)
.height(48.dp)
.padding(end = 8.dp),
text = action.toToken()
)
DropdownMenu(
expanded = showDropDownMenu,
onDismissRequest = { showDropDownMenu = false },
) {
for (dropDownOption in actions) {
DropdownMenuItem(
onClick = {
showDropDownMenu = false
onActionChanged(dropDownOption)
}
) {
Text(dropDownOption.toToken())
}
}
}
}
}
val actions = listOf(
Action.PICK,
Action.REWORD,
// RebaseTodoLine.Action.EDIT,
Action.SQUASH,
Action.FIXUP,
// RebaseTodoLine.Action.COMMENT,
)

View File

@ -10,7 +10,6 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.* import androidx.compose.material.*
@ -90,7 +89,7 @@ fun Log(
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val logStatusState = logViewModel.logStatus.collectAsState() val logStatusState = logViewModel.logStatus.collectAsState()
val logStatus = logStatusState.value val logStatus = logStatusState.value
val showLogDialog = remember { mutableStateOf<LogDialog>(LogDialog.None) } val showLogDialog by logViewModel.logDialog.collectAsState()
val selectedCommit = if (selectedItem is SelectedItem.CommitBasedItem) { val selectedCommit = if (selectedItem is SelectedItem.CommitBasedItem) {
selectedItem.revCommit selectedItem.revCommit
@ -124,8 +123,8 @@ fun Log(
LogDialogs( LogDialogs(
logViewModel, logViewModel,
currentBranch = logStatus.currentBranch, currentBranch = logStatus.currentBranch,
onResetShowLogDialog = { showLogDialog.value = LogDialog.None }, onResetShowLogDialog = { logViewModel.showDialog(LogDialog.None) },
showLogDialog = showLogDialog.value, showLogDialog = showLogDialog,
) )
Column( Column(
@ -169,7 +168,7 @@ fun Log(
logViewModel = logViewModel, logViewModel = logViewModel,
graphWidth = graphWidth, graphWidth = graphWidth,
onShowLogDialog = { dialog -> onShowLogDialog = { dialog ->
showLogDialog.value = dialog logViewModel.showDialog(dialog)
}) })
DividerLog( DividerLog(
@ -346,6 +345,7 @@ fun MessagesList(
resetBranch = { onShowLogDialog(LogDialog.ResetBranch(graphNode)) }, resetBranch = { onShowLogDialog(LogDialog.ResetBranch(graphNode)) },
onMergeBranch = { ref -> onShowLogDialog(LogDialog.MergeBranch(ref)) }, onMergeBranch = { ref -> onShowLogDialog(LogDialog.MergeBranch(ref)) },
onRebaseBranch = { ref -> onShowLogDialog(LogDialog.RebaseBranch(ref)) }, onRebaseBranch = { ref -> onShowLogDialog(LogDialog.RebaseBranch(ref)) },
onRebaseInteractive = { onShowLogDialog(LogDialog.RebaseInteractive(graphNode)) },
onRevCommitSelected = { logViewModel.selectLogLine(graphNode) }, onRevCommitSelected = { logViewModel.selectLogLine(graphNode) },
) )
} }
@ -459,6 +459,13 @@ fun LogDialogs(
}) })
} }
} }
is LogDialog.RebaseInteractive -> {
RebaseInteractiveDialog(
revCommit = showLogDialog.revCommit,
rebaseInteractiveViewModel = checkNotNull(logViewModel.rebaseInteractiveViewModel), // Never null, value should be set before showing dialog
onClose = onResetShowLogDialog,
)
}
LogDialog.None -> { LogDialog.None -> {
} }
} }
@ -635,6 +642,7 @@ fun CommitLine(
onMergeBranch: (Ref) -> Unit, onMergeBranch: (Ref) -> Unit,
onRebaseBranch: (Ref) -> Unit, onRebaseBranch: (Ref) -> Unit,
onRevCommitSelected: () -> Unit, onRevCommitSelected: () -> Unit,
onRebaseInteractive: () -> Unit,
) { ) {
ContextMenuArea( ContextMenuArea(
items = { items = {
@ -644,6 +652,7 @@ fun CommitLine(
onCreateNewTag = showCreateNewTag, onCreateNewTag = showCreateNewTag,
onRevertCommit = { logViewModel.revertCommit(graphNode) }, onRevertCommit = { logViewModel.revertCommit(graphNode) },
onCherryPickCommit = { logViewModel.cherrypickCommit(graphNode) }, onCherryPickCommit = { logViewModel.cherrypickCommit(graphNode) },
onRebaseInteractive = onRebaseInteractive,
onResetBranch = { resetBranch() }, onResetBranch = { resetBranch() },
) )
}, },

View File

@ -2,6 +2,7 @@ package app.ui.log
import app.git.graph.GraphNode import app.git.graph.GraphNode
import org.eclipse.jgit.lib.Ref import org.eclipse.jgit.lib.Ref
import org.eclipse.jgit.revwalk.RevCommit
sealed class LogDialog { sealed class LogDialog {
object None : LogDialog() object None : LogDialog()
@ -10,4 +11,5 @@ sealed class LogDialog {
data class ResetBranch(val graphNode: GraphNode) : LogDialog() data class ResetBranch(val graphNode: GraphNode) : LogDialog()
data class MergeBranch(val ref: Ref) : LogDialog() data class MergeBranch(val ref: Ref) : LogDialog()
data class RebaseBranch(val ref: Ref) : LogDialog() data class RebaseBranch(val ref: Ref) : LogDialog()
data class RebaseInteractive(val revCommit: RevCommit) : LogDialog()
} }

View File

@ -5,6 +5,7 @@ import app.git.*
import app.git.graph.GraphCommitList import app.git.graph.GraphCommitList
import app.git.graph.GraphNode import app.git.graph.GraphNode
import app.ui.SelectedItem import app.ui.SelectedItem
import app.ui.log.LogDialog
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharedFlow
@ -13,6 +14,7 @@ import org.eclipse.jgit.api.Git
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
import javax.inject.Provider
/** /**
* Represents when the search filter is not being used or the results list is empty * Represents when the search filter is not being used or the results list is empty
@ -34,9 +36,13 @@ class LogViewModel @Inject constructor(
private val mergeManager: MergeManager, private val mergeManager: MergeManager,
private val remoteOperationsManager: RemoteOperationsManager, private val remoteOperationsManager: RemoteOperationsManager,
private val tabState: TabState, private val tabState: TabState,
private val rebaseInteractiveViewModelProvider: Provider<RebaseInteractiveViewModel>
) { ) {
private val _logStatus = MutableStateFlow<LogStatus>(LogStatus.Loading) private val _logStatus = MutableStateFlow<LogStatus>(LogStatus.Loading)
var rebaseInteractiveViewModel: RebaseInteractiveViewModel? = null
private set
val logStatus: StateFlow<LogStatus> val logStatus: StateFlow<LogStatus>
get() = _logStatus get() = _logStatus
@ -45,6 +51,9 @@ class LogViewModel @Inject constructor(
private val _focusCommit = MutableSharedFlow<GraphNode>() private val _focusCommit = MutableSharedFlow<GraphNode>()
val focusCommit: SharedFlow<GraphNode> = _focusCommit val focusCommit: SharedFlow<GraphNode> = _focusCommit
private val _logDialog = MutableStateFlow<LogDialog>(LogDialog.None)
val logDialog: StateFlow<LogDialog> = _logDialog
val lazyListState = MutableStateFlow( val lazyListState = MutableStateFlow(
LazyListState( LazyListState(
0, 0,
@ -302,6 +311,15 @@ class LogViewModel @Inject constructor(
_focusCommit.emit(newCommitToSelect) _focusCommit.emit(newCommitToSelect)
} }
fun showDialog(dialog: LogDialog) {
rebaseInteractiveViewModel = if(dialog is LogDialog.RebaseInteractive) {
rebaseInteractiveViewModelProvider.get()
} else
null
_logDialog.value = dialog
}
fun closeSearch() { fun closeSearch() {
_logSearchFilterResults.value = LogSearch.NotSearching _logSearchFilterResults.value = LogSearch.NotSearching
} }

View File

@ -0,0 +1,99 @@
package app.viewmodels
import app.git.RebaseManager
import app.git.RefreshType
import app.git.TabState
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import org.eclipse.jgit.api.RebaseCommand.InteractiveHandler
import org.eclipse.jgit.lib.AbbreviatedObjectId
import org.eclipse.jgit.lib.RebaseTodoLine
import org.eclipse.jgit.lib.RebaseTodoLine.Action
import org.eclipse.jgit.revwalk.RevCommit
import javax.inject.Inject
class RebaseInteractiveViewModel @Inject constructor(
private val tabState: TabState,
private val rebaseManager: RebaseManager,
) {
lateinit var revCommit: RevCommit
private val _rebaseState = MutableStateFlow<RebaseInteractiveState>(RebaseInteractiveState.Loading)
val rebaseState: StateFlow<RebaseInteractiveState> = _rebaseState
private var interactiveHandler = object : InteractiveHandler {
override fun prepareSteps(steps: MutableList<RebaseTodoLine>?) {
_rebaseState.value = RebaseInteractiveState.Loaded(steps?.reversed() ?: emptyList())
}
override fun modifyCommitMessage(commit: String?): String {
return commit.orEmpty() // we don't care about this since it's not called
}
}
fun startRebaseInteractive() = tabState.runOperation(
refreshType = RefreshType.NONE,
) { git ->
rebaseManager.rebaseInteractive(git, interactiveHandler, revCommit)
}
fun continueRebaseInteractive() = tabState.runOperation(
refreshType = RefreshType.ONLY_LOG,
) { git ->
rebaseManager.rebaseInteractive(
git = git,
interactiveHandler = object : InteractiveHandler {
override fun prepareSteps(steps: MutableList<RebaseTodoLine>?) {
val rebaseState = _rebaseState.value
if(rebaseState !is RebaseInteractiveState.Loaded)
return
val newSteps = rebaseState.stepsList
for (step in steps ?: emptyList()) {
val foundStep = newSteps.firstOrNull { it.commit.name() == step.commit.name() }
if (foundStep != null) {
step.action = foundStep.action
}
}
}
override fun modifyCommitMessage(commit: String?): String {
return commit.orEmpty()
}
},
commit = revCommit
)
}
fun onCommitActionChanged(commit: AbbreviatedObjectId, action: Action) {
val rebaseState = _rebaseState.value
if(rebaseState !is RebaseInteractiveState.Loaded)
return
val newStepsList = rebaseState.stepsList.toMutableList() // Change the list reference to update the flow with .toList()
val stepIndex = newStepsList.indexOfFirst {
it.commit == commit
}
if (stepIndex >= 0) {
val step = newStepsList[stepIndex]
val newTodoLine = RebaseTodoLine(action, step.commit, step.shortMessage)
newStepsList[stepIndex] = newTodoLine
_rebaseState.value = RebaseInteractiveState.Loaded(newStepsList)
}
}
}
sealed interface RebaseInteractiveState {
object Loading : RebaseInteractiveState
data class Loaded(val stepsList: List<RebaseTodoLine>) : RebaseInteractiveState
data class Failed(val error: String) : RebaseInteractiveState
object Finished : RebaseInteractiveState
}

View File

@ -255,6 +255,7 @@ class TabViewModel @Inject constructor(
private fun updateDiffEntry() { private fun updateDiffEntry() {
val diffSelected = diffSelected.value val diffSelected = diffSelected.value
println("Update diff entry $diffSelected")
if (diffSelected != null) { if (diffSelected != null) {
diffViewModel.updateDiff(diffSelected) diffViewModel.updateDiff(diffSelected)