From 8b36537451c3d2925d15567c296202b9a2749816 Mon Sep 17 00:00:00 2001 From: Abdelilah El Aissaoui Date: Tue, 23 Jan 2024 17:26:09 +0100 Subject: [PATCH] Implemented treeview in files list Fixes #22 --- .../kotlin/com/jetpackduba/gitnuro/Icons.kt | 3 + .../git/workspace/StageByDirectoryUseCase.kt | 15 + .../workspace/UnstageByDirectoryUseCase.kt | 15 + .../gitnuro/preferences/AppSettings.kt | 26 +- .../jetpackduba/gitnuro/ui/CommitChanges.kt | 419 ++++--- .../gitnuro/ui/UncommitedChanges.kt | 1000 +++++++++++------ .../gitnuro/ui/components/FileEntry.kt | 161 +++ .../gitnuro/ui/context_menu/ContextMenu.kt | 2 +- .../StatusDirEntriesContextMenu.kt | 40 + .../jetpackduba/gitnuro/ui/tree_files/Tree.kt | 70 ++ .../viewmodels/CommitChangesViewModel.kt | 83 +- .../gitnuro/viewmodels/StatusViewModel.kt | 159 ++- src/main/resources/folder.svg | 1 + src/main/resources/folder_open.svg | 1 + src/main/resources/tree.svg | 1 + 15 files changed, 1467 insertions(+), 529 deletions(-) create mode 100644 src/main/kotlin/com/jetpackduba/gitnuro/git/workspace/StageByDirectoryUseCase.kt create mode 100644 src/main/kotlin/com/jetpackduba/gitnuro/git/workspace/UnstageByDirectoryUseCase.kt create mode 100644 src/main/kotlin/com/jetpackduba/gitnuro/ui/components/FileEntry.kt create mode 100644 src/main/kotlin/com/jetpackduba/gitnuro/ui/context_menu/StatusDirEntriesContextMenu.kt create mode 100644 src/main/kotlin/com/jetpackduba/gitnuro/ui/tree_files/Tree.kt create mode 100644 src/main/resources/folder.svg create mode 100644 src/main/resources/folder_open.svg create mode 100644 src/main/resources/tree.svg diff --git a/src/main/kotlin/com/jetpackduba/gitnuro/Icons.kt b/src/main/kotlin/com/jetpackduba/gitnuro/Icons.kt index f3e5fb9..066d085 100644 --- a/src/main/kotlin/com/jetpackduba/gitnuro/Icons.kt +++ b/src/main/kotlin/com/jetpackduba/gitnuro/Icons.kt @@ -25,6 +25,8 @@ object AppIcons { const val ERROR = "error.svg" const val EXPAND_MORE = "expand_more.svg" const val FETCH = "fetch.svg" + const val FOLDER = "folder.svg" + const val FOLDER_OPEN = "folder_open.svg" const val GRADE = "grade.svg" const val HORIZONTAL_SPLIT = "horizontal_split.svg" const val HISTORY = "history.svg" @@ -59,6 +61,7 @@ object AppIcons { const val TAG = "tag.svg" const val TERMINAL = "terminal.svg" const val TOPIC = "topic.svg" + const val TREE = "tree.svg" const val UNDO = "undo.svg" const val UNIFIED = "unified.svg" const val UPDATE = "update.svg" diff --git a/src/main/kotlin/com/jetpackduba/gitnuro/git/workspace/StageByDirectoryUseCase.kt b/src/main/kotlin/com/jetpackduba/gitnuro/git/workspace/StageByDirectoryUseCase.kt new file mode 100644 index 0000000..fb9b767 --- /dev/null +++ b/src/main/kotlin/com/jetpackduba/gitnuro/git/workspace/StageByDirectoryUseCase.kt @@ -0,0 +1,15 @@ +package com.jetpackduba.gitnuro.git.workspace + +import com.jetpackduba.gitnuro.system.systemSeparator +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.eclipse.jgit.api.Git +import javax.inject.Inject + +class StageByDirectoryUseCase @Inject constructor() { + suspend operator fun invoke(git: Git, dir: String) = withContext(Dispatchers.IO) { + git.add() + .addFilepattern(dir) + .call() + } +} diff --git a/src/main/kotlin/com/jetpackduba/gitnuro/git/workspace/UnstageByDirectoryUseCase.kt b/src/main/kotlin/com/jetpackduba/gitnuro/git/workspace/UnstageByDirectoryUseCase.kt new file mode 100644 index 0000000..47a2bf1 --- /dev/null +++ b/src/main/kotlin/com/jetpackduba/gitnuro/git/workspace/UnstageByDirectoryUseCase.kt @@ -0,0 +1,15 @@ +package com.jetpackduba.gitnuro.git.workspace + +import com.jetpackduba.gitnuro.system.systemSeparator +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.eclipse.jgit.api.Git +import javax.inject.Inject + +class UnstageByDirectoryUseCase @Inject constructor() { + suspend operator fun invoke(git: Git, dir: String) = withContext(Dispatchers.IO) { + git.reset() + .addPath(dir) + .call() + } +} diff --git a/src/main/kotlin/com/jetpackduba/gitnuro/preferences/AppSettings.kt b/src/main/kotlin/com/jetpackduba/gitnuro/preferences/AppSettings.kt index 57518ad..b465497 100644 --- a/src/main/kotlin/com/jetpackduba/gitnuro/preferences/AppSettings.kt +++ b/src/main/kotlin/com/jetpackduba/gitnuro/preferences/AppSettings.kt @@ -31,6 +31,7 @@ private const val PREF_DIFF_TYPE = "diffType" private const val PREF_DIFF_FULL_FILE = "diffFullFile" private const val PREF_SWAP_UNCOMMITTED_CHANGES = "inverseUncommittedChanges" private const val PREF_TERMINAL_PATH = "terminalPath" +private const val PREF_SHOW_CHANGES_AS_TREE = "showChangesAsTree" private const val PREF_USE_PROXY = "useProxy" private const val PREF_PROXY_TYPE = "proxyType" private const val PREF_PROXY_HOST_NAME = "proxyHostName" @@ -50,6 +51,7 @@ private const val PREF_VERIFY_SSL = "verifySsl" private const val DEFAULT_COMMITS_LIMIT = 1000 private const val DEFAULT_COMMITS_LIMIT_ENABLED = true private const val DEFAULT_SWAP_UNCOMMITTED_CHANGES = false +private const val DEFAULT_SHOW_CHANGES_AS_TREE = false private const val DEFAULT_CACHE_CREDENTIALS_IN_MEMORY = true private const val DEFAULT_VERIFY_SSL = true const val DEFAULT_UI_SCALE = -1f @@ -67,6 +69,9 @@ class AppSettings @Inject constructor() { private val _swapUncommittedChangesFlow = MutableStateFlow(swapUncommittedChanges) val swapUncommittedChangesFlow = _swapUncommittedChangesFlow.asStateFlow() + private val _showChangesAsTreeFlow = MutableStateFlow(showChangesAsTree) + val showChangesAsTreeFlow = _showChangesAsTreeFlow.asStateFlow() + private val _cacheCredentialsInMemoryFlow = MutableStateFlow(cacheCredentialsInMemory) val cacheCredentialsInMemoryFlow = _cacheCredentialsInMemoryFlow.asStateFlow() @@ -165,6 +170,15 @@ class AppSettings @Inject constructor() { _swapUncommittedChangesFlow.value = value } + var showChangesAsTree: Boolean + get() { + return preferences.getBoolean(PREF_SHOW_CHANGES_AS_TREE, DEFAULT_SHOW_CHANGES_AS_TREE) + } + set(value) { + preferences.putBoolean(PREF_SHOW_CHANGES_AS_TREE, value) + _showChangesAsTreeFlow.value = value + } + var cacheCredentialsInMemory: Boolean get() { return preferences.getBoolean(PREF_CACHE_CREDENTIALS_IN_MEMORY, DEFAULT_CACHE_CREDENTIALS_IN_MEMORY) @@ -347,18 +361,6 @@ class AppSettings @Inject constructor() { _customThemeFlow.value = Json.decodeFromString(themeJson) } } - - private fun loadProxySettings() { - _proxyFlow.value = ProxySettings( - useProxy, - proxyType, - proxyHostName, - proxyPortNumber, - proxyUseAuth, - proxyHostUser, - proxyHostPassword, - ) - } } data class ProxySettings( diff --git a/src/main/kotlin/com/jetpackduba/gitnuro/ui/CommitChanges.kt b/src/main/kotlin/com/jetpackduba/gitnuro/ui/CommitChanges.kt index 3a4be81..af98e70 100644 --- a/src/main/kotlin/com/jetpackduba/gitnuro/ui/CommitChanges.kt +++ b/src/main/kotlin/com/jetpackduba/gitnuro/ui/CommitChanges.kt @@ -1,6 +1,5 @@ package com.jetpackduba.gitnuro.ui -import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ScrollState import androidx.compose.foundation.background import androidx.compose.foundation.layout.* @@ -21,18 +20,17 @@ import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.jetpackduba.gitnuro.AppIcons import com.jetpackduba.gitnuro.extensions.* import com.jetpackduba.gitnuro.git.DiffEntryType -import com.jetpackduba.gitnuro.theme.backgroundSelected import com.jetpackduba.gitnuro.theme.onBackgroundSecondary import com.jetpackduba.gitnuro.theme.tertiarySurface import com.jetpackduba.gitnuro.ui.components.* -import com.jetpackduba.gitnuro.ui.context_menu.ContextMenu +import com.jetpackduba.gitnuro.ui.context_menu.ContextMenuElement import com.jetpackduba.gitnuro.ui.context_menu.committedChangesEntriesContextMenuItems -import com.jetpackduba.gitnuro.viewmodels.CommitChangesState +import com.jetpackduba.gitnuro.ui.tree_files.TreeItem +import com.jetpackduba.gitnuro.viewmodels.CommitChangesStateUi import com.jetpackduba.gitnuro.viewmodels.CommitChangesViewModel import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -53,28 +51,29 @@ fun CommitChanges( commitChangesViewModel.loadChanges(selectedItem.revCommit) } - val commitChangesStatus = commitChangesViewModel.commitChangesState.collectAsState().value + val commitChangesStatus = commitChangesViewModel.commitChangesStateUi.collectAsState().value val showSearch by commitChangesViewModel.showSearch.collectAsState() val changesListScroll by commitChangesViewModel.changesLazyListState.collectAsState() val textScroll by commitChangesViewModel.textScroll.collectAsState() + val showAsTree by commitChangesViewModel.showAsTree.collectAsState() var searchFilter by remember(commitChangesViewModel, showSearch, commitChangesStatus) { mutableStateOf(commitChangesViewModel.searchFilter.value) } when (commitChangesStatus) { - CommitChangesState.Loading -> { + CommitChangesStateUi.Loading -> { LinearProgressIndicator(modifier = Modifier.fillMaxWidth(), color = MaterialTheme.colors.primaryVariant) } - is CommitChangesState.Loaded -> { + is CommitChangesStateUi.Loaded -> { CommitChangesView( diffSelected = diffSelected, - commit = commitChangesStatus.commit, - changes = commitChangesStatus.changesFiltered, + commitChangesStatus = commitChangesStatus, onBlame = onBlame, onHistory = onHistory, showSearch = showSearch, + showAsTree = showAsTree, changesListScroll = changesListScroll, textScroll = textScroll, searchFilter = searchFilter, @@ -86,38 +85,37 @@ fun CommitChanges( searchFilter = filter commitChangesViewModel.onSearchFilterChanged(filter) }, + onDirectoryClicked = { commitChangesViewModel.onDirectoryClicked(it.fullPath) }, + onAlternateShowAsTree = { commitChangesViewModel.alternateShowAsTree() }, ) } } } @Composable -fun CommitChangesView( - commit: RevCommit, - changes: List, +private fun CommitChangesView( + commitChangesStatus: CommitChangesStateUi.Loaded, diffSelected: DiffEntryType?, changesListScroll: LazyListState, textScroll: ScrollState, showSearch: Boolean, + showAsTree: Boolean, searchFilter: TextFieldValue, onBlame: (String) -> Unit, onHistory: (String) -> Unit, onDiffSelected: (DiffEntry) -> Unit, onSearchFilterToggled: (Boolean) -> Unit, onSearchFilterChanged: (TextFieldValue) -> Unit, + onDirectoryClicked: (TreeItem.Dir) -> Unit, + onAlternateShowAsTree: () -> Unit, ) { - - /** - * State used to prevent the text field from getting the focus when returning from another tab - */ - var requestFocus by remember { mutableStateOf(false) } + val commit = commitChangesStatus.commit Column( modifier = Modifier .padding(end = 8.dp, bottom = 8.dp) .fillMaxSize(), ) { - val searchFocusRequester = remember { FocusRequester() } Column( modifier = Modifier @@ -126,92 +124,169 @@ fun CommitChangesView( .weight(1f, fill = true) .background(MaterialTheme.colors.background) ) { - Row( - modifier = Modifier - .fillMaxWidth() - .height(34.dp) - .background(MaterialTheme.colors.tertiarySurface), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - modifier = Modifier - .padding(vertical = 8.dp, horizontal = 16.dp), - text = "Files changed", - fontWeight = FontWeight.Normal, - textAlign = TextAlign.Left, - color = MaterialTheme.colors.onBackground, - maxLines = 1, - style = MaterialTheme.typography.body2, - ) + Header( + showSearch, + searchFilter, + onSearchFilterChanged, + onSearchFilterToggled, + showAsTree = showAsTree, + onAlternateShowAsTree = onAlternateShowAsTree, + ) - Box(modifier = Modifier.weight(1f)) + when (commitChangesStatus) { + is CommitChangesStateUi.ListLoaded -> { + val changes = commitChangesStatus.changes - IconButton( - onClick = { - onSearchFilterToggled(!showSearch) + ListCommitLogChanges( + diffSelected = diffSelected, + changesListScroll = changesListScroll, + diffEntries = changes, + onDiffSelected = onDiffSelected, + onGenerateContextMenu = { diffEntry -> + committedChangesEntriesContextMenuItems( + diffEntry, + onBlame = { onBlame(diffEntry.filePath) }, + onHistory = { onHistory(diffEntry.filePath) }, + ) + } + ) + } - if (!showSearch) - requestFocus = true - }, - modifier = Modifier.handOnHover(), - ) { - Icon( - painter = painterResource(AppIcons.SEARCH), - contentDescription = null, - modifier = Modifier.size(18.dp), - tint = MaterialTheme.colors.onBackground, + is CommitChangesStateUi.TreeLoaded -> { + TreeCommitLogChanges( + diffSelected = diffSelected, + changesListScroll = changesListScroll, + treeItems = commitChangesStatus.changes, + onDiffSelected = onDiffSelected, + onGenerateContextMenu = { diffEntry -> + committedChangesEntriesContextMenuItems( + diffEntry, + onBlame = { onBlame(diffEntry.filePath) }, + onHistory = { onHistory(diffEntry.filePath) }, + ) + }, + onDirectoryClicked = onDirectoryClicked, ) } } - if (showSearch) { - SearchTextField( - searchFilter = searchFilter, - onSearchFilterChanged = onSearchFilterChanged, - searchFocusRequester = searchFocusRequester, - onClose = { onSearchFilterToggled(false) }, - ) - } + } - LaunchedEffect(showSearch, requestFocus) { - if (showSearch && requestFocus) { - searchFocusRequester.requestFocus() - requestFocus = false - } - } + MessageAuthorFooter(commit, textScroll) + } +} - CommitLogChanges( - diffSelected = diffSelected, - changesListScroll = changesListScroll, - diffEntries = changes, - onDiffSelected = onDiffSelected, - onBlame = onBlame, - onHistory = onHistory, +@Composable +private fun Header( + showSearch: Boolean, + searchFilter: TextFieldValue, + onSearchFilterChanged: (TextFieldValue) -> Unit, + onSearchFilterToggled: (Boolean) -> Unit, + showAsTree: Boolean, + onAlternateShowAsTree: () -> Unit, +) { + val searchFocusRequester = remember { FocusRequester() } + + /** + * State used to prevent the text field from getting the focus when returning from another tab + */ + var requestFocus by remember { mutableStateOf(false) } + + Row( + modifier = Modifier + .fillMaxWidth() + .height(34.dp) + .background(MaterialTheme.colors.tertiarySurface), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + modifier = Modifier + .padding(vertical = 8.dp, horizontal = 16.dp), + text = "Files changed", + fontWeight = FontWeight.Normal, + textAlign = TextAlign.Left, + color = MaterialTheme.colors.onBackground, + maxLines = 1, + style = MaterialTheme.typography.body2, + ) + + Box(modifier = Modifier.weight(1f)) + + IconButton( + onClick = { + onAlternateShowAsTree() + }, + modifier = Modifier.handOnHover() + ) { + Icon( + painter = painterResource(if (showAsTree) AppIcons.LIST else AppIcons.TREE), + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colors.onBackground, ) } - Column( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(4.dp)) - .background(MaterialTheme.colors.background), - ) { - SelectionContainer { - Text( - text = commit.fullMessage, - style = MaterialTheme.typography.body1, - color = MaterialTheme.colors.onBackground, - modifier = Modifier - .fillMaxWidth() - .height(120.dp) - .padding(8.dp) - .verticalScroll(textScroll), - ) - } + IconButton( + onClick = { + onSearchFilterToggled(!showSearch) - Author(commit.shortName, commit.name, commit.authorIdent) + if (!showSearch) + requestFocus = true + }, + modifier = Modifier.handOnHover(), + ) { + Icon( + painter = painterResource(AppIcons.SEARCH), + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colors.onBackground, + ) } } + + if (showSearch) { + SearchTextField( + searchFilter = searchFilter, + onSearchFilterChanged = onSearchFilterChanged, + searchFocusRequester = searchFocusRequester, + onClose = { onSearchFilterToggled(false) }, + ) + } + + LaunchedEffect(showSearch, requestFocus) { + if (showSearch && requestFocus) { + searchFocusRequester.requestFocus() + requestFocus = false + } + } +} + +@Composable +private fun MessageAuthorFooter( + commit: RevCommit, + textScroll: ScrollState, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(4.dp)) + .background(MaterialTheme.colors.background), + ) { + SelectionContainer { + Text( + text = commit.fullMessage, + style = MaterialTheme.typography.body1, + color = MaterialTheme.colors.onBackground, + modifier = Modifier + .fillMaxWidth() + .height(120.dp) + .padding(8.dp) + .verticalScroll(textScroll), + ) + } + + Author(commit.shortName, commit.name, commit.authorIdent) + } } @Composable @@ -300,15 +375,13 @@ fun Author( } } -@OptIn(ExperimentalFoundationApi::class) @Composable -fun CommitLogChanges( +fun ListCommitLogChanges( diffEntries: List, diffSelected: DiffEntryType?, changesListScroll: LazyListState, - onBlame: (String) -> Unit, - onHistory: (String) -> Unit, onDiffSelected: (DiffEntry) -> Unit, + onGenerateContextMenu: (DiffEntry) -> List, ) { ScrollableLazyColumn( modifier = Modifier @@ -316,74 +389,96 @@ fun CommitLogChanges( state = changesListScroll, ) { items(items = diffEntries) { diffEntry -> - ContextMenu( - items = { - committedChangesEntriesContextMenuItems( - diffEntry, - onBlame = { onBlame(diffEntry.filePath) }, - onHistory = { onHistory(diffEntry.filePath) }, - ) - } - ) { - Column( - modifier = Modifier - .height(40.dp) - .fillMaxWidth() - .handMouseClickable { - onDiffSelected(diffEntry) - } - .backgroundIf( - condition = diffSelected is DiffEntryType.CommitDiff && diffSelected.diffEntry == diffEntry, - color = MaterialTheme.colors.backgroundSelected, - ), - verticalArrangement = Arrangement.Center, - ) { - Spacer(modifier = Modifier.weight(2f)) - - Row { - Icon( - modifier = Modifier - .padding(horizontal = 8.dp) - .size(16.dp), - imageVector = diffEntry.icon, - contentDescription = null, - tint = diffEntry.iconColor, - ) - - if (diffEntry.parentDirectoryPath.isNotEmpty()) { - Text( - text = diffEntry.parentDirectoryPath.removeSuffix("/"), - modifier = Modifier.weight(1f, fill = false), - maxLines = 1, - softWrap = false, - overflow = TextOverflow.Ellipsis, - style = MaterialTheme.typography.body2, - color = MaterialTheme.colors.onBackgroundSecondary, - ) - - Text( - text = "/", - maxLines = 1, - softWrap = false, - style = MaterialTheme.typography.body2, - overflow = TextOverflow.Visible, - color = MaterialTheme.colors.onBackgroundSecondary, - ) - } - Text( - text = diffEntry.fileName, - maxLines = 1, - softWrap = false, - modifier = Modifier.padding(end = 16.dp), - style = MaterialTheme.typography.body2, - color = MaterialTheme.colors.onBackground, - ) - } - - Spacer(modifier = Modifier.weight(2f)) - - } - } + FileEntry( + icon = diffEntry.icon, + iconColor = diffEntry.iconColor, + parentDirectoryPath = diffEntry.parentDirectoryPath, + fileName = diffEntry.fileName, + isSelected = diffSelected is DiffEntryType.CommitDiff && diffSelected.diffEntry == diffEntry, + onClick = { onDiffSelected(diffEntry) }, + onDoubleClick = {}, + onGenerateContextMenu = { onGenerateContextMenu(diffEntry) }, + trailingAction = null, + ) } } +} + +@Composable +fun TreeCommitLogChanges( + treeItems: List>, + diffSelected: DiffEntryType?, + changesListScroll: LazyListState, + onDiffSelected: (DiffEntry) -> Unit, + onDirectoryClicked: (TreeItem.Dir) -> Unit, + onGenerateContextMenu: (DiffEntry) -> List, +) { + ScrollableLazyColumn( + modifier = Modifier + .fillMaxSize(), + state = changesListScroll, + ) { + items(items = treeItems) { entry -> + CommitTreeItemEntry( + entry = entry, + isSelected = entry is TreeItem.File && + diffSelected is DiffEntryType.CommitDiff && + diffSelected.diffEntry == entry.data, + onFileClick = { onDiffSelected(it) }, + onDirectoryClick = { onDirectoryClicked(it) }, + onGenerateContextMenu = onGenerateContextMenu, + onGenerateDirectoryContextMenu = { emptyList() }, + ) + } + } +} + + +@Composable +private fun CommitTreeItemEntry( + entry: TreeItem, + isSelected: Boolean, + onFileClick: (DiffEntry) -> Unit, + onDirectoryClick: (TreeItem.Dir) -> Unit, + onGenerateContextMenu: (DiffEntry) -> List, + onGenerateDirectoryContextMenu: (TreeItem.Dir) -> List, +) { + when (entry) { + is TreeItem.File -> CommitFileEntry( + fileEntry = entry, + isSelected = isSelected, + onClick = { onFileClick(entry.data) }, + onGenerateContextMenu = onGenerateContextMenu, + ) + + is TreeItem.Dir -> DirectoryEntry( + dirName = entry.displayName, + onClick = { onDirectoryClick(entry) }, + depth = entry.depth, + onGenerateContextMenu = { onGenerateDirectoryContextMenu(entry) }, + ) + } +} + +@Composable +private fun CommitFileEntry( + fileEntry: TreeItem.File, + isSelected: Boolean, + onClick: () -> Unit, + onGenerateContextMenu: (DiffEntry) -> List, +) { + val diffEntry = fileEntry.data + + FileEntry( + icon = diffEntry.icon, + iconColor = diffEntry.iconColor, + parentDirectoryPath = "", + fileName = diffEntry.fileName, + isSelected = isSelected, + onClick = onClick, + onDoubleClick = {}, + depth = fileEntry.depth, + onGenerateContextMenu = { onGenerateContextMenu(diffEntry) }, + trailingAction = null, + ) } \ No newline at end of file diff --git a/src/main/kotlin/com/jetpackduba/gitnuro/ui/UncommitedChanges.kt b/src/main/kotlin/com/jetpackduba/gitnuro/ui/UncommitedChanges.kt index 4d15979..61b1223 100644 --- a/src/main/kotlin/com/jetpackduba/gitnuro/ui/UncommitedChanges.kt +++ b/src/main/kotlin/com/jetpackduba/gitnuro/ui/UncommitedChanges.kt @@ -28,25 +28,26 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.jetpackduba.gitnuro.AppIcons import com.jetpackduba.gitnuro.extensions.* import com.jetpackduba.gitnuro.git.DiffEntryType import com.jetpackduba.gitnuro.git.rebase.RebaseInteractiveState import com.jetpackduba.gitnuro.git.workspace.StatusEntry -import com.jetpackduba.gitnuro.git.workspace.StatusType import com.jetpackduba.gitnuro.keybindings.KeybindingOption import com.jetpackduba.gitnuro.keybindings.matchesBinding -import com.jetpackduba.gitnuro.theme.* +import com.jetpackduba.gitnuro.theme.abortButton +import com.jetpackduba.gitnuro.theme.tertiarySurface +import com.jetpackduba.gitnuro.theme.textFieldColors import com.jetpackduba.gitnuro.ui.components.* -import com.jetpackduba.gitnuro.ui.context_menu.ContextMenu import com.jetpackduba.gitnuro.ui.context_menu.ContextMenuElement import com.jetpackduba.gitnuro.ui.context_menu.EntryType +import com.jetpackduba.gitnuro.ui.context_menu.statusDirEntriesContextMenuItems import com.jetpackduba.gitnuro.ui.context_menu.statusEntriesContextMenuItems import com.jetpackduba.gitnuro.ui.dialogs.CommitAuthorDialog +import com.jetpackduba.gitnuro.ui.tree_files.TreeItem import com.jetpackduba.gitnuro.viewmodels.CommitterDataRequestState -import com.jetpackduba.gitnuro.viewmodels.StageState +import com.jetpackduba.gitnuro.viewmodels.StageStateUi import com.jetpackduba.gitnuro.viewmodels.StatusViewModel import org.eclipse.jgit.lib.RepositoryState @@ -60,9 +61,9 @@ fun UncommittedChanges( onBlameFile: (String) -> Unit, onHistoryFile: (String) -> Unit, ) { - val stageStatus = statusViewModel.stageState.collectAsState().value + val stageStateUi = statusViewModel.stageStateUi.collectAsState().value val swapUncommittedChanges by statusViewModel.swapUncommittedChanges.collectAsState() - var commitMessage by remember(statusViewModel) { mutableStateOf(statusViewModel.savedCommitMessage.message) } + val (commitMessage, setCommitMessage) = remember(statusViewModel) { mutableStateOf(statusViewModel.savedCommitMessage.message) } val stagedListState by statusViewModel.stagedLazyListState.collectAsState() val unstagedListState by statusViewModel.unstagedLazyListState.collectAsState() val isAmend by statusViewModel.isAmend.collectAsState() @@ -79,32 +80,18 @@ fun UncommittedChanges( val isAmenableRebaseInteractive = repositoryState.isRebasing && rebaseInteractiveState is RebaseInteractiveState.ProcessingCommits && rebaseInteractiveState.isCurrentStepAmenable - val staged: List - val unstaged: List - val isLoading: Boolean - - if (stageStatus is StageState.Loaded) { - staged = stageStatus.stagedFiltered - unstaged = stageStatus.unstagedFiltered - isLoading = stageStatus.isPartiallyReloading - } else { - staged = listOf() - unstaged = listOf() // return empty lists if still loading - isLoading = true - } - val doCommit = { statusViewModel.commit(commitMessage) onStagedDiffEntrySelected(null) - commitMessage = "" + setCommitMessage("") } - val canCommit = commitMessage.isNotEmpty() && staged.isNotEmpty() + val canCommit = commitMessage.isNotEmpty() && stageStateUi.hasStagedFiles val canAmend = commitMessage.isNotEmpty() && statusViewModel.hasPreviousCommits LaunchedEffect(statusViewModel) { statusViewModel.commitMessageChangesFlow.collect { newCommitMessage -> - commitMessage = newCommitMessage + setCommitMessage(newCommitMessage) } } @@ -124,7 +111,7 @@ fun UncommittedChanges( .fillMaxWidth(), ) { AnimatedVisibility( - visible = isLoading, + visible = stageStateUi.isLoading, enter = fadeIn(), exit = fadeOut(), ) { @@ -132,206 +119,422 @@ fun UncommittedChanges( } Column( - modifier = Modifier.weight(1f) + modifier = Modifier.weight(1f), ) { - val stagedView: @Composable () -> Unit = { - EntriesList( - modifier = Modifier - .weight(5f) - .padding(bottom = 4.dp) - .fillMaxWidth(), - title = "Staged", - allActionTitle = "Unstage all", - actionTitle = "Unstage", - actionIcon = AppIcons.REMOVE_DONE, - selectedEntryType = if (selectedEntryType is DiffEntryType.StagedDiff) selectedEntryType else null, - actionColor = MaterialTheme.colors.error, - actionTextColor = MaterialTheme.colors.onError, - statusEntries = staged, - lazyListState = stagedListState, - onDiffEntrySelected = onStagedDiffEntrySelected, - showSearch = showSearchStaged, - searchFilter = searchFilterStaged, - onSearchFilterToggled = { - statusViewModel.onSearchFilterToggledStaged(it) - }, - onSearchFilterChanged = { - statusViewModel.onSearchFilterChangedStaged(it) - }, - onDiffEntryOptionSelected = { - statusViewModel.unstage(it) - }, - onGenerateContextMenu = { statusEntry -> - statusEntriesContextMenuItems( - statusEntry = statusEntry, - entryType = EntryType.STAGED, - onBlame = { onBlameFile(statusEntry.filePath) }, - onReset = { statusViewModel.resetStaged(statusEntry) }, - onHistory = { onHistoryFile(statusEntry.filePath) }, - ) - }, - onAllAction = { - statusViewModel.unstageAll() - }, - ) - } + if (stageStateUi is StageStateUi.Loaded) { + @Composable + fun staged() { + StagedView( + stageStateUi, + showSearchStaged, + searchFilterStaged, + stagedListState, + selectedEntryType, + onSearchFilterToggled = { statusViewModel.onSearchFilterToggledStaged(it) }, + onDiffEntryOptionSelected = { statusViewModel.unstage(it) }, + onDiffEntrySelected = onStagedDiffEntrySelected, + onSearchFilterChanged = { statusViewModel.onSearchFilterChangedStaged(it) }, + onBlameFile = onBlameFile, + onHistoryFile = onHistoryFile, + onReset = { statusViewModel.resetStaged(it) }, + onDelete = { statusViewModel.deleteFile(it) }, + onAllAction = { statusViewModel.unstageAll() }, + onAlternateShowAsTree = { statusViewModel.alternateShowAsTree() }, + onTreeDirectoryClicked = { statusViewModel.stagedTreeDirectoryClicked(it) }, + onTreeDirectoryAction = { statusViewModel.unstageByDirectory(it) }, + ) + } - val unstagedView: @Composable () -> Unit = { - EntriesList( - modifier = Modifier - .weight(5f) - .padding(bottom = 4.dp) - .fillMaxWidth(), - title = "Unstaged", - actionTitle = "Stage", - actionIcon = AppIcons.DONE, - selectedEntryType = if (selectedEntryType is DiffEntryType.UnstagedDiff) selectedEntryType else null, - actionColor = MaterialTheme.colors.primary, - actionTextColor = MaterialTheme.colors.onPrimary, - statusEntries = unstaged, - lazyListState = unstagedListState, - onDiffEntrySelected = onUnstagedDiffEntrySelected, - showSearch = showSearchUnstaged, - searchFilter = searchFilterUnstaged, - onSearchFilterToggled = { - statusViewModel.onSearchFilterToggledUnstaged(it) - }, - onSearchFilterChanged = { - statusViewModel.onSearchFilterChangedUnstaged(it) - }, - onDiffEntryOptionSelected = { - statusViewModel.stage(it) - }, - onGenerateContextMenu = { statusEntry -> - statusEntriesContextMenuItems( - statusEntry = statusEntry, - entryType = EntryType.UNSTAGED, - onBlame = { onBlameFile(statusEntry.filePath) }, - onHistory = { onHistoryFile(statusEntry.filePath) }, - onReset = { statusViewModel.resetUnstaged(statusEntry) }, - onDelete = { - statusViewModel.deleteFile(statusEntry) - }, - ) - }, - onAllAction = { - statusViewModel.stageAll() - }, - allActionTitle = "Stage all", - ) - } + @Composable + fun unstaged() { + UnstagedView( + stageStateUi, + showSearchUnstaged, + searchFilterUnstaged, + unstagedListState, + selectedEntryType, + onSearchFilterToggled = { statusViewModel.onSearchFilterToggledUnstaged(it) }, + onDiffEntryOptionSelected = { statusViewModel.stage(it) }, + onDiffEntrySelected = onUnstagedDiffEntrySelected, + onSearchFilterChanged = { statusViewModel.onSearchFilterChangedUnstaged(it) }, + onBlameFile = onBlameFile, + onHistoryFile = onHistoryFile, + onReset = { statusViewModel.resetUnstaged(it) }, + onDelete = { statusViewModel.deleteFile(it) }, + onAllAction = { statusViewModel.stageAll() }, + onAlternateShowAsTree = { statusViewModel.alternateShowAsTree() }, + onTreeDirectoryClicked = { statusViewModel.stagedTreeDirectoryClicked(it) }, + onTreeDirectoryAction = { statusViewModel.stageByDirectory(it) }, + ) + } - if (swapUncommittedChanges) { - unstagedView() - stagedView() - } else { - stagedView() - unstagedView() + if (swapUncommittedChanges) { + unstaged() + staged() + } else { + staged() + unstaged() + } } } - Column( + CommitField( + canCommit, + isAmend, + canAmend, + doCommit, + commitMessage, + repositoryState, + isAmenableRebaseInteractive, + stageStateUi.hasUnstagedFiles, + rebaseInteractiveState, + stageStateUi.hasStagedFiles, + isAmendRebaseInteractive, + stageStateUi.haveConflictsBeenSolved, + setCommitMessage = { + setCommitMessage(it) + statusViewModel.updateCommitMessage(it) + }, + onResetRepoState = { + statusViewModel.resetRepoState() + statusViewModel.updateCommitMessage("") + }, + onAbortRebase = { + statusViewModel.abortRebase() + statusViewModel.updateCommitMessage("") + }, + onAmendChecked = { statusViewModel.amend(it) }, + onContinueRebase = { statusViewModel.continueRebase(commitMessage) }, + onSkipRebase = { statusViewModel.skipRebase() }, + ) + } +} + +@Composable +private fun CommitField( + canCommit: Boolean, + isAmend: Boolean, + canAmend: Boolean, + doCommit: () -> Unit, + commitMessage: String, + repositoryState: RepositoryState, + isAmenableRebaseInteractive: Boolean, + hasUnstagedFiles: Boolean, + rebaseInteractiveState: RebaseInteractiveState, + hasStagedFiles: Boolean, + isAmendRebaseInteractive: Boolean, + haveConflictsBeenSolved: Boolean, + setCommitMessage: (String) -> Unit, + onResetRepoState: () -> Unit, + onAbortRebase: () -> Unit, + onContinueRebase: () -> Unit, + onSkipRebase: () -> Unit, + onAmendChecked: (Boolean) -> Unit, +) { + Column( + modifier = Modifier + .height(192.dp) + .fillMaxWidth() + ) { + TextField( modifier = Modifier - .height(192.dp) .fillMaxWidth() - ) { - TextField( - modifier = Modifier - .fillMaxWidth() - .weight(weight = 1f, fill = true) - .onPreviewKeyEvent { keyEvent -> - if (keyEvent.matchesBinding(KeybindingOption.TEXT_ACCEPT) && (canCommit || isAmend && canAmend)) { - doCommit() - true - } else - false - }, - value = commitMessage, - onValueChange = { - commitMessage = it - - statusViewModel.updateCommitMessage(it) + .weight(weight = 1f, fill = true) + .onPreviewKeyEvent { keyEvent -> + if (keyEvent.matchesBinding(KeybindingOption.TEXT_ACCEPT) && (canCommit || isAmend && canAmend)) { + doCommit() + true + } else + false }, - enabled = !repositoryState.isRebasing || isAmenableRebaseInteractive, - label = { - val text = if (repositoryState.isRebasing && !isAmenableRebaseInteractive) { - "Commit message (read-only)" - } else { - "Write your commit message here" - } + value = commitMessage, + onValueChange = setCommitMessage, + enabled = !repositoryState.isRebasing || isAmenableRebaseInteractive, + label = { + val text = if (repositoryState.isRebasing && !isAmenableRebaseInteractive) { + "Commit message (read-only)" + } else { + "Write your commit message here" + } - Text( - text = text, - style = MaterialTheme.typography.body2, - color = MaterialTheme.colors.primaryVariant, - ) - }, - colors = textFieldColors(), - textStyle = MaterialTheme.typography.body1, + Text( + text = text, + style = MaterialTheme.typography.body2, + color = MaterialTheme.colors.primaryVariant, + ) + }, + colors = textFieldColors(), + textStyle = MaterialTheme.typography.body1, + ) + + when { + repositoryState.isMerging -> MergeButtons( + haveConflictsBeenSolved = !hasUnstagedFiles, + onAbort = onResetRepoState, + onMerge = { doCommit() } ) - when { - repositoryState.isMerging -> MergeButtons( - haveConflictsBeenSolved = unstaged.isEmpty(), - onAbort = { - statusViewModel.resetRepoState() - statusViewModel.updateCommitMessage("") - }, - onMerge = { doCommit() } - ) + repositoryState.isRebasing && rebaseInteractiveState is RebaseInteractiveState.ProcessingCommits -> RebasingButtons( + canContinue = hasStagedFiles || hasUnstagedFiles || (isAmenableRebaseInteractive && isAmendRebaseInteractive && commitMessage.isNotEmpty()), + haveConflictsBeenSolved = !hasUnstagedFiles, + onAbort = onAbortRebase, + onContinue = onContinueRebase, + onSkip = onSkipRebase, + isAmendable = rebaseInteractiveState.isCurrentStepAmenable, + isAmend = isAmendRebaseInteractive, + onAmendChecked = onAmendChecked, + ) - repositoryState.isRebasing && rebaseInteractiveState is RebaseInteractiveState.ProcessingCommits -> RebasingButtons( - canContinue = staged.isNotEmpty() || unstaged.isNotEmpty() || (isAmenableRebaseInteractive && isAmendRebaseInteractive && commitMessage.isNotEmpty()), - haveConflictsBeenSolved = unstaged.isEmpty(), - onAbort = { - statusViewModel.abortRebase() - statusViewModel.updateCommitMessage("") - }, - onContinue = { statusViewModel.continueRebase(commitMessage) }, - onSkip = { statusViewModel.skipRebase() }, - isAmendable = rebaseInteractiveState.isCurrentStepAmenable, - isAmend = isAmendRebaseInteractive, - onAmendChecked = { isAmend -> - statusViewModel.amendRebaseInteractive(isAmend) - } - ) + repositoryState.isCherryPicking -> CherryPickingButtons( + haveConflictsBeenSolved = !hasUnstagedFiles, + onAbort = onResetRepoState, + onCommit = { + doCommit() + } + ) - repositoryState.isCherryPicking -> CherryPickingButtons( - haveConflictsBeenSolved = unstaged.isEmpty(), - onAbort = { - statusViewModel.resetRepoState() - statusViewModel.updateCommitMessage("") - }, - onCommit = { - doCommit() - } - ) + repositoryState.isReverting -> RevertingButtons( + haveConflictsBeenSolved = haveConflictsBeenSolved, + canCommit = commitMessage.isNotBlank(), + onAbort = onResetRepoState, + onCommit = { + doCommit() + } + ) - repositoryState.isReverting -> RevertingButtons( - haveConflictsBeenSolved = unstaged.none { it.statusType == StatusType.CONFLICTING }, - canCommit = commitMessage.isNotBlank(), - onAbort = { - statusViewModel.resetRepoState() - statusViewModel.updateCommitMessage("") - }, - onCommit = { - doCommit() - } - ) - - else -> UncommittedChangesButtons( - canCommit = canCommit, - canAmend = canAmend, - isAmend = isAmend, - onAmendChecked = { isAmend -> - statusViewModel.amend(isAmend) - }, - onCommit = doCommit, - ) - } + else -> UncommittedChangesButtons( + canCommit = canCommit, + canAmend = canAmend, + isAmend = isAmend, + onAmendChecked = onAmendChecked, + onCommit = doCommit, + ) } } +} +@Composable +fun ColumnScope.StagedView( + stageStateUi: StageStateUi.Loaded, + showSearchUnstaged: Boolean, + searchFilterUnstaged: TextFieldValue, + stagedListState: LazyListState, + selectedEntryType: DiffEntryType?, + onSearchFilterToggled: (Boolean) -> Unit, + onDiffEntryOptionSelected: (StatusEntry) -> Unit, + onDiffEntrySelected: (StatusEntry) -> Unit, + onSearchFilterChanged: (TextFieldValue) -> Unit, + onBlameFile: (String) -> Unit, + onHistoryFile: (String) -> Unit, + onReset: (StatusEntry) -> Unit, + onDelete: (StatusEntry) -> Unit, + onAllAction: () -> Unit, + onAlternateShowAsTree: () -> Unit, + onTreeDirectoryClicked: (String) -> Unit, + onTreeDirectoryAction: (String) -> Unit, +) { + val title = "Staged" + val actionTitle = "Untage" + val allActionTitle = "Unstage all" + val actionColor = MaterialTheme.colors.error + val actionTextColor = MaterialTheme.colors.onError + val actionIcon = AppIcons.REMOVE_DONE + + this.NeutralView( + title = title, + actionTitle = actionTitle, + allActionTitle = allActionTitle, + actionColor = actionColor, + actionTextColor = actionTextColor, + actionIcon = actionIcon, + entryType = EntryType.STAGED, + stageStateUi = stageStateUi, + showSearchUnstaged = showSearchUnstaged, + searchFilterUnstaged = searchFilterUnstaged, + listState = stagedListState, + selectedEntryType = selectedEntryType, + onSearchFilterToggled = onSearchFilterToggled, + onDiffEntryOptionSelected = onDiffEntryOptionSelected, + onDiffEntrySelected = onDiffEntrySelected, + onSearchFilterChanged = onSearchFilterChanged, + onBlameFile = onBlameFile, + onHistoryFile = onHistoryFile, + onReset = onReset, + onDelete = onDelete, + onAllAction = onAllAction, + onAlternateShowAsTree = onAlternateShowAsTree, + onTreeDirectoryClicked = onTreeDirectoryClicked, + onTreeDirectoryAction = onTreeDirectoryAction, + onTreeEntries = { it.staged }, + onListEntries = { it.staged }, + onGetSelectedEntry = { if (selectedEntryType is DiffEntryType.StagedDiff) selectedEntryType else null } + ) +} + +@Composable +fun ColumnScope.UnstagedView( + stageStateUi: StageStateUi.Loaded, + showSearchUnstaged: Boolean, + searchFilterUnstaged: TextFieldValue, + unstagedListState: LazyListState, + selectedEntryType: DiffEntryType?, + onSearchFilterToggled: (Boolean) -> Unit, + onDiffEntryOptionSelected: (StatusEntry) -> Unit, + onDiffEntrySelected: (StatusEntry) -> Unit, + onSearchFilterChanged: (TextFieldValue) -> Unit, + onBlameFile: (String) -> Unit, + onHistoryFile: (String) -> Unit, + onReset: (StatusEntry) -> Unit, + onDelete: (StatusEntry) -> Unit, + onAllAction: () -> Unit, + onAlternateShowAsTree: () -> Unit, + onTreeDirectoryClicked: (String) -> Unit, + onTreeDirectoryAction: (String) -> Unit, +) { + val title = "Unstaged" + val actionTitle = "Stage" + val allActionTitle = "Stage all" + val actionColor = MaterialTheme.colors.primary + val actionTextColor = MaterialTheme.colors.onPrimary + val actionIcon = AppIcons.DONE + + this.NeutralView( + title = title, + actionTitle = actionTitle, + allActionTitle = allActionTitle, + actionColor = actionColor, + actionTextColor = actionTextColor, + actionIcon = actionIcon, + entryType = EntryType.UNSTAGED, + stageStateUi = stageStateUi, + showSearchUnstaged = showSearchUnstaged, + searchFilterUnstaged = searchFilterUnstaged, + listState = unstagedListState, + selectedEntryType = selectedEntryType, + onSearchFilterToggled = onSearchFilterToggled, + onDiffEntryOptionSelected = onDiffEntryOptionSelected, + onDiffEntrySelected = onDiffEntrySelected, + onSearchFilterChanged = onSearchFilterChanged, + onBlameFile = onBlameFile, + onHistoryFile = onHistoryFile, + onReset = onReset, + onDelete = onDelete, + onAllAction = onAllAction, + onAlternateShowAsTree = onAlternateShowAsTree, + onTreeDirectoryClicked = onTreeDirectoryClicked, + onTreeDirectoryAction = onTreeDirectoryAction, + onTreeEntries = { it.unstaged }, + onListEntries = { it.unstaged }, + onGetSelectedEntry = { if (selectedEntryType is DiffEntryType.UnstagedDiff) selectedEntryType else null } + ) +} + +@Composable +fun ColumnScope.NeutralView( + title: String, + actionTitle: String, + allActionTitle: String, + actionColor: Color, + actionTextColor: Color, + actionIcon: String, + entryType: EntryType, + stageStateUi: StageStateUi.Loaded, + showSearchUnstaged: Boolean, + searchFilterUnstaged: TextFieldValue, + listState: LazyListState, + selectedEntryType: DiffEntryType?, + onTreeEntries: (StageStateUi.TreeLoaded) -> List>, + onListEntries: (StageStateUi.ListLoaded) -> List, + onSearchFilterToggled: (Boolean) -> Unit, + onDiffEntryOptionSelected: (StatusEntry) -> Unit, + onDiffEntrySelected: (StatusEntry) -> Unit, + onSearchFilterChanged: (TextFieldValue) -> Unit, + onBlameFile: (String) -> Unit, + onHistoryFile: (String) -> Unit, + onReset: (StatusEntry) -> Unit, + onDelete: (StatusEntry) -> Unit, + onAllAction: () -> Unit, + onAlternateShowAsTree: () -> Unit, + onTreeDirectoryClicked: (String) -> Unit, + onTreeDirectoryAction: (String) -> Unit, + onGetSelectedEntry: () -> DiffEntryType?, +) { + val modifier = Modifier + .weight(5f) + .padding(bottom = 4.dp) + .fillMaxWidth() + + if (stageStateUi is StageStateUi.TreeLoaded) { + TreeEntriesList( + modifier = modifier, + title = title, + actionTitle = actionTitle, + actionColor = actionColor, + actionTextColor = actionTextColor, + actionIcon = actionIcon, + showSearch = showSearchUnstaged, + searchFilter = searchFilterUnstaged, + onSearchFilterToggled = onSearchFilterToggled, + onSearchFilterChanged = onSearchFilterChanged, + statusEntries = onTreeEntries(stageStateUi), + lazyListState = listState, + onDiffEntrySelected = onDiffEntrySelected, + onDiffEntryOptionSelected = onDiffEntryOptionSelected, + onGenerateContextMenu = { statusEntry -> + statusEntriesContextMenuItems( + statusEntry = statusEntry, + entryType = entryType, + onBlame = { onBlameFile(statusEntry.filePath) }, + onHistory = { onHistoryFile(statusEntry.filePath) }, + onReset = { onReset(statusEntry) }, + onDelete = { onDelete(statusEntry) }, + ) + }, + onAllAction = onAllAction, + onTreeDirectoryClicked = { onTreeDirectoryClicked(it.fullPath) }, + allActionTitle = allActionTitle, + selectedEntryType = if (selectedEntryType is DiffEntryType.UnstagedDiff) selectedEntryType else null, + onAlternateShowAsTree = onAlternateShowAsTree, + onGenerateDirectoryContextMenu = { dir -> + statusDirEntriesContextMenuItems( + entryType = entryType, + onStageChanges = { onTreeDirectoryAction(dir.fullPath) }, + onDiscardDirectoryChanges = {}, + ) + } + ) + } else if (stageStateUi is StageStateUi.ListLoaded) { + EntriesList( + modifier = modifier, + title = title, + actionTitle = actionTitle, + actionColor = actionColor, + actionTextColor = actionTextColor, + actionIcon = actionIcon, + showSearch = showSearchUnstaged, + searchFilter = searchFilterUnstaged, + onSearchFilterToggled = onSearchFilterToggled, + onSearchFilterChanged = onSearchFilterChanged, + statusEntries = onListEntries(stageStateUi), + lazyListState = listState, + onDiffEntrySelected = onDiffEntrySelected, + onDiffEntryOptionSelected = onDiffEntryOptionSelected, + onGenerateContextMenu = { statusEntry -> + statusEntriesContextMenuItems( + statusEntry = statusEntry, + entryType = entryType, + onBlame = { onBlameFile(statusEntry.filePath) }, + onHistory = { onHistoryFile(statusEntry.filePath) }, + onReset = { onReset(statusEntry) }, + onDelete = { onDelete(statusEntry) }, + ) + }, + onAllAction = onAllAction, + allActionTitle = allActionTitle, + selectedEntryType = onGetSelectedEntry(), + onAlternateShowAsTree = onAlternateShowAsTree, + ) + } } @Composable @@ -591,21 +794,163 @@ private fun EntriesList( onDiffEntryOptionSelected: (StatusEntry) -> Unit, onGenerateContextMenu: (StatusEntry) -> List, onAllAction: () -> Unit, + onAlternateShowAsTree: () -> Unit, allActionTitle: String, selectedEntryType: DiffEntryType?, ) { + Column( + modifier = modifier + ) { + EntriesHeader( + title = title, + actionColor = actionColor, + allActionTitle = allActionTitle, + actionTextColor = actionTextColor, + actionIcon = actionIcon, + onAllAction = onAllAction, + onAlternateShowAsTree = onAlternateShowAsTree, + searchFilter = searchFilter, + onSearchFilterChanged = onSearchFilterChanged, + onSearchFilterToggled = onSearchFilterToggled, + showAsTree = false, + showSearch = showSearch, + ) + + + ScrollableLazyColumn( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colors.background), + state = lazyListState, + ) { + items(statusEntries, key = { it.filePath }) { statusEntry -> + val isEntrySelected = selectedEntryType != null && + selectedEntryType is DiffEntryType.UncommittedDiff && // Added for smartcast + selectedEntryType.statusEntry == statusEntry + UncommittedFileEntry( + statusEntry = statusEntry, + isSelected = isEntrySelected, + actionTitle = actionTitle, + actionColor = actionColor, + showDirectory = true, + onClick = { + onDiffEntrySelected(statusEntry) + }, + onButtonClick = { + onDiffEntryOptionSelected(statusEntry) + }, + onGenerateContextMenu = onGenerateContextMenu, + ) + } + } + } +} + +@Composable +private fun TreeEntriesList( + modifier: Modifier, + title: String, + actionTitle: String, + actionColor: Color, + actionTextColor: Color, + actionIcon: String, + showSearch: Boolean, + searchFilter: TextFieldValue, + onSearchFilterToggled: (Boolean) -> Unit, + onSearchFilterChanged: (TextFieldValue) -> Unit, + statusEntries: List>, + lazyListState: LazyListState, + onDiffEntrySelected: (StatusEntry) -> Unit, + onDiffEntryOptionSelected: (StatusEntry) -> Unit, + onGenerateContextMenu: (StatusEntry) -> List, + onGenerateDirectoryContextMenu: (TreeItem.Dir) -> List, + onAllAction: () -> Unit, + onAlternateShowAsTree: () -> Unit, + onTreeDirectoryClicked: (TreeItem.Dir) -> Unit, + allActionTitle: String, + selectedEntryType: DiffEntryType?, +) { + Column( + modifier = modifier + ) { + EntriesHeader( + title = title, + actionColor = actionColor, + allActionTitle = allActionTitle, + actionTextColor = actionTextColor, + actionIcon = actionIcon, + onAllAction = onAllAction, + onAlternateShowAsTree = onAlternateShowAsTree, + searchFilter = searchFilter, + onSearchFilterChanged = onSearchFilterChanged, + onSearchFilterToggled = onSearchFilterToggled, + showAsTree = true, + showSearch = showSearch, + ) + + ScrollableLazyColumn( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colors.background), + state = lazyListState, + ) { + items(statusEntries, key = { it.fullPath }) { treeEntry -> + val isEntrySelected = treeEntry is TreeItem.File && + selectedEntryType != null && + selectedEntryType is DiffEntryType.UncommittedDiff && // Added for smartcast + selectedEntryType.statusEntry == treeEntry.data + + UncommittedTreeItemEntry( + treeEntry, + isSelected = isEntrySelected, + actionTitle = actionTitle, + actionColor = actionColor, + onClick = { + if (treeEntry is TreeItem.File) { + onDiffEntrySelected(treeEntry.data) + } else if (treeEntry is TreeItem.Dir) { + onTreeDirectoryClicked(treeEntry) + } + }, + onButtonClick = { + if (treeEntry is TreeItem.File) { + onDiffEntryOptionSelected(treeEntry.data) + } + }, + onGenerateContextMenu = onGenerateContextMenu, + onGenerateDirectoryContextMenu = onGenerateDirectoryContextMenu, + ) + } + } + } +} + +@Composable +fun EntriesHeader( + title: String, + showAsTree: Boolean, + showSearch: Boolean, + allActionTitle: String, + actionIcon: String, + actionColor: Color, + actionTextColor: Color, + onAllAction: () -> Unit, + onAlternateShowAsTree: () -> Unit, + onSearchFilterToggled: (Boolean) -> Unit, + searchFilter: TextFieldValue, + onSearchFilterChanged: (TextFieldValue) -> Unit, +) { + val searchFocusRequester = remember { FocusRequester() } - val headerHoverInteraction = remember { MutableInteractionSource() } - val isHeaderHovered by headerHoverInteraction.collectIsHoveredAsState() /** * State used to prevent the text field from getting the focus when returning from another tab */ var requestFocus by remember { mutableStateOf(false) } - Column( - modifier = modifier - ) { + val headerHoverInteraction = remember { MutableInteractionSource() } + val isHeaderHovered by headerHoverInteraction.collectIsHoveredAsState() + Column { Row( modifier = Modifier .height(34.dp) @@ -626,6 +971,20 @@ private fun EntriesList( maxLines = 1, ) + IconButton( + onClick = { + onAlternateShowAsTree() + }, + modifier = Modifier.handOnHover() + ) { + Icon( + painter = painterResource(if (showAsTree) AppIcons.LIST else AppIcons.TREE), + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colors.onBackground, + ) + } + IconButton( onClick = { onSearchFilterToggled(!showSearch) @@ -654,6 +1013,8 @@ private fun EntriesList( ) } + + if (showSearch) { SearchTextField( searchFilter = searchFilter, @@ -670,124 +1031,135 @@ private fun EntriesList( } } - ScrollableLazyColumn( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colors.background), - state = lazyListState, - ) { - items(statusEntries, key = { it.filePath }) { statusEntry -> - val isEntrySelected = selectedEntryType != null && - selectedEntryType is DiffEntryType.UncommittedDiff && // Added for smartcast - selectedEntryType.statusEntry == statusEntry - FileEntry( - statusEntry = statusEntry, - isSelected = isEntrySelected, - actionTitle = actionTitle, - actionColor = actionColor, - onClick = { - onDiffEntrySelected(statusEntry) - }, - onButtonClick = { - onDiffEntryOptionSelected(statusEntry) - }, - onGenerateContextMenu = onGenerateContextMenu, - ) - } - } } } @Composable -private fun FileEntry( +private fun UncommittedFileEntry( statusEntry: StatusEntry, isSelected: Boolean, + showDirectory: Boolean, + actionTitle: String, + actionColor: Color, + onClick: () -> Unit, + onButtonClick: () -> Unit, + depth: Int = 0, + onGenerateContextMenu: (StatusEntry) -> List, +) { + FileEntry( + icon = statusEntry.icon, + depth = depth, + iconColor = statusEntry.iconColor, + parentDirectoryPath = if (showDirectory) statusEntry.parentDirectoryPath else "", + fileName = statusEntry.fileName, + isSelected = isSelected, + onClick = onClick, + onDoubleClick = onButtonClick, + onGenerateContextMenu = { onGenerateContextMenu(statusEntry) }, + trailingAction = { isHovered -> + AnimatedVisibility( + modifier = Modifier + .align(Alignment.CenterEnd), + visible = isHovered, + enter = fadeIn(), + exit = fadeOut(), + ) { + SecondaryButton( + onClick = onButtonClick, + text = actionTitle, + backgroundButton = actionColor, + modifier = Modifier + .padding(horizontal = 16.dp), + ) + } + } + ) +} + +@Composable +private fun TreeFileEntry( + fileEntry: TreeItem.File, + isSelected: Boolean, + actionTitle: String, + actionColor: Color, + onClick: () -> Unit, + onDoubleClick: () -> Unit, + onGenerateContextMenu: (StatusEntry) -> List, +) { + UncommittedFileEntry( + statusEntry = fileEntry.data, + isSelected = isSelected, + showDirectory = false, + actionTitle = actionTitle, + actionColor = actionColor, + onClick = onClick, + onButtonClick = onDoubleClick, + depth = fileEntry.depth, + onGenerateContextMenu = onGenerateContextMenu, + ) +} + +@Composable +private fun UncommittedTreeItemEntry( + entry: TreeItem, + isSelected: Boolean, actionTitle: String, actionColor: Color, onClick: () -> Unit, onButtonClick: () -> Unit, onGenerateContextMenu: (StatusEntry) -> List, + onGenerateDirectoryContextMenu: (TreeItem.Dir) -> List, ) { - val hoverInteraction = remember { MutableInteractionSource() } - val isHovered by hoverInteraction.collectIsHoveredAsState() + when (entry) { + is TreeItem.File -> TreeFileEntry( + entry, + isSelected, + actionTitle, + actionColor, + onClick, + onButtonClick, + onGenerateContextMenu, + ) - Box( - modifier = Modifier - .handMouseClickable { onClick() } - .onDoubleClick(onButtonClick) - .fillMaxWidth() - .hoverable(hoverInteraction) - ) { - ContextMenu( - items = { - onGenerateContextMenu(statusEntry) - }, - ) { - Row( - modifier = Modifier - .height(40.dp) - .fillMaxWidth() - .backgroundIf(isSelected, MaterialTheme.colors.backgroundSelected), - verticalAlignment = Alignment.CenterVertically, - ) { - - Icon( - imageVector = statusEntry.icon, - contentDescription = null, - modifier = Modifier - .padding(horizontal = 8.dp) - .size(16.dp), - tint = statusEntry.iconColor, - ) - - if (statusEntry.parentDirectoryPath.isNotEmpty()) { - Text( - text = statusEntry.parentDirectoryPath.removeSuffix("/"), - modifier = Modifier.weight(1f, fill = false), - maxLines = 1, - softWrap = false, - style = MaterialTheme.typography.body2, - overflow = TextOverflow.Ellipsis, - color = MaterialTheme.colors.onBackgroundSecondary, - ) - - Text( - text = "/", - maxLines = 1, - softWrap = false, - style = MaterialTheme.typography.body2, - overflow = TextOverflow.Visible, - color = MaterialTheme.colors.onBackgroundSecondary, - ) - } - Text( - text = statusEntry.fileName, - maxLines = 1, - softWrap = false, - modifier = Modifier.padding(end = 16.dp), - style = MaterialTheme.typography.body2, - color = MaterialTheme.colors.onBackground, - ) - } - } - AnimatedVisibility( - modifier = Modifier - .align(Alignment.CenterEnd), - visible = isHovered, - enter = fadeIn(), - exit = fadeOut(), - ) { - SecondaryButton( - onClick = onButtonClick, - text = actionTitle, - backgroundButton = actionColor, - modifier = Modifier - .padding(horizontal = 16.dp), - ) - } + is TreeItem.Dir -> DirectoryEntry( + entry.displayName, + onClick, + depth = entry.depth, + onGenerateContextMenu = { onGenerateDirectoryContextMenu(entry) }, + ) } } +//@Composable +//private fun TreeItemEntry( +// entry: TreeItem, +// isSelected: Boolean, +// actionTitle: String, +// actionColor: Color, +// onClick: () -> Unit, +// onButtonClick: () -> Unit, +// onGenerateContextMenu: (StatusEntry) -> List, +// onGenerateDirectoryContextMenu: (TreeItem.Dir) -> List, +//) { +// when (entry) { +// is TreeItem.File -> TreeFileEntry( +// entry, +// isSelected, +// actionTitle, +// actionColor, +// onClick, +// onButtonClick, +// onGenerateContextMenu, +// ) +// +// is TreeItem.Dir -> TreeDirEntry( +// entry, +// onClick, +// onGenerateDirectoryContextMenu, +// ) +// } +//} + internal fun placeRightOrBottom( totalSize: Int, size: IntArray, diff --git a/src/main/kotlin/com/jetpackduba/gitnuro/ui/components/FileEntry.kt b/src/main/kotlin/com/jetpackduba/gitnuro/ui/components/FileEntry.kt new file mode 100644 index 0000000..a3016e7 --- /dev/null +++ b/src/main/kotlin/com/jetpackduba/gitnuro/ui/components/FileEntry.kt @@ -0,0 +1,161 @@ +package com.jetpackduba.gitnuro.ui.components + +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.layout.* +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.jetpackduba.gitnuro.AppIcons +import com.jetpackduba.gitnuro.extensions.backgroundIf +import com.jetpackduba.gitnuro.extensions.handMouseClickable +import com.jetpackduba.gitnuro.extensions.onDoubleClick +import com.jetpackduba.gitnuro.theme.backgroundSelected +import com.jetpackduba.gitnuro.theme.onBackgroundSecondary +import com.jetpackduba.gitnuro.ui.context_menu.ContextMenu +import com.jetpackduba.gitnuro.ui.context_menu.ContextMenuElement + +private const val TREE_START_PADDING = 12 + +@Composable +fun FileEntry( + icon: ImageVector, + iconColor: Color, + parentDirectoryPath: String, + fileName: String, + isSelected: Boolean, + onClick: () -> Unit, + onDoubleClick: () -> Unit, + depth: Int = 0, + onGenerateContextMenu: () -> List, + trailingAction: (@Composable BoxScope.(isHovered: Boolean) -> Unit)?, +) { + FileEntry( + icon = rememberVectorPainter(icon), + iconColor = iconColor, + parentDirectoryPath = parentDirectoryPath, + fileName = fileName, + isSelected = isSelected, + onClick = onClick, + onDoubleClick = onDoubleClick, + depth = depth, + onGenerateContextMenu = onGenerateContextMenu, + trailingAction = trailingAction + ) +} + +@Composable +fun FileEntry( + icon: Painter, + iconColor: Color, + parentDirectoryPath: String, + fileName: String, + isSelected: Boolean, + onClick: () -> Unit, + onDoubleClick: () -> Unit, + depth: Int = 0, + onGenerateContextMenu: () -> List, + trailingAction: (@Composable BoxScope.(isHovered: Boolean) -> Unit)?, +) { + val hoverInteraction = remember { MutableInteractionSource() } + val isHovered by hoverInteraction.collectIsHoveredAsState() + + Box( + modifier = Modifier + .handMouseClickable { onClick() } + .onDoubleClick(onDoubleClick) + .fillMaxWidth() + .hoverable(hoverInteraction) + ) { + ContextMenu( + items = { + onGenerateContextMenu() + }, + ) { + Row( + modifier = Modifier + .height(40.dp) + .fillMaxWidth() + .backgroundIf(isSelected, MaterialTheme.colors.backgroundSelected) + .padding(start = (TREE_START_PADDING * depth).dp), + verticalAlignment = Alignment.CenterVertically, + ) { + + Icon( + painter = icon, + contentDescription = null, + modifier = Modifier + .padding(horizontal = 8.dp) + .size(16.dp), + tint = iconColor, + ) + + if (parentDirectoryPath.isNotEmpty()) { + Text( + text = parentDirectoryPath.removeSuffix("/"), + modifier = Modifier.weight(1f, fill = false), + maxLines = 1, + softWrap = false, + style = MaterialTheme.typography.body2, + overflow = TextOverflow.Ellipsis, + color = MaterialTheme.colors.onBackgroundSecondary, + ) + + Text( + text = "/", + maxLines = 1, + softWrap = false, + style = MaterialTheme.typography.body2, + overflow = TextOverflow.Visible, + color = MaterialTheme.colors.onBackgroundSecondary, + ) + } + Text( + text = fileName, + maxLines = 1, + softWrap = false, + modifier = Modifier.padding(end = 16.dp), + style = MaterialTheme.typography.body2, + color = MaterialTheme.colors.onBackground, + ) + } + } + + trailingAction?.invoke(this, isHovered) + } +} + +@Composable +fun DirectoryEntry( + dirName: String, + onClick: () -> Unit, + depth: Int = 0, + onGenerateContextMenu: () -> List, +) { + + FileEntry( + icon = painterResource(AppIcons.FOLDER), + iconColor = MaterialTheme.colors.onBackground, + isSelected = false, + onClick = onClick, + onDoubleClick = {}, + parentDirectoryPath = "", + fileName = dirName, + depth = depth, + onGenerateContextMenu = onGenerateContextMenu, + trailingAction = null, + ) +} \ No newline at end of file diff --git a/src/main/kotlin/com/jetpackduba/gitnuro/ui/context_menu/ContextMenu.kt b/src/main/kotlin/com/jetpackduba/gitnuro/ui/context_menu/ContextMenu.kt index ed26fe2..fd10ef1 100644 --- a/src/main/kotlin/com/jetpackduba/gitnuro/ui/context_menu/ContextMenu.kt +++ b/src/main/kotlin/com/jetpackduba/gitnuro/ui/context_menu/ContextMenu.kt @@ -220,7 +220,7 @@ fun showPopup(x: Int, y: Int, contextMenuElements: List, onD fun Separator() { Box( modifier = Modifier - .padding(horizontal = 16.dp) + .padding(horizontal = 16.dp, vertical = 8.dp) .fillMaxWidth() .height(1.dp) .background(MaterialTheme.colors.onBackground.copy(alpha = 0.4f)) diff --git a/src/main/kotlin/com/jetpackduba/gitnuro/ui/context_menu/StatusDirEntriesContextMenu.kt b/src/main/kotlin/com/jetpackduba/gitnuro/ui/context_menu/StatusDirEntriesContextMenu.kt new file mode 100644 index 0000000..6c90c70 --- /dev/null +++ b/src/main/kotlin/com/jetpackduba/gitnuro/ui/context_menu/StatusDirEntriesContextMenu.kt @@ -0,0 +1,40 @@ +package com.jetpackduba.gitnuro.ui.context_menu + +import androidx.compose.ui.res.painterResource +import com.jetpackduba.gitnuro.AppIcons + +fun statusDirEntriesContextMenuItems( + entryType: EntryType, + onStageChanges: () -> Unit, + onDiscardDirectoryChanges: () -> Unit, +): List { + return mutableListOf().apply { + + val (text, icon) = if (entryType == EntryType.STAGED) { + "Unstage changes in the directory" to AppIcons.REMOVE_DONE + } else { + "Stage changes in the directory" to AppIcons.DONE + } + + add( + ContextMenuElement.ContextTextEntry( + label = text, + icon = { painterResource(icon) }, + onClick = onStageChanges, + ) + ) + + + if (entryType == EntryType.UNSTAGED) { + add(ContextMenuElement.ContextSeparator) + + add( + ContextMenuElement.ContextTextEntry( + label = "Discard changes in the directory", + icon = { painterResource(AppIcons.UNDO) }, + onClick = onDiscardDirectoryChanges, + ) + ) + } + } +} diff --git a/src/main/kotlin/com/jetpackduba/gitnuro/ui/tree_files/Tree.kt b/src/main/kotlin/com/jetpackduba/gitnuro/ui/tree_files/Tree.kt new file mode 100644 index 0000000..4c3256c --- /dev/null +++ b/src/main/kotlin/com/jetpackduba/gitnuro/ui/tree_files/Tree.kt @@ -0,0 +1,70 @@ +package com.jetpackduba.gitnuro.ui.tree_files + +import com.jetpackduba.gitnuro.system.systemSeparator + +fun entriesToTreeEntry( + entries: List, + treeContractedDirs: List, + onGetEntryPath: (T) -> String, +): List> { + return entries + .asSequence() + .map { entry -> + val filePath = onGetEntryPath(entry) + val parts = filePath.split(systemSeparator) + + parts.mapIndexed { index, partName -> + if (index == parts.lastIndex) { + val isParentContracted = treeContractedDirs.none { contractedDir -> + filePath.startsWith(contractedDir + systemSeparator) + } + + if (isParentContracted) { + TreeItem.File(entry, partName, filePath, index) + } else { + null + } + } else { + val dirPath = parts.slice(0..index).joinToString(systemSeparator) + val isParentDirectoryContracted = treeContractedDirs.any { contractedDir -> + dirPath.startsWith(contractedDir + systemSeparator) && + dirPath != contractedDir + } + val isExactDirectoryContracted = treeContractedDirs.any { contractedDir -> + dirPath == contractedDir + } + + when { + isParentDirectoryContracted -> null + isExactDirectoryContracted -> TreeItem.Dir(false, partName, dirPath, index) + else -> TreeItem.Dir(true, partName, dirPath, index) + } + } + } + } + .flatten() + .filterNotNull() + .distinct() + .sortedBy { it.fullPath } + .toList() +} + +sealed interface TreeItem { + val fullPath: String + val displayName: String + val depth: Int + + data class Dir( + val isExpanded: Boolean, + override val displayName: String, + override val fullPath: String, + override val depth: Int + ) : TreeItem + + data class File( + val data: T, + override val displayName: String, + override val fullPath: String, + override val depth: Int + ) : TreeItem +} diff --git a/src/main/kotlin/com/jetpackduba/gitnuro/viewmodels/CommitChangesViewModel.kt b/src/main/kotlin/com/jetpackduba/gitnuro/viewmodels/CommitChangesViewModel.kt index 86da38d..b00fa9d 100644 --- a/src/main/kotlin/com/jetpackduba/gitnuro/viewmodels/CommitChangesViewModel.kt +++ b/src/main/kotlin/com/jetpackduba/gitnuro/viewmodels/CommitChangesViewModel.kt @@ -10,6 +10,9 @@ import com.jetpackduba.gitnuro.extensions.lowercaseContains import com.jetpackduba.gitnuro.git.RefreshType import com.jetpackduba.gitnuro.git.TabState import com.jetpackduba.gitnuro.git.diff.GetCommitDiffEntriesUseCase +import com.jetpackduba.gitnuro.preferences.AppSettings +import com.jetpackduba.gitnuro.ui.tree_files.TreeItem +import com.jetpackduba.gitnuro.ui.tree_files.entriesToTreeEntry import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.* import org.eclipse.jgit.diff.DiffEntry @@ -21,6 +24,7 @@ private const val MIN_TIME_IN_MS_TO_SHOW_LOAD = 300L class CommitChangesViewModel @Inject constructor( private val tabState: TabState, private val getCommitDiffEntriesUseCase: GetCommitDiffEntriesUseCase, + private val appSettings: AppSettings, tabScope: CoroutineScope, ) { private val _showSearch = MutableStateFlow(false) @@ -33,26 +37,48 @@ class CommitChangesViewModel @Inject constructor( LazyListState(firstVisibleItemIndex = 0, firstVisibleItemScrollOffset = 0) ) - val textScroll = MutableStateFlow( - ScrollState(0) - ) + val textScroll = MutableStateFlow(ScrollState(0)) + + val showAsTree = appSettings.showChangesAsTreeFlow + private val treeContractedDirectories = MutableStateFlow(emptyList()) private val _commitChangesState = MutableStateFlow(CommitChangesState.Loading) - val commitChangesState: StateFlow = + + private val commitChangesFiltered = combine(_commitChangesState, _showSearch, _searchFilter) { state, showSearch, filter -> - if (state is CommitChangesState.Loaded) { - if (showSearch && filter.text.isNotBlank()) { - state.copy(changesFiltered = state.changes.filter { it.filePath.lowercaseContains(filter.text) }) - } else { - state - } + if (state is CommitChangesState.Loaded && showSearch && filter.text.isNotBlank()) { + state.copy(changes = state.changes.filter { it.filePath.lowercaseContains(filter.text) }) } else { state } - }.stateIn( + } + + val commitChangesStateUi: StateFlow = combine( + commitChangesFiltered, + showAsTree, + treeContractedDirectories, + ) { commitState, showAsTree, contractedDirs -> + when (commitState) { + CommitChangesState.Loading -> CommitChangesStateUi.Loading + is CommitChangesState.Loaded -> { + if (showAsTree) { + CommitChangesStateUi.TreeLoaded( + commit = commitState.commit, + changes = entriesToTreeEntry(commitState.changes, contractedDirs) { it.filePath } + ) + } else { + CommitChangesStateUi.ListLoaded( + commit = commitState.commit, + changes = commitState.changes + ) + } + } + } + } + .stateIn( tabScope, SharingStarted.Lazily, - CommitChangesState.Loading + CommitChangesStateUi.Loading ) @@ -92,7 +118,7 @@ class CommitChangesViewModel @Inject constructor( } } - _commitChangesState.value = CommitChangesState.Loaded(commit, changes, changes) + _commitChangesState.value = CommitChangesState.Loaded(commit, changes) } } @@ -106,6 +132,20 @@ class CommitChangesViewModel @Inject constructor( } } + fun alternateShowAsTree() { + appSettings.showChangesAsTree = !appSettings.showChangesAsTree + } + + fun onDirectoryClicked(directoryPath: String) { + val contractedDirectories = treeContractedDirectories.value + + if (contractedDirectories.contains(directoryPath)) { + treeContractedDirectories.value -= directoryPath + } else { + treeContractedDirectories.value += directoryPath + } + } + fun onSearchFilterToggled(visible: Boolean) { _showSearch.value = visible } @@ -115,9 +155,22 @@ class CommitChangesViewModel @Inject constructor( } } -sealed interface CommitChangesState { +private sealed interface CommitChangesState { data object Loading : CommitChangesState - data class Loaded(val commit: RevCommit, val changes: List, val changesFiltered: List) : + data class Loaded(val commit: RevCommit, val changes: List) : CommitChangesState } +sealed interface CommitChangesStateUi { + data object Loading : CommitChangesStateUi + + sealed interface Loaded : CommitChangesStateUi { + val commit: RevCommit + } + data class ListLoaded(override val commit: RevCommit, val changes: List) : + Loaded + + data class TreeLoaded(override val commit: RevCommit, val changes: List>) : + Loaded +} + diff --git a/src/main/kotlin/com/jetpackduba/gitnuro/viewmodels/StatusViewModel.kt b/src/main/kotlin/com/jetpackduba/gitnuro/viewmodels/StatusViewModel.kt index 8188e6b..0bd5571 100644 --- a/src/main/kotlin/com/jetpackduba/gitnuro/viewmodels/StatusViewModel.kt +++ b/src/main/kotlin/com/jetpackduba/gitnuro/viewmodels/StatusViewModel.kt @@ -21,6 +21,8 @@ import com.jetpackduba.gitnuro.git.repository.ResetRepositoryStateUseCase import com.jetpackduba.gitnuro.git.workspace.* import com.jetpackduba.gitnuro.models.AuthorInfo import com.jetpackduba.gitnuro.preferences.AppSettings +import com.jetpackduba.gitnuro.ui.tree_files.TreeItem +import com.jetpackduba.gitnuro.ui.tree_files.entriesToTreeEntry import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.* @@ -38,6 +40,8 @@ class StatusViewModel @Inject constructor( private val tabState: TabState, private val stageEntryUseCase: StageEntryUseCase, private val unstageEntryUseCase: UnstageEntryUseCase, + private val stageByDirectoryUseCase: StageByDirectoryUseCase, + private val unstageByDirectoryUseCase: UnstageByDirectoryUseCase, private val resetEntryUseCase: DiscardEntryUseCase, private val stageAllUseCase: StageAllUseCase, private val unstageAllUseCase: UnstageAllUseCase, @@ -55,8 +59,8 @@ class StatusViewModel @Inject constructor( private val saveAuthorUseCase: SaveAuthorUseCase, private val sharedRepositoryStateManager: SharedRepositoryStateManager, private val getSpecificCommitMessageUseCase: GetSpecificCommitMessageUseCase, + private val appSettings: AppSettings, tabScope: CoroutineScope, - appSettings: AppSettings, ) { private val _showSearchUnstaged = MutableStateFlow(false) val showSearchUnstaged: StateFlow = _showSearchUnstaged @@ -73,9 +77,11 @@ class StatusViewModel @Inject constructor( val swapUncommittedChanges = appSettings.swapUncommittedChangesFlow val rebaseInteractiveState = sharedRepositoryStateManager.rebaseInteractiveState + private val treeContractedDirectories = MutableStateFlow(emptyList()) + private val showAsTree = appSettings.showChangesAsTreeFlow private val _stageState = MutableStateFlow(StageState.Loading) - val stageState: StateFlow = combine( + private val stageStateFiltered: StateFlow = combine( _stageState, _showSearchStaged, _searchFilterStaged, @@ -83,7 +89,6 @@ class StatusViewModel @Inject constructor( _searchFilterUnstaged, ) { state, showSearchStaged, filterStaged, showSearchUnstaged, filterUnstaged -> if (state is StageState.Loaded) { - val unstaged = if (showSearchUnstaged && filterUnstaged.text.isNotBlank()) { state.unstaged.filter { it.filePath.lowercaseContains(filterUnstaged.text) } } else { @@ -96,31 +101,49 @@ class StatusViewModel @Inject constructor( state.staged }.prioritizeConflicts() - state.copy(stagedFiltered = staged, unstagedFiltered = unstaged) + state.copy(staged = staged, unstaged = unstaged) } else { state } - }.stateIn( - tabScope, - SharingStarted.Lazily, - StageState.Loading - ) + } + .stateIn( + tabScope, + SharingStarted.Lazily, + StageState.Loading + ) - fun List.prioritizeConflicts(): List { - return this.groupBy { it.filePath } - .map { - val statusEntries = it.value - return@map if (statusEntries.count() == 1) { - statusEntries.first() + + val stageStateUi: StateFlow = combine( + stageStateFiltered, + showAsTree, + treeContractedDirectories, + ) { stageStateFiltered, showAsTree, contractedDirectories -> + when (stageStateFiltered) { + is StageState.Loaded -> { + if (showAsTree) { + StageStateUi.TreeLoaded( + staged = entriesToTreeEntry(stageStateFiltered.staged, contractedDirectories) { it.filePath }, + unstaged = entriesToTreeEntry(stageStateFiltered.unstaged, contractedDirectories) { it.filePath }, + isPartiallyReloading = stageStateFiltered.isPartiallyReloading, + ) } else { - val conflictingEntry = - statusEntries.firstOrNull { entry -> entry.statusType == StatusType.CONFLICTING } - - conflictingEntry ?: statusEntries.first() + StageStateUi.ListLoaded( + staged = stageStateFiltered.staged, + unstaged = stageStateFiltered.unstaged, + isPartiallyReloading = stageStateFiltered.isPartiallyReloading, + ) } } + + StageState.Loading -> StageStateUi.Loading + } } + .stateIn( + tabScope, + SharingStarted.Lazily, + StageStateUi.Loading + ) var savedCommitMessage = CommitMessage("", MessageType.NORMAL) @@ -248,9 +271,8 @@ class StatusViewModel @Inject constructor( _stageState.value = StageState.Loaded( staged = staged, - stagedFiltered = staged, unstaged = unstaged, - unstagedFiltered = unstaged, isPartiallyReloading = false + isPartiallyReloading = false, ) } } catch (ex: Exception) { @@ -259,6 +281,21 @@ class StatusViewModel @Inject constructor( } } + private fun List.prioritizeConflicts(): List { + return this.groupBy { it.filePath } + .map { + val statusEntries = it.value + return@map if (statusEntries.count() == 1) { + statusEntries.first() + } else { + val conflictingEntry = + statusEntries.firstOrNull { entry -> entry.statusType == StatusType.CONFLICTING } + + conflictingEntry ?: statusEntries.first() + } + } + } + private fun messageByRepoState(git: Git): String { val message: String? = if ( git.repository.repositoryState.isMerging || @@ -449,19 +486,91 @@ class StatusViewModel @Inject constructor( fun onSearchFilterChangedUnstaged(filter: TextFieldValue) { _searchFilterUnstaged.value = filter } + + fun stagedTreeDirectoryClicked(directoryPath: String) { + val contractedDirectories = treeContractedDirectories.value + + if (contractedDirectories.contains(directoryPath)) { + treeContractedDirectories.value -= directoryPath + } else { + treeContractedDirectories.value += directoryPath + } + } + + fun alternateShowAsTree() { + appSettings.showChangesAsTree = !appSettings.showChangesAsTree + } + + fun stageByDirectory(dir: String) = tabState.runOperation( + refreshType = RefreshType.UNCOMMITTED_CHANGES, + showError = true, + ) { git -> + stageByDirectoryUseCase(git, dir) + } + + fun unstageByDirectory(dir: String) = tabState.runOperation( + refreshType = RefreshType.UNCOMMITTED_CHANGES, + showError = true, + ) { git -> + unstageByDirectoryUseCase(git, dir) + } } sealed interface StageState { data object Loading : StageState data class Loaded( val staged: List, - val stagedFiltered: List, val unstaged: List, - val unstagedFiltered: List, - val isPartiallyReloading: Boolean + val isPartiallyReloading: Boolean, ) : StageState } + +sealed interface StageStateUi { + val hasStagedFiles: Boolean + val hasUnstagedFiles: Boolean + val isLoading: Boolean + val haveConflictsBeenSolved: Boolean + + data object Loading : StageStateUi { + override val hasStagedFiles: Boolean + get() = false + override val hasUnstagedFiles: Boolean + get() = false + override val isLoading: Boolean + get() = true + override val haveConflictsBeenSolved: Boolean + get() = false + } + + sealed interface Loaded : StageStateUi + + data class TreeLoaded( + val staged: List>, + val unstaged: List>, + val isPartiallyReloading: Boolean, + ) : Loaded { + + override val hasStagedFiles: Boolean = staged.isNotEmpty() + override val hasUnstagedFiles: Boolean = unstaged.isNotEmpty() + override val isLoading: Boolean = isPartiallyReloading + override val haveConflictsBeenSolved: Boolean = unstaged.none { + it is TreeItem.File && it.data.statusType == StatusType.CONFLICTING + } + } + + data class ListLoaded( + val staged: List, + val unstaged: List, + val isPartiallyReloading: Boolean, + ) : Loaded { + override val hasStagedFiles: Boolean = staged.isNotEmpty() + override val hasUnstagedFiles: Boolean = unstaged.isNotEmpty() + override val isLoading: Boolean = isPartiallyReloading + override val haveConflictsBeenSolved: Boolean = unstaged.none { it.statusType == StatusType.CONFLICTING } + } +} + data class CommitMessage(val message: String, val messageType: MessageType) enum class MessageType { @@ -470,7 +579,7 @@ enum class MessageType { } sealed interface CommitterDataRequestState { - object None : CommitterDataRequestState + data object None : CommitterDataRequestState data class WaitingInput(val authorInfo: AuthorInfo) : CommitterDataRequestState data class Accepted(val authorInfo: AuthorInfo, val persist: Boolean) : CommitterDataRequestState object Reject : CommitterDataRequestState diff --git a/src/main/resources/folder.svg b/src/main/resources/folder.svg new file mode 100644 index 0000000..b4f4787 --- /dev/null +++ b/src/main/resources/folder.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/main/resources/folder_open.svg b/src/main/resources/folder_open.svg new file mode 100644 index 0000000..65b7a9f --- /dev/null +++ b/src/main/resources/folder_open.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/main/resources/tree.svg b/src/main/resources/tree.svg new file mode 100644 index 0000000..80312fa --- /dev/null +++ b/src/main/resources/tree.svg @@ -0,0 +1 @@ + \ No newline at end of file