Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
3efacae
feat(ADFA-2880): Commit changes
dara-abijo-adfa Mar 12, 2026
432b2d5
feat(ADFA-2880): View git commit history
dara-abijo-adfa Mar 13, 2026
a9f43ef
Merge branch 'stage' into ADFA-2880-git-commit
dara-abijo-adfa Mar 13, 2026
11c78bb
Merge branch 'stage' into ADFA-2880-git-commit-operations
dara-abijo-adfa Mar 13, 2026
7c3965c
Merge branch 'stage' into ADFA-2880-git-commit-operations
dara-abijo-adfa Mar 16, 2026
84fb63a
feat(ADFA-2880): Set up git preferences
dara-abijo-adfa Mar 16, 2026
9238949
feat(ADFA-2880): Set commit author
dara-abijo-adfa Mar 16, 2026
3712a0c
feat(ADFA-2880): Show commits that have not been pushed
dara-abijo-adfa Mar 16, 2026
561c233
fix(ADFA-2880): Update view id
dara-abijo-adfa Mar 16, 2026
020432d
Merge branch 'stage' into ADFA-2880-git-commit-operations
dara-abijo-adfa Mar 16, 2026
26c26ee
refactor(ADFA-2880): Extract string resource
dara-abijo-adfa Mar 18, 2026
b5f4e2d
feat(ADFA-2880): Handle commit history errors
dara-abijo-adfa Mar 18, 2026
c70eaf1
fic(ADFA-2880): Clear inputs after commit succeeds
dara-abijo-adfa Mar 18, 2026
edc90aa
fix(ADFA-2880): Resolve issue with showing author error message prema…
dara-abijo-adfa Mar 18, 2026
29e7f34
feat(ADFA-2880): Show git author identity values
dara-abijo-adfa Mar 18, 2026
7fcd137
fix(ADFA-2880): Prevent memory leaks
dara-abijo-adfa Mar 19, 2026
30dd707
fix(ADFA-2880): Verify clone success before state update
dara-abijo-adfa Mar 19, 2026
4847480
Merge branch 'stage' into ADFA-2880-git-commit-operations
dara-abijo-adfa Mar 19, 2026
4b5f63e
Merge branch 'stage' into ADFA-2880-git-commit-operations
dara-abijo-adfa Mar 20, 2026
a976a09
feat(ADFA-2880): Differentiate no repo from no commits
dara-abijo-adfa Mar 20, 2026
6f42415
fix(ADFA-2880): Fix race condition
dara-abijo-adfa Mar 20, 2026
0f2d9a5
Merge branch 'stage' into ADFA-2880-git-commit-operations
dara-abijo-adfa Mar 20, 2026
5d4c206
feat(ADFA-2880): Colour-code local and remote commits
dara-abijo-adfa Mar 20, 2026
87b3f61
refactor(ADFA-2880): Move strings to resources module
dara-abijo-adfa Mar 20, 2026
7d01a8a
Merge branch 'stage' into ADFA-2880-git-commit-operations
dara-abijo-adfa Mar 23, 2026
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
@@ -1,22 +1,33 @@
package com.itsaky.androidide.fragments.git

import android.content.Intent
import android.os.Bundle
import android.text.SpannableString
import android.text.Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
import android.text.method.LinkMovementMethod
import android.text.style.ClickableSpan
import android.view.View
import android.widget.TextView
import androidx.core.widget.doAfterTextChanged
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.itsaky.androidide.R
import com.itsaky.androidide.activities.PreferencesActivity
import com.itsaky.androidide.databinding.FragmentGitBottomSheetBinding
import com.itsaky.androidide.fragments.git.adapter.GitFileChangeAdapter
import com.itsaky.androidide.preferences.internal.GitPreferences
import com.itsaky.androidide.viewmodel.GitBottomSheetViewModel
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
import androidx.fragment.app.activityViewModels

class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) {

private val viewModel: GitBottomSheetViewModel by activityViewModels()
private lateinit var adapter: GitFileChangeAdapter
private lateinit var fileChangeAdapter: GitFileChangeAdapter

private var _binding: FragmentGitBottomSheetBinding? = null
private val binding get() = _binding!!
Expand All @@ -25,31 +36,157 @@ class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) {
super.onViewCreated(view, savedInstanceState)
_binding = FragmentGitBottomSheetBinding.bind(view)

adapter = GitFileChangeAdapter(onFileClicked = { change ->
// Show diff in a dialog when changed file is clicked
val dialog = GitDiffViewerDialog.newInstance(change.path)
dialog.show(childFragmentManager, "GitDiffViewerDialog")
})
fileChangeAdapter = GitFileChangeAdapter(
onFileClicked = { change ->
// Show diff in a dialog when changed file is clicked
val dialog = GitDiffViewerDialog.newInstance(change.path)
dialog.show(childFragmentManager, "GitDiffViewerDialog")
},
onSelectionChanged = {
validateCommitButton()
}
)

binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
binding.recyclerView.adapter = adapter
binding.recyclerView.adapter = fileChangeAdapter

viewLifecycleOwner.lifecycleScope.launch {
viewModel.gitStatus.collectLatest { status ->
combine(
viewModel.isGitRepository,
viewModel.gitStatus
) { isRepo, status ->
val allChanges = status.staged + status.unstaged + status.untracked + status.conflicted

if (allChanges.isEmpty()) {
binding.emptyView.visibility = View.VISIBLE
binding.recyclerView.visibility = View.GONE
} else {
binding.emptyView.visibility = View.GONE
binding.recyclerView.visibility = View.VISIBLE
adapter.submitList(allChanges)
when {
!isRepo -> binding.apply {
emptyView.visibility = View.VISIBLE
emptyView.text = getString(R.string.not_a_git_repo)
recyclerView.visibility = View.GONE
commitSection.visibility = View.GONE
authorWarning.visibility = View.GONE
commitHistoryButton.visibility = View.GONE
}
allChanges.isEmpty() -> binding.apply {
emptyView.visibility = View.VISIBLE
emptyView.text = getString(R.string.no_uncommitted_changes)
recyclerView.visibility = View.GONE
commitSection.visibility = View.GONE
authorWarning.visibility = View.GONE
commitHistoryButton.visibility = View.VISIBLE
}
else -> {
binding.apply {
emptyView.visibility = View.GONE
recyclerView.visibility = View.VISIBLE
commitSection.visibility = View.VISIBLE
authorWarning.visibility = if (hasAuthorInfo()) View.GONE else View.VISIBLE
commitHistoryButton.visibility = View.VISIBLE
}
fileChangeAdapter.submitList(allChanges)
}
}
}.collectLatest { }
}

setupCommitUI()

binding.commitHistoryButton.setOnClickListener {
val dialog = GitCommitHistoryDialog()
dialog.show(childFragmentManager, "CommitHistoryDialog")
}

}

override fun onResume() {
super.onResume()
updateAuthorUI()
}

private fun updateAuthorUI() {
val hasAuthor = hasAuthorInfo()
val allChanges = viewModel.gitStatus.value.staged + viewModel.gitStatus.value.unstaged + viewModel.gitStatus.value.untracked + viewModel.gitStatus.value.conflicted
binding.authorWarning.visibility = if (!hasAuthor && allChanges.isNotEmpty()) View.VISIBLE else View.GONE
validateCommitButton()
}

private fun hasAuthorInfo(): Boolean {
return !GitPreferences.userName.isNullOrBlank() && !GitPreferences.userEmail.isNullOrBlank()
}

private fun setupCommitUI() {
binding.commitSummary.doAfterTextChanged { validateCommitButton() }
binding.commitDescription.doAfterTextChanged { validateCommitButton() }

binding.authorAvatar.setOnClickListener {
showAuthorPopup()
}

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

private fun showAuthorPopup() {
val name = GitPreferences.userName.orEmpty().ifBlank { getString(R.string.author_not_set) }
val email = GitPreferences.userEmail.orEmpty().ifBlank { getString(R.string.author_not_set) }
val message = getString(R.string.git_committing_as, name) + "\n" +
getString(R.string.git_committing_email, email) + "\n\n" +
getString(R.string.git_update_config_in_preferences)

val spannable = SpannableString(message)
val preferencesText = getString(R.string.git_update_config_in_preferences)
val startIndex = message.indexOf(preferencesText)

val builder = MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.idepref_git_author_title)
.setMessage(spannable)
.setPositiveButton(android.R.string.ok, null)

val dialog = builder.create()

if (startIndex != -1) {
spannable.setSpan(
object : ClickableSpan() {
override fun onClick(widget: View) {
val intent = Intent(
requireContext(),
PreferencesActivity::class.java
)
dialog.dismiss()
startActivity(intent)
}
},
startIndex,
startIndex + preferencesText.length,
SPAN_EXCLUSIVE_EXCLUSIVE
)
}

dialog.show()
dialog.findViewById<TextView>(android.R.id.message)?.movementMethod = LinkMovementMethod.getInstance()
}

private fun validateCommitButton() {
val hasSummary = !binding.commitSummary.text.isNullOrBlank()
val hasSelection = fileChangeAdapter.selectedFiles.isNotEmpty()
val hasAuthor = hasAuthorInfo()
binding.commitButton.isEnabled = hasSummary && hasSelection && hasAuthor
}

override fun onDestroyView() {
super.onDestroyView()
_binding = null
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package com.itsaky.androidide.fragments.git

import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import com.itsaky.androidide.R
import com.itsaky.androidide.databinding.DialogGitCommitHistoryBinding
import com.itsaky.androidide.fragments.git.adapter.GitCommitHistoryAdapter
import com.itsaky.androidide.git.core.models.CommitHistoryUiState
import com.itsaky.androidide.viewmodel.GitBottomSheetViewModel
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch

class GitCommitHistoryDialog : DialogFragment() {

private var _binding: DialogGitCommitHistoryBinding? = null
private val binding get() = _binding!!
private val viewModel: GitBottomSheetViewModel by activityViewModels()
private lateinit var commitHistoryAdapter: GitCommitHistoryAdapter

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setStyle(STYLE_NORMAL, R.style.Theme_AndroidIDE)
}

override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
_binding = DialogGitCommitHistoryBinding.inflate(inflater, container, false)
return binding.root
}

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
commitHistoryAdapter = GitCommitHistoryAdapter()
val linearLayoutManager = LinearLayoutManager(requireContext())
val dividerItemDecoration = DividerItemDecoration(
binding.rvCommitHistory.context,
linearLayoutManager.orientation
)
binding.rvCommitHistory.apply {
layoutManager = linearLayoutManager
addItemDecoration(dividerItemDecoration)
adapter = commitHistoryAdapter
}

viewModel.getCommitHistoryList()

viewLifecycleOwner.lifecycleScope.launch {
viewModel.commitHistory.collectLatest { state ->
when (state) {
is CommitHistoryUiState.Loading -> {
binding.progressBar.visibility = View.VISIBLE
binding.emptyView.visibility = View.GONE
binding.rvCommitHistory.visibility = View.GONE
}
is CommitHistoryUiState.Empty -> {
binding.progressBar.visibility = View.GONE
binding.emptyView.visibility = View.VISIBLE
binding.emptyView.setText(R.string.no_commit_history)
binding.rvCommitHistory.visibility = View.GONE
}
is CommitHistoryUiState.Error -> {
binding.progressBar.visibility = View.GONE
binding.emptyView.visibility = View.VISIBLE
binding.emptyView.text = state.message ?: getString(R.string.unknown_error)
binding.rvCommitHistory.visibility = View.GONE
}
is CommitHistoryUiState.Success -> {
binding.progressBar.visibility = View.GONE
binding.emptyView.visibility = View.GONE
binding.rvCommitHistory.visibility = View.VISIBLE
commitHistoryAdapter.submitList(state.commits)
}
}
}
}
}

override fun onDestroyView() {
super.onDestroyView()
_binding = null
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
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
import androidx.recyclerview.widget.RecyclerView
import com.itsaky.androidide.R
import com.itsaky.androidide.databinding.ItemGitCommitBinding
import com.itsaky.androidide.git.core.models.GitCommit
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale

class GitCommitHistoryAdapter :
ListAdapter<GitCommit, GitCommitHistoryAdapter.ViewHolder>(DiffCallback()) {

override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val binding = ItemGitCommitBinding.inflate(
LayoutInflater.from(parent.context), parent, false
)
return ViewHolder(binding)
}

override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val commit = getItem(position)
holder.bind(commit)
}

class ViewHolder(private val binding: ItemGitCommitBinding) :
RecyclerView.ViewHolder(binding.root) {

private val dateFormat = SimpleDateFormat("dd MMM yyyy, HH:mm", Locale.getDefault())

fun bind(commit: GitCommit) {
binding.apply {
tvCommitMessage.text = commit.message
tvCommitAuthor.text = commit.authorName
tvCommitTime.text = dateFormat.format(Date(commit.timestamp))
imgNotPushedCommit.setImageResource(if (commit.hasBeenPushed) R.drawable.ic_cloud_done else R.drawable.ic_upload)
imgNotPushedCommit.contentDescription = if (commit.hasBeenPushed) {
binding.root.context.getString(R.string.commit_pushed)
} else {
binding.root.context.getString(R.string.commit_not_pushed)
}
}
}
}

class DiffCallback : DiffUtil.ItemCallback<GitCommit>() {
override fun areItemsTheSame(oldItem: GitCommit, newItem: GitCommit): Boolean {
return oldItem.hash == newItem.hash
}

override fun areContentsTheSame(oldItem: GitCommit, newItem: GitCommit): Boolean {
return oldItem == newItem
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ 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 onFileClicked: (FileChange) -> Unit,
private val onSelectionChanged: (Int) -> Unit = {}
) : ListAdapter<FileChange, GitFileChangeAdapter.ViewHolder>(DiffCallback()) {

// Keep track of which files are selected to be committed
Expand Down Expand Up @@ -48,6 +49,7 @@ class GitFileChangeAdapter(
} else {
selectedFiles.remove(change.path)
}
onSelectionChanged(selectedFiles.size)
}
}
}
Expand Down
Loading
Loading