Code refactoring

This commit is contained in:
Abdelilah El Aissaoui 2024-09-08 23:47:31 +02:00
parent a9a35b304a
commit 355cbc3f79
No known key found for this signature in database
GPG Key ID: 7587FC860F594869
9 changed files with 636 additions and 509 deletions

View File

@ -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
}

View File

@ -39,6 +39,7 @@ class TabState @Inject constructor(
val selectedItem: StateFlow<SelectedItem> = _selectedItem val selectedItem: StateFlow<SelectedItem> = _selectedItem
private val _taskEvent = MutableSharedFlow<TaskEvent>() private val _taskEvent = MutableSharedFlow<TaskEvent>()
val taskEvent: SharedFlow<TaskEvent> = _taskEvent val taskEvent: SharedFlow<TaskEvent> = _taskEvent
var lastOperation: Long = 0 var lastOperation: Long = 0
private set private set
@ -52,8 +53,11 @@ class TabState @Inject constructor(
return unsafeGit return unsafeGit
} }
private val _refreshData = MutableSharedFlow<RefreshType>() private val refreshData = MutableSharedFlow<RefreshType>()
val refreshData: SharedFlow<RefreshType> = _refreshData private val closeableViews = ArrayDeque<Int>()
private val _closeView = MutableSharedFlow<Int>()
val closeViewFlow = _closeView.asSharedFlow()
/** /**
* Property that indicates if a git operation is running * Property that indicates if a git operation is running
@ -130,7 +134,7 @@ class TabState @Inject constructor(
lastOperation = System.currentTimeMillis() lastOperation = System.currentTimeMillis()
if (refreshType != RefreshType.NONE && (!hasProcessFailed || refreshEvenIfCrashes || refreshEvenIfCrashesInteractiveResult)) { 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) printError(TAG, ex.message.orEmpty(), ex)
} finally { } finally {
if (refreshType != RefreshType.NONE && (!hasProcessFailed || refreshEvenIfCrashes)) if (refreshType != RefreshType.NONE && (!hasProcessFailed || refreshEvenIfCrashes))
_refreshData.emit(refreshType) refreshData.emit(refreshType)
operationRunning = false operationRunning = false
lastOperation = System.currentTimeMillis() lastOperation = System.currentTimeMillis()
@ -236,7 +240,7 @@ class TabState @Inject constructor(
} }
suspend fun refreshData(refreshType: RefreshType) { suspend fun refreshData(refreshType: RefreshType) {
_refreshData.emit(refreshType) refreshData.emit(refreshType)
} }
suspend fun newSelectedStash(stash: RevCommit) { 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() { fun cancelCurrentTask() {
currentJob?.cancel() currentJob?.cancel()
} }

View File

@ -1,34 +1,19 @@
package com.jetpackduba.gitnuro.ui package com.jetpackduba.gitnuro.ui
import androidx.compose.animation.Crossfade import androidx.compose.animation.Crossfade
import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.* 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.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.jetpackduba.gitnuro.AppIcons
import com.jetpackduba.gitnuro.LoadingRepository import com.jetpackduba.gitnuro.LoadingRepository
import com.jetpackduba.gitnuro.ProcessingScreen 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.git.ProcessingState
import com.jetpackduba.gitnuro.models.Notification import com.jetpackduba.gitnuro.ui.components.Notification
import com.jetpackduba.gitnuro.models.NotificationType
import com.jetpackduba.gitnuro.theme.AppTheme
import com.jetpackduba.gitnuro.ui.dialogs.CloneDialog import com.jetpackduba.gitnuro.ui.dialogs.CloneDialog
import com.jetpackduba.gitnuro.ui.dialogs.GpgPasswordDialog import com.jetpackduba.gitnuro.ui.dialogs.CredentialsDialog
import com.jetpackduba.gitnuro.ui.dialogs.SshPasswordDialog
import com.jetpackduba.gitnuro.ui.dialogs.UserPasswordDialog
import com.jetpackduba.gitnuro.ui.dialogs.errors.ErrorDialog import com.jetpackduba.gitnuro.ui.dialogs.errors.ErrorDialog
import com.jetpackduba.gitnuro.ui.dialogs.settings.SettingsDialog import com.jetpackduba.gitnuro.ui.dialogs.settings.SettingsDialog
import com.jetpackduba.gitnuro.viewmodels.RepositorySelectionStatus import com.jetpackduba.gitnuro.viewmodels.RepositorySelectionStatus
@ -109,7 +94,7 @@ fun AppTab(
is RepositorySelectionStatus.Open -> { is RepositorySelectionStatus.Open -> {
RepositoryOpenPage( RepositoryOpenPage(
tabViewModel = tabViewModel, repositoryOpenViewModel = tabViewModel.repositoryOpenViewModel,
onShowSettingsDialog = { showSettingsDialog = true }, onShowSettingsDialog = { showSettingsDialog = true },
onShowCloneDialog = { showCloneDialog = 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 */
}
}
}

View File

@ -29,7 +29,7 @@ import com.jetpackduba.gitnuro.ui.diff.Diff
import com.jetpackduba.gitnuro.ui.log.Log import com.jetpackduba.gitnuro.ui.log.Log
import com.jetpackduba.gitnuro.updates.Update import com.jetpackduba.gitnuro.updates.Update
import com.jetpackduba.gitnuro.viewmodels.BlameState 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.flow.collectLatest
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.eclipse.jgit.lib.RepositoryState import org.eclipse.jgit.lib.RepositoryState
@ -37,16 +37,16 @@ import org.eclipse.jgit.revwalk.RevCommit
@Composable @Composable
fun RepositoryOpenPage( fun RepositoryOpenPage(
tabViewModel: TabViewModel, repositoryOpenViewModel: RepositoryOpenViewModel,
onShowSettingsDialog: () -> Unit, onShowSettingsDialog: () -> Unit,
onShowCloneDialog: () -> Unit, onShowCloneDialog: () -> Unit,
) { ) {
val repositoryState by tabViewModel.repositoryState.collectAsState() val repositoryState by repositoryOpenViewModel.repositoryState.collectAsState()
val diffSelected by tabViewModel.diffSelected.collectAsState() val diffSelected by repositoryOpenViewModel.diffSelected.collectAsState()
val selectedItem by tabViewModel.selectedItem.collectAsState() val selectedItem by repositoryOpenViewModel.selectedItem.collectAsState()
val blameState by tabViewModel.blameState.collectAsState() val blameState by repositoryOpenViewModel.blameState.collectAsState()
val showHistory by tabViewModel.showHistory.collectAsState() val showHistory by repositoryOpenViewModel.showHistory.collectAsState()
val showAuthorInfo by tabViewModel.showAuthorInfo.collectAsState() val showAuthorInfo by repositoryOpenViewModel.showAuthorInfo.collectAsState()
var showNewBranchDialog by remember { mutableStateOf(false) } var showNewBranchDialog by remember { mutableStateOf(false) }
var showStashWithMessageDialog by remember { mutableStateOf(false) } var showStashWithMessageDialog by remember { mutableStateOf(false) }
@ -59,7 +59,7 @@ fun RepositoryOpenPage(
showNewBranchDialog = false showNewBranchDialog = false
}, },
onAccept = { branchName -> onAccept = { branchName ->
tabViewModel.createBranch(branchName) repositoryOpenViewModel.createBranch(branchName)
showNewBranchDialog = false showNewBranchDialog = false
} }
) )
@ -69,17 +69,17 @@ fun RepositoryOpenPage(
showStashWithMessageDialog = false showStashWithMessageDialog = false
}, },
onAccept = { stashMessage -> onAccept = { stashMessage ->
tabViewModel.stashWithMessage(stashMessage) repositoryOpenViewModel.stashWithMessage(stashMessage)
showStashWithMessageDialog = false showStashWithMessageDialog = false
} }
) )
} else if (showAuthorInfo) { } else if (showAuthorInfo) {
val authorViewModel = tabViewModel.authorViewModel val authorViewModel = repositoryOpenViewModel.authorViewModel
if (authorViewModel != null) { if (authorViewModel != null) {
AuthorDialog( AuthorDialog(
authorViewModel = authorViewModel, authorViewModel = authorViewModel,
onClose = { onClose = {
tabViewModel.closeAuthorInfoDialog() repositoryOpenViewModel.closeAuthorInfoDialog()
} }
) )
} }
@ -89,16 +89,16 @@ fun RepositoryOpenPage(
onAction = { onAction = {
showQuickActionsDialog = false showQuickActionsDialog = false
when (it) { when (it) {
QuickActionType.OPEN_DIR_IN_FILE_MANAGER -> tabViewModel.openFolderInFileExplorer() QuickActionType.OPEN_DIR_IN_FILE_MANAGER -> repositoryOpenViewModel.openFolderInFileExplorer()
QuickActionType.CLONE -> onShowCloneDialog() QuickActionType.CLONE -> onShowCloneDialog()
QuickActionType.REFRESH -> tabViewModel.refreshAll() QuickActionType.REFRESH -> repositoryOpenViewModel.refreshAll()
QuickActionType.SIGN_OFF -> showSignOffDialog = true QuickActionType.SIGN_OFF -> showSignOffDialog = true
} }
}, },
) )
} else if (showSignOffDialog) { } else if (showSignOffDialog) {
SignOffDialog( SignOffDialog(
viewModel = tabViewModel.tabViewModelsProvider.signOffDialogViewModel, viewModel = repositoryOpenViewModel.tabViewModelsProvider.signOffDialogViewModel,
onClose = { showSignOffDialog = false }, onClose = { showSignOffDialog = false },
) )
} }
@ -113,11 +113,11 @@ fun RepositoryOpenPage(
println("Key event $it") println("Key event $it")
when { when {
it.matchesBinding(KeybindingOption.PULL) -> { it.matchesBinding(KeybindingOption.PULL) -> {
tabViewModel.pull(PullType.DEFAULT) repositoryOpenViewModel.pull(PullType.DEFAULT)
true true
} }
it.matchesBinding(KeybindingOption.PUSH) -> { it.matchesBinding(KeybindingOption.PUSH) -> {
tabViewModel.push() repositoryOpenViewModel.push()
true true
} }
it.matchesBinding(KeybindingOption.BRANCH_CREATE) -> { it.matchesBinding(KeybindingOption.BRANCH_CREATE) -> {
@ -129,11 +129,15 @@ fun RepositoryOpenPage(
} }
} }
it.matchesBinding(KeybindingOption.STASH) -> { it.matchesBinding(KeybindingOption.STASH) -> {
tabViewModel.stash() repositoryOpenViewModel.stash()
true true
} }
it.matchesBinding(KeybindingOption.STASH_POP) -> { it.matchesBinding(KeybindingOption.STASH_POP) -> {
tabViewModel.popStash() repositoryOpenViewModel.popStash()
true
}
it.matchesBinding(KeybindingOption.EXIT) -> {
repositoryOpenViewModel.closeLastView()
true true
} }
else -> false else -> false
@ -148,7 +152,7 @@ fun RepositoryOpenPage(
.focusable() .focusable()
.onKeyEvent { keyEvent -> .onKeyEvent { keyEvent ->
if (keyEvent.matchesBinding(KeybindingOption.REFRESH)) { if (keyEvent.matchesBinding(KeybindingOption.REFRESH)) {
tabViewModel.refreshAll() repositoryOpenViewModel.refreshAll()
true true
} else { } else {
false false
@ -157,7 +161,7 @@ fun RepositoryOpenPage(
) { ) {
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Menu( Menu(
menuViewModel = tabViewModel.tabViewModelsProvider.menuViewModel, menuViewModel = repositoryOpenViewModel.tabViewModelsProvider.menuViewModel,
modifier = Modifier modifier = Modifier
.padding( .padding(
vertical = 4.dp vertical = 4.dp
@ -165,12 +169,12 @@ fun RepositoryOpenPage(
.fillMaxWidth(), .fillMaxWidth(),
onCreateBranch = { showNewBranchDialog = true }, onCreateBranch = { showNewBranchDialog = true },
onStashWithMessage = { showStashWithMessageDialog = true }, onStashWithMessage = { showStashWithMessageDialog = true },
onOpenAnotherRepository = { tabViewModel.openAnotherRepository(it) }, onOpenAnotherRepository = { repositoryOpenViewModel.openAnotherRepository(it) },
onOpenAnotherRepositoryFromPicker = { onOpenAnotherRepositoryFromPicker = {
val repoToOpen = tabViewModel.openDirectoryPicker() val repoToOpen = repositoryOpenViewModel.openDirectoryPicker()
if (repoToOpen != null) { if (repoToOpen != null) {
tabViewModel.openAnotherRepository(repoToOpen) repositoryOpenViewModel.openAnotherRepository(repoToOpen)
} }
}, },
onQuickActions = { showQuickActionsDialog = true }, onQuickActions = { showQuickActionsDialog = true },
@ -178,7 +182,7 @@ fun RepositoryOpenPage(
) )
RepoContent( RepoContent(
tabViewModel = tabViewModel, repositoryOpenViewModel = repositoryOpenViewModel,
diffSelected = diffSelected, diffSelected = diffSelected,
selectedItem = selectedItem, selectedItem = selectedItem,
repositoryState = repositoryState, repositoryState = repositoryState,
@ -197,14 +201,14 @@ fun RepositoryOpenPage(
) )
val userInfo by tabViewModel.authorInfoSimple.collectAsState() val userInfo by repositoryOpenViewModel.authorInfoSimple.collectAsState()
val newUpdate = tabViewModel.update.collectAsState().value val newUpdate = repositoryOpenViewModel.update.collectAsState().value
BottomInfoBar( BottomInfoBar(
userInfo, userInfo,
newUpdate, newUpdate,
onOpenUrlInBrowser = { tabViewModel.openUrlInBrowser(it) }, onOpenUrlInBrowser = { repositoryOpenViewModel.openUrlInBrowser(it) },
onShowAuthorInfoDialog = { tabViewModel.showAuthorInfoDialog() }, onShowAuthorInfoDialog = { repositoryOpenViewModel.showAuthorInfoDialog() },
) )
} }
} }
@ -258,7 +262,7 @@ private fun BottomInfoBar(
@Composable @Composable
fun RepoContent( fun RepoContent(
tabViewModel: TabViewModel, repositoryOpenViewModel: RepositoryOpenViewModel,
diffSelected: DiffType?, diffSelected: DiffType?,
selectedItem: SelectedItem, selectedItem: SelectedItem,
repositoryState: RepositoryState, repositoryState: RepositoryState,
@ -266,19 +270,19 @@ fun RepoContent(
showHistory: Boolean, showHistory: Boolean,
) { ) {
if (showHistory) { if (showHistory) {
val historyViewModel = tabViewModel.historyViewModel val historyViewModel = repositoryOpenViewModel.historyViewModel
if (historyViewModel != null) { if (historyViewModel != null) {
FileHistory( FileHistory(
historyViewModel = historyViewModel, historyViewModel = historyViewModel,
onClose = { onClose = {
tabViewModel.closeHistory() repositoryOpenViewModel.closeHistory()
} }
) )
} }
} else { } else {
MainContentView( MainContentView(
tabViewModel, repositoryOpenViewModel,
diffSelected, diffSelected,
selectedItem, selectedItem,
repositoryState, repositoryState,
@ -289,25 +293,25 @@ fun RepoContent(
@Composable @Composable
fun MainContentView( fun MainContentView(
tabViewModel: TabViewModel, repositoryOpenViewModel: RepositoryOpenViewModel,
diffSelected: DiffType?, diffSelected: DiffType?,
selectedItem: SelectedItem, selectedItem: SelectedItem,
repositoryState: RepositoryState, repositoryState: RepositoryState,
blameState: BlameState, blameState: BlameState,
) { ) {
val rebaseInteractiveState by tabViewModel.rebaseInteractiveState.collectAsState() val rebaseInteractiveState by repositoryOpenViewModel.rebaseInteractiveState.collectAsState()
val density = LocalDensity.current.density val density = LocalDensity.current.density
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
// We create 2 mutableStates here because using directly the flow makes compose lose some drag events for some reason // 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 firstWidth by remember(repositoryOpenViewModel) { mutableStateOf(repositoryOpenViewModel.firstPaneWidth.value) }
var thirdWidth by remember(tabViewModel) { mutableStateOf(tabViewModel.thirdPaneWidth.value) } var thirdWidth by remember(repositoryOpenViewModel) { mutableStateOf(repositoryOpenViewModel.thirdPaneWidth.value) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
// Update the pane widths if they have been changed in a different tab // Update the pane widths if they have been changed in a different tab
tabViewModel.onPanelsWidthPersisted.collectLatest { repositoryOpenViewModel.onPanelsWidthPersisted.collectLatest {
firstWidth = tabViewModel.firstPaneWidth.value firstWidth = repositoryOpenViewModel.firstPaneWidth.value
thirdWidth = tabViewModel.thirdPaneWidth.value thirdWidth = repositoryOpenViewModel.thirdPaneWidth.value
} }
} }
@ -317,9 +321,9 @@ fun MainContentView(
thirdWidth = thirdWidth, thirdWidth = thirdWidth,
first = { first = {
SidePanel( SidePanel(
tabViewModel.tabViewModelsProvider.sidePanelViewModel, repositoryOpenViewModel.tabViewModelsProvider.sidePanelViewModel,
changeDefaultUpstreamBranchViewModel = { tabViewModel.tabViewModelsProvider.changeDefaultUpstreamBranchViewModel }, changeDefaultUpstreamBranchViewModel = { repositoryOpenViewModel.tabViewModelsProvider.changeDefaultUpstreamBranchViewModel },
submoduleDialogViewModel = { tabViewModel.tabViewModelsProvider.submoduleDialogViewModel }, submoduleDialogViewModel = { repositoryOpenViewModel.tabViewModelsProvider.submoduleDialogViewModel },
) )
}, },
second = { second = {
@ -328,13 +332,13 @@ fun MainContentView(
.fillMaxSize() .fillMaxSize()
) { ) {
if (rebaseInteractiveState == RebaseInteractiveState.AwaitingInteraction && diffSelected == null) { if (rebaseInteractiveState == RebaseInteractiveState.AwaitingInteraction && diffSelected == null) {
RebaseInteractive(tabViewModel.tabViewModelsProvider.rebaseInteractiveViewModel) RebaseInteractive(repositoryOpenViewModel.tabViewModelsProvider.rebaseInteractiveViewModel)
} else if (blameState is BlameState.Loaded && !blameState.isMinimized) { } else if (blameState is BlameState.Loaded && !blameState.isMinimized) {
Blame( Blame(
filePath = blameState.filePath, filePath = blameState.filePath,
blameResult = blameState.blameResult, blameResult = blameState.blameResult,
onClose = { tabViewModel.resetBlameState() }, onClose = { repositoryOpenViewModel.resetBlameState() },
onSelectCommit = { tabViewModel.selectCommit(it) } onSelectCommit = { repositoryOpenViewModel.selectCommit(it) }
) )
} else { } else {
Column { Column {
@ -342,21 +346,21 @@ fun MainContentView(
when (diffSelected) { when (diffSelected) {
null -> { null -> {
Log( Log(
logViewModel = tabViewModel.tabViewModelsProvider.logViewModel, logViewModel = repositoryOpenViewModel.tabViewModelsProvider.logViewModel,
selectedItem = selectedItem, selectedItem = selectedItem,
repositoryState = repositoryState, repositoryState = repositoryState,
changeDefaultUpstreamBranchViewModel = { tabViewModel.tabViewModelsProvider.changeDefaultUpstreamBranchViewModel }, changeDefaultUpstreamBranchViewModel = { repositoryOpenViewModel.tabViewModelsProvider.changeDefaultUpstreamBranchViewModel },
) )
} }
else -> { else -> {
val diffViewModel = tabViewModel.diffViewModel val diffViewModel = repositoryOpenViewModel.diffViewModel
if (diffViewModel != null) { if (diffViewModel != null) {
Diff( Diff(
diffViewModel = diffViewModel, diffViewModel = diffViewModel,
onCloseDiffView = { onCloseDiffView = {
tabViewModel.newDiffSelected = null repositoryOpenViewModel.newDiffSelected = null
} }
) )
} }
@ -367,8 +371,8 @@ fun MainContentView(
if (blameState is BlameState.Loaded) { // BlameState.isMinimized is true here if (blameState is BlameState.Loaded) { // BlameState.isMinimized is true here
MinimizedBlame( MinimizedBlame(
filePath = blameState.filePath, filePath = blameState.filePath,
onExpand = { tabViewModel.expandBlame() }, onExpand = { repositoryOpenViewModel.expandBlame() },
onClose = { tabViewModel.resetBlameState() } onClose = { repositoryOpenViewModel.resetBlameState() }
) )
} }
} }
@ -383,13 +387,13 @@ fun MainContentView(
when (selectedItem) { when (selectedItem) {
SelectedItem.UncommittedChanges -> { SelectedItem.UncommittedChanges -> {
UncommittedChanges( UncommittedChanges(
statusViewModel = tabViewModel.tabViewModelsProvider.statusViewModel, statusViewModel = repositoryOpenViewModel.tabViewModelsProvider.statusViewModel,
selectedEntryType = diffSelected, selectedEntryType = diffSelected,
repositoryState = repositoryState, repositoryState = repositoryState,
onStagedDiffEntrySelected = { diffEntry -> onStagedDiffEntrySelected = { diffEntry ->
tabViewModel.minimizeBlame() repositoryOpenViewModel.minimizeBlame()
tabViewModel.newDiffSelected = if (diffEntry != null) { repositoryOpenViewModel.newDiffSelected = if (diffEntry != null) {
if (repositoryState == RepositoryState.SAFE) if (repositoryState == RepositoryState.SAFE)
DiffType.SafeStagedDiff(diffEntry) DiffType.SafeStagedDiff(diffEntry)
else else
@ -399,29 +403,29 @@ fun MainContentView(
} }
}, },
onUnstagedDiffEntrySelected = { diffEntry -> onUnstagedDiffEntrySelected = { diffEntry ->
tabViewModel.minimizeBlame() repositoryOpenViewModel.minimizeBlame()
if (repositoryState == RepositoryState.SAFE) if (repositoryState == RepositoryState.SAFE)
tabViewModel.newDiffSelected = DiffType.SafeUnstagedDiff(diffEntry) repositoryOpenViewModel.newDiffSelected = DiffType.SafeUnstagedDiff(diffEntry)
else else
tabViewModel.newDiffSelected = DiffType.UnsafeUnstagedDiff(diffEntry) repositoryOpenViewModel.newDiffSelected = DiffType.UnsafeUnstagedDiff(diffEntry)
}, },
onBlameFile = { tabViewModel.blameFile(it) }, onBlameFile = { repositoryOpenViewModel.blameFile(it) },
onHistoryFile = { tabViewModel.fileHistory(it) } onHistoryFile = { repositoryOpenViewModel.fileHistory(it) }
) )
} }
is SelectedItem.CommitBasedItem -> { is SelectedItem.CommitBasedItem -> {
CommitChanges( CommitChanges(
commitChangesViewModel = tabViewModel.tabViewModelsProvider.commitChangesViewModel, commitChangesViewModel = repositoryOpenViewModel.tabViewModelsProvider.commitChangesViewModel,
selectedItem = selectedItem, selectedItem = selectedItem,
diffSelected = diffSelected, diffSelected = diffSelected,
onDiffSelected = { diffEntry -> onDiffSelected = { diffEntry ->
tabViewModel.minimizeBlame() repositoryOpenViewModel.minimizeBlame()
tabViewModel.newDiffSelected = DiffType.CommitDiff(diffEntry) repositoryOpenViewModel.newDiffSelected = DiffType.CommitDiff(diffEntry)
}, },
onBlame = { tabViewModel.blameFile(it) }, onBlame = { repositoryOpenViewModel.blameFile(it) },
onHistory = { tabViewModel.fileHistory(it) }, onHistory = { repositoryOpenViewModel.fileHistory(it) },
) )
} }
@ -431,19 +435,19 @@ fun MainContentView(
}, },
onFirstSizeDragStarted = { currentWidth -> onFirstSizeDragStarted = { currentWidth ->
firstWidth = currentWidth firstWidth = currentWidth
tabViewModel.setFirstPaneWidth(currentWidth) repositoryOpenViewModel.setFirstPaneWidth(currentWidth)
}, },
onFirstSizeChange = { onFirstSizeChange = {
val newWidth = firstWidth + it / density val newWidth = firstWidth + it / density
if (newWidth > 150 && rebaseInteractiveState !is RebaseInteractiveState.AwaitingInteraction) { if (newWidth > 150 && rebaseInteractiveState !is RebaseInteractiveState.AwaitingInteraction) {
firstWidth = newWidth firstWidth = newWidth
tabViewModel.setFirstPaneWidth(newWidth) repositoryOpenViewModel.setFirstPaneWidth(newWidth)
} }
}, },
onFirstSizeDragStopped = { onFirstSizeDragStopped = {
scope.launch { scope.launch {
tabViewModel.persistFirstPaneWidth() repositoryOpenViewModel.persistFirstPaneWidth()
} }
}, },
onThirdSizeChange = { onThirdSizeChange = {
@ -451,16 +455,16 @@ fun MainContentView(
if (newWidth > 150) { if (newWidth > 150) {
thirdWidth = newWidth thirdWidth = newWidth
tabViewModel.setThirdPaneWidth(newWidth) repositoryOpenViewModel.setThirdPaneWidth(newWidth)
} }
}, },
onThirdSizeDragStarted = { currentWidth -> onThirdSizeDragStarted = { currentWidth ->
thirdWidth = currentWidth thirdWidth = currentWidth
tabViewModel.setThirdPaneWidth(currentWidth) repositoryOpenViewModel.setThirdPaneWidth(currentWidth)
}, },
onThirdSizeDragStopped = { onThirdSizeDragStopped = {
scope.launch { scope.launch {
tabViewModel.persistThirdPaneWidth() repositoryOpenViewModel.persistThirdPaneWidth()
} }
}, },
) )

View File

@ -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,
)
}
}
}

View File

@ -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 */
}
}
}

View File

@ -19,7 +19,7 @@ private val updateJson = Json {
class UpdatesRepository @Inject constructor( class UpdatesRepository @Inject constructor(
private val updatesWebService: UpdatesService, private val updatesWebService: UpdatesService,
) { ) {
fun hasUpdatesFlow() = flow { val hasUpdatesFlow = flow {
val latestReleaseJson = updatesWebService.release(AppConstants.VERSION_CHECK_URL) val latestReleaseJson = updatesWebService.release(AppConstants.VERSION_CHECK_URL)
while (coroutineContext.isActive) { while (coroutineContext.isActive) {

View File

@ -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<DiffViewModel>,
private val historyViewModelProvider: Provider<HistoryViewModel>,
private val authorViewModelProvider: Provider<AuthorViewModel>,
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<SelectedItem> = tabState.selectedItem
var diffViewModel: DiffViewModel? = null
val repositoryState: StateFlow<RepositoryState> = sharedRepositoryStateManager.repositoryState
val rebaseInteractiveState: StateFlow<RebaseInteractiveState> = sharedRepositoryStateManager.rebaseInteractiveState
private val _diffSelected = MutableStateFlow<DiffType?>(null)
val diffSelected: StateFlow<DiffType?> = _diffSelected
var newDiffSelected: DiffType?
get() = diffSelected.value
set(value) {
_diffSelected.value = value
updateDiffEntry()
}
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
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<Update?> = 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
}

View File

@ -57,81 +57,39 @@ class TabViewModel @Inject constructor(
private val initLocalRepositoryUseCase: InitLocalRepositoryUseCase, private val initLocalRepositoryUseCase: InitLocalRepositoryUseCase,
private val openRepositoryUseCase: OpenRepositoryUseCase, private val openRepositoryUseCase: OpenRepositoryUseCase,
private val openSubmoduleRepositoryUseCase: OpenSubmoduleRepositoryUseCase, private val openSubmoduleRepositoryUseCase: OpenSubmoduleRepositoryUseCase,
private val getWorkspacePathUseCase: GetWorkspacePathUseCase,
private val diffViewModelProvider: Provider<DiffViewModel>,
private val historyViewModelProvider: Provider<HistoryViewModel>,
private val authorViewModelProvider: Provider<AuthorViewModel>,
private val tabState: TabState, private val tabState: TabState,
val appStateManager: AppStateManager, val appStateManager: AppStateManager,
private val fileChangesWatcher: FileChangesWatcher, private val fileChangesWatcher: FileChangesWatcher,
private val updatesRepository: UpdatesRepository,
private val credentialsStateManager: CredentialsStateManager, private val credentialsStateManager: CredentialsStateManager,
private val createBranchUseCase: CreateBranchUseCase,
private val stashChangesUseCase: StashChangesUseCase,
private val stageUntrackedFileUseCase: StageUntrackedFileUseCase,
private val openFilePickerUseCase: OpenFilePickerUseCase, private val openFilePickerUseCase: OpenFilePickerUseCase,
private val openUrlInBrowserUseCase: OpenUrlInBrowserUseCase, private val openUrlInBrowserUseCase: OpenUrlInBrowserUseCase,
private val sharedRepositoryStateManager: SharedRepositoryStateManager,
private val tabsManager: TabsManager,
private val tabScope: CoroutineScope, private val tabScope: CoroutineScope,
private val verticalSplitPaneConfig: VerticalSplitPaneConfig, private val verticalSplitPaneConfig: VerticalSplitPaneConfig,
val tabViewModelsProvider: TabViewModelsProvider, val tabViewModelsProvider: TabViewModelsProvider,
private val globalMenuActionsViewModel: GlobalMenuActionsViewModel, private val globalMenuActionsViewModel: GlobalMenuActionsViewModel,
private val repositoryOpenViewModelProvider: Provider<RepositoryOpenViewModel>,
updatesRepository: UpdatesRepository,
) : IVerticalSplitPaneConfig by verticalSplitPaneConfig, ) : IVerticalSplitPaneConfig by verticalSplitPaneConfig,
IGlobalMenuActionsViewModel by globalMenuActionsViewModel { IGlobalMenuActionsViewModel by globalMenuActionsViewModel {
var initialPath: String? = null // Stores the path that should be opened when the tab is selected var initialPath: String? = null // Stores the path that should be opened when the tab is selected
val errorsManager: ErrorsManager = tabState.errorsManager val errorsManager: ErrorsManager = tabState.errorsManager
val selectedItem: StateFlow<SelectedItem> = tabState.selectedItem val selectedItem: StateFlow<SelectedItem> = tabState.selectedItem
var diffViewModel: DiffViewModel? = null
val repositoryOpenViewModel: RepositoryOpenViewModel by lazy {
repositoryOpenViewModelProvider.get()
}
private val _repositorySelectionStatus = MutableStateFlow<RepositorySelectionStatus>(RepositorySelectionStatus.None) private val _repositorySelectionStatus = MutableStateFlow<RepositorySelectionStatus>(RepositorySelectionStatus.None)
val repositorySelectionStatus: StateFlow<RepositorySelectionStatus> val repositorySelectionStatus: StateFlow<RepositorySelectionStatus>
get() = _repositorySelectionStatus get() = _repositorySelectionStatus
val repositoryState: StateFlow<RepositoryState> = sharedRepositoryStateManager.repositoryState
val rebaseInteractiveState: StateFlow<RebaseInteractiveState> = sharedRepositoryStateManager.rebaseInteractiveState
val processing: StateFlow<ProcessingState> = tabState.processing val processing: StateFlow<ProcessingState> = tabState.processing
val credentialsState: StateFlow<CredentialsState> = credentialsStateManager.credentialsState val credentialsState: StateFlow<CredentialsState> = credentialsStateManager.credentialsState
private val _diffSelected = MutableStateFlow<DiffType?>(null)
val diffSelected: StateFlow<DiffType?> = _diffSelected
var newDiffSelected: DiffType?
get() = diffSelected.value
set(value) {
_diffSelected.value = value
updateDiffEntry()
}
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) val showError = MutableStateFlow(false)
init { init {
tabScope.run { tabScope.run {
launch {
tabState.refreshFlowFiltered(RefreshType.ALL_DATA, RefreshType.REPO_STATE) {
loadAuthorInfo(tabState.git)
}
}
launch { launch {
errorsManager.error.collect { 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) { fun openRepository(directory: String) {
openRepository(File(directory)) openRepository(File(directory))
} }
@ -182,10 +127,6 @@ class TabViewModel @Inject constructor(
onRepositoryChanged(path) onRepositoryChanged(path)
tabState.newSelectedItem(selectedItem = SelectedItem.UncommittedChanges) tabState.newSelectedItem(selectedItem = SelectedItem.UncommittedChanges)
newDiffSelected = null
refreshRepositoryInfo()
watchRepositoryChanges(git)
} catch (ex: Exception) { } catch (ex: Exception) {
onRepositoryChanged(null) onRepositoryChanged(null)
ex.printStackTrace() 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() { fun credentialsDenied() {
credentialsStateManager.updateState(CredentialsState.CredentialsDenied) credentialsStateManager.updateState(CredentialsState.CredentialsDenied)
} }
@ -322,22 +160,6 @@ class TabViewModel @Inject constructor(
tabScope.cancel() 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? { fun openDirectoryPicker(): String? {
val latestDirectoryOpened = appStateManager.latestOpenedRepositoryPath val latestDirectoryOpened = appStateManager.latestOpenedRepositoryPath
@ -350,105 +172,9 @@ class TabViewModel @Inject constructor(
openRepository(repoDir) openRepository(repoDir)
} }
val update: StateFlow<Update?> = updatesRepository.hasUpdatesFlow() val update: StateFlow<Update?> = updatesRepository.hasUpdatesFlow
.flowOn(Dispatchers.IO)
.stateIn(tabScope, started = SharingStarted.Eagerly, null) .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) { fun gpgCredentialsAccepted(password: String) {
credentialsStateManager.updateState(CredentialsAccepted.GpgCredentialsAccepted(password)) credentialsStateManager.updateState(CredentialsAccepted.GpgCredentialsAccepted(password))
} }
@ -468,15 +194,7 @@ class TabViewModel @Inject constructor(
sealed class RepositorySelectionStatus { sealed class RepositorySelectionStatus {
object None : RepositorySelectionStatus() data object None : RepositorySelectionStatus()
data class Opening(val path: String) : RepositorySelectionStatus() data class Opening(val path: String) : RepositorySelectionStatus()
data class Open(val repository: Repository) : 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
}