Gitnuro/src/main/kotlin/com/jetpackduba/gitnuro/viewmodels/StatusViewModel.kt
2024-03-24 00:14:04 +01:00

595 lines
21 KiB
Kotlin

package com.jetpackduba.gitnuro.viewmodels
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.ui.text.input.TextFieldValue
import com.jetpackduba.gitnuro.SharedRepositoryStateManager
import com.jetpackduba.gitnuro.TaskType
import com.jetpackduba.gitnuro.extensions.delayedStateChange
import com.jetpackduba.gitnuro.extensions.isMerging
import com.jetpackduba.gitnuro.extensions.isReverting
import com.jetpackduba.gitnuro.extensions.lowercaseContains
import com.jetpackduba.gitnuro.git.RefreshType
import com.jetpackduba.gitnuro.git.TabState
import com.jetpackduba.gitnuro.git.author.LoadAuthorUseCase
import com.jetpackduba.gitnuro.git.author.SaveAuthorUseCase
import com.jetpackduba.gitnuro.git.log.GetLastCommitMessageUseCase
import com.jetpackduba.gitnuro.git.log.GetSpecificCommitMessageUseCase
import com.jetpackduba.gitnuro.git.rebase.AbortRebaseUseCase
import com.jetpackduba.gitnuro.git.rebase.ContinueRebaseUseCase
import com.jetpackduba.gitnuro.git.rebase.RebaseInteractiveState
import com.jetpackduba.gitnuro.git.rebase.SkipRebaseUseCase
import com.jetpackduba.gitnuro.git.repository.ResetRepositoryStateUseCase
import com.jetpackduba.gitnuro.git.workspace.*
import com.jetpackduba.gitnuro.models.AuthorInfo
import com.jetpackduba.gitnuro.preferences.AppSettings
import com.jetpackduba.gitnuro.ui.tree_files.TreeItem
import com.jetpackduba.gitnuro.ui.tree_files.entriesToTreeEntry
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.eclipse.jgit.api.Git
import org.eclipse.jgit.lib.PersonIdent
import org.eclipse.jgit.lib.RepositoryState
import java.io.File
import javax.inject.Inject
private const val MIN_TIME_IN_MS_TO_SHOW_LOAD = 500L
class StatusViewModel @Inject constructor(
private val tabState: TabState,
private val stageEntryUseCase: StageEntryUseCase,
private val unstageEntryUseCase: UnstageEntryUseCase,
private val stageByDirectoryUseCase: StageByDirectoryUseCase,
private val unstageByDirectoryUseCase: UnstageByDirectoryUseCase,
private val resetEntryUseCase: DiscardEntryUseCase,
private val stageAllUseCase: StageAllUseCase,
private val unstageAllUseCase: UnstageAllUseCase,
private val getLastCommitMessageUseCase: GetLastCommitMessageUseCase,
private val resetRepositoryStateUseCase: ResetRepositoryStateUseCase,
private val continueRebaseUseCase: ContinueRebaseUseCase,
private val abortRebaseUseCase: AbortRebaseUseCase,
private val skipRebaseUseCase: SkipRebaseUseCase,
private val getStatusUseCase: GetStatusUseCase,
private val getStagedUseCase: GetStagedUseCase,
private val getUnstagedUseCase: GetUnstagedUseCase,
private val checkHasUncommittedChangesUseCase: CheckHasUncommittedChangesUseCase,
private val doCommitUseCase: DoCommitUseCase,
private val loadAuthorUseCase: LoadAuthorUseCase,
private val saveAuthorUseCase: SaveAuthorUseCase,
private val sharedRepositoryStateManager: SharedRepositoryStateManager,
private val getSpecificCommitMessageUseCase: GetSpecificCommitMessageUseCase,
private val appSettings: AppSettings,
tabScope: CoroutineScope,
) {
private val _showSearchUnstaged = MutableStateFlow(false)
val showSearchUnstaged: StateFlow<Boolean> = _showSearchUnstaged
private val _showSearchStaged = MutableStateFlow(false)
val showSearchStaged: StateFlow<Boolean> = _showSearchStaged
private val _searchFilterUnstaged = MutableStateFlow(TextFieldValue(""))
val searchFilterUnstaged: StateFlow<TextFieldValue> = _searchFilterUnstaged
private val _searchFilterStaged = MutableStateFlow(TextFieldValue(""))
val searchFilterStaged: StateFlow<TextFieldValue> = _searchFilterStaged
val swapUncommittedChanges = appSettings.swapUncommittedChangesFlow
val rebaseInteractiveState = sharedRepositoryStateManager.rebaseInteractiveState
private val treeContractedDirectories = MutableStateFlow(emptyList<String>())
private val showAsTree = appSettings.showChangesAsTreeFlow
private val _stageState = MutableStateFlow<StageState>(StageState.Loading)
private val stageStateFiltered: StateFlow<StageState> = combine(
_stageState,
_showSearchStaged,
_searchFilterStaged,
_showSearchUnstaged,
_searchFilterUnstaged,
) { state, showSearchStaged, filterStaged, showSearchUnstaged, filterUnstaged ->
if (state is StageState.Loaded) {
val unstaged = if (showSearchUnstaged && filterUnstaged.text.isNotBlank()) {
state.unstaged.filter { it.filePath.lowercaseContains(filterUnstaged.text) }
} else {
state.unstaged
}.prioritizeConflicts()
val staged = if (showSearchStaged && filterStaged.text.isNotBlank()) {
state.staged.filter { it.filePath.lowercaseContains(filterStaged.text) }
} else {
state.staged
}.prioritizeConflicts()
state.copy(staged = staged, unstaged = unstaged)
} else {
state
}
}
.stateIn(
tabScope,
SharingStarted.Lazily,
StageState.Loading
)
val stageStateUi: StateFlow<StageStateUi> = combine(
stageStateFiltered,
showAsTree,
treeContractedDirectories,
) { stageStateFiltered, showAsTree, contractedDirectories ->
when (stageStateFiltered) {
is StageState.Loaded -> {
if (showAsTree) {
StageStateUi.TreeLoaded(
staged = entriesToTreeEntry(stageStateFiltered.staged, contractedDirectories) { it.filePath },
unstaged = entriesToTreeEntry(stageStateFiltered.unstaged, contractedDirectories) { it.filePath },
isPartiallyReloading = stageStateFiltered.isPartiallyReloading,
)
} else {
StageStateUi.ListLoaded(
staged = stageStateFiltered.staged,
unstaged = stageStateFiltered.unstaged,
isPartiallyReloading = stageStateFiltered.isPartiallyReloading,
)
}
}
StageState.Loading -> StageStateUi.Loading
}
}
.stateIn(
tabScope,
SharingStarted.Lazily,
StageStateUi.Loading
)
var savedCommitMessage = CommitMessage("", MessageType.NORMAL)
var hasPreviousCommits = true // When false, disable "amend previous commit"
private var lastUncommittedChangesState = false
val stagedLazyListState = MutableStateFlow(LazyListState(0, 0))
val unstagedLazyListState = MutableStateFlow(LazyListState(0, 0))
private val _committerDataRequestState = MutableStateFlow<CommitterDataRequestState>(CommitterDataRequestState.None)
val committerDataRequestState: StateFlow<CommitterDataRequestState> = _committerDataRequestState
/**
* Notify the UI that the commit message has been changed by the view model
*/
private val _commitMessageChangesFlow = MutableSharedFlow<String>()
val commitMessageChangesFlow: SharedFlow<String> = _commitMessageChangesFlow
private val _isAmend = MutableStateFlow(false)
val isAmend: StateFlow<Boolean> = _isAmend
private val _isAmendRebaseInteractive =
MutableStateFlow(true) // TODO should copy message from previous commit when this is required
val isAmendRebaseInteractive: StateFlow<Boolean> = _isAmendRebaseInteractive
init {
tabScope.launch {
tabState.refreshFlowFiltered(
RefreshType.ALL_DATA,
RefreshType.UNCOMMITTED_CHANGES,
RefreshType.UNCOMMITTED_CHANGES_AND_LOG,
) {
refresh(tabState.git)
}
}
}
private fun persistMessage() = tabState.runOperation(
refreshType = RefreshType.NONE,
) { git ->
val messageToPersist = savedCommitMessage.message.ifBlank { null }
if (git.repository.repositoryState.isMerging ||
git.repository.repositoryState.isRebasing ||
git.repository.repositoryState.isReverting
) {
git.repository.writeMergeCommitMsg(messageToPersist)
} else if (git.repository.repositoryState == RepositoryState.SAFE) {
git.repository.writeCommitEditMsg(messageToPersist)
}
}
fun stage(statusEntry: StatusEntry) = tabState.runOperation(
refreshType = RefreshType.UNCOMMITTED_CHANGES,
showError = true,
) { git ->
stageEntryUseCase(git, statusEntry)
}
fun unstage(statusEntry: StatusEntry) = tabState.runOperation(
refreshType = RefreshType.UNCOMMITTED_CHANGES,
showError = true,
) { git ->
unstageEntryUseCase(git, statusEntry)
}
fun unstageAll() = tabState.safeProcessing(
refreshType = RefreshType.UNCOMMITTED_CHANGES,
taskType = TaskType.UNSTAGE_ALL_FILES,
) { git ->
unstageAllUseCase(git)
}
fun stageAll() = tabState.safeProcessing(
refreshType = RefreshType.UNCOMMITTED_CHANGES,
taskType = TaskType.STAGE_ALL_FILES,
) { git ->
stageAllUseCase(git)
}
fun resetStaged(statusEntry: StatusEntry) = tabState.runOperation(
refreshType = RefreshType.UNCOMMITTED_CHANGES_AND_LOG,
) { git ->
resetEntryUseCase(git, statusEntry, staged = true)
}
fun resetUnstaged(statusEntry: StatusEntry) = tabState.runOperation(
refreshType = RefreshType.UNCOMMITTED_CHANGES_AND_LOG,
) { git ->
resetEntryUseCase(git, statusEntry, staged = false)
}
private suspend fun loadStatus(git: Git) {
val previousStatus = _stageState.value
val requiredMessageType = if (git.repository.repositoryState == RepositoryState.MERGING) {
MessageType.MERGE
} else {
MessageType.NORMAL
}
if (requiredMessageType != savedCommitMessage.messageType) {
savedCommitMessage = CommitMessage(messageByRepoState(git), requiredMessageType)
_commitMessageChangesFlow.emit(savedCommitMessage.message)
} else if (savedCommitMessage.message.isEmpty()) {
savedCommitMessage = savedCommitMessage.copy(message = messageByRepoState(git))
_commitMessageChangesFlow.emit(savedCommitMessage.message)
}
try {
delayedStateChange(
delayMs = MIN_TIME_IN_MS_TO_SHOW_LOAD,
onDelayTriggered = {
if (previousStatus is StageState.Loaded) {
_stageState.value = previousStatus.copy(isPartiallyReloading = true)
} else {
_stageState.value = StageState.Loading
}
}
) {
val status = getStatusUseCase(git)
val staged = getStagedUseCase(status).sortedBy { it.filePath }
val unstaged = getUnstagedUseCase(status).sortedBy { it.filePath }
_stageState.value = StageState.Loaded(
staged = staged,
unstaged = unstaged,
isPartiallyReloading = false,
)
}
} catch (ex: Exception) {
_stageState.value = previousStatus
throw ex
}
}
private fun List<StatusEntry>.prioritizeConflicts(): List<StatusEntry> {
return this.groupBy { it.filePath }
.map {
val statusEntries = it.value
return@map if (statusEntries.count() == 1) {
statusEntries.first()
} else {
val conflictingEntry =
statusEntries.firstOrNull { entry -> entry.statusType == StatusType.CONFLICTING }
conflictingEntry ?: statusEntries.first()
}
}
}
private fun messageByRepoState(git: Git): String {
val message: String? = if (
git.repository.repositoryState.isMerging ||
git.repository.repositoryState.isRebasing ||
git.repository.repositoryState.isReverting
) {
git.repository.readMergeCommitMsg()
} else {
git.repository.readCommitEditMsg()
}
//TODO this replace is a workaround until this issue gets fixed https://github.com/JetBrains/compose-jb/issues/615
return message.orEmpty().replace("\t", " ")
}
private suspend fun loadHasUncommittedChanges(git: Git) = withContext(Dispatchers.IO) {
lastUncommittedChangesState = checkHasUncommittedChangesUseCase(git)
}
fun amend(isAmend: Boolean) {
_isAmend.value = isAmend
if (isAmend && savedCommitMessage.message.isEmpty()) {
takeMessageFromPreviousCommit()
}
}
fun amendRebaseInteractive(isAmend: Boolean) {
_isAmendRebaseInteractive.value = isAmend
if (isAmend && savedCommitMessage.message.isEmpty()) {
takeMessageFromAmendCommit()
}
}
fun commit(message: String) = tabState.safeProcessing(
refreshType = RefreshType.ALL_DATA,
taskType = TaskType.DO_COMMIT,
) { git ->
val amend = isAmend.value
val commitMessage = if (amend && message.isBlank()) {
getLastCommitMessageUseCase(git)
} else
message
val personIdent = getPersonIdent(git)
doCommitUseCase(git, commitMessage, amend, personIdent)
updateCommitMessage("")
_isAmend.value = false
}
private suspend fun getPersonIdent(git: Git): PersonIdent? {
val author = loadAuthorUseCase(git)
return if (
author.name.isNullOrEmpty() && author.globalName.isNullOrEmpty() ||
author.email.isNullOrEmpty() && author.globalEmail.isNullOrEmpty()
) {
_committerDataRequestState.value = CommitterDataRequestState.WaitingInput(author)
var committerData = _committerDataRequestState.value
while (committerData is CommitterDataRequestState.WaitingInput) {
committerData = _committerDataRequestState.value
}
if (committerData is CommitterDataRequestState.Accepted) {
val authorInfo = committerData.authorInfo
if (committerData.persist) {
saveAuthorUseCase(git, authorInfo)
}
PersonIdent(authorInfo.globalName, authorInfo.globalEmail)
} else {
null
}
} else
null
}
suspend fun refresh(git: Git) = withContext(Dispatchers.IO) {
loadStatus(git)
loadHasUncommittedChanges(git)
}
fun continueRebase(message: String) = tabState.safeProcessing(
refreshType = RefreshType.ALL_DATA,
taskType = TaskType.CONTINUE_REBASE,
) { git ->
val repositoryState = sharedRepositoryStateManager.repositoryState.value
val rebaseInteractiveState = sharedRepositoryStateManager.rebaseInteractiveState.value
if (
repositoryState == RepositoryState.REBASING_INTERACTIVE &&
rebaseInteractiveState is RebaseInteractiveState.ProcessingCommits &&
rebaseInteractiveState.isCurrentStepAmenable &&
isAmendRebaseInteractive.value
) {
val amendCommitId = rebaseInteractiveState.commitToAmendId
if (!amendCommitId.isNullOrBlank()) {
doCommitUseCase(git, message, true, getPersonIdent(git))
}
}
continueRebaseUseCase(git)
}
fun abortRebase() = tabState.safeProcessing(
refreshType = RefreshType.ALL_DATA,
taskType = TaskType.ABORT_REBASE,
) { git ->
abortRebaseUseCase(git)
}
fun skipRebase() = tabState.safeProcessing(
refreshType = RefreshType.ALL_DATA,
taskType = TaskType.SKIP_REBASE,
) { git ->
skipRebaseUseCase(git)
}
fun resetRepoState() = tabState.safeProcessing(
refreshType = RefreshType.ALL_DATA,
taskType = TaskType.RESET_REPO_STATE,
) { git ->
resetRepositoryStateUseCase(git)
}
fun deleteFile(statusEntry: StatusEntry) = tabState.runOperation(
refreshType = RefreshType.UNCOMMITTED_CHANGES,
) { git ->
val path = statusEntry.filePath
val fileToDelete = File(git.repository.workTree, path)
fileToDelete.deleteRecursively()
}
fun updateCommitMessage(message: String) {
savedCommitMessage = savedCommitMessage.copy(message = message)
persistMessage()
}
private fun takeMessageFromPreviousCommit() = tabState.runOperation(
refreshType = RefreshType.NONE,
) { git ->
savedCommitMessage = savedCommitMessage.copy(message = getLastCommitMessageUseCase(git))
persistMessage()
_commitMessageChangesFlow.emit(savedCommitMessage.message)
}
private fun takeMessageFromAmendCommit() = tabState.runOperation(
refreshType = RefreshType.NONE,
) { git ->
val rebaseInteractiveState = rebaseInteractiveState.value
if (rebaseInteractiveState !is RebaseInteractiveState.ProcessingCommits) {
return@runOperation
}
val commitId = rebaseInteractiveState.commitToAmendId ?: return@runOperation
val message = getSpecificCommitMessageUseCase(git, commitId)
savedCommitMessage = savedCommitMessage.copy(message = message)
persistMessage()
_commitMessageChangesFlow.emit(savedCommitMessage.message)
}
fun onRejectCommitterData() {
this._committerDataRequestState.value = CommitterDataRequestState.Reject
}
fun onAcceptCommitterData(newAuthorInfo: AuthorInfo, persist: Boolean) {
this._committerDataRequestState.value = CommitterDataRequestState.Accepted(newAuthorInfo, persist)
}
fun onSearchFilterToggledStaged(visible: Boolean) {
_showSearchStaged.value = visible
}
fun onSearchFilterChangedStaged(filter: TextFieldValue) {
_searchFilterStaged.value = filter
}
fun onSearchFilterToggledUnstaged(visible: Boolean) {
_showSearchUnstaged.value = visible
}
fun onSearchFilterChangedUnstaged(filter: TextFieldValue) {
_searchFilterUnstaged.value = filter
}
fun stagedTreeDirectoryClicked(directoryPath: String) {
val contractedDirectories = treeContractedDirectories.value
if (contractedDirectories.contains(directoryPath)) {
treeContractedDirectories.value -= directoryPath
} else {
treeContractedDirectories.value += directoryPath
}
}
fun alternateShowAsTree() {
appSettings.showChangesAsTree = !appSettings.showChangesAsTree
}
fun stageByDirectory(dir: String) = tabState.runOperation(
refreshType = RefreshType.UNCOMMITTED_CHANGES,
showError = true,
) { git ->
stageByDirectoryUseCase(git, dir)
}
fun unstageByDirectory(dir: String) = tabState.runOperation(
refreshType = RefreshType.UNCOMMITTED_CHANGES,
showError = true,
) { git ->
unstageByDirectoryUseCase(git, dir)
}
}
sealed interface StageState {
data object Loading : StageState
data class Loaded(
val staged: List<StatusEntry>,
val unstaged: List<StatusEntry>,
val isPartiallyReloading: Boolean,
) : StageState
}
sealed interface StageStateUi {
val hasStagedFiles: Boolean
val hasUnstagedFiles: Boolean
val isLoading: Boolean
val haveConflictsBeenSolved: Boolean
data object Loading : StageStateUi {
override val hasStagedFiles: Boolean
get() = false
override val hasUnstagedFiles: Boolean
get() = false
override val isLoading: Boolean
get() = true
override val haveConflictsBeenSolved: Boolean
get() = false
}
sealed interface Loaded : StageStateUi
data class TreeLoaded(
val staged: List<TreeItem<StatusEntry>>,
val unstaged: List<TreeItem<StatusEntry>>,
val isPartiallyReloading: Boolean,
) : Loaded {
override val hasStagedFiles: Boolean = staged.isNotEmpty()
override val hasUnstagedFiles: Boolean = unstaged.isNotEmpty()
override val isLoading: Boolean = isPartiallyReloading
override val haveConflictsBeenSolved: Boolean = unstaged.none {
it is TreeItem.File && it.data.statusType == StatusType.CONFLICTING
}
}
data class ListLoaded(
val staged: List<StatusEntry>,
val unstaged: List<StatusEntry>,
val isPartiallyReloading: Boolean,
) : Loaded {
override val hasStagedFiles: Boolean = staged.isNotEmpty()
override val hasUnstagedFiles: Boolean = unstaged.isNotEmpty()
override val isLoading: Boolean = isPartiallyReloading
override val haveConflictsBeenSolved: Boolean = unstaged.none { it.statusType == StatusType.CONFLICTING }
}
}
data class CommitMessage(val message: String, val messageType: MessageType)
enum class MessageType {
NORMAL,
MERGE;
}
sealed interface CommitterDataRequestState {
data object None : CommitterDataRequestState
data class WaitingInput(val authorInfo: AuthorInfo) : CommitterDataRequestState
data class Accepted(val authorInfo: AuthorInfo, val persist: Boolean) : CommitterDataRequestState
object Reject : CommitterDataRequestState
}