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.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 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 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, tabScope: CoroutineScope, appSettings: AppSettings, ) { private val _showSearchUnstaged = MutableStateFlow(false) val showSearchUnstaged: StateFlow = _showSearchUnstaged private val _showSearchStaged = MutableStateFlow(false) val showSearchStaged: StateFlow = _showSearchStaged private val _searchFilterUnstaged = MutableStateFlow(TextFieldValue("")) val searchFilterUnstaged: StateFlow = _searchFilterUnstaged private val _searchFilterStaged = MutableStateFlow(TextFieldValue("")) val searchFilterStaged: StateFlow = _searchFilterStaged val swapUncommittedChanges = appSettings.swapUncommittedChangesFlow val rebaseInteractiveState = sharedRepositoryStateManager.rebaseInteractiveState private val _stageState = MutableStateFlow(StageState.Loading) val stageState: StateFlow = 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(stagedFiltered = staged, unstagedFiltered = unstaged) } else { state } }.stateIn( tabScope, SharingStarted.Lazily, StageState.Loading ) fun List.prioritizeConflicts(): List { 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() } } } 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.None) val committerDataRequestState: StateFlow = _committerDataRequestState /** * Notify the UI that the commit message has been changed by the view model */ private val _commitMessageChangesFlow = MutableSharedFlow() val commitMessageChangesFlow: SharedFlow = _commitMessageChangesFlow private val _isAmend = MutableStateFlow(false) val isAmend: StateFlow = _isAmend private val _isAmendRebaseInteractive = MutableStateFlow(true) // TODO should copy message from previous commit when this is required val isAmendRebaseInteractive: StateFlow = _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, ) { git -> unstageAllUseCase(git) } fun stageAll() = tabState.safeProcessing( refreshType = RefreshType.UNCOMMITTED_CHANGES, ) { 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, stagedFiltered = staged, unstaged = unstaged, unstagedFiltered = unstaged, isPartiallyReloading = false ) } } catch (ex: Exception) { _stageState.value = previousStatus throw ex } } 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, ) { 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, ) { 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, ) { git -> abortRebaseUseCase(git) } fun skipRebase() = tabState.safeProcessing( refreshType = RefreshType.ALL_DATA, ) { git -> skipRebaseUseCase(git) } fun resetRepoState() = tabState.safeProcessing( refreshType = RefreshType.ALL_DATA, ) { 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 } } sealed interface StageState { data object Loading : StageState data class Loaded( val staged: List, val stagedFiltered: List, val unstaged: List, val unstagedFiltered: List, val isPartiallyReloading: Boolean ) : StageState } data class CommitMessage(val message: String, val messageType: MessageType) enum class MessageType { NORMAL, MERGE; } sealed interface CommitterDataRequestState { object None : CommitterDataRequestState data class WaitingInput(val authorInfo: AuthorInfo) : CommitterDataRequestState data class Accepted(val authorInfo: AuthorInfo, val persist: Boolean) : CommitterDataRequestState object Reject : CommitterDataRequestState }