diff --git a/src/main/kotlin/app/git/RebaseManager.kt b/src/main/kotlin/app/git/RebaseManager.kt index eb88772..8f0ede5 100644 --- a/src/main/kotlin/app/git/RebaseManager.kt +++ b/src/main/kotlin/app/git/RebaseManager.kt @@ -5,8 +5,10 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.eclipse.jgit.api.Git import org.eclipse.jgit.api.RebaseCommand +import org.eclipse.jgit.api.RebaseCommand.InteractiveHandler import org.eclipse.jgit.api.RebaseResult import org.eclipse.jgit.lib.Ref +import org.eclipse.jgit.revwalk.RevCommit import javax.inject.Inject class RebaseManager @Inject constructor() { @@ -39,4 +41,13 @@ class RebaseManager @Inject constructor() { .setOperation(RebaseCommand.Operation.SKIP) .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() + } } \ 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 0d302ac..7ebc5a8 100644 --- a/src/main/kotlin/app/git/TabState.kt +++ b/src/main/kotlin/app/git/TabState.kt @@ -4,14 +4,11 @@ import app.ErrorsManager import app.di.TabScope import app.newErrorNow import app.ui.SelectedItem -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.* import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import org.eclipse.jgit.api.Git @@ -127,7 +124,15 @@ class TabState @Inject constructor( if (showError) errorsManager.addError(newErrorNow(ex, ex.localizedMessage)) } 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)) _refreshData.emit(refreshType) diff --git a/src/main/kotlin/app/ui/context_menu/LogContextMenu.kt b/src/main/kotlin/app/ui/context_menu/LogContextMenu.kt index 7534144..37d2255 100644 --- a/src/main/kotlin/app/ui/context_menu/LogContextMenu.kt +++ b/src/main/kotlin/app/ui/context_menu/LogContextMenu.kt @@ -11,6 +11,7 @@ fun logContextMenu( onRevertCommit: () -> Unit, onCherryPickCommit: () -> Unit, onResetBranch: () -> Unit, + onRebaseInteractive: () -> Unit, ) = listOf( ContextMenuItem( label = "Checkout commit", @@ -24,6 +25,10 @@ fun logContextMenu( label = "Create tag", onClick = onCreateNewTag ), + ContextMenuItem( + label = "Rebase interactive", + onClick = onRebaseInteractive + ), ContextMenuItem( label = "Revert commit", onClick = onRevertCommit diff --git a/src/main/kotlin/app/ui/dialogs/RebaseInteractiveDialog.kt b/src/main/kotlin/app/ui/dialogs/RebaseInteractiveDialog.kt new file mode 100644 index 0000000..25688a5 --- /dev/null +++ b/src/main/kotlin/app/ui/dialogs/RebaseInteractiveDialog.kt @@ -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, +) + + diff --git a/src/main/kotlin/app/ui/log/Log.kt b/src/main/kotlin/app/ui/log/Log.kt index 178c4fb..9d2f9d7 100644 --- a/src/main/kotlin/app/ui/log/Log.kt +++ b/src/main/kotlin/app/ui/log/Log.kt @@ -10,7 +10,6 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.* @@ -90,7 +89,7 @@ fun Log( val scope = rememberCoroutineScope() val logStatusState = logViewModel.logStatus.collectAsState() val logStatus = logStatusState.value - val showLogDialog = remember { mutableStateOf(LogDialog.None) } + val showLogDialog by logViewModel.logDialog.collectAsState() val selectedCommit = if (selectedItem is SelectedItem.CommitBasedItem) { selectedItem.revCommit @@ -124,8 +123,8 @@ fun Log( LogDialogs( logViewModel, currentBranch = logStatus.currentBranch, - onResetShowLogDialog = { showLogDialog.value = LogDialog.None }, - showLogDialog = showLogDialog.value, + onResetShowLogDialog = { logViewModel.showDialog(LogDialog.None) }, + showLogDialog = showLogDialog, ) Column( @@ -169,7 +168,7 @@ fun Log( logViewModel = logViewModel, graphWidth = graphWidth, onShowLogDialog = { dialog -> - showLogDialog.value = dialog + logViewModel.showDialog(dialog) }) DividerLog( @@ -346,6 +345,7 @@ fun MessagesList( resetBranch = { onShowLogDialog(LogDialog.ResetBranch(graphNode)) }, onMergeBranch = { ref -> onShowLogDialog(LogDialog.MergeBranch(ref)) }, onRebaseBranch = { ref -> onShowLogDialog(LogDialog.RebaseBranch(ref)) }, + onRebaseInteractive = { onShowLogDialog(LogDialog.RebaseInteractive(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 -> { } } @@ -635,6 +642,7 @@ fun CommitLine( onMergeBranch: (Ref) -> Unit, onRebaseBranch: (Ref) -> Unit, onRevCommitSelected: () -> Unit, + onRebaseInteractive: () -> Unit, ) { ContextMenuArea( items = { @@ -644,6 +652,7 @@ fun CommitLine( onCreateNewTag = showCreateNewTag, onRevertCommit = { logViewModel.revertCommit(graphNode) }, onCherryPickCommit = { logViewModel.cherrypickCommit(graphNode) }, + onRebaseInteractive = onRebaseInteractive, onResetBranch = { resetBranch() }, ) }, diff --git a/src/main/kotlin/app/ui/log/LogDialog.kt b/src/main/kotlin/app/ui/log/LogDialog.kt index 1ca5799..7292b79 100644 --- a/src/main/kotlin/app/ui/log/LogDialog.kt +++ b/src/main/kotlin/app/ui/log/LogDialog.kt @@ -2,6 +2,7 @@ package app.ui.log import app.git.graph.GraphNode import org.eclipse.jgit.lib.Ref +import org.eclipse.jgit.revwalk.RevCommit sealed class LogDialog { object None : LogDialog() @@ -10,4 +11,5 @@ sealed class LogDialog { data class ResetBranch(val graphNode: GraphNode) : LogDialog() data class MergeBranch(val ref: Ref) : LogDialog() data class RebaseBranch(val ref: Ref) : LogDialog() + data class RebaseInteractive(val revCommit: RevCommit) : LogDialog() } \ No newline at end of file diff --git a/src/main/kotlin/app/viewmodels/LogViewModel.kt b/src/main/kotlin/app/viewmodels/LogViewModel.kt index b6f68c0..5542f66 100644 --- a/src/main/kotlin/app/viewmodels/LogViewModel.kt +++ b/src/main/kotlin/app/viewmodels/LogViewModel.kt @@ -5,6 +5,7 @@ import app.git.* import app.git.graph.GraphCommitList import app.git.graph.GraphNode import app.ui.SelectedItem +import app.ui.log.LogDialog import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow 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.revwalk.RevCommit import javax.inject.Inject +import javax.inject.Provider /** * 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 remoteOperationsManager: RemoteOperationsManager, private val tabState: TabState, + private val rebaseInteractiveViewModelProvider: Provider ) { private val _logStatus = MutableStateFlow(LogStatus.Loading) + var rebaseInteractiveViewModel: RebaseInteractiveViewModel? = null + private set + val logStatus: StateFlow get() = _logStatus @@ -45,6 +51,9 @@ class LogViewModel @Inject constructor( private val _focusCommit = MutableSharedFlow() val focusCommit: SharedFlow = _focusCommit + private val _logDialog = MutableStateFlow(LogDialog.None) + val logDialog: StateFlow = _logDialog + val lazyListState = MutableStateFlow( LazyListState( 0, @@ -302,6 +311,15 @@ class LogViewModel @Inject constructor( _focusCommit.emit(newCommitToSelect) } + fun showDialog(dialog: LogDialog) { + rebaseInteractiveViewModel = if(dialog is LogDialog.RebaseInteractive) { + rebaseInteractiveViewModelProvider.get() + } else + null + + _logDialog.value = dialog + } + fun closeSearch() { _logSearchFilterResults.value = LogSearch.NotSearching } diff --git a/src/main/kotlin/app/viewmodels/RebaseInteractiveViewModel.kt b/src/main/kotlin/app/viewmodels/RebaseInteractiveViewModel.kt new file mode 100644 index 0000000..3ca3be1 --- /dev/null +++ b/src/main/kotlin/app/viewmodels/RebaseInteractiveViewModel.kt @@ -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.Loading) + val rebaseState: StateFlow = _rebaseState + + private var interactiveHandler = object : InteractiveHandler { + override fun prepareSteps(steps: MutableList?) { + _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?) { + 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) : RebaseInteractiveState + data class Failed(val error: String) : RebaseInteractiveState + object Finished : RebaseInteractiveState +} \ No newline at end of file diff --git a/src/main/kotlin/app/viewmodels/TabViewModel.kt b/src/main/kotlin/app/viewmodels/TabViewModel.kt index 5c93450..24ad713 100644 --- a/src/main/kotlin/app/viewmodels/TabViewModel.kt +++ b/src/main/kotlin/app/viewmodels/TabViewModel.kt @@ -255,6 +255,7 @@ class TabViewModel @Inject constructor( private fun updateDiffEntry() { val diffSelected = diffSelected.value + println("Update diff entry $diffSelected") if (diffSelected != null) { diffViewModel.updateDiff(diffSelected)