Gitnuro/src/main/kotlin/com/jetpackduba/gitnuro/ui/log/Log.kt

1200 lines
41 KiB
Kotlin

@file:OptIn(ExperimentalComposeUiApi::class, ExperimentalFoundationApi::class)
package com.jetpackduba.gitnuro.ui.log
import androidx.compose.foundation.*
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.gestures.rememberDraggableState
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
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.draw.clipToBounds
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.clipRect
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.pointer.pointerHoverIcon
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.jetpackduba.gitnuro.AppIcons
import com.jetpackduba.gitnuro.extensions.*
import com.jetpackduba.gitnuro.git.graph.GraphCommitList
import com.jetpackduba.gitnuro.git.graph.GraphNode
import com.jetpackduba.gitnuro.git.workspace.StatusSummary
import com.jetpackduba.gitnuro.keybindings.KeybindingOption
import com.jetpackduba.gitnuro.keybindings.matchesBinding
import com.jetpackduba.gitnuro.theme.*
import com.jetpackduba.gitnuro.ui.SelectedItem
import com.jetpackduba.gitnuro.ui.components.*
import com.jetpackduba.gitnuro.ui.context_menu.*
import com.jetpackduba.gitnuro.ui.dialogs.NewBranchDialog
import com.jetpackduba.gitnuro.ui.dialogs.NewTagDialog
import com.jetpackduba.gitnuro.ui.dialogs.ResetBranchDialog
import com.jetpackduba.gitnuro.ui.dialogs.SetDefaultUpstreamBranchDialog
import com.jetpackduba.gitnuro.ui.resizePointerIconEast
import com.jetpackduba.gitnuro.viewmodels.LogSearch
import com.jetpackduba.gitnuro.viewmodels.LogStatus
import com.jetpackduba.gitnuro.viewmodels.LogViewModel
import kotlinx.coroutines.launch
import org.eclipse.jgit.lib.Ref
import org.eclipse.jgit.lib.RepositoryState
import org.eclipse.jgit.revwalk.RevCommit
private val colors = listOf(
Color(0xFF42a5f5),
Color(0xFFef5350),
Color(0xFFe78909c),
Color(0xFFff7043),
Color(0xFF66bb6a),
Color(0xFFec407a),
)
private const val CANVAS_MIN_WIDTH = 100
private const val CANVAS_DEFAULT_WIDTH = 120
private const val MIN_GRAPH_LANES = 2
/**
* Additional number of lanes to simulate to create a margin at the end of the graph.
*/
private const val MARGIN_GRAPH_LANES = 2
private const val LANE_WIDTH = 30f
private const val DIVIDER_WIDTH = 8
private const val LINE_HEIGHT = 40
private const val LOG_BOTTOM_PADDING = 80
// TODO Min size for message column
@OptIn(
ExperimentalFoundationApi::class, ExperimentalComposeUiApi::class
)
@Composable
fun Log(
logViewModel: LogViewModel = gitnuroViewModel(),
selectedItem: SelectedItem,
repositoryState: RepositoryState,
) {
val logStatusState = logViewModel.logStatus.collectAsState()
val logStatus = logStatusState.value
val showLogDialog by logViewModel.logDialog.collectAsState()
when (logStatus) {
is LogStatus.Loaded -> LogLoaded(logViewModel, logStatus, showLogDialog, selectedItem, repositoryState)
LogStatus.Loading -> LogLoading()
}
}
@Composable
private fun LogLoading() {
Column (
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colors.background),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
CircularProgressIndicator()
Text(
text = "Loading commits history...",
style = MaterialTheme.typography.h4,
modifier = Modifier.padding(top = 8.dp),
)
}
}
@Composable
private fun LogLoaded(
logViewModel: LogViewModel,
logStatus: LogStatus.Loaded,
showLogDialog: LogDialog,
selectedItem: SelectedItem,
repositoryState: RepositoryState
) {
val scope = rememberCoroutineScope()
val commitList = logStatus.plotCommitList
val verticalScrollState by logViewModel.verticalListState.collectAsState()
val horizontalScrollState by logViewModel.horizontalListState.collectAsState()
val searchFilter = logViewModel.logSearchFilterResults.collectAsState()
val searchFilterValue = searchFilter.value
val selectedCommit = if (selectedItem is SelectedItem.CommitBasedItem) {
selectedItem.revCommit
} else {
null
}
LaunchedEffect(verticalScrollState, commitList) {
launch {
logViewModel.focusCommit.collect { commit ->
scrollToCommit(verticalScrollState, commitList, commit)
}
}
launch {
logViewModel.scrollToUncommitedChanges.collect {
scrollToUncommitedChanges(verticalScrollState, commitList)
}
}
}
LogDialogs(
logViewModel,
onResetShowLogDialog = { logViewModel.showDialog(LogDialog.None) },
showLogDialog = showLogDialog,
)
Column(
modifier = Modifier
.background(MaterialTheme.colors.background)
.fillMaxSize()
) {
var graphPadding by remember(logViewModel) { mutableStateOf(logViewModel.graphPadding) }
var graphWidth = (CANVAS_DEFAULT_WIDTH + graphPadding).dp
if (graphWidth.value < CANVAS_MIN_WIDTH) graphWidth = CANVAS_MIN_WIDTH.dp
val maxLinePosition = if (commitList.isNotEmpty())
commitList.maxLine
else
MIN_GRAPH_LANES
var graphRealWidth = ((maxLinePosition + MARGIN_GRAPH_LANES) * LANE_WIDTH).dp
// Using remember(graphRealWidth, graphWidth) makes the selected background color glitch when changing tabs
if (graphRealWidth < graphWidth) {
graphRealWidth = graphWidth
}
if (searchFilterValue is LogSearch.SearchResults) {
SearchFilter(logViewModel, searchFilterValue)
}
GraphHeader(
graphWidth = graphWidth,
onPaddingChange = {
graphPadding += it
logViewModel.graphPadding = graphPadding
},
onShowSearch = { scope.launch { logViewModel.onSearchValueChanged("") } }
)
Box {
Box(
Modifier
.width(graphWidth)
.fillMaxHeight()
) {
Box(
modifier = Modifier
.fillMaxSize()
.horizontalScroll(horizontalScrollState)
.padding(bottom = 8.dp)
) {
Box(
modifier = Modifier.width(graphRealWidth)
)
}
}
// The commits' messages list overlaps with the graph list to catch all the click events but leaves
// a padding, so it doesn't cover the graph
MessagesList(
scrollState = verticalScrollState,
searchFilter = if (searchFilterValue is LogSearch.SearchResults) searchFilterValue.commits else null,
selectedCommit = selectedCommit,
logStatus = logStatus,
repositoryState = repositoryState,
selectedItem = selectedItem,
commitList = commitList,
logViewModel = logViewModel,
commitsLimit = logStatus.commitsLimit,
onMerge = { ref ->
logViewModel.mergeBranch(ref)
},
onRebase = { ref ->
logViewModel.rebaseBranch(ref)
},
onShowLogDialog = { dialog ->
logViewModel.showDialog(dialog)
},
graphWidth = graphWidth,
horizontalScrollState = horizontalScrollState
)
val density = LocalDensity.current.density
DividerLog(
modifier = Modifier.draggable(
rememberDraggableState {
graphPadding += it / density
logViewModel.graphPadding = graphPadding
}, Orientation.Horizontal
),
graphWidth = graphWidth,
)
// Scrollbar used to scroll horizontally the graph nodes
// Added after every component to have the highest priority when clicking
HorizontalScrollbar(
modifier = Modifier
.align(Alignment.BottomStart)
.width(graphWidth)
.padding(start = 4.dp, bottom = 4.dp), style = LocalScrollbarStyle.current.copy(
unhoverColor = MaterialTheme.colors.scrollbarNormal,
hoverColor = MaterialTheme.colors.scrollbarHover,
),
adapter = rememberScrollbarAdapter(horizontalScrollState)
)
val isFirstItemVisible by remember {
derivedStateOf { verticalScrollState.firstVisibleItemIndex > 0 }
}
if (isFirstItemVisible) {
Box(
modifier = Modifier
.align(Alignment.TopCenter)
.padding(top = 16.dp)
.clip(RoundedCornerShape(50))
.handMouseClickable {
scope.launch {
verticalScrollState.scrollToItem(0)
}
}
.background(MaterialTheme.colors.primary)
.padding(vertical = 4.dp, horizontal = 8.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
painterResource(AppIcons.ALIGN_TOP),
contentDescription = null,
tint = MaterialTheme.colors.onPrimary,
modifier = Modifier.size(20.dp),
)
Text(
text = "Scroll to top",
modifier = Modifier.padding(start = 8.dp),
color = MaterialTheme.colors.onPrimary,
style = MaterialTheme.typography.body2,
)
}
}
}
}
}
}
suspend fun scrollToCommit(
verticalScrollState: LazyListState,
commitList: GraphCommitList,
commit: RevCommit?,
) {
val index = commitList.indexOfFirst { it.name == commit?.name }
// TODO Show a message informing the user why we aren't scrolling
// Index can be -1 if the ref points to a commit that is not shown in the graph due to the limited
// number of displayed commits.
if (index >= 0) verticalScrollState.scrollToItem(index)
}
suspend fun scrollToUncommitedChanges(
verticalScrollState: LazyListState,
commitList: GraphCommitList,
) {
if (commitList.isNotEmpty())
verticalScrollState.scrollToItem(0)
}
@Composable
fun SearchFilter(
logViewModel: LogViewModel,
searchFilterResults: LogSearch.SearchResults
) {
val scope = rememberCoroutineScope()
var searchFilterText by remember { mutableStateOf(logViewModel.savedSearchFilter) }
val textFieldFocusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) {
textFieldFocusRequester.requestFocus()
}
Row(
modifier = Modifier
.fillMaxWidth()
.height(56.dp),
verticalAlignment = Alignment.CenterVertically,
) {
TextField(
value = searchFilterText,
onValueChange = {
searchFilterText = it
scope.launch {
logViewModel.onSearchValueChanged(it)
}
},
maxLines = 1,
modifier = Modifier
.fillMaxSize()
.focusRequester(textFieldFocusRequester)
.onPreviewKeyEvent { keyEvent ->
when {
keyEvent.matchesBinding(KeybindingOption.SIMPLE_ACCEPT) -> {
scope.launch {
logViewModel.selectNextFilterCommit()
}
true
}
keyEvent.matchesBinding(KeybindingOption.EXIT) -> {
logViewModel.closeSearch()
true
}
else -> false
}
},
label = {
Text("Search by message, author name or commit ID")
},
colors = textFieldColors(),
textStyle = MaterialTheme.typography.body1,
trailingIcon = {
Row(
modifier = Modifier
.fillMaxHeight(),
verticalAlignment = Alignment.CenterVertically,
) {
if (searchFilterText.isNotEmpty()) {
Text(
"${searchFilterResults.index}/${searchFilterResults.totalCount}",
color = MaterialTheme.colors.onBackgroundSecondary,
)
}
IconButton(
modifier = Modifier
.handOnHover(),
onClick = {
scope.launch { logViewModel.selectPreviousFilterCommit() }
}
) {
Icon(Icons.Default.KeyboardArrowUp, contentDescription = null)
}
IconButton(
modifier = Modifier
.handOnHover(),
onClick = {
scope.launch { logViewModel.selectNextFilterCommit() }
}
) {
Icon(Icons.Default.KeyboardArrowDown, contentDescription = null)
}
IconButton(
modifier = Modifier
.handOnHover()
.padding(end = 4.dp),
onClick = { logViewModel.closeSearch() }
) {
Icon(Icons.Default.Clear, contentDescription = null)
}
}
}
)
}
}
@Composable
fun MessagesList(
scrollState: LazyListState,
searchFilter: List<GraphNode>?,
selectedCommit: RevCommit?,
logStatus: LogStatus.Loaded,
repositoryState: RepositoryState,
selectedItem: SelectedItem,
commitList: GraphCommitList,
logViewModel: LogViewModel,
commitsLimit: Int,
onMerge: (Ref) -> Unit,
onRebase: (Ref) -> Unit,
onShowLogDialog: (LogDialog) -> Unit,
graphWidth: Dp,
horizontalScrollState: ScrollState,
) {
ScrollableLazyColumn(
state = scrollState,
modifier = Modifier.fillMaxSize(),
) {
item {
Box(
modifier = Modifier.height(LINE_HEIGHT.dp)
.clipToBounds()
.fillMaxWidth()
.clickable { logViewModel.selectUncommitedChanges() }
) {
UncommitedChangesGraphNode(
isSelected = selectedItem is SelectedItem.UncommitedChanges,
modifier = Modifier.offset(-horizontalScrollState.value.dp)
)
UncommitedChangesLine(
graphWidth = graphWidth,
isSelected = selectedItem == SelectedItem.UncommitedChanges,
statusSummary = logStatus.statusSummary,
repositoryState = repositoryState,
)
}
}
// Setting a key makes the graph preserve the scroll position when a new line has been added on top (uncommited changes)
// Therefore, after popping a stash, the uncommited changes wouldn't be visible and requires the user scrolling.
items(items = commitList) { graphNode ->
CommitLine(
graphWidth = graphWidth,
logViewModel = logViewModel,
graphNode = graphNode,
isSelected = selectedCommit?.name == graphNode.name,
currentBranch = logStatus.currentBranch,
matchesSearchFilter = searchFilter?.contains(graphNode),
horizontalScrollState = horizontalScrollState,
showCreateNewBranch = { onShowLogDialog(LogDialog.NewBranch(graphNode)) },
showCreateNewTag = { onShowLogDialog(LogDialog.NewTag(graphNode)) },
resetBranch = { onShowLogDialog(LogDialog.ResetBranch(graphNode)) },
onMergeBranch = onMerge,
onRebaseBranch = onRebase,
onRebaseInteractive = { logViewModel.rebaseInteractive(graphNode) },
onRevCommitSelected = { logViewModel.selectLogLine(graphNode) },
onChangeDefaultUpstreamBranch = { onShowLogDialog(LogDialog.ChangeDefaultBranch(it)) }
)
}
if (commitsLimit >= 0 && commitsLimit <= commitList.count()) {
item {
Box(
modifier = Modifier
.padding(start = graphWidth + 24.dp)
.height(LINE_HEIGHT.dp),
contentAlignment = Alignment.CenterStart,
) {
Text(
text = "The commits list has been limited to $commitsLimit. Access the settings to change it.",
color = MaterialTheme.colors.onBackground,
fontStyle = FontStyle.Italic,
style = MaterialTheme.typography.body2,
maxLines = 1,
)
}
}
}
item {
Box(modifier = Modifier.height(LOG_BOTTOM_PADDING.dp))
}
}
}
@Composable
fun LogDialogs(
logViewModel: LogViewModel,
onResetShowLogDialog: () -> Unit,
showLogDialog: LogDialog,
) {
when (showLogDialog) {
is LogDialog.NewBranch -> {
NewBranchDialog(onClose = onResetShowLogDialog, onAccept = { branchName ->
logViewModel.createBranchOnCommit(branchName, showLogDialog.graphNode)
onResetShowLogDialog()
})
}
is LogDialog.NewTag -> {
NewTagDialog(onReject = onResetShowLogDialog, onAccept = { tagName ->
logViewModel.createTagOnCommit(tagName, showLogDialog.graphNode)
onResetShowLogDialog()
})
}
is LogDialog.ResetBranch -> ResetBranchDialog(onReject = onResetShowLogDialog, onAccept = { resetType ->
logViewModel.resetToCommit(showLogDialog.graphNode, resetType)
onResetShowLogDialog()
})
LogDialog.None -> {
}
is LogDialog.ChangeDefaultBranch -> {
SetDefaultUpstreamBranchDialog(
viewModel = gitnuroDynamicViewModel(),
branch = showLogDialog.ref,
onClose = { onResetShowLogDialog() },
)
}
}
}
@Composable
fun GraphHeader(
graphWidth: Dp,
onPaddingChange: (Float) -> Unit,
onShowSearch: () -> Unit
) {
Box(
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(34.dp)
.background(MaterialTheme.colors.tertiarySurface),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
modifier = Modifier
.width(graphWidth)
.padding(start = 16.dp),
text = "Graph",
color = MaterialTheme.colors.onBackground,
style = MaterialTheme.typography.body2,
maxLines = 1,
)
val density = LocalDensity.current.density
SimpleDividerLog(
modifier = Modifier.draggable(
rememberDraggableState {
onPaddingChange(it * density) // Multiply by density for screens with scaling > 1
}, Orientation.Horizontal
),
)
Text(
modifier = Modifier
.padding(start = 16.dp)
.weight(1f),
text = "Message",
color = MaterialTheme.colors.onBackground,
style = MaterialTheme.typography.body2,
maxLines = 1,
)
IconButton(
modifier = Modifier
.padding(end = 8.dp)
.handOnHover(),
onClick = onShowSearch
) {
Icon(
Icons.Default.Search,
modifier = Modifier.size(18.dp),
contentDescription = null,
tint = MaterialTheme.colors.onBackground,
)
}
}
}
}
@Composable
fun UncommitedChangesLine(
graphWidth: Dp,
isSelected: Boolean,
repositoryState: RepositoryState,
statusSummary: StatusSummary,
) {
Row(
modifier = Modifier
.height(40.dp)
.padding(start = graphWidth)
.backgroundIf(isSelected, MaterialTheme.colors.backgroundSelected)
.padding(DIVIDER_WIDTH.dp),
verticalAlignment = Alignment.CenterVertically,
) {
val text = when {
repositoryState.isRebasing -> "Pending changes to rebase"
repositoryState.isMerging -> "Pending changes to merge"
repositoryState.isCherryPicking -> "Pending changes to cherry-pick"
repositoryState.isReverting -> "Pending changes to revert"
else -> "Uncommited changes"
}
Text(
text = text,
fontStyle = FontStyle.Italic,
modifier = Modifier.padding(start = 16.dp),
style = MaterialTheme.typography.body2,
maxLines = 1,
color = MaterialTheme.colors.onBackground,
)
Spacer(modifier = Modifier.weight(1f))
LogStatusSummary(
statusSummary = statusSummary,
modifier = Modifier.padding(horizontal = 8.dp),
)
}
}
@Composable
fun LogStatusSummary(statusSummary: StatusSummary, modifier: Modifier) {
Row(
modifier = modifier,
) {
if (statusSummary.modifiedCount > 0) {
SummaryEntry(
count = statusSummary.modifiedCount,
icon = Icons.Default.Edit,
color = MaterialTheme.colors.modifyFile,
)
}
if (statusSummary.addedCount > 0) {
SummaryEntry(
count = statusSummary.addedCount,
icon = Icons.Default.Add,
color = MaterialTheme.colors.addFile,
)
}
if (statusSummary.deletedCount > 0) {
SummaryEntry(
count = statusSummary.deletedCount,
icon = Icons.Default.Delete,
color = MaterialTheme.colors.deleteFile,
)
}
if (statusSummary.conflictingCount > 0) {
SummaryEntry(
count = statusSummary.conflictingCount,
icon = Icons.Default.Warning,
color = MaterialTheme.colors.conflictFile,
)
}
}
}
@Composable
fun SummaryEntry(
count: Int, icon: ImageVector, color: Color
) {
Row(
modifier = Modifier.padding(horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = count.toString(),
style = MaterialTheme.typography.body2,
color = MaterialTheme.colors.onBackground,
)
Icon(
imageVector = icon, tint = color, contentDescription = null, modifier = Modifier.size(14.dp)
)
}
}
@Composable
fun CommitLine(
graphWidth: Dp,
logViewModel: LogViewModel,
graphNode: GraphNode,
isSelected: Boolean,
currentBranch: Ref?,
matchesSearchFilter: Boolean?,
showCreateNewBranch: () -> Unit,
showCreateNewTag: () -> Unit,
resetBranch: () -> Unit,
onMergeBranch: (Ref) -> Unit,
onRebaseBranch: (Ref) -> Unit,
onRevCommitSelected: () -> Unit,
onRebaseInteractive: () -> Unit,
onChangeDefaultUpstreamBranch: (Ref) -> Unit,
horizontalScrollState: ScrollState,
) {
val isLastCommitOfCurrentBranch = currentBranch?.objectId?.name == graphNode.id.name
ContextMenu(
items = {
logContextMenu(
onCheckoutCommit = { logViewModel.checkoutCommit(graphNode) },
onCreateNewBranch = showCreateNewBranch,
onCreateNewTag = showCreateNewTag,
onRevertCommit = { logViewModel.revertCommit(graphNode) },
onCherryPickCommit = { logViewModel.cherrypickCommit(graphNode) },
onRebaseInteractive = onRebaseInteractive,
onResetBranch = { resetBranch() },
isLastCommit = isLastCommitOfCurrentBranch
)
},
) {
Box(
modifier = Modifier
.clickable { onRevCommitSelected() }
) {
val nodeColor = colors[graphNode.lane.position % colors.size]
Box {
Row(
modifier = Modifier
.clipToBounds()
.height(LINE_HEIGHT.dp)
.fillMaxWidth()
.offset(-horizontalScrollState.value.dp)
) {
CommitsGraphLine(
modifier = Modifier
.fillMaxHeight(),
plotCommit = graphNode,
nodeColor = nodeColor,
isSelected = isSelected,
)
}
}
Box(
modifier = Modifier
.padding(start = graphWidth)
.height(LINE_HEIGHT.dp)
.background(MaterialTheme.colors.background)
.backgroundIf(isSelected, MaterialTheme.colors.backgroundSelected)
) {
if (matchesSearchFilter == true) {
Box(
modifier = Modifier
.padding(start = DIVIDER_WIDTH.dp)
.background(MaterialTheme.colors.secondary)
.fillMaxHeight()
.width(4.dp)
)
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(end = 4.dp),
) {
CommitMessage(
commit = graphNode,
refs = graphNode.refs,
nodeColor = nodeColor,
matchesSearchFilter = matchesSearchFilter,
currentBranch = currentBranch,
onCheckoutRef = { ref -> logViewModel.checkoutRef(ref) },
onMergeBranch = { ref -> onMergeBranch(ref) },
onDeleteBranch = { ref -> logViewModel.deleteBranch(ref) },
onDeleteRemoteBranch = { ref -> logViewModel.deleteRemoteBranch(ref) },
onDeleteTag = { ref -> logViewModel.deleteTag(ref) },
onRebaseBranch = { ref -> onRebaseBranch(ref) },
onPushRemoteBranch = { ref -> logViewModel.pushToRemoteBranch(ref) },
onPullRemoteBranch = { ref -> logViewModel.pullFromRemoteBranch(ref) },
onChangeDefaultUpstreamBranch = { ref -> onChangeDefaultUpstreamBranch(ref) },
)
}
}
}
}
}
@Composable
fun CommitMessage(
commit: RevCommit,
refs: List<Ref>,
currentBranch: Ref?,
nodeColor: Color,
matchesSearchFilter: Boolean?,
onCheckoutRef: (ref: Ref) -> Unit,
onMergeBranch: (ref: Ref) -> Unit,
onDeleteBranch: (ref: Ref) -> Unit,
onDeleteRemoteBranch: (ref: Ref) -> Unit,
onRebaseBranch: (ref: Ref) -> Unit,
onDeleteTag: (ref: Ref) -> Unit,
onPushRemoteBranch: (ref: Ref) -> Unit,
onPullRemoteBranch: (ref: Ref) -> Unit,
onChangeDefaultUpstreamBranch: (ref: Ref) -> Unit,
) {
Row(
modifier = Modifier.fillMaxSize(),
verticalAlignment = Alignment.CenterVertically,
) {
Row(
modifier = Modifier.padding(start = 16.dp)
) {
refs.sortedWith { ref1, ref2 ->
if (ref1.isSameBranch(currentBranch)) {
-1
} else {
ref1.name.compareTo(ref2.name)
}
}.forEach { ref ->
if (ref.isTag) {
TagChip(
ref = ref,
color = nodeColor,
onCheckoutTag = { onCheckoutRef(ref) },
onDeleteTag = { onDeleteTag(ref) },
)
} else if (ref.isBranch) {
BranchChip(
ref = ref,
color = nodeColor,
currentBranch = currentBranch,
isCurrentBranch = ref.isSameBranch(currentBranch),
onCheckoutBranch = { onCheckoutRef(ref) },
onMergeBranch = { onMergeBranch(ref) },
onDeleteBranch = { onDeleteBranch(ref) },
onDeleteRemoteBranch = { onDeleteRemoteBranch(ref) },
onRebaseBranch = { onRebaseBranch(ref) },
onPullRemoteBranch = { onPullRemoteBranch(ref) },
onPushRemoteBranch = { onPushRemoteBranch(ref) },
onChangeDefaultUpstreamBranch = { onChangeDefaultUpstreamBranch(ref) },
)
}
}
}
val message = remember(commit.id.name) {
commit.getShortMessageTrimmed()
}
Text(
text = message,
modifier = Modifier
.padding(start = 8.dp)
.weight(1f),
style = MaterialTheme.typography.body2,
color = if (matchesSearchFilter == false) MaterialTheme.colors.onBackgroundSecondary else MaterialTheme.colors.onBackground,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = commit.authorIdent.`when`.toSmartSystemString(),
modifier = Modifier.padding(horizontal = 16.dp),
style = MaterialTheme.typography.caption,
color = MaterialTheme.colors.onBackgroundSecondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
@Composable
fun DividerLog(modifier: Modifier, graphWidth: Dp) {
Box(
modifier = Modifier
.padding(start = graphWidth)
.width(DIVIDER_WIDTH.dp)
.then(modifier)
.pointerHoverIcon(resizePointerIconEast)
) {
Box(
modifier = Modifier
.fillMaxHeight()
.width(1.dp)
.background(color = MaterialTheme.colors.primaryVariant)
.align(Alignment.Center)
)
}
}
@Composable
fun SimpleDividerLog(modifier: Modifier) {
DividerLog(modifier, graphWidth = 0.dp)
}
@Composable
fun CommitsGraphLine(
modifier: Modifier = Modifier,
plotCommit: GraphNode,
nodeColor: Color,
isSelected: Boolean,
) {
val passingLanes = plotCommit.passingLanes
val forkingOffLanes = plotCommit.forkingOffLanes
val mergingLanes = plotCommit.mergingLanes
val density = LocalDensity.current.density
val laneWidthWithDensity = remember(density) {
LANE_WIDTH * density
}
Box(
modifier = modifier
.backgroundIf(isSelected, MaterialTheme.colors.backgroundSelected)
.fillMaxHeight(),
contentAlignment = Alignment.CenterStart,
) {
val itemPosition = plotCommit.lane.position
Canvas(
modifier = Modifier.fillMaxSize()
) {
clipRect {
if (plotCommit.childCount > 0) {
drawLine(
color = colors[itemPosition % colors.size],
start = Offset(laneWidthWithDensity * (itemPosition + 1), this.center.y),
end = Offset(laneWidthWithDensity * (itemPosition + 1), 0f),
strokeWidth = 2f,
)
}
forkingOffLanes.forEach { plotLane ->
drawLine(
color = colors[plotLane.position % colors.size],
start = Offset(laneWidthWithDensity * (itemPosition + 1), this.center.y),
end = Offset(laneWidthWithDensity * (plotLane.position + 1), 0f),
strokeWidth = 2f,
)
}
mergingLanes.forEach { plotLane ->
drawLine(
color = colors[plotLane.position % colors.size],
start = Offset(laneWidthWithDensity * (plotLane.position + 1), this.size.height),
end = Offset(laneWidthWithDensity * (itemPosition + 1), this.center.y),
strokeWidth = 2f,
)
}
if (plotCommit.parentCount > 0) {
drawLine(
color = colors[itemPosition % colors.size],
start = Offset(laneWidthWithDensity * (itemPosition + 1), this.center.y),
end = Offset(laneWidthWithDensity * (itemPosition + 1), this.size.height),
strokeWidth = 2f,
)
}
passingLanes.forEach { plotLane ->
drawLine(
color = colors[plotLane.position % colors.size],
start = Offset(laneWidthWithDensity * (plotLane.position + 1), 0f),
end = Offset(laneWidthWithDensity * (plotLane.position + 1), this.size.height),
strokeWidth = 2f,
)
}
}
}
CommitNode(
modifier = Modifier
.align(Alignment.CenterStart)
.padding(start = ((itemPosition + 1) * 30 - 15).dp),
plotCommit = plotCommit,
color = nodeColor,
)
}
}
@Composable
fun CommitNode(
modifier: Modifier = Modifier,
plotCommit: GraphNode,
color: Color,
) {
val author = plotCommit.authorIdent
Tooltip("${author.name} <${author.emailAddress}>") {
Box(
modifier = modifier
.size(30.dp)
.border(2.dp, color, shape = CircleShape)
.clip(CircleShape)
) {
AvatarImage(
modifier = Modifier.fillMaxSize(),
personIdent = plotCommit.authorIdent,
color = color,
)
}
}
}
@Composable
fun UncommitedChangesGraphNode(
modifier: Modifier = Modifier,
isSelected: Boolean,
) {
val density = LocalDensity.current.density
val laneWidthWithDensity = remember(density) {
LANE_WIDTH * density
}
Box(
modifier = modifier
.backgroundIf(isSelected, MaterialTheme.colors.backgroundSelected)
) {
Canvas(
modifier = Modifier.fillMaxSize()
) {
clipRect {
drawLine(
color = colors[0],
start = Offset(laneWidthWithDensity, this.center.y),
end = Offset(laneWidthWithDensity, this.size.height),
strokeWidth = 2f,
)
drawCircle(
color = colors[0],
radius = 15f * density,
center = Offset(laneWidthWithDensity, this.center.y),
)
}
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun BranchChip(
modifier: Modifier = Modifier,
isCurrentBranch: Boolean = false,
ref: Ref,
currentBranch: Ref?,
onCheckoutBranch: () -> Unit,
onMergeBranch: () -> Unit,
onDeleteBranch: () -> Unit,
onDeleteRemoteBranch: () -> Unit,
onRebaseBranch: () -> Unit,
onPushRemoteBranch: () -> Unit,
onPullRemoteBranch: () -> Unit,
onChangeDefaultUpstreamBranch: () -> Unit,
color: Color,
) {
val contextMenuItemsList = {
branchContextMenuItems(
branch = ref,
currentBranch = currentBranch,
isCurrentBranch = isCurrentBranch,
isLocal = ref.isLocal,
onCheckoutBranch = onCheckoutBranch,
onMergeBranch = onMergeBranch,
onDeleteBranch = onDeleteBranch,
onDeleteRemoteBranch = onDeleteRemoteBranch,
onRebaseBranch = onRebaseBranch,
onPushToRemoteBranch = onPushRemoteBranch,
onPullFromRemoteBranch = onPullRemoteBranch,
onChangeDefaultUpstreamBranch = onChangeDefaultUpstreamBranch,
)
}
var endingContent: @Composable () -> Unit = {}
if (isCurrentBranch) {
endingContent = {
Icon(
painter = painterResource(AppIcons.LOCATION),
contentDescription = null,
modifier = Modifier.padding(end = 6.dp),
tint = MaterialTheme.colors.primaryVariant,
)
}
}
RefChip(
modifier = modifier,
color = color,
ref = ref,
icon = AppIcons.BRANCH,
onCheckoutRef = onCheckoutBranch,
contextMenuItemsList = contextMenuItemsList,
endingContent = endingContent,
)
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun TagChip(
modifier: Modifier = Modifier,
ref: Ref,
onCheckoutTag: () -> Unit,
onDeleteTag: () -> Unit,
color: Color,
) {
val contextMenuItemsList = {
tagContextMenuItems(
onCheckoutTag = onCheckoutTag,
onDeleteTag = onDeleteTag,
)
}
RefChip(
modifier,
ref,
AppIcons.TAG,
onCheckoutRef = onCheckoutTag,
contextMenuItemsList = contextMenuItemsList,
color = color,
)
}
@OptIn(ExperimentalFoundationApi::class, ExperimentalComposeUiApi::class)
@Composable
fun RefChip(
modifier: Modifier = Modifier,
ref: Ref,
icon: String,
color: Color,
onCheckoutRef: () -> Unit,
contextMenuItemsList: () -> List<ContextMenuElement>,
endingContent: @Composable () -> Unit = {},
) {
Box(
modifier = Modifier
.padding(horizontal = 4.dp)
.clip(RoundedCornerShape(16.dp))
.border(width = 2.dp, color = color, shape = RoundedCornerShape(16.dp))
.combinedClickable(onDoubleClick = onCheckoutRef, onClick = {})
.handOnHover()
) {
ContextMenu(
items = contextMenuItemsList
) {
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically,
) {
Box(modifier = Modifier.background(color = color)) {
Icon(
modifier = Modifier
.padding(6.dp)
.size(14.dp),
painter = painterResource(icon),
contentDescription = null,
tint = MaterialTheme.colors.background,
)
}
Text(
text = ref.simpleLogName,
style = MaterialTheme.typography.body2,
color = MaterialTheme.colors.onBackground,
maxLines = 1,
modifier = Modifier.padding(horizontal = 6.dp)
)
endingContent()
}
}
}
}