496 lines
16 KiB
Kotlin
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()
|
|
} |