Gitnuro/src/main/kotlin/com/jetpackduba/gitnuro/ui/UncommitedChanges.kt
2024-09-09 01:49:26 +02:00

1228 lines
42 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.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
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.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.unit.dp
import com.jetpackduba.gitnuro.AppIcons
import com.jetpackduba.gitnuro.extensions.*
import com.jetpackduba.gitnuro.git.DiffType
import com.jetpackduba.gitnuro.git.rebase.RebaseInteractiveState
import com.jetpackduba.gitnuro.git.workspace.StatusEntry
import com.jetpackduba.gitnuro.keybindings.KeybindingOption
import com.jetpackduba.gitnuro.keybindings.matchesBinding
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.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.StageStateUi
import com.jetpackduba.gitnuro.viewmodels.StatusViewModel
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import org.eclipse.jgit.lib.RepositoryState
@Composable
fun UncommittedChanges(
statusViewModel: StatusViewModel,
selectedEntryType: DiffType?,
repositoryState: RepositoryState,
onStagedDiffEntrySelected: (StatusEntry?) -> Unit,
onUnstagedDiffEntrySelected: (StatusEntry) -> Unit,
onBlameFile: (String) -> Unit,
onHistoryFile: (String) -> Unit,
) {
val stageStateUi = statusViewModel.stageStateUi.collectAsState().value
val swapUncommittedChanges by statusViewModel.swapUncommittedChanges.collectAsState()
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()
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 doCommit = {
statusViewModel.commit(commitMessage)
onStagedDiffEntrySelected(null)
setCommitMessage("")
}
val canCommit = commitMessage.isNotEmpty() && stageStateUi.hasStagedFiles
val canAmend = commitMessage.isNotEmpty() && statusViewModel.hasPreviousCommits
val tabFocusRequester = LocalTabFocusRequester.current
LaunchedEffect(statusViewModel) {
launch {
statusViewModel.commitMessageChangesFlow.collect { newCommitMessage ->
setCommitMessage(newCommitMessage)
}
}
launch {
statusViewModel.showSearchUnstaged.collectLatest { show ->
if (!show) {
tabFocusRequester.requestFocus()
}
}
}
launch {
statusViewModel.showSearchStaged.collectLatest { show ->
if (!show) {
tabFocusRequester.requestFocus()
}
}
}
}
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 = stageStateUi.isLoading,
enter = fadeIn(),
exit = fadeOut(),
) {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth(), color = MaterialTheme.colors.primaryVariant)
}
Column(
modifier = Modifier.weight(1f),
) {
if (stageStateUi is StageStateUi.Loaded) {
@Composable
fun staged() {
StagedView(
stageStateUi,
showSearchStaged,
searchFilterStaged,
stagedListState,
selectedEntryType,
onSearchFilterToggled = { statusViewModel.onSearchFilterToggledStaged(it) },
onSearchFocused = { statusViewModel.addStagedSearchToCloseableView() },
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) },
)
}
@Composable
fun unstaged() {
UnstagedView(
stageStateUi,
showSearchUnstaged,
searchFilterUnstaged,
unstagedListState,
selectedEntryType,
onSearchFilterToggled = { statusViewModel.onSearchFilterToggledUnstaged(it) },
onSearchFocused = { statusViewModel.addUnstagedSearchToCloseableView() },
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) {
unstaged()
staged()
} else {
staged()
unstaged()
}
}
}
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() },
onAmendRebaseInteractiveChecked = { statusViewModel.amendRebaseInteractive(it) }
)
}
}
@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,
onAmendRebaseInteractiveChecked: (Boolean) -> Unit,
) {
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 = 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,
)
when {
repositoryState.isMerging -> MergeButtons(
haveConflictsBeenSolved = !hasUnstagedFiles,
onAbort = onResetRepoState,
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 = onAmendRebaseInteractiveChecked,
)
repositoryState.isCherryPicking -> CherryPickingButtons(
haveConflictsBeenSolved = !hasUnstagedFiles,
onAbort = onResetRepoState,
onCommit = {
doCommit()
}
)
repositoryState.isReverting -> RevertingButtons(
haveConflictsBeenSolved = haveConflictsBeenSolved,
canCommit = commitMessage.isNotBlank(),
onAbort = onResetRepoState,
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: DiffType?,
onSearchFilterToggled: (Boolean) -> Unit,
onSearchFocused: () -> 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 = "Unstage"
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,
onSearchFocused = onSearchFocused,
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 DiffType.StagedDiff) selectedEntryType else null },
)
}
@Composable
fun ColumnScope.UnstagedView(
stageStateUi: StageStateUi.Loaded,
showSearchUnstaged: Boolean,
searchFilterUnstaged: TextFieldValue,
unstagedListState: LazyListState,
selectedEntryType: DiffType?,
onSearchFilterToggled: (Boolean) -> Unit,
onSearchFocused: () -> 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,
onSearchFocused = onSearchFocused,
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 DiffType.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: DiffType?,
onTreeEntries: (StageStateUi.TreeLoaded) -> List<TreeItem<StatusEntry>>,
onListEntries: (StageStateUi.ListLoaded) -> List<StatusEntry>,
onSearchFilterToggled: (Boolean) -> Unit,
onSearchFocused: () -> 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: () -> DiffType?,
) {
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,
onSearchFocused = onSearchFocused,
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 DiffType.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,
onSearchFocused = onSearchFocused,
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
fun UncommittedChangesButtons(
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()
.padding(top = 8.dp)
.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()
.padding(top = 8.dp)
.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()
.padding(top = 8.dp)
.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()
.padding(top = 8.dp)
.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
.handMouseClickable { 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
.handMouseClickable { 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,
onSearchFocused: () -> Unit,
onSearchFilterChanged: (TextFieldValue) -> Unit,
statusEntries: List<StatusEntry>,
lazyListState: LazyListState,
onDiffEntrySelected: (StatusEntry) -> Unit,
onDiffEntryOptionSelected: (StatusEntry) -> Unit,
onGenerateContextMenu: (StatusEntry) -> List<ContextMenuElement>,
onAllAction: () -> Unit,
onAlternateShowAsTree: () -> Unit,
allActionTitle: String,
selectedEntryType: DiffType?,
) {
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,
onSearchFocused = onSearchFocused,
)
ScrollableLazyColumn(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colors.background),
state = lazyListState,
) {
items(statusEntries, key = { it.filePath }) { statusEntry ->
val isEntrySelected = selectedEntryType != null &&
selectedEntryType is DiffType.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,
onSearchFocused: () -> Unit,
onSearchFilterChanged: (TextFieldValue) -> Unit,
statusEntries: List<TreeItem<StatusEntry>>,
lazyListState: LazyListState,
onDiffEntrySelected: (StatusEntry) -> Unit,
onDiffEntryOptionSelected: (StatusEntry) -> Unit,
onGenerateContextMenu: (StatusEntry) -> List<ContextMenuElement>,
onGenerateDirectoryContextMenu: (TreeItem.Dir) -> List<ContextMenuElement>,
onAllAction: () -> Unit,
onAlternateShowAsTree: () -> Unit,
onTreeDirectoryClicked: (TreeItem.Dir) -> Unit,
allActionTitle: String,
selectedEntryType: DiffType?,
) {
Column(
modifier = modifier
) {
EntriesHeader(
title = title,
actionColor = actionColor,
allActionTitle = allActionTitle,
actionTextColor = actionTextColor,
actionIcon = actionIcon,
onAllAction = onAllAction,
onAlternateShowAsTree = onAlternateShowAsTree,
searchFilter = searchFilter,
onSearchFilterChanged = onSearchFilterChanged,
onSearchFilterToggled = onSearchFilterToggled,
onSearchFocused = onSearchFocused,
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<StatusEntry> &&
selectedEntryType != null &&
selectedEntryType is DiffType.UncommittedDiff && // Added for smartcast
selectedEntryType.statusEntry == treeEntry.data
UncommittedTreeItemEntry(
treeEntry,
isSelected = isEntrySelected,
actionTitle = actionTitle,
actionColor = actionColor,
onClick = {
if (treeEntry is TreeItem.File<StatusEntry>) {
onDiffEntrySelected(treeEntry.data)
} else if (treeEntry is TreeItem.Dir) {
onTreeDirectoryClicked(treeEntry)
}
},
onButtonClick = {
if (treeEntry is TreeItem.File<StatusEntry>) {
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,
onSearchFocused: () -> Unit,
searchFilter: TextFieldValue,
onSearchFilterChanged: (TextFieldValue) -> 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) }
val headerHoverInteraction = remember { MutableInteractionSource() }
val isHeaderHovered by headerHoverInteraction.collectIsHoveredAsState()
Column {
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 = {
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)
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,
onSearchFocused = onSearchFocused,
onClose = { onSearchFilterToggled(false) },
)
}
LaunchedEffect(showSearch, requestFocus) {
if (showSearch && requestFocus) {
searchFocusRequester.requestFocus()
requestFocus = false
}
}
}
}
@Composable
private fun UncommittedFileEntry(
statusEntry: StatusEntry,
isSelected: Boolean,
showDirectory: Boolean,
actionTitle: String,
actionColor: Color,
onClick: () -> Unit,
onButtonClick: () -> Unit,
depth: Int = 0,
onGenerateContextMenu: (StatusEntry) -> List<ContextMenuElement>,
) {
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<StatusEntry>,
isSelected: Boolean,
actionTitle: String,
actionColor: Color,
onClick: () -> Unit,
onDoubleClick: () -> Unit,
onGenerateContextMenu: (StatusEntry) -> List<ContextMenuElement>,
) {
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<StatusEntry>,
isSelected: Boolean,
actionTitle: String,
actionColor: Color,
onClick: () -> Unit,
onButtonClick: () -> Unit,
onGenerateContextMenu: (StatusEntry) -> List<ContextMenuElement>,
onGenerateDirectoryContextMenu: (TreeItem.Dir) -> List<ContextMenuElement>,
) {
when (entry) {
is TreeItem.File -> TreeFileEntry(
entry,
isSelected,
actionTitle,
actionColor,
onClick,
onButtonClick,
onGenerateContextMenu,
)
is TreeItem.Dir -> DirectoryEntry(
dirName = entry.displayName,
isExpanded = entry.isExpanded,
onClick = onClick,
depth = entry.depth,
onGenerateContextMenu = { onGenerateDirectoryContextMenu(entry) },
)
}
}
//@Composable
//private fun TreeItemEntry(
// entry: TreeItem<StatusEntry>,
// isSelected: Boolean,
// actionTitle: String,
// actionColor: Color,
// onClick: () -> Unit,
// onButtonClick: () -> Unit,
// onGenerateContextMenu: (StatusEntry) -> List<ContextMenuElement>,
// onGenerateDirectoryContextMenu: (TreeItem.Dir) -> List<ContextMenuElement>,
//) {
// 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,
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))
}
}
}