@file:OptIn(ExperimentalComposeUiApi::class, ExperimentalFoundationApi::class) @file:Suppress("UNUSED_PARAMETER") package app.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.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.Icon import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.runtime.* 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.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.drawscope.clipRect import androidx.compose.ui.input.pointer.PointerIconDefaults import androidx.compose.ui.input.pointer.pointerHoverIcon import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import app.extensions.* import app.git.GitManager import app.git.LogStatus import app.git.graph.GraphNode import app.images.rememberNetworkImage import app.theme.* import app.ui.SelectedItem import app.ui.components.ScrollableLazyColumn import app.ui.context_menu.branchContextMenuItems import app.ui.context_menu.tagContextMenuItems import app.ui.dialogs.MergeDialog import app.ui.dialogs.NewBranchDialog import app.ui.dialogs.NewTagDialog import app.ui.dialogs.ResetBranchDialog import org.eclipse.jgit.lib.Ref 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 // TODO Min size for message column // TODO Horizontal scroll for the graph @OptIn( ExperimentalFoundationApi::class, ExperimentalComposeUiApi::class ) @Composable fun Log( gitManager: GitManager, selectedItem: SelectedItem, onItemSelected: (SelectedItem) -> Unit, ) { val logStatusState = gitManager.logStatus.collectAsState() val logStatus = logStatusState.value val showLogDialog = remember { mutableStateOf(LogDialog.None) } val selectedCommit = if (selectedItem is SelectedItem.CommitBasedItem) { selectedItem.revCommit } else { null } if (logStatus is LogStatus.Loaded) { val commitList = logStatus.plotCommitList val scrollState = rememberLazyListState() LaunchedEffect(selectedCommit) { // Scroll to commit if a Ref is selected if (selectedItem is SelectedItem.Ref) scrollState.scrollToItem(commitList.indexOfFirst { it.name == selectedCommit?.name }) } LogDialogs( gitManager, currentBranch = logStatus.currentBranch, onResetShowLogDialog = { showLogDialog.value = LogDialog.None }, showLogDialog = showLogDialog.value, ) Column( modifier = Modifier .padding(8.dp) .background(MaterialTheme.colors.background) .fillMaxSize() ) { val hasUncommitedChanges by gitManager.hasUncommitedChanges.collectAsState() val weightMod = remember { mutableStateOf(0f) } var graphWidth = (CANVAS_MIN_WIDTH + weightMod.value).dp if (graphWidth.value < CANVAS_MIN_WIDTH) graphWidth = CANVAS_MIN_WIDTH.dp GraphHeader( graphWidth = graphWidth, weightMod = weightMod, ) ScrollableLazyColumn( state = scrollState, modifier = Modifier .background(MaterialTheme.colors.background) .fillMaxSize(), ) { if (hasUncommitedChanges) item { UncommitedChangesLine( selected = selectedItem == SelectedItem.UncommitedChanges, hasPreviousCommits = commitList.count() > 0, graphWidth = graphWidth, weightMod = weightMod, onUncommitedChangesSelected = { onItemSelected(SelectedItem.UncommitedChanges) } ) } items(items = commitList) { graphNode -> CommitLine( gitManager = gitManager, graphNode = graphNode, selected = selectedCommit?.name == graphNode.name, weightMod = weightMod, graphWidth = graphWidth, currentBranch = logStatus.currentBranch, showCreateNewBranch = { showLogDialog.value = LogDialog.NewBranch(graphNode) }, showCreateNewTag = { showLogDialog.value = LogDialog.NewTag(graphNode) }, resetBranch = { showLogDialog.value = LogDialog.ResetBranch(graphNode) }, onMergeBranch = { ref -> showLogDialog.value = LogDialog.MergeBranch(ref) }, onRevCommitSelected = { onItemSelected(SelectedItem.Commit(graphNode)) } ) } } } } } @Composable fun LogDialogs( gitManager: GitManager, onResetShowLogDialog: () -> Unit, showLogDialog: LogDialog, currentBranch: Ref?, ) { when (showLogDialog) { is LogDialog.NewBranch -> { NewBranchDialog( onReject = onResetShowLogDialog, onAccept = { branchName -> gitManager.createBranchOnCommit(branchName, showLogDialog.graphNode) onResetShowLogDialog() } ) } is LogDialog.NewTag -> { NewTagDialog( onReject = onResetShowLogDialog, onAccept = { tagName -> gitManager.createTagOnCommit(tagName, showLogDialog.graphNode) onResetShowLogDialog() } ) } is LogDialog.MergeBranch -> { if (currentBranch != null) MergeDialog( currentBranchName = currentBranch.simpleName, mergeBranchName = showLogDialog.ref.simpleName, onReject = onResetShowLogDialog, onAccept = { ff -> gitManager.mergeBranch(showLogDialog.ref, ff) onResetShowLogDialog() } ) } is LogDialog.ResetBranch -> ResetBranchDialog( onReject = onResetShowLogDialog, onAccept = { resetType -> gitManager.resetToCommit(showLogDialog.graphNode, resetType) onResetShowLogDialog() } ) LogDialog.None -> { } } } @Composable fun GraphHeader( graphWidth: Dp, weightMod: MutableState, ) { Row( modifier = Modifier .fillMaxWidth() .height(32.dp) .background(MaterialTheme.colors.headerBackground), verticalAlignment = Alignment.CenterVertically, ) { Text( modifier = Modifier .width(graphWidth) .padding(start = 8.dp), text = "Graph", fontWeight = FontWeight.Bold, color = MaterialTheme.colors.headerText, fontSize = 14.sp, maxLines = 1, ) DividerLog( modifier = Modifier.draggable(rememberDraggableState { weightMod.value += it }, Orientation.Horizontal) ) Text( modifier = Modifier .padding(start = 8.dp) .width(graphWidth), text = "Message", fontWeight = FontWeight.Bold, color = MaterialTheme.colors.headerText, fontSize = 14.sp, maxLines = 1, ) } } @Composable fun UncommitedChangesLine( selected: Boolean, hasPreviousCommits: Boolean, graphWidth: Dp, weightMod: MutableState, onUncommitedChangesSelected: () -> Unit ) { val textColor = if (selected) { MaterialTheme.colors.primary } else MaterialTheme.colors.primaryTextColor Row( modifier = Modifier .height(40.dp) .fillMaxWidth() .clickable { onUncommitedChangesSelected() }, ) { UncommitedChangesGraphLine( modifier = Modifier .width(graphWidth), hasPreviousCommits = hasPreviousCommits, ) DividerLog( modifier = Modifier .draggable( rememberDraggableState { weightMod.value += it }, Orientation.Horizontal ) ) Column( modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.Center, ) { Spacer(modifier = Modifier.weight(2f)) Text( text = "Uncommited changes", fontStyle = FontStyle.Italic, modifier = Modifier.padding(start = 16.dp), fontSize = 14.sp, color = textColor, ) Spacer(modifier = Modifier.weight(2f)) } } } @Composable fun CommitLine( gitManager: GitManager, graphNode: GraphNode, selected: Boolean, weightMod: MutableState, graphWidth: Dp, currentBranch: Ref?, showCreateNewBranch: () -> Unit, showCreateNewTag: () -> Unit, resetBranch: (GraphNode) -> Unit, onMergeBranch: (Ref) -> Unit, onRevCommitSelected: (GraphNode) -> Unit, ) { val commitRefs = graphNode.refs Box(modifier = Modifier .clickable { onRevCommitSelected(graphNode) } ) { ContextMenuArea( items = { listOf( ContextMenuItem( label = "Checkout commit", onClick = { gitManager.checkoutCommit(graphNode) }), ContextMenuItem( label = "Create branch", onClick = showCreateNewBranch ), ContextMenuItem( label = "Create tag", onClick = showCreateNewTag ), ContextMenuItem( label = "Revert commit", onClick = { gitManager.revertCommit(graphNode) } ), ContextMenuItem( label = "Reset current branch to this commit", onClick = { resetBranch(graphNode) } ) ) }, ) { Row( modifier = Modifier .height(40.dp) .fillMaxWidth(), ) { val nodeColor = colors[graphNode.lane.position % colors.size] CommitsGraphLine( modifier = Modifier .width(graphWidth) .fillMaxHeight(), plotCommit = graphNode, nodeColor = nodeColor, ) DividerLog( modifier = Modifier .draggable( rememberDraggableState { weightMod.value += it }, Orientation.Horizontal ) ) CommitMessage( modifier = Modifier.weight(1f), commit = graphNode, selected = selected, refs = commitRefs, nodeColor = nodeColor, currentBranch = currentBranch, onCheckoutRef = { ref -> gitManager.checkoutRef(ref) }, onMergeBranch = { ref -> onMergeBranch(ref) }, onDeleteBranch = { ref -> gitManager.deleteBranch(ref) }, onDeleteTag = { ref -> gitManager.deleteTag(ref) }, ) } } } } @Composable fun CommitMessage( modifier: Modifier = Modifier, commit: RevCommit, selected: Boolean, refs: List, currentBranch: Ref?, nodeColor: Color, onCheckoutRef: (ref: Ref) -> Unit, onMergeBranch: (ref: Ref) -> Unit, onDeleteBranch: (ref: Ref) -> Unit, onDeleteTag: (ref: Ref) -> Unit, ) { val textColor = if (selected) { MaterialTheme.colors.primary } else MaterialTheme.colors.primaryTextColor val secondaryTextColor = if (selected) { MaterialTheme.colors.primary } else MaterialTheme.colors.secondaryTextColor Column( modifier = modifier ) { Spacer(modifier = Modifier.weight(2f)) Row( modifier = Modifier .fillMaxSize(), verticalAlignment = Alignment.CenterVertically, ) { 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, isCurrentBranch = ref.isSameBranch(currentBranch), onCheckoutBranch = { onCheckoutRef(ref) }, onMergeBranch = { onMergeBranch(ref) }, onDeleteBranch = { onDeleteBranch(ref) }, ) } } Text( text = commit.shortMessage, modifier = Modifier.padding(start = 16.dp), fontSize = 14.sp, color = textColor, maxLines = 1, overflow = TextOverflow.Ellipsis, ) Spacer(modifier = Modifier.weight(2f)) Text( text = commit.committerIdent.`when`.toSmartSystemString(), modifier = Modifier.padding(horizontal = 16.dp), fontSize = 12.sp, color = secondaryTextColor, maxLines = 1, overflow = TextOverflow.Ellipsis, ) } Spacer(modifier = Modifier.weight(2f)) } } @OptIn(ExperimentalComposeUiApi::class) @Composable fun DividerLog(modifier: Modifier) { Box( modifier = Modifier .width(8.dp) .then(modifier) .pointerHoverIcon(PointerIconDefaults.Hand) ) { Box( modifier = Modifier .fillMaxHeight() .width(1.dp) .background(color = MaterialTheme.colors.primary) .align(Alignment.Center) ) } } @Composable fun CommitsGraphLine( modifier: Modifier = Modifier, plotCommit: GraphNode, nodeColor: Color, ) { val passingLanes = plotCommit.passingLanes val forkingOffLanes = plotCommit.forkingOffLanes val mergingLanes = plotCommit.mergingLanes Box(modifier = modifier) { val itemPosition = plotCommit.lane.position Canvas( modifier = Modifier .fillMaxSize() ) { clipRect { if (plotCommit.childCount > 0) { drawLine( color = colors[itemPosition % colors.size], start = Offset(30f * (itemPosition + 1), this.center.y), end = Offset(30f * (itemPosition + 1), 0f), ) } forkingOffLanes.forEach { plotLane -> drawLine( color = colors[plotLane.position % colors.size], start = Offset(30f * (itemPosition + 1), this.center.y), end = Offset(30f * (plotLane.position + 1), 0f), ) } mergingLanes.forEach { plotLane -> drawLine( color = colors[plotLane.position % colors.size], start = Offset(30f * (plotLane.position + 1), this.size.height), end = Offset(30f * (itemPosition + 1), this.center.y), ) } if (plotCommit.parentCount > 0) { drawLine( color = colors[itemPosition % colors.size], start = Offset(30f * (itemPosition + 1), this.center.y), end = Offset(30f * (itemPosition + 1), this.size.height), ) } passingLanes.forEach { plotLane -> drawLine( color = colors[plotLane.position % colors.size], start = Offset(30f * (plotLane.position + 1), 0f), end = Offset(30f * (plotLane.position + 1), this.size.height), ) } } } 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) ) { val url = "https://www.gravatar.com/avatar/${plotCommit.authorIdent.emailAddress.md5}?s=60" Image( bitmap = rememberNetworkImage(url), modifier = Modifier .fillMaxSize(), contentDescription = null ) } } @Composable fun UncommitedChangesGraphLine( modifier: Modifier = Modifier, hasPreviousCommits: Boolean, ) { Box(modifier = modifier) { Canvas( modifier = Modifier .fillMaxSize() ) { clipRect { if (hasPreviousCommits) drawLine( color = colors[0], start = Offset(30f, this.center.y), end = Offset(30f, this.size.height), ) drawCircle( color = colors[0], radius = 15f, center = Offset(30f, this.center.y), ) } } } } @OptIn(ExperimentalFoundationApi::class) @Composable fun BranchChip( modifier: Modifier = Modifier, isCurrentBranch: Boolean = false, ref: Ref, onCheckoutBranch: () -> Unit, onMergeBranch: () -> Unit, onDeleteBranch: () -> Unit, color: Color, ) { val contextMenuItemsList = { branchContextMenuItems( isCurrentBranch = isCurrentBranch, isLocal = ref.isLocal, onCheckoutBranch = onCheckoutBranch, onMergeBranch = onMergeBranch, onDeleteBranch = onDeleteBranch, ) } var endingContent: @Composable () -> Unit = {} if (isCurrentBranch) { endingContent = { Icon( painter = painterResource("location.svg"), contentDescription = null, modifier = Modifier.padding(end = 6.dp), tint = MaterialTheme.colors.primary, ) } } 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) @Composable fun RefChip( modifier: Modifier = Modifier, ref: Ref, icon: String, color: Color, onCheckoutRef: () -> Unit, contextMenuItemsList: () -> List, 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 = {} ) ) { ContextMenuArea( 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.inversePrimaryTextColor, ) } Text( text = ref.simpleVisibleName, color = MaterialTheme.colors.primaryTextColor, fontSize = 13.sp, modifier = Modifier .padding(horizontal = 6.dp) ) endingContent() } } } }