diff --git a/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt b/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt index 01d43226b5..df98d31f35 100644 --- a/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt +++ b/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt @@ -9,6 +9,8 @@ import android.text.style.ClickableSpan import android.view.View import android.widget.TextView import androidx.appcompat.app.AlertDialog +import androidx.core.view.isGone +import androidx.core.view.isVisible import androidx.core.widget.doAfterTextChanged import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope @@ -33,6 +35,7 @@ import com.itsaky.androidide.idetooltips.TooltipTag import com.itsaky.androidide.idetooltips.attachTooltip import com.itsaky.androidide.interfaces.IEditorHandler import com.itsaky.androidide.preferences.internal.GitPreferences +import com.itsaky.androidide.projects.IProjectManager import com.itsaky.androidide.utils.flashError import com.itsaky.androidide.utils.flashSuccess import com.itsaky.androidide.utils.onLongPress @@ -55,6 +58,7 @@ class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) { private lateinit var branchPopupWindow: GitBranchPopupWindow private var _binding: FragmentGitBottomSheetBinding? = null + private var isUpdatingWatermarkUI = false val binding: FragmentGitBottomSheetBinding get() = checkNotNull(_binding) { "Fragment binding is null or view has been destroyed" } @@ -276,6 +280,18 @@ class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) { } } + launch { + viewModel.isProjectWatermarkEnabled.collectLatest { + updateWatermarkUI() + } + } + + launch { + viewModel.watermarkError.collectLatest { + flashError(getString(R.string.git_watermark_save_failed)) + } + } + combine( viewModel.isGitRepository, viewModel.gitStatus, @@ -290,6 +306,7 @@ class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) { recyclerView.visibility = View.GONE cbCheckAll.visibility = View.GONE commitSection.visibility = View.GONE + layoutWatermark.visibility = View.GONE authorWarning.visibility = View.GONE commitHistoryButton.visibility = View.GONE btnAbortMerge.visibility = View.GONE @@ -306,6 +323,7 @@ class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) { cbCheckAll.isChecked = false cbCheckAll.text = getString(R.string.changed_files_count, 0) commitSection.visibility = View.GONE + layoutWatermark.visibility = View.GONE authorWarning.visibility = View.GONE commitHistoryButton.visibility = View.VISIBLE btnAbortMerge.visibility = View.GONE @@ -321,6 +339,7 @@ class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) { cbCheckAll.isEnabled = hasSelectable cbCheckAll.text = getString(R.string.changed_files_count, allChanges.size) commitSection.visibility = View.VISIBLE + updateWatermarkUI(isRepo = isRepo, hasChanges = allChanges.isNotEmpty()) authorWarning.visibility = if (hasAuthorInfo()) View.GONE else View.VISIBLE commitHistoryButton.visibility = View.VISIBLE @@ -351,6 +370,7 @@ class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) { override fun onResume() { super.onResume() updateAuthorUI() + updateWatermarkUI() } private fun updateAuthorUI() { @@ -361,6 +381,53 @@ class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) { validateCommitButton() } + private fun updateWatermarkUI( + isRepo: Boolean = viewModel.isGitRepository.value, + hasChanges: Boolean = + viewModel.gitStatus.value + .allChanges() + .isNotEmpty(), + ) { + if (!isRepo || !hasChanges) { + binding.layoutWatermark.isGone = true + return + } + + binding.layoutWatermark.isVisible = true + isUpdatingWatermarkUI = true + + try { + if (!GitPreferences.shouldAddGlobalCommitWatermark) { + showGlobalWatermarkDisabled() + } else { + showProjectWatermarkState() + } + } finally { + isUpdatingWatermarkUI = false + } + } + + private fun showGlobalWatermarkDisabled() { + val projectEnabled = viewModel.isProjectWatermarkEnabled.value + binding.apply { + switchCommitWatermark.isEnabled = false + switchCommitWatermark.isChecked = projectEnabled + tvCommitWatermark.isGone = true + tvWatermarkGlobalDisabled.isVisible = true + } + } + + private fun showProjectWatermarkState() { + val projectEnabled = viewModel.isProjectWatermarkEnabled.value + + binding.apply { + switchCommitWatermark.isEnabled = true + switchCommitWatermark.isChecked = projectEnabled + tvCommitWatermark.isVisible = projectEnabled + tvWatermarkGlobalDisabled.isGone = true + } + } + private fun hasAuthorInfo(): Boolean = !GitPreferences.userName.isNullOrBlank() && !GitPreferences.userEmail.isNullOrBlank() private fun setupCommitUI() { @@ -408,21 +475,34 @@ class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) { binding.commitSummary.text ?.toString() ?.trim() ?: "" + val description = binding.commitDescription.text ?.toString() ?.trim() - if (summary.isNotEmpty() && fileChangeAdapter.selectedFiles.isNotEmpty() && hasAuthorInfo()) { - viewModel.commitChanges( + val watermark = + getString(R.string.made_with_code_on_the_go) + .takeIf { + GitPreferences.shouldAddGlobalCommitWatermark && binding.switchCommitWatermark.isChecked + } + + val message = + formatCommitMessage( summary = summary, description = description, + watermark = watermark, + ) + + if (summary.isNotEmpty() && fileChangeAdapter.selectedFiles.isNotEmpty() && hasAuthorInfo()) { + viewModel.commitChanges( + message = message, selectedPaths = fileChangeAdapter.selectedFiles.toList(), ) { // Clear the inputs on successful commit binding.commitSummary.text?.clear() binding.commitDescription.text?.clear() - fileChangeAdapter.selectedFiles.clear() + fileChangeAdapter.clearSelection() updateCheckAllButton() } } @@ -430,8 +510,46 @@ class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) { } setTooltipOnView(TooltipTag.PROJECT_GIT_COMMIT) } + binding.switchCommitWatermark.setOnCheckedChangeListener { _, isChecked -> + if (isUpdatingWatermarkUI) { + return@setOnCheckedChangeListener + } + binding.tvCommitWatermark.isVisible = isChecked + if (viewModel.isProjectWatermarkEnabled.value != isChecked) { + viewModel.setProjectWatermarkEnabled(isChecked) + } + } + updateWatermarkUI() + } + + fun formatCommitMessage( + summary: String, + description: String? = null, + watermark: String? = null, + ): String { + val trimmedSummary = summary.trim() + val trimmedDescription = description?.trim()?.takeIf(String::isNotEmpty) + val trimmedWatermark = watermark?.trim()?.takeIf(String::isNotEmpty) + + val effectiveWatermark = + trimmedWatermark + ?.takeUnless { containsWatermark(trimmedSummary, trimmedDescription, it) } + + return listOfNotNull( + trimmedSummary.takeIf(String::isNotEmpty), + trimmedDescription, + effectiveWatermark, + ).joinToString("\n\n") } + internal fun containsWatermark( + summary: String, + description: String?, + watermark: String, + ): Boolean = + summary.contains(watermark, ignoreCase = true) || + (description?.contains(watermark, ignoreCase = true) == true) + private fun showAuthorPopup() { val name = GitPreferences.userName.orEmpty().ifBlank { getString(R.string.author_not_set) } val email = diff --git a/app/src/main/java/com/itsaky/androidide/fragments/git/adapter/GitFileChangeAdapter.kt b/app/src/main/java/com/itsaky/androidide/fragments/git/adapter/GitFileChangeAdapter.kt index f18fa9a064..940f91fa95 100644 --- a/app/src/main/java/com/itsaky/androidide/fragments/git/adapter/GitFileChangeAdapter.kt +++ b/app/src/main/java/com/itsaky/androidide/fragments/git/adapter/GitFileChangeAdapter.kt @@ -12,147 +12,160 @@ import com.itsaky.androidide.git.core.models.ChangeType import com.itsaky.androidide.git.core.models.FileChange class GitFileChangeAdapter( - private val onFileClicked: (FileChange) -> Unit, - private val onSelectionChanged: (Int) -> Unit = {}, - private val onResolveConflict: (FileChange) -> Unit = {} + private val onFileClicked: (FileChange) -> Unit, + private val onSelectionChanged: (Int) -> Unit = {}, + private val onResolveConflict: (FileChange) -> Unit = {}, ) : ListAdapter(DiffCallback()) { - - // Keep track of which files are selected to be committed - val selectedFiles = mutableSetOf() - - // Conflicted files can't be staged, so they are excluded from "select all". - private val selectablePaths: List - get() = currentList.filter { it.type != ChangeType.CONFLICTED }.map { it.path } - - /** True when every selectable (non-conflicted) file is currently selected. */ - fun areAllSelected(): Boolean = - selectablePaths.isNotEmpty() && selectedFiles.containsAll(selectablePaths) - - /** Select every non-conflicted file. */ - fun selectAll() { - selectedFiles.addAll(selectablePaths) - notifyItemRangeChanged(0, itemCount) - onSelectionChanged(selectedFiles.size) - } - - /** Clear the entire selection. */ - fun clearSelection() { - selectedFiles.clear() - notifyItemRangeChanged(0, itemCount) - onSelectionChanged(selectedFiles.size) - } - - override fun onCurrentListChanged( - previousList: List, - currentList: List - ) { - super.onCurrentListChanged(previousList, currentList) - // Drop selections for files that are no longer in the change set so they - // aren't committed and don't skew areAllSelected()/the commit button. - val currentPaths = currentList.mapTo(HashSet()) { it.path } - if (selectedFiles.retainAll(currentPaths)) { - onSelectionChanged(selectedFiles.size) - } - } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { - val binding = ItemGitFileChangeBinding.inflate( - LayoutInflater.from(parent.context), parent, false - ) - return ViewHolder(binding) - } - - override fun onBindViewHolder(holder: ViewHolder, position: Int) { - val change = getItem(position) - holder.bind(change) - } - - inner class ViewHolder(private val binding: ItemGitFileChangeBinding) : RecyclerView.ViewHolder(binding.root) { - - init { - itemView.setOnClickListener { - val pos = bindingAdapterPosition - if (pos != RecyclerView.NO_POSITION) { - onFileClicked(getItem(pos)) - } - } - - binding.btnMarkResolved.setOnClickListener { - val pos = bindingAdapterPosition - if (pos != RecyclerView.NO_POSITION) { - onResolveConflict(getItem(pos)) - } - } - } - - fun bind(change: FileChange) { - binding.filePath.text = change.path - - val isConflicted = change.type == ChangeType.CONFLICTED - - // Clear listener before setting state to avoid recursive/accidental calls during binding - binding.checkbox.setOnCheckedChangeListener(null) - - binding.checkbox.apply { - isEnabled = !isConflicted - visibility = if (isConflicted) View.INVISIBLE else View.VISIBLE - } - binding.btnMarkResolved.visibility = if (isConflicted) View.VISIBLE else View.GONE - - // Ensure conflicted files are never in selectedFiles - if (isConflicted && selectedFiles.remove(change.path)) { - onSelectionChanged(selectedFiles.size) - } - - binding.checkbox.isChecked = selectedFiles.contains(change.path) - - // Re-set the listener after the state is initialized - binding.checkbox.setOnCheckedChangeListener { _, isChecked -> - val pos = bindingAdapterPosition - if (pos == RecyclerView.NO_POSITION) return@setOnCheckedChangeListener - - val changeAtPos = getItem(pos) - // Conflicted files should not be selectable - if (changeAtPos.type == ChangeType.CONFLICTED) { - binding.checkbox.isChecked = false - return@setOnCheckedChangeListener - } - - if (isChecked) { - selectedFiles.add(changeAtPos.path) - } else { - selectedFiles.remove(changeAtPos.path) - } - onSelectionChanged(selectedFiles.size) - } - - val contentAlpha = if (isConflicted) 0.5f else 1.0f - binding.filePath.alpha = contentAlpha - binding.statusIcon.alpha = contentAlpha - binding.root.alpha = 1.0f - - val (imageRes, descRes) = when (change.type) { - ChangeType.ADDED -> R.drawable.ic_file_added to R.string.desc_file_added - ChangeType.MODIFIED -> R.drawable.ic_file_modified to R.string.desc_file_modified - ChangeType.DELETED -> R.drawable.ic_file_deleted to R.string.desc_file_deleted - ChangeType.UNTRACKED -> R.drawable.ic_file_added to R.string.desc_file_untracked - ChangeType.RENAMED -> R.drawable.ic_file_renamed to R.string.desc_file_renamed - ChangeType.CONFLICTED -> R.drawable.ic_file_conflicted to R.string.desc_file_conflicted - } - binding.statusIcon.apply { - setImageResource(imageRes) - contentDescription = binding.root.context.getString(descRes) - } - } - } - - class DiffCallback : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: FileChange, newItem: FileChange): Boolean { - return oldItem.path == newItem.path - } - - override fun areContentsTheSame(oldItem: FileChange, newItem: FileChange): Boolean { - return oldItem == newItem - } - } + // Keep track of which files are selected to be committed + private val _selectedFiles = mutableSetOf() + val selectedFiles: Set get() = _selectedFiles + + // Conflicted files can't be staged, so they are excluded from "select all". + private val selectablePaths: List + get() = currentList.filter { it.type != ChangeType.CONFLICTED }.map { it.path } + + /** True when every selectable (non-conflicted) file is currently selected. */ + fun areAllSelected(): Boolean = selectablePaths.isNotEmpty() && _selectedFiles.containsAll(selectablePaths) + + /** Select every non-conflicted file. */ + fun selectAll() { + _selectedFiles.addAll(selectablePaths) + notifyItemRangeChanged(0, itemCount) + onSelectionChanged(_selectedFiles.size) + } + + /** Clear the entire selection. */ + fun clearSelection() { + _selectedFiles.clear() + notifyItemRangeChanged(0, itemCount) + onSelectionChanged(_selectedFiles.size) + } + + override fun onCurrentListChanged( + previousList: List, + currentList: List, + ) { + super.onCurrentListChanged(previousList, currentList) + // Drop selections for files that are no longer in the change set so they + // aren't committed and don't skew areAllSelected()/the commit button. + val currentPaths = currentList.mapTo(HashSet()) { it.path } + if (_selectedFiles.retainAll(currentPaths)) { + notifyItemRangeChanged(0, itemCount) + onSelectionChanged(_selectedFiles.size) + } + } + + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int, + ): ViewHolder { + val binding = + ItemGitFileChangeBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false, + ) + return ViewHolder(binding) + } + + override fun onBindViewHolder( + holder: ViewHolder, + position: Int, + ) { + val change = getItem(position) + holder.bind(change) + } + + inner class ViewHolder( + private val binding: ItemGitFileChangeBinding, + ) : RecyclerView.ViewHolder(binding.root) { + init { + itemView.setOnClickListener { + val pos = bindingAdapterPosition + if (pos != RecyclerView.NO_POSITION) { + onFileClicked(getItem(pos)) + } + } + + binding.btnMarkResolved.setOnClickListener { + val pos = bindingAdapterPosition + if (pos != RecyclerView.NO_POSITION) { + onResolveConflict(getItem(pos)) + } + } + } + + fun bind(change: FileChange) { + binding.filePath.text = change.path + + val isConflicted = change.type == ChangeType.CONFLICTED + + // Clear listener before setting state to avoid recursive/accidental calls during binding + binding.checkbox.setOnCheckedChangeListener(null) + + binding.checkbox.apply { + isEnabled = !isConflicted + visibility = if (isConflicted) View.INVISIBLE else View.VISIBLE + } + binding.btnMarkResolved.visibility = if (isConflicted) View.VISIBLE else View.GONE + + // Ensure conflicted files are never in selectedFiles + if (isConflicted && _selectedFiles.remove(change.path)) { + onSelectionChanged(_selectedFiles.size) + } + + binding.checkbox.isChecked = _selectedFiles.contains(change.path) + + // Re-set the listener after the state is initialized + binding.checkbox.setOnCheckedChangeListener { _, isChecked -> + val pos = bindingAdapterPosition + if (pos == RecyclerView.NO_POSITION) return@setOnCheckedChangeListener + + val changeAtPos = getItem(pos) + // Conflicted files should not be selectable + if (changeAtPos.type == ChangeType.CONFLICTED) { + binding.checkbox.isChecked = false + return@setOnCheckedChangeListener + } + + if (isChecked) { + _selectedFiles.add(changeAtPos.path) + } else { + _selectedFiles.remove(changeAtPos.path) + } + onSelectionChanged(_selectedFiles.size) + } + + val contentAlpha = if (isConflicted) 0.5f else 1.0f + binding.filePath.alpha = contentAlpha + binding.statusIcon.alpha = contentAlpha + binding.root.alpha = 1.0f + + val (imageRes, descRes) = + when (change.type) { + ChangeType.ADDED -> R.drawable.ic_file_added to R.string.desc_file_added + ChangeType.MODIFIED -> R.drawable.ic_file_modified to R.string.desc_file_modified + ChangeType.DELETED -> R.drawable.ic_file_deleted to R.string.desc_file_deleted + ChangeType.UNTRACKED -> R.drawable.ic_file_added to R.string.desc_file_untracked + ChangeType.RENAMED -> R.drawable.ic_file_renamed to R.string.desc_file_renamed + ChangeType.CONFLICTED -> R.drawable.ic_file_conflicted to R.string.desc_file_conflicted + } + binding.statusIcon.apply { + setImageResource(imageRes) + contentDescription = binding.root.context.getString(descRes) + } + } + } + + class DiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame( + oldItem: FileChange, + newItem: FileChange, + ): Boolean = oldItem.path == newItem.path + + override fun areContentsTheSame( + oldItem: FileChange, + newItem: FileChange, + ): Boolean = oldItem == newItem + } } diff --git a/app/src/main/java/com/itsaky/androidide/preferences/gitPrefExts.kt b/app/src/main/java/com/itsaky/androidide/preferences/gitPrefExts.kt index 9e0d102823..1912f7824f 100644 --- a/app/src/main/java/com/itsaky/androidide/preferences/gitPrefExts.kt +++ b/app/src/main/java/com/itsaky/androidide/preferences/gitPrefExts.kt @@ -7,6 +7,9 @@ import com.itsaky.androidide.idetooltips.TooltipTag.PREFS_GIT_USEREMAIL import com.itsaky.androidide.idetooltips.TooltipTag.PREFS_GIT_USERNAME import com.itsaky.androidide.preferences.internal.GitPreferences import com.itsaky.androidide.R +import com.itsaky.androidide.idetooltips.TooltipTag +import com.itsaky.androidide.resources.R.drawable +import com.itsaky.androidide.resources.R.string import kotlinx.parcelize.Parcelize @Parcelize @@ -20,6 +23,7 @@ class GitPreferencesScreen( init { addPreference(GitAuthorConfig()) + addPreference(CommitWatermarkConfig()) } } @@ -27,7 +31,6 @@ class GitPreferencesScreen( class GitAuthorConfig( override val key: String = "idepref_git_author", override val title: Int = R.string.idepref_git_author_title, - override val summary: Int? = R.string.idepref_git_author_summary, override val children: List = mutableListOf(), ) : IPreferenceGroup() { @@ -37,6 +40,18 @@ class GitAuthorConfig( } } +@Parcelize +class CommitWatermarkConfig( + override val key: String = "idepref_git_commit_watermark", + override val title: Int = R.string.idepref_git_commit_watermark_title, + override val children: List = mutableListOf(), +) : IPreferenceGroup() { + + init { + addPreference(AddGitCommitWatermark()) + } +} + @Parcelize class GitUserName( override val key: String = GitPreferences.GIT_USER_NAME, @@ -110,3 +125,15 @@ class GitUserEmail( return true } } + +@Parcelize +private class AddGitCommitWatermark( + override val key: String = GitPreferences.ADD_GLOBAL_COMMIT_WATERMARK, + override val title: Int = string.idepref_git_add_commit_watermark, + override val summary: Int? = string.idepref_git_add_commit_watermark_summary, + override val icon: Int? = drawable.ic_watermark, + override val tooltipTag: String = TooltipTag.PREFS_GIT_WATERMARK, +) : SwitchPreference( + setValue = GitPreferences::shouldAddGlobalCommitWatermark::set, + getValue = GitPreferences::shouldAddGlobalCommitWatermark::get +) diff --git a/app/src/main/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModel.kt b/app/src/main/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModel.kt index c36f9ab0d3..111fcc71a8 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModel.kt @@ -21,8 +21,11 @@ import com.itsaky.androidide.utils.isNetworkConnected import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Job import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import org.eclipse.jgit.api.MergeResult.MergeStatus @@ -77,9 +80,16 @@ class GitBottomSheetViewModel( private val _mergeState = MutableStateFlow(MergeUiState.Idle) val mergeState: StateFlow = _mergeState.asStateFlow() + private val _isProjectWatermarkEnabled = MutableStateFlow(true) + val isProjectWatermarkEnabled: StateFlow = _isProjectWatermarkEnabled.asStateFlow() + + private val _watermarkError = MutableSharedFlow(extraBufferCapacity = 1) + val watermarkError: SharedFlow = _watermarkError.asSharedFlow() + private var initJob: Job? = null private var pullResetJob: Job? = null private var pushResetJob: Job? = null + private var watermarkWriteJob: Job? = null var currentRepository: GitRepository? = repository private set @@ -125,6 +135,7 @@ class GitBottomSheetViewModel( _currentBranch.value = null _branches.value = BranchesUiState.None _localCommitsCount.value = 0 + _isProjectWatermarkEnabled.value = true return@launch } val projectDir = File(projectDirPath) @@ -136,6 +147,7 @@ class GitBottomSheetViewModel( currentRepository = GitRepositoryManager.openRepository(projectDir) _isGitRepository.value = currentRepository != null } + _isProjectWatermarkEnabled.value = currentRepository?.isCommitWatermarkEnabled() ?: true refreshStatus() } catch (e: CancellationException) { throw e @@ -147,6 +159,7 @@ class GitBottomSheetViewModel( _currentBranch.value = null _branches.value = BranchesUiState.None _localCommitsCount.value = 0 + _isProjectWatermarkEnabled.value = true } } } @@ -163,6 +176,7 @@ class GitBottomSheetViewModel( _currentBranch.value = null _branches.value = BranchesUiState.None _localCommitsCount.value = 0 + _isProjectWatermarkEnabled.value = true return@launch } @@ -263,8 +277,7 @@ class GitBottomSheetViewModel( } fun commitChanges( - summary: String, - description: String? = null, + message: String, selectedPaths: List, onSuccess: () -> Unit, ) { @@ -279,8 +292,6 @@ class GitBottomSheetViewModel( repository.stageFiles(filesToStage) - val message = - if (!description.isNullOrBlank()) "$summary\n\n$description" else summary repository.commit( message = message, authorName = GitPreferences.userName, @@ -729,4 +740,26 @@ class GitBottomSheetViewModel( } } } + + fun setProjectWatermarkEnabled( + enabled: Boolean, + onError: ((Throwable) -> Unit)? = null, + ) { + val previous = _isProjectWatermarkEnabled.value + _isProjectWatermarkEnabled.value = enabled + watermarkWriteJob?.cancel() + watermarkWriteJob = + viewModelScope.launch { + try { + currentRepository?.setCommitWatermarkEnabled(enabled) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + log.error("Failed to save commit watermark preference to git config", e) + _isProjectWatermarkEnabled.value = previous + _watermarkError.tryEmit(e) + onError?.invoke(e) + } + } + } } diff --git a/app/src/main/res/layout/fragment_git_bottom_sheet.xml b/app/src/main/res/layout/fragment_git_bottom_sheet.xml index 9a7fcf2eac..ec39da37c3 100644 --- a/app/src/main/res/layout/fragment_git_bottom_sheet.xml +++ b/app/src/main/res/layout/fragment_git_bottom_sheet.xml @@ -2,6 +2,7 @@ @@ -99,13 +100,6 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/tv_branch_name" /> - - @@ -158,7 +152,7 @@ android:hint="@string/commit_summary" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toEndOf="@id/authorAvatar" - app:layout_constraintTop_toBottomOf="@id/guideline_limit"> + app:layout_constraintTop_toBottomOf="@id/recyclerView"> + + + + + + + + + + + app:layout_constraintTop_toBottomOf="@id/layout_watermark" /> @@ -228,6 +257,7 @@ - + - + - + - + - + diff --git a/app/src/test/java/com/itsaky/androidide/fragments/git/GitCommitWatermarkTest.kt b/app/src/test/java/com/itsaky/androidide/fragments/git/GitCommitWatermarkTest.kt new file mode 100644 index 0000000000..427e22f7f4 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/fragments/git/GitCommitWatermarkTest.kt @@ -0,0 +1,80 @@ +package com.itsaky.androidide.fragments.git + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class GitCommitWatermarkTest { + private val fragment = GitBottomSheetFragment() + private val watermark = "Made with Code on the Go (appdevforall.org)" + + @Test + fun `formatCommitMessage appends watermark to summary and description`() { + val summary = "feat: add user profile" + val description = "Detailed description of user profile implementation." + + val result = fragment.formatCommitMessage(summary, description, watermark) + + assertThat(result).isEqualTo("$summary\n\n$description\n\n$watermark") + } + + @Test + fun `formatCommitMessage appends watermark when description is null or empty`() { + val summary = "fix: resolve crash on startup" + + val resultNullDesc = fragment.formatCommitMessage(summary, null, watermark) + assertThat(resultNullDesc).isEqualTo("$summary\n\n$watermark") + + val resultEmptyDesc = fragment.formatCommitMessage(summary, "", watermark) + assertThat(resultEmptyDesc).isEqualTo("$summary\n\n$watermark") + + val resultWhitespaceDesc = fragment.formatCommitMessage(summary, " \n\n ", watermark) + assertThat(resultWhitespaceDesc).isEqualTo("$summary\n\n$watermark") + } + + @Test + fun `formatCommitMessage omits watermark when watermark is null or empty`() { + val summary = "chore: update dependencies" + val description = "Bump gradle plugins" + + val resultNull = fragment.formatCommitMessage(summary, description, null) + assertThat(resultNull).isEqualTo("$summary\n\n$description") + + val resultEmpty = fragment.formatCommitMessage(summary, description, "") + assertThat(resultEmpty).isEqualTo("$summary\n\n$description") + + val resultBlank = fragment.formatCommitMessage(summary, description, " ") + assertThat(resultBlank).isEqualTo("$summary\n\n$description") + } + + @Test + fun `formatCommitMessage deduplicates when summary already contains watermark`() { + val summary = "docs: Made with Code on the Go (appdevforall.org)" + val description = "Updated readme with attribution." + + val result = fragment.formatCommitMessage(summary, description, watermark) + + assertThat(result).isEqualTo("$summary\n\n$description") + assertThat(result.split(watermark).size - 1).isEqualTo(1) + } + + @Test + fun `formatCommitMessage deduplicates when description already contains watermark`() { + val summary = "feat: new feature" + val description = "Some notes\nMade with Code on the Go (appdevforall.org)" + + val result = fragment.formatCommitMessage(summary, description, watermark) + + assertThat(result).isEqualTo("$summary\n\n$description") + assertThat(result.split(watermark).size - 1).isEqualTo(1) + } + + @Test + fun `formatCommitMessage deduplicates case-insensitively`() { + val summary = "feat: new feature" + val description = "notes\nmade with code on the go (appdevforall.org)" + + val result = fragment.formatCommitMessage(summary, description, watermark) + + assertThat(result).isEqualTo("$summary\n\n$description") + } +} diff --git a/app/src/test/java/com/itsaky/androidide/preferences/GitPreferencesTest.kt b/app/src/test/java/com/itsaky/androidide/preferences/GitPreferencesTest.kt new file mode 100644 index 0000000000..c8e0826c00 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/preferences/GitPreferencesTest.kt @@ -0,0 +1,43 @@ +package com.itsaky.androidide.preferences + +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.preferences.internal.GitPreferences +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class GitPreferencesTest { + @Before + fun setUp() { + System.setProperty("androidide.test.mode", "true") + GitPreferences.shouldAddGlobalCommitWatermark = true + } + + @After + fun tearDown() { + GitPreferences.shouldAddGlobalCommitWatermark = true + } + + @Test + fun `global commit watermark defaults to true`() { + assertThat(GitPreferences.shouldAddGlobalCommitWatermark).isTrue() + } + + @Test + fun `global commit watermark persists false when disabled`() { + GitPreferences.shouldAddGlobalCommitWatermark = false + assertThat(GitPreferences.shouldAddGlobalCommitWatermark).isFalse() + } + + @Test + fun `global commit watermark persists true when re-enabled`() { + GitPreferences.shouldAddGlobalCommitWatermark = false + assertThat(GitPreferences.shouldAddGlobalCommitWatermark).isFalse() + + GitPreferences.shouldAddGlobalCommitWatermark = true + assertThat(GitPreferences.shouldAddGlobalCommitWatermark).isTrue() + } +} diff --git a/app/src/test/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModelTest.kt b/app/src/test/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModelTest.kt index bf398f28a4..de4d44e530 100644 --- a/app/src/test/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModelTest.kt +++ b/app/src/test/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModelTest.kt @@ -10,18 +10,22 @@ import io.mockk.every import io.mockk.mockk import io.mockk.unmockkAll import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.eclipse.jgit.api.MergeResult.MergeStatus import org.eclipse.jgit.api.errors.CheckoutConflictException import org.junit.After import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import org.junit.runners.JUnit4 +import java.io.IOException +import kotlin.time.Duration.Companion.milliseconds @RunWith(JUnit4::class) @OptIn(ExperimentalCoroutinesApi::class) @@ -245,4 +249,70 @@ class GitBottomSheetViewModelTest { assertEquals(null, viewModel.currentBranch.value) assertEquals(GitBottomSheetViewModel.BranchesUiState.None, viewModel.branches.value) } + + @Test + fun `isProjectWatermarkEnabled reflects repository setting on refreshStatus`() = + runTest { + coEvery { repository.isCommitWatermarkEnabled() } returns false + coEvery { repository.getStatus() } returns mockk(relaxed = true) + + viewModel.refreshStatus() + advanceUntilIdle() + + assertEquals(false, viewModel.isProjectWatermarkEnabled.value) + } + + @Test + fun `setProjectWatermarkEnabled updates state flow and delegates to repository`() = + runTest { + viewModel.setProjectWatermarkEnabled(false) + assertEquals(false, viewModel.isProjectWatermarkEnabled.value) + + advanceUntilIdle() + coVerify { repository.setCommitWatermarkEnabled(false) } + + viewModel.setProjectWatermarkEnabled(true) + assertEquals(true, viewModel.isProjectWatermarkEnabled.value) + + advanceUntilIdle() + coVerify { repository.setCommitWatermarkEnabled(true) } + } + + @Test + fun `setProjectWatermarkEnabled rolls back state flow and invokes onError when repository write fails`() = + runTest { + coEvery { repository.setCommitWatermarkEnabled(false) } coAnswers { + delay(50.milliseconds) + throw IOException("Disk write failed") + } + + var errorInvoked: Throwable? = null + viewModel.setProjectWatermarkEnabled(false) { error -> + errorInvoked = error + } + assertEquals(false, viewModel.isProjectWatermarkEnabled.value) + + advanceUntilIdle() + + // State flow should have rolled back to previous value (true) + assertEquals(true, viewModel.isProjectWatermarkEnabled.value) + assertNotNull(errorInvoked) + assertEquals("Disk write failed", errorInvoked?.message) + } + + @Test + fun `rapid setProjectWatermarkEnabled calls cancel prior in-flight write and commit latest value`() = + runTest { + coEvery { repository.setCommitWatermarkEnabled(any()) } coAnswers { + delay(100.milliseconds) + } + + viewModel.setProjectWatermarkEnabled(false) + viewModel.setProjectWatermarkEnabled(true) + + advanceUntilIdle() + + assertEquals(true, viewModel.isProjectWatermarkEnabled.value) + coVerify(exactly = 1) { repository.setCommitWatermarkEnabled(true) } + } } diff --git a/git-core/src/main/java/com/itsaky/androidide/git/core/GitRepository.kt b/git-core/src/main/java/com/itsaky/androidide/git/core/GitRepository.kt index 39ee4c1968..5fc87f92f0 100644 --- a/git-core/src/main/java/com/itsaky/androidide/git/core/GitRepository.kt +++ b/git-core/src/main/java/com/itsaky/androidide/git/core/GitRepository.kt @@ -30,6 +30,11 @@ interface GitRepository : Closeable { // Commit Operations suspend fun stageFiles(files: List) + // Git commit watermark configurations + suspend fun isCommitWatermarkEnabled(): Boolean + + suspend fun setCommitWatermarkEnabled(enabled: Boolean) + suspend fun commit( message: String, authorName: String? = null, diff --git a/git-core/src/main/java/com/itsaky/androidide/git/core/JGitRepository.kt b/git-core/src/main/java/com/itsaky/androidide/git/core/JGitRepository.kt index 23a5c05c6c..e517605f9c 100644 --- a/git-core/src/main/java/com/itsaky/androidide/git/core/JGitRepository.kt +++ b/git-core/src/main/java/com/itsaky/androidide/git/core/JGitRepository.kt @@ -6,6 +6,8 @@ import com.itsaky.androidide.git.core.models.GitBranch import com.itsaky.androidide.git.core.models.GitCommit import com.itsaky.androidide.git.core.models.GitStatus import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import org.eclipse.jgit.api.CheckoutCommand import org.eclipse.jgit.api.CreateBranchCommand @@ -42,7 +44,13 @@ import java.io.File class JGitRepository( override val rootDir: File, ) : GitRepository { + companion object { + private const val CONFIG_SECTION_COTG = "cotg" + private const val CONFIG_KEY_WATERMARK = "commit-watermark" + } + private val log = LoggerFactory.getLogger(JGitRepository::class.java) + private val configMutex = Mutex() private val repository: Repository = FileRepositoryBuilder() @@ -215,6 +223,28 @@ class JGitRepository( Unit } + override suspend fun isCommitWatermarkEnabled(): Boolean = + withContext(Dispatchers.IO) { + configMutex.withLock { + repository.config.load() + repository.config.getBoolean(CONFIG_SECTION_COTG, null, CONFIG_KEY_WATERMARK, true) + } + } + + override suspend fun setCommitWatermarkEnabled(enabled: Boolean) = + withContext(Dispatchers.IO) { + configMutex.withLock { + repository.config.load() + repository.config.setBoolean( + CONFIG_SECTION_COTG, + null, + CONFIG_KEY_WATERMARK, + enabled, + ) + repository.config.save() + } + } + override suspend fun commit( message: String, authorName: String?, diff --git a/git-core/src/test/java/com/itsaky/androidide/git/core/JGitRepositoryTest.kt b/git-core/src/test/java/com/itsaky/androidide/git/core/JGitRepositoryTest.kt index 4d7345fe3b..7f62367ca6 100644 --- a/git-core/src/test/java/com/itsaky/androidide/git/core/JGitRepositoryTest.kt +++ b/git-core/src/test/java/com/itsaky/androidide/git/core/JGitRepositoryTest.kt @@ -1,5 +1,8 @@ package com.itsaky.androidide.git.core +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll import kotlinx.coroutines.runBlocking import org.eclipse.jgit.api.Git import org.junit.After @@ -240,4 +243,75 @@ class JGitRepositoryTest { jgitRepo.getBranches().any { it.name == "origin-release" }, ) } + + @Test + fun testCommitWatermarkDefaultsToTrue() = + runBlocking { + assertTrue(jgitRepo.isCommitWatermarkEnabled()) + } + + @Test + fun testSetCommitWatermarkPersistsInGitConfig() = + runBlocking { + jgitRepo.setCommitWatermarkEnabled(false) + assertFalse(jgitRepo.isCommitWatermarkEnabled()) + + val configFile = File(repoDir, ".git/config") + assertTrue("config file must exist", configFile.exists()) + val configContent = configFile.readText() + assertTrue("config must contain cotg section", configContent.contains("[cotg]")) + assertTrue("config must set commit-watermark to false", configContent.contains("commit-watermark = false")) + + // Verify a new repository instance reading from disk also sees false + JGitRepository(repoDir).use { freshRepo -> + assertFalse(freshRepo.isCommitWatermarkEnabled()) + } + } + + @Test + fun testToggleCommitWatermarkBackToTrue() = + runBlocking { + jgitRepo.setCommitWatermarkEnabled(false) + assertFalse(jgitRepo.isCommitWatermarkEnabled()) + + jgitRepo.setCommitWatermarkEnabled(true) + assertTrue(jgitRepo.isCommitWatermarkEnabled()) + + // Verify a new repository instance reading from disk also sees true + JGitRepository(repoDir).use { freshRepo -> + assertTrue(freshRepo.isCommitWatermarkEnabled()) + } + } + + @Test + fun testConcurrentWatermarkWritesSerializeWithoutLockCollision() = + runBlocking { + val jobs = + List(10) { index -> + async(Dispatchers.IO) { + jgitRepo.setCommitWatermarkEnabled(index % 2 == 0) + } + } + jobs.awaitAll() + // Should complete without throwing LockFailedException and return a valid boolean + val isEnabled = jgitRepo.isCommitWatermarkEnabled() + assertTrue(isEnabled || !isEnabled) + } + + @Test(expected = Exception::class) + fun testSetCommitWatermarkPropagatesExceptionOnFailure() = + runBlocking { + val lockFile = File(repoDir, ".git/config.lock") + lockFile.mkdir() // Making the lock path a directory causes LockFile creation to fail + jgitRepo.setCommitWatermarkEnabled(false) + } + + @Test(expected = Exception::class) + fun testIsCommitWatermarkPropagatesExceptionOnFailure() = + runBlocking { + val configFile = File(repoDir, ".git/config") + configFile.writeText("[unclosed_section\nkey = value") + jgitRepo.isCommitWatermarkEnabled() + Unit + } } diff --git a/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/TooltipTag.kt b/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/TooltipTag.kt index 7c4e23f0e3..a90ba51468 100644 --- a/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/TooltipTag.kt +++ b/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/TooltipTag.kt @@ -117,6 +117,7 @@ object TooltipTag { // Preferences - Git screen const val PREFS_GIT_USERNAME = "prefs.git.username" const val PREFS_GIT_USEREMAIL = "prefs.git.useremail" + const val PREFS_GIT_WATERMARK = "prefs.git.watermark" // Preferences - Developer options screen const val PREFS_DEVOPTIONS_DUMPLOGS = "prefs.devoptions.dumplogs" diff --git a/preferences/src/main/java/com/itsaky/androidide/preferences/internal/GitPreferences.kt b/preferences/src/main/java/com/itsaky/androidide/preferences/internal/GitPreferences.kt index 8be1d1f33d..89121b3d76 100644 --- a/preferences/src/main/java/com/itsaky/androidide/preferences/internal/GitPreferences.kt +++ b/preferences/src/main/java/com/itsaky/androidide/preferences/internal/GitPreferences.kt @@ -4,18 +4,25 @@ package com.itsaky.androidide.preferences.internal * Preferences for Git configuration. */ object GitPreferences { - const val GIT_USER_NAME = "git_user_name" - const val GIT_USER_EMAIL = "git_user_email" + const val GIT_USER_NAME = "git_user_name" + const val GIT_USER_EMAIL = "git_user_email" + const val ADD_GLOBAL_COMMIT_WATERMARK = "add_git_commit_watermark" - var userName: String? - get() = prefManager.getString(GIT_USER_NAME, null) - set(value) { - prefManager.putString(GIT_USER_NAME, value) - } + var userName: String? + get() = prefManager.getString(GIT_USER_NAME, null) + set(value) { + prefManager.putString(GIT_USER_NAME, value) + } - var userEmail: String? - get() = prefManager.getString(GIT_USER_EMAIL, null) - set(value) { - prefManager.putString(GIT_USER_EMAIL, value) - } + var userEmail: String? + get() = prefManager.getString(GIT_USER_EMAIL, null) + set(value) { + prefManager.putString(GIT_USER_EMAIL, value) + } + + var shouldAddGlobalCommitWatermark: Boolean + get() = prefManager.getBoolean(ADD_GLOBAL_COMMIT_WATERMARK, true) + set(value) { + prefManager.putBoolean(ADD_GLOBAL_COMMIT_WATERMARK, value) + } } diff --git a/resources/src/main/res/drawable/bg_commit_watermark.xml b/resources/src/main/res/drawable/bg_commit_watermark.xml new file mode 100644 index 0000000000..481688565a --- /dev/null +++ b/resources/src/main/res/drawable/bg_commit_watermark.xml @@ -0,0 +1,7 @@ + + + + + diff --git a/resources/src/main/res/drawable/ic_watermark.xml b/resources/src/main/res/drawable/ic_watermark.xml new file mode 100644 index 0000000000..abe69be2ea --- /dev/null +++ b/resources/src/main/res/drawable/ic_watermark.xml @@ -0,0 +1,19 @@ + + + + + + diff --git a/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml index bab84b6648..2ba0c50021 100644 --- a/resources/src/main/res/values/strings.xml +++ b/resources/src/main/res/values/strings.xml @@ -1522,16 +1522,22 @@ Cancel clone Initializing clone… Cancelling clone… + Add commit message watermark + Made with Code on the Go (appdevforall.org) + Disabled globally in Preferences + Failed to update watermark setting Set and update Git configuration Author identity - Set the name and email used for commits User name Used as author name in commits User email Used as author email in commits Configure Git author in Preferences to enable commits.\nTap the avatar for more info. + Add watermark to commits + Automatically adds a Code on the Go credit to Git commits. You can disable it for individual projects in the Git tab. + Commit watermark Committing as %1$s Email - %1$s You can update your Git configuration in Preferences.