From 6a5e9084712fe30d796be0dc9b19ba75d59239ea Mon Sep 17 00:00:00 2001 From: John Trujillo Date: Wed, 1 Apr 2026 12:48:42 -0500 Subject: [PATCH 1/2] feat(dnd): implement core drag-and-drop architecture and Git URL support Add reusable DragEventRouter and enable Git URL dropping for repository cloning. --- .../itsaky/androidide/dnd/DragEventRouter.kt | 53 +++++++++ .../androidide/dnd/DropTargetCallback.kt | 35 ++++++ .../androidide/dnd/GitUrlDropExtensions.kt | 28 +++++ .../itsaky/androidide/dnd/GitUrlDropTarget.kt | 107 ++++++++++++++++++ .../fragments/CloneRepositoryFragment.kt | 20 ++++ .../androidide/fragments/MainFragment.kt | 13 ++- .../androidide/utils/DropHighlighter.kt | 35 ++++++ .../viewmodel/CloneRepositoryViewModel.kt | 32 ++++-- .../androidide/viewmodel/MainViewModel.kt | 15 ++- .../androidide/git/core/GitRepositoryUrls.kt | 59 ++++++++++ resources/src/main/res/values/ids.xml | 1 + resources/src/main/res/values/strings.xml | 10 ++ 12 files changed, 397 insertions(+), 11 deletions(-) create mode 100644 app/src/main/java/com/itsaky/androidide/dnd/DragEventRouter.kt create mode 100644 app/src/main/java/com/itsaky/androidide/dnd/DropTargetCallback.kt create mode 100644 app/src/main/java/com/itsaky/androidide/dnd/GitUrlDropExtensions.kt create mode 100644 app/src/main/java/com/itsaky/androidide/dnd/GitUrlDropTarget.kt create mode 100644 app/src/main/java/com/itsaky/androidide/utils/DropHighlighter.kt create mode 100644 git-core/src/main/java/com/itsaky/androidide/git/core/GitRepositoryUrls.kt diff --git a/app/src/main/java/com/itsaky/androidide/dnd/DragEventRouter.kt b/app/src/main/java/com/itsaky/androidide/dnd/DragEventRouter.kt new file mode 100644 index 0000000000..a991cca0f8 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/dnd/DragEventRouter.kt @@ -0,0 +1,53 @@ +package com.itsaky.androidide.dnd + +import android.view.DragEvent +import android.view.View + +/** + * A [View.OnDragListener] implementation that delegates the complex Android drag state machine + * into a clean [DropTargetCallback]. + */ +class DragEventRouter( + private val callback: DropTargetCallback +) : View.OnDragListener { + override fun onDrag(view: View, event: DragEvent): Boolean { + val canHandle = callback.canAcceptDrop(event) + + return when (event.action) { + DragEvent.ACTION_DRAG_STARTED -> { + if (canHandle) callback.onDragStarted(view) + canHandle + } + + DragEvent.ACTION_DRAG_ENTERED -> { + if (canHandle) callback.onDragEntered(view) + true + } + + DragEvent.ACTION_DRAG_LOCATION -> { + canHandle + } + + DragEvent.ACTION_DRAG_EXITED -> { + callback.onDragExited(view) + true + } + + DragEvent.ACTION_DROP -> { + callback.onDragExited(view) + if (canHandle) { + callback.onDrop(event) + } else { + false + } + } + + DragEvent.ACTION_DRAG_ENDED -> { + callback.onDragExited(view) + canHandle + } + + else -> false + } + } +} diff --git a/app/src/main/java/com/itsaky/androidide/dnd/DropTargetCallback.kt b/app/src/main/java/com/itsaky/androidide/dnd/DropTargetCallback.kt new file mode 100644 index 0000000000..dce6898c84 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/dnd/DropTargetCallback.kt @@ -0,0 +1,35 @@ +package com.itsaky.androidide.dnd + +import android.view.DragEvent +import android.view.View + +/** + * Callback interface for handling routed drag-and-drop events on a specific target view. + */ +interface DropTargetCallback { + /** + * Determines whether the current [event] contains data that this target can handle. + */ + fun canAcceptDrop(event: DragEvent): Boolean + + /** + * Called when a drag operation begins. Useful for applying initial visual cues. + */ + fun onDragStarted(view: View) {} + + /** + * Called when a valid dragged item enters the bounds of the target [view]. + */ + fun onDragEntered(view: View) + + /** + * Called when a dragged item exits the target [view], or when the drag operation ends/is canceled. + */ + fun onDragExited(view: View) + + /** + * Called when the user successfully drops a valid item on the target. + * * @return True if the drop was successfully consumed and handled. + */ + fun onDrop(event: DragEvent): Boolean +} diff --git a/app/src/main/java/com/itsaky/androidide/dnd/GitUrlDropExtensions.kt b/app/src/main/java/com/itsaky/androidide/dnd/GitUrlDropExtensions.kt new file mode 100644 index 0000000000..a6fdb6e018 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/dnd/GitUrlDropExtensions.kt @@ -0,0 +1,28 @@ +package com.itsaky.androidide.dnd + +import android.view.View +import androidx.fragment.app.Fragment +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner + + +fun Fragment.handleGitUrlDrop( + targetView: View = requireView(), + shouldAcceptDrop: () -> Boolean = { isVisible }, + onDropped: (String) -> Unit +) { + val dropTarget = GitUrlDropTarget( + context = requireContext(), + rootView = targetView, + shouldAcceptDrop = shouldAcceptDrop, + onRepositoryDropped = onDropped + ) + + dropTarget.attach() + + viewLifecycleOwner.lifecycle.addObserver(object : DefaultLifecycleObserver { + override fun onDestroy(owner: LifecycleOwner) { + dropTarget.detach() + } + }) +} diff --git a/app/src/main/java/com/itsaky/androidide/dnd/GitUrlDropTarget.kt b/app/src/main/java/com/itsaky/androidide/dnd/GitUrlDropTarget.kt new file mode 100644 index 0000000000..bd8b1b1aa9 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/dnd/GitUrlDropTarget.kt @@ -0,0 +1,107 @@ +package com.itsaky.androidide.dnd + +import android.content.ClipDescription +import android.content.Context +import android.view.DragEvent +import android.view.View +import androidx.core.view.ContentInfoCompat +import androidx.core.view.ViewCompat +import com.itsaky.androidide.git.core.parseGitRepositoryUrl +import com.itsaky.androidide.utils.DropHighlighter + +internal class GitUrlDropTarget( + private val context: Context, + private val rootView: View, + private val shouldAcceptDrop: () -> Boolean = { true }, + private val onRepositoryDropped: (String) -> Unit, +) { + + fun attach() { + ViewCompat.setOnReceiveContentListener( + rootView, + supportedDropMimeTypes, + ) { _, payload -> tryConsumeRepositoryPayload(payload) } + + bindRepositoryDropTarget(rootView) + } + + fun detach() { + ViewCompat.setOnReceiveContentListener(rootView, null, null) + rootView.setOnDragListener(null) + } + + /** + * Attempts to consume a dropped repository URL from the given [payload]. + * Returns `null` on success to indicate consumption, or the original [payload] otherwise. + */ + private fun tryConsumeRepositoryPayload(payload: ContentInfoCompat): ContentInfoCompat? { + if (!shouldAcceptDrop()) { + return payload + } + + val repositoryUrl = extractRepositoryUrl(payload) ?: return payload + + onRepositoryDropped(repositoryUrl) + return null + } + + private fun bindRepositoryDropTarget(view: View) { + val dropCallback = object : DropTargetCallback { + override fun canAcceptDrop(event: DragEvent): Boolean { + return shouldAcceptDrop() && event.clipDescription.isSupportedDropPayload() + } + + override fun onDragStarted(view: View) { + DropHighlighter.highlight(view, context) + } + + override fun onDragEntered(view: View) { + DropHighlighter.highlight(view, context) + } + + override fun onDragExited(view: View) { + DropHighlighter.clear(view) + } + + override fun onDrop(event: DragEvent): Boolean { + val contentInfo = ContentInfoCompat.Builder( + event.clipData, + ContentInfoCompat.SOURCE_DRAG_AND_DROP, + ).build() + + return ViewCompat.performReceiveContent(view, contentInfo) == null + } + } + + view.setOnDragListener(DragEventRouter(dropCallback)) + } + + private fun extractRepositoryUrl(payload: ContentInfoCompat): String? { + val clip = payload.clip + for (index in 0 until clip.itemCount) { + val item = clip.getItemAt(index) + val text = item.uri?.toString() ?: item.coerceToText(context)?.toString() + + if (text.isNullOrBlank()) continue + + parseGitRepositoryUrl(text)?.let { return it } + } + + return null + } + + private fun ClipDescription?.isSupportedDropPayload(): Boolean { + if (this == null) { + return false + } + + return supportedDropMimeTypes.any(::hasMimeType) + } + + private companion object { + val supportedDropMimeTypes = arrayOf( + ClipDescription.MIMETYPE_TEXT_PLAIN, + ClipDescription.MIMETYPE_TEXT_URILIST, + ) + } +} diff --git a/app/src/main/java/com/itsaky/androidide/fragments/CloneRepositoryFragment.kt b/app/src/main/java/com/itsaky/androidide/fragments/CloneRepositoryFragment.kt index 5310349baf..b6a585fff9 100644 --- a/app/src/main/java/com/itsaky/androidide/fragments/CloneRepositoryFragment.kt +++ b/app/src/main/java/com/itsaky/androidide/fragments/CloneRepositoryFragment.kt @@ -19,6 +19,7 @@ import com.itsaky.androidide.viewmodel.CloneRepositoryViewModel import com.itsaky.androidide.viewmodel.MainViewModel import com.itsaky.androidide.git.core.models.CloneRepoUiState import com.itsaky.androidide.R +import com.itsaky.androidide.dnd.handleGitUrlDrop import com.itsaky.androidide.idetooltips.TooltipManager import com.itsaky.androidide.idetooltips.TooltipTag import com.itsaky.androidide.utils.forEachViewRecursively @@ -44,7 +45,12 @@ class CloneRepositoryFragment : BaseFragment() { super.onViewCreated(view, savedInstanceState) setupUI() + observePendingCloneUrl() observeViewModel() + handleGitUrlDrop { url -> + binding?.repoUrl?.setText(url) + viewModel.onInputChanged(url, binding?.localPath?.text?.toString().orEmpty()) + } } private fun setupUI() { @@ -201,6 +207,20 @@ class CloneRepositoryFragment : BaseFragment() { } } + private fun observePendingCloneUrl() { + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + mainViewModel.cloneRepositoryEvent.collect { url -> + val trimmedUrl = url.trim() + if (trimmedUrl.isNotBlank()) { + binding?.repoUrl?.setText(trimmedUrl) + viewModel.onInputChanged(trimmedUrl, binding?.localPath?.text?.toString().orEmpty()) + } + } + } + } + } + private fun MaterialButton.refreshStatus(isForRetry: Boolean) { setIconResource(if (isForRetry) R.drawable.ic_refresh else 0) diff --git a/app/src/main/java/com/itsaky/androidide/fragments/MainFragment.kt b/app/src/main/java/com/itsaky/androidide/fragments/MainFragment.kt index eb34e32d93..62134c9148 100644 --- a/app/src/main/java/com/itsaky/androidide/fragments/MainFragment.kt +++ b/app/src/main/java/com/itsaky/androidide/fragments/MainFragment.kt @@ -5,7 +5,6 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import org.koin.androidx.viewmodel.ext.android.activityViewModel import com.itsaky.androidide.R import com.itsaky.androidide.activities.editor.HelpActivity import com.itsaky.androidide.adapters.MainActionsListAdapter @@ -14,11 +13,14 @@ import com.itsaky.androidide.actions.ActionItem import com.itsaky.androidide.actions.ActionsRegistry import com.itsaky.androidide.actions.internal.DefaultActionsRegistry import com.itsaky.androidide.databinding.FragmentMainBinding +import com.itsaky.androidide.dnd.handleGitUrlDrop import com.itsaky.androidide.idetooltips.TooltipManager import com.itsaky.androidide.idetooltips.TooltipTag.MAIN_GET_STARTED import com.itsaky.androidide.viewmodel.MainViewModel import org.adfa.constants.CONTENT_KEY import org.adfa.constants.CONTENT_TITLE_KEY +import org.appdevforall.codeonthego.layouteditor.managers.ProjectManager +import org.koin.androidx.viewmodel.ext.android.activityViewModel class MainFragment : BaseFragment() { private val viewModel by activityViewModel() @@ -81,6 +83,15 @@ class MainFragment : BaseFragment() { true } binding!!.greetingText.setOnClickListener { ifAttached { openQuickstartPageAction() } } + + handleGitUrlDrop( + shouldAcceptDrop = { + isVisible && + viewModel.currentScreen.value == MainViewModel.SCREEN_MAIN && + ProjectManager.instance.openedProject == null + }, + onDropped = viewModel::requestCloneRepository + ) } private fun openQuickstartPageAction() { diff --git a/app/src/main/java/com/itsaky/androidide/utils/DropHighlighter.kt b/app/src/main/java/com/itsaky/androidide/utils/DropHighlighter.kt new file mode 100644 index 0000000000..1834e8cf10 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/utils/DropHighlighter.kt @@ -0,0 +1,35 @@ +package com.itsaky.androidide.utils + +import android.content.Context +import android.graphics.drawable.Drawable +import android.view.View +import androidx.core.content.ContextCompat +import com.itsaky.androidide.R +import androidx.core.graphics.drawable.toDrawable + +object DropHighlighter { + /** + * Applies a highlight foreground to the [view] to indicate an active drop target, + * saving its original foreground state safely. + */ + fun highlight(view: View, context: Context) { + if (view.getTag(R.id.filetree_drop_target_tag) == null) { + view.setTag(R.id.filetree_drop_target_tag, view.foreground ?: "NULL_FG") + } + + val baseColor = ContextCompat.getColor(context, R.color.teal_200) + val highlightColor = (baseColor and 0x00FFFFFF) or (64 shl 24) + + view.foreground = highlightColor.toDrawable() + } + + /** + * Restores the original foreground of the [view] and clears the drop target highlight. + */ + fun clear(view: View) { + val savedFg = view.getTag(R.id.filetree_drop_target_tag) ?: return + + view.foreground = if (savedFg == "NULL_FG") null else savedFg as? Drawable + view.setTag(R.id.filetree_drop_target_tag, null) + } +} diff --git a/app/src/main/java/com/itsaky/androidide/viewmodel/CloneRepositoryViewModel.kt b/app/src/main/java/com/itsaky/androidide/viewmodel/CloneRepositoryViewModel.kt index 2cbd3d8416..b6f7ea367f 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodel/CloneRepositoryViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodel/CloneRepositoryViewModel.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.application import androidx.lifecycle.viewModelScope import com.itsaky.androidide.git.core.GitRepositoryManager +import com.itsaky.androidide.git.core.parseGitRepositoryUrl import com.itsaky.androidide.git.core.models.CloneRepoUiState import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow @@ -31,13 +32,14 @@ class CloneRepositoryViewModel(application: Application) : AndroidViewModel(appl private var isCloneCancelled = false fun onInputChanged(url: String, path: String) { + val normalizedUrl = parseGitRepositoryUrl(url) val currentState = _uiState.value if (currentState is CloneRepoUiState.Idle) { _uiState.update { currentState.copy( url = url, localPath = path, - isCloneButtonEnabled = url.isNotBlank() && path.isNotBlank() + isCloneButtonEnabled = normalizedUrl != null && path.isNotBlank() ) } } else if (currentState is CloneRepoUiState.Error) { @@ -45,7 +47,7 @@ class CloneRepositoryViewModel(application: Application) : AndroidViewModel(appl CloneRepoUiState.Idle( url = url, localPath = path, - isCloneButtonEnabled = url.isNotBlank() && path.isNotBlank() + isCloneButtonEnabled = normalizedUrl != null && path.isNotBlank() ) } } @@ -61,13 +63,25 @@ class CloneRepositoryViewModel(application: Application) : AndroidViewModel(appl username: String? = null, token: String? = null ) { + val normalizedUrl = parseGitRepositoryUrl(url) + if (normalizedUrl == null) { + _uiState.update { + CloneRepoUiState.Error( + url = url, + localPath = localPath, + errorResId = R.string.msg_invalid_url + ) + } + return + } + isCloneCancelled = false val destDir = File(localPath) val isExistingDir = destDir.exists() if (isExistingDir && destDir.listFiles()?.isNotEmpty() == true) { _uiState.update { CloneRepoUiState.Error( - url = url, + url = normalizedUrl, localPath = localPath, errorResId = R.string.destination_directory_not_empty ) @@ -78,7 +92,7 @@ class CloneRepositoryViewModel(application: Application) : AndroidViewModel(appl if (!NetworkUtils.isConnected()) { _uiState.update { CloneRepoUiState.Error( - url = url, + url = normalizedUrl, localPath = localPath, errorResId = R.string.no_internet_connection, canRetry = true @@ -91,7 +105,7 @@ class CloneRepositoryViewModel(application: Application) : AndroidViewModel(appl var hasCloned = false _uiState.update { CloneRepoUiState.Cloning( - url = url, + url = normalizedUrl, localPath = localPath, statusTextResId = R.string.initialising_clone ) @@ -158,7 +172,7 @@ class CloneRepositoryViewModel(application: Application) : AndroidViewModel(appl } } - GitRepositoryManager.cloneRepository(url, destDir, credentials, progressMonitor) + GitRepositoryManager.cloneRepository(normalizedUrl, destDir, credentials, progressMonitor) if (!destDir.exists()) { throw Exception("Destination directory was not created.") @@ -173,7 +187,7 @@ class CloneRepositoryViewModel(application: Application) : AndroidViewModel(appl if (isCloneCancelled) { _uiState.update { CloneRepoUiState.Idle( - url = url, + url = normalizedUrl, localPath = localPath, isCloneButtonEnabled = true ) @@ -198,7 +212,7 @@ class CloneRepositoryViewModel(application: Application) : AndroidViewModel(appl _uiState.update { CloneRepoUiState.Error( - url = url, + url = normalizedUrl, localPath = localPath, errorResId = errorResId, errorMessage = errorMessage?.let { application.getString(R.string.clone_failed, it) }, @@ -220,7 +234,7 @@ class CloneRepositoryViewModel(application: Application) : AndroidViewModel(appl if (currentState is CloneRepoUiState.Cloning) { _uiState.update { CloneRepoUiState.Idle( - url = url, + url = normalizedUrl, localPath = localPath, isCloneButtonEnabled = true ) diff --git a/app/src/main/java/com/itsaky/androidide/viewmodel/MainViewModel.kt b/app/src/main/java/com/itsaky/androidide/viewmodel/MainViewModel.kt index b5bf496a5c..46f42ba1ab 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodel/MainViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodel/MainViewModel.kt @@ -27,6 +27,8 @@ import com.itsaky.androidide.roomData.recentproject.RecentProject import com.itsaky.androidide.roomData.recentproject.RecentProjectDao import com.itsaky.androidide.templates.Template import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch import org.slf4j.Logger import org.slf4j.LoggerFactory @@ -65,11 +67,15 @@ class MainViewModel( private val _previousScreen = AtomicInteger(-1) private val _isTransitionInProgress = MutableLiveData(false) + private val cloneRepositoryEventChannel = Channel(Channel.BUFFERED) + internal val template = MutableLiveData>(null) internal val creatingProject = MutableLiveData(false) val currentScreen: LiveData = _currentScreen + val cloneRepositoryEvent = cloneRepositoryEventChannel.receiveAsFlow() + val previousScreen: Int get() = _previousScreen.get() @@ -84,6 +90,13 @@ class MainViewModel( _currentScreen.value = screen } + fun requestCloneRepository(url: String) { + viewModelScope.launch { + cloneRepositoryEventChannel.send(url) + } + setScreen(SCREEN_CLONE_REPO) + } + fun postTransition(owner: LifecycleOwner, action: Runnable) { if (isTransitionInProgress) { _isTransitionInProgress.observe(owner, object : Observer { @@ -106,4 +119,4 @@ class MainViewModel( } } } -} \ No newline at end of file +} diff --git a/git-core/src/main/java/com/itsaky/androidide/git/core/GitRepositoryUrls.kt b/git-core/src/main/java/com/itsaky/androidide/git/core/GitRepositoryUrls.kt new file mode 100644 index 0000000000..49658fe25c --- /dev/null +++ b/git-core/src/main/java/com/itsaky/androidide/git/core/GitRepositoryUrls.kt @@ -0,0 +1,59 @@ +package com.itsaky.androidide.git.core + +import java.net.URI +import java.net.URISyntaxException + +private val sshGitUrlRegex = Regex("^[^\\s@/]+@[^\\s:/]+:\\S+$") +private val supportedGitSchemes = setOf("http", "https", "git", "ssh") + +fun parseGitRepositoryUrl(rawText: String): String? { + val candidate = rawText.trim() + if (candidate.isBlank()) { + return null + } + + if (candidate.matches(sshGitUrlRegex)) { + return candidate + } + + val uri = try { + URI(candidate) + } catch (_: URISyntaxException) { + return null + } + + val scheme = uri.scheme?.lowercase() ?: return null + if (scheme !in supportedGitSchemes) { + return null + } + + val host = uri.host + if (host.isNullOrBlank()) { + return null + } + + val path = uri.path ?: "" + val pathSegments = path.split("/").filter { it.isNotBlank() } + + val isExplicitGitUrl = path.endsWith(".git") + val webUiIndicators = setOf("tree", "blob", "raw", "commits", "commit", "pull", "issues", "releases", "tags", "branches", "-") + val hasWebUiSegments = pathSegments.any { it in webUiIndicators } + + if (!isExplicitGitUrl && (pathSegments.size < 2 || hasWebUiSegments || !uri.query.isNullOrBlank())) { + return null + } + + return try { + URI( + uri.scheme, + uri.userInfo, + uri.host, + uri.port, + uri.path, + if (isExplicitGitUrl) uri.query else null, + null + ).toString() + } catch (_: URISyntaxException) { + null + } +} diff --git a/resources/src/main/res/values/ids.xml b/resources/src/main/res/values/ids.xml index 48008b803b..df2416d458 100644 --- a/resources/src/main/res/values/ids.xml +++ b/resources/src/main/res/values/ids.xml @@ -1,4 +1,5 @@ + diff --git a/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml index 0f5f508f3c..ec118b2699 100644 --- a/resources/src/main/res/values/strings.xml +++ b/resources/src/main/res/values/strings.xml @@ -170,6 +170,8 @@ Gradle version Kotlin version Java version + Failed to start dragging the file + Error preparing file for drag When you close the project, all build tasks will be stopped and unsaved changes to your project file may be lost. Do you want to save the files before you close the project? Basic Activity @@ -273,6 +275,14 @@ Kotlin New Kotlin class Failed to list project files + Failed to import dropped files + No external files were dropped + Destination folder does not exist + Cannot resolve the destination folder + Dropped file + Unable to read dropped file: %1$s + Imported 1 file + Imported %1$d files Close current file From 8cb01142ac4c070227ffc351d24f39f8e503e9a4 Mon Sep 17 00:00:00 2001 From: John Trujillo Date: Thu, 2 Apr 2026 08:27:15 -0500 Subject: [PATCH 2/2] refactor: Use URIish to handle various Git URL formats --- .../androidide/git/core/GitRepositoryUrls.kt | 68 ++++++------------- 1 file changed, 21 insertions(+), 47 deletions(-) diff --git a/git-core/src/main/java/com/itsaky/androidide/git/core/GitRepositoryUrls.kt b/git-core/src/main/java/com/itsaky/androidide/git/core/GitRepositoryUrls.kt index 49658fe25c..a088892d92 100644 --- a/git-core/src/main/java/com/itsaky/androidide/git/core/GitRepositoryUrls.kt +++ b/git-core/src/main/java/com/itsaky/androidide/git/core/GitRepositoryUrls.kt @@ -1,59 +1,33 @@ package com.itsaky.androidide.git.core -import java.net.URI +import android.util.Log +import org.eclipse.jgit.transport.URIish import java.net.URISyntaxException -private val sshGitUrlRegex = Regex("^[^\\s@/]+@[^\\s:/]+:\\S+$") -private val supportedGitSchemes = setOf("http", "https", "git", "ssh") - +/** + * Parses and validates a raw string into a Git repository URL. + * + * This method leverages JGit's [URIish] to handle various Git URL formats, + * including SCP-like SSH URLs. It enforces the presence of either a host or + * a scheme to prevent plain clipboard text from being incorrectly treated as a valid URI. + * + * @param rawText The raw string input to be parsed. + * @return The normalized Git URL string, or null if the input is invalid or parsing fails. + */ fun parseGitRepositoryUrl(rawText: String): String? { val candidate = rawText.trim() - if (candidate.isBlank()) { - return null - } - - if (candidate.matches(sshGitUrlRegex)) { - return candidate - } - - val uri = try { - URI(candidate) - } catch (_: URISyntaxException) { - return null - } - - val scheme = uri.scheme?.lowercase() ?: return null - if (scheme !in supportedGitSchemes) { - return null - } - - val host = uri.host - if (host.isNullOrBlank()) { - return null - } - - val path = uri.path ?: "" - val pathSegments = path.split("/").filter { it.isNotBlank() } - - val isExplicitGitUrl = path.endsWith(".git") - val webUiIndicators = setOf("tree", "blob", "raw", "commits", "commit", "pull", "issues", "releases", "tags", "branches", "-") - val hasWebUiSegments = pathSegments.any { it in webUiIndicators } - - if (!isExplicitGitUrl && (pathSegments.size < 2 || hasWebUiSegments || !uri.query.isNullOrBlank())) { - return null - } + if (candidate.isBlank()) return null return try { - URI( - uri.scheme, - uri.userInfo, - uri.host, - uri.port, - uri.path, - if (isExplicitGitUrl) uri.query else null, + val uri = URIish(candidate) + + if (uri.host != null || uri.scheme != null) { + uri.toString() + } else { null - ).toString() - } catch (_: URISyntaxException) { + } + } catch (e: URISyntaxException) { + Log.w("GitParser", "Failed to parse Git URL candidate: $candidate", e) null } }