Gitnuro/src/main/kotlin/app/ui/Diff.kt
2022-05-28 16:28:39 +02:00

423 lines
14 KiB
Kotlin

@file:OptIn(ExperimentalComposeUiApi::class)
package app.ui
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.selection.DisableSelection
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.IconButton
import androidx.compose.material.LinearProgressIndicator
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.input.pointer.PointerIconDefaults
import androidx.compose.ui.input.pointer.pointerHoverIcon
import androidx.compose.ui.res.loadImageBitmap
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import app.extensions.toStringWithSpaces
import app.git.DiffEntryType
import app.git.EntryContent
import app.git.StatusEntry
import app.git.StatusType
import app.git.diff.DiffResult
import app.git.diff.Hunk
import app.git.diff.Line
import app.git.diff.LineType
import app.theme.primaryTextColor
import app.theme.stageButton
import app.theme.unstageButton
import app.ui.components.ScrollableLazyColumn
import app.ui.components.SecondaryButton
import app.viewmodels.DiffViewModel
import app.viewmodels.ViewDiffResult
import org.eclipse.jgit.diff.DiffEntry
import java.io.FileInputStream
import java.nio.file.Path
import kotlin.io.path.absolutePathString
import kotlin.math.max
@Composable
fun Diff(
diffViewModel: DiffViewModel,
onCloseDiffView: () -> Unit,
) {
val diffResultState = diffViewModel.diffResult.collectAsState()
val viewDiffResult = diffResultState.value ?: return
Column(
modifier = Modifier
.background(MaterialTheme.colors.background)
.fillMaxSize()
) {
when (viewDiffResult) {
ViewDiffResult.DiffNotFound -> { onCloseDiffView() }
is ViewDiffResult.Loaded -> {
val diffEntryType = viewDiffResult.diffEntryType
val diffEntry = viewDiffResult.diffResult.diffEntry
val diffResult = viewDiffResult.diffResult
DiffHeader(
diffEntryType = diffEntryType,
diffEntry = diffEntry,
onCloseDiffView = onCloseDiffView,
stageFile = { diffViewModel.stageFile(it) },
unstageFile = { diffViewModel.unstageFile(it) },
)
if (diffResult is DiffResult.Text) {
TextDiff(diffEntryType, diffViewModel, diffResult)
} else if (diffResult is DiffResult.NonText) {
NonTextDiff(diffResult)
}
}
ViewDiffResult.Loading -> {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
}
}
}
}
@Composable
fun NonTextDiff(diffResult: DiffResult.NonText) {
val oldBinaryContent = diffResult.oldBinaryContent
val newBinaryContent = diffResult.newBinaryContent
val showOldAndNew = oldBinaryContent != EntryContent.Missing && newBinaryContent != EntryContent.Missing
Row(
modifier = Modifier
.fillMaxSize(),
verticalAlignment = Alignment.CenterVertically
) {
if (showOldAndNew) {
Column(
modifier = Modifier.weight(0.5f)
.padding(start = 24.dp, end = 8.dp, top = 24.dp, bottom = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
SideTitle("Old")
SideDiff(oldBinaryContent)
}
Column(
modifier = Modifier.weight(0.5f)
.padding(start = 8.dp, end = 24.dp, top = 24.dp, bottom = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
SideTitle("New")
SideDiff(newBinaryContent)
}
} else if (oldBinaryContent != EntryContent.Missing) {
Box(
modifier = Modifier.fillMaxSize()
.padding(all = 24.dp),
) {
SideDiff(oldBinaryContent)
}
} else if (newBinaryContent != EntryContent.Missing) {
Column(
modifier = Modifier.fillMaxSize()
.padding(all = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
SideTitle("Binary file")
Spacer(modifier = Modifier.height(24.dp))
SideDiff(newBinaryContent)
}
}
}
}
@Composable
fun SideTitle(text: String) {
Text(
text = text,
fontSize = 20.sp,
color = MaterialTheme.colors.primaryTextColor,
)
}
@Composable
fun SideDiff(entryContent: EntryContent) {
when (entryContent) {
EntryContent.Binary -> BinaryDiff()
is EntryContent.ImageBinary -> ImageDiff(entryContent.tempFilePath)
else -> {
}
// is EntryContent.Text -> //TODO maybe have a text view if the file was a binary before?
// TODO Show some info about this EntryContent.TooLargeEntry -> TODO()
}
}
@Composable
fun ImageDiff(tempImagePath: Path) {
Image(
bitmap = loadImageBitmap(inputStream = FileInputStream(tempImagePath.absolutePathString())),
contentDescription = null,
modifier = Modifier.fillMaxSize()
)
}
@Composable
fun BinaryDiff() {
Image(
painter = painterResource("binary.svg"),
contentDescription = null,
modifier = Modifier.width(400.dp),
colorFilter = ColorFilter.tint(MaterialTheme.colors.primary)
)
}
@Composable
fun TextDiff(diffEntryType: DiffEntryType, diffViewModel: DiffViewModel, diffResult: DiffResult.Text) {
val hunks = diffResult.hunks
val scrollState by diffViewModel.lazyListState.collectAsState()
SelectionContainer {
ScrollableLazyColumn(
modifier = Modifier
.fillMaxSize(),
state = scrollState
) {
for (hunk in hunks) {
item {
DisableSelection {
HunkHeader(
hunk = hunk,
diffViewModel = diffViewModel,
diffEntryType = diffEntryType,
diffEntry =diffResult.diffEntry,
)
}
}
val oldHighestLineNumber = hunk.lines.maxOf { it.displayOldLineNumber }
val newHighestLineNumber = hunk.lines.maxOf { it.displayNewLineNumber }
val highestLineNumber = max(oldHighestLineNumber, newHighestLineNumber)
val highestLineNumberLength = highestLineNumber.toString().count()
items(hunk.lines) { line ->
DiffLine(highestLineNumberLength, line)
}
}
}
}
}
@Composable
fun HunkHeader(
hunk: Hunk,
diffEntryType: DiffEntryType,
diffViewModel: DiffViewModel,
diffEntry: DiffEntry,
) {
Row(
modifier = Modifier
.background(MaterialTheme.colors.surface)
.padding(horizontal = 8.dp, vertical = 4.dp)
.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = hunk.header,
color = MaterialTheme.colors.primaryTextColor,
fontSize = 13.sp,
)
Spacer(modifier = Modifier.weight(1f))
// Hunks options are only visible when repository is a normal state (not during merge/rebase)
if (
(diffEntryType is DiffEntryType.SafeStagedDiff || diffEntryType is DiffEntryType.SafeUnstagedDiff) &&
(diffEntryType is DiffEntryType.UncommitedDiff && // Added just to make smartcast work
diffEntryType.statusEntry.statusType == StatusType.MODIFIED)
) {
val buttonText: String
val color: Color
if (diffEntryType is DiffEntryType.StagedDiff) {
buttonText = "Unstage hunk"
color = MaterialTheme.colors.unstageButton
} else {
buttonText = "Stage hunk"
color = MaterialTheme.colors.stageButton
}
SecondaryButton(
text = buttonText,
backgroundButton = color,
onClick = {
if (diffEntryType is DiffEntryType.StagedDiff) {
diffViewModel.unstageHunk(diffEntry, hunk)
} else {
diffViewModel.stageHunk(diffEntry, hunk)
}
}
)
}
}
}
@Composable
fun DiffHeader(
diffEntryType: DiffEntryType,
diffEntry: DiffEntry,
onCloseDiffView: () -> Unit,
stageFile: (StatusEntry) -> Unit,
unstageFile: (StatusEntry) -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(50.dp)
.padding(start = 8.dp, end = 8.dp, top = 8.dp)
.background(MaterialTheme.colors.surface),
verticalAlignment = Alignment.CenterVertically,
) {
val filePath = if (diffEntry.newPath != "/dev/null")
diffEntry.newPath
else
diffEntry.oldPath
Text(
text = filePath,
color = MaterialTheme.colors.primaryTextColor,
fontSize = 13.sp,
modifier = Modifier.padding(horizontal = 16.dp),
)
Spacer(modifier = Modifier.weight(1f))
if(diffEntryType is DiffEntryType.UncommitedDiff) {
val buttonText: String
val color: Color
if (diffEntryType is DiffEntryType.StagedDiff) {
buttonText = "Unstage file"
color = MaterialTheme.colors.unstageButton
} else {
buttonText = "Stage file"
color = MaterialTheme.colors.stageButton
}
SecondaryButton(
text = buttonText,
backgroundButton = color,
onClick = {
if (diffEntryType is DiffEntryType.StagedDiff) {
unstageFile(diffEntryType.statusEntry)
} else {
stageFile(diffEntryType.statusEntry)
}
}
)
}
IconButton(
onClick = onCloseDiffView,
modifier = Modifier
.pointerHoverIcon(PointerIconDefaults.Hand)
) {
Image(
painter = painterResource("close.svg"),
contentDescription = "Close diff",
colorFilter = ColorFilter.tint(MaterialTheme.colors.primaryTextColor),
)
}
}
}
@Composable
fun DiffLine(highestLineNumberLength: Int, line: Line) {
val backgroundColor = when (line.lineType) {
LineType.ADDED -> {
Color(0x77a9d49b)
}
LineType.REMOVED -> {
Color(0x77dea2a2)
}
LineType.CONTEXT -> {
MaterialTheme.colors.background
}
}
Row(
modifier = Modifier
.padding(horizontal = 8.dp)
.background(backgroundColor)
.height(IntrinsicSize.Min)
) {
val oldLineText = if (line.lineType == LineType.REMOVED || line.lineType == LineType.CONTEXT) {
line.displayOldLineNumber.toStringWithSpaces(highestLineNumberLength)
} else
emptyLineNumber(highestLineNumberLength)
val newLineText = if (line.lineType == LineType.ADDED || line.lineType == LineType.CONTEXT) {
line.displayNewLineNumber.toStringWithSpaces(highestLineNumberLength)
} else
emptyLineNumber(highestLineNumberLength)
DisableSelection {
LineNumber(
text = oldLineText,
)
LineNumber(
text = newLineText
)
}
Text(
text = line.text.replace("\t", " "), // this replace is a workaround until this issue gets fixed https://github.com/JetBrains/compose-jb/issues/615
modifier = Modifier
.padding(start = 8.dp)
.fillMaxSize(),
color = MaterialTheme.colors.primaryTextColor,
maxLines = 1,
fontFamily = FontFamily.Monospace,
fontSize = 13.sp,
overflow = TextOverflow.Visible,
)
}
}
@Composable
fun LineNumber(text: String) {
Text(
text = text,
color = MaterialTheme.colors.primaryTextColor,
modifier = Modifier
.background(MaterialTheme.colors.surface)
.fillMaxHeight()
.padding(horizontal = 4.dp),
fontFamily = FontFamily.Monospace,
fontSize = 13.sp,
)
}
fun emptyLineNumber(charactersCount: Int): String {
val numberBuilder = StringBuilder()
// Add whitespaces before the numbers
repeat(charactersCount) {
numberBuilder.append(" ")
}
return numberBuilder.toString()
}