diff --git a/src/main/kotlin/app/extensions/RawTextExtensions.kt b/src/main/kotlin/app/extensions/RawTextExtensions.kt new file mode 100644 index 0000000..a1e655d --- /dev/null +++ b/src/main/kotlin/app/extensions/RawTextExtensions.kt @@ -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" +} \ No newline at end of file diff --git a/src/main/kotlin/app/git/DiffManager.kt b/src/main/kotlin/app/git/DiffManager.kt index 44399f8..ac1ef94 100644 --- a/src/main/kotlin/app/git/DiffManager.kt +++ b/src/main/kotlin/app/git/DiffManager.kt @@ -1,8 +1,11 @@ package app.git import app.extensions.fullData +import app.git.diff.Hunk +import app.git.diff.HunkDiffGenerator import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import org.apache.commons.logging.LogFactory.objectId import org.eclipse.jgit.api.Git import org.eclipse.jgit.diff.DiffEntry import org.eclipse.jgit.diff.DiffFormatter @@ -17,18 +20,19 @@ import org.eclipse.jgit.treewalk.FileTreeIterator import java.io.ByteArrayOutputStream import javax.inject.Inject + class DiffManager @Inject constructor() { - suspend fun diffFormat(git: Git, diffEntryType: DiffEntryType): List = withContext(Dispatchers.IO) { + suspend fun diffFormat(git: Git, diffEntryType: DiffEntryType): List = withContext(Dispatchers.IO) { val diffEntry = diffEntryType.diffEntry val byteArrayOutputStream = ByteArrayOutputStream() + val repository = git.repository DiffFormatter(byteArrayOutputStream).use { formatter -> - val repo = git.repository - formatter.setRepository(repo) + formatter.setRepository(repository) - val oldTree = DirCacheIterator(repo.readDirCache()) - val newTree = FileTreeIterator(repo) + val oldTree = DirCacheIterator(repository.readDirCache()) + val newTree = FileTreeIterator(repository) if (diffEntryType is DiffEntryType.UnstagedDiff) formatter.scan(oldTree, newTree) @@ -38,22 +42,21 @@ class DiffManager @Inject constructor() { formatter.flush() } - val diff = byteArrayOutputStream.toString(Charsets.UTF_8) + val hunkDiffGenerator = HunkDiffGenerator(git.repository) + val hunks = mutableListOf() - // 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") - - return@withContext diff.split("\n", "\r\n").filterNot { - it.startsWith("diff --git") - }.map { - if (containsWindowsNewLine) - "$it\r\n" - else - "$it\n" + hunks.addAll(hunkDiffGenerator.format(diffEntry)) } - } + return@withContext hunks + } suspend fun commitDiffEntries(git: Git, commit: RevCommit): List = withContext(Dispatchers.IO) { val fullCommit = commit.fullData(git.repository) ?: return@withContext emptyList() diff --git a/src/main/kotlin/app/git/GitManager.kt b/src/main/kotlin/app/git/GitManager.kt index ba971f9..993df3d 100644 --- a/src/main/kotlin/app/git/GitManager.kt +++ b/src/main/kotlin/app/git/GitManager.kt @@ -5,6 +5,7 @@ import app.app.ErrorsManager import app.app.newErrorNow import app.credentials.CredentialsState import app.credentials.CredentialsStateManager +import app.git.diff.Hunk import kotlinx.coroutines.* import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -188,7 +189,7 @@ class GitManager @Inject constructor( val hasUncommitedChanges: StateFlow get() = statusManager.hasUncommitedChanges - suspend fun diffFormat(diffEntryType: DiffEntryType): List { + suspend fun diffFormat(diffEntryType: DiffEntryType): List { try { return diffManager.diffFormat(safeGit, diffEntryType) } catch (ex: Exception) { diff --git a/src/main/kotlin/app/git/diff/Hunk.kt b/src/main/kotlin/app/git/diff/Hunk.kt new file mode 100644 index 0000000..46bf481 --- /dev/null +++ b/src/main/kotlin/app/git/diff/Hunk.kt @@ -0,0 +1,9 @@ +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 lineNumber: Int): Line(content) + class RemovedLine(content: String, val lineNumber: Int): Line(content) +} \ 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 new file mode 100644 index 0000000..e4c397e --- /dev/null +++ b/src/main/kotlin/app/git/diff/HunkDiffGenerator.kt @@ -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 { + 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 { + return if (head.patchType == PatchType.UNIFIED) + format(head.toEditList(), oldRawText, newRawText) + else + emptyList() + } + + private fun format(edits: EditList, oldRawText: RawText, newRawText: RawText): List { + var curIdx = 0 + val hunksList = mutableListOf() + 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() + + 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, 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, i: Int): Boolean { + return e[i].beginA - e[i - 1].endA <= 2 * context + } + + private fun combineB(e: List, 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 + } +} \ No newline at end of file diff --git a/src/main/kotlin/app/ui/Diff.kt b/src/main/kotlin/app/ui/Diff.kt index 9c48cca..51dcd75 100644 --- a/src/main/kotlin/app/ui/Diff.kt +++ b/src/main/kotlin/app/ui/Diff.kt @@ -20,12 +20,14 @@ import androidx.compose.ui.unit.dp 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.theme.primaryTextColor import app.ui.components.ScrollableLazyColumn @Composable fun Diff(gitManager: GitManager, diffEntryType: DiffEntryType, onCloseDiffView: () -> Unit) { - var text by remember { mutableStateOf(listOf()) } + var text by remember { mutableStateOf(listOf()) } LaunchedEffect(diffEntryType.diffEntry) { text = gitManager.diffFormat(diffEntryType) @@ -58,44 +60,44 @@ fun Diff(gitManager: GitManager, diffEntryType: DiffEntryType, onCloseDiffView: .fillMaxSize() .padding(16.dp) ) { - items(text) { line -> - val isHunkLine = line.startsWith("@@") + items(text) { hunk -> + Text( + text = hunk.header, + color = MaterialTheme.colors.primaryTextColor, + modifier = Modifier + .background(MaterialTheme.colors.surface) + .fillMaxWidth(), + ) - val backgroundColor = when { - line.startsWith("+") -> { - Color(0x77a9d49b) - } - line.startsWith("-") -> { - Color(0x77dea2a2) - } - isHunkLine -> { - MaterialTheme.colors.surface - } - else -> { - MaterialTheme.colors.background + Column { + hunk.lines.forEach { line -> + val backgroundColor = when (line) { + is Line.AddedLine -> { + Color(0x77a9d49b) + } + is Line.RemovedLine -> { + Color(0x77dea2a2) + } + is Line.ContextLine -> { + 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, - ) } } } - } }