Skip to content

Commit cb05a7d

Browse files
authored
ADFA-3574 | Add drag and drop support for Git repository URLs (#1132)
* feat(dnd): implement core drag-and-drop architecture and Git URL support Add reusable DragEventRouter and enable Git URL dropping for repository cloning. * refactor: Use URIish to handle various Git URL formats
1 parent b68bc5a commit cb05a7d

12 files changed

Lines changed: 371 additions & 11 deletions

File tree

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
package com.itsaky.androidide.dnd
2+
3+
import android.view.DragEvent
4+
import android.view.View
5+
6+
/**
7+
* A [View.OnDragListener] implementation that delegates the complex Android drag state machine
8+
* into a clean [DropTargetCallback].
9+
*/
10+
class DragEventRouter(
11+
private val callback: DropTargetCallback
12+
) : View.OnDragListener {
13+
override fun onDrag(view: View, event: DragEvent): Boolean {
14+
val canHandle = callback.canAcceptDrop(event)
15+
16+
return when (event.action) {
17+
DragEvent.ACTION_DRAG_STARTED -> {
18+
if (canHandle) callback.onDragStarted(view)
19+
canHandle
20+
}
21+
22+
DragEvent.ACTION_DRAG_ENTERED -> {
23+
if (canHandle) callback.onDragEntered(view)
24+
true
25+
}
26+
27+
DragEvent.ACTION_DRAG_LOCATION -> {
28+
canHandle
29+
}
30+
31+
DragEvent.ACTION_DRAG_EXITED -> {
32+
callback.onDragExited(view)
33+
true
34+
}
35+
36+
DragEvent.ACTION_DROP -> {
37+
callback.onDragExited(view)
38+
if (canHandle) {
39+
callback.onDrop(event)
40+
} else {
41+
false
42+
}
43+
}
44+
45+
DragEvent.ACTION_DRAG_ENDED -> {
46+
callback.onDragExited(view)
47+
canHandle
48+
}
49+
50+
else -> false
51+
}
52+
}
53+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
package com.itsaky.androidide.dnd
2+
3+
import android.view.DragEvent
4+
import android.view.View
5+
6+
/**
7+
* Callback interface for handling routed drag-and-drop events on a specific target view.
8+
*/
9+
interface DropTargetCallback {
10+
/**
11+
* Determines whether the current [event] contains data that this target can handle.
12+
*/
13+
fun canAcceptDrop(event: DragEvent): Boolean
14+
15+
/**
16+
* Called when a drag operation begins. Useful for applying initial visual cues.
17+
*/
18+
fun onDragStarted(view: View) {}
19+
20+
/**
21+
* Called when a valid dragged item enters the bounds of the target [view].
22+
*/
23+
fun onDragEntered(view: View)
24+
25+
/**
26+
* Called when a dragged item exits the target [view], or when the drag operation ends/is canceled.
27+
*/
28+
fun onDragExited(view: View)
29+
30+
/**
31+
* Called when the user successfully drops a valid item on the target.
32+
* * @return True if the drop was successfully consumed and handled.
33+
*/
34+
fun onDrop(event: DragEvent): Boolean
35+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
package com.itsaky.androidide.dnd
2+
3+
import android.view.View
4+
import androidx.fragment.app.Fragment
5+
import androidx.lifecycle.DefaultLifecycleObserver
6+
import androidx.lifecycle.LifecycleOwner
7+
8+
9+
fun Fragment.handleGitUrlDrop(
10+
targetView: View = requireView(),
11+
shouldAcceptDrop: () -> Boolean = { isVisible },
12+
onDropped: (String) -> Unit
13+
) {
14+
val dropTarget = GitUrlDropTarget(
15+
context = requireContext(),
16+
rootView = targetView,
17+
shouldAcceptDrop = shouldAcceptDrop,
18+
onRepositoryDropped = onDropped
19+
)
20+
21+
dropTarget.attach()
22+
23+
viewLifecycleOwner.lifecycle.addObserver(object : DefaultLifecycleObserver {
24+
override fun onDestroy(owner: LifecycleOwner) {
25+
dropTarget.detach()
26+
}
27+
})
28+
}
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
package com.itsaky.androidide.dnd
2+
3+
import android.content.ClipDescription
4+
import android.content.Context
5+
import android.view.DragEvent
6+
import android.view.View
7+
import androidx.core.view.ContentInfoCompat
8+
import androidx.core.view.ViewCompat
9+
import com.itsaky.androidide.git.core.parseGitRepositoryUrl
10+
import com.itsaky.androidide.utils.DropHighlighter
11+
12+
internal class GitUrlDropTarget(
13+
private val context: Context,
14+
private val rootView: View,
15+
private val shouldAcceptDrop: () -> Boolean = { true },
16+
private val onRepositoryDropped: (String) -> Unit,
17+
) {
18+
19+
fun attach() {
20+
ViewCompat.setOnReceiveContentListener(
21+
rootView,
22+
supportedDropMimeTypes,
23+
) { _, payload -> tryConsumeRepositoryPayload(payload) }
24+
25+
bindRepositoryDropTarget(rootView)
26+
}
27+
28+
fun detach() {
29+
ViewCompat.setOnReceiveContentListener(rootView, null, null)
30+
rootView.setOnDragListener(null)
31+
}
32+
33+
/**
34+
* Attempts to consume a dropped repository URL from the given [payload].
35+
* Returns `null` on success to indicate consumption, or the original [payload] otherwise.
36+
*/
37+
private fun tryConsumeRepositoryPayload(payload: ContentInfoCompat): ContentInfoCompat? {
38+
if (!shouldAcceptDrop()) {
39+
return payload
40+
}
41+
42+
val repositoryUrl = extractRepositoryUrl(payload) ?: return payload
43+
44+
onRepositoryDropped(repositoryUrl)
45+
return null
46+
}
47+
48+
private fun bindRepositoryDropTarget(view: View) {
49+
val dropCallback = object : DropTargetCallback {
50+
override fun canAcceptDrop(event: DragEvent): Boolean {
51+
return shouldAcceptDrop() && event.clipDescription.isSupportedDropPayload()
52+
}
53+
54+
override fun onDragStarted(view: View) {
55+
DropHighlighter.highlight(view, context)
56+
}
57+
58+
override fun onDragEntered(view: View) {
59+
DropHighlighter.highlight(view, context)
60+
}
61+
62+
override fun onDragExited(view: View) {
63+
DropHighlighter.clear(view)
64+
}
65+
66+
override fun onDrop(event: DragEvent): Boolean {
67+
val contentInfo = ContentInfoCompat.Builder(
68+
event.clipData,
69+
ContentInfoCompat.SOURCE_DRAG_AND_DROP,
70+
).build()
71+
72+
return ViewCompat.performReceiveContent(view, contentInfo) == null
73+
}
74+
}
75+
76+
view.setOnDragListener(DragEventRouter(dropCallback))
77+
}
78+
79+
private fun extractRepositoryUrl(payload: ContentInfoCompat): String? {
80+
val clip = payload.clip
81+
for (index in 0 until clip.itemCount) {
82+
val item = clip.getItemAt(index)
83+
val text = item.uri?.toString() ?: item.coerceToText(context)?.toString()
84+
85+
if (text.isNullOrBlank()) continue
86+
87+
parseGitRepositoryUrl(text)?.let { return it }
88+
}
89+
90+
return null
91+
}
92+
93+
private fun ClipDescription?.isSupportedDropPayload(): Boolean {
94+
if (this == null) {
95+
return false
96+
}
97+
98+
return supportedDropMimeTypes.any(::hasMimeType)
99+
}
100+
101+
private companion object {
102+
val supportedDropMimeTypes = arrayOf(
103+
ClipDescription.MIMETYPE_TEXT_PLAIN,
104+
ClipDescription.MIMETYPE_TEXT_URILIST,
105+
)
106+
}
107+
}

app/src/main/java/com/itsaky/androidide/fragments/CloneRepositoryFragment.kt

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import com.itsaky.androidide.viewmodel.CloneRepositoryViewModel
1919
import com.itsaky.androidide.viewmodel.MainViewModel
2020
import com.itsaky.androidide.git.core.models.CloneRepoUiState
2121
import com.itsaky.androidide.R
22+
import com.itsaky.androidide.dnd.handleGitUrlDrop
2223
import com.itsaky.androidide.idetooltips.TooltipManager
2324
import com.itsaky.androidide.idetooltips.TooltipTag
2425
import com.itsaky.androidide.utils.forEachViewRecursively
@@ -44,7 +45,12 @@ class CloneRepositoryFragment : BaseFragment() {
4445
super.onViewCreated(view, savedInstanceState)
4546

4647
setupUI()
48+
observePendingCloneUrl()
4749
observeViewModel()
50+
handleGitUrlDrop { url ->
51+
binding?.repoUrl?.setText(url)
52+
viewModel.onInputChanged(url, binding?.localPath?.text?.toString().orEmpty())
53+
}
4854
}
4955

5056
private fun setupUI() {
@@ -201,6 +207,20 @@ class CloneRepositoryFragment : BaseFragment() {
201207
}
202208
}
203209

210+
private fun observePendingCloneUrl() {
211+
viewLifecycleOwner.lifecycleScope.launch {
212+
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
213+
mainViewModel.cloneRepositoryEvent.collect { url ->
214+
val trimmedUrl = url.trim()
215+
if (trimmedUrl.isNotBlank()) {
216+
binding?.repoUrl?.setText(trimmedUrl)
217+
viewModel.onInputChanged(trimmedUrl, binding?.localPath?.text?.toString().orEmpty())
218+
}
219+
}
220+
}
221+
}
222+
}
223+
204224
private fun MaterialButton.refreshStatus(isForRetry: Boolean) {
205225
setIconResource(if (isForRetry) R.drawable.ic_refresh else 0)
206226

app/src/main/java/com/itsaky/androidide/fragments/MainFragment.kt

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import android.os.Bundle
55
import android.view.LayoutInflater
66
import android.view.View
77
import android.view.ViewGroup
8-
import org.koin.androidx.viewmodel.ext.android.activityViewModel
98
import com.itsaky.androidide.R
109
import com.itsaky.androidide.activities.editor.HelpActivity
1110
import com.itsaky.androidide.adapters.MainActionsListAdapter
@@ -14,11 +13,14 @@ import com.itsaky.androidide.actions.ActionItem
1413
import com.itsaky.androidide.actions.ActionsRegistry
1514
import com.itsaky.androidide.actions.internal.DefaultActionsRegistry
1615
import com.itsaky.androidide.databinding.FragmentMainBinding
16+
import com.itsaky.androidide.dnd.handleGitUrlDrop
1717
import com.itsaky.androidide.idetooltips.TooltipManager
1818
import com.itsaky.androidide.idetooltips.TooltipTag.MAIN_GET_STARTED
1919
import com.itsaky.androidide.viewmodel.MainViewModel
2020
import org.adfa.constants.CONTENT_KEY
2121
import org.adfa.constants.CONTENT_TITLE_KEY
22+
import org.appdevforall.codeonthego.layouteditor.managers.ProjectManager
23+
import org.koin.androidx.viewmodel.ext.android.activityViewModel
2224

2325
class MainFragment : BaseFragment() {
2426
private val viewModel by activityViewModel<MainViewModel>()
@@ -81,6 +83,15 @@ class MainFragment : BaseFragment() {
8183
true
8284
}
8385
binding!!.greetingText.setOnClickListener { ifAttached { openQuickstartPageAction() } }
86+
87+
handleGitUrlDrop(
88+
shouldAcceptDrop = {
89+
isVisible &&
90+
viewModel.currentScreen.value == MainViewModel.SCREEN_MAIN &&
91+
ProjectManager.instance.openedProject == null
92+
},
93+
onDropped = viewModel::requestCloneRepository
94+
)
8495
}
8596

8697
private fun openQuickstartPageAction() {
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
package com.itsaky.androidide.utils
2+
3+
import android.content.Context
4+
import android.graphics.drawable.Drawable
5+
import android.view.View
6+
import androidx.core.content.ContextCompat
7+
import com.itsaky.androidide.R
8+
import androidx.core.graphics.drawable.toDrawable
9+
10+
object DropHighlighter {
11+
/**
12+
* Applies a highlight foreground to the [view] to indicate an active drop target,
13+
* saving its original foreground state safely.
14+
*/
15+
fun highlight(view: View, context: Context) {
16+
if (view.getTag(R.id.filetree_drop_target_tag) == null) {
17+
view.setTag(R.id.filetree_drop_target_tag, view.foreground ?: "NULL_FG")
18+
}
19+
20+
val baseColor = ContextCompat.getColor(context, R.color.teal_200)
21+
val highlightColor = (baseColor and 0x00FFFFFF) or (64 shl 24)
22+
23+
view.foreground = highlightColor.toDrawable()
24+
}
25+
26+
/**
27+
* Restores the original foreground of the [view] and clears the drop target highlight.
28+
*/
29+
fun clear(view: View) {
30+
val savedFg = view.getTag(R.id.filetree_drop_target_tag) ?: return
31+
32+
view.foreground = if (savedFg == "NULL_FG") null else savedFg as? Drawable
33+
view.setTag(R.id.filetree_drop_target_tag, null)
34+
}
35+
}

0 commit comments

Comments
 (0)