Implemented graph search

Fixed bug where deleting text input value too fast (keeping pressed backspace) would not behave properly
This commit is contained in:
Abdelilah El Aissaoui 2022-03-31 20:39:25 +02:00
parent b83133f4fe
commit 054778bdcc
4 changed files with 484 additions and 284 deletions

View File

@ -57,7 +57,7 @@ fun UncommitedChanges(
onUnstagedDiffEntrySelected: (DiffEntry) -> Unit,
) {
val stageStatusState = statusViewModel.stageStatus.collectAsState()
val commitMessage by statusViewModel.commitMessage.collectAsState()
var commitMessage by remember { mutableStateOf(statusViewModel.savedCommitMessage) }
val stageStatus = stageStatusState.value
val staged: List<StatusEntry>
@ -86,7 +86,7 @@ fun UncommitedChanges(
val doCommit = { amend: Boolean ->
statusViewModel.commit(commitMessage, amend)
onStagedDiffEntrySelected(null)
statusViewModel.newCommitMessage = ""
statusViewModel.savedCommitMessage = ""
}
val canCommit = commitMessage.isNotEmpty() && staged.isNotEmpty()
@ -186,7 +186,10 @@ fun UncommitedChanges(
false
},
value = commitMessage,
onValueChange = { statusViewModel.newCommitMessage = it },
onValueChange = {
commitMessage = it
statusViewModel.savedCommitMessage = it
},
label = { Text("Write your commit message here", fontSize = 14.sp) },
colors = TextFieldDefaults.textFieldColors(backgroundColor = MaterialTheme.colors.background),
textStyle = TextStyle.Default.copy(fontSize = 14.sp, color = MaterialTheme.colors.primaryTextColor),

View File

@ -13,25 +13,25 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.*
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.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.input.key.*
import androidx.compose.ui.input.pointer.PointerIconDefaults
import androidx.compose.ui.input.pointer.pointerHoverIcon
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
@ -49,8 +49,11 @@ import app.ui.context_menu.branchContextMenuItems
import app.ui.context_menu.logContextMenu
import app.ui.context_menu.tagContextMenuItems
import app.ui.dialogs.*
import app.viewmodels.LogSearch
import app.viewmodels.LogStatus
import app.viewmodels.LogViewModel
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import org.eclipse.jgit.lib.Ref
import org.eclipse.jgit.lib.RepositoryState
import org.eclipse.jgit.revwalk.RevCommit
@ -66,12 +69,11 @@ private val colors = listOf(
private const val CANVAS_MIN_WIDTH = 100
private const val MIN_GRAPH_LINES = 2
private const val PADDING_BETWEEN_DIVIDER_AND_MESSAGE = 8
private const val DIVIDER_WIDTH = 8
// TODO Min size for message column
@OptIn(
ExperimentalFoundationApi::class,
ExperimentalComposeUiApi::class
ExperimentalFoundationApi::class, ExperimentalComposeUiApi::class
)
@Composable
fun Log(
@ -79,6 +81,7 @@ fun Log(
selectedItem: SelectedItem,
repositoryState: RepositoryState,
) {
val scope = rememberCoroutineScope()
val logStatusState = logViewModel.logStatus.collectAsState()
val logStatus = logStatusState.value
val showLogDialog = remember { mutableStateOf<LogDialog>(LogDialog.None) }
@ -93,7 +96,8 @@ fun Log(
val hasUncommitedChanges = logStatus.hasUncommitedChanges
val commitList = logStatus.plotCommitList
val verticalScrollState = rememberLazyListState()
val searchFilter = logViewModel.logSearchFilterResults.collectAsState()
val searchFilterValue = searchFilter.value
// With this method, whenever the scroll changes, the log is recomposed and the graph list is updated with
// the proper scroll position
verticalScrollState.observeScrollChanges()
@ -101,12 +105,13 @@ fun Log(
LaunchedEffect(selectedCommit) {
// Scroll to commit if a Ref is selected
if (selectedItem is SelectedItem.Ref) {
val index = commitList.indexOfFirst { it.name == selectedCommit?.name }
// TODO Show a message informing the user why we aren't scrolling
// Index can be -1 if the ref points to a commit that is not shown in the graph due to the limited
// number of displayed commits.
if (index >= 0)
verticalScrollState.scrollToItem(index)
scrollToCommit(verticalScrollState, commitList, selectedCommit)
}
}
LaunchedEffect(Unit) {
logViewModel.focusCommit.collect { commit ->
scrollToCommit(verticalScrollState, commitList, commit)
}
}
@ -118,19 +123,20 @@ fun Log(
)
Column(
modifier = Modifier
.background(MaterialTheme.colors.background)
.fillMaxSize()
modifier = Modifier.background(MaterialTheme.colors.background).fillMaxSize()
) {
val weightMod = remember { mutableStateOf(0f) }
var graphWidth = (CANVAS_MIN_WIDTH + weightMod.value).dp
if (graphWidth.value < CANVAS_MIN_WIDTH)
graphWidth = CANVAS_MIN_WIDTH.dp
if (graphWidth.value < CANVAS_MIN_WIDTH) graphWidth = CANVAS_MIN_WIDTH.dp
if (searchFilterValue is LogSearch.SearchResults) {
SearchFilter(logViewModel, searchFilterValue)
}
GraphHeader(
graphWidth = graphWidth,
weightMod = weightMod,
onShowSearch = { scope.launch { logViewModel.onSearchValueChanged("") } }
)
val horizontalScrollState = rememberScrollState(0)
@ -148,6 +154,7 @@ fun Log(
MessagesList(
scrollState = verticalScrollState,
hasUncommitedChanges = hasUncommitedChanges,
searchFilter = if (searchFilterValue is LogSearch.SearchResults) searchFilterValue.commits else null,
selectedCommit = selectedCommit,
logStatus = logStatus,
repositoryState = repositoryState,
@ -157,42 +164,147 @@ fun Log(
graphWidth = graphWidth,
onShowLogDialog = { dialog ->
showLogDialog.value = dialog
}
)
})
DividerLog(
modifier = Modifier
.draggable(
rememberDraggableState {
weightMod.value += it
},
Orientation.Horizontal
),
modifier = Modifier.draggable(
rememberDraggableState {
weightMod.value += it
}, Orientation.Horizontal
),
graphWidth = graphWidth,
)
// Scrollbar used to scroll horizontally the graph nodes
// Added after every component to have the highest priority when clicking
HorizontalScrollbar(
modifier = Modifier
.align(Alignment.BottomStart)
.width(graphWidth)
.padding(start = 4.dp, bottom = 4.dp),
style = LocalScrollbarStyle.current.copy(
modifier = Modifier.align(Alignment.BottomStart).width(graphWidth)
.padding(start = 4.dp, bottom = 4.dp), style = LocalScrollbarStyle.current.copy(
unhoverColor = MaterialTheme.colors.scrollbarUnhover,
hoverColor = MaterialTheme.colors.scrollbarHover,
),
adapter = rememberScrollbarAdapter(horizontalScrollState)
), adapter = rememberScrollbarAdapter(horizontalScrollState)
)
}
}
}
}
suspend fun scrollToCommit(
verticalScrollState: LazyListState,
commitList: GraphCommitList,
commit: RevCommit?,
) {
val index = commitList.indexOfFirst { it.name == commit?.name }
// TODO Show a message informing the user why we aren't scrolling
// Index can be -1 if the ref points to a commit that is not shown in the graph due to the limited
// number of displayed commits.
if (index >= 0) verticalScrollState.scrollToItem(index)
}
@Composable
fun SearchFilter(
logViewModel: LogViewModel,
searchFilterResults: LogSearch.SearchResults
) {
val scope = rememberCoroutineScope()
var searchFilterText by remember { mutableStateOf(logViewModel.savedSearchFilter) }
val textFieldFocusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) {
textFieldFocusRequester.requestFocus()
}
Row(
modifier = Modifier
.fillMaxWidth()
.height(64.dp)
.background(MaterialTheme.colors.graphHeaderBackground),
verticalAlignment = Alignment.CenterVertically,
) {
TextField(
value = searchFilterText,
onValueChange = {
searchFilterText = it
scope.launch {
logViewModel.onSearchValueChanged(it)
}
},
maxLines = 1,
modifier = Modifier
.fillMaxSize()
.focusRequester(textFieldFocusRequester)
.onPreviewKeyEvent {
when {
it.key == Key.Enter && it.type == KeyEventType.KeyUp-> {
scope.launch {
logViewModel.selectNextFilterCommit()
}
true
}
it.key == Key.Escape && it.type == KeyEventType.KeyUp -> {
logViewModel.closeSearch()
true
}
else -> false
}
},
label = {
Text("Search by message, author name or commit ID")
},
colors = TextFieldDefaults.textFieldColors(backgroundColor = MaterialTheme.colors.background),
textStyle = TextStyle.Default.copy(fontSize = 14.sp, color = MaterialTheme.colors.primaryTextColor),
trailingIcon = {
Row(
modifier = Modifier
.fillMaxHeight(),
verticalAlignment = Alignment.CenterVertically,
) {
if (searchFilterText.isNotEmpty()) {
Text(
"${searchFilterResults.index}/${searchFilterResults.totalCount}",
color = MaterialTheme.colors.secondaryTextColor,
)
}
IconButton(
modifier = Modifier
.pointerHoverIcon(PointerIconDefaults.Hand),
onClick = {
scope.launch { logViewModel.selectPreviousFilterCommit() }
}
) {
Icon(Icons.Default.KeyboardArrowUp, contentDescription = null)
}
IconButton(
modifier = Modifier
.pointerHoverIcon(PointerIconDefaults.Hand),
onClick = {
scope.launch { logViewModel.selectNextFilterCommit() }
}
) {
Icon(Icons.Default.KeyboardArrowDown, contentDescription = null)
}
IconButton(
modifier = Modifier
.pointerHoverIcon(PointerIconDefaults.Hand)
.padding(end = 4.dp),
onClick = { logViewModel.closeSearch() }
) {
Icon(Icons.Default.Clear, contentDescription = null)
}
}
}
)
}
}
@Composable
fun MessagesList(
scrollState: LazyListState,
hasUncommitedChanges: Boolean,
searchFilter: List<GraphNode>?,
selectedCommit: RevCommit?,
logStatus: LogStatus.Loaded,
repositoryState: RepositoryState,
@ -204,37 +316,32 @@ fun MessagesList(
) {
ScrollableLazyColumn(
state = scrollState,
modifier = Modifier
.fillMaxSize(),
modifier = Modifier.fillMaxSize(),
) {
if (hasUncommitedChanges)
item {
UncommitedChangesLine(
graphWidth = graphWidth,
selected = selectedItem == SelectedItem.UncommitedChanges,
statusSummary = logStatus.statusSummary,
repositoryState = repositoryState,
onUncommitedChangesSelected = {
logViewModel.selectLogLine(SelectedItem.UncommitedChanges)
}
)
}
if (hasUncommitedChanges) item {
UncommitedChangesLine(graphWidth = graphWidth,
selected = selectedItem == SelectedItem.UncommitedChanges,
statusSummary = logStatus.statusSummary,
repositoryState = repositoryState,
onUncommitedChangesSelected = {
logViewModel.selectUncommitedChanges()
})
}
items(items = commitList) { graphNode ->
CommitLine(
graphWidth = graphWidth,
CommitLine(graphWidth = graphWidth,
logViewModel = logViewModel,
graphNode = graphNode,
selected = selectedCommit?.name == graphNode.name,
currentBranch = logStatus.currentBranch,
matchesSearchFilter = searchFilter?.contains(graphNode),
showCreateNewBranch = { onShowLogDialog(LogDialog.NewBranch(graphNode)) },
showCreateNewTag = { onShowLogDialog(LogDialog.NewTag(graphNode)) },
resetBranch = { onShowLogDialog(LogDialog.ResetBranch(graphNode)) },
onMergeBranch = { ref -> onShowLogDialog(LogDialog.MergeBranch(ref)) },
onRebaseBranch = { ref -> onShowLogDialog(LogDialog.RebaseBranch(ref)) },
onRevCommitSelected = {
logViewModel.selectLogLine(SelectedItem.Commit(graphNode))
}
)
logViewModel.selectLogLine(graphNode)
})
}
}
}
@ -245,44 +352,32 @@ fun GraphList(
stateHorizontal: ScrollState = rememberScrollState(0),
graphWidth: Dp,
scrollState: LazyListState,
hasUncommitedChanges: Boolean
hasUncommitedChanges: Boolean,
) {
val graphRealWidth = remember(commitList, graphWidth) {
val maxLinePosition = if (commitList.isNotEmpty())
commitList.maxOf { it.lane.position }
else
MIN_GRAPH_LINES
val maxLinePosition = if (commitList.isNotEmpty()) commitList.maxOf { it.lane.position }
else MIN_GRAPH_LINES
((maxLinePosition + 2) * 30f).dp
}
Box(
Modifier
.width(graphWidth)
.fillMaxHeight()
Modifier.width(graphWidth).fillMaxHeight()
) {
Box(
modifier = Modifier
.fillMaxSize()
.horizontalScroll(stateHorizontal)
.padding(bottom = 8.dp)
modifier = Modifier.fillMaxSize().horizontalScroll(stateHorizontal).padding(bottom = 8.dp)
) {
LazyColumn(
state = scrollState,
modifier = Modifier
.width(graphRealWidth)
state = scrollState, modifier = Modifier.width(graphRealWidth)
) {
if (hasUncommitedChanges) {
item {
Row(
modifier = Modifier
.height(40.dp)
.fillMaxWidth(),
modifier = Modifier.height(40.dp).fillMaxWidth(),
) {
UncommitedChangesGraphNode(
modifier = Modifier
.width(graphWidth),
modifier = Modifier.width(graphWidth),
hasPreviousCommits = commitList.isNotEmpty(),
)
}
@ -292,13 +387,10 @@ fun GraphList(
items(items = commitList) { graphNode ->
val nodeColor = colors[graphNode.lane.position % colors.size]
Row(
modifier = Modifier
.height(40.dp)
.fillMaxWidth(),
modifier = Modifier.height(40.dp).fillMaxWidth(),
) {
CommitsGraphLine(
modifier = Modifier
.fillMaxHeight(),
modifier = Modifier.fillMaxHeight(),
plotCommit = graphNode,
nodeColor = nodeColor,
)
@ -318,53 +410,39 @@ fun LogDialogs(
) {
when (showLogDialog) {
is LogDialog.NewBranch -> {
NewBranchDialog(
onReject = onResetShowLogDialog,
onAccept = { branchName ->
logViewModel.createBranchOnCommit(branchName, showLogDialog.graphNode)
onResetShowLogDialog()
}
)
NewBranchDialog(onReject = onResetShowLogDialog, onAccept = { branchName ->
logViewModel.createBranchOnCommit(branchName, showLogDialog.graphNode)
onResetShowLogDialog()
})
}
is LogDialog.NewTag -> {
NewTagDialog(
onReject = onResetShowLogDialog,
onAccept = { tagName ->
logViewModel.createTagOnCommit(tagName, showLogDialog.graphNode)
onResetShowLogDialog()
}
)
NewTagDialog(onReject = onResetShowLogDialog, onAccept = { tagName ->
logViewModel.createTagOnCommit(tagName, showLogDialog.graphNode)
onResetShowLogDialog()
})
}
is LogDialog.MergeBranch -> {
if (currentBranch != null)
MergeDialog(
currentBranchName = currentBranch.simpleName,
mergeBranchName = showLogDialog.ref.simpleName,
onReject = onResetShowLogDialog,
onAccept = { ff ->
logViewModel.mergeBranch(showLogDialog.ref, ff)
onResetShowLogDialog()
}
)
if (currentBranch != null) MergeDialog(currentBranchName = currentBranch.simpleName,
mergeBranchName = showLogDialog.ref.simpleName,
onReject = onResetShowLogDialog,
onAccept = { ff ->
logViewModel.mergeBranch(showLogDialog.ref, ff)
onResetShowLogDialog()
})
}
is LogDialog.ResetBranch -> ResetBranchDialog(
onReject = onResetShowLogDialog,
onAccept = { resetType ->
logViewModel.resetToCommit(showLogDialog.graphNode, resetType)
onResetShowLogDialog()
}
)
is LogDialog.ResetBranch -> ResetBranchDialog(onReject = onResetShowLogDialog, onAccept = { resetType ->
logViewModel.resetToCommit(showLogDialog.graphNode, resetType)
onResetShowLogDialog()
})
is LogDialog.RebaseBranch -> {
if (currentBranch != null) {
RebaseDialog(
currentBranchName = currentBranch.simpleName,
RebaseDialog(currentBranchName = currentBranch.simpleName,
rebaseBranchName = showLogDialog.ref.simpleName,
onReject = onResetShowLogDialog,
onAccept = {
logViewModel.rebaseBranch(showLogDialog.ref)
onResetShowLogDialog()
}
)
})
}
}
LogDialog.None -> {
@ -376,43 +454,51 @@ fun LogDialogs(
fun GraphHeader(
graphWidth: Dp,
weightMod: MutableState<Float>,
onShowSearch: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(32.dp)
.background(MaterialTheme.colors.graphHeaderBackground),
verticalAlignment = Alignment.CenterVertically,
Box(
modifier = Modifier.fillMaxWidth()
) {
Text(
modifier = Modifier
.width(graphWidth)
.padding(start = 8.dp),
text = "Graph",
color = MaterialTheme.colors.headerText,
fontSize = 14.sp,
maxLines = 1,
)
Row(
modifier = Modifier.fillMaxWidth().height(48.dp).background(MaterialTheme.colors.graphHeaderBackground),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
modifier = Modifier.width(graphWidth).padding(start = 8.dp),
text = "Graph",
color = MaterialTheme.colors.headerText,
fontSize = 14.sp,
maxLines = 1,
)
SimpleDividerLog(
modifier = Modifier
.draggable(
SimpleDividerLog(
modifier = Modifier.draggable(
rememberDraggableState {
weightMod.value += it
},
Orientation.Horizontal
}, Orientation.Horizontal
),
)
)
Text(
modifier = Modifier
.padding(start = 8.dp)
.width(graphWidth),
text = "Message",
color = MaterialTheme.colors.headerText,
fontSize = 14.sp,
maxLines = 1,
)
Text(
modifier = Modifier.padding(start = 8.dp).weight(1f),
text = "Message",
color = MaterialTheme.colors.headerText,
fontSize = 14.sp,
maxLines = 1,
)
IconButton(
modifier = Modifier.padding(end = 8.dp),
onClick = onShowSearch
) {
Icon(
Icons.Default.Search,
modifier = Modifier.size(24.dp),
contentDescription = null,
tint = MaterialTheme.colors.primaryTextColor,
)
}
}
}
}
@ -426,20 +512,15 @@ fun UncommitedChangesLine(
) {
val textColor = if (selected) {
MaterialTheme.colors.primary
} else
MaterialTheme.colors.primaryTextColor
} else MaterialTheme.colors.primaryTextColor
Row(
modifier = Modifier
.height(40.dp)
.fillMaxWidth()
.clickable {
onUncommitedChangesSelected()
}
.padding(
start = graphWidth + PADDING_BETWEEN_DIVIDER_AND_MESSAGE.dp,
end = 4.dp,
),
modifier = Modifier.height(40.dp).fillMaxWidth().clickable {
onUncommitedChangesSelected()
}.padding(
start = graphWidth + DIVIDER_WIDTH.dp,
end = 4.dp,
),
verticalAlignment = Alignment.CenterVertically,
) {
val text = when {
@ -499,9 +580,7 @@ fun LogStatusSummary(statusSummary: StatusSummary, modifier: Modifier) {
@Composable
fun SummaryEntry(
count: Int,
icon: ImageVector,
color: Color
count: Int, icon: ImageVector, color: Color
) {
Row(
modifier = Modifier.padding(horizontal = 4.dp),
@ -514,14 +593,12 @@ fun SummaryEntry(
)
Icon(
imageVector = icon,
tint = color,
contentDescription = null,
modifier = Modifier.size(14.dp)
imageVector = icon, tint = color, contentDescription = null, modifier = Modifier.size(14.dp)
)
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun CommitLine(
graphWidth: Dp,
@ -529,6 +606,7 @@ fun CommitLine(
graphNode: GraphNode,
selected: Boolean,
currentBranch: Ref?,
matchesSearchFilter: Boolean?,
showCreateNewBranch: () -> Unit,
showCreateNewTag: () -> Unit,
resetBranch: (GraphNode) -> Unit,
@ -548,25 +626,35 @@ fun CommitLine(
)
},
) {
Box(modifier = Modifier
.clickable {
Box(
modifier = Modifier.clickable {
onRevCommitSelected(graphNode)
}
.padding(start = graphWidth + PADDING_BETWEEN_DIVIDER_AND_MESSAGE.dp)
}.padding(start = graphWidth)
.height(40.dp)
) {
if (matchesSearchFilter == true) {
Box(
modifier = Modifier
.padding(start = DIVIDER_WIDTH.dp)
.background(MaterialTheme.colors.secondary)
.fillMaxHeight()
.width(4.dp)
)
}
Row(
modifier = Modifier
.height(40.dp)
.fillMaxWidth()
.padding(end = 4.dp),
) {
val nodeColor = colors[graphNode.lane.position % colors.size]
CommitMessage(
modifier = Modifier.weight(1f),
commit = graphNode,
selected = selected,
refs = graphNode.refs,
nodeColor = nodeColor,
matchesSearchFilter = matchesSearchFilter,
currentBranch = currentBranch,
onCheckoutRef = { ref -> logViewModel.checkoutRef(ref) },
onMergeBranch = { ref -> onMergeBranch(ref) },
@ -583,12 +671,12 @@ fun CommitLine(
@Composable
fun CommitMessage(
modifier: Modifier = Modifier,
commit: RevCommit,
selected: Boolean,
refs: List<Ref>,
currentBranch: Ref?,
nodeColor: Color,
matchesSearchFilter: Boolean?,
onCheckoutRef: (ref: Ref) -> Unit,
onMergeBranch: (ref: Ref) -> Unit,
onDeleteBranch: (ref: Ref) -> Unit,
@ -599,78 +687,68 @@ fun CommitMessage(
) {
val textColor = if (selected) {
MaterialTheme.colors.primary
} else
MaterialTheme.colors.primaryTextColor
} else MaterialTheme.colors.primaryTextColor
val secondaryTextColor = if (selected) {
MaterialTheme.colors.primary
} else
MaterialTheme.colors.secondaryTextColor
} else MaterialTheme.colors.secondaryTextColor
Column(
modifier = modifier
Row(
modifier = Modifier.fillMaxSize(),
verticalAlignment = Alignment.CenterVertically,
) {
Spacer(modifier = Modifier.weight(2f))
Row(
modifier = Modifier
.fillMaxSize(),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(start = 16.dp)
) {
refs
.sortedWith { ref1, ref2 ->
if (ref1.isSameBranch(currentBranch)) {
-1
} else {
ref1.name.compareTo(ref2.name)
}
refs.sortedWith { ref1, ref2 ->
if (ref1.isSameBranch(currentBranch)) {
-1
} else {
ref1.name.compareTo(ref2.name)
}
.forEach { ref ->
if (ref.isTag) {
TagChip(
ref = ref,
color = nodeColor,
onCheckoutTag = { onCheckoutRef(ref) },
onDeleteTag = { onDeleteTag(ref) },
)
} else if (ref.isBranch) {
BranchChip(
ref = ref,
color = nodeColor,
currentBranch = currentBranch,
isCurrentBranch = ref.isSameBranch(currentBranch),
onCheckoutBranch = { onCheckoutRef(ref) },
onMergeBranch = { onMergeBranch(ref) },
onDeleteBranch = { onDeleteBranch(ref) },
onRebaseBranch = { onRebaseBranch(ref) },
onPullRemoteBranch = { onPullRemoteBranch(ref) },
onPushRemoteBranch = { onPushRemoteBranch(ref) },
)
}
}.forEach { ref ->
if (ref.isTag) {
TagChip(
ref = ref,
color = nodeColor,
onCheckoutTag = { onCheckoutRef(ref) },
onDeleteTag = { onDeleteTag(ref) },
)
} else if (ref.isBranch) {
BranchChip(
ref = ref,
color = nodeColor,
currentBranch = currentBranch,
isCurrentBranch = ref.isSameBranch(currentBranch),
onCheckoutBranch = { onCheckoutRef(ref) },
onMergeBranch = { onMergeBranch(ref) },
onDeleteBranch = { onDeleteBranch(ref) },
onRebaseBranch = { onRebaseBranch(ref) },
onPullRemoteBranch = { onPullRemoteBranch(ref) },
onPushRemoteBranch = { onPushRemoteBranch(ref) },
)
}
Text(
text = commit.shortMessage,
modifier = Modifier.padding(start = 16.dp),
fontSize = 14.sp,
color = textColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Spacer(modifier = Modifier.weight(2f))
Text(
text = commit.committerIdent.`when`.toSmartSystemString(),
modifier = Modifier.padding(horizontal = 16.dp),
fontSize = 12.sp,
color = secondaryTextColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
Text(
text = commit.shortMessage,
modifier = Modifier.padding(start = 8.dp),
fontSize = 14.sp,
color = if (matchesSearchFilter == false) secondaryTextColor else textColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Spacer(modifier = Modifier.weight(2f))
}
Text(
text = commit.committerIdent.`when`.toSmartSystemString(),
modifier = Modifier.padding(horizontal = 16.dp),
fontSize = 12.sp,
color = secondaryTextColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
@OptIn(ExperimentalComposeUiApi::class)
@ -679,15 +757,12 @@ fun DividerLog(modifier: Modifier, graphWidth: Dp) {
Box(
modifier = Modifier
.padding(start = graphWidth)
.width(8.dp)
.width(DIVIDER_WIDTH.dp)
.then(modifier)
.pointerHoverIcon(PointerIconDefaults.Hand)
) {
Box(
modifier = Modifier
.fillMaxHeight()
.width(1.dp)
.background(color = MaterialTheme.colors.primary)
modifier = Modifier.fillMaxHeight().width(1.dp).background(color = MaterialTheme.colors.primary)
.align(Alignment.Center)
)
}
@ -714,8 +789,7 @@ fun CommitsGraphLine(
val itemPosition = plotCommit.lane.position
Canvas(
modifier = Modifier
.fillMaxSize()
modifier = Modifier.fillMaxSize()
) {
clipRect {
if (plotCommit.childCount > 0) {
@ -761,9 +835,7 @@ fun CommitsGraphLine(
}
CommitNode(
modifier = Modifier
.align(Alignment.CenterStart)
.padding(start = ((itemPosition + 1) * 30 - 15).dp),
modifier = Modifier.align(Alignment.CenterStart).padding(start = ((itemPosition + 1) * 30 - 15).dp),
plotCommit = plotCommit,
color = nodeColor,
)
@ -777,10 +849,7 @@ fun CommitNode(
color: Color,
) {
Box(
modifier = modifier
.size(30.dp)
.border(2.dp, color, shape = CircleShape)
.clip(CircleShape)
modifier = modifier.size(30.dp).border(2.dp, color, shape = CircleShape).clip(CircleShape)
) {
AvatarImage(
modifier = Modifier.fillMaxSize(),
@ -797,17 +866,15 @@ fun UncommitedChangesGraphNode(
) {
Box(modifier = modifier) {
Canvas(
modifier = Modifier
.fillMaxSize()
modifier = Modifier.fillMaxSize()
) {
clipRect {
if (hasPreviousCommits)
drawLine(
color = colors[0],
start = Offset(30f, this.center.y),
end = Offset(30f, this.size.height),
)
if (hasPreviousCommits) drawLine(
color = colors[0],
start = Offset(30f, this.center.y),
end = Offset(30f, this.size.height),
)
drawCircle(
color = colors[0],
@ -910,14 +977,9 @@ fun RefChip(
endingContent: @Composable () -> Unit = {},
) {
Box(
modifier = Modifier
.padding(horizontal = 4.dp)
.clip(RoundedCornerShape(16.dp))
modifier = Modifier.padding(horizontal = 4.dp).clip(RoundedCornerShape(16.dp))
.border(width = 2.dp, color = color, shape = RoundedCornerShape(16.dp))
.combinedClickable(
onDoubleClick = onCheckoutRef,
onClick = {}
)
.combinedClickable(onDoubleClick = onCheckoutRef, onClick = {})
) {
ContextMenuArea(
items = contextMenuItemsList
@ -928,9 +990,7 @@ fun RefChip(
) {
Box(modifier = Modifier.background(color = color)) {
Icon(
modifier = Modifier
.padding(6.dp)
.size(14.dp),
modifier = Modifier.padding(6.dp).size(14.dp),
painter = painterResource(icon),
contentDescription = null,
tint = MaterialTheme.colors.inversePrimaryTextColor,
@ -941,8 +1001,7 @@ fun RefChip(
color = MaterialTheme.colors.primaryTextColor,
fontSize = 13.sp,
maxLines = 1,
modifier = Modifier
.padding(horizontal = 6.dp)
modifier = Modifier.padding(horizontal = 6.dp)
)
endingContent()

View File

@ -1,16 +1,29 @@
package app.viewmodels
import app.extensions.simpleName
import app.git.*
import app.git.graph.GraphCommitList
import app.git.graph.GraphNode
import app.ui.SelectedItem
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import org.eclipse.jgit.api.Git
import org.eclipse.jgit.lib.Ref
import org.eclipse.jgit.revwalk.RevCommit
import javax.inject.Inject
/**
* Represents when the search filter is not being used or the results list is empty
*/
private const val NONE_MATCHING_INDEX = 0
/**
* The search UI starts the index count at 1 (for example "1/10" to represent the first commit of the search result
* being selected)
*/
private const val FIRST_INDEX = 1
class LogViewModel @Inject constructor(
private val logManager: LogManager,
private val statusManager: StatusManager,
@ -27,6 +40,15 @@ class LogViewModel @Inject constructor(
val logStatus: StateFlow<LogStatus>
get() = _logStatus
var savedSearchFilter: String = ""
private val _focusCommit = MutableSharedFlow<GraphNode>()
val focusCommit: SharedFlow<GraphNode> = _focusCommit
private val _logSearchFilterResults = MutableStateFlow<LogSearch>(LogSearch.NotSearching)
val logSearchFilterResults: StateFlow<LogSearch> = _logSearchFilterResults
private suspend fun loadLog(git: Git) {
_logStatus.value = LogStatus.Loading
@ -38,13 +60,18 @@ class LogViewModel @Inject constructor(
repositoryState = repositoryManager.getRepositoryState(git),
)
val hasUncommitedChanges = statusSummary.addedCount + statusSummary.deletedCount + statusSummary.modifiedCount > 0
val hasUncommitedChanges =
statusSummary.addedCount + statusSummary.deletedCount + statusSummary.modifiedCount > 0
val log = logManager.loadLog(git, currentBranch, hasUncommitedChanges)
_logStatus.value = LogStatus.Loaded(hasUncommitedChanges, log, currentBranch, statusSummary)
// Remove search filter if the log has been updated
// TODO: Should we just update the search instead of closing it?
_logSearchFilterResults.value = LogSearch.NotSearching
}
fun pushToRemoteBranch(branch: Ref) = tabState.safeProcessing(
fun pushToRemoteBranch(branch: Ref) = tabState.safeProcessing(
refreshType = RefreshType.ALL_DATA,
) { git ->
remoteOperationsManager.pushToBranch(
@ -55,7 +82,7 @@ class LogViewModel @Inject constructor(
)
}
fun pullFromRemoteBranch(branch: Ref) = tabState.safeProcessing(
fun pullFromRemoteBranch(branch: Ref) = tabState.safeProcessing(
refreshType = RefreshType.ALL_DATA,
) { git ->
remoteOperationsManager.pullFromBranch(
@ -166,8 +193,117 @@ class LogViewModel @Inject constructor(
rebaseManager.rebaseBranch(git, ref)
}
fun selectLogLine(selectedItem: SelectedItem) {
tabState.newSelectedItem(selectedItem)
fun selectUncommitedChanges() {
tabState.newSelectedItem(SelectedItem.UncommitedChanges)
val searchValue = _logSearchFilterResults.value
if (searchValue is LogSearch.SearchResults) {
val lastIndexSelected = getLastIndexSelected()
_logSearchFilterResults.value = searchValue.copy(index = lastIndexSelected)
}
}
private fun getLastIndexSelected(): Int {
val logSearchFilterResultsValue = logSearchFilterResults.value
return if (logSearchFilterResultsValue is LogSearch.SearchResults) {
logSearchFilterResultsValue.index
} else
NONE_MATCHING_INDEX
}
fun selectLogLine(commit: GraphNode) {
tabState.newSelectedItem(SelectedItem.Commit(commit))
val searchValue = _logSearchFilterResults.value
if (searchValue is LogSearch.SearchResults) {
var index = searchValue.commits.indexOf(commit)
if (index == -1)
index = getLastIndexSelected()
else
index += 1 // +1 because UI count starts at 1
_logSearchFilterResults.value = searchValue.copy(index = index)
}
}
suspend fun onSearchValueChanged(searchTerm: String) {
val logStatusValue = logStatus.value
if (logStatusValue !is LogStatus.Loaded)
return
savedSearchFilter = searchTerm
if (searchTerm.isNotBlank()) {
val lowercaseValue = searchTerm.lowercase()
val plotCommitList = logStatusValue.plotCommitList
val matchingCommits = plotCommitList.filter {
it.fullMessage.lowercase().contains(lowercaseValue) ||
it.authorIdent.name.lowercase().contains(lowercaseValue) ||
it.committerIdent.name.lowercase().contains(lowercaseValue) ||
it.name.lowercase().contains(lowercaseValue)
}
var startingUiIndex = NONE_MATCHING_INDEX
if (matchingCommits.isNotEmpty()) {
_focusCommit.emit(matchingCommits.first())
startingUiIndex = FIRST_INDEX
}
_logSearchFilterResults.value = LogSearch.SearchResults(matchingCommits, startingUiIndex)
} else
_logSearchFilterResults.value = LogSearch.SearchResults(emptyList(), NONE_MATCHING_INDEX)
}
suspend fun selectPreviousFilterCommit() {
val logSearchFilterResultsValue = logSearchFilterResults.value
if(logSearchFilterResultsValue !is LogSearch.SearchResults) {
return
}
val index = logSearchFilterResultsValue.index
val commits = logSearchFilterResultsValue.commits
if(index == NONE_MATCHING_INDEX || index == FIRST_INDEX)
return
val newIndex = index - 1
val newCommitToSelect = commits[newIndex - 1]
_logSearchFilterResults.value = logSearchFilterResultsValue.copy(index = newIndex)
_focusCommit.emit(newCommitToSelect)
}
suspend fun selectNextFilterCommit() {
val logSearchFilterResultsValue = logSearchFilterResults.value
if(logSearchFilterResultsValue !is LogSearch.SearchResults) {
return
}
val index = logSearchFilterResultsValue.index
val commits = logSearchFilterResultsValue.commits
val totalCount = logSearchFilterResultsValue.totalCount
if(index == NONE_MATCHING_INDEX || index == totalCount)
return
val newIndex = index + 1
// Use index instead of newIndex because Kotlin arrays start at 0 while the UI count starts at 1
val newCommitToSelect = commits[index]
_logSearchFilterResults.value = logSearchFilterResultsValue.copy(index = newIndex)
_focusCommit.emit(newCommitToSelect)
}
fun closeSearch() {
_logSearchFilterResults.value = LogSearch.NotSearching
}
}
@ -180,3 +316,12 @@ sealed class LogStatus {
val statusSummary: StatusSummary,
) : LogStatus()
}
sealed class LogSearch {
object NotSearching : LogSearch()
data class SearchResults(
val commits: List<GraphNode>,
val index: Int,
val totalCount: Int = commits.count(),
) : LogSearch()
}

View File

@ -22,14 +22,7 @@ class StatusViewModel @Inject constructor(
private val _stageStatus = MutableStateFlow<StageStatus>(StageStatus.Loaded(listOf(), listOf()))
val stageStatus: StateFlow<StageStatus> = _stageStatus
private val _commitMessage = MutableStateFlow("")
val commitMessage: StateFlow<String> = _commitMessage
var newCommitMessage: String
get() = commitMessage.value
set(value) {
_commitMessage.value = value
}
var savedCommitMessage: String = ""
var hasPreviousCommits = true // When false, disable "amend previous commit"
private var lastUncommitedChangesState = false