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

1201 lines
42 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.LazyColumn
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.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.KeyEventType
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.input.pointer.PointerIcon
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.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.AvatarImage
import com.jetpackduba.gitnuro.ui.components.ScrollableLazyColumn
import com.jetpackduba.gitnuro.ui.components.gitnuroViewModel
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.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
import java.awt.Cursor
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 scope = rememberCoroutineScope()
val logStatusState = logViewModel.logStatus.collectAsState()
val logStatus = logStatusState.value
val showLogDialog by logViewModel.logDialog.collectAsState()
val selectedCommit = if (selectedItem is SelectedItem.CommitBasedItem) {
selectedItem.revCommit
} else {
null
}
if (logStatus is LogStatus.Loaded) {
val hasUncommitedChanges = logStatus.hasUncommitedChanges
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
// With this method, whenever the scroll changes, the log is recomposed and the graph list is updated with
// the proper scroll position
verticalScrollState.observeScrollChanges()
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
if (searchFilterValue is LogSearch.SearchResults) {
SearchFilter(logViewModel, searchFilterValue)
}
GraphHeader(
graphWidth = graphWidth,
onPaddingChange = {
graphPadding += it
logViewModel.graphPadding = graphPadding
},
onShowSearch = { scope.launch { logViewModel.onSearchValueChanged("") } }
)
Box {
GraphList(
commitList = commitList,
selectedCommit = selectedCommit,
selectedItem = selectedItem,
repositoryState = repositoryState,
horizontalScrollState = horizontalScrollState,
graphWidth = graphWidth,
verticalScrollState = verticalScrollState,
hasUncommitedChanges = hasUncommitedChanges,
commitsLimit = logStatus.commitsLimit,
)
// 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,
hasUncommitedChanges = hasUncommitedChanges,
searchFilter = if (searchFilterValue is LogSearch.SearchResults) searchFilterValue.commits else null,
selectedCommit = selectedCommit,
logStatus = logStatus,
repositoryState = repositoryState,
selectedItem = selectedItem,
commitList = commitList,
logViewModel = logViewModel,
graphWidth = graphWidth,
commitsLimit = logStatus.commitsLimit,
onMerge = { ref ->
logViewModel.mergeBranch(ref)
},
onRebase = { ref ->
logViewModel.rebaseBranch(ref)
},
onShowLogDialog = { dialog ->
logViewModel.showDialog(dialog)
}
)
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("align_top.svg"),
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(64.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) && keyEvent.type == KeyEventType.KeyUp -> {
scope.launch {
logViewModel.selectNextFilterCommit()
}
true
}
keyEvent.matchesBinding(KeybindingOption.EXIT) && keyEvent.type == KeyEventType.KeyUp -> {
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,
hasUncommitedChanges: Boolean,
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,
) {
ScrollableLazyColumn(
state = scrollState,
modifier = Modifier.fillMaxSize(),
) {
if (
hasUncommitedChanges ||
repositoryState.isMerging ||
repositoryState.isRebasing ||
repositoryState.isCherryPicking
) {
item {
UncommitedChangesLine(
graphWidth = graphWidth,
isSelected = selectedItem == SelectedItem.UncommitedChanges,
statusSummary = logStatus.statusSummary,
repositoryState = repositoryState,
onUncommitedChangesSelected = {
logViewModel.selectUncommitedChanges()
}
)
}
}
// 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),
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) },
)
}
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 GraphList(
commitList: GraphCommitList,
horizontalScrollState: ScrollState,
verticalScrollState: LazyListState,
graphWidth: Dp,
hasUncommitedChanges: Boolean,
selectedCommit: RevCommit?,
selectedItem: SelectedItem,
commitsLimit: Int,
repositoryState: RepositoryState,
) {
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
}
Box(
Modifier
.width(graphWidth)
.fillMaxHeight()
) {
Box(
modifier = Modifier
.fillMaxSize()
.horizontalScroll(horizontalScrollState)
.padding(bottom = 8.dp)
) {
LazyColumn(
state = verticalScrollState, modifier = Modifier.width(graphRealWidth)
) {
if (
hasUncommitedChanges ||
repositoryState.isMerging ||
repositoryState.isRebasing ||
repositoryState.isCherryPicking
) {
item {
Row(
modifier = Modifier
.height(LINE_HEIGHT.dp)
.fillMaxWidth(),
) {
UncommitedChangesGraphNode(
modifier = Modifier.fillMaxSize(),
hasPreviousCommits = commitList.isNotEmpty(),
isSelected = selectedItem is SelectedItem.UncommitedChanges,
)
}
}
}
items(items = commitList) { graphNode ->
val nodeColor = colors[graphNode.lane.position % colors.size]
Row(
modifier = Modifier
.height(LINE_HEIGHT.dp)
.fillMaxWidth(),
) {
CommitsGraphLine(
modifier = Modifier.fillMaxSize(),
plotCommit = graphNode,
nodeColor = nodeColor,
isSelected = selectedCommit?.name == graphNode.name,
)
}
}
// Spacing when the commits limit is present
if (commitsLimit >= 0 && commitsLimit <= commitList.count()) {
item {
Box(
modifier = Modifier
.height(LINE_HEIGHT.dp),
)
}
}
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 -> {
}
}
}
@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,
onUncommitedChangesSelected: () -> Unit,
) {
Row(
modifier = Modifier
.height(40.dp)
.fillMaxWidth()
.handMouseClickable { onUncommitedChangesSelected() }
.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)
)
}
}
@OptIn(ExperimentalFoundationApi::class)
@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,
) {
ContextMenu(
items = {
logContextMenu(
onCheckoutCommit = { logViewModel.checkoutCommit(graphNode) },
onCreateNewBranch = showCreateNewBranch,
onCreateNewTag = showCreateNewTag,
onRevertCommit = { logViewModel.revertCommit(graphNode) },
onCherryPickCommit = { logViewModel.cherrypickCommit(graphNode) },
onRebaseInteractive = onRebaseInteractive,
onResetBranch = { resetBranch() },
)
},
) {
Box(
modifier = Modifier
.fastClickable(graphNode, logViewModel) { onRevCommitSelected() }
.padding(start = graphWidth)
.height(LINE_HEIGHT.dp)
.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),
) {
val nodeColor = colors[graphNode.lane.position % colors.size]
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) },
)
}
}
}
}
@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,
) {
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) },
)
}
}
}
Text(
text = commit.shortMessage,
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(PointerIcon(Cursor(Cursor.E_RESIZE_CURSOR)))
) {
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)
) {
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,
) {
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,
hasPreviousCommits: Boolean,
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 {
if (hasPreviousCommits) 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,
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,
)
}
var endingContent: @Composable () -> Unit = {}
if (isCurrentBranch) {
endingContent = {
Icon(
painter = painterResource("location.svg"),
contentDescription = null,
modifier = Modifier.padding(end = 6.dp),
tint = MaterialTheme.colors.primaryVariant,
)
}
}
RefChip(
modifier = modifier,
color = color,
ref = ref,
icon = "branch.svg",
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,
"tag.svg",
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()
}
}
}
}