Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
- Notifications
You must be signed in to change notification settings - Fork 54
ADFA-2881: Manage git branches#1696
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Uh oh!
There was an error while loading. Please reload this page.
Changes from all commits
fedb5ca104337321ec0a5c40dcbd8837e3f58d707479398d709e51baded8ac5f2d40311b7b44326fe818168eec03ad2364590d8aa32eca83d9d45a96c6b9da333d21a3e7e0dc61134f79534df404cf1ba7c1b70fe274c6c6a2fb8d1ca9170d93c527157d5bab65130c3b88a3a8a0eef924b061a0245f2bfeec6c93d50b48File filter
Filter by extension
Conversations
Uh oh!
There was an error while loading. Please reload this page.
Jump to
Uh oh!
There was an error while loading. Please reload this page.
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
Uh oh!
There was an error while loading. Please reload this page.
| Original file line number | Diff line number | Diff 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( | ||
| 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 number | Diff line number | Diff 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) | ||
dara-abijo-adfa marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| binding.root.contentDescription = item.displayName | ||
coderabbitai[bot] marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| } | ||
| 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 | ||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.