diff --git a/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt index 1d86a1db28..08b24a3b1d 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt @@ -279,25 +279,28 @@ open class EditorHandlerActivity : * [onPause] snapshot **and** the in-memory buffer is still clean ([CodeEditorView.isModified] is * false). A clean buffer may still have undo history after [IDEEditor.markUnmodified] / save; we * reload anyway so external edits are not ignored. Never replaces buffers with unsaved edits. + * + * @param force If true, reloads even if the buffer is modified or the timestamp hasn't changed. */ - private fun checkForExternalFileChanges() { + fun checkForExternalFileChanges(force: Boolean = false) { val openFiles = editorViewModel.getOpenedFiles() - if (openFiles.isEmpty() || fileTimestamps.isEmpty()) return + if (openFiles.isEmpty() || (fileTimestamps.isEmpty() && !force)) return lifecycleScope.launch(Dispatchers.IO) { openFiles.forEach { file -> - val lastKnownTimestamp = fileTimestamps[file.absolutePath] ?: return@forEach + val lastKnownTimestamp = fileTimestamps[file.absolutePath] ?: 0L val currentTimestamp = file.lastModified() - if (currentTimestamp > lastKnownTimestamp) { + if (currentTimestamp > lastKnownTimestamp || force) { val newContent = runCatching { file.readText() }.getOrNull() ?: return@forEach withContext(Dispatchers.Main) { val editorView = getEditorForFile(file) ?: return@withContext - if (editorView.isModified) return@withContext + if (editorView.isModified && !force) return@withContext val ideEditor = editorView.editor ?: return@withContext ideEditor.setText(newContent) editorView.markAsSaved() + fileTimestamps[file.absolutePath] = currentTimestamp updateTabs() } } 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 12e17b154a..c8d7058607 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 @@ -12,24 +12,31 @@ import androidx.core.widget.doAfterTextChanged import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.LinearLayoutManager +import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.itsaky.androidide.R import com.itsaky.androidide.activities.PreferencesActivity +import com.itsaky.androidide.activities.editor.EditorHandlerActivity import com.itsaky.androidide.databinding.DialogGitCredentialsBinding 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.preferences.internal.GitPreferences import com.itsaky.androidide.utils.flashSuccess +import com.itsaky.androidide.viewmodel.BottomSheetViewModel import com.itsaky.androidide.viewmodel.GitBottomSheetViewModel +import com.itsaky.androidide.viewmodel.GitBottomSheetViewModel.PullUiState import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine import kotlinx.coroutines.launch import org.koin.androidx.viewmodel.ext.android.activityViewModel +import java.io.File class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) { private val viewModel: GitBottomSheetViewModel by activityViewModel() + private val bottomSheetViewModel: BottomSheetViewModel by activityViewModel() private lateinit var fileChangeAdapter: GitFileChangeAdapter private lateinit var credentialsManager: GitCredentialsManager @@ -43,9 +50,27 @@ class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) { fileChangeAdapter = GitFileChangeAdapter( onFileClicked = { change -> - // Show diff in a dialog when changed file is clicked - val dialog = GitDiffViewerDialog.newInstance(change.path) - dialog.show(childFragmentManager, "GitDiffViewerDialog") + when (change.type) { + ChangeType.CONFLICTED -> { + // Open conflicted file in editor + val activity = requireActivity() + if (activity is EditorHandlerActivity) { + viewLifecycleOwner.lifecycleScope.launch { + val repo = viewModel.currentRepository + repo?.let { + activity.checkForExternalFileChanges(force = true) + activity.openFile(File(repo.rootDir, change.path)) + bottomSheetViewModel.setSheetState(BottomSheetBehavior.STATE_COLLAPSED) + } + } + } + } + else -> { + // Show diff in a dialog when changed file is clicked + val dialog = GitDiffViewerDialog.newInstance(change.path) + dialog.show(childFragmentManager, "GitDiffViewerDialog") + } + } }, onSelectionChanged = { validateCommitButton() @@ -81,6 +106,7 @@ class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) { commitSection.visibility = View.GONE authorWarning.visibility = View.GONE commitHistoryButton.visibility = View.GONE + btnAbortMerge.visibility = View.GONE } allChanges.isEmpty() -> binding.apply { emptyView.visibility = View.VISIBLE @@ -89,6 +115,7 @@ class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) { commitSection.visibility = View.GONE authorWarning.visibility = View.GONE commitHistoryButton.visibility = View.VISIBLE + btnAbortMerge.visibility = View.GONE } else -> { binding.apply { @@ -97,6 +124,7 @@ class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) { commitSection.visibility = View.VISIBLE authorWarning.visibility = if (hasAuthorInfo()) View.GONE else View.VISIBLE commitHistoryButton.visibility = View.VISIBLE + btnAbortMerge.visibility = if (status.isMerging) View.VISIBLE else View.GONE } fileChangeAdapter.submitList(allChanges) } @@ -134,6 +162,22 @@ class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) { binding.commitSummary.doAfterTextChanged { validateCommitButton() } binding.commitDescription.doAfterTextChanged { validateCommitButton() } + binding.btnAbortMerge.setOnClickListener { + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.abort_merge) + .setMessage(R.string.confirm_abort_merge) + .setPositiveButton(R.string.abort_merge) { _, _ -> + viewModel.abortMerge { + val activity = requireActivity() + if (activity is EditorHandlerActivity) { + activity.checkForExternalFileChanges(force = true) + } + } + } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + binding.authorAvatar.setOnClickListener { showAuthorPopup() } @@ -214,25 +258,38 @@ class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) { viewLifecycleOwner.lifecycleScope.launch { viewModel.pullState.collectLatest { state -> when (state) { - is GitBottomSheetViewModel.PullUiState.Idle -> { + is PullUiState.Idle -> { binding.btnPull.isEnabled = true binding.pullProgress.visibility = View.GONE } - is GitBottomSheetViewModel.PullUiState.Pulling -> { + is PullUiState.Pulling -> { binding.btnPull.isEnabled = false binding.pullProgress.visibility = View.VISIBLE } - is GitBottomSheetViewModel.PullUiState.Success -> { + is PullUiState.Success -> { binding.btnPull.isEnabled = true binding.pullProgress.visibility = View.GONE flashSuccess(R.string.pull_successful) viewModel.resetPullState() } - is GitBottomSheetViewModel.PullUiState.Error -> { + is PullUiState.Conflicts -> { + binding.btnPull.isEnabled = true + binding.pullProgress.visibility = View.GONE + val message = state.message ?: getString(R.string.info_merge_conflicts) + MaterialAlertDialogBuilder(requireContext()) + .setTitle(getString(R.string.merge_conflicts)) + .setMessage(message) + .setPositiveButton(android.R.string.ok, null) + .show() + viewModel.resetPullState() + } + is PullUiState.Error -> { binding.btnPull.isEnabled = true binding.pullProgress.visibility = View.GONE val message = - state.errorResId?.let { getString(it) } ?: state.message + state.message ?: state.errorResId?.let { resId -> + if (state.errorArgs != null) getString(resId, *state.errorArgs.toTypedArray()) else getString(resId) + } MaterialAlertDialogBuilder(requireContext()) .setTitle(R.string.pull_failed) .setMessage(message) diff --git a/app/src/main/java/com/itsaky/androidide/fragments/git/GitCommitHistoryDialog.kt b/app/src/main/java/com/itsaky/androidide/fragments/git/GitCommitHistoryDialog.kt index 824de50c0e..841ff279cc 100644 --- a/app/src/main/java/com/itsaky/androidide/fragments/git/GitCommitHistoryDialog.kt +++ b/app/src/main/java/com/itsaky/androidide/fragments/git/GitCommitHistoryDialog.kt @@ -132,8 +132,11 @@ class GitCommitHistoryDialog : DialogFragment() { is GitBottomSheetViewModel.PushUiState.Error -> { binding.btnPush.isEnabled = true binding.pushProgress.visibility = View.GONE - val message = - state.errorResId?.let { getString(it) } ?: state.message + val message = if (state.errorResId != null && state.errorResId != R.string.unknown_error) { + getString(state.errorResId) + } else { + state.message ?: getString(R.string.unknown_error) + } MaterialAlertDialogBuilder(requireContext()) .setTitle(R.string.push_failed) .setMessage(message) 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 40bed1d722..dac3257c0f 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModel.kt @@ -22,8 +22,10 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow 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 import org.greenrobot.eventbus.EventBus @@ -219,8 +221,14 @@ class GitBottomSheetViewModel(private val credentialsManager: GitCredentialsMana } else null private fun handlePushError(update: RemoteRefUpdate) { + val resId = if (update.status == RemoteRefUpdate.Status.REJECTED_NONFASTFORWARD) { + R.string.push_rejected_nonfastforward + } else { + R.string.unknown_error + } _pushState.value = PushUiState.Error( - update.message ?: update.status.name + message = update.message ?: update.status.name, + errorResId = resId ) } @@ -256,6 +264,10 @@ class GitBottomSheetViewModel(private val credentialsManager: GitCredentialsMana } handlePullSuccess(username, token) + } catch (e: CheckoutConflictException) { + log.error("Pull failed with checkout conflict", e) + val paths = e.conflictingPaths?.joinToString("\n") ?: "" + _pullState.value = PullUiState.Error(errorResId = R.string.checkout_conflict_message, errorArgs = listOf(paths)) } catch (e: Exception) { log.error("Pull failed", e) if (e.message?.contains("not authorized", ignoreCase = true) == true) { @@ -274,8 +286,15 @@ class GitBottomSheetViewModel(private val credentialsManager: GitCredentialsMana } private fun handlePullError(result: PullResult) { - val status = result.mergeResult?.mergeStatus?.name ?: "Unknown error" - _pullState.value = PullUiState.Error("Pull failed: $status") + val mergeStatus = result.mergeResult?.mergeStatus + val statusName = mergeStatus?.name ?: "Unknown error" + + if (mergeStatus == MergeStatus.CONFLICTING) { + _pullState.value = PullUiState.Conflicts() + refreshStatus() + } else { + _pullState.value = PullUiState.Error("Pull failed: $statusName") + } } private fun handlePullSuccess( @@ -302,7 +321,8 @@ class GitBottomSheetViewModel(private val credentialsManager: GitCredentialsMana object Idle : PullUiState() object Pulling : PullUiState() object Success : PullUiState() - data class Error(val message: String? = null, val errorResId: Int? = R.string.unknown_error) : PullUiState() + data class Conflicts(val message: String? = null) : PullUiState() + data class Error(val message: String? = null, val errorResId: Int? = R.string.unknown_error, val errorArgs: List? = null) : PullUiState() } sealed class PushUiState { @@ -337,4 +357,16 @@ class GitBottomSheetViewModel(private val credentialsManager: GitCredentialsMana refreshStatus() } + fun abortMerge(onSuccess: (() -> Unit)? = null) { + viewModelScope.launch { + try { + currentRepository?.abortMerge() + refreshStatus() + onSuccess?.invoke() + } catch (e: Exception) { + log.error("Failed to abort merge", 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 dda3b4bbad..8eff81abb2 100644 --- a/app/src/main/res/layout/fragment_git_bottom_sheet.xml +++ b/app/src/main/res/layout/fragment_git_bottom_sheet.xml @@ -140,7 +140,7 @@ android:layout_marginBottom="8dp" android:enabled="false" android:text="@string/commit" - app:layout_constraintBottom_toTopOf="@id/commitHistoryButton" /> + app:layout_constraintBottom_toTopOf="@id/btnAbortMerge" /> + + , val unstaged: List, val untracked: List, @@ -15,6 +16,7 @@ data class GitStatus( val EMPTY = GitStatus( isClean = true, hasConflicts = false, + isMerging = false, staged = emptyList(), unstaged = emptyList(), untracked = emptyList(), diff --git a/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml index 3a67d9d109..eabc0a8fd1 100644 --- a/resources/src/main/res/values/strings.xml +++ b/resources/src/main/res/values/strings.xml @@ -619,6 +619,7 @@ Warning GitHub + Checkout conflict: Please commit or stash your local changes before pulling. Conflicting files:\n%1$s Choose a different location New directory name Error @@ -1267,6 +1268,7 @@ Pushing… Push successful! Push failed + Push rejected: Your branch is behind the remote. Please pull to resolve conflicts before pushing. Pull Pulling… Pull successful! @@ -1275,4 +1277,8 @@ Enter your credentials to sync changes with the remote repository. Clear Credentials Authentication failed. Ensure your Personal Access Token has \'repo\' permissions, or try clearing and re-entering credentials. + Pull completed with conflicts!\nPlease select the conflicted files from the list, resolve the highlighted issues in the editor, save file(s) and then commit your changes. + Merge Conflicts + Abort Merge + Are you sure you want to abort the current merge? All conflict resolutions will be discarded.