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 4341add028..f77c4411df 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 @@ -22,6 +22,7 @@ import com.itsaky.androidide.databinding.FragmentGitBottomSheetBinding import com.itsaky.androidide.fragments.git.adapter.GitFileChangeAdapter import com.itsaky.androidide.git.core.GitCredentialsManager import com.itsaky.androidide.git.core.models.ChangeType +import com.itsaky.androidide.interfaces.IEditorHandler import com.itsaky.androidide.preferences.internal.GitPreferences import com.itsaky.androidide.utils.flashSuccess import com.itsaky.androidide.viewmodel.BottomSheetViewModel @@ -52,7 +53,6 @@ class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) { onFileClicked = { change -> when (change.type) { ChangeType.CONFLICTED -> { - // Open conflicted file in editor val activity = requireActivity() if (activity is EditorHandlerActivity) { viewLifecycleOwner.lifecycleScope.launch { @@ -66,7 +66,6 @@ class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) { } } else -> { - // Show diff in a dialog when changed file is clicked val dialog = GitDiffViewerDialog.newInstance(change.path) dialog.show(childFragmentManager, "GitDiffViewerDialog") } @@ -74,6 +73,9 @@ class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) { }, onSelectionChanged = { validateCommitButton() + }, + onResolveConflict = { change -> + viewModel.resolveConflict(change.path) } ) @@ -183,19 +185,21 @@ class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) { } binding.commitButton.setOnClickListener { - val summary = binding.commitSummary.text?.toString()?.trim() ?: "" - val description = binding.commitDescription.text?.toString()?.trim() - - if (summary.isNotEmpty() && fileChangeAdapter.selectedFiles.isNotEmpty() && hasAuthorInfo()) { - viewModel.commitChanges( - summary = summary, - description = description, - selectedPaths = fileChangeAdapter.selectedFiles.toList() - ) { - // Clear the inputs on successful commit - binding.commitSummary.text?.clear() - binding.commitDescription.text?.clear() - fileChangeAdapter.selectedFiles.clear() + checkUnsavedChangesAndProceed { + val summary = binding.commitSummary.text?.toString()?.trim() ?: "" + val description = binding.commitDescription.text?.toString()?.trim() + + if (summary.isNotEmpty() && fileChangeAdapter.selectedFiles.isNotEmpty() && hasAuthorInfo()) { + viewModel.commitChanges( + summary = summary, + description = description, + selectedPaths = fileChangeAdapter.selectedFiles.toList() + ) { + // Clear the inputs on successful commit + binding.commitSummary.text?.clear() + binding.commitDescription.text?.clear() + fileChangeAdapter.selectedFiles.clear() + } } } } @@ -303,12 +307,14 @@ class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) { } binding.btnPull.setOnClickListener { - val username = credentialsManager.getUsername() - val token = credentialsManager.getToken() - if (!username.isNullOrBlank() && !token.isNullOrBlank()) { - viewModel.pull(username, token) - } else { - showCredentialsDialog() + checkUnsavedChangesAndProceed { + val username = credentialsManager.getUsername() + val token = credentialsManager.getToken() + if (!username.isNullOrBlank() && !token.isNullOrBlank()) { + viewModel.pull(username, token) + } else { + showCredentialsDialog() + } } } } @@ -344,6 +350,25 @@ class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) { } } + private fun checkUnsavedChangesAndProceed(action: () -> Unit) { + val handler = requireActivity() as? IEditorHandler + if (handler?.areFilesModified() == true) { + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.title_files_unsaved) + .setMessage(R.string.msg_save_before_git_action) + .setPositiveButton(R.string.save_before_git_action) { _, _ -> + handler.saveAllAsync { action() } + } + .setNegativeButton(R.string.no_save_before_git_action) { _, _ -> + action() + } + .setNeutralButton(android.R.string.cancel, null) + .show() + } else { + action() + } + } + override fun onDestroyView() { super.onDestroyView() _binding = null 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 5b6649e50e..6642e807fa 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 @@ -1,6 +1,7 @@ package com.itsaky.androidide.fragments.git.adapter import android.view.LayoutInflater +import android.view.View import android.view.ViewGroup import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.ListAdapter @@ -12,7 +13,8 @@ import com.itsaky.androidide.git.core.models.FileChange class GitFileChangeAdapter( private val onFileClicked: (FileChange) -> Unit, - private val onSelectionChanged: (Int) -> Unit = {} + private val onSelectionChanged: (Int) -> Unit = {}, + private val onResolveConflict: (FileChange) -> Unit = {} ) : ListAdapter(DiffCallback()) { // Keep track of which files are selected to be committed @@ -40,16 +42,10 @@ class GitFileChangeAdapter( } } - binding.checkbox.setOnCheckedChangeListener { _, isChecked -> + binding.btnMarkResolved.setOnClickListener { val pos = bindingAdapterPosition if (pos != RecyclerView.NO_POSITION) { - val change = getItem(pos) - if (isChecked) { - selectedFiles.add(change.path) - } else { - selectedFiles.remove(change.path) - } - onSelectionChanged(selectedFiles.size) + onResolveConflict(getItem(pos)) } } } @@ -57,7 +53,48 @@ class GitFileChangeAdapter( 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 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 dac3257c0f..4e04772785 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModel.kt @@ -24,7 +24,6 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import org.eclipse.jgit.api.MergeResult.MergeStatus import org.eclipse.jgit.api.PullResult -import org.eclipse.jgit.errors.NoRemoteRepositoryException import org.eclipse.jgit.api.errors.CheckoutConflictException import org.eclipse.jgit.transport.RemoteRefUpdate import org.eclipse.jgit.transport.UsernamePasswordCredentialsProvider @@ -369,4 +368,17 @@ class GitBottomSheetViewModel(private val credentialsManager: GitCredentialsMana } } + fun resolveConflict(path: String) { + viewModelScope.launch { + try { + val repository = currentRepository ?: return@launch + val projectDir = File(IProjectManager.getInstance().projectDirPath) + repository.stageFiles(listOf(File(projectDir, path))) + refreshStatus() + } catch (e: Exception) { + log.error("Failed to resolve conflict for $path", e) + } + } + } + } diff --git a/app/src/main/res/layout/item_git_file_change.xml b/app/src/main/res/layout/item_git_file_change.xml index a9b49083f2..8f917b5b53 100644 --- a/app/src/main/res/layout/item_git_file_change.xml +++ b/app/src/main/res/layout/item_git_file_change.xml @@ -1,5 +1,6 @@ + + () val untracked = mutableListOf() val conflicted = mutableListOf() + + // Track unique paths to avoid duplicates across categories + // Priority: Conflicted > Staged > Unstaged > Untracked + val processedPaths = mutableSetOf() - jgitStatus.added.forEach { staged.add(FileChange(it, ChangeType.ADDED)) } - jgitStatus.changed.forEach { staged.add(FileChange(it, ChangeType.MODIFIED)) } - jgitStatus.removed.forEach { staged.add(FileChange(it, ChangeType.DELETED)) } + // 1. Conflicted (Highest Priority) + jgitStatus.conflicting.forEach { + if (processedPaths.add(it)) { + conflicted.add(FileChange(it, ChangeType.CONFLICTED)) + } + } + + // 2. Staged files (Added, Changed, Removed) + jgitStatus.added.forEach { if (processedPaths.add(it)) staged.add(FileChange(it, ChangeType.ADDED)) } + jgitStatus.changed.forEach { if (processedPaths.add(it)) staged.add(FileChange(it, ChangeType.MODIFIED)) } + jgitStatus.removed.forEach { if (processedPaths.add(it)) staged.add(FileChange(it, ChangeType.DELETED)) } - jgitStatus.modified.forEach { unstaged.add(FileChange(it, ChangeType.MODIFIED)) } - jgitStatus.missing.forEach { unstaged.add(FileChange(it, ChangeType.DELETED)) } + // 3. Unstaged files (Modified, Missing) + jgitStatus.modified.forEach { if (processedPaths.add(it)) unstaged.add(FileChange(it, ChangeType.MODIFIED)) } + jgitStatus.missing.forEach { if (processedPaths.add(it)) unstaged.add(FileChange(it, ChangeType.DELETED)) } - jgitStatus.untracked.forEach { untracked.add(FileChange(it, ChangeType.UNTRACKED)) } + // 4. Untracked files + jgitStatus.untracked.forEach { if (processedPaths.add(it)) untracked.add(FileChange(it, ChangeType.UNTRACKED)) } - jgitStatus.conflicting.forEach { conflicted.add(FileChange(it, ChangeType.CONFLICTED)) } val isMerging = repository.repositoryState == RepositoryState.MERGING GitStatus( isClean = jgitStatus.isClean, - hasConflicts = jgitStatus.conflicting.isNotEmpty(), + hasConflicts = conflicted.isNotEmpty(), isMerging = isMerging, staged = staged, unstaged = unstaged, diff --git a/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml index 4491356f7e..512b0fc4b9 100644 --- a/resources/src/main/res/values/strings.xml +++ b/resources/src/main/res/values/strings.xml @@ -1289,6 +1289,10 @@ Merge Conflicts Abort Merge Are you sure you want to abort the current merge? All conflict resolutions will be discarded. + You have unsaved changes. Would you like to save them before proceeding? + Proceed without saving + Save before proceeding + Mark Resolved Starting project creation for %1$s