Gitnuro/src/main/kotlin/com/jetpackduba/gitnuro/ui/UncommitedChanges.kt
Abdelilah El Aissaoui 17839bfaf1 Added TODO reminder
2023-08-19 14:47:18 +02:00

812 lines
28 KiB
Kotlin

@file:OptIn(ExperimentalAnimationApi::class, ExperimentalComposeUiApi::class, ExperimentalFoundationApi::class)
package com.jetpackduba.gitnuro.ui
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.*
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsHoveredAsState
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.runtime.*
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.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.compositeOver
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.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.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.StageState
import com.jetpackduba.gitnuro.viewmodels.StatusViewModel
import org.eclipse.jgit.lib.RepositoryState
@Composable
fun UncommitedChanges(
statusViewModel: StatusViewModel = gitnuroViewModel(),
selectedEntryType: DiffEntryType?,
repositoryState: RepositoryState,
onStagedDiffEntrySelected: (StatusEntry?) -> Unit,
onUnstagedDiffEntrySelected: (StatusEntry) -> Unit,
onBlameFile: (String) -> Unit,
onHistoryFile: (String) -> Unit,
) {
val stageStatus = statusViewModel.stageState.collectAsState().value
val swapUncommitedChanges by statusViewModel.swapUncommitedChanges.collectAsState()
var commitMessage by remember(statusViewModel) { mutableStateOf(statusViewModel.savedCommitMessage.message) }
val stagedListState by statusViewModel.stagedLazyListState.collectAsState()
val unstagedListState by statusViewModel.unstagedLazyListState.collectAsState()
val isAmend by statusViewModel.isAmend.collectAsState()
val isAmendRebaseInteractive by statusViewModel.isAmendRebaseInteractive.collectAsState()
val committerDataRequestState = statusViewModel.committerDataRequestState.collectAsState()
val committerDataRequestStateValue = committerDataRequestState.value
val rebaseInteractiveState = statusViewModel.rebaseInteractiveState.collectAsState().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 isAmenableRebaseInteractive =
repositoryState.isRebasing && rebaseInteractiveState is RebaseInteractiveState.ProcessingCommits && rebaseInteractiveState.isCurrentStepAmenable
val staged: List<StatusEntry>
val unstaged: List<StatusEntry>
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 = ""
}
val canCommit = commitMessage.isNotEmpty() && staged.isNotEmpty()
val canAmend = commitMessage.isNotEmpty() && statusViewModel.hasPreviousCommits
LaunchedEffect(statusViewModel) {
statusViewModel.commitMessageChangesFlow.collect { newCommitMessage ->
commitMessage = newCommitMessage
}
}
if (committerDataRequestStateValue is CommitterDataRequestState.WaitingInput) {
CommitAuthorDialog(
committerDataRequestStateValue.authorInfo,
onClose = { statusViewModel.onRejectCommitterData() },
onAccept = { newAuthorInfo, persist ->
statusViewModel.onAcceptCommitterData(newAuthorInfo, persist)
},
)
}
Column(
modifier = Modifier
.padding(end = 8.dp, bottom = 8.dp)
.fillMaxWidth(),
) {
AnimatedVisibility(
visible = isLoading,
enter = fadeIn(),
exit = fadeOut(),
) {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth(), color = MaterialTheme.colors.primaryVariant)
}
Column(
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()
},
)
}
// TODO After moving to compose 1.5.0 (beta and RC), the right click event stops working randomly just on
// unstaged changes. It could be related to the changes to the Popup API and the custom implementation
// that Gitnuro uses
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",
)
}
if (swapUncommitedChanges) {
unstagedView()
stagedView()
} else {
stagedView()
unstagedView()
}
}
Column(
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)
},
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,
)
when {
repositoryState.isMerging -> MergeButtons(
haveConflictsBeenSolved = unstaged.isEmpty(),
onAbort = {
statusViewModel.resetRepoState()
statusViewModel.updateCommitMessage("")
},
onMerge = { doCommit() }
)
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 = unstaged.isEmpty(),
onAbort = {
statusViewModel.resetRepoState()
statusViewModel.updateCommitMessage("")
},
onCommit = {
doCommit()
}
)
repositoryState.isReverting -> RevertingButtons(
haveConflictsBeenSolved = unstaged.none { it.statusType == StatusType.CONFLICTING },
canCommit = commitMessage.isNotBlank(),
onAbort = {
statusViewModel.resetRepoState()
statusViewModel.updateCommitMessage("")
},
onCommit = {
doCommit()
}
)
else -> UncommitedChangesButtons(
canCommit = canCommit,
canAmend = canAmend,
isAmend = isAmend,
onAmendChecked = { isAmend ->
statusViewModel.amend(isAmend)
},
onCommit = doCommit,
)
}
}
}
}
@Composable
fun UncommitedChangesButtons(
canCommit: Boolean,
canAmend: Boolean,
isAmend: Boolean,
onAmendChecked: (Boolean) -> Unit,
onCommit: () -> Unit
) {
val buttonText = if (isAmend)
"Amend"
else
"Commit"
Column {
CheckboxText(
value = isAmend,
onCheckedChange = { onAmendChecked(!isAmend) },
text = "Amend previous commit"
)
Row(
modifier = Modifier
.padding(top = 2.dp)
) {
ConfirmationButton(
text = buttonText,
modifier = Modifier
.weight(1f)
.height(36.dp),
onClick = {
onCommit()
},
enabled = canCommit || (canAmend && isAmend),
shape = RoundedCornerShape(4.dp)
)
}
}
}
@Composable
fun MergeButtons(
haveConflictsBeenSolved: Boolean,
onAbort: () -> Unit,
onMerge: () -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(36.dp)
) {
AbortButton(
modifier = Modifier
.weight(1f)
.padding(end = 4.dp)
.fillMaxHeight(),
onClick = onAbort
)
ConfirmationButton(
text = "Merge",
modifier = Modifier
.weight(1f)
.padding(start = 4.dp)
.fillMaxHeight(),
enabled = haveConflictsBeenSolved,
onClick = onMerge,
)
}
}
@Composable
fun CherryPickingButtons(
haveConflictsBeenSolved: Boolean,
onAbort: () -> Unit,
onCommit: () -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(36.dp)
) {
AbortButton(
modifier = Modifier
.weight(1f)
.padding(end = 4.dp)
.fillMaxHeight(),
onClick = onAbort
)
ConfirmationButton(
text = "Commit",
modifier = Modifier
.weight(1f)
.padding(start = 4.dp)
.fillMaxHeight(),
enabled = haveConflictsBeenSolved,
onClick = onCommit,
)
}
}
@Composable
fun RebasingButtons(
canContinue: Boolean,
isAmendable: Boolean,
isAmend: Boolean,
onAmendChecked: (Boolean) -> Unit,
haveConflictsBeenSolved: Boolean,
onAbort: () -> Unit,
onContinue: () -> Unit,
onSkip: () -> Unit,
) {
Column {
if (isAmendable) {
CheckboxText(
value = isAmend,
onCheckedChange = { onAmendChecked(!isAmend) },
text = "Amend previous commit"
)
}
Row(
modifier = Modifier
.fillMaxWidth()
.height(36.dp)
) {
AbortButton(
modifier = Modifier
.weight(1f)
.padding(end = 4.dp)
.fillMaxHeight(),
onClick = onAbort
)
if (canContinue) {
ConfirmationButton(
text = "Continue",
modifier = Modifier
.weight(1f)
.padding(start = 4.dp)
.fillMaxHeight(),
enabled = haveConflictsBeenSolved,
onClick = onContinue,
)
} else {
ConfirmationButton(
text = "Skip",
modifier = Modifier
.weight(1f)
.padding(start = 4.dp)
.fillMaxHeight(),
onClick = onSkip,
)
}
}
}
}
@Composable
fun RevertingButtons(
canCommit: Boolean,
haveConflictsBeenSolved: Boolean,
onAbort: () -> Unit,
onCommit: () -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(36.dp)
) {
AbortButton(
modifier = Modifier
.weight(1f)
.padding(end = 4.dp),
onClick = onAbort
)
ConfirmationButton(
text = "Continue",
modifier = Modifier
.weight(1f)
.padding(start = 4.dp)
.fillMaxHeight(),
enabled = canCommit && haveConflictsBeenSolved,
onClick = onCommit,
)
}
}
@Composable
fun AbortButton(modifier: Modifier, onClick: () -> Unit) {
Box(
modifier = modifier
.clickable { onClick() }
.focusable(false)
.clip(MaterialTheme.shapes.small)
.background(MaterialTheme.colors.abortButton),
contentAlignment = Alignment.Center,
) {
Text(
text = "Abort",
style = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onError),
)
}
}
@Composable
fun ConfirmationButton(
text: String,
modifier: Modifier,
enabled: Boolean = true,
shape: Shape = MaterialTheme.shapes.small,
onClick: () -> Unit,
) {
val (backgroundColor, contentColor) = if (enabled) {
(MaterialTheme.colors.primary to MaterialTheme.colors.onPrimary)
} else {
(MaterialTheme.colors.onSurface.copy(alpha = 0.12f)
.compositeOver(MaterialTheme.colors.surface) to MaterialTheme.colors.onSurface.copy(alpha = ContentAlpha.disabled))
}
Box(
modifier = modifier
.clickable { if (enabled) onClick() }
.focusable(false) // TODO this and the abort button should be focusable (show some kind of border when focused?)
.clip(shape)
.background(backgroundColor),
contentAlignment = Alignment.Center,
) {
Text(
text = text,
style = MaterialTheme.typography.body1.copy(color = contentColor),
)
}
}
@Composable
private fun EntriesList(
modifier: Modifier,
title: String,
actionTitle: String,
actionColor: Color,
actionTextColor: Color,
actionIcon: String,
showSearch: Boolean,
searchFilter: TextFieldValue,
onSearchFilterToggled: (Boolean) -> Unit,
onSearchFilterChanged: (TextFieldValue) -> Unit,
statusEntries: List<StatusEntry>,
lazyListState: LazyListState,
onDiffEntrySelected: (StatusEntry) -> Unit,
onDiffEntryOptionSelected: (StatusEntry) -> Unit,
onGenerateContextMenu: (StatusEntry) -> List<ContextMenuElement>,
onAllAction: () -> Unit,
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
) {
Row(
modifier = Modifier
.height(34.dp)
.fillMaxWidth()
.background(color = MaterialTheme.colors.tertiarySurface)
.hoverable(headerHoverInteraction),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
modifier = Modifier
.padding(start = 16.dp, end = 8.dp)
.weight(1f),
text = title,
fontWeight = FontWeight.Normal,
textAlign = TextAlign.Left,
color = MaterialTheme.colors.onBackground,
style = MaterialTheme.typography.body2,
maxLines = 1,
)
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,
onBackgroundColor = actionTextColor,
onClick = onAllAction,
modifier = Modifier.padding(start = 4.dp, end = 16.dp),
)
}
if (showSearch) {
SearchTextField(
searchFilter = searchFilter,
onSearchFilterChanged = onSearchFilterChanged,
searchFocusRequester = searchFocusRequester,
onClose = { onSearchFilterToggled(false) },
)
}
LaunchedEffect(showSearch, requestFocus) {
if (showSearch && requestFocus) {
searchFocusRequester.requestFocus()
requestFocus = false
}
}
ScrollableLazyColumn(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colors.background),
state = lazyListState,
) {
items(statusEntries, key = { it.filePath }) { statusEntry ->
val isEntrySelected = selectedEntryType != null &&
selectedEntryType is DiffEntryType.UncommitedDiff && // 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(
statusEntry: StatusEntry,
isSelected: Boolean,
actionTitle: String,
actionColor: Color,
onClick: () -> Unit,
onButtonClick: () -> Unit,
onGenerateContextMenu: (StatusEntry) -> List<ContextMenuElement>,
) {
val hoverInteraction = remember { MutableInteractionSource() }
val isHovered by hoverInteraction.collectIsHoveredAsState()
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),
)
}
}
}
internal fun placeRightOrBottom(
totalSize: Int,
size: IntArray,
outPosition: IntArray,
reverseInput: Boolean
) {
val consumedSize = size.fold(0) { a, b -> a + b }
var current = totalSize - consumedSize
size.forEachIndexed(reverseInput) { index, it ->
outPosition[index] = current
current += it
}
}
private inline fun IntArray.forEachIndexed(reversed: Boolean, action: (Int, Int) -> Unit) {
if (!reversed) {
forEachIndexed(action)
} else {
for (i in (size - 1) downTo 0) {
action(i, get(i))
}
}
}