From a8ed01784d777f8bc7e879a67c646feba8bc3d75 Mon Sep 17 00:00:00 2001 From: Abdelilah El Aissaoui Date: Tue, 28 Dec 2021 01:19:29 +0100 Subject: [PATCH] Implemented stage/unstage by hunks --- .../kotlin/app/extensions/StringExtensions.kt | 7 +- src/main/kotlin/app/git/GitManager.kt | 12 ++ src/main/kotlin/app/git/StatusManager.kt | 143 ++++++++++++++++-- src/main/kotlin/app/git/diff/Hunk.kt | 10 +- .../kotlin/app/git/diff/HunkDiffGenerator.kt | 6 +- src/main/kotlin/app/ui/Diff.kt | 80 +++++++--- src/main/kotlin/app/ui/UncommitedChanges.kt | 26 +--- .../app/ui/components/SecondaryButton.kt | 39 +++++ 8 files changed, 266 insertions(+), 57 deletions(-) create mode 100644 src/main/kotlin/app/ui/components/SecondaryButton.kt diff --git a/src/main/kotlin/app/extensions/StringExtensions.kt b/src/main/kotlin/app/extensions/StringExtensions.kt index 0040466..37f025b 100644 --- a/src/main/kotlin/app/extensions/StringExtensions.kt +++ b/src/main/kotlin/app/extensions/StringExtensions.kt @@ -28,4 +28,9 @@ val String.dirPath: String parts.joinToString("/") } else this - } \ No newline at end of file + } + +val String.withoutLineEnding: String +get() = this + .removeSuffix("\n") + .removeSuffix("\r\n") \ No newline at end of file diff --git a/src/main/kotlin/app/git/GitManager.kt b/src/main/kotlin/app/git/GitManager.kt index 993df3d..60d498d 100644 --- a/src/main/kotlin/app/git/GitManager.kt +++ b/src/main/kotlin/app/git/GitManager.kt @@ -173,6 +173,18 @@ class GitManager @Inject constructor( } } + fun stageHunk(diffEntry: DiffEntry, hunk: Hunk) = managerScope.launch { + runOperation { + statusManager.stageHunk(safeGit, diffEntry, hunk) + } + } + + fun unstageHunk(diffEntry: DiffEntry, hunk: Hunk) = managerScope.launch { + runOperation { + statusManager.unstageHunk(safeGit, diffEntry, hunk) + } + } + fun unstage(diffEntry: DiffEntry) = managerScope.launch { runOperation { statusManager.unstage(safeGit, diffEntry) diff --git a/src/main/kotlin/app/git/StatusManager.kt b/src/main/kotlin/app/git/StatusManager.kt index a53b513..3d06e78 100644 --- a/src/main/kotlin/app/git/StatusManager.kt +++ b/src/main/kotlin/app/git/StatusManager.kt @@ -1,7 +1,12 @@ package app.git +import app.di.RawFileManagerFactory import app.extensions.filePath import app.extensions.hasUntrackedChanges +import app.extensions.isMerging +import app.extensions.withoutLineEnding +import app.git.diff.Hunk +import app.git.diff.LineType import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.MutableStateFlow @@ -9,12 +14,21 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.withContext import org.eclipse.jgit.api.Git import org.eclipse.jgit.diff.DiffEntry -import org.eclipse.jgit.lib.RepositoryState +import org.eclipse.jgit.diff.RawText +import org.eclipse.jgit.dircache.DirCacheEditor.PathEdit +import org.eclipse.jgit.dircache.DirCacheEntry +import org.eclipse.jgit.lib.* import org.eclipse.jgit.treewalk.EmptyTreeIterator +import java.io.ByteArrayInputStream +import java.io.IOException +import java.nio.ByteBuffer +import java.time.Instant import javax.inject.Inject + class StatusManager @Inject constructor( private val branchesManager: BranchesManager, + private val rawFileManagerFactory: RawFileManagerFactory, ) { private val _stageStatus = MutableStateFlow(StageStatus.Loaded(listOf(), listOf())) val stageStatus: StateFlow = _stageStatus @@ -53,7 +67,7 @@ class StatusManager @Inject constructor( val currentBranch = branchesManager.currentBranchRef(git) val repositoryState = git.repository.repositoryState val staged = git.diff().apply { - if(currentBranch == null && repositoryState != RepositoryState.MERGING && !repositoryState.isRebasing ) + if (currentBranch == null && !repositoryState.isMerging && !repositoryState.isRebasing) setOldTree(EmptyTreeIterator()) // Required if the repository is empty setCached(true) @@ -64,7 +78,7 @@ class StatusManager @Inject constructor( .map { val entries = it.value - if(entries.count() > 1) + if (entries.count() > 1 && (repositoryState.isMerging || repositoryState.isRebasing)) entries.filter { entry -> entry.oldPath != "/dev/null" } else entries @@ -79,7 +93,7 @@ class StatusManager @Inject constructor( .map { val entries = it.value - if(entries.count() > 1) + if (entries.count() > 1 && (repositoryState.isMerging || repositoryState.isRebasing)) entries.filter { entry -> entry.newPath != "/dev/null" } else entries @@ -108,12 +122,121 @@ class StatusManager @Inject constructor( loadStatus(git) } -// suspend fun stageHunk(git: Git) { -//// val repository = git.repository -//// val objectInserter = repository.newObjectInserter() -// -//// objectInserter.insert(Constants.OBJ_BLOB,) -// } + suspend fun stageHunk(git: Git, diffEntry: DiffEntry, hunk: Hunk) = withContext(Dispatchers.IO) { + val repository = git.repository + val dirCache = repository.lockDirCache() + val dirCacheEditor = dirCache.editor() + var completedWithErrors = true + + try { + val rawFileManager = rawFileManagerFactory.create(git.repository) + val rawFile = rawFileManager.getRawContent(DiffEntry.Side.OLD, diffEntry) + val textLines = getTextLines(rawFile).toMutableList() + + val hunkLines = hunk.lines.filter { it.lineType != LineType.CONTEXT } + + var linesAdded = 0 + for (line in hunkLines) { + when (line.lineType) { + LineType.ADDED -> { + textLines.add(line.oldLineNumber + linesAdded, line.text.withoutLineEnding) + linesAdded++ + } + LineType.REMOVED -> { + textLines.removeAt(line.oldLineNumber + linesAdded) + linesAdded-- + } + else -> throw NotImplementedError("Line type not implemented for stage hunk") + } + } + + val stagedFileText = textLines.joinToString(rawFile.lineDelimiter) + dirCacheEditor.add(HunkEdit(diffEntry.newPath, repository, ByteBuffer.wrap(stagedFileText.toByteArray()))) + dirCacheEditor.commit() + + completedWithErrors = false + + loadStatus(git) + } finally { + if (completedWithErrors) + dirCache.unlock() + } + } + + suspend fun unstageHunk(git: Git, diffEntry: DiffEntry, hunk: Hunk) = withContext(Dispatchers.IO) { + val repository = git.repository + val dirCache = repository.lockDirCache() + val dirCacheEditor = dirCache.editor() + + val rawFileManager = rawFileManagerFactory.create(git.repository) + val rawFile = rawFileManager.getRawContent(DiffEntry.Side.NEW, diffEntry) + val textLines = getTextLines(rawFile).toMutableList() + + val hunkLines = hunk.lines.filter { it.lineType != LineType.CONTEXT } + + val addedLines = hunkLines + .filter { it.lineType == LineType.ADDED } + .sortedBy { it.newLineNumber } + val removedLines = hunkLines + .filter { it.lineType == LineType.REMOVED } + .sortedBy { it.newLineNumber } + + var linesRemoved = 0 + + // Start by removing the added lines to the index + for (line in addedLines) { + textLines.removeAt(line.newLineNumber + linesRemoved) + linesRemoved-- + } + + var linesAdded = 0 + + // Restore previously removed lines to the index + for (line in removedLines) { + textLines.add(line.newLineNumber + linesAdded, line.text.withoutLineEnding) + linesAdded++ + } + + val stagedFileText = textLines.joinToString(rawFile.lineDelimiter) + dirCacheEditor.add(HunkEdit(diffEntry.newPath, repository, ByteBuffer.wrap(stagedFileText.toByteArray()))) + dirCacheEditor.commit() + + loadStatus(git) + } + + private fun getTextLines(rawFile: RawText): List { + val content = rawFile.rawContent.toString(Charsets.UTF_8) + return content.split(rawFile.lineDelimiter).toMutableList() + } + + private class HunkEdit( + path: String?, + private val repo: Repository, + private val content: ByteBuffer, + ) : PathEdit(path) { + override fun apply(ent: DirCacheEntry) { + val inserter: ObjectInserter = repo.newObjectInserter() + if (ent.rawMode and FileMode.TYPE_MASK != FileMode.TYPE_FILE) { + ent.fileMode = FileMode.REGULAR_FILE + } + ent.length = content.limit() + ent.setLastModified(Instant.now()) + try { + val `in` = ByteArrayInputStream( + content.array(), 0, content.limit() + ) + ent.setObjectId( + inserter.insert( + Constants.OBJ_BLOB, content.limit().toLong(), + `in` + ) + ) + inserter.flush() + } catch (ex: IOException) { + throw RuntimeException(ex) + } + } + } suspend fun unstage(git: Git, diffEntry: DiffEntry) = withContext(Dispatchers.IO) { git.reset() diff --git a/src/main/kotlin/app/git/diff/Hunk.kt b/src/main/kotlin/app/git/diff/Hunk.kt index 3c8804d..568ecac 100644 --- a/src/main/kotlin/app/git/diff/Hunk.kt +++ b/src/main/kotlin/app/git/diff/Hunk.kt @@ -2,8 +2,10 @@ package app.git.diff data class Hunk(val header: String, val lines: List) -sealed class Line(val content: String) { - class ContextLine(content: String, val oldLineNumber: Int, val newLineNumber: Int): Line(content) - class AddedLine(content: String, val oldLineNumber: Int, val newLineNumber: Int): Line(content) - class RemovedLine(content: String, val lineNumber: Int): Line(content) +data class Line(val text: String, val oldLineNumber: Int, val newLineNumber: Int, val lineType: LineType) + +enum class LineType { + CONTEXT, + ADDED, + REMOVED, } \ No newline at end of file diff --git a/src/main/kotlin/app/git/diff/HunkDiffGenerator.kt b/src/main/kotlin/app/git/diff/HunkDiffGenerator.kt index 374286a..cb4658a 100644 --- a/src/main/kotlin/app/git/diff/HunkDiffGenerator.kt +++ b/src/main/kotlin/app/git/diff/HunkDiffGenerator.kt @@ -72,18 +72,18 @@ class HunkDiffGenerator @AssistedInject constructor( while (oldCurrentLine < oldEndLine || newCurrentLine < newEndLine) { if (oldCurrentLine < curEdit.beginA || endIdx + 1 < curIdx) { val lineText = oldRawText.lineAt(oldCurrentLine) - lines.add(Line.ContextLine(lineText, oldCurrentLine, newCurrentLine)) + lines.add(Line(lineText, oldCurrentLine, newCurrentLine, LineType.CONTEXT)) oldCurrentLine++ newCurrentLine++ } else if (oldCurrentLine < curEdit.endA) { val lineText = oldRawText.lineAt(oldCurrentLine) - lines.add(Line.RemovedLine(lineText, oldCurrentLine)) + lines.add(Line(lineText, oldCurrentLine, newCurrentLine, LineType.REMOVED)) oldCurrentLine++ } else if (newCurrentLine < curEdit.endB) { val lineText = newRawText.lineAt(newCurrentLine) - lines.add(Line.AddedLine(lineText, oldCurrentLine, newCurrentLine)) + lines.add(Line(lineText, oldCurrentLine, newCurrentLine, LineType.ADDED)) newCurrentLine++ } diff --git a/src/main/kotlin/app/ui/Diff.kt b/src/main/kotlin/app/ui/Diff.kt index 51dcd75..4e342e3 100644 --- a/src/main/kotlin/app/ui/Diff.kt +++ b/src/main/kotlin/app/ui/Diff.kt @@ -1,11 +1,8 @@ package app.ui import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material.ButtonDefaults import androidx.compose.material.MaterialTheme @@ -21,9 +18,11 @@ import androidx.compose.ui.unit.sp import app.git.DiffEntryType import app.git.GitManager import app.git.diff.Hunk -import app.git.diff.Line +import app.git.diff.LineType import app.theme.primaryTextColor import app.ui.components.ScrollableLazyColumn +import app.ui.components.SecondaryButton +import org.eclipse.jgit.diff.DiffEntry @Composable fun Diff(gitManager: GitManager, diffEntryType: DiffEntryType, onCloseDiffView: () -> Unit) { @@ -54,39 +53,78 @@ fun Diff(gitManager: GitManager, diffEntryType: DiffEntryType, onCloseDiffView: ) { Text("Close diff") } - SelectionContainer { - ScrollableLazyColumn( - modifier = Modifier - .fillMaxSize() - .padding(16.dp) - ) { - items(text) { hunk -> + + ScrollableLazyColumn( + modifier = Modifier + .fillMaxSize() +// .padding(16.dp) + ) { + itemsIndexed(text) { index, hunk -> + val hunksSeparation = if (index == 0) + 0.dp + else + 16.dp + Row( + modifier = Modifier + .padding(top = hunksSeparation) + .background(MaterialTheme.colors.surface) + .padding(vertical = 4.dp) + .fillMaxWidth() + ) { Text( text = hunk.header, color = MaterialTheme.colors.primaryTextColor, - modifier = Modifier - .background(MaterialTheme.colors.surface) - .fillMaxWidth(), ) + Spacer(modifier = Modifier.weight(1f)) + if ( + (diffEntryType is DiffEntryType.StagedDiff || diffEntryType is DiffEntryType.UnstagedDiff) && + diffEntryType.diffEntry.changeType == DiffEntry.ChangeType.MODIFY + ) { + val buttonText: String + val color: Color + if (diffEntryType is DiffEntryType.StagedDiff) { + buttonText = "Unstage" + color = MaterialTheme.colors.error + } else { + buttonText = "Stage" + color = MaterialTheme.colors.primary + } + + SecondaryButton( + text = buttonText, + backgroundButton = color, + onClick = { + if (diffEntryType is DiffEntryType.StagedDiff) { + gitManager.unstageHunk(diffEntryType.diffEntry, hunk) + } else { + gitManager.stageHunk(diffEntryType.diffEntry, hunk) + } + } + ) + } + } + + SelectionContainer { Column { hunk.lines.forEach { line -> - val backgroundColor = when (line) { - is Line.AddedLine -> { + val backgroundColor = when (line.lineType) { + LineType.ADDED -> { Color(0x77a9d49b) } - is Line.RemovedLine -> { + LineType.REMOVED -> { Color(0x77dea2a2) } - is Line.ContextLine -> { + LineType.CONTEXT -> { MaterialTheme.colors.background } } Text( - text = line.content, + text = line.text, modifier = Modifier .background(backgroundColor) + .padding(start = 16.dp) .fillMaxWidth(), color = MaterialTheme.colors.primaryTextColor, maxLines = 1, diff --git a/src/main/kotlin/app/ui/UncommitedChanges.kt b/src/main/kotlin/app/ui/UncommitedChanges.kt index a1cbcf3..1cd24d8 100644 --- a/src/main/kotlin/app/ui/UncommitedChanges.kt +++ b/src/main/kotlin/app/ui/UncommitedChanges.kt @@ -9,13 +9,11 @@ import androidx.compose.animation.fadeOut import androidx.compose.foundation.* 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.graphics.Color import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.input.key.Key @@ -38,6 +36,7 @@ import app.theme.headerBackground import app.theme.headerText import app.theme.primaryTextColor import app.ui.components.ScrollableLazyColumn +import app.ui.components.SecondaryButton import org.eclipse.jgit.diff.DiffEntry @OptIn(ExperimentalAnimationApi::class, androidx.compose.ui.ExperimentalComposeUiApi::class) @@ -50,6 +49,7 @@ fun UncommitedChanges( val stageStatusState = gitManager.stageStatus.collectAsState() val stageStatus = stageStatusState.value val lastCheck by gitManager.lastTimeChecked.collectAsState() + val repositoryState by gitManager.repositoryState.collectAsState() LaunchedEffect(lastCheck) { gitManager.loadStatus() @@ -201,22 +201,12 @@ private fun EntriesList( maxLines = 1, ) - Box( - modifier = Modifier - .padding(horizontal = 16.dp) - .align(Alignment.CenterEnd) - .clip(RoundedCornerShape(5.dp)) - .background(actionColor) - .clickable { onAllAction() }, - ) { - Text( - text = allActionTitle, - fontSize = 12.sp, - color = MaterialTheme.colors.contentColorFor(actionColor), - modifier = Modifier.padding(vertical = 4.dp, horizontal = 16.dp) - ) - } - + SecondaryButton( + modifier = Modifier.align(Alignment.CenterEnd), + text = allActionTitle, + backgroundButton = actionColor, + onClick = onAllAction + ) } ScrollableLazyColumn( diff --git a/src/main/kotlin/app/ui/components/SecondaryButton.kt b/src/main/kotlin/app/ui/components/SecondaryButton.kt new file mode 100644 index 0000000..d049677 --- /dev/null +++ b/src/main/kotlin/app/ui/components/SecondaryButton.kt @@ -0,0 +1,39 @@ +package app.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.material.contentColorFor +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +@Composable +fun SecondaryButton( + modifier: Modifier = Modifier, + text: String, + backgroundButton: Color, + onClick: () -> Unit, +) { + Box( + modifier = modifier + .padding(horizontal = 16.dp) + .clip(RoundedCornerShape(5.dp)) + .background(backgroundButton) + .clickable { onClick() }, + ) { + Text( + text = text, + fontSize = 12.sp, + color = MaterialTheme.colors.contentColorFor(backgroundButton), + modifier = Modifier.padding(vertical = 4.dp, horizontal = 16.dp) + ) + } +} \ No newline at end of file