From fedb5ca23fafdcb2a0ea7fcfec06abd48567837a Mon Sep 17 00:00:00 2001 From: Oluwadara Abijo Date: Tue, 18 Aug 2026 16:11:18 +0100 Subject: [PATCH 01/28] feat(ADFA-2881): Create checkout method --- .../androidide/git/core/GitRepository.kt | 8 +++ .../androidide/git/core/JGitRepository.kt | 51 +++++++++++++- .../androidide/git/core/JGitRepositoryTest.kt | 70 +++++++++++++++++++ 3 files changed, 127 insertions(+), 2 deletions(-) create mode 100644 git-core/src/test/java/com/itsaky/androidide/git/core/JGitRepositoryTest.kt diff --git a/git-core/src/main/java/com/itsaky/androidide/git/core/GitRepository.kt b/git-core/src/main/java/com/itsaky/androidide/git/core/GitRepository.kt index 539d98d34b..72fee368a5 100644 --- a/git-core/src/main/java/com/itsaky/androidide/git/core/GitRepository.kt +++ b/git-core/src/main/java/com/itsaky/androidide/git/core/GitRepository.kt @@ -46,4 +46,12 @@ interface GitRepository : Closeable { // Merge Operations suspend fun merge(branchName: String): MergeResult suspend fun abortMerge() + + // Branch Operations + suspend fun checkout( + branchName: String, + createNew: Boolean = false, + startPoint: String? = null + ) } + diff --git a/git-core/src/main/java/com/itsaky/androidide/git/core/JGitRepository.kt b/git-core/src/main/java/com/itsaky/androidide/git/core/JGitRepository.kt index acfafaf30b..bb8b13a911 100644 --- a/git-core/src/main/java/com/itsaky/androidide/git/core/JGitRepository.kt +++ b/git-core/src/main/java/com/itsaky/androidide/git/core/JGitRepository.kt @@ -7,6 +7,7 @@ import com.itsaky.androidide.git.core.models.GitCommit import com.itsaky.androidide.git.core.models.GitStatus import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import org.eclipse.jgit.api.CreateBranchCommand import org.eclipse.jgit.api.Git import org.eclipse.jgit.api.ListBranchCommand.ListMode import org.eclipse.jgit.api.MergeResult @@ -119,11 +120,17 @@ class JGitRepository(override val rootDir: File) : GitRepository { override suspend fun getBranches(): List = withContext(Dispatchers.IO) { val currentBranch = repository.fullBranch git.branchList().setListMode(ListMode.ALL).call().map { ref -> + val isRemote = ref.name.startsWith(Constants.R_REMOTES) + val shortName = Repository.shortenRefName(ref.name) + val remoteName = if (isRemote) { + shortName.substringBefore('/') + } else null GitBranch( - name = Repository.shortenRefName(ref.name), + name = shortName, fullName = ref.name, isCurrent = ref.name == currentBranch, - isRemote = ref.name.startsWith(Constants.R_REMOTES) + isRemote = isRemote, + remoteName = remoteName ) } } @@ -308,8 +315,48 @@ class JGitRepository(override val rootDir: File) : GitRepository { } } + override suspend fun checkout( + branchName: String, + createNew: Boolean, + startPoint: String? + ) { + withContext(Dispatchers.IO) { + val checkoutCommand = git.checkout() + if (createNew) { + checkoutCommand.setCreateBranch(true) + checkoutCommand.setName(branchName) + if (!startPoint.isNullOrBlank()) { + checkoutCommand.setStartPoint(startPoint) + } + } else { + val isRemoteRef = branchName.startsWith(Constants.R_REMOTES) || branchName.startsWith("origin/") + if (isRemoteRef) { + val fullRemoteRef = if (branchName.startsWith(Constants.R_REMOTES)) { + branchName + } else { + "${Constants.R_REMOTES}$branchName" + } + val localName = Repository.shortenRefName(fullRemoteRef).substringAfter('/') + val localRef = repository.findRef("${Constants.R_HEADS}$localName") + if (localRef != null) { + checkoutCommand.setName(localName) + } else { + checkoutCommand.setCreateBranch(true) + .setName(localName) + .setStartPoint(fullRemoteRef) + .setUpstreamMode(CreateBranchCommand.SetupUpstreamMode.TRACK) + } + } else { + checkoutCommand.setName(branchName) + } + } + checkoutCommand.call() + } + } + override fun close() { repository.close() git.close() } } + diff --git a/git-core/src/test/java/com/itsaky/androidide/git/core/JGitRepositoryTest.kt b/git-core/src/test/java/com/itsaky/androidide/git/core/JGitRepositoryTest.kt new file mode 100644 index 0000000000..39ad0d2b93 --- /dev/null +++ b/git-core/src/test/java/com/itsaky/androidide/git/core/JGitRepositoryTest.kt @@ -0,0 +1,70 @@ +package com.itsaky.androidide.git.core + +import kotlinx.coroutines.runBlocking +import org.eclipse.jgit.api.Git +import org.junit.Assert.* +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +class JGitRepositoryTest { + + @get:Rule + val tempFolder = TemporaryFolder() + + private lateinit var repoDir: File + private lateinit var jgitRepo: JGitRepository + + @Before + fun setUp() { + repoDir = tempFolder.newFolder("test-repo") + val git = Git.init().setDirectory(repoDir).call() + + // Create an initial commit so HEAD points to a valid commit + val dummyFile = File(repoDir, "file.txt") + dummyFile.writeText("initial content") + git.add().addFilepattern("file.txt").call() + git.commit().setMessage("Initial commit").setAuthor("Test", "test@example.com").call() + + jgitRepo = JGitRepository(repoDir) + } + + @Test + fun testGetCurrentBranchAndGetBranches() = runBlocking { + val currentBranch = jgitRepo.getCurrentBranch() + assertNotNull(currentBranch) + assertTrue(currentBranch!!.isCurrent) + + val branches = jgitRepo.getBranches() + assertFalse(branches.isEmpty()) + assertTrue(branches.any { it.isCurrent }) + } + + @Test + fun testCreateAndCheckoutBranch() = runBlocking { + val newBranchName = "feature-test" + jgitRepo.checkout(newBranchName, createNew = true) + + val currentBranch = jgitRepo.getCurrentBranch() + assertNotNull(currentBranch) + assertEquals(newBranchName, currentBranch!!.name) + + val branches = jgitRepo.getBranches() + assertTrue(branches.any { it.name == newBranchName && it.isCurrent }) + } + + @Test + fun testSwitchExistingBranches() = runBlocking { + val initialBranch = jgitRepo.getCurrentBranch()!!.name + + // Create feature branch + jgitRepo.checkout("feature-1", createNew = true) + assertEquals("feature-1", jgitRepo.getCurrentBranch()!!.name) + + // Switch back to initial branch + jgitRepo.checkout(initialBranch, createNew = false) + assertEquals(initialBranch, jgitRepo.getCurrentBranch()!!.name) + } +} From 10433736b2f9d2d9095715aa2932268e8d2c6ffe Mon Sep 17 00:00:00 2001 From: Oluwadara Abijo Date: Tue, 18 Aug 2026 16:28:35 +0100 Subject: [PATCH 02/28] feat(ADFA-2881): Update viewmodel state --- .../viewmodel/GitBottomSheetViewModel.kt | 76 +++++++++++++ .../viewmodel/GitBottomSheetViewModelTest.kt | 106 ++++++++++++++++++ 2 files changed, 182 insertions(+) create mode 100644 app/src/test/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModelTest.kt diff --git a/app/src/main/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModel.kt b/app/src/main/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModel.kt index 988bd198ad..7d98d8422b 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModel.kt @@ -12,6 +12,7 @@ import com.itsaky.androidide.git.core.GitCredentialsManager import com.itsaky.androidide.git.core.GitRepository import com.itsaky.androidide.git.core.GitRepositoryManager import com.itsaky.androidide.git.core.models.CommitHistoryUiState +import com.itsaky.androidide.git.core.models.GitBranch import com.itsaky.androidide.git.core.models.GitStatus import com.itsaky.androidide.preferences.internal.GitPreferences import com.itsaky.androidide.projects.IProjectManager @@ -33,6 +34,7 @@ import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.ThreadMode import org.slf4j.LoggerFactory import java.io.File +import kotlin.time.Duration.Companion.milliseconds class GitBottomSheetViewModel( private val credentialsManager: GitCredentialsManager, @@ -46,6 +48,12 @@ class GitBottomSheetViewModel( private val _currentBranch = MutableStateFlow(null) val currentBranch: StateFlow = _currentBranch.asStateFlow() + private val _branches = MutableStateFlow>(emptyList()) + val branches: StateFlow> = _branches.asStateFlow() + + private val _checkoutState = MutableStateFlow(CheckoutUiState.Idle) + val checkoutState: StateFlow = _checkoutState.asStateFlow() + private val _commitHistory = MutableStateFlow(CommitHistoryUiState.Loading) val commitHistory: StateFlow = _commitHistory.asStateFlow() @@ -64,6 +72,7 @@ class GitBottomSheetViewModel( private var pullResetJob: Job? = null private var pushResetJob: Job? = null + private var checkoutResetJob: Job? = null var currentRepository: GitRepository? = null private set @@ -104,21 +113,66 @@ class GitBottomSheetViewModel( val status = repo.getStatus() _gitStatus.value = status _currentBranch.value = repo.getCurrentBranch()?.name + _branches.value = repo.getBranches() getLocalCommitsCount() } ?: run { _gitStatus.value = GitStatus.EMPTY _currentBranch.value = null + _branches.value = emptyList() _localCommitsCount.value = 0 } } catch (e: Exception) { log.error("Failed to refresh git status", e) _gitStatus.value = GitStatus.EMPTY _currentBranch.value = null + _branches.value = emptyList() _localCommitsCount.value = 0 } } } + fun fetchBranches() { + viewModelScope.launch { + try { + val repo = currentRepository ?: return@launch + _branches.value = repo.getBranches() + } catch (e: Exception) { + log.error("Failed to fetch branches", e) + _branches.value = emptyList() + } + } + } + + fun checkoutBranch( + branchName: String, + createNew: Boolean = false, + startPoint: String? = null, + onSuccess: (() -> Unit)? = null, + ) { + checkoutResetJob?.cancel() + viewModelScope.launch { + try { + _checkoutState.value = CheckoutUiState.CheckingOut + val repository = currentRepository ?: return@launch + repository.checkout(branchName, createNew, startPoint) + refreshStatus() + _checkoutState.value = CheckoutUiState.Success(branchName) + onSuccess?.invoke() + } catch (e: CheckoutConflictException) { + log.error("Checkout conflict occurred", e) + _checkoutState.value = CheckoutUiState.Conflicts(e.conflictingPaths ?: emptyList()) + } catch (e: Exception) { + log.error("Checkout failed", e) + _checkoutState.value = CheckoutUiState.Error(message = e.message) + } finally { + checkoutResetJob = viewModelScope.launch { + delay(3000.milliseconds) + _checkoutState.value = CheckoutUiState.Idle + } + } + } + } + suspend fun getLocalCommitsCount() { _localCommitsCount.value = currentRepository?.getLocalCommitsCount() ?: 0 } @@ -334,6 +388,28 @@ class GitBottomSheetViewModel( _pushState.value = PushUiState.Idle } + fun resetCheckoutState() { + checkoutResetJob?.cancel() + _checkoutState.value = CheckoutUiState.Idle + } + + sealed class CheckoutUiState { + object Idle : CheckoutUiState() + + object CheckingOut : CheckoutUiState() + + data class Success(val branchName: String) : CheckoutUiState() + + data class Conflicts( + val conflictingPaths: List = emptyList(), + ) : CheckoutUiState() + + data class Error( + val message: String? = null, + val errorResId: Int? = R.string.unknown_error, + ) : CheckoutUiState() + } + sealed class PullUiState { object Idle : PullUiState() diff --git a/app/src/test/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModelTest.kt b/app/src/test/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModelTest.kt new file mode 100644 index 0000000000..7b35598cfa --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModelTest.kt @@ -0,0 +1,106 @@ +package com.itsaky.androidide.viewmodel + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import com.itsaky.androidide.git.core.GitCredentialsManager +import com.itsaky.androidide.git.core.GitRepository +import com.itsaky.androidide.git.core.models.GitBranch +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.unmockkAll +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.eclipse.jgit.api.errors.CheckoutConflictException +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 + +@RunWith(JUnit4::class) +@OptIn(ExperimentalCoroutinesApi::class) +class GitBottomSheetViewModelTest { + + @get:Rule + val instantExecutorRule = InstantTaskExecutorRule() + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + private val credentialsManager = mockk(relaxed = true) + private val repository = mockk(relaxed = true) + private lateinit var viewModel: GitBottomSheetViewModel + + @Before + fun setup() { + viewModel = GitBottomSheetViewModel(credentialsManager, isNetworkConnected = { true }) + // Inject mock repository manually + val field = GitBottomSheetViewModel::class.java.getDeclaredField("currentRepository") + field.isAccessible = true + field.set(viewModel, repository) + } + + @After + fun tearDown() { + unmockkAll() + } + + @Test + fun `fetchBranches updates branches state`() = runTest { + val mockBranches = listOf( + GitBranch(name = "main", fullName = "refs/heads/main", isCurrent = true, isRemote = false), + GitBranch(name = "feature", fullName = "refs/heads/feature", isCurrent = false, isRemote = false) + ) + coEvery { repository.getBranches() } returns mockBranches + + viewModel.fetchBranches() + advanceUntilIdle() + + assertEquals(mockBranches, viewModel.branches.value) + } + + @Test + fun `checkoutBranch success updates checkoutState to Success and then resets to Idle`() = runTest { + coEvery { repository.checkout("feature", false, null) } returns Unit + coEvery { repository.getStatus() } returns mockk(relaxed = true) + + var successCalled = false + viewModel.checkoutBranch("feature", onSuccess = { successCalled = true }) + testScheduler.advanceTimeBy(100) + + val state = viewModel.checkoutState.value + assertTrue(state is GitBottomSheetViewModel.CheckoutUiState.Success) + assertEquals("feature", (state as GitBottomSheetViewModel.CheckoutUiState.Success).branchName) + assertTrue(successCalled) + coVerify { repository.checkout("feature", false, null) } + + // Advance past 3000ms delay to verify state resets to Idle + testScheduler.advanceTimeBy(3000) + assertTrue(viewModel.checkoutState.value is GitBottomSheetViewModel.CheckoutUiState.Idle) + } + + @Test + fun `checkoutBranch conflict updates checkoutState to Conflicts and then resets to Idle`() = runTest { + val conflictPaths = listOf("file1.txt", "file2.txt") + val exception = mockk(relaxed = true) + every { exception.conflictingPaths } returns conflictPaths + every { exception.getConflictingPaths() } returns conflictPaths + coEvery { repository.checkout("feature", false, null) } throws exception + + viewModel.checkoutBranch("feature") + testScheduler.advanceTimeBy(100) + + val state = viewModel.checkoutState.value + assertTrue(state is GitBottomSheetViewModel.CheckoutUiState.Conflicts) + assertEquals(conflictPaths, (state as GitBottomSheetViewModel.CheckoutUiState.Conflicts).conflictingPaths) + + // Advance past 3000ms delay to verify state resets to Idle + testScheduler.advanceTimeBy(3000) + assertTrue(viewModel.checkoutState.value is GitBottomSheetViewModel.CheckoutUiState.Idle) + } +} From 21ec0a5d8e559a2c95ad1af0438a375358448299 Mon Sep 17 00:00:00 2001 From: Oluwadara Abijo Date: Tue, 18 Aug 2026 17:43:31 +0100 Subject: [PATCH 03/28] feat(ADFA-2881): Setup branch selection UI --- .../fragments/git/GitBranchPopupWindow.kt | 76 ++++++++++++++++++ .../fragments/git/adapter/GitBranchAdapter.kt | 52 ++++++++++++ .../res/layout/dialog_git_create_branch.xml | 25 ++++++ .../res/layout/fragment_git_bottom_sheet.xml | 11 ++- app/src/main/res/layout/item_git_branch.xml | 68 ++++++++++++++++ .../main/res/layout/popup_git_branches.xml | 79 +++++++++++++++++++ resources/src/main/res/drawable/ic_branch.xml | 10 +++ resources/src/main/res/values/strings.xml | 10 +++ 8 files changed, 330 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/com/itsaky/androidide/fragments/git/GitBranchPopupWindow.kt create mode 100644 app/src/main/java/com/itsaky/androidide/fragments/git/adapter/GitBranchAdapter.kt create mode 100644 app/src/main/res/layout/dialog_git_create_branch.xml create mode 100644 app/src/main/res/layout/item_git_branch.xml create mode 100644 app/src/main/res/layout/popup_git_branches.xml create mode 100644 resources/src/main/res/drawable/ic_branch.xml diff --git a/app/src/main/java/com/itsaky/androidide/fragments/git/GitBranchPopupWindow.kt b/app/src/main/java/com/itsaky/androidide/fragments/git/GitBranchPopupWindow.kt new file mode 100644 index 0000000000..8931f3606c --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/fragments/git/GitBranchPopupWindow.kt @@ -0,0 +1,76 @@ +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.widget.doAfterTextChanged +import androidx.recyclerview.widget.LinearLayoutManager +import com.itsaky.androidide.databinding.PopupGitBranchesBinding +import com.itsaky.androidide.fragments.git.adapter.GitBranchAdapter +import com.itsaky.androidide.git.core.models.GitBranch +import androidx.core.graphics.drawable.toDrawable + +class GitBranchPopupWindow( + private val context: Context, + private val onBranchSelected: (GitBranch) -> Unit, + private val onNewBranchRequested: () -> Unit +) { + + 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 { branch -> + popupWindow.dismiss() + onBranchSelected(branch) + } + + private var allBranches: List = emptyList() + + init { + binding.rvBranches.layoutManager = LinearLayoutManager(context) + binding.rvBranches.adapter = adapter + + binding.btnNewBranch.setOnClickListener { + popupWindow.dismiss() + onNewBranchRequested() + } + + binding.etSearchBranches.doAfterTextChanged { text -> + filterBranches(text?.toString()) + } + } + + fun setBranches(branches: List) { + allBranches = branches + filterBranches(binding.etSearchBranches.text?.toString()) + } + + private fun filterBranches(query: String?) { + val filtered = if (query.isNullOrBlank()) { + allBranches + } else { + allBranches.filter { it.name.contains(query, ignoreCase = true) } + } + adapter.submitList(filtered) + } + + fun show(anchor: View) { + binding.etSearchBranches.text?.clear() + adapter.submitList(allBranches) + popupWindow.showAsDropDown(anchor, 0, 8) + } +} diff --git a/app/src/main/java/com/itsaky/androidide/fragments/git/adapter/GitBranchAdapter.kt b/app/src/main/java/com/itsaky/androidide/fragments/git/adapter/GitBranchAdapter.kt new file mode 100644 index 0000000000..cabeeaecd9 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/fragments/git/adapter/GitBranchAdapter.kt @@ -0,0 +1,52 @@ +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.databinding.ItemGitBranchBinding +import com.itsaky.androidide.git.core.models.GitBranch + +class GitBranchAdapter( + private val onBranchSelected: (GitBranch) -> Unit +) : ListAdapter(DiffCallback) { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BranchViewHolder { + val binding = ItemGitBranchBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) + return BranchViewHolder(binding) + } + + override fun onBindViewHolder(holder: BranchViewHolder, position: Int) { + holder.bind(getItem(position)) + } + + inner class BranchViewHolder(private val binding: ItemGitBranchBinding) : + RecyclerView.ViewHolder(binding.root) { + + fun bind(branch: GitBranch) { + binding.tvBranchName.text = branch.name + binding.tvRemoteBadge.visibility = if (branch.isRemote) View.VISIBLE else View.GONE + binding.ivActiveCheck.visibility = if (branch.isCurrent) View.VISIBLE else View.GONE + + binding.root.setOnClickListener { + onBranchSelected(branch) + } + } + } + + private object DiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: GitBranch, newItem: GitBranch): Boolean { + return oldItem.fullName == newItem.fullName + } + + override fun areContentsTheSame(oldItem: GitBranch, newItem: GitBranch): Boolean { + return oldItem == newItem + } + } +} diff --git a/app/src/main/res/layout/dialog_git_create_branch.xml b/app/src/main/res/layout/dialog_git_create_branch.xml new file mode 100644 index 0000000000..1deb7f4b7a --- /dev/null +++ b/app/src/main/res/layout/dialog_git_create_branch.xml @@ -0,0 +1,25 @@ + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_git_bottom_sheet.xml b/app/src/main/res/layout/fragment_git_bottom_sheet.xml index 0fa53d2699..53930def3a 100644 --- a/app/src/main/res/layout/fragment_git_bottom_sheet.xml +++ b/app/src/main/res/layout/fragment_git_bottom_sheet.xml @@ -15,11 +15,20 @@ android:layout_width="0dp" android:layout_height="wrap_content" android:layout_marginBottom="8dp" + android:background="@drawable/bg_ripple" + android:clickable="true" + android:drawableEnd="@drawable/ic_chevron_down" + android:drawablePadding="6dp" android:ellipsize="end" - android:maxLines="2" + android:focusable="true" + android:maxLines="1" + android:paddingHorizontal="8dp" + android:paddingVertical="4dp" android:textAppearance="?attr/textAppearanceSubtitle1" + android:textColor="?attr/colorPrimary" android:textStyle="bold" android:visibility="gone" + app:drawableTint="?attr/colorPrimary" app:layout_constraintEnd_toStartOf="@id/btnCheckAll" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" /> diff --git a/app/src/main/res/layout/item_git_branch.xml b/app/src/main/res/layout/item_git_branch.xml new file mode 100644 index 0000000000..0482f7b2c8 --- /dev/null +++ b/app/src/main/res/layout/item_git_branch.xml @@ -0,0 +1,68 @@ + + + + + + + + + + + + diff --git a/app/src/main/res/layout/popup_git_branches.xml b/app/src/main/res/layout/popup_git_branches.xml new file mode 100644 index 0000000000..873327a015 --- /dev/null +++ b/app/src/main/res/layout/popup_git_branches.xml @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/resources/src/main/res/drawable/ic_branch.xml b/resources/src/main/res/drawable/ic_branch.xml new file mode 100644 index 0000000000..0e5b8f4a06 --- /dev/null +++ b/resources/src/main/res/drawable/ic_branch.xml @@ -0,0 +1,10 @@ + + + diff --git a/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml index 97d441fbbb..8426d5df34 100644 --- a/resources/src/main/res/values/strings.xml +++ b/resources/src/main/res/values/strings.xml @@ -1349,6 +1349,16 @@ Not set This project is not a Git repository Current branch: %1$s + Git branches + New branch + Create new branch + Branch name + Search branches… + Switched to branch %1$s + Failed to switch branch + Checkout conflict + Cannot switch branch because uncommitted changes would be overwritten. Please commit or stash your changes before switching branches.\n\nConflicting files:\n%1$s + Please enter a valid branch name Push Pushing… Push successful! From c40dcbdf00c0a96999de299a8057e5b7519d1fa3 Mon Sep 17 00:00:00 2001 From: Oluwadara Abijo Date: Tue, 18 Aug 2026 22:00:18 +0100 Subject: [PATCH 04/28] feat(ADFA-2881): Switch branches --- .../fragments/git/GitBottomSheetFragment.kt | 98 +++++++++++++++++++ 1 file changed, 98 insertions(+) diff --git a/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt b/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt index 51a0acda05..c4b6ea1543 100644 --- a/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt +++ b/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt @@ -37,12 +37,18 @@ import kotlinx.coroutines.launch import org.koin.androidx.viewmodel.ext.android.activityViewModel import java.io.File +import com.itsaky.androidide.events.ListProjectFilesRequestEvent +import com.itsaky.androidide.fragments.git.GitBranchPopupWindow +import com.google.android.material.textfield.TextInputEditText +import org.greenrobot.eventbus.EventBus + class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) { private val viewModel: GitBottomSheetViewModel by activityViewModel() private val bottomSheetViewModel: BottomSheetViewModel by activityViewModel() private lateinit var fileChangeAdapter: GitFileChangeAdapter private lateinit var credentialsManager: GitCredentialsManager + private lateinit var branchPopupWindow: GitBranchPopupWindow private var _binding: FragmentGitBottomSheetBinding? = null private val binding get() = _binding!! @@ -52,6 +58,28 @@ class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) { _binding = FragmentGitBottomSheetBinding.bind(view) credentialsManager = GitCredentialsManager(requireContext()) + branchPopupWindow = GitBranchPopupWindow( + context = requireContext(), + onBranchSelected = { branch -> + if (!branch.isCurrent) { + checkUnsavedChangesAndProceed { + viewModel.checkoutBranch( + branchName = branch.name, + createNew = false, + startPoint = if (branch.isRemote) branch.fullName else null + ) + } + } + }, + onNewBranchRequested = { + showCreateBranchDialog() + } + ) + + binding.tvBranchName.setOnClickListener { + branchPopupWindow.show(binding.tvBranchName) + } + fileChangeAdapter = GitFileChangeAdapter( onFileClicked = { change -> when (change.type) { @@ -107,10 +135,59 @@ class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) { } } + launch { + viewModel.branches.collectLatest { branches -> + branchPopupWindow.setBranches(branches) + } + } + + launch { + viewModel.checkoutState.collectLatest { state -> + when (state) { + is GitBottomSheetViewModel.CheckoutUiState.Idle -> { + binding.tvBranchName.isEnabled = true + } + is GitBottomSheetViewModel.CheckoutUiState.CheckingOut -> { + binding.tvBranchName.isEnabled = false + } + is GitBottomSheetViewModel.CheckoutUiState.Success -> { + binding.tvBranchName.isEnabled = true + flashSuccess(getString(R.string.git_checkout_success, state.branchName)) + refreshEditorContent(force = true) + EventBus.getDefault().post(ListProjectFilesRequestEvent()) + } + is GitBottomSheetViewModel.CheckoutUiState.Conflicts -> { + binding.tvBranchName.isEnabled = true + val message = getString( + R.string.git_checkout_conflict_msg, + state.conflictingPaths.joinToString("\n• ", prefix = "• ") + ) + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.git_checkout_conflict_title) + .setMessage(message) + .setPositiveButton(android.R.string.ok, null) + .show() + } + is GitBottomSheetViewModel.CheckoutUiState.Error -> { + binding.tvBranchName.isEnabled = true + val message = state.message ?: getString(R.string.git_checkout_failed) + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.git_checkout_failed) + .setMessage(message) + .setPositiveButton(android.R.string.ok, null) + .show() + } + } + } + } + combine( viewModel.isGitRepository, viewModel.gitStatus ) { isRepo, status -> + if (isRepo) { + viewModel.fetchBranches() + } val allChanges = status.staged + status.unstaged + status.untracked + status.conflicted @@ -400,6 +477,27 @@ class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) { } } + private fun showCreateBranchDialog() { + val dialogView = layoutInflater.inflate(R.layout.dialog_git_create_branch, null) + val etBranchName = dialogView.findViewById(R.id.etBranchName) + + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.git_create_branch_title) + .setView(dialogView) + .setPositiveButton(R.string.git_create_branch) { _, _ -> + val branchName = etBranchName?.text?.toString()?.trim() ?: "" + if (branchName.isNotBlank()) { + checkUnsavedChangesAndProceed { + viewModel.checkoutBranch(branchName = branchName, createNew = true) + } + } else { + flashSuccess(getString(R.string.git_create_branch_invalid_name)) + } + } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + private fun refreshEditorContent(force: Boolean = false) { val activity = requireActivity() if (activity is EditorHandlerActivity) { From 8837e3fd4be70bff6675e6f6aa20fb15430c653f Mon Sep 17 00:00:00 2001 From: Oluwadara Abijo Date: Tue, 18 Aug 2026 23:59:51 +0100 Subject: [PATCH 05/28] feat(ADFA-2881): Create local and remote subsections --- .../fragments/git/GitBranchPopupWindow.kt | 49 +++++++++-- .../fragments/git/adapter/GitBranchAdapter.kt | 84 ++++++++++++++----- app/src/main/res/layout/item_git_branch.xml | 62 +++++--------- .../res/layout/item_git_branch_header.xml | 13 +++ resources/src/main/res/values/strings.xml | 2 + 5 files changed, 146 insertions(+), 64 deletions(-) create mode 100644 app/src/main/res/layout/item_git_branch_header.xml diff --git a/app/src/main/java/com/itsaky/androidide/fragments/git/GitBranchPopupWindow.kt b/app/src/main/java/com/itsaky/androidide/fragments/git/GitBranchPopupWindow.kt index 8931f3606c..d0adc52b27 100644 --- a/app/src/main/java/com/itsaky/androidide/fragments/git/GitBranchPopupWindow.kt +++ b/app/src/main/java/com/itsaky/androidide/fragments/git/GitBranchPopupWindow.kt @@ -2,16 +2,18 @@ package com.itsaky.androidide.fragments.git import android.content.Context import android.graphics.Color +import android.graphics.drawable.ColorDrawable import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.PopupWindow 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 androidx.core.graphics.drawable.toDrawable class GitBranchPopupWindow( private val context: Context, @@ -29,7 +31,7 @@ class GitBranchPopupWindow( ViewGroup.LayoutParams.WRAP_CONTENT, true ).apply { - setBackgroundDrawable(Color.TRANSPARENT.toDrawable()) + setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) elevation = 16f } @@ -63,14 +65,51 @@ class GitBranchPopupWindow( val filtered = if (query.isNullOrBlank()) { allBranches } else { - allBranches.filter { it.name.contains(query, ignoreCase = true) } + 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() + + 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))) + } + } + + adapter.submitList(items) + } + + private fun getDisplayName(branch: GitBranch): String { + if (!branch.isRemote) return branch.name + val remoteName = branch.remoteName + return when { + !remoteName.isNullOrEmpty() && branch.name.startsWith("$remoteName/") -> + branch.name.removePrefix("$remoteName/") + branch.name.startsWith("origin/") -> + branch.name.removePrefix("origin/") + branch.name.startsWith("refs/remotes/") -> + branch.name.substringAfter("refs/remotes/").substringAfter('/') + else -> branch.name } - adapter.submitList(filtered) } fun show(anchor: View) { binding.etSearchBranches.text?.clear() - adapter.submitList(allBranches) + filterBranches(null) popupWindow.showAsDropDown(anchor, 0, 8) } } diff --git a/app/src/main/java/com/itsaky/androidide/fragments/git/adapter/GitBranchAdapter.kt b/app/src/main/java/com/itsaky/androidide/fragments/git/adapter/GitBranchAdapter.kt index cabeeaecd9..c18f7cab07 100644 --- a/app/src/main/java/com/itsaky/androidide/fragments/git/adapter/GitBranchAdapter.kt +++ b/app/src/main/java/com/itsaky/androidide/fragments/git/adapter/GitBranchAdapter.kt @@ -3,49 +3,93 @@ 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 +sealed class GitBranchListItem { + data class Header(val title: String) : GitBranchListItem() + data class BranchItem(val branch: GitBranch, val displayName: String) : GitBranchListItem() +} + class GitBranchAdapter( private val onBranchSelected: (GitBranch) -> Unit -) : ListAdapter(DiffCallback) { - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BranchViewHolder { - val binding = ItemGitBranchBinding.inflate( - LayoutInflater.from(parent.context), - parent, - false - ) - return BranchViewHolder(binding) +) : ListAdapter(DiffCallback) { + + companion object { + private const val VIEW_TYPE_HEADER = 0 + private const val VIEW_TYPE_BRANCH = 1 } - override fun onBindViewHolder(holder: BranchViewHolder, position: Int) { - holder.bind(getItem(position)) + override fun getItemViewType(position: Int): Int { + return 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(branch: GitBranch) { - binding.tvBranchName.text = branch.name - binding.tvRemoteBadge.visibility = if (branch.isRemote) View.VISIBLE else View.GONE - binding.ivActiveCheck.visibility = if (branch.isCurrent) View.VISIBLE else View.GONE + fun bind(item: GitBranchListItem.BranchItem) { + binding.tvBranchName.text = item.displayName + + if (item.branch.isCurrent) { + binding.ivActiveCheck.visibility = View.VISIBLE + binding.ivBranchIcon.visibility = View.GONE + } else { + binding.ivActiveCheck.visibility = View.GONE + binding.ivBranchIcon.visibility = View.VISIBLE + } binding.root.setOnClickListener { - onBranchSelected(branch) + onBranchSelected(item.branch) } } } - private object DiffCallback : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: GitBranch, newItem: GitBranch): Boolean { - return oldItem.fullName == newItem.fullName + private object DiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: GitBranchListItem, newItem: GitBranchListItem): Boolean { + return 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: GitBranch, newItem: GitBranch): Boolean { + override fun areContentsTheSame(oldItem: GitBranchListItem, newItem: GitBranchListItem): Boolean { return oldItem == newItem } } diff --git a/app/src/main/res/layout/item_git_branch.xml b/app/src/main/res/layout/item_git_branch.xml index 0482f7b2c8..df41ee4fce 100644 --- a/app/src/main/res/layout/item_git_branch.xml +++ b/app/src/main/res/layout/item_git_branch.xml @@ -8,61 +8,45 @@ android:clickable="true" android:focusable="true" android:paddingHorizontal="16dp" - android:paddingVertical="12dp"> + android:paddingVertical="10dp"> - + app:layout_constraintTop_toTopOf="parent"> + + + + + - - - - + tools:text="main" /> diff --git a/app/src/main/res/layout/item_git_branch_header.xml b/app/src/main/res/layout/item_git_branch_header.xml new file mode 100644 index 0000000000..680f13c67d --- /dev/null +++ b/app/src/main/res/layout/item_git_branch_header.xml @@ -0,0 +1,13 @@ + + diff --git a/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml index 8426d5df34..470c366e55 100644 --- a/resources/src/main/res/values/strings.xml +++ b/resources/src/main/res/values/strings.xml @@ -1350,6 +1350,8 @@ This project is not a Git repository Current branch: %1$s Git branches + Local + Remote New branch Create new branch Branch name From 58d7074aa72e7ffc5414133da0a9949ed30da14a Mon Sep 17 00:00:00 2001 From: Oluwadara Abijo Date: Wed, 19 Aug 2026 12:32:00 +0100 Subject: [PATCH 06/28] feat(ADFA-2881): Polish branches UI --- .../fragments/git/GitBottomSheetFragment.kt | 1022 +++++++++-------- .../fragments/git/GitBranchPopupWindow.kt | 201 ++-- .../fragments/git/adapter/GitBranchAdapter.kt | 173 +-- .../res/layout/fragment_git_bottom_sheet.xml | 83 +- app/src/main/res/layout/item_git_branch.xml | 2 +- .../main/res/layout/popup_git_branches.xml | 4 +- resources/src/main/res/drawable/ic_branch.xml | 13 +- resources/src/main/res/values/strings.xml | 2 + 8 files changed, 783 insertions(+), 717 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt b/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt index c4b6ea1543..11cb31fe86 100644 --- a/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt +++ b/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt @@ -38,518 +38,522 @@ import org.koin.androidx.viewmodel.ext.android.activityViewModel import java.io.File import com.itsaky.androidide.events.ListProjectFilesRequestEvent -import com.itsaky.androidide.fragments.git.GitBranchPopupWindow import com.google.android.material.textfield.TextInputEditText import org.greenrobot.eventbus.EventBus class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) { - private val viewModel: GitBottomSheetViewModel by activityViewModel() - private val bottomSheetViewModel: BottomSheetViewModel by activityViewModel() - private lateinit var fileChangeAdapter: GitFileChangeAdapter - private lateinit var credentialsManager: GitCredentialsManager - private lateinit var branchPopupWindow: GitBranchPopupWindow - - private var _binding: FragmentGitBottomSheetBinding? = null - private val binding get() = _binding!! - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - _binding = FragmentGitBottomSheetBinding.bind(view) - credentialsManager = GitCredentialsManager(requireContext()) - - branchPopupWindow = GitBranchPopupWindow( - context = requireContext(), - onBranchSelected = { branch -> - if (!branch.isCurrent) { - checkUnsavedChangesAndProceed { - viewModel.checkoutBranch( - branchName = branch.name, - createNew = false, - startPoint = if (branch.isRemote) branch.fullName else null - ) - } - } - }, - onNewBranchRequested = { - showCreateBranchDialog() - } - ) - - binding.tvBranchName.setOnClickListener { - branchPopupWindow.show(binding.tvBranchName) - } - - fileChangeAdapter = GitFileChangeAdapter( - onFileClicked = { change -> - when (change.type) { - ChangeType.CONFLICTED -> { - val activity = requireActivity() - if (activity is EditorHandlerActivity) { - viewLifecycleOwner.lifecycleScope.launch { - val repo = viewModel.currentRepository - repo?.let { - activity.checkForExternalFileChanges(force = true) - activity.openFile(File(repo.rootDir, change.path)) - bottomSheetViewModel.setSheetState(BottomSheetBehavior.STATE_COLLAPSED) - } - } - } - } - - else -> { - val dialog = GitDiffViewerDialog.newInstance(change.path) - dialog.show(childFragmentManager, "GitDiffViewerDialog") - } - } - }, - onSelectionChanged = { - validateCommitButton() - updateCheckAllButton() - }, - onResolveConflict = { change -> - viewModel.resolveConflict(change.path) - } - ) - - binding.recyclerView.layoutManager = LinearLayoutManager(requireContext()) - binding.recyclerView.adapter = fileChangeAdapter - binding.recyclerView.onLongPress { _ -> - TooltipManager.showIdeCategoryTooltip( - context = requireContext(), - anchorView = binding.recyclerView, - tag = TooltipTag.PROJECT_GIT_FILES, - ) - } - - viewLifecycleOwner.lifecycleScope.launch { - launch { - viewModel.currentBranch.collectLatest { branchName -> - if (branchName != null) { - binding.tvBranchName.visibility = View.VISIBLE - binding.tvBranchName.text = - getString(R.string.current_branch_name, branchName) - } else { - binding.tvBranchName.visibility = View.GONE - } - } - } - - launch { - viewModel.branches.collectLatest { branches -> - branchPopupWindow.setBranches(branches) - } - } - - launch { - viewModel.checkoutState.collectLatest { state -> - when (state) { - is GitBottomSheetViewModel.CheckoutUiState.Idle -> { - binding.tvBranchName.isEnabled = true - } - is GitBottomSheetViewModel.CheckoutUiState.CheckingOut -> { - binding.tvBranchName.isEnabled = false - } - is GitBottomSheetViewModel.CheckoutUiState.Success -> { - binding.tvBranchName.isEnabled = true - flashSuccess(getString(R.string.git_checkout_success, state.branchName)) - refreshEditorContent(force = true) - EventBus.getDefault().post(ListProjectFilesRequestEvent()) - } - is GitBottomSheetViewModel.CheckoutUiState.Conflicts -> { - binding.tvBranchName.isEnabled = true - val message = getString( - R.string.git_checkout_conflict_msg, - state.conflictingPaths.joinToString("\n• ", prefix = "• ") - ) - MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.git_checkout_conflict_title) - .setMessage(message) - .setPositiveButton(android.R.string.ok, null) - .show() - } - is GitBottomSheetViewModel.CheckoutUiState.Error -> { - binding.tvBranchName.isEnabled = true - val message = state.message ?: getString(R.string.git_checkout_failed) - MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.git_checkout_failed) - .setMessage(message) - .setPositiveButton(android.R.string.ok, null) - .show() - } - } - } - } - - combine( - viewModel.isGitRepository, - viewModel.gitStatus - ) { isRepo, status -> - if (isRepo) { - viewModel.fetchBranches() - } - val allChanges = - status.staged + status.unstaged + status.untracked + status.conflicted - - when { - !isRepo -> binding.apply { - emptyView.visibility = View.VISIBLE - emptyView.text = getString(R.string.not_a_git_repo) - recyclerView.visibility = View.GONE - btnCheckAll.visibility = View.GONE - commitSection.visibility = View.GONE - authorWarning.visibility = View.GONE - commitHistoryButton.visibility = View.GONE - btnAbortMerge.visibility = View.GONE - } - - allChanges.isEmpty() -> binding.apply { - emptyView.visibility = View.VISIBLE - emptyView.text = getString(R.string.no_uncommitted_changes) - recyclerView.visibility = View.GONE - btnCheckAll.visibility = View.GONE - commitSection.visibility = View.GONE - authorWarning.visibility = View.GONE - commitHistoryButton.visibility = View.VISIBLE - btnAbortMerge.visibility = View.GONE - } - - else -> { - // Only offer "Check All" when there is at least one - // non-conflicted file; conflicted files can't be staged. - val hasSelectable = allChanges.any { it.type != ChangeType.CONFLICTED } - binding.apply { - emptyView.visibility = View.GONE - recyclerView.visibility = View.VISIBLE - btnCheckAll.visibility = - if (hasSelectable) View.VISIBLE else View.GONE - commitSection.visibility = View.VISIBLE - authorWarning.visibility = - if (hasAuthorInfo()) View.GONE else View.VISIBLE - commitHistoryButton.visibility = View.VISIBLE - btnAbortMerge.visibility = - if (status.isMerging) View.VISIBLE else View.GONE - } - fileChangeAdapter.submitList(allChanges) { - updateCheckAllButton() - } - } - } - }.collectLatest { } - } - - setupCommitUI() - - binding.commitHistoryButton.apply { - setOnClickListener { - val dialog = GitCommitHistoryDialog() - dialog.show(childFragmentManager, "CommitHistoryDialog") - } - setTooltipOnView(TooltipTag.PROJECT_GIT_COMMIT_HISTORY) - } - - setupPullUI() - } - - 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.btnCheckAll.setOnClickListener { - if (fileChangeAdapter.areAllSelected()) { - fileChangeAdapter.clearSelection() - } else { - fileChangeAdapter.selectAll() - } - } - - binding.btnAbortMerge.apply { - setOnClickListener { - val dialog = MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.abort_merge) - .setMessage(R.string.confirm_abort_merge) - .setPositiveButton(R.string.abort_merge) { _, _ -> - viewModel.abortMerge { - val activity = requireActivity() - if (activity is EditorHandlerActivity) { - activity.checkForExternalFileChanges(force = true) - } - } - } - .setNegativeButton(android.R.string.cancel, null) - .create() - dialog.setTooltipOnDialog(TooltipTag.GIT_DIALOG_ABORT_MERGE) - dialog.show() - } - setTooltipOnView(TooltipTag.PROJECT_GIT_ABORT) - } - - binding.authorAvatar.apply { - setOnClickListener { showAuthorPopup() } - setTooltipOnView(TooltipTag.PROJECT_GIT_ID) - } - - binding.commitButton.apply { - setOnClickListener { - 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() - updateCheckAllButton() - } - } - } - } - setTooltipOnView(TooltipTag.PROJECT_GIT_COMMIT) - } - } - - 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(android.R.id.message)?.movementMethod = - LinkMovementMethod.getInstance() - } - - private fun validateCommitButton() { - // May be invoked from async adapter callbacks; bail if the view is gone. - val binding = _binding ?: return - val hasSummary = !binding.commitSummary.text.isNullOrBlank() - val hasSelection = fileChangeAdapter.selectedFiles.isNotEmpty() - val hasAuthor = hasAuthorInfo() - binding.commitButton.isEnabled = hasSummary && hasSelection && hasAuthor - } - - private fun updateCheckAllButton() { - // May be invoked from the async submitList commit callback; bail if the view is gone. - val binding = _binding ?: return - binding.btnCheckAll.setText( - if (fileChangeAdapter.areAllSelected()) R.string.uncheck_all else R.string.check_all - ) - } - - private fun setupPullUI() { - viewLifecycleOwner.lifecycleScope.launch { - viewModel.isGitRepository.collectLatest { isRepo -> - binding.btnPull.visibility = if (isRepo) View.VISIBLE else View.GONE - } - } - - viewLifecycleOwner.lifecycleScope.launch { - viewModel.pullState.collectLatest { state -> - when (state) { - is PullUiState.Idle -> { - binding.btnPull.isEnabled = true - binding.pullProgress.visibility = View.GONE - } - - is PullUiState.Pulling -> { - binding.btnPull.isEnabled = false - binding.pullProgress.visibility = View.VISIBLE - } - - is PullUiState.Success -> { - binding.btnPull.isEnabled = true - binding.pullProgress.visibility = View.GONE - flashSuccess(R.string.pull_successful) - viewModel.resetPullState() - refreshEditorContent() - } - - is PullUiState.Conflicts -> { - binding.btnPull.isEnabled = true - binding.pullProgress.visibility = View.GONE - val message = state.message ?: getString(R.string.info_merge_conflicts) - val dialog = MaterialAlertDialogBuilder(requireContext()) - .setTitle(getString(R.string.merge_conflicts)) - .setMessage(message) - .setPositiveButton(android.R.string.ok, null) - .create() - dialog.setTooltipOnDialog(TooltipTag.GIT_DIALOG_MERGE_CONFLICTS) - dialog.show() - viewModel.resetPullState() - refreshEditorContent() - } - - is PullUiState.Error -> { - binding.btnPull.isEnabled = true - binding.pullProgress.visibility = View.GONE - val message = - state.message ?: state.errorResId?.let { resId -> - if (state.errorArgs != null) getString( - resId, - *state.errorArgs.toTypedArray() - ) else getString(resId) - } - val dialog = MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.pull_failed) - .setMessage(message) - .setPositiveButton(android.R.string.ok, null) - .create() - dialog.setTooltipOnDialog(TooltipTag.GIT_DIALOG_PULL_FAIL) - dialog.show() - } - } - } - } - - binding.btnPull.apply { - setOnClickListener { - checkUnsavedChangesAndProceed { - val username = credentialsManager.getUsername() - val token = credentialsManager.getToken() - if (!username.isNullOrBlank() && !token.isNullOrBlank()) { - viewModel.pull(username, token) - } else { - showGitCredentialsDialog( - credentialsManager = credentialsManager, - positiveButtonTextResId = R.string.pull - ) { user, accessToken -> - viewModel.pull(user, accessToken) - } - } - } - } - setTooltipOnView(TooltipTag.GIT_PULL) - } - } - - private fun showCreateBranchDialog() { - val dialogView = layoutInflater.inflate(R.layout.dialog_git_create_branch, null) - val etBranchName = dialogView.findViewById(R.id.etBranchName) - - MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.git_create_branch_title) - .setView(dialogView) - .setPositiveButton(R.string.git_create_branch) { _, _ -> - val branchName = etBranchName?.text?.toString()?.trim() ?: "" - if (branchName.isNotBlank()) { - checkUnsavedChangesAndProceed { - viewModel.checkoutBranch(branchName = branchName, createNew = true) - } - } else { - flashSuccess(getString(R.string.git_create_branch_invalid_name)) - } - } - .setNegativeButton(android.R.string.cancel, null) - .show() - } - - private fun refreshEditorContent(force: Boolean = false) { - val activity = requireActivity() - if (activity is EditorHandlerActivity) { - activity.checkForExternalFileChanges(force) - } - } - - private fun checkUnsavedChangesAndProceed(action: () -> Unit) { - val handler = requireActivity() as? IEditorHandler - if (handler?.areFilesModified() == true) { - val dialog = 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) - .create() - dialog.setTooltipOnDialog(TooltipTag.GIT_DIALOG_SAVE) - dialog.show() - } else { - action() - } - } - - override fun onDestroyView() { - super.onDestroyView() - _binding = null - } - - private fun AlertDialog.setTooltipOnDialog(tag: String) { - onLongPress { view -> - TooltipManager.showIdeCategoryTooltip( - context = view.context, - anchorView = view, - tag = tag - ) - true - } - } - - private fun View.setTooltipOnView(tag: String) { - setOnLongClickListener { view -> - TooltipManager.showIdeCategoryTooltip( - context = view.context, - anchorView = view, - tag = tag - ) - true - } - } + private val viewModel: GitBottomSheetViewModel by activityViewModel() + private val bottomSheetViewModel: BottomSheetViewModel by activityViewModel() + private lateinit var fileChangeAdapter: GitFileChangeAdapter + private lateinit var credentialsManager: GitCredentialsManager + private lateinit var branchPopupWindow: GitBranchPopupWindow + + private var _binding: FragmentGitBottomSheetBinding? = null + private val binding get() = _binding!! + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + _binding = FragmentGitBottomSheetBinding.bind(view) + credentialsManager = GitCredentialsManager(requireContext()) + + branchPopupWindow = GitBranchPopupWindow( + context = requireContext(), + onBranchSelected = { branch -> + if (!branch.isCurrent) { + checkUnsavedChangesAndProceed { + viewModel.checkoutBranch( + branchName = branch.name, + createNew = false, + startPoint = if (branch.isRemote) branch.fullName else null + ) + } + } + }, + onNewBranchRequested = { + showCreateBranchDialog() + } + ) + + binding.tvBranchName.setOnClickListener { + branchPopupWindow.show(binding.tvBranchName) + } + + fileChangeAdapter = GitFileChangeAdapter( + onFileClicked = { change -> + when (change.type) { + ChangeType.CONFLICTED -> { + val activity = requireActivity() + if (activity is EditorHandlerActivity) { + viewLifecycleOwner.lifecycleScope.launch { + val repo = viewModel.currentRepository + repo?.let { + activity.checkForExternalFileChanges(force = true) + activity.openFile(File(repo.rootDir, change.path)) + bottomSheetViewModel.setSheetState(BottomSheetBehavior.STATE_COLLAPSED) + } + } + } + } + + else -> { + val dialog = GitDiffViewerDialog.newInstance(change.path) + dialog.show(childFragmentManager, "GitDiffViewerDialog") + } + } + }, + onSelectionChanged = { + validateCommitButton() + updateCheckAllButton() + }, + onResolveConflict = { change -> + viewModel.resolveConflict(change.path) + } + ) + + binding.recyclerView.layoutManager = LinearLayoutManager(requireContext()) + binding.recyclerView.adapter = fileChangeAdapter + binding.recyclerView.onLongPress { _ -> + TooltipManager.showIdeCategoryTooltip( + context = requireContext(), + anchorView = binding.recyclerView, + tag = TooltipTag.PROJECT_GIT_FILES, + ) + } + + viewLifecycleOwner.lifecycleScope.launch { + launch { + viewModel.currentBranch.collectLatest { branchName -> + if (branchName != null) { + binding.groupCurrentBranch.visibility = View.VISIBLE + binding.tvBranchName.text = branchName + } else { + binding.groupCurrentBranch.visibility = View.GONE + } + } + } + + launch { + viewModel.branches.collectLatest { branches -> + branchPopupWindow.setBranches(branches) + } + } + + launch { + viewModel.checkoutState.collectLatest { state -> + when (state) { + is GitBottomSheetViewModel.CheckoutUiState.Idle -> { + binding.tvBranchName.isEnabled = true + } + is GitBottomSheetViewModel.CheckoutUiState.CheckingOut -> { + binding.tvBranchName.isEnabled = false + } + is GitBottomSheetViewModel.CheckoutUiState.Success -> { + binding.tvBranchName.isEnabled = true + flashSuccess(getString(R.string.git_checkout_success, state.branchName)) + refreshEditorContent(force = true) + EventBus.getDefault().post(ListProjectFilesRequestEvent()) + } + is GitBottomSheetViewModel.CheckoutUiState.Conflicts -> { + binding.tvBranchName.isEnabled = true + val message = getString( + R.string.git_checkout_conflict_msg, + state.conflictingPaths.joinToString("\n• ", prefix = "• ") + ) + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.git_checkout_conflict_title) + .setMessage(message) + .setPositiveButton(android.R.string.ok, null) + .show() + } + is GitBottomSheetViewModel.CheckoutUiState.Error -> { + binding.tvBranchName.isEnabled = true + val message = state.message ?: getString(R.string.git_checkout_failed) + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.git_checkout_failed) + .setMessage(message) + .setPositiveButton(android.R.string.ok, null) + .show() + } + } + } + } + + combine( + viewModel.isGitRepository, + viewModel.gitStatus + ) { isRepo, status -> + if (isRepo) { + viewModel.fetchBranches() + } + val allChanges = + status.staged + status.unstaged + status.untracked + status.conflicted + + when { + !isRepo -> binding.apply { + emptyView.visibility = View.VISIBLE + emptyView.text = getString(R.string.not_a_git_repo) + recyclerView.visibility = View.GONE + cbCheckAll.visibility = View.GONE + commitSection.visibility = View.GONE + authorWarning.visibility = View.GONE + commitHistoryButton.visibility = View.GONE + btnAbortMerge.visibility = View.GONE + } + + allChanges.isEmpty() -> binding.apply { + emptyView.visibility = View.VISIBLE + emptyView.text = getString(R.string.no_uncommitted_changes) + recyclerView.visibility = View.GONE + cbCheckAll.visibility = View.VISIBLE + cbCheckAll.isEnabled = false + cbCheckAll.isChecked = false + cbCheckAll.text = getString(R.string.changed_files_count, 0) + commitSection.visibility = View.GONE + authorWarning.visibility = View.GONE + commitHistoryButton.visibility = View.VISIBLE + btnAbortMerge.visibility = View.GONE + } + + else -> { + val hasSelectable = allChanges.any { it.type != ChangeType.CONFLICTED } + binding.apply { + emptyView.visibility = View.GONE + recyclerView.visibility = View.VISIBLE + cbCheckAll.visibility = View.VISIBLE + cbCheckAll.isEnabled = hasSelectable + cbCheckAll.text = getString(R.string.changed_files_count, allChanges.size) + commitSection.visibility = View.VISIBLE + authorWarning.visibility = + if (hasAuthorInfo()) View.GONE else View.VISIBLE + commitHistoryButton.visibility = View.VISIBLE + btnAbortMerge.visibility = + if (status.isMerging) View.VISIBLE else View.GONE + } + fileChangeAdapter.submitList(allChanges) { + updateCheckAllButton() + } + } + } + }.collectLatest { } + } + + setupCommitUI() + + binding.commitHistoryButton.apply { + setOnClickListener { + val dialog = GitCommitHistoryDialog() + dialog.show(childFragmentManager, "CommitHistoryDialog") + } + setTooltipOnView(TooltipTag.PROJECT_GIT_COMMIT_HISTORY) + } + + setupPullUI() + } + + 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.cbCheckAll.setOnClickListener { + if (binding.cbCheckAll.isChecked) { + fileChangeAdapter.selectAll() + } else { + fileChangeAdapter.clearSelection() + } + validateCommitButton() + } + + binding.btnAbortMerge.apply { + setOnClickListener { + val dialog = MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.abort_merge) + .setMessage(R.string.confirm_abort_merge) + .setPositiveButton(R.string.abort_merge) { _, _ -> + viewModel.abortMerge { + val activity = requireActivity() + if (activity is EditorHandlerActivity) { + activity.checkForExternalFileChanges(force = true) + } + } + } + .setNegativeButton(android.R.string.cancel, null) + .create() + dialog.setTooltipOnDialog(TooltipTag.GIT_DIALOG_ABORT_MERGE) + dialog.show() + } + setTooltipOnView(TooltipTag.PROJECT_GIT_ABORT) + } + + binding.authorAvatar.apply { + setOnClickListener { showAuthorPopup() } + setTooltipOnView(TooltipTag.PROJECT_GIT_ID) + } + + binding.commitButton.apply { + setOnClickListener { + 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() + updateCheckAllButton() + } + } + } + } + setTooltipOnView(TooltipTag.PROJECT_GIT_COMMIT) + } + } + + 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(android.R.id.message)?.movementMethod = + LinkMovementMethod.getInstance() + } + + private fun validateCommitButton() { + // May be invoked from async adapter callbacks; bail if the view is gone. + val binding = _binding ?: return + val hasSummary = !binding.commitSummary.text.isNullOrBlank() + val hasSelection = fileChangeAdapter.selectedFiles.isNotEmpty() + val hasAuthor = hasAuthorInfo() + binding.commitButton.isEnabled = hasSummary && hasSelection && hasAuthor + } + + private fun updateCheckAllButton() { + // May be invoked from the async submitList commit callback; bail if the view is gone. + val binding = _binding ?: return + val status = viewModel.gitStatus.value + val allChanges = status.staged + status.unstaged + status.untracked + status.conflicted + val hasSelectable = allChanges.any { it.type != ChangeType.CONFLICTED } + binding.cbCheckAll.text = getString(R.string.changed_files_count, allChanges.size) + binding.cbCheckAll.isEnabled = hasSelectable && allChanges.isNotEmpty() + binding.cbCheckAll.isChecked = hasSelectable && allChanges.isNotEmpty() && fileChangeAdapter.areAllSelected() + } + + private fun setupPullUI() { + viewLifecycleOwner.lifecycleScope.launch { + viewModel.isGitRepository.collectLatest { isRepo -> + binding.btnPull.visibility = if (isRepo) View.VISIBLE else View.GONE + } + } + + viewLifecycleOwner.lifecycleScope.launch { + viewModel.pullState.collectLatest { state -> + when (state) { + is PullUiState.Idle -> { + binding.btnPull.isEnabled = true + binding.pullProgress.visibility = View.GONE + } + + is PullUiState.Pulling -> { + binding.btnPull.isEnabled = false + binding.pullProgress.visibility = View.VISIBLE + } + + is PullUiState.Success -> { + binding.btnPull.isEnabled = true + binding.pullProgress.visibility = View.GONE + flashSuccess(R.string.pull_successful) + viewModel.resetPullState() + refreshEditorContent() + } + + is PullUiState.Conflicts -> { + binding.btnPull.isEnabled = true + binding.pullProgress.visibility = View.GONE + val message = state.message ?: getString(R.string.info_merge_conflicts) + val dialog = MaterialAlertDialogBuilder(requireContext()) + .setTitle(getString(R.string.merge_conflicts)) + .setMessage(message) + .setPositiveButton(android.R.string.ok, null) + .create() + dialog.setTooltipOnDialog(TooltipTag.GIT_DIALOG_MERGE_CONFLICTS) + dialog.show() + viewModel.resetPullState() + refreshEditorContent() + } + + is PullUiState.Error -> { + binding.btnPull.isEnabled = true + binding.pullProgress.visibility = View.GONE + val message = + state.message ?: state.errorResId?.let { resId -> + if (state.errorArgs != null) getString( + resId, + *state.errorArgs.toTypedArray() + ) else getString(resId) + } + val dialog = MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.pull_failed) + .setMessage(message) + .setPositiveButton(android.R.string.ok, null) + .create() + dialog.setTooltipOnDialog(TooltipTag.GIT_DIALOG_PULL_FAIL) + dialog.show() + } + } + } + } + + binding.btnPull.apply { + setOnClickListener { + checkUnsavedChangesAndProceed { + val username = credentialsManager.getUsername() + val token = credentialsManager.getToken() + if (!username.isNullOrBlank() && !token.isNullOrBlank()) { + viewModel.pull(username, token) + } else { + showGitCredentialsDialog( + credentialsManager = credentialsManager, + positiveButtonTextResId = R.string.pull + ) { user, accessToken -> + viewModel.pull(user, accessToken) + } + } + } + } + setTooltipOnView(TooltipTag.GIT_PULL) + } + } + + private fun showCreateBranchDialog() { + val dialogView = layoutInflater.inflate(R.layout.dialog_git_create_branch, null) + val etBranchName = dialogView.findViewById(R.id.etBranchName) + + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.git_create_branch_title) + .setView(dialogView) + .setPositiveButton(R.string.git_create_branch) { _, _ -> + val branchName = etBranchName?.text?.toString()?.trim() ?: "" + if (branchName.isNotBlank()) { + checkUnsavedChangesAndProceed { + viewModel.checkoutBranch(branchName = branchName, createNew = true) + } + } else { + flashSuccess(getString(R.string.git_create_branch_invalid_name)) + } + } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + + private fun refreshEditorContent(force: Boolean = false) { + val activity = requireActivity() + if (activity is EditorHandlerActivity) { + activity.checkForExternalFileChanges(force) + } + } + + private fun checkUnsavedChangesAndProceed(action: () -> Unit) { + val handler = requireActivity() as? IEditorHandler + if (handler?.areFilesModified() == true) { + val dialog = 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) + .create() + dialog.setTooltipOnDialog(TooltipTag.GIT_DIALOG_SAVE) + dialog.show() + } else { + action() + } + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + private fun AlertDialog.setTooltipOnDialog(tag: String) { + onLongPress { view -> + TooltipManager.showIdeCategoryTooltip( + context = view.context, + anchorView = view, + tag = tag + ) + true + } + } + + private fun View.setTooltipOnView(tag: String) { + setOnLongClickListener { view -> + TooltipManager.showIdeCategoryTooltip( + context = view.context, + anchorView = view, + tag = tag + ) + true + } + } } diff --git a/app/src/main/java/com/itsaky/androidide/fragments/git/GitBranchPopupWindow.kt b/app/src/main/java/com/itsaky/androidide/fragments/git/GitBranchPopupWindow.kt index d0adc52b27..818d68d0e4 100644 --- a/app/src/main/java/com/itsaky/androidide/fragments/git/GitBranchPopupWindow.kt +++ b/app/src/main/java/com/itsaky/androidide/fragments/git/GitBranchPopupWindow.kt @@ -16,100 +16,111 @@ import com.itsaky.androidide.fragments.git.adapter.GitBranchListItem import com.itsaky.androidide.git.core.models.GitBranch class GitBranchPopupWindow( - private val context: Context, - private val onBranchSelected: (GitBranch) -> Unit, - private val onNewBranchRequested: () -> Unit + private val context: Context, + private val onBranchSelected: (GitBranch) -> Unit, + private val onNewBranchRequested: () -> Unit, ) { - - 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(ColorDrawable(Color.TRANSPARENT)) - elevation = 16f - } - - private val adapter: GitBranchAdapter = GitBranchAdapter { branch -> - popupWindow.dismiss() - onBranchSelected(branch) - } - - private var allBranches: List = emptyList() - - init { - binding.rvBranches.layoutManager = LinearLayoutManager(context) - binding.rvBranches.adapter = adapter - - binding.btnNewBranch.setOnClickListener { - popupWindow.dismiss() - onNewBranchRequested() - } - - binding.etSearchBranches.doAfterTextChanged { text -> - filterBranches(text?.toString()) - } - } - - fun setBranches(branches: List) { - allBranches = branches - filterBranches(binding.etSearchBranches.text?.toString()) - } - - 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() - - 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))) - } - } - - adapter.submitList(items) - } - - private fun getDisplayName(branch: GitBranch): String { - if (!branch.isRemote) return branch.name - val remoteName = branch.remoteName - return when { - !remoteName.isNullOrEmpty() && branch.name.startsWith("$remoteName/") -> - branch.name.removePrefix("$remoteName/") - branch.name.startsWith("origin/") -> - branch.name.removePrefix("origin/") - branch.name.startsWith("refs/remotes/") -> - branch.name.substringAfter("refs/remotes/").substringAfter('/') - else -> branch.name - } - } - - fun show(anchor: View) { - binding.etSearchBranches.text?.clear() - filterBranches(null) - popupWindow.showAsDropDown(anchor, 0, 8) - } + 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(ColorDrawable(Color.TRANSPARENT)) + elevation = 16f + } + + private val adapter: GitBranchAdapter = + GitBranchAdapter { branch -> + popupWindow.dismiss() + onBranchSelected(branch) + } + + private var allBranches: List = emptyList() + + init { + binding.rvBranches.layoutManager = LinearLayoutManager(context) + binding.rvBranches.adapter = adapter + + binding.btnNewBranch.setOnClickListener { + popupWindow.dismiss() + onNewBranchRequested() + } + + binding.etSearchBranches.doAfterTextChanged { text -> + filterBranches(text?.toString()) + } + } + + fun setBranches(branches: List) { + allBranches = branches + filterBranches(binding.etSearchBranches.text?.toString()) + } + + 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() + + 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))) + } + } + + adapter.submitList(items) + } + + private fun getDisplayName(branch: GitBranch): String { + if (!branch.isRemote) return branch.name + val remoteName = branch.remoteName + return when { + !remoteName.isNullOrEmpty() && branch.name.startsWith("$remoteName/") -> { + branch.name.removePrefix("$remoteName/") + } + + branch.name.startsWith("origin/") -> { + branch.name.removePrefix("origin/") + } + + branch.name.startsWith("refs/remotes/") -> { + branch.name.substringAfter("refs/remotes/").substringAfter('/') + } + + else -> { + branch.name + } + } + } + + fun show(anchor: View) { + binding.etSearchBranches.text?.clear() + filterBranches(null) + popupWindow.showAsDropDown(anchor, 0, 8) + } } diff --git a/app/src/main/java/com/itsaky/androidide/fragments/git/adapter/GitBranchAdapter.kt b/app/src/main/java/com/itsaky/androidide/fragments/git/adapter/GitBranchAdapter.kt index c18f7cab07..f288a52c2c 100644 --- a/app/src/main/java/com/itsaky/androidide/fragments/git/adapter/GitBranchAdapter.kt +++ b/app/src/main/java/com/itsaky/androidide/fragments/git/adapter/GitBranchAdapter.kt @@ -12,85 +12,106 @@ import com.itsaky.androidide.databinding.ItemGitBranchBinding import com.itsaky.androidide.git.core.models.GitBranch sealed class GitBranchListItem { - data class Header(val title: String) : GitBranchListItem() - data class BranchItem(val branch: GitBranch, val displayName: String) : GitBranchListItem() + data class Header( + val title: String, + ) : GitBranchListItem() + + data class BranchItem( + val branch: GitBranch, + val displayName: String, + ) : GitBranchListItem() } class GitBranchAdapter( - private val onBranchSelected: (GitBranch) -> Unit + private val onBranchSelected: (GitBranch) -> Unit, ) : ListAdapter(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) { + binding.tvBranchName.text = item.displayName + + if (item.branch.isCurrent) { + binding.ivActiveCheck.visibility = View.VISIBLE + binding.imgBranchIcon.visibility = View.GONE + } else { + binding.ivActiveCheck.visibility = View.GONE + binding.imgBranchIcon.visibility = View.VISIBLE + } + + binding.root.setOnClickListener { + onBranchSelected(item.branch) + } + } + } + + private object DiffCallback : DiffUtil.ItemCallback() { + 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 + } + } - companion object { - private const val VIEW_TYPE_HEADER = 0 - private const val VIEW_TYPE_BRANCH = 1 - } - - override fun getItemViewType(position: Int): Int { - return 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) { - binding.tvBranchName.text = item.displayName - - if (item.branch.isCurrent) { - binding.ivActiveCheck.visibility = View.VISIBLE - binding.ivBranchIcon.visibility = View.GONE - } else { - binding.ivActiveCheck.visibility = View.GONE - binding.ivBranchIcon.visibility = View.VISIBLE - } - - binding.root.setOnClickListener { - onBranchSelected(item.branch) - } - } - } - - private object DiffCallback : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: GitBranchListItem, newItem: GitBranchListItem): Boolean { - return 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 { - return oldItem == newItem - } - } + override fun areContentsTheSame( + oldItem: GitBranchListItem, + newItem: GitBranchListItem, + ): Boolean = oldItem == newItem + } } diff --git a/app/src/main/res/layout/fragment_git_bottom_sheet.xml b/app/src/main/res/layout/fragment_git_bottom_sheet.xml index 53930def3a..5704bcf3c6 100644 --- a/app/src/main/res/layout/fragment_git_bottom_sheet.xml +++ b/app/src/main/res/layout/fragment_git_bottom_sheet.xml @@ -10,28 +10,53 @@ android:layout_height="match_parent" android:padding="16dp"> + + + + + app:layout_constrainedWidth="true" + app:layout_constraintBottom_toBottomOf="@id/imgBranchIcon" + app:layout_constraintEnd_toStartOf="@id/btnPull" + app:layout_constraintHorizontal_bias="0.0" + app:layout_constraintStart_toEndOf="@id/imgBranchIcon" + app:layout_constraintTop_toTopOf="@id/imgBranchIcon" /> + + + + - - + app:layout_constraintTop_toBottomOf="@id/cbCheckAll" /> + app:layout_constraintTop_toBottomOf="@id/cbCheckAll" /> + app:strokeColor="?attr/colorOutline" + app:strokeWidth="1dp"> - + android:viewportHeight="24"> + + + diff --git a/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml index 470c366e55..3b3ad560af 100644 --- a/resources/src/main/res/values/strings.xml +++ b/resources/src/main/res/values/strings.xml @@ -1349,6 +1349,8 @@ Not set This project is not a Git repository Current branch: %1$s + Current branch + Changed files: %1$d Git branches Local Remote From 79398d7e0045a1ebadf67e304aae513747d9425f Mon Sep 17 00:00:00 2001 From: Oluwadara Abijo Date: Wed, 19 Aug 2026 12:44:14 +0100 Subject: [PATCH 07/28] format(ADFA-2881): Apply spotless --- .../fragments/git/GitBottomSheetFragment.kt | 293 ++++---- .../viewmodel/GitBottomSheetViewModel.kt | 13 +- .../res/layout/dialog_git_create_branch.xml | 41 +- .../res/layout/fragment_git_bottom_sheet.xml | 471 ++++++------ app/src/main/res/layout/item_git_branch.xml | 91 +-- .../res/layout/item_git_branch_header.xml | 25 +- .../main/res/layout/popup_git_branches.xml | 139 ++-- .../viewmodel/GitBottomSheetViewModelTest.kt | 159 +++-- .../androidide/git/core/GitRepository.kt | 88 +-- .../androidide/git/core/JGitRepository.kt | 670 +++++++++--------- .../androidide/git/core/JGitRepositoryTest.kt | 101 +-- resources/src/main/res/drawable/ic_branch.xml | 21 +- 12 files changed, 1094 insertions(+), 1018 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt b/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt index 11cb31fe86..66429ee898 100644 --- a/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt +++ b/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt @@ -15,10 +15,12 @@ import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.LinearLayoutManager import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.textfield.TextInputEditText import com.itsaky.androidide.R import com.itsaky.androidide.activities.PreferencesActivity import com.itsaky.androidide.activities.editor.EditorHandlerActivity import com.itsaky.androidide.databinding.FragmentGitBottomSheetBinding +import com.itsaky.androidide.events.ListProjectFilesRequestEvent import com.itsaky.androidide.fragments.git.adapter.GitFileChangeAdapter import com.itsaky.androidide.git.core.GitCredentialsManager import com.itsaky.androidide.git.core.models.ChangeType @@ -34,15 +36,11 @@ import com.itsaky.androidide.viewmodel.GitBottomSheetViewModel.PullUiState import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine import kotlinx.coroutines.launch +import org.greenrobot.eventbus.EventBus import org.koin.androidx.viewmodel.ext.android.activityViewModel import java.io.File -import com.itsaky.androidide.events.ListProjectFilesRequestEvent -import com.google.android.material.textfield.TextInputEditText -import org.greenrobot.eventbus.EventBus - class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) { - private val viewModel: GitBottomSheetViewModel by activityViewModel() private val bottomSheetViewModel: BottomSheetViewModel by activityViewModel() private lateinit var fileChangeAdapter: GitFileChangeAdapter @@ -50,66 +48,72 @@ class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) { private lateinit var branchPopupWindow: GitBranchPopupWindow private var _binding: FragmentGitBottomSheetBinding? = null - private val binding get() = _binding!! + val binding: FragmentGitBottomSheetBinding + get() = checkNotNull(_binding) { "Fragment binding is null or view has been destroyed" } - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + override fun onViewCreated( + view: View, + savedInstanceState: Bundle?, + ) { super.onViewCreated(view, savedInstanceState) _binding = FragmentGitBottomSheetBinding.bind(view) credentialsManager = GitCredentialsManager(requireContext()) - branchPopupWindow = GitBranchPopupWindow( - context = requireContext(), - onBranchSelected = { branch -> - if (!branch.isCurrent) { - checkUnsavedChangesAndProceed { - viewModel.checkoutBranch( - branchName = branch.name, - createNew = false, - startPoint = if (branch.isRemote) branch.fullName else null - ) + branchPopupWindow = + GitBranchPopupWindow( + context = requireContext(), + onBranchSelected = { branch -> + if (!branch.isCurrent) { + checkUnsavedChangesAndProceed { + viewModel.checkoutBranch( + branchName = branch.name, + createNew = false, + startPoint = if (branch.isRemote) branch.fullName else null, + ) + } } - } - }, - onNewBranchRequested = { - showCreateBranchDialog() - } - ) + }, + onNewBranchRequested = { + showCreateBranchDialog() + }, + ) binding.tvBranchName.setOnClickListener { branchPopupWindow.show(binding.tvBranchName) } - fileChangeAdapter = GitFileChangeAdapter( - onFileClicked = { change -> - when (change.type) { - ChangeType.CONFLICTED -> { - val activity = requireActivity() - if (activity is EditorHandlerActivity) { - viewLifecycleOwner.lifecycleScope.launch { - val repo = viewModel.currentRepository - repo?.let { - activity.checkForExternalFileChanges(force = true) - activity.openFile(File(repo.rootDir, change.path)) - bottomSheetViewModel.setSheetState(BottomSheetBehavior.STATE_COLLAPSED) + fileChangeAdapter = + GitFileChangeAdapter( + onFileClicked = { change -> + when (change.type) { + ChangeType.CONFLICTED -> { + val activity = requireActivity() + if (activity is EditorHandlerActivity) { + viewLifecycleOwner.lifecycleScope.launch { + val repo = viewModel.currentRepository + repo?.let { + activity.checkForExternalFileChanges(force = true) + activity.openFile(File(repo.rootDir, change.path)) + bottomSheetViewModel.setSheetState(BottomSheetBehavior.STATE_COLLAPSED) + } } } } - } - else -> { - val dialog = GitDiffViewerDialog.newInstance(change.path) - dialog.show(childFragmentManager, "GitDiffViewerDialog") + else -> { + val dialog = GitDiffViewerDialog.newInstance(change.path) + dialog.show(childFragmentManager, "GitDiffViewerDialog") + } } - } - }, - onSelectionChanged = { - validateCommitButton() - updateCheckAllButton() - }, - onResolveConflict = { change -> - viewModel.resolveConflict(change.path) - } - ) + }, + onSelectionChanged = { + validateCommitButton() + updateCheckAllButton() + }, + onResolveConflict = { change -> + viewModel.resolveConflict(change.path) + }, + ) binding.recyclerView.layoutManager = LinearLayoutManager(requireContext()) binding.recyclerView.adapter = fileChangeAdapter @@ -145,27 +149,32 @@ class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) { is GitBottomSheetViewModel.CheckoutUiState.Idle -> { binding.tvBranchName.isEnabled = true } + is GitBottomSheetViewModel.CheckoutUiState.CheckingOut -> { binding.tvBranchName.isEnabled = false } + is GitBottomSheetViewModel.CheckoutUiState.Success -> { binding.tvBranchName.isEnabled = true flashSuccess(getString(R.string.git_checkout_success, state.branchName)) refreshEditorContent(force = true) EventBus.getDefault().post(ListProjectFilesRequestEvent()) } + is GitBottomSheetViewModel.CheckoutUiState.Conflicts -> { binding.tvBranchName.isEnabled = true - val message = getString( - R.string.git_checkout_conflict_msg, - state.conflictingPaths.joinToString("\n• ", prefix = "• ") - ) + val message = + getString( + R.string.git_checkout_conflict_msg, + state.conflictingPaths.joinToString("\n• ", prefix = "• "), + ) MaterialAlertDialogBuilder(requireContext()) .setTitle(R.string.git_checkout_conflict_title) .setMessage(message) .setPositiveButton(android.R.string.ok, null) .show() } + is GitBottomSheetViewModel.CheckoutUiState.Error -> { binding.tvBranchName.isEnabled = true val message = state.message ?: getString(R.string.git_checkout_failed) @@ -181,7 +190,7 @@ class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) { combine( viewModel.isGitRepository, - viewModel.gitStatus + viewModel.gitStatus, ) { isRepo, status -> if (isRepo) { viewModel.fetchBranches() @@ -190,29 +199,33 @@ class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) { status.staged + status.unstaged + status.untracked + status.conflicted when { - !isRepo -> binding.apply { - emptyView.visibility = View.VISIBLE - emptyView.text = getString(R.string.not_a_git_repo) - recyclerView.visibility = View.GONE - cbCheckAll.visibility = View.GONE - commitSection.visibility = View.GONE - authorWarning.visibility = View.GONE - commitHistoryButton.visibility = View.GONE - btnAbortMerge.visibility = View.GONE + !isRepo -> { + binding.apply { + emptyView.visibility = View.VISIBLE + emptyView.text = getString(R.string.not_a_git_repo) + recyclerView.visibility = View.GONE + cbCheckAll.visibility = View.GONE + commitSection.visibility = View.GONE + authorWarning.visibility = View.GONE + commitHistoryButton.visibility = View.GONE + btnAbortMerge.visibility = View.GONE + } } - allChanges.isEmpty() -> binding.apply { - emptyView.visibility = View.VISIBLE - emptyView.text = getString(R.string.no_uncommitted_changes) - recyclerView.visibility = View.GONE - cbCheckAll.visibility = View.VISIBLE - cbCheckAll.isEnabled = false - cbCheckAll.isChecked = false - cbCheckAll.text = getString(R.string.changed_files_count, 0) - commitSection.visibility = View.GONE - authorWarning.visibility = View.GONE - commitHistoryButton.visibility = View.VISIBLE - btnAbortMerge.visibility = View.GONE + allChanges.isEmpty() -> { + binding.apply { + emptyView.visibility = View.VISIBLE + emptyView.text = getString(R.string.no_uncommitted_changes) + recyclerView.visibility = View.GONE + cbCheckAll.visibility = View.VISIBLE + cbCheckAll.isEnabled = false + cbCheckAll.isChecked = false + cbCheckAll.text = getString(R.string.changed_files_count, 0) + commitSection.visibility = View.GONE + authorWarning.visibility = View.GONE + commitHistoryButton.visibility = View.VISIBLE + btnAbortMerge.visibility = View.GONE + } } else -> { @@ -259,15 +272,14 @@ class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) { private fun updateAuthorUI() { val hasAuthor = hasAuthorInfo() val allChanges = - viewModel.gitStatus.value.staged + viewModel.gitStatus.value.unstaged + viewModel.gitStatus.value.untracked + viewModel.gitStatus.value.conflicted + 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 hasAuthorInfo(): Boolean = !GitPreferences.userName.isNullOrBlank() && !GitPreferences.userEmail.isNullOrBlank() private fun setupCommitUI() { binding.commitSummary.doAfterTextChanged { validateCommitButton() } @@ -284,19 +296,19 @@ class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) { binding.btnAbortMerge.apply { setOnClickListener { - val dialog = MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.abort_merge) - .setMessage(R.string.confirm_abort_merge) - .setPositiveButton(R.string.abort_merge) { _, _ -> - viewModel.abortMerge { - val activity = requireActivity() - if (activity is EditorHandlerActivity) { - activity.checkForExternalFileChanges(force = true) + val dialog = + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.abort_merge) + .setMessage(R.string.confirm_abort_merge) + .setPositiveButton(R.string.abort_merge) { _, _ -> + viewModel.abortMerge { + val activity = requireActivity() + if (activity is EditorHandlerActivity) { + activity.checkForExternalFileChanges(force = true) + } } - } - } - .setNegativeButton(android.R.string.cancel, null) - .create() + }.setNegativeButton(android.R.string.cancel, null) + .create() dialog.setTooltipOnDialog(TooltipTag.GIT_DIALOG_ABORT_MERGE) dialog.show() } @@ -311,14 +323,20 @@ class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) { binding.commitButton.apply { setOnClickListener { checkUnsavedChangesAndProceed { - val summary = binding.commitSummary.text?.toString()?.trim() ?: "" - val description = binding.commitDescription.text?.toString()?.trim() + 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() + selectedPaths = fileChangeAdapter.selectedFiles.toList(), ) { // Clear the inputs on successful commit binding.commitSummary.text?.clear() @@ -337,7 +355,8 @@ class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) { 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" + + 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) @@ -345,10 +364,11 @@ class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) { 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 builder = + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.idepref_git_author_title) + .setMessage(spannable) + .setPositiveButton(android.R.string.ok, null) val dialog = builder.create() @@ -356,17 +376,18 @@ class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) { spannable.setSpan( object : ClickableSpan() { override fun onClick(widget: View) { - val intent = Intent( - requireContext(), - PreferencesActivity::class.java - ) + val intent = + Intent( + requireContext(), + PreferencesActivity::class.java, + ) dialog.dismiss() startActivity(intent) } }, startIndex, startIndex + preferencesText.length, - SPAN_EXCLUSIVE_EXCLUSIVE + SPAN_EXCLUSIVE_EXCLUSIVE, ) } @@ -427,11 +448,12 @@ class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) { binding.btnPull.isEnabled = true binding.pullProgress.visibility = View.GONE val message = state.message ?: getString(R.string.info_merge_conflicts) - val dialog = MaterialAlertDialogBuilder(requireContext()) - .setTitle(getString(R.string.merge_conflicts)) - .setMessage(message) - .setPositiveButton(android.R.string.ok, null) - .create() + val dialog = + MaterialAlertDialogBuilder(requireContext()) + .setTitle(getString(R.string.merge_conflicts)) + .setMessage(message) + .setPositiveButton(android.R.string.ok, null) + .create() dialog.setTooltipOnDialog(TooltipTag.GIT_DIALOG_MERGE_CONFLICTS) dialog.show() viewModel.resetPullState() @@ -443,16 +465,21 @@ class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) { binding.pullProgress.visibility = View.GONE val message = state.message ?: state.errorResId?.let { resId -> - if (state.errorArgs != null) getString( - resId, - *state.errorArgs.toTypedArray() - ) else getString(resId) + if (state.errorArgs != null) { + getString( + resId, + *state.errorArgs.toTypedArray(), + ) + } else { + getString(resId) + } } - val dialog = MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.pull_failed) - .setMessage(message) - .setPositiveButton(android.R.string.ok, null) - .create() + val dialog = + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.pull_failed) + .setMessage(message) + .setPositiveButton(android.R.string.ok, null) + .create() dialog.setTooltipOnDialog(TooltipTag.GIT_DIALOG_PULL_FAIL) dialog.show() } @@ -470,7 +497,7 @@ class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) { } else { showGitCredentialsDialog( credentialsManager = credentialsManager, - positiveButtonTextResId = R.string.pull + positiveButtonTextResId = R.string.pull, ) { user, accessToken -> viewModel.pull(user, accessToken) } @@ -497,8 +524,7 @@ class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) { } else { flashSuccess(getString(R.string.git_create_branch_invalid_name)) } - } - .setNegativeButton(android.R.string.cancel, null) + }.setNegativeButton(android.R.string.cancel, null) .show() } @@ -512,17 +538,16 @@ class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) { private fun checkUnsavedChangesAndProceed(action: () -> Unit) { val handler = requireActivity() as? IEditorHandler if (handler?.areFilesModified() == true) { - val dialog = 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) - .create() + val dialog = + 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) + .create() dialog.setTooltipOnDialog(TooltipTag.GIT_DIALOG_SAVE) dialog.show() } else { @@ -540,7 +565,7 @@ class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) { TooltipManager.showIdeCategoryTooltip( context = view.context, anchorView = view, - tag = tag + tag = tag, ) true } @@ -551,7 +576,7 @@ class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) { TooltipManager.showIdeCategoryTooltip( context = view.context, anchorView = view, - tag = tag + tag = tag, ) true } diff --git a/app/src/main/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModel.kt b/app/src/main/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModel.kt index 7d98d8422b..bb0034877b 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModel.kt @@ -165,10 +165,11 @@ class GitBottomSheetViewModel( log.error("Checkout failed", e) _checkoutState.value = CheckoutUiState.Error(message = e.message) } finally { - checkoutResetJob = viewModelScope.launch { - delay(3000.milliseconds) - _checkoutState.value = CheckoutUiState.Idle - } + checkoutResetJob = + viewModelScope.launch { + delay(3000.milliseconds) + _checkoutState.value = CheckoutUiState.Idle + } } } } @@ -398,7 +399,9 @@ class GitBottomSheetViewModel( object CheckingOut : CheckoutUiState() - data class Success(val branchName: String) : CheckoutUiState() + data class Success( + val branchName: String, + ) : CheckoutUiState() data class Conflicts( val conflictingPaths: List = emptyList(), diff --git a/app/src/main/res/layout/dialog_git_create_branch.xml b/app/src/main/res/layout/dialog_git_create_branch.xml index 1deb7f4b7a..c651293ad2 100644 --- a/app/src/main/res/layout/dialog_git_create_branch.xml +++ b/app/src/main/res/layout/dialog_git_create_branch.xml @@ -1,25 +1,26 @@ - + - + - - + + diff --git a/app/src/main/res/layout/fragment_git_bottom_sheet.xml b/app/src/main/res/layout/fragment_git_bottom_sheet.xml index 5704bcf3c6..1ea5d626f9 100644 --- a/app/src/main/res/layout/fragment_git_bottom_sheet.xml +++ b/app/src/main/res/layout/fragment_git_bottom_sheet.xml @@ -1,237 +1,238 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_git_branch.xml b/app/src/main/res/layout/item_git_branch.xml index 9a93e3382a..9beb0e1cc4 100644 --- a/app/src/main/res/layout/item_git_branch.xml +++ b/app/src/main/res/layout/item_git_branch.xml @@ -1,52 +1,53 @@ - + - + - + - - + + - + diff --git a/app/src/main/res/layout/item_git_branch_header.xml b/app/src/main/res/layout/item_git_branch_header.xml index 680f13c67d..df35b72e31 100644 --- a/app/src/main/res/layout/item_git_branch_header.xml +++ b/app/src/main/res/layout/item_git_branch_header.xml @@ -1,13 +1,14 @@ - + diff --git a/app/src/main/res/layout/popup_git_branches.xml b/app/src/main/res/layout/popup_git_branches.xml index 45cb6978f4..14062f15d2 100644 --- a/app/src/main/res/layout/popup_git_branches.xml +++ b/app/src/main/res/layout/popup_git_branches.xml @@ -1,79 +1,80 @@ - + - + - + - + - - + + - + - - + + - + - + diff --git a/app/src/test/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModelTest.kt b/app/src/test/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModelTest.kt index 7b35598cfa..019808e58a 100644 --- a/app/src/test/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModelTest.kt +++ b/app/src/test/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModelTest.kt @@ -25,82 +25,85 @@ import org.junit.runners.JUnit4 @RunWith(JUnit4::class) @OptIn(ExperimentalCoroutinesApi::class) class GitBottomSheetViewModelTest { - - @get:Rule - val instantExecutorRule = InstantTaskExecutorRule() - - @get:Rule - val mainDispatcherRule = MainDispatcherRule() - - private val credentialsManager = mockk(relaxed = true) - private val repository = mockk(relaxed = true) - private lateinit var viewModel: GitBottomSheetViewModel - - @Before - fun setup() { - viewModel = GitBottomSheetViewModel(credentialsManager, isNetworkConnected = { true }) - // Inject mock repository manually - val field = GitBottomSheetViewModel::class.java.getDeclaredField("currentRepository") - field.isAccessible = true - field.set(viewModel, repository) - } - - @After - fun tearDown() { - unmockkAll() - } - - @Test - fun `fetchBranches updates branches state`() = runTest { - val mockBranches = listOf( - GitBranch(name = "main", fullName = "refs/heads/main", isCurrent = true, isRemote = false), - GitBranch(name = "feature", fullName = "refs/heads/feature", isCurrent = false, isRemote = false) - ) - coEvery { repository.getBranches() } returns mockBranches - - viewModel.fetchBranches() - advanceUntilIdle() - - assertEquals(mockBranches, viewModel.branches.value) - } - - @Test - fun `checkoutBranch success updates checkoutState to Success and then resets to Idle`() = runTest { - coEvery { repository.checkout("feature", false, null) } returns Unit - coEvery { repository.getStatus() } returns mockk(relaxed = true) - - var successCalled = false - viewModel.checkoutBranch("feature", onSuccess = { successCalled = true }) - testScheduler.advanceTimeBy(100) - - val state = viewModel.checkoutState.value - assertTrue(state is GitBottomSheetViewModel.CheckoutUiState.Success) - assertEquals("feature", (state as GitBottomSheetViewModel.CheckoutUiState.Success).branchName) - assertTrue(successCalled) - coVerify { repository.checkout("feature", false, null) } - - // Advance past 3000ms delay to verify state resets to Idle - testScheduler.advanceTimeBy(3000) - assertTrue(viewModel.checkoutState.value is GitBottomSheetViewModel.CheckoutUiState.Idle) - } - - @Test - fun `checkoutBranch conflict updates checkoutState to Conflicts and then resets to Idle`() = runTest { - val conflictPaths = listOf("file1.txt", "file2.txt") - val exception = mockk(relaxed = true) - every { exception.conflictingPaths } returns conflictPaths - every { exception.getConflictingPaths() } returns conflictPaths - coEvery { repository.checkout("feature", false, null) } throws exception - - viewModel.checkoutBranch("feature") - testScheduler.advanceTimeBy(100) - - val state = viewModel.checkoutState.value - assertTrue(state is GitBottomSheetViewModel.CheckoutUiState.Conflicts) - assertEquals(conflictPaths, (state as GitBottomSheetViewModel.CheckoutUiState.Conflicts).conflictingPaths) - - // Advance past 3000ms delay to verify state resets to Idle - testScheduler.advanceTimeBy(3000) - assertTrue(viewModel.checkoutState.value is GitBottomSheetViewModel.CheckoutUiState.Idle) - } + @get:Rule + val instantExecutorRule = InstantTaskExecutorRule() + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + private val credentialsManager = mockk(relaxed = true) + private val repository = mockk(relaxed = true) + private lateinit var viewModel: GitBottomSheetViewModel + + @Before + fun setup() { + viewModel = GitBottomSheetViewModel(credentialsManager, isNetworkConnected = { true }) + // Inject mock repository manually + val field = GitBottomSheetViewModel::class.java.getDeclaredField("currentRepository") + field.isAccessible = true + field.set(viewModel, repository) + } + + @After + fun tearDown() { + unmockkAll() + } + + @Test + fun `fetchBranches updates branches state`() = + runTest { + val mockBranches = + listOf( + GitBranch(name = "main", fullName = "refs/heads/main", isCurrent = true, isRemote = false), + GitBranch(name = "feature", fullName = "refs/heads/feature", isCurrent = false, isRemote = false), + ) + coEvery { repository.getBranches() } returns mockBranches + + viewModel.fetchBranches() + advanceUntilIdle() + + assertEquals(mockBranches, viewModel.branches.value) + } + + @Test + fun `checkoutBranch success updates checkoutState to Success and then resets to Idle`() = + runTest { + coEvery { repository.checkout("feature", false, null) } returns Unit + coEvery { repository.getStatus() } returns mockk(relaxed = true) + + var successCalled = false + viewModel.checkoutBranch("feature", onSuccess = { successCalled = true }) + testScheduler.advanceTimeBy(100) + + val state = viewModel.checkoutState.value + assertTrue(state is GitBottomSheetViewModel.CheckoutUiState.Success) + assertEquals("feature", (state as GitBottomSheetViewModel.CheckoutUiState.Success).branchName) + assertTrue(successCalled) + coVerify { repository.checkout("feature", false, null) } + + // Advance past 3000ms delay to verify state resets to Idle + testScheduler.advanceTimeBy(3000) + assertTrue(viewModel.checkoutState.value is GitBottomSheetViewModel.CheckoutUiState.Idle) + } + + @Test + fun `checkoutBranch conflict updates checkoutState to Conflicts and then resets to Idle`() = + runTest { + val conflictPaths = listOf("file1.txt", "file2.txt") + val exception = mockk(relaxed = true) + every { exception.conflictingPaths } returns conflictPaths + every { exception.getConflictingPaths() } returns conflictPaths + coEvery { repository.checkout("feature", false, null) } throws exception + + viewModel.checkoutBranch("feature") + testScheduler.advanceTimeBy(100) + + val state = viewModel.checkoutState.value + assertTrue(state is GitBottomSheetViewModel.CheckoutUiState.Conflicts) + assertEquals(conflictPaths, (state as GitBottomSheetViewModel.CheckoutUiState.Conflicts).conflictingPaths) + + // Advance past 3000ms delay to verify state resets to Idle + testScheduler.advanceTimeBy(3000) + assertTrue(viewModel.checkoutState.value is GitBottomSheetViewModel.CheckoutUiState.Idle) + } } diff --git a/git-core/src/main/java/com/itsaky/androidide/git/core/GitRepository.kt b/git-core/src/main/java/com/itsaky/androidide/git/core/GitRepository.kt index 72fee368a5..249705ca0c 100644 --- a/git-core/src/main/java/com/itsaky/androidide/git/core/GitRepository.kt +++ b/git-core/src/main/java/com/itsaky/androidide/git/core/GitRepository.kt @@ -8,50 +8,58 @@ import org.eclipse.jgit.api.PullResult import org.eclipse.jgit.lib.ProgressMonitor import org.eclipse.jgit.transport.CredentialsProvider import org.eclipse.jgit.transport.PushResult -import java.io.File - import java.io.Closeable +import java.io.File /** * Interface defining core Git repository operations. */ interface GitRepository : Closeable { - val rootDir: File - - suspend fun getStatus(): GitStatus - suspend fun getCurrentBranch(): GitBranch? - suspend fun getBranches(): List - suspend fun getHistory(limit: Int = 50): List - suspend fun getDiff(file: File): String - - // Commit Operations - suspend fun stageFiles(files: List) - suspend fun commit(message: String, authorName: String? = null, authorEmail: String? = null): GitCommit? - - // Push Operations - suspend fun push( - remote: String = "origin", - credentialsProvider: CredentialsProvider? = null, - progressMonitor: ProgressMonitor? = null - ): Iterable - - suspend fun getLocalCommitsCount(): Int - - suspend fun pull( - remote: String = "origin", - credentialsProvider: CredentialsProvider? = null, - progressMonitor: ProgressMonitor? = null - ): PullResult - - // Merge Operations - suspend fun merge(branchName: String): MergeResult - suspend fun abortMerge() - - // Branch Operations - suspend fun checkout( - branchName: String, - createNew: Boolean = false, - startPoint: String? = null - ) -} + val rootDir: File + + suspend fun getStatus(): GitStatus + + suspend fun getCurrentBranch(): GitBranch? + + suspend fun getBranches(): List + + suspend fun getHistory(limit: Int = 50): List + + suspend fun getDiff(file: File): String + // Commit Operations + suspend fun stageFiles(files: List) + + suspend fun commit( + message: String, + authorName: String? = null, + authorEmail: String? = null, + ): GitCommit? + + // Push Operations + suspend fun push( + remote: String = "origin", + credentialsProvider: CredentialsProvider? = null, + progressMonitor: ProgressMonitor? = null, + ): Iterable + + suspend fun getLocalCommitsCount(): Int + + suspend fun pull( + remote: String = "origin", + credentialsProvider: CredentialsProvider? = null, + progressMonitor: ProgressMonitor? = null, + ): PullResult + + // Merge Operations + suspend fun merge(branchName: String): MergeResult + + suspend fun abortMerge() + + // Branch Operations + suspend fun checkout( + branchName: String, + createNew: Boolean = false, + startPoint: String? = null, + ) +} diff --git a/git-core/src/main/java/com/itsaky/androidide/git/core/JGitRepository.kt b/git-core/src/main/java/com/itsaky/androidide/git/core/JGitRepository.kt index bb8b13a911..813669e578 100644 --- a/git-core/src/main/java/com/itsaky/androidide/git/core/JGitRepository.kt +++ b/git-core/src/main/java/com/itsaky/androidide/git/core/JGitRepository.kt @@ -11,6 +11,8 @@ import org.eclipse.jgit.api.CreateBranchCommand import org.eclipse.jgit.api.Git import org.eclipse.jgit.api.ListBranchCommand.ListMode import org.eclipse.jgit.api.MergeResult +import org.eclipse.jgit.api.PullResult +import org.eclipse.jgit.api.ResetCommand.ResetType import org.eclipse.jgit.diff.DiffFormatter import org.eclipse.jgit.dircache.DirCacheIterator import org.eclipse.jgit.lib.BranchConfig @@ -18,14 +20,12 @@ import org.eclipse.jgit.lib.Constants import org.eclipse.jgit.lib.PersonIdent import org.eclipse.jgit.lib.ProgressMonitor import org.eclipse.jgit.lib.Repository +import org.eclipse.jgit.lib.RepositoryState import org.eclipse.jgit.revwalk.RevCommit import org.eclipse.jgit.revwalk.RevWalk import org.eclipse.jgit.storage.file.FileRepositoryBuilder import org.eclipse.jgit.transport.CredentialsProvider import org.eclipse.jgit.transport.PushResult -import org.eclipse.jgit.api.PullResult -import org.eclipse.jgit.api.ResetCommand.ResetType -import org.eclipse.jgit.lib.RepositoryState import org.eclipse.jgit.treewalk.AbstractTreeIterator import org.eclipse.jgit.treewalk.CanonicalTreeParser import org.eclipse.jgit.treewalk.EmptyTreeIterator @@ -38,325 +38,347 @@ import java.io.File /** * JGit-based implementation of the [GitRepository] interface. */ -class JGitRepository(override val rootDir: File) : GitRepository { - - private val log = LoggerFactory.getLogger(JGitRepository::class.java) - - private val repository: Repository = FileRepositoryBuilder() - .setWorkTree(rootDir) - .findGitDir(rootDir) - .build() - - private val git: Git = Git(repository) - - private fun getHeadTree(repository: Repository): AbstractTreeIterator { - val head = repository.resolve(Constants.HEAD) ?: return EmptyTreeIterator() - val treeParser = CanonicalTreeParser() - RevWalk(repository).use { revWalk -> - val commit = revWalk.parseCommit(head) - repository.newObjectReader().use { reader -> - treeParser.reset(reader, commit.tree.id) - } - } - return treeParser - } - - override suspend fun getStatus(): GitStatus = withContext(Dispatchers.IO) { - val jgitStatus = git.status().call() - - val staged = mutableListOf() - val unstaged = mutableListOf() - val untracked = mutableListOf() - val conflicted = mutableListOf() - - // Track unique paths to avoid duplicates across categories - // Priority: Conflicted > Staged > Unstaged > Untracked - val processedPaths = mutableSetOf() - - // 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)) } - - // 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)) } - - // 4. Untracked files - jgitStatus.untracked.forEach { if (processedPaths.add(it)) untracked.add(FileChange(it, ChangeType.UNTRACKED)) } - - - val isMerging = repository.repositoryState == RepositoryState.MERGING - - GitStatus( - isClean = jgitStatus.isClean, - hasConflicts = conflicted.isNotEmpty(), - isMerging = isMerging, - staged = staged, - unstaged = unstaged, - untracked = untracked, - conflicted = conflicted - ) - } - - override suspend fun getCurrentBranch(): GitBranch? = withContext(Dispatchers.IO) { - val head = repository.fullBranch ?: return@withContext null - val shortName = repository.branch ?: head - GitBranch( - name = shortName, - fullName = head, - isCurrent = true, - isRemote = head.startsWith(Constants.R_REMOTES) - ) - } - - override suspend fun getBranches(): List = withContext(Dispatchers.IO) { - val currentBranch = repository.fullBranch - git.branchList().setListMode(ListMode.ALL).call().map { ref -> - val isRemote = ref.name.startsWith(Constants.R_REMOTES) - val shortName = Repository.shortenRefName(ref.name) - val remoteName = if (isRemote) { - shortName.substringBefore('/') - } else null - GitBranch( - name = shortName, - fullName = ref.name, - isCurrent = ref.name == currentBranch, - isRemote = isRemote, - remoteName = remoteName - ) - } - } - - override suspend fun getHistory(limit: Int): List = withContext(Dispatchers.IO) { - try { - val branchName = repository.branch ?: return@withContext emptyList() - val trackingBranch = BranchConfig(repository.config, branchName).trackingBranch - - RevWalk(repository).use { walk -> - val remoteCommit = trackingBranch?.let { repository.resolve(it) }?.let { - walk.parseCommit(it) - } - - git.log().setMaxCount(limit).call().map { revCommit -> - val commit = walk.parseCommit(revCommit.id) - val isPushed = remoteCommit?.let { walk.isMergedInto(commit, it) } ?: false - commit.toGitCommit(isPushed) - } - } - } catch (e: Exception) { - log.error("Error fetching commit history", e) - emptyList() - } - } - - override suspend fun getDiff(file: File): String = withContext(Dispatchers.IO) { - val relativePath = file.toRelativeString(rootDir).replace('\\', '/') - val outputStream = ByteArrayOutputStream() - DiffFormatter(outputStream).use { formatter -> - formatter.setRepository(repository) - val indexTree = DirCacheIterator(repository.readDirCache()) - val workingTree = FileTreeIterator(repository) - formatter.pathFilter = PathFilter.create(relativePath) - formatter.format(indexTree, workingTree) - - // If empty, check staged diff - if (outputStream.size() == 0) { - val headTree = getHeadTree(repository) - val freshIndexTree = DirCacheIterator(repository.readDirCache()) - formatter.format(headTree, freshIndexTree) - } - } - outputStream.toString() - } - - override suspend fun stageFiles(files: List) = withContext(Dispatchers.IO) { - val addCommand = git.add() - val rmCommand = git.rm() - var hasAdds = false - var hasRms = false - - files.forEach { file -> - val relativePath = file.toRelativeString(rootDir).replace('\\', '/') - if (file.exists()) { - addCommand.addFilepattern(relativePath) - hasAdds = true - } else { - rmCommand.addFilepattern(relativePath) - hasRms = true - } - } - if (hasAdds) addCommand.call() - if (hasRms) rmCommand.call() - Unit - } - - override suspend fun commit( - message: String, - authorName: String?, - authorEmail: String? - ): GitCommit? = withContext(Dispatchers.IO) { - val commitCommand = git.commit().setMessage(message) - - if (!authorName.isNullOrBlank() && !authorEmail.isNullOrBlank()) { - val author = PersonIdent(authorName, authorEmail) - commitCommand.apply { - setAuthor(author) - setCommitter(author) - } - } - - val revCommit = commitCommand.call() - revCommit?.toGitCommit(false) - } - - private fun RevCommit.toGitCommit(hasBeenPushed: Boolean): GitCommit { - val author = authorIdent - return GitCommit( - hash = name, - shortHash = name.take(7), - authorName = author.name, - authorEmail = author.emailAddress, - message = fullMessage.trim(), - timestamp = author.`when`.time, - parentHashes = parents.map { it.name }, - hasBeenPushed = hasBeenPushed - ) - } - - override suspend fun push( - remote: String, - credentialsProvider: CredentialsProvider?, - progressMonitor: ProgressMonitor? - ): Iterable = withContext(Dispatchers.IO) { - val pushCommand = git.push().setRemote(remote) - - if (credentialsProvider != null) { - pushCommand.setCredentialsProvider(credentialsProvider) - } - - if (progressMonitor != null) { - pushCommand.setProgressMonitor(progressMonitor) - } - - pushCommand.call() - } - - override suspend fun getLocalCommitsCount(): Int = withContext(Dispatchers.IO) { - try { - val branchName = repository.branch ?: return@withContext 0 - val branch = repository.resolve(Constants.HEAD) ?: return@withContext 0 - val config = BranchConfig(repository.config, branchName) - val trackingBranch = config.trackingBranch - val remoteBranch = trackingBranch?.let { repository.resolve(it) } - - RevWalk(repository).use { walk -> - val localCommit = walk.parseCommit(branch) - walk.markStart(localCommit) - - if (remoteBranch != null) { - val remoteCommit = walk.parseCommit(remoteBranch) - walk.markUninteresting(remoteCommit) - } - - var count = 0 - walk.forEach { _ -> - count++ - } - count - } - } catch (e: Exception) { - log.error("Error fetching local commits", e) - 0 - } - } - - override suspend fun pull( - remote: String, - credentialsProvider: CredentialsProvider?, - progressMonitor: ProgressMonitor? - ): PullResult = withContext(Dispatchers.IO) { - val pullCommand = git.pull().setRemote(remote) - - if (credentialsProvider != null) { - pullCommand.setCredentialsProvider(credentialsProvider) - } - - if (progressMonitor != null) { - pullCommand.setProgressMonitor(progressMonitor) - } - - pullCommand.call() - } - - override suspend fun merge(branchName: String): MergeResult = withContext(Dispatchers.IO) { - val branchRef = repository.findRef(branchName) ?: throw IllegalArgumentException("Branch $branchName not found") - git.merge().include(branchRef).call() - } - - override suspend fun abortMerge(): Unit = withContext(Dispatchers.IO) { - // Reset working tree and index to HEAD - git.reset().setMode(ResetType.HARD).setRef(Constants.HEAD).call() - - // Explicitly clear merge-related files to exit the MERGING state - repository.apply { - writeMergeHeads(null) - writeMergeCommitMsg(null) - writeCherryPickHead(null) - writeRevertHead(null) - writeSquashCommitMsg(null) - } - } - - override suspend fun checkout( - branchName: String, - createNew: Boolean, - startPoint: String? - ) { - withContext(Dispatchers.IO) { - val checkoutCommand = git.checkout() - if (createNew) { - checkoutCommand.setCreateBranch(true) - checkoutCommand.setName(branchName) - if (!startPoint.isNullOrBlank()) { - checkoutCommand.setStartPoint(startPoint) - } - } else { - val isRemoteRef = branchName.startsWith(Constants.R_REMOTES) || branchName.startsWith("origin/") - if (isRemoteRef) { - val fullRemoteRef = if (branchName.startsWith(Constants.R_REMOTES)) { - branchName - } else { - "${Constants.R_REMOTES}$branchName" - } - val localName = Repository.shortenRefName(fullRemoteRef).substringAfter('/') - val localRef = repository.findRef("${Constants.R_HEADS}$localName") - if (localRef != null) { - checkoutCommand.setName(localName) - } else { - checkoutCommand.setCreateBranch(true) - .setName(localName) - .setStartPoint(fullRemoteRef) - .setUpstreamMode(CreateBranchCommand.SetupUpstreamMode.TRACK) - } - } else { - checkoutCommand.setName(branchName) - } - } - checkoutCommand.call() - } - } - - override fun close() { - repository.close() - git.close() - } +class JGitRepository( + override val rootDir: File, +) : GitRepository { + private val log = LoggerFactory.getLogger(JGitRepository::class.java) + + private val repository: Repository = + FileRepositoryBuilder() + .setWorkTree(rootDir) + .findGitDir(rootDir) + .build() + + private val git: Git = Git(repository) + + private fun getHeadTree(repository: Repository): AbstractTreeIterator { + val head = repository.resolve(Constants.HEAD) ?: return EmptyTreeIterator() + val treeParser = CanonicalTreeParser() + RevWalk(repository).use { revWalk -> + val commit = revWalk.parseCommit(head) + repository.newObjectReader().use { reader -> + treeParser.reset(reader, commit.tree.id) + } + } + return treeParser + } + + override suspend fun getStatus(): GitStatus = + withContext(Dispatchers.IO) { + val jgitStatus = git.status().call() + + val staged = mutableListOf() + val unstaged = mutableListOf() + val untracked = mutableListOf() + val conflicted = mutableListOf() + + // Track unique paths to avoid duplicates across categories + // Priority: Conflicted > Staged > Unstaged > Untracked + val processedPaths = mutableSetOf() + + // 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)) } + + // 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)) } + + // 4. Untracked files + jgitStatus.untracked.forEach { if (processedPaths.add(it)) untracked.add(FileChange(it, ChangeType.UNTRACKED)) } + + val isMerging = repository.repositoryState == RepositoryState.MERGING + + GitStatus( + isClean = jgitStatus.isClean, + hasConflicts = conflicted.isNotEmpty(), + isMerging = isMerging, + staged = staged, + unstaged = unstaged, + untracked = untracked, + conflicted = conflicted, + ) + } + + override suspend fun getCurrentBranch(): GitBranch? = + withContext(Dispatchers.IO) { + val head = repository.fullBranch ?: return@withContext null + val shortName = repository.branch ?: head + GitBranch( + name = shortName, + fullName = head, + isCurrent = true, + isRemote = head.startsWith(Constants.R_REMOTES), + ) + } + + override suspend fun getBranches(): List = + withContext(Dispatchers.IO) { + val currentBranch = repository.fullBranch + git.branchList().setListMode(ListMode.ALL).call().map { ref -> + val isRemote = ref.name.startsWith(Constants.R_REMOTES) + val shortName = Repository.shortenRefName(ref.name) + val remoteName = + if (isRemote) { + shortName.substringBefore('/') + } else { + null + } + GitBranch( + name = shortName, + fullName = ref.name, + isCurrent = ref.name == currentBranch, + isRemote = isRemote, + remoteName = remoteName, + ) + } + } + + override suspend fun getHistory(limit: Int): List = + withContext(Dispatchers.IO) { + try { + val branchName = repository.branch ?: return@withContext emptyList() + val trackingBranch = BranchConfig(repository.config, branchName).trackingBranch + + RevWalk(repository).use { walk -> + val remoteCommit = + trackingBranch?.let { repository.resolve(it) }?.let { + walk.parseCommit(it) + } + + git.log().setMaxCount(limit).call().map { revCommit -> + val commit = walk.parseCommit(revCommit.id) + val isPushed = remoteCommit?.let { walk.isMergedInto(commit, it) } ?: false + commit.toGitCommit(isPushed) + } + } + } catch (e: Exception) { + log.error("Error fetching commit history", e) + emptyList() + } + } + + override suspend fun getDiff(file: File): String = + withContext(Dispatchers.IO) { + val relativePath = file.toRelativeString(rootDir).replace('\\', '/') + val outputStream = ByteArrayOutputStream() + DiffFormatter(outputStream).use { formatter -> + formatter.setRepository(repository) + val indexTree = DirCacheIterator(repository.readDirCache()) + val workingTree = FileTreeIterator(repository) + formatter.pathFilter = PathFilter.create(relativePath) + formatter.format(indexTree, workingTree) + + // If empty, check staged diff + if (outputStream.size() == 0) { + val headTree = getHeadTree(repository) + val freshIndexTree = DirCacheIterator(repository.readDirCache()) + formatter.format(headTree, freshIndexTree) + } + } + outputStream.toString() + } + + override suspend fun stageFiles(files: List) = + withContext(Dispatchers.IO) { + val addCommand = git.add() + val rmCommand = git.rm() + var hasAdds = false + var hasRms = false + + files.forEach { file -> + val relativePath = file.toRelativeString(rootDir).replace('\\', '/') + if (file.exists()) { + addCommand.addFilepattern(relativePath) + hasAdds = true + } else { + rmCommand.addFilepattern(relativePath) + hasRms = true + } + } + if (hasAdds) addCommand.call() + if (hasRms) rmCommand.call() + Unit + } + + override suspend fun commit( + message: String, + authorName: String?, + authorEmail: String?, + ): GitCommit? = + withContext(Dispatchers.IO) { + val commitCommand = git.commit().setMessage(message) + + if (!authorName.isNullOrBlank() && !authorEmail.isNullOrBlank()) { + val author = PersonIdent(authorName, authorEmail) + commitCommand.apply { + setAuthor(author) + setCommitter(author) + } + } + + val revCommit = commitCommand.call() + revCommit?.toGitCommit(false) + } + + private fun RevCommit.toGitCommit(hasBeenPushed: Boolean): GitCommit { + val author = authorIdent + return GitCommit( + hash = name, + shortHash = name.take(7), + authorName = author.name, + authorEmail = author.emailAddress, + message = fullMessage.trim(), + timestamp = author.`when`.time, + parentHashes = parents.map { it.name }, + hasBeenPushed = hasBeenPushed, + ) + } + + override suspend fun push( + remote: String, + credentialsProvider: CredentialsProvider?, + progressMonitor: ProgressMonitor?, + ): Iterable = + withContext(Dispatchers.IO) { + val pushCommand = git.push().setRemote(remote) + + if (credentialsProvider != null) { + pushCommand.setCredentialsProvider(credentialsProvider) + } + + if (progressMonitor != null) { + pushCommand.setProgressMonitor(progressMonitor) + } + + pushCommand.call() + } + + override suspend fun getLocalCommitsCount(): Int = + withContext(Dispatchers.IO) { + try { + val branchName = repository.branch ?: return@withContext 0 + val branch = repository.resolve(Constants.HEAD) ?: return@withContext 0 + val config = BranchConfig(repository.config, branchName) + val trackingBranch = config.trackingBranch + val remoteBranch = trackingBranch?.let { repository.resolve(it) } + + RevWalk(repository).use { walk -> + val localCommit = walk.parseCommit(branch) + walk.markStart(localCommit) + + if (remoteBranch != null) { + val remoteCommit = walk.parseCommit(remoteBranch) + walk.markUninteresting(remoteCommit) + } + + var count = 0 + walk.forEach { _ -> + count++ + } + count + } + } catch (e: Exception) { + log.error("Error fetching local commits", e) + 0 + } + } + + override suspend fun pull( + remote: String, + credentialsProvider: CredentialsProvider?, + progressMonitor: ProgressMonitor?, + ): PullResult = + withContext(Dispatchers.IO) { + val pullCommand = git.pull().setRemote(remote) + + if (credentialsProvider != null) { + pullCommand.setCredentialsProvider(credentialsProvider) + } + + if (progressMonitor != null) { + pullCommand.setProgressMonitor(progressMonitor) + } + + pullCommand.call() + } + + override suspend fun merge(branchName: String): MergeResult = + withContext(Dispatchers.IO) { + val branchRef = repository.findRef(branchName) ?: throw IllegalArgumentException("Branch $branchName not found") + git.merge().include(branchRef).call() + } + + override suspend fun abortMerge(): Unit = + withContext(Dispatchers.IO) { + // Reset working tree and index to HEAD + git + .reset() + .setMode(ResetType.HARD) + .setRef(Constants.HEAD) + .call() + + // Explicitly clear merge-related files to exit the MERGING state + repository.apply { + writeMergeHeads(null) + writeMergeCommitMsg(null) + writeCherryPickHead(null) + writeRevertHead(null) + writeSquashCommitMsg(null) + } + } + + override suspend fun checkout( + branchName: String, + createNew: Boolean, + startPoint: String?, + ) { + withContext(Dispatchers.IO) { + val checkoutCommand = git.checkout() + if (createNew) { + checkoutCommand.setCreateBranch(true) + checkoutCommand.setName(branchName) + if (!startPoint.isNullOrBlank()) { + checkoutCommand.setStartPoint(startPoint) + } + } else { + val isRemoteRef = branchName.startsWith(Constants.R_REMOTES) || branchName.startsWith("origin/") + if (isRemoteRef) { + val fullRemoteRef = + if (branchName.startsWith(Constants.R_REMOTES)) { + branchName + } else { + "${Constants.R_REMOTES}$branchName" + } + val localName = Repository.shortenRefName(fullRemoteRef).substringAfter('/') + val localRef = repository.findRef("${Constants.R_HEADS}$localName") + if (localRef != null) { + checkoutCommand.setName(localName) + } else { + checkoutCommand + .setCreateBranch(true) + .setName(localName) + .setStartPoint(fullRemoteRef) + .setUpstreamMode(CreateBranchCommand.SetupUpstreamMode.TRACK) + } + } else { + checkoutCommand.setName(branchName) + } + } + checkoutCommand.call() + } + } + + override fun close() { + repository.close() + git.close() + } } - diff --git a/git-core/src/test/java/com/itsaky/androidide/git/core/JGitRepositoryTest.kt b/git-core/src/test/java/com/itsaky/androidide/git/core/JGitRepositoryTest.kt index 39ad0d2b93..b5ab3c4d28 100644 --- a/git-core/src/test/java/com/itsaky/androidide/git/core/JGitRepositoryTest.kt +++ b/git-core/src/test/java/com/itsaky/androidide/git/core/JGitRepositoryTest.kt @@ -2,7 +2,10 @@ package com.itsaky.androidide.git.core import kotlinx.coroutines.runBlocking import org.eclipse.jgit.api.Git -import org.junit.Assert.* +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Rule import org.junit.Test @@ -10,61 +13,67 @@ import org.junit.rules.TemporaryFolder import java.io.File class JGitRepositoryTest { + @get:Rule + val tempFolder = TemporaryFolder() - @get:Rule - val tempFolder = TemporaryFolder() + private lateinit var repoDir: File + private lateinit var jgitRepo: JGitRepository - private lateinit var repoDir: File - private lateinit var jgitRepo: JGitRepository + @Before + fun setUp() { + repoDir = tempFolder.newFolder("test-repo") + val git = Git.init().setDirectory(repoDir).call() - @Before - fun setUp() { - repoDir = tempFolder.newFolder("test-repo") - val git = Git.init().setDirectory(repoDir).call() - - // Create an initial commit so HEAD points to a valid commit - val dummyFile = File(repoDir, "file.txt") - dummyFile.writeText("initial content") - git.add().addFilepattern("file.txt").call() - git.commit().setMessage("Initial commit").setAuthor("Test", "test@example.com").call() + // Create an initial commit so HEAD points to a valid commit + val dummyFile = File(repoDir, "file.txt") + dummyFile.writeText("initial content") + git.add().addFilepattern("file.txt").call() + git + .commit() + .setMessage("Initial commit") + .setAuthor("Test", "test@example.com") + .call() - jgitRepo = JGitRepository(repoDir) - } + jgitRepo = JGitRepository(repoDir) + } - @Test - fun testGetCurrentBranchAndGetBranches() = runBlocking { - val currentBranch = jgitRepo.getCurrentBranch() - assertNotNull(currentBranch) - assertTrue(currentBranch!!.isCurrent) + @Test + fun testGetCurrentBranchAndGetBranches() = + runBlocking { + val currentBranch = jgitRepo.getCurrentBranch() + assertNotNull(currentBranch) + assertTrue(currentBranch!!.isCurrent) - val branches = jgitRepo.getBranches() - assertFalse(branches.isEmpty()) - assertTrue(branches.any { it.isCurrent }) - } + val branches = jgitRepo.getBranches() + assertFalse(branches.isEmpty()) + assertTrue(branches.any { it.isCurrent }) + } - @Test - fun testCreateAndCheckoutBranch() = runBlocking { - val newBranchName = "feature-test" - jgitRepo.checkout(newBranchName, createNew = true) + @Test + fun testCreateAndCheckoutBranch() = + runBlocking { + val newBranchName = "feature-test" + jgitRepo.checkout(newBranchName, createNew = true) - val currentBranch = jgitRepo.getCurrentBranch() - assertNotNull(currentBranch) - assertEquals(newBranchName, currentBranch!!.name) + val currentBranch = jgitRepo.getCurrentBranch() + assertNotNull(currentBranch) + assertEquals(newBranchName, currentBranch!!.name) - val branches = jgitRepo.getBranches() - assertTrue(branches.any { it.name == newBranchName && it.isCurrent }) - } + val branches = jgitRepo.getBranches() + assertTrue(branches.any { it.name == newBranchName && it.isCurrent }) + } - @Test - fun testSwitchExistingBranches() = runBlocking { - val initialBranch = jgitRepo.getCurrentBranch()!!.name + @Test + fun testSwitchExistingBranches() = + runBlocking { + val initialBranch = jgitRepo.getCurrentBranch()!!.name - // Create feature branch - jgitRepo.checkout("feature-1", createNew = true) - assertEquals("feature-1", jgitRepo.getCurrentBranch()!!.name) + // Create feature branch + jgitRepo.checkout("feature-1", createNew = true) + assertEquals("feature-1", jgitRepo.getCurrentBranch()!!.name) - // Switch back to initial branch - jgitRepo.checkout(initialBranch, createNew = false) - assertEquals(initialBranch, jgitRepo.getCurrentBranch()!!.name) - } + // Switch back to initial branch + jgitRepo.checkout(initialBranch, createNew = false) + assertEquals(initialBranch, jgitRepo.getCurrentBranch()!!.name) + } } diff --git a/resources/src/main/res/drawable/ic_branch.xml b/resources/src/main/res/drawable/ic_branch.xml index 6523c22ed8..9177970862 100644 --- a/resources/src/main/res/drawable/ic_branch.xml +++ b/resources/src/main/res/drawable/ic_branch.xml @@ -1,13 +1,14 @@ - + - + From 09e51ba7c13bf33bbd58454dc5213ee9acf4e358 Mon Sep 17 00:00:00 2001 From: Oluwadara Abijo Date: Wed, 19 Aug 2026 14:27:29 +0100 Subject: [PATCH 08/28] feat(ADFA-2881): Add merge action button --- .../fragments/git/GitBranchPopupWindow.kt | 15 ++++++++++---- .../fragments/git/adapter/GitBranchAdapter.kt | 7 +++++++ app/src/main/res/layout/item_git_branch.xml | 20 ++++++++++++++++++- resources/src/main/res/drawable/ic_merge.xml | 14 +++++++++++++ resources/src/main/res/values/strings.xml | 8 +++++++- 5 files changed, 58 insertions(+), 6 deletions(-) create mode 100644 resources/src/main/res/drawable/ic_merge.xml diff --git a/app/src/main/java/com/itsaky/androidide/fragments/git/GitBranchPopupWindow.kt b/app/src/main/java/com/itsaky/androidide/fragments/git/GitBranchPopupWindow.kt index 818d68d0e4..a343698d03 100644 --- a/app/src/main/java/com/itsaky/androidide/fragments/git/GitBranchPopupWindow.kt +++ b/app/src/main/java/com/itsaky/androidide/fragments/git/GitBranchPopupWindow.kt @@ -19,6 +19,7 @@ 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( @@ -37,10 +38,16 @@ class GitBranchPopupWindow( } private val adapter: GitBranchAdapter = - GitBranchAdapter { branch -> - popupWindow.dismiss() - onBranchSelected(branch) - } + GitBranchAdapter( + onBranchSelected = { branch -> + popupWindow.dismiss() + onBranchSelected(branch) + }, + onMergeClicked = { branch -> + popupWindow.dismiss() + onMergeBranch?.invoke(branch) + }, + ) private var allBranches: List = emptyList() diff --git a/app/src/main/java/com/itsaky/androidide/fragments/git/adapter/GitBranchAdapter.kt b/app/src/main/java/com/itsaky/androidide/fragments/git/adapter/GitBranchAdapter.kt index f288a52c2c..3f9e36d686 100644 --- a/app/src/main/java/com/itsaky/androidide/fragments/git/adapter/GitBranchAdapter.kt +++ b/app/src/main/java/com/itsaky/androidide/fragments/git/adapter/GitBranchAdapter.kt @@ -24,6 +24,7 @@ sealed class GitBranchListItem { class GitBranchAdapter( private val onBranchSelected: (GitBranch) -> Unit, + private val onMergeClicked: ((GitBranch) -> Unit)? = null, ) : ListAdapter(DiffCallback) { companion object { private const val VIEW_TYPE_HEADER = 0 @@ -79,9 +80,15 @@ class GitBranchAdapter( if (item.branch.isCurrent) { binding.ivActiveCheck.visibility = View.VISIBLE binding.imgBranchIcon.visibility = View.GONE + binding.btnMergeAction.visibility = View.GONE } else { binding.ivActiveCheck.visibility = View.GONE binding.imgBranchIcon.visibility = View.VISIBLE + binding.btnMergeAction.visibility = if (onMergeClicked != null) View.VISIBLE else View.GONE + } + + binding.btnMergeAction.setOnClickListener { + onMergeClicked?.invoke(item.branch) } binding.root.setOnClickListener { diff --git a/app/src/main/res/layout/item_git_branch.xml b/app/src/main/res/layout/item_git_branch.xml index 9beb0e1cc4..9748ed0a67 100644 --- a/app/src/main/res/layout/item_git_branch.xml +++ b/app/src/main/res/layout/item_git_branch.xml @@ -45,9 +45,27 @@ android:maxLines="1" android:textAppearance="?attr/textAppearanceBody2" app:layout_constraintBottom_toBottomOf="parent" - app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintEnd_toStartOf="@id/btnMergeAction" app:layout_constraintStart_toEndOf="@id/iconContainer" app:layout_constraintTop_toTopOf="parent" tools:text="main" /> + + diff --git a/resources/src/main/res/drawable/ic_merge.xml b/resources/src/main/res/drawable/ic_merge.xml new file mode 100644 index 0000000000..bda7d9579c --- /dev/null +++ b/resources/src/main/res/drawable/ic_merge.xml @@ -0,0 +1,14 @@ + + + + + diff --git a/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml index 3b3ad560af..c88b9beba1 100644 --- a/resources/src/main/res/values/strings.xml +++ b/resources/src/main/res/values/strings.xml @@ -1351,7 +1351,7 @@ Current branch: %1$s Current branch Changed files: %1$d - Git branches + Branches Local Remote New branch @@ -1380,6 +1380,12 @@ Merge conflicts Abort merge Are you sure you want to abort the current merge? All conflict resolutions will be discarded. + Merge into %1$s + Merged %1$s into %2$s successfully + Failed to merge %1$s + Merge conflict + Conflicts occurred while merging %1$s into %2$s. Please resolve conflicts or abort merge. + Already up to date You have unsaved changes. Would you like to save them before proceeding? Proceed without saving Save before proceeding From ded8ac5976d4d557001bca8febdad47383a03904 Mon Sep 17 00:00:00 2001 From: Oluwadara Abijo Date: Wed, 19 Aug 2026 14:32:04 +0100 Subject: [PATCH 09/28] feat(ADFA-2881): Execute merge and manage merge result --- .../viewmodel/GitBottomSheetViewModel.kt | 86 +++++++++++++++++++ .../viewmodel/GitBottomSheetViewModelTest.kt | 18 ++++ 2 files changed, 104 insertions(+) diff --git a/app/src/main/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModel.kt b/app/src/main/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModel.kt index bb0034877b..826c834491 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModel.kt @@ -70,9 +70,13 @@ class GitBottomSheetViewModel( private val _pushState = MutableStateFlow(PushUiState.Idle) val pushState: StateFlow = _pushState.asStateFlow() + private val _mergeState = MutableStateFlow(MergeUiState.Idle) + val mergeState: StateFlow = _mergeState.asStateFlow() + private var pullResetJob: Job? = null private var pushResetJob: Job? = null private var checkoutResetJob: Job? = null + private var mergeResetJob: Job? = null var currentRepository: GitRepository? = null private set @@ -394,6 +398,62 @@ class GitBottomSheetViewModel( _checkoutState.value = CheckoutUiState.Idle } + fun resetMergeState() { + mergeResetJob?.cancel() + _mergeState.value = MergeUiState.Idle + } + + fun mergeBranch(targetBranchName: String) { + mergeResetJob?.cancel() + + viewModelScope.launch { + val repo = currentRepository ?: return@launch + val currentBranchName = _currentBranch.value ?: "HEAD" + _mergeState.value = MergeUiState.Merging + + try { + val result = repo.merge(targetBranchName) + when (result.mergeStatus) { + MergeStatus.FAST_FORWARD, MergeStatus.FAST_FORWARD_SQUASHED, MergeStatus.MERGED, MergeStatus.MERGED_SQUASHED, MergeStatus.MERGED_SQUASHED_NOT_COMMITTED -> { + _mergeState.value = MergeUiState.Success( + targetBranch = targetBranchName, + currentBranch = currentBranchName, + ) + refreshStatus() + getCommitHistoryList() + getLocalCommitsCount() + } + MergeStatus.ALREADY_UP_TO_DATE -> { + _mergeState.value = MergeUiState.AlreadyUpToDate(targetBranch = targetBranchName) + } + MergeStatus.CONFLICTING -> { + val conflictingFiles = repo.getStatus().conflicted.map { it.path } + _mergeState.value = MergeUiState.Conflicts( + targetBranch = targetBranchName, + currentBranch = currentBranchName, + conflictingFiles = conflictingFiles, + ) + refreshStatus() + } + else -> { + _mergeState.value = MergeUiState.Error( + message = "Merge status: ${result.mergeStatus.name}", + ) + } + } + } catch (e: Exception) { + log.error("Failed to merge branch $targetBranchName", e) + _mergeState.value = MergeUiState.Error(message = e.message) + } finally { + mergeResetJob = + viewModelScope.launch { + delay(3000) + _mergeState.value = MergeUiState.Idle + } + } + } + } + sealed class CheckoutUiState { object Idle : CheckoutUiState() @@ -413,6 +473,32 @@ class GitBottomSheetViewModel( ) : CheckoutUiState() } + sealed class MergeUiState { + object Idle : MergeUiState() + + object Merging : MergeUiState() + + data class Success( + val targetBranch: String, + val currentBranch: String, + ) : MergeUiState() + + data class AlreadyUpToDate( + val targetBranch: String, + ) : MergeUiState() + + data class Conflicts( + val targetBranch: String, + val currentBranch: String, + val conflictingFiles: List = emptyList(), + ) : MergeUiState() + + data class Error( + val message: String? = null, + val errorResId: Int? = R.string.unknown_error, + ) : MergeUiState() + } + sealed class PullUiState { object Idle : PullUiState() diff --git a/app/src/test/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModelTest.kt b/app/src/test/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModelTest.kt index 019808e58a..e3b14e5f19 100644 --- a/app/src/test/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModelTest.kt +++ b/app/src/test/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModelTest.kt @@ -12,6 +12,7 @@ import io.mockk.unmockkAll import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest +import org.eclipse.jgit.api.MergeResult.MergeStatus import org.eclipse.jgit.api.errors.CheckoutConflictException import org.junit.After import org.junit.Assert.assertEquals @@ -106,4 +107,21 @@ class GitBottomSheetViewModelTest { testScheduler.advanceTimeBy(3000) assertTrue(viewModel.checkoutState.value is GitBottomSheetViewModel.CheckoutUiState.Idle) } + + @Test + fun `mergeBranch success updates mergeState to Success`() = + runTest { + val mergeResult = mockk(relaxed = true) + every { mergeResult.mergeStatus } returns MergeStatus.FAST_FORWARD + coEvery { repository.merge("feature-login") } returns mergeResult + coEvery { repository.getStatus() } returns mockk(relaxed = true) + + viewModel.mergeBranch("feature-login") + testScheduler.advanceTimeBy(100) + + val state = viewModel.mergeState.value + assertTrue(state is GitBottomSheetViewModel.MergeUiState.Success) + assertEquals("feature-login", (state as GitBottomSheetViewModel.MergeUiState.Success).targetBranch) + coVerify { repository.merge("feature-login") } + } } From f2d403127d88ba8028508b9f37381cedc68bfae8 Mon Sep 17 00:00:00 2001 From: Oluwadara Abijo Date: Wed, 19 Aug 2026 15:05:06 +0100 Subject: [PATCH 10/28] feat(ADFA-2881): Implement merging --- .../fragments/git/GitBottomSheetFragment.kt | 64 +++++++++++++++++++ .../viewmodel/GitBottomSheetViewModel.kt | 37 +++++++---- .../res/layout/fragment_git_bottom_sheet.xml | 4 +- 3 files changed, 91 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt b/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt index 66429ee898..56922e7f69 100644 --- a/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt +++ b/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt @@ -76,6 +76,11 @@ class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) { onNewBranchRequested = { showCreateBranchDialog() }, + onMergeBranch = { branch -> + checkUnsavedChangesAndProceed { + viewModel.mergeBranch(branch.name) + } + }, ) binding.tvBranchName.setOnClickListener { @@ -188,6 +193,65 @@ class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) { } } + launch { + viewModel.mergeState.collectLatest { state -> + when (state) { + is GitBottomSheetViewModel.MergeUiState.Idle -> { + binding.tvBranchName.isEnabled = true + } + + is GitBottomSheetViewModel.MergeUiState.Merging -> { + binding.tvBranchName.isEnabled = false + } + + is GitBottomSheetViewModel.MergeUiState.Success -> { + binding.tvBranchName.isEnabled = true + flashSuccess( + getString( + R.string.git_merge_success, + state.targetBranch, + state.currentBranch, + ), + ) + refreshEditorContent(force = true) + EventBus.getDefault().post(ListProjectFilesRequestEvent()) + } + + is GitBottomSheetViewModel.MergeUiState.AlreadyUpToDate -> { + binding.tvBranchName.isEnabled = true + flashSuccess(getString(R.string.git_already_up_to_date)) + } + + is GitBottomSheetViewModel.MergeUiState.Conflicts -> { + binding.tvBranchName.isEnabled = true + val message = + getString( + R.string.git_merge_conflict_msg, + state.targetBranch, + state.currentBranch, + ) + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.git_merge_conflict_title) + .setMessage(message) + .setPositiveButton(android.R.string.ok, null) + .show() + refreshEditorContent(force = true) + EventBus.getDefault().post(ListProjectFilesRequestEvent()) + } + + is GitBottomSheetViewModel.MergeUiState.Error -> { + binding.tvBranchName.isEnabled = true + val message = state.message ?: getString(R.string.git_merge_failed, "") + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.git_merge_failed) + .setMessage(message) + .setPositiveButton(android.R.string.ok, null) + .show() + } + } + } + } + combine( viewModel.isGitRepository, viewModel.gitStatus, diff --git a/app/src/main/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModel.kt b/app/src/main/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModel.kt index 826c834491..6fe1f5ea1e 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModel.kt @@ -414,31 +414,42 @@ class GitBottomSheetViewModel( try { val result = repo.merge(targetBranchName) when (result.mergeStatus) { - MergeStatus.FAST_FORWARD, MergeStatus.FAST_FORWARD_SQUASHED, MergeStatus.MERGED, MergeStatus.MERGED_SQUASHED, MergeStatus.MERGED_SQUASHED_NOT_COMMITTED -> { - _mergeState.value = MergeUiState.Success( - targetBranch = targetBranchName, - currentBranch = currentBranchName, - ) + MergeStatus.FAST_FORWARD, + MergeStatus.FAST_FORWARD_SQUASHED, + MergeStatus.MERGED, + MergeStatus.MERGED_SQUASHED, + MergeStatus.MERGED_SQUASHED_NOT_COMMITTED, + -> { + _mergeState.value = + MergeUiState.Success( + targetBranch = targetBranchName, + currentBranch = currentBranchName, + ) refreshStatus() getCommitHistoryList() getLocalCommitsCount() } + MergeStatus.ALREADY_UP_TO_DATE -> { _mergeState.value = MergeUiState.AlreadyUpToDate(targetBranch = targetBranchName) } + MergeStatus.CONFLICTING -> { val conflictingFiles = repo.getStatus().conflicted.map { it.path } - _mergeState.value = MergeUiState.Conflicts( - targetBranch = targetBranchName, - currentBranch = currentBranchName, - conflictingFiles = conflictingFiles, - ) + _mergeState.value = + MergeUiState.Conflicts( + targetBranch = targetBranchName, + currentBranch = currentBranchName, + conflictingFiles = conflictingFiles, + ) refreshStatus() } + else -> { - _mergeState.value = MergeUiState.Error( - message = "Merge status: ${result.mergeStatus.name}", - ) + _mergeState.value = + MergeUiState.Error( + message = "Merge status: ${result.mergeStatus.name}", + ) } } } catch (e: Exception) { diff --git a/app/src/main/res/layout/fragment_git_bottom_sheet.xml b/app/src/main/res/layout/fragment_git_bottom_sheet.xml index 1ea5d626f9..91983a51f4 100644 --- a/app/src/main/res/layout/fragment_git_bottom_sheet.xml +++ b/app/src/main/res/layout/fragment_git_bottom_sheet.xml @@ -35,8 +35,10 @@ android:id="@+id/tv_branch_name" android:layout_width="wrap_content" android:layout_height="wrap_content" + android:background="?attr/selectableItemBackground" android:clickable="true" android:ellipsize="end" + android:focusable="true" android:maxLines="1" android:paddingStart="4dp" android:paddingEnd="4dp" @@ -56,7 +58,7 @@ android:id="@+id/group_current_branch" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:visibility="gone" + android:visibility="visible" app:constraint_referenced_ids="tv_current_branch_label, imgBranchIcon, tv_branch_name" /> Date: Wed, 19 Aug 2026 20:54:22 +0100 Subject: [PATCH 11/28] refactor(ADFA-2881): Separate title string resources --- .../fragments/git/GitBottomSheetFragment.kt | 62 ++++++++++++++----- resources/src/main/res/values/strings.xml | 3 +- 2 files changed, 47 insertions(+), 18 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt b/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt index 56922e7f69..8d22ba9872 100644 --- a/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt +++ b/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt @@ -16,6 +16,7 @@ import androidx.recyclerview.widget.LinearLayoutManager import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.textfield.TextInputEditText +import com.google.android.material.textfield.TextInputLayout import com.itsaky.androidide.R import com.itsaky.androidide.activities.PreferencesActivity import com.itsaky.androidide.activities.editor.EditorHandlerActivity @@ -176,8 +177,11 @@ class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) { MaterialAlertDialogBuilder(requireContext()) .setTitle(R.string.git_checkout_conflict_title) .setMessage(message) - .setPositiveButton(android.R.string.ok, null) - .show() + .setPositiveButton(android.R.string.ok) { _, _ -> + viewModel.resetCheckoutState() + }.setOnDismissListener { + viewModel.resetCheckoutState() + }.show() } is GitBottomSheetViewModel.CheckoutUiState.Error -> { @@ -186,8 +190,11 @@ class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) { MaterialAlertDialogBuilder(requireContext()) .setTitle(R.string.git_checkout_failed) .setMessage(message) - .setPositiveButton(android.R.string.ok, null) - .show() + .setPositiveButton(android.R.string.ok) { _, _ -> + viewModel.resetCheckoutState() + }.setOnDismissListener { + viewModel.resetCheckoutState() + }.show() } } } @@ -233,20 +240,27 @@ class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) { MaterialAlertDialogBuilder(requireContext()) .setTitle(R.string.git_merge_conflict_title) .setMessage(message) - .setPositiveButton(android.R.string.ok, null) - .show() + .setPositiveButton(android.R.string.ok) { _, _ -> + viewModel.resetMergeState() + }.setOnDismissListener { + viewModel.resetMergeState() + }.show() refreshEditorContent(force = true) EventBus.getDefault().post(ListProjectFilesRequestEvent()) } is GitBottomSheetViewModel.MergeUiState.Error -> { binding.tvBranchName.isEnabled = true - val message = state.message ?: getString(R.string.git_merge_failed, "") + val targetName = state.targetBranch ?: "" + val message = state.message ?: getString(R.string.git_merge_failed, targetName) MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.git_merge_failed) + .setTitle(R.string.git_merge_failed_title) .setMessage(message) - .setPositiveButton(android.R.string.ok, null) - .show() + .setPositiveButton(android.R.string.ok) { _, _ -> + viewModel.resetMergeState() + }.setOnDismissListener { + viewModel.resetMergeState() + }.show() } } } @@ -574,22 +588,36 @@ class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) { private fun showCreateBranchDialog() { val dialogView = layoutInflater.inflate(R.layout.dialog_git_create_branch, null) + val branchNameLayout = dialogView.findViewById(R.id.branchNameLayout) val etBranchName = dialogView.findViewById(R.id.etBranchName) - MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.git_create_branch_title) - .setView(dialogView) - .setPositiveButton(R.string.git_create_branch) { _, _ -> + etBranchName?.doAfterTextChanged { + branchNameLayout?.error = null + } + + val dialog = + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.git_create_branch_title) + .setView(dialogView) + .setPositiveButton(R.string.git_create_branch, null) + .setNegativeButton(android.R.string.cancel, null) + .create() + + dialog.setOnShowListener { + dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener { val branchName = etBranchName?.text?.toString()?.trim() ?: "" if (branchName.isNotBlank()) { + dialog.dismiss() checkUnsavedChangesAndProceed { viewModel.checkoutBranch(branchName = branchName, createNew = true) } } else { - flashSuccess(getString(R.string.git_create_branch_invalid_name)) + branchNameLayout?.error = getString(R.string.git_create_branch_invalid_name) } - }.setNegativeButton(android.R.string.cancel, null) - .show() + } + } + + dialog.show() } private fun refreshEditorContent(force: Boolean = false) { diff --git a/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml index c88b9beba1..ee22ac4f53 100644 --- a/resources/src/main/res/values/strings.xml +++ b/resources/src/main/res/values/strings.xml @@ -1354,7 +1354,7 @@ Branches Local Remote - New branch + Create branch Create new branch Branch name Search branches… @@ -1382,6 +1382,7 @@ Are you sure you want to abort the current merge? All conflict resolutions will be discarded. Merge into %1$s Merged %1$s into %2$s successfully + Merge failed Failed to merge %1$s Merge conflict Conflicts occurred while merging %1$s into %2$s. Please resolve conflicts or abort merge. From 26fe8189490e6f7f6b338e5d14ffb94a9dbf904c Mon Sep 17 00:00:00 2001 From: Oluwadara Abijo Date: Wed, 19 Aug 2026 21:10:41 +0100 Subject: [PATCH 12/28] feat(ADFA-2881): Keep the remote name in remote branches --- .../fragments/git/GitBranchPopupWindow.kt | 22 +------------------ .../viewmodel/GitBottomSheetViewModel.kt | 10 +++++---- .../main/res/layout/popup_git_branches.xml | 2 +- 3 files changed, 8 insertions(+), 26 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/fragments/git/GitBranchPopupWindow.kt b/app/src/main/java/com/itsaky/androidide/fragments/git/GitBranchPopupWindow.kt index a343698d03..241f11c708 100644 --- a/app/src/main/java/com/itsaky/androidide/fragments/git/GitBranchPopupWindow.kt +++ b/app/src/main/java/com/itsaky/androidide/fragments/git/GitBranchPopupWindow.kt @@ -103,27 +103,7 @@ class GitBranchPopupWindow( adapter.submitList(items) } - private fun getDisplayName(branch: GitBranch): String { - if (!branch.isRemote) return branch.name - val remoteName = branch.remoteName - return when { - !remoteName.isNullOrEmpty() && branch.name.startsWith("$remoteName/") -> { - branch.name.removePrefix("$remoteName/") - } - - branch.name.startsWith("origin/") -> { - branch.name.removePrefix("origin/") - } - - branch.name.startsWith("refs/remotes/") -> { - branch.name.substringAfter("refs/remotes/").substringAfter('/') - } - - else -> { - branch.name - } - } - } + private fun getDisplayName(branch: GitBranch): String = branch.name fun show(anchor: View) { binding.etSearchBranches.text?.clear() diff --git a/app/src/main/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModel.kt b/app/src/main/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModel.kt index 6fe1f5ea1e..d604c349b5 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModel.kt @@ -274,7 +274,7 @@ class GitBottomSheetViewModel( } finally { pushResetJob = viewModelScope.launch { - delay(3000) + delay(3000.milliseconds) _pushState.value = PushUiState.Idle } } @@ -354,7 +354,7 @@ class GitBottomSheetViewModel( } finally { pullResetJob = viewModelScope.launch { - delay(3000) + delay(3000.milliseconds) _pullState.value = PullUiState.Idle } } @@ -448,17 +448,18 @@ class GitBottomSheetViewModel( else -> { _mergeState.value = MergeUiState.Error( + targetBranch = targetBranchName, message = "Merge status: ${result.mergeStatus.name}", ) } } } catch (e: Exception) { log.error("Failed to merge branch $targetBranchName", e) - _mergeState.value = MergeUiState.Error(message = e.message) + _mergeState.value = MergeUiState.Error(targetBranch = targetBranchName, message = e.message) } finally { mergeResetJob = viewModelScope.launch { - delay(3000) + delay(3000.milliseconds) _mergeState.value = MergeUiState.Idle } } @@ -506,6 +507,7 @@ class GitBottomSheetViewModel( data class Error( val message: String? = null, + val targetBranch: String? = null, val errorResId: Int? = R.string.unknown_error, ) : MergeUiState() } diff --git a/app/src/main/res/layout/popup_git_branches.xml b/app/src/main/res/layout/popup_git_branches.xml index 14062f15d2..ae1ca9075e 100644 --- a/app/src/main/res/layout/popup_git_branches.xml +++ b/app/src/main/res/layout/popup_git_branches.xml @@ -2,7 +2,7 @@ Date: Wed, 19 Aug 2026 21:53:44 +0100 Subject: [PATCH 13/28] feat(ADFA-2881): Represent branch-load failures explicitly --- .../fragments/git/GitBottomSheetFragment.kt | 6 ++-- .../fragments/git/GitBranchPopupWindow.kt | 23 ++++++++++-- .../viewmodel/GitBottomSheetViewModel.kt | 35 ++++++++++++++----- app/src/main/res/layout/item_git_branch.xml | 2 +- .../main/res/layout/popup_git_branches.xml | 10 +++++- .../viewmodel/GitBottomSheetViewModelTest.kt | 2 +- 6 files changed, 62 insertions(+), 16 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt b/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt index 8d22ba9872..50f01a1773 100644 --- a/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt +++ b/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt @@ -33,6 +33,7 @@ import com.itsaky.androidide.utils.flashSuccess import com.itsaky.androidide.utils.onLongPress import com.itsaky.androidide.viewmodel.BottomSheetViewModel import com.itsaky.androidide.viewmodel.GitBottomSheetViewModel +import com.itsaky.androidide.viewmodel.GitBottomSheetViewModel.BranchesUiState import com.itsaky.androidide.viewmodel.GitBottomSheetViewModel.PullUiState import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine @@ -144,8 +145,9 @@ class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) { } launch { - viewModel.branches.collectLatest { branches -> - branchPopupWindow.setBranches(branches) + viewModel.branches.collectLatest { state -> + binding.tvBranchName.isEnabled = state !is BranchesUiState.Loading + branchPopupWindow.setBranchesState(state) } } diff --git a/app/src/main/java/com/itsaky/androidide/fragments/git/GitBranchPopupWindow.kt b/app/src/main/java/com/itsaky/androidide/fragments/git/GitBranchPopupWindow.kt index 241f11c708..58cc291b75 100644 --- a/app/src/main/java/com/itsaky/androidide/fragments/git/GitBranchPopupWindow.kt +++ b/app/src/main/java/com/itsaky/androidide/fragments/git/GitBranchPopupWindow.kt @@ -14,6 +14,8 @@ 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 class GitBranchPopupWindow( private val context: Context, @@ -65,9 +67,24 @@ class GitBranchPopupWindow( } } - fun setBranches(branches: List) { - allBranches = branches - filterBranches(binding.etSearchBranches.text?.toString()) + fun setBranchesState(state: BranchesUiState) { + when (state) { + is BranchesUiState.Loading -> { + binding.branchesProgress.visibility = View.VISIBLE + } + + is BranchesUiState.Success -> { + binding.branchesProgress.visibility = View.GONE + allBranches = state.branches + filterBranches(binding.etSearchBranches.text?.toString()) + } + + is BranchesUiState.None, is BranchesUiState.Error -> { + binding.branchesProgress.visibility = View.GONE + allBranches = emptyList() + filterBranches(binding.etSearchBranches.text?.toString()) + } + } } private fun filterBranches(query: String?) { diff --git a/app/src/main/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModel.kt b/app/src/main/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModel.kt index d604c349b5..c8a8406bae 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModel.kt @@ -48,8 +48,8 @@ class GitBottomSheetViewModel( private val _currentBranch = MutableStateFlow(null) val currentBranch: StateFlow = _currentBranch.asStateFlow() - private val _branches = MutableStateFlow>(emptyList()) - val branches: StateFlow> = _branches.asStateFlow() + private val _branches = MutableStateFlow(BranchesUiState.None) + val branches: StateFlow = _branches.asStateFlow() private val _checkoutState = MutableStateFlow(CheckoutUiState.Idle) val checkoutState: StateFlow = _checkoutState.asStateFlow() @@ -117,19 +117,19 @@ class GitBottomSheetViewModel( val status = repo.getStatus() _gitStatus.value = status _currentBranch.value = repo.getCurrentBranch()?.name - _branches.value = repo.getBranches() + _branches.value = BranchesUiState.Success(repo.getBranches()) getLocalCommitsCount() } ?: run { _gitStatus.value = GitStatus.EMPTY _currentBranch.value = null - _branches.value = emptyList() + _branches.value = BranchesUiState.None _localCommitsCount.value = 0 } } catch (e: Exception) { log.error("Failed to refresh git status", e) _gitStatus.value = GitStatus.EMPTY _currentBranch.value = null - _branches.value = emptyList() + _branches.value = BranchesUiState.Error(e.message) _localCommitsCount.value = 0 } } @@ -137,12 +137,17 @@ class GitBottomSheetViewModel( fun fetchBranches() { viewModelScope.launch { + _branches.value = BranchesUiState.Loading try { - val repo = currentRepository ?: return@launch - _branches.value = repo.getBranches() + val repo = currentRepository + if (repo == null) { + _branches.value = BranchesUiState.None + return@launch + } + _branches.value = BranchesUiState.Success(repo.getBranches()) } catch (e: Exception) { log.error("Failed to fetch branches", e) - _branches.value = emptyList() + _branches.value = BranchesUiState.Error(e.message) } } } @@ -466,6 +471,20 @@ class GitBottomSheetViewModel( } } + sealed class BranchesUiState { + object None : BranchesUiState() + + object Loading : BranchesUiState() + + data class Success( + val branches: List, + ) : BranchesUiState() + + data class Error( + val message: String? = null, + ) : BranchesUiState() + } + sealed class CheckoutUiState { object Idle : CheckoutUiState() diff --git a/app/src/main/res/layout/item_git_branch.xml b/app/src/main/res/layout/item_git_branch.xml index 9748ed0a67..04e070adb4 100644 --- a/app/src/main/res/layout/item_git_branch.xml +++ b/app/src/main/res/layout/item_git_branch.xml @@ -41,7 +41,7 @@ android:layout_width="0dp" android:layout_height="wrap_content" android:layout_marginStart="12dp" - android:ellipsize="middle" + android:ellipsize="marquee" android:maxLines="1" android:textAppearance="?attr/textAppearanceBody2" app:layout_constraintBottom_toBottomOf="parent" diff --git a/app/src/main/res/layout/popup_git_branches.xml b/app/src/main/res/layout/popup_git_branches.xml index ae1ca9075e..036717d616 100644 --- a/app/src/main/res/layout/popup_git_branches.xml +++ b/app/src/main/res/layout/popup_git_branches.xml @@ -2,7 +2,7 @@ + + Date: Wed, 19 Aug 2026 21:57:55 +0100 Subject: [PATCH 14/28] refactor(ADFA-2881): Reposition branches popup window --- .../fragments/git/GitBranchPopupWindow.kt | 20 ++++++++++++++++++- .../main/res/layout/popup_git_branches.xml | 2 +- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/fragments/git/GitBranchPopupWindow.kt b/app/src/main/java/com/itsaky/androidide/fragments/git/GitBranchPopupWindow.kt index 58cc291b75..fd6c28e14f 100644 --- a/app/src/main/java/com/itsaky/androidide/fragments/git/GitBranchPopupWindow.kt +++ b/app/src/main/java/com/itsaky/androidide/fragments/git/GitBranchPopupWindow.kt @@ -125,6 +125,24 @@ class GitBranchPopupWindow( fun show(anchor: View) { binding.etSearchBranches.text?.clear() filterBranches(null) - popupWindow.showAsDropDown(anchor, 0, 8) + + val parentView = (anchor.parent as? View) ?: anchor.rootView + val marginPx = (16 * context.resources.displayMetrics.density).toInt() + val targetWidth = parentView.width - (marginPx * 2) + + val xOff = + if (targetWidth > 0) { + popupWindow.width = targetWidth + val anchorLocation = IntArray(2) + val parentLocation = IntArray(2) + anchor.getLocationInWindow(anchorLocation) + parentView.getLocationInWindow(parentLocation) + val anchorLeftInParent = anchorLocation[0] - parentLocation[0] + marginPx - anchorLeftInParent + } else { + 0 + } + + popupWindow.showAsDropDown(anchor, xOff, 8) } } diff --git a/app/src/main/res/layout/popup_git_branches.xml b/app/src/main/res/layout/popup_git_branches.xml index 036717d616..7e51173710 100644 --- a/app/src/main/res/layout/popup_git_branches.xml +++ b/app/src/main/res/layout/popup_git_branches.xml @@ -2,7 +2,7 @@ Date: Wed, 19 Aug 2026 22:01:13 +0100 Subject: [PATCH 15/28] feat(ADFA-2881): Re-throw coroutine cancellation --- .../viewmodel/GitBottomSheetViewModel.kt | 25 ++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModel.kt b/app/src/main/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModel.kt index c8a8406bae..accb6d39f6 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModel.kt @@ -18,6 +18,7 @@ import com.itsaky.androidide.preferences.internal.GitPreferences import com.itsaky.androidide.projects.IProjectManager import com.itsaky.androidide.resources.R import com.itsaky.androidide.utils.isNetworkConnected +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow @@ -99,6 +100,8 @@ class GitBottomSheetViewModel( currentRepository = GitRepositoryManager.openRepository(projectDir) _isGitRepository.value = currentRepository != null refreshStatus() + } catch (e: CancellationException) { + throw e } catch (e: Exception) { log.error("Failed to initialize repository", e) _isGitRepository.value = false @@ -125,6 +128,8 @@ class GitBottomSheetViewModel( _branches.value = BranchesUiState.None _localCommitsCount.value = 0 } + } catch (e: CancellationException) { + throw e } catch (e: Exception) { log.error("Failed to refresh git status", e) _gitStatus.value = GitStatus.EMPTY @@ -145,6 +150,8 @@ class GitBottomSheetViewModel( return@launch } _branches.value = BranchesUiState.Success(repo.getBranches()) + } catch (e: CancellationException) { + throw e } catch (e: Exception) { log.error("Failed to fetch branches", e) _branches.value = BranchesUiState.Error(e.message) @@ -170,6 +177,8 @@ class GitBottomSheetViewModel( } catch (e: CheckoutConflictException) { log.error("Checkout conflict occurred", e) _checkoutState.value = CheckoutUiState.Conflicts(e.conflictingPaths ?: emptyList()) + } catch (e: CancellationException) { + throw e } catch (e: Exception) { log.error("Checkout failed", e) _checkoutState.value = CheckoutUiState.Error(message = e.message) @@ -214,6 +223,8 @@ class GitBottomSheetViewModel( refreshStatus() onSuccess() + } catch (e: CancellationException) { + throw e } catch (e: Exception) { log.error("Failed to commit changes", e) } @@ -231,6 +242,8 @@ class GitBottomSheetViewModel( _commitHistory.value = CommitHistoryUiState.Success(history) } getLocalCommitsCount() + } catch (e: CancellationException) { + throw e } catch (e: Exception) { log.error("Failed to fetch commit history", e) _commitHistory.value = CommitHistoryUiState.Error(e.message) @@ -269,6 +282,8 @@ class GitBottomSheetViewModel( } handlePushSuccess(username, token) + } catch (e: CancellationException) { + throw e } catch (e: Exception) { if (e.message?.contains("not authorized", ignoreCase = true) == true) { credentialsManager.clearCredentials() @@ -345,9 +360,11 @@ class GitBottomSheetViewModel( handlePullSuccess(username, token) } catch (e: CheckoutConflictException) { - log.error("Pull failed with checkout conflict", e) + log.error("Pull checkout conflict occurred", e) val paths = e.conflictingPaths?.joinToString("\n") ?: "" _pullState.value = PullUiState.Error(errorResId = R.string.checkout_conflict_message, errorArgs = listOf(paths)) + } catch (e: CancellationException) { + throw e } catch (e: Exception) { log.error("Pull failed", e) if (e.message?.contains("not authorized", ignoreCase = true) == true) { @@ -458,6 +475,8 @@ class GitBottomSheetViewModel( ) } } + } catch (e: CancellationException) { + throw e } catch (e: Exception) { log.error("Failed to merge branch $targetBranchName", e) _mergeState.value = MergeUiState.Error(targetBranch = targetBranchName, message = e.message) @@ -593,6 +612,8 @@ class GitBottomSheetViewModel( currentRepository?.abortMerge() refreshStatus() onSuccess?.invoke() + } catch (e: CancellationException) { + throw e } catch (e: Exception) { log.error("Failed to abort merge", e) } @@ -606,6 +627,8 @@ class GitBottomSheetViewModel( val projectDir = File(IProjectManager.getInstance().projectDirPath) repository.stageFiles(listOf(File(projectDir, path))) refreshStatus() + } catch (e: CancellationException) { + throw e } catch (e: Exception) { log.error("Failed to resolve conflict for $path", e) } From 32eca8372b1766aaed596268df1a47010bf8191a Mon Sep 17 00:00:00 2001 From: Oluwadara Abijo Date: Wed, 19 Aug 2026 22:08:11 +0100 Subject: [PATCH 16/28] feat(ADFA-2881): Add accessibility attributes to the new branch views --- .../itsaky/androidide/fragments/git/GitBottomSheetFragment.kt | 2 ++ .../androidide/fragments/git/adapter/GitBranchAdapter.kt | 4 ++++ app/src/main/res/layout/fragment_git_bottom_sheet.xml | 1 + app/src/main/res/layout/item_git_branch.xml | 3 ++- app/src/main/res/layout/item_git_branch_header.xml | 1 + app/src/main/res/layout/popup_git_branches.xml | 1 + 6 files changed, 11 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt b/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt index 50f01a1773..08aca7e9a7 100644 --- a/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt +++ b/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt @@ -138,6 +138,8 @@ class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) { if (branchName != null) { binding.groupCurrentBranch.visibility = View.VISIBLE binding.tvBranchName.text = branchName + binding.tvBranchName.contentDescription = + "${getString(R.string.current_branch)}: $branchName" } else { binding.groupCurrentBranch.visibility = View.GONE } diff --git a/app/src/main/java/com/itsaky/androidide/fragments/git/adapter/GitBranchAdapter.kt b/app/src/main/java/com/itsaky/androidide/fragments/git/adapter/GitBranchAdapter.kt index 3f9e36d686..4638d96aa3 100644 --- a/app/src/main/java/com/itsaky/androidide/fragments/git/adapter/GitBranchAdapter.kt +++ b/app/src/main/java/com/itsaky/androidide/fragments/git/adapter/GitBranchAdapter.kt @@ -75,16 +75,20 @@ class GitBranchAdapter( 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 = "${item.displayName}, ${context.getString(R.string.current_branch)}" } 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) + binding.root.contentDescription = item.displayName } binding.btnMergeAction.setOnClickListener { diff --git a/app/src/main/res/layout/fragment_git_bottom_sheet.xml b/app/src/main/res/layout/fragment_git_bottom_sheet.xml index 91983a51f4..c9ba6aab7f 100644 --- a/app/src/main/res/layout/fragment_git_bottom_sheet.xml +++ b/app/src/main/res/layout/fragment_git_bottom_sheet.xml @@ -26,6 +26,7 @@ android:layout_width="16dp" android:layout_height="16dp" android:layout_marginTop="4dp" + android:importantForAccessibility="no" android:src="@drawable/ic_branch" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/tv_current_branch_label" diff --git a/app/src/main/res/layout/item_git_branch.xml b/app/src/main/res/layout/item_git_branch.xml index 04e070adb4..6de7379fb4 100644 --- a/app/src/main/res/layout/item_git_branch.xml +++ b/app/src/main/res/layout/item_git_branch.xml @@ -23,6 +23,7 @@ android:id="@+id/imgBranchIcon" android:layout_width="20dp" android:layout_height="20dp" + android:importantForAccessibility="no" android:src="@drawable/ic_branch" app:tint="?attr/colorOnSurface" /> @@ -30,6 +31,7 @@ android:id="@+id/ivActiveCheck" android:layout_width="20dp" android:layout_height="20dp" + android:contentDescription="@string/current_branch" android:src="@drawable/ic_check" android:visibility="gone" app:tint="?attr/colorPrimary" @@ -57,7 +59,6 @@ android:layout_marginStart="8dp" android:background="@drawable/bg_ripple" android:clickable="true" - android:contentDescription="@string/git_merge_branch" android:focusable="true" android:scaleType="fitCenter" android:src="@drawable/ic_merge" diff --git a/app/src/main/res/layout/item_git_branch_header.xml b/app/src/main/res/layout/item_git_branch_header.xml index df35b72e31..8d5cc867f6 100644 --- a/app/src/main/res/layout/item_git_branch_header.xml +++ b/app/src/main/res/layout/item_git_branch_header.xml @@ -10,5 +10,6 @@ android:textAppearance="?attr/textAppearanceCaption" android:textColor="?attr/colorOnSurfaceVariant" android:textStyle="bold" + android:accessibilityHeading="true" android:textAllCaps="true" android:textSize="11sp" /> diff --git a/app/src/main/res/layout/popup_git_branches.xml b/app/src/main/res/layout/popup_git_branches.xml index 7e51173710..84f8e02281 100644 --- a/app/src/main/res/layout/popup_git_branches.xml +++ b/app/src/main/res/layout/popup_git_branches.xml @@ -56,6 +56,7 @@ app:boxCornerRadiusTopEnd="8dp" app:boxCornerRadiusTopStart="8dp" app:hintEnabled="false" + app:startIconContentDescription="@null" app:startIconDrawable="@drawable/ic_search"> Date: Wed, 19 Aug 2026 22:24:56 +0100 Subject: [PATCH 17/28] fix(ADFA-2881): Eliminate reflection in tests --- .../viewmodel/GitBottomSheetViewModel.kt | 9 ++- .../viewmodel/GitBottomSheetViewModelTest.kt | 80 +++++++++++++++++-- .../androidide/git/core/JGitRepositoryTest.kt | 52 ++++++++++++ 3 files changed, 134 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModel.kt b/app/src/main/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModel.kt index accb6d39f6..fc6a9ca42c 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModel.kt @@ -40,6 +40,7 @@ import kotlin.time.Duration.Companion.milliseconds class GitBottomSheetViewModel( private val credentialsManager: GitCredentialsManager, private val isNetworkConnected: () -> Boolean = { BaseApplication.baseInstance.isNetworkConnected() }, + repository: GitRepository? = null, ) : ViewModel() { private val log = LoggerFactory.getLogger(GitBottomSheetViewModel::class.java) @@ -79,12 +80,16 @@ class GitBottomSheetViewModel( private var checkoutResetJob: Job? = null private var mergeResetJob: Job? = null - var currentRepository: GitRepository? = null + var currentRepository: GitRepository? = repository private set init { EventBus.getDefault().register(this) - initializeRepository() + if (currentRepository == null) { + initializeRepository() + } else { + _isGitRepository.value = true + } } override fun onCleared() { diff --git a/app/src/test/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModelTest.kt b/app/src/test/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModelTest.kt index 885c2cb430..076f5277e4 100644 --- a/app/src/test/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModelTest.kt +++ b/app/src/test/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModelTest.kt @@ -38,11 +38,12 @@ class GitBottomSheetViewModelTest { @Before fun setup() { - viewModel = GitBottomSheetViewModel(credentialsManager, isNetworkConnected = { true }) - // Inject mock repository manually - val field = GitBottomSheetViewModel::class.java.getDeclaredField("currentRepository") - field.isAccessible = true - field.set(viewModel, repository) + viewModel = + GitBottomSheetViewModel( + credentialsManager = credentialsManager, + isNetworkConnected = { true }, + repository = repository, + ) } @After @@ -124,4 +125,73 @@ class GitBottomSheetViewModelTest { assertEquals("feature-login", (state as GitBottomSheetViewModel.MergeUiState.Success).targetBranch) coVerify { repository.merge("feature-login") } } + + @Test + fun `mergeBranch already up to date updates mergeState to AlreadyUpToDate`() = + runTest { + val mergeResult = mockk(relaxed = true) + every { mergeResult.mergeStatus } returns MergeStatus.ALREADY_UP_TO_DATE + coEvery { repository.merge("main") } returns mergeResult + + viewModel.mergeBranch("main") + testScheduler.advanceTimeBy(100) + + val state = viewModel.mergeState.value + assertTrue(state is GitBottomSheetViewModel.MergeUiState.AlreadyUpToDate) + assertEquals("main", (state as GitBottomSheetViewModel.MergeUiState.AlreadyUpToDate).targetBranch) + } + + @Test + fun `mergeBranch conflict updates mergeState to Conflicts`() = + runTest { + val mergeResult = mockk(relaxed = true) + every { mergeResult.mergeStatus } returns MergeStatus.CONFLICTING + coEvery { repository.merge("feature-conflict") } returns mergeResult + val mockStatus = mockk(relaxed = true) + every { mockStatus.conflicted } returns + listOf( + com.itsaky.androidide.git.core.models.FileChange( + "conflicted.txt", + com.itsaky.androidide.git.core.models.ChangeType.CONFLICTED, + ), + ) + coEvery { repository.getStatus() } returns mockStatus + + viewModel.mergeBranch("feature-conflict") + testScheduler.advanceTimeBy(100) + + val state = viewModel.mergeState.value + assertTrue(state is GitBottomSheetViewModel.MergeUiState.Conflicts) + val conflictState = state as GitBottomSheetViewModel.MergeUiState.Conflicts + assertEquals("feature-conflict", conflictState.targetBranch) + assertEquals(listOf("conflicted.txt"), conflictState.conflictingFiles) + } + + @Test + fun `mergeBranch error updates mergeState to Error`() = + runTest { + coEvery { repository.merge("non-existent") } throws IllegalArgumentException("Branch not found") + + viewModel.mergeBranch("non-existent") + testScheduler.advanceTimeBy(100) + + val state = viewModel.mergeState.value + assertTrue(state is GitBottomSheetViewModel.MergeUiState.Error) + val errorState = state as GitBottomSheetViewModel.MergeUiState.Error + assertEquals("non-existent", errorState.targetBranch) + assertEquals("Branch not found", errorState.message) + } + + @Test + fun `fetchBranches error updates branches state to Error`() = + runTest { + coEvery { repository.getBranches() } throws RuntimeException("Git error") + + viewModel.fetchBranches() + advanceUntilIdle() + + val state = viewModel.branches.value + assertTrue(state is GitBottomSheetViewModel.BranchesUiState.Error) + assertEquals("Git error", (state as GitBottomSheetViewModel.BranchesUiState.Error).message) + } } diff --git a/git-core/src/test/java/com/itsaky/androidide/git/core/JGitRepositoryTest.kt b/git-core/src/test/java/com/itsaky/androidide/git/core/JGitRepositoryTest.kt index b5ab3c4d28..a9630be13b 100644 --- a/git-core/src/test/java/com/itsaky/androidide/git/core/JGitRepositoryTest.kt +++ b/git-core/src/test/java/com/itsaky/androidide/git/core/JGitRepositoryTest.kt @@ -76,4 +76,56 @@ class JGitRepositoryTest { jgitRepo.checkout(initialBranch, createNew = false) assertEquals(initialBranch, jgitRepo.getCurrentBranch()!!.name) } + + @Test + fun testMergeFastForward() = + runBlocking { + val initialBranch = jgitRepo.getCurrentBranch()!!.name + + // Create feature branch and make a commit + jgitRepo.checkout("feature-merge", createNew = true) + val featureFile = File(repoDir, "feature.txt") + featureFile.writeText("feature content") + jgitRepo.stageFiles(listOf(featureFile)) + jgitRepo.commit("Feature commit", "Test", "test@example.com") + + // Switch back to initial branch and merge + jgitRepo.checkout(initialBranch, createNew = false) + val result = jgitRepo.merge("feature-merge") + assertTrue(result.mergeStatus.isSuccessful) + assertTrue(File(repoDir, "feature.txt").exists()) + } + + @Test + fun testAbortMerge() = + runBlocking { + val initialBranch = jgitRepo.getCurrentBranch()!!.name + + // Create feature branch and change file.txt + jgitRepo.checkout("feature-conflict", createNew = true) + val file = File(repoDir, "file.txt") + file.writeText("feature conflict content") + jgitRepo.stageFiles(listOf(file)) + jgitRepo.commit("Feature conflict commit", "Test", "test@example.com") + + // Switch to initial branch and make a conflicting change + jgitRepo.checkout(initialBranch, createNew = false) + file.writeText("initial conflicting content") + jgitRepo.stageFiles(listOf(file)) + jgitRepo.commit("Main conflicting commit", "Test", "test@example.com") + + // Attempt merge -> CONFLICTING + val mergeResult = jgitRepo.merge("feature-conflict") + assertEquals(org.eclipse.jgit.api.MergeResult.MergeStatus.CONFLICTING, mergeResult.mergeStatus) + val statusBeforeAbort = jgitRepo.getStatus() + assertTrue(statusBeforeAbort.isMerging) + assertTrue(statusBeforeAbort.hasConflicts) + + // Abort merge -> verify clean state + jgitRepo.abortMerge() + val statusAfterAbort = jgitRepo.getStatus() + assertFalse(statusAfterAbort.isMerging) + assertFalse(statusAfterAbort.hasConflicts) + assertEquals("initial conflicting content", file.readText()) + } } From 6c6b9dafa7464e4934f9e4d9f84b0d7bc7cb1251 Mon Sep 17 00:00:00 2001 From: Oluwadara Abijo Date: Wed, 19 Aug 2026 22:53:20 +0100 Subject: [PATCH 18/28] fix(ADFA-2881): Address PR issues --- .../fragments/git/GitBottomSheetFragment.kt | 31 ++++++---- .../res/layout/fragment_git_bottom_sheet.xml | 4 +- .../androidide/git/core/JGitRepository.kt | 40 +++++++++---- .../androidide/git/core/JGitRepositoryTest.kt | 56 ++++++++++++++++--- 4 files changed, 100 insertions(+), 31 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt b/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt index 08aca7e9a7..68ff2d4f30 100644 --- a/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt +++ b/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt @@ -25,6 +25,8 @@ import com.itsaky.androidide.events.ListProjectFilesRequestEvent 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.git.core.models.FileChange +import com.itsaky.androidide.git.core.models.GitStatus import com.itsaky.androidide.idetooltips.TooltipManager import com.itsaky.androidide.idetooltips.TooltipTag import com.itsaky.androidide.interfaces.IEditorHandler @@ -270,15 +272,19 @@ class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) { } } + launch { + viewModel.isGitRepository.collectLatest { isRepo -> + if (isRepo) { + viewModel.fetchBranches() + } + } + } + combine( viewModel.isGitRepository, viewModel.gitStatus, ) { isRepo, status -> - if (isRepo) { - viewModel.fetchBranches() - } - val allChanges = - status.staged + status.unstaged + status.untracked + status.conflicted + val allChanges = status.allChanges() when { !isRepo -> { @@ -311,7 +317,7 @@ class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) { } else -> { - val hasSelectable = allChanges.any { it.type != ChangeType.CONFLICTED } + val hasSelectable = allChanges.hasSelectable() binding.apply { emptyView.visibility = View.GONE recyclerView.visibility = View.VISIBLE @@ -353,9 +359,7 @@ class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) { private fun updateAuthorUI() { val hasAuthor = hasAuthorInfo() - val allChanges = - viewModel.gitStatus.value.staged + viewModel.gitStatus.value.unstaged + viewModel.gitStatus.value.untracked + - viewModel.gitStatus.value.conflicted + val allChanges = viewModel.gitStatus.value.allChanges() binding.authorWarning.visibility = if (!hasAuthor && allChanges.isNotEmpty()) View.VISIBLE else View.GONE validateCommitButton() @@ -490,9 +494,8 @@ class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) { private fun updateCheckAllButton() { // May be invoked from the async submitList commit callback; bail if the view is gone. val binding = _binding ?: return - val status = viewModel.gitStatus.value - val allChanges = status.staged + status.unstaged + status.untracked + status.conflicted - val hasSelectable = allChanges.any { it.type != ChangeType.CONFLICTED } + val allChanges = viewModel.gitStatus.value.allChanges() + val hasSelectable = allChanges.hasSelectable() binding.cbCheckAll.text = getString(R.string.changed_files_count, allChanges.size) binding.cbCheckAll.isEnabled = hasSelectable && allChanges.isNotEmpty() binding.cbCheckAll.isChecked = hasSelectable && allChanges.isNotEmpty() && fileChangeAdapter.areAllSelected() @@ -677,4 +680,8 @@ class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) { true } } + + private fun GitStatus.allChanges(): List = staged + unstaged + untracked + conflicted + + private fun List.hasSelectable(): Boolean = any { it.type != ChangeType.CONFLICTED } } diff --git a/app/src/main/res/layout/fragment_git_bottom_sheet.xml b/app/src/main/res/layout/fragment_git_bottom_sheet.xml index c9ba6aab7f..9a7fcf2eac 100644 --- a/app/src/main/res/layout/fragment_git_bottom_sheet.xml +++ b/app/src/main/res/layout/fragment_git_bottom_sheet.xml @@ -108,8 +108,8 @@ branchName + + repository.findRef("${Constants.R_REMOTES}$branchName") != null -> "${Constants.R_REMOTES}$branchName" + + branchName.startsWith( + "origin/", + ) || repository.remoteNames.any { branchName.startsWith("$it/") } -> "${Constants.R_REMOTES}$branchName" + + else -> null + } + if (fullRemoteRef != null) { + val shortRemote = Repository.shortenRefName(fullRemoteRef) + val localName = shortRemote.substringAfter('/') val localRef = repository.findRef("${Constants.R_HEADS}$localName") - if (localRef != null) { + val trackingBranch = BranchConfig(repository.config, localName).trackingBranch + + if (localRef != null && (trackingBranch == fullRemoteRef || trackingBranch == shortRemote)) { checkoutCommand.setName(localName) + } else if (localRef != null) { + val scopedLocalName = shortRemote.replace('/', '-') + val scopedRef = repository.findRef("${Constants.R_HEADS}$scopedLocalName") + if (scopedRef != null) { + checkoutCommand.setName(scopedLocalName) + } else { + checkoutCommand + .setCreateBranch(true) + .setName(scopedLocalName) + .setStartPoint(fullRemoteRef) + .setUpstreamMode(CreateBranchCommand.SetupUpstreamMode.TRACK) + } } else { checkoutCommand .setCreateBranch(true) diff --git a/git-core/src/test/java/com/itsaky/androidide/git/core/JGitRepositoryTest.kt b/git-core/src/test/java/com/itsaky/androidide/git/core/JGitRepositoryTest.kt index a9630be13b..e593bacafb 100644 --- a/git-core/src/test/java/com/itsaky/androidide/git/core/JGitRepositoryTest.kt +++ b/git-core/src/test/java/com/itsaky/androidide/git/core/JGitRepositoryTest.kt @@ -2,6 +2,7 @@ package com.itsaky.androidide.git.core import kotlinx.coroutines.runBlocking import org.eclipse.jgit.api.Git +import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull @@ -22,21 +23,28 @@ class JGitRepositoryTest { @Before fun setUp() { repoDir = tempFolder.newFolder("test-repo") - val git = Git.init().setDirectory(repoDir).call() // Create an initial commit so HEAD points to a valid commit val dummyFile = File(repoDir, "file.txt") dummyFile.writeText("initial content") - git.add().addFilepattern("file.txt").call() - git - .commit() - .setMessage("Initial commit") - .setAuthor("Test", "test@example.com") - .call() + + Git.init().setDirectory(repoDir).call().use { git -> + git.add().addFilepattern("file.txt").call() + git + .commit() + .setMessage("Initial commit") + .setAuthor("Test", "test@example.com") + .call() + } jgitRepo = JGitRepository(repoDir) } + @After + fun tearDown() { + jgitRepo.close() + } + @Test fun testGetCurrentBranchAndGetBranches() = runBlocking { @@ -128,4 +136,38 @@ class JGitRepositoryTest { assertFalse(statusAfterAbort.hasConflicts) assertEquals("initial conflicting content", file.readText()) } + + @Test + fun testCheckoutRemoteTrackingBranch() = + runBlocking { + // Manually create a remote ref refs/remotes/origin/release + val headCommit = + org.eclipse.jgit.storage.file.FileRepositoryBuilder().setWorkTree(repoDir).findGitDir(repoDir).build().use { repo -> + repo.resolve(org.eclipse.jgit.lib.Constants.HEAD) + } + org.eclipse.jgit.storage.file.FileRepositoryBuilder().setWorkTree(repoDir).findGitDir(repoDir).build().use { repo -> + val refUpdate = repo.updateRef("refs/remotes/origin/release") + refUpdate.setNewObjectId(headCommit) + refUpdate.update() + } + + // Checkout remote branch -> should create local branch "release" + jgitRepo.checkout("origin/release", createNew = false) + val currentBranch = jgitRepo.getCurrentBranch() + assertNotNull(currentBranch) + assertEquals("release", currentBranch!!.name) + + // Now create another remote ref with the same short name under a different remote "upstream/release" + org.eclipse.jgit.storage.file.FileRepositoryBuilder().setWorkTree(repoDir).findGitDir(repoDir).build().use { repo -> + val refUpdate = repo.updateRef("refs/remotes/upstream/release") + refUpdate.setNewObjectId(headCommit) + refUpdate.update() + } + + // Checkout upstream/release -> local "release" exists and tracks origin/release, so it should create "upstream-release" + jgitRepo.checkout("upstream/release", createNew = false) + val newCurrentBranch = jgitRepo.getCurrentBranch() + assertNotNull(newCurrentBranch) + assertEquals("upstream-release", newCurrentBranch!!.name) + } } From 333d21a52629802efdb76a7a4f7a32d925faea1f Mon Sep 17 00:00:00 2001 From: Oluwadara Abijo Date: Wed, 19 Aug 2026 23:04:54 +0100 Subject: [PATCH 19/28] docs(ADFA-2881): Add documentation to functions --- .../fragments/git/GitBranchPopupWindow.kt | 20 ++++++++++++++++ .../viewmodel/GitBottomSheetViewModel.kt | 23 +++++++++++++++++++ .../androidide/git/core/GitRepository.kt | 22 ++++++++++++++++++ 3 files changed, 65 insertions(+) diff --git a/app/src/main/java/com/itsaky/androidide/fragments/git/GitBranchPopupWindow.kt b/app/src/main/java/com/itsaky/androidide/fragments/git/GitBranchPopupWindow.kt index fd6c28e14f..d85cf5f3d8 100644 --- a/app/src/main/java/com/itsaky/androidide/fragments/git/GitBranchPopupWindow.kt +++ b/app/src/main/java/com/itsaky/androidide/fragments/git/GitBranchPopupWindow.kt @@ -17,6 +17,15 @@ 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, @@ -67,6 +76,11 @@ class GitBranchPopupWindow( } } + /** + * 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 -> { @@ -122,6 +136,12 @@ class GitBranchPopupWindow( private fun getDisplayName(branch: GitBranch): String = branch.name + /** + * Displays the popup dropdown positioned relative to [anchor], dynamically sized to span + * the parent bottom sheet width with symmetric 16dp margins. + * + * @param anchor The view below which the popup dropdown should appear. + */ fun show(anchor: View) { binding.etSearchBranches.text?.clear() filterBranches(null) diff --git a/app/src/main/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModel.kt b/app/src/main/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModel.kt index fc6a9ca42c..b367ca180e 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModel.kt @@ -145,6 +145,10 @@ class GitBottomSheetViewModel( } } + /** + * Fetches the list of all local and remote branches from the current repository + * and updates the [_branches] flow with [BranchesUiState]. + */ fun fetchBranches() { viewModelScope.launch { _branches.value = BranchesUiState.Loading @@ -164,6 +168,14 @@ class GitBottomSheetViewModel( } } + /** + * Checks out the given [branchName]. + * + * @param branchName The branch name or remote reference to switch to or create. + * @param createNew If true, creates a new branch. + * @param startPoint Optional start commit or branch name when creating a new branch. + * @param onSuccess Optional callback invoked when the checkout succeeds. + */ fun checkoutBranch( branchName: String, createNew: Boolean = false, @@ -420,16 +432,27 @@ class GitBottomSheetViewModel( _pushState.value = PushUiState.Idle } + /** + * Cancels any scheduled checkout state reset timer and resets [_checkoutState] to [CheckoutUiState.Idle]. + */ fun resetCheckoutState() { checkoutResetJob?.cancel() _checkoutState.value = CheckoutUiState.Idle } + /** + * Cancels any scheduled merge state reset timer and resets [_mergeState] to [MergeUiState.Idle]. + */ fun resetMergeState() { mergeResetJob?.cancel() _mergeState.value = MergeUiState.Idle } + /** + * Merges [targetBranchName] into the currently checked-out branch and updates [_mergeState]. + * + * @param targetBranchName The name of the branch to merge into HEAD. + */ fun mergeBranch(targetBranchName: String) { mergeResetJob?.cancel() diff --git a/git-core/src/main/java/com/itsaky/androidide/git/core/GitRepository.kt b/git-core/src/main/java/com/itsaky/androidide/git/core/GitRepository.kt index 249705ca0c..f5f666b720 100644 --- a/git-core/src/main/java/com/itsaky/androidide/git/core/GitRepository.kt +++ b/git-core/src/main/java/com/itsaky/androidide/git/core/GitRepository.kt @@ -52,11 +52,33 @@ interface GitRepository : Closeable { ): PullResult // Merge Operations + + /** + * Merges the specified branch into the current HEAD branch. + * + * @param branchName The name of the target branch to merge into current HEAD. + * @return [MergeResult] containing the merge status (e.g. FAST_FORWARD, CONFLICTING). + */ suspend fun merge(branchName: String): MergeResult + /** + * Aborts an ongoing conflicted merge, resetting the working tree and index back to HEAD. + */ suspend fun abortMerge() // Branch Operations + + /** + * Checks out the specified branch. + * + * When [createNew] is true, creates a new local branch with [branchName] starting from [startPoint] (or HEAD if null). + * When [createNew] is false and [branchName] refers to a remote-tracking branch, creates or switches to a corresponding + * local branch configured to track the remote ref. + * + * @param branchName The target branch name or remote ref (e.g., "main", "feature", "origin/main"). + * @param createNew If true, creates a new branch instead of switching to an existing one. + * @param startPoint Optional start commit or branch name when creating a new branch. + */ suspend fun checkout( branchName: String, createNew: Boolean = false, From 3e7e0dc7fad3027f3ae9a653d4e58027e9c4d4ec Mon Sep 17 00:00:00 2001 From: Oluwadara Abijo Date: Wed, 19 Aug 2026 23:16:54 +0100 Subject: [PATCH 20/28] fix(ADFA-2881): Bug fixes --- .../fragments/git/GitBottomSheetFragment.kt | 5 +- .../fragments/git/GitBranchPopupWindow.kt | 11 ++- .../fragments/git/adapter/GitBranchAdapter.kt | 18 +++- .../viewmodel/GitBottomSheetViewModel.kt | 86 +++++++++++++++---- app/src/main/res/layout/item_git_branch.xml | 2 +- .../viewmodel/GitBottomSheetViewModelTest.kt | 17 ++++ .../androidide/git/core/JGitRepository.kt | 25 +++++- .../androidide/git/core/JGitRepositoryTest.kt | 16 ++++ 8 files changed, 155 insertions(+), 25 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt b/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt index 68ff2d4f30..091d6ca949 100644 --- a/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt +++ b/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt @@ -62,6 +62,7 @@ class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) { super.onViewCreated(view, savedInstanceState) _binding = FragmentGitBottomSheetBinding.bind(view) credentialsManager = GitCredentialsManager(requireContext()) + viewModel.initializeRepository() branchPopupWindow = GitBranchPopupWindow( @@ -141,7 +142,7 @@ class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) { binding.groupCurrentBranch.visibility = View.VISIBLE binding.tvBranchName.text = branchName binding.tvBranchName.contentDescription = - "${getString(R.string.current_branch)}: $branchName" + getString(R.string.current_branch_name, branchName) } else { binding.groupCurrentBranch.visibility = View.GONE } @@ -614,8 +615,8 @@ class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) { dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener { val branchName = etBranchName?.text?.toString()?.trim() ?: "" if (branchName.isNotBlank()) { - dialog.dismiss() checkUnsavedChangesAndProceed { + dialog.dismiss() viewModel.checkoutBranch(branchName = branchName, createNew = true) } } else { diff --git a/app/src/main/java/com/itsaky/androidide/fragments/git/GitBranchPopupWindow.kt b/app/src/main/java/com/itsaky/androidide/fragments/git/GitBranchPopupWindow.kt index d85cf5f3d8..841e612443 100644 --- a/app/src/main/java/com/itsaky/androidide/fragments/git/GitBranchPopupWindow.kt +++ b/app/src/main/java/com/itsaky/androidide/fragments/git/GitBranchPopupWindow.kt @@ -93,10 +93,17 @@ class GitBranchPopupWindow( filterBranches(binding.etSearchBranches.text?.toString()) } - is BranchesUiState.None, is BranchesUiState.Error -> { + is BranchesUiState.None -> { binding.branchesProgress.visibility = View.GONE allBranches = emptyList() - filterBranches(binding.etSearchBranches.text?.toString()) + adapter.submitList(emptyList()) + } + + is BranchesUiState.Error -> { + binding.branchesProgress.visibility = View.GONE + allBranches = emptyList() + val errorMsg = state.message ?: context.getString(R.string.unknown_error) + adapter.submitList(listOf(GitBranchListItem.Header(errorMsg))) } } } diff --git a/app/src/main/java/com/itsaky/androidide/fragments/git/adapter/GitBranchAdapter.kt b/app/src/main/java/com/itsaky/androidide/fragments/git/adapter/GitBranchAdapter.kt index 4638d96aa3..1325adfd40 100644 --- a/app/src/main/java/com/itsaky/androidide/fragments/git/adapter/GitBranchAdapter.kt +++ b/app/src/main/java/com/itsaky/androidide/fragments/git/adapter/GitBranchAdapter.kt @@ -11,17 +11,33 @@ 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, @@ -82,7 +98,7 @@ class GitBranchAdapter( binding.ivActiveCheck.visibility = View.VISIBLE binding.imgBranchIcon.visibility = View.GONE binding.btnMergeAction.visibility = View.GONE - binding.root.contentDescription = "${item.displayName}, ${context.getString(R.string.current_branch)}" + binding.root.contentDescription = context.getString(R.string.current_branch_name, item.displayName) } else { binding.ivActiveCheck.visibility = View.GONE binding.imgBranchIcon.visibility = View.VISIBLE diff --git a/app/src/main/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModel.kt b/app/src/main/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModel.kt index b367ca180e..08aee0c184 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModel.kt @@ -98,12 +98,34 @@ class GitBottomSheetViewModel( currentRepository?.close() } - private fun initializeRepository() { + /** + * Initializes or re-initializes the Git repository for the currently open project. + * + * When [force] is true or if the repository's root directory does not match the active + * project directory from [IProjectManager], any previously opened repository is closed + * and a new instance is initialized. + */ + fun initializeRepository(force: Boolean = false) { viewModelScope.launch { try { - val projectDir = File(IProjectManager.getInstance().projectDirPath) - currentRepository = GitRepositoryManager.openRepository(projectDir) - _isGitRepository.value = currentRepository != null + val projectDirPath = IProjectManager.getInstance().projectDirPath + if (projectDirPath.isNullOrBlank()) { + currentRepository?.close() + currentRepository = null + _isGitRepository.value = false + _gitStatus.value = GitStatus.EMPTY + _currentBranch.value = null + _branches.value = BranchesUiState.None + _localCommitsCount.value = 0 + return@launch + } + val projectDir = File(projectDirPath) + val currentRoot = currentRepository?.rootDir + if (force || currentRepository == null || currentRoot?.canonicalPath != projectDir.canonicalPath) { + currentRepository?.close() + currentRepository = GitRepositoryManager.openRepository(projectDir) + _isGitRepository.value = currentRepository != null + } refreshStatus() } catch (e: CancellationException) { throw e @@ -111,37 +133,50 @@ class GitBottomSheetViewModel( log.error("Failed to initialize repository", e) _isGitRepository.value = false _gitStatus.value = GitStatus.EMPTY + _currentBranch.value = null + _branches.value = BranchesUiState.None + _localCommitsCount.value = 0 } } } /** - * Refreshes the Git status of the project. + * Refreshes the Git status and branch state of the project. + * Failures while querying branches are handled separately to preserve valid status and commit count. */ fun refreshStatus() { viewModelScope.launch { + val repo = currentRepository + if (repo == null) { + _gitStatus.value = GitStatus.EMPTY + _currentBranch.value = null + _branches.value = BranchesUiState.None + _localCommitsCount.value = 0 + return@launch + } + try { - currentRepository?.let { repo -> - val status = repo.getStatus() - _gitStatus.value = status - _currentBranch.value = repo.getCurrentBranch()?.name - _branches.value = BranchesUiState.Success(repo.getBranches()) - getLocalCommitsCount() - } ?: run { - _gitStatus.value = GitStatus.EMPTY - _currentBranch.value = null - _branches.value = BranchesUiState.None - _localCommitsCount.value = 0 - } + val status = repo.getStatus() + _gitStatus.value = status + _currentBranch.value = repo.getCurrentBranch()?.name + getLocalCommitsCount() } catch (e: CancellationException) { throw e } catch (e: Exception) { log.error("Failed to refresh git status", e) _gitStatus.value = GitStatus.EMPTY _currentBranch.value = null - _branches.value = BranchesUiState.Error(e.message) _localCommitsCount.value = 0 } + + try { + _branches.value = BranchesUiState.Success(repo.getBranches()) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + log.error("Failed to fetch branches during status refresh", e) + _branches.value = BranchesUiState.Error(e.message) + } } } @@ -518,15 +553,30 @@ class GitBottomSheetViewModel( } } + /** + * Represents the UI state for the repository branches list. + */ sealed class BranchesUiState { + /** No repository is opened or branch listing has not been initiated. */ object None : BranchesUiState() + /** Branches are currently being queried asynchronously from the repository. */ object Loading : BranchesUiState() + /** + * Branches were fetched successfully. + * + * @param branches The list of available local and remote branches (can be empty). + */ data class Success( val branches: List, ) : BranchesUiState() + /** + * An error occurred while discovering or listing repository branches. + * + * @param message Human-readable error description or exception message. + */ data class Error( val message: String? = null, ) : BranchesUiState() diff --git a/app/src/main/res/layout/item_git_branch.xml b/app/src/main/res/layout/item_git_branch.xml index 6de7379fb4..c998d46814 100644 --- a/app/src/main/res/layout/item_git_branch.xml +++ b/app/src/main/res/layout/item_git_branch.xml @@ -31,7 +31,7 @@ android:id="@+id/ivActiveCheck" android:layout_width="20dp" android:layout_height="20dp" - android:contentDescription="@string/current_branch" + android:importantForAccessibility="no" android:src="@drawable/ic_check" android:visibility="gone" app:tint="?attr/colorPrimary" diff --git a/app/src/test/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModelTest.kt b/app/src/test/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModelTest.kt index 076f5277e4..4527b96adf 100644 --- a/app/src/test/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModelTest.kt +++ b/app/src/test/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModelTest.kt @@ -194,4 +194,21 @@ class GitBottomSheetViewModelTest { assertTrue(state is GitBottomSheetViewModel.BranchesUiState.Error) assertEquals("Git error", (state as GitBottomSheetViewModel.BranchesUiState.Error).message) } + + @Test + fun `refreshStatus preserves gitStatus when branch fetching fails`() = + runTest { + val mockStatus = mockk(relaxed = true) + coEvery { repository.getStatus() } returns mockStatus + coEvery { repository.getCurrentBranch() } returns GitBranch("main", "refs/heads/main", true, false) + coEvery { repository.getLocalCommitsCount() } returns 2 + coEvery { repository.getBranches() } throws RuntimeException("Branch failure") + + viewModel.refreshStatus() + advanceUntilIdle() + + assertEquals(mockStatus, viewModel.gitStatus.value) + assertEquals("main", viewModel.currentBranch.value) + assertTrue(viewModel.branches.value is GitBottomSheetViewModel.BranchesUiState.Error) + } } diff --git a/git-core/src/main/java/com/itsaky/androidide/git/core/JGitRepository.kt b/git-core/src/main/java/com/itsaky/androidide/git/core/JGitRepository.kt index 147e8e478b..9f754dd456 100644 --- a/git-core/src/main/java/com/itsaky/androidide/git/core/JGitRepository.kt +++ b/git-core/src/main/java/com/itsaky/androidide/git/core/JGitRepository.kt @@ -373,8 +373,31 @@ class JGitRepository( } else if (localRef != null) { val scopedLocalName = shortRemote.replace('/', '-') val scopedRef = repository.findRef("${Constants.R_HEADS}$scopedLocalName") - if (scopedRef != null) { + val scopedTracking = BranchConfig(repository.config, scopedLocalName).trackingBranch + + if (scopedRef != null && (scopedTracking == fullRemoteRef || scopedTracking == shortRemote)) { checkoutCommand.setName(scopedLocalName) + } else if (scopedRef != null) { + var suffix = 1 + var candidate = "$scopedLocalName-$suffix" + while (repository.findRef("${Constants.R_HEADS}$candidate") != null) { + val candTracking = BranchConfig(repository.config, candidate).trackingBranch + if (candTracking == fullRemoteRef || candTracking == shortRemote) { + break + } + suffix++ + candidate = "$scopedLocalName-$suffix" + } + val candRef = repository.findRef("${Constants.R_HEADS}$candidate") + if (candRef != null) { + checkoutCommand.setName(candidate) + } else { + checkoutCommand + .setCreateBranch(true) + .setName(candidate) + .setStartPoint(fullRemoteRef) + .setUpstreamMode(CreateBranchCommand.SetupUpstreamMode.TRACK) + } } else { checkoutCommand .setCreateBranch(true) diff --git a/git-core/src/test/java/com/itsaky/androidide/git/core/JGitRepositoryTest.kt b/git-core/src/test/java/com/itsaky/androidide/git/core/JGitRepositoryTest.kt index e593bacafb..ed8ecec110 100644 --- a/git-core/src/test/java/com/itsaky/androidide/git/core/JGitRepositoryTest.kt +++ b/git-core/src/test/java/com/itsaky/androidide/git/core/JGitRepositoryTest.kt @@ -13,6 +13,14 @@ import org.junit.Test import org.junit.rules.TemporaryFolder import java.io.File +/** + * Unit tests verifying [JGitRepository] core operations and contracts. + * + * Each test executes against an isolated disposable Git repository created in [TemporaryFolder]. + * Verifies that branch checkout resolves local and remote tracking refs correctly, + * handles naming collisions cleanly, and ensures that aborting a merge completely restores + * the pre-merge working tree state back to HEAD. + */ class JGitRepositoryTest { @get:Rule val tempFolder = TemporaryFolder() @@ -169,5 +177,13 @@ class JGitRepositoryTest { val newCurrentBranch = jgitRepo.getCurrentBranch() assertNotNull(newCurrentBranch) assertEquals("upstream-release", newCurrentBranch!!.name) + + // Now test when "upstream-release" already exists and we check out upstream/release again + jgitRepo.checkout("origin/release", createNew = false) + assertEquals("release", jgitRepo.getCurrentBranch()?.name) + + // Checking out upstream/release should reuse upstream-release because it tracks upstream/release + jgitRepo.checkout("upstream/release", createNew = false) + assertEquals("upstream-release", jgitRepo.getCurrentBranch()?.name) } } From 9534df4f5843a663dafdabfb4cf4cefc34bb42eb Mon Sep 17 00:00:00 2001 From: Oluwadara Abijo Date: Thu, 20 Aug 2026 10:41:47 +0100 Subject: [PATCH 21/28] feat(ADFA-2881): Modify branches popup window --- .../fragments/git/GitBranchPopupWindow.kt | 28 ++++--------------- 1 file changed, 6 insertions(+), 22 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/fragments/git/GitBranchPopupWindow.kt b/app/src/main/java/com/itsaky/androidide/fragments/git/GitBranchPopupWindow.kt index 841e612443..7642507926 100644 --- a/app/src/main/java/com/itsaky/androidide/fragments/git/GitBranchPopupWindow.kt +++ b/app/src/main/java/com/itsaky/androidide/fragments/git/GitBranchPopupWindow.kt @@ -144,32 +144,16 @@ class GitBranchPopupWindow( private fun getDisplayName(branch: GitBranch): String = branch.name /** - * Displays the popup dropdown positioned relative to [anchor], dynamically sized to span - * the parent bottom sheet width with symmetric 16dp margins. + * Displays the popup window as a dropdown anchored below [anchor]. * - * @param anchor The view below which the popup dropdown should appear. + * Clears any existing search input, resets the filtered branch list, + * and positions the dropdown below the specified anchor view. + * + * @param anchor The view below which the popup dropdown should be displayed. */ fun show(anchor: View) { binding.etSearchBranches.text?.clear() filterBranches(null) - - val parentView = (anchor.parent as? View) ?: anchor.rootView - val marginPx = (16 * context.resources.displayMetrics.density).toInt() - val targetWidth = parentView.width - (marginPx * 2) - - val xOff = - if (targetWidth > 0) { - popupWindow.width = targetWidth - val anchorLocation = IntArray(2) - val parentLocation = IntArray(2) - anchor.getLocationInWindow(anchorLocation) - parentView.getLocationInWindow(parentLocation) - val anchorLeftInParent = anchorLocation[0] - parentLocation[0] - marginPx - anchorLeftInParent - } else { - 0 - } - - popupWindow.showAsDropDown(anchor, xOff, 8) + popupWindow.showAsDropDown(anchor, 0, 8) } } From 04cf1bac5a3289186c312196203cd7e928a3f380 Mon Sep 17 00:00:00 2001 From: Oluwadara Abijo Date: Thu, 20 Aug 2026 10:47:55 +0100 Subject: [PATCH 22/28] feat(ADFA-2881): Clear the closed repo before opening a replacement --- .../viewmodel/GitBottomSheetViewModel.kt | 5 +++- .../viewmodel/GitBottomSheetViewModelTest.kt | 27 +++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModel.kt b/app/src/main/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModel.kt index 08aee0c184..e7a06ee41b 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModel.kt @@ -122,7 +122,9 @@ class GitBottomSheetViewModel( val projectDir = File(projectDirPath) val currentRoot = currentRepository?.rootDir if (force || currentRepository == null || currentRoot?.canonicalPath != projectDir.canonicalPath) { - currentRepository?.close() + val previousRepo = currentRepository + currentRepository = null + previousRepo?.close() currentRepository = GitRepositoryManager.openRepository(projectDir) _isGitRepository.value = currentRepository != null } @@ -131,6 +133,7 @@ class GitBottomSheetViewModel( throw e } catch (e: Exception) { log.error("Failed to initialize repository", e) + currentRepository = null _isGitRepository.value = false _gitStatus.value = GitStatus.EMPTY _currentBranch.value = null diff --git a/app/src/test/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModelTest.kt b/app/src/test/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModelTest.kt index 4527b96adf..845799c1c5 100644 --- a/app/src/test/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModelTest.kt +++ b/app/src/test/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModelTest.kt @@ -211,4 +211,31 @@ class GitBottomSheetViewModelTest { assertEquals("main", viewModel.currentBranch.value) assertTrue(viewModel.branches.value is GitBottomSheetViewModel.BranchesUiState.Error) } + + @Test + fun `initializeRepository clears currentRepository when opening fails`() = + runTest { + io.mockk.mockkObject(com.itsaky.androidide.projects.IProjectManager.Companion) + val mockProjectManager = mockk(relaxed = true) + every { mockProjectManager.projectDirPath } returns "/mock/path" + every { + com.itsaky.androidide.projects.IProjectManager + .getInstance() + } returns mockProjectManager + + io.mockk.mockkObject(com.itsaky.androidide.git.core.GitRepositoryManager) + coEvery { + com.itsaky.androidide.git.core.GitRepositoryManager + .openRepository(any()) + } throws RuntimeException("Corrupt repository") + + viewModel.initializeRepository(force = true) + advanceUntilIdle() + + assertEquals(null, viewModel.currentRepository) + assertEquals(false, viewModel.isGitRepository.value) + assertEquals(com.itsaky.androidide.git.core.models.GitStatus.EMPTY, viewModel.gitStatus.value) + assertEquals(null, viewModel.currentBranch.value) + assertEquals(GitBottomSheetViewModel.BranchesUiState.None, viewModel.branches.value) + } } From e274c6c06e490b6ec7b9a2a7d307ea0886436be9 Mon Sep 17 00:00:00 2001 From: Oluwadara Abijo Date: Thu, 20 Aug 2026 15:38:19 +0100 Subject: [PATCH 23/28] fix(ADFA-2881): Bug fixes --- .../fragments/git/GitBottomSheetFragment.kt | 20 ++- .../fragments/git/GitBranchPopupWindow.kt | 51 ++++++-- .../viewmodel/GitBottomSheetViewModel.kt | 66 +++++----- app/src/main/res/layout/item_git_branch.xml | 9 +- .../main/res/layout/popup_git_branches.xml | 114 +++++++++++------- .../viewmodel/GitBottomSheetViewModelTest.kt | 17 ++- .../androidide/git/core/JGitRepository.kt | 55 +++++---- .../androidide/git/core/JGitRepositoryTest.kt | 54 +++++++++ resources/src/main/res/values/strings.xml | 6 +- 9 files changed, 271 insertions(+), 121 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt b/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt index 091d6ca949..fca28f1585 100644 --- a/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt +++ b/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt @@ -73,7 +73,6 @@ class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) { viewModel.checkoutBranch( branchName = branch.name, createNew = false, - startPoint = if (branch.isRemote) branch.fullName else null, ) } } @@ -172,6 +171,7 @@ class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) { flashSuccess(getString(R.string.git_checkout_success, state.branchName)) refreshEditorContent(force = true) EventBus.getDefault().post(ListProjectFilesRequestEvent()) + viewModel.resetCheckoutState() } is GitBottomSheetViewModel.CheckoutUiState.Conflicts -> { @@ -193,7 +193,7 @@ class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) { is GitBottomSheetViewModel.CheckoutUiState.Error -> { binding.tvBranchName.isEnabled = true - val message = state.message ?: getString(R.string.git_checkout_failed) + val message = formatError(state.errorResId, state.errorArgs) MaterialAlertDialogBuilder(requireContext()) .setTitle(R.string.git_checkout_failed) .setMessage(message) @@ -229,11 +229,13 @@ class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) { ) refreshEditorContent(force = true) EventBus.getDefault().post(ListProjectFilesRequestEvent()) + viewModel.resetMergeState() } is GitBottomSheetViewModel.MergeUiState.AlreadyUpToDate -> { binding.tvBranchName.isEnabled = true flashSuccess(getString(R.string.git_already_up_to_date)) + viewModel.resetMergeState() } is GitBottomSheetViewModel.MergeUiState.Conflicts -> { @@ -258,8 +260,7 @@ class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) { is GitBottomSheetViewModel.MergeUiState.Error -> { binding.tvBranchName.isEnabled = true - val targetName = state.targetBranch ?: "" - val message = state.message ?: getString(R.string.git_merge_failed, targetName) + val message = formatError(state.errorResId, state.errorArgs) MaterialAlertDialogBuilder(requireContext()) .setTitle(R.string.git_merge_failed_title) .setMessage(message) @@ -628,6 +629,16 @@ class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) { dialog.show() } + private fun formatError( + errorResId: Int, + errorArgs: List?, + ): String = + if (errorArgs.isNullOrEmpty()) { + getString(errorResId) + } else { + getString(errorResId, *errorArgs.toTypedArray()) + } + private fun refreshEditorContent(force: Boolean = false) { val activity = requireActivity() if (activity is EditorHandlerActivity) { @@ -656,6 +667,7 @@ class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) { } override fun onDestroyView() { + branchPopupWindow.dismiss() super.onDestroyView() _binding = null } diff --git a/app/src/main/java/com/itsaky/androidide/fragments/git/GitBranchPopupWindow.kt b/app/src/main/java/com/itsaky/androidide/fragments/git/GitBranchPopupWindow.kt index 7642507926..e92f8ef12c 100644 --- a/app/src/main/java/com/itsaky/androidide/fragments/git/GitBranchPopupWindow.kt +++ b/app/src/main/java/com/itsaky/androidide/fragments/git/GitBranchPopupWindow.kt @@ -2,11 +2,11 @@ package com.itsaky.androidide.fragments.git import android.content.Context import android.graphics.Color -import android.graphics.drawable.ColorDrawable 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 @@ -44,7 +44,7 @@ class GitBranchPopupWindow( ViewGroup.LayoutParams.WRAP_CONTENT, true, ).apply { - setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) + setBackgroundDrawable(Color.TRANSPARENT.toDrawable()) elevation = 16f } @@ -85,6 +85,8 @@ class GitBranchPopupWindow( when (state) { is BranchesUiState.Loading -> { binding.branchesProgress.visibility = View.VISIBLE + binding.tvEmptyBranches.visibility = View.GONE + binding.rvBranches.visibility = View.VISIBLE } is BranchesUiState.Success -> { @@ -95,6 +97,8 @@ class GitBranchPopupWindow( is BranchesUiState.None -> { binding.branchesProgress.visibility = View.GONE + binding.tvEmptyBranches.visibility = View.GONE + binding.rvBranches.visibility = View.VISIBLE allBranches = emptyList() adapter.submitList(emptyList()) } @@ -102,8 +106,10 @@ class GitBranchPopupWindow( is BranchesUiState.Error -> { binding.branchesProgress.visibility = View.GONE allBranches = emptyList() - val errorMsg = state.message ?: context.getString(R.string.unknown_error) - adapter.submitList(listOf(GitBranchListItem.Header(errorMsg))) + binding.tvEmptyBranches.text = context.getString(R.string.git_branches_load_failed) + binding.tvEmptyBranches.visibility = View.VISIBLE + binding.rvBranches.visibility = View.GONE + adapter.submitList(emptyList()) } } } @@ -138,22 +144,53 @@ class GitBranchPopupWindow( } } + 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 as a dropdown anchored below [anchor]. + * Displays the popup window centered horizontally on the screen below [anchor]. * * Clears any existing search input, resets the filtered branch list, - * and positions the dropdown below the specified anchor view. + * 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) - popupWindow.showAsDropDown(anchor, 0, 8) + 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() + } } } diff --git a/app/src/main/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModel.kt b/app/src/main/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModel.kt index e7a06ee41b..c4b832f389 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModel.kt @@ -28,6 +28,8 @@ import kotlinx.coroutines.launch import org.eclipse.jgit.api.MergeResult.MergeStatus import org.eclipse.jgit.api.PullResult import org.eclipse.jgit.api.errors.CheckoutConflictException +import org.eclipse.jgit.api.errors.InvalidRefNameException +import org.eclipse.jgit.api.errors.RefAlreadyExistsException import org.eclipse.jgit.transport.RemoteRefUpdate import org.eclipse.jgit.transport.UsernamePasswordCredentialsProvider import org.greenrobot.eventbus.EventBus @@ -77,8 +79,6 @@ class GitBottomSheetViewModel( private var pullResetJob: Job? = null private var pushResetJob: Job? = null - private var checkoutResetJob: Job? = null - private var mergeResetJob: Job? = null var currentRepository: GitRepository? = repository private set @@ -220,11 +220,10 @@ class GitBottomSheetViewModel( startPoint: String? = null, onSuccess: (() -> Unit)? = null, ) { - checkoutResetJob?.cancel() viewModelScope.launch { + val repository = currentRepository ?: return@launch + _checkoutState.value = CheckoutUiState.CheckingOut try { - _checkoutState.value = CheckoutUiState.CheckingOut - val repository = currentRepository ?: return@launch repository.checkout(branchName, createNew, startPoint) refreshStatus() _checkoutState.value = CheckoutUiState.Success(branchName) @@ -234,15 +233,19 @@ class GitBottomSheetViewModel( _checkoutState.value = CheckoutUiState.Conflicts(e.conflictingPaths ?: emptyList()) } catch (e: CancellationException) { throw e + } catch (e: RefAlreadyExistsException) { + log.error("Branch $branchName already exists", e) + _checkoutState.value = + CheckoutUiState.Error( + errorResId = R.string.git_branch_already_exists, + errorArgs = listOf(branchName), + ) + } catch (e: InvalidRefNameException) { + log.error("Invalid branch name $branchName", e) + _checkoutState.value = CheckoutUiState.Error(errorResId = R.string.git_create_branch_invalid_name) } catch (e: Exception) { log.error("Checkout failed", e) - _checkoutState.value = CheckoutUiState.Error(message = e.message) - } finally { - checkoutResetJob = - viewModelScope.launch { - delay(3000.milliseconds) - _checkoutState.value = CheckoutUiState.Idle - } + _checkoutState.value = CheckoutUiState.Error() } } } @@ -471,18 +474,16 @@ class GitBottomSheetViewModel( } /** - * Cancels any scheduled checkout state reset timer and resets [_checkoutState] to [CheckoutUiState.Idle]. + * Resets [_checkoutState] to [CheckoutUiState.Idle] once the UI has consumed a terminal state. */ fun resetCheckoutState() { - checkoutResetJob?.cancel() _checkoutState.value = CheckoutUiState.Idle } /** - * Cancels any scheduled merge state reset timer and resets [_mergeState] to [MergeUiState.Idle]. + * Resets [_mergeState] to [MergeUiState.Idle] once the UI has consumed a terminal state. */ fun resetMergeState() { - mergeResetJob?.cancel() _mergeState.value = MergeUiState.Idle } @@ -492,8 +493,6 @@ class GitBottomSheetViewModel( * @param targetBranchName The name of the branch to merge into HEAD. */ fun mergeBranch(targetBranchName: String) { - mergeResetJob?.cancel() - viewModelScope.launch { val repo = currentRepository ?: return@launch val currentBranchName = _currentBranch.value ?: "HEAD" @@ -534,24 +533,31 @@ class GitBottomSheetViewModel( } else -> { + log.error("Merge of $targetBranchName ended with status ${result.mergeStatus.name}") _mergeState.value = MergeUiState.Error( targetBranch = targetBranchName, - message = "Merge status: ${result.mergeStatus.name}", + errorArgs = listOf(targetBranchName), ) } } + } catch (e: CheckoutConflictException) { + log.error("Merge blocked by uncommitted local changes", e) + _mergeState.value = + MergeUiState.Error( + targetBranch = targetBranchName, + errorResId = R.string.git_merge_local_changes, + errorArgs = listOf(e.conflictingPaths?.joinToString("\n") ?: ""), + ) } catch (e: CancellationException) { throw e } catch (e: Exception) { log.error("Failed to merge branch $targetBranchName", e) - _mergeState.value = MergeUiState.Error(targetBranch = targetBranchName, message = e.message) - } finally { - mergeResetJob = - viewModelScope.launch { - delay(3000.milliseconds) - _mergeState.value = MergeUiState.Idle - } + _mergeState.value = + MergeUiState.Error( + targetBranch = targetBranchName, + errorArgs = listOf(targetBranchName), + ) } } } @@ -599,8 +605,8 @@ class GitBottomSheetViewModel( ) : CheckoutUiState() data class Error( - val message: String? = null, - val errorResId: Int? = R.string.unknown_error, + val errorResId: Int = R.string.git_checkout_failed, + val errorArgs: List? = null, ) : CheckoutUiState() } @@ -625,9 +631,9 @@ class GitBottomSheetViewModel( ) : MergeUiState() data class Error( - val message: String? = null, val targetBranch: String? = null, - val errorResId: Int? = R.string.unknown_error, + val errorResId: Int = R.string.git_merge_failed, + val errorArgs: List? = null, ) : MergeUiState() } diff --git a/app/src/main/res/layout/item_git_branch.xml b/app/src/main/res/layout/item_git_branch.xml index c998d46814..832eb04b6a 100644 --- a/app/src/main/res/layout/item_git_branch.xml +++ b/app/src/main/res/layout/item_git_branch.xml @@ -43,7 +43,7 @@ android:layout_width="0dp" android:layout_height="wrap_content" android:layout_marginStart="12dp" - android:ellipsize="marquee" + android:ellipsize="end" android:maxLines="1" android:textAppearance="?attr/textAppearanceBody2" app:layout_constraintBottom_toBottomOf="parent" @@ -54,12 +54,13 @@ - + - - + android:paddingVertical="4dp" + android:text="@string/git_branches" + android:textAppearance="?attr/textAppearanceSubtitle2" + android:textStyle="bold" + app:layout_constraintBottom_toBottomOf="@id/btnNewBranch" + app:layout_constraintEnd_toStartOf="@id/btnNewBranch" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="@id/btnNewBranch" /> - - - - + @@ -72,18 +73,43 @@ + android:visibility="gone" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@id/searchLayout" /> + + + android:gravity="center" + android:padding="16dp" + android:text="@string/git_no_branches_found" + android:textAppearance="?attr/textAppearanceBody2" + android:textColor="?attr/colorOnSurfaceVariant" + android:visibility="gone" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@id/branchesProgress" /> - + diff --git a/app/src/test/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModelTest.kt b/app/src/test/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModelTest.kt index 845799c1c5..4e11a3fb7b 100644 --- a/app/src/test/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModelTest.kt +++ b/app/src/test/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModelTest.kt @@ -68,7 +68,7 @@ class GitBottomSheetViewModelTest { } @Test - fun `checkoutBranch success updates checkoutState to Success and then resets to Idle`() = + fun `checkoutBranch success updates checkoutState to Success until consumed`() = runTest { coEvery { repository.checkout("feature", false, null) } returns Unit coEvery { repository.getStatus() } returns mockk(relaxed = true) @@ -83,13 +83,17 @@ class GitBottomSheetViewModelTest { assertTrue(successCalled) coVerify { repository.checkout("feature", false, null) } - // Advance past 3000ms delay to verify state resets to Idle + // The terminal state is one-shot: it must survive until the UI consumes it, + // otherwise a view recreation would replay the toast and the file-list refresh. testScheduler.advanceTimeBy(3000) + assertTrue(viewModel.checkoutState.value is GitBottomSheetViewModel.CheckoutUiState.Success) + + viewModel.resetCheckoutState() assertTrue(viewModel.checkoutState.value is GitBottomSheetViewModel.CheckoutUiState.Idle) } @Test - fun `checkoutBranch conflict updates checkoutState to Conflicts and then resets to Idle`() = + fun `checkoutBranch conflict updates checkoutState to Conflicts until consumed`() = runTest { val conflictPaths = listOf("file1.txt", "file2.txt") val exception = mockk(relaxed = true) @@ -104,8 +108,10 @@ class GitBottomSheetViewModelTest { assertTrue(state is GitBottomSheetViewModel.CheckoutUiState.Conflicts) assertEquals(conflictPaths, (state as GitBottomSheetViewModel.CheckoutUiState.Conflicts).conflictingPaths) - // Advance past 3000ms delay to verify state resets to Idle testScheduler.advanceTimeBy(3000) + assertTrue(viewModel.checkoutState.value is GitBottomSheetViewModel.CheckoutUiState.Conflicts) + + viewModel.resetCheckoutState() assertTrue(viewModel.checkoutState.value is GitBottomSheetViewModel.CheckoutUiState.Idle) } @@ -179,7 +185,8 @@ class GitBottomSheetViewModelTest { assertTrue(state is GitBottomSheetViewModel.MergeUiState.Error) val errorState = state as GitBottomSheetViewModel.MergeUiState.Error assertEquals("non-existent", errorState.targetBranch) - assertEquals("Branch not found", errorState.message) + assertEquals(com.itsaky.androidide.resources.R.string.git_merge_failed, errorState.errorResId) + assertEquals(listOf("non-existent"), errorState.errorArgs) } @Test diff --git a/git-core/src/main/java/com/itsaky/androidide/git/core/JGitRepository.kt b/git-core/src/main/java/com/itsaky/androidide/git/core/JGitRepository.kt index 9f754dd456..456dcb7deb 100644 --- a/git-core/src/main/java/com/itsaky/androidide/git/core/JGitRepository.kt +++ b/git-core/src/main/java/com/itsaky/androidide/git/core/JGitRepository.kt @@ -123,23 +123,28 @@ class JGitRepository( override suspend fun getBranches(): List = withContext(Dispatchers.IO) { val currentBranch = repository.fullBranch - git.branchList().setListMode(ListMode.ALL).call().map { ref -> - val isRemote = ref.name.startsWith(Constants.R_REMOTES) - val shortName = Repository.shortenRefName(ref.name) - val remoteName = - if (isRemote) { - shortName.substringBefore('/') - } else { - null - } - GitBranch( - name = shortName, - fullName = ref.name, - isCurrent = ref.name == currentBranch, - isRemote = isRemote, - remoteName = remoteName, - ) - } + git + .branchList() + .setListMode(ListMode.ALL) + .call() + .filter { ref -> !ref.name.endsWith("/HEAD") && !ref.isSymbolic } + .map { ref -> + val isRemote = ref.name.startsWith(Constants.R_REMOTES) + val shortName = Repository.shortenRefName(ref.name) + val remoteName = + if (isRemote) { + shortName.substringBefore('/') + } else { + null + } + GitBranch( + name = shortName, + fullName = ref.name, + isCurrent = ref.name == currentBranch, + isRemote = isRemote, + remoteName = remoteName, + ) + } } override suspend fun getHistory(limit: Int): List = @@ -353,13 +358,7 @@ class JGitRepository( val fullRemoteRef = when { branchName.startsWith(Constants.R_REMOTES) -> branchName - repository.findRef("${Constants.R_REMOTES}$branchName") != null -> "${Constants.R_REMOTES}$branchName" - - branchName.startsWith( - "origin/", - ) || repository.remoteNames.any { branchName.startsWith("$it/") } -> "${Constants.R_REMOTES}$branchName" - else -> null } if (fullRemoteRef != null) { @@ -368,21 +367,25 @@ class JGitRepository( val localRef = repository.findRef("${Constants.R_HEADS}$localName") val trackingBranch = BranchConfig(repository.config, localName).trackingBranch - if (localRef != null && (trackingBranch == fullRemoteRef || trackingBranch == shortRemote)) { + val isLocalTracking = + trackingBranch == null || trackingBranch == fullRemoteRef || trackingBranch == shortRemote + if (localRef != null && isLocalTracking) { checkoutCommand.setName(localName) } else if (localRef != null) { val scopedLocalName = shortRemote.replace('/', '-') val scopedRef = repository.findRef("${Constants.R_HEADS}$scopedLocalName") val scopedTracking = BranchConfig(repository.config, scopedLocalName).trackingBranch + val isScopedTracking = + scopedTracking == null || scopedTracking == fullRemoteRef || scopedTracking == shortRemote - if (scopedRef != null && (scopedTracking == fullRemoteRef || scopedTracking == shortRemote)) { + if (scopedRef != null && isScopedTracking) { checkoutCommand.setName(scopedLocalName) } else if (scopedRef != null) { var suffix = 1 var candidate = "$scopedLocalName-$suffix" while (repository.findRef("${Constants.R_HEADS}$candidate") != null) { val candTracking = BranchConfig(repository.config, candidate).trackingBranch - if (candTracking == fullRemoteRef || candTracking == shortRemote) { + if (candTracking == null || candTracking == fullRemoteRef || candTracking == shortRemote) { break } suffix++ diff --git a/git-core/src/test/java/com/itsaky/androidide/git/core/JGitRepositoryTest.kt b/git-core/src/test/java/com/itsaky/androidide/git/core/JGitRepositoryTest.kt index ed8ecec110..4d7345fe3b 100644 --- a/git-core/src/test/java/com/itsaky/androidide/git/core/JGitRepositoryTest.kt +++ b/git-core/src/test/java/com/itsaky/androidide/git/core/JGitRepositoryTest.kt @@ -186,4 +186,58 @@ class JGitRepositoryTest { jgitRepo.checkout("upstream/release", createNew = false) assertEquals("upstream-release", jgitRepo.getCurrentBranch()?.name) } + + @Test + fun testGetBranchesFiltersOutOriginHead() = + runBlocking { + val headCommit = + org.eclipse.jgit.storage.file.FileRepositoryBuilder().setWorkTree(repoDir).findGitDir(repoDir).build().use { repo -> + repo.resolve(org.eclipse.jgit.lib.Constants.HEAD) + } + org.eclipse.jgit.storage.file.FileRepositoryBuilder().setWorkTree(repoDir).findGitDir(repoDir).build().use { repo -> + // Create refs/remotes/origin/main + val refMain = repo.updateRef("refs/remotes/origin/main") + refMain.setNewObjectId(headCommit) + refMain.update() + + // Create symbolic or direct refs/remotes/origin/HEAD + val refHead = repo.updateRef("refs/remotes/origin/HEAD") + refHead.setNewObjectId(headCommit) + refHead.update() + } + + val branches = jgitRepo.getBranches() + val branchNames = branches.map { it.name } + assertTrue(branchNames.contains("origin/main")) + assertFalse("origin/HEAD should be filtered out", branchNames.contains("origin/HEAD")) + assertFalse("refs/remotes/origin/HEAD should be filtered out", branches.any { it.fullName.endsWith("/HEAD") }) + } + + @Test + fun testCheckoutRemoteReusesUntrackedLocalBranch() = + runBlocking { + val initialBranch = jgitRepo.getCurrentBranch()!!.name + + // A branch created locally has no upstream configured + jgitRepo.checkout("release", createNew = true) + jgitRepo.checkout(initialBranch, createNew = false) + + val headCommit = + org.eclipse.jgit.storage.file.FileRepositoryBuilder().setWorkTree(repoDir).findGitDir(repoDir).build().use { repo -> + repo.resolve(org.eclipse.jgit.lib.Constants.HEAD) + } + org.eclipse.jgit.storage.file.FileRepositoryBuilder().setWorkTree(repoDir).findGitDir(repoDir).build().use { repo -> + val refUpdate = repo.updateRef("refs/remotes/origin/release") + refUpdate.setNewObjectId(headCommit) + refUpdate.update() + } + + // The untracked local "release" is the counterpart, so reuse it instead of forking "origin-release" + jgitRepo.checkout("origin/release", createNew = false) + assertEquals("release", jgitRepo.getCurrentBranch()!!.name) + assertFalse( + "origin-release should not be created", + jgitRepo.getBranches().any { it.name == "origin-release" }, + ) + } } diff --git a/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml index 8ccf413240..7ef43ad680 100644 --- a/resources/src/main/res/values/strings.xml +++ b/resources/src/main/res/values/strings.xml @@ -1379,6 +1379,8 @@ Checkout conflict Cannot switch branch because uncommitted changes would be overwritten. Please commit or stash your changes before switching branches.\n\nConflicting files:\n%1$s Please enter a valid branch name + A branch named %1$s already exists + Could not load branches Push Pushing… Push successful! @@ -1396,12 +1398,14 @@ Merge conflicts Abort merge Are you sure you want to abort the current merge? All conflict resolutions will be discarded. - Merge into %1$s + Merge %1$s into current branch + No branches found Merged %1$s into %2$s successfully Merge failed Failed to merge %1$s Merge conflict Conflicts occurred while merging %1$s into %2$s. Please resolve conflicts or abort merge. + Cannot merge because you have uncommitted changes that would be overwritten. Commit or discard them first.\n\nAffected files:\n%1$s Already up to date You have unsaved changes. Would you like to save them before proceeding? Proceed without saving From d93c5272159e0e97922eda313602c1af45cf03be Mon Sep 17 00:00:00 2001 From: Oluwadara Abijo Date: Fri, 21 Aug 2026 17:20:12 +0100 Subject: [PATCH 24/28] fix(ADFA-2881): Fix repository leaks --- .../viewmodel/GitBottomSheetViewModel.kt | 62 +++++++++++-------- 1 file changed, 35 insertions(+), 27 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModel.kt b/app/src/main/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModel.kt index c4b832f389..2458aa6292 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModel.kt @@ -77,6 +77,7 @@ class GitBottomSheetViewModel( private val _mergeState = MutableStateFlow(MergeUiState.Idle) val mergeState: StateFlow = _mergeState.asStateFlow() + private var initJob: Job? = null private var pullResetJob: Job? = null private var pushResetJob: Job? = null @@ -95,6 +96,7 @@ class GitBottomSheetViewModel( override fun onCleared() { super.onCleared() EventBus.getDefault().unregister(this) + initJob?.cancel() currentRepository?.close() } @@ -106,41 +108,47 @@ class GitBottomSheetViewModel( * and a new instance is initialized. */ fun initializeRepository(force: Boolean = false) { - viewModelScope.launch { - try { - val projectDirPath = IProjectManager.getInstance().projectDirPath - if (projectDirPath.isNullOrBlank()) { - currentRepository?.close() + if (initJob?.isActive == true && !force) { + return + } + initJob?.cancel() + initJob = + viewModelScope.launch { + try { + val projectDirPath = IProjectManager.getInstance().projectDirPath + if (projectDirPath.isNullOrBlank()) { + val previousRepo = currentRepository + currentRepository = null + previousRepo?.close() + _isGitRepository.value = false + _gitStatus.value = GitStatus.EMPTY + _currentBranch.value = null + _branches.value = BranchesUiState.None + _localCommitsCount.value = 0 + return@launch + } + val projectDir = File(projectDirPath) + val currentRoot = currentRepository?.rootDir + if (force || currentRepository == null || currentRoot?.canonicalPath != projectDir.canonicalPath) { + val previousRepo = currentRepository + currentRepository = null + previousRepo?.close() + currentRepository = GitRepositoryManager.openRepository(projectDir) + _isGitRepository.value = currentRepository != null + } + refreshStatus() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + log.error("Failed to initialize repository", e) currentRepository = null _isGitRepository.value = false _gitStatus.value = GitStatus.EMPTY _currentBranch.value = null _branches.value = BranchesUiState.None _localCommitsCount.value = 0 - return@launch - } - val projectDir = File(projectDirPath) - val currentRoot = currentRepository?.rootDir - if (force || currentRepository == null || currentRoot?.canonicalPath != projectDir.canonicalPath) { - val previousRepo = currentRepository - currentRepository = null - previousRepo?.close() - currentRepository = GitRepositoryManager.openRepository(projectDir) - _isGitRepository.value = currentRepository != null } - refreshStatus() - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - log.error("Failed to initialize repository", e) - currentRepository = null - _isGitRepository.value = false - _gitStatus.value = GitStatus.EMPTY - _currentBranch.value = null - _branches.value = BranchesUiState.None - _localCommitsCount.value = 0 } - } } /** From b65130c1dcd99941f9ee59813ec84696504cec2e Mon Sep 17 00:00:00 2001 From: Oluwadara Abijo Date: Mon, 24 Aug 2026 20:17:24 +0100 Subject: [PATCH 25/28] refactor(ADFA-2881): Simplify checkout method --- .../viewmodel/GitBottomSheetViewModel.kt | 4 +- .../viewmodel/GitBottomSheetViewModelTest.kt | 2 +- .../androidide/git/core/GitRepository.kt | 2 +- .../androidide/git/core/JGitRepository.kt | 207 ++++++++++++------ 4 files changed, 140 insertions(+), 75 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModel.kt b/app/src/main/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModel.kt index 2458aa6292..c36f9ab0d3 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModel.kt @@ -232,9 +232,9 @@ class GitBottomSheetViewModel( val repository = currentRepository ?: return@launch _checkoutState.value = CheckoutUiState.CheckingOut try { - repository.checkout(branchName, createNew, startPoint) + val resolvedBranch = repository.checkout(branchName, createNew, startPoint) refreshStatus() - _checkoutState.value = CheckoutUiState.Success(branchName) + _checkoutState.value = CheckoutUiState.Success(resolvedBranch) onSuccess?.invoke() } catch (e: CheckoutConflictException) { log.error("Checkout conflict occurred", e) diff --git a/app/src/test/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModelTest.kt b/app/src/test/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModelTest.kt index 4e11a3fb7b..bf398f28a4 100644 --- a/app/src/test/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModelTest.kt +++ b/app/src/test/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModelTest.kt @@ -70,7 +70,7 @@ class GitBottomSheetViewModelTest { @Test fun `checkoutBranch success updates checkoutState to Success until consumed`() = runTest { - coEvery { repository.checkout("feature", false, null) } returns Unit + coEvery { repository.checkout("feature", false, null) } returns "feature" coEvery { repository.getStatus() } returns mockk(relaxed = true) var successCalled = false diff --git a/git-core/src/main/java/com/itsaky/androidide/git/core/GitRepository.kt b/git-core/src/main/java/com/itsaky/androidide/git/core/GitRepository.kt index f5f666b720..39ee4c1968 100644 --- a/git-core/src/main/java/com/itsaky/androidide/git/core/GitRepository.kt +++ b/git-core/src/main/java/com/itsaky/androidide/git/core/GitRepository.kt @@ -83,5 +83,5 @@ interface GitRepository : Closeable { branchName: String, createNew: Boolean = false, startPoint: String? = null, - ) + ): String } diff --git a/git-core/src/main/java/com/itsaky/androidide/git/core/JGitRepository.kt b/git-core/src/main/java/com/itsaky/androidide/git/core/JGitRepository.kt index 456dcb7deb..23a5c05c6c 100644 --- a/git-core/src/main/java/com/itsaky/androidide/git/core/JGitRepository.kt +++ b/git-core/src/main/java/com/itsaky/androidide/git/core/JGitRepository.kt @@ -7,6 +7,7 @@ import com.itsaky.androidide.git.core.models.GitCommit import com.itsaky.androidide.git.core.models.GitStatus import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import org.eclipse.jgit.api.CheckoutCommand import org.eclipse.jgit.api.CreateBranchCommand import org.eclipse.jgit.api.Git import org.eclipse.jgit.api.ListBranchCommand.ListMode @@ -345,84 +346,148 @@ class JGitRepository( branchName: String, createNew: Boolean, startPoint: String?, - ) { + ): String = withContext(Dispatchers.IO) { - val checkoutCommand = git.checkout() + val command = git.checkout() + if (createNew) { - checkoutCommand.setCreateBranch(true) - checkoutCommand.setName(branchName) - if (!startPoint.isNullOrBlank()) { - checkoutCommand.setStartPoint(startPoint) - } + configureNewBranchCheckout(command, branchName, startPoint) } else { - val fullRemoteRef = - when { - branchName.startsWith(Constants.R_REMOTES) -> branchName - repository.findRef("${Constants.R_REMOTES}$branchName") != null -> "${Constants.R_REMOTES}$branchName" - else -> null - } - if (fullRemoteRef != null) { - val shortRemote = Repository.shortenRefName(fullRemoteRef) - val localName = shortRemote.substringAfter('/') - val localRef = repository.findRef("${Constants.R_HEADS}$localName") - val trackingBranch = BranchConfig(repository.config, localName).trackingBranch - - val isLocalTracking = - trackingBranch == null || trackingBranch == fullRemoteRef || trackingBranch == shortRemote - if (localRef != null && isLocalTracking) { - checkoutCommand.setName(localName) - } else if (localRef != null) { - val scopedLocalName = shortRemote.replace('/', '-') - val scopedRef = repository.findRef("${Constants.R_HEADS}$scopedLocalName") - val scopedTracking = BranchConfig(repository.config, scopedLocalName).trackingBranch - val isScopedTracking = - scopedTracking == null || scopedTracking == fullRemoteRef || scopedTracking == shortRemote - - if (scopedRef != null && isScopedTracking) { - checkoutCommand.setName(scopedLocalName) - } else if (scopedRef != null) { - var suffix = 1 - var candidate = "$scopedLocalName-$suffix" - while (repository.findRef("${Constants.R_HEADS}$candidate") != null) { - val candTracking = BranchConfig(repository.config, candidate).trackingBranch - if (candTracking == null || candTracking == fullRemoteRef || candTracking == shortRemote) { - break - } - suffix++ - candidate = "$scopedLocalName-$suffix" - } - val candRef = repository.findRef("${Constants.R_HEADS}$candidate") - if (candRef != null) { - checkoutCommand.setName(candidate) - } else { - checkoutCommand - .setCreateBranch(true) - .setName(candidate) - .setStartPoint(fullRemoteRef) - .setUpstreamMode(CreateBranchCommand.SetupUpstreamMode.TRACK) - } - } else { - checkoutCommand - .setCreateBranch(true) - .setName(scopedLocalName) - .setStartPoint(fullRemoteRef) - .setUpstreamMode(CreateBranchCommand.SetupUpstreamMode.TRACK) - } - } else { - checkoutCommand - .setCreateBranch(true) - .setName(localName) - .setStartPoint(fullRemoteRef) - .setUpstreamMode(CreateBranchCommand.SetupUpstreamMode.TRACK) - } - } else { - checkoutCommand.setName(branchName) - } + configureExistingBranchCheckout(command, branchName) + } + + command.call() + Repository.shortenRefName(repository.fullBranch) + } + + private fun configureNewBranchCheckout( + command: CheckoutCommand, + branchName: String, + startPoint: String?, + ) { + command + .setCreateBranch(true) + .setName(branchName) + + if (!startPoint.isNullOrBlank()) { + command.setStartPoint(startPoint) + } + } + + private fun configureExistingBranchCheckout( + command: CheckoutCommand, + branchName: String, + ) { + val isExistingLocal = repository.findRef("${Constants.R_HEADS}$branchName") != null + val remoteRef = if (isExistingLocal) null else resolveRemoteRef(branchName) + + if (remoteRef == null) { + command.setName(branchName) + return + } + + when (val resolution = resolveLocalBranch(remoteRef)) { + is LocalBranch -> { + command.setName(resolution.name) + } + + is NewBranch -> { + configureNewTrackingBranch( + command, + resolution.name, + remoteRef, + ) } - checkoutCommand.call() } } + private fun resolveRemoteRef(branchName: String): String? { + if (branchName.startsWith(Constants.R_REMOTES)) { + return branchName + } + + return "${Constants.R_REMOTES}$branchName" + .takeIf { repository.findRef(it) != null } + } + + private fun resolveLocalBranch(remoteRef: String): BranchResolution { + val shortRemoteName = Repository.shortenRefName(remoteRef) + val localName = shortRemoteName.substringAfter('/') + + repository.findRef("${Constants.R_HEADS}$localName") ?: return NewBranch(localName) + if (isTrackingRemote(localName, remoteRef, shortRemoteName)) { + return LocalBranch(localName) + } + + val scopedName = shortRemoteName.replace('/', '-') + repository.findRef("${Constants.R_HEADS}$scopedName") ?: return NewBranch(scopedName) + if (isTrackingRemote(scopedName, remoteRef, shortRemoteName)) { + return LocalBranch(scopedName) + } + + return NewBranch( + findAvailableBranchName( + scopedName, + remoteRef, + shortRemoteName, + ), + ) + } + + private fun isTrackingRemote( + name: String, + remoteRef: String, + shortRemoteName: String, + ): Boolean { + val trackingBranch = BranchConfig(repository.config, name).trackingBranch + + return trackingBranch == null || + trackingBranch == remoteRef || + trackingBranch == shortRemoteName + } + + private fun findAvailableBranchName( + baseName: String, + remoteRef: String, + shortRemoteName: String, + ): String { + var suffix = 1 + var candidate = "$baseName-$suffix" + + while (repository.findRef("${Constants.R_HEADS}$candidate") != null) { + if (isTrackingRemote(candidate, remoteRef, shortRemoteName)) { + return candidate + } + + suffix++ + candidate = "$baseName-$suffix" + } + + return candidate + } + + private fun configureNewTrackingBranch( + command: CheckoutCommand, + name: String, + remoteRef: String, + ) { + command + .setCreateBranch(true) + .setName(name) + .setStartPoint(remoteRef) + .setUpstreamMode(CreateBranchCommand.SetupUpstreamMode.TRACK) + } + + private sealed interface BranchResolution + + private data class LocalBranch( + val name: String, + ) : BranchResolution + + private data class NewBranch( + val name: String, + ) : BranchResolution + override fun close() { repository.close() git.close() From 8a0eef9502949521b6e891d75cedbfe5824bddbc Mon Sep 17 00:00:00 2001 From: Oluwadara Abijo Date: Wed, 26 Aug 2026 15:44:39 +0100 Subject: [PATCH 26/28] feat(ADFA-2881): Show dialog before merging branches --- .../fragments/git/GitBottomSheetFragment.kt | 20 ++++++++++++++++--- resources/src/main/res/values/strings.xml | 3 +++ 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt b/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt index fca28f1585..008de897e0 100644 --- a/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt +++ b/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt @@ -81,9 +81,7 @@ class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) { showCreateBranchDialog() }, onMergeBranch = { branch -> - checkUnsavedChangesAndProceed { - viewModel.mergeBranch(branch.name) - } + showMergeDialog(viewModel.currentBranch.value ?: "HEAD", branch.name) }, ) @@ -629,6 +627,22 @@ class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) { dialog.show() } + private fun showMergeDialog( + currentBranch: String, + targetBranch: String, + ) { + MaterialAlertDialogBuilder(requireContext()) + .setTitle(getString(R.string.merge_dialog_title)) + .setMessage(getString(R.string.merge_dialog_message, targetBranch, currentBranch)) + .setPositiveButton(R.string.proceed_with_merge) { dialog, _ -> + checkUnsavedChangesAndProceed { + viewModel.mergeBranch(targetBranch) + } + }.setNegativeButton(android.R.string.cancel) { dialog, _ -> dialog.dismiss() } + .setCancelable(true) + .show() + } + private fun formatError( errorResId: Int, errorArgs: List?, diff --git a/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml index cded0b2e82..ca8f0c4c0a 100644 --- a/resources/src/main/res/values/strings.xml +++ b/resources/src/main/res/values/strings.xml @@ -1450,6 +1450,9 @@ Mark resolved Check All Uncheck All + Merge branch? + You are about to merge \'%1$s\' into \'%2$s\'. Are you sure you want to proceed? + Proceed Starting project creation for %1$s From 24b061a4a0e8cfb8f8a773d488716cb84c60548f Mon Sep 17 00:00:00 2001 From: Oluwadara Abijo Date: Wed, 26 Aug 2026 18:10:46 +0100 Subject: [PATCH 27/28] feat(ADFA-2881): UI fixes --- app/src/main/res/drawable/bg_merge_button.xml | 11 +++++++++++ app/src/main/res/layout/item_git_branch.xml | 16 ++++++++-------- resources/src/main/res/values/styles.xml | 1 + 3 files changed, 20 insertions(+), 8 deletions(-) create mode 100644 app/src/main/res/drawable/bg_merge_button.xml diff --git a/app/src/main/res/drawable/bg_merge_button.xml b/app/src/main/res/drawable/bg_merge_button.xml new file mode 100644 index 0000000000..cf43d2833e --- /dev/null +++ b/app/src/main/res/drawable/bg_merge_button.xml @@ -0,0 +1,11 @@ + + + + + + + + + diff --git a/app/src/main/res/layout/item_git_branch.xml b/app/src/main/res/layout/item_git_branch.xml index 832eb04b6a..40b308395b 100644 --- a/app/src/main/res/layout/item_git_branch.xml +++ b/app/src/main/res/layout/item_git_branch.xml @@ -42,7 +42,7 @@ android:id="@+id/tvBranchName" android:layout_width="0dp" android:layout_height="wrap_content" - android:layout_marginStart="12dp" + android:layout_marginStart="4dp" android:ellipsize="end" android:maxLines="1" android:textAppearance="?attr/textAppearanceBody2" @@ -52,22 +52,22 @@ app:layout_constraintTop_toTopOf="parent" tools:text="main" /> - diff --git a/resources/src/main/res/values/styles.xml b/resources/src/main/res/values/styles.xml index 14ab5e2a05..4b0db344b6 100755 --- a/resources/src/main/res/values/styles.xml +++ b/resources/src/main/res/values/styles.xml @@ -35,6 +35,7 @@ @color/md_theme_light_onSurface @color/md_theme_light_surfaceVariant @color/md_theme_light_onSurfaceVariant + @color/white @color/md_theme_light_inverseSurface @color/md_theme_light_inverseOnSurface @color/md_theme_light_inversePrimary From 0245f2bfe26c3a3ebca3b6573811a4d418761b80 Mon Sep 17 00:00:00 2001 From: Oluwadara Abijo Date: Wed, 26 Aug 2026 18:47:10 +0100 Subject: [PATCH 28/28] feat(ADFA-2881): Show dialog before branch checkout --- .../fragments/git/GitBottomSheetFragment.kt | 32 +++++++++++++------ resources/src/main/res/values/strings.xml | 6 ++-- 2 files changed, 26 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt b/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt index 008de897e0..77bd4b1a8e 100644 --- a/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt +++ b/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt @@ -26,6 +26,7 @@ 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.git.core.models.FileChange +import com.itsaky.androidide.git.core.models.GitBranch import com.itsaky.androidide.git.core.models.GitStatus import com.itsaky.androidide.idetooltips.TooltipManager import com.itsaky.androidide.idetooltips.TooltipTag @@ -68,14 +69,7 @@ class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) { GitBranchPopupWindow( context = requireContext(), onBranchSelected = { branch -> - if (!branch.isCurrent) { - checkUnsavedChangesAndProceed { - viewModel.checkoutBranch( - branchName = branch.name, - createNew = false, - ) - } - } + showCheckoutDialog(branch) }, onNewBranchRequested = { showCreateBranchDialog() @@ -634,11 +628,29 @@ class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) { MaterialAlertDialogBuilder(requireContext()) .setTitle(getString(R.string.merge_dialog_title)) .setMessage(getString(R.string.merge_dialog_message, targetBranch, currentBranch)) - .setPositiveButton(R.string.proceed_with_merge) { dialog, _ -> + .setPositiveButton(R.string.proceed_with_git_action) { _, _ -> checkUnsavedChangesAndProceed { viewModel.mergeBranch(targetBranch) } - }.setNegativeButton(android.R.string.cancel) { dialog, _ -> dialog.dismiss() } + }.setNegativeButton(android.R.string.cancel) { _, _ -> } + .setCancelable(true) + .show() + } + + private fun showCheckoutDialog(targetBranch: GitBranch) { + MaterialAlertDialogBuilder(requireContext()) + .setTitle(getString(R.string.checkout_dialog_title)) + .setMessage(getString(R.string.checkout_dialog_message, targetBranch.name)) + .setPositiveButton(R.string.proceed_with_git_action) { _, _ -> + if (!targetBranch.isCurrent) { + checkUnsavedChangesAndProceed { + viewModel.checkoutBranch( + branchName = targetBranch.name, + createNew = false, + ) + } + } + }.setNegativeButton(android.R.string.cancel) { _, _ -> } .setCancelable(true) .show() } diff --git a/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml index ca8f0c4c0a..83c1bd539b 100644 --- a/resources/src/main/res/values/strings.xml +++ b/resources/src/main/res/values/strings.xml @@ -1411,7 +1411,7 @@ Create new branch Branch name Search branches… - Switched to branch %1$s + Switched to branch \'%1$s\' Failed to switch branch Checkout conflict Cannot switch branch because uncommitted changes would be overwritten. Please commit or stash your changes before switching branches.\n\nConflicting files:\n%1$s @@ -1452,7 +1452,9 @@ Uncheck All Merge branch? You are about to merge \'%1$s\' into \'%2$s\'. Are you sure you want to proceed? - Proceed + Proceed + Switch branch? + You are about to check out branch \'%1$s\'. Are you sure you want to proceed? Starting project creation for %1$s