diff --git a/src/main/kotlin/com/jetpackduba/gitnuro/Icons.kt b/src/main/kotlin/com/jetpackduba/gitnuro/Icons.kt index 88e26bd..5678c58 100644 --- a/src/main/kotlin/com/jetpackduba/gitnuro/Icons.kt +++ b/src/main/kotlin/com/jetpackduba/gitnuro/Icons.kt @@ -17,6 +17,7 @@ object AppIcons { const val COPY = "copy.svg" const val CUT = "cut.svg" const val DELETE = "delete.svg" + const val DONE = "done.svg" const val DOWNLOAD = "download.svg" const val DROPDOWN = "dropdown.svg" const val ERROR = "error.svg" @@ -37,6 +38,7 @@ object AppIcons { const val PERSON = "person.svg" const val REFRESH = "refresh.svg" const val REMOVE = "remove.svg" + const val REMOVE_DONE = "remove_done.svg" const val REVERT = "revert.svg" const val SEARCH = "search.svg" const val SETTINGS = "settings.svg" diff --git a/src/main/kotlin/com/jetpackduba/gitnuro/ui/CommitChanges.kt b/src/main/kotlin/com/jetpackduba/gitnuro/ui/CommitChanges.kt index 52f254d..1c62fe1 100644 --- a/src/main/kotlin/com/jetpackduba/gitnuro/ui/CommitChanges.kt +++ b/src/main/kotlin/com/jetpackduba/gitnuro/ui/CommitChanges.kt @@ -11,11 +11,11 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.res.painterResource 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 @@ -26,7 +26,7 @@ import com.jetpackduba.gitnuro.theme.* import com.jetpackduba.gitnuro.ui.components.* import com.jetpackduba.gitnuro.ui.context_menu.ContextMenu import com.jetpackduba.gitnuro.ui.context_menu.commitedChangesEntriesContextMenuItems -import com.jetpackduba.gitnuro.viewmodels.CommitChangesStatus +import com.jetpackduba.gitnuro.viewmodels.CommitChangesState import com.jetpackduba.gitnuro.viewmodels.CommitChangesViewModel import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -48,21 +48,36 @@ fun CommitChanges( commitChangesViewModel.loadChanges(selectedItem.revCommit) } - val commitChangesStatusState = commitChangesViewModel.commitChangesStatus.collectAsState() + val commitChangesStatus = commitChangesViewModel.commitChangesState.collectAsState().value + val showSearch by commitChangesViewModel.showSearch.collectAsState() - when (val commitChangesStatus = commitChangesStatusState.value) { - CommitChangesStatus.Loading -> { + + var searchFilter by remember(commitChangesViewModel, showSearch, commitChangesStatus) { + mutableStateOf(commitChangesViewModel.searchFilter.value) + } + + when (commitChangesStatus) { + CommitChangesState.Loading -> { LinearProgressIndicator(modifier = Modifier.fillMaxWidth(), color = MaterialTheme.colors.primaryVariant) } - is CommitChangesStatus.Loaded -> { + is CommitChangesState.Loaded -> { CommitChangesView( diffSelected = diffSelected, commit = commitChangesStatus.commit, - changes = commitChangesStatus.changes, + changes = commitChangesStatus.changesFiltered, onDiffSelected = onDiffSelected, onBlame = onBlame, onHistory = onHistory, + showSearch = showSearch, + searchFilter = searchFilter, + onSearchFilterToggled = { visible -> + commitChangesViewModel.onSearchFilterToggled(visible) + }, + onSearchFilterChanged = { filter -> + searchFilter = filter + commitChangesViewModel.onSearchFilterChanged(filter) + }, ) } } @@ -76,15 +91,23 @@ fun CommitChangesView( diffSelected: DiffEntryType?, onBlame: (String) -> Unit, onHistory: (String) -> Unit, + showSearch: Boolean, + searchFilter: TextFieldValue, + onSearchFilterToggled: (Boolean) -> Unit, + onSearchFilterChanged: (TextFieldValue) -> Unit, ) { + + /** + * 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 .padding(end = 8.dp, bottom = 8.dp) .fillMaxSize(), ) { val scroll = rememberScrollState(0) - var showSearch by remember(commit) { mutableStateOf(false) } - var searchFilter by remember(commit) { mutableStateOf("") } // TODO Persist in viewmodel val searchFocusRequester = remember { FocusRequester() } Column( @@ -116,43 +139,40 @@ fun CommitChangesView( IconButton( onClick = { - showSearch = !showSearch - } + onSearchFilterToggled(!showSearch) + + if (!showSearch) + requestFocus = true + }, + modifier = Modifier.handOnHover(), ) { Icon( painter = painterResource(AppIcons.SEARCH), contentDescription = null, - modifier = Modifier.size(16.dp), + modifier = Modifier.size(18.dp), tint = MaterialTheme.colors.onBackground, ) } } if (showSearch) { - AdjustableOutlinedTextField( - value = searchFilter, - onValueChange = { - searchFilter = it - }, - modifier = Modifier.fillMaxWidth() - .focusable() - .focusRequester(searchFocusRequester) + SearchTextField( + searchFilter = searchFilter, + onSearchFilterChanged = onSearchFilterChanged, + searchFocusRequester = searchFocusRequester, ) } - LaunchedEffect(showSearch) { - if (showSearch) { + LaunchedEffect(showSearch, requestFocus) { + if (showSearch && requestFocus) { searchFocusRequester.requestFocus() + requestFocus = false } } CommitLogChanges( diffSelected = diffSelected, - diffEntries = if (showSearch && searchFilter.isNotBlank()) changes.filter { - it.filePath.lowercaseContains( - searchFilter - ) - } else changes, + diffEntries = changes, onDiffSelected = onDiffSelected, onBlame = onBlame, onHistory = onHistory, diff --git a/src/main/kotlin/com/jetpackduba/gitnuro/ui/UncommitedChanges.kt b/src/main/kotlin/com/jetpackduba/gitnuro/ui/UncommitedChanges.kt index d103248..f587284 100644 --- a/src/main/kotlin/com/jetpackduba/gitnuro/ui/UncommitedChanges.kt +++ b/src/main/kotlin/com/jetpackduba/gitnuro/ui/UncommitedChanges.kt @@ -8,6 +8,7 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background +import androidx.compose.foundation.focusable import androidx.compose.foundation.hoverable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsHoveredAsState @@ -23,14 +24,19 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape import androidx.compose.ui.input.key.onPreviewKeyEvent +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.Density 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.workspace.StatusEntry @@ -38,16 +44,14 @@ 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.ui.components.ScrollableLazyColumn -import com.jetpackduba.gitnuro.ui.components.SecondaryButton -import com.jetpackduba.gitnuro.ui.components.gitnuroViewModel +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.statusEntriesContextMenuItems import com.jetpackduba.gitnuro.ui.dialogs.CommitAuthorDialog import com.jetpackduba.gitnuro.viewmodels.CommitterDataRequestState -import com.jetpackduba.gitnuro.viewmodels.StageStatus +import com.jetpackduba.gitnuro.viewmodels.StageState import com.jetpackduba.gitnuro.viewmodels.StatusViewModel import org.eclipse.jgit.lib.RepositoryState @@ -61,7 +65,7 @@ fun UncommitedChanges( onBlameFile: (String) -> Unit, onHistoryFile: (String) -> Unit, ) { - val stageStatusState = statusViewModel.stageStatus.collectAsState() + val stageStatus = statusViewModel.stageState.collectAsState().value var commitMessage by remember(statusViewModel) { mutableStateOf(statusViewModel.savedCommitMessage.message) } val stagedListState by statusViewModel.stagedLazyListState.collectAsState() val unstagedListState by statusViewModel.unstagedLazyListState.collectAsState() @@ -69,14 +73,18 @@ fun UncommitedChanges( val committerDataRequestState = statusViewModel.committerDataRequestState.collectAsState() val committerDataRequestStateValue = committerDataRequestState.value - val stageStatus = stageStatusState.value + val showSearchStaged by statusViewModel.showSearchStaged.collectAsState() + val searchFilterStaged by statusViewModel.searchFilterStaged.collectAsState() + val showSearchUnstaged by statusViewModel.showSearchUnstaged.collectAsState() + val searchFilterUnstaged by statusViewModel.searchFilterUnstaged.collectAsState() + val staged: List val unstaged: List val isLoading: Boolean - if (stageStatus is StageStatus.Loaded) { - staged = stageStatus.staged - unstaged = stageStatus.unstaged + if (stageStatus is StageState.Loaded) { + staged = stageStatus.stagedFiltered + unstaged = stageStatus.unstagedFiltered isLoading = stageStatus.isPartiallyReloading } else { staged = listOf() @@ -133,12 +141,21 @@ fun UncommitedChanges( 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) }, @@ -163,12 +180,21 @@ fun UncommitedChanges( .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) }, @@ -558,6 +584,11 @@ private fun EntriesList( 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, @@ -567,19 +598,30 @@ private fun EntriesList( allActionTitle: String, selectedEntryType: DiffEntryType?, ) { + 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 ) { - Box( + Row( modifier = Modifier .height(34.dp) .fillMaxWidth() .background(color = MaterialTheme.colors.tertiarySurface) + .hoverable(headerHoverInteraction), + verticalAlignment = Alignment.CenterVertically, ) { Text( modifier = Modifier - .padding(vertical = 8.dp, horizontal = 16.dp) - .fillMaxWidth(), + .padding(start = 16.dp, end = 8.dp) + .weight(1f), text = title, fontWeight = FontWeight.Normal, textAlign = TextAlign.Left, @@ -588,15 +630,49 @@ private fun EntriesList( maxLines = 1, ) - SecondaryButton( - modifier = Modifier.align(Alignment.CenterEnd), + IconButton( + onClick = { + onSearchFilterToggled(!showSearch) + + if (!showSearch) + requestFocus = true + }, + modifier = Modifier.handOnHover() + ) { + Icon( + painter = painterResource(AppIcons.SEARCH), + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colors.onBackground, + ) + } + + SecondaryButtonCompactable( text = allActionTitle, + icon = actionIcon, + isParentHovered = isHeaderHovered, backgroundButton = actionColor, textColor = actionTextColor, - onClick = onAllAction + onClick = onAllAction, + modifier = Modifier.padding(start = 4.dp, end = 16.dp), ) } + if (showSearch) { + SearchTextField( + searchFilter = searchFilter, + onSearchFilterChanged = onSearchFilterChanged, + searchFocusRequester = searchFocusRequester, + ) + } + + LaunchedEffect(showSearch, requestFocus) { + if (showSearch && requestFocus) { + searchFocusRequester.requestFocus() + requestFocus = false + } + } + ScrollableLazyColumn( modifier = Modifier .fillMaxSize() diff --git a/src/main/kotlin/com/jetpackduba/gitnuro/ui/components/AdjustableOutlinedTextField.kt b/src/main/kotlin/com/jetpackduba/gitnuro/ui/components/AdjustableOutlinedTextField.kt index 461ad80..c9980e0 100644 --- a/src/main/kotlin/com/jetpackduba/gitnuro/ui/components/AdjustableOutlinedTextField.kt +++ b/src/main/kotlin/com/jetpackduba/gitnuro/ui/components/AdjustableOutlinedTextField.kt @@ -21,6 +21,7 @@ import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.takeOrElse import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -125,4 +126,104 @@ fun AdjustableOutlinedTextField( } } } -} \ No newline at end of file +} + +@Composable +fun AdjustableOutlinedTextField( + value: TextFieldValue, + hint: String = "", + onValueChange: (TextFieldValue) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + isError: Boolean = false, + singleLine: Boolean = false, + colors: TextFieldColors = outlinedTextFieldColors(), + maxLines: Int = Int.MAX_VALUE, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + textStyle: TextStyle = LocalTextStyle.current.copy( + fontSize = MaterialTheme.typography.body1.fontSize, + color = MaterialTheme.colors.onBackground, + ), + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + shape: Shape = RoundedCornerShape(4.dp), + backgroundColor: Color = MaterialTheme.colors.background, + visualTransformation: VisualTransformation = VisualTransformation.None, + leadingIcon: @Composable (() -> Unit)? = null, + trailingIcon: @Composable (() -> Unit)? = null, +) { + val textColor = textStyle.color.takeOrElse { + colors.textColor(enabled).value + } + + val cursorColor = colors.cursorColor(isError).value + val indicatorColor by colors.indicatorColor(enabled, isError, interactionSource) + + Box( + modifier = modifier + .height(IntrinsicSize.Min) + ) { + BasicTextField( + modifier = Modifier + .heightIn(min = 38.dp) + .background(backgroundColor) + .fillMaxWidth(), + value = value, + onValueChange = onValueChange, + enabled = enabled, + maxLines = maxLines, + textStyle = textStyle.copy(color = textColor), + interactionSource = interactionSource, + keyboardOptions = keyboardOptions, + cursorBrush = SolidColor(cursorColor), + singleLine = singleLine, + visualTransformation = visualTransformation, + decorationBox = { innerTextField: @Composable () -> Unit -> + Row( + modifier = Modifier + .border( + width = 2.dp, + color = indicatorColor, + shape = shape + ) + .padding(horizontal = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (leadingIcon != null) { + leadingIcon() + Spacer(modifier = Modifier.width(8.dp)) + } + Box(modifier = Modifier.weight(1f)) { + innerTextField() + } + + + if (trailingIcon != null) { + Spacer(modifier = Modifier.width(8.dp)) + trailingIcon() + } + } + } + ) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxSize() + .padding(start = 12.dp), + ) { + if (leadingIcon != null) { + leadingIcon() + Spacer(modifier = Modifier.width(8.dp)) + } + + if (value.text.isEmpty() && hint.isNotEmpty()) { + Text( + hint, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = MaterialTheme.colors.onBackgroundSecondary, + style = MaterialTheme.typography.body2 + ) + } + } + } +} diff --git a/src/main/kotlin/com/jetpackduba/gitnuro/ui/components/RepositoriesTabPanel.kt b/src/main/kotlin/com/jetpackduba/gitnuro/ui/components/RepositoriesTabPanel.kt index ef3131d..1b19c72 100644 --- a/src/main/kotlin/com/jetpackduba/gitnuro/ui/components/RepositoriesTabPanel.kt +++ b/src/main/kotlin/com/jetpackduba/gitnuro/ui/components/RepositoriesTabPanel.kt @@ -146,7 +146,8 @@ fun RepositoriesTabPanel( fun Tab( title: MutableState, isSelected: Boolean, - onClick: () -> Unit, onCloseTab: () -> Unit + onClick: () -> Unit, + onCloseTab: () -> Unit, ) { val backgroundColor = if (isSelected) MaterialTheme.colors.surface diff --git a/src/main/kotlin/com/jetpackduba/gitnuro/ui/components/SearchTextField.kt b/src/main/kotlin/com/jetpackduba/gitnuro/ui/components/SearchTextField.kt new file mode 100644 index 0000000..993dcbc --- /dev/null +++ b/src/main/kotlin/com/jetpackduba/gitnuro/ui/components/SearchTextField.kt @@ -0,0 +1,38 @@ +package com.jetpackduba.gitnuro.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.unit.dp + +@Composable +fun SearchTextField( + searchFilter: TextFieldValue, + onSearchFilterChanged: (TextFieldValue) -> Unit, + searchFocusRequester: FocusRequester, +) { + Box( + modifier = Modifier + .background(MaterialTheme.colors.background) + .padding(horizontal = 4.dp, vertical = 4.dp) + ) { + AdjustableOutlinedTextField( + value = searchFilter, + onValueChange = { + onSearchFilterChanged(it) + }, + hint = "Search files by name or path", + modifier = Modifier.fillMaxWidth() + .focusable() + .focusRequester(searchFocusRequester) + ) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/jetpackduba/gitnuro/ui/components/SecondaryButton.kt b/src/main/kotlin/com/jetpackduba/gitnuro/ui/components/SecondaryButton.kt index 8d5ea02..e7d614e 100644 --- a/src/main/kotlin/com/jetpackduba/gitnuro/ui/components/SecondaryButton.kt +++ b/src/main/kotlin/com/jetpackduba/gitnuro/ui/components/SecondaryButton.kt @@ -1,17 +1,29 @@ package com.jetpackduba.gitnuro.ui.components +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.background +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size 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.runtime.* +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp import com.jetpackduba.gitnuro.extensions.handMouseClickable +import com.jetpackduba.gitnuro.theme.tertiarySurface @Composable fun SecondaryButton( @@ -24,8 +36,7 @@ fun SecondaryButton( ) { Box( modifier = modifier - .padding(horizontal = 16.dp) - .clip(RoundedCornerShape(5.dp)) + .clip(RoundedCornerShape(4.dp)) .background(backgroundButton) .handMouseClickable { onClick() }, ) { @@ -37,4 +48,73 @@ fun SecondaryButton( modifier = Modifier.padding(vertical = 4.dp, horizontal = 16.dp) ) } +} + +@Composable +fun SecondaryButtonCompactable( + modifier: Modifier = Modifier, + icon: String, + text: String, + textColor: Color = MaterialTheme.colors.onPrimary, + backgroundButton: Color, + maxLines: Int = 1, + isParentHovered: Boolean, + onClick: () -> Unit, +) { + val hoverInteraction = remember { MutableInteractionSource() } + val isHovered by hoverInteraction.collectIsHoveredAsState() + var isExpanded by remember { mutableStateOf(false) } + + LaunchedEffect(isHovered, isParentHovered) { + isExpanded = when { + isHovered -> true + isExpanded && isParentHovered -> true + else -> false + } + } + + val targetBackground: Color + val iconPadding: Float + + if (isExpanded) { + targetBackground = backgroundButton + iconPadding = 12f + } else { + targetBackground = MaterialTheme.colors.tertiarySurface + iconPadding = 0f + } + + val color by animateColorAsState(targetBackground) + val iconPaddingState by animateFloatAsState(iconPadding) + + Row( + modifier = modifier + .clip(RoundedCornerShape(4.dp)) + .background(color) + .hoverable(hoverInteraction) + .handMouseClickable { onClick() } + .padding(horizontal = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + painterResource(icon), + contentDescription = null, + tint = MaterialTheme.colors.onBackground, + modifier = Modifier + .padding(start = iconPaddingState.dp, end = 8.dp) + .size(18.dp) + ) + + AnimatedVisibility( + visible = isExpanded, + ) { + Text( + text = text, + style = MaterialTheme.typography.body2, + color = textColor, + maxLines = maxLines, + modifier = Modifier.padding(top = 4.dp, bottom = 4.dp, end = 12.dp) + ) + } + } } \ No newline at end of file diff --git a/src/main/kotlin/com/jetpackduba/gitnuro/ui/diff/Diff.kt b/src/main/kotlin/com/jetpackduba/gitnuro/ui/diff/Diff.kt index 94c7187..5e1f6a5 100644 --- a/src/main/kotlin/com/jetpackduba/gitnuro/ui/diff/Diff.kt +++ b/src/main/kotlin/com/jetpackduba/gitnuro/ui/diff/Diff.kt @@ -626,13 +626,15 @@ fun HunkHeader( text = "Discard hunk", backgroundButton = MaterialTheme.colors.error, textColor = MaterialTheme.colors.onError, - onClick = onResetHunk + onClick = onResetHunk, + modifier = Modifier.padding(horizontal = 16.dp), ) } SecondaryButton( text = buttonText, backgroundButton = color, + modifier = Modifier.padding(horizontal = 16.dp), onClick = { if (diffEntryType is DiffEntryType.StagedDiff) { onUnstageHunk() @@ -787,6 +789,7 @@ fun UncommitedDiffFileHeaderButtons( SecondaryButton( text = buttonText, backgroundButton = color, + modifier = Modifier.padding(horizontal = 16.dp), onClick = { if (diffEntryType is DiffEntryType.StagedDiff) { onUnstageFile(diffEntryType.statusEntry) diff --git a/src/main/kotlin/com/jetpackduba/gitnuro/viewmodels/CommitChangesViewModel.kt b/src/main/kotlin/com/jetpackduba/gitnuro/viewmodels/CommitChangesViewModel.kt index aa9467f..a93cd2e 100644 --- a/src/main/kotlin/com/jetpackduba/gitnuro/viewmodels/CommitChangesViewModel.kt +++ b/src/main/kotlin/com/jetpackduba/gitnuro/viewmodels/CommitChangesViewModel.kt @@ -1,11 +1,14 @@ package com.jetpackduba.gitnuro.viewmodels +import androidx.compose.ui.text.input.TextFieldValue import com.jetpackduba.gitnuro.extensions.delayedStateChange +import com.jetpackduba.gitnuro.extensions.filePath +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 kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.* import org.eclipse.jgit.diff.DiffEntry import org.eclipse.jgit.revwalk.RevCommit import javax.inject.Inject @@ -15,26 +18,69 @@ private const val MIN_TIME_IN_MS_TO_SHOW_LOAD = 300L class CommitChangesViewModel @Inject constructor( private val tabState: TabState, private val getCommitDiffEntriesUseCase: GetCommitDiffEntriesUseCase, + tabScope: CoroutineScope, ) { - private val _commitChangesStatus = MutableStateFlow(CommitChangesStatus.Loading) - val commitChangesStatus: StateFlow = _commitChangesStatus + private val _showSearch = MutableStateFlow(false) + val showSearch: StateFlow = _showSearch + + private val _searchFilter = MutableStateFlow(TextFieldValue("")) + val searchFilter: StateFlow = _searchFilter + + private val _commitChangesState = MutableStateFlow(CommitChangesState.Loading) + val commitChangesState: StateFlow = + 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 + } + } else { + state + } + }.stateIn( + tabScope, + SharingStarted.Lazily, + CommitChangesState.Loading + ) + fun loadChanges(commit: RevCommit) = tabState.runOperation( refreshType = RefreshType.NONE, ) { git -> - delayedStateChange( - delayMs = MIN_TIME_IN_MS_TO_SHOW_LOAD, - onDelayTriggered = { _commitChangesStatus.value = CommitChangesStatus.Loading } - ) { - val changes = getCommitDiffEntriesUseCase(git, commit) + val state = _commitChangesState.value - _commitChangesStatus.value = CommitChangesStatus.Loaded(commit, changes) + // Check if it's a different commit before resetting everything + if ( + state is CommitChangesState.Loading || + state is CommitChangesState.Loaded && state.commit != commit + ) { + delayedStateChange( + delayMs = MIN_TIME_IN_MS_TO_SHOW_LOAD, + onDelayTriggered = { _commitChangesState.value = CommitChangesState.Loading } + ) { + val changes = getCommitDiffEntriesUseCase(git, commit) + + _commitChangesState.value = CommitChangesState.Loaded(commit, changes, changes) + } + + _showSearch.value = false + _searchFilter.value = TextFieldValue("") } } + + fun onSearchFilterToggled(visible: Boolean) { + _showSearch.value = visible + } + + fun onSearchFilterChanged(filter: TextFieldValue) { + _searchFilter.value = filter + } } -sealed class CommitChangesStatus { - object Loading : CommitChangesStatus() - data class Loaded(val commit: RevCommit, val changes: List) : CommitChangesStatus() +sealed class CommitChangesState { + object Loading : CommitChangesState() + data class Loaded(val commit: RevCommit, val changes: List, val changesFiltered: List) : + CommitChangesState() } diff --git a/src/main/kotlin/com/jetpackduba/gitnuro/viewmodels/StatusViewModel.kt b/src/main/kotlin/com/jetpackduba/gitnuro/viewmodels/StatusViewModel.kt index b655005..7afe995 100644 --- a/src/main/kotlin/com/jetpackduba/gitnuro/viewmodels/StatusViewModel.kt +++ b/src/main/kotlin/com/jetpackduba/gitnuro/viewmodels/StatusViewModel.kt @@ -1,9 +1,11 @@ package com.jetpackduba.gitnuro.viewmodels import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.ui.text.input.TextFieldValue import com.jetpackduba.gitnuro.extensions.delayedStateChange import com.jetpackduba.gitnuro.extensions.isMerging import com.jetpackduba.gitnuro.extensions.isReverting +import com.jetpackduba.gitnuro.extensions.lowercaseContains import com.jetpackduba.gitnuro.git.RefreshType import com.jetpackduba.gitnuro.git.TabState import com.jetpackduba.gitnuro.git.author.LoadAuthorUseCase @@ -18,10 +20,7 @@ import com.jetpackduba.gitnuro.git.workspace.* import com.jetpackduba.gitnuro.models.AuthorInfo import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.eclipse.jgit.api.Git @@ -54,8 +53,50 @@ class StatusViewModel @Inject constructor( private val saveAuthorUseCase: SaveAuthorUseCase, private val tabScope: CoroutineScope, ) { - private val _stageStatus = MutableStateFlow(StageStatus.Loaded(listOf(), listOf(), false)) - val stageStatus: StateFlow = _stageStatus + private val _showSearchUnstaged = MutableStateFlow(false) + val showSearchUnstaged: StateFlow = _showSearchUnstaged + + private val _showSearchStaged = MutableStateFlow(false) + val showSearchStaged: StateFlow = _showSearchStaged + + private val _searchFilterUnstaged = MutableStateFlow(TextFieldValue("")) + val searchFilterUnstaged: StateFlow = _searchFilterUnstaged + + private val _searchFilterStaged = MutableStateFlow(TextFieldValue("")) + val searchFilterStaged: StateFlow = _searchFilterStaged + + private val _stageState = MutableStateFlow(StageState.Loading) + + val stageState: StateFlow = combine( + _stageState, + _showSearchStaged, + _searchFilterStaged, + _showSearchUnstaged, + _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 { + state.unstaged + } + + val staged = if (showSearchStaged && filterStaged.text.isNotBlank()) { + state.staged.filter { it.filePath.lowercaseContains(filterStaged.text) } + } else { + state.staged + } + + state.copy(stagedFiltered = staged, unstagedFiltered = unstaged) + + } else { + state + } + }.stateIn( + tabScope, + SharingStarted.Lazily, + StageState.Loading + ) var savedCommitMessage = CommitMessage("", MessageType.NORMAL) @@ -145,7 +186,7 @@ class StatusViewModel @Inject constructor( } private suspend fun loadStatus(git: Git) { - val previousStatus = _stageStatus.value + val previousStatus = _stageState.value val requiredMessageType = if (git.repository.repositoryState == RepositoryState.MERGING) { MessageType.MERGE @@ -166,10 +207,10 @@ class StatusViewModel @Inject constructor( delayedStateChange( delayMs = MIN_TIME_IN_MS_TO_SHOW_LOAD, onDelayTriggered = { - if (previousStatus is StageStatus.Loaded) { - _stageStatus.value = previousStatus.copy(isPartiallyReloading = true) + if (previousStatus is StageState.Loaded) { + _stageState.value = previousStatus.copy(isPartiallyReloading = true) } else { - _stageStatus.value = StageStatus.Loading + _stageState.value = StageState.Loading } } ) { @@ -177,10 +218,15 @@ class StatusViewModel @Inject constructor( val staged = getStagedUseCase(status).sortedBy { it.filePath } val unstaged = getUnstagedUseCase(status).sortedBy { it.filePath } - _stageStatus.value = StageStatus.Loaded(staged, unstaged, isPartiallyReloading = false) + _stageState.value = StageState.Loaded( + staged = staged, + stagedFiltered = staged, + unstaged = unstaged, + unstagedFiltered = unstaged, isPartiallyReloading = false + ) } } catch (ex: Exception) { - _stageStatus.value = previousStatus + _stageState.value = previousStatus throw ex } } @@ -330,15 +376,33 @@ class StatusViewModel @Inject constructor( fun onAcceptCommitterData(newAuthorInfo: AuthorInfo, persist: Boolean) { this._committerDataRequestState.value = CommitterDataRequestState.Accepted(newAuthorInfo, persist) } + + fun onSearchFilterToggledStaged(visible: Boolean) { + _showSearchStaged.value = visible + } + + fun onSearchFilterChangedStaged(filter: TextFieldValue) { + _searchFilterStaged.value = filter + } + + fun onSearchFilterToggledUnstaged(visible: Boolean) { + _showSearchUnstaged.value = visible + } + + fun onSearchFilterChangedUnstaged(filter: TextFieldValue) { + _searchFilterUnstaged.value = filter + } } -sealed class StageStatus { - object Loading : StageStatus() +sealed class StageState { + object Loading : StageState() data class Loaded( val staged: List, + val stagedFiltered: List, val unstaged: List, + val unstagedFiltered: List, val isPartiallyReloading: Boolean - ) : StageStatus() + ) : StageState() } data class CommitMessage(val message: String, val messageType: MessageType) diff --git a/src/main/resources/done.svg b/src/main/resources/done.svg new file mode 100644 index 0000000..8e859e5 --- /dev/null +++ b/src/main/resources/done.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/main/resources/remove_done.svg b/src/main/resources/remove_done.svg new file mode 100644 index 0000000..9e4c817 --- /dev/null +++ b/src/main/resources/remove_done.svg @@ -0,0 +1 @@ + \ No newline at end of file