Added basic version of blame

This commit is contained in:
Abdelilah El Aissaoui 2022-05-26 23:35:32 +02:00
parent 543545d93d
commit 8e366741ac
8 changed files with 297 additions and 85 deletions

View File

@ -33,7 +33,7 @@ class TabState @Inject constructor(
return git return git
} }
val mutex = Mutex() private val mutex = Mutex()
private val _refreshData = MutableSharedFlow<RefreshType>() private val _refreshData = MutableSharedFlow<RefreshType>()
val refreshData: Flow<RefreshType> = _refreshData val refreshData: Flow<RefreshType> = _refreshData

View File

@ -0,0 +1,106 @@
@file:Suppress("UNUSED_PARAMETER")
package app.ui
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import app.extensions.lineAt
import app.theme.primaryTextColor
import app.ui.components.ScrollableLazyColumn
import org.eclipse.jgit.blame.BlameResult
@Composable
fun Blame(
filePath: String,
blameResult: BlameResult,
onClose: () -> Unit,
) {
Column {
Header(filePath, onClose = onClose)
ScrollableLazyColumn(
modifier = Modifier.fillMaxSize()
) {
val contents = blameResult.resultContents
items(contents.size()) { index ->
val line = contents.lineAt(index)
val author = blameResult.getSourceAuthor(index)
val commit = blameResult.getSourceCommit(index)
Row(
modifier = Modifier.fillMaxWidth().background(MaterialTheme.colors.background)
.height(IntrinsicSize.Min),
verticalAlignment = Alignment.CenterVertically,
) {
Column(
modifier = Modifier.width(200.dp).fillMaxHeight().background(MaterialTheme.colors.surface),
verticalArrangement = Arrangement.Center,
) {
Text(
text = author?.name.orEmpty(),
color = MaterialTheme.colors.primaryTextColor,
maxLines = 1,
modifier = Modifier.padding(start = 16.dp),
fontSize = 12.sp,
)
Text(
text = commit.shortMessage,
color = MaterialTheme.colors.primaryTextColor,
maxLines = 1,
modifier = Modifier.padding(start = 16.dp),
fontSize = 10.sp,
)
}
Text(
text = line,
color = MaterialTheme.colors.primaryTextColor,
modifier = Modifier.padding(vertical = 8.dp, horizontal = 16.dp),
fontFamily = FontFamily.Monospace,
)
}
}
}
}
}
@Composable
private fun Header(
filePath: String,
onClose: () -> Unit,
) {
Row(
modifier = Modifier.fillMaxWidth().background(MaterialTheme.colors.surface),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = filePath,
color = MaterialTheme.colors.primaryTextColor,
fontSize = 13.sp,
modifier = Modifier.padding(horizontal = 16.dp),
)
Spacer(modifier = Modifier.weight(1f))
IconButton(
onClick = onClose
) {
Image(
painter = painterResource("close.svg"),
contentDescription = "Close diff",
colorFilter = ColorFilter.tint(MaterialTheme.colors.primaryTextColor),
)
}
}
}

View File

@ -1,13 +1,10 @@
package app.ui package app.ui
import androidx.compose.foundation.background import androidx.compose.foundation.*
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.* import androidx.compose.material.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@ -24,6 +21,7 @@ import app.theme.*
import app.ui.components.AvatarImage import app.ui.components.AvatarImage
import app.ui.components.ScrollableLazyColumn import app.ui.components.ScrollableLazyColumn
import app.ui.components.TooltipText import app.ui.components.TooltipText
import app.ui.context_menu.commitedChangesEntriesContextMenuItems
import app.viewmodels.CommitChangesStatus import app.viewmodels.CommitChangesStatus
import app.viewmodels.CommitChangesViewModel import app.viewmodels.CommitChangesViewModel
import org.eclipse.jgit.diff.DiffEntry import org.eclipse.jgit.diff.DiffEntry
@ -34,7 +32,8 @@ fun CommitChanges(
commitChangesViewModel: CommitChangesViewModel, commitChangesViewModel: CommitChangesViewModel,
selectedItem: SelectedItem.CommitBasedItem, selectedItem: SelectedItem.CommitBasedItem,
onDiffSelected: (DiffEntry) -> Unit, onDiffSelected: (DiffEntry) -> Unit,
diffSelected: DiffEntryType? diffSelected: DiffEntryType?,
onBlame: (String) -> Unit,
) { ) {
LaunchedEffect(selectedItem) { LaunchedEffect(selectedItem) {
commitChangesViewModel.loadChanges(selectedItem.revCommit) commitChangesViewModel.loadChanges(selectedItem.revCommit)
@ -52,6 +51,7 @@ fun CommitChanges(
commit = commitChangesStatus.commit, commit = commitChangesStatus.commit,
changes = commitChangesStatus.changes, changes = commitChangesStatus.changes,
onDiffSelected = onDiffSelected, onDiffSelected = onDiffSelected,
onBlame = onBlame
) )
} }
} }
@ -62,7 +62,8 @@ fun CommitChangesView(
commit: RevCommit, commit: RevCommit,
changes: List<DiffEntry>, changes: List<DiffEntry>,
onDiffSelected: (DiffEntry) -> Unit, onDiffSelected: (DiffEntry) -> Unit,
diffSelected: DiffEntryType? diffSelected: DiffEntryType?,
onBlame: (String) -> Unit,
) { ) {
Column( Column(
modifier = Modifier modifier = Modifier
@ -118,7 +119,8 @@ fun CommitChangesView(
CommitLogChanges( CommitLogChanges(
diffSelected = diffSelected, diffSelected = diffSelected,
diffEntries = changes, diffEntries = changes,
onDiffSelected = onDiffSelected onDiffSelected = onDiffSelected,
onBlame = onBlame,
) )
} }
} }
@ -183,17 +185,27 @@ fun Author(commit: RevCommit) {
} }
} }
@OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun CommitLogChanges( fun CommitLogChanges(
diffEntries: List<DiffEntry>, diffEntries: List<DiffEntry>,
onDiffSelected: (DiffEntry) -> Unit, onDiffSelected: (DiffEntry) -> Unit,
diffSelected: DiffEntryType? diffSelected: DiffEntryType?,
onBlame: (String) -> Unit,
) { ) {
ScrollableLazyColumn( ScrollableLazyColumn(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
) { ) {
items(items = diffEntries) { diffEntry -> items(items = diffEntries) { diffEntry ->
ContextMenuArea(
items = {
commitedChangesEntriesContextMenuItems(
diffEntry,
onBlame = { onBlame(diffEntry.filePath) }
)
}
) {
Column( Column(
modifier = Modifier modifier = Modifier
.height(40.dp) .height(40.dp)
@ -248,3 +260,4 @@ fun CommitLogChanges(
} }
} }
} }
}

View File

@ -14,6 +14,7 @@ import app.theme.primaryTextColor
import app.ui.dialogs.NewBranchDialog import app.ui.dialogs.NewBranchDialog
import app.ui.dialogs.RebaseInteractive import app.ui.dialogs.RebaseInteractive
import app.ui.log.Log import app.ui.log.Log
import app.viewmodels.BlameState
import app.viewmodels.TabViewModel import app.viewmodels.TabViewModel
import openRepositoryDialog import openRepositoryDialog
import org.eclipse.jgit.lib.RepositoryState import org.eclipse.jgit.lib.RepositoryState
@ -29,6 +30,7 @@ fun RepositoryOpenPage(tabViewModel: TabViewModel) {
val repositoryState by tabViewModel.repositoryState.collectAsState() val repositoryState by tabViewModel.repositoryState.collectAsState()
val diffSelected by tabViewModel.diffSelected.collectAsState() val diffSelected by tabViewModel.diffSelected.collectAsState()
val selectedItem by tabViewModel.selectedItem.collectAsState() val selectedItem by tabViewModel.selectedItem.collectAsState()
val blameState by tabViewModel.blameState.collectAsState()
var showNewBranchDialog by remember { mutableStateOf(false) } var showNewBranchDialog by remember { mutableStateOf(false) }
@ -63,7 +65,7 @@ fun RepositoryOpenPage(tabViewModel: TabViewModel) {
onCreateBranch = { showNewBranchDialog = true } onCreateBranch = { showNewBranchDialog = true }
) )
RepoContent(tabViewModel, diffSelected, selectedItem, repositoryState) RepoContent(tabViewModel, diffSelected, selectedItem, repositoryState, blameState)
} }
} }
@ -75,7 +77,8 @@ fun RepoContent(
tabViewModel: TabViewModel, tabViewModel: TabViewModel,
diffSelected: DiffEntryType?, diffSelected: DiffEntryType?,
selectedItem: SelectedItem, selectedItem: SelectedItem,
repositoryState: RepositoryState repositoryState: RepositoryState,
blameState: BlameState,
) { ) {
Row { Row {
HorizontalSplitPane { HorizontalSplitPane {
@ -115,6 +118,13 @@ fun RepoContent(
shape = RoundedCornerShape(4.dp) shape = RoundedCornerShape(4.dp)
) )
) { ) {
if (blameState is BlameState.Loaded) {
Blame(
filePath = blameState.filePath,
blameResult = blameState.blameResult,
onClose = { tabViewModel.resetBlameState() }
)
} else {
when (diffSelected) { when (diffSelected) {
null -> { null -> {
Log( Log(
@ -131,6 +141,7 @@ fun RepoContent(
} }
} }
} }
}
second(minSize = 300.dp) { second(minSize = 300.dp) {
Box( Box(
@ -144,6 +155,11 @@ fun RepoContent(
selectedEntryType = diffSelected, selectedEntryType = diffSelected,
repositoryState = repositoryState, repositoryState = repositoryState,
onStagedDiffEntrySelected = { diffEntry -> onStagedDiffEntrySelected = { diffEntry ->
// TODO: Instead of resetting the state, create a new one where the blame
// is "on hold". In this state we can show a bar at the bottom so the user
// can click on it and return to the blame
tabViewModel.resetBlameState()
tabViewModel.newDiffSelected = if (diffEntry != null) { tabViewModel.newDiffSelected = if (diffEntry != null) {
if (repositoryState == RepositoryState.SAFE) if (repositoryState == RepositoryState.SAFE)
DiffEntryType.SafeStagedDiff(diffEntry) DiffEntryType.SafeStagedDiff(diffEntry)
@ -154,11 +170,14 @@ fun RepoContent(
} }
}, },
onUnstagedDiffEntrySelected = { diffEntry -> onUnstagedDiffEntrySelected = { diffEntry ->
tabViewModel.resetBlameState()
if (repositoryState == RepositoryState.SAFE) if (repositoryState == RepositoryState.SAFE)
tabViewModel.newDiffSelected = DiffEntryType.SafeUnstagedDiff(diffEntry) tabViewModel.newDiffSelected = DiffEntryType.SafeUnstagedDiff(diffEntry)
else else
tabViewModel.newDiffSelected = DiffEntryType.UnsafeUnstagedDiff(diffEntry) tabViewModel.newDiffSelected = DiffEntryType.UnsafeUnstagedDiff(diffEntry)
} },
onBlameFile = { tabViewModel.blameFile(it) }
) )
} else if (safeSelectedItem is SelectedItem.CommitBasedItem) { } else if (safeSelectedItem is SelectedItem.CommitBasedItem) {
CommitChanges( CommitChanges(
@ -166,8 +185,10 @@ fun RepoContent(
selectedItem = safeSelectedItem, selectedItem = safeSelectedItem,
diffSelected = diffSelected, diffSelected = diffSelected,
onDiffSelected = { diffEntry -> onDiffSelected = { diffEntry ->
tabViewModel.resetBlameState()
tabViewModel.newDiffSelected = DiffEntryType.CommitDiff(diffEntry) tabViewModel.newDiffSelected = DiffEntryType.CommitDiff(diffEntry)
} },
onBlame = { tabViewModel.blameFile(it) }
) )
} }
} }

View File

@ -40,14 +40,10 @@ import app.git.StatusEntry
import app.theme.* import app.theme.*
import app.ui.components.ScrollableLazyColumn import app.ui.components.ScrollableLazyColumn
import app.ui.components.SecondaryButton import app.ui.components.SecondaryButton
import app.ui.context_menu.DropDownContent import app.ui.context_menu.*
import app.ui.context_menu.DropDownContentData
import app.ui.context_menu.stagedEntriesContextMenuItems
import app.ui.context_menu.unstagedEntriesContextMenuItems
import app.viewmodels.StageStatus import app.viewmodels.StageStatus
import app.viewmodels.StatusViewModel import app.viewmodels.StatusViewModel
import org.eclipse.jgit.lib.RepositoryState import org.eclipse.jgit.lib.RepositoryState
import kotlin.reflect.KClass
@Composable @Composable
fun UncommitedChanges( fun UncommitedChanges(
@ -56,6 +52,7 @@ fun UncommitedChanges(
repositoryState: RepositoryState, repositoryState: RepositoryState,
onStagedDiffEntrySelected: (StatusEntry?) -> Unit, onStagedDiffEntrySelected: (StatusEntry?) -> Unit,
onUnstagedDiffEntrySelected: (StatusEntry) -> Unit, onUnstagedDiffEntrySelected: (StatusEntry) -> Unit,
onBlameFile: (String) -> Unit,
) { ) {
val stageStatusState = statusViewModel.stageStatus.collectAsState() val stageStatusState = statusViewModel.stageStatus.collectAsState()
var commitMessage by remember { mutableStateOf(statusViewModel.savedCommitMessage) } var commitMessage by remember { mutableStateOf(statusViewModel.savedCommitMessage) }
@ -106,12 +103,12 @@ fun UncommitedChanges(
onDiffEntryOptionSelected = { onDiffEntryOptionSelected = {
statusViewModel.unstage(it) statusViewModel.unstage(it)
}, },
onGenerateContextMenu = { diffEntry -> onGenerateContextMenu = { statusEntry ->
stagedEntriesContextMenuItems( statusEntriesContextMenuItems(
diffEntry = diffEntry, statusEntry = statusEntry,
onReset = { entryType = EntryType.STAGED,
statusViewModel.resetStaged(diffEntry) onBlame = { onBlameFile(statusEntry.filePath) },
}, onReset = { statusViewModel.resetStaged(statusEntry) },
) )
}, },
onAllAction = { onAllAction = {
@ -134,11 +131,11 @@ fun UncommitedChanges(
statusViewModel.stage(it) statusViewModel.stage(it)
}, },
onGenerateContextMenu = { statusEntry -> onGenerateContextMenu = { statusEntry ->
unstagedEntriesContextMenuItems( statusEntriesContextMenuItems(
statusEntry = statusEntry, statusEntry = statusEntry,
onReset = { entryType = EntryType.UNSTAGED,
statusViewModel.resetUnstaged(statusEntry) onBlame = { onBlameFile(statusEntry.filePath) },
}, onReset = { statusViewModel.resetUnstaged(statusEntry) },
onDelete = { onDelete = {
statusViewModel.deleteFile(statusEntry) statusViewModel.deleteFile(statusEntry)
} }

View File

@ -0,0 +1,24 @@
package app.ui.context_menu
import androidx.compose.foundation.ContextMenuItem
import androidx.compose.foundation.ExperimentalFoundationApi
import app.git.StatusType
import org.eclipse.jgit.diff.DiffEntry
@OptIn(ExperimentalFoundationApi::class)
fun commitedChangesEntriesContextMenuItems(
diffEntry: DiffEntry,
onBlame: () -> Unit,
): List<ContextMenuItem> {
return mutableListOf<ContextMenuItem>().apply {
if (diffEntry.changeType != DiffEntry.ChangeType.ADD ||
diffEntry.changeType != DiffEntry.ChangeType.DELETE) {
add(
ContextMenuItem(
label = "Blame file",
onClick = onBlame,
)
)
}
}
}

View File

@ -4,13 +4,14 @@ import androidx.compose.foundation.ContextMenuItem
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import app.git.StatusEntry import app.git.StatusEntry
import app.git.StatusType import app.git.StatusType
import org.eclipse.jgit.diff.DiffEntry
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
fun unstagedEntriesContextMenuItems( fun statusEntriesContextMenuItems(
statusEntry: StatusEntry, statusEntry: StatusEntry,
entryType: EntryType,
onReset: () -> Unit, onReset: () -> Unit,
onDelete: () -> Unit, onDelete: () -> Unit = {},
onBlame: () -> Unit,
): List<ContextMenuItem> { ): List<ContextMenuItem> {
return mutableListOf<ContextMenuItem>().apply { return mutableListOf<ContextMenuItem>().apply {
if (statusEntry.statusType != StatusType.ADDED) { if (statusEntry.statusType != StatusType.ADDED) {
@ -20,9 +21,21 @@ fun unstagedEntriesContextMenuItems(
onClick = onReset, onClick = onReset,
) )
) )
}
if (statusEntry.statusType != StatusType.REMOVED) { if (statusEntry.statusType != StatusType.REMOVED) {
add(
ContextMenuItem(
label = "Blame file",
onClick = onBlame,
)
)
}
}
if (
entryType == EntryType.UNSTAGED &&
statusEntry.statusType != StatusType.REMOVED
) {
add( add(
ContextMenuItem( ContextMenuItem(
label = "Delete file", label = "Delete file",
@ -32,3 +45,9 @@ fun unstagedEntriesContextMenuItems(
} }
} }
} }
enum class EntryType {
STAGED,
UNSTAGED,
}

View File

@ -1,6 +1,5 @@
package app.viewmodels package app.viewmodels
import app.AppPreferences
import app.AppStateManager import app.AppStateManager
import app.ErrorsManager import app.ErrorsManager
import app.credentials.CredentialsState import app.credentials.CredentialsState
@ -15,6 +14,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collect
import org.eclipse.jgit.api.Git import org.eclipse.jgit.api.Git
import org.eclipse.jgit.blame.BlameResult
import org.eclipse.jgit.lib.Repository import org.eclipse.jgit.lib.Repository
import org.eclipse.jgit.lib.RepositoryState import org.eclipse.jgit.lib.RepositoryState
import java.io.File import java.io.File
@ -75,6 +75,9 @@ class TabViewModel @Inject constructor(
private val _repositoryState = MutableStateFlow(RepositoryState.SAFE) private val _repositoryState = MutableStateFlow(RepositoryState.SAFE)
val repositoryState: StateFlow<RepositoryState> = _repositoryState val repositoryState: StateFlow<RepositoryState> = _repositoryState
private val _blameState = MutableStateFlow<BlameState>(BlameState.None)
val blameState: StateFlow<BlameState> = _blameState
val showError = MutableStateFlow(false) val showError = MutableStateFlow(false)
init { init {
@ -315,6 +318,28 @@ class TabViewModel @Inject constructor(
null null
} }
} }
fun blameFile(filePath: String) = tabState.safeProcessing(
refreshType = RefreshType.NONE,
) { git ->
_blameState.value = BlameState.Loading(filePath)
try {
val result = git.blame()
.setFilePath(filePath)
.setFollowFileRenames(true)
.call()
_blameState.value = BlameState.Loaded(filePath, result)
} catch (ex: Exception) {
resetBlameState()
throw ex
}
}
fun resetBlameState() {
_blameState.value = BlameState.None
}
} }
@ -323,3 +348,10 @@ sealed class RepositorySelectionStatus {
data class Opening(val path: String) : RepositorySelectionStatus() data class Opening(val path: String) : RepositorySelectionStatus()
data class Open(val repository: Repository) : RepositorySelectionStatus() data class Open(val repository: Repository) : RepositorySelectionStatus()
} }
sealed interface BlameState {
data class Loading(val filePath: String) : BlameState
data class Loaded(val filePath: String, val blameResult: BlameResult) : BlameState
object None : BlameState
}