Gitnuro/src/main/kotlin/app/ui/Diff.kt
2022-08-07 17:24:21 +02:00

496 lines
16 KiB
Kotlin

@file:OptIn(ExperimentalComposeUiApi::class)
package app.ui
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyListState
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.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.input.key.onPreviewKeyEvent
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.lineDelimiter
import app.extensions.removeLineDelimiters
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.keybindings.KeybindingOption
import app.keybindings.matchesBinding
import app.theme.*
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
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
Column(
modifier = Modifier
.background(MaterialTheme.colors.background)
.fillMaxSize()
.focusRequester(focusRequester)
.focusable()
.onPreviewKeyEvent { keyEvent ->
if (keyEvent.matchesBinding(KeybindingOption.EXIT)) {
onCloseDiffView()
true
} else
false
}
) {
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) {
val scrollState by diffViewModel.lazyListState.collectAsState()
TextDiff(
diffEntryType = diffEntryType,
scrollState = scrollState,
diffResult = diffResult,
onUnstageHunk = { entry, hunk ->
diffViewModel.unstageHunk(entry, hunk)
},
onStageHunk = { entry, hunk ->
diffViewModel.stageHunk(entry, hunk)
},
onResetHunk = { entry, hunk ->
diffViewModel.resetHunk(entry, hunk)
}
)
} else if (diffResult is DiffResult.NonText) {
NonTextDiff(diffResult)
}
}
ViewDiffResult.Loading, ViewDiffResult.None -> {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth(), color = MaterialTheme.colors.primaryVariant)
}
}
}
}
@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,
scrollState: LazyListState,
diffResult: DiffResult.Text,
onUnstageHunk: (DiffEntry, Hunk) -> Unit,
onStageHunk: (DiffEntry, Hunk) -> Unit,
onResetHunk: (DiffEntry, Hunk) -> Unit,
) {
val hunks = diffResult.hunks
SelectionContainer {
ScrollableLazyColumn(
modifier = Modifier
.fillMaxSize(),
state = scrollState
) {
for (hunk in hunks) {
item {
DisableSelection {
HunkHeader(
hunk = hunk,
diffEntryType = diffEntryType,
onUnstageHunk = { onUnstageHunk(diffResult.diffEntry, hunk) },
onStageHunk = { onStageHunk(diffResult.diffEntry, hunk) },
onResetHunk = { onResetHunk(diffResult.diffEntry, hunk) },
)
}
}
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,
onUnstageHunk: () -> Unit,
onStageHunk: () -> Unit,
onResetHunk: () -> Unit,
) {
Row(
modifier = Modifier
.background(MaterialTheme.colors.secondarySurface)
.padding(horizontal = 8.dp, vertical = 4.dp)
.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = hunk.header,
color = MaterialTheme.colors.primaryTextColor,
style = MaterialTheme.typography.body1,
)
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.error
} else {
buttonText = "Stage hunk"
color = MaterialTheme.colors.primary
}
if (diffEntryType is DiffEntryType.UnstagedDiff) {
SecondaryButton(
text = "Discard hunk",
backgroundButton = MaterialTheme.colors.error,
textColor = MaterialTheme.colors.onError,
onClick = onResetHunk
)
}
SecondaryButton(
text = buttonText,
backgroundButton = color,
onClick = {
if (diffEntryType is DiffEntryType.StagedDiff) {
onUnstageHunk()
} else {
onStageHunk()
}
}
)
}
}
}
@Composable
fun DiffHeader(
diffEntryType: DiffEntryType,
diffEntry: DiffEntry,
onCloseDiffView: () -> Unit,
stageFile: (StatusEntry) -> Unit,
unstageFile: (StatusEntry) -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(40.dp)
.background(MaterialTheme.colors.headerBackground)
.padding(start = 8.dp, end = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
val filePath = if (diffEntry.newPath != "/dev/null")
diffEntry.newPath
else
diffEntry.oldPath
Text(
text = filePath,
style = MaterialTheme.typography.body2,
maxLines = 1,
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.error
} else {
buttonText = "Stage file"
color = MaterialTheme.colors.primary
}
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
.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
)
}
Row {
Text(
text = line.text.replace( // TODO this replace is a workaround until this issue gets fixed https://github.com/JetBrains/compose-jb/issues/615
"\t",
" "
).removeLineDelimiters(),
modifier = Modifier
.padding(start = 8.dp)
.fillMaxSize(),
fontFamily = FontFamily.Monospace,
style = MaterialTheme.typography.body2,
overflow = TextOverflow.Visible,
)
val lineDelimiter = line.text.lineDelimiter
// Display line delimiter in its own text with a maxLines = 1. This will fix the issue
// where copying a line didn't contain the line ending & also fix the issue where the text line would
// display multiple lines even if there is only a single line with a line delimiter at the end
if (lineDelimiter != null) {
Text(
text = lineDelimiter,
maxLines = 1,
)
}
}
}
}
@Composable
fun LineNumber(text: String) {
Text(
text = text,
modifier = Modifier
.background(MaterialTheme.colors.secondarySurface)
.fillMaxHeight()
.padding(horizontal = 4.dp),
fontFamily = FontFamily.Monospace,
style = MaterialTheme.typography.body2,
)
}
fun emptyLineNumber(charactersCount: Int): String {
val numberBuilder = StringBuilder()
// Add whitespaces before the numbers
repeat(charactersCount) {
numberBuilder.append(" ")
}
return numberBuilder.toString()
}