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

View File

@ -1,16 +1,29 @@
package app.viewmodels package app.viewmodels
import app.extensions.simpleName
import app.git.* import app.git.*
import app.git.graph.GraphCommitList import app.git.graph.GraphCommitList
import app.git.graph.GraphNode
import app.ui.SelectedItem import app.ui.SelectedItem
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import org.eclipse.jgit.api.Git import org.eclipse.jgit.api.Git
import org.eclipse.jgit.lib.Ref import org.eclipse.jgit.lib.Ref
import org.eclipse.jgit.revwalk.RevCommit import org.eclipse.jgit.revwalk.RevCommit
import javax.inject.Inject 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( class LogViewModel @Inject constructor(
private val logManager: LogManager, private val logManager: LogManager,
private val statusManager: StatusManager, private val statusManager: StatusManager,
@ -27,6 +40,15 @@ class LogViewModel @Inject constructor(
val logStatus: StateFlow<LogStatus> val logStatus: StateFlow<LogStatus>
get() = _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) { private suspend fun loadLog(git: Git) {
_logStatus.value = LogStatus.Loading _logStatus.value = LogStatus.Loading
@ -38,13 +60,18 @@ class LogViewModel @Inject constructor(
repositoryState = repositoryManager.getRepositoryState(git), 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) val log = logManager.loadLog(git, currentBranch, hasUncommitedChanges)
_logStatus.value = LogStatus.Loaded(hasUncommitedChanges, log, currentBranch, statusSummary) _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, refreshType = RefreshType.ALL_DATA,
) { git -> ) { git ->
remoteOperationsManager.pushToBranch( 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, refreshType = RefreshType.ALL_DATA,
) { git -> ) { git ->
remoteOperationsManager.pullFromBranch( remoteOperationsManager.pullFromBranch(
@ -166,8 +193,117 @@ class LogViewModel @Inject constructor(
rebaseManager.rebaseBranch(git, ref) rebaseManager.rebaseBranch(git, ref)
} }
fun selectLogLine(selectedItem: SelectedItem) { fun selectUncommitedChanges() {
tabState.newSelectedItem(selectedItem) 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
} }
} }
@ -179,4 +315,13 @@ sealed class LogStatus {
val currentBranch: Ref?, val currentBranch: Ref?,
val statusSummary: StatusSummary, val statusSummary: StatusSummary,
) : LogStatus() ) : 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())) private val _stageStatus = MutableStateFlow<StageStatus>(StageStatus.Loaded(listOf(), listOf()))
val stageStatus: StateFlow<StageStatus> = _stageStatus val stageStatus: StateFlow<StageStatus> = _stageStatus
private val _commitMessage = MutableStateFlow("") var savedCommitMessage: String = ""
val commitMessage: StateFlow<String> = _commitMessage
var newCommitMessage: String
get() = commitMessage.value
set(value) {
_commitMessage.value = value
}
var hasPreviousCommits = true // When false, disable "amend previous commit" var hasPreviousCommits = true // When false, disable "amend previous commit"
private var lastUncommitedChangesState = false private var lastUncommitedChangesState = false