package com.jetpackduba.gitnuro.viewmodels import androidx.compose.foundation.ScrollState import androidx.compose.foundation.lazy.LazyListState import com.jetpackduba.gitnuro.TaskType import com.jetpackduba.gitnuro.extensions.delayedStateChange import com.jetpackduba.gitnuro.extensions.shortName import com.jetpackduba.gitnuro.git.RefreshType import com.jetpackduba.gitnuro.git.TabState import com.jetpackduba.gitnuro.git.TaskEvent import com.jetpackduba.gitnuro.git.branches.CreateBranchOnCommitUseCase import com.jetpackduba.gitnuro.git.branches.GetCurrentBranchUseCase 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.StartRebaseInteractiveUseCase import com.jetpackduba.gitnuro.git.tags.CreateTagOnCommitUseCase import com.jetpackduba.gitnuro.git.workspace.CheckHasUncommittedChangesUseCase import com.jetpackduba.gitnuro.git.workspace.GetStatusSummaryUseCase import com.jetpackduba.gitnuro.git.workspace.StatusSummary import com.jetpackduba.gitnuro.repositories.AppSettingsRepository import com.jetpackduba.gitnuro.ui.SelectedItem import com.jetpackduba.gitnuro.ui.log.LogDialog import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import org.eclipse.jgit.api.Git import org.eclipse.jgit.api.errors.CheckoutConflictException import org.eclipse.jgit.lib.Ref import org.eclipse.jgit.revwalk.RevCommit import javax.inject.Inject /** * Represents when the search filter is not being used or the results list is empty */ private const val NONE_MATCHING_INDEX = 0 /** * The search UI starts the index count at 1 (for example "1/10" to represent the first commit of the search result * being selected) */ private const val FIRST_INDEX = 1 private const val LOG_MIN_TIME_IN_MS_TO_SHOW_LOAD = 500L class LogViewModel @Inject constructor( private val getLogUseCase: GetLogUseCase, private val getStatusSummaryUseCase: GetStatusSummaryUseCase, private val checkHasUncommittedChangesUseCase: CheckHasUncommittedChangesUseCase, private val getCurrentBranchUseCase: GetCurrentBranchUseCase, private val createBranchOnCommitUseCase: CreateBranchOnCommitUseCase, private val checkoutCommitUseCase: CheckoutCommitUseCase, private val revertCommitUseCase: RevertCommitUseCase, private val resetToCommitUseCase: ResetToCommitUseCase, private val cherryPickCommitUseCase: CherryPickCommitUseCase, private val createTagOnCommitUseCase: CreateTagOnCommitUseCase, private val startRebaseInteractiveUseCase: StartRebaseInteractiveUseCase, private val tabState: TabState, private val appSettingsRepository: AppSettingsRepository, tabScope: CoroutineScope, sharedStashViewModel: SharedStashViewModel, sharedBranchesViewModel: SharedBranchesViewModel, sharedRemotesViewModel: SharedRemotesViewModel, sharedTagsViewModel: SharedTagsViewModel, ) : ViewModel, ISharedStashViewModel by sharedStashViewModel, ISharedBranchesViewModel by sharedBranchesViewModel, ISharedRemotesViewModel by sharedRemotesViewModel, ISharedTagsViewModel by sharedTagsViewModel { private val _logStatus = MutableStateFlow(LogStatus.Loading) val logStatus: StateFlow get() = _logStatus var savedSearchFilter: String = "" var graphPadding = 0f private val scrollToItem: Flow = tabState.taskEvent .filterIsInstance() .map { it.selectedItem } .filterIsInstance() .map { it.revCommit } val scrollToUncommittedChanges: Flow = tabState.taskEvent .filterIsInstance() .map { it.selectedItem } .filterIsInstance() private val _focusCommit = MutableSharedFlow() val focusCommit: Flow = merge(_focusCommit, scrollToItem) private val _logDialog = MutableStateFlow(LogDialog.None) val logDialog: StateFlow = _logDialog val verticalListState = MutableStateFlow(LazyListState(0, 0)) val horizontalListState = MutableStateFlow(ScrollState(0)) private val _logSearchFilterResults = MutableStateFlow(LogSearch.NotSearching) val logSearchFilterResults: StateFlow = _logSearchFilterResults init { tabScope.launch { appSettingsRepository.commitsLimitEnabledFlow.drop(1).collectLatest { tabState.refreshData(RefreshType.ONLY_LOG) } } tabScope.launch { appSettingsRepository.commitsLimitFlow.collectLatest { tabState.refreshData(RefreshType.ONLY_LOG) } } tabScope.launch { tabState.refreshFlowFiltered( RefreshType.ALL_DATA, RefreshType.ONLY_LOG, RefreshType.UNCOMMITTED_CHANGES, RefreshType.UNCOMMITTED_CHANGES_AND_LOG, ) { refreshType -> if (refreshType == RefreshType.UNCOMMITTED_CHANGES) { uncommittedChangesLoadLog(tabState.git) } else refresh(tabState.git) } } } private suspend fun loadLog(git: Git) = delayedStateChange( delayMs = LOG_MIN_TIME_IN_MS_TO_SHOW_LOAD, onDelayTriggered = { _logStatus.value = LogStatus.Loading } ) { val currentBranch = getCurrentBranchUseCase(git) val statusSummary = getStatusSummaryUseCase( git = git, ) val hasUncommittedChanges = statusSummary.total > 0 val commitsLimit = if (appSettingsRepository.commitsLimitEnabled) { appSettingsRepository.commitsLimit } else Int.MAX_VALUE val commitsLimitDisplayed = if (appSettingsRepository.commitsLimitEnabled) { appSettingsRepository.commitsLimit } else -1 val log = getLogUseCase(git, currentBranch, hasUncommittedChanges, commitsLimit) _logStatus.value = LogStatus.Loaded(hasUncommittedChanges, log, currentBranch, statusSummary, commitsLimitDisplayed) // Remove search filter if the log has been updated _logSearchFilterResults.value = LogSearch.NotSearching } fun checkoutCommit(revCommit: RevCommit) = tabState.safeProcessing( refreshType = RefreshType.ALL_DATA, title = "Commit checkout", subtitle = "Checking out commit ${revCommit.name}", taskType = TaskType.CHECKOUT_COMMIT, ) { git -> checkoutCommitUseCase(git, revCommit) } fun revertCommit(revCommit: RevCommit) = tabState.safeProcessing( refreshType = RefreshType.ALL_DATA, title = "Commit revert", subtitle = "Reverting commit ${revCommit.name}", refreshEvenIfCrashes = true, taskType = TaskType.REVERT_COMMIT, ) { git -> revertCommitUseCase(git, revCommit) } fun resetToCommit(revCommit: RevCommit, resetType: ResetType) = tabState.safeProcessing( refreshType = RefreshType.ALL_DATA, title = "Branch reset", subtitle = "Reseting branch to commit ${revCommit.shortName}", taskType = TaskType.RESET_TO_COMMIT, ) { git -> resetToCommitUseCase(git, revCommit, resetType = resetType) } fun cherrypickCommit(revCommit: RevCommit) = tabState.safeProcessing( refreshType = RefreshType.UNCOMMITTED_CHANGES_AND_LOG, title = "Cherry-pick", subtitle = "Cherry-picking commit ${revCommit.shortName}", taskType = TaskType.CHERRY_PICK_COMMIT, ) { git -> cherryPickCommitUseCase(git, revCommit) } fun createBranchOnCommit(branch: String, revCommit: RevCommit) = tabState.safeProcessing( refreshType = RefreshType.ALL_DATA, title = "New branch", subtitle = "Creating new branch \"$branch\" on commit ${revCommit.shortName}", refreshEvenIfCrashesInteractive = { it is CheckoutConflictException }, taskType = TaskType.CREATE_BRANCH, ) { git -> createBranchOnCommitUseCase(git, branch, revCommit) } fun createTagOnCommit(tag: String, revCommit: RevCommit) = tabState.safeProcessing( refreshType = RefreshType.ALL_DATA, title = "New tag", subtitle = "Creating new tag \"$tag\" on commit ${revCommit.shortName}", taskType = TaskType.CREATE_TAG, ) { git -> createTagOnCommitUseCase(git, tag, revCommit) } private suspend fun uncommittedChangesLoadLog(git: Git) { val currentBranch = getCurrentBranchUseCase(git) val hasUncommittedChanges = checkHasUncommittedChangesUseCase(git) val statsSummary = if (hasUncommittedChanges) { getStatusSummaryUseCase( git = git, ) } else StatusSummary(0, 0, 0, 0) val previousLogStatusValue = _logStatus.value if (previousLogStatusValue is LogStatus.Loaded) { val newLogStatusValue = LogStatus.Loaded( hasUncommittedChanges = hasUncommittedChanges, plotCommitList = previousLogStatusValue.plotCommitList, currentBranch = currentBranch, statusSummary = statsSummary, commitsLimit = previousLogStatusValue.commitsLimit, ) _logStatus.value = newLogStatusValue } } suspend fun refresh(git: Git) { loadLog(git) } fun selectUncommittedChanges() = tabState.runOperation( refreshType = RefreshType.NONE, ) { tabState.newSelectedItem(SelectedItem.UncommittedChanges) val searchValue = _logSearchFilterResults.value if (searchValue is LogSearch.SearchResults) { val lastIndexSelected = getLastIndexSelected() _logSearchFilterResults.value = searchValue.copy(index = lastIndexSelected) } } private fun getLastIndexSelected(): Int { val logSearchFilterResultsValue = logSearchFilterResults.value return if (logSearchFilterResultsValue is LogSearch.SearchResults) { logSearchFilterResultsValue.index } else NONE_MATCHING_INDEX } fun selectLogLine(commit: GraphNode) = tabState.runOperation( refreshType = RefreshType.NONE, ) { tabState.newSelectedCommit(commit) val searchValue = _logSearchFilterResults.value if (searchValue is LogSearch.SearchResults) { var index = searchValue.commits.indexOf(commit) if (index == -1) index = getLastIndexSelected() else index += 1 // +1 because UI count starts at 1 _logSearchFilterResults.value = searchValue.copy(index = index) } } suspend fun onSearchValueChanged(searchTerm: String) { val logStatusValue = logStatus.value if (logStatusValue !is LogStatus.Loaded) return savedSearchFilter = searchTerm if (searchTerm.isNotBlank()) { val lowercaseValue = searchTerm.lowercase() val plotCommitList = logStatusValue.plotCommitList val matchingCommits = plotCommitList.filter { it.fullMessage.lowercase().contains(lowercaseValue) || it.authorIdent.name.lowercase().contains(lowercaseValue) || it.committerIdent.name.lowercase().contains(lowercaseValue) || it.name.lowercase().contains(lowercaseValue) } var startingUiIndex = NONE_MATCHING_INDEX if (matchingCommits.isNotEmpty()) { _focusCommit.emit(matchingCommits.first()) startingUiIndex = FIRST_INDEX } _logSearchFilterResults.value = LogSearch.SearchResults(matchingCommits, startingUiIndex) } else _logSearchFilterResults.value = LogSearch.SearchResults(emptyList(), NONE_MATCHING_INDEX) } suspend fun selectPreviousFilterCommit() { val logSearchFilterResultsValue = logSearchFilterResults.value if (logSearchFilterResultsValue !is LogSearch.SearchResults) { return } val index = logSearchFilterResultsValue.index val commits = logSearchFilterResultsValue.commits if (index == NONE_MATCHING_INDEX || index == FIRST_INDEX) return val newIndex = index - 1 val newCommitToSelect = commits[newIndex - 1] _logSearchFilterResults.value = logSearchFilterResultsValue.copy(index = newIndex) _focusCommit.emit(newCommitToSelect) } suspend fun selectNextFilterCommit() { val logSearchFilterResultsValue = logSearchFilterResults.value if (logSearchFilterResultsValue !is LogSearch.SearchResults) { return } val index = logSearchFilterResultsValue.index val commits = logSearchFilterResultsValue.commits val totalCount = logSearchFilterResultsValue.totalCount if (index == NONE_MATCHING_INDEX || index == totalCount) return val newIndex = index + 1 // Use index instead of newIndex because Kotlin arrays start at 0 while the UI count starts at 1 val newCommitToSelect = commits[index] _logSearchFilterResults.value = logSearchFilterResultsValue.copy(index = newIndex) _focusCommit.emit(newCommitToSelect) } fun showDialog(dialog: LogDialog) { _logDialog.value = dialog } fun closeSearch() { _logSearchFilterResults.value = LogSearch.NotSearching } fun rebaseInteractive(revCommit: RevCommit) = tabState.safeProcessing( refreshType = RefreshType.REBASE_INTERACTIVE_STATE, taskType = TaskType.REBASE_INTERACTIVE, ) { git -> startRebaseInteractiveUseCase(git, revCommit) } } sealed interface LogStatus { data object Loading : LogStatus class Loaded( val hasUncommittedChanges: Boolean, val plotCommitList: GraphCommitList, val currentBranch: Ref?, val statusSummary: StatusSummary, val commitsLimit: Int, ) : LogStatus } sealed interface LogSearch { data object NotSearching : LogSearch data class SearchResults( val commits: List, val index: Int, val totalCount: Int = commits.count(), ) : LogSearch }