From 87d7f1cdae43ed88f41dbed233ff23001bdc1169 Mon Sep 17 00:00:00 2001 From: Abdelilah El Aissaoui Date: Fri, 15 Oct 2021 01:09:25 +0200 Subject: [PATCH] Added new log tree --- src/main/kotlin/app/App.kt | 9 +- .../kotlin/app/extensions/RefExtensions.kt | 5 +- src/main/kotlin/app/git/BranchesManager.kt | 12 +- src/main/kotlin/app/git/DiffManager.kt | 6 + src/main/kotlin/app/git/GitManager.kt | 4 + src/main/kotlin/app/git/LogManager.kt | 41 ++- .../kotlin/app/git/RemoteOperationsManager.kt | 2 + src/main/kotlin/app/git/StatusManager.kt | 8 +- .../kotlin/app/git/graph/GraphCommitList.kt | 337 ++++++++++++++++++ src/main/kotlin/app/git/graph/GraphLane.kt | 5 + src/main/kotlin/app/git/graph/GraphNode.kt | 147 ++++++++ src/main/kotlin/app/git/graph/GraphWalk.kt | 165 +++++++++ src/main/kotlin/app/git/graph/IGraphNode.kt | 6 + .../git/graph/UncommitedChangesGraphNode.kt | 17 + src/main/kotlin/app/main.kt | 3 +- src/main/kotlin/app/theme/Color.kt | 7 +- src/main/kotlin/app/theme/Theme.kt | 54 ++- src/main/kotlin/app/ui/Branches.kt | 3 +- src/main/kotlin/app/ui/CommitChanges.kt | 15 +- src/main/kotlin/app/ui/Log.kt | 93 +++-- src/main/kotlin/app/ui/Stashes.kt | 3 +- src/main/kotlin/app/ui/UncommitedChanges.kt | 3 +- .../app/ui/components/RepositoriesTabPanel.kt | 12 +- src/main/resources/tag.svg | 1 + 24 files changed, 881 insertions(+), 77 deletions(-) create mode 100644 src/main/kotlin/app/git/graph/GraphCommitList.kt create mode 100644 src/main/kotlin/app/git/graph/GraphLane.kt create mode 100644 src/main/kotlin/app/git/graph/GraphNode.kt create mode 100644 src/main/kotlin/app/git/graph/GraphWalk.kt create mode 100644 src/main/kotlin/app/git/graph/IGraphNode.kt create mode 100644 src/main/kotlin/app/git/graph/UncommitedChangesGraphNode.kt create mode 100644 src/main/resources/tag.svg diff --git a/src/main/kotlin/app/App.kt b/src/main/kotlin/app/App.kt index fc06feb..9db5ad6 100644 --- a/src/main/kotlin/app/App.kt +++ b/src/main/kotlin/app/App.kt @@ -15,7 +15,7 @@ import androidx.compose.ui.zIndex import app.di.DaggerAppComponent import app.git.GitManager import app.git.RepositorySelectionStatus -import app.theme.GitnuroTheme +import app.theme.AppTheme import app.ui.RepositoryOpenPage import app.ui.WelcomePage import app.ui.components.RepositoriesTabPanel @@ -47,7 +47,7 @@ class Main { size = WindowSize(1280.dp, 720.dp) ) ) { - GitnuroTheme { + AppTheme { val tabs = remember { val tabName = mutableStateOf("New tab") mutableStateOf( @@ -61,7 +61,10 @@ class Main { } var selectedTabKey by remember { mutableStateOf(0) } - Column { + Column( + modifier = + Modifier.background(MaterialTheme.colors.surface) + ) { RepositoriesTabPanel( modifier = Modifier .padding(top = 4.dp, bottom = 2.dp, start = 4.dp, end = 4.dp) diff --git a/src/main/kotlin/app/extensions/RefExtensions.kt b/src/main/kotlin/app/extensions/RefExtensions.kt index 8beb850..59bc815 100644 --- a/src/main/kotlin/app/extensions/RefExtensions.kt +++ b/src/main/kotlin/app/extensions/RefExtensions.kt @@ -4,5 +4,8 @@ import org.eclipse.jgit.lib.Ref val Ref.simpleName: String get() { - return this.name.split("/").last() + return if (this.name.startsWith("refs/remotes/")) + name.replace("refs/remotes/", "") + else + this.name.split("/").last() } \ No newline at end of file diff --git a/src/main/kotlin/app/git/BranchesManager.kt b/src/main/kotlin/app/git/BranchesManager.kt index 08e2382..3620a4d 100644 --- a/src/main/kotlin/app/git/BranchesManager.kt +++ b/src/main/kotlin/app/git/BranchesManager.kt @@ -5,6 +5,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.withContext import org.eclipse.jgit.api.Git +import org.eclipse.jgit.api.ListBranchCommand import org.eclipse.jgit.lib.Ref import javax.inject.Inject @@ -18,9 +19,7 @@ class BranchesManager @Inject constructor() { get() = _currentBranch suspend fun loadBranches(git: Git) = withContext(Dispatchers.IO) { - val branchList = git - .branchList() - .call() + val branchList = getBranches(git) val branchName = git .repository @@ -30,6 +29,13 @@ class BranchesManager @Inject constructor() { _currentBranch.value = branchName } + suspend fun getBranches(git: Git) = withContext(Dispatchers.IO) { + return@withContext git + .branchList() + .setListMode(ListBranchCommand.ListMode.ALL) + .call() + } + suspend fun createBranch(git: Git, branchName: String) = withContext(Dispatchers.IO) { git .branchCreate() diff --git a/src/main/kotlin/app/git/DiffManager.kt b/src/main/kotlin/app/git/DiffManager.kt index e8c17c9..18d02dd 100644 --- a/src/main/kotlin/app/git/DiffManager.kt +++ b/src/main/kotlin/app/git/DiffManager.kt @@ -5,8 +5,10 @@ import kotlinx.coroutines.withContext import org.eclipse.jgit.api.Git import org.eclipse.jgit.diff.DiffEntry import org.eclipse.jgit.diff.DiffFormatter +import org.eclipse.jgit.diff.EditList import org.eclipse.jgit.dircache.DirCacheIterator import org.eclipse.jgit.lib.Repository +import org.eclipse.jgit.patch.HunkHeader import org.eclipse.jgit.revplot.PlotCommit import org.eclipse.jgit.revplot.PlotCommitList import org.eclipse.jgit.revwalk.RevCommit @@ -25,6 +27,7 @@ class DiffManager @Inject constructor() { DiffFormatter(byteArrayOutputStream).use { formatter -> val repo = git.repository + formatter.setRepository(repo) val oldTree = DirCacheIterator(repo.readDirCache()) @@ -34,6 +37,7 @@ class DiffManager @Inject constructor() { formatter.scan(oldTree, newTree) formatter.format(diffEntry) + formatter.flush() } @@ -53,6 +57,8 @@ class DiffManager @Inject constructor() { } } + + suspend fun commitDiffEntries(git: Git, commit: RevCommit): List = withContext(Dispatchers.IO) { val repository = git.repository val parent = if (commit.parentCount == 0) { diff --git a/src/main/kotlin/app/git/GitManager.kt b/src/main/kotlin/app/git/GitManager.kt index 2e6e19c..754e1b9 100644 --- a/src/main/kotlin/app/git/GitManager.kt +++ b/src/main/kotlin/app/git/GitManager.kt @@ -147,10 +147,12 @@ class GitManager @Inject constructor( fun pull() = managerScope.launch { remoteOperationsManager.pull(safeGit) + logManager.loadLog(safeGit) } fun push() = managerScope.launch { remoteOperationsManager.push(safeGit) + logManager.loadLog(safeGit) } private fun refreshRepositoryInfo() = managerScope.launch { @@ -163,11 +165,13 @@ class GitManager @Inject constructor( fun stash() = managerScope.launch { stashManager.stash(safeGit) loadStatus() + loadLog() } fun popStash() = managerScope.launch { stashManager.popStash(safeGit) loadStatus() + loadLog() } fun createBranch(branchName: String) = managerScope.launch { diff --git a/src/main/kotlin/app/git/LogManager.kt b/src/main/kotlin/app/git/LogManager.kt index a04a5f4..4607441 100644 --- a/src/main/kotlin/app/git/LogManager.kt +++ b/src/main/kotlin/app/git/LogManager.kt @@ -1,20 +1,21 @@ package app.git +import app.git.graph.GraphCommitList +import app.git.graph.GraphWalk import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ensureActive 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 org.eclipse.jgit.lib.Constants +import org.eclipse.jgit.lib.Ref import javax.inject.Inject -class LogManager @Inject constructor() { + +class LogManager @Inject constructor( + private val statusManager: StatusManager, +) { private val _logStatus = MutableStateFlow(LogStatus.Loading) val logStatus: StateFlow @@ -23,21 +24,33 @@ class LogManager @Inject constructor() { suspend fun loadLog(git: Git) = withContext(Dispatchers.IO) { _logStatus.value = LogStatus.Loading - val commitList = PlotCommitList() - val walk = PlotWalk(git.repository) + val logList = git.log().setMaxCount(2).call().toList() - walk.markStart(walk.parseCommit(git.repository.resolve("HEAD"))); - commitList.source(walk) + val commitList = GraphCommitList() + val walk = GraphWalk(git.repository) - commitList.fillTo(Int.MAX_VALUE) + walk.use { + walk.markStartAllRefs(Constants.R_HEADS) + walk.markStartAllRefs(Constants.R_REMOTES) + walk.markStartAllRefs(Constants.R_TAGS) + + if (statusManager.checkHasUncommitedChanges(git)) + commitList.addUncommitedChangesGraphCommit(logList.first()) + + commitList.source(walk) + commitList.fillTo(Int.MAX_VALUE) + } ensureActive() - _logStatus.value = LogStatus.Loaded(commitList) + val loadedStatus = LogStatus.Loaded(commitList) + + _logStatus.value = loadedStatus } } + sealed class LogStatus { object Loading : LogStatus() - data class Loaded(val plotCommitList: PlotCommitList) : LogStatus() + class Loaded(val plotCommitList: GraphCommitList) : LogStatus() } \ No newline at end of file diff --git a/src/main/kotlin/app/git/RemoteOperationsManager.kt b/src/main/kotlin/app/git/RemoteOperationsManager.kt index ea355ec..a91dc04 100644 --- a/src/main/kotlin/app/git/RemoteOperationsManager.kt +++ b/src/main/kotlin/app/git/RemoteOperationsManager.kt @@ -39,5 +39,7 @@ class RemoteOperationsManager @Inject constructor( } } .call() + + } } \ No newline at end of file diff --git a/src/main/kotlin/app/git/StatusManager.kt b/src/main/kotlin/app/git/StatusManager.kt index 5856c1e..86eb62a 100644 --- a/src/main/kotlin/app/git/StatusManager.kt +++ b/src/main/kotlin/app/git/StatusManager.kt @@ -22,13 +22,15 @@ class StatusManager @Inject constructor() { get() = _hasUncommitedChanges suspend fun loadHasUncommitedChanges(git: Git) = withContext(Dispatchers.IO) { + _hasUncommitedChanges.value = checkHasUncommitedChanges(git) + } + + suspend fun checkHasUncommitedChanges(git: Git) = withContext(Dispatchers.IO) { val status = git .status() .call() - val hasUncommitedChanges = status.hasUncommittedChanges() || status.hasUntrackedChanges() - - _hasUncommitedChanges.value = hasUncommitedChanges + return@withContext status.hasUncommittedChanges() || status.hasUntrackedChanges() } suspend fun loadStatus(git: Git) = withContext(Dispatchers.IO) { diff --git a/src/main/kotlin/app/git/graph/GraphCommitList.kt b/src/main/kotlin/app/git/graph/GraphCommitList.kt new file mode 100644 index 0000000..a3d0907 --- /dev/null +++ b/src/main/kotlin/app/git/graph/GraphCommitList.kt @@ -0,0 +1,337 @@ +package app.git.graph + +import org.eclipse.jgit.revwalk.RevCommitList +import java.lang.ClassCastException +import java.text.MessageFormat +import org.eclipse.jgit.internal.JGitText +import org.eclipse.jgit.lib.AnyObjectId +import org.eclipse.jgit.revwalk.RevCommit +import org.eclipse.jgit.revwalk.RevWalk +import java.util.* + +/** + * An ordered list of [GraphNode] subclasses. + * + * + * Commits are allocated into lanes as they enter the list, based upon their + * connections between descendant (child) commits and ancestor (parent) commits. + * + * + * The source of the list must be a [GraphWalk] + * and [.fillTo] must be used to populate the list. + * + * type of lane used by the application. + */ +class GraphCommitList : RevCommitList() { + private var positionsAllocated = 0 + private val freePositions = TreeSet() + private val activeLanes = HashSet(32) + + /** number of (child) commits on a lane */ + private val laneLength = HashMap( + 32 + ) + + override fun clear() { + super.clear() + positionsAllocated = 0 + freePositions.clear() + activeLanes.clear() + laneLength.clear() + } + + override fun source(revWalk: RevWalk) { + if (revWalk !is GraphWalk) throw ClassCastException( + MessageFormat.format( + JGitText.get().classCastNotA, + GraphWalk::class.java.name + ) + ) + + super.source(revWalk) + } + + private var parentId: AnyObjectId? = null + + private val graphCommit = UncommitedChangesGraphNode() + + fun addUncommitedChangesGraphCommit(parent: RevCommit) { + parentId = parent.id + graphCommit.lane = nextFreeLane() + } + + override fun enter(index: Int, currCommit: GraphNode) { + if(currCommit.id == parentId) { + graphCommit.graphParent = currCommit + currCommit.addChild(graphCommit) + } + + setupChildren(currCommit) + val nChildren = currCommit.childCount + if (nChildren == 0) { + currCommit.lane = nextFreeLane() + } else if (nChildren == 1 + && currCommit.children[0].graphParentCount < 2 + ) { + // Only one child, child has only us as their parent. + // Stay in the same lane as the child. + val graphNode: GraphNode = currCommit.children[0] + currCommit.lane = graphNode.lane + var len = laneLength[currCommit.lane] + len = if (len != null) Integer.valueOf(len.toInt() + 1) else Integer.valueOf(0) + + if(currCommit.lane.position != INVALID_LANE_POSITION) + laneLength[currCommit.lane] = len + } else { + // More than one child, or our child is a merge. + + // We look for the child lane the current commit should continue. + // Candidate lanes for this are those with children, that have the + // current commit as their first parent. + // There can be multiple candidate lanes. In that case the longest + // lane is chosen, as this is usually the lane representing the + // branch the commit actually was made on. + + // When there are no candidate lanes (i.e. the current commit has + // only children whose non-first parent it is) we place the current + // commit on a new lane. + + // The lane the current commit will be placed on: + var reservedLane: GraphLane? = null + var childOnReservedLane: GraphNode? = null + var lengthOfReservedLane = -1 + for (i in 0 until nChildren) { + val c: GraphNode = currCommit.children[i] + if (c.getGraphParent(0) === currCommit) { + if (c.lane.position < 0) + println("c.lane.position is invalid (${c.lane.position})") + + val length = laneLength[c.lane] + + // we may be the first parent for multiple lines of + // development, try to continue the longest one + if (length != null && length > lengthOfReservedLane) { + reservedLane = c.lane + childOnReservedLane = c + lengthOfReservedLane = length + } + } + } + if (reservedLane != null) { + currCommit.lane = reservedLane + laneLength[reservedLane] = Integer.valueOf(lengthOfReservedLane + 1) + handleBlockedLanes(index, currCommit, childOnReservedLane) + } else { + currCommit.lane = nextFreeLane() + handleBlockedLanes(index, currCommit, null) + } + + // close lanes of children, if there are no first parents that might + // want to continue the child lanes + for (i in 0 until nChildren) { + val graphNode = currCommit.children[i] + + val firstParent = graphNode.getGraphParent(0) + + if (firstParent.lane.position != INVALID_LANE_POSITION && firstParent.lane !== graphNode.lane) + closeLane(graphNode.lane) + } + } + + continueActiveLanes(currCommit) + + if (currCommit.parentCount == 0 && currCommit.lane.position == INVALID_LANE_POSITION) + closeLane(currCommit.lane) + + } + + private fun continueActiveLanes(currCommit: GraphNode) { + for (lane in activeLanes) if (lane !== currCommit.lane) + currCommit.addPassingLane(lane) + } + + /** + * Sets up fork and merge information in the involved PlotCommits. + * Recognizes and handles blockades that involve forking or merging arcs. + * + * @param index + * the index of `currCommit` in the list + * @param currentNode + * @param childOnLane + * the direct child on the same lane as `currCommit`, + * may be null if `currCommit` is the first commit on + * the lane + */ + private fun handleBlockedLanes( + index: Int, currentNode: GraphNode, + childOnLane: GraphNode? + ) { + for (child in currentNode.children) { + if (child === childOnLane) continue // simple continuations of lanes are handled by + // continueActiveLanes() calls in enter() + + // Is the child a merge or is it forking off? + val childIsMerge = child.getGraphParent(0) !== currentNode + if (childIsMerge) { + var laneToUse = currentNode.lane + laneToUse = handleMerge( + index, currentNode, childOnLane, child, + laneToUse + ) + child.addMergingLane(laneToUse) + } else { + // We want to draw a forking arc in the child's lane. + // As an active lane, the child lane already continues + // (unblocked) up to this commit, we only need to mark it as + // forking off from the current commit. + val laneToUse = child.lane + currentNode.addForkingOffLane(laneToUse) + } + } + } + + // Handles the case where currCommit is a non-first parent of the child + private fun handleMerge( + index: Int, currCommit: GraphNode, + childOnLane: GraphNode?, child: GraphNode, laneToUse: GraphLane + ): GraphLane { + + // find all blocked positions between currCommit and this child + var newLaneToUse = laneToUse + var childIndex = index // useless initialization, should + // always be set in the loop below + val blockedPositions = BitSet() + for (r in index - 1 downTo 0) { + val rObj: GraphNode? = get(r) + if (rObj === child) { + childIndex = r + break + } + addBlockedPosition(blockedPositions, rObj) + } + + // handle blockades + if (blockedPositions[newLaneToUse.position]) { + // We want to draw a merging arc in our lane to the child, + // which is on another lane, but our lane is blocked. + + // Check if childOnLane is beetween commit and the child we + // are currently processing + var needDetour = false + if (childOnLane != null) { + for (r in index - 1 downTo childIndex + 1) { + val rObj: GraphNode? = get(r) + if (rObj === childOnLane) { + needDetour = true + break + } + } + } + if (needDetour) { + // It is childOnLane which is blocking us. Repositioning + // our lane would not help, because this repositions the + // child too, keeping the blockade. + // Instead, we create a "detour lane" which gets us + // around the blockade. That lane has no commits on it. + newLaneToUse = nextFreeLane(blockedPositions) + currCommit.addForkingOffLane(newLaneToUse) + closeLane(newLaneToUse) + } else { + // The blockade is (only) due to other (already closed) + // lanes at the current lane's position. In this case we + // reposition the current lane. + // We are the first commit on this lane, because + // otherwise the child commit on this lane would have + // kept other lanes from blocking us. Since we are the + // first commit, we can freely reposition. + val newPos = getFreePosition(blockedPositions) + freePositions.add( + Integer.valueOf( + newLaneToUse + .position + ) + ) + + newLaneToUse.position = newPos + } + } + + // Actually connect currCommit to the merge child + drawLaneToChild(index, child, newLaneToUse) + return newLaneToUse + } + + /** + * Connects the commit at commitIndex to the child, using the given lane. + * All blockades on the lane must be resolved before calling this method. + * + * @param commitIndex + * @param child + * @param laneToContinue + */ + private fun drawLaneToChild( + commitIndex: Int, child: GraphNode, + laneToContinue: GraphLane + ) { + for (r in commitIndex - 1 downTo 0) { + val rObj: GraphNode? = get(r) + if (rObj === child) break + rObj?.addPassingLane(laneToContinue) + } + } + + private fun closeLane(lane: GraphLane) { + if (activeLanes.remove(lane)) { + laneLength.remove(lane) + freePositions.add(Integer.valueOf(lane.position)) + } + } + + private fun setupChildren(currCommit: GraphNode) { + val nParents = currCommit.parentCount + for (i in 0 until nParents) (currCommit.getParent(i) as GraphNode).addChild(currCommit) + } + + private fun nextFreeLane(blockedPositions: BitSet? = null): GraphLane { + val newPlotLane = GraphLane(position = getFreePosition(blockedPositions)) + activeLanes.add(newPlotLane) + laneLength[newPlotLane] = Integer.valueOf(1) + + return newPlotLane + } + + /** + * @param blockedPositions + * may be null + * @return a free lane position + */ + private fun getFreePosition(blockedPositions: BitSet?): Int { + if (freePositions.isEmpty()) return positionsAllocated++ + if (blockedPositions != null) { + for (pos in freePositions) if (!blockedPositions[pos]) { + freePositions.remove(pos) + return pos + } + return positionsAllocated++ + } + val min = freePositions.first() + freePositions.remove(min) + return min.toInt() + } + + private fun addBlockedPosition( + blockedPositions: BitSet, + graphNode: GraphNode? + ) { + if (graphNode != null) { + val lane = graphNode.lane + // Positions may be blocked by a commit on a lane. + if (lane.position != INVALID_LANE_POSITION) blockedPositions.set(lane.position) + // Positions may also be blocked by forking off and merging lanes. + // We don't consider passing lanes, because every passing lane forks + // off and merges at it ends. + for (l in graphNode.forkingOffLanes) blockedPositions.set(l.position) + for (l in graphNode.mergingLanes) blockedPositions.set(l.position) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/app/git/graph/GraphLane.kt b/src/main/kotlin/app/git/graph/GraphLane.kt new file mode 100644 index 0000000..c61d545 --- /dev/null +++ b/src/main/kotlin/app/git/graph/GraphLane.kt @@ -0,0 +1,5 @@ +package app.git.graph + +const val INVALID_LANE_POSITION = -1 + +class GraphLane(var position: Int = 0) \ No newline at end of file diff --git a/src/main/kotlin/app/git/graph/GraphNode.kt b/src/main/kotlin/app/git/graph/GraphNode.kt new file mode 100644 index 0000000..5400629 --- /dev/null +++ b/src/main/kotlin/app/git/graph/GraphNode.kt @@ -0,0 +1,147 @@ +@file:Suppress("unused") + +package app.git.graph + +import org.eclipse.jgit.lib.AnyObjectId +import org.eclipse.jgit.lib.Ref +import org.eclipse.jgit.revwalk.RevCommit + +val NO_CHILDREN = arrayOf() +val NO_LANES = arrayOf() +val NO_REFS = listOf() +val NO_LANE = GraphLane(INVALID_LANE_POSITION) + +open class GraphNode(id: AnyObjectId?) : RevCommit(id), IGraphNode { + var forkingOffLanes: Array = NO_LANES + var passingLanes: Array = NO_LANES + var mergingLanes: Array = NO_LANES + var lane: GraphLane = NO_LANE + var children: Array = NO_CHILDREN + var refs: List = NO_REFS + + fun addForkingOffLane(graphLane: GraphLane) { + forkingOffLanes = addLane(graphLane, forkingOffLanes) + } + + fun addPassingLane(graphLane: GraphLane) { + passingLanes = addLane(graphLane, passingLanes) + } + + fun addMergingLane(graphLane: GraphLane) { + mergingLanes = addLane(graphLane, mergingLanes) + } + + fun addChild(c: GraphNode) { + when (val childrenCount = children.count()) { + 0 -> children = arrayOf(c) + 1 -> if (!c.id.equals(children[0].id)) children = arrayOf(children[0], c) + else -> { + for (pc in children) + if (c.id.equals(pc.id)) + return + + val n: Array = children.copyOf(childrenCount + 1).run { + this[childrenCount] = c + requireNoNulls() + } + + n[childrenCount] = c + children = n + } + } + } + + val childCount: Int + get() { + return children.size + } + + /** + * Get the nth child from this commit's child list. + * + * @param nth + * child index to obtain. Must be in the range 0 through + * [.getChildCount]-1. + * @return the specified child. + * @throws ArrayIndexOutOfBoundsException + * an invalid child index was specified. + */ + fun getChild(nth: Int): GraphNode { + return children[nth] + } + + /** + * Determine if the given commit is a child (descendant) of this commit. + * + * @param c + * the commit to test. + * @return true if the given commit built on top of this commit. + */ + fun isChild(c: GraphNode): Boolean { + for (a in children) + if (a === c) + return true + + return false + } + + /** + * Get the number of refs for this commit. + * + * @return number of refs; always a positive value but can be 0. + */ + fun getRefCount(): Int { + return refs.size + } + + /** + * Get the nth Ref from this commit's ref list. + * + * @param nth + * ref index to obtain. Must be in the range 0 through + * [.getRefCount]-1. + * @return the specified ref. + * @throws ArrayIndexOutOfBoundsException + * an invalid ref index was specified. + */ + fun getRef(nth: Int): Ref { + return refs[nth] + } + + + /** {@inheritDoc} */ + override fun reset() { + forkingOffLanes = NO_LANES + passingLanes = NO_LANES + mergingLanes = NO_LANES + children = NO_CHILDREN + lane = NO_LANE + super.reset() + } + + private fun addLane(graphLane: GraphLane, lanes: Array): Array { + var newLines = lanes + + when (val linesCount = newLines.count()) { + 0 -> newLines = arrayOf(graphLane) + 1 -> newLines = arrayOf(newLines[0], graphLane) + else -> { + val n = newLines.copyOf(linesCount + 1).run { + this[linesCount] = graphLane + requireNoNulls() + } + + newLines = n + } + } + + return newLines + } + + override val graphParentCount: Int + get() = parentCount + + override fun getGraphParent(nth: Int): GraphNode { + return getParent(nth) as GraphNode + } +} \ No newline at end of file diff --git a/src/main/kotlin/app/git/graph/GraphWalk.kt b/src/main/kotlin/app/git/graph/GraphWalk.kt new file mode 100644 index 0000000..a762d4c --- /dev/null +++ b/src/main/kotlin/app/git/graph/GraphWalk.kt @@ -0,0 +1,165 @@ +package app.git.graph + +import org.eclipse.jgit.errors.MissingObjectException +import org.eclipse.jgit.internal.JGitText +import org.eclipse.jgit.lib.AnyObjectId +import org.eclipse.jgit.lib.Constants +import org.eclipse.jgit.lib.Ref +import org.eclipse.jgit.lib.Repository +import org.eclipse.jgit.revwalk.* +import java.io.IOException +import java.util.* + +/** + * Specialized RevWalk for visualization of a commit graph. + */ +class GraphWalk(private var repository: Repository?) : RevWalk(repository) { + private var additionalRefMap: MutableMap>? + private var reverseRefMap: MutableMap>? = null + + + /** + * Create a new revision walker for a given repository. + */ + init { + super.sort(RevSort.TOPO, true) + additionalRefMap = HashMap() + } + + /** {@inheritDoc} */ + override fun dispose() { + super.dispose() + if (reverseRefMap != null) { + reverseRefMap?.clear() + reverseRefMap = null + } + if (additionalRefMap != null) { + additionalRefMap?.clear() + additionalRefMap = null + } + repository = null + } + + override fun sort(revSort: RevSort, use: Boolean) { + require(!(revSort == RevSort.TOPO && !use)) { + JGitText.get().topologicalSortRequired + } + + super.sort(revSort, use) + } + + override fun createCommit(id: AnyObjectId): RevCommit { + return GraphNode(id) + } + + override fun next(): RevCommit? { + val graphNode = super.next() as GraphNode? + + if (graphNode != null) + graphNode.refs = getRefs(graphNode) + + return graphNode + } + + private fun getRefs(commitId: AnyObjectId): List { + val repository = this.repository + var reverseRefMap = this.reverseRefMap + var additionalRefMap = this.additionalRefMap + if (reverseRefMap == null && repository != null && additionalRefMap != null) { + + reverseRefMap = repository.allRefsByPeeledObjectId + this.reverseRefMap = reverseRefMap + + for (entry in additionalRefMap.entries) { + val refsSet = reverseRefMap[entry.key] + var additional = entry.value.toMutableSet() + + if (refsSet != null) { + if (additional.size == 1) { + // It's an unmodifiable singleton set... + additional = HashSet(additional) + } + additional.addAll(refsSet) + } + reverseRefMap[entry.key] = additional + } + + additionalRefMap.clear() + additionalRefMap = null + + this.additionalRefMap = additionalRefMap + } + + requireNotNull(reverseRefMap) // This should never be null + + val refsSet = reverseRefMap[commitId] + ?: return NO_REFS + val tags = refsSet.toList() + + tags.sortedWith(GraphRefComparator()) + + return tags + } + + fun markStartAllRefs(prefix: String) { + repository?.let { repo -> + for (ref in repo.refDatabase.getRefsByPrefix(prefix)) { + if (ref.isSymbolic) continue + markStartRef(ref) + } + } + } + + private fun markStartRef(ref: Ref) { + try { + val refTarget: Any = parseAny(ref.leaf.objectId) + + if (refTarget is RevCommit) + markStart(refTarget) + } catch (e: MissingObjectException) { + // Ignore missing Refs + } + } + + internal inner class GraphRefComparator : Comparator { + override fun compare(o1: Ref, o2: Ref): Int { + try { + val obj1 = parseAny(o1.objectId) + val obj2 = parseAny(o2.objectId) + val t1 = timeOf(obj1) + val t2 = timeOf(obj2) + if (t1 > t2) return -1 + if (t1 < t2) return 1 + } catch (e: IOException) { + // ignore + } + + var cmp = kind(o1) - kind(o2) + + if (cmp == 0) + cmp = o1.name.compareTo(o2.name) + + return cmp + } + + private fun timeOf(revObject: RevObject): Long { + if (revObject is RevCommit) return revObject.commitTime.toLong() + if (revObject is RevTag) { + try { + parseBody(revObject) + } catch (e: IOException) { + return 0 + } + val who = revObject.taggerIdent + return who?.getWhen()?.time ?: 0 + } + return 0 + } + + private fun kind(r: Ref): Int { + if (r.name.startsWith(Constants.R_TAGS)) return 0 + if (r.name.startsWith(Constants.R_HEADS)) return 1 + return if (r.name.startsWith(Constants.R_REMOTES)) 2 else 3 + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/app/git/graph/IGraphNode.kt b/src/main/kotlin/app/git/graph/IGraphNode.kt new file mode 100644 index 0000000..3b08da5 --- /dev/null +++ b/src/main/kotlin/app/git/graph/IGraphNode.kt @@ -0,0 +1,6 @@ +package app.git.graph + +interface IGraphNode { + val graphParentCount: Int + fun getGraphParent(nth: Int): GraphNode +} \ No newline at end of file diff --git a/src/main/kotlin/app/git/graph/UncommitedChangesGraphNode.kt b/src/main/kotlin/app/git/graph/UncommitedChangesGraphNode.kt new file mode 100644 index 0000000..a564ed9 --- /dev/null +++ b/src/main/kotlin/app/git/graph/UncommitedChangesGraphNode.kt @@ -0,0 +1,17 @@ +@file:Suppress("unused") + +package app.git.graph + +import org.eclipse.jgit.lib.ObjectId + +class UncommitedChangesGraphNode : GraphNode(ObjectId(0, 0, 0, 0, 0)) { + + var graphParent: GraphNode? = null + + override val graphParentCount: Int + get() = 1 // TODO: Check what happens with an empty tree + + override fun getGraphParent(nth: Int): GraphNode { + return requireNotNull(graphParent) + } +} \ No newline at end of file diff --git a/src/main/kotlin/app/main.kt b/src/main/kotlin/app/main.kt index f7537dc..b75313a 100644 --- a/src/main/kotlin/app/main.kt +++ b/src/main/kotlin/app/main.kt @@ -1,5 +1,6 @@ +package app + import androidx.compose.ui.ExperimentalComposeUiApi -import app.Main @OptIn(ExperimentalComposeUiApi::class) fun main() { diff --git a/src/main/kotlin/app/theme/Color.kt b/src/main/kotlin/app/theme/Color.kt index 03387b3..dc237cb 100644 --- a/src/main/kotlin/app/theme/Color.kt +++ b/src/main/kotlin/app/theme/Color.kt @@ -16,8 +16,13 @@ val accentGrayLight = Color(0xFFCCCCCC) val backgroundColorLight = Color(0xFFEBEFF7) val surfaceColorLight = Color(0xFFFFFFFF) +val surfaceColorDark = Color(0xFF1C1D1E) val headerBackgroundLight = Color(0xFFF4F6FA) +val headerBackgroundDark = Color(0xFF303132) val addFileLight = Color(0xFF32A852) val deleteFileLight = errorColor -val modifyFileLight = primary \ No newline at end of file +val modifyFileLight = primary + +val tabColorActiveDark = Color(0xFF606061) +val tabColorInactiveDark = Color(0xFF262626) \ No newline at end of file diff --git a/src/main/kotlin/app/theme/Theme.kt b/src/main/kotlin/app/theme/Theme.kt index 81b1489..db8eeeb 100644 --- a/src/main/kotlin/app/theme/Theme.kt +++ b/src/main/kotlin/app/theme/Theme.kt @@ -5,25 +5,26 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color private val DarkColorPalette = darkColors( - primary = primaryLight, - primaryVariant = primaryDark, - secondary = secondary + primary = primaryLight, + primaryVariant = primaryDark, + secondary = secondary, + surface = surfaceColorDark, ) private val LightColorPalette = lightColors( - primary = primary, - primaryVariant = primaryDark, - secondary = secondary, - background = backgroundColorLight, - surface = surfaceColorLight, - error = errorColor - /* Other default colors to override + primary = primary, + primaryVariant = primaryDark, + secondary = secondary, + background = backgroundColorLight, + surface = surfaceColorLight, + error = errorColor + /* Other default colors to override - */ + */ ) @Composable -fun GitnuroTheme(darkTheme: Boolean = false, content: @Composable() () -> Unit) { +fun AppTheme(darkTheme: Boolean = false, content: @Composable() () -> Unit) { val colors = if (darkTheme) { DarkColorPalette } else { @@ -31,18 +32,18 @@ fun GitnuroTheme(darkTheme: Boolean = false, content: @Composable() () -> Unit) } MaterialTheme( - colors = colors, - content = content, + colors = colors, + content = content, ) } @get:Composable val Colors.primaryTextColor: Color - get() = if(isLight) mainText else mainTextDark + get() = if (isLight) mainText else mainTextDark @get:Composable val Colors.secondaryTextColor: Color - get() = if(isLight) secondaryText else secondaryTextDark + get() = if (isLight) secondaryText else secondaryTextDark @get:Composable val Colors.accent: Color @@ -58,7 +59,12 @@ val Colors.accentGray: Color @get:Composable val Colors.headerBackground: Color - get() = headerBackgroundLight + get() { + return if (isLight) + headerBackgroundLight + else + headerBackgroundDark + } @get:Composable val Colors.addFile: Color @@ -71,3 +77,17 @@ val Colors.deleteFile: Color @get:Composable val Colors.modifyFile: Color get() = modifyFileLight + +@get:Composable +val Colors.headerText: Color + get() = if (isLight) primary else mainTextDark + + +val Colors.tabColorActive: Color + get() = if (isLight) primary else tabColorActiveDark + + +val Colors.tabColorInactive: Color + get() = if (isLight) primaryLight else tabColorInactiveDark + + diff --git a/src/main/kotlin/app/ui/Branches.kt b/src/main/kotlin/app/ui/Branches.kt index afb303f..57c0e1e 100644 --- a/src/main/kotlin/app/ui/Branches.kt +++ b/src/main/kotlin/app/ui/Branches.kt @@ -23,6 +23,7 @@ import app.extensions.simpleName import app.git.GitManager import org.eclipse.jgit.lib.Ref import app.theme.headerBackground +import app.theme.headerText @Composable fun Branches(gitManager: GitManager) { @@ -44,7 +45,7 @@ fun Branches(gitManager: GitManager) { text = "Local branches", fontWeight = FontWeight.Bold, textAlign = TextAlign.Center, - color = MaterialTheme.colors.primary, + color = MaterialTheme.colors.headerText, fontSize = 14.sp, maxLines = 1, ) diff --git a/src/main/kotlin/app/ui/CommitChanges.kt b/src/main/kotlin/app/ui/CommitChanges.kt index 1789e95..9f7e0c9 100644 --- a/src/main/kotlin/app/ui/CommitChanges.kt +++ b/src/main/kotlin/app/ui/CommitChanges.kt @@ -31,11 +31,12 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.setValue import app.ui.components.ScrollableLazyColumn import app.git.GitManager +import app.theme.headerText @Composable fun CommitChanges( gitManager: GitManager, - commit: RevCommit, + commit: RevCommit, onDiffSelected: (DiffEntry) -> Unit ) { var diff by remember { mutableStateOf(emptyList()) } @@ -142,7 +143,7 @@ fun CommitChanges( text = "Files changed", fontWeight = FontWeight.Bold, textAlign = TextAlign.Center, - color = MaterialTheme.colors.primary, + color = MaterialTheme.colors.headerText, maxLines = 1, fontSize = 14.sp, ) @@ -173,12 +174,18 @@ fun rememberNetworkImage(url: String): ImageBitmap { ) } + LaunchedEffect(url) { - loadImage(url).let { - image = makeFromEncoded(it).asImageBitmap() + try { + loadImage(url).let { + image = makeFromEncoded(it).asImageBitmap() + } + } catch (ex: Exception) { + println("Avatar loading failed: ${ex.message}") } } + return image } diff --git a/src/main/kotlin/app/ui/Log.kt b/src/main/kotlin/app/ui/Log.kt index 8299f29..3157888 100644 --- a/src/main/kotlin/app/ui/Log.kt +++ b/src/main/kotlin/app/ui/Log.kt @@ -6,33 +6,36 @@ 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.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.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.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.sp -import app.extensions.reflectForkingOffLanes -import app.extensions.reflectMergingLanes +import app.extensions.simpleName import app.extensions.toSmartSystemString import app.git.GitManager import app.git.LogStatus +import app.git.graph.GraphNode import app.theme.headerBackground +import app.theme.headerText 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.lib.ObjectIdRef +import org.eclipse.jgit.lib.Ref import org.eclipse.jgit.revwalk.RevCommit private val colors = listOf( @@ -100,7 +103,7 @@ fun Log( .padding(start = 8.dp), text = "Graph", fontWeight = FontWeight.Bold, - color = MaterialTheme.colors.primary, + color = MaterialTheme.colors.headerText, fontSize = 14.sp, maxLines = 1, ) @@ -117,7 +120,7 @@ fun Log( .width(graphWidth), text = "Message", fontWeight = FontWeight.Bold, - color = MaterialTheme.colors.primary, + color = MaterialTheme.colors.headerText, fontSize = 14.sp, maxLines = 1, ) @@ -179,6 +182,9 @@ fun Log( } itemsIndexed(items = commitList) { index, item -> + val commitRefs = remember(commitList, item) { + item.refs + } Row( modifier = Modifier .height(40.dp) @@ -190,11 +196,9 @@ fun Log( }, ) { CommitsGraphLine( - plotCommit = item, - commitList = commitList, - hasUncommitedChanges = hasUncommitedChanges, modifier = Modifier - .width(graphWidth) + .width(graphWidth), + plotCommit = item ) DividerLog( @@ -210,7 +214,8 @@ fun Log( CommitMessage( modifier = Modifier.weight(1f), commit = item, - selected = selectedIndex.value == index + selected = selectedIndex.value == index, + refs = commitRefs, ) } } @@ -220,7 +225,12 @@ fun Log( } @Composable -fun CommitMessage(modifier: Modifier = Modifier, commit: RevCommit, selected: Boolean) { +fun CommitMessage( + modifier: Modifier = Modifier, + commit: RevCommit, + selected: Boolean, + refs: List +) { val textColor = if (selected) { MaterialTheme.colors.primary } else @@ -241,7 +251,13 @@ fun CommitMessage(modifier: Modifier = Modifier, commit: RevCommit, selected: Bo .fillMaxSize(), verticalAlignment = Alignment.CenterVertically, ) { - + refs.forEach { + RefChip( + modifier = Modifier + .padding(horizontal = 4.dp), + ref = it, + ) + } Text( text = commit.shortMessage, @@ -291,20 +307,14 @@ fun DividerLog(modifier: Modifier) { @Composable fun CommitsGraphLine( modifier: Modifier = Modifier, - commitList: PlotCommitList, - plotCommit: PlotCommit, - hasUncommitedChanges: Boolean, + plotCommit: GraphNode, ) { val passingLanes = remember(plotCommit) { - val passingLanesList = mutableListOf() - commitList.findPassingThrough(plotCommit, passingLanesList) - - passingLanesList + plotCommit.passingLanes } - - val forkingOffLanes = remember(plotCommit) { plotCommit.reflectForkingOffLanes } - val mergingLanes = remember(plotCommit) { plotCommit.reflectMergingLanes } + val forkingOffLanes = remember(plotCommit) { plotCommit.forkingOffLanes } + val mergingLanes = remember(plotCommit) { plotCommit.mergingLanes } Box(modifier = modifier) { Canvas( @@ -313,7 +323,7 @@ fun CommitsGraphLine( ) { val itemPosition = plotCommit.lane.position clipRect { - if (plotCommit.childCount > 0 || hasUncommitedChanges) { + if (plotCommit.childCount > 0) { drawLine( color = colors[itemPosition % colors.size], start = Offset(20f * (itemPosition + 1), this.center.y), @@ -390,4 +400,37 @@ fun UncommitedChangesGraphLine( } } } +} + +@Composable +fun RefChip(modifier: Modifier = Modifier, ref: Ref) { + val icon = remember(ref) { + if(ref is ObjectIdRef.PeeledTag) { + "tag.svg" + } else + "branch.svg" + } + + Row( + modifier = modifier + .clip(RoundedCornerShape(16.dp)) + .background(MaterialTheme.colors.primary), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + modifier = Modifier + .padding(start = 6.dp, end = 4.dp, top = 6.dp, bottom = 6.dp) + .size(14.dp), + painter = painterResource(icon), + contentDescription = null, + tint = MaterialTheme.colors.onPrimary, + ) + Text( + text = ref.simpleName, + color = MaterialTheme.colors.onPrimary, + fontSize = 12.sp, + modifier = Modifier + .padding(end = 6.dp) + ) + } } \ No newline at end of file diff --git a/src/main/kotlin/app/ui/Stashes.kt b/src/main/kotlin/app/ui/Stashes.kt index 8c09444..d413697 100644 --- a/src/main/kotlin/app/ui/Stashes.kt +++ b/src/main/kotlin/app/ui/Stashes.kt @@ -20,6 +20,7 @@ import app.git.GitManager import app.git.StashStatus import org.eclipse.jgit.revwalk.RevCommit import app.theme.headerBackground +import app.theme.headerText @Composable fun Stashes(gitManager: GitManager) { @@ -46,7 +47,7 @@ fun Stashes(gitManager: GitManager) { text = "Stashes", fontWeight = FontWeight.Bold, textAlign = TextAlign.Center, - color = MaterialTheme.colors.primary, + color = MaterialTheme.colors.headerText, fontSize = 14.sp, maxLines = 1, ) diff --git a/src/main/kotlin/app/ui/UncommitedChanges.kt b/src/main/kotlin/app/ui/UncommitedChanges.kt index 5b08c4e..e030264 100644 --- a/src/main/kotlin/app/ui/UncommitedChanges.kt +++ b/src/main/kotlin/app/ui/UncommitedChanges.kt @@ -32,6 +32,7 @@ import app.git.GitManager import app.git.StageStatus import org.eclipse.jgit.diff.DiffEntry import app.theme.headerBackground +import app.theme.headerText @OptIn(ExperimentalAnimationApi::class) @Composable @@ -179,7 +180,7 @@ private fun EntriesList( text = title, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center, - color = MaterialTheme.colors.primary, + color = MaterialTheme.colors.headerText, fontSize = 14.sp, maxLines = 1, ) diff --git a/src/main/kotlin/app/ui/components/RepositoriesTabPanel.kt b/src/main/kotlin/app/ui/components/RepositoriesTabPanel.kt index e611f2d..34970e2 100644 --- a/src/main/kotlin/app/ui/components/RepositoriesTabPanel.kt +++ b/src/main/kotlin/app/ui/components/RepositoriesTabPanel.kt @@ -15,6 +15,9 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import app.theme.primaryLight +import app.theme.primaryTextColor +import app.theme.tabColorActive +import app.theme.tabColorInactive @Composable @@ -125,19 +128,24 @@ fun TabPanel( @Composable fun Tab(title: MutableState, selected: Boolean, onClick: () -> Unit, onCloseTab: () -> Unit) { Card { + val backgroundColor = if (selected) + MaterialTheme.colors.tabColorActive + else + MaterialTheme.colors.tabColorInactive + Box( modifier = Modifier .padding(horizontal = 1.dp) .height(36.dp) .clip(RoundedCornerShape(5.dp)) - .background(if (selected) MaterialTheme.colors.primary else primaryLight) + .background(backgroundColor) .clickable { onClick() }, ) { Text( text = title.value, modifier = Modifier .padding(vertical = 8.dp, horizontal = 32.dp), - color = contentColorFor(MaterialTheme.colors.primary), + color = contentColorFor(backgroundColor), ) IconButton( onClick = onCloseTab, diff --git a/src/main/resources/tag.svg b/src/main/resources/tag.svg new file mode 100644 index 0000000..ccda60c --- /dev/null +++ b/src/main/resources/tag.svg @@ -0,0 +1 @@ + \ No newline at end of file