Added option to sign off commits

Fixes #60
This commit is contained in:
Abdelilah El Aissaoui 2023-05-01 16:20:11 +02:00
parent 07e1bbd4ed
commit ddc198a0d7
No known key found for this signature in database
GPG Key ID: 7587FC860F594869
15 changed files with 360 additions and 53 deletions

View File

@ -42,6 +42,7 @@ object AppIcons {
const val REVERT = "revert.svg"
const val SEARCH = "search.svg"
const val SETTINGS = "settings.svg"
const val SIGN = "sign.svg"
const val SOURCE = "source.svg"
const val START = "start.svg"
const val STASH = "stash.svg"

View File

@ -0,0 +1,35 @@
package com.jetpackduba.gitnuro.git.config
import com.jetpackduba.gitnuro.extensions.nullIfEmpty
import com.jetpackduba.gitnuro.models.SignOffConfig
import org.eclipse.jgit.lib.Repository
import org.eclipse.jgit.storage.file.FileBasedConfig
import java.io.File
import javax.inject.Inject
class LoadSignOffConfigUseCase @Inject constructor() {
operator fun invoke(repository: Repository): SignOffConfig {
val configFile = File(repository.directory, LocalConfigConstants.CONFIG_FILE_NAME)
configFile.createNewFile()
val config = FileBasedConfig(configFile, repository.fs)
config.load()
val enabled = config.getBoolean(
LocalConfigConstants.SignOff.SECTION,
null,
LocalConfigConstants.SignOff.FIELD_ENABLED,
LocalConfigConstants.SignOff.DEFAULT_SIGN_OFF_ENABLED
)
val format = config.getString(
LocalConfigConstants.SignOff.SECTION,
null,
LocalConfigConstants.SignOff.FIELD_FORMAT
)?.nullIfEmpty ?: LocalConfigConstants.SignOff.DEFAULT_SIGN_OFF_FORMAT
return SignOffConfig(enabled, format)
}
}

View File

@ -0,0 +1,16 @@
package com.jetpackduba.gitnuro.git.config
object LocalConfigConstants {
const val CONFIG_FILE_NAME = "gitnuro"
object SignOff {
const val SECTION = "signoff"
const val FIELD_ENABLED = "enabled"
const val FIELD_FORMAT = "format"
const val DEFAULT_SIGN_OFF_FORMAT_USER = "%user"
const val DEFAULT_SIGN_OFF_FORMAT_EMAIL = "%email"
const val DEFAULT_SIGN_OFF_FORMAT = "Signed-off-by: $DEFAULT_SIGN_OFF_FORMAT_USER <$DEFAULT_SIGN_OFF_FORMAT_EMAIL>"
const val DEFAULT_SIGN_OFF_ENABLED = false
}
}

View File

@ -0,0 +1,35 @@
package com.jetpackduba.gitnuro.git.config
import com.jetpackduba.gitnuro.models.SignOffConfig
import org.eclipse.jgit.lib.Repository
import org.eclipse.jgit.storage.file.FileBasedConfig
import java.io.File
import javax.inject.Inject
class SaveLocalRepositoryConfigUseCase @Inject constructor() {
operator fun invoke(
repository: Repository,
signOffConfig: SignOffConfig,
) {
val configFile = File(repository.directory, LocalConfigConstants.CONFIG_FILE_NAME)
configFile.createNewFile()
val config = FileBasedConfig(configFile, repository.fs)
config.setBoolean(
LocalConfigConstants.SignOff.SECTION,
null,
LocalConfigConstants.SignOff.FIELD_ENABLED,
signOffConfig.isEnabled
)
config.setString(
LocalConfigConstants.SignOff.SECTION,
null,
LocalConfigConstants.SignOff.FIELD_FORMAT,
signOffConfig.format
)
config.save()
}
}

View File

@ -1,5 +1,8 @@
package com.jetpackduba.gitnuro.git.workspace
import com.jetpackduba.gitnuro.git.author.LoadAuthorUseCase
import com.jetpackduba.gitnuro.git.config.LoadSignOffConfigUseCase
import com.jetpackduba.gitnuro.git.config.LocalConfigConstants
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.eclipse.jgit.api.Git
@ -7,15 +10,31 @@ import org.eclipse.jgit.lib.PersonIdent
import org.eclipse.jgit.revwalk.RevCommit
import javax.inject.Inject
class DoCommitUseCase @Inject constructor() {
class DoCommitUseCase @Inject constructor(
private val loadSignOffConfigUseCase: LoadSignOffConfigUseCase,
private val loadAuthorUseCase: LoadAuthorUseCase,
) {
suspend operator fun invoke(
git: Git,
message: String,
amend: Boolean,
author: PersonIdent?,
): RevCommit = withContext(Dispatchers.IO) {
val signOffConfig = loadSignOffConfigUseCase(git.repository)
val finalMessage = if(signOffConfig.isEnabled) {
val authorToSign = author ?: loadAuthorUseCase(git).toPersonIdent()
val signature = signOffConfig.format
.replace(LocalConfigConstants.SignOff.DEFAULT_SIGN_OFF_FORMAT_USER, authorToSign.name)
.replace(LocalConfigConstants.SignOff.DEFAULT_SIGN_OFF_FORMAT_EMAIL, authorToSign.emailAddress)
"$message\n\n$signature"
} else
message
git.commit()
.setMessage(message)
.setMessage(finalMessage)
.setAllowEmpty(amend) // Only allow empty commits when amending
.setAmend(amend)
.setAuthor(author)

View File

@ -1,8 +1,16 @@
package com.jetpackduba.gitnuro.models
import org.eclipse.jgit.lib.PersonIdent
data class AuthorInfo(
val globalName: String?,
val globalEmail: String?,
val name: String?,
val email: String?,
)
) {
fun toPersonIdent() = PersonIdent(
name ?: globalName ?: "",
email ?: globalEmail ?: "",
)
}

View File

@ -0,0 +1,6 @@
package com.jetpackduba.gitnuro.models
data class SignOffConfig(
val isEnabled: Boolean,
val format: String,
)

View File

@ -24,6 +24,7 @@ import com.jetpackduba.gitnuro.git.DiffEntryType
import com.jetpackduba.gitnuro.keybindings.KeybindingOption
import com.jetpackduba.gitnuro.keybindings.matchesBinding
import com.jetpackduba.gitnuro.ui.components.PrimaryButton
import com.jetpackduba.gitnuro.ui.components.gitnuroDynamicViewModel
import com.jetpackduba.gitnuro.ui.dialogs.*
import com.jetpackduba.gitnuro.ui.diff.Diff
import com.jetpackduba.gitnuro.ui.log.Log
@ -53,6 +54,7 @@ fun RepositoryOpenPage(
var showNewBranchDialog by remember { mutableStateOf(false) }
var showStashWithMessageDialog by remember { mutableStateOf(false) }
var showQuickActionsDialog by remember { mutableStateOf(false) }
var showSignOffDialog by remember { mutableStateOf(false) }
if (showNewBranchDialog) {
NewBranchDialog(
@ -93,9 +95,15 @@ fun RepositoryOpenPage(
QuickActionType.OPEN_DIR_IN_FILE_MANAGER -> tabViewModel.openFolderInFileExplorer()
QuickActionType.CLONE -> onShowCloneDialog()
QuickActionType.REFRESH -> tabViewModel.refreshAll()
QuickActionType.SIGN_OFF -> showSignOffDialog = true
}
},
)
} else if (showSignOffDialog) {
SignOffDialog(
viewModel = gitnuroDynamicViewModel(),
onClose = { showSignOffDialog = false },
)
}
val focusRequester = remember { FocusRequester() }

View File

@ -15,6 +15,7 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CornerSize
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowDropDown
@ -43,10 +44,7 @@ import com.jetpackduba.gitnuro.keybindings.KeybindingOption
import com.jetpackduba.gitnuro.keybindings.matchesBinding
import com.jetpackduba.gitnuro.theme.*
import com.jetpackduba.gitnuro.ui.components.*
import com.jetpackduba.gitnuro.ui.context_menu.ContextMenu
import com.jetpackduba.gitnuro.ui.context_menu.ContextMenuElement
import com.jetpackduba.gitnuro.ui.context_menu.EntryType
import com.jetpackduba.gitnuro.ui.context_menu.statusEntriesContextMenuItems
import com.jetpackduba.gitnuro.ui.context_menu.*
import com.jetpackduba.gitnuro.ui.dialogs.CommitAuthorDialog
import com.jetpackduba.gitnuro.viewmodels.CommitterDataRequestState
import com.jetpackduba.gitnuro.viewmodels.StageState
@ -306,7 +304,7 @@ fun UncommitedChanges(
onAmendChecked = { isAmend ->
statusViewModel.amend(isAmend)
},
onCommit = { doCommit() },
onCommit = doCommit,
)
}
}
@ -322,8 +320,6 @@ fun UncommitedChangesButtons(
onAmendChecked: (Boolean) -> Unit,
onCommit: () -> Unit
) {
var showDropDownMenu by remember { mutableStateOf(false) }
val buttonText = if (isAmend)
"Amend"
else
@ -368,47 +364,8 @@ fun UncommitedChangesButtons(
onCommit()
},
enabled = canCommit || (canAmend && isAmend),
shape = MaterialTheme.shapes.small.copy(topEnd = CornerSize(0.dp), bottomEnd = CornerSize(0.dp))
shape = RoundedCornerShape(4.dp)
)
Spacer(
modifier = Modifier
.width(1.dp)
.height(36.dp),
)
Box(
modifier = Modifier
.height(36.dp)
.clip(MaterialTheme.shapes.small.copy(topStart = CornerSize(0.dp), bottomStart = CornerSize(0.dp)))
.background(MaterialTheme.colors.primary)
.handMouseClickable { showDropDownMenu = true }
) {
Icon(
Icons.Default.ArrowDropDown,
contentDescription = null,
tint = Color.White,
modifier = Modifier
.padding(horizontal = 8.dp)
.align(Alignment.Center),
)
DropdownMenu(
onDismissRequest = {
showDropDownMenu = false
},
content = {
/*DropDownContent(
enabled = canAmend,
dropDownContentData = DropDownContentData(
label = "Amend previous commit",
icon = null,
onClick = onCommit
),
onDismiss = { showDropDownMenu = false }
)*/
},
expanded = showDropDownMenu,
)
}
}
}
}

View File

@ -26,13 +26,13 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.jetpackduba.gitnuro.AppIcons
import com.jetpackduba.gitnuro.managers.AppStateManager
import com.jetpackduba.gitnuro.LocalTabScope
import com.jetpackduba.gitnuro.di.AppComponent
import com.jetpackduba.gitnuro.di.DaggerTabComponent
import com.jetpackduba.gitnuro.di.TabComponent
import com.jetpackduba.gitnuro.extensions.handMouseClickable
import com.jetpackduba.gitnuro.extensions.handOnHover
import com.jetpackduba.gitnuro.managers.AppStateManager
import com.jetpackduba.gitnuro.viewmodels.TabViewModel
import com.jetpackduba.gitnuro.viewmodels.TabViewModelsHolder
import kotlinx.coroutines.delay
@ -264,9 +264,12 @@ inline fun <reified T> gitnuroViewModel(): T {
tab.tabViewModelsHolder.viewModels[T::class] as T
}
}
@Composable
inline fun <reified T> gitnuroDynamicViewModel(): T {
val tab = LocalTabScope.current
return tab.tabViewModelsHolder.dynamicViewModel(T::class) as T
return remember(tab) {
tab.tabViewModelsHolder.dynamicViewModel(T::class) as T
}
}

View File

@ -36,6 +36,7 @@ fun QuickActionsDialog(
QuickAction(AppIcons.CODE, "Open repository in file manager", QuickActionType.OPEN_DIR_IN_FILE_MANAGER),
QuickAction(AppIcons.DOWNLOAD, "Clone new repository", QuickActionType.CLONE),
QuickAction(AppIcons.REFRESH, "Refresh repository data", QuickActionType.REFRESH),
QuickAction(AppIcons.SIGN, "Signoff config", QuickActionType.SIGN_OFF),
)
}
@ -126,5 +127,6 @@ data class QuickAction(val icon: String, val title: String, val type: QuickActio
enum class QuickActionType {
OPEN_DIR_IN_FILE_MANAGER,
CLONE,
REFRESH;
REFRESH,
SIGN_OFF
}

View File

@ -0,0 +1,174 @@
package com.jetpackduba.gitnuro.ui.dialogs
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.material.Checkbox
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.jetpackduba.gitnuro.AppIcons
import com.jetpackduba.gitnuro.extensions.handMouseClickable
import com.jetpackduba.gitnuro.ui.components.AdjustableOutlinedTextField
import com.jetpackduba.gitnuro.ui.components.PrimaryButton
import com.jetpackduba.gitnuro.viewmodels.SignOffDialogViewModel
import com.jetpackduba.gitnuro.viewmodels.SignOffState
@Composable
fun SignOffDialog(
viewModel: SignOffDialogViewModel,
onClose: () -> Unit,
) {
val state = viewModel.state.collectAsState().value
LaunchedEffect(viewModel) {
viewModel.loadSignOffFormat()
}
var signOffField by remember(viewModel, state) {
val signOff = if (state is SignOffState.Loaded) {
state.signOffConfig.format
} else {
""
}
mutableStateOf(signOff)
}
var enabledSignOff by remember(viewModel, state) {
val signOff = if (state is SignOffState.Loaded) {
state.signOffConfig.isEnabled
} else {
true
}
mutableStateOf(signOff)
}
val signOffFieldFocusRequester = remember { FocusRequester() }
val buttonFieldFocusRequester = remember { FocusRequester() }
MaterialDialog(onCloseRequested = onClose) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = Modifier.width(IntrinsicSize.Min),
) {
Icon(
painterResource(AppIcons.SIGN),
contentDescription = null,
modifier = Modifier
.padding(bottom = 16.dp)
.size(64.dp),
tint = MaterialTheme.colors.onBackground,
)
Text(
text = "Edit sign off",
modifier = Modifier
.padding(bottom = 8.dp),
color = MaterialTheme.colors.onBackground,
style = MaterialTheme.typography.body1,
fontWeight = FontWeight.SemiBold,
)
Text(
text = "Enable or disable the signoff or adjust its format",
modifier = Modifier
.padding(bottom = 16.dp),
color = MaterialTheme.colors.onBackground,
style = MaterialTheme.typography.body2,
textAlign = TextAlign.Center,
)
AdjustableOutlinedTextField(
modifier = Modifier
.focusRequester(signOffFieldFocusRequester)
.focusProperties {
this.next = buttonFieldFocusRequester
}
.width(300.dp),
value = signOffField,
enabled = state is SignOffState.Loaded,
maxLines = 1,
onValueChange = {
signOffField = it
},
)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.handMouseClickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
) {
if (state is SignOffState.Loaded) {
enabledSignOff = !enabledSignOff
}
}
.fillMaxWidth()
.padding(top = 8.dp)
) {
Checkbox(
checked = enabledSignOff,
enabled = state is SignOffState.Loaded,
onCheckedChange = {
enabledSignOff = it
},
modifier = Modifier
.padding(all = 8.dp)
.size(12.dp)
)
Text(
"Enable signoff for this repository",
style = MaterialTheme.typography.body2,
color = MaterialTheme.colors.onBackground,
)
}
Row(
modifier = Modifier
.padding(top = 16.dp)
.align(Alignment.End)
) {
PrimaryButton(
text = "Cancel",
modifier = Modifier.padding(end = 8.dp),
onClick = onClose,
backgroundColor = Color.Transparent,
textColor = MaterialTheme.colors.onBackground,
)
PrimaryButton(
modifier = Modifier
.focusRequester(buttonFieldFocusRequester)
.focusProperties {
this.previous = signOffFieldFocusRequester
this.next = signOffFieldFocusRequester
},
enabled = signOffField.isNotBlank() && state is SignOffState.Loaded,
onClick = {
viewModel.saveSignOffFormat(enabledSignOff, signOffField)
onClose()
},
text = "Save"
)
}
}
}
LaunchedEffect(state) {
signOffFieldFocusRequester.requestFocus()
}
}

View File

@ -0,0 +1,40 @@
package com.jetpackduba.gitnuro.viewmodels
import com.jetpackduba.gitnuro.git.RefreshType
import com.jetpackduba.gitnuro.git.TabState
import com.jetpackduba.gitnuro.git.config.LoadSignOffConfigUseCase
import com.jetpackduba.gitnuro.git.config.SaveLocalRepositoryConfigUseCase
import com.jetpackduba.gitnuro.models.SignOffConfig
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import javax.inject.Inject
class SignOffDialogViewModel @Inject constructor(
private val tabState: TabState,
private val loadSignOffConfigUseCase: LoadSignOffConfigUseCase,
private val saveLocalRepositoryConfigUseCase: SaveLocalRepositoryConfigUseCase,
) {
private val _state = MutableStateFlow<SignOffState>(SignOffState.Loading)
val state = _state.asStateFlow()
fun loadSignOffFormat() = tabState.runOperation(
showError = true,
refreshType = RefreshType.NONE,
) { git ->
val signOffConfig = loadSignOffConfigUseCase(git.repository)
_state.value = SignOffState.Loaded(signOffConfig)
}
fun saveSignOffFormat(newIsEnabled: Boolean, newFormat: String) = tabState.runOperation(
showError = true,
refreshType = RefreshType.NONE,
) { git ->
saveLocalRepositoryConfigUseCase(git.repository, SignOffConfig(newIsEnabled, newFormat))
}
}
sealed interface SignOffState {
object Loading : SignOffState
data class Loaded(val signOffConfig: SignOffConfig) : SignOffState
}

View File

@ -23,6 +23,7 @@ class TabViewModelsHolder @Inject constructor(
private val authorViewModelProvider: Provider<AuthorViewModel>,
private val changeDefaultUpstreamBranchViewModelProvider: Provider<ChangeDefaultUpstreamBranchViewModel>,
private val submoduleDialogViewModelProvider: Provider<SubmoduleDialogViewModel>,
private val signOffDialogViewModelProvider: Provider<SignOffDialogViewModel>,
) {
val viewModels = mapOf(
logViewModel::class to logViewModel,
@ -43,6 +44,7 @@ class TabViewModelsHolder @Inject constructor(
AuthorViewModel::class -> authorViewModelProvider.get()
ChangeDefaultUpstreamBranchViewModel::class -> changeDefaultUpstreamBranchViewModelProvider.get()
SubmoduleDialogViewModel::class -> submoduleDialogViewModelProvider.get()
SignOffDialogViewModel::class -> signOffDialogViewModelProvider.get()
else -> throw NotImplementedError("View model provider not implemented")
}
}

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" enable-background="new 0 0 24 24" height="24px" viewBox="0 0 24 24" width="24px" fill="#000000"><g><rect fill="none" height="24" width="24"/></g><g><g><path d="M16.81,8.94l-3.75-3.75L4,14.25V18h3.75L16.81,8.94z M6,16v-0.92l7.06-7.06l0.92,0.92L6.92,16H6z"/><path d="M19.71,6.04c0.39-0.39,0.39-1.02,0-1.41l-2.34-2.34C17.17,2.09,16.92,2,16.66,2c-0.25,0-0.51,0.1-0.7,0.29l-1.83,1.83 l3.75,3.75L19.71,6.04z"/><rect height="4" width="20" x="2" y="20"/></g></g></svg>

After

Width:  |  Height:  |  Size: 500 B