From 8e366741ac322ab54672a703c1864fed3b3ad420 Mon Sep 17 00:00:00 2001 From: Abdelilah El Aissaoui Date: Thu, 26 May 2022 23:35:32 +0200 Subject: [PATCH] Added basic version of blame --- src/main/kotlin/app/git/TabState.kt | 2 +- src/main/kotlin/app/ui/Blame.kt | 106 +++++++++++++++++ src/main/kotlin/app/ui/CommitChanges.kt | 109 ++++++++++-------- src/main/kotlin/app/ui/RepositoryOpen.kt | 53 ++++++--- src/main/kotlin/app/ui/UncommitedChanges.kt | 27 ++--- .../CommitedChangesEntriesContextMenu.kt | 24 ++++ ...extMenu.kt => StatusEntriesContextMenu.kt} | 27 ++++- .../kotlin/app/viewmodels/TabViewModel.kt | 34 +++++- 8 files changed, 297 insertions(+), 85 deletions(-) create mode 100644 src/main/kotlin/app/ui/Blame.kt create mode 100644 src/main/kotlin/app/ui/context_menu/CommitedChangesEntriesContextMenu.kt rename src/main/kotlin/app/ui/context_menu/{UnstagedEntriesContextMenu.kt => StatusEntriesContextMenu.kt} (58%) diff --git a/src/main/kotlin/app/git/TabState.kt b/src/main/kotlin/app/git/TabState.kt index 6365c14..d00fa00 100644 --- a/src/main/kotlin/app/git/TabState.kt +++ b/src/main/kotlin/app/git/TabState.kt @@ -33,7 +33,7 @@ class TabState @Inject constructor( return git } - val mutex = Mutex() + private val mutex = Mutex() private val _refreshData = MutableSharedFlow() val refreshData: Flow = _refreshData diff --git a/src/main/kotlin/app/ui/Blame.kt b/src/main/kotlin/app/ui/Blame.kt new file mode 100644 index 0000000..e4bd649 --- /dev/null +++ b/src/main/kotlin/app/ui/Blame.kt @@ -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), + ) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/app/ui/CommitChanges.kt b/src/main/kotlin/app/ui/CommitChanges.kt index 0b0edec..21199a6 100644 --- a/src/main/kotlin/app/ui/CommitChanges.kt +++ b/src/main/kotlin/app/ui/CommitChanges.kt @@ -1,13 +1,10 @@ package app.ui -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable +import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.selection.SelectionContainer -import androidx.compose.foundation.verticalScroll import androidx.compose.material.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -24,6 +21,7 @@ import app.theme.* import app.ui.components.AvatarImage import app.ui.components.ScrollableLazyColumn import app.ui.components.TooltipText +import app.ui.context_menu.commitedChangesEntriesContextMenuItems import app.viewmodels.CommitChangesStatus import app.viewmodels.CommitChangesViewModel import org.eclipse.jgit.diff.DiffEntry @@ -34,7 +32,8 @@ fun CommitChanges( commitChangesViewModel: CommitChangesViewModel, selectedItem: SelectedItem.CommitBasedItem, onDiffSelected: (DiffEntry) -> Unit, - diffSelected: DiffEntryType? + diffSelected: DiffEntryType?, + onBlame: (String) -> Unit, ) { LaunchedEffect(selectedItem) { commitChangesViewModel.loadChanges(selectedItem.revCommit) @@ -52,6 +51,7 @@ fun CommitChanges( commit = commitChangesStatus.commit, changes = commitChangesStatus.changes, onDiffSelected = onDiffSelected, + onBlame = onBlame ) } } @@ -62,7 +62,8 @@ fun CommitChangesView( commit: RevCommit, changes: List, onDiffSelected: (DiffEntry) -> Unit, - diffSelected: DiffEntryType? + diffSelected: DiffEntryType?, + onBlame: (String) -> Unit, ) { Column( modifier = Modifier @@ -118,7 +119,8 @@ fun CommitChangesView( CommitLogChanges( diffSelected = diffSelected, diffEntries = changes, - onDiffSelected = onDiffSelected + onDiffSelected = onDiffSelected, + onBlame = onBlame, ) } } @@ -183,67 +185,78 @@ fun Author(commit: RevCommit) { } } +@OptIn(ExperimentalFoundationApi::class) @Composable fun CommitLogChanges( diffEntries: List, onDiffSelected: (DiffEntry) -> Unit, - diffSelected: DiffEntryType? + diffSelected: DiffEntryType?, + onBlame: (String) -> Unit, ) { ScrollableLazyColumn( modifier = Modifier .fillMaxSize() ) { items(items = diffEntries) { diffEntry -> - Column( - modifier = Modifier - .height(40.dp) - .fillMaxWidth() - .clickable { - onDiffSelected(diffEntry) - } - .backgroundIf( - condition = diffSelected is DiffEntryType.CommitDiff && diffSelected.diffEntry == diffEntry, - color = MaterialTheme.colors.backgroundSelected, - ), - verticalArrangement = Arrangement.Center, - ) { - Spacer(modifier = Modifier.weight(2f)) - - - Row { - Icon( - modifier = Modifier - .padding(horizontal = 8.dp) - .size(16.dp), - imageVector = diffEntry.icon, - contentDescription = null, - tint = diffEntry.iconColor, + ContextMenuArea( + items = { + commitedChangesEntriesContextMenuItems( + diffEntry, + onBlame = { onBlame(diffEntry.filePath) } ) + } + ) { + Column( + modifier = Modifier + .height(40.dp) + .fillMaxWidth() + .clickable { + onDiffSelected(diffEntry) + } + .backgroundIf( + condition = diffSelected is DiffEntryType.CommitDiff && diffSelected.diffEntry == diffEntry, + color = MaterialTheme.colors.backgroundSelected, + ), + verticalArrangement = Arrangement.Center, + ) { + Spacer(modifier = Modifier.weight(2f)) - if(diffEntry.parentDirectoryPath.isNotEmpty()) { + + Row { + Icon( + modifier = Modifier + .padding(horizontal = 8.dp) + .size(16.dp), + imageVector = diffEntry.icon, + contentDescription = null, + tint = diffEntry.iconColor, + ) + + if (diffEntry.parentDirectoryPath.isNotEmpty()) { + Text( + text = diffEntry.parentDirectoryPath, + modifier = Modifier.weight(1f, fill = false), + maxLines = 1, + softWrap = false, + fontSize = 13.sp, + overflow = TextOverflow.Ellipsis, + color = MaterialTheme.colors.secondaryTextColor, + ) + } Text( - text = diffEntry.parentDirectoryPath, + text = diffEntry.fileName, modifier = Modifier.weight(1f, fill = false), maxLines = 1, softWrap = false, fontSize = 13.sp, - overflow = TextOverflow.Ellipsis, - color = MaterialTheme.colors.secondaryTextColor, + color = MaterialTheme.colors.primaryTextColor, ) } - Text( - text = diffEntry.fileName, - modifier = Modifier.weight(1f, fill = false), - maxLines = 1, - softWrap = false, - fontSize = 13.sp, - color = MaterialTheme.colors.primaryTextColor, - ) + + Spacer(modifier = Modifier.weight(2f)) + + Divider() } - - Spacer(modifier = Modifier.weight(2f)) - - Divider() } } } diff --git a/src/main/kotlin/app/ui/RepositoryOpen.kt b/src/main/kotlin/app/ui/RepositoryOpen.kt index 7639be2..14e6bbc 100644 --- a/src/main/kotlin/app/ui/RepositoryOpen.kt +++ b/src/main/kotlin/app/ui/RepositoryOpen.kt @@ -14,6 +14,7 @@ import app.theme.primaryTextColor import app.ui.dialogs.NewBranchDialog import app.ui.dialogs.RebaseInteractive import app.ui.log.Log +import app.viewmodels.BlameState import app.viewmodels.TabViewModel import openRepositoryDialog import org.eclipse.jgit.lib.RepositoryState @@ -29,6 +30,7 @@ fun RepositoryOpenPage(tabViewModel: TabViewModel) { val repositoryState by tabViewModel.repositoryState.collectAsState() val diffSelected by tabViewModel.diffSelected.collectAsState() val selectedItem by tabViewModel.selectedItem.collectAsState() + val blameState by tabViewModel.blameState.collectAsState() var showNewBranchDialog by remember { mutableStateOf(false) } @@ -63,7 +65,7 @@ fun RepositoryOpenPage(tabViewModel: TabViewModel) { onCreateBranch = { showNewBranchDialog = true } ) - RepoContent(tabViewModel, diffSelected, selectedItem, repositoryState) + RepoContent(tabViewModel, diffSelected, selectedItem, repositoryState, blameState) } } @@ -75,7 +77,8 @@ fun RepoContent( tabViewModel: TabViewModel, diffSelected: DiffEntryType?, selectedItem: SelectedItem, - repositoryState: RepositoryState + repositoryState: RepositoryState, + blameState: BlameState, ) { Row { HorizontalSplitPane { @@ -115,18 +118,26 @@ fun RepoContent( shape = RoundedCornerShape(4.dp) ) ) { - when (diffSelected) { - null -> { - Log( - logViewModel = tabViewModel.logViewModel, - selectedItem = selectedItem, - repositoryState = repositoryState, - ) - } - else -> { - Diff( - diffViewModel = tabViewModel.diffViewModel, - onCloseDiffView = { tabViewModel.newDiffSelected = null }) + if (blameState is BlameState.Loaded) { + Blame( + filePath = blameState.filePath, + blameResult = blameState.blameResult, + onClose = { tabViewModel.resetBlameState() } + ) + } else { + when (diffSelected) { + null -> { + Log( + logViewModel = tabViewModel.logViewModel, + selectedItem = selectedItem, + repositoryState = repositoryState, + ) + } + else -> { + Diff( + diffViewModel = tabViewModel.diffViewModel, + onCloseDiffView = { tabViewModel.newDiffSelected = null }) + } } } } @@ -144,6 +155,11 @@ fun RepoContent( selectedEntryType = diffSelected, repositoryState = repositoryState, 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) { if (repositoryState == RepositoryState.SAFE) DiffEntryType.SafeStagedDiff(diffEntry) @@ -154,11 +170,14 @@ fun RepoContent( } }, onUnstagedDiffEntrySelected = { diffEntry -> + tabViewModel.resetBlameState() + if (repositoryState == RepositoryState.SAFE) tabViewModel.newDiffSelected = DiffEntryType.SafeUnstagedDiff(diffEntry) else tabViewModel.newDiffSelected = DiffEntryType.UnsafeUnstagedDiff(diffEntry) - } + }, + onBlameFile = { tabViewModel.blameFile(it) } ) } else if (safeSelectedItem is SelectedItem.CommitBasedItem) { CommitChanges( @@ -166,8 +185,10 @@ fun RepoContent( selectedItem = safeSelectedItem, diffSelected = diffSelected, onDiffSelected = { diffEntry -> + tabViewModel.resetBlameState() tabViewModel.newDiffSelected = DiffEntryType.CommitDiff(diffEntry) - } + }, + onBlame = { tabViewModel.blameFile(it) } ) } } diff --git a/src/main/kotlin/app/ui/UncommitedChanges.kt b/src/main/kotlin/app/ui/UncommitedChanges.kt index d0af3cd..86beb6e 100644 --- a/src/main/kotlin/app/ui/UncommitedChanges.kt +++ b/src/main/kotlin/app/ui/UncommitedChanges.kt @@ -40,14 +40,10 @@ import app.git.StatusEntry import app.theme.* import app.ui.components.ScrollableLazyColumn import app.ui.components.SecondaryButton -import app.ui.context_menu.DropDownContent -import app.ui.context_menu.DropDownContentData -import app.ui.context_menu.stagedEntriesContextMenuItems -import app.ui.context_menu.unstagedEntriesContextMenuItems +import app.ui.context_menu.* import app.viewmodels.StageStatus import app.viewmodels.StatusViewModel import org.eclipse.jgit.lib.RepositoryState -import kotlin.reflect.KClass @Composable fun UncommitedChanges( @@ -56,6 +52,7 @@ fun UncommitedChanges( repositoryState: RepositoryState, onStagedDiffEntrySelected: (StatusEntry?) -> Unit, onUnstagedDiffEntrySelected: (StatusEntry) -> Unit, + onBlameFile: (String) -> Unit, ) { val stageStatusState = statusViewModel.stageStatus.collectAsState() var commitMessage by remember { mutableStateOf(statusViewModel.savedCommitMessage) } @@ -106,12 +103,12 @@ fun UncommitedChanges( onDiffEntryOptionSelected = { statusViewModel.unstage(it) }, - onGenerateContextMenu = { diffEntry -> - stagedEntriesContextMenuItems( - diffEntry = diffEntry, - onReset = { - statusViewModel.resetStaged(diffEntry) - }, + onGenerateContextMenu = { statusEntry -> + statusEntriesContextMenuItems( + statusEntry = statusEntry, + entryType = EntryType.STAGED, + onBlame = { onBlameFile(statusEntry.filePath) }, + onReset = { statusViewModel.resetStaged(statusEntry) }, ) }, onAllAction = { @@ -134,11 +131,11 @@ fun UncommitedChanges( statusViewModel.stage(it) }, onGenerateContextMenu = { statusEntry -> - unstagedEntriesContextMenuItems( + statusEntriesContextMenuItems( statusEntry = statusEntry, - onReset = { - statusViewModel.resetUnstaged(statusEntry) - }, + entryType = EntryType.UNSTAGED, + onBlame = { onBlameFile(statusEntry.filePath) }, + onReset = { statusViewModel.resetUnstaged(statusEntry) }, onDelete = { statusViewModel.deleteFile(statusEntry) } diff --git a/src/main/kotlin/app/ui/context_menu/CommitedChangesEntriesContextMenu.kt b/src/main/kotlin/app/ui/context_menu/CommitedChangesEntriesContextMenu.kt new file mode 100644 index 0000000..cc882df --- /dev/null +++ b/src/main/kotlin/app/ui/context_menu/CommitedChangesEntriesContextMenu.kt @@ -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 { + return mutableListOf().apply { + if (diffEntry.changeType != DiffEntry.ChangeType.ADD || + diffEntry.changeType != DiffEntry.ChangeType.DELETE) { + add( + ContextMenuItem( + label = "Blame file", + onClick = onBlame, + ) + ) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/app/ui/context_menu/UnstagedEntriesContextMenu.kt b/src/main/kotlin/app/ui/context_menu/StatusEntriesContextMenu.kt similarity index 58% rename from src/main/kotlin/app/ui/context_menu/UnstagedEntriesContextMenu.kt rename to src/main/kotlin/app/ui/context_menu/StatusEntriesContextMenu.kt index 78fbb41..358f93d 100644 --- a/src/main/kotlin/app/ui/context_menu/UnstagedEntriesContextMenu.kt +++ b/src/main/kotlin/app/ui/context_menu/StatusEntriesContextMenu.kt @@ -4,13 +4,14 @@ import androidx.compose.foundation.ContextMenuItem import androidx.compose.foundation.ExperimentalFoundationApi import app.git.StatusEntry import app.git.StatusType -import org.eclipse.jgit.diff.DiffEntry @OptIn(ExperimentalFoundationApi::class) -fun unstagedEntriesContextMenuItems( +fun statusEntriesContextMenuItems( statusEntry: StatusEntry, + entryType: EntryType, onReset: () -> Unit, - onDelete: () -> Unit, + onDelete: () -> Unit = {}, + onBlame: () -> Unit, ): List { return mutableListOf().apply { if (statusEntry.statusType != StatusType.ADDED) { @@ -20,9 +21,21 @@ fun unstagedEntriesContextMenuItems( onClick = onReset, ) ) + + if (statusEntry.statusType != StatusType.REMOVED) { + add( + ContextMenuItem( + label = "Blame file", + onClick = onBlame, + ) + ) + } } - if (statusEntry.statusType != StatusType.REMOVED) { + if ( + entryType == EntryType.UNSTAGED && + statusEntry.statusType != StatusType.REMOVED + ) { add( ContextMenuItem( label = "Delete file", @@ -32,3 +45,9 @@ fun unstagedEntriesContextMenuItems( } } } + + +enum class EntryType { + STAGED, + UNSTAGED, +} \ No newline at end of file diff --git a/src/main/kotlin/app/viewmodels/TabViewModel.kt b/src/main/kotlin/app/viewmodels/TabViewModel.kt index 52bd226..784903a 100644 --- a/src/main/kotlin/app/viewmodels/TabViewModel.kt +++ b/src/main/kotlin/app/viewmodels/TabViewModel.kt @@ -1,6 +1,5 @@ package app.viewmodels -import app.AppPreferences import app.AppStateManager import app.ErrorsManager import app.credentials.CredentialsState @@ -15,6 +14,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.collect import org.eclipse.jgit.api.Git +import org.eclipse.jgit.blame.BlameResult import org.eclipse.jgit.lib.Repository import org.eclipse.jgit.lib.RepositoryState import java.io.File @@ -75,6 +75,9 @@ class TabViewModel @Inject constructor( private val _repositoryState = MutableStateFlow(RepositoryState.SAFE) val repositoryState: StateFlow = _repositoryState + private val _blameState = MutableStateFlow(BlameState.None) + val blameState: StateFlow = _blameState + val showError = MutableStateFlow(false) init { @@ -315,6 +318,28 @@ class TabViewModel @Inject constructor( 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 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 +} \ No newline at end of file