Implemented stage/unstage by hunks

This commit is contained in:
Abdelilah El Aissaoui 2021-12-28 01:19:29 +01:00
parent 86fd61d0d7
commit a8ed01784d
8 changed files with 266 additions and 57 deletions

View File

@ -28,4 +28,9 @@ val String.dirPath: String
parts.joinToString("/") parts.joinToString("/")
} else } else
this this
} }
val String.withoutLineEnding: String
get() = this
.removeSuffix("\n")
.removeSuffix("\r\n")

View File

@ -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 { fun unstage(diffEntry: DiffEntry) = managerScope.launch {
runOperation { runOperation {
statusManager.unstage(safeGit, diffEntry) statusManager.unstage(safeGit, diffEntry)

View File

@ -1,7 +1,12 @@
package app.git package app.git
import app.di.RawFileManagerFactory
import app.extensions.filePath import app.extensions.filePath
import app.extensions.hasUntrackedChanges 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.Dispatchers
import kotlinx.coroutines.ensureActive import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@ -9,12 +14,21 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.eclipse.jgit.api.Git import org.eclipse.jgit.api.Git
import org.eclipse.jgit.diff.DiffEntry 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 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 import javax.inject.Inject
class StatusManager @Inject constructor( class StatusManager @Inject constructor(
private val branchesManager: BranchesManager, private val branchesManager: BranchesManager,
private val rawFileManagerFactory: RawFileManagerFactory,
) { ) {
private val _stageStatus = MutableStateFlow<StageStatus>(StageStatus.Loaded(listOf(), listOf())) private val _stageStatus = MutableStateFlow<StageStatus>(StageStatus.Loaded(listOf(), listOf()))
val stageStatus: StateFlow<StageStatus> = _stageStatus val stageStatus: StateFlow<StageStatus> = _stageStatus
@ -53,7 +67,7 @@ class StatusManager @Inject constructor(
val currentBranch = branchesManager.currentBranchRef(git) val currentBranch = branchesManager.currentBranchRef(git)
val repositoryState = git.repository.repositoryState val repositoryState = git.repository.repositoryState
val staged = git.diff().apply { 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 setOldTree(EmptyTreeIterator()) // Required if the repository is empty
setCached(true) setCached(true)
@ -64,7 +78,7 @@ class StatusManager @Inject constructor(
.map { .map {
val entries = it.value val entries = it.value
if(entries.count() > 1) if (entries.count() > 1 && (repositoryState.isMerging || repositoryState.isRebasing))
entries.filter { entry -> entry.oldPath != "/dev/null" } entries.filter { entry -> entry.oldPath != "/dev/null" }
else else
entries entries
@ -79,7 +93,7 @@ class StatusManager @Inject constructor(
.map { .map {
val entries = it.value val entries = it.value
if(entries.count() > 1) if (entries.count() > 1 && (repositoryState.isMerging || repositoryState.isRebasing))
entries.filter { entry -> entry.newPath != "/dev/null" } entries.filter { entry -> entry.newPath != "/dev/null" }
else else
entries entries
@ -108,12 +122,121 @@ class StatusManager @Inject constructor(
loadStatus(git) loadStatus(git)
} }
// suspend fun stageHunk(git: Git) { suspend fun stageHunk(git: Git, diffEntry: DiffEntry, hunk: Hunk) = withContext(Dispatchers.IO) {
//// val repository = git.repository val repository = git.repository
//// val objectInserter = repository.newObjectInserter() val dirCache = repository.lockDirCache()
// val dirCacheEditor = dirCache.editor()
//// objectInserter.insert(Constants.OBJ_BLOB,) 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<String> {
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) { suspend fun unstage(git: Git, diffEntry: DiffEntry) = withContext(Dispatchers.IO) {
git.reset() git.reset()

View File

@ -2,8 +2,10 @@ package app.git.diff
data class Hunk(val header: String, val lines: List<Line>) data class Hunk(val header: String, val lines: List<Line>)
sealed class Line(val content: String) { data class Line(val text: String, val oldLineNumber: Int, val newLineNumber: Int, val lineType: LineType)
class ContextLine(content: String, val oldLineNumber: Int, val newLineNumber: Int): Line(content)
class AddedLine(content: String, val oldLineNumber: Int, val newLineNumber: Int): Line(content) enum class LineType {
class RemovedLine(content: String, val lineNumber: Int): Line(content) CONTEXT,
ADDED,
REMOVED,
} }

View File

@ -72,18 +72,18 @@ class HunkDiffGenerator @AssistedInject constructor(
while (oldCurrentLine < oldEndLine || newCurrentLine < newEndLine) { while (oldCurrentLine < oldEndLine || newCurrentLine < newEndLine) {
if (oldCurrentLine < curEdit.beginA || endIdx + 1 < curIdx) { if (oldCurrentLine < curEdit.beginA || endIdx + 1 < curIdx) {
val lineText = oldRawText.lineAt(oldCurrentLine) val lineText = oldRawText.lineAt(oldCurrentLine)
lines.add(Line.ContextLine(lineText, oldCurrentLine, newCurrentLine)) lines.add(Line(lineText, oldCurrentLine, newCurrentLine, LineType.CONTEXT))
oldCurrentLine++ oldCurrentLine++
newCurrentLine++ newCurrentLine++
} else if (oldCurrentLine < curEdit.endA) { } else if (oldCurrentLine < curEdit.endA) {
val lineText = oldRawText.lineAt(oldCurrentLine) val lineText = oldRawText.lineAt(oldCurrentLine)
lines.add(Line.RemovedLine(lineText, oldCurrentLine)) lines.add(Line(lineText, oldCurrentLine, newCurrentLine, LineType.REMOVED))
oldCurrentLine++ oldCurrentLine++
} else if (newCurrentLine < curEdit.endB) { } else if (newCurrentLine < curEdit.endB) {
val lineText = newRawText.lineAt(newCurrentLine) val lineText = newRawText.lineAt(newCurrentLine)
lines.add(Line.AddedLine(lineText, oldCurrentLine, newCurrentLine)) lines.add(Line(lineText, oldCurrentLine, newCurrentLine, LineType.ADDED))
newCurrentLine++ newCurrentLine++
} }

View File

@ -1,11 +1,8 @@
package app.ui package app.ui
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.ButtonDefaults import androidx.compose.material.ButtonDefaults
import androidx.compose.material.MaterialTheme import androidx.compose.material.MaterialTheme
@ -21,9 +18,11 @@ import androidx.compose.ui.unit.sp
import app.git.DiffEntryType import app.git.DiffEntryType
import app.git.GitManager import app.git.GitManager
import app.git.diff.Hunk import app.git.diff.Hunk
import app.git.diff.Line import app.git.diff.LineType
import app.theme.primaryTextColor import app.theme.primaryTextColor
import app.ui.components.ScrollableLazyColumn import app.ui.components.ScrollableLazyColumn
import app.ui.components.SecondaryButton
import org.eclipse.jgit.diff.DiffEntry
@Composable @Composable
fun Diff(gitManager: GitManager, diffEntryType: DiffEntryType, onCloseDiffView: () -> Unit) { fun Diff(gitManager: GitManager, diffEntryType: DiffEntryType, onCloseDiffView: () -> Unit) {
@ -54,39 +53,78 @@ fun Diff(gitManager: GitManager, diffEntryType: DiffEntryType, onCloseDiffView:
) { ) {
Text("Close diff") Text("Close diff")
} }
SelectionContainer {
ScrollableLazyColumn( ScrollableLazyColumn(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(16.dp) // .padding(16.dp)
) { ) {
items(text) { hunk -> 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(
text = hunk.header, text = hunk.header,
color = MaterialTheme.colors.primaryTextColor, 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 { Column {
hunk.lines.forEach { line -> hunk.lines.forEach { line ->
val backgroundColor = when (line) { val backgroundColor = when (line.lineType) {
is Line.AddedLine -> { LineType.ADDED -> {
Color(0x77a9d49b) Color(0x77a9d49b)
} }
is Line.RemovedLine -> { LineType.REMOVED -> {
Color(0x77dea2a2) Color(0x77dea2a2)
} }
is Line.ContextLine -> { LineType.CONTEXT -> {
MaterialTheme.colors.background MaterialTheme.colors.background
} }
} }
Text( Text(
text = line.content, text = line.text,
modifier = Modifier modifier = Modifier
.background(backgroundColor) .background(backgroundColor)
.padding(start = 16.dp)
.fillMaxWidth(), .fillMaxWidth(),
color = MaterialTheme.colors.primaryTextColor, color = MaterialTheme.colors.primaryTextColor,
maxLines = 1, maxLines = 1,

View File

@ -9,13 +9,11 @@ import androidx.compose.animation.fadeOut
import androidx.compose.foundation.* import androidx.compose.foundation.*
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.* import androidx.compose.material.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.Key
@ -38,6 +36,7 @@ import app.theme.headerBackground
import app.theme.headerText import app.theme.headerText
import app.theme.primaryTextColor import app.theme.primaryTextColor
import app.ui.components.ScrollableLazyColumn import app.ui.components.ScrollableLazyColumn
import app.ui.components.SecondaryButton
import org.eclipse.jgit.diff.DiffEntry import org.eclipse.jgit.diff.DiffEntry
@OptIn(ExperimentalAnimationApi::class, androidx.compose.ui.ExperimentalComposeUiApi::class) @OptIn(ExperimentalAnimationApi::class, androidx.compose.ui.ExperimentalComposeUiApi::class)
@ -50,6 +49,7 @@ fun UncommitedChanges(
val stageStatusState = gitManager.stageStatus.collectAsState() val stageStatusState = gitManager.stageStatus.collectAsState()
val stageStatus = stageStatusState.value val stageStatus = stageStatusState.value
val lastCheck by gitManager.lastTimeChecked.collectAsState() val lastCheck by gitManager.lastTimeChecked.collectAsState()
val repositoryState by gitManager.repositoryState.collectAsState()
LaunchedEffect(lastCheck) { LaunchedEffect(lastCheck) {
gitManager.loadStatus() gitManager.loadStatus()
@ -201,22 +201,12 @@ private fun EntriesList(
maxLines = 1, maxLines = 1,
) )
Box( SecondaryButton(
modifier = Modifier modifier = Modifier.align(Alignment.CenterEnd),
.padding(horizontal = 16.dp) text = allActionTitle,
.align(Alignment.CenterEnd) backgroundButton = actionColor,
.clip(RoundedCornerShape(5.dp)) onClick = onAllAction
.background(actionColor) )
.clickable { onAllAction() },
) {
Text(
text = allActionTitle,
fontSize = 12.sp,
color = MaterialTheme.colors.contentColorFor(actionColor),
modifier = Modifier.padding(vertical = 4.dp, horizontal = 16.dp)
)
}
} }
ScrollableLazyColumn( ScrollableLazyColumn(

View File

@ -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)
)
}
}