From 355cbc3f79c69eec3adf10f83a4dc41c6aa9e886 Mon Sep 17 00:00:00 2001 From: Abdelilah El Aissaoui Date: Sun, 8 Sep 2024 23:47:31 +0200 Subject: [PATCH] Code refactoring --- .../extensions/LazyListStateExtensions.kt | 9 - .../com/jetpackduba/gitnuro/git/TabState.kt | 30 +- .../com/jetpackduba/gitnuro/ui/AppTab.kt | 137 +------ .../jetpackduba/gitnuro/ui/RepositoryOpen.kt | 146 +++---- .../gitnuro/ui/components/Notification.kt | 93 +++++ .../gitnuro/ui/dialogs/CredentialsDialog.kt | 52 +++ .../gitnuro/updates/UpdatesRepository.kt | 2 +- .../viewmodels/RepositoryOpenViewModel.kt | 380 ++++++++++++++++++ .../gitnuro/viewmodels/TabViewModel.kt | 296 +------------- 9 files changed, 636 insertions(+), 509 deletions(-) delete mode 100644 src/main/kotlin/com/jetpackduba/gitnuro/extensions/LazyListStateExtensions.kt create mode 100644 src/main/kotlin/com/jetpackduba/gitnuro/ui/components/Notification.kt create mode 100644 src/main/kotlin/com/jetpackduba/gitnuro/ui/dialogs/CredentialsDialog.kt create mode 100644 src/main/kotlin/com/jetpackduba/gitnuro/viewmodels/RepositoryOpenViewModel.kt diff --git a/src/main/kotlin/com/jetpackduba/gitnuro/extensions/LazyListStateExtensions.kt b/src/main/kotlin/com/jetpackduba/gitnuro/extensions/LazyListStateExtensions.kt deleted file mode 100644 index df6c431..0000000 --- a/src/main/kotlin/com/jetpackduba/gitnuro/extensions/LazyListStateExtensions.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.jetpackduba.gitnuro.extensions - -import androidx.compose.foundation.lazy.LazyListState - -fun LazyListState.observeScrollChanges() { - // When accessing this property, this parent composable is recomposed when the scroll changes - // because LazyListState is marked with @Stable - this.firstVisibleItemScrollOffset -} \ No newline at end of file diff --git a/src/main/kotlin/com/jetpackduba/gitnuro/git/TabState.kt b/src/main/kotlin/com/jetpackduba/gitnuro/git/TabState.kt index 60fc50a..d5ed6fd 100644 --- a/src/main/kotlin/com/jetpackduba/gitnuro/git/TabState.kt +++ b/src/main/kotlin/com/jetpackduba/gitnuro/git/TabState.kt @@ -39,6 +39,7 @@ class TabState @Inject constructor( val selectedItem: StateFlow = _selectedItem private val _taskEvent = MutableSharedFlow() val taskEvent: SharedFlow = _taskEvent + var lastOperation: Long = 0 private set @@ -52,8 +53,11 @@ class TabState @Inject constructor( return unsafeGit } - private val _refreshData = MutableSharedFlow() - val refreshData: SharedFlow = _refreshData + private val refreshData = MutableSharedFlow() + private val closeableViews = ArrayDeque() + + private val _closeView = MutableSharedFlow() + val closeViewFlow = _closeView.asSharedFlow() /** * Property that indicates if a git operation is running @@ -130,7 +134,7 @@ class TabState @Inject constructor( lastOperation = System.currentTimeMillis() if (refreshType != RefreshType.NONE && (!hasProcessFailed || refreshEvenIfCrashes || refreshEvenIfCrashesInteractiveResult)) { - _refreshData.emit(refreshType) + refreshData.emit(refreshType) } } } @@ -228,7 +232,7 @@ class TabState @Inject constructor( printError(TAG, ex.message.orEmpty(), ex) } finally { if (refreshType != RefreshType.NONE && (!hasProcessFailed || refreshEvenIfCrashes)) - _refreshData.emit(refreshType) + refreshData.emit(refreshType) operationRunning = false lastOperation = System.currentTimeMillis() @@ -236,7 +240,7 @@ class TabState @Inject constructor( } suspend fun refreshData(refreshType: RefreshType) { - _refreshData.emit(refreshType) + refreshData.emit(refreshType) } suspend fun newSelectedStash(stash: RevCommit) { @@ -307,6 +311,22 @@ class TabState @Inject constructor( } } + fun addCloseableView(id: Int) { + closeableViews.add(id) + } + + fun removeCloseableView(id: Int) { + closeableViews.remove(id) + } + + suspend fun closeLastView() { + val last = closeableViews.removeLastOrNull() + + if (last != null) { + _closeView.emit(last) + } + } + fun cancelCurrentTask() { currentJob?.cancel() } diff --git a/src/main/kotlin/com/jetpackduba/gitnuro/ui/AppTab.kt b/src/main/kotlin/com/jetpackduba/gitnuro/ui/AppTab.kt index fab1110..f486bb2 100644 --- a/src/main/kotlin/com/jetpackduba/gitnuro/ui/AppTab.kt +++ b/src/main/kotlin/com/jetpackduba/gitnuro/ui/AppTab.kt @@ -1,34 +1,19 @@ package com.jetpackduba.gitnuro.ui import androidx.compose.animation.Crossfade -import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.foundation.background -import androidx.compose.foundation.border import androidx.compose.foundation.layout.* -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.Icon import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp -import com.jetpackduba.gitnuro.AppIcons import com.jetpackduba.gitnuro.LoadingRepository import com.jetpackduba.gitnuro.ProcessingScreen -import com.jetpackduba.gitnuro.credentials.CredentialsAccepted -import com.jetpackduba.gitnuro.credentials.CredentialsRequested -import com.jetpackduba.gitnuro.credentials.CredentialsState import com.jetpackduba.gitnuro.git.ProcessingState -import com.jetpackduba.gitnuro.models.Notification -import com.jetpackduba.gitnuro.models.NotificationType -import com.jetpackduba.gitnuro.theme.AppTheme +import com.jetpackduba.gitnuro.ui.components.Notification import com.jetpackduba.gitnuro.ui.dialogs.CloneDialog -import com.jetpackduba.gitnuro.ui.dialogs.GpgPasswordDialog -import com.jetpackduba.gitnuro.ui.dialogs.SshPasswordDialog -import com.jetpackduba.gitnuro.ui.dialogs.UserPasswordDialog +import com.jetpackduba.gitnuro.ui.dialogs.CredentialsDialog import com.jetpackduba.gitnuro.ui.dialogs.errors.ErrorDialog import com.jetpackduba.gitnuro.ui.dialogs.settings.SettingsDialog import com.jetpackduba.gitnuro.viewmodels.RepositorySelectionStatus @@ -109,7 +94,7 @@ fun AppTab( is RepositorySelectionStatus.Open -> { RepositoryOpenPage( - tabViewModel = tabViewModel, + repositoryOpenViewModel = tabViewModel.repositoryOpenViewModel, onShowSettingsDialog = { showSettingsDialog = true }, onShowCloneDialog = { showCloneDialog = true }, ) @@ -148,119 +133,3 @@ fun AppTab( } } } - -@Preview -@Composable -fun NotificationSuccessPreview() { - AppTheme(customTheme = null) { - Notification(NotificationType.Positive, "Hello world!") - } -} - -@Composable -fun Notification(notification: Notification) { - val notificationShape = RoundedCornerShape(8.dp) - - Row( - modifier = Modifier - .padding(8.dp) - .border(2.dp, MaterialTheme.colors.onBackground.copy(0.2f), notificationShape) - .clip(notificationShape) - .background(MaterialTheme.colors.background) - .height(IntrinsicSize.Max) - .padding(2.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - val backgroundColor = when (notification.type) { - NotificationType.Positive -> MaterialTheme.colors.primary - NotificationType.Warning -> MaterialTheme.colors.secondary - NotificationType.Error -> MaterialTheme.colors.error - } - - val contentColor = when (notification.type) { - NotificationType.Positive -> MaterialTheme.colors.onPrimary - NotificationType.Warning -> MaterialTheme.colors.onSecondary - NotificationType.Error -> MaterialTheme.colors.onError - } - - val icon = when (notification.type) { - NotificationType.Positive -> AppIcons.INFO - NotificationType.Warning -> AppIcons.WARNING - NotificationType.Error -> AppIcons.ERROR - } - - Box( - modifier = Modifier - .clip(RoundedCornerShape(topStart = 6.dp, bottomStart = 6.dp)) - .background(backgroundColor) - .fillMaxHeight() - ) { - Icon( - painterResource(icon), - contentDescription = null, - tint = contentColor, - modifier = Modifier - .padding(4.dp) - ) - } - - Box( - modifier = Modifier - .padding(end = 8.dp) - .fillMaxHeight(), - contentAlignment = Alignment.CenterStart, - ) { - Text( - text = notification.text, - modifier = Modifier, - color = MaterialTheme.colors.onBackground, - style = MaterialTheme.typography.body1, - ) - } - } -} - -@Composable -fun CredentialsDialog(gitManager: TabViewModel) { - val credentialsState = gitManager.credentialsState.collectAsState() - - when (val credentialsStateValue = credentialsState.value) { - CredentialsRequested.HttpCredentialsRequested -> { - UserPasswordDialog( - onReject = { - gitManager.credentialsDenied() - }, - onAccept = { user, password -> - gitManager.httpCredentialsAccepted(user, password) - } - ) - } - - CredentialsRequested.SshCredentialsRequested -> { - SshPasswordDialog( - onReject = { - gitManager.credentialsDenied() - }, - onAccept = { password -> - gitManager.sshCredentialsAccepted(password) - } - ) - } - - is CredentialsRequested.GpgCredentialsRequested -> { - GpgPasswordDialog( - gpgCredentialsRequested = credentialsStateValue, - onReject = { - gitManager.credentialsDenied() - }, - onAccept = { password -> - gitManager.gpgCredentialsAccepted(password) - } - ) - } - - is CredentialsAccepted, CredentialsState.None, CredentialsState.CredentialsDenied -> { /* Nothing to do */ - } - } -} diff --git a/src/main/kotlin/com/jetpackduba/gitnuro/ui/RepositoryOpen.kt b/src/main/kotlin/com/jetpackduba/gitnuro/ui/RepositoryOpen.kt index 28b0bb0..ff1243b 100644 --- a/src/main/kotlin/com/jetpackduba/gitnuro/ui/RepositoryOpen.kt +++ b/src/main/kotlin/com/jetpackduba/gitnuro/ui/RepositoryOpen.kt @@ -29,7 +29,7 @@ import com.jetpackduba.gitnuro.ui.diff.Diff import com.jetpackduba.gitnuro.ui.log.Log import com.jetpackduba.gitnuro.updates.Update import com.jetpackduba.gitnuro.viewmodels.BlameState -import com.jetpackduba.gitnuro.viewmodels.TabViewModel +import com.jetpackduba.gitnuro.viewmodels.RepositoryOpenViewModel import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import org.eclipse.jgit.lib.RepositoryState @@ -37,16 +37,16 @@ import org.eclipse.jgit.revwalk.RevCommit @Composable fun RepositoryOpenPage( - tabViewModel: TabViewModel, + repositoryOpenViewModel: RepositoryOpenViewModel, onShowSettingsDialog: () -> Unit, onShowCloneDialog: () -> Unit, ) { - val repositoryState by tabViewModel.repositoryState.collectAsState() - val diffSelected by tabViewModel.diffSelected.collectAsState() - val selectedItem by tabViewModel.selectedItem.collectAsState() - val blameState by tabViewModel.blameState.collectAsState() - val showHistory by tabViewModel.showHistory.collectAsState() - val showAuthorInfo by tabViewModel.showAuthorInfo.collectAsState() + val repositoryState by repositoryOpenViewModel.repositoryState.collectAsState() + val diffSelected by repositoryOpenViewModel.diffSelected.collectAsState() + val selectedItem by repositoryOpenViewModel.selectedItem.collectAsState() + val blameState by repositoryOpenViewModel.blameState.collectAsState() + val showHistory by repositoryOpenViewModel.showHistory.collectAsState() + val showAuthorInfo by repositoryOpenViewModel.showAuthorInfo.collectAsState() var showNewBranchDialog by remember { mutableStateOf(false) } var showStashWithMessageDialog by remember { mutableStateOf(false) } @@ -59,7 +59,7 @@ fun RepositoryOpenPage( showNewBranchDialog = false }, onAccept = { branchName -> - tabViewModel.createBranch(branchName) + repositoryOpenViewModel.createBranch(branchName) showNewBranchDialog = false } ) @@ -69,17 +69,17 @@ fun RepositoryOpenPage( showStashWithMessageDialog = false }, onAccept = { stashMessage -> - tabViewModel.stashWithMessage(stashMessage) + repositoryOpenViewModel.stashWithMessage(stashMessage) showStashWithMessageDialog = false } ) } else if (showAuthorInfo) { - val authorViewModel = tabViewModel.authorViewModel + val authorViewModel = repositoryOpenViewModel.authorViewModel if (authorViewModel != null) { AuthorDialog( authorViewModel = authorViewModel, onClose = { - tabViewModel.closeAuthorInfoDialog() + repositoryOpenViewModel.closeAuthorInfoDialog() } ) } @@ -89,16 +89,16 @@ fun RepositoryOpenPage( onAction = { showQuickActionsDialog = false when (it) { - QuickActionType.OPEN_DIR_IN_FILE_MANAGER -> tabViewModel.openFolderInFileExplorer() + QuickActionType.OPEN_DIR_IN_FILE_MANAGER -> repositoryOpenViewModel.openFolderInFileExplorer() QuickActionType.CLONE -> onShowCloneDialog() - QuickActionType.REFRESH -> tabViewModel.refreshAll() + QuickActionType.REFRESH -> repositoryOpenViewModel.refreshAll() QuickActionType.SIGN_OFF -> showSignOffDialog = true } }, ) } else if (showSignOffDialog) { SignOffDialog( - viewModel = tabViewModel.tabViewModelsProvider.signOffDialogViewModel, + viewModel = repositoryOpenViewModel.tabViewModelsProvider.signOffDialogViewModel, onClose = { showSignOffDialog = false }, ) } @@ -113,11 +113,11 @@ fun RepositoryOpenPage( println("Key event $it") when { it.matchesBinding(KeybindingOption.PULL) -> { - tabViewModel.pull(PullType.DEFAULT) + repositoryOpenViewModel.pull(PullType.DEFAULT) true } it.matchesBinding(KeybindingOption.PUSH) -> { - tabViewModel.push() + repositoryOpenViewModel.push() true } it.matchesBinding(KeybindingOption.BRANCH_CREATE) -> { @@ -129,11 +129,15 @@ fun RepositoryOpenPage( } } it.matchesBinding(KeybindingOption.STASH) -> { - tabViewModel.stash() + repositoryOpenViewModel.stash() true } it.matchesBinding(KeybindingOption.STASH_POP) -> { - tabViewModel.popStash() + repositoryOpenViewModel.popStash() + true + } + it.matchesBinding(KeybindingOption.EXIT) -> { + repositoryOpenViewModel.closeLastView() true } else -> false @@ -148,7 +152,7 @@ fun RepositoryOpenPage( .focusable() .onKeyEvent { keyEvent -> if (keyEvent.matchesBinding(KeybindingOption.REFRESH)) { - tabViewModel.refreshAll() + repositoryOpenViewModel.refreshAll() true } else { false @@ -157,7 +161,7 @@ fun RepositoryOpenPage( ) { Column(modifier = Modifier.weight(1f)) { Menu( - menuViewModel = tabViewModel.tabViewModelsProvider.menuViewModel, + menuViewModel = repositoryOpenViewModel.tabViewModelsProvider.menuViewModel, modifier = Modifier .padding( vertical = 4.dp @@ -165,12 +169,12 @@ fun RepositoryOpenPage( .fillMaxWidth(), onCreateBranch = { showNewBranchDialog = true }, onStashWithMessage = { showStashWithMessageDialog = true }, - onOpenAnotherRepository = { tabViewModel.openAnotherRepository(it) }, + onOpenAnotherRepository = { repositoryOpenViewModel.openAnotherRepository(it) }, onOpenAnotherRepositoryFromPicker = { - val repoToOpen = tabViewModel.openDirectoryPicker() + val repoToOpen = repositoryOpenViewModel.openDirectoryPicker() if (repoToOpen != null) { - tabViewModel.openAnotherRepository(repoToOpen) + repositoryOpenViewModel.openAnotherRepository(repoToOpen) } }, onQuickActions = { showQuickActionsDialog = true }, @@ -178,7 +182,7 @@ fun RepositoryOpenPage( ) RepoContent( - tabViewModel = tabViewModel, + repositoryOpenViewModel = repositoryOpenViewModel, diffSelected = diffSelected, selectedItem = selectedItem, repositoryState = repositoryState, @@ -197,14 +201,14 @@ fun RepositoryOpenPage( ) - val userInfo by tabViewModel.authorInfoSimple.collectAsState() - val newUpdate = tabViewModel.update.collectAsState().value + val userInfo by repositoryOpenViewModel.authorInfoSimple.collectAsState() + val newUpdate = repositoryOpenViewModel.update.collectAsState().value BottomInfoBar( userInfo, newUpdate, - onOpenUrlInBrowser = { tabViewModel.openUrlInBrowser(it) }, - onShowAuthorInfoDialog = { tabViewModel.showAuthorInfoDialog() }, + onOpenUrlInBrowser = { repositoryOpenViewModel.openUrlInBrowser(it) }, + onShowAuthorInfoDialog = { repositoryOpenViewModel.showAuthorInfoDialog() }, ) } } @@ -258,7 +262,7 @@ private fun BottomInfoBar( @Composable fun RepoContent( - tabViewModel: TabViewModel, + repositoryOpenViewModel: RepositoryOpenViewModel, diffSelected: DiffType?, selectedItem: SelectedItem, repositoryState: RepositoryState, @@ -266,19 +270,19 @@ fun RepoContent( showHistory: Boolean, ) { if (showHistory) { - val historyViewModel = tabViewModel.historyViewModel + val historyViewModel = repositoryOpenViewModel.historyViewModel if (historyViewModel != null) { FileHistory( historyViewModel = historyViewModel, onClose = { - tabViewModel.closeHistory() + repositoryOpenViewModel.closeHistory() } ) } } else { MainContentView( - tabViewModel, + repositoryOpenViewModel, diffSelected, selectedItem, repositoryState, @@ -289,25 +293,25 @@ fun RepoContent( @Composable fun MainContentView( - tabViewModel: TabViewModel, + repositoryOpenViewModel: RepositoryOpenViewModel, diffSelected: DiffType?, selectedItem: SelectedItem, repositoryState: RepositoryState, blameState: BlameState, ) { - val rebaseInteractiveState by tabViewModel.rebaseInteractiveState.collectAsState() + val rebaseInteractiveState by repositoryOpenViewModel.rebaseInteractiveState.collectAsState() val density = LocalDensity.current.density val scope = rememberCoroutineScope() // We create 2 mutableStates here because using directly the flow makes compose lose some drag events for some reason - var firstWidth by remember(tabViewModel) { mutableStateOf(tabViewModel.firstPaneWidth.value) } - var thirdWidth by remember(tabViewModel) { mutableStateOf(tabViewModel.thirdPaneWidth.value) } + var firstWidth by remember(repositoryOpenViewModel) { mutableStateOf(repositoryOpenViewModel.firstPaneWidth.value) } + var thirdWidth by remember(repositoryOpenViewModel) { mutableStateOf(repositoryOpenViewModel.thirdPaneWidth.value) } LaunchedEffect(Unit) { // Update the pane widths if they have been changed in a different tab - tabViewModel.onPanelsWidthPersisted.collectLatest { - firstWidth = tabViewModel.firstPaneWidth.value - thirdWidth = tabViewModel.thirdPaneWidth.value + repositoryOpenViewModel.onPanelsWidthPersisted.collectLatest { + firstWidth = repositoryOpenViewModel.firstPaneWidth.value + thirdWidth = repositoryOpenViewModel.thirdPaneWidth.value } } @@ -317,9 +321,9 @@ fun MainContentView( thirdWidth = thirdWidth, first = { SidePanel( - tabViewModel.tabViewModelsProvider.sidePanelViewModel, - changeDefaultUpstreamBranchViewModel = { tabViewModel.tabViewModelsProvider.changeDefaultUpstreamBranchViewModel }, - submoduleDialogViewModel = { tabViewModel.tabViewModelsProvider.submoduleDialogViewModel }, + repositoryOpenViewModel.tabViewModelsProvider.sidePanelViewModel, + changeDefaultUpstreamBranchViewModel = { repositoryOpenViewModel.tabViewModelsProvider.changeDefaultUpstreamBranchViewModel }, + submoduleDialogViewModel = { repositoryOpenViewModel.tabViewModelsProvider.submoduleDialogViewModel }, ) }, second = { @@ -328,13 +332,13 @@ fun MainContentView( .fillMaxSize() ) { if (rebaseInteractiveState == RebaseInteractiveState.AwaitingInteraction && diffSelected == null) { - RebaseInteractive(tabViewModel.tabViewModelsProvider.rebaseInteractiveViewModel) + RebaseInteractive(repositoryOpenViewModel.tabViewModelsProvider.rebaseInteractiveViewModel) } else if (blameState is BlameState.Loaded && !blameState.isMinimized) { Blame( filePath = blameState.filePath, blameResult = blameState.blameResult, - onClose = { tabViewModel.resetBlameState() }, - onSelectCommit = { tabViewModel.selectCommit(it) } + onClose = { repositoryOpenViewModel.resetBlameState() }, + onSelectCommit = { repositoryOpenViewModel.selectCommit(it) } ) } else { Column { @@ -342,21 +346,21 @@ fun MainContentView( when (diffSelected) { null -> { Log( - logViewModel = tabViewModel.tabViewModelsProvider.logViewModel, + logViewModel = repositoryOpenViewModel.tabViewModelsProvider.logViewModel, selectedItem = selectedItem, repositoryState = repositoryState, - changeDefaultUpstreamBranchViewModel = { tabViewModel.tabViewModelsProvider.changeDefaultUpstreamBranchViewModel }, + changeDefaultUpstreamBranchViewModel = { repositoryOpenViewModel.tabViewModelsProvider.changeDefaultUpstreamBranchViewModel }, ) } else -> { - val diffViewModel = tabViewModel.diffViewModel + val diffViewModel = repositoryOpenViewModel.diffViewModel if (diffViewModel != null) { Diff( diffViewModel = diffViewModel, onCloseDiffView = { - tabViewModel.newDiffSelected = null + repositoryOpenViewModel.newDiffSelected = null } ) } @@ -367,8 +371,8 @@ fun MainContentView( if (blameState is BlameState.Loaded) { // BlameState.isMinimized is true here MinimizedBlame( filePath = blameState.filePath, - onExpand = { tabViewModel.expandBlame() }, - onClose = { tabViewModel.resetBlameState() } + onExpand = { repositoryOpenViewModel.expandBlame() }, + onClose = { repositoryOpenViewModel.resetBlameState() } ) } } @@ -383,13 +387,13 @@ fun MainContentView( when (selectedItem) { SelectedItem.UncommittedChanges -> { UncommittedChanges( - statusViewModel = tabViewModel.tabViewModelsProvider.statusViewModel, + statusViewModel = repositoryOpenViewModel.tabViewModelsProvider.statusViewModel, selectedEntryType = diffSelected, repositoryState = repositoryState, onStagedDiffEntrySelected = { diffEntry -> - tabViewModel.minimizeBlame() + repositoryOpenViewModel.minimizeBlame() - tabViewModel.newDiffSelected = if (diffEntry != null) { + repositoryOpenViewModel.newDiffSelected = if (diffEntry != null) { if (repositoryState == RepositoryState.SAFE) DiffType.SafeStagedDiff(diffEntry) else @@ -399,29 +403,29 @@ fun MainContentView( } }, onUnstagedDiffEntrySelected = { diffEntry -> - tabViewModel.minimizeBlame() + repositoryOpenViewModel.minimizeBlame() if (repositoryState == RepositoryState.SAFE) - tabViewModel.newDiffSelected = DiffType.SafeUnstagedDiff(diffEntry) + repositoryOpenViewModel.newDiffSelected = DiffType.SafeUnstagedDiff(diffEntry) else - tabViewModel.newDiffSelected = DiffType.UnsafeUnstagedDiff(diffEntry) + repositoryOpenViewModel.newDiffSelected = DiffType.UnsafeUnstagedDiff(diffEntry) }, - onBlameFile = { tabViewModel.blameFile(it) }, - onHistoryFile = { tabViewModel.fileHistory(it) } + onBlameFile = { repositoryOpenViewModel.blameFile(it) }, + onHistoryFile = { repositoryOpenViewModel.fileHistory(it) } ) } is SelectedItem.CommitBasedItem -> { CommitChanges( - commitChangesViewModel = tabViewModel.tabViewModelsProvider.commitChangesViewModel, + commitChangesViewModel = repositoryOpenViewModel.tabViewModelsProvider.commitChangesViewModel, selectedItem = selectedItem, diffSelected = diffSelected, onDiffSelected = { diffEntry -> - tabViewModel.minimizeBlame() - tabViewModel.newDiffSelected = DiffType.CommitDiff(diffEntry) + repositoryOpenViewModel.minimizeBlame() + repositoryOpenViewModel.newDiffSelected = DiffType.CommitDiff(diffEntry) }, - onBlame = { tabViewModel.blameFile(it) }, - onHistory = { tabViewModel.fileHistory(it) }, + onBlame = { repositoryOpenViewModel.blameFile(it) }, + onHistory = { repositoryOpenViewModel.fileHistory(it) }, ) } @@ -431,19 +435,19 @@ fun MainContentView( }, onFirstSizeDragStarted = { currentWidth -> firstWidth = currentWidth - tabViewModel.setFirstPaneWidth(currentWidth) + repositoryOpenViewModel.setFirstPaneWidth(currentWidth) }, onFirstSizeChange = { val newWidth = firstWidth + it / density if (newWidth > 150 && rebaseInteractiveState !is RebaseInteractiveState.AwaitingInteraction) { firstWidth = newWidth - tabViewModel.setFirstPaneWidth(newWidth) + repositoryOpenViewModel.setFirstPaneWidth(newWidth) } }, onFirstSizeDragStopped = { scope.launch { - tabViewModel.persistFirstPaneWidth() + repositoryOpenViewModel.persistFirstPaneWidth() } }, onThirdSizeChange = { @@ -451,16 +455,16 @@ fun MainContentView( if (newWidth > 150) { thirdWidth = newWidth - tabViewModel.setThirdPaneWidth(newWidth) + repositoryOpenViewModel.setThirdPaneWidth(newWidth) } }, onThirdSizeDragStarted = { currentWidth -> thirdWidth = currentWidth - tabViewModel.setThirdPaneWidth(currentWidth) + repositoryOpenViewModel.setThirdPaneWidth(currentWidth) }, onThirdSizeDragStopped = { scope.launch { - tabViewModel.persistThirdPaneWidth() + repositoryOpenViewModel.persistThirdPaneWidth() } }, ) diff --git a/src/main/kotlin/com/jetpackduba/gitnuro/ui/components/Notification.kt b/src/main/kotlin/com/jetpackduba/gitnuro/ui/components/Notification.kt new file mode 100644 index 0000000..a5dbba0 --- /dev/null +++ b/src/main/kotlin/com/jetpackduba/gitnuro/ui/components/Notification.kt @@ -0,0 +1,93 @@ +package com.jetpackduba.gitnuro.ui.components + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import com.jetpackduba.gitnuro.AppIcons +import com.jetpackduba.gitnuro.models.Notification +import com.jetpackduba.gitnuro.models.NotificationType +import com.jetpackduba.gitnuro.theme.AppTheme + + +@Preview +@Composable +fun NotificationSuccessPreview() { + AppTheme(customTheme = null) { + Notification(NotificationType.Positive, "Hello world!") + } +} + +@Composable +fun Notification(notification: Notification) { + val notificationShape = RoundedCornerShape(8.dp) + + Row( + modifier = Modifier + .padding(8.dp) + .border(2.dp, MaterialTheme.colors.onBackground.copy(0.2f), notificationShape) + .clip(notificationShape) + .background(MaterialTheme.colors.background) + .height(IntrinsicSize.Max) + .padding(2.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + val backgroundColor = when (notification.type) { + NotificationType.Positive -> MaterialTheme.colors.primary + NotificationType.Warning -> MaterialTheme.colors.secondary + NotificationType.Error -> MaterialTheme.colors.error + } + + val contentColor = when (notification.type) { + NotificationType.Positive -> MaterialTheme.colors.onPrimary + NotificationType.Warning -> MaterialTheme.colors.onSecondary + NotificationType.Error -> MaterialTheme.colors.onError + } + + val icon = when (notification.type) { + NotificationType.Positive -> AppIcons.INFO + NotificationType.Warning -> AppIcons.WARNING + NotificationType.Error -> AppIcons.ERROR + } + + Box( + modifier = Modifier + .clip(RoundedCornerShape(topStart = 6.dp, bottomStart = 6.dp)) + .background(backgroundColor) + .fillMaxHeight() + ) { + Icon( + painterResource(icon), + contentDescription = null, + tint = contentColor, + modifier = Modifier + .padding(4.dp) + ) + } + + Box( + modifier = Modifier + .padding(end = 8.dp) + .fillMaxHeight(), + contentAlignment = Alignment.CenterStart, + ) { + Text( + text = notification.text, + modifier = Modifier, + color = MaterialTheme.colors.onBackground, + style = MaterialTheme.typography.body1, + ) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/jetpackduba/gitnuro/ui/dialogs/CredentialsDialog.kt b/src/main/kotlin/com/jetpackduba/gitnuro/ui/dialogs/CredentialsDialog.kt new file mode 100644 index 0000000..2661a50 --- /dev/null +++ b/src/main/kotlin/com/jetpackduba/gitnuro/ui/dialogs/CredentialsDialog.kt @@ -0,0 +1,52 @@ +package com.jetpackduba.gitnuro.ui.dialogs + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import com.jetpackduba.gitnuro.credentials.CredentialsAccepted +import com.jetpackduba.gitnuro.credentials.CredentialsRequested +import com.jetpackduba.gitnuro.credentials.CredentialsState +import com.jetpackduba.gitnuro.viewmodels.TabViewModel + +@Composable +fun CredentialsDialog(gitManager: TabViewModel) { + val credentialsState = gitManager.credentialsState.collectAsState() + + when (val credentialsStateValue = credentialsState.value) { + CredentialsRequested.HttpCredentialsRequested -> { + UserPasswordDialog( + onReject = { + gitManager.credentialsDenied() + }, + onAccept = { user, password -> + gitManager.httpCredentialsAccepted(user, password) + } + ) + } + + CredentialsRequested.SshCredentialsRequested -> { + SshPasswordDialog( + onReject = { + gitManager.credentialsDenied() + }, + onAccept = { password -> + gitManager.sshCredentialsAccepted(password) + } + ) + } + + is CredentialsRequested.GpgCredentialsRequested -> { + GpgPasswordDialog( + gpgCredentialsRequested = credentialsStateValue, + onReject = { + gitManager.credentialsDenied() + }, + onAccept = { password -> + gitManager.gpgCredentialsAccepted(password) + } + ) + } + + is CredentialsAccepted, CredentialsState.None, CredentialsState.CredentialsDenied -> { /* Nothing to do */ + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/jetpackduba/gitnuro/updates/UpdatesRepository.kt b/src/main/kotlin/com/jetpackduba/gitnuro/updates/UpdatesRepository.kt index be29e95..013662c 100644 --- a/src/main/kotlin/com/jetpackduba/gitnuro/updates/UpdatesRepository.kt +++ b/src/main/kotlin/com/jetpackduba/gitnuro/updates/UpdatesRepository.kt @@ -19,7 +19,7 @@ private val updateJson = Json { class UpdatesRepository @Inject constructor( private val updatesWebService: UpdatesService, ) { - fun hasUpdatesFlow() = flow { + val hasUpdatesFlow = flow { val latestReleaseJson = updatesWebService.release(AppConstants.VERSION_CHECK_URL) while (coroutineContext.isActive) { diff --git a/src/main/kotlin/com/jetpackduba/gitnuro/viewmodels/RepositoryOpenViewModel.kt b/src/main/kotlin/com/jetpackduba/gitnuro/viewmodels/RepositoryOpenViewModel.kt new file mode 100644 index 0000000..f26e576 --- /dev/null +++ b/src/main/kotlin/com/jetpackduba/gitnuro/viewmodels/RepositoryOpenViewModel.kt @@ -0,0 +1,380 @@ +package com.jetpackduba.gitnuro.viewmodels + +import com.jetpackduba.gitnuro.SharedRepositoryStateManager +import com.jetpackduba.gitnuro.TaskType +import com.jetpackduba.gitnuro.credentials.CredentialsAccepted +import com.jetpackduba.gitnuro.credentials.CredentialsState +import com.jetpackduba.gitnuro.credentials.CredentialsStateManager +import com.jetpackduba.gitnuro.exceptions.WatcherInitException +import com.jetpackduba.gitnuro.git.* +import com.jetpackduba.gitnuro.git.branches.CreateBranchUseCase +import com.jetpackduba.gitnuro.git.rebase.RebaseInteractiveState +import com.jetpackduba.gitnuro.git.repository.InitLocalRepositoryUseCase +import com.jetpackduba.gitnuro.git.repository.OpenRepositoryUseCase +import com.jetpackduba.gitnuro.git.repository.OpenSubmoduleRepositoryUseCase +import com.jetpackduba.gitnuro.git.stash.StashChangesUseCase +import com.jetpackduba.gitnuro.git.workspace.StageUntrackedFileUseCase +import com.jetpackduba.gitnuro.logging.printDebug +import com.jetpackduba.gitnuro.logging.printLog +import com.jetpackduba.gitnuro.managers.AppStateManager +import com.jetpackduba.gitnuro.managers.ErrorsManager +import com.jetpackduba.gitnuro.managers.newErrorNow +import com.jetpackduba.gitnuro.models.AuthorInfoSimple +import com.jetpackduba.gitnuro.models.errorNotification +import com.jetpackduba.gitnuro.models.positiveNotification +import com.jetpackduba.gitnuro.system.OpenFilePickerUseCase +import com.jetpackduba.gitnuro.system.OpenUrlInBrowserUseCase +import com.jetpackduba.gitnuro.system.PickerType +import com.jetpackduba.gitnuro.ui.IVerticalSplitPaneConfig +import com.jetpackduba.gitnuro.ui.SelectedItem +import com.jetpackduba.gitnuro.ui.TabsManager +import com.jetpackduba.gitnuro.ui.VerticalSplitPaneConfig +import com.jetpackduba.gitnuro.updates.Update +import com.jetpackduba.gitnuro.updates.UpdatesRepository +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* +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_AFTER_GIT_OPERATION = 2000L + +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 RepositoryOpenViewModel @Inject constructor( + private val getWorkspacePathUseCase: GetWorkspacePathUseCase, + private val diffViewModelProvider: Provider, + private val historyViewModelProvider: Provider, + private val authorViewModelProvider: Provider, + private val tabState: TabState, + val appStateManager: AppStateManager, + private val fileChangesWatcher: FileChangesWatcher, + private val createBranchUseCase: CreateBranchUseCase, + private val stashChangesUseCase: StashChangesUseCase, + private val stageUntrackedFileUseCase: StageUntrackedFileUseCase, + private val openFilePickerUseCase: OpenFilePickerUseCase, + private val openUrlInBrowserUseCase: OpenUrlInBrowserUseCase, + private val tabsManager: TabsManager, + private val tabScope: CoroutineScope, + private val verticalSplitPaneConfig: VerticalSplitPaneConfig, + val tabViewModelsProvider: TabViewModelsProvider, + private val globalMenuActionsViewModel: GlobalMenuActionsViewModel, + sharedRepositoryStateManager: SharedRepositoryStateManager, + updatesRepository: UpdatesRepository, +) : IVerticalSplitPaneConfig by verticalSplitPaneConfig, + IGlobalMenuActionsViewModel by globalMenuActionsViewModel { + private val errorsManager: ErrorsManager = tabState.errorsManager + + val selectedItem: StateFlow = tabState.selectedItem + var diffViewModel: DiffViewModel? = null + + val repositoryState: StateFlow = sharedRepositoryStateManager.repositoryState + val rebaseInteractiveState: StateFlow = sharedRepositoryStateManager.rebaseInteractiveState + + private val _diffSelected = MutableStateFlow(null) + val diffSelected: StateFlow = _diffSelected + + var newDiffSelected: DiffType? + get() = diffSelected.value + set(value) { + _diffSelected.value = value + updateDiffEntry() + } + + private val _blameState = MutableStateFlow(BlameState.None) + val blameState: StateFlow = _blameState + + private val _showHistory = MutableStateFlow(false) + val showHistory: StateFlow = _showHistory + + private val _showAuthorInfo = MutableStateFlow(false) + val showAuthorInfo: StateFlow = _showAuthorInfo + + private val _authorInfoSimple = MutableStateFlow(AuthorInfoSimple(null, null)) + val authorInfoSimple: StateFlow = _authorInfoSimple + + var historyViewModel: HistoryViewModel? = null + private set + + var authorViewModel: AuthorViewModel? = null + private set + + init { + tabScope.run { + launch { + tabState.refreshFlowFiltered(RefreshType.ALL_DATA, RefreshType.REPO_STATE) { + loadAuthorInfo(tabState.git) + } + } + + launch { + watchRepositoryChanges(tabState.git) + } + + launch { tabState.refreshData(RefreshType.ALL_DATA) } + } + } + + + /** + * To make sure the tab opens the new repository with a clean state, + * instead of opening the repo in the same ViewModel we simply create a new tab with a new TabViewModel + * replacing the current tab + */ + fun openAnotherRepository(directory: String) = tabState.runOperation( + showError = true, + refreshType = RefreshType.NONE, + ) { git -> + tabsManager.addNewTabFromPath(directory, true, getWorkspacePathUseCase(git)) + } + + 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 + } + + /** + * 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" or even full refreshes. + * + */ + private suspend fun watchRepositoryChanges(git: Git) = tabScope.launch(Dispatchers.IO) { + var hasGitDirChanged = false + + launch { + fileChangesWatcher.changesNotifier.collect { latestUpdateChangedGitDir -> + val isOperationRunning = tabState.operationRunning + + if (!isOperationRunning) { // Only update if there isn't any process running + printDebug(TAG, "Detected changes in the repository's directory") + + val currentTimeMillis = System.currentTimeMillis() + + if ( + latestUpdateChangedGitDir && + currentTimeMillis - tabState.lastOperation < MIN_TIME_AFTER_GIT_OPERATION + ) { + printDebug(TAG, "Git operation was executed recently, ignoring file system change") + return@collect + } + + if (latestUpdateChangedGitDir) { + hasGitDirChanged = true + } + + if (isActive) { + updateApp(hasGitDirChanged) + } + + hasGitDirChanged = false + } else { + printDebug(TAG, "Ignored file events during operation") + } + } + } + + try { + fileChangesWatcher.watchDirectoryPath( + repository = git.repository, + ) + } catch (ex: WatcherInitException) { + val message = ex.message + if (message != null) { + errorsManager.addError( + newErrorNow( + exception = ex, + taskType = TaskType.CHANGES_DETECTION, + ), + ) + } + } + } + + private suspend fun updateApp(hasGitDirChanged: Boolean) { + if (hasGitDirChanged) { + printLog(TAG, "Changes detected in git directory, full refresh") + + refreshRepositoryInfo() + } else { + printLog(TAG, "Changes detected, partial refresh") + + checkUncommittedChanges() + } + } + + private suspend fun checkUncommittedChanges() = tabState.runOperation( + refreshType = RefreshType.NONE, + ) { + updateDiffEntry() + tabState.refreshData(RefreshType.UNCOMMITTED_CHANGES_AND_LOG) + } + + private suspend fun refreshRepositoryInfo() { + tabState.refreshData(RefreshType.ALL_DATA) + } + + 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 openDirectoryPicker(): String? { + val latestDirectoryOpened = appStateManager.latestOpenedRepositoryPath + + return openFilePickerUseCase(PickerType.DIRECTORIES, latestDirectoryOpened) + } + + val update: StateFlow = updatesRepository.hasUpdatesFlow + .stateIn(tabScope, started = SharingStarted.Eagerly, null) + + fun blameFile(filePath: String) = tabState.safeProcessing( + refreshType = RefreshType.NONE, + taskType = TaskType.BLAME_FILE, + ) { 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 + } + + null + } + + 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.newSelectedCommit(commit) + } + + 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 }, + taskType = TaskType.CREATE_BRANCH, + ) { git -> + createBranchUseCase(git, branchName) + + positiveNotification("Branch \"${branchName}\" created") + } + + fun stashWithMessage(message: String) = tabState.safeProcessing( + refreshType = RefreshType.UNCOMMITTED_CHANGES_AND_LOG, + taskType = TaskType.STASH, + ) { git -> + stageUntrackedFileUseCase(git) + + if (stashChangesUseCase(git, message)) { + positiveNotification("Changes stashed") + } else { + errorNotification("There are no changes to stash") + } + } + + fun openFolderInFileExplorer() = tabState.runOperation( + showError = true, + refreshType = RefreshType.NONE, + ) { git -> + Desktop.getDesktop().open(git.repository.workTree) + } + + fun openUrlInBrowser(url: String) { + openUrlInBrowserUseCase(url) + } + + fun closeLastView() = tabScope.launch { + tabState.closeLastView() + } +} + + +sealed interface BlameState { + data class Loading(val filePath: String) : BlameState + + data class Loaded(val filePath: String, val blameResult: BlameResult, val isMinimized: Boolean = false) : BlameState + + data object None : BlameState +} \ No newline at end of file diff --git a/src/main/kotlin/com/jetpackduba/gitnuro/viewmodels/TabViewModel.kt b/src/main/kotlin/com/jetpackduba/gitnuro/viewmodels/TabViewModel.kt index 673f3a4..7ef9834 100644 --- a/src/main/kotlin/com/jetpackduba/gitnuro/viewmodels/TabViewModel.kt +++ b/src/main/kotlin/com/jetpackduba/gitnuro/viewmodels/TabViewModel.kt @@ -57,81 +57,39 @@ class TabViewModel @Inject constructor( private val initLocalRepositoryUseCase: InitLocalRepositoryUseCase, private val openRepositoryUseCase: OpenRepositoryUseCase, private val openSubmoduleRepositoryUseCase: OpenSubmoduleRepositoryUseCase, - private val getWorkspacePathUseCase: GetWorkspacePathUseCase, - private val diffViewModelProvider: Provider, - private val historyViewModelProvider: Provider, - private val authorViewModelProvider: Provider, 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 openFilePickerUseCase: OpenFilePickerUseCase, private val openUrlInBrowserUseCase: OpenUrlInBrowserUseCase, - private val sharedRepositoryStateManager: SharedRepositoryStateManager, - private val tabsManager: TabsManager, private val tabScope: CoroutineScope, private val verticalSplitPaneConfig: VerticalSplitPaneConfig, val tabViewModelsProvider: TabViewModelsProvider, private val globalMenuActionsViewModel: GlobalMenuActionsViewModel, + private val repositoryOpenViewModelProvider: Provider, + updatesRepository: UpdatesRepository, ) : IVerticalSplitPaneConfig by verticalSplitPaneConfig, IGlobalMenuActionsViewModel by globalMenuActionsViewModel { var initialPath: String? = null // Stores the path that should be opened when the tab is selected val errorsManager: ErrorsManager = tabState.errorsManager val selectedItem: StateFlow = tabState.selectedItem - var diffViewModel: DiffViewModel? = null + val repositoryOpenViewModel: RepositoryOpenViewModel by lazy { + repositoryOpenViewModelProvider.get() + } private val _repositorySelectionStatus = MutableStateFlow(RepositorySelectionStatus.None) val repositorySelectionStatus: StateFlow get() = _repositorySelectionStatus - val repositoryState: StateFlow = sharedRepositoryStateManager.repositoryState - val rebaseInteractiveState: StateFlow = sharedRepositoryStateManager.rebaseInteractiveState - val processing: StateFlow = tabState.processing val credentialsState: StateFlow = credentialsStateManager.credentialsState - private val _diffSelected = MutableStateFlow(null) - val diffSelected: StateFlow = _diffSelected - - var newDiffSelected: DiffType? - get() = diffSelected.value - set(value) { - _diffSelected.value = value - updateDiffEntry() - } - - private val _blameState = MutableStateFlow(BlameState.None) - val blameState: StateFlow = _blameState - - private val _showHistory = MutableStateFlow(false) - val showHistory: StateFlow = _showHistory - - private val _showAuthorInfo = MutableStateFlow(false) - val showAuthorInfo: StateFlow = _showAuthorInfo - - private val _authorInfoSimple = MutableStateFlow(AuthorInfoSimple(null, null)) - val authorInfoSimple: StateFlow = _authorInfoSimple - - var historyViewModel: HistoryViewModel? = null - private set - - var authorViewModel: AuthorViewModel? = null - private set - val showError = MutableStateFlow(false) init { tabScope.run { - launch { - tabState.refreshFlowFiltered(RefreshType.ALL_DATA, RefreshType.REPO_STATE) { - loadAuthorInfo(tabState.git) - } - } launch { errorsManager.error.collect { @@ -141,19 +99,6 @@ class TabViewModel @Inject constructor( } } - - /** - * To make sure the tab opens the new repository with a clean state, - * instead of opening the repo in the same ViewModel we simply create a new tab with a new TabViewModel - * replacing the current tab - */ - fun openAnotherRepository(directory: String) = tabState.runOperation( - showError = true, - refreshType = RefreshType.NONE, - ) { git -> - tabsManager.addNewTabFromPath(directory, true, getWorkspacePathUseCase(git)) - } - fun openRepository(directory: String) { openRepository(File(directory)) } @@ -182,10 +127,6 @@ class TabViewModel @Inject constructor( onRepositoryChanged(path) tabState.newSelectedItem(selectedItem = SelectedItem.UncommittedChanges) - newDiffSelected = null - refreshRepositoryInfo() - - watchRepositoryChanges(git) } catch (ex: Exception) { onRepositoryChanged(null) ex.printStackTrace() @@ -200,109 +141,6 @@ class TabViewModel @Inject constructor( } } - 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 - } - - /** - * 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" or even full refreshes. - * - */ - - private suspend fun watchRepositoryChanges(git: Git) = tabScope.launch(Dispatchers.IO) { - var hasGitDirChanged = false - - launch { - fileChangesWatcher.changesNotifier.collect { latestUpdateChangedGitDir -> - val isOperationRunning = tabState.operationRunning - - if (!isOperationRunning) { // Only update if there isn't any process running - printDebug(TAG, "Detected changes in the repository's directory") - - val currentTimeMillis = System.currentTimeMillis() - - if ( - latestUpdateChangedGitDir && - currentTimeMillis - tabState.lastOperation < MIN_TIME_AFTER_GIT_OPERATION - ) { - printDebug(TAG, "Git operation was executed recently, ignoring file system change") - return@collect - } - - if (latestUpdateChangedGitDir) { - hasGitDirChanged = true - } - - if (isActive) { - updateApp(hasGitDirChanged) - } - - hasGitDirChanged = false - } else { - printDebug(TAG, "Ignored file events during operation") - } - } - } - - try { - fileChangesWatcher.watchDirectoryPath( - repository = git.repository, - ) - } catch (ex: WatcherInitException) { - val message = ex.message - if (message != null) { - errorsManager.addError( - newErrorNow( - exception = ex, - taskType = TaskType.CHANGES_DETECTION, -// message = message, - ), - ) - } - } - } - - private suspend fun updateApp(hasGitDirChanged: Boolean) { - if (hasGitDirChanged) { - printLog(TAG, "Changes detected in git directory, full refresh") - - refreshRepositoryInfo() - } else { - printLog(TAG, "Changes detected, partial refresh") - - checkUncommittedChanges() - } - } - - private suspend fun checkUncommittedChanges() = tabState.runOperation( - refreshType = RefreshType.NONE, - ) { - updateDiffEntry() - tabState.refreshData(RefreshType.UNCOMMITTED_CHANGES_AND_LOG) - } - - private suspend fun refreshRepositoryInfo() { - tabState.refreshData(RefreshType.ALL_DATA) - } - fun credentialsDenied() { credentialsStateManager.updateState(CredentialsState.CredentialsDenied) } @@ -322,22 +160,6 @@ class TabViewModel @Inject constructor( 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 openDirectoryPicker(): String? { val latestDirectoryOpened = appStateManager.latestOpenedRepositoryPath @@ -350,105 +172,9 @@ class TabViewModel @Inject constructor( openRepository(repoDir) } - val update: StateFlow = updatesRepository.hasUpdatesFlow() - .flowOn(Dispatchers.IO) + val update: StateFlow = updatesRepository.hasUpdatesFlow .stateIn(tabScope, started = SharingStarted.Eagerly, null) - fun blameFile(filePath: String) = tabState.safeProcessing( - refreshType = RefreshType.NONE, - taskType = TaskType.BLAME_FILE, - ) { 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 - } - - null - } - - 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.newSelectedCommit(commit) - } - - 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 }, - taskType = TaskType.CREATE_BRANCH, - ) { git -> - createBranchUseCase(git, branchName) - - positiveNotification("Branch \"${branchName}\" created") - } - - fun stashWithMessage(message: String) = tabState.safeProcessing( - refreshType = RefreshType.UNCOMMITTED_CHANGES_AND_LOG, - taskType = TaskType.STASH, - ) { git -> - stageUntrackedFileUseCase(git) - - if (stashChangesUseCase(git, message)) { - positiveNotification("Changes stashed") - } else { - errorNotification("There are no changes to stash") - } - } - - fun openFolderInFileExplorer() = tabState.runOperation( - showError = true, - refreshType = RefreshType.NONE, - ) { git -> - Desktop.getDesktop().open(git.repository.workTree) - } - fun gpgCredentialsAccepted(password: String) { credentialsStateManager.updateState(CredentialsAccepted.GpgCredentialsAccepted(password)) } @@ -468,15 +194,7 @@ class TabViewModel @Inject constructor( sealed class RepositorySelectionStatus { - object None : RepositorySelectionStatus() + data 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 - - data object None : BlameState } \ No newline at end of file