Skip to content

Commit 0c5fcc8

Browse files
ADFA-2881: Manage git branches (#1696)
* feat(ADFA-2881): Create checkout method * feat(ADFA-2881): Update viewmodel state * feat(ADFA-2881): Setup branch selection UI * feat(ADFA-2881): Switch branches * feat(ADFA-2881): Create local and remote subsections * feat(ADFA-2881): Polish branches UI * format(ADFA-2881): Apply spotless * feat(ADFA-2881): Add merge action button * feat(ADFA-2881): Execute merge and manage merge result * feat(ADFA-2881): Implement merging * refactor(ADFA-2881): Separate title string resources * feat(ADFA-2881): Keep the remote name in remote branches * feat(ADFA-2881): Represent branch-load failures explicitly * refactor(ADFA-2881): Reposition branches popup window * feat(ADFA-2881): Re-throw coroutine cancellation * feat(ADFA-2881): Add accessibility attributes to the new branch views * fix(ADFA-2881): Eliminate reflection in tests * fix(ADFA-2881): Address PR issues * docs(ADFA-2881): Add documentation to functions * fix(ADFA-2881): Bug fixes * feat(ADFA-2881): Modify branches popup window * feat(ADFA-2881): Clear the closed repo before opening a replacement * fix(ADFA-2881): Bug fixes * fix(ADFA-2881): Fix repository leaks * refactor(ADFA-2881): Simplify checkout method * feat(ADFA-2881): Show dialog before merging branches * feat(ADFA-2881): UI fixes * feat(ADFA-2881): Show dialog before branch checkout
1 parent 371a3ff commit 0c5fcc8

18 files changed

Lines changed: 2925 additions & 954 deletions

File tree

app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt

Lines changed: 685 additions & 416 deletions
Large diffs are not rendered by default.
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
package com.itsaky.androidide.fragments.git
2+
3+
import android.content.Context
4+
import android.graphics.Color
5+
import android.view.LayoutInflater
6+
import android.view.View
7+
import android.view.ViewGroup
8+
import android.widget.PopupWindow
9+
import androidx.core.graphics.drawable.toDrawable
10+
import androidx.core.widget.doAfterTextChanged
11+
import androidx.recyclerview.widget.LinearLayoutManager
12+
import com.itsaky.androidide.R
13+
import com.itsaky.androidide.databinding.PopupGitBranchesBinding
14+
import com.itsaky.androidide.fragments.git.adapter.GitBranchAdapter
15+
import com.itsaky.androidide.fragments.git.adapter.GitBranchListItem
16+
import com.itsaky.androidide.git.core.models.GitBranch
17+
import com.itsaky.androidide.viewmodel.GitBottomSheetViewModel
18+
import com.itsaky.androidide.viewmodel.GitBottomSheetViewModel.BranchesUiState
19+
20+
/**
21+
* Popup window that displays the list of local and remote branches with search,
22+
* branch creation, checkout, and merge capabilities.
23+
*
24+
* @param context The host context.
25+
* @param onBranchSelected Callback invoked when a branch row is tapped to switch/checkout.
26+
* @param onNewBranchRequested Callback invoked when the "New branch" action is tapped.
27+
* @param onMergeBranch Optional callback invoked when a branch's merge button is tapped.
28+
*/
29+
class GitBranchPopupWindow(
30+
private val context: Context,
31+
private val onBranchSelected: (GitBranch) -> Unit,
32+
private val onNewBranchRequested: () -> Unit,
33+
private val onMergeBranch: ((GitBranch) -> Unit)? = null,
34+
) {
35+
private val binding: PopupGitBranchesBinding =
36+
PopupGitBranchesBinding.inflate(
37+
LayoutInflater.from(context),
38+
)
39+
40+
private val popupWindow: PopupWindow =
41+
PopupWindow(
42+
binding.root,
43+
ViewGroup.LayoutParams.WRAP_CONTENT,
44+
ViewGroup.LayoutParams.WRAP_CONTENT,
45+
true,
46+
).apply {
47+
setBackgroundDrawable(Color.TRANSPARENT.toDrawable())
48+
elevation = 16f
49+
}
50+
51+
private val adapter: GitBranchAdapter =
52+
GitBranchAdapter(
53+
onBranchSelected = { branch ->
54+
popupWindow.dismiss()
55+
onBranchSelected(branch)
56+
},
57+
onMergeClicked = { branch ->
58+
popupWindow.dismiss()
59+
onMergeBranch?.invoke(branch)
60+
},
61+
)
62+
63+
private var allBranches: List<GitBranch> = emptyList()
64+
65+
init {
66+
binding.rvBranches.layoutManager = LinearLayoutManager(context)
67+
binding.rvBranches.adapter = adapter
68+
69+
binding.btnNewBranch.setOnClickListener {
70+
popupWindow.dismiss()
71+
onNewBranchRequested()
72+
}
73+
74+
binding.etSearchBranches.doAfterTextChanged { text ->
75+
filterBranches(text?.toString())
76+
}
77+
}
78+
79+
/**
80+
* Updates the branch list and loading indicator state in the popup.
81+
*
82+
* @param state The current [BranchesUiState] to render.
83+
*/
84+
fun setBranchesState(state: BranchesUiState) {
85+
when (state) {
86+
is BranchesUiState.Loading -> {
87+
binding.branchesProgress.visibility = View.VISIBLE
88+
binding.tvEmptyBranches.visibility = View.GONE
89+
binding.rvBranches.visibility = View.VISIBLE
90+
}
91+
92+
is BranchesUiState.Success -> {
93+
binding.branchesProgress.visibility = View.GONE
94+
allBranches = state.branches
95+
filterBranches(binding.etSearchBranches.text?.toString())
96+
}
97+
98+
is BranchesUiState.None -> {
99+
binding.branchesProgress.visibility = View.GONE
100+
binding.tvEmptyBranches.visibility = View.GONE
101+
binding.rvBranches.visibility = View.VISIBLE
102+
allBranches = emptyList()
103+
adapter.submitList(emptyList())
104+
}
105+
106+
is BranchesUiState.Error -> {
107+
binding.branchesProgress.visibility = View.GONE
108+
allBranches = emptyList()
109+
binding.tvEmptyBranches.text = context.getString(R.string.git_branches_load_failed)
110+
binding.tvEmptyBranches.visibility = View.VISIBLE
111+
binding.rvBranches.visibility = View.GONE
112+
adapter.submitList(emptyList())
113+
}
114+
}
115+
}
116+
117+
private fun filterBranches(query: String?) {
118+
val filtered =
119+
if (query.isNullOrBlank()) {
120+
allBranches
121+
} else {
122+
allBranches.filter { branch ->
123+
val displayName = getDisplayName(branch)
124+
branch.name.contains(query, ignoreCase = true) || displayName.contains(query, ignoreCase = true)
125+
}
126+
}
127+
128+
val localBranches = filtered.filter { !it.isRemote }
129+
val remoteBranches = filtered.filter { it.isRemote }
130+
131+
val items = mutableListOf<GitBranchListItem>()
132+
133+
if (localBranches.isNotEmpty()) {
134+
items.add(GitBranchListItem.Header(context.getString(R.string.git_local_branches)))
135+
localBranches.forEach { branch ->
136+
items.add(GitBranchListItem.BranchItem(branch, getDisplayName(branch)))
137+
}
138+
}
139+
140+
if (remoteBranches.isNotEmpty()) {
141+
items.add(GitBranchListItem.Header(context.getString(R.string.git_remote_branches)))
142+
remoteBranches.forEach { branch ->
143+
items.add(GitBranchListItem.BranchItem(branch, getDisplayName(branch)))
144+
}
145+
}
146+
147+
if (items.isEmpty()) {
148+
binding.tvEmptyBranches.text = context.getString(R.string.git_no_branches_found)
149+
binding.tvEmptyBranches.visibility = View.VISIBLE
150+
binding.rvBranches.visibility = View.GONE
151+
} else {
152+
binding.tvEmptyBranches.visibility = View.GONE
153+
binding.rvBranches.visibility = View.VISIBLE
154+
}
155+
156+
adapter.submitList(items)
157+
}
158+
159+
private fun getDisplayName(branch: GitBranch): String = branch.name
160+
161+
/**
162+
* Displays the popup window centered horizontally on the screen below [anchor].
163+
*
164+
* Clears any existing search input, resets the filtered branch list,
165+
* dynamically sizes the popup, and positions the dropdown centered on screen.
166+
*
167+
* @param anchor The view below which the popup dropdown should be displayed.
168+
*/
169+
fun show(anchor: View) {
170+
binding.etSearchBranches.text?.clear()
171+
filterBranches(null)
172+
val displayWidth = context.resources.displayMetrics.widthPixels
173+
val density = context.resources.displayMetrics.density
174+
val maxWidth = (360 * density).toInt()
175+
val margin = (32 * density).toInt()
176+
val targetWidth = maxWidth.coerceAtMost(displayWidth - margin)
177+
popupWindow.width = targetWidth
178+
179+
val location = IntArray(2)
180+
anchor.getLocationOnScreen(location)
181+
val anchorX = location[0]
182+
val desiredX = (displayWidth - targetWidth) / 2
183+
val xOffset = desiredX - anchorX
184+
185+
popupWindow.showAsDropDown(anchor, xOffset, (8 * density).toInt())
186+
}
187+
188+
/**
189+
* Dismisses the popup window if it is currently showing.
190+
*/
191+
fun dismiss() {
192+
if (popupWindow.isShowing) {
193+
popupWindow.dismiss()
194+
}
195+
}
196+
}
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
package com.itsaky.androidide.fragments.git.adapter
2+
3+
import android.view.LayoutInflater
4+
import android.view.View
5+
import android.view.ViewGroup
6+
import android.widget.TextView
7+
import androidx.recyclerview.widget.DiffUtil
8+
import androidx.recyclerview.widget.ListAdapter
9+
import androidx.recyclerview.widget.RecyclerView
10+
import com.itsaky.androidide.R
11+
import com.itsaky.androidide.databinding.ItemGitBranchBinding
12+
import com.itsaky.androidide.git.core.models.GitBranch
13+
14+
/**
15+
* Represents items rendered in the Git branch selection list.
16+
*/
17+
sealed class GitBranchListItem {
18+
/**
19+
* Section header dividing branch categories (e.g., Local vs Remote).
20+
*/
21+
data class Header(
22+
val title: String,
23+
) : GitBranchListItem()
24+
25+
/**
26+
* Selectable branch item row.
27+
*/
28+
data class BranchItem(
29+
val branch: GitBranch,
30+
val displayName: String,
31+
) : GitBranchListItem()
32+
}
33+
34+
/**
35+
* RecyclerView adapter for displaying local and remote Git branches with selection
36+
* and optional merge action callbacks.
37+
*
38+
* @param onBranchSelected Callback invoked when a branch row is tapped to switch/checkout.
39+
* @param onMergeClicked Optional callback invoked when the merge button for a branch is tapped.
40+
*/
41+
class GitBranchAdapter(
42+
private val onBranchSelected: (GitBranch) -> Unit,
43+
private val onMergeClicked: ((GitBranch) -> Unit)? = null,
44+
) : ListAdapter<GitBranchListItem, RecyclerView.ViewHolder>(DiffCallback) {
45+
companion object {
46+
private const val VIEW_TYPE_HEADER = 0
47+
private const val VIEW_TYPE_BRANCH = 1
48+
}
49+
50+
override fun getItemViewType(position: Int): Int =
51+
when (getItem(position)) {
52+
is GitBranchListItem.Header -> VIEW_TYPE_HEADER
53+
is GitBranchListItem.BranchItem -> VIEW_TYPE_BRANCH
54+
}
55+
56+
override fun onCreateViewHolder(
57+
parent: ViewGroup,
58+
viewType: Int,
59+
): RecyclerView.ViewHolder {
60+
val inflater = LayoutInflater.from(parent.context)
61+
return if (viewType == VIEW_TYPE_HEADER) {
62+
val view = inflater.inflate(R.layout.item_git_branch_header, parent, false)
63+
HeaderViewHolder(view)
64+
} else {
65+
val binding = ItemGitBranchBinding.inflate(inflater, parent, false)
66+
BranchViewHolder(binding)
67+
}
68+
}
69+
70+
override fun onBindViewHolder(
71+
holder: RecyclerView.ViewHolder,
72+
position: Int,
73+
) {
74+
when (val item = getItem(position)) {
75+
is GitBranchListItem.Header -> (holder as HeaderViewHolder).bind(item)
76+
is GitBranchListItem.BranchItem -> (holder as BranchViewHolder).bind(item)
77+
}
78+
}
79+
80+
inner class HeaderViewHolder(
81+
itemView: View,
82+
) : RecyclerView.ViewHolder(itemView) {
83+
private val tvTitle: TextView = itemView.findViewById(R.id.tvHeaderTitle)
84+
85+
fun bind(item: GitBranchListItem.Header) {
86+
tvTitle.text = item.title
87+
}
88+
}
89+
90+
inner class BranchViewHolder(
91+
private val binding: ItemGitBranchBinding,
92+
) : RecyclerView.ViewHolder(binding.root) {
93+
fun bind(item: GitBranchListItem.BranchItem) {
94+
val context = binding.root.context
95+
binding.tvBranchName.text = item.displayName
96+
97+
if (item.branch.isCurrent) {
98+
binding.ivActiveCheck.visibility = View.VISIBLE
99+
binding.imgBranchIcon.visibility = View.GONE
100+
binding.btnMergeAction.visibility = View.GONE
101+
binding.root.contentDescription = context.getString(R.string.current_branch_name, item.displayName)
102+
} else {
103+
binding.ivActiveCheck.visibility = View.GONE
104+
binding.imgBranchIcon.visibility = View.VISIBLE
105+
binding.btnMergeAction.visibility = if (onMergeClicked != null) View.VISIBLE else View.GONE
106+
binding.btnMergeAction.contentDescription = context.getString(R.string.git_merge_branch, item.displayName)
107+
binding.root.contentDescription = item.displayName
108+
}
109+
110+
binding.btnMergeAction.setOnClickListener {
111+
onMergeClicked?.invoke(item.branch)
112+
}
113+
114+
binding.root.setOnClickListener {
115+
onBranchSelected(item.branch)
116+
}
117+
}
118+
}
119+
120+
private object DiffCallback : DiffUtil.ItemCallback<GitBranchListItem>() {
121+
override fun areItemsTheSame(
122+
oldItem: GitBranchListItem,
123+
newItem: GitBranchListItem,
124+
): Boolean =
125+
when {
126+
oldItem is GitBranchListItem.Header && newItem is GitBranchListItem.Header -> {
127+
oldItem.title == newItem.title
128+
}
129+
130+
oldItem is GitBranchListItem.BranchItem && newItem is GitBranchListItem.BranchItem -> {
131+
oldItem.branch.fullName == newItem.branch.fullName
132+
}
133+
134+
else -> {
135+
false
136+
}
137+
}
138+
139+
override fun areContentsTheSame(
140+
oldItem: GitBranchListItem,
141+
newItem: GitBranchListItem,
142+
): Boolean = oldItem == newItem
143+
}
144+
}

0 commit comments

Comments
 (0)