Refactored diff by replacing text diff patch with a custom Hunk class

This commit is contained in:
Abdelilah El Aissaoui 2021-12-27 05:13:55 +01:00
parent 908696735b
commit bd719be963
6 changed files with 276 additions and 51 deletions

View File

@ -0,0 +1,10 @@
package app.extensions
import org.eclipse.jgit.diff.RawText
import java.io.ByteArrayOutputStream
fun RawText.lineAt(line: Int): String {
val outputStream = ByteArrayOutputStream()
this.writeLine(outputStream, line)
return outputStream.toString(Charsets.UTF_8) + "\n"
}

View File

@ -1,8 +1,11 @@
package app.git package app.git
import app.extensions.fullData import app.extensions.fullData
import app.git.diff.Hunk
import app.git.diff.HunkDiffGenerator
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.apache.commons.logging.LogFactory.objectId
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.diff.DiffFormatter import org.eclipse.jgit.diff.DiffFormatter
@ -17,18 +20,19 @@ import org.eclipse.jgit.treewalk.FileTreeIterator
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import javax.inject.Inject import javax.inject.Inject
class DiffManager @Inject constructor() { class DiffManager @Inject constructor() {
suspend fun diffFormat(git: Git, diffEntryType: DiffEntryType): List<String> = withContext(Dispatchers.IO) { suspend fun diffFormat(git: Git, diffEntryType: DiffEntryType): List<Hunk> = withContext(Dispatchers.IO) {
val diffEntry = diffEntryType.diffEntry val diffEntry = diffEntryType.diffEntry
val byteArrayOutputStream = ByteArrayOutputStream() val byteArrayOutputStream = ByteArrayOutputStream()
val repository = git.repository
DiffFormatter(byteArrayOutputStream).use { formatter -> DiffFormatter(byteArrayOutputStream).use { formatter ->
val repo = git.repository
formatter.setRepository(repo) formatter.setRepository(repository)
val oldTree = DirCacheIterator(repo.readDirCache()) val oldTree = DirCacheIterator(repository.readDirCache())
val newTree = FileTreeIterator(repo) val newTree = FileTreeIterator(repository)
if (diffEntryType is DiffEntryType.UnstagedDiff) if (diffEntryType is DiffEntryType.UnstagedDiff)
formatter.scan(oldTree, newTree) formatter.scan(oldTree, newTree)
@ -38,22 +42,21 @@ class DiffManager @Inject constructor() {
formatter.flush() formatter.flush()
} }
val diff = byteArrayOutputStream.toString(Charsets.UTF_8) val hunkDiffGenerator = HunkDiffGenerator(git.repository)
val hunks = mutableListOf<Hunk>()
// TODO This is just a workaround, try to find properly which lines have to be displayed by using a custom diff hunkDiffGenerator.use {
if (diffEntryType is DiffEntryType.UnstagedDiff) {
val oldTree = DirCacheIterator(repository.readDirCache())
val newTree = FileTreeIterator(repository)
hunkDiffGenerator.scan(oldTree, newTree)
}
val containsWindowsNewLine = diff.contains("\r\n") hunks.addAll(hunkDiffGenerator.format(diffEntry))
return@withContext diff.split("\n", "\r\n").filterNot {
it.startsWith("diff --git")
}.map {
if (containsWindowsNewLine)
"$it\r\n"
else
"$it\n"
} }
}
return@withContext hunks
}
suspend fun commitDiffEntries(git: Git, commit: RevCommit): List<DiffEntry> = withContext(Dispatchers.IO) { suspend fun commitDiffEntries(git: Git, commit: RevCommit): List<DiffEntry> = withContext(Dispatchers.IO) {
val fullCommit = commit.fullData(git.repository) ?: return@withContext emptyList() val fullCommit = commit.fullData(git.repository) ?: return@withContext emptyList()

View File

@ -5,6 +5,7 @@ import app.app.ErrorsManager
import app.app.newErrorNow import app.app.newErrorNow
import app.credentials.CredentialsState import app.credentials.CredentialsState
import app.credentials.CredentialsStateManager import app.credentials.CredentialsStateManager
import app.git.diff.Hunk
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@ -188,7 +189,7 @@ class GitManager @Inject constructor(
val hasUncommitedChanges: StateFlow<Boolean> val hasUncommitedChanges: StateFlow<Boolean>
get() = statusManager.hasUncommitedChanges get() = statusManager.hasUncommitedChanges
suspend fun diffFormat(diffEntryType: DiffEntryType): List<String> { suspend fun diffFormat(diffEntryType: DiffEntryType): List<Hunk> {
try { try {
return diffManager.diffFormat(safeGit, diffEntryType) return diffManager.diffFormat(safeGit, diffEntryType)
} catch (ex: Exception) { } catch (ex: Exception) {

View File

@ -0,0 +1,9 @@
package app.git.diff
data class Hunk(val header: String, val lines: List<Line>)
sealed class Line(val content: String) {
class ContextLine(content: String, val oldLineNumber: Int, val newLineNumber: Int): Line(content)
class AddedLine(content: String, val lineNumber: Int): Line(content)
class RemovedLine(content: String, val lineNumber: Int): Line(content)
}

View File

@ -0,0 +1,200 @@
package app.git.diff
import app.extensions.lineAt
import org.eclipse.jgit.diff.*
import org.eclipse.jgit.diff.DiffAlgorithm.SupportedAlgorithm
import org.eclipse.jgit.lib.*
import org.eclipse.jgit.patch.FileHeader
import org.eclipse.jgit.patch.FileHeader.PatchType
import org.eclipse.jgit.storage.pack.PackConfig
import org.eclipse.jgit.treewalk.AbstractTreeIterator
import org.eclipse.jgit.treewalk.WorkingTreeIterator
import org.eclipse.jgit.util.LfsFactory
import java.io.ByteArrayOutputStream
import java.io.IOException
import kotlin.math.max
import kotlin.math.min
private const val DEFAULT_BINARY_FILE_THRESHOLD = PackConfig.DEFAULT_BIG_FILE_THRESHOLD
/**
* Generator of [Hunk] lists from [DiffEntry]
*/
class HunkDiffGenerator(
private val repository: Repository,
) : AutoCloseable {
private var reader: ObjectReader? = null
private lateinit var diffCfg: DiffConfig
private lateinit var diffAlgorithm: DiffAlgorithm
private var context = 3
private var binaryFileThreshold = DEFAULT_BINARY_FILE_THRESHOLD
private val outputStream = ByteArrayOutputStream() // Dummy output stream used for the diff formatter
private val diffFormatter = DiffFormatter(outputStream).apply {
setRepository(repository)
}
init {
setReader(repository.newObjectReader(), repository.config)
}
private var source: ContentSource.Pair? = null
private var quotePaths: Boolean? = null
private fun setReader(reader: ObjectReader, cfg: Config) {
close()
this.reader = reader
diffCfg = cfg.get(DiffConfig.KEY)
if (quotePaths == null) {
quotePaths = java.lang.Boolean
.valueOf(
cfg.getBoolean(
ConfigConstants.CONFIG_CORE_SECTION,
ConfigConstants.CONFIG_KEY_QUOTE_PATH, true
)
)
}
val cs = ContentSource.create(reader)
source = ContentSource.Pair(cs, cs)
diffAlgorithm = DiffAlgorithm.getAlgorithm(
cfg.getEnum(
ConfigConstants.CONFIG_DIFF_SECTION, null,
ConfigConstants.CONFIG_KEY_ALGORITHM,
SupportedAlgorithm.HISTOGRAM
)
)
}
override fun close() {
reader?.close()
outputStream.close()
}
fun scan(oldTreeIterator: AbstractTreeIterator, newTreeIterator: AbstractTreeIterator) {
source = ContentSource.Pair(source(oldTreeIterator), source(newTreeIterator))
diffFormatter.scan(oldTreeIterator, newTreeIterator)
}
private fun source(iterator: AbstractTreeIterator): ContentSource {
return if (iterator is WorkingTreeIterator)
ContentSource.create(iterator)
else
ContentSource.create(reader)
}
fun format(ent: DiffEntry): List<Hunk> {
val fileHeader = diffFormatter.toFileHeader(ent)
val rawOld = getRawContent(DiffEntry.Side.OLD, ent)
val rawNew = getRawContent(DiffEntry.Side.NEW, ent)
return format(fileHeader, rawOld, rawNew)
}
private fun getRawContent(side: DiffEntry.Side, entry: DiffEntry): RawText {
if (entry.getMode(side) === FileMode.MISSING) return RawText.EMPTY_TEXT
if (entry.getMode(side).objectType != Constants.OBJ_BLOB) return RawText.EMPTY_TEXT
val ldr = LfsFactory.getInstance().applySmudgeFilter(
repository,
source!!.open(side, entry), entry.diffAttribute
)
return RawText.load(ldr, binaryFileThreshold)
}
/**
* Given a [FileHeader] and the both [RawText], generate a [List] of [Hunk]
*/
private fun format(head: FileHeader, oldRawText: RawText, newRawText: RawText): List<Hunk> {
return if (head.patchType == PatchType.UNIFIED)
format(head.toEditList(), oldRawText, newRawText)
else
emptyList()
}
private fun format(edits: EditList, oldRawText: RawText, newRawText: RawText): List<Hunk> {
var curIdx = 0
val hunksList = mutableListOf<Hunk>()
while (curIdx < edits.size) {
var curEdit = edits[curIdx]
val endIdx = findCombinedEnd(edits, curIdx)
val endEdit = edits[endIdx]
var oldCurrentLine = max(0, curEdit.beginA - context)
var newCurrentLine = max(0, curEdit.beginB - context)
val oldEndLine = min(oldRawText.size(), endEdit.endA + context)
val newEndLine = min(newRawText.size(), endEdit.endB + context)
val headerText = createHunkHeader(oldCurrentLine, oldEndLine, newCurrentLine, newEndLine)
val lines = mutableListOf<Line>()
while (oldCurrentLine < oldEndLine || newCurrentLine < newEndLine) {
if (oldCurrentLine < curEdit.beginA || endIdx + 1 < curIdx) {
val lineText = oldRawText.lineAt(oldCurrentLine)
lines.add(Line.ContextLine(lineText, oldCurrentLine, newCurrentLine))
oldCurrentLine++
newCurrentLine++
} else if (oldCurrentLine < curEdit.endA) {
val lineText = oldRawText.lineAt(oldCurrentLine)
lines.add(Line.RemovedLine(lineText, oldCurrentLine))
oldCurrentLine++
} else if (newCurrentLine < curEdit.endB) {
val lineText = newRawText.lineAt(newCurrentLine)
lines.add(Line.AddedLine(lineText, newCurrentLine))
newCurrentLine++
}
if (end(curEdit, oldCurrentLine, newCurrentLine) && ++curIdx < edits.size) curEdit = edits[curIdx]
}
hunksList.add(Hunk(headerText, lines))
}
return hunksList
}
/**
* Generates the hunk's header string like in git diff
*/
@Throws(IOException::class)
private fun createHunkHeader(
oldStartLine: Int,
oldEndLine: Int,
newStartLine: Int,
newEndLine: Int
): String {
val prefix = "@@"
val contentRemoved = createRange('-', oldStartLine + 1, oldEndLine - oldStartLine)
val contentAdded = createRange('+', newStartLine + 1, newEndLine - newStartLine)
val suffix = " @@"
return prefix + contentRemoved + contentAdded + suffix
}
private fun createRange(symbol: Char, begin: Int, linesCount: Int): String {
return when (linesCount) {
0 -> " $symbol${begin - 1},0"
1 -> " $symbol$begin" // If the range is exactly one line, produce only the number.
else -> " $symbol$begin,$linesCount"
}
}
private fun findCombinedEnd(edits: List<Edit>, i: Int): Int {
var end = i + 1
while (end < edits.size
&& (combineA(edits, end) || combineB(edits, end))
) end++
return end - 1
}
private fun combineA(e: List<Edit>, i: Int): Boolean {
return e[i].beginA - e[i - 1].endA <= 2 * context
}
private fun combineB(e: List<Edit>, i: Int): Boolean {
return e[i].beginB - e[i - 1].endB <= 2 * context
}
private fun end(edit: Edit, a: Int, b: Int): Boolean {
return edit.endA <= a && edit.endB <= b
}
}

View File

@ -20,12 +20,14 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp 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.Line
import app.theme.primaryTextColor import app.theme.primaryTextColor
import app.ui.components.ScrollableLazyColumn import app.ui.components.ScrollableLazyColumn
@Composable @Composable
fun Diff(gitManager: GitManager, diffEntryType: DiffEntryType, onCloseDiffView: () -> Unit) { fun Diff(gitManager: GitManager, diffEntryType: DiffEntryType, onCloseDiffView: () -> Unit) {
var text by remember { mutableStateOf(listOf<String>()) } var text by remember { mutableStateOf(listOf<Hunk>()) }
LaunchedEffect(diffEntryType.diffEntry) { LaunchedEffect(diffEntryType.diffEntry) {
text = gitManager.diffFormat(diffEntryType) text = gitManager.diffFormat(diffEntryType)
@ -58,44 +60,44 @@ fun Diff(gitManager: GitManager, diffEntryType: DiffEntryType, onCloseDiffView:
.fillMaxSize() .fillMaxSize()
.padding(16.dp) .padding(16.dp)
) { ) {
items(text) { line -> items(text) { hunk ->
val isHunkLine = line.startsWith("@@") Text(
text = hunk.header,
color = MaterialTheme.colors.primaryTextColor,
modifier = Modifier
.background(MaterialTheme.colors.surface)
.fillMaxWidth(),
)
val backgroundColor = when { Column {
line.startsWith("+") -> { hunk.lines.forEach { line ->
Color(0x77a9d49b) val backgroundColor = when (line) {
} is Line.AddedLine -> {
line.startsWith("-") -> { Color(0x77a9d49b)
Color(0x77dea2a2) }
} is Line.RemovedLine -> {
isHunkLine -> { Color(0x77dea2a2)
MaterialTheme.colors.surface }
} is Line.ContextLine -> {
else -> { MaterialTheme.colors.background
MaterialTheme.colors.background }
}
Text(
text = line.content,
modifier = Modifier
.background(backgroundColor)
.fillMaxWidth(),
color = MaterialTheme.colors.primaryTextColor,
maxLines = 1,
fontFamily = FontFamily.Monospace,
fontSize = 14.sp,
)
} }
} }
val paddingTop = if (isHunkLine)
32.dp
else
0.dp
Text(
text = line,
modifier = Modifier
.padding(top = paddingTop)
.background(backgroundColor)
.fillMaxWidth(),
color = MaterialTheme.colors.primaryTextColor,
maxLines = 1,
fontFamily = FontFamily.Monospace,
fontSize = 14.sp,
)
} }
} }
} }
} }
} }