Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -66,14 +66,16 @@ 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")
}
}
},
onSelectionChanged = {
validateCommitButton()
},
onResolveConflict = { change ->
viewModel.resolveConflict(change.path)
}
)

Expand Down Expand Up @@ -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()
}
}
}
}
Expand Down Expand Up @@ -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()
}
}
}
}
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<FileChange, GitFileChangeAdapter.ViewHolder>(DiffCallback()) {

// Keep track of which files are selected to be committed
Expand Down Expand Up @@ -40,24 +42,59 @@ 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))
}
}
}

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)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
}
}

}
14 changes: 14 additions & 0 deletions app/src/main/res/layout/item_git_file_change.xml
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackground"
Expand All @@ -25,6 +26,19 @@
android:singleLine="true"
android:textAppearance="?attr/textAppearanceBody2" />

<com.google.android.material.button.MaterialButton
android:id="@+id/btnMarkResolved"
style="@style/Widget.MaterialComponents.Button.TextButton.Icon"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:minWidth="0dp"
android:minHeight="0dp"
android:padding="0dp"
android:text="@string/mark_as_resolved"
android:visibility="gone"
app:icon="@drawable/ic_check" />

<ImageView
android:id="@+id/statusIcon"
android:layout_width="16dp"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,23 +67,36 @@ class JGitRepository(override val rootDir: File) : GitRepository {
val unstaged = mutableListOf<FileChange>()
val untracked = mutableListOf<FileChange>()
val conflicted = mutableListOf<FileChange>()

// Track unique paths to avoid duplicates across categories
// Priority: Conflicted > Staged > Unstaged > Untracked
val processedPaths = mutableSetOf<String>()

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,
Expand Down
4 changes: 4 additions & 0 deletions resources/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -1289,6 +1289,10 @@
<string name="merge_conflicts">Merge Conflicts</string>
<string name="abort_merge">Abort Merge</string>
<string name="confirm_abort_merge">Are you sure you want to abort the current merge? All conflict resolutions will be discarded.</string>
<string name="msg_save_before_git_action">You have unsaved changes. Would you like to save them before proceeding?</string>
<string name="no_save_before_git_action">Proceed without saving</string>
<string name="save_before_git_action">Save before proceeding</string>
<string name="mark_as_resolved">Mark Resolved</string>

<!-- Templates -->
<string name="template_exec_info_basepath">Starting project creation for %1$s</string>
Expand Down
Loading