From 6d0f621bcd499b101005c060bff7e1562de2db2b Mon Sep 17 00:00:00 2001 From: Abdelilah El Aissaoui Date: Fri, 8 Oct 2021 22:57:18 +0200 Subject: [PATCH] Added new log graph --- build.gradle.kts | 1 + src/main/kotlin/app/CommitNode.kt | 13 + src/main/kotlin/app/extensions/PlotCommit.kt | 30 ++ src/main/kotlin/app/git/LogManager.kt | 19 +- src/main/kotlin/app/ui/Log.kt | 404 +++++++++++++++---- 5 files changed, 384 insertions(+), 83 deletions(-) create mode 100644 src/main/kotlin/app/CommitNode.kt create mode 100644 src/main/kotlin/app/extensions/PlotCommit.kt diff --git a/build.gradle.kts b/build.gradle.kts index c218790..8944256 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -26,6 +26,7 @@ dependencies { implementation("org.eclipse.jgit:org.eclipse.jgit:5.13.0.202109080827-r") implementation("org.apache.sshd:sshd-core:2.7.0") implementation("com.google.dagger:dagger:2.39.1") + implementation("org.jetbrains.kotlin:kotlin-reflect:1.5.21") kapt("com.google.dagger:dagger-compiler:2.39.1") } diff --git a/src/main/kotlin/app/CommitNode.kt b/src/main/kotlin/app/CommitNode.kt new file mode 100644 index 0000000..2a382b3 --- /dev/null +++ b/src/main/kotlin/app/CommitNode.kt @@ -0,0 +1,13 @@ +package app + +import org.eclipse.jgit.revwalk.RevCommit + +class CommitNode(val revCommit: RevCommit) { + private val children = mutableListOf() + + fun addChild(node: CommitNode) { + if(children.find { it.revCommit.id == node.revCommit.id } == null) { + children.add(node) + } + } +} diff --git a/src/main/kotlin/app/extensions/PlotCommit.kt b/src/main/kotlin/app/extensions/PlotCommit.kt new file mode 100644 index 0000000..920ddf3 --- /dev/null +++ b/src/main/kotlin/app/extensions/PlotCommit.kt @@ -0,0 +1,30 @@ +@file:Suppress("UNCHECKED_CAST") + +package app.extensions + +import org.eclipse.jgit.revplot.PlotCommit +import org.eclipse.jgit.revplot.PlotLane +import kotlin.reflect.full.memberProperties +import kotlin.reflect.jvm.isAccessible + +val PlotCommit.reflectForkingOffLanes: Array + get() { + val f = PlotCommit::class.memberProperties.find { it.name == "forkingOffLanes" } + f?.let { + it.isAccessible = true + return it.get(this) as Array + } + + return emptyArray() + } + +val PlotCommit.reflectMergingLanes: Array + get() { + val f = PlotCommit::class.memberProperties.find { it.name == "mergingLanes" } + f?.let { + it.isAccessible = true + return it.get(this) as Array + } + + return emptyArray() + } \ No newline at end of file diff --git a/src/main/kotlin/app/git/LogManager.kt b/src/main/kotlin/app/git/LogManager.kt index 54d3ab5..a04a5f4 100644 --- a/src/main/kotlin/app/git/LogManager.kt +++ b/src/main/kotlin/app/git/LogManager.kt @@ -6,11 +6,16 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.withContext import org.eclipse.jgit.api.Git +import org.eclipse.jgit.revplot.PlotCommit +import org.eclipse.jgit.revplot.PlotCommitList +import org.eclipse.jgit.revplot.PlotLane +import org.eclipse.jgit.revplot.PlotWalk import org.eclipse.jgit.revwalk.RevCommit +import org.eclipse.jgit.revwalk.RevWalk import javax.inject.Inject class LogManager @Inject constructor() { - private val _logStatus = MutableStateFlow(LogStatus.Loaded(listOf())) + private val _logStatus = MutableStateFlow(LogStatus.Loading) val logStatus: StateFlow get() = _logStatus @@ -18,15 +23,21 @@ class LogManager @Inject constructor() { suspend fun loadLog(git: Git) = withContext(Dispatchers.IO) { _logStatus.value = LogStatus.Loading - val log: Iterable = git.log().call() + val commitList = PlotCommitList() + val walk = PlotWalk(git.repository) + + walk.markStart(walk.parseCommit(git.repository.resolve("HEAD"))); + commitList.source(walk) + + commitList.fillTo(Int.MAX_VALUE) ensureActive() - _logStatus.value = LogStatus.Loaded(log.toList()) + _logStatus.value = LogStatus.Loaded(commitList) } } sealed class LogStatus { object Loading : LogStatus() - data class Loaded(val commits: List) : LogStatus() + data class Loaded(val plotCommitList: PlotCommitList) : LogStatus() } \ No newline at end of file diff --git a/src/main/kotlin/app/ui/Log.kt b/src/main/kotlin/app/ui/Log.kt index f3ad9a8..8299f29 100644 --- a/src/main/kotlin/app/ui/Log.kt +++ b/src/main/kotlin/app/ui/Log.kt @@ -1,28 +1,57 @@ package app.ui -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable +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.itemsIndexed -import androidx.compose.material.Card -import androidx.compose.material.Divider -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text +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.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.clipRect +import androidx.compose.ui.input.pointer.PointerIcon +import androidx.compose.ui.input.pointer.pointerIcon 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.sp -import app.ui.components.ScrollableLazyColumn +import app.extensions.reflectForkingOffLanes +import app.extensions.reflectMergingLanes import app.extensions.toSmartSystemString import app.git.GitManager import app.git.LogStatus -import org.eclipse.jgit.revwalk.RevCommit +import app.theme.headerBackground import app.theme.primaryTextColor import app.theme.secondaryTextColor +import app.ui.components.ScrollableLazyColumn +import org.eclipse.jgit.revplot.PlotCommit +import org.eclipse.jgit.revplot.PlotCommitList +import org.eclipse.jgit.revplot.PlotLane +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( + ExperimentalDesktopApi::class, ExperimentalFoundationApi::class, + ExperimentalComposeUiApi::class +) @Composable fun Log( gitManager: GitManager, @@ -35,74 +64,121 @@ fun Log( val selectedUncommited = remember { mutableStateOf(false) } - val log = if (logStatus is LogStatus.Loaded) { - logStatus.commits - } else - listOf() + if (logStatus is LogStatus.Loaded) { + val commitList = logStatus.plotCommitList - - Card( - modifier = Modifier - .padding(8.dp) - .background(MaterialTheme.colors.surface) - .fillMaxSize() - ) { - val hasUncommitedChanges by gitManager.hasUncommitedChanges.collectAsState() - - ScrollableLazyColumn( + Card( modifier = Modifier + .padding(8.dp) .background(MaterialTheme.colors.surface) - .fillMaxSize(), + .fillMaxSize() ) { + val hasUncommitedChanges by gitManager.hasUncommitedChanges.collectAsState() + var weightMod by remember { mutableStateOf(0f) } + var graphWidth = (CANVAS_MIN_WIDTH + weightMod).dp//(weightMod / 500) - if (hasUncommitedChanges) - item { - val textColor = if (selectedUncommited.value) { - MaterialTheme.colors.primary - } else - MaterialTheme.colors.primaryTextColor + if (graphWidth.value < CANVAS_MIN_WIDTH) + graphWidth = CANVAS_MIN_WIDTH.dp - Column( + ScrollableLazyColumn( + modifier = Modifier + .background(MaterialTheme.colors.surface) + .fillMaxSize(), + ) { + + stickyHeader { + Row( modifier = Modifier - .height(40.dp) .fillMaxWidth() - .clickable { - selectedIndex.value = -1 - selectedUncommited.value = true - onUncommitedChangesSelected() - }, - verticalArrangement = Arrangement.Center, + .height(32.dp) + .background(MaterialTheme.colors.headerBackground), + verticalAlignment = Alignment.CenterVertically, ) { - Spacer(modifier = Modifier.weight(2f)) - Text( - text = "Uncommited changes", - fontStyle = FontStyle.Italic, - modifier = Modifier.padding(start = 16.dp), + modifier = Modifier + .width(graphWidth) + .padding(start = 8.dp), + text = "Graph", + fontWeight = FontWeight.Bold, + color = MaterialTheme.colors.primary, fontSize = 14.sp, - color = textColor, + maxLines = 1, ) - Spacer(modifier = Modifier.weight(2f)) + DividerLog( + modifier = Modifier.draggable(rememberDraggableState { + weightMod += it + }, Orientation.Horizontal) + ) - Divider() + Text( + modifier = Modifier + .padding(start = 8.dp) + .width(graphWidth), + text = "Message", + fontWeight = FontWeight.Bold, + color = MaterialTheme.colors.primary, + fontSize = 14.sp, + maxLines = 1, + ) } } - itemsIndexed(items = log) { index, item -> - val textColor = if (selectedIndex.value == index) { - MaterialTheme.colors.primary - } else - MaterialTheme.colors.primaryTextColor + if (hasUncommitedChanges) + item { + val textColor = if (selectedUncommited.value) { + MaterialTheme.colors.primary + } else + MaterialTheme.colors.primaryTextColor - val secondaryTextColor = if (selectedIndex.value == index) { - MaterialTheme.colors.primary - } else - MaterialTheme.colors.secondaryTextColor + Row( + modifier = Modifier + .height(40.dp) + .fillMaxWidth() + .clickable { + selectedIndex.value = -1 + selectedUncommited.value = true + onUncommitedChangesSelected() + }, + ) { + val hasPreviousCommits = remember(commitList) { commitList.count() > 0 } + UncommitedChangesGraphLine( + modifier = Modifier + .width(graphWidth), + hasPreviousCommits = hasPreviousCommits, + ) - Column { - Spacer(modifier = Modifier.weight(2f)) + DividerLog( + modifier = Modifier + .draggable( + rememberDraggableState { + weightMod += 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)) + } + } + } + + itemsIndexed(items = commitList) { index, item -> Row( modifier = Modifier .height(40.dp) @@ -112,36 +188,206 @@ fun Log( selectedUncommited.value = false onRevCommitSelected(item) }, - verticalAlignment = Alignment.CenterVertically, ) { - - - Text( - text = item.shortMessage, - modifier = Modifier.padding(start = 16.dp), - fontSize = 14.sp, - color = textColor, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Spacer(modifier = Modifier.weight(2f)) - - Text( - text = item.committerIdent.`when`.toSmartSystemString(), - modifier = Modifier.padding(horizontal = 16.dp), - fontSize = 12.sp, - color = secondaryTextColor, - maxLines = 1, - overflow = TextOverflow.Ellipsis, + CommitsGraphLine( + plotCommit = item, + commitList = commitList, + hasUncommitedChanges = hasUncommitedChanges, + modifier = Modifier + .width(graphWidth) ) + DividerLog( + modifier = Modifier + .draggable( + rememberDraggableState { + weightMod += it + }, + Orientation.Horizontal + ) + ) + + CommitMessage( + modifier = Modifier.weight(1f), + commit = item, + selected = selectedIndex.value == index + ) } - Spacer(modifier = Modifier.weight(2f)) - Divider() } - } } } +} +@Composable +fun CommitMessage(modifier: Modifier = Modifier, commit: RevCommit, selected: Boolean) { + 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, + ) { + + + 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) + .pointerIcon(PointerIcon.Hand) + ) { + Box( + modifier = Modifier + .fillMaxHeight() + .width(1.dp) + .background(color = MaterialTheme.colors.primary) + .align(Alignment.Center) + ) + } +} + + +@Composable +fun CommitsGraphLine( + modifier: Modifier = Modifier, + commitList: PlotCommitList, + plotCommit: PlotCommit, + hasUncommitedChanges: Boolean, +) { + val passingLanes = remember(plotCommit) { + val passingLanesList = mutableListOf() + commitList.findPassingThrough(plotCommit, passingLanesList) + + passingLanesList + } + + + val forkingOffLanes = remember(plotCommit) { plotCommit.reflectForkingOffLanes } + val mergingLanes = remember(plotCommit) { plotCommit.reflectMergingLanes } + + Box(modifier = modifier) { + Canvas( + modifier = Modifier + .fillMaxSize() + ) { + val itemPosition = plotCommit.lane.position + clipRect { + if (plotCommit.childCount > 0 || hasUncommitedChanges) { + drawLine( + color = colors[itemPosition % colors.size], + start = Offset(20f * (itemPosition + 1), this.center.y), + end = Offset(20f * (itemPosition + 1), 0f), + ) + } + + forkingOffLanes.forEach { plotLane -> + drawLine( + color = colors[plotLane.position % colors.size], + start = Offset(20f * (itemPosition + 1), this.center.y), + end = Offset(20f * (plotLane.position + 1), 0f), + ) + } + + mergingLanes.forEach { plotLane -> + drawLine( + color = colors[plotLane.position % colors.size], + start = Offset(20f * (plotLane.position + 1), this.size.height), + end = Offset(20f * (itemPosition + 1), this.center.y), + ) + } + + if (plotCommit.parentCount > 0) { + drawLine( + color = colors[itemPosition % colors.size], + start = Offset(20f * (itemPosition + 1), this.center.y), + end = Offset(20f * (itemPosition + 1), this.size.height), + ) + } + + passingLanes.forEach { plotLane -> + drawLine( + color = colors[plotLane.position % colors.size], + start = Offset(20f * (plotLane.position + 1), 0f), + end = Offset(20f * (plotLane.position + 1), this.size.height), + ) + } + + drawCircle( + color = colors[itemPosition % colors.size], + radius = 10f, + center = Offset(20f * (itemPosition + 1), this.center.y), + ) + } + } + } +} + +@Composable +fun UncommitedChangesGraphLine( + modifier: Modifier = Modifier, + hasPreviousCommits: Boolean, +) { + Box(modifier = modifier) { + Canvas( + modifier = Modifier + .fillMaxSize() + ) { + clipRect { + + if (hasPreviousCommits) + drawLine( + color = colors[0], + start = Offset(20f, this.center.y), + end = Offset(20f, this.size.height), + ) + + drawCircle( + color = colors[0], + radius = 10f, + center = Offset(20f, this.center.y), + ) + } + } + } } \ No newline at end of file