Gitnuro/src/main/kotlin/com/jetpackduba/gitnuro/viewmodels/TabViewModel.kt

459 lines
16 KiB
Kotlin

package com.jetpackduba.gitnuro.viewmodels
import com.jetpackduba.gitnuro.AppStateManager
import com.jetpackduba.gitnuro.ErrorsManager
import com.jetpackduba.gitnuro.credentials.CredentialsState
import com.jetpackduba.gitnuro.credentials.CredentialsStateManager
import com.jetpackduba.gitnuro.git.*
import com.jetpackduba.gitnuro.git.branches.CreateBranchUseCase
import com.jetpackduba.gitnuro.git.rebase.AbortRebaseUseCase
import com.jetpackduba.gitnuro.git.repository.GetRepositoryStateUseCase
import com.jetpackduba.gitnuro.git.repository.InitLocalRepositoryUseCase
import com.jetpackduba.gitnuro.git.repository.OpenRepositoryUseCase
import com.jetpackduba.gitnuro.git.stash.StashChangesUseCase
import com.jetpackduba.gitnuro.git.workspace.StageUntrackedFileUseCase
import com.jetpackduba.gitnuro.logging.printLog
import com.jetpackduba.gitnuro.models.AuthorInfoSimple
import com.jetpackduba.gitnuro.newErrorNow
import com.jetpackduba.gitnuro.ui.SelectedItem
import com.jetpackduba.gitnuro.updates.Update
import com.jetpackduba.gitnuro.updates.UpdatesRepository
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import org.eclipse.jgit.api.Git
import org.eclipse.jgit.api.errors.CheckoutConflictException
import org.eclipse.jgit.blame.BlameResult
import org.eclipse.jgit.lib.Repository
import org.eclipse.jgit.lib.RepositoryState
import org.eclipse.jgit.revwalk.RevCommit
import java.awt.Desktop
import java.io.File
import javax.inject.Inject
import javax.inject.Provider
private const val MIN_TIME_IN_MS_BETWEEN_REFRESHES = 1000L
private const val TAG = "TabViewModel"
/**
* Contains all the information related to a tab and its subcomponents (smaller composables like the log, branches,
* commit changes, etc.). It holds a reference to every view model because this class lives as long as the tab is open (survives
* across full app recompositions), therefore, tab's content can be recreated with these view models.
*/
class TabViewModel @Inject constructor(
private val getRepositoryStateUseCase: GetRepositoryStateUseCase,
private val initLocalRepositoryUseCase: InitLocalRepositoryUseCase,
private val openRepositoryUseCase: OpenRepositoryUseCase,
private val diffViewModelProvider: Provider<DiffViewModel>,
private val rebaseInteractiveViewModelProvider: Provider<RebaseInteractiveViewModel>,
private val historyViewModelProvider: Provider<HistoryViewModel>,
private val authorViewModelProvider: Provider<AuthorViewModel>,
private val tabState: TabState,
val appStateManager: AppStateManager,
private val fileChangesWatcher: FileChangesWatcher,
private val updatesRepository: UpdatesRepository,
private val credentialsStateManager: CredentialsStateManager,
private val createBranchUseCase: CreateBranchUseCase,
private val stashChangesUseCase: StashChangesUseCase,
private val stageUntrackedFileUseCase: StageUntrackedFileUseCase,
private val abortRebaseUseCase: AbortRebaseUseCase,
private val tabScope: CoroutineScope,
) {
val errorsManager: ErrorsManager = tabState.errorsManager
val selectedItem: StateFlow<SelectedItem> = tabState.selectedItem
var diffViewModel: DiffViewModel? = null
var rebaseInteractiveViewModel: RebaseInteractiveViewModel? = null
private set
private val _repositorySelectionStatus = MutableStateFlow<RepositorySelectionStatus>(RepositorySelectionStatus.None)
val repositorySelectionStatus: StateFlow<RepositorySelectionStatus>
get() = _repositorySelectionStatus
val processing: StateFlow<Boolean> = tabState.processing
val credentialsState: StateFlow<CredentialsState> = credentialsStateManager.credentialsState
private val _diffSelected = MutableStateFlow<DiffEntryType?>(null)
val diffSelected: StateFlow<DiffEntryType?> = _diffSelected
var newDiffSelected: DiffEntryType?
get() = diffSelected.value
set(value) {
_diffSelected.value = value
updateDiffEntry()
}
private val _repositoryState = MutableStateFlow(RepositoryState.SAFE)
val repositoryState: StateFlow<RepositoryState> = _repositoryState
private val _blameState = MutableStateFlow<BlameState>(BlameState.None)
val blameState: StateFlow<BlameState> = _blameState
private val _showHistory = MutableStateFlow(false)
val showHistory: StateFlow<Boolean> = _showHistory
private val _showAuthorInfo = MutableStateFlow(false)
val showAuthorInfo: StateFlow<Boolean> = _showAuthorInfo
private val _authorInfoSimple = MutableStateFlow(AuthorInfoSimple(null, null))
val authorInfoSimple: StateFlow<AuthorInfoSimple> = _authorInfoSimple
var historyViewModel: HistoryViewModel? = null
private set
var authorViewModel: AuthorViewModel? = null
private set
val showError = MutableStateFlow(false)
init {
tabScope.run {
launch {
tabState.refreshData.collect { refreshType ->
when (refreshType) {
RefreshType.NONE -> printLog(TAG, "Not refreshing...")
RefreshType.REPO_STATE -> refreshRepositoryState()
else -> {}
}
}
}
launch {
tabState.taskEvent.collect { taskEvent ->
when (taskEvent) {
is TaskEvent.RebaseInteractive -> onRebaseInteractive(taskEvent)
else -> { /*Nothing to do here*/
}
}
}
}
launch {
tabState.refreshFlowFiltered(RefreshType.ALL_DATA, RefreshType.REPO_STATE)
{
loadRepositoryState(tabState.git)
}
}
}
}
private fun refreshRepositoryState() = tabState.safeProcessing(
refreshType = RefreshType.NONE,
) { git ->
loadRepositoryState(git)
}
private suspend fun onRebaseInteractive(taskEvent: TaskEvent.RebaseInteractive) {
rebaseInteractiveViewModel = rebaseInteractiveViewModelProvider.get()
rebaseInteractiveViewModel?.startRebaseInteractive(taskEvent.revCommit)
}
fun openRepository(directory: String) {
openRepository(File(directory))
}
fun openRepository(directory: File) = tabState.safeProcessingWithoutGit {
printLog(TAG, "Trying to open repository ${directory.absoluteFile}")
_repositorySelectionStatus.value = RepositorySelectionStatus.Opening(directory.absolutePath)
val repository: Repository = openRepositoryUseCase(directory)
try {
repository.workTree // test if repository is valid
_repositorySelectionStatus.value = RepositorySelectionStatus.Open(repository)
val git = Git(repository)
tabState.initGit(git)
onRepositoryChanged(repository.directory.parent)
tabState.newSelectedItem(selectedItem = SelectedItem.UncommitedChanges)
newDiffSelected = null
refreshRepositoryInfo()
watchRepositoryChanges(git)
} catch (ex: Exception) {
ex.printStackTrace()
onRepositoryChanged(null)
errorsManager.addError(newErrorNow(ex, ex.localizedMessage))
_repositorySelectionStatus.value = RepositorySelectionStatus.None
}
}
private suspend fun loadRepositoryState(git: Git) = withContext(Dispatchers.IO) {
val newRepoState = getRepositoryStateUseCase(git)
printLog(TAG, "Refreshing repository state $newRepoState")
_repositoryState.value = newRepoState
loadAuthorInfo(git)
onRepositoryStateChanged(newRepoState)
}
private fun loadAuthorInfo(git: Git) {
val config = git.repository.config
config.load()
val userName = config.getString("user", null, "name")
val email = config.getString("user", null, "email")
_authorInfoSimple.value = AuthorInfoSimple(userName, email)
}
fun showAuthorInfoDialog() {
authorViewModel = authorViewModelProvider.get()
authorViewModel?.loadAuthorInfo()
_showAuthorInfo.value = true
}
fun closeAuthorInfoDialog() {
_showAuthorInfo.value = false
authorViewModel = null
}
private fun onRepositoryStateChanged(newRepoState: RepositoryState) {
if (newRepoState != RepositoryState.REBASING_INTERACTIVE && rebaseInteractiveViewModel != null) {
rebaseInteractiveViewModel?.cancel()
rebaseInteractiveViewModel = null
}
}
private suspend fun watchRepositoryChanges(git: Git) = tabScope.launch(Dispatchers.IO) {
val ignored = git.status().call().ignoredNotInIndex.toList()
var asyncJob: Job? = null
var lastNotify = 0L
var hasGitDirChanged = false
launch {
fileChangesWatcher.changesNotifier.collect { latestUpdateChangedGitDir ->
if (!tabState.operationRunning) { // Only update if there isn't any process running
printLog(TAG, "Detected changes in the repository's directory")
if (latestUpdateChangedGitDir) {
hasGitDirChanged = true
}
asyncJob?.cancel()
// Sometimes external apps can run filesystem multiple operations in a fraction of a second.
// To prevent excessive updates, we add a slight delay between updates emission to prevent slowing down
// the app by constantly running "git status".
val currentTimeMillis = System.currentTimeMillis()
val diffTime = currentTimeMillis - lastNotify
// When .git dir has changed, do the refresh with a delay to avoid doing operations while a git
// operation may be running
if (diffTime > MIN_TIME_IN_MS_BETWEEN_REFRESHES && !hasGitDirChanged) {
updateApp(false)
printLog(TAG, "Sync emit with diff time $diffTime")
} else {
asyncJob = async {
delay(MIN_TIME_IN_MS_BETWEEN_REFRESHES)
printLog(TAG, "Async emit")
if (isActive)
updateApp(hasGitDirChanged)
hasGitDirChanged = false
}
}
lastNotify = currentTimeMillis
}
}
}
fileChangesWatcher.watchDirectoryPath(
pathStr = git.repository.directory.parent,
ignoredDirsPath = ignored,
)
}
suspend fun updateApp(hasGitDirChanged: Boolean) {
if (hasGitDirChanged) {
printLog(TAG, "Changes detected in git directory, full refresh")
refreshRepositoryInfo()
} else {
printLog(TAG, "Changes detected, partial refresh")
checkUncommitedChanges()
}
}
private suspend fun checkUncommitedChanges() = tabState.runOperation(
refreshType = RefreshType.NONE,
) {
updateDiffEntry()
tabState.refreshData(RefreshType.UNCOMMITED_CHANGES_AND_LOG)
//
// // Stashes list should only be updated if we are doing a stash operation, however it's a small operation
// // that we can afford to do when doing other operations
// stashesViewModel.refresh(git)
// loadRepositoryState(git)
}
private suspend fun refreshRepositoryInfo() {
tabState.refreshData(RefreshType.ALL_DATA)
}
fun credentialsDenied() {
credentialsStateManager.updateState(CredentialsState.CredentialsDenied)
}
fun httpCredentialsAccepted(user: String, password: String) {
credentialsStateManager.updateState(CredentialsState.HttpCredentialsAccepted(user, password))
}
fun sshCredentialsAccepted(password: String) {
credentialsStateManager.updateState(CredentialsState.SshCredentialsAccepted(password))
}
var onRepositoryChanged: (path: String?) -> Unit = {}
fun dispose() {
tabScope.cancel()
}
private fun updateDiffEntry() {
val diffSelected = diffSelected.value
if (diffSelected != null) {
if (diffViewModel == null) { // Initialize the view model if required
diffViewModel = diffViewModelProvider.get()
}
diffViewModel?.cancelRunningJobs()
diffViewModel?.updateDiff(diffSelected)
} else {
diffViewModel?.cancelRunningJobs()
diffViewModel = null // Free the view model from the memory if not being used.
}
}
fun initLocalRepository(dir: String) = tabState.safeProcessingWithoutGit(
showError = true,
) {
val repoDir = File(dir)
initLocalRepositoryUseCase(repoDir)
openRepository(repoDir)
}
suspend fun latestRelease(): Update? = withContext(Dispatchers.IO) {
try {
updatesRepository.latestRelease()
} catch (ex: Exception) {
ex.printStackTrace()
null
}
}
fun blameFile(filePath: String) = tabState.safeProcessing(
refreshType = RefreshType.NONE,
) { git ->
_blameState.value = BlameState.Loading(filePath)
try {
val result = git.blame()
.setFilePath(filePath)
.setFollowFileRenames(true)
.call() ?: throw Exception("File is no longer present in the workspace and can't be blamed")
_blameState.value = BlameState.Loaded(filePath, result)
} catch (ex: Exception) {
resetBlameState()
throw ex
}
}
fun resetBlameState() {
_blameState.value = BlameState.None
}
fun expandBlame() {
val blameState = _blameState.value
if (blameState is BlameState.Loaded && blameState.isMinimized) {
_blameState.value = blameState.copy(isMinimized = false)
}
}
fun minimizeBlame() {
val blameState = _blameState.value
if (blameState is BlameState.Loaded && !blameState.isMinimized) {
_blameState.value = blameState.copy(isMinimized = true)
}
}
fun selectCommit(commit: RevCommit) = tabState.runOperation(
refreshType = RefreshType.NONE,
) {
tabState.newSelectedItem(SelectedItem.Commit(commit))
}
fun selectUncommitedChanges() = tabState.runOperation(
refreshType = RefreshType.NONE,
) {
tabState.newSelectedItem(SelectedItem.UncommitedChanges, true)
}
fun fileHistory(filePath: String) {
historyViewModel = historyViewModelProvider.get()
historyViewModel?.fileHistory(filePath)
_showHistory.value = true
}
fun closeHistory() {
_showHistory.value = false
historyViewModel = null
}
fun refreshAll() = tabScope.launch {
printLog(TAG, "Manual refresh triggered. IS OPERATION RUNNING ${tabState.operationRunning}")
if (!tabState.operationRunning) {
refreshRepositoryInfo()
}
}
fun createBranch(branchName: String) = tabState.safeProcessing(
refreshType = RefreshType.ALL_DATA,
refreshEvenIfCrashesInteractive = { it is CheckoutConflictException },
) { git ->
createBranchUseCase(git, branchName)
}
fun stashWithMessage(message: String) = tabState.safeProcessing(
refreshType = RefreshType.UNCOMMITED_CHANGES_AND_LOG,
) { git ->
stageUntrackedFileUseCase(git)
stashChangesUseCase(git, message)
}
fun openFolderInFileExplorer() = tabState.runOperation(
showError = true,
refreshType = RefreshType.NONE,
) { git ->
Desktop.getDesktop().open(git.repository.directory.parentFile)
}
fun cancelRebaseInteractive() = tabState.safeProcessing(
refreshType = RefreshType.ALL_DATA,
) { git ->
abortRebaseUseCase(git)
rebaseInteractiveViewModel = null // shouldn't be necessary but just to make sure
}
}
sealed class RepositorySelectionStatus {
object None : RepositorySelectionStatus()
data class Opening(val path: String) : RepositorySelectionStatus()
data class Open(val repository: Repository) : RepositorySelectionStatus()
}
sealed interface BlameState {
data class Loading(val filePath: String) : BlameState
data class Loaded(val filePath: String, val blameResult: BlameResult, val isMinimized: Boolean = false) : BlameState
object None : BlameState
}