Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
fedb5ca
feat(ADFA-2881): Create checkout method
dara-abijo-adfa Aug 18, 2026
1043373
feat(ADFA-2881): Update viewmodel state
dara-abijo-adfa Aug 18, 2026
21ec0a5
feat(ADFA-2881): Setup branch selection UI
dara-abijo-adfa Aug 18, 2026
c40dcbd
feat(ADFA-2881): Switch branches
dara-abijo-adfa Aug 18, 2026
8837e3f
feat(ADFA-2881): Create local and remote subsections
dara-abijo-adfa Aug 18, 2026
58d7074
feat(ADFA-2881): Polish branches UI
dara-abijo-adfa Aug 19, 2026
79398d7
format(ADFA-2881): Apply spotless
dara-abijo-adfa Aug 19, 2026
09e51ba
feat(ADFA-2881): Add merge action button
dara-abijo-adfa Aug 19, 2026
ded8ac5
feat(ADFA-2881): Execute merge and manage merge result
dara-abijo-adfa Aug 19, 2026
f2d4031
feat(ADFA-2881): Implement merging
dara-abijo-adfa Aug 19, 2026
1b7b443
refactor(ADFA-2881): Separate title string resources
dara-abijo-adfa Aug 19, 2026
26fe818
feat(ADFA-2881): Keep the remote name in remote branches
dara-abijo-adfa Aug 19, 2026
168eec0
feat(ADFA-2881): Represent branch-load failures explicitly
dara-abijo-adfa Aug 19, 2026
3ad2364
refactor(ADFA-2881): Reposition branches popup window
dara-abijo-adfa Aug 19, 2026
590d8aa
feat(ADFA-2881): Re-throw coroutine cancellation
dara-abijo-adfa Aug 19, 2026
32eca83
feat(ADFA-2881): Add accessibility attributes to the new branch views
dara-abijo-adfa Aug 19, 2026
d9d45a9
fix(ADFA-2881): Eliminate reflection in tests
dara-abijo-adfa Aug 19, 2026
6c6b9da
fix(ADFA-2881): Address PR issues
dara-abijo-adfa Aug 19, 2026
333d21a
docs(ADFA-2881): Add documentation to functions
dara-abijo-adfa Aug 19, 2026
3e7e0dc
fix(ADFA-2881): Bug fixes
dara-abijo-adfa Aug 19, 2026
61134f7
Merge branch 'stage' into ADFA-2881-manage-git-branches
dara-abijo-adfa Aug 20, 2026
9534df4
feat(ADFA-2881): Modify branches popup window
dara-abijo-adfa Aug 20, 2026
04cf1ba
feat(ADFA-2881): Clear the closed repo before opening a replacement
dara-abijo-adfa Aug 20, 2026
7c1b70f
Merge branch 'ADFA-2881-manage-git-branches' of https://github.com/ap…
dara-abijo-adfa Aug 20, 2026
e274c6c
fix(ADFA-2881): Bug fixes
dara-abijo-adfa Aug 20, 2026
6a2fb8d
Merge branch 'stage' into ADFA-2881-manage-git-branches
dara-abijo-adfa Aug 20, 2026
1ca9170
Merge branch 'stage' into ADFA-2881-manage-git-branches
dara-abijo-adfa Aug 21, 2026
d93c527
fix(ADFA-2881): Fix repository leaks
dara-abijo-adfa Aug 21, 2026
157d5ba
Merge branch 'stage' into ADFA-2881-manage-git-branches
dara-abijo-adfa Aug 24, 2026
b65130c
refactor(ADFA-2881): Simplify checkout method
dara-abijo-adfa Aug 24, 2026
3b88a3a
Merge branch 'stage' into ADFA-2881-manage-git-branches
dara-abijo-adfa Aug 25, 2026
8a0eef9
feat(ADFA-2881): Show dialog before merging branches
dara-abijo-adfa Aug 26, 2026
24b061a
feat(ADFA-2881): UI fixes
dara-abijo-adfa Aug 26, 2026
0245f2b
feat(ADFA-2881): Show dialog before branch checkout
dara-abijo-adfa Aug 26, 2026
feec6c9
Merge branch 'stage' into ADFA-2881-manage-git-branches
dara-abijo-adfa Aug 26, 2026
3d50b48
Merge branch 'stage' into ADFA-2881-manage-git-branches
dara-abijo-adfa Aug 27, 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

Large diffs are not rendered by default.

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
package com.itsaky.androidide.fragments.git

import android.content.Context
import android.graphics.Color
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.PopupWindow
import androidx.core.graphics.drawable.toDrawable
import androidx.core.widget.doAfterTextChanged
import androidx.recyclerview.widget.LinearLayoutManager
import com.itsaky.androidide.R
import com.itsaky.androidide.databinding.PopupGitBranchesBinding
import com.itsaky.androidide.fragments.git.adapter.GitBranchAdapter
import com.itsaky.androidide.fragments.git.adapter.GitBranchListItem
import com.itsaky.androidide.git.core.models.GitBranch
import com.itsaky.androidide.viewmodel.GitBottomSheetViewModel
import com.itsaky.androidide.viewmodel.GitBottomSheetViewModel.BranchesUiState

/**
* Popup window that displays the list of local and remote branches with search,
* branch creation, checkout, and merge capabilities.
*
* @param context The host context.
* @param onBranchSelected Callback invoked when a branch row is tapped to switch/checkout.
* @param onNewBranchRequested Callback invoked when the "New branch" action is tapped.
* @param onMergeBranch Optional callback invoked when a branch's merge button is tapped.
*/
class GitBranchPopupWindow(
private val context: Context,
private val onBranchSelected: (GitBranch) -> Unit,
private val onNewBranchRequested: () -> Unit,
private val onMergeBranch: ((GitBranch) -> Unit)? = null,
) {
private val binding: PopupGitBranchesBinding =
PopupGitBranchesBinding.inflate(
Comment thread
dara-abijo-adfa marked this conversation as resolved.
LayoutInflater.from(context),
)

private val popupWindow: PopupWindow =
PopupWindow(
binding.root,
ViewGroup.LayoutParams.WRAP_CONTENT,
ViewGroup.LayoutParams.WRAP_CONTENT,
true,
).apply {
setBackgroundDrawable(Color.TRANSPARENT.toDrawable())
elevation = 16f
}

private val adapter: GitBranchAdapter =
GitBranchAdapter(
onBranchSelected = { branch ->
popupWindow.dismiss()
onBranchSelected(branch)
},
onMergeClicked = { branch ->
popupWindow.dismiss()
onMergeBranch?.invoke(branch)
},
)

private var allBranches: List<GitBranch> = emptyList()

init {
binding.rvBranches.layoutManager = LinearLayoutManager(context)
binding.rvBranches.adapter = adapter

binding.btnNewBranch.setOnClickListener {
popupWindow.dismiss()
onNewBranchRequested()
}

binding.etSearchBranches.doAfterTextChanged { text ->
filterBranches(text?.toString())
}
}

/**
* Updates the branch list and loading indicator state in the popup.
*
* @param state The current [BranchesUiState] to render.
*/
fun setBranchesState(state: BranchesUiState) {
when (state) {
is BranchesUiState.Loading -> {
binding.branchesProgress.visibility = View.VISIBLE
binding.tvEmptyBranches.visibility = View.GONE
binding.rvBranches.visibility = View.VISIBLE
}

is BranchesUiState.Success -> {
binding.branchesProgress.visibility = View.GONE
allBranches = state.branches
filterBranches(binding.etSearchBranches.text?.toString())
}

is BranchesUiState.None -> {
binding.branchesProgress.visibility = View.GONE
binding.tvEmptyBranches.visibility = View.GONE
binding.rvBranches.visibility = View.VISIBLE
allBranches = emptyList()
adapter.submitList(emptyList())
}

is BranchesUiState.Error -> {
binding.branchesProgress.visibility = View.GONE
allBranches = emptyList()
binding.tvEmptyBranches.text = context.getString(R.string.git_branches_load_failed)
binding.tvEmptyBranches.visibility = View.VISIBLE
binding.rvBranches.visibility = View.GONE
adapter.submitList(emptyList())
}
}
}

private fun filterBranches(query: String?) {
val filtered =
if (query.isNullOrBlank()) {
allBranches
} else {
allBranches.filter { branch ->
val displayName = getDisplayName(branch)
branch.name.contains(query, ignoreCase = true) || displayName.contains(query, ignoreCase = true)
}
}

val localBranches = filtered.filter { !it.isRemote }
val remoteBranches = filtered.filter { it.isRemote }

val items = mutableListOf<GitBranchListItem>()

if (localBranches.isNotEmpty()) {
items.add(GitBranchListItem.Header(context.getString(R.string.git_local_branches)))
localBranches.forEach { branch ->
items.add(GitBranchListItem.BranchItem(branch, getDisplayName(branch)))
}
}

if (remoteBranches.isNotEmpty()) {
items.add(GitBranchListItem.Header(context.getString(R.string.git_remote_branches)))
remoteBranches.forEach { branch ->
items.add(GitBranchListItem.BranchItem(branch, getDisplayName(branch)))
}
}

if (items.isEmpty()) {
binding.tvEmptyBranches.text = context.getString(R.string.git_no_branches_found)
binding.tvEmptyBranches.visibility = View.VISIBLE
binding.rvBranches.visibility = View.GONE
} else {
binding.tvEmptyBranches.visibility = View.GONE
binding.rvBranches.visibility = View.VISIBLE
}

adapter.submitList(items)
}

private fun getDisplayName(branch: GitBranch): String = branch.name

/**
* Displays the popup window centered horizontally on the screen below [anchor].
*
* Clears any existing search input, resets the filtered branch list,
* dynamically sizes the popup, and positions the dropdown centered on screen.
*
* @param anchor The view below which the popup dropdown should be displayed.
*/
fun show(anchor: View) {
binding.etSearchBranches.text?.clear()
filterBranches(null)
val displayWidth = context.resources.displayMetrics.widthPixels
val density = context.resources.displayMetrics.density
val maxWidth = (360 * density).toInt()
val margin = (32 * density).toInt()
val targetWidth = maxWidth.coerceAtMost(displayWidth - margin)
popupWindow.width = targetWidth

val location = IntArray(2)
anchor.getLocationOnScreen(location)
val anchorX = location[0]
val desiredX = (displayWidth - targetWidth) / 2
val xOffset = desiredX - anchorX

popupWindow.showAsDropDown(anchor, xOffset, (8 * density).toInt())
}

/**
* Dismisses the popup window if it is currently showing.
*/
fun dismiss() {
if (popupWindow.isShowing) {
popupWindow.dismiss()
}
}
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
package com.itsaky.androidide.fragments.git.adapter

import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
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.ItemGitBranchBinding
import com.itsaky.androidide.git.core.models.GitBranch

/**
* Represents items rendered in the Git branch selection list.
*/
sealed class GitBranchListItem {
/**
* Section header dividing branch categories (e.g., Local vs Remote).
*/
data class Header(
val title: String,
) : GitBranchListItem()

/**
* Selectable branch item row.
*/
data class BranchItem(
val branch: GitBranch,
val displayName: String,
) : GitBranchListItem()
}

/**
* RecyclerView adapter for displaying local and remote Git branches with selection
* and optional merge action callbacks.
*
* @param onBranchSelected Callback invoked when a branch row is tapped to switch/checkout.
* @param onMergeClicked Optional callback invoked when the merge button for a branch is tapped.
*/
class GitBranchAdapter(
private val onBranchSelected: (GitBranch) -> Unit,
private val onMergeClicked: ((GitBranch) -> Unit)? = null,
) : ListAdapter<GitBranchListItem, RecyclerView.ViewHolder>(DiffCallback) {
companion object {
private const val VIEW_TYPE_HEADER = 0
private const val VIEW_TYPE_BRANCH = 1
}

override fun getItemViewType(position: Int): Int =
when (getItem(position)) {
is GitBranchListItem.Header -> VIEW_TYPE_HEADER
is GitBranchListItem.BranchItem -> VIEW_TYPE_BRANCH
}

override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int,
): RecyclerView.ViewHolder {
val inflater = LayoutInflater.from(parent.context)
return if (viewType == VIEW_TYPE_HEADER) {
val view = inflater.inflate(R.layout.item_git_branch_header, parent, false)
HeaderViewHolder(view)
} else {
val binding = ItemGitBranchBinding.inflate(inflater, parent, false)
BranchViewHolder(binding)
}
}

override fun onBindViewHolder(
holder: RecyclerView.ViewHolder,
position: Int,
) {
when (val item = getItem(position)) {
is GitBranchListItem.Header -> (holder as HeaderViewHolder).bind(item)
is GitBranchListItem.BranchItem -> (holder as BranchViewHolder).bind(item)
}
}

inner class HeaderViewHolder(
itemView: View,
) : RecyclerView.ViewHolder(itemView) {
private val tvTitle: TextView = itemView.findViewById(R.id.tvHeaderTitle)

fun bind(item: GitBranchListItem.Header) {
tvTitle.text = item.title
}
}

inner class BranchViewHolder(
private val binding: ItemGitBranchBinding,
) : RecyclerView.ViewHolder(binding.root) {
fun bind(item: GitBranchListItem.BranchItem) {
val context = binding.root.context
binding.tvBranchName.text = item.displayName

if (item.branch.isCurrent) {
binding.ivActiveCheck.visibility = View.VISIBLE
binding.imgBranchIcon.visibility = View.GONE
binding.btnMergeAction.visibility = View.GONE
binding.root.contentDescription = context.getString(R.string.current_branch_name, item.displayName)
} else {
binding.ivActiveCheck.visibility = View.GONE
binding.imgBranchIcon.visibility = View.VISIBLE
binding.btnMergeAction.visibility = if (onMergeClicked != null) View.VISIBLE else View.GONE
binding.btnMergeAction.contentDescription = context.getString(R.string.git_merge_branch, item.displayName)
Comment thread
dara-abijo-adfa marked this conversation as resolved.
binding.root.contentDescription = item.displayName
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

binding.btnMergeAction.setOnClickListener {
onMergeClicked?.invoke(item.branch)
}

binding.root.setOnClickListener {
onBranchSelected(item.branch)
}
}
}

private object DiffCallback : DiffUtil.ItemCallback<GitBranchListItem>() {
override fun areItemsTheSame(
oldItem: GitBranchListItem,
newItem: GitBranchListItem,
): Boolean =
when {
oldItem is GitBranchListItem.Header && newItem is GitBranchListItem.Header -> {
oldItem.title == newItem.title
}

oldItem is GitBranchListItem.BranchItem && newItem is GitBranchListItem.BranchItem -> {
oldItem.branch.fullName == newItem.branch.fullName
}

else -> {
false
}
}

override fun areContentsTheSame(
oldItem: GitBranchListItem,
newItem: GitBranchListItem,
): Boolean = oldItem == newItem
}
}
Loading
Loading