Added basic split diff

This commit is contained in:
Abdelilah El Aissaoui 2022-08-13 03:00:55 +02:00
parent 34164fb2bc
commit 5659bf8918
23 changed files with 661 additions and 188 deletions

View File

@ -26,8 +26,10 @@ import androidx.compose.ui.window.rememberWindowState
import app.di.DaggerAppComponent
import app.extensions.preferenceValue
import app.extensions.toWindowPlacement
import app.preferences.AppPreferences
import app.logging.printLog
import app.preferences.AppSettings
import app.theme.AppTheme
import app.theme.Theme
import app.theme.primaryTextColor
import app.theme.secondaryTextColor
import app.ui.AppTab
@ -40,6 +42,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
private const val TAG = "App"
class App {
private val appComponent = DaggerAppComponent.create()
@ -48,7 +51,7 @@ class App {
lateinit var appStateManager: AppStateManager
@Inject
lateinit var appPreferences: AppPreferences
lateinit var appSettings: AppSettings
@Inject
lateinit var settingsViewModel: SettingsViewModel
@ -60,17 +63,26 @@ class App {
private val tabsFlow = MutableStateFlow<List<TabInformation>>(emptyList())
fun start() {
val windowPlacement = appPreferences.windowPlacement.toWindowPlacement
val windowPlacement = appSettings.windowPlacement.toWindowPlacement
appStateManager.loadRepositoriesTabs()
appPreferences.loadCustomTheme()
try {
if (appSettings.theme == Theme.CUSTOM) {
appSettings.loadCustomTheme()
}
} catch (ex: Exception) {
printLog(TAG, "Failed to load custom theme")
ex.printStackTrace()
}
loadTabs()
application {
var isOpen by remember { mutableStateOf(true) }
val theme by appPreferences.themeState.collectAsState()
val customTheme by appPreferences.customThemeFlow.collectAsState()
val scale by appPreferences.scaleUiFlow.collectAsState()
val theme by appSettings.themeState.collectAsState()
val customTheme by appSettings.customThemeFlow.collectAsState()
val scale by appSettings.scaleUiFlow.collectAsState()
val windowState = rememberWindowState(
placement = windowPlacement,
@ -78,7 +90,7 @@ class App {
)
// Save window state for next time the Window is started
appPreferences.windowPlacement = windowState.placement.preferenceValue
appSettings.windowPlacement = windowState.placement.preferenceValue
if (isOpen) {
Window(
@ -191,6 +203,7 @@ class App {
tabsFlow.value = tabsFlow.value.toMutableList().apply { add(tabInformation) }
}
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun Tabs(
selectedTabKey: MutableState<Int>,

View File

@ -31,5 +31,8 @@ object AppConstants {
private val apache__2_0 = License("Apache 2.0", "https://www.apache.org/licenses/LICENSE-2.0")
private val edl = License("EDL", "https://www.eclipse.org/org/documents/edl-v10.php")
data class License(val name: String, val url: String)
data class License(
val name: String,
val url: String
)
data class Project(val name: String, val url: String, val license: License)

View File

@ -1,6 +1,6 @@
package app
import app.preferences.AppPreferences
import app.preferences.AppSettings
import kotlinx.coroutines.*
import kotlinx.coroutines.sync.Mutex
import kotlinx.serialization.decodeFromString
@ -11,7 +11,7 @@ import javax.inject.Singleton
@Singleton
class AppStateManager @Inject constructor(
private val appPreferences: AppPreferences,
private val appSettings: AppSettings,
) {
private val mutex = Mutex()
@ -59,15 +59,15 @@ class AppStateManager @Inject constructor(
private suspend fun updateSavedRepositoryTabs() = withContext(Dispatchers.IO) {
val tabsList = _openRepositoriesPaths.map { it.value }
appPreferences.latestTabsOpened = Json.encodeToString(tabsList)
appSettings.latestTabsOpened = Json.encodeToString(tabsList)
}
private suspend fun updateLatestRepositoryTabs() = withContext(Dispatchers.IO) {
appPreferences.latestOpenedRepositoriesPath = Json.encodeToString(_latestOpenedRepositoriesPaths)
appSettings.latestOpenedRepositoriesPath = Json.encodeToString(_latestOpenedRepositoriesPaths)
}
fun loadRepositoriesTabs() {
val repositoriesSaved = appPreferences.latestTabsOpened
val repositoriesSaved = appSettings.latestTabsOpened
if (repositoriesSaved.isNotEmpty()) {
val repositoriesList = Json.decodeFromString<List<String>>(repositoriesSaved)
@ -77,7 +77,7 @@ class AppStateManager @Inject constructor(
}
}
val repositoriesPathsSaved = appPreferences.latestOpenedRepositoriesPath
val repositoriesPathsSaved = appSettings.latestOpenedRepositoriesPath
if (repositoriesPathsSaved.isNotEmpty()) {
val repositories = Json.decodeFromString<List<String>>(repositoriesPathsSaved)
_latestOpenedRepositoriesPaths.addAll(repositories)

View File

@ -2,7 +2,7 @@ package app.di
import app.App
import app.AppStateManager
import app.preferences.AppPreferences
import app.preferences.AppSettings
import dagger.Component
import javax.inject.Singleton
@ -12,5 +12,5 @@ interface AppComponent {
fun inject(main: App)
fun appStateManager(): AppStateManager
fun appPreferences(): AppPreferences
fun appPreferences(): AppSettings
}

View File

@ -118,3 +118,12 @@ val DiffEntry.iconColor: Color
else -> throw NotImplementedError("Unexpected ChangeType")
}
}
fun DiffEntry.toStatusType(): StatusType = when (this.changeType) {
DiffEntry.ChangeType.ADD -> StatusType.ADDED
DiffEntry.ChangeType.MODIFY -> StatusType.MODIFIED
DiffEntry.ChangeType.DELETE -> StatusType.REMOVED
DiffEntry.ChangeType.COPY -> StatusType.ADDED
DiffEntry.ChangeType.RENAME -> StatusType.MODIFIED
else -> throw NotImplementedError("Unexpected ChangeType")
}

View File

@ -1,11 +1,25 @@
package app.git
import app.extensions.filePath
import app.extensions.toStatusType
import org.eclipse.jgit.diff.DiffEntry
sealed class DiffEntryType {
class CommitDiff(val diffEntry: DiffEntry) : DiffEntryType()
class CommitDiff(val diffEntry: DiffEntry) : DiffEntryType() {
override val filePath: String
get() = diffEntry.filePath
sealed class UncommitedDiff(val statusEntry: StatusEntry) : DiffEntryType()
override val statusType: StatusType
get() = diffEntry.toStatusType()
}
sealed class UncommitedDiff(val statusEntry: StatusEntry) : DiffEntryType() {
override val filePath: String
get() = statusEntry.filePath
override val statusType: StatusType
get() = statusEntry.statusType
}
sealed class UnstagedDiff(statusEntry: StatusEntry) : UncommitedDiff(statusEntry)
sealed class StagedDiff(statusEntry: StatusEntry) : UncommitedDiff(statusEntry)
@ -23,4 +37,7 @@ sealed class DiffEntryType {
class SafeStagedDiff(statusEntry: StatusEntry) : StagedDiff(statusEntry)
class SafeUnstagedDiff(statusEntry: StatusEntry) : UnstagedDiff(statusEntry)
abstract val filePath: String
abstract val statusType: StatusType
}

View File

@ -131,3 +131,17 @@ fun prepareTreeParser(repository: Repository, commit: RevCommit): AbstractTreeIt
return treeParser
}
}
enum class TextDiffType(val value: Int) {
SPLIT(0),
UNIFIED(1);
}
fun textDiffTypeFromValue(diffTypeValue: Int): TextDiffType {
return when (diffTypeValue) {
TextDiffType.SPLIT.value -> TextDiffType.SPLIT
TextDiffType.UNIFIED.value -> TextDiffType.UNIFIED
else -> throw NotImplementedError("Diff type not implemented")
}
}

View File

@ -2,6 +2,8 @@ package app.git.diff
data class Hunk(val header: String, val lines: List<Line>)
data class SplitHunk(val hunk: Hunk, val lines: List<Pair<Line?, Line?>>)
data class Line(val text: String, val oldLineNumber: Int, val newLineNumber: Int, val lineType: LineType) {
// lines numbers are stored based on 0 being the first one but on a file the first line is the 1, so increment it!
val displayOldLineNumber: Int = oldLineNumber + 1

View File

@ -221,6 +221,10 @@ sealed class DiffResult(
diffEntry: DiffEntry,
val hunks: List<Hunk>
) : DiffResult(diffEntry)
class TextSplit(
diffEntry: DiffEntry,
val hunks: List<SplitHunk>
) : DiffResult(diffEntry)
class NonText(
diffEntry: DiffEntry,

View File

@ -1,6 +1,8 @@
package app.preferences
import app.extensions.defaultWindowPlacement
import app.git.TextDiffType
import app.git.textDiffTypeFromValue
import app.theme.ColorsScheme
import app.theme.Theme
import kotlinx.coroutines.flow.MutableStateFlow
@ -22,6 +24,7 @@ private const val PREF_COMMITS_LIMIT_ENABLED = "commitsLimitEnabled"
private const val PREF_WINDOW_PLACEMENT = "windowsPlacement"
private const val PREF_CUSTOM_THEME = "customTheme"
private const val PREF_UI_SCALE = "ui_scale"
private const val PREF_DIFF_TYPE = "diffType"
private const val PREF_GIT_FF_MERGE = "gitFFMerge"
@ -31,7 +34,7 @@ private const val DEFAULT_COMMITS_LIMIT_ENABLED = true
const val DEFAULT_UI_SCALE = -1f
@Singleton
class AppPreferences @Inject constructor() {
class AppSettings @Inject constructor() {
private val preferences: Preferences = Preferences.userRoot().node(PREFERENCES_NAME)
private val _themeState = MutableStateFlow(theme)
@ -52,6 +55,9 @@ class AppPreferences @Inject constructor() {
private val _scaleUiFlow = MutableStateFlow(scaleUi)
val scaleUiFlow: StateFlow<Float> = _scaleUiFlow
private val _textDiffTypeFlow = MutableStateFlow(textDiffType)
val textDiffTypeFlow: StateFlow<TextDiffType> = _textDiffTypeFlow
var latestTabsOpened: String
get() = preferences.get(PREF_LATEST_REPOSITORIES_TABS_OPENED, "")
set(value) {
@ -128,6 +134,18 @@ class AppPreferences @Inject constructor() {
preferences.putInt(PREF_WINDOW_PLACEMENT, placement.value)
}
var textDiffType: TextDiffType
get() {
val diffTypeValue = preferences.getInt(PREF_DIFF_TYPE, TextDiffType.UNIFIED.value)
return textDiffTypeFromValue(diffTypeValue)
}
set(placement) {
preferences.putInt(PREF_DIFF_TYPE, placement.value)
_textDiffTypeFlow.value = textDiffType
}
fun saveCustomTheme(filePath: String) {
try {
val file = File(filePath)

View File

@ -25,6 +25,8 @@ val lightTheme = ColorsScheme(
dialogOverlay = Color(0xAA000000),
normalScrollbar = Color(0xFFCCCCCC),
hoverScrollbar = Color(0xFF0070D8),
diffLineAdded = Color(0xFFd7ebd0),
diffLineRemoved = Color(0xFFf0d4d4),
)
@ -50,7 +52,10 @@ val darkBlueTheme = ColorsScheme(
conflictingFile = Color(0xFFFFB638),
dialogOverlay = Color(0xAA000000),
normalScrollbar = Color(0xFF888888),
hoverScrollbar = Color(0xFFCCCCCC)
hoverScrollbar = Color(0xFFCCCCCC),
diffLineAdded = Color(0xFF566f5a),
diffLineRemoved = Color(0xFF6f585e),
)
val darkGrayTheme = ColorsScheme(
@ -75,5 +80,7 @@ val darkGrayTheme = ColorsScheme(
conflictingFile = Color(0xFFFFB638),
dialogOverlay = Color(0xAA000000),
normalScrollbar = Color(0xFF888888),
hoverScrollbar = Color(0xFFCCCCCC)
hoverScrollbar = Color(0xFFCCCCCC),
diffLineAdded = Color(0xFF5b7059),
diffLineRemoved = Color(0xFF74595c),
)

View File

@ -14,6 +14,7 @@ import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
// TODO Add line added + removed colors, graph colors and icons color for added/modified/removed files.
@Serializable
data class ColorsScheme(
val primary: Color,
@ -39,6 +40,8 @@ data class ColorsScheme(
val dialogOverlay: Color,
val normalScrollbar: Color,
val hoverScrollbar: Color,
val diffLineAdded: Color,
val diffLineRemoved: Color,
) {
fun toComposeColors(): Colors {
return Colors(

View File

@ -92,6 +92,12 @@ val Colors.secondarySurface: Color
val Colors.dialogOverlay: Color
get() = appTheme.dialogOverlay
val Colors.diffLineAdded: Color
get() = appTheme.diffLineAdded
val Colors.diffLineRemoved: Color
get() = appTheme.diffLineRemoved
enum class Theme(val displayName: String) : DropDownOption {
LIGHT("Light"),

View File

@ -162,22 +162,36 @@ fun HistoryContentLoaded(
viewDiffResult != null &&
viewDiffResult is ViewDiffResult.Loaded
) {
val diffResult = viewDiffResult.diffResult
if (diffResult is DiffResult.Text) {
TextDiff(
diffEntryType = viewDiffResult.diffEntryType,
scrollState = scrollState,
diffResult = diffResult,
onUnstageHunk = { _, _ -> },
onStageHunk = { _, _ -> },
onResetHunk = { _, _ -> },
)
} else {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colors.background)
)
when (val diffResult = viewDiffResult.diffResult) {
is DiffResult.Text -> {
HunkUnifiedTextDiff(
diffEntryType = viewDiffResult.diffEntryType,
scrollState = scrollState,
diffResult = diffResult,
onUnstageHunk = { _, _ -> },
onStageHunk = { _, _ -> },
onResetHunk = { _, _ -> },
)
}
is DiffResult.TextSplit -> {
HunkSplitTextDiff(
diffEntryType = viewDiffResult.diffEntryType,
scrollState = scrollState,
diffResult = diffResult,
onUnstageHunk = { _, _ -> },
onStageHunk = { _, _ -> },
onResetHunk = { _, _ -> },
)
}
else -> {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colors.background)
)
}
}
} else {
Box(

View File

@ -10,10 +10,7 @@ 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.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
@ -34,10 +31,7 @@ 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.*
import app.git.diff.DiffResult
import app.git.diff.Hunk
import app.git.diff.Line
@ -61,6 +55,7 @@ fun Diff(
onCloseDiffView: () -> Unit,
) {
val diffResultState = diffViewModel.diffResult.collectAsState()
val diffType by diffViewModel.diffTypeFlow.collectAsState()
val viewDiffResult = diffResultState.value ?: return
val focusRequester = remember { FocusRequester() }
@ -86,6 +81,7 @@ fun Diff(
ViewDiffResult.DiffNotFound -> {
onCloseDiffView()
}
is ViewDiffResult.Loaded -> {
val diffEntryType = viewDiffResult.diffEntryType
val diffEntry = viewDiffResult.diffResult.diffEntry
@ -95,14 +91,16 @@ fun Diff(
diffEntryType = diffEntryType,
diffEntry = diffEntry,
onCloseDiffView = onCloseDiffView,
stageFile = { diffViewModel.stageFile(it) },
unstageFile = { diffViewModel.unstageFile(it) },
diffType = diffType,
onStageFile = { diffViewModel.stageFile(it) },
onUnstageFile = { diffViewModel.unstageFile(it) },
onChangeDiffType = { diffViewModel.changeTextDiffType(it) }
)
if (diffResult is DiffResult.Text) {
val scrollState by diffViewModel.lazyListState.collectAsState()
val scrollState by diffViewModel.lazyListState.collectAsState()
TextDiff(
when (diffResult) {
is DiffResult.TextSplit -> HunkSplitTextDiff(
diffEntryType = diffEntryType,
scrollState = scrollState,
diffResult = diffResult,
@ -116,13 +114,40 @@ fun Diff(
diffViewModel.resetHunk(entry, hunk)
}
)
} else if (diffResult is DiffResult.NonText) {
NonTextDiff(diffResult)
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)
}
)
is DiffResult.NonText -> {
NonTextDiff(diffResult)
}
}
}
ViewDiffResult.Loading, ViewDiffResult.None -> {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth(), color = MaterialTheme.colors.primaryVariant)
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")
}
@ -225,8 +250,9 @@ fun BinaryDiff() {
)
}
@Composable
fun TextDiff(
fun HunkUnifiedTextDiff(
diffEntryType: DiffEntryType,
scrollState: LazyListState,
diffResult: DiffResult.Text,
@ -246,7 +272,7 @@ fun TextDiff(
item {
DisableSelection {
HunkHeader(
hunk = hunk,
header = hunk.header,
diffEntryType = diffEntryType,
onUnstageHunk = { onUnstageHunk(diffResult.diffEntry, hunk) },
onStageHunk = { onStageHunk(diffResult.diffEntry, hunk) },
@ -269,9 +295,74 @@ fun TextDiff(
}
@Composable
fun HunkSplitTextDiff(
diffEntryType: DiffEntryType,
scrollState: LazyListState,
diffResult: DiffResult.TextSplit,
onUnstageHunk: (DiffEntry, Hunk) -> Unit,
onStageHunk: (DiffEntry, Hunk) -> Unit,
onResetHunk: (DiffEntry, Hunk) -> Unit,
) {
val hunks = diffResult.hunks
SelectionContainer {
ScrollableLazyColumn(
modifier = Modifier
.fillMaxSize(),
state = scrollState
) {
for (splitHunk in hunks) {
item {
DisableSelection {
HunkHeader(
header = splitHunk.hunk.header,
diffEntryType = diffEntryType,
onUnstageHunk = { onUnstageHunk(diffResult.diffEntry, splitHunk.hunk) },
onStageHunk = { onStageHunk(diffResult.diffEntry, splitHunk.hunk) },
onResetHunk = { onResetHunk(diffResult.diffEntry, splitHunk.hunk) },
)
}
}
val oldHighestLineNumber = splitHunk.hunk.lines.maxOf { it.displayOldLineNumber }
val newHighestLineNumber = splitHunk.hunk.lines.maxOf { it.displayNewLineNumber }
val highestLineNumber = max(oldHighestLineNumber, newHighestLineNumber)
val highestLineNumberLength = highestLineNumber.toString().count()
items(splitHunk.lines) { linesPair ->
SplitDiffLine(highestLineNumberLength, linesPair.first, linesPair.second)
}
}
}
}
}
@Composable
fun SplitDiffLine(highestLineNumberLength: Int, first: Line?, second: Line?) {
Row(
modifier = Modifier
.background(MaterialTheme.colors.secondarySurface)
) {
Box(
modifier = Modifier
.weight(1f)
) {
if (first != null)
SplitDiffLine(highestLineNumberLength, first, first.oldLineNumber + 1)
}
Box(modifier = Modifier.weight(1f)) {
if (second != null)
SplitDiffLine(highestLineNumberLength, second, second.newLineNumber + 1)
}
}
}
@Composable
fun HunkHeader(
hunk: Hunk,
header: String,
diffEntryType: DiffEntryType,
onUnstageHunk: () -> Unit,
onStageHunk: () -> Unit,
@ -285,7 +376,7 @@ fun HunkHeader(
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = hunk.header,
text = header,
color = MaterialTheme.colors.primaryTextColor,
style = MaterialTheme.typography.body1,
)
@ -332,13 +423,16 @@ fun HunkHeader(
}
}
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun DiffHeader(
private fun DiffHeader(
diffEntryType: DiffEntryType,
diffEntry: DiffEntry,
diffType: TextDiffType,
onCloseDiffView: () -> Unit,
stageFile: (StatusEntry) -> Unit,
unstageFile: (StatusEntry) -> Unit,
onStageFile: (StatusEntry) -> Unit,
onUnstageFile: (StatusEntry) -> Unit,
onChangeDiffType: (TextDiffType) -> Unit,
) {
Row(
modifier = Modifier
@ -362,28 +456,15 @@ fun DiffHeader(
Spacer(modifier = Modifier.weight(1f))
if (diffEntryType.statusType != StatusType.ADDED && diffEntryType.statusType != StatusType.REMOVED) {
DiffTypeButtons(diffType = diffType, onChangeDiffType = onChangeDiffType)
}
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)
}
}
UncommitedDiffFileHeaderButtons(
diffEntryType,
onUnstageFile = onUnstageFile,
onStageFile = onStageFile
)
}
@ -401,21 +482,120 @@ fun DiffHeader(
}
}
@Composable
fun DiffTypeButtons(diffType: TextDiffType, onChangeDiffType: (TextDiffType) -> Unit) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(horizontal = 16.dp)
) {
Text(
"Unified",
color = MaterialTheme.colors.primaryTextColor,
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.primaryTextColor,
// 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)
}
}
)
}
@OptIn(ExperimentalComposeUiApi::class)
@Composable
private fun PathOnlyDiffHeader(
filePath: String,
onCloseDiffView: () -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(40.dp)
.background(MaterialTheme.colors.headerBackground)
.padding(start = 8.dp, end = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = filePath,
style = MaterialTheme.typography.body2,
maxLines = 1,
modifier = Modifier.padding(horizontal = 16.dp),
)
Spacer(modifier = Modifier.weight(1f))
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
}
LineType.ADDED -> MaterialTheme.colors.diffLineAdded
LineType.REMOVED -> MaterialTheme.colors.diffLineRemoved
LineType.CONTEXT -> MaterialTheme.colors.background
}
Row(
modifier = Modifier
@ -442,33 +622,65 @@ fun DiffLine(
)
}
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,
DiffLineText(line.text)
}
}
@Composable
fun SplitDiffLine(
highestLineNumberLength: Int,
line: Line,
lineNumber: Int,
) {
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)
) {
DisableSelection {
LineNumber(
text = lineNumber.toStringWithSpaces(highestLineNumberLength),
)
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,
)
}
}
DiffLineText(line.text)
}
}
@Composable
fun DiffLineText(text: String) {
Row {
Text(
text = text.replace(
"\t",
" "
).removeLineDelimiters(),
modifier = Modifier
.padding(start = 8.dp)
.fillMaxSize(),
fontFamily = FontFamily.Monospace,
style = MaterialTheme.typography.body2,
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,
)
}
}
}

View File

@ -26,6 +26,7 @@ import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.clipRect
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.PathBuilder
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
@ -102,6 +103,7 @@ fun Log(
val logStatus = logStatusState.value
val showLogDialog by logViewModel.logDialog.collectAsState()
val selectedCommit = if (selectedItem is SelectedItem.CommitBasedItem) {
selectedItem.revCommit
} else {

View File

@ -1,7 +1,7 @@
package app.viewmodels
import app.git.*
import app.preferences.AppPreferences
import app.preferences.AppSettings
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import org.eclipse.jgit.api.Git
@ -14,7 +14,7 @@ class BranchesViewModel @Inject constructor(
private val mergeManager: MergeManager,
private val remoteOperationsManager: RemoteOperationsManager,
private val tabState: TabState,
private val appPreferences: AppPreferences,
private val appSettings: AppSettings,
) : ExpandableViewModel(true) {
private val _branches = MutableStateFlow<List<Ref>>(listOf())
val branches: StateFlow<List<Ref>>
@ -51,7 +51,7 @@ class BranchesViewModel @Inject constructor(
fun mergeBranch(ref: Ref) = tabState.safeProcessing(
refreshType = RefreshType.ALL_DATA,
) { git ->
mergeManager.mergeBranch(git, ref, appPreferences.ffMerge)
mergeManager.mergeBranch(git, ref, appSettings.ffMerge)
}
fun deleteBranch(branch: Ref) = tabState.safeProcessing(

View File

@ -1,13 +1,19 @@
//asdasd
package app.viewmodels
import androidx.compose.foundation.lazy.LazyListState
import app.exceptions.MissingDiffEntryException
import app.extensions.delayedStateChange
import app.git.*
import app.git.diff.Hunk
import app.git.diff.*
import app.preferences.AppSettings
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import org.eclipse.jgit.diff.DiffEntry
import java.lang.Integer.max
import javax.inject.Inject
private const val DIFF_MIN_TIME_IN_MS_TO_SHOW_LOAD = 200L
@ -16,10 +22,30 @@ class DiffViewModel @Inject constructor(
private val tabState: TabState,
private val diffManager: DiffManager,
private val statusManager: StatusManager,
private val settings: AppSettings,
) {
private val _diffResult = MutableStateFlow<ViewDiffResult>(ViewDiffResult.Loading)
private val _diffResult = MutableStateFlow<ViewDiffResult>(ViewDiffResult.Loading(""))
val diffResult: StateFlow<ViewDiffResult?> = _diffResult
val diffTypeFlow = settings.textDiffTypeFlow
private var diffEntryType: DiffEntryType? = null
private var diffTypeFlowChangesCount = 0
private var diffJob: Job? = null
init {
tabState.managerScope.launch {
diffTypeFlow.collect {
val diffEntryType = this@DiffViewModel.diffEntryType
if (diffTypeFlowChangesCount > 0 && diffEntryType != null) { // Ignore the first time the flow triggers, we only care about updates
updateDiff(diffEntryType)
}
diffTypeFlowChangesCount++
}
}
}
val lazyListState = MutableStateFlow(
LazyListState(
0,
@ -27,46 +53,155 @@ class DiffViewModel @Inject constructor(
)
)
// TODO Cancel job if the user closed the diff view while loading
fun updateDiff(diffEntryType: DiffEntryType) = tabState.runOperation(
refreshType = RefreshType.NONE,
) { git ->
var oldDiffEntryType: DiffEntryType? = null
val oldDiffResult = _diffResult.value
fun updateDiff(diffEntryType: DiffEntryType) {
diffJob = tabState.runOperation(refreshType = RefreshType.NONE) { git ->
this.diffEntryType = diffEntryType
if (oldDiffResult is ViewDiffResult.Loaded) {
oldDiffEntryType = oldDiffResult.diffEntryType
}
var oldDiffEntryType: DiffEntryType? = null
val oldDiffResult = _diffResult.value
// If it's a different file or different state (index or workdir), reset the scroll state
if (oldDiffEntryType != null &&
oldDiffEntryType is DiffEntryType.UncommitedDiff && diffEntryType is DiffEntryType.UncommitedDiff &&
oldDiffEntryType.statusEntry.filePath != diffEntryType.statusEntry.filePath
) {
lazyListState.value = LazyListState(
0,
0
)
}
try {
delayedStateChange(
delayMs = DIFF_MIN_TIME_IN_MS_TO_SHOW_LOAD,
onDelayTriggered = { _diffResult.value = ViewDiffResult.Loading }
) {
val diffFormat = diffManager.diffFormat(git, diffEntryType)
_diffResult.value = ViewDiffResult.Loaded(diffEntryType, diffFormat)
if (oldDiffResult is ViewDiffResult.Loaded) {
oldDiffEntryType = oldDiffResult.diffEntryType
}
// If it's a different file or different state (index or workdir), reset the scroll state
if (oldDiffEntryType != null &&
oldDiffEntryType is DiffEntryType.UncommitedDiff &&
diffEntryType is DiffEntryType.UncommitedDiff &&
oldDiffEntryType.statusEntry.filePath != diffEntryType.statusEntry.filePath
) {
lazyListState.value = LazyListState(
0,
0
)
}
val isFirstLoad = oldDiffResult is ViewDiffResult.Loading && oldDiffResult.filePath.isEmpty()
try {
delayedStateChange(
delayMs = if (isFirstLoad) 0 else DIFF_MIN_TIME_IN_MS_TO_SHOW_LOAD,
onDelayTriggered = { _diffResult.value = ViewDiffResult.Loading(diffEntryType.filePath) }
) {
val diffFormat = diffManager.diffFormat(git, diffEntryType)
val diffEntry = diffFormat.diffEntry
if (
diffTypeFlow.value == TextDiffType.SPLIT &&
diffFormat is DiffResult.Text &&
diffEntry.changeType != DiffEntry.ChangeType.ADD &&
diffEntry.changeType != DiffEntry.ChangeType.DELETE
) {
val splitHunkList = generateSplitDiffFormat(diffFormat)
_diffResult.value = ViewDiffResult.Loaded(
diffEntryType,
DiffResult.TextSplit(diffEntry, splitHunkList)
)
} else {
_diffResult.value = ViewDiffResult.Loaded(diffEntryType, diffFormat)
}
}
} catch (ex: Exception) {
if (ex is MissingDiffEntryException) {
tabState.refreshData(refreshType = RefreshType.UNCOMMITED_CHANGES)
_diffResult.value = ViewDiffResult.DiffNotFound
} else
ex.printStackTrace()
}
} catch (ex: Exception) {
if (ex is MissingDiffEntryException) {
tabState.refreshData(refreshType = RefreshType.UNCOMMITED_CHANGES)
_diffResult.value = ViewDiffResult.DiffNotFound
} else
ex.printStackTrace()
}
}
private fun generateSplitDiffFormat(diffFormat: DiffResult.Text): List<SplitHunk> {
val unifiedHunksList = diffFormat.hunks
val hunksList = mutableListOf<SplitHunk>()
for (hunk in unifiedHunksList) {
val linesNewSideCount =
hunk.lines.count { it.lineType == LineType.ADDED || it.lineType == LineType.CONTEXT }
val linesOldSideCount =
hunk.lines.count { it.lineType == LineType.REMOVED || it.lineType == LineType.CONTEXT }
val addedLines = hunk.lines.filter { it.lineType == LineType.ADDED }
val removedLines = hunk.lines.filter { it.lineType == LineType.REMOVED }
val maxLinesCountOfBothParts = max(linesNewSideCount, linesOldSideCount)
val oldLinesArray = arrayOfNulls<Line?>(maxLinesCountOfBothParts)
val newLinesArray = arrayOfNulls<Line?>(maxLinesCountOfBothParts)
val lines = hunk.lines
val firstLineOldNumber = hunk.lines.first().oldLineNumber
val firstLineNewNumber = hunk.lines.first().newLineNumber
val firstLine = if (maxLinesCountOfBothParts == linesOldSideCount) {
firstLineOldNumber
} else
firstLineNewNumber
val contextLines = lines.filter { it.lineType == LineType.CONTEXT }
for (contextLine in contextLines) {
val lineNumber = if (maxLinesCountOfBothParts == linesOldSideCount) {
contextLine.oldLineNumber
} else
contextLine.newLineNumber
oldLinesArray[lineNumber - firstLine] = contextLine
newLinesArray[lineNumber - firstLine] = contextLine
}
for (removedLine in removedLines) {
val previousLinesToCurrent = lines.takeWhile { it != removedLine }
val previousContextLine = previousLinesToCurrent.lastOrNull { it.lineType == LineType.CONTEXT }
val contextArrayPosition = if (previousContextLine != null)
oldLinesArray.indexOf(previousContextLine)
else
-1
// Get the position the list of null position of the array
val availableIndexes =
newLinesArray.mapIndexed { index, line ->
if (line != null)
null
else
index
}.filterNotNull()
val nextAvailableLinePosition = availableIndexes.first { index -> index > contextArrayPosition }
oldLinesArray[nextAvailableLinePosition] = removedLine
}
for (addedLine in addedLines) {
val previousLinesToCurrent = lines.takeWhile { it != addedLine }
val previousContextLine = previousLinesToCurrent.lastOrNull { it.lineType == LineType.CONTEXT }
val contextArrayPosition = if (previousContextLine != null)
newLinesArray.indexOf(previousContextLine)
else
-1
val availableIndexes =
newLinesArray.mapIndexed { index, line -> if (line != null) null else index }.filterNotNull()
val newLinePosition = availableIndexes.first { index -> index > contextArrayPosition }
newLinesArray[newLinePosition] = addedLine
}
val newHunkLines = mutableListOf<Pair<Line?, Line?>>()
for (i in 0 until maxLinesCountOfBothParts) {
val old = oldLinesArray[i]
val new = newLinesArray[i]
newHunkLines.add(old to new)
}
hunksList.add(SplitHunk(hunk, newHunkLines))
}
return hunksList
}
fun stageHunk(diffEntry: DiffEntry, hunk: Hunk) = tabState.runOperation(
refreshType = RefreshType.UNCOMMITED_CHANGES,
) { git ->
@ -97,6 +232,12 @@ class DiffViewModel @Inject constructor(
) { git ->
statusManager.unstage(git, statusEntry)
}
fun cancelRunningJobs() {
diffJob?.cancel()
}
fun changeTextDiffType(newDiffType: TextDiffType) {
settings.textDiffType = newDiffType
}
}

View File

@ -7,6 +7,7 @@ import app.git.DiffEntryType
import app.git.DiffManager
import app.git.RefreshType
import app.git.TabState
import app.preferences.AppSettings
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import org.eclipse.jgit.revwalk.RevCommit
@ -15,6 +16,7 @@ import javax.inject.Inject
class HistoryViewModel @Inject constructor(
private val tabState: TabState,
private val diffManager: DiffManager,
private val settings: AppSettings,
) {
private val _historyState = MutableStateFlow<HistoryState>(HistoryState.Loading(""))
val historyState: StateFlow<HistoryState> = _historyState

View File

@ -6,7 +6,7 @@ import app.extensions.delayedStateChange
import app.git.*
import app.git.graph.GraphCommitList
import app.git.graph.GraphNode
import app.preferences.AppPreferences
import app.preferences.AppSettings
import app.ui.SelectedItem
import app.ui.log.LogDialog
import kotlinx.coroutines.CoroutineScope
@ -40,7 +40,7 @@ class LogViewModel @Inject constructor(
private val mergeManager: MergeManager,
private val remoteOperationsManager: RemoteOperationsManager,
private val tabState: TabState,
private val appPreferences: AppPreferences,
private val appSettings: AppSettings,
) {
private val _logStatus = MutableStateFlow<LogStatus>(LogStatus.Loading)
@ -66,12 +66,12 @@ class LogViewModel @Inject constructor(
init {
scope.launch {
appPreferences.commitsLimitEnabledFlow.collect {
appSettings.commitsLimitEnabledFlow.collect {
tabState.refreshData(RefreshType.ONLY_LOG)
}
}
scope.launch {
appPreferences.commitsLimitFlow.collect {
appSettings.commitsLimitFlow.collect {
tabState.refreshData(RefreshType.ONLY_LOG)
}
}
@ -90,13 +90,13 @@ class LogViewModel @Inject constructor(
)
val hasUncommitedChanges = statusSummary.total > 0
val commitsLimit = if (appPreferences.commitsLimitEnabled) {
appPreferences.commitsLimit
val commitsLimit = if (appSettings.commitsLimitEnabled) {
appSettings.commitsLimit
} else
Int.MAX_VALUE
val commitsLimitDisplayed = if (appPreferences.commitsLimitEnabled) {
appPreferences.commitsLimit
val commitsLimitDisplayed = if (appSettings.commitsLimitEnabled) {
appSettings.commitsLimit
} else
-1
@ -176,7 +176,7 @@ class LogViewModel @Inject constructor(
fun mergeBranch(ref: Ref) = tabState.safeProcessing(
refreshType = RefreshType.ALL_DATA,
) { git ->
mergeManager.mergeBranch(git, ref, appPreferences.ffMerge)
mergeManager.mergeBranch(git, ref, appSettings.ffMerge)
}
fun deleteBranch(branch: Ref) = tabState.safeProcessing(

View File

@ -1,60 +1,60 @@
package app.viewmodels
import app.preferences.AppPreferences
import app.preferences.AppSettings
import app.theme.Theme
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class SettingsViewModel @Inject constructor(
val appPreferences: AppPreferences,
val appSettings: AppSettings,
) {
// Temporary values to detect changed variables
var commitsLimit: Int = -1
val themeState = appPreferences.themeState
val customThemeFlow = appPreferences.customThemeFlow
val ffMergeFlow = appPreferences.ffMergeFlow
val commitsLimitEnabledFlow = appPreferences.commitsLimitEnabledFlow
val themeState = appSettings.themeState
val customThemeFlow = appSettings.customThemeFlow
val ffMergeFlow = appSettings.ffMergeFlow
val commitsLimitEnabledFlow = appSettings.commitsLimitEnabledFlow
var scaleUi: Float
get() = appPreferences.scaleUi
get() = appSettings.scaleUi
set(value) {
appPreferences.scaleUi = value
appSettings.scaleUi = value
}
var commitsLimitEnabled: Boolean
get() = appPreferences.commitsLimitEnabled
get() = appSettings.commitsLimitEnabled
set(value) {
appPreferences.commitsLimitEnabled = value
appSettings.commitsLimitEnabled = value
}
var ffMerge: Boolean
get() = appPreferences.ffMerge
get() = appSettings.ffMerge
set(value) {
appPreferences.ffMerge = value
appSettings.ffMerge = value
}
var theme: Theme
get() = appPreferences.theme
get() = appSettings.theme
set(value) {
appPreferences.theme = value
appSettings.theme = value
}
fun saveCustomTheme(filePath: String) {
appPreferences.saveCustomTheme(filePath)
appSettings.saveCustomTheme(filePath)
}
fun resetInfo() {
commitsLimit = appPreferences.commitsLimit
commitsLimit = appSettings.commitsLimit
}
fun savePendingChanges() {
val commitsLimit = this.commitsLimit
if (appPreferences.commitsLimit != commitsLimit) {
appPreferences.commitsLimit = commitsLimit
if (appSettings.commitsLimit != commitsLimit) {
appSettings.commitsLimit = commitsLimit
}
}
}

View File

@ -352,8 +352,10 @@ class TabViewModel @Inject constructor(
diffViewModel = diffViewModelProvider.get()
}
diffViewModel?.cancelRunningJobs()
diffViewModel?.updateDiff(diffSelected)
} else {
diffViewModel?.cancelRunningJobs()
diffViewModel = null // Free the view model from the memory if not being used.
}
}

View File

@ -1,11 +1,15 @@
package app.viewmodels
import app.git.DiffEntryType
import app.git.TextDiffType
import app.git.diff.DiffResult
sealed interface ViewDiffResult {
object None : ViewDiffResult
object Loading : ViewDiffResult
data class Loading(val filePath: String) : ViewDiffResult
object DiffNotFound : ViewDiffResult
data class Loaded(val diffEntryType: DiffEntryType, val diffResult: DiffResult) : ViewDiffResult
data class Loaded(val diffEntryType: DiffEntryType, val diffResult: DiffResult/*, val diffType: TextDiffType*/) : ViewDiffResult
}