1207 lines
42 KiB
Kotlin
1207 lines
42 KiB
Kotlin
@file:OptIn(ExperimentalComposeUiApi::class)
|
|
|
|
package com.jetpackduba.gitnuro.ui.diff
|
|
|
|
import androidx.compose.foundation.*
|
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
|
import androidx.compose.foundation.interaction.collectIsHoveredAsState
|
|
import androidx.compose.foundation.layout.*
|
|
import androidx.compose.foundation.lazy.LazyListState
|
|
import androidx.compose.foundation.lazy.items
|
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
import androidx.compose.foundation.text.LocalTextContextMenu
|
|
import androidx.compose.foundation.text.selection.DisableSelection
|
|
import androidx.compose.foundation.text.selection.SelectionContainer
|
|
import androidx.compose.material.*
|
|
import androidx.compose.runtime.*
|
|
import androidx.compose.ui.Alignment
|
|
import androidx.compose.ui.ExperimentalComposeUiApi
|
|
import androidx.compose.ui.Modifier
|
|
import androidx.compose.ui.draw.clip
|
|
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.graphics.ImageBitmap
|
|
import androidx.compose.ui.input.key.KeyEventType
|
|
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
|
import androidx.compose.ui.input.key.type
|
|
import androidx.compose.ui.input.pointer.PointerEventType
|
|
import androidx.compose.ui.input.pointer.onPointerEvent
|
|
import androidx.compose.ui.platform.ClipboardManager
|
|
import androidx.compose.ui.platform.LocalClipboardManager
|
|
import androidx.compose.ui.platform.LocalLocalization
|
|
import androidx.compose.ui.platform.PlatformLocalization
|
|
import androidx.compose.ui.res.loadImageBitmap
|
|
import androidx.compose.ui.res.painterResource
|
|
import androidx.compose.ui.text.AnnotatedString
|
|
import androidx.compose.ui.text.font.FontWeight
|
|
import androidx.compose.ui.text.style.TextOverflow
|
|
import androidx.compose.ui.unit.dp
|
|
import androidx.compose.ui.unit.sp
|
|
import com.jetpackduba.gitnuro.AppIcons
|
|
import com.jetpackduba.gitnuro.extensions.*
|
|
import com.jetpackduba.gitnuro.git.DiffEntryType
|
|
import com.jetpackduba.gitnuro.git.EntryContent
|
|
import com.jetpackduba.gitnuro.git.animatedImages
|
|
import com.jetpackduba.gitnuro.git.diff.DiffResult
|
|
import com.jetpackduba.gitnuro.git.diff.Hunk
|
|
import com.jetpackduba.gitnuro.git.diff.Line
|
|
import com.jetpackduba.gitnuro.git.diff.LineType
|
|
import com.jetpackduba.gitnuro.git.workspace.StatusEntry
|
|
import com.jetpackduba.gitnuro.git.workspace.StatusType
|
|
import com.jetpackduba.gitnuro.keybindings.KeybindingOption
|
|
import com.jetpackduba.gitnuro.keybindings.matchesBinding
|
|
import com.jetpackduba.gitnuro.theme.*
|
|
import com.jetpackduba.gitnuro.ui.components.ScrollableLazyColumn
|
|
import com.jetpackduba.gitnuro.ui.components.SecondaryButton
|
|
import com.jetpackduba.gitnuro.ui.components.Tooltip
|
|
import com.jetpackduba.gitnuro.ui.context_menu.ContextMenu
|
|
import com.jetpackduba.gitnuro.ui.context_menu.ContextMenuElement
|
|
import com.jetpackduba.gitnuro.ui.context_menu.CustomTextContextMenu
|
|
import com.jetpackduba.gitnuro.viewmodels.DiffViewModel
|
|
import com.jetpackduba.gitnuro.viewmodels.TextDiffType
|
|
import com.jetpackduba.gitnuro.viewmodels.ViewDiffResult
|
|
import kotlinx.coroutines.Dispatchers
|
|
import kotlinx.coroutines.withContext
|
|
import org.eclipse.jgit.diff.DiffEntry
|
|
import org.jetbrains.compose.animatedimage.Blank
|
|
import org.jetbrains.compose.animatedimage.animate
|
|
import org.jetbrains.compose.animatedimage.loadAnimatedImage
|
|
import org.jetbrains.compose.resources.loadOrNull
|
|
import java.io.FileInputStream
|
|
import kotlin.math.max
|
|
|
|
private const val MAX_MOVES_COUNT = 5
|
|
|
|
@Composable
|
|
fun Diff(
|
|
diffViewModel: DiffViewModel,
|
|
onCloseDiffView: () -> Unit,
|
|
) {
|
|
val diffResultState = diffViewModel.diffResult.collectAsState()
|
|
val diffType by diffViewModel.diffTypeFlow.collectAsState()
|
|
val isDisplayFullFile by diffViewModel.isDisplayFullFile.collectAsState()
|
|
val viewDiffResult = diffResultState.value ?: return
|
|
val focusRequester = remember { FocusRequester() }
|
|
|
|
Column(
|
|
modifier = Modifier
|
|
.background(MaterialTheme.colors.background)
|
|
.fillMaxSize()
|
|
.focusable()
|
|
.focusRequester(focusRequester)
|
|
.clickable(
|
|
interactionSource = remember { MutableInteractionSource() },
|
|
indication = null
|
|
) {}
|
|
.onPreviewKeyEvent { keyEvent ->
|
|
if (keyEvent.matchesBinding(KeybindingOption.EXIT) && keyEvent.type == KeyEventType.KeyDown) {
|
|
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,
|
|
diffType = diffType,
|
|
isDisplayFullFile = isDisplayFullFile,
|
|
onStageFile = { diffViewModel.stageFile(it) },
|
|
onUnstageFile = { diffViewModel.unstageFile(it) },
|
|
onChangeDiffType = { diffViewModel.changeTextDiffType(it) },
|
|
onDisplayFullFile = { diffViewModel.changeDisplayFullFile(it) },
|
|
)
|
|
|
|
val scrollState by diffViewModel.lazyListState.collectAsState()
|
|
|
|
when (diffResult) {
|
|
is DiffResult.TextSplit -> HunkSplitTextDiff(
|
|
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)
|
|
},
|
|
onUnStageLine = { entry, hunk, line ->
|
|
if (diffEntryType is DiffEntryType.UnstagedDiff)
|
|
diffViewModel.stageHunkLine(entry, hunk, line)
|
|
else if (diffEntryType is DiffEntryType.StagedDiff)
|
|
diffViewModel.unstageHunkLine(entry, hunk, line)
|
|
},
|
|
onDiscardLine = { entry, hunk, line ->
|
|
diffViewModel.discardHunkLine(entry, hunk, line)
|
|
}
|
|
)
|
|
|
|
is DiffResult.Text -> HunkUnifiedTextDiff(
|
|
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)
|
|
},
|
|
onUnStageLine = { entry, hunk, line ->
|
|
if (diffEntryType is DiffEntryType.UnstagedDiff)
|
|
diffViewModel.stageHunkLine(entry, hunk, line)
|
|
else if (diffEntryType is DiffEntryType.StagedDiff)
|
|
diffViewModel.unstageHunkLine(entry, hunk, line)
|
|
},
|
|
onDiscardLine = { entry, hunk, line ->
|
|
diffViewModel.discardHunkLine(entry, hunk, line)
|
|
}
|
|
)
|
|
|
|
is DiffResult.NonText -> {
|
|
NonTextDiff(
|
|
diffResult,
|
|
onOpenFileWithExternalApp = { path -> diffViewModel.openFileWithExternalApp(path) })
|
|
}
|
|
}
|
|
}
|
|
|
|
is ViewDiffResult.Loading -> {
|
|
Column {
|
|
PathOnlyDiffHeader(filePath = viewDiffResult.filePath, onCloseDiffView = onCloseDiffView)
|
|
LinearProgressIndicator(
|
|
modifier = Modifier.fillMaxWidth(),
|
|
color = MaterialTheme.colors.primaryVariant
|
|
)
|
|
}
|
|
|
|
}
|
|
|
|
ViewDiffResult.None -> throw NotImplementedError("None should be a possible state in the diff")
|
|
}
|
|
|
|
LaunchedEffect(Unit) {
|
|
focusRequester.requestFocus()
|
|
}
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
fun NonTextDiff(diffResult: DiffResult.NonText, onOpenFileWithExternalApp: (String) -> Unit) {
|
|
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, onOpenFileWithExternalApp)
|
|
}
|
|
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, onOpenFileWithExternalApp)
|
|
}
|
|
} else if (oldBinaryContent != EntryContent.Missing) {
|
|
Box(
|
|
modifier = Modifier
|
|
.fillMaxSize()
|
|
.padding(all = 24.dp),
|
|
) {
|
|
SideDiff(oldBinaryContent, onOpenFileWithExternalApp)
|
|
}
|
|
} else if (newBinaryContent != EntryContent.Missing) {
|
|
Column(
|
|
modifier = Modifier
|
|
.fillMaxSize()
|
|
.padding(all = 24.dp),
|
|
horizontalAlignment = Alignment.CenterHorizontally,
|
|
verticalArrangement = Arrangement.Center,
|
|
) {
|
|
Spacer(modifier = Modifier.height(24.dp))
|
|
SideDiff(newBinaryContent, onOpenFileWithExternalApp)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
fun SideTitle(text: String) {
|
|
Text(
|
|
text = text,
|
|
fontSize = 20.sp,
|
|
color = MaterialTheme.colors.onBackground,
|
|
)
|
|
}
|
|
|
|
@Composable
|
|
fun SideDiff(entryContent: EntryContent, onOpenFileWithExternalApp: (String) -> Unit) {
|
|
when (entryContent) {
|
|
EntryContent.Binary -> BinaryDiff()
|
|
is EntryContent.ImageBinary -> ImageDiff(
|
|
entryContent.imagePath,
|
|
entryContent.contentType,
|
|
onOpenFileWithExternalApp = { onOpenFileWithExternalApp(entryContent.imagePath) }
|
|
)
|
|
|
|
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
|
|
private fun ImageDiff(
|
|
imagePath: String,
|
|
contentType: String,
|
|
onOpenFileWithExternalApp: () -> Unit
|
|
) {
|
|
if (animatedImages.contains(contentType)) {
|
|
AnimatedImage(imagePath, onOpenFileWithExternalApp)
|
|
} else {
|
|
StaticImage(imagePath, onOpenFileWithExternalApp)
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
private fun StaticImage(
|
|
tempImagePath: String,
|
|
onOpenFileWithExternalApp: () -> Unit
|
|
) {
|
|
var image by remember(tempImagePath) { mutableStateOf<ImageBitmap?>(null) }
|
|
|
|
LaunchedEffect(tempImagePath) {
|
|
withContext(Dispatchers.IO) {
|
|
FileInputStream(tempImagePath).use { inputStream ->
|
|
image = loadImageBitmap(inputStream = inputStream)
|
|
}
|
|
}
|
|
}
|
|
|
|
Image(
|
|
bitmap = image ?: ImageBitmap.Blank,
|
|
contentDescription = null,
|
|
modifier = Modifier
|
|
.run {
|
|
val safeImage = image
|
|
if (safeImage == null)
|
|
fillMaxSize()
|
|
else {
|
|
width(safeImage.width.dp)
|
|
.height(safeImage.height.dp)
|
|
|
|
}
|
|
}
|
|
.handMouseClickable {
|
|
onOpenFileWithExternalApp()
|
|
}
|
|
)
|
|
}
|
|
|
|
@Composable
|
|
private fun AnimatedImage(
|
|
imagePath: String,
|
|
onOpenFileWithExternalApp: () -> Unit
|
|
) {
|
|
Image(
|
|
bitmap = loadOrNull(imagePath) { loadAnimatedImage(imagePath) }?.animate() ?: ImageBitmap.Blank,
|
|
contentDescription = null,
|
|
modifier = Modifier.fillMaxSize()
|
|
.handMouseClickable {
|
|
onOpenFileWithExternalApp()
|
|
}
|
|
)
|
|
}
|
|
|
|
@Composable
|
|
fun BinaryDiff() {
|
|
Image(
|
|
painter = painterResource(AppIcons.BINARY),
|
|
contentDescription = null,
|
|
modifier = Modifier.width(400.dp),
|
|
colorFilter = ColorFilter.tint(MaterialTheme.colors.primary)
|
|
)
|
|
}
|
|
|
|
|
|
@OptIn(ExperimentalFoundationApi::class)
|
|
@Composable
|
|
fun HunkUnifiedTextDiff(
|
|
diffEntryType: DiffEntryType,
|
|
scrollState: LazyListState,
|
|
diffResult: DiffResult.Text,
|
|
onUnstageHunk: (DiffEntry, Hunk) -> Unit,
|
|
onStageHunk: (DiffEntry, Hunk) -> Unit,
|
|
onUnStageLine: (DiffEntry, Hunk, Line) -> Unit,
|
|
onResetHunk: (DiffEntry, Hunk) -> Unit,
|
|
onDiscardLine: (DiffEntry, Hunk, Line) -> Unit,
|
|
) {
|
|
val hunks = diffResult.hunks
|
|
var selectedText by remember { mutableStateOf(AnnotatedString("")) }
|
|
val localClipboardManager = LocalClipboardManager.current
|
|
val localization = LocalLocalization.current
|
|
|
|
CompositionLocalProvider(
|
|
LocalTextContextMenu provides CustomTextContextMenu {
|
|
selectedText = it
|
|
}
|
|
) {
|
|
SelectionContainer {
|
|
ScrollableLazyColumn(
|
|
modifier = Modifier
|
|
.fillMaxSize(),
|
|
state = scrollState
|
|
) {
|
|
for (hunk in hunks) {
|
|
item {
|
|
DisableSelection {
|
|
HunkHeader(
|
|
header = hunk.header,
|
|
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 ->
|
|
DiffContextMenu(
|
|
selectedText = selectedText,
|
|
localization = localization,
|
|
localClipboardManager = localClipboardManager,
|
|
diffEntryType = diffEntryType,
|
|
onDiscardLine = { onDiscardLine(diffResult.diffEntry, hunk, line) },
|
|
line = line,
|
|
) {
|
|
DiffLine(
|
|
highestLineNumberLength,
|
|
line,
|
|
diffEntryType = diffEntryType,
|
|
onActionTriggered = {
|
|
onUnStageLine(
|
|
diffResult.diffEntry,
|
|
hunk,
|
|
line,
|
|
)
|
|
},
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@OptIn(ExperimentalFoundationApi::class)
|
|
@Composable
|
|
fun HunkSplitTextDiff(
|
|
diffEntryType: DiffEntryType,
|
|
scrollState: LazyListState,
|
|
diffResult: DiffResult.TextSplit,
|
|
onUnstageHunk: (DiffEntry, Hunk) -> Unit,
|
|
onStageHunk: (DiffEntry, Hunk) -> Unit,
|
|
onResetHunk: (DiffEntry, Hunk) -> Unit,
|
|
onUnStageLine: (DiffEntry, Hunk, Line) -> Unit,
|
|
onDiscardLine: (DiffEntry, Hunk, Line) -> Unit,
|
|
) {
|
|
val hunks = diffResult.hunks
|
|
|
|
/**
|
|
* Disables selection in one side when the other is being selected
|
|
*/
|
|
var selectableSide by remember { mutableStateOf(SelectableSide.BOTH) }
|
|
|
|
var selectedText by remember { mutableStateOf(AnnotatedString("")) }
|
|
|
|
CompositionLocalProvider(
|
|
LocalTextContextMenu provides CustomTextContextMenu {
|
|
selectedText = it
|
|
}
|
|
) {
|
|
SelectionContainer {
|
|
ScrollableLazyColumn(
|
|
modifier = Modifier
|
|
.fillMaxSize(),
|
|
state = scrollState
|
|
) {
|
|
for (splitHunk in hunks) {
|
|
item {
|
|
DisableSelection {
|
|
HunkHeader(
|
|
header = splitHunk.sourceHunk.header,
|
|
diffEntryType = diffEntryType,
|
|
onUnstageHunk = { onUnstageHunk(diffResult.diffEntry, splitHunk.sourceHunk) },
|
|
onStageHunk = { onStageHunk(diffResult.diffEntry, splitHunk.sourceHunk) },
|
|
onResetHunk = { onResetHunk(diffResult.diffEntry, splitHunk.sourceHunk) },
|
|
)
|
|
}
|
|
}
|
|
|
|
val oldHighestLineNumber = splitHunk.sourceHunk.lines.maxOf { it.displayOldLineNumber }
|
|
val newHighestLineNumber = splitHunk.sourceHunk.lines.maxOf { it.displayNewLineNumber }
|
|
val highestLineNumber = max(oldHighestLineNumber, newHighestLineNumber)
|
|
val highestLineNumberLength = highestLineNumber.toString().count()
|
|
|
|
items(splitHunk.lines) { linesPair ->
|
|
SplitDiffLine(
|
|
highestLineNumberLength = highestLineNumberLength,
|
|
oldLine = linesPair.first,
|
|
newLine = linesPair.second,
|
|
selectableSide = selectableSide,
|
|
diffEntryType = diffEntryType,
|
|
selectedText = selectedText,
|
|
onActionTriggered = { line ->
|
|
onUnStageLine(diffResult.diffEntry, splitHunk.sourceHunk, line)
|
|
},
|
|
onChangeSelectableSide = { newSelectableSide ->
|
|
if (newSelectableSide != selectableSide) {
|
|
selectableSide = newSelectableSide
|
|
}
|
|
},
|
|
onDiscardLine = { line ->
|
|
onDiscardLine(diffResult.diffEntry, splitHunk.sourceHunk, line)
|
|
}
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
fun DynamicSelectionDisable(isDisabled: Boolean, content: @Composable () -> Unit) {
|
|
if (isDisabled) {
|
|
DisableSelection(content)
|
|
} else
|
|
content()
|
|
}
|
|
|
|
@Composable
|
|
fun SplitDiffLine(
|
|
highestLineNumberLength: Int,
|
|
oldLine: Line?,
|
|
newLine: Line?,
|
|
selectableSide: SelectableSide,
|
|
diffEntryType: DiffEntryType,
|
|
selectedText: AnnotatedString,
|
|
onChangeSelectableSide: (SelectableSide) -> Unit,
|
|
onActionTriggered: (Line) -> Unit,
|
|
onDiscardLine: (Line) -> Unit,
|
|
) {
|
|
Row(
|
|
modifier = Modifier
|
|
.background(MaterialTheme.colors.secondarySurface)
|
|
.height(IntrinsicSize.Min)
|
|
) {
|
|
SplitDiffLineSide(
|
|
modifier = Modifier
|
|
.weight(1f),
|
|
highestLineNumberLength = highestLineNumberLength,
|
|
line = oldLine,
|
|
displayLineNumber = oldLine?.displayOldLineNumber ?: 0,
|
|
currentSelectableSide = selectableSide,
|
|
lineSelectableSide = SelectableSide.OLD,
|
|
onChangeSelectableSide = onChangeSelectableSide,
|
|
diffEntryType = diffEntryType,
|
|
onActionTriggered = { if (oldLine != null) onActionTriggered(oldLine) },
|
|
selectedText = selectedText,
|
|
onDiscardLine = onDiscardLine,
|
|
)
|
|
|
|
Box(
|
|
modifier = Modifier
|
|
.fillMaxHeight()
|
|
.width(4.dp)
|
|
.background(MaterialTheme.colors.secondarySurface)
|
|
)
|
|
|
|
SplitDiffLineSide(
|
|
modifier = Modifier
|
|
.weight(1f),
|
|
highestLineNumberLength = highestLineNumberLength,
|
|
line = newLine,
|
|
displayLineNumber = newLine?.displayNewLineNumber ?: 0,
|
|
currentSelectableSide = selectableSide,
|
|
lineSelectableSide = SelectableSide.NEW,
|
|
onChangeSelectableSide = onChangeSelectableSide,
|
|
diffEntryType = diffEntryType,
|
|
onActionTriggered = { if (newLine != null) onActionTriggered(newLine) },
|
|
selectedText = selectedText,
|
|
onDiscardLine = onDiscardLine,
|
|
)
|
|
|
|
}
|
|
}
|
|
|
|
@OptIn(ExperimentalComposeUiApi::class)
|
|
@Composable
|
|
fun SplitDiffLineSide(
|
|
modifier: Modifier,
|
|
highestLineNumberLength: Int,
|
|
line: Line?,
|
|
displayLineNumber: Int,
|
|
currentSelectableSide: SelectableSide,
|
|
lineSelectableSide: SelectableSide,
|
|
diffEntryType: DiffEntryType,
|
|
selectedText: AnnotatedString,
|
|
onChangeSelectableSide: (SelectableSide) -> Unit,
|
|
onActionTriggered: () -> Unit,
|
|
onDiscardLine: (Line) -> Unit,
|
|
) {
|
|
var pressedAndMoved by remember(line) { mutableStateOf(Pair(false, false)) }
|
|
var movesCount by remember(line) { mutableStateOf(0) }
|
|
|
|
val localClipboardManager = LocalClipboardManager.current
|
|
val localization = LocalLocalization.current
|
|
|
|
|
|
Box(
|
|
modifier = modifier
|
|
.onPointerEvent(PointerEventType.Press) {
|
|
movesCount = 0
|
|
pressedAndMoved = pressedAndMoved.copy(first = true, second = false)
|
|
onChangeSelectableSide(SelectableSide.BOTH)
|
|
|
|
}
|
|
.onPointerEvent(PointerEventType.Release) {
|
|
pressedAndMoved = pressedAndMoved.copy(first = false)
|
|
|
|
// When using DynamicDisableSelection, there is a bug in compose where ctrl+C copies different stuff
|
|
// than using the contextual menu Copy.
|
|
// Ctrl+c copies everything that is not currently contained in the DisableSelection block,
|
|
// even if it was during text selection. The context menu only copies what is currently selected.
|
|
//
|
|
// With this workaround, both sides are enabled if the mouse hasn't been moved (or not enough
|
|
// to select something)
|
|
if (movesCount < MAX_MOVES_COUNT)
|
|
onChangeSelectableSide(SelectableSide.BOTH)
|
|
}
|
|
.onPointerEvent(PointerEventType.Move) {
|
|
movesCount++
|
|
|
|
if (pressedAndMoved.first)
|
|
onChangeSelectableSide(lineSelectableSide)
|
|
}
|
|
) {
|
|
if (line != null) {
|
|
// To avoid both sides being selected, disable one side when the use is interacting with the other
|
|
DynamicSelectionDisable(
|
|
currentSelectableSide != lineSelectableSide &&
|
|
currentSelectableSide != SelectableSide.BOTH
|
|
) {
|
|
DiffContextMenu(
|
|
selectedText,
|
|
localization,
|
|
localClipboardManager,
|
|
line,
|
|
diffEntryType,
|
|
onDiscardLine = { onDiscardLine(line) },
|
|
) {
|
|
SplitDiffLine(
|
|
highestLineNumberLength = highestLineNumberLength,
|
|
line = line,
|
|
lineNumber = displayLineNumber,
|
|
diffEntryType = diffEntryType,
|
|
onActionTriggered = onActionTriggered,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
fun DiffContextMenu(
|
|
selectedText: AnnotatedString,
|
|
localization: PlatformLocalization,
|
|
localClipboardManager: ClipboardManager,
|
|
line: Line,
|
|
diffEntryType: DiffEntryType,
|
|
onDiscardLine: () -> Unit,
|
|
content: @Composable () -> Unit,
|
|
) {
|
|
ContextMenu(
|
|
items = {
|
|
val isTextSelected = selectedText.isNotEmpty()
|
|
|
|
if (isTextSelected) {
|
|
listOf(
|
|
ContextMenuElement.ContextTextEntry(
|
|
label = localization.copy,
|
|
icon = { painterResource(AppIcons.COPY) },
|
|
onClick = {
|
|
localClipboardManager.setText(selectedText)
|
|
}
|
|
)
|
|
)
|
|
} else {
|
|
if (
|
|
line.lineType != LineType.CONTEXT &&
|
|
diffEntryType is DiffEntryType.UnstagedDiff &&
|
|
diffEntryType.statusType == StatusType.MODIFIED
|
|
) {
|
|
listOf(
|
|
ContextMenuElement.ContextTextEntry(
|
|
label = "Discard line",
|
|
icon = { painterResource(AppIcons.UNDO) },
|
|
onClick = {
|
|
onDiscardLine()
|
|
}
|
|
)
|
|
)
|
|
} else
|
|
emptyList()
|
|
}
|
|
},
|
|
) {
|
|
content()
|
|
}
|
|
}
|
|
|
|
enum class SelectableSide {
|
|
BOTH,
|
|
OLD,
|
|
NEW;
|
|
}
|
|
|
|
@Composable
|
|
fun HunkHeader(
|
|
header: String,
|
|
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 = header,
|
|
color = MaterialTheme.colors.onBackground,
|
|
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,
|
|
modifier = Modifier.padding(horizontal = 16.dp),
|
|
)
|
|
}
|
|
|
|
SecondaryButton(
|
|
text = buttonText,
|
|
backgroundButton = color,
|
|
modifier = Modifier.padding(horizontal = 16.dp),
|
|
onClick = {
|
|
if (diffEntryType is DiffEntryType.StagedDiff) {
|
|
onUnstageHunk()
|
|
} else {
|
|
onStageHunk()
|
|
}
|
|
}
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
private fun DiffHeader(
|
|
diffEntryType: DiffEntryType,
|
|
diffEntry: DiffEntry,
|
|
diffType: TextDiffType,
|
|
isDisplayFullFile: Boolean,
|
|
onCloseDiffView: () -> Unit,
|
|
onStageFile: (StatusEntry) -> Unit,
|
|
onUnstageFile: (StatusEntry) -> Unit,
|
|
onChangeDiffType: (TextDiffType) -> Unit,
|
|
onDisplayFullFile: (Boolean) -> Unit,
|
|
) {
|
|
Row(
|
|
modifier = Modifier
|
|
.fillMaxWidth()
|
|
.height(34.dp)
|
|
.background(MaterialTheme.colors.tertiarySurface)
|
|
.padding(start = 16.dp, end = 8.dp),
|
|
verticalAlignment = Alignment.CenterVertically,
|
|
) {
|
|
val fileName = diffEntry.fileName
|
|
val dirPath: String = diffEntry.parentDirectoryPath
|
|
|
|
Row(Modifier.weight(1f, true)) {
|
|
if (dirPath.isNotEmpty()) {
|
|
Text(
|
|
text = dirPath.removeSuffix("/"),
|
|
style = MaterialTheme.typography.body2,
|
|
color = MaterialTheme.colors.onBackgroundSecondary,
|
|
maxLines = 1,
|
|
softWrap = false,
|
|
overflow = TextOverflow.Ellipsis,
|
|
fontWeight = FontWeight.Medium,
|
|
modifier = Modifier
|
|
.weight(1f, false),
|
|
)
|
|
|
|
Text(
|
|
text = "/",
|
|
maxLines = 1,
|
|
softWrap = false,
|
|
style = MaterialTheme.typography.body2,
|
|
overflow = TextOverflow.Visible,
|
|
color = MaterialTheme.colors.onBackgroundSecondary,
|
|
)
|
|
}
|
|
Text(
|
|
text = fileName,
|
|
style = MaterialTheme.typography.body2,
|
|
color = MaterialTheme.colors.onBackground,
|
|
fontWeight = FontWeight.Medium,
|
|
maxLines = 1,
|
|
modifier = Modifier.padding(end = 16.dp),
|
|
)
|
|
}
|
|
|
|
|
|
Row(
|
|
verticalAlignment = Alignment.CenterVertically,
|
|
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
|
) {
|
|
if (diffEntryType.statusType != StatusType.ADDED && diffEntryType.statusType != StatusType.REMOVED) {
|
|
DiffTypeButtons(
|
|
diffType = diffType,
|
|
isDisplayFullFile = isDisplayFullFile,
|
|
onChangeDiffType = onChangeDiffType,
|
|
onDisplayFullFile = onDisplayFullFile,
|
|
)
|
|
}
|
|
|
|
if (diffEntryType is DiffEntryType.UncommitedDiff) {
|
|
UncommitedDiffFileHeaderButtons(
|
|
diffEntryType,
|
|
onUnstageFile = onUnstageFile,
|
|
onStageFile = onStageFile
|
|
)
|
|
}
|
|
|
|
IconButton(
|
|
onClick = onCloseDiffView,
|
|
modifier = Modifier
|
|
.handOnHover()
|
|
) {
|
|
Icon(
|
|
painter = painterResource(AppIcons.CLOSE),
|
|
contentDescription = "Close diff",
|
|
tint = MaterialTheme.colors.onBackground,
|
|
modifier = Modifier.size(24.dp),
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
fun StateIcon(
|
|
icon: String,
|
|
tooltip: String,
|
|
isToggled: Boolean,
|
|
onClick: () -> Unit,
|
|
) {
|
|
Tooltip(tooltip) {
|
|
Box(
|
|
modifier = Modifier
|
|
.clip(RoundedCornerShape(4.dp))
|
|
.run {
|
|
if (isToggled)
|
|
this.background(MaterialTheme.colors.onSurface.copy(alpha = 0.2f))
|
|
else
|
|
this
|
|
}
|
|
.handMouseClickable { if (!isToggled) onClick() }
|
|
.padding(4.dp)
|
|
) {
|
|
Icon(
|
|
painterResource(icon),
|
|
contentDescription = null,
|
|
tint = MaterialTheme.colors.onSurface,
|
|
modifier = Modifier
|
|
.size(24.dp),
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
fun DiffTypeButtons(
|
|
diffType: TextDiffType,
|
|
isDisplayFullFile: Boolean,
|
|
onChangeDiffType: (TextDiffType) -> Unit,
|
|
onDisplayFullFile: (Boolean) -> Unit
|
|
) {
|
|
Row(
|
|
verticalAlignment = Alignment.CenterVertically,
|
|
) {
|
|
|
|
Row(
|
|
verticalAlignment = Alignment.CenterVertically,
|
|
modifier = Modifier.padding(end = 16.dp)
|
|
) {
|
|
StateIcon(
|
|
icon = AppIcons.HORIZONTAL_SPLIT,
|
|
tooltip = "Divide by hunks",
|
|
isToggled = !isDisplayFullFile,
|
|
onClick = { onDisplayFullFile(false) },
|
|
)
|
|
|
|
StateIcon(
|
|
icon = AppIcons.DESCRIPTION,
|
|
tooltip = "View the complete file",
|
|
isToggled = isDisplayFullFile,
|
|
onClick = { onDisplayFullFile(true) },
|
|
)
|
|
}
|
|
|
|
Row(
|
|
verticalAlignment = Alignment.CenterVertically,
|
|
) {
|
|
StateIcon(
|
|
icon = AppIcons.UNIFIED,
|
|
tooltip = "Unified diff",
|
|
isToggled = diffType == TextDiffType.UNIFIED,
|
|
onClick = { onChangeDiffType(TextDiffType.UNIFIED) },
|
|
)
|
|
|
|
StateIcon(
|
|
icon = AppIcons.VERTICAL_SPLIT,
|
|
tooltip = "Split diff",
|
|
isToggled = diffType == TextDiffType.SPLIT,
|
|
onClick = { onChangeDiffType(TextDiffType.SPLIT) },
|
|
)
|
|
}
|
|
//
|
|
// Text(
|
|
// "Unified",
|
|
// color = MaterialTheme.colors.onBackground,
|
|
// style = MaterialTheme.typography.caption,
|
|
// )
|
|
//
|
|
// Switch(
|
|
// checked = diffType == TextDiffType.SPLIT,
|
|
// onCheckedChange = { checked ->
|
|
// val newType = if (checked)
|
|
// TextDiffType.SPLIT
|
|
// else
|
|
// TextDiffType.UNIFIED
|
|
//
|
|
// onChangeDiffType(newType)
|
|
// },
|
|
// colors = SwitchDefaults.colors(
|
|
// uncheckedThumbColor = MaterialTheme.colors.secondaryVariant,
|
|
// uncheckedTrackColor = MaterialTheme.colors.secondaryVariant,
|
|
// uncheckedTrackAlpha = 0.54f
|
|
// )
|
|
// )
|
|
//
|
|
// Text(
|
|
// "Split",
|
|
// color = MaterialTheme.colors.onBackground,
|
|
//// modifier = Modifier.padding(horizontal = 4.dp),
|
|
// style = MaterialTheme.typography.caption,
|
|
// )
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
fun UncommitedDiffFileHeaderButtons(
|
|
diffEntryType: DiffEntryType.UncommitedDiff,
|
|
onUnstageFile: (StatusEntry) -> Unit,
|
|
onStageFile: (StatusEntry) -> Unit
|
|
) {
|
|
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) {
|
|
onUnstageFile(diffEntryType.statusEntry)
|
|
} else {
|
|
onStageFile(diffEntryType.statusEntry)
|
|
}
|
|
}
|
|
)
|
|
}
|
|
|
|
@Composable
|
|
private fun PathOnlyDiffHeader(
|
|
filePath: String,
|
|
onCloseDiffView: () -> Unit,
|
|
) {
|
|
Row(
|
|
modifier = Modifier
|
|
.fillMaxWidth()
|
|
.height(40.dp)
|
|
.background(MaterialTheme.colors.tertiarySurface)
|
|
.padding(start = 8.dp, end = 8.dp),
|
|
verticalAlignment = Alignment.CenterVertically,
|
|
) {
|
|
Text(
|
|
text = filePath,
|
|
style = MaterialTheme.typography.body2,
|
|
color = MaterialTheme.colors.onBackground,
|
|
maxLines = 1,
|
|
modifier = Modifier.padding(horizontal = 16.dp),
|
|
)
|
|
|
|
Spacer(modifier = Modifier.weight(1f))
|
|
|
|
IconButton(
|
|
onClick = onCloseDiffView,
|
|
modifier = Modifier
|
|
.handOnHover()
|
|
) {
|
|
Image(
|
|
painter = painterResource(AppIcons.CLOSE),
|
|
contentDescription = "Close diff",
|
|
colorFilter = ColorFilter.tint(MaterialTheme.colors.onBackground),
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
fun DiffLine(
|
|
highestLineNumberLength: Int,
|
|
line: Line,
|
|
diffEntryType: DiffEntryType,
|
|
onActionTriggered: () -> Unit,
|
|
) {
|
|
val backgroundColor = when (line.lineType) {
|
|
LineType.ADDED -> MaterialTheme.colors.diffLineAdded
|
|
LineType.REMOVED -> MaterialTheme.colors.diffLineRemoved
|
|
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,
|
|
remarked = line.lineType != LineType.CONTEXT,
|
|
)
|
|
|
|
LineNumber(
|
|
text = newLineText,
|
|
remarked = line.lineType != LineType.CONTEXT,
|
|
)
|
|
}
|
|
|
|
DiffLineText(line, diffEntryType, onActionTriggered = onActionTriggered)
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
fun SplitDiffLine(
|
|
highestLineNumberLength: Int,
|
|
line: Line,
|
|
lineNumber: Int,
|
|
diffEntryType: DiffEntryType,
|
|
onActionTriggered: () -> Unit,
|
|
) {
|
|
val backgroundColor = when (line.lineType) {
|
|
LineType.ADDED -> MaterialTheme.colors.diffLineAdded
|
|
LineType.REMOVED -> MaterialTheme.colors.diffLineRemoved
|
|
LineType.CONTEXT -> MaterialTheme.colors.background
|
|
}
|
|
Row(
|
|
modifier = Modifier
|
|
.background(backgroundColor)
|
|
.fillMaxHeight(),
|
|
) {
|
|
DisableSelection {
|
|
LineNumber(
|
|
text = lineNumber.toStringWithSpaces(highestLineNumberLength),
|
|
remarked = line.lineType != LineType.CONTEXT,
|
|
)
|
|
}
|
|
|
|
DiffLineText(line, diffEntryType, onActionTriggered = onActionTriggered)
|
|
}
|
|
}
|
|
|
|
|
|
@Composable
|
|
fun DiffLineText(line: Line, diffEntryType: DiffEntryType, onActionTriggered: () -> Unit) {
|
|
val text = line.text
|
|
val hoverInteraction = remember { MutableInteractionSource() }
|
|
val isHovered by hoverInteraction.collectIsHoveredAsState()
|
|
|
|
Box(modifier = Modifier.hoverable(hoverInteraction)) {
|
|
if (isHovered && diffEntryType is DiffEntryType.UncommitedDiff && line.lineType != LineType.CONTEXT) {
|
|
val color: Color = if (diffEntryType is DiffEntryType.StagedDiff) {
|
|
MaterialTheme.colors.error
|
|
} else {
|
|
MaterialTheme.colors.primary
|
|
}
|
|
|
|
val iconName = remember(diffEntryType) {
|
|
if (diffEntryType is DiffEntryType.StagedDiff) {
|
|
AppIcons.REMOVE
|
|
} else {
|
|
AppIcons.ADD
|
|
}
|
|
}
|
|
|
|
Icon(
|
|
painterResource(iconName),
|
|
contentDescription = null,
|
|
tint = Color.White,
|
|
modifier = Modifier
|
|
.fastClickable(line) { onActionTriggered() }
|
|
.size(16.dp)
|
|
.clip(RoundedCornerShape(2.dp))
|
|
.background(color),
|
|
)
|
|
}
|
|
|
|
Row {
|
|
Text(
|
|
text = text.replace(
|
|
"\t",
|
|
" "
|
|
).removeLineDelimiters(),
|
|
modifier = Modifier
|
|
.padding(start = 16.dp)
|
|
.fillMaxSize(),
|
|
fontFamily = notoSansMonoFontFamily,
|
|
style = MaterialTheme.typography.body2,
|
|
color = MaterialTheme.colors.onBackground,
|
|
overflow = TextOverflow.Visible,
|
|
)
|
|
|
|
val lineDelimiter = 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,
|
|
color = MaterialTheme.colors.onBackground,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
fun LineNumber(text: String, remarked: Boolean) {
|
|
Text(
|
|
text = text,
|
|
modifier = Modifier
|
|
.padding(start = 8.dp, end = 4.dp),
|
|
fontFamily = notoSansMonoFontFamily,
|
|
style = MaterialTheme.typography.body2,
|
|
color = if (remarked) MaterialTheme.colors.onBackground else MaterialTheme.colors.onBackgroundSecondary,
|
|
)
|
|
}
|
|
|
|
fun emptyLineNumber(charactersCount: Int): String {
|
|
val numberBuilder = StringBuilder()
|
|
// Add whitespaces before the numbers
|
|
repeat(charactersCount) {
|
|
numberBuilder.append(" ")
|
|
}
|
|
|
|
return numberBuilder.toString()
|
|
}
|