-
-
Notifications
You must be signed in to change notification settings - Fork 26
ADFA-4399: Floating windows feature #1456
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: stage
Are you sure you want to change the base?
Changes from all commits
483eadf
fdcb1f7
485c278
3bd4bf4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,79 @@ | ||
| package com.itsaky.androidide.editor.floating | ||
|
|
||
| import android.content.Context | ||
| import android.view.View | ||
| import com.itsaky.androidide.R | ||
| import com.itsaky.androidide.api.IDEApiFacade | ||
| import com.itsaky.androidide.floating.model.DockAction | ||
| import com.itsaky.androidide.floating.model.DockableContent | ||
| import com.itsaky.androidide.floating.window.FloatingWindowHost | ||
| import com.itsaky.androidide.idetooltips.TooltipCategory | ||
| import com.itsaky.androidide.idetooltips.TooltipManager | ||
| import com.itsaky.androidide.idetooltips.TooltipTag | ||
| import com.itsaky.androidide.models.Position | ||
| import com.itsaky.androidide.models.Range | ||
| import com.itsaky.androidide.ui.CodeEditorView | ||
| import java.io.File | ||
| import com.itsaky.androidide.resources.R as ResR | ||
|
|
||
| /** | ||
| * Adapts an editor file tab to [DockableContent] for a floating window. Builds a [CodeEditorView] | ||
| * against the window's long-lived themed context so it outlives the editor activity. Content | ||
| * continuity across dock/undock is preserved by saving to disk on each transition; this view simply | ||
| * re-opens the saved file. | ||
| */ | ||
| class EditorPanelDockableContent( | ||
| val file: File, | ||
| ) : DockableContent { | ||
| override val id: String = file.absolutePath | ||
| override val title: String = file.name | ||
|
|
||
| private var editorView: CodeEditorView? = null | ||
| private var dockActions: List<DockAction> = emptyList() | ||
|
|
||
| override val actions: List<DockAction> | ||
| get() = dockActions | ||
|
|
||
| override fun onCreateView( | ||
| context: Context, | ||
| host: FloatingWindowHost, | ||
| ): View { | ||
| val view = CodeEditorView(context, file, Range(Position(0, 0), Position(0, 0))) | ||
| editorView = view | ||
| dockActions = | ||
| listOf( | ||
| DockAction( | ||
| id = "ide.floating.save", | ||
| label = "Save", | ||
| iconRes = ResR.drawable.ic_save, | ||
| confirmIconRes = R.drawable.ic_check, | ||
| onLongPress = { anchor -> | ||
| TooltipManager.showTooltip(context, anchor, TooltipCategory.CATEGORY_IDE, TooltipTag.EDITOR_TOOLBAR_QUICK_SAVE) | ||
| }, | ||
| ) { | ||
| view.save() | ||
| true | ||
| }, | ||
| DockAction( | ||
| id = "ide.floating.run", | ||
| label = "Run", | ||
| iconRes = ResR.drawable.ic_run, | ||
| onLongPress = { anchor -> | ||
| TooltipManager.showTooltip(context, anchor, TooltipCategory.CATEGORY_IDE, TooltipTag.EDITOR_TOOLBAR_QUICK_RUN) | ||
| }, | ||
| ) { | ||
| IDEApiFacade.runApp() | ||
| false | ||
| }, | ||
| ) | ||
| view.onEditorSelected() | ||
| return view | ||
| } | ||
|
|
||
| suspend fun save(): Boolean = editorView?.save() ?: false | ||
|
|
||
| fun release() { | ||
| editorView?.close() | ||
| editorView = null | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,102 @@ | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| package com.itsaky.androidide.editor.floating | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| import android.content.Intent | ||||||||||||||||||||||
| import androidx.lifecycle.Lifecycle | ||||||||||||||||||||||
| import androidx.lifecycle.lifecycleScope | ||||||||||||||||||||||
| import com.itsaky.androidide.activities.editor.EditorHandlerActivity | ||||||||||||||||||||||
| import com.itsaky.androidide.floating.model.DockingEvent | ||||||||||||||||||||||
| import com.itsaky.androidide.floating.model.DockingManager | ||||||||||||||||||||||
| import com.itsaky.androidide.floating.permission.OverlayPermission | ||||||||||||||||||||||
| import com.itsaky.androidide.floating.service.FloatingTabService | ||||||||||||||||||||||
| import com.itsaky.androidide.floating.window.InitialBounds | ||||||||||||||||||||||
| import kotlinx.coroutines.launch | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| /** | ||||||||||||||||||||||
| * Bridges the editor activity to the floating-window system: turns an editor file tab into a | ||||||||||||||||||||||
| * floating window and back. | ||||||||||||||||||||||
| * | ||||||||||||||||||||||
| * - Undock: persist the docked panel, close its docked tab, then float a fresh panel built against | ||||||||||||||||||||||
| * the service's window context (so it survives this activity being destroyed). | ||||||||||||||||||||||
| * - Redock/Close ([DockingManager.events]): persist and release the floating panel, and for redock, | ||||||||||||||||||||||
| * re-open the file as a docked tab. | ||||||||||||||||||||||
| */ | ||||||||||||||||||||||
| class IdeFloatingTabController( | ||||||||||||||||||||||
| private val activity: EditorHandlerActivity, | ||||||||||||||||||||||
| ) { | ||||||||||||||||||||||
| private var undockCounter = 0 | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| fun start() { | ||||||||||||||||||||||
| activity.lifecycleScope.launch { | ||||||||||||||||||||||
| DockingManager.events.collect(::onEvent) | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
Comment on lines
+29
to
+33
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟡 Minor 🧩 Analysis chain🏁 Script executed: ast-grep run --pattern 'class DockingManager' --lang kotlinRepository: appdevforall/CodeOnTheGo Length of output: 162 🏁 Script executed: fd -t f "DockingManager.kt" .Repository: appdevforall/CodeOnTheGo Length of output: 249 🏁 Script executed: cat -n ./floating-window/src/main/java/com/itsaky/androidide/floating/model/DockingManager.kt | head -100Repository: appdevforall/CodeOnTheGo Length of output: 4045 🏁 Script executed: node - <<'JS'
const fs = require('fs');
// Simulating the default behavior of MutableSharedFlow
// In Kotlin Coroutines:
// public constructor(replay: Int = 0, extraBufferCapacity: Int = 0, onBufferOverflow: BufferOverflow = SUSPEND)
// The review states replay=0 (default) which means no history is saved.
console.log("Verification: MutableSharedFlow defaults to replay=0");
console.log("Conclusion: Events emitted while no collector is active are lost (unless buffered, but not replayed to new collectors).");
JSRepository: appdevforall/CodeOnTheGo Length of output: 335 🏁 Script executed: cat -n ./app/src/main/java/com/itsaky/androidide/editor/floating/IdeFloatingTabController.kt | head -50Repository: appdevforall/CodeOnTheGo Length of output: 2242 Do not bind docking-event reconciliation to the activity lifecycle.
Since Decouple the collection from the activity lifecycle (e.g., move to Application scope) or configure the 🤖 Prompt for AI Agents |
||||||||||||||||||||||
|
|
||||||||||||||||||||||
| fun undock(fileIndex: Int) { | ||||||||||||||||||||||
| if (!OverlayPermission.canDrawOverlays(activity)) { | ||||||||||||||||||||||
| activity.startActivity(OverlayPermission.requestIntent(activity)) | ||||||||||||||||||||||
| return | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| val panel = activity.getEditorAtIndex(fileIndex) ?: return | ||||||||||||||||||||||
| val file = panel.file ?: return | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| activity.lifecycleScope.launch { | ||||||||||||||||||||||
| panel.save() | ||||||||||||||||||||||
| panel.markAsSaved() | ||||||||||||||||||||||
| activity.closeFile(fileIndex) {} | ||||||||||||||||||||||
|
Comment on lines
+44
to
+47
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🔴 Critical | ⚡ Quick win Abort the undock if This path clears the dirty state and closes the docked editor unconditionally. If Suggested fix activity.lifecycleScope.launch {
- panel.save()
+ if (!panel.save()) {
+ return@launch
+ }
panel.markAsSaved()
activity.closeFile(fileIndex) {}
DockingManager.undock(📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||
| DockingManager.undock( | ||||||||||||||||||||||
| EditorPanelDockableContent(file), | ||||||||||||||||||||||
| InitialBounds.cascaded(activity, undockCounter++), | ||||||||||||||||||||||
| ) | ||||||||||||||||||||||
| FloatingTabService.ensureRunning(activity.applicationContext) | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| fun floatPluginTab( | ||||||||||||||||||||||
| tabId: String, | ||||||||||||||||||||||
| title: String, | ||||||||||||||||||||||
| remove: () -> Unit, | ||||||||||||||||||||||
| ) { | ||||||||||||||||||||||
| if (!OverlayPermission.canDrawOverlays(activity)) { | ||||||||||||||||||||||
| activity.startActivity(OverlayPermission.requestIntent(activity)) | ||||||||||||||||||||||
| return | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| remove() | ||||||||||||||||||||||
| DockingManager.undock( | ||||||||||||||||||||||
| PluginTabDockableContent(tabId, title), | ||||||||||||||||||||||
| InitialBounds.cascaded(activity, undockCounter++), | ||||||||||||||||||||||
| ) | ||||||||||||||||||||||
| FloatingTabService.ensureRunning(activity.applicationContext) | ||||||||||||||||||||||
|
Comment on lines
+65
to
+70
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift The docked plugin tab is removed before the floating copy is known-good.
🤖 Prompt for AI Agents |
||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| private fun onEvent(event: DockingEvent) { | ||||||||||||||||||||||
| when (val content = event.content) { | ||||||||||||||||||||||
| is EditorPanelDockableContent -> | ||||||||||||||||||||||
| activity.lifecycleScope.launch { | ||||||||||||||||||||||
| content.save() | ||||||||||||||||||||||
| content.release() | ||||||||||||||||||||||
| if (event is DockingEvent.Redock) { | ||||||||||||||||||||||
| bringIdeToFront() | ||||||||||||||||||||||
| activity.openFile(content.file, null) | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| is PluginTabDockableContent -> | ||||||||||||||||||||||
| if (event is DockingEvent.Redock) { | ||||||||||||||||||||||
| bringIdeToFront() | ||||||||||||||||||||||
| activity.selectPluginTabById(content.tabId) | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| private fun bringIdeToFront() { | ||||||||||||||||||||||
| if (activity.lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) { | ||||||||||||||||||||||
| return | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| activity.startActivity( | ||||||||||||||||||||||
| Intent(activity, activity.javaClass) | ||||||||||||||||||||||
| .addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT), | ||||||||||||||||||||||
| ) | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,62 @@ | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| package com.itsaky.androidide.editor.floating | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| import android.content.Context | ||||||||||||||||||||||||||
| import android.util.Log | ||||||||||||||||||||||||||
| import android.view.View | ||||||||||||||||||||||||||
| import android.widget.TextView | ||||||||||||||||||||||||||
| import androidx.fragment.app.FragmentFactory | ||||||||||||||||||||||||||
| import com.itsaky.androidide.floating.fragment.OverlayFragmentHost | ||||||||||||||||||||||||||
| import com.itsaky.androidide.floating.model.DockableContent | ||||||||||||||||||||||||||
| import com.itsaky.androidide.floating.window.FloatingWindowHost | ||||||||||||||||||||||||||
| import com.itsaky.androidide.plugins.manager.fragment.PluginFragmentFactory | ||||||||||||||||||||||||||
| import com.itsaky.androidide.plugins.manager.ui.PluginEditorTabManager | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||
| * Adapts a plugin editor tab (an [com.itsaky.androidide.plugins.extensions.EditorTabExtension] | ||||||||||||||||||||||||||
| * Fragment) to [DockableContent]. The Fragment is re-instantiated through the plugin's factory and | ||||||||||||||||||||||||||
| * hosted in an [OverlayFragmentHost] (a FragmentManager with no Activity), so it can run over other | ||||||||||||||||||||||||||
| * apps. A [PluginFragmentFactory] is installed so the plugin's classloader resolves on recreation. | ||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||
| class PluginTabDockableContent( | ||||||||||||||||||||||||||
| val tabId: String, | ||||||||||||||||||||||||||
| override val title: String, | ||||||||||||||||||||||||||
| ) : DockableContent { | ||||||||||||||||||||||||||
| override val id: String = "plugin:$tabId" | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| private var fragmentHost: OverlayFragmentHost? = null | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| override fun onCreateView( | ||||||||||||||||||||||||||
| context: Context, | ||||||||||||||||||||||||||
| host: FloatingWindowHost, | ||||||||||||||||||||||||||
| ): View = | ||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||
| val overlayHost = OverlayFragmentHost(context, host, PluginFragmentFactory(FragmentFactory())) | ||||||||||||||||||||||||||
| fragmentHost = overlayHost | ||||||||||||||||||||||||||
| overlayHost.start() | ||||||||||||||||||||||||||
| PluginEditorTabManager.getInstance().newTabFragment(tabId)?.let(overlayHost::setFragment) | ||||||||||||||||||||||||||
| overlayHost.view | ||||||||||||||||||||||||||
|
Comment on lines
+37
to
+39
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win Treat a
Suggested fix- overlayHost.start()
- PluginEditorTabManager.getInstance().newTabFragment(tabId)?.let(overlayHost::setFragment)
- overlayHost.view
+ overlayHost.start()
+ val fragment = PluginEditorTabManager.getInstance().newTabFragment(tabId)
+ ?: run {
+ overlayHost.destroy()
+ fragmentHost = null
+ return errorView(context)
+ }
+ overlayHost.setFragment(fragment)
+ overlayHost.view📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||
| } catch (t: Throwable) { | ||||||||||||||||||||||||||
| Log.e(TAG, "Plugin tab '$tabId' failed to load in a floating window", t) | ||||||||||||||||||||||||||
| runCatching { fragmentHost?.destroy() } | ||||||||||||||||||||||||||
| fragmentHost = null | ||||||||||||||||||||||||||
| errorView(context) | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| override fun onDestroyView() { | ||||||||||||||||||||||||||
| runCatching { fragmentHost?.destroy() } | ||||||||||||||||||||||||||
| fragmentHost = null | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| private fun errorView(context: Context): View = | ||||||||||||||||||||||||||
| TextView(context).apply { | ||||||||||||||||||||||||||
| text = "Couldn't open “$title” in a floating window." | ||||||||||||||||||||||||||
| val pad = (16 * context.resources.displayMetrics.density).toInt() | ||||||||||||||||||||||||||
| setPadding(pad, pad, pad, pad) | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| private companion object { | ||||||||||||||||||||||||||
| private const val TAG = "PluginTabDockableContent" | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,41 @@ | ||
|
|
||
|
|
||
| import com.itsaky.androidide.build.config.BuildConfig | ||
|
|
||
| plugins { | ||
| id("com.android.library") | ||
| id("kotlin-android") | ||
| alias(libs.plugins.kotlin.compose) | ||
| } | ||
|
|
||
| android { | ||
| namespace = "${BuildConfig.PACKAGE_NAME}.floating" | ||
|
|
||
| buildFeatures { | ||
| compose = true | ||
| } | ||
| } | ||
|
|
||
| dependencies { | ||
| implementation(platform(libs.compose.bom)) | ||
| implementation(libs.compose.runtime) | ||
| implementation(libs.compose.ui) | ||
| implementation(libs.compose.foundation) | ||
| implementation(libs.compose.material3) | ||
| implementation(libs.compose.activity) | ||
| implementation(libs.compose.ui.tooling.preview) | ||
| debugImplementation(libs.compose.ui.tooling) | ||
|
|
||
| implementation(libs.androidx.core.ktx) | ||
| implementation(libs.androidx.appcompat) | ||
| implementation(libs.androidx.fragment) | ||
| implementation(libs.androidx.lifecycle.runtime.ktx) | ||
| implementation(libs.androidx.lifecycle.viewmodel.ktx) | ||
| implementation(libs.common.kotlin.coroutines.android) | ||
| implementation(libs.google.material) | ||
|
|
||
| implementation(projects.editorApi) | ||
| implementation(projects.common) | ||
| implementation(projects.resources) | ||
| implementation(projects.logger) | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
Implement the
DockableContent.onDestroyView()teardown path.This class only exposes
release(), but the floating-window contract tears content down viaonDestroyView(). If the overlay is destroyed outside the activity-driven redock flow,CodeEditorView.close()never runs and the editor instance stays alive longer than intended.🤖 Prompt for AI Agents